summaryrefslogtreecommitdiffstats
path: root/src/plugins/lua/antivirus.lua
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins/lua/antivirus.lua')
-rw-r--r--src/plugins/lua/antivirus.lua348
1 files changed, 348 insertions, 0 deletions
diff --git a/src/plugins/lua/antivirus.lua b/src/plugins/lua/antivirus.lua
new file mode 100644
index 0000000..e39ddc5
--- /dev/null
+++ b/src/plugins/lua/antivirus.lua
@@ -0,0 +1,348 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+]] --
+
+local rspamd_logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local rspamd_util = require "rspamd_util"
+local lua_redis = require "lua_redis"
+local fun = require "fun"
+local lua_antivirus = require("lua_scanners").filter('antivirus')
+local common = require "lua_scanners/common"
+local redis_params
+
+local N = "antivirus"
+
+if confighelp then
+ rspamd_config:add_example(nil, 'antivirus',
+ "Check messages for viruses",
+ [[
+ antivirus {
+ # multiple scanners could be checked, for each we create a configuration block with an arbitrary name
+ clamav {
+ # 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)
+ # message = '${SCANNER}: virus found: "${VIRUS}"';
+ # Scan mime_parts separately - otherwise the complete mail will be transferred to AV Scanner
+ #scan_mime_parts = true;
+ # Scanning Text is suitable for some av scanner databases (e.g. Sanesecurity)
+ #scan_text_mime = false;
+ #scan_image_mime = false;
+ # If `max_size` is set, messages > n bytes in size are not scanned
+ max_size = 20000000;
+ # symbol to add (add it to metric if you want non-zero weight)
+ symbol = "CLAM_VIRUS";
+ # type of scanner: "clamav", "fprot", "sophos" or "savapi"
+ type = "clamav";
+ # For "savapi" you must also specify the following variable
+ product_id = 12345;
+ # You can enable logging for clean messages
+ log_clean = true;
+ # servers to query (if port is unspecified, scanner-specific default is used)
+ # can be specified multiple times to pool servers
+ # can be set to a path to a unix socket
+ # Enable this in local.d/antivirus.conf
+ servers = "127.0.0.1:3310";
+ # 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";
+ # Replace content that exactly matches the following string to the EICAR pattern
+ # Useful for E2E testing when another party removes/blocks EICAR attachments
+ #eicar_fake_pattern = 'testpatterneicar';
+ }
+ }
+ ]])
+ return
+end
+
+-- Encode as base32 in the source to avoid crappy stuff
+local eicar_pattern = rspamd_util.decode_base32(
+ [[akp6woykfbonrepmwbzyfpbmibpone3mj3pgwbffzj9e1nfjdkorisckwkohrnfe1nt41y3jwk1cirjki4w4nkieuni4ndfjcktnn1yjmb1wn]]
+)
+
+local function add_antivirus_rule(sym, opts)
+ 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 cfg then
+ rspamd_logger.errx(rspamd_config, 'unknown antivirus type: %s',
+ opts.type)
+ return nil
+ end
+
+ if not opts.symbol_fail then
+ opts.symbol_fail = opts.symbol .. '_FAIL'
+ end
+ if not opts.symbol_encrypted then
+ opts.symbol_encrypted = opts.symbol .. '_ENCRYPTED'
+ end
+ if not opts.symbol_macro then
+ opts.symbol_macro = opts.symbol .. '_MACRO'
+ end
+
+ -- WORKAROUND for deprecated 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)
+ end
+ -- WORKAROUND for deprecated attachments_only
+
+ local rule = cfg.configure(opts)
+ if not rule then
+ return nil
+ end
+
+ rule.type = opts.type
+ rule.symbol_fail = opts.symbol_fail
+ rule.symbol_encrypted = opts.symbol_encrypted
+ rule.redis_params = redis_params
+
+ if not rule then
+ rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s',
+ opts.type, opts.symbol)
+ return nil
+ end
+
+ rule.patterns = common.create_regex_table(opts.patterns or {})
+ rule.patterns_fail = common.create_regex_table(opts.patterns_fail or {})
+
+ lua_redis.register_prefix(rule.prefix .. '_*', N,
+ string.format('Antivirus cache for rule "%s"',
+ rule.type), {
+ type = 'string',
+ })
+
+ -- 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
+ else
+ rule.scan_all_mime_parts = true
+ end
+
+ rule.patterns = common.create_regex_table(opts.patterns or {})
+ rule.patterns_fail = common.create_regex_table(opts.patterns_fail 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)
+ if rule.scan_mime_parts then
+
+ fun.each(function(p)
+ local content = p:get_content()
+ local clen = #content
+ if content and clen > 0 then
+ if opts.eicar_fake_pattern then
+ if type(opts.eicar_fake_pattern) == 'string' then
+ -- Convert it to Rspamd text
+ local rspamd_text = require "rspamd_text"
+ opts.eicar_fake_pattern = rspamd_text.fromstring(opts.eicar_fake_pattern)
+ end
+
+ 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")',
+ p:get_filename())
+ content = eicar_pattern
+ end
+ end
+ cfg.check(task, content, p:get_digest(), rule, p)
+ end
+ end, common.check_parts_match(task, rule))
+
+ else
+ cfg.check(task, task:get_content(), task:get_digest(), rule)
+ end
+ end
+end
+
+-- Registration
+local opts = rspamd_config:get_all_opt(N)
+if opts and type(opts) == 'table' then
+ redis_params = lua_redis.parse_redis_server(N)
+ local has_valid = false
+ for k, m in pairs(opts) do
+ if type(m) == 'table' 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
+ rspamd_logger.errx(rspamd_config, 'cannot add rule: "' .. k .. '"')
+ lua_util.config_utils.push_config_error(N, 'cannot add AV rule: "' .. k .. '"')
+ else
+ rspamd_logger.infox(rspamd_config, 'added antivirus engine %s -> %s', k, m.symbol)
+ local t = {
+ name = m.symbol,
+ callback = cb,
+ score = 0.0,
+ group = N
+ }
+
+ if m.symbol_type == 'postfilter' then
+ t.type = 'postfilter'
+ t.priority = lua_util.symbols_priorities.medium
+ else
+ t.type = 'normal'
+ end
+
+ t.augmentations = {}
+
+ if type(m.timeout) == 'number' then
+ -- Here, we ignore possible DNS timeout and timeout from multiple retries
+ -- as these situations are not usual nor likely for the antivirus module
+ table.insert(t.augmentations, string.format("timeout=%f", m.timeout))
+ end
+
+ local id = rspamd_config:register_symbol(t)
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_fail'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_encrypted'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_macro'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ has_valid = true
+ if type(m['patterns']) == 'table' then
+ if m['patterns'][1] then
+ for _, p in ipairs(m['patterns']) do
+ if type(p) == 'table' then
+ for sym in pairs(p) do
+ rspamd_logger.debugm(N, rspamd_config, 'registering: %1', {
+ type = 'virtual',
+ name = sym,
+ parent = m['symbol'],
+ parent_id = id,
+ group = N
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ end
+ end
+ else
+ for sym in pairs(m['patterns']) do
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ end
+ end
+ if type(m['patterns_fail']) == 'table' then
+ if m['patterns_fail'][1] then
+ for _, p in ipairs(m['patterns_fail']) do
+ if type(p) == 'table' then
+ for sym in pairs(p) do
+ rspamd_logger.debugm(N, rspamd_config, 'registering: %1', {
+ type = 'virtual',
+ name = sym,
+ parent = m['symbol'],
+ parent_id = id,
+ group = N
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ end
+ end
+ else
+ for sym in pairs(m['patterns_fail']) do
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ end
+ end
+ if m['score'] then
+ -- Register metric symbol
+ local description = 'antivirus symbol'
+ local group = N
+ if m['description'] then
+ description = m['description']
+ end
+ if m['group'] then
+ group = m['group']
+ end
+ rspamd_config:set_metric_symbol({
+ name = m['symbol'],
+ score = m['score'],
+ description = description,
+ group = group or 'antivirus'
+ })
+ end
+ end
+ end
+ end
+
+ if not has_valid then
+ lua_util.disable_module(N, 'config')
+ end
+end