Browse Source

Merge pull request #2711 from HeinleinSupport/master

Oletools,ICAP support / lua_scanners enhancements
tags/1.9.0
Vsevolod Stakhov 5 years ago
parent
commit
c20a13ccab
No account linked to committer's email address

+ 11
- 1
conf/groups.conf View File

@@ -107,5 +107,15 @@ group "neural" {
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/neural_group.conf"
}

group "antivirus" {
.include(try=true; priority=1; duplicate=merge) "$LOCAL_CONFDIR/local.d/antivirus_group.conf"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/antivirus_group.conf"
}

group "external_services" {
.include(try=true; priority=1; duplicate=merge) "$LOCAL_CONFDIR/local.d/external_services_group.conf"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/external_services_group.conf"
}

.include(try=true; priority=1; duplicate=merge) "$LOCAL_CONFDIR/local.d/groups.conf"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/groups.conf"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/groups.conf"

+ 91
- 0
conf/modules.d/external_services.conf View File

@@ -0,0 +1,91 @@
# Please don't modify this file as your changes might be overwritten with
# the next update.
#
# You can modify '$LOCAL_CONFDIR/rspamd.conf.local.override' to redefine
# parameters defined on the top level
#
# You can modify '$LOCAL_CONFDIR/rspamd.conf.local' to add
# parameters defined on the top level
#
# For specific modules or configuration you can also modify
# '$LOCAL_CONFDIR/local.d/file.conf' - to add your options or rewrite defaults
# '$LOCAL_CONFDIR/override.d/file.conf' - to override the defaults
#
# See https://rspamd.com/doc/tutorials/writing_rules.html for details

external_services {
oletools {
# If set force this action if any virus is found (default unset: no action is forced)
# action = "reject";
# If set, then rejection message is set to this value (mention single quotes)
# If `max_size` is set, messages > n bytes in size are not scanned
# max_size = 20000000;
# log_clean = true;
# servers = "127.0.0.1:10050";
# cache_expire = 86400;
# scan_mime_parts = true;
# extended = false;
# if `patterns` is specified virus name will be matched against provided regexes and the related
# symbol will be yielded if a match is found. If no match is found, default symbol is yielded.
patterns {
# symbol_name = "pattern";
JUST_EICAR = "^Eicar-Test-Signature$";
}
# mime-part regex matching in content-type or filename
mime_parts_filter_regex {
#GEN1 = "application\/octet-stream";
DOC2 = "application\/msword";
DOC3 = "application\/vnd\.ms-word.*";
XLS = "application\/vnd\.ms-excel.*";
PPT = "application\/vnd\.ms-powerpoint.*";
GEN2 = "application\/vnd\.openxmlformats-officedocument.*";
}
# Mime-Part filename extension matching (no regex)
mime_parts_filter_ext {
doc = "doc";
dot = "dot";
docx = "docx";
dotx = "dotx";
docm = "docm";
dotm = "dotm";
xls = "xls";
xlt = "xlt";
xla = "xla";
xlsx = "xlsx";
xltx = "xltx";
xlsm = "xlsm";
xltm = "xltm";
xlam = "xlam";
xlsb = "xlsb";
ppt = "ppt";
pot = "pot";
pps = "pps";
ppa = "ppa";
pptx = "pptx";
potx = "potx";
ppsx = "ppsx";
ppam = "ppam";
pptm = "pptm";
potm = "potm";
ppsm = "ppsm";
}
# `whitelist` points to a map of IP addresses. Mail from these addresses is not scanned.
whitelist = "/etc/rspamd/antivirus.wl";
}
dcc {
# If set force this action if any virus is found (default unset: no action is forced)
# action = "reject";
# If set, then rejection message is set to this value (mention single quotes)
# If `max_size` is set, messages > n bytes in size are not scanned
max_size = 20000000;
#servers = "127.0.0.1:10045";
# if `patterns` is specified virus name will be matched against provided regexes and the related
# symbol will be yielded if a match is found. If no match is found, default symbol is yielded.
patterns {
# symbol_name = "pattern";
JUST_EICAR = "^Eicar-Test-Signature$";
}
# `whitelist` points to a map of IP addresses. Mail from these addresses is not scanned.
whitelist = "/etc/rspamd/antivirus.wl";
}
}

+ 27
- 20
lualib/lua_scanners/clamav.lua View File

@@ -32,9 +32,10 @@ local default_message = '${SCANNER}: virus found: "${VIRUS}"'

local function clamav_config(opts)
local clamav_conf = {
scan_mime_parts = true;
scan_text_mime = false;
scan_image_mime = false;
N = N,
scan_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
default_port = 3310,
log_clean = false,
timeout = 5.0, -- FIXME: this will break task_timeout!
@@ -44,12 +45,18 @@ local function clamav_config(opts)
message = default_message,
}

for k,v in pairs(opts) do
clamav_conf[k] = v
end
clamav_conf = lua_util.override_defaults(clamav_conf, opts)

if not clamav_conf.prefix then
clamav_conf.prefix = 'rs_cl'
clamav_conf.prefix = 'rs_' .. clamav_conf.name .. '_'
end

if not clamav_conf.log_prefix then
if clamav_conf.name:lower() == clamav_conf.type:lower() then
clamav_conf.log_prefix = clamav_conf.name
else
clamav_conf.log_prefix = clamav_conf.name .. ' (' .. clamav_conf.type .. ')'
end
end

if not clamav_conf['servers'] then
@@ -63,7 +70,7 @@ local function clamav_config(opts)
clamav_conf.default_port)

if clamav_conf['upstreams'] then
lua_util.add_debug_alias('antivirus', N)
lua_util.add_debug_alias('antivirus', clamav_conf.N)
return clamav_conf
end

@@ -96,7 +103,7 @@ local function clamav_check(task, content, digest, rule)
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)
lua_util.debugm(rule.N, task, '%s: retry IP: %s', rule.log_prefix, addr)

tcp.request({
task = task,
@@ -108,7 +115,7 @@ local function clamav_check(task, content, digest, rule)
stop_pattern = '\0'
})
else
rspamd_logger.errx(task, '%s [%s]: failed to scan, maximum retransmits exceed', rule['symbol'], rule['type'])
rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits exceed', rule.log_prefix)
task:insert_result(rule['symbol_fail'], 0.0, 'failed to scan and retransmits exceed')
end

@@ -116,26 +123,26 @@ local function clamav_check(task, content, digest, rule)
upstream:ok()
data = tostring(data)
local cached
lua_util.debugm(N, task, '%s [%s]: got reply: %s', rule['symbol'], rule['type'], data)
lua_util.debugm(rule.N, task, '%s: got reply: %s', rule.log_prefix, data)
if data == 'stream: OK' then
cached = 'OK'
if rule['log_clean'] then
rspamd_logger.infox(task, '%s [%s]: message or mime_part is clean', rule['symbol'], rule['type'])
rspamd_logger.infox(task, '%s: message or mime_part is clean', rule.log_prefix)
else
lua_util.debugm(N, task, '%s [%s]: message or mime_part is clean', rule['symbol'], rule['type'])
lua_util.debugm(rule.N, task, '%s: message or mime_part is clean', rule.log_prefix)
end
else
local vname = string.match(data, 'stream: (.+) FOUND')
if vname then
common.yield_result(task, rule, vname, N)
common.yield_result(task, rule, vname)
cached = vname
else
rspamd_logger.errx(task, 'unhandled response: %s', data)
rspamd_logger.errx(task, '%s: unhandled response: %s', rule.log_prefix, data)
task:insert_result(rule['symbol_fail'], 0.0, 'unhandled response')
end
end
if cached then
common.save_av_cache(task, digest, rule, cached, N)
common.save_av_cache(task, digest, rule, cached)
end
end
end
@@ -151,8 +158,8 @@ local function clamav_check(task, content, digest, rule)
})
end

if common.need_av_check(task, content, rule, N) then
if common.check_av_cache(task, digest, rule, clamav_check_uncached, N) then
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, clamav_check_uncached) then
return
else
clamav_check_uncached()
@@ -165,5 +172,5 @@ return {
description = 'clamav antivirus',
configure = clamav_config,
check = clamav_check,
name = 'clamav'
}
name = N
}

+ 154
- 39
lualib/lua_scanners/common.lua View File

@@ -1,5 +1,6 @@
--[[
Copyright (c) 2018, Vsevolod Stakhov <vsevolod@highsecure.ru>
Copyright (c) 2019, Carsten Rosenberg <c.rosenberg@heinlein-support.de>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,30 +21,43 @@ limitations under the License.
--]]

local rspamd_logger = require "rspamd_logger"
local rspamd_regexp = require "rspamd_regexp"
local lua_util = require "lua_util"
local lua_redis = require "lua_redis"
local fun = require "fun"

local exports = {}

local function match_patterns(default_sym, found, patterns)
if type(patterns) ~= 'table' then return default_sym end
local function log_clean(task, rule, msg)

msg = msg or 'message or mime_part is clean'

if rule.log_clean then
rspamd_logger.infox(task, '%s: %s', rule.log_prefix, msg)
else
lua_util.debugm(rule.module_name, task, '%s: %s', rule.log_prefix, msg)
end

end

local function match_patterns(default_sym, found, patterns, dyn_weight)
if type(patterns) ~= 'table' then return default_sym, dyn_weight end
if not patterns[1] then
for sym, pat in pairs(patterns) do
if pat:match(found) then
return sym
return sym, '1'
end
end
return default_sym
return default_sym, dyn_weight
else
for _, p in ipairs(patterns) do
for sym, pat in pairs(p) do
if pat:match(found) then
return sym
return sym, '1'
end
end
end
return default_sym
return default_sym, dyn_weight
end
end

@@ -51,23 +65,23 @@ local function yield_result(task, rule, vname, N, dyn_weight)
local all_whitelisted = true
if not dyn_weight then dyn_weight = 1.0 end
if type(vname) == 'string' then
local symname = match_patterns(rule.symbol, vname, rule.patterns)
local symname, symscore = match_patterns(rule.symbol, vname, rule.patterns, dyn_weight)
if rule.whitelist and rule.whitelist:get_key(vname) then
rspamd_logger.infox(task, '%s: "%s" is in whitelist', N, vname)
rspamd_logger.infox(task, '%s: "%s" is in whitelist', rule.log_prefix, vname)
return
end
task:insert_result(symname, 1.0, vname)
rspamd_logger.infox(task, '%s: %s found: "%s"', N, rule.detection_category, vname)
task:insert_result(symname, symscore, vname)
rspamd_logger.infox(task, '%s: %s found: "%s"', rule.log_prefix, rule.detection_category, vname)
elseif type(vname) == 'table' then
for _, vn in ipairs(vname) do
local symname = match_patterns(rule.symbol, vn, rule.patterns)
local symname, symscore = match_patterns(rule.symbol, vn, rule.patterns, dyn_weight)
if rule.whitelist and rule.whitelist:get_key(vn) then
rspamd_logger.infox(task, '%s: "%s" is in whitelist', N, vn)
rspamd_logger.infox(task, '%s: "%s" is in whitelist', rule.log_prefix, vn)
else
all_whitelisted = false
task:insert_result(symname, dyn_weight, vn)
task:insert_result(symname, symscore, vn)
rspamd_logger.infox(task, '%s: %s found: "%s"',
N, rule.detection_category, vn)
rule.log_prefix, rule.detection_category, vn)
end
end
end
@@ -76,43 +90,45 @@ local function yield_result(task, rule, vname, N, dyn_weight)
if all_whitelisted then return end
vname = table.concat(vname, '; ')
end
task:set_pre_result(rule['action'],
task:set_pre_result(rule.action,
lua_util.template(rule.message or 'Rejected', {
SCANNER = N,
SCANNER = rule.name,
VIRUS = vname,
}), N)
end
end

local function message_not_too_large(task, content, rule, N)
local function message_not_too_large(task, content, rule)
local max_size = tonumber(rule.max_size)
if not max_size then return true end
if #content > max_size then
rspamd_logger.infox(task, "skip %s check as it is too large: %s (%s is allowed)",
N, #content, max_size)
rule.log_prefix, #content, max_size)
return false
end
return true
end

local function need_av_check(task, content, rule, N)
return message_not_too_large(task, content, rule, N)
local function need_av_check(task, content, rule)
return message_not_too_large(task, content, rule)
end

local function check_av_cache(task, digest, rule, fn, N)
local function check_av_cache(task, digest, rule, fn)
local key = digest

local function redis_av_cb(err, data)
if data and type(data) == 'string' then
-- Cached
if data ~= 'OK' then
lua_util.debugm(N, task, 'got cached result for %s: %s',
key, data)
data = lua_util.str_split(data, '\v')
yield_result(task, rule, data, N)
data = rspamd_str_split(data, '\t')
local threat_string = rspamd_str_split(data[1], '\v')
local score = data[2] or rule.default_score
if threat_string[1] ~= 'OK' then
lua_util.debugm(rule.module_name, task, '%s: got cached threat result for %s: %s',
rule.log_prefix, key, threat_string[1])
yield_result(task, rule, threat_string, score)
else
lua_util.debugm(N, task, 'got cached result for %s: %s',
key, data)
lua_util.debugm(rule.module_name, task, '%s: got cached negative result for %s: %s',
rule.log_prefix, key, threat_string[1])
end
else
if err then
@@ -124,7 +140,7 @@ local function check_av_cache(task, digest, rule, fn, N)

if rule.redis_params then

key = rule['prefix'] .. key
key = rule.prefix .. key

if lua_redis.redis_make_request(task,
rule.redis_params, -- connect params
@@ -141,7 +157,7 @@ local function check_av_cache(task, digest, rule, fn, N)
return false
end

local function save_av_cache(task, digest, rule, to_save, N)
local function save_av_cache(task, digest, rule, to_save, dyn_weight)
local key = digest

local function redis_set_cb(err)
@@ -150,8 +166,7 @@ local function save_av_cache(task, digest, rule, to_save, N)
rspamd_logger.errx(task, 'failed to save %s cache for %s -> "%s": %s',
rule.detection_category, to_save, key, err)
else
lua_util.debugm(N, task, 'saved cached result for %s: %s',
key, to_save)
lua_util.debugm(rule.module_name, task, '%s: saved cached result for %s: %s', rule.log_prefix, key, to_save)
end
end

@@ -159,6 +174,8 @@ local function save_av_cache(task, digest, rule, to_save, N)
to_save = table.concat(to_save, '\v')
end

local value = table.concat({to_save, dyn_weight}, '\t')

if rule.redis_params and rule.prefix then
key = rule.prefix .. key

@@ -168,29 +185,127 @@ local function save_av_cache(task, digest, rule, to_save, N)
true, -- is write
redis_set_cb, --callback
'SETEX', -- command
{ key, rule.cache_expire or 0, to_save }
{ key, rule.cache_expire or 0, value }
)
end

return false
end

local function text_parts_min_words(task, min_words)
local filter_func = function(p)
return p:get_words_count() >= min_words
local function create_regex_table(patterns)
local regex_table = {}
if patterns[1] then
for i, p in ipairs(patterns) do
if type(p) == 'table' then
local new_set = {}
for k, v in pairs(p) do
new_set[k] = rspamd_regexp.create_cached(v)
end
regex_table[i] = new_set
else
regex_table[i] = {}
end
end
else
for k, v in pairs(patterns) do
regex_table[k] = rspamd_regexp.create_cached(v)
end
end
return regex_table
end

local function match_filter(task, found, patterns)
if type(patterns) ~= 'table' then return false end
if not patterns[1] then
for _, pat in pairs(patterns) do
if pat:match(found) then
return true
end
end
return false
else
for _, p in ipairs(patterns) do
for _, pat in ipairs(p) do
if pat:match(found) then
return true
end
end
end
return false
end
end

return fun.any(filter_func, task:get_text_parts())
-- borrowed from mime_types.lua
-- ext is the last extension, LOWERCASED
-- ext2 is the one before last extension LOWERCASED
local function gen_extension(fname)
local filename_parts = rspamd_str_split(fname, '.')

local ext = {}
for n = 1, 2 do
ext[n] = #filename_parts > n and string.lower(filename_parts[#filename_parts + 1 - n]) or nil
end
return ext[1],ext[2],filename_parts
end

local function check_parts_match(task, rule)

local filter_func = function(p)
local content_type,content_subtype = p:get_type()
local fname = p:get_filename()
local ext, ext2, part_table
local extension_check = false
local content_type_check = false
local text_part_min_words_check = true

if rule.scan_all_mime_parts == false then
-- check file extension and filename regex matching
if fname ~= nil then
ext,ext2,part_table = gen_extension(fname)
lua_util.debugm(rule.module_name, task, '%s: extension found: %s - 2.ext: %s - parts: %s',
rule.log_prefix, ext, ext2, part_table)
if match_filter(task, ext, rule.mime_parts_filter_ext)
or match_filter(task, ext2, rule.mime_parts_filter_ext) then
lua_util.debugm(rule.module_name, task, '%s: extension matched: %s', rule.log_prefix, ext)
extension_check = true
end
if match_filter(task, fname, rule.mime_parts_filter_regex) then
content_type_check = true
end
end
-- check content type regex matching
if content_type ~= nil and content_subtype ~= nil then
if match_filter(task, content_type..'/'..content_subtype, rule.mime_parts_filter_regex) then
lua_util.debugm(rule.module_name, task, '%s: regex ct: %s', rule.log_prefix,
content_type..'/'..content_subtype)
content_type_check = true
end
end
end

-- check text_part has more words than text_part_min_words_check
if rule.text_part_min_words and p:is_text() then
text_part_min_words_check = p:get_words_count() >= tonumber(rule.text_part_min_words)
end

return (rule.scan_image_mime and p:is_image())
or (rule.scan_text_mime and text_part_min_words_check)
or (p:is_attachment() and rule.scan_all_mime_parts ~= false)
or extension_check
or content_type_check
end

return fun.filter(filter_func, task:get_parts())
end

exports.log_clean = log_clean
exports.yield_result = yield_result
exports.match_patterns = match_patterns
exports.need_av_check = need_av_check
exports.check_av_cache = check_av_cache
exports.save_av_cache = save_av_cache
exports.text_parts_min_words = text_parts_min_words
exports.create_regex_table = create_regex_table
exports.check_parts_match = check_parts_match

setmetatable(exports, {
__call = function(t, override)
@@ -210,4 +325,4 @@ setmetatable(exports, {
end,
})

return exports
return exports

+ 58
- 27
lualib/lua_scanners/dcc.lua View File

@@ -29,7 +29,7 @@ local fun = require "fun"

local N = 'dcc'

local function dcc_check(task, content, _, rule)
local function dcc_check(task, content, digest, rule)
local function dcc_check_uncached ()
local upstream = rule.upstreams:get_upstream_round_robin()
local addr = upstream:get_addr()
@@ -81,8 +81,7 @@ local function dcc_check(task, content, _, rule)

local function dcc_callback(err, data, conn)

if err then

local function dcc_requery()
-- set current upstream to fail because an error occurred
upstream:fail()

@@ -91,11 +90,15 @@ local function dcc_check(task, content, _, rule)

retransmits = retransmits - 1

lua_util.debugm(rule.N, task, '%s: Request Error: %s - retries left: %s',
rule.log_prefix, err, retransmits)

-- Select a different upstream!
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task, '%s: retry IP: %s', rule.log_prefix, addr)
lua_util.debugm(rule.N, task, '%s: retry IP: %s:%s',
rule.log_prefix, addr, addr:get_port())

tcp.request({
task = task,
@@ -110,35 +113,35 @@ local function dcc_check(task, content, _, rule)
fuz2_max = 999999,
})
else
rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits exceed', rule['symbol'], rule['type'])
task:insert_result(rule['symbol_fail'], 0.0, 'failed to scan and retransmits exceed')
rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits '..
'exceed', rule.log_prefix)
task:insert_result(rule['symbol_fail'], 0.0, 'failed to scan and '..
'retransmits exceed')
end
end

if err then

dcc_requery()

else
-- Parse the response
if upstream then upstream:ok() end
local _,_,result,disposition,header = tostring(data):find("(.-)\n(.-)\n(.-)\n")
lua_util.debugm(N, task, 'DCC result=%1 disposition=%2 header="%3"',
lua_util.debugm(rule.N, task, 'DCC result=%1 disposition=%2 header="%3"',
result, disposition, header)

if header then
local _,_,info = header:find("; (.-)$")

if (result == 'R') then
-- Reject
common.yield_result(task, rule, info, rule.default_score)
common.save_av_cache(task, digest, rule, info, rule.default_score)
elseif (result == 'T') then
-- Temporary failure
rspamd_logger.warnx(task, 'DCC returned a temporary failure result: %s', result)
task:insert_result(rule.symbol_fail,
0.0,
'tempfail:' .. result)
dcc_requery()
elseif result == 'A' then
if rule.log_clean then
rspamd_logger.infox(task, '%s: clean, returned result A - info: %s',
rule.log_prefix, info)
else
lua_util.debugm(N, task, '%s: returned result A - info: %s',
rule.log_prefix, info)

local opts = {}
local score = 0.0
@@ -188,25 +191,36 @@ local function dcc_check(task, content, _, rule)
task:insert_result(rule.symbol_bulk,
score,
opts)
common.save_av_cache(task, digest, rule, opts, score)
else
common.save_av_cache(task, digest, rule, 'OK')
if rule.log_clean then
rspamd_logger.infox(task, '%s: clean, returned result A - info: %s',
rule.log_prefix, info)
else
lua_util.debugm(rule.N, task, '%s: returned result A - info: %s',
rule.log_prefix, info)
end
end
elseif result == 'G' then
-- do nothing
common.save_av_cache(task, digest, rule, 'OK')
if rule.log_clean then
rspamd_logger.infox(task, '%s: clean, returned result G - info: %s', rule.log_prefix, info)
else
lua_util.debugm(N, task, '%s: returned result G - info: %s', rule.log_prefix, info)
lua_util.debugm(rule.N, task, '%s: returned result G - info: %s', rule.log_prefix, info)
end
elseif result == 'S' then
-- do nothing
common.save_av_cache(task, digest, rule, 'OK')
if rule.log_clean then
rspamd_logger.infox(task, '%s: clean, returned result S - info: %s', rule.log_prefix, info)
else
lua_util.debugm(N, task, '%s: returned result S - info: %s', rule.log_prefix, info)
lua_util.debugm(rule.N, task, '%s: returned result S - info: %s', rule.log_prefix, info)
end
else
-- Unknown result
rspamd_logger.warnx(task, 'DCC result error: %1', result);
rspamd_logger.warnx(task, '%s: result error: %1', rule.log_prefix, result);
task:insert_result(rule.symbol_fail,
0.0,
'error: ' .. result)
@@ -222,21 +236,30 @@ local function dcc_check(task, content, _, rule)
timeout = rule.timeout or 2.0,
shutdown = true,
data = request_data,
callback = dcc_callback
callback = dcc_callback,
body_max = 999999,
fuz1_max = 999999,
fuz2_max = 999999,
})
end
if common.need_av_check(task, content, rule, N) then
dcc_check_uncached()
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, dcc_check_uncached) then
return
else
dcc_check_uncached()
end
end
end

local function dcc_config(opts)

local dcc_conf = {
N = N,
default_port = 10045,
timeout = 5.0,
log_clean = false,
retransmits = 2,
cache_expire = 7200, -- expire redis in 2h
message = '${SCANNER}: bulk message found: "${VIRUS}"',
detection_category = "hash",
default_score = 1,
@@ -252,8 +275,16 @@ local function dcc_config(opts)

dcc_conf = lua_util.override_defaults(dcc_conf, opts)

if not dcc_conf.prefix then
dcc_conf.prefix = 'rs_' .. dcc_conf.name .. '_'
end

if not dcc_conf.log_prefix then
dcc_conf.log_prefix = N
if dcc_conf.name:lower() == dcc_conf.type:lower() then
dcc_conf.log_prefix = dcc_conf.name
else
dcc_conf.log_prefix = dcc_conf.name .. ' (' .. dcc_conf.type .. ')'
end
end

if not dcc_conf.servers and dcc_conf.socket then
@@ -271,7 +302,7 @@ local function dcc_config(opts)
dcc_conf.default_port)

if dcc_conf.upstreams then
lua_util.add_debug_alias('external_services', N)
lua_util.add_debug_alias('external_services', dcc_conf.N)
return dcc_conf
end

@@ -285,5 +316,5 @@ return {
description = 'dcc bulk scanner',
configure = dcc_config,
check = dcc_check,
name = 'dcc'
}
name = N
}

+ 22
- 15
lualib/lua_scanners/fprot.lua View File

@@ -31,9 +31,10 @@ local default_message = '${SCANNER}: virus found: "${VIRUS}"'

local function fprot_config(opts)
local fprot_conf = {
scan_mime_parts = true;
scan_text_mime = false;
scan_image_mime = false;
N = N,
scan_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
default_port = 10200,
timeout = 5.0, -- FIXME: this will break task_timeout!
log_clean = false,
@@ -43,12 +44,18 @@ local function fprot_config(opts)
message = default_message,
}

for k,v in pairs(opts) do
fprot_conf[k] = v
end
fprot_conf = lua_util.override_defaults(fprot_conf, opts)

if not fprot_conf.prefix then
fprot_conf.prefix = 'rs_fp'
fprot_conf.prefix = 'rs_' .. fprot_conf.name .. '_'
end

if not fprot_conf.log_prefix then
if fprot_conf.name:lower() == fprot_conf.type:lower() then
fprot_conf.log_prefix = fprot_conf.name
else
fprot_conf.log_prefix = fprot_conf.name .. ' (' .. fprot_conf.type .. ')'
end
end

