Browse Source
Merge pull request #5656 from rspamd/cursor/RSP-271-implement-metadefender-hash-lookup-module-09c0
Merge pull request #5656 from rspamd/cursor/RSP-271-implement-metadefender-hash-lookup-module-09c0
Implement Metadefender hash lookup modulepull/5662/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 639 additions and 98 deletions
-
76conf/local.d/antivirus.conf.example
-
103conf/modules.d/antivirus.conf
-
86lualib/lua_scanners/common.lua
-
1lualib/lua_scanners/init.lua
-
259lualib/lua_scanners/metadefender.lua
-
124lualib/lua_scanners/virustotal.lua
-
88src/plugins/lua/antivirus.lua
@ -0,0 +1,76 @@ |
|||
# Example MetaDefender configuration |
|||
# Copy relevant sections to local.d/antivirus.conf and customize |
|||
|
|||
metadefender { |
|||
# Required: Your MetaDefender API key from https://metadefender.opswat.com/ |
|||
apikey = "YOUR_API_KEY_HERE"; |
|||
|
|||
# Main symbol name (for compatibility, usually not used directly) |
|||
symbol = "METADEFENDER"; |
|||
|
|||
# Scanner type - must be "metadefender" |
|||
type = "metadefender"; |
|||
|
|||
# Scan MIME parts separately instead of full message (recommended: true) |
|||
scan_mime_parts = true; |
|||
|
|||
# Don't scan text or image MIME parts (saves API quota) |
|||
scan_text_mime = false; |
|||
scan_image_mime = false; |
|||
|
|||
# Maximum file size to scan (20MB default) |
|||
max_size = 20000000; |
|||
|
|||
# Log when files are clean (default: false to reduce noise) |
|||
log_clean = false; |
|||
|
|||
# Minimum AV engines that must detect malware before flagging (default: 3) |
|||
# Lower value = more sensitive, may have more false positives |
|||
minimum_engines = 3; |
|||
|
|||
# Threshold for low category (default: 5) |
|||
# Detections from minimum_engines to low_category-1 = LOW |
|||
low_category = 5; |
|||
|
|||
# Threshold for medium category (default: 10) |
|||
# Detections from low_category to medium_category-1 = MEDIUM |
|||
# Detections >= medium_category = HIGH |
|||
medium_category = 10; |
|||
|
|||
# HTTP request timeout in seconds |
|||
timeout = 5.0; |
|||
|
|||
# Redis cache expiration (2 hours = 7200 seconds) |
|||
# Longer cache reduces API calls but may miss new detections |
|||
cache_expire = 7200; |
|||
|
|||
# Symbol categories with scores (can be customized) |
|||
symbols = { |
|||
clean = { |
|||
symbol = "METADEFENDER_CLEAN"; |
|||
score = -0.5; |
|||
description = "MetaDefender decided attachment to be clean"; |
|||
}; |
|||
low = { |
|||
symbol = "METADEFENDER_LOW"; |
|||
score = 2.0; |
|||
description = "MetaDefender found low number of threats (3-4 engines)"; |
|||
}; |
|||
medium = { |
|||
symbol = "METADEFENDER_MEDIUM"; |
|||
score = 5.0; |
|||
description = "MetaDefender found medium number of threats (5-9 engines)"; |
|||
}; |
|||
high = { |
|||
symbol = "METADEFENDER_HIGH"; |
|||
score = 8.0; |
|||
description = "MetaDefender found high number of threats (10+ engines)"; |
|||
}; |
|||
} |
|||
|
|||
# Optional: Force an action when malware is detected |
|||
# action = "reject"; |
|||
|
|||
# Optional: Custom message template |
|||
# message = '${SCANNER}: virus found: "${VIRUS}"'; |
|||
} |
@ -0,0 +1,259 @@ |
|||
--[[ |
|||
Copyright (c) 2025, Vsevolod Stakhov <vsevolod@rspamd.com> |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
]] -- |
|||
|
|||
--[[[ |
|||
-- @module metadefender |
|||
-- This module contains MetaDefender Cloud integration support for hash lookups |
|||
-- https://metadefender.opswat.com/ |
|||
--]] |
|||
|
|||
local lua_util = require "lua_util" |
|||
local http = require "rspamd_http" |
|||
local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash" |
|||
local rspamd_logger = require "rspamd_logger" |
|||
local common = require "lua_scanners/common" |
|||
|
|||
local N = 'metadefender' |
|||
|
|||
local function metadefender_config(opts) |
|||
local default_conf = { |
|||
name = N, |
|||
url = 'https://api.metadefender.com/v4/hash', |
|||
timeout = 5.0, |
|||
log_clean = false, |
|||
retransmits = 1, |
|||
cache_expire = 7200, -- expire redis in 2h |
|||
message = '${SCANNER}: spam message found: "${VIRUS}"', |
|||
detection_category = "virus", |
|||
default_score = 1, |
|||
action = false, |
|||
scan_mime_parts = true, |
|||
scan_text_mime = false, |
|||
scan_image_mime = false, |
|||
apikey = nil, -- Required to set by user |
|||
-- Specific for metadefender |
|||
minimum_engines = 3, -- Minimum required to get scored |
|||
-- Threshold-based categorization |
|||
low_category = 5, -- Low threat: minimum_engines to low_category-1 |
|||
medium_category = 10, -- Medium threat: low_category to medium_category-1 |
|||
-- High threat: medium_category and above |
|||
-- Symbol categories |
|||
symbols = { |
|||
clean = { |
|||
symbol = 'METADEFENDER_CLEAN', |
|||
score = -0.5, |
|||
description = 'MetaDefender decided attachment to be clean' |
|||
}, |
|||
low = { |
|||
symbol = 'METADEFENDER_LOW', |
|||
score = 2.0, |
|||
description = 'MetaDefender found low number of threats' |
|||
}, |
|||
medium = { |
|||
symbol = 'METADEFENDER_MEDIUM', |
|||
score = 5.0, |
|||
description = 'MetaDefender found medium number of threats' |
|||
}, |
|||
high = { |
|||
symbol = 'METADEFENDER_HIGH', |
|||
score = 8.0, |
|||
description = 'MetaDefender found high number of threats' |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
default_conf = lua_util.override_defaults(default_conf, opts) |
|||
|
|||
if not default_conf.prefix then |
|||
default_conf.prefix = 'rs_' .. default_conf.name .. '_' |
|||
end |
|||
|
|||
if not default_conf.log_prefix then |
|||
if default_conf.name:lower() == default_conf.type:lower() then |
|||
default_conf.log_prefix = default_conf.name |
|||
else |
|||
default_conf.log_prefix = default_conf.name .. ' (' .. default_conf.type .. ')' |
|||
end |
|||
end |
|||
|
|||
if not default_conf.apikey then |
|||
rspamd_logger.errx(rspamd_config, 'no apikey defined for metadefender, disable checks') |
|||
|
|||
return nil |
|||
end |
|||
|
|||
lua_util.add_debug_alias('external_services', default_conf.name) |
|||
return default_conf |
|||
end |
|||
|
|||
local function metadefender_check(task, content, digest, rule, maybe_part) |
|||
local function metadefender_check_uncached() |
|||
local function make_url(hash) |
|||
return string.format('%s/%s', rule.url, hash) |
|||
end |
|||
|
|||
-- MetaDefender uses SHA256 hash |
|||
local hash = rspamd_cryptobox_hash.create_specific('sha256') |
|||
hash:update(content) |
|||
hash = hash:hex() |
|||
|
|||
local url = make_url(hash) |
|||
lua_util.debugm(N, task, "send request %s", url) |
|||
local request_data = { |
|||
task = task, |
|||
url = url, |
|||
timeout = rule.timeout, |
|||
headers = { |
|||
['apikey'] = rule.apikey, |
|||
} |
|||
} |
|||
|
|||
local function md_http_callback(http_err, code, body, headers) |
|||
if http_err then |
|||
rspamd_logger.errx(task, 'HTTP error: %s, body: %s, headers: %s', http_err, body, headers) |
|||
task:insert_result(rule.symbol_fail, 1.0, 'HTTP error: ' .. http_err) |
|||
else |
|||
local cached |
|||
-- Parse the response |
|||
if code ~= 200 then |
|||
if code == 404 then |
|||
cached = 'OK' |
|||
if rule['log_clean'] then |
|||
rspamd_logger.infox(task, '%s: hash %s clean (not found)', |
|||
rule.log_prefix, hash) |
|||
else |
|||
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)', |
|||
rule.log_prefix, hash) |
|||
end |
|||
elseif code == 429 then |
|||
-- Request rate limit exceeded |
|||
rspamd_logger.infox(task, 'metadefender request rate limit exceeded') |
|||
task:insert_result(rule.symbol_fail, 1.0, 'rate limit exceeded') |
|||
return |
|||
else |
|||
rspamd_logger.errx(task, 'invalid HTTP code: %s, body: %s, headers: %s', code, body, headers) |
|||
task:insert_result(rule.symbol_fail, 1.0, 'Bad HTTP code: ' .. code) |
|||
return |
|||
end |
|||
else |
|||
local ucl = require "ucl" |
|||
local parser = ucl.parser() |
|||
local res, json_err = parser:parse_string(body) |
|||
|
|||
lua_util.debugm(rule.name, task, '%s: got reply data: "%s"', |
|||
rule.log_prefix, body) |
|||
|
|||
if res then |
|||
local obj = parser:get_object() |
|||
|
|||
-- MetaDefender API response structure: |
|||
-- scan_results.scan_all_result_a: 'Clean', 'Infected', 'Suspicious' |
|||
-- scan_results.scan_all_result_i: numeric result (0=clean) |
|||
-- scan_results.total_detected_avs: number of engines detecting malware |
|||
-- scan_results.total_avs: total number of engines |
|||
|
|||
if not obj.scan_results then |
|||
rspamd_logger.errx(task, 'invalid JSON reply: no scan_results field, body: %s', body) |
|||
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no scan_results') |
|||
return |
|||
end |
|||
|
|||
local scan_results = obj.scan_results |
|||
local detected = scan_results.total_detected_avs or 0 |
|||
local total = scan_results.total_avs or 0 |
|||
|
|||
if detected == 0 then |
|||
if rule['log_clean'] then |
|||
rspamd_logger.infox(task, '%s: hash %s clean', |
|||
rule.log_prefix, hash) |
|||
else |
|||
lua_util.debugm(rule.name, task, '%s: hash %s clean', |
|||
rule.log_prefix, hash) |
|||
end |
|||
-- Insert CLEAN symbol |
|||
if rule.symbols and rule.symbols.clean then |
|||
local clean_sym = rule.symbols.clean.symbol or 'METADEFENDER_CLEAN' |
|||
local sopt = string.format("%s:0/%s", hash, total) |
|||
task:insert_result(clean_sym, 1.0, sopt) |
|||
-- Save with symbol name for proper cache retrieval |
|||
cached = string.format("%s\v%s", clean_sym, sopt) |
|||
else |
|||
cached = 'OK' |
|||
end |
|||
else |
|||
if detected < rule.minimum_engines then |
|||
lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min', |
|||
rule.log_prefix, hash, detected, rule.minimum_engines) |
|||
cached = 'OK' |
|||
else |
|||
-- Determine category based on detection count |
|||
local category |
|||
local category_sym |
|||
local sopt = string.format("%s:%s/%s", hash, detected, total) |
|||
|
|||
if detected >= rule.medium_category then |
|||
category = 'high' |
|||
category_sym = rule.symbols.high.symbol or 'METADEFENDER_HIGH' |
|||
elseif detected >= rule.low_category then |
|||
category = 'medium' |
|||
category_sym = rule.symbols.medium.symbol or 'METADEFENDER_MEDIUM' |
|||
else |
|||
category = 'low' |
|||
category_sym = rule.symbols.low.symbol or 'METADEFENDER_LOW' |
|||
end |
|||
|
|||
rspamd_logger.infox(task, '%s: result - %s: "%s" - category: %s', |
|||
rule.log_prefix, rule.detection_category .. 'found', sopt, category) |
|||
|
|||
task:insert_result(category_sym, 1.0, sopt) |
|||
-- Save with symbol name for proper cache retrieval |
|||
cached = string.format("%s\v%s", category_sym, sopt) |
|||
end |
|||
end |
|||
else |
|||
-- not res |
|||
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s', |
|||
json_err, body, headers) |
|||
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err) |
|||
return |
|||
end |
|||
end |
|||
|
|||
if cached then |
|||
common.save_cache(task, digest, rule, cached, 1.0, maybe_part) |
|||
end |
|||
end |
|||
end |
|||
|
|||
request_data.callback = md_http_callback |
|||
http.request(request_data) |
|||
end |
|||
|
|||
if common.condition_check_and_continue(task, content, rule, digest, |
|||
metadefender_check_uncached) then |
|||
return |
|||
else |
|||
metadefender_check_uncached() |
|||
end |
|||
end |
|||
|
|||
return { |
|||
type = 'antivirus', |
|||
description = 'MetaDefender Cloud integration', |
|||
configure = metadefender_config, |
|||
check = metadefender_check, |
|||
name = N |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue