Browse Source

[Feature] Add symbol categories for MetaDefender and VirusTotal

Implemented a category-based symbol system for hash lookup antivirus
scanners (MetaDefender and VirusTotal) to replace dynamic scoring:

- Added 4 symbol categories: CLEAN (-0.5), LOW (2.0), MEDIUM (5.0), HIGH (8.0)
- Replaced full_score_engines with threshold-based categorization (low_category, medium_category)
- Fixed symbol registration in antivirus.lua to use rule instead of config
- Updated cache format to preserve symbol category across requests
- Added backward compatibility for old cache format
- Added symbols registration and metric score assignment
- Updated configuration documentation with examples

The new system provides:
- Clear threat categorization instead of linear interpolation
- Proper symbol weights applied automatically
- Consistent behavior between MetaDefender and VirusTotal
- Cache that preserves symbol categories

Configuration example:
metadefender {
  apikey = "KEY";
  type = "metadefender";
  minimum_engines = 3;
  low_category = 5;
  medium_category = 10;
}
pull/5656/head
Vsevolod Stakhov 2 days ago
parent
commit
64fc71440b
No known key found for this signature in database GPG Key ID: 7647B6790081437
  1. 63
      conf/local.d/antivirus.conf.example
  2. 84
      conf/modules.d/antivirus.conf
  3. 86
      lualib/lua_scanners/common.lua
  4. 96
      lualib/lua_scanners/metadefender.lua
  5. 124
      lualib/lua_scanners/virustotal.lua
  6. 88
      src/plugins/lua/antivirus.lua

63
conf/local.d/antivirus.conf.example

@ -4,44 +4,73 @@
metadefender { metadefender {
# Required: Your MetaDefender API key from https://metadefender.opswat.com/ # Required: Your MetaDefender API key from https://metadefender.opswat.com/
apikey = "YOUR_API_KEY_HERE"; apikey = "YOUR_API_KEY_HERE";
# Symbol name (default: METADEFENDER_VIRUS)
symbol = "METADEFENDER_VIRUS";
# Main symbol name (for compatibility, usually not used directly)
symbol = "METADEFENDER";
# Scanner type - must be "metadefender" # Scanner type - must be "metadefender"
type = "metadefender"; type = "metadefender";
# Scan MIME parts separately instead of full message (recommended: true) # Scan MIME parts separately instead of full message (recommended: true)
scan_mime_parts = true; scan_mime_parts = true;
# Don't scan text or image MIME parts (saves API quota) # Don't scan text or image MIME parts (saves API quota)
scan_text_mime = false; scan_text_mime = false;
scan_image_mime = false; scan_image_mime = false;
# Maximum file size to scan (20MB default) # Maximum file size to scan (20MB default)
max_size = 20000000; max_size = 20000000;
# Log when files are clean (default: false to reduce noise) # Log when files are clean (default: false to reduce noise)
log_clean = false; log_clean = false;
# Minimum AV engines that must detect malware before flagging (default: 3) # Minimum AV engines that must detect malware before flagging (default: 3)
# Lower value = more sensitive, may have more false positives # Lower value = more sensitive, may have more false positives
minimum_engines = 3; minimum_engines = 3;
# Number of engines at which maximum score is assigned (default: 7)
# Scores scale linearly between minimum_engines and full_score_engines
full_score_engines = 7;
# 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 # HTTP request timeout in seconds
timeout = 5.0; timeout = 5.0;
# Redis cache expiration (2 hours = 7200 seconds) # Redis cache expiration (2 hours = 7200 seconds)
# Longer cache reduces API calls but may miss new detections # Longer cache reduces API calls but may miss new detections
cache_expire = 7200; 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 # Optional: Force an action when malware is detected
# action = "reject"; # action = "reject";
# Optional: Custom message template # Optional: Custom message template
# message = '${SCANNER}: virus found: "${VIRUS}"'; # message = '${SCANNER}: virus found: "${VIRUS}"';
} }

84
conf/modules.d/antivirus.conf