if not fprot_conf['servers'] then
@@ -62,7 +69,7 @@ local function fprot_config(opts)
fprot_conf.default_port)

if fprot_conf['upstreams'] then
lua_util.add_debug_alias('antivirus', N)
lua_util.add_debug_alias('antivirus', fprot_conf.N)
return fprot_conf
end

@@ -96,7 +103,7 @@ local function fprot_check(task, content, digest, rule)
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)
lua_util.debugm(rule.N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)

tcp.request({
task = task,
@@ -133,12 +140,12 @@ local function fprot_check(task, content, digest, rule)
if not vname then
rspamd_logger.errx(task, 'Unhandled response: %s', data)
else
common.yield_result(task, rule, vname, N)
common.yield_result(task, rule, vname)
cached = vname
end
end
if cached then
common.save_av_cache(task, digest, rule, cached, N)
common.save_av_cache(task, digest, rule, cached)
end
end
end
@@ -154,8 +161,8 @@ local function fprot_check(task, content, digest, rule)
})
end

if common.need_av_check(task, content, rule, N) then
if common.check_av_cache(task, digest, rule, fprot_check_uncached, N) then
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, fprot_check_uncached) then
return
else
fprot_check_uncached()
@@ -168,5 +175,5 @@ return {
description = 'fprot antivirus',
configure = fprot_config,
check = fprot_check,
name = 'fprot'
}
name = N
}

+ 301
- 0
lualib/lua_scanners/icap.lua View File

@@ -0,0 +1,301 @@
--[[
Copyright (c) 2018, Vsevolod Stakhov <vsevolod@highsecure.ru>
Copyright (c) 2019, Carsten Rosenberg <c.rosenberg@heinlein-support.de>

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 icap
-- This module contains icap access functions.
-- Currently tested with Symantec, Sophos Savdi, ClamAV/c-icap
--]]

local lua_util = require "lua_util"
local tcp = require "rspamd_tcp"
local upstream_list = require "rspamd_upstream_list"
local rspamd_logger = require "rspamd_logger"
local common = require "lua_scanners/common"

local N = 'icap'

local function icap_check(task, content, digest, rule)
local function icap_check_uncached ()
local upstream = rule.upstreams:get_upstream_round_robin()
local addr = upstream:get_addr()
local retransmits = rule.retransmits
local respond_headers = {}

-- Build the icap queries
local options_request = {
"OPTIONS icap://" .. addr:to_string() .. ":" .. addr:get_port() .. "/" .. rule.scheme .. " ICAP/1.0\r\n",
"Host:" .. addr:to_string() .. "\r\n",
"User-Agent: Rspamd\r\n",
"Encapsulated: null-body=0\r\n\r\n",
}
local size = string.format("%x", tonumber(#content))
lua_util.debugm(rule.N, task, '%s: size: %s', rule.log_prefix, size)

local function get_respond_query()
table.insert(respond_headers, 1, 'RESPMOD icap://' .. addr:to_string() .. ':' .. addr:get_port() .. '/'
.. rule.scheme .. ' ICAP/1.0\r\n')
table.insert(respond_headers, 'Encapsulated: res-body=0\r\n')
table.insert(respond_headers, '\r\n')
table.insert(respond_headers, size .. '\r\n')
table.insert(respond_headers, content)
table.insert(respond_headers, '\r\n0\r\n\r\n')
return respond_headers
end

local function add_respond_header(name, value)
table.insert(respond_headers, name .. ': ' .. value .. '\r\n' )
end

local function icap_result_header_table(result)
local icap_headers = {}
for s in result:gmatch("[^\r\n]+") do
if string.find(s, '^ICAP') then
icap_headers['icap'] = s
end
if string.find(s, '[%a%d-+]-: ') then
local _,_,key,value = tostring(s):find("([%a%d-+]-):%s(.+)")
icap_headers[key] = value
end
end
lua_util.debugm(rule.N, task, '%s: icap_headers: %s', rule.log_prefix, icap_headers)
return icap_headers
end

local function icap_parse_result(icap_headers)

local threat_string = {}

--[[
@ToDo: handle type in response

Generic Strings:
X-Infection-Found: Type=0; Resolution=2; Threat=Troj/DocDl-OYC;
X-Infection-Found: Type=0; Resolution=2; Threat=W97M.Downloader;
Symantec String:
X-Infection-Found: Type=2; Resolution=2; Threat=Container size violation
X-Infection-Found: Type=2; Resolution=2; Threat=Encrypted container violation;
Sophos Strings:
X-Virus-ID: Troj/DocDl-OYC
]] --

local pattern_symbols
local match

if icap_headers['X-Infection-Found'] ~= nil then
pattern_symbols = "(Type%=%d; .* Threat%=)(.*)([;]+)"
match = string.gsub(icap_headers['X-Infection-Found'], pattern_symbols, "%2")
lua_util.debugm(rule.N, task, '%s: icap X-Infection-Found: %s', rule.log_prefix, match)
table.insert(threat_string, match)
elseif icap_headers['X-Virus-ID'] ~= nil then
lua_util.debugm(rule.N, task, '%s: icap X-Virus-ID: %s', rule.log_prefix, icap_headers['X-Virus-ID'])
table.insert(threat_string, icap_headers['X-Virus-ID'])
end

if #threat_string > 0 then
common.yield_result(task, rule, threat_string, rule.default_score)
common.save_av_cache(task, digest, rule, threat_string, rule.default_score)
else
common.save_av_cache(task, digest, rule, 'OK', 0)
common.log_clean(task, rule)
end
end

local function icap_r_respond_cb(err, data, conn)
local result = tostring(data)
conn:close()

local icap_headers = icap_result_header_table(result)
-- Find ICAP/1.x 2xx response
if string.find(icap_headers.icap, 'ICAP%/1%.. 2%d%d') then
icap_parse_result(icap_headers)
elseif string.find(icap_headers.icap, 'ICAP%/1%.. [45]%d%d') then
-- Find ICAP/1.x 5/4xx response
--[[
Symantec String:
ICAP/1.0 539 Aborted - No AV scanning license
SquidClamAV/C-ICAP:
ICAP/1.0 500 Server error
]]--
rspamd_logger.errx(task, '%s: ICAP ERROR: %s', rule.log_prefix, icap_headers.icap)
task:insert_result(rule.symbol_fail, 0.0, icap_headers.icap)
return false
else
rspamd_logger.errx(task, '%s: unhandled response |%s|',
rule.log_prefix, string.gsub(result, "\r\n", ", "))
task:insert_result(rule.symbol_fail, 0.0, 'unhandled icap response: ' .. icap_headers.icap)
end
end

local function icap_w_respond_cb(err, conn)
conn:add_read(icap_r_respond_cb, '\r\n\r\n')
end

local function icap_r_options_cb(err, data, conn)
local icap_headers = icap_result_header_table(tostring(data))

if string.find(icap_headers.icap, 'ICAP%/1%.. 2%d%d') then
if icap_headers['Methods'] ~= nil and string.find(icap_headers['Methods'], 'RESPMOD') then
if icap_headers['Allow'] ~= nil and string.find(icap_headers['Allow'], '204') then
add_respond_header('Allow', '204')
end
conn:add_write(icap_w_respond_cb, get_respond_query())
else
rspamd_logger.errx(task, '%s: RESPMOD method not advertised: Methods: %s',
rule.log_prefix, icap_headers['Methods'])
task:insert_result(rule.symbol_fail, 0.0, 'NO RESPMOD')
end
else
rspamd_logger.errx(task, '%s: OPTIONS query failed: %s',
rule.log_prefix, icap_headers.icap)
task:insert_result(rule.symbol_fail, 0.0, 'OPTIONS query failed')
end
end

local function icap_callback(err, conn)

local function icap_requery(error)
-- set current upstream to fail because an error occurred
upstream:fail()

-- retry with another upstream until retransmits exceeds
if retransmits > 0 then

retransmits = retransmits - 1

lua_util.debugm(rule.N, task, '%s: Request Error: %s - retries left: %s',
rule.log_prefix, error, retransmits)

-- Select a different upstream!
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(rule.N, task, '%s: retry IP: %s:%s',
rule.log_prefix, addr, addr:get_port())

tcp.request({
task = task,
host = addr:to_string(),
port = addr:get_port(),
timeout = rule.timeout,
stop_pattern = '\r\n',
data = options_request,
read = false,
callback = icap_callback,
})
else
rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits '..
'exceed - err: %s', rule.log_prefix, error)
task:insert_result(rule.symbol_fail, 0.0, 'failed - err: ' .. error)
end
end

if err then
icap_requery(err)
else
-- set upstream ok
if upstream then upstream:ok() end
conn:add_read(icap_r_options_cb, '\r\n\r\n')
end
end

tcp.request({
task = task,
host = addr:to_string(),
port = addr:get_port(),
timeout = rule.timeout,
stop_pattern = '\r\n',
data = options_request,
read = false,
callback = icap_callback,
})
end
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, icap_check_uncached) then
return
else
icap_check_uncached()
end
end
end


local function icap_config(opts)

local icap_conf = {
N = N,
scan_mime_parts = true,
scan_all_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
scheme = "scan",
default_port = 4020,
timeout = 10.0,
log_clean = false,
retransmits = 2,
cache_expire = 7200, -- expire redis in one hour
message = '${SCANNER}: threat found with icap scanner: "${VIRUS}"',
detection_category = "virus",
default_score = 1,
action = false,
}

icap_conf = lua_util.override_defaults(icap_conf, opts)

if not icap_conf.prefix then
icap_conf.prefix = 'rs_' .. icap_conf.name .. '_'
end

if not icap_conf.log_prefix then
icap_conf.log_prefix = icap_conf.name .. ' (' .. icap_conf.type .. ')'
end

if not icap_conf.log_prefix then
if icap_conf.name:lower() == icap_conf.type:lower() then
icap_conf.log_prefix = icap_conf.name
else
icap_conf.log_prefix = icap_conf.name .. ' (' .. icap_conf.type .. ')'
end
end

if not icap_conf.servers then
rspamd_logger.errx(rspamd_config, 'no servers defined')

return nil
end

icap_conf.upstreams = upstream_list.create(rspamd_config,
icap_conf.servers,
icap_conf.default_port)

if icap_conf.upstreams then
lua_util.add_debug_alias('external_services', icap_conf.N)
return icap_conf
end

rspamd_logger.errx(rspamd_config, 'cannot parse servers %s',
icap_conf.servers)
return nil
end

return {
type = {N,'virus', 'virus', 'scanner'},
description = 'generic icap antivirus',
configure = icap_config,
check = icap_check,
name = N
}

+ 3
- 1
lualib/lua_scanners/init.lua View File

@@ -39,6 +39,8 @@ require_scanner('sophos')

-- Other scanners
require_scanner('dcc')
require_scanner('oletools')
require_scanner('icap')

exports.add_scanner = function(name, t, conf_func, check_func)
assert(type(conf_func) == 'function' and type(check_func) == 'function',
@@ -59,4 +61,4 @@ exports.filter = function(t)
end, exports))
end

return exports
return exports

+ 27
- 21
lualib/lua_scanners/kaspersky_av.lua View File

@@ -32,9 +32,10 @@ local default_message = '${SCANNER}: virus found: "${VIRUS}"'

local function kaspersky_config(opts)
local kaspersky_conf = {
scan_mime_parts = true;
scan_text_mime = false;
scan_image_mime = false;
N = N,
scan_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
product_id = 0,
log_clean = false,
timeout = 5.0,
@@ -43,11 +44,22 @@ local function kaspersky_config(opts)
message = default_message,
detection_category = "virus",
tmpdir = '/tmp',
prefix = 'rs_ak',
}

kaspersky_conf = lua_util.override_defaults(kaspersky_conf, opts)

if not kaspersky_conf.prefix then
kaspersky_conf.prefix = 'rs_' .. kaspersky_conf.name .. '_'
end

if not kaspersky_conf.log_prefix then
if kaspersky_conf.name:lower() == kaspersky_conf.type:lower() then
kaspersky_conf.log_prefix = kaspersky_conf.name
else
kaspersky_conf.log_prefix = kaspersky_conf.name .. ' (' .. kaspersky_conf.type .. ')'
end
end

if not kaspersky_conf['servers'] then
rspamd_logger.errx(rspamd_config, 'no servers defined')

@@ -58,7 +70,7 @@ local function kaspersky_config(opts)
kaspersky_conf['servers'], 0)

if kaspersky_conf['upstreams'] then
lua_util.add_debug_alias('antivirus', N)
lua_util.add_debug_alias('antivirus', kaspersky_conf.N)
return kaspersky_conf
end

@@ -110,7 +122,7 @@ local function kaspersky_check(task, content, digest, rule)
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task,
lua_util.debugm(rule.N, task,
'%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)

tcp.request({
@@ -134,21 +146,15 @@ local function kaspersky_check(task, content, digest, rule)
upstream:ok()
data = tostring(data)
local cached
lua_util.debugm(N, task, '%s [%s]: got reply: %s',
lua_util.debugm(rule.N, task, '%s [%s]: got reply: %s',
rule['symbol'], rule['type'], data)
if data == 'stream: OK' then
if data == 'stream: OK' or data == fname .. ': OK' then
cached = 'OK'
if rule['log_clean'] then
rspamd_logger.infox(task, '%s [%s]: message or mime_part is clean',
rule['symbol'], rule['type'])
else
lua_util.debugm(N, task, '%s [%s]: message or mime_part is clean',
rule['symbol'], rule['type'])
end
common.log_clean(task, rule)
else
local vname = string.match(data, ': (.+) FOUND')
if vname then
common.yield_result(task, rule, vname, N)
common.yield_result(task, rule, vname)
cached = vname
else
rspamd_logger.errx(task, 'unhandled response: %s', data)
@@ -156,7 +162,7 @@ local function kaspersky_check(task, content, digest, rule)
end
end
if cached then
common.save_av_cache(task, digest, rule, cached, N)
common.save_av_cache(task, digest, rule, cached)
end
end
end
@@ -172,8 +178,8 @@ local function kaspersky_check(task, content, digest, rule)
})
end

if common.need_av_check(task, content, rule, N) then
if common.check_av_cache(task, digest, rule, kaspersky_check_uncached, N) then
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, kaspersky_check_uncached) then
return
else
kaspersky_check_uncached()
@@ -186,5 +192,5 @@ return {
description = 'kaspersky antivirus',
configure = kaspersky_config,
check = kaspersky_check,
name = 'kaspersky'
}
name = N
}

+ 302
- 0
lualib/lua_scanners/oletools.lua View File

@@ -0,0 +1,302 @@
--[[
Copyright (c) 2018, Vsevolod Stakhov <vsevolod@highsecure.ru>
Copyright (c) 2018, Carsten Rosenberg <c.rosenberg@heinlein-support.de>

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 oletools
-- This module contains oletools access functions.
-- Olefy is needed: https://github.com/HeinleinSupport/olefy
--]]

local lua_util = require "lua_util"
local tcp = require "rspamd_tcp"
local upstream_list = require "rspamd_upstream_list"
local rspamd_logger = require "rspamd_logger"
local ucl = require "ucl"
local common = require "lua_scanners/common"

local N = 'oletools'

local function oletools_check(task, content, digest, rule)
local function oletools_check_uncached ()
local upstream = rule.upstreams:get_upstream_round_robin()
local addr = upstream:get_addr()
local retransmits = rule.retransmits
local protocol = 'OLEFY/1.0\nMethod: oletools\nRspamd-ID: ' .. task:get_uid() .. '\n\n'

local function oletools_callback(err, data)

local function oletools_requery(error)
-- set current upstream to fail because an error occurred
upstream:fail()

-- retry with another upstream until retransmits exceeds
if retransmits > 0 then

retransmits = retransmits - 1

lua_util.debugm(rule.N, task, '%s: Request Error: %s - retries left: %s',
rule.log_prefix, error, retransmits)

-- Select a different upstream!
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(rule.N, task, '%s: retry IP: %s:%s',
rule.log_prefix, addr, addr:get_port())

tcp.request({
task = task,
host = addr:to_string(),
port = addr:get_port(),
timeout = rule.timeout,
shutdown = true,
data = { protocol, content },
callback = oletools_callback,
})
else
rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits '..
'exceed - err: %s', rule.log_prefix, error)
task:insert_result(rule.symbol_fail, 0.0, 'failed - err: ' .. error)
end
end

if err then

oletools_requery(err)

else
-- Parse the response
if upstream then upstream:ok() end

data = tostring(data)

local ucl_parser = ucl.parser()
local ok, ucl_err = ucl_parser:parse_string(tostring(data))
if not ok then
rspamd_logger.errx(task, "%s: error parsing json response: %s",
rule.log_prefix, ucl_err)
return
end

local result = ucl_parser:get_object()

local oletools_rc = {
[0] = 'RETURN_OK',
[1] = 'RETURN_WARNINGS',
[2] = 'RETURN_WRONG_ARGS',
[3] = 'RETURN_FILE_NOT_FOUND',
[4] = 'RETURN_XGLOB_ERR',
[5] = 'RETURN_OPEN_ERROR',
[6] = 'RETURN_PARSE_ERROR',
[7] = 'RETURN_SEVERAL_ERRS',
[8] = 'RETURN_UNEXPECTED',
[9] = 'RETURN_ENCRYPTED',
}