@ -59,8 +59,8 @@ antivirus {
# #
# If `max_size` is set, messages > n bytes in size are not scanned # If `max_size` is set, messages > n bytes in size are not scanned
#max_size = 20000000; #max_size = 20000000;
# symbol to add
#symbol = "METADEFENDER_VIRUS";
# Main symbol (for compatibility, usually not used directly)
#symbol = "METADEFENDER";
# type of scanner # type of scanner
#type = "metadefender"; #type = "metadefender";
# Your MetaDefender API key (required) # Your MetaDefender API key (required)
@ -71,12 +71,88 @@ antivirus {
#log_clean = false; #log_clean = false;
# Minimum number of engines detecting malware for a hit (default 3) # Minimum number of engines detecting malware for a hit (default 3)
#minimum_engines = 3; #minimum_engines = 3;
# Number of engines at which we assign full score (default 7)
#full_score_engines = 7;
# Threshold for low category (default 5)
#low_category = 5;
# Threshold for medium category (default 10)
#medium_category = 10;
# Request timeout # Request timeout
#timeout = 5.0; #timeout = 5.0;
# Redis cache expiration time in seconds (default 7200 = 2 hours) # Redis cache expiration time in seconds (default 7200 = 2 hours)
#cache_expire = 7200; #cache_expire = 7200;
# Symbol categories with scores (can be overridden)
#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";
# };
#}
#}
#virustotal {
# VirusTotal API (hash lookup)
# Get your API key at https://www.virustotal.com/
#
# If `max_size` is set, messages > n bytes in size are not scanned
#max_size = 20000000;
# Main symbol (for compatibility, usually not used directly)
#symbol = "VIRUSTOTAL";
# type of scanner
#type = "virustotal";
# Your VirusTotal API key (required)
#apikey = "YOUR_API_KEY_HERE";
# Scan mime_parts separately (default true)
#scan_mime_parts = true;
# You can enable logging for clean messages
#log_clean = false;
# Minimum number of engines detecting malware for a hit (default 3)
#minimum_engines = 3;
# Threshold for low category (default 5)
#low_category = 5;
# Threshold for medium category (default 10)
#medium_category = 10;
# Request timeout
#timeout = 5.0;
# Redis cache expiration time in seconds (default 7200 = 2 hours)
#cache_expire = 7200;
# Symbol categories with scores (can be overridden)
#symbols = {
# clean = {
# symbol = "VIRUSTOTAL_CLEAN";
# score = -0.5;
# description = "VirusTotal decided attachment to be clean";
# };
# low = {
# symbol = "VIRUSTOTAL_LOW";
# score = 2.0;
# description = "VirusTotal found low number of threats";
# };
# medium = {
# symbol = "VIRUSTOTAL_MEDIUM";
# score = 5.0;
# description = "VirusTotal found medium number of threats";
# };
# high = {
# symbol = "VIRUSTOTAL_HIGH";
# score = 8.0;
# description = "VirusTotal found high number of threats";
# };
#}
#} #}
.include(try=true,priority=5) "${DBDIR}/dynamic/antivirus.conf" .include(try=true,priority=5) "${DBDIR}/dynamic/antivirus.conf"

86
lualib/lua_scanners/common.lua

@ -13,7 +13,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
]]--
]] --
--[[[ --[[[
-- @module lua_scanners_common -- @module lua_scanners_common
@ -30,7 +30,6 @@ local fun = require "fun"
local exports = {} local exports = {}
local function log_clean(task, rule, msg) local function log_clean(task, rule, msg)
msg = msg or 'message or mime_part is clean' msg = msg or 'message or mime_part is clean'
if rule.log_clean then if rule.log_clean then
@ -38,7 +37,6 @@ local function log_clean(task, rule, msg)
else else
lua_util.debugm(rule.name, task, '%s: %s', rule.log_prefix, msg) lua_util.debugm(rule.name, task, '%s: %s', rule.log_prefix, msg)
end end
end end
local function match_patterns(default_sym, found, patterns, dyn_weight) local function match_patterns(default_sym, found, patterns, dyn_weight)
@ -111,16 +109,15 @@ local function yield_result(task, rule, vname, dyn_weight, is_fail, maybe_part)
else else
all_whitelisted = false all_whitelisted = false
rspamd_logger.infox(task, '%s: result - %s: "%s - score: %s"', rspamd_logger.infox(task, '%s: result - %s: "%s - score: %s"',
rule.log_prefix, threat_info, tm, symscore)
rule.log_prefix, threat_info, tm, symscore)
if maybe_part and rule.show_attachments and maybe_part:get_filename() then if maybe_part and rule.show_attachments and maybe_part:get_filename() then
local fname = maybe_part:get_filename() local fname = maybe_part:get_filename()
task:insert_result(symname, symscore, string.format("%s|%s", task:insert_result(symname, symscore, string.format("%s|%s",
tm, fname))
tm, fname))
else else
task:insert_result(symname, symscore, tm) task:insert_result(symname, symscore, tm)
end end
end end
end end
@ -130,10 +127,10 @@ local function yield_result(task, rule, vname, dyn_weight, is_fail, maybe_part)
flags = 'least' flags = 'least'
end end
task:set_pre_result(rule.action, task:set_pre_result(rule.action,
lua_util.template(rule.message or 'Rejected', {
SCANNER = rule.name,
VIRUS = threat_table,
}), rule.name, nil, nil, flags)
lua_util.template(rule.message or 'Rejected', {
SCANNER = rule.name,
VIRUS = threat_table,
}), rule.name, nil, nil, flags)
end end
end end
@ -144,7 +141,7 @@ local function message_not_too_large(task, content, rule)
end end
if #content > max_size then if #content > max_size then
rspamd_logger.infox(task, "skip %s check as it is too large: %s (%s is allowed)", rspamd_logger.infox(task, "skip %s check as it is too large: %s (%s is allowed)",
rule.log_prefix, #content, max_size)
rule.log_prefix, #content, max_size)
return false return false
end end
return true return true
@ -157,7 +154,7 @@ local function message_not_too_small(task, content, rule)
end end
if #content < min_size then if #content < min_size then
rspamd_logger.infox(task, "skip %s check as it is too small: %s (%s is allowed)", rspamd_logger.infox(task, "skip %s check as it is too small: %s (%s is allowed)",
rule.log_prefix, #content, min_size)
rule.log_prefix, #content, min_size)
return false return false
end end
return true return true
@ -178,7 +175,7 @@ local function message_min_words(task, rule)
if not text_part_above_limit then if not text_part_above_limit then
rspamd_logger.infox(task, '%s: #words in all text parts is below text_part_min_words limit: %s', rspamd_logger.infox(task, '%s: #words in all text parts is below text_part_min_words limit: %s',
rule.log_prefix, rule.text_part_min_words)
rule.log_prefix, rule.text_part_min_words)
end end
return text_part_above_limit return text_part_above_limit
@ -217,7 +214,6 @@ local function dynamic_scan(task, rule)
end end
local function need_check(task, content, rule, digest, fn, maybe_part) local function need_check(task, content, rule, digest, fn, maybe_part)
local uncached = true local uncached = true
local key = digest local key = digest
@ -231,19 +227,30 @@ local function need_check(task, content, rule, digest, fn, maybe_part)
if threat_string[1] ~= 'OK' then if threat_string[1] ~= 'OK' then
if threat_string[1] == 'MACRO' then if threat_string[1] == 'MACRO' then
yield_result(task, rule, 'File contains macros', yield_result(task, rule, 'File contains macros',
0.0, 'macro', maybe_part)
0.0, 'macro', maybe_part)
elseif threat_string[1] == 'ENCRYPTED' then elseif threat_string[1] == 'ENCRYPTED' then
yield_result(task, rule, 'File is encrypted', yield_result(task, rule, 'File is encrypted',
0.0, 'encrypted', maybe_part)
0.0, 'encrypted', maybe_part)
else else
lua_util.debugm(rule.name, task, '%s: got cached threat result for %s: %s - score: %s',
-- Check if cached data contains symbol name (for category-based scanners)
-- Format: "SYMBOL_NAME\vdetails" or just "details"
if #threat_string >= 2 and rule.symbols then
-- New format with symbol name
local symbol_name = threat_string[1]
local details = threat_string[2]
lua_util.debugm(rule.name, task, '%s: got cached threat result for %s: %s - %s',
rule.log_prefix, key, symbol_name, details)
task:insert_result(symbol_name, 1.0, details)
else
-- Old format without symbol name
lua_util.debugm(rule.name, task, '%s: got cached threat result for %s: %s - score: %s',
rule.log_prefix, key, threat_string[1], score) rule.log_prefix, key, threat_string[1], score)
yield_result(task, rule, threat_string, score, false, maybe_part)
yield_result(task, rule, threat_string, score, false, maybe_part)
end
end end
else else
lua_util.debugm(rule.name, task, '%s: got cached negative result for %s: %s', lua_util.debugm(rule.name, task, '%s: got cached negative result for %s: %s',
rule.log_prefix, key, threat_string[1])
rule.log_prefix, key, threat_string[1])
end end
uncached = false uncached = false
else else
@ -262,31 +269,26 @@ local function need_check(task, content, rule, digest, fn, maybe_part)
f_message_not_too_small and f_message_not_too_small and
f_message_min_words and f_message_min_words and
f_dynamic_scan then f_dynamic_scan then
fn() fn()
end end
end end
if rule.redis_params and not rule.no_cache then if rule.redis_params and not rule.no_cache then
key = rule.prefix .. key key = rule.prefix .. key
if lua_redis.redis_make_request(task, if lua_redis.redis_make_request(task,
rule.redis_params, -- connect params
key, -- hash key
false, -- is write
redis_av_cb, --callback
'GET', -- command
{ key } -- arguments)
) then
rule.redis_params, -- connect params
key, -- hash key
false, -- is write
redis_av_cb, --callback
'GET', -- command
{ key } -- arguments)
) then
return true return true
end end
end end
return false return false
end end
local function save_cache(task, digest, rule, to_save, dyn_weight, maybe_part) local function save_cache(task, digest, rule, to_save, dyn_weight, maybe_part)
@ -299,10 +301,10 @@ local function save_cache(task, digest, rule, to_save, dyn_weight, maybe_part)
-- Do nothing -- Do nothing
if err then if err then
rspamd_logger.errx(task, 'failed to save %s cache for %s -> "%s": %s', rspamd_logger.errx(task, 'failed to save %s cache for %s -> "%s": %s',
rule.detection_category, to_save, key, err)
rule.detection_category, to_save, key, err)
else else
lua_util.debugm(rule.name, task, '%s: saved cached result for %s: %s - score %s - ttl %s', lua_util.debugm(rule.name, task, '%s: saved cached result for %s: %s - score %s - ttl %s',
rule.log_prefix, key, to_save, dyn_weight, rule.cache_expire)
rule.log_prefix, key, to_save, dyn_weight, rule.cache_expire)
end end
end end
@ -321,12 +323,12 @@ local function save_cache(task, digest, rule, to_save, dyn_weight, maybe_part)
key = rule.prefix .. key key = rule.prefix .. key
lua_redis.redis_make_request(task, lua_redis.redis_make_request(task,
rule.redis_params, -- connect params
key, -- hash key
true, -- is write
redis_set_cb, --callback
'SETEX', -- command
{ key, rule.cache_expire or 0, value }
rule.redis_params, -- connect params
key, -- hash key
true, -- is write
redis_set_cb, --callback
'SETEX', -- command
{ key, rule.cache_expire or 0, value }
) )
end end
@ -396,7 +398,6 @@ local function gen_extension(fname)
end end
local function check_parts_match(task, rule) local function check_parts_match(task, rule)
local filter_func = function(p) local filter_func = function(p)
local mtype, msubtype = p:get_type() local mtype, msubtype = p:get_type()
local detected_ext = p:get_detected_ext() local detected_ext = p:get_detected_ext()
@ -434,7 +435,7 @@ local function check_parts_match(task, rule)
return true return true
elseif magic.ct and match_filter(task, rule, magic.ct, rule.mime_parts_filter_regex, 'regex') then elseif magic.ct and match_filter(task, rule, magic.ct, rule.mime_parts_filter_regex, 'regex') then
lua_util.debugm(rule.name, task, '%s: regex detected libmagic content-type: %s', lua_util.debugm(rule.name, task, '%s: regex detected libmagic content-type: %s',
rule.log_prefix, magic.ct)
rule.log_prefix, magic.ct)
return true return true
end end
end end
@ -489,7 +490,6 @@ local function check_parts_match(task, rule)
end end
local function check_metric_results(task, rule) local function check_metric_results(task, rule)
if rule.action ~= 'reject' then if rule.action ~= 'reject' then
local metric_result = task:get_metric_score() local metric_result = task:get_metric_score()
local metric_action = task:get_metric_action() local metric_action = task:get_metric_action()

96
lualib/lua_scanners/metadefender.lua

@ -12,7 +12,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
]]--
]] --
--[[[ --[[[
-- @module metadefender -- @module metadefender
@ -29,7 +29,6 @@ local common = require "lua_scanners/common"
local N = 'metadefender' local N = 'metadefender'
local function metadefender_config(opts) local function metadefender_config(opts)
local default_conf = { local default_conf = {
name = N, name = N,
url = 'https://api.metadefender.com/v4/hash', url = 'https://api.metadefender.com/v4/hash',
@ -44,10 +43,36 @@ local function metadefender_config(opts)
scan_mime_parts = true, scan_mime_parts = true,
scan_text_mime = false, scan_text_mime = false,
scan_image_mime = false, scan_image_mime = false,
apikey = nil, -- Required to set by user
apikey = nil, -- Required to set by user
-- Specific for metadefender -- Specific for metadefender
minimum_engines = 3, -- Minimum required to get scored
full_score_engines = 7, -- After this number we set max score
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) default_conf = lua_util.override_defaults(default_conf, opts)
@ -102,17 +127,16 @@ local function metadefender_check(task, content, digest, rule, maybe_part)
task:insert_result(rule.symbol_fail, 1.0, 'HTTP error: ' .. http_err) task:insert_result(rule.symbol_fail, 1.0, 'HTTP error: ' .. http_err)
else else
local cached local cached
local dyn_score
-- Parse the response -- Parse the response
if code ~= 200 then if code ~= 200 then
if code == 404 then if code == 404 then
cached = 'OK' cached = 'OK'
if rule['log_clean'] then if rule['log_clean'] then
rspamd_logger.infox(task, '%s: hash %s clean (not found)', rspamd_logger.infox(task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
else else
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)', lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
end end
elseif code == 429 then elseif code == 429 then
-- Request rate limit exceeded -- Request rate limit exceeded
@ -130,7 +154,7 @@ local function metadefender_check(task, content, digest, rule, maybe_part)
local res, json_err = parser:parse_string(body) local res, json_err = parser:parse_string(body)
lua_util.debugm(rule.name, task, '%s: got reply data: "%s"', lua_util.debugm(rule.name, task, '%s: got reply data: "%s"',
rule.log_prefix, body)
rule.log_prefix, body)
if res then if res then
local obj = parser:get_object() local obj = parser:get_object()
@ -152,48 +176,64 @@ local function metadefender_check(task, content, digest, rule, maybe_part)
local total = scan_results.total_avs or 0 local total = scan_results.total_avs or 0
if detected == 0 then if detected == 0 then
cached = 'OK'
if rule['log_clean'] then if rule['log_clean'] then
rspamd_logger.infox(task, '%s: hash %s clean', rspamd_logger.infox(task, '%s: hash %s clean',
rule.log_prefix, hash)
rule.log_prefix, hash)
else else
lua_util.debugm(rule.name, task, '%s: hash %s clean', lua_util.debugm(rule.name, task, '%s: hash %s clean',
rule.log_prefix, hash)
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 end
else else
if detected < rule.minimum_engines then if detected < rule.minimum_engines then
lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min', 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)
rule.log_prefix, hash, detected, rule.minimum_engines)
cached = 'OK' cached = 'OK'
else else
if detected >= rule.full_score_engines then
dyn_score = 1.0
-- 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 else
local norm_detected = detected - rule.minimum_engines
dyn_score = norm_detected / (rule.full_score_engines - rule.minimum_engines)
category = 'low'
category_sym = rule.symbols.low.symbol or 'METADEFENDER_LOW'
end end
if dyn_score < 0 or dyn_score > 1 then
dyn_score = 1.0
end
rspamd_logger.infox(task, '%s: result - %s: "%s" - category: %s',
rule.log_prefix, rule.detection_category .. 'found', sopt, category)
local sopt = string.format("%s:%s/%s",
hash, detected, total)
common.yield_result(task, rule, sopt, dyn_score, nil, maybe_part)
cached = sopt
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
end end
else else
-- not res -- not res
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s', rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
json_err, body, headers)
json_err, body, headers)
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err) task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err)
return return
end end
end end
if cached then if cached then
common.save_cache(task, digest, rule, cached, dyn_score, maybe_part)
common.save_cache(task, digest, rule, cached, 1.0, maybe_part)
end end
end end
end end
@ -203,13 +243,11 @@ local function metadefender_check(task, content, digest, rule, maybe_part)
end end
if common.condition_check_and_continue(task, content, rule, digest, if common.condition_check_and_continue(task, content, rule, digest,
metadefender_check_uncached) then
metadefender_check_uncached) then
return return
else else
metadefender_check_uncached() metadefender_check_uncached()
end end
end end
return { return {

124
lualib/lua_scanners/virustotal.lua

@ -12,7 +12,7 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
]]--
]] --
--[[[ --[[[
-- @module virustotal -- @module virustotal
@ -29,7 +29,6 @@ local common = require "lua_scanners/common"
local N = 'virustotal' local N = 'virustotal'
local function virustotal_config(opts) local function virustotal_config(opts)
local default_conf = { local default_conf = {
name = N, name = N,
url = 'https://www.virustotal.com/vtapi/v2/file', url = 'https://www.virustotal.com/vtapi/v2/file',
@ -44,10 +43,36 @@ local function virustotal_config(opts)
scan_mime_parts = true, scan_mime_parts = true,
scan_text_mime = false, scan_text_mime = false,
scan_image_mime = false, scan_image_mime = false,
apikey = nil, -- Required to set by user
apikey = nil, -- Required to set by user
-- Specific for virustotal -- Specific for virustotal
minimum_engines = 3, -- Minimum required to get scored
full_score_engines = 7, -- After this number we set max score
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 = 'VIRUSTOTAL_CLEAN',
score = -0.5,
description = 'VirusTotal decided attachment to be clean'
},
low = {
symbol = 'VIRUSTOTAL_LOW',
score = 2.0,
description = 'VirusTotal found low number of threats'
},
medium = {
symbol = 'VIRUSTOTAL_MEDIUM',
score = 5.0,
description = 'VirusTotal found medium number of threats'
},
high = {
symbol = 'VIRUSTOTAL_HIGH',
score = 8.0,
description = 'VirusTotal found high number of threats'
},
},
} }
default_conf = lua_util.override_defaults(default_conf, opts) default_conf = lua_util.override_defaults(default_conf, opts)
@ -78,7 +103,7 @@ local function virustotal_check(task, content, digest, rule, maybe_part)
local function virustotal_check_uncached() local function virustotal_check_uncached()
local function make_url(hash) local function make_url(hash)
return string.format('%s/report?apikey=%s&resource=%s', return string.format('%s/report?apikey=%s&resource=%s',
rule.url, rule.apikey, hash)
rule.url, rule.apikey, hash)
end end
local hash = rspamd_cryptobox_hash.create_specific('md5') local hash = rspamd_cryptobox_hash.create_specific('md5')
@ -98,17 +123,16 @@ local function virustotal_check(task, content, digest, rule, maybe_part)
rspamd_logger.errx(task, 'HTTP error: %s, body: %s, headers: %s', http_err, body, headers) rspamd_logger.errx(task, 'HTTP error: %s, body: %s, headers: %s', http_err, body, headers)
else else
local cached local cached
local dyn_score
-- Parse the response -- Parse the response
if code ~= 200 then if code ~= 200 then
if code == 404 then if code == 404 then
cached = 'OK' cached = 'OK'
if rule['log_clean'] then if rule['log_clean'] then
rspamd_logger.infox(task, '%s: hash %s clean (not found)', rspamd_logger.infox(task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
else else
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)', lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
end end
elseif code == 204 then elseif code == 204 then
-- Request rate limit exceeded -- Request rate limit exceeded
@ -126,67 +150,101 @@ local function virustotal_check(task, content, digest, rule, maybe_part)
local res, json_err = parser:parse_string(body) local res, json_err = parser:parse_string(body)
lua_util.debugm(rule.name, task, '%s: got reply data: "%s"', lua_util.debugm(rule.name, task, '%s: got reply data: "%s"',
rule.log_prefix, body)
rule.log_prefix, body)
if res then if res then
local obj = parser:get_object() local obj = parser:get_object()
if not obj.positives or type(obj.positives) ~= 'number' then if not obj.positives or type(obj.positives) ~= 'number' then
if obj.response_code then if obj.response_code then
if obj.response_code == 0 then if obj.response_code == 0 then
cached = 'OK'
if rule['log_clean'] then if rule['log_clean'] then
rspamd_logger.infox(task, '%s: hash %s clean (not found)', rspamd_logger.infox(task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
else else
lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)', lua_util.debugm(rule.name, task, '%s: hash %s clean (not found)',
rule.log_prefix, hash)
rule.log_prefix, hash)
end
-- Insert CLEAN symbol
if rule.symbols and rule.symbols.clean then
local clean_sym = rule.symbols.clean.symbol or 'VIRUSTOTAL_CLEAN'
local sopt = string.format("%s:0", hash)
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 end
else else
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s', rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
'bad response code: ' .. tostring(obj.response_code), body, headers)
'bad response code: ' .. tostring(obj.response_code), body, headers)
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element') task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
return return
end end
else else
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s', rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
'no response_code', body, headers)
'no response_code', body, headers)
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element') task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `positives` element')
return return
end end
else else
if obj.positives < rule.minimum_engines then
if obj.positives == 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 'VIRUSTOTAL_CLEAN'
local sopt = string.format("%s:0/%s", hash, obj.total or 0)
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
elseif obj.positives < rule.minimum_engines then
lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min', lua_util.debugm(rule.name, task, '%s: hash %s has not enough hits: %s where %s is min',
rule.log_prefix, obj.positives, rule.minimum_engines)
-- TODO: add proper hashing!
rule.log_prefix, hash, obj.positives, rule.minimum_engines)
cached = 'OK' cached = 'OK'
else else
if obj.positives > rule.full_score_engines then
dyn_score = 1.0
-- Determine category based on detection count
local category
local category_sym
local sopt = string.format("%s:%s/%s", hash, obj.positives, obj.total)
if obj.positives >= rule.medium_category then
category = 'high'
category_sym = rule.symbols.high.symbol or 'VIRUSTOTAL_HIGH'
elseif obj.positives >= rule.low_category then
category = 'medium'
category_sym = rule.symbols.medium.symbol or 'VIRUSTOTAL_MEDIUM'
else else
local norm_pos = obj.positives - rule.minimum_engines
dyn_score = norm_pos / (rule.full_score_engines - rule.minimum_engines)
category = 'low'
category_sym = rule.symbols.low.symbol or 'VIRUSTOTAL_LOW'
end end
if dyn_score < 0 or dyn_score > 1 then
dyn_score = 1.0
end
local sopt = string.format("%s:%s/%s",
hash, obj.positives, obj.total)
common.yield_result(task, rule, sopt, dyn_score, nil, maybe_part)
cached = sopt
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
end end
else else
-- not res -- not res
rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s', rspamd_logger.errx(task, 'invalid JSON reply: %s, body: %s, headers: %s',
json_err, body, headers)
json_err, body, headers)
task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err) task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: ' .. json_err)
return return
end end
end end
if cached then if cached then
common.save_cache(task, digest, rule, cached, dyn_score, maybe_part)
common.save_cache(task, digest, rule, cached, 1.0, maybe_part)
end end
end end
end end
@ -196,13 +254,11 @@ local function virustotal_check(task, content, digest, rule, maybe_part)
end end
if common.condition_check_and_continue(task, content, rule, digest, if common.condition_check_and_continue(task, content, rule, digest,
virustotal_check_uncached) then
virustotal_check_uncached) then
return return
else else
virustotal_check_uncached() virustotal_check_uncached()
end end
end end
return { return {

88
src/plugins/lua/antivirus.lua

@ -27,8 +27,8 @@ local N = "antivirus"
if confighelp then if confighelp then
rspamd_config:add_example(nil, 'antivirus', rspamd_config:add_example(nil, 'antivirus',
"Check messages for viruses",
[[
"Check messages for viruses",
[[
antivirus { antivirus {
# multiple scanners could be checked, for each we create a configuration block with an arbitrary name # multiple scanners could be checked, for each we create a configuration block with an arbitrary name
clamav { clamav {
@ -75,7 +75,7 @@ end
-- Encode as base32 in the source to avoid crappy stuff -- Encode as base32 in the source to avoid crappy stuff
local eicar_pattern = rspamd_util.decode_base32( local eicar_pattern = rspamd_util.decode_base32(
[[akp6woykfbonrepmwbzyfpbmibpone3mj3pgwbffzj9e1nfjdkorisckwkohrnfe1nt41y3jwk1cirjki4w4nkieuni4ndfjcktnn1yjmb1wn]]
[[akp6woykfbonrepmwbzyfpbmibpone3mj3pgwbffzj9e1nfjdkorisckwkohrnfe1nt41y3jwk1cirjki4w4nkieuni4ndfjcktnn1yjmb1wn]]
) )
local function add_antivirus_rule(sym, opts) local function add_antivirus_rule(sym, opts)
@ -91,7 +91,7 @@ local function add_antivirus_rule(sym, opts)
if not cfg then if not cfg then
rspamd_logger.errx(rspamd_config, 'unknown antivirus type: %s', rspamd_logger.errx(rspamd_config, 'unknown antivirus type: %s',
opts.type)
opts.type)
return nil return nil
end end
@ -109,7 +109,7 @@ local function add_antivirus_rule(sym, opts)
if opts.attachments_only ~= nil then if opts.attachments_only ~= nil then
opts.scan_mime_parts = opts.attachments_only opts.scan_mime_parts = opts.attachments_only
rspamd_logger.warnx(rspamd_config, '%s [%s]: Using attachments_only is deprecated. ' .. rspamd_logger.warnx(rspamd_config, '%s [%s]: Using attachments_only is deprecated. ' ..
'Please use scan_mime_parts = %s instead', opts.symbol, opts.type, opts.attachments_only)
'Please use scan_mime_parts = %s instead', opts.symbol, opts.type, opts.attachments_only)
end end
-- WORKAROUND for deprecated attachments_only -- WORKAROUND for deprecated attachments_only
@ -123,9 +123,12 @@ local function add_antivirus_rule(sym, opts)
rule.symbol_encrypted = opts.symbol_encrypted rule.symbol_encrypted = opts.symbol_encrypted
rule.redis_params = redis_params rule.redis_params = redis_params
-- Store rule for symbol registration later
rule.symbol_main = opts.symbol
if not rule then if not rule then
rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s', rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s',
opts.type, opts.symbol)
opts.type, opts.symbol)
return nil return nil
end end
@ -133,10 +136,10 @@ local function add_antivirus_rule(sym, opts)
rule.patterns_fail = common.create_regex_table(opts.patterns_fail or {}) rule.patterns_fail = common.create_regex_table(opts.patterns_fail or {})
lua_redis.register_prefix(rule.prefix .. '_*', N, lua_redis.register_prefix(rule.prefix .. '_*', N,
string.format('Antivirus cache for rule "%s"',
rule.type), {
type = 'string',
})
string.format('Antivirus cache for rule "%s"',
rule.type), {
type = 'string',
})
-- if any mime_part filter defined, do not scan all attachments -- if any mime_part filter defined, do not scan all attachments
if opts.mime_parts_filter_regex ~= nil if opts.mime_parts_filter_regex ~= nil
@ -157,9 +160,9 @@ local function add_antivirus_rule(sym, opts)
rule.whitelist = rspamd_config:add_hash_map(opts.whitelist) rule.whitelist = rspamd_config:add_hash_map(opts.whitelist)
end end
return function(task)
-- Return both callback and rule for symbol registration
local cb = function(task)
if rule.scan_mime_parts then if rule.scan_mime_parts then
fun.each(function(p) fun.each(function(p)
local content = p:get_content() local content = p:get_content()
local clen = #content local clen = #content
@ -173,18 +176,19 @@ local function add_antivirus_rule(sym, opts)
if clen == #opts.eicar_fake_pattern and content == opts.eicar_fake_pattern then if clen == #opts.eicar_fake_pattern and content == opts.eicar_fake_pattern then
rspamd_logger.infox(task, 'found eicar fake replacement part in the part (filename="%s")', rspamd_logger.infox(task, 'found eicar fake replacement part in the part (filename="%s")',
p:get_filename())
p:get_filename())
content = eicar_pattern content = eicar_pattern
end end
end end
cfg.check(task, content, p:get_digest(), rule, p) cfg.check(task, content, p:get_digest(), rule, p)
end end
end, common.check_parts_match(task, rule)) end, common.check_parts_match(task, rule))
else else
cfg.check(task, task:get_content(), task:get_digest(), rule) cfg.check(task, task:get_content(), task:get_digest(), rule)
end end
end end
return cb, rule
end end
-- Registration -- Registration
@ -200,15 +204,15 @@ if opts and type(opts) == 'table' then
if not m.name then if not m.name then
m.name = k m.name = k
end end
local cb = add_antivirus_rule(k, m)
local cb, rule = add_antivirus_rule(k, m)
if not cb then if not cb then
rspamd_logger.errx(rspamd_config, 'cannot add rule: "' .. k .. '"') rspamd_logger.errx(rspamd_config, 'cannot add rule: "' .. k .. '"')
lua_util.config_utils.push_config_error(N, 'cannot add AV rule: "' .. k .. '"') lua_util.config_utils.push_config_error(N, 'cannot add AV rule: "' .. k .. '"')
else else
rspamd_logger.infox(rspamd_config, 'added antivirus engine %s -> %s', k, m.symbol)
rspamd_logger.infox(rspamd_config, 'added antivirus engine %s -> %s', k, rule.symbol or m.symbol)
local t = { local t = {
name = m.symbol,
name = rule.symbol or m.symbol,
callback = cb, callback = cb,
score = 0.0, score = 0.0,
group = N group = N
@ -233,27 +237,27 @@ if opts and type(opts) == 'table' then
rspamd_config:register_symbol({ rspamd_config:register_symbol({
type = 'virtual', type = 'virtual',
name = m['symbol_fail'],
name = rule.symbol_fail or m['symbol_fail'],
parent = id, parent = id,
score = 0.0, score = 0.0,
group = N group = N
}) })
rspamd_config:register_symbol({ rspamd_config:register_symbol({
type = 'virtual', type = 'virtual',
name = m['symbol_encrypted'],
name = rule.symbol_encrypted or m['symbol_encrypted'],
parent = id, parent = id,
score = 0.0, score = 0.0,
group = N group = N
}) })
rspamd_config:register_symbol({ rspamd_config:register_symbol({
type = 'virtual', type = 'virtual',
name = m['symbol_macro'],
name = rule.symbol_macro or m['symbol_macro'],
parent = id, parent = id,
score = 0.0, score = 0.0,
group = N group = N
}) })
has_valid = true has_valid = true
if type(m['patterns']) == 'table' then
if type(rule.patterns) == 'table' and type(m['patterns']) == 'table' then
if m['patterns'][1] then if m['patterns'][1] then
for _, p in ipairs(m['patterns']) do for _, p in ipairs(m['patterns']) do
if type(p) == 'table' then if type(p) == 'table' then
@ -321,6 +325,48 @@ if opts and type(opts) == 'table' then
end end
end end
end end
if rule.symbols then
rspamd_logger.infox(rspamd_config, 'registering category symbols for %s', rule.name)
local function reg_symbols(tbl)
for _, sym in pairs(tbl) do
if type(sym) == 'string' then
rspamd_logger.infox(rspamd_config, 'registering symbol: %s (string)', sym)
rspamd_config:register_symbol({
type = 'virtual',
name = sym,
parent = id,
group = N
})
elseif type(sym) == 'table' then
if sym.symbol then
rspamd_logger.infox(rspamd_config, 'registering symbol: %s with score %s',
sym.symbol, sym.score or 'default')
rspamd_config:register_symbol({
type = 'virtual',
name = sym.symbol,
parent = id,
group = N
})
if sym.score then
rspamd_config:set_metric_symbol({
name = sym.symbol,
score = sym.score,
description = sym.description,
group = sym.group or N,
})
end
else
reg_symbols(sym)
end
end
end
end
reg_symbols(rule.symbols)
else
rspamd_logger.infox(rspamd_config, 'no category symbols defined for %s', rule.name)
end
if m['score'] then if m['score'] then
-- Register metric symbol -- Register metric symbol
local description = 'antivirus symbol' local description = 'antivirus symbol'

Loading…
Cancel
Save