if result[1].error ~= nil then
rspamd_logger.errx(task, '%s: ERROR found: %s', rule.log_prefix,
result[1].error)
if result[1].error == 'File too small' then
common.save_av_cache(task, digest, rule, 'OK')
common.log_clean(task, rule, 'File too small to be scanned for macros')
else
oletools_requery(result[1].error)
end
elseif result[3]['return_code'] == 9 then
rspamd_logger.warnx(task, '%s: File is encrypted.', rule.log_prefix)
elseif result[3]['return_code'] > 6 then
rspamd_logger.errx(task, '%s: Error Returned: %s',
rule.log_prefix, oletools_rc[result[3]['return_code']])
rspamd_logger.errx(task, '%s: Error message: %s',
rule.log_prefix, result[2]['message'])
task:insert_result(rule.symbol_fail, 0.0, 'failed - err: ' .. oletools_rc[result[3]['return_code']])
elseif result[3]['return_code'] > 1 then
rspamd_logger.errx(task, '%s: Error message: %s',
rule.log_prefix, result[2]['message'])
oletools_requery(oletools_rc[result[3]['return_code']])
elseif #result[2]['analysis'] == 0 and #result[2]['macros'] == 0 then
rspamd_logger.warnx(task, '%s: maybe unhandled python or oletools error', rule.log_prefix)
task:insert_result(rule.symbol_fail, 0.0, 'oletools unhandled error')
elseif result[2]['analysis'] == 'null' and #result[2]['macros'] == 0 then
common.save_av_cache(task, digest, rule, 'OK')
common.log_clean(task, rule, 'No macro found')
elseif #result[2]['macros'] > 0 then
-- M=Macros, A=Auto-executable, S=Suspicious keywords, I=IOCs,
-- H=Hex strings, B=Base64 strings, D=Dridex strings, V=VBA strings
local m_exist = 'M'
local m_autoexec = '-'
local m_suspicious = '-'
local m_iocs = '-'
local m_hex = '-'
local m_base64 = '-'
local m_dridex = '-'
local m_vba = '-'

lua_util.debugm(rule.N, task, '%s: filename: %s', rule.log_prefix, result[2]['file'])
lua_util.debugm(rule.N, task, '%s: type: %s', rule.log_prefix, result[2]['type'])

for _,m in ipairs(result[2]['macros']) do
lua_util.debugm(rule.N, task, '%s: macros found - code: %s, ole_stream: %s, '..
'vba_filename: %s', rule.log_prefix, m.code, m.ole_stream, m.vba_filename)
end

local analysis_keyword_table = {}

for _,a in ipairs(result[2]['analysis']) do
lua_util.debugm(rule.N, task, '%s: threat found - type: %s, keyword: %s, '..
'description: %s', rule.log_prefix, a.type, a.keyword, a.description)
if a.type == 'AutoExec' then
m_autoexec = 'A'
table.insert(analysis_keyword_table, a.keyword)
elseif a.type == 'Suspicious' then
m_suspicious = 'S'
if a.keyword ~= 'Base64 Strings' and a.keyword ~= 'Hex Strings'
then
table.insert(analysis_keyword_table, a.keyword)
end
elseif a.type == 'IOCs' then
m_iocs = 'I'
elseif a.type == 'Hex strings' then
m_hex = 'H'
elseif a.type == 'Base64 strings' then
m_base64 = 'B'
elseif a.type == 'Dridex strings' then
m_dridex = 'D'
elseif a.type == 'VBA strings' then
m_vba = 'V'
end
end

--lua_util.debugm(N, task, '%s: analysis_keyword_table: %s', rule.log_prefix, analysis_keyword_table)

if rule.extended == false and m_autoexec == 'A' and m_suspicious == 'S' then
-- use single string as virus name
local threat = 'AutoExec + Suspicious (' .. table.concat(analysis_keyword_table, ',') .. ')'
lua_util.debugm(rule.N, task, '%s: threat result: %s', rule.log_prefix, threat)
common.yield_result(task, rule, threat, rule.default_score)
common.save_av_cache(task, digest, rule, threat, rule.default_score)

elseif rule.extended == true and #analysis_keyword_table > 0 then
-- report any flags (types) and any most keywords as individual virus name

local flags = m_exist ..
m_autoexec ..
m_suspicious ..
m_iocs ..
m_hex ..
m_base64 ..
m_dridex ..
m_vba
table.insert(analysis_keyword_table, 1, flags)

lua_util.debugm(rule.N, task, '%s: extended threat result: %s',
rule.log_prefix, table.concat(analysis_keyword_table, ','))

common.yield_result(task, rule, analysis_keyword_table, rule.default_score)
common.save_av_cache(task, digest, rule, analysis_keyword_table, rule.default_score)
else
common.save_av_cache(task, digest, rule, 'OK')
common.log_clean(task, rule, 'Scanned Macro is OK')
end

else
rspamd_logger.warnx(task, '%s: unhandled response', rule.log_prefix)
task:insert_result(rule.symbol_fail, 0.0, 'unhandled response')
end
end
end

tcp.request({
task = task,
host = addr:to_string(),
port = addr:get_port(),
timeout = rule.timeout,
shutdown = true,
data = { protocol, content },
callback = oletools_callback,
})

end
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, oletools_check_uncached) then
return
else
oletools_check_uncached()
end
end
end

local function oletools_config(opts)

local oletools_conf = {
N = N,
scan_mime_parts = false,
scan_text_mime = false,
scan_image_mime = false,
default_port = 5954,
timeout = 15.0,
log_clean = false,
retransmits = 2,
cache_expire = 86400, -- expire redis in 1d
message = '${SCANNER}: Oletools threat message found: "${VIRUS}"',
detection_category = "office macro",
default_score = 1,
action = false,
extended = false,
}

oletools_conf = lua_util.override_defaults(oletools_conf, opts)

if not oletools_conf.prefix then
oletools_conf.prefix = 'rs_' .. oletools_conf.name .. '_'
end

if not oletools_conf.log_prefix then
if oletools_conf.name:lower() == oletools_conf.type:lower() then
oletools_conf.log_prefix = oletools_conf.name
else
oletools_conf.log_prefix = oletools_conf.name .. ' (' .. oletools_conf.type .. ')'
end
end

if not oletools_conf.servers then
rspamd_logger.errx(rspamd_config, 'no servers defined')

return nil
end

oletools_conf.upstreams = upstream_list.create(rspamd_config,
oletools_conf.servers,
oletools_conf.default_port)

if oletools_conf.upstreams then
lua_util.add_debug_alias('external_services', oletools_conf.N)
return oletools_conf
end

rspamd_logger.errx(rspamd_config, 'cannot parse servers %s',
oletools_conf.servers)
return nil
end

return {
type = {N,'attachment scanner', 'hash', 'scanner'},
description = 'oletools office macro scanner',
configure = oletools_config,
check = oletools_check,
name = N
}

+ 26
- 19
lualib/lua_scanners/savapi.lua View File

@@ -32,9 +32,10 @@ local default_message = '${SCANNER}: virus found: "${VIRUS}"'

local function savapi_config(opts)
local savapi_conf = {
scan_mime_parts = true;
scan_text_mime = false;
scan_image_mime = false;
N = N,
scan_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
default_port = 4444, -- note: You must set ListenAddress in savapi.conf
product_id = 0,
log_clean = false,
@@ -46,12 +47,18 @@ local function savapi_config(opts)
tmpdir = '/tmp',
}

for k,v in pairs(opts) do
savapi_conf[k] = v
end
savapi_conf = lua_util.override_defaults(savapi_conf, opts)

if not savapi_conf.prefix then
savapi_conf.prefix = 'rs_ap'
savapi_conf.prefix = 'rs_' .. savapi_conf.name .. '_'
end

if not savapi_conf.log_prefix then
if savapi_conf.name:lower() == savapi_conf.type:lower() then
savapi_conf.log_prefix = savapi_conf.name
else
savapi_conf.log_prefix = savapi_conf.name .. ' (' .. savapi_conf.type .. ')'
end
end

if not savapi_conf['servers'] then
@@ -65,7 +72,7 @@ local function savapi_config(opts)
savapi_conf.default_port)

if savapi_conf['upstreams'] then
lua_util.add_debug_alias('antivirus', N)
lua_util.add_debug_alias('antivirus', savapi_conf.N)
return savapi_conf
end

@@ -112,15 +119,15 @@ local function savapi_check(task, content, digest, rule)
for virus,_ in pairs(vnames) do
table.insert(vnames_reordered, virus)
end
lua_util.debugm(N, task, "%s: number of virus names found %s", rule['type'], #vnames_reordered)
lua_util.debugm(rule.N, task, "%s: number of virus names found %s", rule['type'], #vnames_reordered)
if #vnames_reordered > 0 then
local vname = {}
for _,virus in ipairs(vnames_reordered) do
table.insert(vname, virus)
end

common.yield_result(task, rule, vname, N)
common.save_av_cache(task, digest, rule, vname, N)
common.yield_result(task, rule, vname)
common.save_av_cache(task, digest, rule, vname)
end
if conn then
conn:close()
@@ -129,7 +136,7 @@ local function savapi_check(task, content, digest, rule)

local function savapi_scan2_cb(err, data, conn)
local result = tostring(data)
lua_util.debugm(N, task, "%s: got reply: %s",
lua_util.debugm(rule.N, task, "%s: got reply: %s",
rule['type'], result)

-- Terminal response - clean
@@ -137,7 +144,7 @@ local function savapi_check(task, content, digest, rule)
if rule['log_clean'] then
rspamd_logger.infox(task, '%s: message or mime_part is clean', rule['type'])
end
common.save_av_cache(task, digest, rule, 'OK', N)
common.save_av_cache(task, digest, rule, 'OK')
conn:add_write(savapi_fin_cb, 'QUIT\n')

-- Terminal response - infected
@@ -171,7 +178,7 @@ local function savapi_check(task, content, digest, rule)
local function savapi_greet2_cb(err, data, conn)
local result = tostring(data)
if string.find(result, '100 PRODUCT') then
lua_util.debugm(N, task, "%s: scanning file: %s",
lua_util.debugm(rule.N, task, "%s: scanning file: %s",
rule['type'], fname)
conn:add_write(savapi_scan1_cb, {string.format('SCAN %s\n',
fname)})
@@ -201,7 +208,7 @@ local function savapi_check(task, content, digest, rule)
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)
lua_util.debugm(rule.N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)

tcp.request({
task = task,
@@ -236,8 +243,8 @@ local function savapi_check(task, content, digest, rule)
})
end

if common.need_av_check(task, content, rule, N) then
if common.check_av_cache(task, digest, rule, savapi_check_uncached, N) then
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, savapi_check_uncached) then
return
else
savapi_check_uncached()
@@ -250,5 +257,5 @@ return {
description = 'savapi avira antivirus',
configure = savapi_config,
check = savapi_check,
name = 'savapi'
}
name = N
}

+ 30
- 23
lualib/lua_scanners/sophos.lua View File

@@ -31,9 +31,10 @@ local default_message = '${SCANNER}: virus found: "${VIRUS}"'

local function sophos_config(opts)
local sophos_conf = {
scan_mime_parts = true;
scan_text_mime = false;
scan_image_mime = false;
N = N,
scan_mime_parts = true,
scan_text_mime = false,
scan_image_mime = false,
default_port = 4010,
timeout = 15.0,
log_clean = false,
@@ -45,12 +46,18 @@ local function sophos_config(opts)
savdi_report_oversize = false,
}

for k,v in pairs(opts) do
sophos_conf[k] = v
end
sophos_conf = lua_util.override_defaults(sophos_conf, opts)

if not sophos_conf.prefix then
sophos_conf.prefix = 'rs_sp'
sophos_conf.prefix = 'rs_' .. sophos_conf.name .. '_'
end

if not sophos_conf.log_prefix then
if sophos_conf.name:lower() == sophos_conf.type:lower() then
sophos_conf.log_prefix = sophos_conf.name
else
sophos_conf.log_prefix = sophos_conf.name .. ' (' .. sophos_conf.type .. ')'
end
end

if not sophos_conf['servers'] then
@@ -64,7 +71,7 @@ local function sophos_config(opts)
sophos_conf.default_port)

if sophos_conf['upstreams'] then
lua_util.add_debug_alias('antivirus', N)
lua_util.add_debug_alias('antivirus', sophos_conf.N)
return sophos_conf
end

@@ -97,7 +104,7 @@ local function sophos_check(task, content, digest, rule)
upstream = rule.upstreams:get_upstream_round_robin()
addr = upstream:get_addr()

lua_util.debugm(N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)
lua_util.debugm(rule.N, task, '%s [%s]: retry IP: %s', rule['symbol'], rule['type'], addr)

tcp.request({
task = task,
@@ -114,19 +121,19 @@ local function sophos_check(task, content, digest, rule)
else
upstream:ok()
data = tostring(data)
lua_util.debugm(N, task, '%s [%s]: got reply: %s', rule['symbol'], rule['type'], data)
lua_util.debugm(rule.N, task, '%s [%s]: got reply: %s', rule['symbol'], rule['type'], data)
local vname = string.match(data, 'VIRUS (%S+) ')
if vname then
common.yield_result(task, rule, vname, N)
common.save_av_cache(task, digest, rule, vname, N)
common.yield_result(task, rule, vname)
common.save_av_cache(task, digest, rule, vname)
else
if string.find(data, 'DONE OK') then
if rule['log_clean'] then
rspamd_logger.infox(task, '%s [%s]: message or mime_part is clean', rule['symbol'], rule['type'])
rspamd_logger.infox(task, '%s: message or mime_part is clean', rule.log_prefix)
else
lua_util.debugm(N, task, '%s [%s]: message or mime_part is clean', rule['symbol'], rule['type'])
lua_util.debugm(rule.N, task, '%s: message or mime_part is clean', rule.log_prefix)
end
common.save_av_cache(task, digest, rule, 'OK', N)
common.save_av_cache(task, digest, rule, 'OK')
-- not finished - continue
elseif string.find(data, 'ACC') or string.find(data, 'OK SSSP') then
conn:add_read(sophos_callback)
@@ -134,15 +141,15 @@ local function sophos_check(task, content, digest, rule)
elseif string.find(data, 'FAIL 0212') then
rspamd_logger.infox(task, 'Message is ENCRYPTED (0212 SOPHOS_SAVI_ERROR_FILE_ENCRYPTED): %s', data)
if rule['savdi_report_encrypted'] then
common.yield_result(task, rule, "SAVDI_FILE_ENCRYPTED", N)
common.save_av_cache(task, digest, rule, "SAVDI_FILE_ENCRYPTED", N)
common.yield_result(task, rule, "SAVDI_FILE_ENCRYPTED")
common.save_av_cache(task, digest, rule, "SAVDI_FILE_ENCRYPTED")
end
-- set pseudo virus if configured, else set fail since part was not scanned
elseif string.find(data, 'REJ 4') then
if rule['savdi_report_oversize'] then
rspamd_logger.infox(task, 'SAVDI: Message is OVERSIZED (SSSP reject code 4): %s', data)
common.yield_result(task, rule, "SAVDI_FILE_OVERSIZED", N)
common.save_av_cache(task, digest, rule, "SAVDI_FILE_OVERSIZED", N)
common.yield_result(task, rule, "SAVDI_FILE_OVERSIZED")
common.save_av_cache(task, digest, rule, "SAVDI_FILE_OVERSIZED")
else
rspamd_logger.errx(task, 'SAVDI: Message is OVERSIZED (SSSP reject code 4): %s', data)
task:insert_result(rule['symbol_fail'], 0.0, 'Message is OVERSIZED (SSSP reject code 4):' .. data)
@@ -170,8 +177,8 @@ local function sophos_check(task, content, digest, rule)
})
end

if common.need_av_check(task, content, rule, N) then
if common.check_av_cache(task, digest, rule, sophos_check_uncached, N) then
if common.need_av_check(task, content, rule) then
if common.check_av_cache(task, digest, rule, sophos_check_uncached) then
return
else
sophos_check_uncached()
@@ -184,5 +191,5 @@ return {
description = 'sophos antivirus',
configure = sophos_config,
check = sophos_check,
name = 'sophos'
}
name = N
}

+ 16
- 42
src/plugins/lua/antivirus.lua View File

@@ -15,10 +15,10 @@ limitations under the License.
]] --

local rspamd_logger = require "rspamd_logger"
local rspamd_regexp = require "rspamd_regexp"
local lua_util = require "lua_util"
local fun = require "fun"
local lua_antivirus = require("lua_scanners").filter('antivirus')
local common = require "lua_scanners/common"
local redis_params

local N = "antivirus"
@@ -70,29 +70,29 @@ end


local function add_antivirus_rule(sym, opts)
if not opts['type'] then
if not opts.type then
rspamd_logger.errx(rspamd_config, 'unknown type for AV rule %s', sym)
return nil
end

if not opts['symbol'] then opts['symbol'] = sym:upper() end
local cfg = lua_antivirus[opts['type']]
if not opts.symbol then opts.symbol = sym:upper() end
local cfg = lua_antivirus[opts.type]

if not cfg then
rspamd_logger.errx(rspamd_config, 'unknown antivirus type: %s',
opts['type'])
opts.type)
return nil
end

if not opts['symbol_fail'] then
opts['symbol_fail'] = string.upper(opts['type']) .. '_FAIL'
if not opts.symbol_fail then
opts.symbol_fail = opts.symbol .. '_FAIL'
end

-- WORKAROUND for deprecated attachments_only
if opts['attachments_only'] ~= nil then
opts['scan_mime_parts'] = opts['attachments_only']
if opts.attachments_only ~= nil then
opts.scan_mime_parts = opts.attachments_only
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
-- WORKAROUND for deprecated attachments_only

@@ -103,52 +103,25 @@ local function add_antivirus_rule(sym, opts)

if not rule then
rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s',
opts['type'], opts['symbol'])
opts.type, opts.symbol)
return nil
end

if type(opts['patterns']) == 'table' then
rule['patterns'] = {}
if opts['patterns'][1] then
for i, p in ipairs(opts['patterns']) do
if type(p) == 'table' then
local new_set = {}
for k, v in pairs(p) do
new_set[k] = rspamd_regexp.create_cached(v)
end
rule['patterns'][i] = new_set
else
rule['patterns'][i] = {}
end
end
else
for k, v in pairs(opts['patterns']) do
rule['patterns'][k] = rspamd_regexp.create_cached(v)
end
end
end
rule.patterns = common.create_regex_table(opts.patterns or {})

if opts['whitelist'] then
rule['whitelist'] = rspamd_config:add_hash_map(opts['whitelist'])
if opts.whitelist then
rule.whitelist = rspamd_config:add_hash_map(opts.whitelist)
end

return function(task)
if rule.scan_mime_parts then
local parts = task:get_parts() or {}

local filter_func = function(p)
return (rule.scan_image_mime and p:is_image())
or (rule.scan_text_mime and p:is_text())
or (p:is_attachment())
end

fun.each(function(p)
local content = p:get_content()

if content and #content > 0 then
cfg.check(task, content, p:get_digest(), rule)
end
end, fun.filter(filter_func, parts))
end, common.check_parts_match(task, rule))

else
cfg.check(task, task:get_content(), task:get_digest(), rule)
@@ -164,6 +137,7 @@ if opts and type(opts) == 'table' then
for k, m in pairs(opts) do
if type(m) == 'table' and m.servers then
if not m.type then m.type = k end
if not m.name then m.name = k end
local cb = add_antivirus_rule(k, m)

if not cb then

+ 96
- 32
src/plugins/lua/external_services.lua View File

@@ -1,5 +1,6 @@
--[[
Copyright (c) 2019, Vsevolod Stakhov <vsevolod@highsecure.ru>
Copyright (c) 2019, Carsten Rosenberg <c.rosenberg@heinlein-support.de>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,9 +16,10 @@ limitations under the License.
]] --

local rspamd_logger = require "rspamd_logger"
local rspamd_regexp = require "rspamd_regexp"
local lua_util = require "lua_util"
local fun = require "fun"
local lua_scanners = require("lua_scanners").filter('scanner')
local common = require "lua_scanners/common"
local redis_params

local N = "external_services"
@@ -28,13 +30,72 @@ if confighelp then
[[
external_services {
# multiple scanners could be checked, for each we create a configuration block with an arbitrary name

oletools {
# If set force this action if any virus is found (default unset: no action is forced)
# action = "reject";
# If set, then rejection message is set to this value (mention single quotes)
# If `max_size` is set, messages > n bytes in size are not scanned
# max_size = 20000000;
# log_clean = true;
# servers = "127.0.0.1:10050";
# cache_expire = 86400;
# scan_mime_parts = true;
# extended = false;
# if `patterns` is specified virus name will be matched against provided regexes and the related
# symbol will be yielded if a match is found. If no match is found, default symbol is yielded.
patterns {
# symbol_name = "pattern";
JUST_EICAR = "^Eicar-Test-Signature$";
}
# mime-part regex matching in content-type or filename
mime_parts_filter_regex {
#GEN1 = "application\/octet-stream";
DOC2 = "application\/msword";
DOC3 = "application\/vnd\.ms-word.*";
XLS = "application\/vnd\.ms-excel.*";
PPT = "application\/vnd\.ms-powerpoint.*";
GEN2 = "application\/vnd\.openxmlformats-officedocument.*";
}
# Mime-Part filename extension matching (no regex)
mime_parts_filter_ext {
doc = "doc";
dot = "dot";
docx = "docx";
dotx = "dotx";
docm = "docm";
dotm = "dotm";
xls = "xls";
xlt = "xlt";
xla = "xla";
xlsx = "xlsx";
xltx = "xltx";
xlsm = "xlsm";
xltm = "xltm";
xlam = "xlam";
xlsb = "xlsb";
ppt = "ppt";
pot = "pot";
pps = "pps";
ppa = "ppa";
pptx = "pptx";
potx = "potx";
ppsx = "ppsx";
ppam = "ppam";
pptm = "pptm";
potm = "potm";
ppsm = "ppsm";
}
# `whitelist` points to a map of IP addresses. Mail from these addresses is not scanned.
whitelist = "/etc/rspamd/antivirus.wl";
}
dcc {
# If set force this action if any virus is found (default unset: no action is forced)
# action = "reject";
# If set, then rejection message is set to this value (mention single quotes)
# If `max_size` is set, messages > n bytes in size are not scanned
max_size = 20000000;
servers = "127.0.0.1:3310";
#servers = "127.0.0.1:10045;
# if `patterns` is specified virus name will be matched against provided regexes and the related
# symbol will be yielded if a match is found. If no match is found, default symbol is yielded.
patterns {
@@ -51,22 +112,22 @@ end


local function add_scanner_rule(sym, opts)
if not opts['type'] then
if not opts.type then
rspamd_logger.errx(rspamd_config, 'unknown type for external scanner rule %s', sym)
return nil
end

if not opts['symbol'] then opts['symbol'] = sym:upper() end
local cfg = lua_scanners[opts['type']]
if not opts.symbol then opts.symbol = sym:upper() end
local cfg = lua_scanners[opts.type]

if not cfg then
rspamd_logger.errx(rspamd_config, 'unknown antivirus type: %s',
opts['type'])
rspamd_logger.errx(rspamd_config, 'unknown external scanner type: %s',
opts.type)
return nil
end

if not opts['symbol_fail'] then
opts['symbol_fail'] = string.upper(opts['type']) .. '_FAIL'
if not opts.symbol_fail then
opts.symbol_fail = opts.symbol .. '_FAIL'
end

local rule = cfg.configure(opts)
@@ -76,37 +137,39 @@ local function add_scanner_rule(sym, opts)

if not rule then
rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s',
opts['type'], opts['symbol'])
opts.type, opts.symbol)
return nil
end

if type(opts['patterns']) == 'table' then
rule['patterns'] = {}
if opts['patterns'][1] then
for i, p in ipairs(opts['patterns']) do
if type(p) == 'table' then
local new_set = {}
for k, v in pairs(p) do
new_set[k] = rspamd_regexp.create_cached(v)
end
rule['patterns'][i] = new_set
else
rule['patterns'][i] = {}
end
end
else
for k, v in pairs(opts['patterns']) do
rule['patterns'][k] = rspamd_regexp.create_cached(v)
end
end
-- if any mime_part filter defined, do not scan all attachments
if opts.mime_parts_filter_regex ~= nil
or opts.mime_parts_filter_ext ~= nil then
rule.scan_all_mime_parts = false
end

if opts['whitelist'] then
rule['whitelist'] = rspamd_config:add_hash_map(opts['whitelist'])
rule.patterns = common.create_regex_table(opts.patterns or {})

rule.mime_parts_filter_regex = common.create_regex_table(opts.mime_parts_filter_regex or {})

rule.mime_parts_filter_ext = common.create_regex_table(opts.mime_parts_filter_ext or {})

if opts.whitelist then
rule.whitelist = rspamd_config:add_hash_map(opts.whitelist)
end

return function(task)
cfg.check(task, task:get_content(), task:get_digest(), rule)
if rule.scan_mime_parts then

fun.each(function(p)
local content = p:get_content()
if content and #content > 0 then
cfg.check(task, content, p:get_digest(), rule)
end
end, common.check_parts_match(task, rule))

else
cfg.check(task, task:get_content(), task:get_digest(), rule)
end
end
end

@@ -118,6 +181,7 @@ if opts and type(opts) == 'table' then
for k, m in pairs(opts) do
if type(m) == 'table' and m.servers then
if not m.type then m.type = k end
if not m.name then m.name = k end
local cb = add_scanner_rule(k, m)

if not cb then

Loading…
Cancel
Save