summaryrefslogtreecommitdiffstats
path: root/src/plugins/lua
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins/lua')
-rw-r--r--src/plugins/lua/antivirus.lua348
-rw-r--r--src/plugins/lua/arc.lua853
-rw-r--r--src/plugins/lua/asn.lua168
-rw-r--r--src/plugins/lua/aws_s3.lua269
-rw-r--r--src/plugins/lua/bayes_expiry.lua503
-rw-r--r--src/plugins/lua/bimi.lua391
-rw-r--r--src/plugins/lua/clickhouse.lua1556
-rw-r--r--src/plugins/lua/clustering.lua322
-rw-r--r--src/plugins/lua/dcc.lua119
-rw-r--r--src/plugins/lua/dkim_signing.lua186
-rw-r--r--src/plugins/lua/dmarc.lua685
-rw-r--r--src/plugins/lua/dynamic_conf.lua333
-rw-r--r--src/plugins/lua/elastic.lua544
-rw-r--r--src/plugins/lua/emails.lua4
-rw-r--r--src/plugins/lua/external_relay.lua285
-rw-r--r--src/plugins/lua/external_services.lua408
-rw-r--r--src/plugins/lua/force_actions.lua227
-rw-r--r--src/plugins/lua/forged_recipients.lua183
-rw-r--r--src/plugins/lua/fuzzy_collect.lua193
-rw-r--r--src/plugins/lua/greylist.lua542
-rw-r--r--src/plugins/lua/hfilter.lua622
-rw-r--r--src/plugins/lua/history_redis.lua314
-rw-r--r--src/plugins/lua/http_headers.lua198
-rw-r--r--src/plugins/lua/ip_score.lua4
-rw-r--r--src/plugins/lua/known_senders.lua245
-rw-r--r--src/plugins/lua/maillist.lua235
-rw-r--r--src/plugins/lua/maps_stats.lua133
-rw-r--r--src/plugins/lua/metadata_exporter.lua707
-rw-r--r--src/plugins/lua/metric_exporter.lua252
-rw-r--r--src/plugins/lua/mid.lua123
-rw-r--r--src/plugins/lua/milter_headers.lua762
-rw-r--r--src/plugins/lua/mime_types.lua737
-rw-r--r--src/plugins/lua/multimap.lua1403
-rw-r--r--src/plugins/lua/mx_check.lua392
-rw-r--r--src/plugins/lua/neural.lua1000
-rw-r--r--src/plugins/lua/once_received.lua230
-rw-r--r--src/plugins/lua/p0f.lua124
-rw-r--r--src/plugins/lua/phishing.lua667
-rw-r--r--src/plugins/lua/ratelimit.lua868
-rw-r--r--src/plugins/lua/rbl.lua1425
-rw-r--r--src/plugins/lua/replies.lua328
-rw-r--r--src/plugins/lua/reputation.lua1390
-rw-r--r--src/plugins/lua/rspamd_update.lua161
-rw-r--r--src/plugins/lua/settings.lua1437
-rw-r--r--src/plugins/lua/spamassassin.lua1774
-rw-r--r--src/plugins/lua/spamtrap.lua200
-rw-r--r--src/plugins/lua/spf.lua242
-rw-r--r--src/plugins/lua/trie.lua184
-rw-r--r--src/plugins/lua/url_redirector.lua422
-rw-r--r--src/plugins/lua/whitelist.lua443
50 files changed, 25141 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
diff --git a/src/plugins/lua/arc.lua b/src/plugins/lua/arc.lua
new file mode 100644
index 0000000..ff19aef
--- /dev/null
+++ b/src/plugins/lua/arc.lua
@@ -0,0 +1,853 @@
+--[[
+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 dkim_sign_tools = require "lua_dkim_tools"
+local rspamd_util = require "rspamd_util"
+local rspamd_rsa_privkey = require "rspamd_rsa_privkey"
+local rspamd_rsa = require "rspamd_rsa"
+local fun = require "fun"
+local lua_auth_results = require "lua_auth_results"
+local hash = require "rspamd_cryptobox_hash"
+local lua_mime = require "lua_mime"
+
+if confighelp then
+ return
+end
+
+local N = 'arc'
+local AR_TRUSTED_CACHE_KEY = 'arc_trusted_aar'
+
+if not rspamd_plugins.dkim then
+ rspamd_logger.errx(rspamd_config, "cannot enable arc plugin: dkim is disabled")
+ return
+end
+
+local dkim_verify = rspamd_plugins.dkim.verify
+local dkim_sign = rspamd_plugins.dkim.sign
+local dkim_canonicalize = rspamd_plugins.dkim.canon_header_relaxed
+local redis_params
+
+if not dkim_verify or not dkim_sign or not dkim_canonicalize then
+ rspamd_logger.errx(rspamd_config, "cannot enable arc plugin: dkim is disabled")
+ return
+end
+
+local arc_symbols = {
+ allow = 'ARC_ALLOW',
+ invalid = 'ARC_INVALID',
+ dnsfail = 'ARC_DNSFAIL',
+ na = 'ARC_NA',
+ reject = 'ARC_REJECT',
+}
+
+local settings = {
+ allow_envfrom_empty = true,
+ allow_hdrfrom_mismatch = false,
+ allow_hdrfrom_mismatch_local = false,
+ allow_hdrfrom_mismatch_sign_networks = false,
+ allow_hdrfrom_multiple = false,
+ allow_username_mismatch = false,
+ sign_authenticated = true,
+ domain = {},
+ path = string.format('%s/%s/%s', rspamd_paths['DBDIR'], 'arc', '$domain.$selector.key'),
+ sign_local = true,
+ selector = 'arc',
+ sign_symbol = 'ARC_SIGNED',
+ try_fallback = true,
+ use_domain = 'header',
+ use_esld = true,
+ use_redis = false,
+ key_prefix = 'arc_keys', -- default hash name
+ reuse_auth_results = false, -- Reuse the existing authentication results
+ whitelisted_signers_map = nil, -- Trusted signers domains
+ adjust_dmarc = true, -- Adjust DMARC rejected policy for trusted forwarders
+ allowed_ids = nil, -- Allowed settings id
+ forbidden_ids = nil, -- Banned settings id
+}
+
+-- To match normal AR
+local ar_settings = lua_auth_results.default_settings
+
+local function parse_arc_header(hdr, target, is_aar)
+ -- Split elements by ';' and trim spaces
+ local arr = fun.totable(fun.map(
+ function(val)
+ return fun.totable(fun.map(lua_util.rspamd_str_trim,
+ fun.filter(function(v)
+ return v and #v > 0
+ end,
+ lua_util.rspamd_str_split(val.decoded, ';')
+ )
+ ))
+ end, hdr
+ ))
+
+ -- v[1] is the key and v[2] is the value
+ local function fill_arc_header_table(v, t)
+ if v[1] and v[2] then
+ local key = lua_util.rspamd_str_trim(v[1])
+ local value = lua_util.rspamd_str_trim(v[2])
+ t[key] = value
+ end
+ end
+
+ -- Now we have two tables in format:
+ -- [arc_header] -> [{arc_header1_elts}, {arc_header2_elts}...]
+ for i, elts in ipairs(arr) do
+ if not target[i] then
+ target[i] = {}
+ end
+ if not is_aar then
+ -- For normal ARC headers we split by kv pair, like k=v
+ fun.each(function(v)
+ fill_arc_header_table(v, target[i])
+ end,
+ fun.map(function(elt)
+ return lua_util.rspamd_str_split(elt, '=')
+ end, elts)
+ )
+ else
+ -- For AAR we check special case of i=%d and pass everything else to
+ -- AAR specific parser
+ for _, elt in ipairs(elts) do
+ if string.match(elt, "%s*i%s*=%s*%d+%s*") then
+ local pair = lua_util.rspamd_str_split(elt, '=')
+ fill_arc_header_table(pair, target[i])
+ else
+ -- Normal element
+ local ar_elt = lua_auth_results.parse_ar_element(elt)
+
+ if ar_elt then
+ if not target[i].ar then
+ target[i].ar = {}
+ end
+ table.insert(target[i].ar, ar_elt)
+ end
+ end
+ end
+ end
+ target[i].header = hdr[i].decoded
+ target[i].raw_header = hdr[i].value
+ end
+
+ -- sort by i= attribute
+ table.sort(target, function(a, b)
+ return (a.i or 0) < (b.i or 0)
+ end)
+end
+
+local function arc_validate_seals(task, seals, sigs, seal_headers, sig_headers)
+ local fail_reason
+ for i = 1, #seals do
+ if (sigs[i].i or 0) ~= i then
+ fail_reason = string.format('bad i for signature: %d, expected %d; d=%s',
+ sigs[i].i, i, sigs[i].d)
+ rspamd_logger.infox(task, fail_reason)
+ task:insert_result(arc_symbols['invalid'], 1.0, fail_reason)
+ return false, fail_reason
+ end
+ if (seals[i].i or 0) ~= i then
+ fail_reason = string.format('bad i for seal: %d, expected %d; d=%s',
+ seals[i].i, i, seals[i].d)
+ rspamd_logger.infox(task, fail_reason)
+ task:insert_result(arc_symbols['invalid'], 1.0, fail_reason)
+ return false, fail_reason
+ end
+
+ if not seals[i].cv then
+ fail_reason = string.format('no cv on i=%d', i)
+ task:insert_result(arc_symbols['invalid'], 1.0, fail_reason)
+ return false, fail_reason
+ end
+
+ if i == 1 then
+ -- We need to ensure that cv of seal is equal to 'none'
+ if seals[i].cv ~= 'none' then
+ fail_reason = 'cv is not "none" for i=1'
+ task:insert_result(arc_symbols['invalid'], 1.0, fail_reason)
+ return false, fail_reason
+ end
+ else
+ if seals[i].cv ~= 'pass' then
+ fail_reason = string.format('cv is %s on i=%d', seals[i].cv, i)
+ task:insert_result(arc_symbols['reject'], 1.0, fail_reason)
+ return true, fail_reason
+ end
+ end
+ end
+
+ return true, nil
+end
+
+local function arc_callback(task)
+ local arc_sig_headers = task:get_header_full('ARC-Message-Signature')
+ local arc_seal_headers = task:get_header_full('ARC-Seal')
+ local arc_ar_headers = task:get_header_full('ARC-Authentication-Results')
+
+ if not arc_sig_headers or not arc_seal_headers then
+ task:insert_result(arc_symbols['na'], 1.0)
+ return
+ end
+
+ if #arc_sig_headers ~= #arc_seal_headers then
+ -- We mandate that count of seals is equal to count of signatures
+ rspamd_logger.infox(task, 'number of seals (%s) is not equal to number of signatures (%s)',
+ #arc_seal_headers, #arc_sig_headers)
+ task:insert_result(arc_symbols['invalid'], 1.0, 'invalid count of seals and signatures')
+ return
+ end
+
+ local cbdata = {
+ seals = {},
+ sigs = {},
+ ars = {},
+ res = 'success',
+ errors = {},
+ allowed_by_trusted = false
+ }
+
+ parse_arc_header(arc_seal_headers, cbdata.seals, false)
+ parse_arc_header(arc_sig_headers, cbdata.sigs, false)
+
+ if arc_ar_headers then
+ parse_arc_header(arc_ar_headers, cbdata.ars, true)
+ end
+
+ -- Fix i type
+ fun.each(function(hdr)
+ hdr.i = tonumber(hdr.i) or 0
+ end, cbdata.seals)
+
+ fun.each(function(hdr)
+ hdr.i = tonumber(hdr.i) or 0
+ end, cbdata.sigs)
+
+ -- Now we need to sort elements according to their [i] value
+ table.sort(cbdata.seals, function(e1, e2)
+ return (e1.i or 0) < (e2.i or 0)
+ end)
+ table.sort(cbdata.sigs, function(e1, e2)
+ return (e1.i or 0) < (e2.i or 0)
+ end)
+
+ lua_util.debugm(N, task, 'got %s arc sections', #cbdata.seals)
+
+ -- Now check sanity of what we have
+ local valid, validation_error = arc_validate_seals(task, cbdata.seals, cbdata.sigs,
+ arc_seal_headers, arc_sig_headers)
+ if not valid then
+ task:cache_set('arc-failure', validation_error)
+ return
+ end
+
+ task:cache_set('arc-sigs', cbdata.sigs)
+ task:cache_set('arc-seals', cbdata.seals)
+ task:cache_set('arc-authres', cbdata.ars)
+
+ if validation_error then
+ -- ARC rejection but no strong failure for signing
+ return
+ end
+
+ local function gen_arc_seal_cb(index, sig)
+ return function(_, res, err, domain)
+ lua_util.debugm(N, task, 'checked arc seal: %s(%s), %s processed',
+ res, err, index)
+
+ if not res then
+ cbdata.res = 'fail'
+ if err and domain then
+ table.insert(cbdata.errors, string.format('sig:%s:%s', domain, err))
+ end
+ end
+
+ if settings.whitelisted_signers_map and cbdata.res == 'success' then
+ if settings.whitelisted_signers_map:get_key(sig.d) then
+ -- Whitelisted signer has been found in a valid chain
+ local mult = 1.0
+ local cur_aar = cbdata.ars[index]
+ if not cur_aar then
+ rspamd_logger.warnx(task, "cannot find Arc-Authentication-Results for trusted " ..
+ "forwarder %s on i=%s", domain, cbdata.index)
+ else
+ task:cache_set(AR_TRUSTED_CACHE_KEY, cur_aar)
+ local seen_dmarc
+ for _, ar in ipairs(cur_aar.ar) do
+ if ar.dmarc then
+ local dmarc_fwd = ar.dmarc
+ seen_dmarc = true
+ if dmarc_fwd == 'reject' or dmarc_fwd == 'fail' or dmarc_fwd == 'quarantine' then
+ lua_util.debugm(N, "found rejected dmarc on forwarding")
+ mult = 0.0
+ elseif dmarc_fwd == 'pass' then
+ mult = 1.0
+ end
+ elseif ar.spf then
+ local spf_fwd = ar.spf
+ if spf_fwd == 'reject' or spf_fwd == 'fail' or spf_fwd == 'quarantine' then
+ lua_util.debugm(N, "found rejected spf on forwarding")
+ if not seen_dmarc then
+ mult = mult * 0.5
+ end
+ end
+ end
+ end
+ end
+ task:insert_result(arc_symbols.trusted_allow, mult,
+ string.format('%s:s=%s:i=%d', domain, sig.s, index))
+ end
+ end
+
+ if index == #arc_sig_headers then
+ if cbdata.res == 'success' then
+ local arc_allow_result = string.format('%s:s=%s:i=%d',
+ domain, sig.s, index)
+ task:insert_result(arc_symbols.allow, 1.0, arc_allow_result)
+ task:cache_set('arc-allow', arc_allow_result)
+ else
+ task:insert_result(arc_symbols.reject, 1.0,
+ rspamd_logger.slog('seal check failed: %s, %s', cbdata.res,
+ cbdata.errors))
+ end
+ end
+ end
+ end
+
+ local function arc_signature_cb(_, res, err, domain)
+ lua_util.debugm(N, task, 'checked arc signature %s: %s(%s)',
+ domain, res, err)
+
+ if not res then
+ cbdata.res = 'fail'
+ if err and domain then
+ table.insert(cbdata.errors, string.format('sig:%s:%s', domain, err))
+ end
+ end
+ if cbdata.res == 'success' then
+ -- Verify seals
+ for i, sig in ipairs(cbdata.seals) do
+ local ret, lerr = dkim_verify(task, sig.header, gen_arc_seal_cb(i, sig), 'arc-seal')
+ if not ret then
+ cbdata.res = 'fail'
+ table.insert(cbdata.errors, string.format('seal:%s:s=%s:i=%s:%s',
+ sig.d or '', sig.s or '', sig.i or '', lerr))
+ lua_util.debugm(N, task, 'checked arc seal %s: %s(%s), %s processed',
+ sig.d, ret, lerr, i)
+ end
+ end
+ else
+ task:insert_result(arc_symbols['reject'], 1.0,
+ rspamd_logger.slog('signature check failed: %s, %s', cbdata.res,
+ cbdata.errors))
+ end
+ end
+
+ --[[
+ 1. Collect all ARC Sets currently attached to the message. If there
+ are none, the Chain Validation Status is "none" and the algorithm
+ stops here. The maximum number of ARC Sets that can be attached
+ to a message is 50. If more than the maximum number exist the
+ Chain Validation Status is "fail" and the algorithm stops here.
+ In the following algorithm, the maximum ARC instance value is
+ referred to as "N".
+
+ 2. If the Chain Validation Status of the highest instance value ARC
+ Set is "fail", then the Chain Validation status is "fail" and the
+ algorithm stops here.
+
+ 3. Validate the structure of the Authenticated Received Chain. A
+ valid ARC has the following conditions:
+
+ 1. Each ARC Set MUST contain exactly one each of the three ARC
+ header fields (AAR, AMS, and AS).
+
+ 2. The instance values of the ARC Sets MUST form a continuous
+ sequence from 1..N with no gaps or repetition.
+
+ 3. The "cv" value for all ARC-Seal header fields must be non-
+ failing. For instance values > 1, the value must be "pass".
+ For instance value = 1, the value must be "none".
+
+ * If any of these conditions are not met, the Chain Validation
+ Status is "fail" and the algorithm stops here.
+
+ 4. Validate the AMS with the greatest instance value (most recent).
+ If validation fails, then the Chain Validation Status is "fail"
+ and the algorithm stops here.
+
+ 5 - 7. Optional, not implemented
+ 8. Validate each AS beginning with the greatest instance value and
+ proceeding in decreasing order to the AS with the instance value
+ of 1. If any AS fails to validate, the Chain Validation Status
+ is "fail" and the algorithm stops here.
+ 9. If the algorithm reaches this step, then the Chain Validation
+ Status is "pass", and the algorithm is complete.
+ ]]--
+
+ local processed = 0
+ local sig = cbdata.sigs[#cbdata.sigs] -- last AMS
+ local ret, err = dkim_verify(task, sig.header, arc_signature_cb, 'arc-sign')
+
+ if not ret then
+ cbdata.res = 'fail'
+ table.insert(cbdata.errors, string.format('sig:%s:%s', sig.d or '', err))
+ else
+ processed = processed + 1
+ lua_util.debugm(N, task, 'processed arc signature %s[%s]: %s(%s), %s total',
+ sig.d, sig.i, ret, err, #cbdata.seals)
+ end
+
+ if processed == 0 then
+ task:insert_result(arc_symbols['reject'], 1.0,
+ rspamd_logger.slog('cannot verify %s of %s signatures: %s',
+ #arc_sig_headers - processed, #arc_sig_headers, cbdata.errors))
+ end
+end
+
+local opts = rspamd_config:get_all_opt('arc')
+if not opts or type(opts) ~= 'table' then
+ return
+end
+
+if opts['symbols'] then
+ for k, _ in pairs(arc_symbols) do
+ if opts['symbols'][k] then
+ arc_symbols[k] = opts['symbols'][k]
+ end
+ end
+end
+
+local id = rspamd_config:register_symbol({
+ name = 'ARC_CHECK',
+ type = 'callback',
+ group = 'policies',
+ groups = { 'arc' },
+ callback = arc_callback,
+ augmentations = { lua_util.dns_timeout_augmentation(rspamd_config) },
+})
+rspamd_config:register_symbol({
+ name = 'ARC_CALLBACK', -- compatibility symbol
+ type = 'virtual,skip',
+ parent = id,
+})
+
+rspamd_config:register_symbol({
+ name = arc_symbols['allow'],
+ parent = id,
+ type = 'virtual',
+ score = -1.0,
+ group = 'policies',
+ groups = { 'arc' },
+})
+rspamd_config:register_symbol({
+ name = arc_symbols['reject'],
+ parent = id,
+ type = 'virtual',
+ score = 2.0,
+ group = 'policies',
+ groups = { 'arc' },
+})
+rspamd_config:register_symbol({
+ name = arc_symbols['invalid'],
+ parent = id,
+ type = 'virtual',
+ score = 1.0,
+ group = 'policies',
+ groups = { 'arc' },
+})
+rspamd_config:register_symbol({
+ name = arc_symbols['dnsfail'],
+ parent = id,
+ type = 'virtual',
+ score = 0.0,
+ group = 'policies',
+ groups = { 'arc' },
+})
+rspamd_config:register_symbol({
+ name = arc_symbols['na'],
+ parent = id,
+ type = 'virtual',
+ score = 0.0,
+ group = 'policies',
+ groups = { 'arc' },
+})
+
+rspamd_config:register_dependency('ARC_CHECK', 'SPF_CHECK')
+rspamd_config:register_dependency('ARC_CHECK', 'DKIM_CHECK')
+
+local function arc_sign_seal(task, params, header)
+ local arc_sigs = task:cache_get('arc-sigs')
+ local arc_seals = task:cache_get('arc-seals')
+ local arc_auth_results = task:cache_get('arc-authres')
+ local cur_auth_results
+ local privkey
+
+ if params.rawkey then
+ -- Distinguish between pem and base64
+ if string.match(params.rawkey, '^-----BEGIN') then
+ privkey = rspamd_rsa_privkey.load_pem(params.rawkey)
+ else
+ privkey = rspamd_rsa_privkey.load_base64(params.rawkey)
+ end
+ elseif params.key then
+ privkey = rspamd_rsa_privkey.load_file(params.key)
+ end
+
+ if not privkey then
+ rspamd_logger.errx(task, 'cannot load private key for signing')
+ return
+ end
+
+ if settings.reuse_auth_results then
+ local ar_header = task:get_header('Authentication-Results')
+
+ if ar_header then
+ rspamd_logger.debugm(N, task, 'reuse authentication results header for ARC')
+ cur_auth_results = ar_header
+ else
+ rspamd_logger.debugm(N, task, 'cannot reuse authentication results, header is missing')
+ cur_auth_results = lua_auth_results.gen_auth_results(task, ar_settings) or ''
+ end
+ else
+ cur_auth_results = lua_auth_results.gen_auth_results(task, ar_settings) or ''
+ end
+
+ local sha_ctx = hash.create_specific('sha256')
+
+ -- Update using previous seals + sigs + AAR
+ local cur_idx = 1
+ if arc_seals then
+ cur_idx = #arc_seals + 1
+ -- We use the cached version per each ARC-* header field individually, already sorted by instance
+ -- value in ascending order
+ for i = 1, #arc_seals, 1 do
+ if arc_auth_results[i] then
+ local s = dkim_canonicalize('ARC-Authentication-Results',
+ arc_auth_results[i].raw_header)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'update signature with header: %s', s)
+ end
+ if arc_sigs[i] then
+ local s = dkim_canonicalize('ARC-Message-Signature',
+ arc_sigs[i].raw_header)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'update signature with header: %s', s)
+ end
+ if arc_seals[i] then
+ local s = dkim_canonicalize('ARC-Seal', arc_seals[i].raw_header)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'update signature with header: %s', s)
+ end
+ end
+ end
+
+ header = lua_util.fold_header(task,
+ 'ARC-Message-Signature',
+ header)
+
+ cur_auth_results = string.format('i=%d; %s', cur_idx, cur_auth_results)
+ cur_auth_results = lua_util.fold_header(task,
+ 'ARC-Authentication-Results',
+ cur_auth_results, ';')
+
+ local s = dkim_canonicalize('ARC-Authentication-Results',
+ cur_auth_results)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'update signature with header: %s', s)
+ s = dkim_canonicalize('ARC-Message-Signature', header)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'update signature with header: %s', s)
+
+ local cur_arc_seal = string.format('i=%d; s=%s; d=%s; t=%d; a=rsa-sha256; cv=%s; b=',
+ cur_idx,
+ params.selector,
+ params.domain,
+ math.floor(rspamd_util.get_time()), params.arc_cv)
+ s = string.format('%s:%s', 'arc-seal', cur_arc_seal)
+ sha_ctx:update(s)
+ lua_util.debugm(N, task, 'initial update signature with header: %s', s)
+
+ local nl_type
+ if task:has_flag("milter") then
+ nl_type = "lf"
+ else
+ nl_type = task:get_newlines_type()
+ end
+
+ local sig = rspamd_rsa.sign_memory(privkey, sha_ctx:bin())
+ cur_arc_seal = string.format('%s%s', cur_arc_seal,
+ sig:base64(70, nl_type))
+
+ lua_mime.modify_headers(task, {
+ add = {
+ ['ARC-Authentication-Results'] = { order = 1, value = cur_auth_results },
+ ['ARC-Message-Signature'] = { order = 1, value = header },
+ ['ARC-Seal'] = { order = 1, value = lua_util.fold_header(task,
+ 'ARC-Seal', cur_arc_seal) }
+ },
+ -- RFC requires a strict order for these headers to be inserted
+ order = { 'ARC-Authentication-Results', 'ARC-Message-Signature', 'ARC-Seal' },
+ })
+ task:insert_result(settings.sign_symbol, 1.0,
+ string.format('%s:s=%s:i=%d', params.domain, params.selector, cur_idx))
+end
+
+local function prepare_arc_selector(task, sel)
+ local arc_seals = task:cache_get('arc-seals')
+
+ if not arc_seals then
+ -- Check if our arc is broken
+ local failure_reason = task:cache_get('arc-failure')
+ if failure_reason then
+ rspamd_logger.infox(task, 'skip ARC as the existing chain is broken: %s', failure_reason)
+ return false
+ end
+ end
+
+ sel.arc_cv = 'none'
+ sel.arc_idx = 1
+ sel.no_cache = true
+ sel.sign_type = 'arc-sign'
+
+ if arc_seals then
+ sel.arc_idx = #arc_seals + 1
+
+ local function default_arc_cv()
+ if task:cache_get('arc-allow') then
+ sel.arc_cv = 'pass'
+ else
+ sel.arc_cv = 'fail'
+ end
+ end
+
+ if settings.reuse_auth_results then
+ local ar_header = task:get_header('Authentication-Results')
+
+ if ar_header then
+ local arc_match = string.match(ar_header, 'arc=(%w+)')
+
+ if arc_match then
+ if arc_match == 'none' or arc_match == 'pass' then
+ -- none should be converted to `pass`
+ sel.arc_cv = 'pass'
+ else
+ sel.arc_cv = 'fail'
+ end
+ else
+ default_arc_cv()
+ end
+ else
+ -- Cannot reuse, use normal path
+ default_arc_cv()
+ end
+ else
+ default_arc_cv()
+ end
+
+ end
+
+ return true
+end
+
+local function do_sign(task, sign_params)
+ if sign_params.alg and sign_params.alg ~= 'rsa' then
+ -- No support for ed25519 keys
+ return
+ end
+
+ if not prepare_arc_selector(task, sign_params) then
+ -- Broken arc
+ return
+ end
+
+ if settings.check_pubkey then
+ local resolve_name = sign_params.selector .. "._domainkey." .. sign_params.domain
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = function(_, _, results, err)
+ if not err and results and results[1] then
+ sign_params.pubkey = results[1]
+ sign_params.strict_pubkey_check = not settings.allow_pubkey_mismatch
+ elseif not settings.allow_pubkey_mismatch then
+ rspamd_logger.errx('public key for domain %s/%s is not found: %s, skip signing',
+ sign_params.domain, sign_params.selector, err)
+ return
+ else
+ rspamd_logger.infox('public key for domain %s/%s is not found: %s',
+ sign_params.domain, sign_params.selector, err)
+ end
+
+ local dret, hdr = dkim_sign(task, sign_params)
+ if dret then
+ arc_sign_seal(task, sign_params, hdr)
+ end
+
+ end,
+ forced = true
+ })
+ else
+ local dret, hdr = dkim_sign(task, sign_params)
+ if dret then
+ arc_sign_seal(task, sign_params, hdr)
+ end
+ end
+end
+
+local function sign_error(task, msg)
+ rspamd_logger.errx(task, 'signing failure: %s', msg)
+end
+
+local function arc_signing_cb(task)
+ local ret, selectors = dkim_sign_tools.prepare_dkim_signing(N, task, settings)
+
+ if not ret then
+ return
+ end
+
+ if settings.use_redis then
+ dkim_sign_tools.sign_using_redis(N, task, settings, selectors, do_sign, sign_error)
+ else
+ if selectors.vault then
+ dkim_sign_tools.sign_using_vault(N, task, settings, selectors, do_sign, sign_error)
+ else
+ -- TODO: no support for multiple sigs
+ local cur_selector = selectors[1]
+ prepare_arc_selector(task, cur_selector)
+ if ((cur_selector.key or cur_selector.rawkey) and cur_selector.selector) then
+ if cur_selector.key then
+ cur_selector.key = lua_util.template(cur_selector.key, {
+ domain = cur_selector.domain,
+ selector = cur_selector.selector
+ })
+
+ local exists, err = rspamd_util.file_exists(cur_selector.key)
+ if not exists then
+ if err and err == 'No such file or directory' then
+ lua_util.debugm(N, task, 'cannot read key from %s: %s', cur_selector.key, err)
+ else
+ rspamd_logger.warnx(task, 'cannot read key from %s: %s', cur_selector.key, err)
+ end
+ return false
+ end
+ end
+
+ do_sign(task, cur_selector)
+ else
+ rspamd_logger.infox(task, 'key path or dkim selector unconfigured; no signing')
+ return false
+ end
+ end
+ end
+end
+
+dkim_sign_tools.process_signing_settings(N, settings, opts)
+
+if not dkim_sign_tools.validate_signing_settings(settings) then
+ rspamd_logger.infox(rspamd_config, 'mandatory parameters missing, disable arc signing')
+ return
+end
+
+local ar_opts = rspamd_config:get_all_opt('milter_headers')
+
+if ar_opts and ar_opts.routines then
+ local routines = ar_opts.routines
+
+ if routines['authentication-results'] then
+ ar_settings = lua_util.override_defaults(ar_settings,
+ routines['authentication-results'])
+ end
+end
+
+if settings.use_redis then
+ redis_params = rspamd_parse_redis_server('arc')
+
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'no servers are specified, ' ..
+ 'but module is configured to load keys from redis, disable arc signing')
+ return
+ end
+
+ settings.redis_params = redis_params
+end
+
+local sym_reg_tbl = {
+ name = settings['sign_symbol'],
+ callback = arc_signing_cb,
+ groups = { "policies", "arc" },
+ flags = 'ignore_passthrough',
+ score = 0.0,
+}
+if type(settings.allowed_ids) == 'table' then
+ sym_reg_tbl.allowed_ids = settings.allowed_ids
+end
+if type(settings.forbidden_ids) == 'table' then
+ sym_reg_tbl.forbidden_ids = settings.forbidden_ids
+end
+
+if settings.whitelisted_signers_map then
+ arc_symbols.trusted_allow = arc_symbols.trusted_allow or 'ARC_ALLOW_TRUSTED'
+ rspamd_config:register_symbol({
+ name = arc_symbols.trusted_allow,
+ parent = id,
+ type = 'virtual',
+ score = -2.0,
+ group = 'policies',
+ groups = { 'arc' },
+ })
+end
+
+rspamd_config:register_symbol(sym_reg_tbl)
+
+-- Do not sign unless checked
+rspamd_config:register_dependency(settings['sign_symbol'], 'ARC_CHECK')
+-- We need to check dmarc before signing as we have to produce valid AAR header
+-- see #3613
+rspamd_config:register_dependency(settings['sign_symbol'], 'DMARC_CHECK')
+
+if settings.adjust_dmarc and settings.whitelisted_signers_map then
+ local function arc_dmarc_adjust_cb(task)
+ local trusted_arc_ar = task:cache_get(AR_TRUSTED_CACHE_KEY)
+ local sym_to_adjust
+ if task:has_symbol(ar_settings.dmarc_symbols.reject) then
+ sym_to_adjust = ar_settings.dmarc_symbols.reject
+ elseif task:has_symbol(ar_settings.dmarc_symbols.quarantine) then
+ sym_to_adjust = ar_settings.dmarc_symbols.quarantine
+ end
+ if sym_to_adjust and trusted_arc_ar and trusted_arc_ar.ar then
+ for _, ar in ipairs(trusted_arc_ar.ar) do
+ if ar.dmarc then
+ local dmarc_fwd = ar.dmarc
+ if dmarc_fwd == 'pass' then
+ rspamd_logger.infox(task, "adjust dmarc reject score as trusted forwarder "
+ .. "proved DMARC validity for %s", ar['header.from'])
+ task:adjust_result(sym_to_adjust, 0.1,
+ 'ARC trusted')
+ end
+ end
+ end
+ end
+ end
+ rspamd_config:register_symbol({
+ name = 'ARC_DMARC_ADJUSTMENT',
+ callback = arc_dmarc_adjust_cb,
+ type = 'callback',
+ })
+ rspamd_config:register_dependency('ARC_DMARC_ADJUSTMENT', 'DMARC_CHECK')
+ rspamd_config:register_dependency('ARC_DMARC_ADJUSTMENT', 'ARC_CHECK')
+end
diff --git a/src/plugins/lua/asn.lua b/src/plugins/lua/asn.lua
new file mode 100644
index 0000000..24da19e
--- /dev/null
+++ b/src/plugins/lua/asn.lua
@@ -0,0 +1,168 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+
+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 rspamd_regexp = require "rspamd_regexp"
+local lua_util = require "lua_util"
+local N = "asn"
+
+if confighelp then
+ return
+end
+
+local options = {
+ provider_type = 'rspamd',
+ provider_info = {
+ ip4 = 'asn.rspamd.com',
+ ip6 = 'asn6.rspamd.com',
+ },
+ symbol = 'ASN',
+ check_local = false,
+}
+
+local rspamd_re = rspamd_regexp.create_cached("[\\|\\s]")
+
+local function asn_check(task)
+
+ local function asn_set(asn, ipnet, country)
+ local descr_t = {}
+ local mempool = task:get_mempool()
+ if asn then
+ if tonumber(asn) ~= nil then
+ mempool:set_variable("asn", asn)
+ table.insert(descr_t, "asn:" .. asn)
+ else
+ rspamd_logger.errx(task, 'malformed ASN "%s" for ip %s', asn, task:get_from_ip())
+ end
+ end
+ if ipnet then
+ mempool:set_variable("ipnet", ipnet)
+ table.insert(descr_t, "ipnet:" .. ipnet)
+ end
+ if country then
+ mempool:set_variable("country", country)
+ table.insert(descr_t, "country:" .. country)
+ end
+ if options['symbol'] then
+ task:insert_result(options['symbol'], 0.0, table.concat(descr_t, ', '))
+ end
+ end
+
+ local asn_check_func = {}
+ asn_check_func.rspamd = function(ip)
+ local dnsbl = options['provider_info']['ip' .. ip:get_version()]
+ local req_name = string.format("%s.%s",
+ table.concat(ip:inversed_str_octets(), '.'), dnsbl)
+ local function rspamd_dns_cb(_, _, results, dns_err, _, _, serv)
+ if dns_err and (dns_err ~= 'requested record is not found' and dns_err ~= 'no records with this name') then
+ rspamd_logger.errx(task, 'error querying dns "%s" on %s: %s',
+ req_name, serv, dns_err)
+ task:insert_result(options['symbol_fail'], 0, string.format('%s:%s', req_name, dns_err))
+ return
+ end
+ if not results or not results[1] then
+ rspamd_logger.infox(task, 'no ASN information is available for the IP address "%s" on %s',
+ req_name, serv)
+ return
+ end
+
+ lua_util.debugm(N, task, 'got reply from %s when requesting %s: %s',
+ serv, req_name, results[1])
+
+ local parts = rspamd_re:split(results[1])
+ -- "15169 | 8.8.8.0/24 | US | arin |" for 8.8.8.8
+ asn_set(parts[1], parts[2], parts[3])
+ end
+
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = req_name,
+ callback = rspamd_dns_cb
+ })
+ end
+
+ local ip = task:get_from_ip()
+ if not (ip and ip:is_valid()) or
+ (not options.check_local and ip:is_local()) then
+ return
+ end
+
+ asn_check_func[options['provider_type']](ip)
+end
+
+-- Configuration options
+local configure_asn_module = function()
+ local opts = rspamd_config:get_all_opt('asn')
+ if opts then
+ for k, v in pairs(opts) do
+ options[k] = v
+ end
+ end
+
+ local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, true)
+ options.check_local = auth_and_local_conf[1]
+ options.check_authed = auth_and_local_conf[2]
+
+ if options['provider_type'] == 'rspamd' then
+ if not options['provider_info'] and options['provider_info']['ip4'] and
+ options['provider_info']['ip6'] then
+ rspamd_logger.errx("Missing required provider_info for rspamd")
+ return false
+ end
+ else
+ rspamd_logger.errx("Unknown provider_type: %s", options['provider_type'])
+ return false
+ end
+
+ if options['symbol'] then
+ options['symbol_fail'] = options['symbol'] .. '_FAIL'
+ else
+ options['symbol_fail'] = 'ASN_FAIL'
+ end
+
+ return true
+end
+
+if configure_asn_module() then
+ local id = rspamd_config:register_symbol({
+ name = 'ASN_CHECK',
+ type = 'prefilter',
+ callback = asn_check,
+ priority = lua_util.symbols_priorities.high,
+ flags = 'empty,nostat',
+ augmentations = { lua_util.dns_timeout_augmentation(rspamd_config) },
+ })
+ if options['symbol'] then
+ rspamd_config:register_symbol({
+ name = options['symbol'],
+ parent = id,
+ type = 'virtual',
+ flags = 'empty,nostat',
+ score = 0,
+ })
+ end
+ rspamd_config:register_symbol {
+ name = options['symbol_fail'],
+ parent = id,
+ type = 'virtual',
+ flags = 'empty,nostat',
+ score = 0,
+ }
+else
+ lua_util.disable_module(N, 'config')
+end
diff --git a/src/plugins/lua/aws_s3.lua b/src/plugins/lua/aws_s3.lua
new file mode 100644
index 0000000..30e88d2
--- /dev/null
+++ b/src/plugins/lua/aws_s3.lua
@@ -0,0 +1,269 @@
+--[[
+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 N = "aws_s3"
+local lua_util = require "lua_util"
+local lua_aws = require "lua_aws"
+local rspamd_logger = require "rspamd_logger"
+local ts = (require "tableshape").types
+local rspamd_text = require "rspamd_text"
+local rspamd_http = require "rspamd_http"
+local rspamd_util = require "rspamd_util"
+
+local settings = {
+ s3_bucket = nil,
+ s3_region = 'us-east-1',
+ s3_host = 's3.amazonaws.com',
+ s3_secret_key = nil,
+ s3_key_id = nil,
+ s3_timeout = 10,
+ save_raw = true,
+ save_structure = false,
+ inline_content_limit = nil,
+}
+
+local settings_schema = ts.shape {
+ s3_bucket = ts.string,
+ s3_region = ts.string,
+ s3_host = ts.string,
+ s3_secret_key = ts.string,
+ s3_key_id = ts.string,
+ s3_timeout = ts.number + ts.string / lua_util.parse_time_interval,
+ enabled = ts.boolean:is_optional(),
+ fail_action = ts.string:is_optional(),
+ zstd_compress = ts.boolean:is_optional(),
+ save_raw = ts.boolean:is_optional(),
+ save_structure = ts.boolean:is_optional(),
+ inline_content_limit = ts.number:is_optional(),
+}
+
+local function raw_data(task, nonce, queue_id)
+ local ext, content, content_type
+
+ if settings.zstd_compress then
+ ext = 'eml.zst'
+ content = rspamd_util.zstd_compress(task:get_content())
+ content_type = 'application/zstd'
+ else
+ ext = 'eml'
+ content = task:get_content()
+ content_type = 'message/rfc-822'
+ end
+
+ local path = string.format('/%s-%s.%s', queue_id, nonce, ext)
+
+ return path, content, content_type
+end
+
+local function gen_ext(base)
+ local ext = base
+ if settings.zstd_compress then
+ ext = base .. '.zst'
+ end
+
+ return ext
+end
+
+local function convert_to_ref(task, nonce, queue_id, part, external_refs)
+ local path = string.format('/%s-%s-%s.%s', queue_id, nonce,
+ rspamd_text.randombytes(8):base32(), gen_ext('raw'))
+ local content = part.content
+
+ if settings.zstd_compress then
+ external_refs[path] = rspamd_util.zstd_compress(content)
+ else
+ external_refs[path] = content
+ end
+
+ part.content = nil
+ part.content_path = path
+
+ return path
+end
+
+local function structured_data(task, nonce, queue_id)
+ local content, content_type
+ local external_refs = {}
+ local lua_mime = require "lua_mime"
+ local ucl = require "ucl"
+
+ local message_split = lua_mime.message_to_ucl(task)
+ if settings.inline_content_limit and settings.inline_content_limit > 0 then
+
+ for i, part in ipairs(message_split.parts or {}) do
+ if part.content and #part.content >= settings.inline_content_limit then
+ local ref = convert_to_ref(task, nonce, queue_id, part, external_refs)
+ lua_util.debugm(N, task, "convert part number %s to a reference %s",
+ i, ref)
+ end
+ end
+ end
+
+ if settings.zstd_compress then
+ content = rspamd_util.zstd_compress(ucl.to_format(message_split, 'msgpack'))
+ content_type = 'application/zstd'
+ else
+ content = ucl.to_format(message_split, 'msgpack')
+ content_type = 'application/msgpack'
+ end
+
+ local path = string.format('/%s-%s.%s', queue_id, nonce, gen_ext('msgpack'))
+
+ return path, content, content_type, external_refs
+end
+
+local function s3_aws_callback(task)
+ local uri = string.format('https://%s.%s', settings.s3_bucket, settings.s3_host)
+ -- Create a nonce
+ local nonce = rspamd_text.randombytes(16):base32()
+ local queue_id = task:get_queue_id()
+ if not queue_id then
+ queue_id = rspamd_text.randombytes(8):base32()
+ end
+ -- Hack to pass host
+ local aws_host = string.format('%s.%s', settings.s3_bucket, settings.s3_host)
+
+ local function gen_s3_http_callback(path, what)
+ return function(http_err, code, body, headers)
+
+ if http_err then
+ if settings.fail_action then
+ task:set_pre_result(settings.fail_action,
+ string.format('S3 save failed: %s', http_err), N,
+ nil, nil, 'least')
+ end
+ rspamd_logger.errx(task, 'cannot save %s to AWS S3: %s', path, http_err)
+ else
+ rspamd_logger.messagex(task, 'saved %s successfully in S3 object %s', what, path)
+ end
+ lua_util.debugm(N, task, 'obj=%s, err=%s, code=%s, body=%s, headers=%s',
+ path, http_err, code, body, headers)
+ end
+ end
+
+ if settings.save_raw then
+ local path, content, content_type = raw_data(task, nonce, queue_id)
+ local hdrs = lua_aws.aws_request_enrich({
+ region = settings.s3_region,
+ headers = {
+ ['Content-Type'] = content_type,
+ ['Host'] = aws_host
+ },
+ uri = path,
+ key_id = settings.s3_key_id,
+ secret_key = settings.s3_secret_key,
+ method = 'PUT',
+ }, content)
+ rspamd_http.request({
+ url = uri .. path,
+ task = task,
+ method = 'PUT',
+ body = content,
+ callback = gen_s3_http_callback(path, 'raw message'),
+ headers = hdrs,
+ timeout = settings.s3_timeout,
+ })
+ end
+ if settings.save_structure then
+ local path, content, content_type, external_refs = structured_data(task, nonce, queue_id)
+ local hdrs = lua_aws.aws_request_enrich({
+ region = settings.s3_region,
+ headers = {
+ ['Content-Type'] = content_type,
+ ['Host'] = aws_host
+ },
+ uri = path,
+ key_id = settings.s3_key_id,
+ secret_key = settings.s3_secret_key,
+ method = 'PUT',
+ }, content)
+ rspamd_http.request({
+ url = uri .. path,
+ task = task,
+ method = 'PUT',
+ body = content,
+ callback = gen_s3_http_callback(path, 'structured message'),
+ headers = hdrs,
+ upstream = settings.upstreams:get_upstream_round_robin(),
+ timeout = settings.s3_timeout,
+ })
+
+ for ref, part_content in pairs(external_refs) do
+ local part_hdrs = lua_aws.aws_request_enrich({
+ region = settings.s3_region,
+ headers = {
+ ['Content-Type'] = content_type,
+ ['Host'] = aws_host
+ },
+ uri = ref,
+ key_id = settings.s3_key_id,
+ secret_key = settings.s3_secret_key,
+ method = 'PUT',
+ }, part_content)
+ rspamd_http.request({
+ url = uri .. ref,
+ task = task,
+ upstream = settings.upstreams:get_upstream_round_robin(),
+ method = 'PUT',
+ body = part_content,
+ callback = gen_s3_http_callback(ref, 'part content'),
+ headers = part_hdrs,
+ timeout = settings.s3_timeout,
+ })
+ end
+ end
+
+
+end
+
+local opts = rspamd_config:get_all_opt('aws_s3')
+if not opts then
+ return
+end
+
+settings = lua_util.override_defaults(settings, opts)
+local res, err = settings_schema:transform(settings)
+
+if not res then
+ rspamd_logger.warnx(rspamd_config, 'plugin is misconfigured: %s', err)
+ lua_util.disable_module(N, "config")
+ return
+end
+
+rspamd_logger.infox(rspamd_config, 'enabled AWS s3 dump to %s', res.s3_bucket)
+
+settings = res
+
+settings.upstreams = lua_util.http_upstreams_by_url(rspamd_config:get_mempool(),
+ string.format('https://%s.%s', settings.s3_bucket, settings.s3_host))
+
+if not settings.upstreams then
+ rspamd_logger.warnx(rspamd_config, 'cannot parse hostname: %s',
+ string.format('https://%s.%s', settings.s3_bucket, settings.s3_host))
+ lua_util.disable_module(N, "config")
+ return
+end
+
+local is_postfilter = settings.fail_action ~= nil
+
+rspamd_config:register_symbol({
+ name = 'EXPORT_AWS_S3',
+ type = is_postfilter and 'postfilter' or 'idempotent',
+ callback = s3_aws_callback,
+ augmentations = { string.format("timeout=%f", settings.s3_timeout) },
+ priority = is_postfilter and lua_util.symbols_priorities.high or nil,
+ flags = 'empty,explicit_disable,ignore_passthrough,nostat',
+}) \ No newline at end of file
diff --git a/src/plugins/lua/bayes_expiry.lua b/src/plugins/lua/bayes_expiry.lua
new file mode 100644
index 0000000..44ff9da
--- /dev/null
+++ b/src/plugins/lua/bayes_expiry.lua
@@ -0,0 +1,503 @@
+--[[
+Copyright (c) 2017, Andrew Lewis <nerf@judo.za.org>
+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.
+]] --
+
+if confighelp then
+ return
+end
+
+local N = 'bayes_expiry'
+local E = {}
+local logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lutil = require "lua_util"
+local lredis = require "lua_redis"
+
+local settings = {
+ interval = 60, -- one iteration step per minute
+ count = 1000, -- check up to 1000 keys on each iteration
+ epsilon_common = 0.01, -- eliminate common if spam to ham rate is equal to this epsilon
+ common_ttl = 10 * 86400, -- TTL of discriminated common elements
+ significant_factor = 3.0 / 4.0, -- which tokens should we update
+ classifiers = {},
+ cluster_nodes = 0,
+}
+
+local template = {}
+
+local function check_redis_classifier(cls, cfg)
+ -- Skip old classifiers
+ if cls.new_schema then
+ local symbol_spam, symbol_ham
+ local expiry = (cls.expiry or cls.expire)
+ if type(expiry) == 'table' then
+ expiry = expiry[1]
+ end
+
+ -- Load symbols from statfiles
+
+ local function check_statfile_table(tbl, def_sym)
+ local symbol = tbl.symbol or def_sym
+
+ local spam
+ if tbl.spam then
+ spam = tbl.spam
+ else
+ if string.match(symbol:upper(), 'SPAM') then
+ spam = true
+ else
+ spam = false
+ end
+ end
+
+ if spam then
+ symbol_spam = symbol
+ else
+ symbol_ham = symbol
+ end
+ end
+
+ local statfiles = cls.statfile
+ if statfiles[1] then
+ for _, stf in ipairs(statfiles) do
+ if not stf.symbol then
+ for k, v in pairs(stf) do
+ check_statfile_table(v, k)
+ end
+ else
+ check_statfile_table(stf, 'undefined')
+ end
+ end
+ else
+ for stn, stf in pairs(statfiles) do
+ check_statfile_table(stf, stn)
+ end
+ end
+
+ if not symbol_spam or not symbol_ham or type(expiry) ~= 'number' then
+ logger.debugm(N, rspamd_config,
+ 'disable expiry for classifier %s: no expiry %s',
+ symbol_spam, cls)
+ return
+ end
+ -- Now try to load redis_params if needed
+
+ local redis_params
+ redis_params = lredis.try_load_redis_servers(cls, rspamd_config, false, 'bayes')
+ if not redis_params then
+ redis_params = lredis.try_load_redis_servers(cfg[N] or E, rspamd_config, false, 'bayes')
+ if not redis_params then
+ redis_params = lredis.try_load_redis_servers(cfg[N] or E, rspamd_config, true)
+ if not redis_params then
+ return false
+ end
+ end
+ end
+
+ if redis_params['read_only'] then
+ logger.infox(rspamd_config, 'disable expiry for classifier %s: read only redis configuration',
+ symbol_spam)
+ return
+ end
+
+ logger.debugm(N, rspamd_config, "enabled expiry for %s/%s -> %s expiry",
+ symbol_spam, symbol_ham, expiry)
+
+ table.insert(settings.classifiers, {
+ symbol_spam = symbol_spam,
+ symbol_ham = symbol_ham,
+ redis_params = redis_params,
+ expiry = expiry
+ })
+ end
+end
+
+-- Check classifiers and try find the appropriate ones
+local obj = rspamd_config:get_ucl()
+
+local classifier = obj.classifier
+
+if classifier then
+ if classifier[1] then
+ for _, cls in ipairs(classifier) do
+ if cls.bayes then
+ cls = cls.bayes
+ end
+ if cls.backend and cls.backend == 'redis' then
+ check_redis_classifier(cls, obj)
+ end
+ end
+ else
+ if classifier.bayes then
+
+ classifier = classifier.bayes
+ if classifier[1] then
+ for _, cls in ipairs(classifier) do
+ if cls.backend and cls.backend == 'redis' then
+ check_redis_classifier(cls, obj)
+ end
+ end
+ else
+ if classifier.backend and classifier.backend == 'redis' then
+ check_redis_classifier(classifier, obj)
+ end
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt(N)
+
+if opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+end
+
+-- In clustered setup, we need to increase interval of expiration
+-- according to number of nodes in a cluster
+if settings.cluster_nodes == 0 then
+ local neighbours = obj.neighbours or {}
+ local n_neighbours = 0
+ for _, _ in pairs(neighbours) do
+ n_neighbours = n_neighbours + 1
+ end
+ settings.cluster_nodes = n_neighbours
+end
+
+-- Fill template
+template.count = settings.count
+template.threshold = settings.threshold
+template.common_ttl = settings.common_ttl
+template.epsilon_common = settings.epsilon_common
+template.significant_factor = settings.significant_factor
+template.expire_step = settings.interval
+template.hostname = rspamd_util.get_hostname()
+
+for k, v in pairs(template) do
+ template[k] = tostring(v)
+end
+
+-- Arguments:
+-- [1] = symbol pattern
+-- [2] = expire value
+-- [3] = cursor
+-- returns {cursor for the next step, step number, step statistic counters, cycle statistic counters, tokens occurrences distribution}
+local expiry_script = [[
+ local unpack_function = table.unpack or unpack
+
+ local hash2list = function (hash)
+ local res = {}
+ for k, v in pairs(hash) do
+ table.insert(res, k)
+ table.insert(res, v)
+ end
+ return res
+ end
+
+ local function merge_list(table, list)
+ local k
+ for i, v in ipairs(list) do
+ if i % 2 == 1 then
+ k = v
+ else
+ table[k] = v
+ end
+ end
+ end
+
+ local expire = math.floor(KEYS[2])
+ local pattern_sha1 = redis.sha1hex(KEYS[1])
+
+ local lock_key = pattern_sha1 .. '_lock' -- Check locking
+ local lock = redis.call('GET', lock_key)
+
+ if lock then
+ if lock ~= '${hostname}' then
+ return 'locked by ' .. lock
+ end
+ end
+
+ redis.replicate_commands()
+ redis.call('SETEX', lock_key, ${expire_step}, '${hostname}')
+
+ local cursor_key = pattern_sha1 .. '_cursor'
+ local cursor = tonumber(redis.call('GET', cursor_key) or 0)
+
+ local step = 1
+ local step_key = pattern_sha1 .. '_step'
+ if cursor > 0 then
+ step = redis.call('GET', step_key)
+ step = step and (tonumber(step) + 1) or 1
+ end
+
+ local ret = redis.call('SCAN', cursor, 'MATCH', KEYS[1], 'COUNT', '${count}')
+ local next_cursor = ret[1]
+ local keys = ret[2]
+ local tokens = {}
+
+ -- Tokens occurrences distribution counters
+ local occur = {
+ ham = {},
+ spam = {},
+ total = {}
+ }
+
+ -- Expiry step statistics counters
+ local nelts, extended, discriminated, sum, sum_squares, common, significant,
+ infrequent, infrequent_ttls_set, insignificant, insignificant_ttls_set =
+ 0,0,0,0,0,0,0,0,0,0,0
+
+ for _,key in ipairs(keys) do
+ local t = redis.call('TYPE', key)["ok"]
+ if t == 'hash' then
+ local values = redis.call('HMGET', key, 'H', 'S')
+ local ham = tonumber(values[1]) or 0
+ local spam = tonumber(values[2]) or 0
+ local ttl = redis.call('TTL', key)
+ tokens[key] = {
+ ham,
+ spam,
+ ttl
+ }
+ local total = spam + ham
+ sum = sum + total
+ sum_squares = sum_squares + total * total
+ nelts = nelts + 1
+
+ for k,v in pairs({['ham']=ham, ['spam']=spam, ['total']=total}) do
+ if tonumber(v) > 19 then v = 20 end
+ occur[k][v] = occur[k][v] and occur[k][v] + 1 or 1
+ end
+ end
+ end
+
+ local mean, stddev = 0, 0
+
+ if nelts > 0 then
+ mean = sum / nelts
+ stddev = math.sqrt(sum_squares / nelts - mean * mean)
+ end
+
+ for key,token in pairs(tokens) do
+ local ham, spam, ttl = token[1], token[2], tonumber(token[3])
+ local threshold = mean
+ local total = spam + ham
+
+ local function set_ttl()
+ if expire < 0 then
+ if ttl ~= -1 then
+ redis.call('PERSIST', key)
+ return 1
+ end
+ elseif ttl == -1 or ttl > expire then
+ redis.call('EXPIRE', key, expire)
+ return 1
+ end
+ return 0
+ end
+
+ if total == 0 or math.abs(ham - spam) <= total * ${epsilon_common} then
+ common = common + 1
+ if ttl > ${common_ttl} then
+ discriminated = discriminated + 1
+ redis.call('EXPIRE', key, ${common_ttl})
+ end
+ elseif total >= threshold and total > 0 then
+ if ham / total > ${significant_factor} or spam / total > ${significant_factor} then
+ significant = significant + 1
+ if ttl ~= -1 then
+ redis.call('PERSIST', key)
+ extended = extended + 1
+ end
+ else
+ insignificant = insignificant + 1
+ insignificant_ttls_set = insignificant_ttls_set + set_ttl()
+ end
+ else
+ infrequent = infrequent + 1
+ infrequent_ttls_set = infrequent_ttls_set + set_ttl()
+ end
+ end
+
+ -- Expiry cycle statistics counters
+ local c = {nelts = 0, extended = 0, discriminated = 0, sum = 0, sum_squares = 0,
+ common = 0, significant = 0, infrequent = 0, infrequent_ttls_set = 0, insignificant = 0, insignificant_ttls_set = 0}
+
+ local counters_key = pattern_sha1 .. '_counters'
+
+ if cursor ~= 0 then
+ merge_list(c, redis.call('HGETALL', counters_key))
+ end
+
+ c.nelts = c.nelts + nelts
+ c.extended = c.extended + extended
+ c.discriminated = c.discriminated + discriminated
+ c.sum = c.sum + sum
+ c.sum_squares = c.sum_squares + sum_squares
+ c.common = c.common + common
+ c.significant = c.significant + significant
+ c.infrequent = c.infrequent + infrequent
+ c.infrequent_ttls_set = c.infrequent_ttls_set + infrequent_ttls_set
+ c.insignificant = c.insignificant + insignificant
+ c.insignificant_ttls_set = c.insignificant_ttls_set + insignificant_ttls_set
+
+ redis.call('HMSET', counters_key, unpack_function(hash2list(c)))
+ redis.call('SET', cursor_key, tostring(next_cursor))
+ redis.call('SET', step_key, tostring(step))
+ redis.call('DEL', lock_key)
+
+ local occ_distr = {}
+ for _,cl in pairs({'ham', 'spam', 'total'}) do
+ local occur_key = pattern_sha1 .. '_occurrence_' .. cl
+
+ if cursor ~= 0 then
+ local n
+ for i,v in ipairs(redis.call('HGETALL', occur_key)) do
+ if i % 2 == 1 then
+ n = tonumber(v)
+ else
+ occur[cl][n] = occur[cl][n] and occur[cl][n] + v or v
+ end
+ end
+
+ local str = ''
+ if occur[cl][0] ~= nil then
+ str = '0:' .. occur[cl][0] .. ','
+ end
+ for k,v in ipairs(occur[cl]) do
+ if k == 20 then k = '>19' end
+ str = str .. k .. ':' .. v .. ','
+ end
+ table.insert(occ_distr, str)
+ else
+ redis.call('DEL', occur_key)
+ end
+
+ if next(occur[cl]) ~= nil then
+ redis.call('HMSET', occur_key, unpack_function(hash2list(occur[cl])))
+ end
+ end
+
+ return {
+ next_cursor, step,
+ {nelts, extended, discriminated, mean, stddev, common, significant, infrequent,
+ infrequent_ttls_set, insignificant, insignificant_ttls_set},
+ {c.nelts, c.extended, c.discriminated, c.sum, c.sum_squares, c.common,
+ c.significant, c.infrequent, c.infrequent_ttls_set, c.insignificant, c.insignificant_ttls_set},
+ occ_distr
+ }
+]]
+
+local function expire_step(cls, ev_base, worker)
+ local function redis_step_cb(err, args)
+ if err then
+ logger.errx(rspamd_config, 'cannot perform expiry step: %s', err)
+ elseif type(args) == 'table' then
+ local cur = tonumber(args[1])
+ local step = args[2]
+ local data = args[3]
+ local c_data = args[4]
+ local occ_distr = args[5]
+
+ local function log_stat(cycle)
+ local infrequent_action = (cls.expiry < 0) and 'made persistent' or 'ttls set'
+
+ local c_mean, c_stddev = 0, 0
+ if cycle and c_data[1] ~= 0 then
+ c_mean = c_data[4] / c_data[1]
+ c_stddev = math.floor(.5 + math.sqrt(c_data[5] / c_data[1] - c_mean * c_mean))
+ c_mean = math.floor(.5 + c_mean)
+ end
+
+ local d = cycle and {
+ 'cycle in ' .. step .. ' steps', c_data[1],
+ c_data[7], c_data[2], 'made persistent',
+ c_data[10], c_data[11], infrequent_action,
+ c_data[6], c_data[3],
+ c_data[8], c_data[9], infrequent_action,
+ c_mean,
+ c_stddev
+ } or {
+ 'step ' .. step, data[1],
+ data[7], data[2], 'made persistent',
+ data[10], data[11], infrequent_action,
+ data[6], data[3],
+ data[8], data[9], infrequent_action,
+ data[4],
+ data[5]
+ }
+ logger.infox(rspamd_config,
+ 'finished expiry %s: %s items checked, %s significant (%s %s), ' ..
+ '%s insignificant (%s %s), %s common (%s discriminated), ' ..
+ '%s infrequent (%s %s), %s mean, %s std',
+ lutil.unpack(d))
+ if cycle then
+ for i, cl in ipairs({ 'in ham', 'in spam', 'total' }) do
+ logger.infox(rspamd_config, 'tokens occurrences, %s: {%s}', cl, occ_distr[i])
+ end
+ end
+ end
+ log_stat(false)
+ if cur == 0 then
+ log_stat(true)
+ end
+ elseif type(args) == 'string' then
+ logger.infox(rspamd_config, 'skip expiry step: %s', args)
+ end
+ end
+ lredis.exec_redis_script(cls.script,
+ { ev_base = ev_base, is_write = true },
+ redis_step_cb,
+ { 'RS*_*', cls.expiry }
+ )
+end
+
+rspamd_config:add_on_load(function(_, ev_base, worker)
+ -- Exit unless we're the first 'controller' worker
+ if not worker:is_primary_controller() then
+ return
+ end
+
+ local unique_redis_params = {}
+ -- Push redis script to all unique redis servers
+ for _, cls in ipairs(settings.classifiers) do
+ if not unique_redis_params[cls.redis_params.hash] then
+ unique_redis_params[cls.redis_params.hash] = cls.redis_params
+ end
+ end
+
+ for h, rp in pairs(unique_redis_params) do
+ local script_id = lredis.add_redis_script(lutil.template(expiry_script,
+ template), rp)
+
+ for _, cls in ipairs(settings.classifiers) do
+ if cls.redis_params.hash == h then
+ cls.script = script_id
+ end
+ end
+ end
+
+ -- Expire tokens at regular intervals
+ for _, cls in ipairs(settings.classifiers) do
+ rspamd_config:add_periodic(ev_base,
+ settings['interval'],
+ function()
+ expire_step(cls, ev_base, worker)
+ return true
+ end, true)
+ end
+end)
diff --git a/src/plugins/lua/bimi.lua b/src/plugins/lua/bimi.lua
new file mode 100644
index 0000000..2783590
--- /dev/null
+++ b/src/plugins/lua/bimi.lua
@@ -0,0 +1,391 @@
+--[[
+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 N = "bimi"
+local lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local ts = (require "tableshape").types
+local lua_redis = require "lua_redis"
+local ucl = require "ucl"
+local lua_mime = require "lua_mime"
+local rspamd_http = require "rspamd_http"
+local rspamd_util = require "rspamd_util"
+
+local settings = {
+ helper_url = "http://127.0.0.1:3030",
+ helper_timeout = 5,
+ helper_sync = true,
+ vmc_only = true,
+ redis_prefix = 'rs_bimi',
+ redis_min_expiry = 24 * 3600,
+}
+local redis_params
+
+local settings_schema = lua_redis.enrich_schema({
+ helper_url = ts.string,
+ helper_timeout = ts.number + ts.string / lua_util.parse_time_interval,
+ helper_sync = ts.boolean,
+ vmc_only = ts.boolean,
+ redis_min_expiry = ts.number + ts.string / lua_util.parse_time_interval,
+ redis_prefix = ts.string,
+ enabled = ts.boolean:is_optional(),
+})
+
+local function check_dmarc_policy(task)
+ local dmarc_sym = task:get_symbol('DMARC_POLICY_ALLOW')
+
+ if not dmarc_sym then
+ lua_util.debugm(N, task, "no DMARC allow symbol")
+ return nil
+ end
+
+ local opts = dmarc_sym[1].options or {}
+ if not opts[1] or #opts ~= 2 then
+ lua_util.debugm(N, task, "DMARC options are bogus: %s", opts)
+ return nil
+ end
+
+ -- opts[1] - domain; opts[2] - policy
+ local dom, policy = opts[1], opts[2]
+
+ if policy ~= 'reject' and policy ~= 'quarantine' then
+ lua_util.debugm(N, task, "DMARC policy for domain %s is not strict: %s",
+ dom, policy)
+ return nil
+ end
+
+ return dom
+end
+
+local function gen_bimi_grammar()
+ local lpeg = require "lpeg"
+ lpeg.locale(lpeg)
+ local space = lpeg.space ^ 0
+ local name = lpeg.C(lpeg.alpha ^ 1) * space
+ local sep = (lpeg.S("\\;") * space) + (lpeg.space ^ 1)
+ local value = lpeg.C(lpeg.P(lpeg.graph - sep) ^ 1)
+ local pair = lpeg.Cg(name * "=" * space * value) * sep ^ -1
+ local list = lpeg.Cf(lpeg.Ct("") * pair ^ 0, rawset)
+ local version = lpeg.P("v") * space * lpeg.P("=") * space * lpeg.P("BIMI1")
+ local record = version * sep * list
+
+ return record
+end
+
+local bimi_grammar = gen_bimi_grammar()
+
+local function check_bimi_record(task, rec)
+ local elts = bimi_grammar:match(rec)
+
+ if elts then
+ lua_util.debugm(N, task, "got BIMI record: %s, processed=%s",
+ rec, elts)
+ local res = {}
+
+ if type(elts.l) == 'string' then
+ res.l = elts.l
+ end
+ if type(elts.a) == 'string' then
+ res.a = elts.a
+ end
+
+ if res.l or res.a then
+ return res
+ end
+ end
+end
+
+local function insert_bimi_headers(task, domain, bimi_content)
+ local hdr_name = 'BIMI-Indicator'
+ -- Re-encode base64...
+ local content = rspamd_util.encode_base64(rspamd_util.decode_base64(bimi_content),
+ 73, task:get_newlines_type())
+ lua_mime.modify_headers(task, {
+ remove = { [hdr_name] = 0 },
+ add = {
+ [hdr_name] = {
+ order = 0,
+ value = rspamd_util.fold_header(hdr_name, content,
+ task:get_newlines_type())
+ }
+ }
+ })
+ task:insert_result('BIMI_VALID', 1.0, { domain })
+end
+
+local function process_bimi_json(task, domain, redis_data)
+ local parser = ucl.parser()
+ local _, err = parser:parse_string(redis_data)
+
+ if err then
+ rspamd_logger.errx(task, "cannot parse BIMI result from Redis for %s: %s",
+ domain, err)
+ else
+ local d = parser:get_object()
+ if d.content then
+ insert_bimi_headers(task, domain, d.content)
+ elseif d.error then
+ lua_util.debugm(N, task, "invalid BIMI for %s: %s",
+ domain, d.error)
+ end
+ end
+end
+
+local function make_helper_request(task, domain, record, redis_server)
+ local is_sync = settings.helper_sync
+ local helper_url = string.format('%s/v1/check', settings.helper_url)
+ local redis_key = string.format('%s%s', settings.redis_prefix,
+ domain)
+
+ local function http_helper_callback(http_err, code, body, _)
+ if http_err then
+ rspamd_logger.warnx(task, 'got error reply from helper %s: code=%s; reply=%s',
+ helper_url, code, http_err)
+ return
+ end
+ if code ~= 200 then
+ rspamd_logger.warnx(task, 'got non 200 reply from helper %s: code=%s; reply=%s',
+ helper_url, code, http_err)
+ return
+ end
+ if is_sync then
+ local parser = ucl.parser()
+ local _, err = parser:parse_string(body)
+
+ if err then
+ rspamd_logger.errx(task, "cannot parse BIMI result from helper for %s: %s",
+ domain, err)
+ else
+ local d = parser:get_object()
+ if d.content then
+ insert_bimi_headers(task, domain, d.content)
+ elseif d.error then
+ lua_util.debugm(N, task, "invalid BIMI for %s: %s",
+ domain, d.error)
+ end
+
+ local ret, upstream
+ local function redis_set_cb(redis_err, _)
+ if redis_err then
+ rspamd_logger.warnx(task, 'cannot get reply from Redis when storing image %s: %s',
+ upstream:get_addr():to_string(), redis_err)
+ upstream:fail()
+ else
+ lua_util.debugm(N, task, 'stored bimi image in Redis for domain %s; key=%s',
+ domain, redis_key)
+ end
+ end
+
+ ret, _, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ redis_key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'PSETEX', -- command
+ { redis_key, tostring(settings.redis_min_expiry * 1000.0),
+ ucl.to_format(d, "json-compact") })
+
+ if not ret then
+ rspamd_logger.warnx(task, 'cannot make request to Redis when storing image; domain %s',
+ domain)
+ end
+ end
+ else
+ -- In async mode we skip request and use merely Redis to insert indicators
+ lua_util.debugm(N, task, "sent request to resolve %s to %s",
+ domain, helper_url)
+ end
+ end
+
+ local request_data = {
+ url = record.a,
+ sync = is_sync,
+ domain = domain
+ }
+
+ if not is_sync then
+ -- Allow bimi helper to save data in Redis
+ request_data.redis_server = redis_server
+ request_data.redis_prefix = settings.redis_prefix
+ request_data.redis_expiry = settings.redis_min_expiry * 1000.0
+ else
+ request_data.skip_redis = true
+ end
+
+ local serialised = ucl.to_format(request_data, 'json-compact')
+ lua_util.debugm(N, task, "send request to BIMI helper: %s",
+ serialised)
+ rspamd_http.request({
+ task = task,
+ mime_type = 'application/json',
+ timeout = settings.helper_timeout,
+ body = serialised,
+ url = helper_url,
+ callback = http_helper_callback,
+ keepalive = true,
+ })
+end
+
+local function check_bimi_vmc(task, domain, record)
+ local redis_key = string.format('%s%s', settings.redis_prefix,
+ domain)
+ local ret, _, upstream
+
+ local function redis_cached_cb(err, data)
+ if err then
+ rspamd_logger.warnx(task, 'cannot get reply from Redis %s: %s',
+ upstream:get_addr():to_string(), err)
+ upstream:fail()
+ else
+ if type(data) == 'string' then
+ -- We got a cached record, good stuff
+ lua_util.debugm(N, task, "got valid cached BIMI result for domain: %s",
+ domain)
+ process_bimi_json(task, domain, data)
+ else
+ -- Get server addr + port
+ -- We need to fix IPv6 address as redis-rs has no support of
+ -- the braced IPv6 addresses
+ local db, password = '', ''
+ if redis_params.db then
+ db = string.format('/%s', redis_params.db)
+ end
+ if redis_params.username then
+ if redis_params.password then
+ password = string.format( '%s:%s@', redis_params.username, redis_params.password)
+ else
+ rspamd_logger.warnx(task, "Redis requires a password when username is supplied")
+ end
+ elseif redis_params.password then
+ password = string.format(':%s@', redis_params.password)
+ end
+ local redis_server = string.format('redis://%s%s:%s%s',
+ password,
+ upstream:get_name(), upstream:get_port(),
+ db)
+ make_helper_request(task, domain, record, redis_server)
+ end
+ end
+ end
+
+ -- We first check Redis and then try to use helper
+ ret, _, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ redis_key, -- hash key
+ false, -- is write
+ redis_cached_cb, --callback
+ 'GET', -- command
+ { redis_key })
+
+ if not ret then
+ rspamd_logger.warnx(task, 'cannot make request to Redis; domain %s', domain)
+ end
+end
+
+local function check_bimi_dns(task, domain)
+ local resolve_name = string.format('default._bimi.%s', domain)
+ local dns_cb = function(_, _, results, err)
+ if err then
+ lua_util.debugm(N, task, "cannot resolve bimi for %s: %s",
+ domain, err)
+ else
+ for _, rec in ipairs(results) do
+ local res = check_bimi_record(task, rec)
+
+ if res then
+ if settings.vmc_only and not res.a then
+ lua_util.debugm(N, task, "BIMI for domain %s has no VMC, skip it",
+ domain)
+
+ return
+ end
+
+ if res.a then
+ check_bimi_vmc(task, domain, res)
+ elseif res.l then
+ -- TODO: add l check
+ lua_util.debugm(N, task, "l only BIMI for domain %s is not implemented yet",
+ domain)
+ end
+ end
+ end
+ end
+ end
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = dns_cb,
+ forced = true
+ })
+end
+
+local function bimi_callback(task)
+ local dmarc_domain_maybe = check_dmarc_policy(task)
+
+ if not dmarc_domain_maybe then
+ return
+ end
+
+
+ -- We can either check BIMI via DNS or check Redis cache
+ -- BIMI check is an external check, so we might prefer Redis to be checked
+ -- first. On the other hand, DNS request is cheaper and counting low BIMI
+ -- adaptation we would need to have both Redis and DNS request to hit no
+ -- result. So, it might be better to check DNS first at this stage...
+ check_bimi_dns(task, dmarc_domain_maybe)
+end
+
+local opts = rspamd_config:get_all_opt('bimi')
+if not opts then
+ lua_util.disable_module(N, "config")
+ return
+end
+
+settings = lua_util.override_defaults(settings, opts)
+local res, err = settings_schema:transform(settings)
+
+if not res then
+ rspamd_logger.warnx(rspamd_config, 'plugin is misconfigured: %s', err)
+ local err_msg = string.format("schema error: %s", res)
+ lua_util.config_utils.push_config_error(N, err_msg)
+ lua_util.disable_module(N, "failed", err_msg)
+ return
+end
+
+rspamd_logger.infox(rspamd_config, 'enabled BIMI plugin')
+
+settings = res
+redis_params = lua_redis.parse_redis_server(N, opts)
+
+if redis_params then
+ local id = rspamd_config:register_symbol({
+ name = 'BIMI_CHECK',
+ type = 'normal',
+ callback = bimi_callback,
+ augmentations = { string.format("timeout=%f", settings.helper_timeout or
+ redis_params.timeout or 0.0) }
+ })
+ rspamd_config:register_symbol {
+ name = 'BIMI_VALID',
+ type = 'virtual',
+ parent = id,
+ score = 0.0
+ }
+
+ rspamd_config:register_dependency('BIMI_CHECK', 'DMARC_CHECK')
+else
+ lua_util.disable_module(N, "redis")
+end
diff --git a/src/plugins/lua/clickhouse.lua b/src/plugins/lua/clickhouse.lua
new file mode 100644
index 0000000..25eabc7
--- /dev/null
+++ b/src/plugins/lua/clickhouse.lua
@@ -0,0 +1,1556 @@
+--[[
+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 upstream_list = require "rspamd_upstream_list"
+local lua_util = require "lua_util"
+local lua_clickhouse = require "lua_clickhouse"
+local lua_settings = require "lua_settings"
+local fun = require "fun"
+
+local N = "clickhouse"
+
+if confighelp then
+ return
+end
+
+local data_rows = {}
+local custom_rows = {}
+local nrows = 0
+local used_memory = 0
+local last_collection = 0
+local final_call = false -- If the final collection has been started
+local schema_version = 9 -- Current schema version
+
+local settings = {
+ limits = { -- Collection limits
+ max_rows = 1000, -- How many rows are allowed (0 for disable this)
+ max_memory = 50 * 1024 * 1024, -- How many memory should be occupied before sending collection
+ max_interval = 60, -- Maximum collection interval
+ },
+ collect_garbage = false, -- Perform GC collection after sending the data
+ check_timeout = 10.0, -- Periodic timeout
+ timeout = 5.0,
+ bayes_spam_symbols = { 'BAYES_SPAM' },
+ bayes_ham_symbols = { 'BAYES_HAM' },
+ ann_symbols_spam = { 'NEURAL_SPAM' },
+ ann_symbols_ham = { 'NEURAL_HAM' },
+ fuzzy_symbols = { 'FUZZY_DENIED' },
+ whitelist_symbols = { 'WHITELIST_DKIM', 'WHITELIST_SPF_DKIM', 'WHITELIST_DMARC' },
+ dkim_allow_symbols = { 'R_DKIM_ALLOW' },
+ dkim_reject_symbols = { 'R_DKIM_REJECT' },
+ dkim_dnsfail_symbols = { 'R_DKIM_TEMPFAIL', 'R_DKIM_PERMFAIL' },
+ dkim_na_symbols = { 'R_DKIM_NA' },
+ dmarc_allow_symbols = { 'DMARC_POLICY_ALLOW' },
+ dmarc_reject_symbols = { 'DMARC_POLICY_REJECT' },
+ dmarc_quarantine_symbols = { 'DMARC_POLICY_QUARANTINE' },
+ dmarc_softfail_symbols = { 'DMARC_POLICY_SOFTFAIL' },
+ dmarc_na_symbols = { 'DMARC_NA' },
+ spf_allow_symbols = { 'R_SPF_ALLOW' },
+ spf_reject_symbols = { 'R_SPF_FAIL' },
+ spf_dnsfail_symbols = { 'R_SPF_DNSFAIL', 'R_SPF_PERMFAIL' },
+ spf_neutral_symbols = { 'R_DKIM_TEMPFAIL', 'R_DKIM_PERMFAIL' },
+ spf_na_symbols = { 'R_SPF_NA' },
+ stop_symbols = {},
+ ipmask = 19,
+ ipmask6 = 48,
+ full_urls = false,
+ from_tables = nil,
+ enable_symbols = false,
+ database = 'default',
+ use_https = false,
+ use_gzip = true,
+ allow_local = false,
+ insert_subject = false,
+ subject_privacy = false, -- subject privacy is off
+ subject_privacy_alg = 'blake2', -- default hash-algorithm to obfuscate subject
+ subject_privacy_prefix = 'obf', -- prefix to show it's obfuscated
+ subject_privacy_length = 16, -- cut the length of the hash
+ schema_additions = {}, -- additional SQL statements to be executed when schema is uploaded
+ user = nil,
+ password = nil,
+ no_ssl_verify = false,
+ custom_rules = {},
+ enable_digest = false,
+ exceptions = nil,
+ retention = {
+ enable = false,
+ method = 'detach',
+ period_months = 3,
+ run_every = '7d',
+ },
+ extra_columns = {},
+}
+
+--- @language SQL
+local clickhouse_schema = { [[
+CREATE TABLE IF NOT EXISTS rspamd
+(
+ Date Date COMMENT 'Date (used for partitioning)',
+ TS DateTime COMMENT 'Date and time of the request start (UTC)',
+ From String COMMENT 'Domain part of the return address (RFC5321.MailFrom)',
+ MimeFrom String COMMENT 'Domain part of the address in From: header (RFC5322.From)',
+ IP String COMMENT 'SMTP client IP as provided by MTA or from Received: header',
+ Helo String COMMENT 'Full hostname as sent by the SMTP client (RFC5321.HELO/.EHLO)',
+ Score Float32 COMMENT 'Message score',
+ NRcpt UInt8 COMMENT 'Number of envelope recipients (RFC5321.RcptTo)',
+ Size UInt32 COMMENT 'Message size in bytes',
+ IsWhitelist Enum8('blacklist' = 0, 'whitelist' = 1, 'unknown' = 2) DEFAULT 'unknown' COMMENT 'Based on symbols configured in `whitelist_symbols` module option',
+ IsBayes Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2) DEFAULT 'unknown' COMMENT 'Based on symbols configured in `bayes_spam_symbols` and `bayes_ham_symbols` module options',
+ IsFuzzy Enum8('whitelist' = 0, 'deny' = 1, 'unknown' = 2) DEFAULT 'unknown' COMMENT 'Based on symbols configured in `fuzzy_symbols` module option',
+ IsFann Enum8('ham' = 0, 'spam' = 1, 'unknown' = 2) DEFAULT 'unknown' COMMENT 'Based on symbols configured in `ann_symbols_spam` and `ann_symbols_ham` module options',
+ IsDkim Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2, 'dnsfail' = 3, 'na' = 4) DEFAULT 'unknown' COMMENT 'Based on symbols configured in dkim_* module options',
+ IsDmarc Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2, 'softfail' = 3, 'na' = 4, 'quarantine' = 5) DEFAULT 'unknown' COMMENT 'Based on symbols configured in dmarc_* module options',
+ IsSpf Enum8('reject' = 0, 'allow' = 1, 'neutral' = 2, 'dnsfail' = 3, 'na' = 4, 'unknown' = 5) DEFAULT 'unknown' COMMENT 'Based on symbols configured in spf_* module options',
+ NUrls Int32 COMMENT 'Number of URLs and email extracted from the message',
+ Action Enum8('reject' = 0, 'rewrite subject' = 1, 'add header' = 2, 'greylist' = 3, 'no action' = 4, 'soft reject' = 5, 'custom' = 6) DEFAULT 'no action' COMMENT 'Action returned for the message; if action is not predefined actual action will be in `CustomAction` field',
+ CustomAction LowCardinality(String) COMMENT 'Action string for custom action',
+ FromUser String COMMENT 'Local part of the return address (RFC5321.MailFrom)',
+ MimeUser String COMMENT 'Local part of the address in From: header (RFC5322.From)',
+ RcptUser String COMMENT '[Deprecated] Local part of the first envelope recipient (RFC5321.RcptTo)',
+ RcptDomain String COMMENT '[Deprecated] Domain part of the first envelope recipient (RFC5321.RcptTo)',
+ SMTPRecipients Array(String) COMMENT 'List of envelope recipients (RFC5321.RcptTo)',
+ MimeRecipients Array(String) COMMENT 'List of recipients from headers (RFC5322.To/.CC/.BCC)',
+ MessageId String COMMENT 'Message-ID header',
+ ListId String COMMENT 'List-Id header',
+ Subject String COMMENT 'Subject header (or hash if `subject_privacy` module option enabled)',
+ `Attachments.FileName` Array(String) COMMENT 'Attachment name',
+ `Attachments.ContentType` Array(String) COMMENT 'Attachment Content-Type',
+ `Attachments.Length` Array(UInt32) COMMENT 'Attachment size in bytes',
+ `Attachments.Digest` Array(FixedString(16)) COMMENT 'First 16 characters of hash returned by mime_part:get_digest()',
+ `Urls.Tld` Array(String) COMMENT 'Effective second level domain part of the URL host',
+ `Urls.Url` Array(String) COMMENT 'Full URL if `full_urls` module option enabled, host part of URL otherwise',
+ `Urls.Flags` Array(UInt32) COMMENT 'Corresponding url flags, see `enum rspamd_url_flags` in libserver/url.h for details',
+ Emails Array(String) COMMENT 'List of emails extracted from the message',
+ ASN UInt32 COMMENT 'BGP AS number for SMTP client IP (returned by asn.rspamd.com or asn6.rspamd.com)',
+ Country FixedString(2) COMMENT 'Country for SMTP client IP (returned by asn.rspamd.com or asn6.rspamd.com)',
+ IPNet String,
+ `Symbols.Names` Array(LowCardinality(String)) COMMENT 'Symbol name',
+ `Symbols.Scores` Array(Float32) COMMENT 'Symbol score',
+ `Symbols.Options` Array(String) COMMENT 'Symbol options (comma separated list)',
+ `Groups.Names` Array(LowCardinality(String)) COMMENT 'Group name',
+ `Groups.Scores` Array(Float32) COMMENT 'Group score',
+ ScanTimeReal UInt32 COMMENT 'Request time in milliseconds',
+ ScanTimeVirtual UInt32 COMMENT 'Deprecated do not use',
+ AuthUser String COMMENT 'Username for authenticated SMTP client',
+ SettingsId LowCardinality(String) COMMENT 'ID for the settings profile',
+ Digest FixedString(32) COMMENT '[Deprecated]',
+ SMTPFrom ALIAS if(From = '', '', concat(FromUser, '@', From)) COMMENT 'Return address (RFC5321.MailFrom)',
+ SMTPRcpt ALIAS SMTPRecipients[1] COMMENT 'The first envelope recipient (RFC5321.RcptTo)',
+ MIMEFrom ALIAS if(MimeFrom = '', '', concat(MimeUser, '@', MimeFrom)) COMMENT 'Address in From: header (RFC5322.From)',
+ MIMERcpt ALIAS MimeRecipients[1] COMMENT 'The first recipient from headers (RFC5322.To/.CC/.BCC)'
+) ENGINE = MergeTree()
+PARTITION BY toMonday(Date)
+ORDER BY TS
+]],
+ [[CREATE TABLE IF NOT EXISTS rspamd_version ( Version UInt32) ENGINE = TinyLog]],
+ { [[INSERT INTO rspamd_version (Version) Values (${SCHEMA_VERSION})]], true },
+}
+
+-- This describes SQL queries to migrate between versions
+local migrations = {
+ [1] = {
+ -- Move to a wide fat table
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS `Attachments.FileName` Array(String) AFTER ListId,
+ ADD COLUMN IF NOT EXISTS `Attachments.ContentType` Array(String) AFTER `Attachments.FileName`,
+ ADD COLUMN IF NOT EXISTS `Attachments.Length` Array(UInt32) AFTER `Attachments.ContentType`,
+ ADD COLUMN IF NOT EXISTS `Attachments.Digest` Array(FixedString(16)) AFTER `Attachments.Length`,
+ ADD COLUMN IF NOT EXISTS `Urls.Tld` Array(String) AFTER `Attachments.Digest`,
+ ADD COLUMN IF NOT EXISTS `Urls.Url` Array(String) AFTER `Urls.Tld`,
+ ADD COLUMN IF NOT EXISTS Emails Array(String) AFTER `Urls.Url`,
+ ADD COLUMN IF NOT EXISTS ASN UInt32 AFTER Emails,
+ ADD COLUMN IF NOT EXISTS Country FixedString(2) AFTER ASN,
+ ADD COLUMN IF NOT EXISTS IPNet String AFTER Country,
+ ADD COLUMN IF NOT EXISTS `Symbols.Names` Array(String) AFTER IPNet,
+ ADD COLUMN IF NOT EXISTS `Symbols.Scores` Array(Float64) AFTER `Symbols.Names`,
+ ADD COLUMN IF NOT EXISTS `Symbols.Options` Array(String) AFTER `Symbols.Scores`]],
+ -- Add explicit version
+ [[CREATE TABLE rspamd_version ( Version UInt32) ENGINE = TinyLog]],
+ [[INSERT INTO rspamd_version (Version) Values (2)]],
+ },
+ [2] = {
+ -- Add `Subject` column
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS Subject String AFTER ListId]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (3)]],
+ },
+ [3] = {
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS IsSpf Enum8('reject' = 0, 'allow' = 1, 'neutral' = 2, 'dnsfail' = 3, 'na' = 4, 'unknown' = 5) DEFAULT 'unknown' AFTER IsDmarc,
+ MODIFY COLUMN IsDkim Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2, 'dnsfail' = 3, 'na' = 4) DEFAULT 'unknown',
+ MODIFY COLUMN IsDmarc Enum8('reject' = 0, 'allow' = 1, 'unknown' = 2, 'softfail' = 3, 'na' = 4, 'quarantine' = 5) DEFAULT 'unknown',
+ ADD COLUMN IF NOT EXISTS MimeRecipients Array(String) AFTER RcptDomain,
+ ADD COLUMN IF NOT EXISTS MessageId String AFTER MimeRecipients,
+ ADD COLUMN IF NOT EXISTS ScanTimeReal UInt32 AFTER `Symbols.Options`,
+ ADD COLUMN IF NOT EXISTS ScanTimeVirtual UInt32 AFTER ScanTimeReal]],
+ -- Add aliases
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS SMTPFrom ALIAS if(From = '', '', concat(FromUser, '@', From)),
+ ADD COLUMN IF NOT EXISTS SMTPRcpt ALIAS if(RcptDomain = '', '', concat(RcptUser, '@', RcptDomain)),
+ ADD COLUMN IF NOT EXISTS MIMEFrom ALIAS if(MimeFrom = '', '', concat(MimeUser, '@', MimeFrom)),
+ ADD COLUMN IF NOT EXISTS MIMERcpt ALIAS MimeRecipients[1]
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (4)]],
+ },
+ [4] = {
+ [[ALTER TABLE rspamd
+ MODIFY COLUMN Action Enum8('reject' = 0, 'rewrite subject' = 1, 'add header' = 2, 'greylist' = 3, 'no action' = 4, 'soft reject' = 5, 'custom' = 6) DEFAULT 'no action',
+ ADD COLUMN IF NOT EXISTS CustomAction String AFTER Action
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (5)]],
+ },
+ [5] = {
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS AuthUser String AFTER ScanTimeVirtual,
+ ADD COLUMN IF NOT EXISTS SettingsId LowCardinality(String) AFTER AuthUser
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (6)]],
+ },
+ [6] = {
+ -- Add new columns
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS Helo String AFTER IP,
+ ADD COLUMN IF NOT EXISTS SMTPRecipients Array(String) AFTER RcptDomain
+ ]],
+ -- Modify SMTPRcpt alias
+ [[
+ ALTER TABLE rspamd
+ MODIFY COLUMN SMTPRcpt ALIAS SMTPRecipients[1]
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (7)]],
+ },
+ [7] = {
+ -- Add new columns
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS `Groups.Names` Array(LowCardinality(String)) AFTER `Symbols.Options`,
+ ADD COLUMN IF NOT EXISTS `Groups.Scores` Array(Float32) AFTER `Groups.Names`
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (8)]],
+ },
+ [8] = {
+ -- Add new columns
+ [[ALTER TABLE rspamd
+ ADD COLUMN IF NOT EXISTS `Urls.Flags` Array(UInt32) AFTER `Urls.Url`
+ ]],
+ -- New version
+ [[INSERT INTO rspamd_version (Version) Values (9)]],
+ },
+}
+
+local predefined_actions = {
+ ['reject'] = true,
+ ['rewrite subject'] = true,
+ ['add header'] = true,
+ ['greylist'] = true,
+ ['no action'] = true,
+ ['soft reject'] = true
+}
+
+local function clickhouse_main_row(res)
+ local fields = {
+ 'Date',
+ 'TS',
+ 'From',
+ 'MimeFrom',
+ 'IP',
+ 'Helo',
+ 'Score',
+ 'NRcpt',
+ 'Size',
+ 'IsWhitelist',
+ 'IsBayes',
+ 'IsFuzzy',
+ 'IsFann',
+ 'IsDkim',
+ 'IsDmarc',
+ 'NUrls',
+ 'Action',
+ 'FromUser',
+ 'MimeUser',
+ 'RcptUser',
+ 'RcptDomain',
+ 'SMTPRecipients',
+ 'ListId',
+ 'Subject',
+ 'Digest',
+ -- 1.9.2 +
+ 'IsSpf',
+ 'MimeRecipients',
+ 'MessageId',
+ 'ScanTimeReal',
+ -- 1.9.3 +
+ 'CustomAction',
+ -- 2.0 +
+ 'AuthUser',
+ 'SettingsId',
+ }
+
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_attachments_row(res)
+ local fields = {
+ 'Attachments.FileName',
+ 'Attachments.ContentType',
+ 'Attachments.Length',
+ 'Attachments.Digest',
+ }
+
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_urls_row(res)
+ local fields = {
+ 'Urls.Tld',
+ 'Urls.Url',
+ 'Urls.Flags',
+ }
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_emails_row(res)
+ local fields = {
+ 'Emails',
+ }
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_symbols_row(res)
+ local fields = {
+ 'Symbols.Names',
+ 'Symbols.Scores',
+ 'Symbols.Options',
+ }
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_groups_row(res)
+ local fields = {
+ 'Groups.Names',
+ 'Groups.Scores',
+ }
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_asn_row(res)
+ local fields = {
+ 'ASN',
+ 'Country',
+ 'IPNet',
+ }
+ for _, v in ipairs(fields) do
+ table.insert(res, v)
+ end
+end
+
+local function clickhouse_extra_columns(res)
+ for _, v in ipairs(settings.extra_columns) do
+ table.insert(res, v.name)
+ end
+end
+
+local function today(ts)
+ return os.date('!%Y-%m-%d', ts)
+end
+
+local function clickhouse_check_symbol(task, settings_field_name, fields_table,
+ field_name, value, value_negative)
+ for _, s in ipairs(settings[settings_field_name] or {}) do
+ if task:has_symbol(s) then
+ if value_negative then
+ local sym = task:get_symbol(s)[1]
+ if sym['score'] > 0 then
+ fields_table[field_name] = value
+ else
+ fields_table[field_name] = value_negative
+ end
+ else
+ fields_table[field_name] = value
+ end
+
+ return true
+ end
+ end
+
+ return false
+end
+
+local function clickhouse_send_data(task, ev_base, why, gen_rows, cust_rows)
+ local log_object = task or rspamd_config
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local ip_addr = upstream:get_addr():to_string(true)
+ rspamd_logger.infox(log_object, "trying to send %s rows to clickhouse server %s; started as %s",
+ #gen_rows + #cust_rows, ip_addr, why)
+
+ local function gen_success_cb(what, how_many)
+ return function(_, _)
+ rspamd_logger.messagex(log_object, "sent %s rows of %s to clickhouse server %s; started as %s",
+ how_many, what, ip_addr, why)
+ upstream:ok()
+ end
+ end
+
+ local function gen_fail_cb(what, how_many)
+ return function(_, err)
+ rspamd_logger.errx(log_object, "cannot send %s rows of %s data to clickhouse server %s: %s; started as %s",
+ how_many, what, ip_addr, err, why)
+ upstream:fail()
+ end
+ end
+
+ local function send_data(what, tbl, query)
+ local ch_params = {}
+ if task then
+ ch_params.task = task
+ else
+ ch_params.config = rspamd_config
+ ch_params.ev_base = ev_base
+ end
+
+ local ret = lua_clickhouse.insert(upstream, settings, ch_params,
+ query, tbl,
+ gen_success_cb(what, #tbl),
+ gen_fail_cb(what, #tbl))
+ if not ret then
+ rspamd_logger.errx(log_object, "cannot send %s rows of %s data to clickhouse server %s: %s",
+ #tbl, what, ip_addr, 'cannot make HTTP request')
+ end
+ end
+
+ local fields = {}
+ clickhouse_main_row(fields)
+ clickhouse_attachments_row(fields)
+ clickhouse_urls_row(fields)
+ clickhouse_emails_row(fields)
+ clickhouse_asn_row(fields)
+
+ if settings.enable_symbols then
+ clickhouse_symbols_row(fields)
+ clickhouse_groups_row(fields)
+ end
+
+ if #settings.extra_columns > 0 then
+ clickhouse_extra_columns(fields)
+ end
+
+ send_data('generic data', gen_rows,
+ string.format('INSERT INTO rspamd (%s)',
+ table.concat(fields, ',')))
+
+ for k, crows in pairs(cust_rows) do
+ if #crows > 1 then
+ send_data('custom data (' .. k .. ')', crows,
+ settings.custom_rules[k].first_row())
+ end
+ end
+end
+
+local function clickhouse_collect(task)
+ if task:has_flag('skip') then
+ return
+ end
+
+ if not settings.allow_local and lua_util.is_rspamc_or_controller(task) then
+ return
+ end
+
+ for _, sym in ipairs(settings.stop_symbols) do
+ if task:has_symbol(sym) then
+ rspamd_logger.infox(task, 'skip Clickhouse storage for message: symbol %s has fired', sym)
+ return
+ end
+ end
+
+ if settings.exceptions then
+ local excepted, trace = settings.exceptions:process(task)
+ if excepted then
+ rspamd_logger.infox(task, 'skipped Clickhouse storage for message: excepted (%s)',
+ trace)
+ -- Excepted
+ return
+ end
+ end
+
+ local from_domain = ''
+ local from_user = ''
+ if task:has_from('smtp') then
+ local from = task:get_from({ 'smtp', 'orig' })[1]
+
+ if from then
+ from_domain = from['domain']:lower()
+ from_user = from['user']
+ end
+ end
+
+ local mime_domain = ''
+ local mime_user = ''
+ if task:has_from('mime') then
+ local from = task:get_from({ 'mime', 'orig' })[1]
+ if from then
+ mime_domain = from['domain']:lower()
+ mime_user = from['user']
+ end
+ end
+
+ local mime_recipients = {}
+ if task:has_recipients('mime') then
+ local recipients = task:get_recipients({ 'mime', 'orig' })
+ for _, rcpt in ipairs(recipients) do
+ table.insert(mime_recipients, rcpt['user'] .. '@' .. rcpt['domain']:lower())
+ end
+ end
+
+ local ip_str = 'undefined'
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ local ipnet
+ if ip:get_version() == 4 then
+ ipnet = ip:apply_mask(settings['ipmask'])
+ else
+ ipnet = ip:apply_mask(settings['ipmask6'])
+ end
+ ip_str = ipnet:to_string()
+ end
+
+ local helo = task:get_helo() or ''
+
+ local rcpt_user = ''
+ local rcpt_domain = ''
+ local smtp_recipients = {}
+ if task:has_recipients('smtp') then
+ local recipients = task:get_recipients('smtp')
+ -- for compatibility with an old table structure
+ rcpt_user = recipients[1]['user']
+ rcpt_domain = recipients[1]['domain']:lower()
+
+ for _, rcpt in ipairs(recipients) do
+ table.insert(smtp_recipients, rcpt['user'] .. '@' .. rcpt['domain']:lower())
+ end
+ end
+
+ local list_id = task:get_header('List-Id') or ''
+ local message_id = lua_util.maybe_obfuscate_string(task:get_message_id() or '',
+ settings, 'mid')
+
+ local score = task:get_metric_score()[1];
+ local fields = {
+ bayes = 'unknown',
+ fuzzy = 'unknown',
+ ann = 'unknown',
+ whitelist = 'unknown',
+ dkim = 'unknown',
+ dmarc = 'unknown',
+ spf = 'unknown',
+ }
+
+ local ret
+
+ ret = clickhouse_check_symbol(task, 'bayes_spam_symbols', fields,
+ 'bayes', 'spam')
+ if not ret then
+ clickhouse_check_symbol(task, 'bayes_ham_symbols', fields,
+ 'bayes', 'ham')
+ end
+
+ clickhouse_check_symbol(task, 'ann_symbols_spam', fields,
+ 'ann', 'spam')
+ if not ret then
+ clickhouse_check_symbol(task, 'ann_symbols_ham', fields,
+ 'ann', 'ham')
+ end
+
+ clickhouse_check_symbol(task, 'whitelist_symbols', fields,
+ 'whitelist', 'blacklist', 'whitelist')
+
+ clickhouse_check_symbol(task, 'fuzzy_symbols', fields,
+ 'fuzzy', 'deny')
+
+ ret = clickhouse_check_symbol(task, 'dkim_allow_symbols', fields,
+ 'dkim', 'allow')
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'dkim_reject_symbols', fields,
+ 'dkim', 'reject')
+ end
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'dkim_dnsfail_symbols', fields,
+ 'dkim', 'dnsfail')
+ end
+ if not ret then
+ clickhouse_check_symbol(task, 'dkim_na_symbols', fields,
+ 'dkim', 'na')
+ end
+
+ ret = clickhouse_check_symbol(task, 'dmarc_allow_symbols', fields,
+ 'dmarc', 'allow')
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'dmarc_reject_symbols', fields,
+ 'dmarc', 'reject')
+ end
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'dmarc_quarantine_symbols', fields,
+ 'dmarc', 'quarantine')
+ end
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'dmarc_softfail_symbols', fields,
+ 'dmarc', 'softfail')
+ end
+ if not ret then
+ clickhouse_check_symbol(task, 'dmarc_na_symbols', fields,
+ 'dmarc', 'na')
+ end
+
+ ret = clickhouse_check_symbol(task, 'spf_allow_symbols', fields,
+ 'spf', 'allow')
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'spf_reject_symbols', fields,
+ 'spf', 'reject')
+ end
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'spf_neutral_symbols', fields,
+ 'spf', 'neutral')
+ end
+ if not ret then
+ ret = clickhouse_check_symbol(task, 'spf_dnsfail_symbols', fields,
+ 'spf', 'dnsfail')
+ end
+ if not ret then
+ clickhouse_check_symbol(task, 'spf_na_symbols', fields,
+ 'spf', 'na')
+ end
+
+ local nrcpts = 0
+ if task:has_recipients('smtp') then
+ nrcpts = #task:get_recipients('smtp')
+ end
+
+ local nurls = 0
+ local task_urls = task:get_urls({
+ content = true,
+ images = true,
+ emails = false,
+ sort = true,
+ }) or {}
+
+ nurls = #task_urls
+
+ local timestamp = math.floor(task:get_date({
+ format = 'connect',
+ gmt = true, -- The only sane way to sync stuff with different timezones
+ }))
+
+ local action = task:get_metric_action()
+ local custom_action = ''
+
+ if not predefined_actions[action] then
+ custom_action = action
+ action = 'custom'
+ end
+
+ local digest = ''
+
+ if settings.enable_digest then
+ digest = task:get_digest()
+ end
+
+ local subject = ''
+ if settings.insert_subject then
+ subject = lua_util.maybe_obfuscate_string(task:get_subject() or '', settings, 'subject')
+ end
+
+ local scan_real = task:get_scan_time()
+ scan_real = math.floor(scan_real * 1000)
+ if scan_real < 0 then
+ rspamd_logger.messagex(task,
+ 'clock skew detected for message: %s ms real scan time (reset to 0)',
+ scan_real)
+ scan_real = 0
+ end
+
+ local auth_user = task:get_user() or ''
+ local settings_id = task:get_settings_id()
+
+ if settings_id then
+ -- Convert to string
+ settings_id = lua_settings.settings_by_id(settings_id)
+
+ if settings_id then
+ settings_id = settings_id.name
+ end
+ end
+
+ if not settings_id then
+ settings_id = ''
+ end
+
+ local row = {
+ today(timestamp),
+ timestamp,
+ from_domain,
+ mime_domain,
+ ip_str,
+ helo,
+ score,
+ nrcpts,
+ task:get_size(),
+ fields.whitelist,
+ fields.bayes,
+ fields.fuzzy,
+ fields.ann,
+ fields.dkim,
+ fields.dmarc,
+ nurls,
+ action,
+ from_user,
+ mime_user,
+ rcpt_user,
+ rcpt_domain,
+ smtp_recipients,
+ list_id,
+ subject,
+ digest,
+ fields.spf,
+ mime_recipients,
+ message_id,
+ scan_real,
+ custom_action,
+ auth_user,
+ settings_id
+ }
+
+ -- Attachments step
+ local attachments_fnames = {}
+ local attachments_ctypes = {}
+ local attachments_lengths = {}
+ local attachments_digests = {}
+ for _, part in ipairs(task:get_parts()) do
+ if part:is_attachment() then
+ table.insert(attachments_fnames, part:get_filename() or '')
+ local mime_type, mime_subtype = part:get_type()
+ table.insert(attachments_ctypes, string.format("%s/%s", mime_type, mime_subtype))
+ table.insert(attachments_lengths, part:get_length())
+ table.insert(attachments_digests, string.sub(part:get_digest(), 1, 16))
+ end
+ end
+
+ if #attachments_fnames > 0 then
+ table.insert(row, attachments_fnames)
+ table.insert(row, attachments_ctypes)
+ table.insert(row, attachments_lengths)
+ table.insert(row, attachments_digests)
+ else
+ table.insert(row, {})
+ table.insert(row, {})
+ table.insert(row, {})
+ table.insert(row, {})
+ end
+
+ -- Urls step
+ local urls_urls = {}
+ local urls_tlds = {}
+ local urls_flags = {}
+
+ if settings.full_urls then
+ for i, u in ipairs(task_urls) do
+ urls_urls[i] = u:get_text()
+ urls_tlds[i] = u:get_tld() or u:get_host()
+ urls_flags[i] = u:get_flags_num()
+ end
+ else
+ -- We need to store unique
+ local mt = {
+ ord_tbl = {}, -- ordered list of urls
+ idx_tbl = {}, -- indexed by host + flags, reference to an index in ord_tbl
+ __newindex = function(t, k, v)
+ local idx = getmetatable(t).idx_tbl
+ local ord = getmetatable(t).ord_tbl
+ local key = k:get_host() .. tostring(k:get_flags_num())
+ if idx[key] then
+ ord[idx[key]] = v -- replace
+ else
+ ord[#ord + 1] = v
+ idx[key] = #ord
+ end
+ end,
+ __index = function(t, k)
+ local ord = getmetatable(t).ord_tbl
+ if type(k) == 'number' then
+ return ord[k]
+ else
+ local idx = getmetatable(t).idx_tbl
+ local key = k:get_host() .. tostring(k:get_flags_num())
+ if idx[key] then
+ return ord[idx[key]]
+ end
+ end
+ end,
+ }
+ -- Extra index needed for making this unique
+ local urls_idx = {}
+ setmetatable(urls_idx, mt)
+ for _, u in ipairs(task_urls) do
+ if not urls_idx[u] then
+ urls_idx[u] = u
+ urls_urls[#urls_urls + 1] = u:get_host()
+ urls_tlds[#urls_tlds + 1] = u:get_tld() or u:get_host()
+ urls_flags[#urls_flags + 1] = u:get_flags_num()
+ end
+ end
+ end
+
+
+ -- Get tlds
+ table.insert(row, urls_tlds)
+ -- Get hosts/full urls
+ table.insert(row, urls_urls)
+ -- Numeric flags
+ table.insert(row, urls_flags)
+
+ -- Emails step
+ if task:has_urls(true) then
+ local emails = task:get_emails() or {}
+ local emails_formatted = {}
+ for i, u in ipairs(emails) do
+ emails_formatted[i] = string.format('%s@%s', u:get_user(), u:get_host())
+ end
+ table.insert(row, emails_formatted)
+ else
+ table.insert(row, {})
+ end
+
+ -- ASN information
+ local asn, country, ipnet = 0, '--', '--'
+ local pool = task:get_mempool()
+ ret = pool:get_variable("asn")
+ if ret then
+ asn = ret
+ end
+ ret = pool:get_variable("country")
+ if ret then
+ country = ret:sub(1, 2)
+ end
+ ret = pool:get_variable("ipnet")
+ if ret then
+ ipnet = ret
+ end
+ table.insert(row, asn)
+ table.insert(row, country)
+ table.insert(row, ipnet)
+
+ -- Symbols info
+ if settings.enable_symbols then
+ local symbols = task:get_symbols_all()
+ local syms_tab = {}
+ local scores_tab = {}
+ local options_tab = {}
+
+ for _, s in ipairs(symbols) do
+ table.insert(syms_tab, s.name or '')
+ table.insert(scores_tab, s.score)
+
+ if s.options then
+ table.insert(options_tab, table.concat(s.options, ','))
+ else
+ table.insert(options_tab, '');
+ end
+ end
+ table.insert(row, syms_tab)
+ table.insert(row, scores_tab)
+ table.insert(row, options_tab)
+
+ -- Groups data
+ local groups = task:get_groups()
+ local groups_tab = {}
+ local gr_scores_tab = {}
+ for gr, sc in pairs(groups) do
+ table.insert(groups_tab, gr)
+ table.insert(gr_scores_tab, sc)
+ end
+ table.insert(row, groups_tab)
+ table.insert(row, gr_scores_tab)
+ end
+
+ -- Extra columns
+ if #settings.extra_columns > 0 then
+ for _, col in ipairs(settings.extra_columns) do
+ local elts = col.real_selector(task)
+
+ if elts then
+ table.insert(row, elts)
+ else
+ table.insert(row, col.default_value)
+ end
+ end
+ end
+
+ -- Custom data
+ for k, rule in pairs(settings.custom_rules) do
+ if not custom_rows[k] then
+ custom_rows[k] = {}
+ end
+ table.insert(custom_rows[k], lua_clickhouse.row_to_tsv(rule.get_row(task)))
+ end
+
+ local tsv_row = lua_clickhouse.row_to_tsv(row)
+ used_memory = used_memory + #tsv_row
+ data_rows[#data_rows + 1] = tsv_row
+ nrows = nrows + 1
+ lua_util.debugm(N, task,
+ "add clickhouse row %s / %s; used memory: %s / %s",
+ nrows, settings.limits.max_rows,
+ used_memory, settings.limits.max_memory)
+end
+
+local function do_remove_partition(ev_base, cfg, table_name, partition)
+ lua_util.debugm(N, rspamd_config, "removing partition %s.%s", table_name, partition)
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local remove_partition_sql = "ALTER TABLE ${table_name} ${remove_method} PARTITION '${partition}'"
+ local remove_method = (settings.retention.method == 'drop') and 'DROP' or 'DETACH'
+ local sql_params = {
+ ['table_name'] = table_name,
+ ['remove_method'] = remove_method,
+ ['partition'] = partition
+ }
+
+ local sql = lua_util.template(remove_partition_sql, sql_params)
+
+ local ch_params = {
+ body = sql,
+ ev_base = ev_base,
+ config = cfg,
+ }
+
+ local err, _ = lua_clickhouse.generic_sync(upstream, settings, ch_params, sql)
+ if err then
+ rspamd_logger.errx(rspamd_config,
+ "cannot detach partition %s:%s from server %s: %s",
+ table_name, partition,
+ settings['server'], err)
+ return
+ end
+
+ rspamd_logger.infox(rspamd_config,
+ 'detached partition %s:%s on server %s', table_name, partition,
+ settings['server'])
+
+end
+
+--[[
+ nil - file is not writable, do not perform removal
+ 0 - it's time to perform removal
+ <int> - how many seconds wait until next run
+]]
+local function get_last_removal_ago()
+ local ts_file = string.format('%s/%s', rspamd_paths['DBDIR'], 'clickhouse_retention_run')
+ local last_ts
+ local current_ts = os.time()
+
+ local function write_ts_to_file()
+ local write_file, err = io.open(ts_file, 'w')
+ if err then
+ rspamd_logger.errx(rspamd_config, 'Failed to open %s, will not perform retention: %s', ts_file, err)
+ return nil
+ end
+
+ local res
+ res, err = write_file:write(tostring(current_ts))
+ if err or res == nil then
+ write_file:close()
+ rspamd_logger.errx(rspamd_config, 'Failed to write %s, will not perform retention: %s', ts_file, err)
+ return nil
+ end
+ write_file:close()
+
+ return true
+ end
+
+ local f, err = io.open(ts_file, 'r')
+ if err then
+ lua_util.debugm(N, rspamd_config, 'Failed to open %s: %s', ts_file, err)
+ else
+ last_ts = tonumber(f:read('*number'))
+ f:close()
+ end
+
+ if last_ts == nil or (last_ts + settings.retention.period) <= current_ts then
+ return write_ts_to_file() and 0
+ end
+
+ if last_ts > current_ts then
+ -- Clock skew detected, overwrite last_ts with current_ts and wait for the next
+ -- retention period
+ rspamd_logger.errx(rspamd_config, 'Last collection time is in future: %s; overwrite it with %s in %s',
+ last_ts, current_ts, ts_file)
+ return write_ts_to_file() and -1
+ end
+
+ return (last_ts + settings.retention.period) - current_ts
+end
+
+local function clickhouse_maybe_send_data_periodic(cfg, ev_base, now)
+ local need_collect = false
+ local reason
+
+ if nrows == 0 then
+ lua_util.debugm(N, cfg, "no need to send data, as there are no rows to collect")
+ return settings.check_timeout
+ end
+
+ if final_call then
+ lua_util.debugm(N, cfg, "no need to send data, final call has been issued")
+ return 0
+ end
+
+ if settings.limits.max_rows > 0 then
+ if nrows > settings.limits.max_rows then
+ need_collect = true
+ reason = string.format('limit of rows has been reached: %d', nrows)
+ end
+ end
+
+ if last_collection > 0 and settings.limits.max_interval > 0 then
+ if now - last_collection > settings.limits.max_interval then
+ need_collect = true
+ reason = string.format('limit of time since last collection has been reached: %d seconds passed ' ..
+ '(%d seconds trigger)',
+ (now - last_collection), settings.limits.max_interval)
+ end
+ end
+
+ if settings.limits.max_memory > 0 then
+ if used_memory >= settings.limits.max_memory then
+ need_collect = true
+ reason = string.format('limit of memory has been reached: %d bytes used',
+ used_memory)
+ end
+ end
+
+ if last_collection == 0 then
+ last_collection = now
+ end
+
+ if need_collect then
+ -- Do it atomic
+ local saved_rows = data_rows
+ local saved_custom = custom_rows
+ nrows = 0
+ last_collection = now
+ used_memory = 0
+ data_rows = {}
+ custom_rows = {}
+
+ clickhouse_send_data(nil, ev_base, reason, saved_rows, saved_custom)
+
+ if settings.collect_garbage then
+ collectgarbage()
+ end
+ end
+
+ return settings.check_timeout
+end
+
+local function clickhouse_remove_old_partitions(cfg, ev_base)
+ local last_time_ago = get_last_removal_ago()
+ if last_time_ago == nil then
+ rspamd_logger.errx(rspamd_config, "Failed to get last run time. Disabling retention")
+ return false
+ elseif last_time_ago ~= 0 then
+ return last_time_ago
+ end
+
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local partition_to_remove_sql = "SELECT partition, table " ..
+ "FROM system.parts WHERE table IN ('${tables}') " ..
+ "GROUP BY partition, table " ..
+ "HAVING max(max_date) < toDate(now() - interval ${month} month)"
+
+ local table_names = { 'rspamd' }
+ local tables = table.concat(table_names, "', '")
+ local sql_params = {
+ tables = tables,
+ month = settings.retention.period_months,
+ }
+ local sql = lua_util.template(partition_to_remove_sql, sql_params)
+
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+ local err, rows = lua_clickhouse.select_sync(upstream, settings, ch_params, sql)
+ if err then
+ rspamd_logger.errx(rspamd_config,
+ "cannot send data to clickhouse server %s: %s",
+ settings['server'], err)
+ else
+ fun.each(function(row)
+ do_remove_partition(ev_base, cfg, row.table, row.partition)
+ end, rows)
+ end
+
+ -- settings.retention.period is added on initialisation, see below
+ return settings.retention.period
+end
+
+local function upload_clickhouse_schema(upstream, ev_base, cfg, initial)
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+
+ local errored = false
+
+ -- Upload a single element of the schema
+ local function upload_schema_elt(v)
+ if errored then
+ rspamd_logger.errx(rspamd_config, "cannot upload schema '%s' on clickhouse server %s: due to previous errors",
+ v, upstream:get_addr():to_string(true))
+ return
+ end
+ local sql = v
+ local err, reply = lua_clickhouse.generic_sync(upstream, settings, ch_params, sql)
+
+ if err then
+ rspamd_logger.errx(rspamd_config, "cannot upload schema '%s' on clickhouse server %s: %s",
+ sql, upstream:get_addr():to_string(true), err)
+ errored = true
+ return
+ end
+ rspamd_logger.debugm(N, rspamd_config, 'uploaded clickhouse schema element %s to %s: %s',
+ v, upstream:get_addr():to_string(true), reply)
+ end
+
+ -- Process element and return nil if statement should be skipped
+ local function preprocess_schema_elt(v)
+ if type(v) == 'string' then
+ return lua_util.template(v, { SCHEMA_VERSION = tostring(schema_version) })
+ elseif type(v) == 'table' then
+ -- Pair of statement + boolean
+ if initial == v[2] then
+ return lua_util.template(v[1], { SCHEMA_VERSION = tostring(schema_version) })
+ else
+ rspamd_logger.debugm(N, rspamd_config, 'skip clickhouse schema element %s: schema already exists',
+ v)
+ end
+ end
+
+ return nil
+ end
+
+ -- Apply schema elements sequentially, users additions are concatenated to the tail
+ fun.each(upload_schema_elt,
+ -- Also template schema version
+ fun.filter(function(v)
+ return v ~= nil
+ end,
+ fun.map(preprocess_schema_elt,
+ fun.chain(clickhouse_schema, settings.schema_additions)
+ )
+ )
+ )
+end
+
+local function maybe_apply_migrations(upstream, ev_base, cfg, version)
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+ -- Apply migrations sequentially
+ local function migration_recursor(i)
+ if i < schema_version then
+ if migrations[i] then
+ -- We also need to apply statements sequentially
+ local function sql_recursor(j)
+ if migrations[i][j] then
+ local sql = migrations[i][j]
+ local ret = lua_clickhouse.generic(upstream, settings, ch_params, sql,
+ function(_, _)
+ rspamd_logger.infox(rspamd_config,
+ 'applied migration to version %s from version %s: %s',
+ i + 1, version, sql:gsub('[\n%s]+', ' '))
+ if j == #migrations[i] then
+ -- Go to the next migration
+ migration_recursor(i + 1)
+ else
+ -- Apply the next statement
+ sql_recursor(j + 1)
+ end
+ end,
+ function(_, err)
+ rspamd_logger.errx(rspamd_config,
+ "cannot apply migration %s: '%s' on clickhouse server %s: %s",
+ i, sql, upstream:get_addr():to_string(true), err)
+ end)
+ if not ret then
+ rspamd_logger.errx(rspamd_config,
+ "cannot apply migration %s: '%s' on clickhouse server %s: cannot make request",
+ i, sql, upstream:get_addr():to_string(true))
+ end
+ end
+ end
+
+ sql_recursor(1)
+ else
+ -- Try another migration
+ migration_recursor(i + 1)
+ end
+ end
+ end
+
+ migration_recursor(version)
+end
+
+local function add_extra_columns(upstream, ev_base, cfg)
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+ -- Apply migrations sequentially
+ local function columns_recursor(i)
+ if i <= #settings.extra_columns then
+ local col = settings.extra_columns[i]
+ local prev_column
+ if i == 1 then
+ prev_column = 'MIMERcpt'
+ else
+ prev_column = settings.extra_columns[i - 1].name
+ end
+ local sql = string.format('ALTER TABLE rspamd ADD COLUMN IF NOT EXISTS `%s` %s AFTER `%s`',
+ col.name, col.type, prev_column)
+ if col.comment then
+ sql = sql .. string.format(", COMMENT COLUMN IF EXISTS `%s` '%s'", col.name, col.comment)
+ end
+
+ local ret = lua_clickhouse.generic(upstream, settings, ch_params, sql,
+ function(_, _)
+ rspamd_logger.infox(rspamd_config,
+ 'added extra column %s (%s) after %s',
+ col.name, col.type, prev_column)
+ -- Apply the next statement
+ columns_recursor(i + 1)
+ end,
+ function(_, err)
+ rspamd_logger.errx(rspamd_config,
+ "cannot apply add column alter %s: '%s' on clickhouse server %s: %s",
+ i, sql, upstream:get_addr():to_string(true), err)
+ end)
+ if not ret then
+ rspamd_logger.errx(rspamd_config,
+ "cannot apply add column alter %s: '%s' on clickhouse server %s: cannot make request",
+ i, sql, upstream:get_addr():to_string(true))
+ end
+ end
+ end
+
+ columns_recursor(1)
+end
+
+local function check_rspamd_table(upstream, ev_base, cfg)
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+ local sql = [[EXISTS TABLE rspamd]]
+ local err, rows = lua_clickhouse.select_sync(upstream, settings, ch_params, sql)
+ if err then
+ rspamd_logger.errx(rspamd_config, "cannot check rspamd table in clickhouse server %s: %s",
+ upstream:get_addr():to_string(true), err)
+ return
+ end
+
+ if rows[1] and rows[1].result then
+ if tonumber(rows[1].result) == 1 then
+ -- Apply migration
+ upload_clickhouse_schema(upstream, ev_base, cfg, false)
+ rspamd_logger.infox(rspamd_config, 'table rspamd exists, check if we need to apply migrations')
+ maybe_apply_migrations(upstream, ev_base, cfg, 1)
+ else
+ -- Upload schema
+ rspamd_logger.infox(rspamd_config, 'table rspamd does not exists, upload full schema')
+ upload_clickhouse_schema(upstream, ev_base, cfg, true)
+ end
+ else
+ rspamd_logger.errx(rspamd_config,
+ "unexpected reply on EXISTS command from server %s: %s",
+ upstream:get_addr():to_string(true), rows)
+ end
+end
+
+local function check_clickhouse_upstream(upstream, ev_base, cfg)
+ local ch_params = {
+ ev_base = ev_base,
+ config = cfg,
+ }
+ -- If we have some custom rules, we just send its schema to the upstream
+ for k, rule in pairs(settings.custom_rules) do
+ if rule.schema then
+ local sql = lua_util.template(rule.schema, settings)
+ local err, _ = lua_clickhouse.generic_sync(upstream, settings, ch_params, sql)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'cannot send custom schema %s to clickhouse server %s: ' ..
+ 'cannot make request (%s)',
+ k, upstream:get_addr():to_string(true), err)
+ end
+ end
+ end
+
+ -- Now check the main schema and apply migrations if needed
+ local sql = [[SELECT MAX(Version) as v FROM rspamd_version]]
+ local err, rows = lua_clickhouse.select_sync(upstream, settings, ch_params, sql)
+ if err then
+ if rows and rows.code == 404 then
+ rspamd_logger.infox(rspamd_config,
+ 'table rspamd_version does not exist, check rspamd table')
+ check_rspamd_table(upstream, ev_base, cfg)
+ else
+ rspamd_logger.errx(rspamd_config,
+ "cannot get version on clickhouse server %s: %s",
+ upstream:get_addr():to_string(true), err)
+ end
+ else
+ upload_clickhouse_schema(upstream, ev_base, cfg, false)
+ local version = tonumber(rows[1].v)
+ maybe_apply_migrations(upstream, ev_base, cfg, version)
+ end
+
+ if #settings.extra_columns > 0 then
+ add_extra_columns(upstream, ev_base, cfg)
+ end
+end
+
+local opts = rspamd_config:get_all_opt('clickhouse')
+if opts then
+ -- Legacy `limit` options
+ if opts.limit and not opts.limits then
+ settings.limits.max_rows = opts.limit
+ end
+ for k, v in pairs(opts) do
+ if k == 'custom_rules' then
+ if not v[1] then
+ v = { v }
+ end
+
+ for i, rule in ipairs(v) do
+ if rule.schema and rule.first_row and rule.get_row then
+ local first_row, get_row
+ local loadstring = loadstring or load
+ local ret, res_or_err = pcall(loadstring(rule.first_row))
+
+ if not ret or type(res_or_err) ~= 'function' then
+ rspamd_logger.errx(rspamd_config, 'invalid first_row (%s) - must be a function',
+ res_or_err)
+ else
+ first_row = res_or_err
+ end
+
+ ret, res_or_err = pcall(loadstring(rule.get_row))
+
+ if not ret or type(res_or_err) ~= 'function' then
+ rspamd_logger.errx(rspamd_config,
+ 'invalid get_row (%s) - must be a function',
+ res_or_err)
+ else
+ get_row = res_or_err
+ end
+
+ if first_row and get_row then
+ local name = rule.name or tostring(i)
+ settings.custom_rules[name] = {
+ schema = rule.schema,
+ first_row = first_row,
+ get_row = get_row,
+ }
+ end
+ else
+ rspamd_logger.errx(rspamd_config, 'custom rule has no required attributes: schema, first_row and get_row')
+ end
+ end
+ else
+ settings[k] = lua_util.deepcopy(v)
+ end
+ end
+
+ if not settings['server'] and not settings['servers'] then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "config")
+ else
+ local lua_maps = require "lua_maps"
+ settings['from_map'] = lua_maps.map_add('clickhouse', 'from_tables',
+ 'regexp', 'clickhouse specific domains')
+
+ settings.upstream = upstream_list.create(rspamd_config,
+ settings['server'] or settings['servers'], 8123)
+
+ if not settings.upstream then
+ rspamd_logger.errx(rspamd_config, 'cannot parse clickhouse address: %s',
+ settings['server'] or settings['servers'])
+ lua_util.disable_module(N, "config")
+ return
+ end
+
+ if settings.exceptions then
+ local maps_expressions = require "lua_maps_expressions"
+
+ settings.exceptions = maps_expressions.create(rspamd_config,
+ settings.exceptions, N)
+ end
+
+ if settings.extra_columns then
+ -- Check sanity and create selector closures
+ local lua_selectors = require "lua_selectors"
+ local columns_transformed = {}
+ local need_sort = false
+ -- Select traverse function depending on what we have
+ local iter_func = settings.extra_columns[1] and ipairs or pairs
+
+ for col_name, col_data in iter_func(settings.extra_columns) do
+ -- Array based extra columns
+ if col_data.name then
+ col_name = col_data.name
+ end
+ if not col_data.selector or not col_data.type then
+ rspamd_logger.errx(rspamd_config, 'cannot add clickhouse extra row %s: no type or no selector',
+ col_name)
+ else
+ local is_array = false
+
+ if col_data.type:lower():match('^array') then
+ is_array = true
+ end
+
+ local selector = lua_selectors.create_selector_closure(rspamd_config,
+ col_data.selector, col_data.delimiter or '', is_array)
+
+ if not selector then
+ rspamd_logger.errx(rspamd_config, 'cannot add clickhouse extra row %s: bad selector: %s',
+ col_name, col_data.selector)
+ else
+ if not col_data.default_value then
+ if is_array then
+ col_data.default_value = {}
+ else
+ col_data.default_value = ''
+ end
+ end
+ col_data.real_selector = selector
+ if not col_data.name then
+ col_data.name = col_name
+ need_sort = true
+ end
+ table.insert(columns_transformed, col_data)
+ end
+ end
+ end
+
+ -- Convert extra columns from a map to an array sorted by column name to
+ -- preserve strict order when doing altering
+ if need_sort then
+ rspamd_logger.infox(rspamd_config, 'sort extra columns as they are not configured as an array')
+ table.sort(columns_transformed, function(c1, c2)
+ return c1.name < c2.name
+ end)
+ end
+ settings.extra_columns = columns_transformed
+ end
+
+ rspamd_config:register_symbol({
+ name = 'CLICKHOUSE_COLLECT',
+ type = 'idempotent',
+ callback = clickhouse_collect,
+ flags = 'empty,explicit_disable,ignore_passthrough',
+ augmentations = { string.format("timeout=%f", settings.timeout) },
+ })
+ rspamd_config:register_finish_script(function(task)
+ if nrows > 0 then
+ final_call = true
+ local saved_rows = data_rows
+ local saved_custom = custom_rows
+
+ nrows = 0
+ data_rows = {}
+ used_memory = 0
+ custom_rows = {}
+
+ clickhouse_send_data(task, nil, 'final collection',
+ saved_rows, saved_custom)
+
+ if settings.collect_garbage then
+ collectgarbage()
+ end
+ end
+ end)
+ -- Create tables on load
+ rspamd_config:add_on_load(function(cfg, ev_base, worker)
+ if worker:is_scanner() then
+ rspamd_config:add_periodic(ev_base, 0,
+ clickhouse_maybe_send_data_periodic, true)
+ end
+ if worker:is_primary_controller() then
+ local upstreams = settings.upstream:all_upstreams()
+
+ for _, up in ipairs(upstreams) do
+ check_clickhouse_upstream(up, ev_base, cfg)
+ end
+
+ if settings.retention.enable and settings.retention.method ~= 'drop' and
+ settings.retention.method ~= 'detach' then
+ rspamd_logger.errx(rspamd_config,
+ "retention.method should be either 'drop' or 'detach' (now: %s). Disabling retention",
+ settings.retention.method)
+ settings.retention.enable = false
+ end
+ if settings.retention.enable and settings.retention.period_months < 1 or
+ settings.retention.period_months > 1000 then
+ rspamd_logger.errx(rspamd_config,
+ "please, set retention.period_months between 1 and 1000 (now: %s). Disabling retention",
+ settings.retention.period_months)
+ settings.retention.enable = false
+ end
+ local period = lua_util.parse_time_interval(settings.retention.run_every)
+ if settings.retention.enable and period == nil then
+ rspamd_logger.errx(rspamd_config, "invalid value for retention.run_every (%s). Disabling retention",
+ settings.retention.run_every)
+ settings.retention.enable = false
+ end
+
+ if settings.retention.enable then
+ settings.retention.period = period
+ rspamd_logger.infox(rspamd_config,
+ "retention will be performed each %s seconds for %s month with method %s",
+ period, settings.retention.period_months, settings.retention.method)
+ rspamd_config:add_periodic(ev_base, 0, clickhouse_remove_old_partitions, false)
+ end
+ end
+ end)
+ end
+end
diff --git a/src/plugins/lua/clustering.lua b/src/plugins/lua/clustering.lua
new file mode 100644
index 0000000..d97bdb9
--- /dev/null
+++ b/src/plugins/lua/clustering.lua
@@ -0,0 +1,322 @@
+--[[
+Copyright (c) 2018, Vsevolod Stakhov
+
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- Plugin for finding patterns in email flows
+
+local N = 'clustering'
+
+local rspamd_logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local lua_verdict = require "lua_verdict"
+local lua_redis = require "lua_redis"
+local lua_selectors = require "lua_selectors"
+local ts = require("tableshape").types
+
+local redis_params
+
+local rules = {} -- Rules placement
+
+local default_rule = {
+ max_elts = 100, -- Maximum elements in a cluster
+ expire = 3600, -- Expire for a bucket when limit is not reached
+ expire_overflow = 36000, -- Expire for a bucket when limit is reached
+ spam_mult = 1.0, -- Increase on spam hit
+ junk_mult = 0.5, -- Increase on junk
+ ham_mult = -0.1, -- Increase on ham
+ size_mult = 0.01, -- Reaches 1.0 on `max_elts`
+ score_mult = 0.1,
+}
+
+local rule_schema = ts.shape {
+ max_elts = ts.number + ts.string / tonumber,
+ expire = ts.number + ts.string / lua_util.parse_time_interval,
+ expire_overflow = ts.number + ts.string / lua_util.parse_time_interval,
+ spam_mult = ts.number,
+ junk_mult = ts.number,
+ ham_mult = ts.number,
+ size_mult = ts.number,
+ score_mult = ts.number,
+ source_selector = ts.string,
+ cluster_selector = ts.string,
+ symbol = ts.string:is_optional(),
+ prefix = ts.string:is_optional(),
+}
+
+-- Redis scripts
+
+-- Queries for a cluster's data
+-- Arguments:
+-- 1. Source selector (string)
+-- 2. Cluster selector (string)
+-- Returns: {cur_elts, total_score, element_score}
+local query_cluster_script = [[
+local sz = redis.call('HLEN', KEYS[1])
+
+if not sz or not tonumber(sz) then
+ -- New bucket, will update on idempotent phase
+ return {0, '0', '0'}
+end
+
+local total_score = redis.call('HGET', KEYS[1], '__s')
+total_score = tonumber(total_score) or 0
+local score = redis.call('HGET', KEYS[1], KEYS[2])
+if not score or not tonumber(score) then
+ return {sz, tostring(total_score), '0'}
+end
+return {sz, tostring(total_score), tostring(score)}
+]]
+local query_cluster_id
+
+-- Updates cluster's data
+-- Arguments:
+-- 1. Source selector (string)
+-- 2. Cluster selector (string)
+-- 3. Score (number)
+-- 4. Max buckets (number)
+-- 5. Expire (number)
+-- 6. Expire overflow (number)
+-- Returns: nothing
+local update_cluster_script = [[
+local sz = redis.call('HLEN', KEYS[1])
+
+if not sz or not tonumber(sz) then
+ -- Create bucket
+ redis.call('HSET', KEYS[1], KEYS[2], math.abs(KEYS[3]))
+ redis.call('HSET', KEYS[1], '__s', KEYS[3])
+ redis.call('EXPIRE', KEYS[1], KEYS[5])
+
+ return
+end
+
+sz = tonumber(sz)
+local lim = tonumber(KEYS[4])
+
+if sz > lim then
+
+ if k then
+ -- Existing key
+ redis.call('HINCRBYFLOAT', KEYS[1], KEYS[2], math.abs(KEYS[3]))
+ end
+else
+ redis.call('HINCRBYFLOAT', KEYS[1], KEYS[2], math.abs(KEYS[3]))
+ redis.call('EXPIRE', KEYS[1], KEYS[6])
+end
+
+redis.call('HINCRBYFLOAT', KEYS[1], '__s', KEYS[3])
+redis.call('EXPIRE', KEYS[1], KEYS[5])
+]]
+local update_cluster_id
+
+-- Callbacks and logic
+
+local function clusterting_filter_cb(task, rule)
+ local source_selector = rule.source_selector(task)
+ local cluster_selector
+
+ if source_selector then
+ cluster_selector = rule.cluster_selector(task)
+ end
+
+ if not cluster_selector or not source_selector then
+ rspamd_logger.debugm(N, task, 'skip rule %s, selectors: source="%s", cluster="%s"',
+ rule.name, source_selector, cluster_selector)
+ return
+ end
+
+ local function combine_scores(cur_elts, total_score, element_score)
+ local final_score
+
+ local size_score = cur_elts * rule.size_mult
+ local cluster_score = total_score * rule.score_mult
+
+ if element_score > 0 then
+ -- We have seen this element mostly in junk/spam
+ final_score = math.min(1.0, size_score + cluster_score)
+ else
+ -- We have seen this element in ham mostly, so subtract average it from the size score
+ final_score = math.min(1.0, size_score - cluster_score / cur_elts)
+ end
+ rspamd_logger.debugm(N, task,
+ 'processed rule %s, selectors: source="%s", cluster="%s"; data: %s elts, %s score, %s elt score',
+ rule.name, source_selector, cluster_selector, cur_elts, total_score, element_score)
+ if final_score > 0.1 then
+ task:insert_result(rule.symbol, final_score, { source_selector,
+ tostring(size_score),
+ tostring(cluster_score) })
+ end
+ end
+
+ local function redis_get_cb(err, data)
+ if data then
+ if type(data) == 'table' then
+ combine_scores(tonumber(data[1]), tonumber(data[2]), tonumber(data[3]))
+ else
+ rspamd_logger.errx(task, 'invalid type while getting clustering keys %s: %s',
+ source_selector, type(data))
+ end
+
+ elseif err then
+ rspamd_logger.errx(task, 'got error while getting clustering keys %s: %s',
+ source_selector, err)
+ else
+ rspamd_logger.errx(task, 'got error while getting clustering keys %s: %s',
+ source_selector, "unknown error")
+ end
+ end
+
+ lua_redis.exec_redis_script(query_cluster_id,
+ { task = task, is_write = false, key = source_selector },
+ redis_get_cb,
+ { source_selector, cluster_selector })
+end
+
+local function clusterting_idempotent_cb(task, rule)
+ if task:has_flag('skip') then
+ return
+ end
+ if not rule.allow_local and lua_util.is_rspamc_or_controller(task) then
+ return
+ end
+
+ local verdict = lua_verdict.get_specific_verdict(N, task)
+ local score
+
+ if verdict == 'ham' then
+ score = rule.ham_mult
+ elseif verdict == 'spam' then
+ score = rule.spam_mult
+ elseif verdict == 'junk' then
+ score = rule.junk_mult
+ else
+ rspamd_logger.debugm(N, task, 'skip rule %s, verdict=%s',
+ rule.name, verdict)
+ return
+ end
+
+ local source_selector = rule.source_selector(task)
+ local cluster_selector
+
+ if source_selector then
+ cluster_selector = rule.cluster_selector(task)
+ end
+
+ if not cluster_selector or not source_selector then
+ rspamd_logger.debugm(N, task, 'skip rule %s, selectors: source="%s", cluster="%s"',
+ rule.name, source_selector, cluster_selector)
+ return
+ end
+
+ local function redis_set_cb(err, data)
+ if err then
+ rspamd_logger.errx(task, 'got error while getting clustering keys %s: %s',
+ source_selector, err)
+ else
+ rspamd_logger.debugm(N, task, 'set clustering key for %s: %s{%s} = %s',
+ source_selector, "unknown error")
+ end
+ end
+
+ lua_redis.exec_redis_script(update_cluster_id,
+ { task = task, is_write = true, key = source_selector },
+ redis_set_cb,
+ {
+ source_selector,
+ cluster_selector,
+ tostring(score),
+ tostring(rule.max_elts),
+ tostring(rule.expire),
+ tostring(rule.expire_overflow)
+ }
+ )
+end
+-- Init part
+redis_params = lua_redis.parse_redis_server('clustering')
+local opts = rspamd_config:get_all_opt("clustering")
+
+-- Initialization part
+if not (opts and type(opts) == 'table') then
+ lua_util.disable_module(N, "config")
+ return
+end
+
+if not redis_params then
+ lua_util.disable_module(N, "redis")
+ return
+end
+
+if opts['rules'] then
+ for k, v in pairs(opts['rules']) do
+ local raw_rule = lua_util.override_defaults(default_rule, v)
+
+ local rule, err = rule_schema:transform(raw_rule)
+
+ if not rule then
+ rspamd_logger.errx(rspamd_config, 'invalid clustering rule %s: %s',
+ k, err)
+ else
+
+ if not rule.symbol then
+ rule.symbol = k
+ end
+ if not rule.prefix then
+ rule.prefix = k .. "_"
+ end
+
+ rule.source_selector = lua_selectors.create_selector_closure(rspamd_config,
+ rule.source_selector, '')
+ rule.cluster_selector = lua_selectors.create_selector_closure(rspamd_config,
+ rule.cluster_selector, '')
+ if rule.source_selector and rule.cluster_selector then
+ rule.name = k
+ table.insert(rules, rule)
+ end
+ end
+ end
+
+ if #rules > 0 then
+
+ query_cluster_id = lua_redis.add_redis_script(query_cluster_script, redis_params)
+ update_cluster_id = lua_redis.add_redis_script(update_cluster_script, redis_params)
+ local function callback_gen(f, rule)
+ return function(task)
+ return f(task, rule)
+ end
+ end
+
+ for _, rule in ipairs(rules) do
+ rspamd_config:register_symbol {
+ name = rule.symbol,
+ type = 'normal',
+ callback = callback_gen(clusterting_filter_cb, rule),
+ }
+ rspamd_config:register_symbol {
+ name = rule.symbol .. '_STORE',
+ type = 'idempotent',
+ flags = 'empty,explicit_disable,ignore_passthrough',
+ callback = callback_gen(clusterting_idempotent_cb, rule),
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
+ }
+ end
+ else
+ lua_util.disable_module(N, "config")
+ end
+else
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/dcc.lua b/src/plugins/lua/dcc.lua
new file mode 100644
index 0000000..8508320
--- /dev/null
+++ b/src/plugins/lua/dcc.lua
@@ -0,0 +1,119 @@
+--[[
+Copyright (c) 2016, Steve Freegard <steve.freegard@fsl.com>
+Copyright (c) 2016, Vsevolod Stakhov
+
+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.
+]]--
+
+-- Check messages for 'bulkiness' using DCC
+
+local N = 'dcc'
+local symbol_bulk = "DCC_BULK"
+local symbol = "DCC_REJECT"
+local opts = rspamd_config:get_all_opt(N)
+local lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local dcc = require("lua_scanners").filter('dcc').dcc
+
+if confighelp then
+ rspamd_config:add_example(nil, 'dcc',
+ "Check messages for 'bulkiness' using DCC",
+ [[
+ dcc {
+ socket = "/var/dcc/dccifd"; # Unix socket
+ servers = "127.0.0.1:10045" # OR TCP upstreams
+ timeout = 2s; # Timeout to wait for checks
+ body_max = 999999; # Bulkness threshold for body
+ fuz1_max = 999999; # Bulkness threshold for fuz1
+ fuz2_max = 999999; # Bulkness threshold for fuz2
+ }
+ ]])
+ return
+end
+
+local rule
+
+local function check_dcc (task)
+ dcc.check(task, task:get_content(), nil, rule)
+end
+
+-- Configuration
+
+-- WORKAROUND for deprecated host and port settings
+if opts['host'] ~= nil and opts['port'] ~= nil then
+ opts['servers'] = opts['host'] .. ':' .. opts['port']
+ rspamd_logger.warnx(rspamd_config, 'Using host and port parameters is deprecated. ' ..
+ 'Please use servers = "%s:%s"; instead', opts['host'], opts['port'])
+end
+if opts['host'] ~= nil and not opts['port'] then
+ opts['socket'] = opts['host']
+ rspamd_logger.warnx(rspamd_config, 'Using host parameters is deprecated. ' ..
+ 'Please use socket = "%s"; instead', opts['host'])
+end
+-- WORKAROUND for deprecated host and port settings
+
+if not opts.symbol_bulk then
+ opts.symbol_bulk = symbol_bulk
+end
+if not opts.symbol then
+ opts.symbol = symbol
+end
+
+rule = dcc.configure(opts)
+
+if rule then
+ local id = rspamd_config:register_symbol({
+ name = 'DCC_CHECK',
+ callback = check_dcc,
+ type = 'callback',
+ })
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ parent = id,
+ name = opts.symbol
+ }
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ parent = id,
+ name = opts.symbol_bulk
+ }
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ parent = id,
+ name = 'DCC_FAIL'
+ }
+ rspamd_config:set_metric_symbol({
+ group = N,
+ score = 1.0,
+ description = 'Detected as bulk mail by DCC',
+ one_shot = true,
+ name = opts.symbol_bulk,
+ })
+ rspamd_config:set_metric_symbol({
+ group = N,
+ score = 2.0,
+ description = 'Rejected by DCC',
+ one_shot = true,
+ name = opts.symbol,
+ })
+ rspamd_config:set_metric_symbol({
+ group = N,
+ score = 0.0,
+ description = 'DCC failure',
+ one_shot = true,
+ name = 'DCC_FAIL',
+ })
+else
+ lua_util.disable_module(N, "config")
+ rspamd_logger.infox('DCC module not configured');
+end
diff --git a/src/plugins/lua/dkim_signing.lua b/src/plugins/lua/dkim_signing.lua
new file mode 100644
index 0000000..6c05520
--- /dev/null
+++ b/src/plugins/lua/dkim_signing.lua
@@ -0,0 +1,186 @@
+--[[
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+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 lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local dkim_sign_tools = require "lua_dkim_tools"
+local lua_redis = require "lua_redis"
+local lua_mime = require "lua_mime"
+
+if confighelp then
+ return
+end
+
+local settings = {
+ allow_envfrom_empty = true,
+ allow_hdrfrom_mismatch = false,
+ allow_hdrfrom_mismatch_local = false,
+ allow_hdrfrom_mismatch_sign_networks = false,
+ allow_hdrfrom_multiple = false,
+ allow_username_mismatch = false,
+ allow_pubkey_mismatch = true,
+ sign_authenticated = true,
+ allowed_ids = nil,
+ forbidden_ids = nil,
+ check_pubkey = false,
+ domain = {},
+ path = string.format('%s/%s/%s', rspamd_paths['DBDIR'], 'dkim', '$domain.$selector.key'),
+ sign_local = true,
+ selector = 'dkim',
+ symbol = 'DKIM_SIGNED',
+ try_fallback = true,
+ use_domain = 'header',
+ use_esld = true,
+ use_redis = false,
+ key_prefix = 'dkim_keys', -- default hash name
+ use_milter_headers = false, -- use milter headers instead of `dkim_signature`
+}
+
+local N = 'dkim_signing'
+local redis_params
+local sign_func = rspamd_plugins.dkim.sign
+
+local function insert_sign_results(task, ret, hdr, dkim_params)
+ if settings.use_milter_headers then
+ lua_mime.modify_headers(task, {
+ add = {
+ ['DKIM-Signature'] = { order = 1, value = hdr },
+ }
+ })
+ end
+ if ret then
+ task:insert_result(settings.symbol, 1.0, string.format('%s:s=%s',
+ dkim_params.domain, dkim_params.selector))
+ end
+end
+
+local function do_sign(task, p)
+ if settings.use_milter_headers then
+ p.no_cache = true -- Disable caching in rspamd_mempool
+ end
+ if settings.check_pubkey then
+ local resolve_name = p.selector .. "._domainkey." .. p.domain
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = function(_, _, results, err)
+ if not err and results and results[1] then
+ p.pubkey = results[1]
+ p.strict_pubkey_check = not settings.allow_pubkey_mismatch
+ elseif not settings.allow_pubkey_mismatch then
+ rspamd_logger.infox(task, 'public key for domain %s/%s is not found: %s, skip signing',
+ p.domain, p.selector, err)
+ return
+ else
+ rspamd_logger.infox(task, 'public key for domain %s/%s is not found: %s',
+ p.domain, p.selector, err)
+ end
+
+ local sret, hdr = sign_func(task, p)
+ insert_sign_results(task, sret, hdr, p)
+ end,
+ forced = true
+ })
+ else
+ local sret, hdr = sign_func(task, p)
+ insert_sign_results(task, sret, hdr, p)
+ end
+end
+
+local function sign_error(task, msg)
+ rspamd_logger.errx(task, 'signing failure: %s', msg)
+end
+
+local function dkim_signing_cb(task)
+ local ret, selectors = dkim_sign_tools.prepare_dkim_signing(N, task, settings)
+
+ if not ret then
+ return
+ end
+
+ if settings.use_redis then
+ dkim_sign_tools.sign_using_redis(N, task, settings, selectors, do_sign, sign_error)
+ else
+ if selectors.vault then
+ dkim_sign_tools.sign_using_vault(N, task, settings, selectors, do_sign, sign_error)
+ else
+ if #selectors > 0 then
+ for _, k in ipairs(selectors) do
+ -- templates
+ if k.key then
+ k.key = lua_util.template(k.key, {
+ domain = k.domain,
+ selector = k.selector
+ })
+ lua_util.debugm(N, task, 'using key "%s", use selector "%s" for domain "%s"',
+ k.key, k.selector, k.domain)
+ end
+
+ do_sign(task, k)
+ end
+ else
+ rspamd_logger.infox(task, 'key path or dkim selector unconfigured; no signing')
+ return false
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('dkim_signing')
+if not opts then
+ return
+end
+
+dkim_sign_tools.process_signing_settings(N, settings, opts)
+
+if not dkim_sign_tools.validate_signing_settings(settings) then
+ rspamd_logger.infox(rspamd_config, 'mandatory parameters missing, disable dkim signing')
+ lua_util.disable_module(N, "config")
+ return
+end
+
+if settings.use_redis then
+ redis_params = lua_redis.parse_redis_server('dkim_signing')
+
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config,
+ 'no servers are specified, but module is configured to load keys from redis, disable dkim signing')
+ lua_util.disable_module(N, "redis")
+ return
+ end
+
+ settings.redis_params = redis_params
+end
+
+local sym_reg_tbl = {
+ name = settings['symbol'],
+ callback = dkim_signing_cb,
+ groups = { "policies", "dkim" },
+ flags = 'ignore_passthrough',
+ score = 0.0,
+}
+
+if type(settings.allowed_ids) == 'table' then
+ sym_reg_tbl.allowed_ids = settings.allowed_ids
+end
+if type(settings.forbidden_ids) == 'table' then
+ sym_reg_tbl.forbidden_ids = settings.forbidden_ids
+end
+
+rspamd_config:register_symbol(sym_reg_tbl)
+-- Add dependency on DKIM checks
+rspamd_config:register_dependency(settings['symbol'], 'DKIM_CHECK')
diff --git a/src/plugins/lua/dmarc.lua b/src/plugins/lua/dmarc.lua
new file mode 100644
index 0000000..792672b
--- /dev/null
+++ b/src/plugins/lua/dmarc.lua
@@ -0,0 +1,685 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2015-2016, Andrew Lewis <nerf@judo.za.org>
+
+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.
+]]--
+
+-- Dmarc policy filter
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_redis = require "lua_redis"
+local lua_util = require "lua_util"
+local dmarc_common = require "plugins/dmarc"
+
+if confighelp then
+ return
+end
+
+local N = 'dmarc'
+
+local settings = dmarc_common.default_settings
+
+local redis_params = nil
+
+local E = {}
+
+-- Keys:
+-- 1 = index key (string)
+-- 2 = report key (string)
+-- 3 = max report elements (number)
+-- 4 = expiry time for elements (number)
+-- Arguments
+-- 1 = dmarc domain
+-- 2 = dmarc report
+local take_report_id
+local take_report_script = [[
+local index_key = KEYS[1]
+local report_key = KEYS[2]
+local max_entries = -(tonumber(KEYS[3]) + 1)
+local keys_expiry = tonumber(KEYS[4])
+local dmarc_domain = ARGV[1]
+local report = ARGV[2]
+redis.call('SADD', index_key, report_key)
+redis.call('EXPIRE', index_key, 172800)
+redis.call('ZINCRBY', report_key, 1, report)
+redis.call('ZREMRANGEBYRANK', report_key, 0, max_entries)
+redis.call('EXPIRE', report_key, 172800)
+]]
+
+local function maybe_force_action(task, disposition)
+ if disposition then
+ local force_action = settings.actions[disposition]
+ if force_action then
+ -- Set least action
+ task:set_pre_result(force_action, 'Action set by DMARC', N, nil, nil, 'least')
+ end
+ end
+end
+
+local function dmarc_validate_policy(task, policy, hdrfromdom, dmarc_esld)
+ local reason = {}
+
+ -- Check dkim and spf symbols
+ local spf_ok = false
+ local dkim_ok = false
+ local spf_tmpfail = false
+ local dkim_tmpfail = false
+
+ local spf_domain = ((task:get_from(1) or E)[1] or E).domain
+
+ if not spf_domain or spf_domain == '' then
+ spf_domain = task:get_helo() or ''
+ end
+
+ if task:has_symbol(settings.symbols['spf_allow_symbol']) then
+ if policy.strict_spf then
+ if rspamd_util.strequal_caseless(spf_domain, hdrfromdom) then
+ spf_ok = true
+ else
+ table.insert(reason, "SPF not aligned (strict)")
+ end
+ else
+ local spf_tld = rspamd_util.get_tld(spf_domain)
+ if rspamd_util.strequal_caseless(spf_tld, dmarc_esld) then
+ spf_ok = true
+ else
+ table.insert(reason, "SPF not aligned (relaxed)")
+ end
+ end
+ else
+ if task:has_symbol(settings.symbols['spf_tempfail_symbol']) then
+ if policy.strict_spf then
+ if rspamd_util.strequal_caseless(spf_domain, hdrfromdom) then
+ spf_tmpfail = true
+ end
+ else
+ local spf_tld = rspamd_util.get_tld(spf_domain)
+ if rspamd_util.strequal_caseless(spf_tld, dmarc_esld) then
+ spf_tmpfail = true
+ end
+ end
+ end
+
+ table.insert(reason, "No valid SPF")
+ end
+
+ local opts = ((task:get_symbol('DKIM_TRACE') or E)[1] or E).options
+ local dkim_results = {
+ pass = {},
+ temperror = {},
+ permerror = {},
+ fail = {},
+ }
+
+ if opts then
+ dkim_results.pass = {}
+ local dkim_violated
+
+ for _, opt in ipairs(opts) do
+ local check_res = string.sub(opt, -1)
+ local domain = string.sub(opt, 1, -3):lower()
+
+ if check_res == '+' then
+ table.insert(dkim_results.pass, domain)
+
+ if policy.strict_dkim then
+ if rspamd_util.strequal_caseless(hdrfromdom, domain) then
+ dkim_ok = true
+ else
+ dkim_violated = "DKIM not aligned (strict)"
+ end
+ else
+ local dkim_tld = rspamd_util.get_tld(domain)
+
+ if rspamd_util.strequal_caseless(dkim_tld, dmarc_esld) then
+ dkim_ok = true
+ else
+ dkim_violated = "DKIM not aligned (relaxed)"
+ end
+ end
+ elseif check_res == '?' then
+ -- Check for dkim tempfail
+ if not dkim_ok then
+ if policy.strict_dkim then
+ if rspamd_util.strequal_caseless(hdrfromdom, domain) then
+ dkim_tmpfail = true
+ end
+ else
+ local dkim_tld = rspamd_util.get_tld(domain)
+
+ if rspamd_util.strequal_caseless(dkim_tld, dmarc_esld) then
+ dkim_tmpfail = true
+ end
+ end
+ end
+ table.insert(dkim_results.temperror, domain)
+ elseif check_res == '-' then
+ table.insert(dkim_results.fail, domain)
+ else
+ table.insert(dkim_results.permerror, domain)
+ end
+ end
+
+ if not dkim_ok and dkim_violated then
+ table.insert(reason, dkim_violated)
+ end
+ else
+ table.insert(reason, "No valid DKIM")
+ end
+
+ lua_util.debugm(N, task,
+ "validated dmarc policy for %s: %s; dkim_ok=%s, dkim_tempfail=%s, spf_ok=%s, spf_tempfail=%s",
+ policy.domain, policy.dmarc_policy,
+ dkim_ok, dkim_tmpfail,
+ spf_ok, spf_tmpfail)
+
+ local disposition = 'none'
+ local sampled_out = false
+
+ local function handle_dmarc_failure(what, reason_str)
+ if not policy.pct or policy.pct == 100 then
+ task:insert_result(settings.symbols[what], 1.0,
+ policy.domain .. ' : ' .. reason_str, policy.dmarc_policy)
+ disposition = what
+ else
+ local coin = math.random(100)
+ if (coin > policy.pct) then
+ if (not settings.no_sampling_domains or
+ not settings.no_sampling_domains:get_key(policy.domain)) then
+
+ if what == 'reject' then
+ disposition = 'quarantine'
+ else
+ disposition = 'softfail'
+ end
+
+ task:insert_result(settings.symbols[disposition], 1.0,
+ policy.domain .. ' : ' .. reason_str, policy.dmarc_policy, "sampled_out")
+ sampled_out = true
+ lua_util.debugm(N, task,
+ 'changed dmarc policy from %s to %s, sampled out: %s < %s',
+ what, disposition, coin, policy.pct)
+ else
+ task:insert_result(settings.symbols[what], 1.0,
+ policy.domain .. ' : ' .. reason_str, policy.dmarc_policy, "local_policy")
+ disposition = what
+ end
+ else
+ task:insert_result(settings.symbols[what], 1.0,
+ policy.domain .. ' : ' .. reason_str, policy.dmarc_policy)
+ disposition = what
+ end
+ end
+
+ maybe_force_action(task, disposition)
+ end
+
+ if spf_ok or dkim_ok then
+ --[[
+ https://tools.ietf.org/html/rfc7489#section-6.6.2
+ DMARC evaluation can only yield a "pass" result after one of the
+ underlying authentication mechanisms passes for an aligned
+ identifier.
+ ]]--
+ task:insert_result(settings.symbols['allow'], 1.0, policy.domain,
+ policy.dmarc_policy)
+ else
+ --[[
+ https://tools.ietf.org/html/rfc7489#section-6.6.2
+
+ If neither passes and one or both of them fail due to a
+ temporary error, the Receiver evaluating the message is unable to
+ conclude that the DMARC mechanism had a permanent failure; they
+ therefore cannot apply the advertised DMARC policy.
+ ]]--
+ if spf_tmpfail or dkim_tmpfail then
+ task:insert_result(settings.symbols['dnsfail'], 1.0, policy.domain ..
+ ' : ' .. 'SPF/DKIM temp error', policy.dmarc_policy)
+ else
+ -- We can now check the failed policy and maybe send report data elt
+ local reason_str = table.concat(reason, ', ')
+
+ if policy.dmarc_policy == 'quarantine' then
+ handle_dmarc_failure('quarantine', reason_str)
+ elseif policy.dmarc_policy == 'reject' then
+ handle_dmarc_failure('reject', reason_str)
+ else
+ task:insert_result(settings.symbols['softfail'], 1.0,
+ policy.domain .. ' : ' .. reason_str,
+ policy.dmarc_policy)
+ end
+ end
+ end
+
+ if policy.rua and redis_params and settings.reporting.enabled then
+ if settings.reporting.exclude_domains then
+ if settings.reporting.exclude_domains:get_key(policy.domain) or
+ settings.reporting.exclude_domains:get_key(rspamd_util.get_tld(policy.domain)) then
+ rspamd_logger.info(task, 'DMARC reporting suppressed for sender domain %s', policy.domain)
+ return
+ end
+ end
+ if settings.reporting.exclude_recipients then
+ local rcpt = task:get_principal_recipient()
+ if rcpt and settings.reporting.exclude_recipients:get_key(rcpt) then
+ rspamd_logger.info(task, 'DMARC reporting suppressed for recipient %s', rcpt)
+ return
+ end
+ end
+
+ local function dmarc_report_cb(err)
+ if not err then
+ rspamd_logger.infox(task, 'dmarc report saved for %s (rua = %s)',
+ hdrfromdom, policy.rua)
+ else
+ rspamd_logger.errx(task, 'dmarc report is not saved for %s: %s',
+ hdrfromdom, err)
+ end
+ end
+
+ local spf_result
+ if spf_ok then
+ spf_result = 'pass'
+ elseif spf_tmpfail then
+ spf_result = 'temperror'
+ else
+ if task:has_symbol(settings.symbols.spf_deny_symbol) then
+ spf_result = 'fail'
+ elseif task:has_symbol(settings.symbols.spf_softfail_symbol) then
+ spf_result = 'softfail'
+ elseif task:has_symbol(settings.symbols.spf_neutral_symbol) then
+ spf_result = 'neutral'
+ elseif task:has_symbol(settings.symbols.spf_permfail_symbol) then
+ spf_result = 'permerror'
+ else
+ spf_result = 'none'
+ end
+ end
+
+ -- Prepare and send redis report element
+ local period = os.date('%Y%m%d',
+ task:get_date({ format = 'connect', gmt = false }))
+
+ -- Dmarc domain key must include dmarc domain, rua and period
+ local dmarc_domain_key = table.concat(
+ { settings.reporting.redis_keys.report_prefix, policy.domain, policy.rua, period },
+ settings.reporting.redis_keys.join_char)
+ local report_data = dmarc_common.dmarc_report(task, settings, {
+ spf_ok = spf_ok and 'pass' or 'fail',
+ dkim_ok = dkim_ok and 'pass' or 'fail',
+ disposition = (disposition == "softfail") and "none" or disposition,
+ sampled_out = sampled_out,
+ domain = hdrfromdom,
+ spf_domain = spf_domain,
+ dkim_results = dkim_results,
+ spf_result = spf_result
+ })
+
+ local idx_key = table.concat({ settings.reporting.redis_keys.index_prefix, period },
+ settings.reporting.redis_keys.join_char)
+
+ if report_data then
+ lua_redis.exec_redis_script(take_report_id,
+ { task = task, is_write = true },
+ dmarc_report_cb,
+ { idx_key, dmarc_domain_key,
+ tostring(settings.reporting.max_entries), tostring(settings.reporting.keys_expire) },
+ { hdrfromdom, report_data })
+ end
+ end
+end
+
+local function dmarc_callback(task)
+ local from = task:get_from(2)
+ local hfromdom = ((from or E)[1] or E).domain
+ local dmarc_domain
+ local ip_addr = task:get_ip()
+ local dmarc_checks = task:get_mempool():get_variable('dmarc_checks', 'double') or 0
+ local seen_invalid = false
+
+ if dmarc_checks ~= 2 then
+ rspamd_logger.infox(task, "skip DMARC checks as either SPF or DKIM were not checked")
+ return
+ end
+
+ if lua_util.is_skip_local_or_authed(task, settings.auth_and_local_conf, ip_addr) then
+ rspamd_logger.infox(task, "skip DMARC checks for local networks and authorized users")
+ return
+ end
+
+ -- Do some initial sanity checks, detect tld domain if different
+ if hfromdom and hfromdom ~= '' and not (from or E)[2] then
+ -- Lowercase domain as per #3940
+ hfromdom = hfromdom:lower()
+ dmarc_domain = rspamd_util.get_tld(hfromdom)
+ elseif (from or E)[2] then
+ task:insert_result(settings.symbols['na'], 1.0, 'Duplicate From header')
+ return maybe_force_action(task, 'na')
+ elseif (from or E)[1] then
+ task:insert_result(settings.symbols['na'], 1.0, 'No domain in From header')
+ return maybe_force_action(task, 'na')
+ else
+ task:insert_result(settings.symbols['na'], 1.0, 'No From header')
+ return maybe_force_action(task, 'na')
+ end
+
+ local dns_checks_inflight = 0
+ local dmarc_domain_policy = {}
+ local dmarc_tld_policy = {}
+
+ local function process_dmarc_policy(policy, final)
+ lua_util.debugm(N, task, "validate DMARC policy (final=%s): %s",
+ true, policy)
+ if policy.err and policy.symbol then
+ -- In case of fatal errors or final check for tld, we give up and
+ -- insert result
+ if final or policy.fatal then
+ task:insert_result(policy.symbol, 1.0, policy.err)
+ maybe_force_action(task, policy.disposition)
+
+ return true
+ end
+ elseif policy.dmarc_policy then
+ dmarc_validate_policy(task, policy, hfromdom, dmarc_domain)
+
+ return true -- We have a more specific version, use it
+ end
+
+ return false -- Missing record
+ end
+
+ local function gen_dmarc_cb(lookup_domain, is_tld)
+ local policy_target = dmarc_domain_policy
+ if is_tld then
+ policy_target = dmarc_tld_policy
+ end
+
+ return function(_, _, results, err)
+ dns_checks_inflight = dns_checks_inflight - 1
+
+ if not seen_invalid then
+ policy_target.domain = lookup_domain
+
+ if err then
+ if (err ~= 'requested record is not found' and
+ err ~= 'no records with this name') then
+ policy_target.err = lookup_domain .. ' : ' .. err
+ policy_target.symbol = settings.symbols['dnsfail']
+ else
+ policy_target.err = lookup_domain
+ policy_target.symbol = settings.symbols['na']
+ end
+ else
+ local has_valid_policy = false
+
+ for _, rec in ipairs(results) do
+ local ret, results_or_err = dmarc_common.dmarc_check_record(task, rec, is_tld)
+
+ if not ret then
+ if results_or_err then
+ -- We have a fatal parsing error, give up
+ policy_target.err = lookup_domain .. ' : ' .. results_or_err
+ policy_target.symbol = settings.symbols['badpolicy']
+ policy_target.fatal = true
+ seen_invalid = true
+ end
+ else
+ if has_valid_policy then
+ policy_target.err = lookup_domain .. ' : ' ..
+ 'Multiple policies defined in DNS'
+ policy_target.symbol = settings.symbols['badpolicy']
+ policy_target.fatal = true
+ seen_invalid = true
+ end
+ has_valid_policy = true
+
+ for k, v in pairs(results_or_err) do
+ policy_target[k] = v
+ end
+ end
+ end
+
+ if not has_valid_policy and not seen_invalid then
+ policy_target.err = lookup_domain .. ':' .. ' no valid DMARC record'
+ policy_target.symbol = settings.symbols['na']
+ end
+ end
+ end
+
+ if dns_checks_inflight == 0 then
+ lua_util.debugm(N, task, "finished DNS queries, validate policies")
+ -- We have checked both tld and real domain (if different)
+ if not process_dmarc_policy(dmarc_domain_policy, false) then
+ -- Try tld policy as well
+ if not process_dmarc_policy(dmarc_tld_policy, true) then
+ process_dmarc_policy(dmarc_domain_policy, true)
+ end
+ end
+ end
+ end
+ end
+
+ local resolve_name = '_dmarc.' .. hfromdom
+
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = gen_dmarc_cb(hfromdom, false),
+ forced = true
+ })
+ dns_checks_inflight = dns_checks_inflight + 1
+
+ if dmarc_domain ~= hfromdom then
+ resolve_name = '_dmarc.' .. dmarc_domain
+
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = gen_dmarc_cb(dmarc_domain, true),
+ forced = true
+ })
+
+ dns_checks_inflight = dns_checks_inflight + 1
+ end
+end
+
+local opts = rspamd_config:get_all_opt('dmarc')
+settings = lua_util.override_defaults(settings, opts)
+
+settings.auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+
+-- Legacy...
+if settings.reporting and not settings.reporting.exclude_domains and settings.no_reporting_domains then
+ settings.reporting.exclude_domains = settings.no_reporting_domains
+end
+
+local lua_maps = require "lua_maps"
+lua_maps.fill_config_maps(N, settings, {
+ no_sampling_domains = {
+ optional = true,
+ type = 'map',
+ description = 'Domains not to apply DMARC sampling to'
+ },
+})
+
+if type(settings.reporting) == 'table' then
+ lua_maps.fill_config_maps(N, settings.reporting, {
+ exclude_domains = {
+ optional = true,
+ type = 'map',
+ description = 'Domains not to store DMARC reports about'
+ },
+ exclude_recipients = {
+ optional = true,
+ type = 'map',
+ description = 'Recipients not to store DMARC reports for'
+ },
+ })
+end
+
+if settings.reporting == true then
+ rspamd_logger.errx(rspamd_config, 'old style dmarc reporting is NO LONGER supported, please read the documentation')
+elseif settings.reporting.enabled then
+ redis_params = lua_redis.parse_redis_server('dmarc', opts)
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'cannot parse servers parameter')
+ else
+ rspamd_logger.infox(rspamd_config, 'dmarc reporting is enabled')
+ take_report_id = lua_redis.add_redis_script(take_report_script, redis_params)
+ end
+end
+
+-- Check spf and dkim sections for changed symbols
+local function check_mopt(var, m_opts, name)
+ if m_opts[name] then
+ settings.symbols[var] = tostring(m_opts[name])
+ end
+end
+
+local spf_opts = rspamd_config:get_all_opt('spf')
+if spf_opts then
+ check_mopt('spf_deny_symbol', spf_opts, 'symbol_fail')
+ check_mopt('spf_allow_symbol', spf_opts, 'symbol_allow')
+ check_mopt('spf_softfail_symbol', spf_opts, 'symbol_softfail')
+ check_mopt('spf_neutral_symbol', spf_opts, 'symbol_neutral')
+ check_mopt('spf_tempfail_symbol', spf_opts, 'symbol_dnsfail')
+ check_mopt('spf_na_symbol', spf_opts, 'symbol_na')
+end
+
+local dkim_opts = rspamd_config:get_all_opt('dkim')
+if dkim_opts then
+ check_mopt('dkim_deny_symbol', dkim_opts, 'symbol_reject')
+ check_mopt('dkim_allow_symbol', dkim_opts, 'symbol_allow')
+ check_mopt('dkim_tempfail_symbol', dkim_opts, 'symbol_tempfail')
+ check_mopt('dkim_na_symbol', dkim_opts, 'symbol_na')
+end
+
+local id = rspamd_config:register_symbol({
+ name = 'DMARC_CHECK',
+ type = 'callback',
+ callback = dmarc_callback
+})
+rspamd_config:register_symbol({
+ name = 'DMARC_CALLBACK', -- compatibility symbol
+ type = 'virtual,skip',
+ parent = id,
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['allow'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['reject'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['quarantine'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['softfail'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['dnsfail'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['badpolicy'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+rspamd_config:register_symbol({
+ name = settings.symbols['na'],
+ parent = id,
+ group = 'policies',
+ groups = { 'dmarc' },
+ type = 'virtual'
+})
+
+rspamd_config:register_dependency('DMARC_CHECK', settings.symbols['spf_allow_symbol'])
+rspamd_config:register_dependency('DMARC_CHECK', settings.symbols['dkim_allow_symbol'])
+
+-- DMARC munging support
+if settings.munging then
+ local lua_maps_expressions = require "lua_maps_expressions"
+
+ local munging_defaults = {
+ reply_goes_to_list = false,
+ mitigate_allow_only = true, -- perform munging based on DMARC_POLICY_ALLOW only
+ mitigate_strict_only = false, -- perform mugning merely for reject/quarantine policies
+ munge_from = true, -- replace from with something like <orig name> via <rcpt user>
+ list_map = nil, -- map of maillist domains
+ munge_map_condition = nil, -- maps expression to enable munging
+ }
+
+ local munging_opts = lua_util.override_defaults(munging_defaults, settings.munging)
+
+ if not munging_opts.list_map then
+ rspamd_logger.errx(rspamd_config, 'cannot enable DMARC munging with no list_map parameter')
+
+ return
+ end
+
+ munging_opts.list_map = lua_maps.map_add_from_ucl(munging_opts.list_map,
+ 'set', 'DMARC munging map of the recipients addresses to munge')
+
+ if not munging_opts.list_map then
+ rspamd_logger.errx(rspamd_config, 'cannot enable DMARC munging with invalid list_map (invalid map)')
+
+ return
+ end
+
+ if munging_opts.munge_map_condition then
+ munging_opts.munge_map_condition = lua_maps_expressions.create(rspamd_config,
+ munging_opts.munge_map_condition, N)
+ end
+
+ rspamd_config:register_symbol({
+ name = 'DMARC_MUNGED',
+ type = 'normal',
+ flags = 'nostat',
+ score = 0,
+ group = 'policies',
+ groups = { 'dmarc' },
+ callback = dmarc_common.gen_munging_callback(munging_opts, settings),
+ augmentations = { lua_util.dns_timeout_augmentation(rspamd_config) },
+ })
+
+ rspamd_config:register_dependency('DMARC_MUNGED', 'DMARC_CHECK')
+ -- To avoid dkim signing issues
+ rspamd_config:register_dependency('DKIM_SIGNED', 'DMARC_MUNGED')
+ rspamd_config:register_dependency('ARC_SIGNED', 'DMARC_MUNGED')
+
+ rspamd_logger.infox(rspamd_config, 'enabled DMARC munging')
+end
diff --git a/src/plugins/lua/dynamic_conf.lua b/src/plugins/lua/dynamic_conf.lua
new file mode 100644
index 0000000..5af26a9
--- /dev/null
+++ b/src/plugins/lua/dynamic_conf.lua
@@ -0,0 +1,333 @@
+--[[
+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 redis_params
+local ucl = require "ucl"
+local fun = require "fun"
+local lua_util = require "lua_util"
+local rspamd_redis = require "lua_redis"
+local N = "dynamic_conf"
+
+if confighelp then
+ return
+end
+
+local settings = {
+ redis_key = "dynamic_conf",
+ redis_watch_interval = 10.0,
+ priority = 10
+}
+
+local cur_settings = {
+ version = 0,
+ updates = {
+ symbols = {},
+ actions = {},
+ has_updates = false
+ }
+}
+
+local function alpha_cmp(v1, v2)
+ local math = math
+ if math.abs(v1 - v2) < 0.001 then
+ return true
+ end
+
+ return false
+end
+
+local function apply_dynamic_actions(_, acts)
+ fun.each(function(k, v)
+ if type(v) == 'table' then
+ v['name'] = k
+ if not v['priority'] then
+ v['priority'] = settings.priority
+ end
+ rspamd_config:set_metric_action(v)
+ else
+ rspamd_config:set_metric_symbol({
+ name = k,
+ score = v,
+ priority = settings.priority
+ })
+ end
+ end, fun.filter(function(k, v)
+ local act = rspamd_config:get_metric_action(k)
+ if (act and alpha_cmp(act, v)) or cur_settings.updates.actions[k] then
+ return false
+ end
+
+ return true
+ end, acts))
+end
+
+local function apply_dynamic_scores(_, sc)
+ fun.each(function(k, v)
+ if type(v) == 'table' then
+ v['name'] = k
+ if not v['priority'] then
+ v['priority'] = settings.priority
+ end
+ rspamd_config:set_metric_symbol(v)
+ else
+ rspamd_config:set_metric_symbol({
+ name = k,
+ score = v,
+ priority = settings.priority
+ })
+ end
+ end, fun.filter(function(k, v)
+ -- Select elts with scores that are different from local ones
+ local sym = rspamd_config:get_symbol(k)
+ if (sym and alpha_cmp(sym.score, v)) or cur_settings.updates.symbols[k] then
+ return false
+ end
+
+ return true
+ end, sc))
+end
+
+local function apply_dynamic_conf(cfg, data)
+ if data['scores'] then
+ -- Apply scores changes
+ apply_dynamic_scores(cfg, data['scores'])
+ end
+
+ if data['actions'] then
+ apply_dynamic_actions(cfg, data['actions'])
+ end
+
+ if data['symbols_enabled'] then
+ fun.each(function(_, v)
+ cfg:enable_symbol(v)
+ end, data['symbols_enabled'])
+ end
+
+ if data['symbols_disabled'] then
+ fun.each(function(_, v)
+ cfg:disable_symbol(v)
+ end, data['symbols_disabled'])
+ end
+end
+
+local function update_dynamic_conf(cfg, ev_base, recv)
+ local function redis_version_set_cb(err, data)
+ if err then
+ rspamd_logger.errx(cfg, "cannot save dynamic conf version to redis: %s", err)
+ else
+ rspamd_logger.infox(cfg, "saved dynamic conf version: %s", data)
+ cur_settings.updates.has_updates = false
+ cur_settings.updates.symbols = {}
+ cur_settings.updates.actions = {}
+ end
+ end
+ local function redis_data_set_cb(err)
+ if err then
+ rspamd_logger.errx(cfg, "cannot save dynamic conf to redis: %s", err)
+ else
+ rspamd_redis.redis_make_request_taskless(ev_base,
+ cfg,
+ redis_params,
+ settings.redis_key,
+ true,
+ redis_version_set_cb,
+ 'HINCRBY', { settings.redis_key, 'v', '1' })
+ end
+ end
+
+ if recv then
+ -- We need to merge two configs
+ if recv['scores'] then
+ if not cur_settings.data.scores then
+ cur_settings.data.scores = {}
+ end
+ fun.each(function(k, v)
+ cur_settings.data.scores[k] = v
+ end,
+ fun.filter(function(k)
+ if cur_settings.updates.symbols[k] then
+ return false
+ end
+ return true
+ end, recv['scores']))
+ end
+ if recv['actions'] then
+ if not cur_settings.data.actions then
+ cur_settings.data.actions = {}
+ end
+ fun.each(function(k, v)
+ cur_settings.data.actions[k] = v
+ end,
+ fun.filter(function(k)
+ if cur_settings.updates.actions[k] then
+ return false
+ end
+ return true
+ end, recv['actions']))
+ end
+ end
+ local newdata = ucl.to_format(cur_settings.data, 'json-compact')
+ rspamd_redis.redis_make_request_taskless(ev_base, cfg, redis_params,
+ settings.redis_key, true,
+ redis_data_set_cb, 'HSET', { settings.redis_key, 'd', newdata })
+end
+
+local function check_dynamic_conf(cfg, ev_base)
+ local function redis_load_cb(redis_err, data)
+ if redis_err then
+ rspamd_logger.errx(cfg, "cannot read dynamic conf from redis: %s", redis_err)
+ elseif data and type(data) == 'string' then
+ local parser = ucl.parser()
+ local _, err = parser:parse_string(data)
+
+ if err then
+ rspamd_logger.errx(cfg, "cannot load dynamic conf from redis: %s", err)
+ else
+ local d = parser:get_object()
+ apply_dynamic_conf(cfg, d)
+ if cur_settings.updates.has_updates then
+ -- Need to send our updates to Redis
+ update_dynamic_conf(cfg, ev_base, d)
+ else
+ cur_settings.data = d
+ end
+ end
+ end
+ end
+ local function redis_check_cb(err, data)
+ if not err and type(data) == 'string' then
+ local rver = tonumber(data)
+
+ if not cur_settings.version or (rver and rver > cur_settings.version) then
+ rspamd_logger.infox(cfg, "need to load fresh dynamic settings with version %s, local version is %s",
+ rver, cur_settings.version)
+ cur_settings.version = rver
+ rspamd_redis.redis_make_request_taskless(ev_base, cfg, redis_params,
+ settings.redis_key, false,
+ redis_load_cb, 'HGET', { settings.redis_key, 'd' })
+ elseif cur_settings.updates.has_updates then
+ -- Need to send our updates to Redis
+ update_dynamic_conf(cfg, ev_base)
+ end
+ elseif cur_settings.updates.has_updates then
+ -- Need to send our updates to Redis
+ update_dynamic_conf(cfg, ev_base)
+ end
+ end
+
+ rspamd_redis.redis_make_request_taskless(ev_base, cfg, redis_params,
+ settings.redis_key, false,
+ redis_check_cb, 'HGET', { settings.redis_key, 'v' })
+end
+
+local section = rspamd_config:get_all_opt("dynamic_conf")
+if section then
+ redis_params = rspamd_redis.parse_redis_server('dynamic_conf')
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ return
+ end
+
+ for k, v in pairs(section) do
+ settings[k] = v
+ end
+
+ rspamd_config:add_on_load(function(_, ev_base, worker)
+ if worker:is_scanner() then
+ rspamd_config:add_periodic(ev_base, 0.0,
+ function(cfg, _)
+ check_dynamic_conf(cfg, ev_base)
+ return settings.redis_watch_interval
+ end, true)
+ end
+ end)
+end
+
+-- Updates part
+local function add_dynamic_symbol(_, sym, score)
+ local add = false
+ if not cur_settings.data then
+ cur_settings.data = {}
+ end
+
+ if not cur_settings.data.scores then
+ cur_settings.data.scores = {}
+ cur_settings.data.scores[sym] = score
+ add = true
+ else
+ if cur_settings.data.scores[sym] then
+ if cur_settings.data.scores[sym] ~= score then
+ add = true
+ end
+ else
+ cur_settings.data.scores[sym] = score
+ add = true
+ end
+ end
+
+ if add then
+ cur_settings.data.scores[sym] = score
+ table.insert(cur_settings.updates.symbols, sym)
+ cur_settings.updates.has_updates = true
+ end
+
+ return add
+end
+
+local function add_dynamic_action(_, act, score)
+ local add = false
+ if not cur_settings.data then
+ cur_settings.data = {}
+ cur_settings.version = 0
+ end
+
+ if not cur_settings.data.actions then
+ cur_settings.data.actions = {}
+ cur_settings.data.actions[act] = score
+ add = true
+ else
+ if cur_settings.data.actions[act] then
+ if cur_settings.data.actions[act] ~= score then
+ add = true
+ end
+ else
+ cur_settings.data.actions[act] = score
+ add = true
+ end
+ end
+
+ if add then
+ cur_settings.data.actions[act] = score
+ table.insert(cur_settings.updates.actions, act)
+ cur_settings.updates.has_updates = true
+ end
+
+ return add
+end
+
+if section then
+ if redis_params then
+ rspamd_plugins["dynamic_conf"] = {
+ add_symbol = add_dynamic_symbol,
+ add_action = add_dynamic_action,
+ }
+ else
+ lua_util.disable_module(N, "redis")
+ end
+else
+ lua_util.disable_module(N, "config")
+end \ No newline at end of file
diff --git a/src/plugins/lua/elastic.lua b/src/plugins/lua/elastic.lua
new file mode 100644
index 0000000..ccbb7c1
--- /dev/null
+++ b/src/plugins/lua/elastic.lua
@@ -0,0 +1,544 @@
+--[[
+Copyright (c) 2017, Veselin Iordanov
+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 rspamd_http = require "rspamd_http"
+local lua_util = require "lua_util"
+local util = require "rspamd_util"
+local ucl = require "ucl"
+local rspamd_redis = require "lua_redis"
+local upstream_list = require "rspamd_upstream_list"
+local lua_settings = require "lua_settings"
+
+if confighelp then
+ return
+end
+
+local rows = {}
+local nrows = 0
+local failed_sends = 0
+local elastic_template
+local redis_params
+local N = "elastic"
+local E = {}
+local HOSTNAME = util.get_hostname()
+local connect_prefix = 'http://'
+local enabled = true
+local ingest_geoip_type = 'plugins'
+local settings = {
+ limit = 500,
+ index_pattern = 'rspamd-%Y.%m.%d',
+ template_file = rspamd_paths['SHAREDIR'] .. '/elastic/rspamd_template.json',
+ kibana_file = rspamd_paths['SHAREDIR'] .. '/elastic/kibana.json',
+ key_prefix = 'elastic-',
+ expire = 3600,
+ timeout = 5.0,
+ failover = false,
+ import_kibana = false,
+ use_https = false,
+ use_gzip = true,
+ allow_local = false,
+ user = nil,
+ password = nil,
+ no_ssl_verify = false,
+ max_fail = 3,
+ ingest_module = false,
+ elasticsearch_version = 6,
+}
+
+local function read_file(path)
+ local file = io.open(path, "rb")
+ if not file then
+ return nil
+ end
+ local content = file:read "*a"
+ file:close()
+ return content
+end
+
+local function elastic_send_data(task)
+ local es_index = os.date(settings['index_pattern'])
+ local tbl = {}
+ for _, value in pairs(rows) do
+ if settings.elasticsearch_version >= 7 then
+ table.insert(tbl, '{ "index" : { "_index" : "' .. es_index ..
+ '","pipeline": "rspamd-geoip"} }')
+ else
+ table.insert(tbl, '{ "index" : { "_index" : "' .. es_index ..
+ '", "_type" : "_doc" ,"pipeline": "rspamd-geoip"} }')
+ end
+ table.insert(tbl, ucl.to_format(value, 'json-compact'))
+ end
+
+ table.insert(tbl, '') -- For last \n
+
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local ip_addr = upstream:get_addr():to_string(true)
+
+ local push_url = connect_prefix .. ip_addr .. '/' .. es_index .. '/_bulk'
+ local bulk_json = table.concat(tbl, "\n")
+
+ local function http_callback(err, code, _, _)
+ if err then
+ rspamd_logger.infox(task, "cannot push data to elastic backend (%s): %s; failed attempts: %s/%s",
+ push_url, err, failed_sends, settings.max_fail)
+ else
+ if code ~= 200 then
+ rspamd_logger.infox(task,
+ "cannot push data to elastic backend (%s): wrong http code %s (%s); failed attempts: %s/%s",
+ push_url, err, code, failed_sends, settings.max_fail)
+ else
+ lua_util.debugm(N, task, "successfully sent %s (%s bytes) rows to ES",
+ nrows, #bulk_json)
+ end
+ end
+ end
+
+ return rspamd_http.request({
+ url = push_url,
+ headers = {
+ ['Content-Type'] = 'application/x-ndjson',
+ },
+ body = bulk_json,
+ callback = http_callback,
+ task = task,
+ method = 'post',
+ gzip = settings.use_gzip,
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+end
+
+local function get_general_metadata(task)
+ local r = {}
+ local ip_addr = task:get_ip()
+
+ if ip_addr and ip_addr:is_valid() then
+ r.is_local = ip_addr:is_local()
+ r.ip = tostring(ip_addr)
+ else
+ r.ip = '127.0.0.1'
+ end
+
+ r.webmail = false
+ r.sender_ip = 'unknown'
+ local origin = task:get_header('X-Originating-IP')
+ if origin then
+ origin = origin:gsub('%[', ''):gsub('%]', '')
+ local rspamd_ip = require "rspamd_ip"
+ local origin_ip = rspamd_ip.from_string(origin)
+ if origin_ip and origin_ip:is_valid() then
+ r.webmail = true
+ r.sender_ip = origin -- use string here
+ end
+ end
+
+ r.direction = "Inbound"
+ r.user = task:get_user() or 'unknown'
+ r.qid = task:get_queue_id() or 'unknown'
+ r.action = task:get_metric_action()
+ r.rspamd_server = HOSTNAME
+ if r.user ~= 'unknown' then
+ r.direction = "Outbound"
+ end
+ local s = task:get_metric_score()[1]
+ r.score = s
+
+ local rcpt = task:get_recipients('smtp')
+ if rcpt then
+ local l = {}
+ for _, a in ipairs(rcpt) do
+ table.insert(l, a['addr'])
+ end
+ r.rcpt = l
+ else
+ r.rcpt = 'unknown'
+ end
+
+ local from = task:get_from { 'smtp', 'orig' }
+ if ((from or E)[1] or E).addr then
+ r.from = from[1].addr
+ else
+ r.from = 'unknown'
+ end
+
+ local mime_from = task:get_from { 'mime', 'orig' }
+ if ((mime_from or E)[1] or E).addr then
+ r.mime_from = mime_from[1].addr
+ else
+ r.mime_from = 'unknown'
+ end
+
+ local syminf = task:get_symbols_all()
+ r.symbols = syminf
+ r.asn = {}
+ local pool = task:get_mempool()
+ r.asn.country = pool:get_variable("country") or 'unknown'
+ r.asn.asn = pool:get_variable("asn") or 0
+ r.asn.ipnet = pool:get_variable("ipnet") or 'unknown'
+
+ local function process_header(name)
+ local hdr = task:get_header_full(name)
+ if hdr then
+ local l = {}
+ for _, h in ipairs(hdr) do
+ table.insert(l, h.decoded)
+ end
+ return l
+ else
+ return 'unknown'
+ end
+ end
+
+ r.header_from = process_header('from')
+ r.header_to = process_header('to')
+ r.header_subject = process_header('subject')
+ r.header_date = process_header('date')
+ r.message_id = task:get_message_id()
+ local hname = task:get_hostname() or 'unknown'
+ r.hostname = hname
+
+ local settings_id = task:get_settings_id()
+
+ if settings_id then
+ -- Convert to string
+ settings_id = lua_settings.settings_by_id(settings_id)
+
+ if settings_id then
+ settings_id = settings_id.name
+ end
+ end
+
+ if not settings_id then
+ settings_id = ''
+ end
+
+ r.settings_id = settings_id
+
+ local scan_real = task:get_scan_time()
+ scan_real = math.floor(scan_real * 1000)
+ if scan_real < 0 then
+ rspamd_logger.messagex(task,
+ 'clock skew detected for message: %s ms real scan time (reset to 0)',
+ scan_real)
+ scan_real = 0
+ end
+
+ r.scan_time = scan_real
+
+ return r
+end
+
+local function elastic_collect(task)
+ if not enabled then
+ return
+ end
+ if task:has_flag('skip') then
+ return
+ end
+ if not settings.allow_local and lua_util.is_rspamc_or_controller(task) then
+ return
+ end
+
+ local row = { ['rspamd_meta'] = get_general_metadata(task),
+ ['@timestamp'] = tostring(util.get_time() * 1000) }
+ table.insert(rows, row)
+ nrows = nrows + 1
+ if nrows > settings['limit'] then
+ lua_util.debugm(N, task, 'send elastic search rows: %s', nrows)
+ if elastic_send_data(task) then
+ nrows = 0
+ rows = {}
+ failed_sends = 0;
+ else
+ failed_sends = failed_sends + 1
+
+ if failed_sends > settings.max_fail then
+ rspamd_logger.errx(task, 'cannot send %s rows to ES %s times, stop trying',
+ nrows, failed_sends)
+ nrows = 0
+ rows = {}
+ failed_sends = 0;
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('elastic')
+
+local function check_elastic_server(cfg, ev_base, _)
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local ip_addr = upstream:get_addr():to_string(true)
+ local plugins_url = connect_prefix .. ip_addr .. '/_nodes/' .. ingest_geoip_type
+ local function http_callback(err, code, body, _)
+ if code == 200 then
+ local parser = ucl.parser()
+ local res, ucl_err = parser:parse_string(body)
+ if not res then
+ rspamd_logger.infox(rspamd_config, 'failed to parse reply from %s: %s',
+ plugins_url, ucl_err)
+ enabled = false;
+ return
+ end
+ local obj = parser:get_object()
+ for node, value in pairs(obj['nodes']) do
+ local plugin_found = false
+ for _, plugin in pairs(value['plugins']) do
+ if plugin['name'] == 'ingest-geoip' then
+ plugin_found = true
+ lua_util.debugm(N, "ingest-geoip plugin has been found")
+ end
+ end
+ if not plugin_found then
+ rspamd_logger.infox(rspamd_config,
+ 'Unable to find ingest-geoip on %1 node, disabling module', node)
+ enabled = false
+ return
+ end
+ end
+ else
+ rspamd_logger.errx('cannot get plugins from %s: %s(%s) (%s)', plugins_url,
+ err, code, body)
+ enabled = false
+ end
+ end
+ rspamd_http.request({
+ url = plugins_url,
+ ev_base = ev_base,
+ config = cfg,
+ method = 'get',
+ callback = http_callback,
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+end
+
+-- import ingest pipeline and kibana dashboard/visualization
+local function initial_setup(cfg, ev_base, worker)
+ if not worker:is_primary_controller() then
+ return
+ end
+
+ local upstream = settings.upstream:get_upstream_round_robin()
+ local ip_addr = upstream:get_addr():to_string(true)
+
+ local function push_kibana_template()
+ -- add kibana dashboard and visualizations
+ if settings['import_kibana'] then
+ local kibana_mappings = read_file(settings['kibana_file'])
+ if kibana_mappings then
+ local parser = ucl.parser()
+ local res, parser_err = parser:parse_string(kibana_mappings)
+ if not res then
+ rspamd_logger.infox(rspamd_config, 'kibana template cannot be parsed: %s',
+ parser_err)
+ enabled = false
+
+ return
+ end
+ local obj = parser:get_object()
+ local tbl = {}
+ for _, item in ipairs(obj) do
+ table.insert(tbl, '{ "index" : { "_index" : ".kibana", "_type" : "doc" ,"_id": "' ..
+ item['_type'] .. ':' .. item["_id"] .. '"} }')
+ table.insert(tbl, ucl.to_format(item['_source'], 'json-compact'))
+ end
+ table.insert(tbl, '') -- For last \n
+
+ local kibana_url = connect_prefix .. ip_addr .. '/.kibana/_bulk'
+ local function kibana_template_callback(err, code, body, _)
+ if code ~= 200 then
+ rspamd_logger.errx('cannot put template to %s: %s(%s) (%s)', kibana_url,
+ err, code, body)
+ enabled = false
+ else
+ lua_util.debugm(N, 'pushed kibana template: %s', body)
+ end
+ end
+
+ rspamd_http.request({
+ url = kibana_url,
+ ev_base = ev_base,
+ config = cfg,
+ headers = {
+ ['Content-Type'] = 'application/x-ndjson',
+ },
+ body = table.concat(tbl, "\n"),
+ method = 'post',
+ gzip = settings.use_gzip,
+ callback = kibana_template_callback,
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+ else
+ rspamd_logger.infox(rspamd_config, 'kibana template file %s not found', settings['kibana_file'])
+ end
+ end
+ end
+
+ if enabled then
+ -- create ingest pipeline
+ local geoip_url = connect_prefix .. ip_addr .. '/_ingest/pipeline/rspamd-geoip'
+ local function geoip_cb(err, code, body, _)
+ if code ~= 200 then
+ rspamd_logger.errx('cannot get data from %s: %s(%s) (%s)',
+ geoip_url, err, code, body)
+ enabled = false
+ end
+ end
+ local template = {
+ description = "Add geoip info for rspamd",
+ processors = {
+ {
+ geoip = {
+ field = "rspamd_meta.ip",
+ target_field = "rspamd_meta.geoip"
+ }
+ }
+ }
+ }
+ rspamd_http.request({
+ url = geoip_url,
+ ev_base = ev_base,
+ config = cfg,
+ callback = geoip_cb,
+ headers = {
+ ['Content-Type'] = 'application/json',
+ },
+ gzip = settings.use_gzip,
+ body = ucl.to_format(template, 'json-compact'),
+ method = 'put',
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+ -- create template mappings if not exist
+ local template_url = connect_prefix .. ip_addr .. '/_template/rspamd'
+ local function http_template_put_callback(err, code, body, _)
+ if code ~= 200 then
+ rspamd_logger.errx('cannot put template to %s: %s(%s) (%s)',
+ template_url, err, code, body)
+ enabled = false
+ else
+ lua_util.debugm(N, 'pushed rspamd template: %s', body)
+ push_kibana_template()
+ end
+ end
+ local function http_template_exist_callback(_, code, _, _)
+ if code ~= 200 then
+ rspamd_http.request({
+ url = template_url,
+ ev_base = ev_base,
+ config = cfg,
+ body = elastic_template,
+ method = 'put',
+ headers = {
+ ['Content-Type'] = 'application/json',
+ },
+ gzip = settings.use_gzip,
+ callback = http_template_put_callback,
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+ else
+ push_kibana_template()
+ end
+ end
+
+ rspamd_http.request({
+ url = template_url,
+ ev_base = ev_base,
+ config = cfg,
+ method = 'head',
+ callback = http_template_exist_callback,
+ no_ssl_verify = settings.no_ssl_verify,
+ user = settings.user,
+ password = settings.password,
+ timeout = settings.timeout,
+ })
+
+ end
+end
+
+redis_params = rspamd_redis.parse_redis_server('elastic')
+
+if redis_params and opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+
+ if not settings['server'] and not settings['servers'] then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "config")
+ else
+ if settings.use_https then
+ connect_prefix = 'https://'
+ end
+
+ if settings.ingest_module then
+ ingest_geoip_type = 'modules'
+ end
+
+ settings.upstream = upstream_list.create(rspamd_config,
+ settings['server'] or settings['servers'], 9200)
+
+ if not settings.upstream then
+ rspamd_logger.errx('cannot parse elastic address: %s',
+ settings['server'] or settings['servers'])
+ lua_util.disable_module(N, "config")
+ return
+ end
+ if not settings['template_file'] then
+ rspamd_logger.infox(rspamd_config, 'elastic template_file is required, disabling module')
+ lua_util.disable_module(N, "config")
+ return
+ end
+
+ elastic_template = read_file(settings['template_file']);
+ if not elastic_template then
+ rspamd_logger.infox(rspamd_config, 'elastic unable to read %s, disabling module',
+ settings['template_file'])
+ lua_util.disable_module(N, "config")
+ return
+ end
+
+ rspamd_config:register_symbol({
+ name = 'ELASTIC_COLLECT',
+ type = 'idempotent',
+ callback = elastic_collect,
+ flags = 'empty,explicit_disable,ignore_passthrough',
+ augmentations = { string.format("timeout=%f", settings.timeout) },
+ })
+
+ rspamd_config:add_on_load(function(cfg, ev_base, worker)
+ if worker:is_scanner() then
+ check_elastic_server(cfg, ev_base, worker) -- check for elasticsearch requirements
+ initial_setup(cfg, ev_base, worker) -- import mappings pipeline and visualizations
+ end
+ end)
+ end
+
+end
diff --git a/src/plugins/lua/emails.lua b/src/plugins/lua/emails.lua
new file mode 100644
index 0000000..5f25e69
--- /dev/null
+++ b/src/plugins/lua/emails.lua
@@ -0,0 +1,4 @@
+-- This module is deprecated and must not be used.
+-- This file serves as a tombstone to prevent old emails to be loaded
+
+return \ No newline at end of file
diff --git a/src/plugins/lua/external_relay.lua b/src/plugins/lua/external_relay.lua
new file mode 100644
index 0000000..3660f92
--- /dev/null
+++ b/src/plugins/lua/external_relay.lua
@@ -0,0 +1,285 @@
+--[[
+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.
+]]--
+
+--[[
+external_relay plugin - sets IP/hostname from Received headers
+]]--
+
+if confighelp then
+ return
+end
+
+local lua_maps = require "lua_maps"
+local lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local ts = require("tableshape").types
+
+local E = {}
+local N = "external_relay"
+
+local settings = {
+ rules = {},
+}
+
+local config_schema = ts.shape {
+ enabled = ts.boolean:is_optional(),
+ rules = ts.map_of(
+ ts.string, ts.one_of {
+ ts.shape {
+ priority = ts.number:is_optional(),
+ strategy = 'authenticated',
+ symbol = ts.string:is_optional(),
+ user_map = lua_maps.map_schema:is_optional(),
+ },
+ ts.shape {
+ count = ts.number,
+ priority = ts.number:is_optional(),
+ strategy = 'count',
+ symbol = ts.string:is_optional(),
+ },
+ ts.shape {
+ priority = ts.number:is_optional(),
+ strategy = 'local',
+ symbol = ts.string:is_optional(),
+ },
+ ts.shape {
+ hostname_map = lua_maps.map_schema,
+ priority = ts.number:is_optional(),
+ strategy = 'hostname_map',
+ symbol = ts.string:is_optional(),
+ },
+ ts.shape {
+ ip_map = lua_maps.map_schema,
+ priority = ts.number:is_optional(),
+ strategy = 'ip_map',
+ symbol = ts.string:is_optional(),
+ },
+ }
+ ),
+}
+
+local function set_from_rcvd(task, rcvd)
+ local rcvd_ip = rcvd.real_ip
+ if not (rcvd_ip and rcvd_ip:is_valid()) then
+ rspamd_logger.errx(task, 'no IP in header: %s', rcvd)
+ return
+ end
+ task:set_from_ip(rcvd_ip)
+ if rcvd.from_hostname then
+ task:set_hostname(rcvd.from_hostname)
+ task:set_helo(rcvd.from_hostname) -- use fake value for HELO
+ else
+ rspamd_logger.warnx(task, "couldn't get hostname from headers")
+ local ipstr = string.format('[%s]', rcvd_ip)
+ task:set_hostname(ipstr) -- returns nil from task:get_hostname()
+ task:set_helo(ipstr)
+ end
+ return true
+end
+
+local strategies = {}
+
+strategies.authenticated = function(rule)
+ local user_map
+ if rule.user_map then
+ user_map = lua_maps.map_add_from_ucl(rule.user_map, 'set', 'external relay usernames')
+ if not user_map then
+ rspamd_logger.errx(rspamd_config, "couldn't add map %s; won't register symbol %s",
+ rule.user_map, rule.symbol)
+ return
+ end
+ end
+
+ return function(task)
+ local user = task:get_user()
+ if not user then
+ lua_util.debugm(N, task, 'sender is unauthenticated')
+ return
+ end
+ if user_map then
+ if not user_map:get_key(user) then
+ lua_util.debugm(N, task, 'sender (%s) is not in user_map', user)
+ return
+ end
+ end
+
+ local rcvd_hdrs = task:get_received_headers()
+ -- Try find end of authentication chain
+ for _, rcvd in ipairs(rcvd_hdrs) do
+ if not rcvd.flags.authenticated then
+ -- Found unauthenticated hop, use this header
+ return set_from_rcvd(task, rcvd)
+ end
+ end
+
+ rspamd_logger.errx(task, 'found nothing useful in Received headers')
+ end
+end
+
+strategies.count = function(rule)
+ return function(task)
+ local rcvd_hdrs = task:get_received_headers()
+ -- Reduce count by 1 if artificial header is present
+ local hdr_count
+ if ((rcvd_hdrs[1] or E).flags or E).artificial then
+ hdr_count = rule.count - 1
+ else
+ hdr_count = rule.count
+ end
+
+ local rcvd = rcvd_hdrs[hdr_count]
+ if not rcvd then
+ rspamd_logger.errx(task, 'found no received header #%s', hdr_count)
+ return
+ end
+
+ return set_from_rcvd(task, rcvd)
+ end
+end
+
+strategies.hostname_map = function(rule)
+ local hostname_map = lua_maps.map_add_from_ucl(rule.hostname_map, 'map', 'external relay hostnames')
+ if not hostname_map then
+ rspamd_logger.errx(rspamd_config, "couldn't add map %s; won't register symbol %s",
+ rule.hostname_map, rule.symbol)
+ return
+ end
+
+ return function(task)
+ local from_hn = task:get_hostname()
+ if not from_hn then
+ lua_util.debugm(N, task, 'sending hostname is missing')
+ return
+ end
+
+ if not hostname_map:get_key(from_hn) then
+ lua_util.debugm(N, task, 'sender\'s hostname (%s) is not a relay', from_hn)
+ return
+ end
+
+ local rcvd_hdrs = task:get_received_headers()
+ -- Try find sending hostname in Received headers
+ for _, rcvd in ipairs(rcvd_hdrs) do
+ if rcvd.by_hostname == from_hn and rcvd.real_ip then
+ if not hostname_map:get_key(rcvd.from_hostname) then
+ -- Remote hostname is not another relay, use this header
+ return set_from_rcvd(task, rcvd)
+ else
+ -- Keep checking with new hostname
+ from_hn = rcvd.from_hostname
+ end
+ end
+ end
+
+ rspamd_logger.errx(task, 'found nothing useful in Received headers')
+ end
+end
+
+strategies.ip_map = function(rule)
+ local ip_map = lua_maps.map_add_from_ucl(rule.ip_map, 'radix', 'external relay IPs')
+ if not ip_map then
+ rspamd_logger.errx(rspamd_config, "couldn't add map %s; won't register symbol %s",
+ rule.ip_map, rule.symbol)
+ return
+ end
+
+ return function(task)
+ local from_ip = task:get_from_ip()
+ if not (from_ip and from_ip:is_valid()) then
+ lua_util.debugm(N, task, 'sender\'s IP is missing')
+ return
+ end
+
+ if not ip_map:get_key(from_ip) then
+ lua_util.debugm(N, task, 'sender\'s ip (%s) is not a relay', from_ip)
+ return
+ end
+
+ local rcvd_hdrs = task:get_received_headers()
+ local num_rcvd = #rcvd_hdrs
+ -- Try find sending IP in Received headers
+ for i, rcvd in ipairs(rcvd_hdrs) do
+ if rcvd.real_ip then
+ local rcvd_ip = rcvd.real_ip
+ if rcvd_ip:is_valid() and (not ip_map:get_key(rcvd_ip) or i == num_rcvd) then
+ return set_from_rcvd(task, rcvd)
+ end
+ end
+ end
+
+ rspamd_logger.errx(task, 'found nothing useful in Received headers')
+ end
+end
+
+strategies['local'] = function(rule)
+ return function(task)
+ local from_ip = task:get_from_ip()
+ if not from_ip then
+ lua_util.debugm(N, task, 'sending IP is missing')
+ return
+ end
+
+ if not from_ip:is_local() then
+ lua_util.debugm(N, task, 'sending IP (%s) is non-local', from_ip)
+ return
+ end
+
+ local rcvd_hdrs = task:get_received_headers()
+ local num_rcvd = #rcvd_hdrs
+ -- Try find first non-local IP in Received headers
+ for i, rcvd in ipairs(rcvd_hdrs) do
+ if rcvd.real_ip then
+ local rcvd_ip = rcvd.real_ip
+ if rcvd_ip and rcvd_ip:is_valid() and (not rcvd_ip:is_local() or i == num_rcvd) then
+ return set_from_rcvd(task, rcvd)
+ end
+ end
+ end
+
+ rspamd_logger.errx(task, 'found nothing useful in Received headers')
+ end
+end
+
+local opts = rspamd_config:get_all_opt(N)
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+
+ local ok, schema_err = config_schema:transform(settings)
+ if not ok then
+ rspamd_logger.errx(rspamd_config, 'config schema error: %s', schema_err)
+ lua_util.disable_module(N, "config")
+ return
+ end
+
+ for k, rule in pairs(settings.rules) do
+
+ if not rule.symbol then
+ rule.symbol = k
+ end
+
+ local cb = strategies[rule.strategy](rule)
+
+ if cb then
+ rspamd_config:register_symbol({
+ name = rule.symbol,
+ type = 'prefilter',
+ priority = rule.priority or lua_util.symbols_priorities.top + 1,
+ group = N,
+ callback = cb,
+ })
+ end
+ end
+end
diff --git a/src/plugins/lua/external_services.lua b/src/plugins/lua/external_services.lua
new file mode 100644
index 0000000..e299d9f
--- /dev/null
+++ b/src/plugins/lua/external_services.lua
@@ -0,0 +1,408 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+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.
+]] --
+
+local rspamd_logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+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"
+
+if confighelp then
+ rspamd_config:add_example(nil, 'external_services',
+ "Check messages using external services (e.g. OEM AS engines, DCC, Pyzor etc)",
+ [[
+ 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: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";
+ }
+ }
+ ]])
+ return
+end
+
+local function add_scanner_rule(sym, opts)
+ if not opts.type then
+ rspamd_logger.errx(rspamd_config, 'unknown type for external scanner rule %s', sym)
+ return nil
+ end
+
+ local cfg = lua_scanners[opts.type]
+
+ if not cfg then
+ rspamd_logger.errx(rspamd_config, 'unknown external scanner type: %s',
+ opts.type)
+ return nil
+ end
+
+ local rule = cfg.configure(opts)
+
+ if not rule then
+ rspamd_logger.errx(rspamd_config, 'cannot configure %s for %s',
+ opts.type, rule.symbol or sym:upper())
+ return nil
+ end
+
+ rule.type = opts.type
+ -- Fill missing symbols
+ if not rule.symbol then
+ rule.symbol = sym:upper()
+ end
+ if not rule.symbol_fail then
+ rule.symbol_fail = rule.symbol .. '_FAIL'
+ end
+ if not rule.symbol_encrypted then
+ rule.symbol_encrypted = rule.symbol .. '_ENCRYPTED'
+ end
+ if not rule.symbol_macro then
+ rule.symbol_macro = rule.symbol .. '_MACRO'
+ end
+
+ rule.redis_params = redis_params
+
+ lua_redis.register_prefix(rule.prefix .. '_*', N,
+ string.format('External services 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
+
+ local function scan_cb(task)
+ 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, p)
+ end
+ end, common.check_parts_match(task, rule))
+
+ else
+ cfg.check(task, task:get_content(), task:get_digest(), rule, nil)
+ end
+ end
+
+ rspamd_logger.infox(rspamd_config, 'registered external services rule: symbol %s; type %s',
+ rule.symbol, rule.type)
+
+ return scan_cb, rule
+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' and m.servers then
+ if not m.type then
+ m.type = k
+ end
+ if not m.name then
+ m.name = k
+ end
+ local cb, nrule = add_scanner_rule(k, m)
+
+ if not cb then
+ rspamd_logger.errx(rspamd_config, 'cannot add rule: "' .. k .. '"')
+ else
+ m = nrule
+
+ 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 external_services module
+ table.insert(t.augmentations, string.format("timeout=%f", m.timeout))
+ end
+
+ local id = rspamd_config:register_symbol(t)
+
+ if m.symbol_fail then
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_fail'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+
+ if m.symbol_encrypted then
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_encrypted'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ if m.symbol_macro then
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = m['symbol_macro'],
+ parent = id,
+ score = 0.0,
+ group = N
+ })
+ end
+ 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,
+ })
+ 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,
+ })
+ 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.symbols then
+ local function reg_symbols(tbl)
+ for _, sym in pairs(tbl) do
+ if type(sym) == 'string' then
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ group = N
+ })
+ elseif type(sym) == 'table' then
+ if sym.symbol then
+ 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(m.symbols)
+ end
+
+ if m['score'] then
+ -- Register metric symbol
+ local description = 'external services 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
+ })
+ end
+
+ -- Add preloads if a module requires that
+ if type(m.preloads) == 'table' then
+ for _, preload in ipairs(m.preloads) do
+ rspamd_config:add_on_load(function(cfg, ev_base, worker)
+ preload(m, cfg, ev_base, worker)
+ end)
+ end
+ end
+ end
+ end
+ end
+
+ if not has_valid then
+ lua_util.disable_module(N, 'config')
+ end
+end
diff --git a/src/plugins/lua/force_actions.lua b/src/plugins/lua/force_actions.lua
new file mode 100644
index 0000000..4a87cf5
--- /dev/null
+++ b/src/plugins/lua/force_actions.lua
@@ -0,0 +1,227 @@
+--[[
+Copyright (c) 2017, Andrew Lewis <nerf@judo.za.org>
+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.
+]]--
+
+-- A plugin that forces actions
+
+if confighelp then
+ return
+end
+
+local E = {}
+local N = 'force_actions'
+local selector_cache = {}
+
+local fun = require "fun"
+local lua_util = require "lua_util"
+local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash"
+local rspamd_expression = require "rspamd_expression"
+local rspamd_logger = require "rspamd_logger"
+local lua_selectors = require "lua_selectors"
+
+-- Params table fields:
+-- expr, act, pool, message, subject, raction, honor, limit, flags
+local function gen_cb(params)
+
+ local function parse_atom(str)
+ local atom = table.concat(fun.totable(fun.take_while(function(c)
+ if string.find(', \t()><+!|&\n', c, 1, true) then
+ return false
+ end
+ return true
+ end, fun.iter(str))), '')
+ return atom
+ end
+
+ local function process_atom(atom, task)
+ local f_ret = task:has_symbol(atom)
+ if f_ret then
+ f_ret = math.abs(task:get_symbol(atom)[1].score)
+ if f_ret < 0.001 then
+ -- Adjust some low score to distinguish from pure zero
+ f_ret = 0.001
+ end
+ return f_ret
+ end
+ return 0
+ end
+
+ local e, err = rspamd_expression.create(params.expr, { parse_atom, process_atom }, params.pool)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'Couldnt create expression [%1]: %2', params.expr, err)
+ return
+ end
+
+ return function(task)
+
+ local function process_message_selectors(repl, selector_expr)
+ -- create/reuse selector to extract value for this placeholder
+ local selector = selector_cache[selector_expr]
+ if not selector then
+ selector_cache[selector_expr] = lua_selectors.create_selector_closure(rspamd_config, selector_expr, '', true)
+ selector = selector_cache[selector_expr]
+ if not selector then
+ rspamd_logger.errx(task, 'could not create selector [%1]', selector_expr)
+ return "((could not create selector))"
+ end
+ end
+ local extracted = selector(task)
+ if extracted then
+ if type(extracted) == 'table' then
+ extracted = table.concat(extracted, ',')
+ end
+ else
+ rspamd_logger.errx(task, 'could not extract value with selector [%1]', selector_expr)
+ extracted = '((error extracting value))'
+ end
+ return extracted
+ end
+
+ local cact = task:get_metric_action()
+ if not params.message and not params.subject and params.act and cact == params.act then
+ return false
+ end
+ if params.honor and params.honor[cact] then
+ return false
+ elseif params.raction and not params.raction[cact] then
+ return false
+ end
+
+ local ret = e:process(task)
+ lua_util.debugm(N, task, "expression %s returned %s", params.expr, ret)
+ if (not params.limit and ret > 0) or (ret > (params.limit or 0)) then
+ if params.subject then
+ task:set_metric_subject(params.subject)
+ end
+
+ local flags = params.flags or ""
+
+ if type(params.message) == 'string' then
+ -- process selector expressions in the message
+ local message = string.gsub(params.message, '(${(.-)})', process_message_selectors)
+ task:set_pre_result { action = params.act, message = message, module = N, flags = flags }
+ else
+ task:set_pre_result { action = params.act, module = N, flags = flags }
+ end
+ return true, params.act
+ end
+
+ end, e:atoms()
+
+end
+
+local function configure_module()
+ local opts = rspamd_config:get_all_opt(N)
+ if not opts then
+ return false
+ end
+ if type(opts.actions) == 'table' then
+ rspamd_logger.warnx(rspamd_config, 'Processing legacy config')
+ for action, expressions in pairs(opts.actions) do
+ if type(expressions) == 'table' then
+ for _, expr in ipairs(expressions) do
+ local message, subject
+ if type(expr) == 'table' then
+ subject = expr[3]
+ message = expr[2]
+ expr = expr[1]
+ else
+ message = (opts.messages or E)[expr]
+ end
+ if type(expr) == 'string' then
+ -- expr, act, pool, message, subject, raction, honor, limit, flags
+ local cb, atoms = gen_cb { expr = expr,
+ act = action,
+ pool = rspamd_config:get_mempool(),
+ message = message,
+ subject = subject }
+ if cb and atoms then
+ local h = rspamd_cryptobox_hash.create()
+ h:update(expr)
+ local name = 'FORCE_ACTION_' .. string.upper(string.sub(h:hex(), 1, 12))
+ rspamd_config:register_symbol({
+ type = 'normal',
+ name = name,
+ callback = cb,
+ flags = 'empty',
+ group = N,
+ })
+ for _, a in ipairs(atoms) do
+ rspamd_config:register_dependency(name, a)
+ end
+ rspamd_logger.infox(rspamd_config, 'Registered symbol %1 <%2> with dependencies [%3]',
+ name, expr, table.concat(atoms, ','))
+ end
+ end
+ end
+ end
+ end
+ elseif type(opts.rules) == 'table' then
+ for name, sett in pairs(opts.rules) do
+ local action = sett.action
+ local expr = sett.expression
+
+ if action and expr then
+ local flags = {}
+ if sett.least then
+ table.insert(flags, "least")
+ end
+ if sett.process_all then
+ table.insert(flags, "process_all")
+ end
+ local raction = lua_util.list_to_hash(sett.require_action)
+ local honor = lua_util.list_to_hash(sett.honor_action)
+ local cb, atoms = gen_cb { expr = expr,
+ act = action,
+ pool = rspamd_config:get_mempool(),
+ message = sett.message,
+ subject = sett.subject,
+ raction = raction,
+ honor = honor,
+ limit = sett.limit,
+ flags = table.concat(flags, ',') }
+ if cb and atoms then
+ local t = {}
+ if (raction or honor) then
+ t.type = 'postfilter'
+ t.priority = lua_util.symbols_priorities.high
+ else
+ t.type = 'normal'
+ if not sett.least then
+ t.augmentations = { 'passthrough', 'important' }
+ end
+ end
+ t.name = 'FORCE_ACTION_' .. name
+ t.callback = cb
+ t.flags = 'empty, ignore_passthrough'
+ t.group = N
+ rspamd_config:register_symbol(t)
+ if t.type == 'normal' then
+ for _, a in ipairs(atoms) do
+ rspamd_config:register_dependency(t.name, a)
+ end
+ rspamd_logger.infox(rspamd_config, 'Registered symbol %1 <%2> with dependencies [%3]',
+ t.name, expr, table.concat(atoms, ','))
+ else
+ rspamd_logger.infox(rspamd_config, 'Registered symbol %1 <%2> as postfilter', t.name, expr)
+ end
+ end
+ end
+ end
+ end
+end
+
+configure_module()
diff --git a/src/plugins/lua/forged_recipients.lua b/src/plugins/lua/forged_recipients.lua
new file mode 100644
index 0000000..0d51db3
--- /dev/null
+++ b/src/plugins/lua/forged_recipients.lua
@@ -0,0 +1,183 @@
+--[[
+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.
+]]--
+
+-- Plugin for comparing smtp dialog recipients and sender with recipients and sender
+-- in mime headers
+
+if confighelp then
+ rspamd_config:add_example(nil, 'forged_recipients',
+ "Check forged recipients and senders (e.g. mime and smtp recipients mismatch)",
+ [[
+ forged_recipients {
+ symbol_sender = "FORGED_SENDER"; # Symbol for a forged sender
+ symbol_rcpt = "FORGED_RECIPIENTS"; # Symbol for a forged recipients
+ }
+ ]])
+end
+
+local symbol_rcpt = 'FORGED_RECIPIENTS'
+local symbol_sender = 'FORGED_SENDER'
+local rspamd_util = require "rspamd_util"
+
+local E = {}
+
+local function check_forged_headers(task)
+ local auser = task:get_user()
+ local delivered_to = task:get_header('Delivered-To')
+ local smtp_rcpts = task:get_recipients(1)
+ local smtp_from = task:get_from(1)
+
+ if not smtp_rcpts then
+ return
+ end
+ if #smtp_rcpts == 0 then
+ return
+ end
+
+ local mime_rcpts = task:get_recipients({ 'mime', 'orig' })
+
+ if not mime_rcpts then
+ return
+ elseif #mime_rcpts == 0 then
+ return
+ end
+
+ -- Find pair for each smtp recipient in To or Cc headers
+ if #smtp_rcpts > 100 or #mime_rcpts > 100 then
+ -- Trim array, suggested by Anton Yuzhaninov
+ smtp_rcpts[100] = nil
+ mime_rcpts[100] = nil
+ end
+
+ -- map smtp recipient domains to a list of addresses for this domain
+ local smtp_rcpt_domain_map = {}
+ local smtp_rcpt_map = {}
+ for _, smtp_rcpt in ipairs(smtp_rcpts) do
+ local addr = smtp_rcpt.addr
+
+ if addr and addr ~= '' then
+ local dom = string.lower(smtp_rcpt.domain)
+ addr = addr:lower()
+
+ local dom_map = smtp_rcpt_domain_map[dom]
+ if not dom_map then
+ dom_map = {}
+ smtp_rcpt_domain_map[dom] = dom_map
+ end
+
+ dom_map[addr] = smtp_rcpt
+ smtp_rcpt_map[addr] = smtp_rcpt
+
+ if auser and auser == addr then
+ smtp_rcpt.matched = true
+ end
+ if ((smtp_from or E)[1] or E).addr and
+ smtp_from[1]['addr'] == addr then
+ -- allow sender to BCC themselves
+ smtp_rcpt.matched = true
+ end
+ end
+ end
+
+ for _, mime_rcpt in ipairs(mime_rcpts) do
+ if mime_rcpt.addr and mime_rcpt.addr ~= '' then
+ local addr = string.lower(mime_rcpt.addr)
+ local dom = string.lower(mime_rcpt.domain)
+ local matched_smtp_addr = smtp_rcpt_map[addr]
+ if matched_smtp_addr then
+ -- Direct match, go forward
+ matched_smtp_addr.matched = true
+ mime_rcpt.matched = true
+ elseif delivered_to and delivered_to == addr then
+ mime_rcpt.matched = true
+ elseif auser and auser == addr then
+ -- allow user to BCC themselves
+ mime_rcpt.matched = true
+ else
+ local matched_smtp_domain = smtp_rcpt_domain_map[dom]
+
+ if matched_smtp_domain then
+ -- Same domain but another user, it is likely okay due to aliases substitution
+ mime_rcpt.matched = true
+ -- Special field
+ matched_smtp_domain._seen_mime_domain = true
+ end
+ end
+ end
+ end
+
+ -- Now go through all lists one more time and find unmatched stuff
+ local opts = {}
+ local seen_mime_unmatched = false
+ local seen_smtp_unmatched = false
+ for _, mime_rcpt in ipairs(mime_rcpts) do
+ if not mime_rcpt.matched then
+ seen_mime_unmatched = true
+ table.insert(opts, 'm:' .. mime_rcpt.addr)
+ end
+ end
+ for _, smtp_rcpt in ipairs(smtp_rcpts) do
+ if not smtp_rcpt.matched then
+ if not smtp_rcpt_domain_map[smtp_rcpt.domain:lower()]._seen_mime_domain then
+ seen_smtp_unmatched = true
+ table.insert(opts, 's:' .. smtp_rcpt.addr)
+ end
+ end
+ end
+
+ if seen_smtp_unmatched and seen_mime_unmatched then
+ task:insert_result(symbol_rcpt, 1.0, opts)
+ end
+
+ -- Check sender
+ if smtp_from and smtp_from[1] and smtp_from[1]['addr'] ~= '' then
+ local mime_from = task:get_from(2)
+ if not mime_from or not mime_from[1] or
+ not rspamd_util.strequal_caseless_utf8(mime_from[1]['addr'], smtp_from[1]['addr']) then
+ task:insert_result(symbol_sender, 1, ((mime_from or E)[1] or E).addr or '', smtp_from[1].addr)
+ end
+ end
+end
+
+-- Configuration
+local opts = rspamd_config:get_all_opt('forged_recipients')
+if opts then
+ if opts['symbol_rcpt'] or opts['symbol_sender'] then
+ local id = rspamd_config:register_symbol({
+ name = 'FORGED_CALLBACK',
+ callback = check_forged_headers,
+ type = 'callback',
+ group = 'headers',
+ score = 0.0,
+ })
+ if opts['symbol_rcpt'] then
+ symbol_rcpt = opts['symbol_rcpt']
+ rspamd_config:register_symbol({
+ name = symbol_rcpt,
+ type = 'virtual',
+ parent = id,
+ })
+ end
+ if opts['symbol_sender'] then
+ symbol_sender = opts['symbol_sender']
+ rspamd_config:register_symbol({
+ name = symbol_sender,
+ type = 'virtual',
+ parent = id,
+ })
+ end
+ end
+end
diff --git a/src/plugins/lua/fuzzy_collect.lua b/src/plugins/lua/fuzzy_collect.lua
new file mode 100644
index 0000000..132ace9
--- /dev/null
+++ b/src/plugins/lua/fuzzy_collect.lua
@@ -0,0 +1,193 @@
+--[[
+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.
+]] --
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local rspamd_http = require "rspamd_http"
+local rspamd_keypairlib = require "rspamd_cryptobox_keypair"
+local rspamd_cryptolib = require "rspamd_cryptobox"
+local fun = require "fun"
+
+local settings = {
+ sync_time = 60.0,
+ saved_cookie = '',
+ timeout = 10.0,
+}
+
+local function send_data_mirror(m, cfg, ev_base, body)
+ local function store_callback(err, _, _, _)
+ if err then
+ rspamd_logger.errx(cfg, 'cannot save data on %(%s): %s', m.server, m.name, err)
+ else
+ rspamd_logger.infox(cfg, 'saved data on %s(%s)', m.server, m.name)
+ end
+ end
+ rspamd_http.request {
+ url = string.format('http://%s//update_v1/%s', m.server, m.name),
+ resolver = cfg:get_resolver(),
+ config = cfg,
+ ev_base = ev_base,
+ timeout = settings.timeout,
+ callback = store_callback,
+ body = body,
+ peer_key = m.pubkey,
+ keypair = m.keypair,
+ }
+end
+
+local function collect_fuzzy_hashes(cfg, ev_base)
+ local function data_callback(err, _, body, _)
+ if not body or err then
+ rspamd_logger.errx(cfg, 'cannot load data: %s', err)
+ else
+ -- Here, we actually copy body once for each mirror
+ fun.each(function(_, v)
+ send_data_mirror(v, cfg, ev_base, body)
+ end,
+ settings.mirrors)
+ end
+ end
+
+ local function cookie_callback(err, _, body, _)
+ if not body or err then
+ rspamd_logger.errx(cfg, 'cannot load cookie: %s', err)
+ else
+ if settings.saved_cookie ~= tostring(body) then
+ settings.saved_cookie = tostring(body)
+ rspamd_logger.infox(cfg, 'received collection cookie %s',
+ tostring(rspamd_util.encode_base32(settings.saved_cookie:sub(1, 6))))
+ local sig = rspamd_cryptolib.sign_memory(settings.sign_keypair,
+ settings.saved_cookie)
+ if not sig then
+ rspamd_logger.info(cfg, 'cannot sign cookie')
+ else
+ rspamd_http.request {
+ url = string.format('http://%s/data', settings.collect_server),
+ resolver = cfg:get_resolver(),
+ config = cfg,
+ ev_base = ev_base,
+ timeout = settings.timeout,
+ callback = data_callback,
+ peer_key = settings.collect_pubkey,
+ headers = {
+ Signature = sig:hex()
+ },
+ opaque_body = true,
+ }
+ end
+ else
+ rspamd_logger.info(cfg, 'cookie has not changed, do not update')
+ end
+ end
+ end
+ rspamd_logger.infox(cfg, 'start fuzzy collection, next sync in %s seconds',
+ settings.sync_time)
+ rspamd_http.request {
+ url = string.format('http://%s/cookie', settings.collect_server),
+ resolver = cfg:get_resolver(),
+ config = cfg,
+ ev_base = ev_base,
+ timeout = settings.timeout,
+ callback = cookie_callback,
+ peer_key = settings.collect_pubkey,
+ }
+
+ return settings.sync_time
+end
+
+local function test_mirror_config(k, m)
+ if not m.server then
+ rspamd_logger.errx(rspamd_config, 'server is missing for the mirror')
+ return false
+ end
+
+ if not m.pubkey then
+ rspamd_logger.errx(rspamd_config, 'pubkey is missing for the mirror')
+ return false
+ end
+
+ if type(k) ~= 'string' and not m.name then
+ rspamd_logger.errx(rspamd_config, 'name is missing for the mirror')
+ return false
+ end
+
+ if not m.keypair then
+ rspamd_logger.errx(rspamd_config, 'keypair is missing for the mirror')
+ return false
+ end
+
+ if not m.name then
+ m.name = k
+ end
+
+ return true
+end
+
+local opts = rspamd_config:get_all_opt('fuzzy_collect')
+
+if opts and type(opts) == 'table' then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+ local sane_config = true
+
+ if not settings['sign_keypair'] then
+ rspamd_logger.errx(rspamd_config, 'sign_keypair is missing')
+ sane_config = false
+ end
+
+ settings['sign_keypair'] = rspamd_keypairlib.create(settings['sign_keypair'])
+ if not settings['sign_keypair'] then
+ rspamd_logger.errx(rspamd_config, 'sign_keypair is invalid')
+ sane_config = false
+ end
+
+ if not settings['collect_server'] then
+ rspamd_logger.errx(rspamd_config, 'collect_server is missing')
+ sane_config = false
+ end
+
+ if not settings['collect_pubkey'] then
+ rspamd_logger.errx(rspamd_config, 'collect_pubkey is missing')
+ sane_config = false
+ end
+
+ if not settings['mirrors'] then
+ rspamd_logger.errx(rspamd_config, 'collect_pubkey is missing')
+ sane_config = false
+ end
+
+ if not fun.all(test_mirror_config, settings['mirrors']) then
+ sane_config = false
+ end
+
+ if sane_config then
+ rspamd_config:add_on_load(function(_, ev_base, worker)
+ if worker:is_primary_controller() then
+ rspamd_config:add_periodic(ev_base, 0.0,
+ function(cfg, _)
+ return collect_fuzzy_hashes(cfg, ev_base)
+ end)
+ end
+ end)
+ else
+ rspamd_logger.errx(rspamd_config, 'module is not configured properly')
+ end
+end
diff --git a/src/plugins/lua/greylist.lua b/src/plugins/lua/greylist.lua
new file mode 100644
index 0000000..6e221b3
--- /dev/null
+++ b/src/plugins/lua/greylist.lua
@@ -0,0 +1,542 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016, Alexey Savelyev <info@homeweb.ru>
+
+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.
+]]--
+
+--[[
+Example domains whitelist config:
+greylist {
+ # Search "example.com" and "mail.example.com" for "mx.out.mail.example.com":
+ whitelist_domains_url = [
+ "$LOCAL_CONFDIR/local.d/maps.d/greylist-whitelist-domains.inc",
+ "${CONFDIR}/maps.d/maillist.inc",
+ "${CONFDIR}/maps.d/redirectors.inc",
+ "${CONFDIR}/maps.d/dmarc_whitelist.inc",
+ "${CONFDIR}/maps.d/spf_dkim_whitelist.inc",
+ "${CONFDIR}/maps.d/surbl-whitelist.inc",
+ "https://maps.rspamd.com/freemail/free.txt.zst"
+ ];
+}
+Example config for exim users:
+greylist {
+ action = "greylist";
+}
+--]]
+
+if confighelp then
+ rspamd_config:add_example(nil, 'greylist',
+ "Performs adaptive greylisting using Redis",
+ [[
+greylist {
+ # Buckets expire (1 day by default)
+ expire = 1d;
+ # Greylisting timeout
+ timeout = 5m;
+ # Redis prefix
+ key_prefix = 'rg';
+ # Use body hash up to this value of bytes for greylisting
+ max_data_len = 10k;
+ # Default greylisting message
+ message = 'Try again later';
+ # Append symbol on greylisting
+ symbol = 'GREYLIST';
+ # Default action change (for Exim use `greylist`)
+ action = 'soft reject';
+ # Skip greylisting if one of the following symbols has been found
+ whitelist_symbols = [];
+ # Mask bits for ipv4
+ ipv4_mask = 19;
+ # Mask bits for ipv6
+ ipv6_mask = 64;
+ # Tell when greylisting is expired (appended to `message`)
+ report_time = false;
+ # Greylist local messages
+ check_local = false;
+ # Greylist messages from authenticated users
+ check_authed = false;
+}
+ ]])
+ return
+end
+
+-- A plugin that implements greylisting using redis
+
+local redis_params
+local whitelisted_ip
+local whitelist_domains_map
+local toint = math.ifloor or math.floor
+local settings = {
+ expire = 86400, -- 1 day by default
+ timeout = 300, -- 5 minutes by default
+ key_prefix = 'rg', -- default hash name
+ max_data_len = 10240, -- default data limit to hash
+ message = 'Try again later', -- default greylisted message
+ symbol = 'GREYLIST',
+ action = 'soft reject', -- default greylisted action
+ whitelist_symbols = {}, -- whitelist when specific symbols have been found
+ ipv4_mask = 19, -- Mask bits for ipv4
+ ipv6_mask = 64, -- Mask bits for ipv6
+ report_time = false, -- Tell when greylisting is expired (appended to `message`)
+ check_local = false,
+ check_authed = false,
+}
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_redis = require "lua_redis"
+local lua_util = require "lua_util"
+local fun = require "fun"
+local hash = require "rspamd_cryptobox_hash"
+local rspamd_lua_utils = require "lua_util"
+local lua_map = require "lua_maps"
+local N = "greylist"
+
+local function data_key(task)
+ local cached = task:get_mempool():get_variable("grey_bodyhash")
+ if cached then
+ return cached
+ end
+
+ local body = task:get_rawbody()
+
+ if not body then
+ return nil
+ end
+
+ local len = body:len()
+ if len > settings['max_data_len'] then
+ len = settings['max_data_len']
+ end
+
+ local h = hash.create()
+ h:update(body, len)
+
+ local b32 = settings['key_prefix'] .. 'b' .. h:base32():sub(1, 20)
+ task:get_mempool():set_variable("grey_bodyhash", b32)
+ return b32
+end
+
+local function envelope_key(task)
+ local cached = task:get_mempool():get_variable("grey_metahash")
+ if cached then
+ return cached
+ end
+
+ local from = task:get_from('smtp')
+ local h = hash.create()
+
+ local addr = '<>'
+ if from and from[1] then
+ addr = from[1]['addr']
+ end
+
+ h:update(addr)
+ local rcpt = task:get_recipients('smtp')
+ if rcpt then
+ table.sort(rcpt, function(r1, r2)
+ return r1['addr'] < r2['addr']
+ end)
+
+ fun.each(function(r)
+ h:update(r['addr'])
+ end, rcpt)
+ end
+
+ local ip = task:get_ip()
+
+ if ip and ip:is_valid() then
+ local s
+ if ip:get_version() == 4 then
+ s = tostring(ip:apply_mask(settings['ipv4_mask']))
+ else
+ s = tostring(ip:apply_mask(settings['ipv6_mask']))
+ end
+ h:update(s)
+ end
+
+ local b32 = settings['key_prefix'] .. 'm' .. h:base32():sub(1, 20)
+ task:get_mempool():set_variable("grey_metahash", b32)
+ return b32
+end
+
+-- Returns pair of booleans: found,greylisted
+local function check_time(task, tm, type, now)
+ local t = tonumber(tm)
+
+ if not t then
+ rspamd_logger.errx(task, 'not a valid number: %s', tm)
+ return false, false
+ end
+
+ if now - t < settings['timeout'] then
+ return true, true
+ else
+ -- We just set variable to pass when in post-filter stage
+ task:get_mempool():set_variable("grey_whitelisted", type)
+
+ return true, false
+ end
+end
+
+local function greylist_message(task, end_time, why)
+ task:insert_result(settings['symbol'], 0.0, 'greylisted', end_time, why)
+
+ if not settings.check_local and rspamd_lua_utils.is_rspamc_or_controller(task) then
+ return
+ end
+
+ if settings.message_func then
+ task:set_pre_result(settings['action'],
+ settings.message_func(task, end_time), N)
+ else
+ local message = settings['message']
+ if settings.report_time then
+ message = string.format("%s: %s", message, end_time)
+ end
+ task:set_pre_result(settings['action'], message, N)
+ end
+
+ task:set_flag('greylisted')
+end
+
+local function greylist_check(task)
+ local ip = task:get_ip()
+
+ if ((not settings.check_authed and task:get_user()) or
+ (not settings.check_local and ip and ip:is_local())) then
+ rspamd_logger.infox(task, "skip greylisting for local networks and/or authorized users");
+ return
+ end
+
+ if ip and ip:is_valid() and whitelisted_ip then
+ if whitelisted_ip:get_key(ip) then
+ -- Do not check whitelisted ip
+ rspamd_logger.infox(task, 'skip greylisting for whitelisted IP')
+ return
+ end
+ end
+
+ local body_key = data_key(task)
+ local meta_key = envelope_key(task)
+ local hash_key = body_key .. meta_key
+
+ local function redis_get_cb(err, data)
+ local ret_body = false
+ local greylisted_body = false
+ local ret_meta = false
+ local greylisted_meta = false
+
+ if data then
+ local end_time_body, end_time_meta
+ local now = rspamd_util.get_time()
+
+ if data[1] and type(data[1]) ~= 'userdata' then
+ local tm = tonumber(data[1]) or now
+ ret_body, greylisted_body = check_time(task, data[1], 'body', now)
+ if greylisted_body then
+ end_time_body = tm + settings['timeout']
+ task:get_mempool():set_variable("grey_greylisted_body",
+ rspamd_util.time_to_string(end_time_body))
+ end
+ end
+
+ if data[2] and type(data[2]) ~= 'userdata' then
+ if not ret_body or greylisted_body then
+ local tm = tonumber(data[2]) or now
+ ret_meta, greylisted_meta = check_time(task, data[2], 'meta', now)
+
+ if greylisted_meta then
+ end_time_meta = tm + settings['timeout']
+ task:get_mempool():set_variable("grey_greylisted_meta",
+ rspamd_util.time_to_string(end_time_meta))
+ end
+ end
+ end
+
+ local how
+ local end_time_str
+
+ if not ret_body and not ret_meta then
+ -- no record found
+ task:get_mempool():set_variable("grey_greylisted", 'true')
+ elseif greylisted_body and greylisted_meta then
+ end_time_str = rspamd_util.time_to_string(
+ math.min(end_time_body, end_time_meta))
+ how = 'meta and body'
+ elseif greylisted_body then
+ end_time_str = rspamd_util.time_to_string(end_time_body)
+ how = 'body only'
+ elseif greylisted_meta then
+ end_time_str = rspamd_util.time_to_string(end_time_meta)
+ how = 'meta only'
+ end
+
+ if how and end_time_str then
+ rspamd_logger.infox(task, 'greylisted until "%s" (%s)',
+ end_time_str, how)
+ greylist_message(task, end_time_str, 'too early')
+ end
+ elseif err then
+ rspamd_logger.errx(task, 'got error while getting greylisting keys: %1', err)
+ return
+ end
+ end
+
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ hash_key, -- hash key
+ false, -- is write
+ redis_get_cb, --callback
+ 'MGET', -- command
+ { body_key, meta_key } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'cannot make redis request to check results')
+ end
+end
+
+local function greylist_set(task)
+ local action = task:get_metric_action()
+ local ip = task:get_ip()
+
+ -- Don't do anything if pre-result has been already set
+ if task:has_pre_result() then
+ return
+ end
+
+ -- Check whitelist_symbols
+ for _, sym in ipairs(settings.whitelist_symbols) do
+ if task:has_symbol(sym) then
+ rspamd_logger.infox(task, 'skip greylisting as we have found symbol %s', sym)
+ if action == 'greylist' then
+ -- We are going to accept message
+ rspamd_logger.infox(task, 'downgrading metric action from "greylist" to "no action"')
+ task:disable_action('greylist')
+ end
+ return
+ end
+ end
+
+ if settings.greylist_min_score then
+ local score = task:get_metric_score('default')[1]
+ if score < settings.greylist_min_score then
+ rspamd_logger.infox(task, 'Score too low - skip greylisting')
+ if action == 'greylist' then
+ -- We are going to accept message
+ rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
+ task:disable_action('greylist')
+ end
+ return
+ end
+ end
+
+ if ((not settings.check_authed and task:get_user()) or
+ (not settings.check_local and ip and ip:is_local())) then
+ if action == 'greylist' then
+ -- We are going to accept message
+ rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
+ task:disable_action('greylist')
+ end
+ return
+ end
+
+ if ip and ip:is_valid() and whitelisted_ip then
+ if whitelisted_ip:get_key(ip) then
+ if action == 'greylist' then
+ -- We are going to accept message
+ rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
+ task:disable_action('greylist')
+ end
+ return
+ end
+ end
+
+ local is_whitelisted = task:get_mempool():get_variable("grey_whitelisted")
+ local do_greylisting = task:get_mempool():get_variable("grey_greylisted")
+ local do_greylisting_required = task:get_mempool():get_variable("grey_greylisted_required")
+
+ -- Third and second level domains whitelist
+ if not is_whitelisted and whitelist_domains_map then
+ local hostname = task:get_hostname()
+ if hostname then
+ local domain = rspamd_util.get_tld(hostname)
+ if whitelist_domains_map:get_key(hostname) or (domain and whitelist_domains_map:get_key(domain)) then
+ is_whitelisted = 'meta'
+ rspamd_logger.infox(task, 'skip greylisting for whitelisted domain')
+ end
+ end
+ end
+
+ if action == 'reject' or
+ not do_greylisting_required and action == 'no action' then
+ return
+ end
+ local body_key = data_key(task)
+ local meta_key = envelope_key(task)
+ local upstream, ret, conn
+ local hash_key = body_key .. meta_key
+
+ local function redis_set_cb(err)
+ if err then
+ rspamd_logger.errx(task, 'got error %s when setting greylisting record on server %s',
+ err, upstream:get_addr())
+ end
+ end
+
+ local is_rspamc = rspamd_lua_utils.is_rspamc_or_controller(task)
+
+ if is_whitelisted then
+ if action == 'greylist' then
+ -- We are going to accept message
+ rspamd_logger.infox(task, 'Downgrading metric action from "greylist" to "no action"')
+ task:disable_action('greylist')
+ end
+
+ task:insert_result(settings['symbol'], 0.0, 'pass', is_whitelisted)
+ rspamd_logger.infox(task, 'greylisting pass (%s) until %s',
+ is_whitelisted,
+ rspamd_util.time_to_string(rspamd_util.get_time() + settings['expire']))
+
+ if not settings.check_local and is_rspamc then
+ return
+ end
+
+ ret, conn, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ hash_key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'EXPIRE', -- command
+ { body_key, tostring(toint(settings['expire'])) } -- arguments
+ )
+ -- Update greylisting record expire
+ if ret then
+ conn:add_cmd('EXPIRE', {
+ meta_key, tostring(toint(settings['expire']))
+ })
+ else
+ rspamd_logger.errx(task, 'got error while connecting to redis')
+ end
+ elseif do_greylisting or do_greylisting_required then
+ if not settings.check_local and is_rspamc then
+ return
+ end
+ local t = tostring(toint(rspamd_util.get_time()))
+ local end_time = rspamd_util.time_to_string(t + settings['timeout'])
+ rspamd_logger.infox(task, 'greylisted until "%s", new record', end_time)
+ greylist_message(task, end_time, 'new record')
+ -- Create new record
+ ret, conn, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ hash_key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'SETEX', -- command
+ { body_key, tostring(toint(settings['expire'])), t } -- arguments
+ )
+
+ if ret then
+ conn:add_cmd('SETEX', {
+ meta_key, tostring(toint(settings['expire'])), t
+ })
+ else
+ rspamd_logger.errx(task, 'got error while connecting to redis')
+ end
+ else
+ if action ~= 'no action' and action ~= 'reject' then
+ local grey_res = task:get_mempool():get_variable("grey_greylisted_body")
+
+ if grey_res then
+ -- We need to delay message, hence set a temporary result
+ rspamd_logger.infox(task, 'greylisting delayed until "%s": body', grey_res)
+ greylist_message(task, grey_res, 'body')
+ else
+ grey_res = task:get_mempool():get_variable("grey_greylisted_meta")
+ if grey_res then
+ greylist_message(task, grey_res, 'meta')
+ end
+ end
+ else
+ task:insert_result(settings['symbol'], 0.0, 'greylisted', 'passed')
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('greylist')
+if opts then
+ if opts['message_func'] then
+ settings.message_func = assert(load(opts['message_func']))()
+ end
+
+ for k, v in pairs(opts) do
+ if k ~= 'message_func' then
+ settings[k] = v
+ end
+ end
+
+ local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+ settings.check_local = auth_and_local_conf[1]
+ settings.check_authed = auth_and_local_conf[2]
+
+ if settings['greylist_min_score'] then
+ settings['greylist_min_score'] = tonumber(settings['greylist_min_score'])
+ else
+ local greylist_threshold = rspamd_config:get_metric_action('greylist')
+ if greylist_threshold then
+ settings['greylist_min_score'] = greylist_threshold
+ end
+ end
+
+ whitelisted_ip = lua_map.rspamd_map_add(N, 'whitelisted_ip', 'radix',
+ 'Greylist whitelist ip map')
+ whitelist_domains_map = lua_map.rspamd_map_add(N, 'whitelist_domains_url',
+ 'map', 'Greylist whitelist domains map')
+
+ redis_params = lua_redis.parse_redis_server(N)
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ rspamd_lua_utils.disable_module(N, "redis")
+ else
+ lua_redis.register_prefix(settings.key_prefix .. 'b[a-z0-9]{20}', N,
+ 'Greylisting elements (body hashes)"', {
+ type = 'string',
+ })
+ lua_redis.register_prefix(settings.key_prefix .. 'm[a-z0-9]{20}', N,
+ 'Greylisting elements (meta hashes)"', {
+ type = 'string',
+ })
+ rspamd_config:register_symbol({
+ name = 'GREYLIST_SAVE',
+ type = 'postfilter',
+ callback = greylist_set,
+ priority = lua_util.symbols_priorities.medium,
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ })
+ local id = rspamd_config:register_symbol({
+ name = 'GREYLIST_CHECK',
+ type = 'prefilter',
+ callback = greylist_check,
+ priority = lua_util.symbols_priorities.medium,
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol,
+ type = 'virtual',
+ parent = id,
+ score = 0,
+ })
+ end
+end
diff --git a/src/plugins/lua/hfilter.lua b/src/plugins/lua/hfilter.lua
new file mode 100644
index 0000000..8c132f5
--- /dev/null
+++ b/src/plugins/lua/hfilter.lua
@@ -0,0 +1,622 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2013-2015, Alexey Savelyev <info@homeweb.ru>
+
+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.
+]]--
+
+
+-- Weight for checks_hellohost and checks_hello: 5 - very hard, 4 - hard, 3 - medium, 2 - low, 1 - very low.
+-- From HFILTER_HELO_* and HFILTER_HOSTNAME_* symbols the maximum weight is selected in case of their actuating.
+
+if confighelp then
+ return
+end
+
+local rspamd_regexp = require "rspamd_regexp"
+local lua_util = require "lua_util"
+local rspamc_local_helo = "rspamc.local"
+local checks_hellohost = [[
+/[-.0-9][0-9][.-]?nat/i 5
+/homeuser[.-][0-9]/i 5
+/[-.0-9][0-9][.-]?unused-addr/i 3
+/[-.0-9][0-9][.-]?pppoe/i 5
+/[-.0-9][0-9][.-]?dynamic/i 5
+/[.-]catv[.-]/i 5
+/unused-addr[.-][0-9]/i 3
+/comcast[.-][0-9]/i 5
+/[.-]broadband[.-]/i 5
+/[0-9][.-]?fbx/i 4
+/[.-]peer[.-]/i 1
+/[.-]homeuser[.-]/i 5
+/[-.0-9][0-9][.-]?catv/i 5
+/customers?[.-][0-9]/i 1
+/[.-]wifi[.-]/i 5
+/[0-9][.-]?kabel/i 3
+/dynip[.-][0-9]/i 5
+/[.-]broad[.-]/i 5
+/[a|x]?dsl-line[.-]?[0-9]/i 4
+/[-.0-9][0-9][.-]?ppp/i 5
+/pool[.-][0-9]/i 4
+/[.-]nat[.-]/i 5
+/gprs[.-][0-9]/i 5
+/brodband[.-][0-9]/i 5
+/[.-]gprs[.-]/i 5
+/[.-]user[.-]/i 1
+/[-.0-9][0-9][.-]?in-?addr/i 4
+/[.-]host[.-]/i 2
+/[.-]fbx[.-]/i 4
+/dynamic[.-][0-9]/i 5
+/[-.0-9][0-9][.-]?peer/i 1
+/[-.0-9][0-9][.-]?pool/i 4
+/[-.0-9][0-9][.-]?user/i 1
+/[.-]cdma[.-]/i 5
+/user[.-][0-9]/i 1
+/[-.0-9][0-9][.-]?customers?/i 1
+/ppp[.-][0-9]/i 5
+/kabel[.-][0-9]/i 3
+/dhcp[.-][0-9]/i 5
+/peer[.-][0-9]/i 1
+/[-.0-9][0-9][.-]?host/i 2
+/clients?[.-][0-9]{2,}/i 5
+/host[.-][0-9]/i 2
+/[.-]ppp[.-]/i 5
+/[.-]dhcp[.-]/i 5
+/[.-]comcast[.-]/i 5
+/cable[.-][0-9]/i 3
+/[-.0-9][0-9][.-]?dial-?up/i 5
+/[-.0-9][0-9][.-]?bredband/i 5
+/[-.0-9][0-9][.-]?[a|x]?dsl-line/i 4
+/[.-]dial-?up[.-]/i 5
+/[.-]cablemodem[.-]/i 5
+/pppoe[.-][0-9]/i 5
+/[.-]unused-addr[.-]/i 3
+/pptp[.-][0-9]/i 5
+/broadband[.-][0-9]/i 5
+/[.-][a|x]?dsl-line[.-]/i 4
+/[.-]customers?[.-]/i 1
+/[-.0-9][0-9][.-]?fibertel/i 4
+/[-.0-9][0-9][.-]?comcast/i 5
+/[.-]dynamic[.-]/i 5
+/cdma[.-][0-9]/i 5
+/[0-9][.-]?broad/i 5
+/fbx[.-][0-9]/i 4
+/catv[.-][0-9]/i 5
+/[-.0-9][0-9][.-]?homeuser/i 5
+/[-.0-9][.-]pppoe[.-]/i 5
+/[-.0-9][.-]dynip[.-]/i 5
+/[-.0-9][0-9][.-]?[a|x]?dsl/i 4
+/[-.0-9][0-9]{3,}[.-]?clients?/i 5
+/[-.0-9][0-9][.-]?pptp/i 5
+/[.-]clients?[.-]/i 1
+/[.-]in-?addr[.-]/i 4
+/[.-]pool[.-]/i 4
+/[a|x]?dsl[.-]?[0-9]/i 4
+/[.-][a|x]?dsl[.-]/i 4
+/[-.0-9][0-9][.-]?[a|x]?dsl-dynamic/i 5
+/dial-?up[.-][0-9]/i 5
+/[-.0-9][0-9][.-]?cablemodem/i 5
+/[a|x]?dsl-dynamic[.-]?[0-9]/i 5
+/[.-]pptp[.-]/i 5
+/[.-][a|x]?dsl-dynamic[.-]/i 5
+/[0-9][.-]?wifi/i 5
+/fibertel[.-][0-9]/i 4
+/dyn[.-][0-9][-.0-9]/i 5
+/[-.0-9][0-9][.-]broadband/i 5
+/[-.0-9][0-9][.-]cable/i 3
+/broad[.-][0-9]/i 5
+/[-.0-9][0-9][.-]gprs/i 5
+/cablemodem[.-][0-9]/i 5
+/[-.0-9][0-9][.-]modem/i 5
+/[-.0-9][0-9][.-]dyn/i 5
+/[-.0-9][0-9][.-]dynip/i 5
+/[-.0-9][0-9][.-]cdma/i 5
+/[.-]modem[.-]/i 5
+/[.-]kabel[.-]/i 3
+/[.-]cable[.-]/i 3
+/in-?addr[.-][0-9]/i 4
+/nat[.-][0-9]/i 5
+/[.-]fibertel[.-]/i 4
+/[.-]bredband[.-]/i 5
+/modem[.-][0-9]/i 5
+/[0-9][.-]?dhcp/i 5
+/wifi[.-][0-9]/i 5
+]]
+local checks_hellohost_map
+
+local checks_hello = [[
+/^[^\.]+$/i 5 # for helo=COMPUTER, ANNA, etc... Without dot in helo
+/^(dsl)?(device|speedtouch)\.lan$/i 5
+/\.(lan|local|home|localdomain|intra|in-addr.arpa|priv|user|veloxzon)$/i 5
+]]
+local checks_hello_map
+
+local checks_hello_badip = [[
+/^\d\.\d\.\d\.255$/i 1
+/^192\.0\.0\./i 1
+/^2001:db8::/i 1
+/^10\./i 1
+/^192\.0\.2\./i 1
+/^172\.1[6-9]\./i 1
+/^192\.168\./i 1
+/^::1$/i 1 # loopback ipv4, ipv6
+/^ffxx::/i 1
+/^fc00::/i 1
+/^203\.0\.113\./i 1
+/^fe[cdf][0-9a-f]:/i 1
+/^100.12[0-7]\d\./i 1
+/^fe[89ab][0-9a-f]::/i 1
+/^169\.254\./i 1
+/^0\./i 1
+/^198\.51\.100\./i 1
+/^172\.3[01]\./i 1
+/^100.[7-9]\d\./i 1
+/^100.1[01]\d\./i 1
+/^127\./i 1
+/^100.6[4-9]\./i 1
+/^192\.88\.99\./i 1
+/^172\.2[0-9]\./i 1
+]]
+local checks_hello_badip_map
+
+local checks_hello_bareip = [[
+/^\d+[x.-]\d+[x.-]\d+[x.-]\d+$/
+/^[0-9a-f]+:/
+]]
+local checks_hello_bareip_map
+
+local config = {
+ ['helo_enabled'] = false,
+ ['hostname_enabled'] = false,
+ ['from_enabled'] = false,
+ ['rcpt_enabled'] = false,
+ ['mid_enabled'] = false,
+ ['url_enabled'] = false
+}
+
+local compiled_regexp = {} -- cache of regexps
+local check_local = false
+local check_authed = false
+local N = "hfilter"
+
+local function check_regexp(str, regexp_text)
+ local re = compiled_regexp[regexp_text]
+ if not re then
+ re = rspamd_regexp.create(regexp_text, 'i')
+ compiled_regexp[regexp_text] = re
+ end
+
+ return re:match(str)
+end
+
+local function add_static_map(data)
+ return rspamd_config:add_map {
+ type = 'regexp_multi',
+ url = {
+ upstreams = 'static',
+ data = data,
+ }
+ }
+end
+
+local function check_fqdn(domain)
+ if check_regexp(domain,
+ '(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\\.)+[a-zA-Z0-9-]{2,63}\\.?$)') then
+ return true
+ end
+ return false
+end
+
+-- host: host for check
+-- symbol_suffix: suffix for symbol
+-- eq_ip: ip for comparing or empty string
+-- eq_host: host for comparing or empty string
+local function check_host(task, host, symbol_suffix, eq_ip, eq_host)
+ local failed_address = 0
+ local resolved_address = {}
+
+ local function check_host_cb_mx(_, to_resolve, results, err)
+ if err and (err ~= 'requested record is not found' and err ~= 'no records with this name') then
+ lua_util.debugm(N, task, 'error looking up %s: %s', to_resolve, err)
+ end
+ if not results then
+ task:insert_result('HFILTER_' .. symbol_suffix .. '_NORES_A_OR_MX', 1.0,
+ to_resolve)
+ else
+ for _, mx in pairs(results) do
+ if mx['name'] then
+ local failed_mx_address = 0
+ -- Capture failed_mx_address
+ local function check_host_cb_mx_a(_, _, mx_results)
+ if not mx_results then
+ failed_mx_address = failed_mx_address + 1
+ end
+
+ if failed_mx_address >= 2 then
+ task:insert_result('HFILTER_' .. symbol_suffix .. '_NORESOLVE_MX',
+ 1.0, mx['name'])
+ end
+ end
+
+ task:get_resolver():resolve('a', {
+ task = task,
+ name = mx['name'],
+ callback = check_host_cb_mx_a
+ })
+ task:get_resolver():resolve('aaaa', {
+ task = task,
+ name = mx['name'],
+ callback = check_host_cb_mx_a
+ })
+ end
+ end
+ end
+ end
+ local function check_host_cb_a(_, _, results)
+ if not results then
+ failed_address = failed_address + 1
+ else
+ for _, result in pairs(results) do
+ table.insert(resolved_address, result:to_string())
+ end
+ end
+
+ if failed_address >= 2 then
+ -- No A or AAAA records
+ if eq_ip and eq_ip ~= '' then
+ for _, result in pairs(resolved_address) do
+ if result == eq_ip then
+ return true
+ end
+ end
+ task:insert_result('HFILTER_' .. symbol_suffix .. '_IP_A', 1.0, host)
+ end
+ task:get_resolver():resolve_mx({
+ task = task,
+ name = host,
+ callback = check_host_cb_mx
+ })
+ end
+ end
+
+ if host then
+ host = string.lower(host)
+ else
+ return false
+ end
+ if eq_host then
+ eq_host = string.lower(eq_host)
+ else
+ eq_host = ''
+ end
+
+ if check_fqdn(host) then
+ if eq_host == '' or eq_host ~= host then
+ task:get_resolver():resolve('a', {
+ task = task,
+ name = host,
+ callback = check_host_cb_a
+ })
+ -- Check ipv6 as well
+ task:get_resolver():resolve('aaaa', {
+ task = task,
+ name = host,
+ callback = check_host_cb_a
+ })
+ end
+ else
+ task:insert_result('HFILTER_' .. symbol_suffix .. '_NOT_FQDN', 1.0, host)
+ end
+
+ return true
+end
+
+--
+local function hfilter_callback(task)
+ -- Links checks
+ if config['url_enabled'] then
+ local parts = task:get_text_parts()
+ if parts then
+ local plain_text_part, html_text_part
+
+ for _, p in ipairs(parts) do
+ if p:is_html() then
+ html_text_part = p
+ else
+ plain_text_part = p
+ end
+ end
+
+ local function check_text_part(part, ty)
+ local url_len = part:get_urls_length()
+ local plen = part:get_length()
+
+ if plen > 0 and url_len > 0 then
+ local rel = url_len / plen
+ if rel > 0.8 then
+ local sc = (rel - 0.8) * 5.0
+ if sc > 1.0 then
+ sc = 1.0
+ end
+ task:insert_result('HFILTER_URL_ONLY', sc, tostring(sc))
+ local lines = part:get_lines_count()
+ if lines > 0 and lines < 2 then
+ task:insert_result('HFILTER_URL_ONELINE', 1.00,
+ string.format('%s:%d:%d', ty, math.floor(rel), lines))
+ end
+ end
+ end
+ end
+ if html_text_part then
+ check_text_part(html_text_part, 'html')
+ elseif plain_text_part then
+ check_text_part(plain_text_part, 'plain')
+ end
+ end
+ end
+
+ --No more checks for auth user or local network
+ local rip = task:get_from_ip()
+ if ((not check_authed and task:get_user()) or
+ (not check_local and rip and rip:is_local())) then
+ return false
+ end
+
+ --local message = task:get_message()
+ local ip = false
+ if rip and rip:is_valid() then
+ ip = rip:to_string()
+ end
+
+ -- Check's HELO
+ local weight_helo = 0
+ local helo
+ if config['helo_enabled'] then
+ helo = task:get_helo()
+ if helo then
+ if helo ~= rspamc_local_helo then
+ helo = string.gsub(helo, '[%[%]]', '')
+ -- Regexp check HELO (checks_hello_badip)
+ local find_badip = false
+ local values = checks_hello_badip_map:get_key(helo)
+ if values then
+ task:insert_result('HFILTER_HELO_BADIP', 1.0, helo, values)
+ find_badip = true
+ end
+
+ -- Regexp check HELO (checks_hello_bareip)
+ local find_bareip = false
+ if not find_badip then
+ values = checks_hello_bareip_map:get_key(helo)
+ if values then
+ task:insert_result('HFILTER_HELO_BAREIP', 1.0, helo, values)
+ find_bareip = true
+ end
+ end
+
+ if not find_badip and not find_bareip then
+ -- Regexp check HELO (checks_hello)
+ local weights = checks_hello_map:get_key(helo)
+ for _, weight in ipairs(weights or {}) do
+ weight = tonumber(weight) or 0
+ if weight > weight_helo then
+ weight_helo = weight
+ end
+ end
+ -- Regexp check HELO (checks_hellohost)
+ weights = checks_hellohost_map:get_key(helo)
+ for _, weight in ipairs(weights or {}) do
+ weight = tonumber(weight) or 0
+ if weight > weight_helo then
+ weight_helo = weight
+ end
+ end
+ --FQDN check HELO
+ if ip and helo and weight_helo == 0 then
+ check_host(task, helo, 'HELO', ip)
+ end
+ end
+ end
+ end
+ end
+
+ -- Check's HOSTNAME
+ local weight_hostname = 0
+ local hostname = task:get_hostname()
+
+ if config['hostname_enabled'] then
+ if hostname then
+ -- Check regexp HOSTNAME
+ local weights = checks_hellohost_map:get_key(hostname)
+ for _, weight in ipairs(weights or {}) do
+ weight = tonumber(weight) or 0
+ if weight > weight_hostname then
+ weight_hostname = weight
+ end
+ end
+ else
+ task:insert_result('HFILTER_HOSTNAME_UNKNOWN', 1.00)
+ end
+ end
+
+ --Insert weight's for HELO or HOSTNAME
+ if weight_helo > 0 and weight_helo >= weight_hostname then
+ task:insert_result('HFILTER_HELO_' .. weight_helo, 1.0, helo)
+ elseif weight_hostname > 0 and weight_hostname > weight_helo then
+ task:insert_result('HFILTER_HOSTNAME_' .. weight_hostname, 1.0, hostname)
+ end
+
+ -- MAILFROM checks --
+ local frombounce = false
+ if config['from_enabled'] then
+ local from = task:get_from(1)
+ if from then
+ --FROM host check
+ for _, fr in ipairs(from) do
+ local fr_split = rspamd_str_split(fr['addr'], '@')
+ if #fr_split == 2 then
+ check_host(task, fr_split[2], 'FROMHOST', '', '')
+ if fr_split[1] == 'postmaster' then
+ frombounce = true
+ end
+ end
+ end
+ else
+ if helo and helo ~= rspamc_local_helo then
+ task:insert_result('HFILTER_FROM_BOUNCE', 1.00, helo)
+ frombounce = true
+ end
+ end
+ end
+
+ -- Recipients checks --
+ if config['rcpt_enabled'] then
+ local rcpt = task:get_recipients()
+ if rcpt then
+ local count_rcpt = #rcpt
+ if frombounce then
+ if count_rcpt > 1 then
+ task:insert_result('HFILTER_RCPT_BOUNCEMOREONE', 1.00,
+ tostring(count_rcpt))
+ end
+ end
+ end
+ end
+
+ --Message ID host check
+ if config['mid_enabled'] then
+ local message_id = task:get_message_id()
+ if message_id then
+ local mid_split = rspamd_str_split(message_id, '@')
+ if #mid_split == 2 and not string.find(mid_split[2], 'local') then
+ check_host(task, mid_split[2], 'MID')
+ end
+ end
+ end
+
+ return false
+end
+
+local symbols_enabled = {}
+
+local symbols_helo = {
+ "HFILTER_HELO_BAREIP",
+ "HFILTER_HELO_BADIP",
+ "HFILTER_HELO_1",
+ "HFILTER_HELO_2",
+ "HFILTER_HELO_3",
+ "HFILTER_HELO_4",
+ "HFILTER_HELO_5",
+ "HFILTER_HELO_NORESOLVE_MX",
+ "HFILTER_HELO_NORES_A_OR_MX",
+ "HFILTER_HELO_IP_A",
+ "HFILTER_HELO_NOT_FQDN"
+}
+local symbols_hostname = {
+ "HFILTER_HOSTNAME_1",
+ "HFILTER_HOSTNAME_2",
+ "HFILTER_HOSTNAME_3",
+ "HFILTER_HOSTNAME_4",
+ "HFILTER_HOSTNAME_5",
+ "HFILTER_HOSTNAME_UNKNOWN"
+}
+local symbols_rcpt = {
+ "HFILTER_RCPT_BOUNCEMOREONE"
+}
+local symbols_mid = {
+ "HFILTER_MID_NORESOLVE_MX",
+ "HFILTER_MID_NORES_A_OR_MX",
+ "HFILTER_MID_NOT_FQDN"
+}
+local symbols_url = {
+ "HFILTER_URL_ONLY",
+ "HFILTER_URL_ONELINE"
+}
+local symbols_from = {
+ "HFILTER_FROMHOST_NORESOLVE_MX",
+ "HFILTER_FROMHOST_NORES_A_OR_MX",
+ "HFILTER_FROMHOST_NOT_FQDN",
+ "HFILTER_FROM_BOUNCE"
+}
+
+local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+check_local = auth_and_local_conf[1]
+check_authed = auth_and_local_conf[2]
+local timeout = 0.0
+
+local opts = rspamd_config:get_all_opt('hfilter')
+if opts then
+ for k, v in pairs(opts) do
+ config[k] = v
+ end
+end
+
+local function append_t(t, a)
+ for _, v in ipairs(a) do
+ table.insert(t, v)
+ end
+end
+if config['helo_enabled'] then
+ checks_hello_bareip_map = add_static_map(checks_hello_bareip)
+ checks_hello_badip_map = add_static_map(checks_hello_badip)
+ checks_hellohost_map = add_static_map(checks_hellohost)
+ checks_hello_map = add_static_map(checks_hello)
+ append_t(symbols_enabled, symbols_helo)
+ timeout = math.max(timeout, rspamd_config:get_dns_timeout() * 3)
+end
+if config['hostname_enabled'] then
+ if not checks_hellohost_map then
+ checks_hellohost_map = add_static_map(checks_hellohost)
+ end
+ append_t(symbols_enabled, symbols_hostname)
+ timeout = math.max(timeout, rspamd_config:get_dns_timeout())
+end
+if config['from_enabled'] then
+ append_t(symbols_enabled, symbols_from)
+ timeout = math.max(timeout, rspamd_config:get_dns_timeout())
+end
+if config['rcpt_enabled'] then
+ append_t(symbols_enabled, symbols_rcpt)
+end
+if config['mid_enabled'] then
+ append_t(symbols_enabled, symbols_mid)
+end
+if config['url_enabled'] then
+ append_t(symbols_enabled, symbols_url)
+end
+
+--dumper(symbols_enabled)
+if #symbols_enabled > 0 then
+ local id = rspamd_config:register_symbol {
+ name = 'HFILTER_CHECK',
+ callback = hfilter_callback,
+ type = 'callback',
+ augmentations = { string.format("timeout=%f", timeout) },
+ }
+ for _, sym in ipairs(symbols_enabled) do
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ score = 1.0,
+ parent = id,
+ name = sym,
+ }
+ rspamd_config:set_metric_symbol({
+ name = sym,
+ score = 0.0,
+ group = 'hfilter'
+ })
+ end
+else
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/history_redis.lua b/src/plugins/lua/history_redis.lua
new file mode 100644
index 0000000..d0aa5ae
--- /dev/null
+++ b/src/plugins/lua/history_redis.lua
@@ -0,0 +1,314 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ rspamd_config:add_example(nil, 'history_redis',
+ "Store history of checks for WebUI using Redis",
+ [[
+redis_history {
+ # History key name
+ key_prefix = 'rs_history';
+ # History expire in seconds
+ expire = 0;
+ # History rows limit
+ nrows = 200;
+ # Use zstd compression when storing data in redis
+ compress = true;
+ # Obfuscate subjects for privacy
+ subject_privacy = false;
+ # Default hash-algorithm to obfuscate subject
+ subject_privacy_alg = 'blake2';
+ # Prefix to show it's obfuscated
+ subject_privacy_prefix = 'obf';
+ # Cut the length of the hash if desired
+ subject_privacy_length = 16;
+}
+ ]])
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local fun = require "fun"
+local ucl = require "ucl"
+local ts = (require "tableshape").types
+local lua_verdict = require "lua_verdict"
+local E = {}
+local N = "history_redis"
+local hostname = rspamd_util.get_hostname()
+
+local redis_params
+
+local settings = {
+ key_prefix = 'rs_history', -- default key name
+ expire = nil, -- default no expire
+ nrows = 200, -- default rows limit
+ compress = true, -- use zstd compression when storing data in redis
+ subject_privacy = false, -- subject privacy is off
+ subject_privacy_alg = 'blake2', -- default hash-algorithm to obfuscate subject
+ subject_privacy_prefix = 'obf', -- prefix to show it's obfuscated
+ subject_privacy_length = 16, -- cut the length of the hash
+}
+
+local settings_schema = lua_redis.enrich_schema({
+ key_prefix = ts.string,
+ expire = (ts.number + ts.string / lua_util.parse_time_interval):is_optional(),
+ nrows = ts.number,
+ compress = ts.boolean,
+ subject_privacy = ts.boolean:is_optional(),
+ subject_privacy_alg = ts.string:is_optional(),
+ subject_privacy_prefix = ts.string:is_optional(),
+ subject_privacy_length = ts.number:is_optional(),
+})
+
+local function process_addr(addr)
+ if addr then
+ return addr.addr
+ end
+
+ return 'unknown'
+end
+
+local function normalise_results(tbl, task)
+ local metric = tbl.default
+
+ -- Convert stupid metric object
+ if metric then
+ tbl.symbols = {}
+ local symbols, others = fun.partition(function(_, v)
+ return type(v) == 'table' and v.score
+ end, metric)
+
+ fun.each(function(k, v)
+ v.name = nil;
+ tbl.symbols[k] = v;
+ end, symbols)
+ fun.each(function(k, v)
+ tbl[k] = v
+ end, others)
+
+ -- Reset the original metric
+ tbl.default = nil
+ end
+
+ -- Now, add recipients and senders
+ tbl.sender_smtp = process_addr((task:get_from('smtp') or E)[1])
+ tbl.sender_mime = process_addr((task:get_from('mime') or E)[1])
+ tbl.rcpt_smtp = fun.totable(fun.map(process_addr, task:get_recipients('smtp') or {}))
+ tbl.rcpt_mime = fun.totable(fun.map(process_addr, task:get_recipients('mime') or {}))
+ tbl.user = task:get_user() or 'unknown'
+ tbl.rmilter = nil
+ tbl.messages = nil
+ tbl.urls = nil
+ tbl.action = lua_verdict.adjust_passthrough_action(task)
+
+ local seconds = task:get_timeval()['tv_sec']
+ tbl.unix_time = seconds
+
+ local subject = task:get_header('subject') or 'unknown'
+ tbl.subject = lua_util.maybe_obfuscate_string(subject, settings, 'subject')
+ tbl.size = task:get_size()
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ tbl.ip = tostring(ip)
+ else
+ tbl.ip = 'unknown'
+ end
+
+ tbl.user = task:get_user() or 'unknown'
+end
+
+local function history_save(task)
+ local function redis_llen_cb(err, _)
+ if err then
+ rspamd_logger.errx(task, 'got error %s when writing history row: %s',
+ err)
+ end
+ end
+
+ -- We skip saving it to the history
+ if task:has_flag('no_log') then
+ return
+ end
+
+ local data = task:get_protocol_reply { 'metrics', 'basic' }
+ local prefix = settings.key_prefix .. hostname
+
+ if data then
+ normalise_results(data, task)
+ else
+ rspamd_logger.errx('cannot get protocol reply, skip saving in history')
+ return
+ end
+
+ local json = ucl.to_format(data, 'json-compact')
+
+ if settings.compress then
+ json = rspamd_util.zstd_compress(json)
+ -- Distinguish between compressed and non-compressed options
+ prefix = prefix .. '_zst'
+ end
+
+ local ret, conn, _ = lua_redis.rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ nil, -- hash key
+ true, -- is write
+ redis_llen_cb, --callback
+ 'LPUSH', -- command
+ { prefix, json } -- arguments
+ )
+
+ if ret then
+ conn:add_cmd('LTRIM', { prefix, '0', string.format('%d', settings.nrows - 1) })
+
+ if settings.expire and settings.expire > 0 then
+ conn:add_cmd('EXPIRE', { prefix, string.format('%d', settings.expire) })
+ end
+ end
+end
+
+local function handle_history_request(task, conn, from, to, reset)
+ local prefix = settings.key_prefix .. hostname
+ if settings.compress then
+ -- Distinguish between compressed and non-compressed options
+ prefix = prefix .. '_zst'
+ end
+
+ if reset then
+ local function redis_ltrim_cb(err, _)
+ if err then
+ rspamd_logger.errx(task, 'got error %s when resetting history: %s',
+ err)
+ conn:send_error(504, '{"error": "' .. err .. '"}')
+ else
+ conn:send_string('{"success":true}')
+ end
+ end
+ lua_redis.rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ nil, -- hash key
+ true, -- is write
+ redis_ltrim_cb, --callback
+ 'LTRIM', -- command
+ { prefix, '0', '0' } -- arguments
+ )
+ else
+ local function redis_lrange_cb(err, data)
+ if data then
+ local reply = {
+ version = 2,
+ }
+ if settings.compress then
+ local t1 = rspamd_util:get_ticks()
+
+ data = fun.totable(fun.filter(function(e)
+ return e ~= nil
+ end,
+ fun.map(function(e)
+ local _, dec = rspamd_util.zstd_decompress(e)
+ if dec then
+ return dec
+ end
+ return nil
+ end, data)))
+ lua_util.debugm(N, task, 'decompress took %s ms',
+ (rspamd_util:get_ticks() - t1) * 1000.0)
+ collectgarbage()
+ end
+ -- Parse elements using ucl
+ local t1 = rspamd_util:get_ticks()
+ data = fun.totable(
+ fun.map(function(_, obj)
+ return obj
+ end,
+ fun.filter(function(res, obj)
+ if res then
+ return true
+ end
+ return false
+ end,
+ fun.map(function(elt)
+ local parser = ucl.parser()
+ local res, _ = parser:parse_text(elt)
+
+ if res then
+ return true, parser:get_object()
+ else
+ return false, nil
+ end
+ end, data))))
+ lua_util.debugm(N, task, 'parse took %s ms',
+ (rspamd_util:get_ticks() - t1) * 1000.0)
+ collectgarbage()
+ t1 = rspamd_util:get_ticks()
+ reply.rows = data
+ conn:send_ucl(reply)
+ lua_util.debugm(N, task, 'process + sending took %s ms',
+ (rspamd_util:get_ticks() - t1) * 1000.0)
+ collectgarbage()
+ else
+ rspamd_logger.errx(task, 'got error %s when getting history: %s',
+ err)
+ conn:send_error(504, '{"error": "' .. err .. '"}')
+ end
+ end
+ lua_redis.rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ nil, -- hash key
+ false, -- is write
+ redis_lrange_cb, --callback
+ 'LRANGE', -- command
+ { prefix, string.format('%d', from), string.format('%d', to) }, -- arguments
+ { opaque_data = true }
+ )
+ end
+end
+
+local opts = rspamd_config:get_all_opt('history_redis')
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+ local res, err = settings_schema:transform(settings)
+
+ if not res then
+ rspamd_logger.warnx(rspamd_config, '%s: plugin is misconfigured: %s', N, err)
+ lua_util.disable_module(N, "config")
+ return
+ end
+ settings = res
+
+ redis_params = lua_redis.parse_redis_server('history_redis')
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ else
+ rspamd_config:register_symbol({
+ name = 'HISTORY_SAVE',
+ type = 'idempotent',
+ callback = history_save,
+ flags = 'empty,explicit_disable,ignore_passthrough',
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
+ })
+ lua_redis.register_prefix(settings.key_prefix .. hostname, N,
+ "Redis history", {
+ type = 'list',
+ })
+ rspamd_plugins['history'] = {
+ handler = handle_history_request
+ }
+ end
+end
diff --git a/src/plugins/lua/http_headers.lua b/src/plugins/lua/http_headers.lua
new file mode 100644
index 0000000..1c6494a
--- /dev/null
+++ b/src/plugins/lua/http_headers.lua
@@ -0,0 +1,198 @@
+--[[
+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 logger = require "rspamd_logger"
+local ucl = require "ucl"
+
+local spf_symbols = {
+ symbol_allow = 'R_SPF_ALLOW',
+ symbol_deny = 'R_SPF_FAIL',
+ symbol_softfail = 'R_SPF_SOFTFAIL',
+ symbol_neutral = 'R_SPF_NEUTRAL',
+ symbol_tempfail = 'R_SPF_DNSFAIL',
+ symbol_na = 'R_SPF_NA',
+ symbol_permfail = 'R_SPF_PERMFAIL',
+}
+
+local dkim_symbols = {
+ symbol_allow = 'R_DKIM_ALLOW',
+ symbol_deny = 'R_DKIM_REJECT',
+ symbol_tempfail = 'R_DKIM_TEMPFAIL',
+ symbol_na = 'R_DKIM_NA',
+ symbol_permfail = 'R_DKIM_PERMFAIL',
+ symbol_trace = 'DKIM_TRACE',
+}
+
+local dkim_trace = {
+ pass = '+',
+ fail = '-',
+ temperror = '?',
+ permerror = '~',
+}
+
+local dmarc_symbols = {
+ allow = 'DMARC_POLICY_ALLOW',
+ badpolicy = 'DMARC_BAD_POLICY',
+ dnsfail = 'DMARC_DNSFAIL',
+ na = 'DMARC_NA',
+ reject = 'DMARC_POLICY_REJECT',
+ softfail = 'DMARC_POLICY_SOFTFAIL',
+ quarantine = 'DMARC_POLICY_QUARANTINE',
+}
+
+local opts = rspamd_config:get_all_opt('dmarc')
+if opts and opts['symbols'] then
+ for k, _ in pairs(dmarc_symbols) do
+ if opts['symbols'][k] then
+ dmarc_symbols[k] = opts['symbols'][k]
+ end
+ end
+end
+
+opts = rspamd_config:get_all_opt('dkim')
+if opts then
+ for k, _ in pairs(dkim_symbols) do
+ if opts[k] then
+ dkim_symbols[k] = opts[k]
+ end
+ end
+end
+
+opts = rspamd_config:get_all_opt('spf')
+if opts then
+ for k, _ in pairs(spf_symbols) do
+ if opts[k] then
+ spf_symbols[k] = opts[k]
+ end
+ end
+end
+
+-- Disable DKIM checks if passed via HTTP headers
+rspamd_config:add_condition("DKIM_CHECK", function(task)
+ local hdr = task:get_request_header('DKIM')
+
+ if hdr then
+ local parser = ucl.parser()
+ local res, err = parser:parse_string(tostring(hdr))
+ if not res then
+ logger.infox(task, "cannot parse DKIM header: %1", err)
+ return true
+ end
+
+ local p_obj = parser:get_object()
+ local results = p_obj['results']
+ if not results and p_obj['result'] then
+ results = { { result = p_obj['result'], domain = 'unknown' } }
+ end
+
+ if results then
+ for _, obj in ipairs(results) do
+ local dkim_domain = obj['domain'] or 'unknown'
+ if obj['result'] == 'pass' or obj['result'] == 'allow' then
+ task:insert_result(dkim_symbols['symbol_allow'], 1.0, 'http header')
+ task:insert_result(dkim_symbols['symbol_trace'], 1.0,
+ string.format('%s:%s', dkim_domain, dkim_trace.pass))
+ elseif obj['result'] == 'fail' or obj['result'] == 'reject' then
+ task:insert_result(dkim_symbols['symbol_deny'], 1.0, 'http header')
+ task:insert_result(dkim_symbols['symbol_trace'], 1.0,
+ string.format('%s:%s', dkim_domain, dkim_trace.fail))
+ elseif obj['result'] == 'tempfail' or obj['result'] == 'softfail' then
+ task:insert_result(dkim_symbols['symbol_tempfail'], 1.0, 'http header')
+ task:insert_result(dkim_symbols['symbol_trace'], 1.0,
+ string.format('%s:%s', dkim_domain, dkim_trace.temperror))
+ elseif obj['result'] == 'permfail' then
+ task:insert_result(dkim_symbols['symbol_permfail'], 1.0, 'http header')
+ task:insert_result(dkim_symbols['symbol_trace'], 1.0,
+ string.format('%s:%s', dkim_domain, dkim_trace.permerror))
+ elseif obj['result'] == 'na' then
+ task:insert_result(dkim_symbols['symbol_na'], 1.0, 'http header')
+ end
+ end
+ end
+ end
+
+ return false
+end)
+
+-- Disable SPF checks if passed via HTTP headers
+rspamd_config:add_condition("SPF_CHECK", function(task)
+ local hdr = task:get_request_header('SPF')
+
+ if hdr then
+ local parser = ucl.parser()
+ local res, err = parser:parse_string(tostring(hdr))
+ if not res then
+ logger.infox(task, "cannot parse SPF header: %1", err)
+ return true
+ end
+
+ local obj = parser:get_object()
+
+ if obj['result'] then
+ if obj['result'] == 'pass' or obj['result'] == 'allow' then
+ task:insert_result(spf_symbols['symbol_allow'], 1.0, 'http header')
+ elseif obj['result'] == 'fail' or obj['result'] == 'reject' then
+ task:insert_result(spf_symbols['symbol_deny'], 1.0, 'http header')
+ elseif obj['result'] == 'neutral' then
+ task:insert_result(spf_symbols['symbol_neutral'], 1.0, 'http header')
+ elseif obj['result'] == 'softfail' then
+ task:insert_result(spf_symbols['symbol_softfail'], 1.0, 'http header')
+ elseif obj['result'] == 'permfail' then
+ task:insert_result(spf_symbols['symbol_permfail'], 1.0, 'http header')
+ elseif obj['result'] == 'na' then
+ task:insert_result(spf_symbols['symbol_na'], 1.0, 'http header')
+ end
+ end
+ end
+
+ return false
+end)
+
+rspamd_config:add_condition("DMARC_CALLBACK", function(task)
+ local hdr = task:get_request_header('DMARC')
+
+ if hdr then
+ local parser = ucl.parser()
+ local res, err = parser:parse_string(tostring(hdr))
+ if not res then
+ logger.infox(task, "cannot parse DMARC header: %1", err)
+ return true
+ end
+
+ local obj = parser:get_object()
+
+ if obj['result'] then
+ if obj['result'] == 'pass' or obj['result'] == 'allow' then
+ task:insert_result(dmarc_symbols['allow'], 1.0, 'http header')
+ elseif obj['result'] == 'fail' or obj['result'] == 'reject' then
+ task:insert_result(dmarc_symbols['reject'], 1.0, 'http header')
+ elseif obj['result'] == 'quarantine' then
+ task:insert_result(dmarc_symbols['quarantine'], 1.0, 'http header')
+ elseif obj['result'] == 'tempfail' then
+ task:insert_result(dmarc_symbols['dnsfail'], 1.0, 'http header')
+ elseif obj['result'] == 'softfail' or obj['result'] == 'none' then
+ task:insert_result(dmarc_symbols['softfail'], 1.0, 'http header')
+ elseif obj['result'] == 'permfail' or obj['result'] == 'badpolicy' then
+ task:insert_result(dmarc_symbols['badpolicy'], 1.0, 'http header')
+ elseif obj['result'] == 'na' then
+ task:insert_result(dmarc_symbols['na'], 1.0, 'http header')
+ end
+ end
+ end
+
+ return false
+end)
+
diff --git a/src/plugins/lua/ip_score.lua b/src/plugins/lua/ip_score.lua
new file mode 100644
index 0000000..e43fa3b
--- /dev/null
+++ b/src/plugins/lua/ip_score.lua
@@ -0,0 +1,4 @@
+-- This module is deprecated and must not be used.
+-- This file serves as a tombstone to prevent old ip_score to be loaded
+
+return \ No newline at end of file
diff --git a/src/plugins/lua/known_senders.lua b/src/plugins/lua/known_senders.lua
new file mode 100644
index 0000000..d26a1df
--- /dev/null
+++ b/src/plugins/lua/known_senders.lua
@@ -0,0 +1,245 @@
+--[[
+Copyright (c) 2023, 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.
+]]--
+
+-- This plugin implements known senders logic for Rspamd
+
+local rspamd_logger = require "rspamd_logger"
+local ts = (require "tableshape").types
+local N = 'known_senders'
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local lua_maps = require "lua_maps"
+local rspamd_cryptobox_hash = require "rspamd_cryptobox_hash"
+
+if confighelp then
+ rspamd_config:add_example(nil, 'known_senders',
+ "Maintain a list of known senders using Redis",
+ [[
+known_senders {
+ # Domains to track senders
+ domains = "https://maps.rspamd.com/freemail/free.txt.zst";
+ # Maximum number of elements
+ max_senders = 100000;
+ # Maximum time to live (when not using bloom filters)
+ max_ttl = 30d;
+ # Use bloom filters (must be enabled in Redis as a plugin)
+ use_bloom = false;
+ # Insert symbol for new senders from the specific domains
+ symbol_unknown = 'UNKNOWN_SENDER';
+}
+ ]])
+ return
+end
+
+local redis_params
+local settings = {
+ domains = {},
+ max_senders = 100000,
+ max_ttl = 30 * 86400,
+ use_bloom = false,
+ symbol = 'KNOWN_SENDER',
+ symbol_unknown = 'UNKNOWN_SENDER',
+ redis_key = 'rs_known_senders',
+}
+
+local settings_schema = lua_redis.enrich_schema({
+ domains = lua_maps.map_schema,
+ enabled = ts.boolean:is_optional(),
+ max_senders = (ts.integer + ts.string / tonumber):is_optional(),
+ max_ttl = (ts.integer + ts.string / tonumber):is_optional(),
+ use_bloom = ts.boolean:is_optional(),
+ redis_key = ts.string:is_optional(),
+ symbol = ts.string:is_optional(),
+ symbol_unknown = ts.string:is_optional(),
+})
+
+local function make_key(input)
+ local hash = rspamd_cryptobox_hash.create_specific('md5')
+ hash:update(input.addr)
+ return hash:hex()
+end
+
+local function check_redis_key(task, key, key_ty)
+ lua_util.debugm(N, task, 'check key %s, type: %s', key, key_ty)
+ local function redis_zset_callback(err, data)
+ lua_util.debugm(N, task, 'got data: %s', data)
+ if err then
+ rspamd_logger.errx(task, 'redis error: %s', err)
+ elseif data then
+ if type(data) ~= 'userdata' then
+ -- non-null reply
+ task:insert_result(settings.symbol, 1.0, string.format("%s:%s", key_ty, key))
+ else
+ if settings.symbol_unknown then
+ task:insert_result(settings.symbol_unknown, 1.0, string.format("%s:%s", key_ty, key))
+ end
+ lua_util.debugm(N, task, 'insert key %s, type: %s', key, key_ty)
+ -- Insert key to zset and trim it's cardinality
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ nil, --callback
+ 'ZADD', -- command
+ { settings.redis_key, tostring(task:get_timeval(true)), key } -- arguments
+ )
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ nil, --callback
+ 'ZREMRANGEBYRANK', -- command
+ { settings.redis_key, '0',
+ tostring(-(settings.max_senders + 1)) } -- arguments
+ )
+ end
+ end
+ end
+
+ local function redis_bloom_callback(err, data)
+ lua_util.debugm(N, task, 'got data: %s', data)
+ if err then
+ rspamd_logger.errx(task, 'redis error: %s', err)
+ elseif data then
+ if type(data) ~= 'userdata' and data == 1 then
+ -- non-null reply equal to `1`
+ task:insert_result(settings.symbol, 1.0, string.format("%s:%s", key_ty, key))
+ else
+ if settings.symbol_unknown then
+ task:insert_result(settings.symbol_unknown, 1.0, string.format("%s:%s", key_ty, key))
+ end
+ lua_util.debugm(N, task, 'insert key %s, type: %s', key, key_ty)
+ -- Reserve bloom filter space
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ nil, --callback
+ 'BF.RESERVE', -- command
+ { settings.redis_key, tostring(settings.max_senders), '0.01', '1000', 'NONSCALING' } -- arguments
+ )
+ -- Insert key and adjust bloom filter
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ nil, --callback
+ 'BF.ADD', -- command
+ { settings.redis_key, key } -- arguments
+ )
+ end
+ end
+ end
+
+ if settings.use_bloom then
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_bloom_callback, --callback
+ 'BF.EXISTS', -- command
+ { settings.redis_key, key } -- arguments
+ )
+ else
+ lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_zset_callback, --callback
+ 'ZSCORE', -- command
+ { settings.redis_key, key } -- arguments
+ )
+ end
+end
+
+local function known_senders_callback(task)
+ local mime_from = (task:get_from('mime') or {})[1]
+ local smtp_from = (task:get_from('smtp') or {})[1]
+ local mime_key, smtp_key
+ if mime_from and mime_from.addr then
+ if settings.domains:get_key(mime_from.domain) then
+ mime_key = make_key(mime_from)
+ else
+ lua_util.debugm(N, task, 'skip mime from domain %s', mime_from.domain)
+ end
+ end
+ if smtp_from and smtp_from.addr then
+ if settings.domains:get_key(smtp_from.domain) then
+ smtp_key = make_key(smtp_from)
+ else
+ lua_util.debugm(N, task, 'skip smtp from domain %s', smtp_from.domain)
+ end
+ end
+
+ if mime_key and smtp_key and mime_key ~= smtp_key then
+ -- Check both keys
+ check_redis_key(task, mime_key, 'mime')
+ check_redis_key(task, smtp_key, 'smtp')
+ elseif mime_key then
+ -- Check mime key
+ check_redis_key(task, mime_key, 'mime')
+ elseif smtp_key then
+ -- Check smtp key
+ check_redis_key(task, smtp_key, 'smtp')
+ end
+end
+
+local opts = rspamd_config:get_all_opt('known_senders')
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+ local res, err = settings_schema:transform(settings)
+ if not res then
+ rspamd_logger.errx(rspamd_config, 'cannot parse known_senders options: %1', err)
+ else
+ settings = res
+ end
+ redis_params = lua_redis.parse_redis_server(N, opts)
+
+ if redis_params then
+ local map_conf = settings.domains
+ settings.domains = lua_maps.map_add_from_ucl(settings.domains, 'set', 'domains to track senders from')
+ if not settings.domains then
+ rspamd_logger.errx(rspamd_config, "couldn't add map %s, disable module",
+ map_conf)
+ lua_util.disable_module(N, "config")
+ return
+ end
+ lua_redis.register_prefix(settings.redis_key, N,
+ 'Known elements redis key', {
+ type = 'zset/bloom filter',
+ })
+ local id = rspamd_config:register_symbol({
+ name = settings.symbol,
+ type = 'normal',
+ callback = known_senders_callback,
+ one_shot = true,
+ score = -1.0,
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }
+ })
+
+ if settings.symbol_unknown and #settings.symbol_unknown > 0 then
+ rspamd_config:register_symbol({
+ name = settings.symbol_unknown,
+ type = 'virtual',
+ parent = id,
+ one_shot = true,
+ score = 0.5,
+ })
+ end
+ else
+ lua_util.disable_module(N, "redis")
+ end
+end
diff --git a/src/plugins/lua/maillist.lua b/src/plugins/lua/maillist.lua
new file mode 100644
index 0000000..be1401c
--- /dev/null
+++ b/src/plugins/lua/maillist.lua
@@ -0,0 +1,235 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- Module for checking mail list headers
+local N = 'maillist'
+local symbol = 'MAILLIST'
+local lua_util = require "lua_util"
+-- EZMLM
+-- Mailing-List: .*run by ezmlm
+-- Precedence: bulk
+-- List-Post: <mailto:
+-- List-Help: <mailto:
+-- List-Unsubscribe: <mailto:[a-zA-Z\.-]+-unsubscribe@
+-- List-Subscribe: <mailto:[a-zA-Z\.-]+-subscribe@
+-- RFC 2919 headers exist
+local function check_ml_ezmlm(task)
+ -- Mailing-List
+ local header = task:get_header('mailing-list')
+ if not header or not string.find(header, 'ezmlm$') then
+ return false
+ end
+ -- Precedence
+ header = task:get_header('precedence')
+ if not header or not string.match(header, '^bulk$') then
+ return false
+ end
+ -- Other headers
+ header = task:get_header('list-post')
+ if not header or not string.find(header, '^<mailto:') then
+ return false
+ end
+ header = task:get_header('list-help')
+ if not header or not string.find(header, '^<mailto:') then
+ return false
+ end
+ -- Subscribe and unsubscribe
+ header = task:get_header('list-subscribe')
+ if not header or not string.find(header, '<mailto:[a-zA-Z.-]+-subscribe@') then
+ return false
+ end
+ header = task:get_header('list-unsubscribe')
+ if not header or not string.find(header, '<mailto:[a-zA-Z.-]+-unsubscribe@') then
+ return false
+ end
+
+ return true
+end
+
+-- GNU Mailman
+-- Two major versions currently in use and they use slightly different headers
+-- Mailman2: https://code.launchpad.net/~mailman-coders/mailman/2.1
+-- Mailman3: https://gitlab.com/mailman/mailman
+local function check_ml_mailman(task)
+ local header = task:get_header('X-Mailman-Version')
+ if not header then
+ return false
+ end
+ local mm_version = header:match('^([23])%.')
+ if not mm_version then
+ lua_util.debugm(N, task, 'unknown Mailman version: %s', header)
+ return false
+ end
+ lua_util.debugm(N, task, 'checking Mailman %s headers', mm_version)
+
+ -- XXX Some messages may not contain Precedence, but they are rare:
+ -- http://bazaar.launchpad.net/~mailman-coders/mailman/2.1/revision/1339
+ header = task:get_header('Precedence')
+ if not header or (header ~= 'bulk' and header ~= 'list') then
+ return false
+ end
+
+ -- Mailman 3 allows to disable all List-* headers in settings, but by default it adds them.
+ -- In all other cases all Mailman message should have List-Id header
+ if not task:has_header('List-Id') then
+ return false
+ end
+
+ if mm_version == '2' then
+ -- X-BeenThere present in all Mailman2 messages
+ if not task:has_header('X-BeenThere') then
+ return false
+ end
+ -- X-List-Administrivia: is only added to messages Mailman creates and
+ -- sends out of its own accord
+ header = task:get_header('X-List-Administrivia')
+ if header and header == 'yes' then
+ -- not much elase we can check, Subjects can be changed in settings
+ return true
+ end
+ else
+ -- Mailman 3
+ -- XXX not Mailman3 admin messages have this headers, but one
+ -- which don't usually have List-* headers examined below
+ if task:has_header('List-Administrivia') then
+ return true
+ end
+ end
+
+ -- List-Archive and List-Post are optional, check other headers
+ for _, h in ipairs({ 'List-Help', 'List-Subscribe', 'List-Unsubscribe' }) do
+ header = task:get_header(h)
+ if not (header and header:find('<mailto:', 1, true)) then
+ return false
+ end
+ end
+
+ return true
+end
+
+-- Google groups detector
+-- header exists X-Google-Loop
+-- RFC 2919 headers exist
+--
+local function check_ml_googlegroup(task)
+ return task:has_header('X-Google-Loop') or task:has_header('X-Google-Group-Id')
+end
+
+-- CGP detector
+-- X-Listserver = CommuniGate Pro LIST
+-- RFC 2919 headers exist
+--
+local function check_ml_cgp(task)
+ local header = task:get_header('X-Listserver')
+
+ if not header or string.sub(header, 0, 20) ~= 'CommuniGate Pro LIST' then
+ return false
+ end
+
+ return true
+end
+
+-- RFC 2919 headers
+local function check_generic_list_headers(task)
+ local score = 0
+ local has_subscribe, has_unsubscribe
+
+ local common_list_headers = {
+ ['List-Id'] = 0.75,
+ ['List-Archive'] = 0.125,
+ ['List-Owner'] = 0.125,
+ ['List-Help'] = 0.125,
+ ['List-Post'] = 0.125,
+ ['X-Loop'] = 0.125,
+ ['List-Subscribe'] = function()
+ has_subscribe = true
+ return 0.125
+ end,
+ ['List-Unsubscribe'] = function()
+ has_unsubscribe = true
+ return 0.125
+ end,
+ ['Precedence'] = function()
+ local header = task:get_header('Precedence')
+ if header and (header == 'list' or header == 'bulk') then
+ return 0.25
+ end
+ end,
+ }
+
+ for hname, hscore in pairs(common_list_headers) do
+ if task:has_header(hname) then
+ if type(hscore) == 'number' then
+ score = score + hscore
+ lua_util.debugm(N, task, 'has %s header, score = %s', hname, score)
+ else
+ local score_change = hscore()
+ if score and score_change then
+ score = score + score_change
+ lua_util.debugm(N, task, 'has %s header, score = %s', hname, score)
+ end
+ end
+ end
+ end
+
+ if has_subscribe and has_unsubscribe then
+ score = score + 0.25
+ end
+
+ lua_util.debugm(N, task, 'final maillist score %s', score)
+ return score
+end
+
+
+-- RFC 2919 headers exist
+local function check_maillist(task)
+ local score = check_generic_list_headers(task)
+ if score >= 1 then
+ if check_ml_ezmlm(task) then
+ task:insert_result(symbol, 1, 'ezmlm')
+ elseif check_ml_mailman(task) then
+ task:insert_result(symbol, 1, 'mailman')
+ elseif check_ml_googlegroup(task) then
+ task:insert_result(symbol, 1, 'googlegroups')
+ elseif check_ml_cgp(task) then
+ task:insert_result(symbol, 1, 'cgp')
+ else
+ if score > 2 then
+ score = 2
+ end
+ task:insert_result(symbol, 0.5 * score, 'generic')
+ end
+ end
+end
+
+
+
+-- Configuration
+local opts = rspamd_config:get_all_opt('maillist')
+if opts then
+ if opts['symbol'] then
+ symbol = opts['symbol']
+ rspamd_config:register_symbol({
+ name = symbol,
+ callback = check_maillist,
+ flags = 'nice'
+ })
+ end
+end
diff --git a/src/plugins/lua/maps_stats.lua b/src/plugins/lua/maps_stats.lua
new file mode 100644
index 0000000..d418810
--- /dev/null
+++ b/src/plugins/lua/maps_stats.lua
@@ -0,0 +1,133 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ rspamd_config:add_example(nil, 'maps_stats',
+ "Stores maps statistics in Redis", [[
+maps_stats {
+ # one iteration step per 2 minutes
+ interval = 2m;
+ # how many elements to store in Redis
+ count = 1k;
+ # common prefix for elements
+ prefix = 'rm_';
+}
+]])
+end
+
+local redis_params
+local lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local lua_redis = require "lua_redis"
+local N = "maps_stats"
+
+local settings = {
+ interval = 120, -- one iteration step per 2 minutes
+ count = 1000, -- how many elements to store in Redis
+ prefix = 'rm_', -- common prefix for elements
+}
+
+local function process_map(map, ev_base, _)
+ if map:get_nelts() > 0 and map:get_uri() ~= 'static' then
+ local key = settings.prefix .. map:get_uri()
+
+ local function redis_zrange_cb(err, data)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'cannot delete extra elements in %s: %s',
+ key, err)
+ elseif data then
+ rspamd_logger.infox(rspamd_config, 'cleared %s elements from %s',
+ data, key)
+ end
+ end
+ local function redis_card_cb(err, data)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'cannot get number of elements in %s: %s',
+ key, err)
+ elseif data then
+ if settings.count > 0 and tonumber(data) > settings.count then
+ lua_redis.rspamd_redis_make_request_taskless(ev_base,
+ rspamd_config,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_zrange_cb, --callback
+ 'ZREMRANGEBYRANK', -- command
+ { key, '0', tostring(-(settings.count) - 1) } -- arguments
+ )
+ end
+ end
+ end
+ local ret, conn, _ = lua_redis.rspamd_redis_make_request_taskless(ev_base,
+ rspamd_config,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_card_cb, --callback
+ 'ZCARD', -- command
+ { key } -- arguments
+ )
+
+ if ret and conn then
+ local stats = map:get_stats(true)
+ for k, s in pairs(stats) do
+ if s > 0 then
+ conn:add_cmd('ZINCRBY', { key, tostring(s), k })
+ end
+ end
+ end
+ end
+end
+
+if not lua_util.check_experimental(N) then
+ return
+end
+
+local opts = rspamd_config:get_all_opt(N)
+
+if opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+end
+
+redis_params = lua_redis.parse_redis_server(N, opts)
+-- XXX, this is a poor approach as not all maps are defined here...
+local tmaps = rspamd_config:get_maps()
+for _, m in ipairs(tmaps) do
+ if m:get_uri() ~= 'static' then
+ lua_redis.register_prefix(settings.prefix .. m:get_uri(), N,
+ 'Maps stats data', {
+ type = 'zlist',
+ persistent = true,
+ })
+ end
+end
+
+if redis_params then
+ rspamd_config:add_on_load(function(_, ev_base, worker)
+ local maps = rspamd_config:get_maps()
+
+ for _, m in ipairs(maps) do
+ rspamd_config:add_periodic(ev_base,
+ settings['interval'],
+ function()
+ process_map(m, ev_base, worker)
+ return true
+ end, true)
+ end
+ end)
+end \ No newline at end of file
diff --git a/src/plugins/lua/metadata_exporter.lua b/src/plugins/lua/metadata_exporter.lua
new file mode 100644
index 0000000..7b353b8
--- /dev/null
+++ b/src/plugins/lua/metadata_exporter.lua
@@ -0,0 +1,707 @@
+--[[
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- A plugin that pushes metadata (or whole messages) to external services
+
+local redis_params
+local lua_util = require "lua_util"
+local rspamd_http = require "rspamd_http"
+local rspamd_util = require "rspamd_util"
+local rspamd_logger = require "rspamd_logger"
+local rspamd_tcp = require "rspamd_tcp"
+local ucl = require "ucl"
+local E = {}
+local N = 'metadata_exporter'
+local HOSTNAME = rspamd_util.get_hostname()
+
+local settings = {
+ pusher_enabled = {},
+ pusher_format = {},
+ pusher_select = {},
+ mime_type = 'text/plain',
+ defer = false,
+ mail_from = '',
+ mail_to = 'postmaster@localhost',
+ helo = 'rspamd',
+ email_template = [[From: "Rspamd" <$mail_from>
+To: $mail_to
+Subject: Spam alert
+Date: $date
+MIME-Version: 1.0
+Message-ID: <$our_message_id>
+Content-type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 8bit
+
+Authenticated username: $user
+IP: $ip
+Queue ID: $qid
+SMTP FROM: $from
+SMTP RCPT: $rcpt
+MIME From: $header_from
+MIME To: $header_to
+MIME Date: $header_date
+Subject: $header_subject
+Message-ID: $message_id
+Action: $action
+Score: $score
+Symbols: $symbols]],
+}
+
+local function get_general_metadata(task, flatten, no_content)
+ local r = {}
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ r.ip = tostring(ip)
+ else
+ r.ip = 'unknown'
+ end
+ r.user = task:get_user() or 'unknown'
+ r.qid = task:get_queue_id() or 'unknown'
+ r.subject = task:get_subject() or 'unknown'
+ r.action = task:get_metric_action()
+ r.rspamd_server = HOSTNAME
+
+ local s = task:get_metric_score()[1]
+ r.score = flatten and string.format('%.2f', s) or s
+
+ local fuzzy = task:get_mempool():get_variable("fuzzy_hashes", "fstrings")
+ if fuzzy and #fuzzy > 0 then
+ local fz = {}
+ for _, h in ipairs(fuzzy) do
+ table.insert(fz, h)
+ end
+ if not flatten then
+ r.fuzzy = fz
+ else
+ r.fuzzy = table.concat(fz, ', ')
+ end
+ else
+ if not flatten then
+ r.fuzzy = {}
+ else
+ r.fuzzy = ''
+ end
+ end
+
+ local rcpt = task:get_recipients('smtp')
+ if rcpt then
+ local l = {}
+ for _, a in ipairs(rcpt) do
+ table.insert(l, a['addr'])
+ end
+ if not flatten then
+ r.rcpt = l
+ else
+ r.rcpt = table.concat(l, ', ')
+ end
+ else
+ r.rcpt = 'unknown'
+ end
+ local from = task:get_from('smtp')
+ if ((from or E)[1] or E).addr then
+ r.from = from[1].addr
+ else
+ r.from = 'unknown'
+ end
+ local syminf = task:get_symbols_all()
+ if flatten then
+ local l = {}
+ for _, sym in ipairs(syminf) do
+ local txt
+ if sym.options then
+ local topt = table.concat(sym.options, ', ')
+ txt = sym.name .. '(' .. string.format('%.2f', sym.score) .. ')' .. ' [' .. topt .. ']'
+ else
+ txt = sym.name .. '(' .. string.format('%.2f', sym.score) .. ')'
+ end
+ table.insert(l, txt)
+ end
+ r.symbols = table.concat(l, '\n\t')
+ else
+ r.symbols = syminf
+ end
+ local function process_header(name)
+ local hdr = task:get_header_full(name)
+ if hdr then
+ local l = {}
+ for _, h in ipairs(hdr) do
+ table.insert(l, h.decoded)
+ end
+ if not flatten then
+ return l
+ else
+ return table.concat(l, '\n')
+ end
+ else
+ return 'unknown'
+ end
+ end
+
+ local scan_real = task:get_scan_time()
+ scan_real = math.floor(scan_real * 1000)
+ if scan_real < 0 then
+ rspamd_logger.messagex(task,
+ 'clock skew detected for message: %s ms real sca time (reset to 0)',
+ scan_real)
+ scan_real = 0
+ end
+
+ r.scan_time = scan_real
+ local content = task:get_content()
+ r.size = content and content:len() or 0
+
+ if not no_content then
+ r.header_from = process_header('from')
+ r.header_to = process_header('to')
+ r.header_subject = process_header('subject')
+ r.header_date = process_header('date')
+ r.message_id = task:get_message_id()
+ end
+ return r
+end
+
+local formatters = {
+ default = function(task)
+ return task:get_content(), {}
+ end,
+ email_alert = function(task, rule, extra)
+ local meta = get_general_metadata(task, true)
+ local display_emails = {}
+ local mail_targets = {}
+ meta.mail_from = rule.mail_from or settings.mail_from
+ local mail_rcpt = rule.mail_to or settings.mail_to
+ if type(mail_rcpt) ~= 'table' then
+ table.insert(display_emails, string.format('<%s>', mail_rcpt))
+ table.insert(mail_targets, mail_rcpt)
+ else
+ for _, e in ipairs(mail_rcpt) do
+ table.insert(display_emails, string.format('<%s>', e))
+ table.insert(mail_targets, e)
+ end
+ end
+ if rule.email_alert_sender then
+ local x = task:get_from('smtp')
+ if x and string.len(x[1].addr) > 0 then
+ table.insert(mail_targets, x)
+ table.insert(display_emails, string.format('<%s>', x[1].addr))
+ end
+ end
+ if rule.email_alert_user then
+ local x = task:get_user()
+ if x then
+ table.insert(mail_targets, x)
+ table.insert(display_emails, string.format('<%s>', x))
+ end
+ end
+ if rule.email_alert_recipients then
+ local x = task:get_recipients('smtp')
+ if x then
+ for _, e in ipairs(x) do
+ if string.len(e.addr) > 0 then
+ table.insert(mail_targets, e.addr)
+ table.insert(display_emails, string.format('<%s>', e.addr))
+ end
+ end
+ end
+ end
+ meta.mail_to = table.concat(display_emails, ', ')
+ meta.our_message_id = rspamd_util.random_hex(12) .. '@rspamd'
+ meta.date = rspamd_util.time_to_string(rspamd_util.get_time())
+ return lua_util.template(rule.email_template or settings.email_template, meta), { mail_targets = mail_targets }
+ end,
+ json = function(task)
+ return ucl.to_format(get_general_metadata(task), 'json-compact')
+ end
+}
+
+local function is_spam(action)
+ return (action == 'reject' or action == 'add header' or action == 'rewrite subject')
+end
+
+local selectors = {
+ default = function(task)
+ return true
+ end,
+ is_spam = function(task)
+ local action = task:get_metric_action()
+ return is_spam(action)
+ end,
+ is_spam_authed = function(task)
+ if not task:get_user() then
+ return false
+ end
+ local action = task:get_metric_action()
+ return is_spam(action)
+ end,
+ is_reject = function(task)
+ local action = task:get_metric_action()
+ return (action == 'reject')
+ end,
+ is_reject_authed = function(task)
+ if not task:get_user() then
+ return false
+ end
+ local action = task:get_metric_action()
+ return (action == 'reject')
+ end,
+ is_not_soft_reject = function(task)
+ local action = task:get_metric_action()
+ return (action ~= 'soft reject')
+ end,
+}
+
+local function maybe_defer(task, rule)
+ if rule.defer then
+ rspamd_logger.warnx(task, 'deferring message')
+ task:set_pre_result('soft reject', 'deferred', N)
+ end
+end
+
+local pushers = {
+ redis_pubsub = function(task, formatted, rule)
+ local _, ret, upstream
+ local function redis_pub_cb(err)
+ if err then
+ rspamd_logger.errx(task, 'got error %s when publishing on server %s',
+ err, upstream:get_addr())
+ return maybe_defer(task, rule)
+ end
+ return true
+ end
+ ret, _, upstream = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ nil, -- hash key
+ true, -- is write
+ redis_pub_cb, --callback
+ 'PUBLISH', -- command
+ { rule.channel, formatted } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'error connecting to redis')
+ maybe_defer(task, rule)
+ end
+ end,
+ http = function(task, formatted, rule)
+ local function http_callback(err, code)
+ local valid_status = { 200, 201, 202, 204 }
+
+ if err then
+ rspamd_logger.errx(task, 'got error %s in http callback', err)
+ return maybe_defer(task, rule)
+ end
+ for _, v in ipairs(valid_status) do
+ if v == code then
+ return true
+ end
+ end
+ rspamd_logger.errx(task, 'got unexpected http status: %s', code)
+ return maybe_defer(task, rule)
+ end
+ local hdrs = {}
+ if rule.meta_headers then
+ local gm = get_general_metadata(task, false, true)
+ local pfx = rule.meta_header_prefix or 'X-Rspamd-'
+ for k, v in pairs(gm) do
+ if type(v) == 'table' then
+ hdrs[pfx .. k] = ucl.to_format(v, 'json-compact')
+ else
+ hdrs[pfx .. k] = v
+ end
+ end
+ end
+ rspamd_http.request({
+ task = task,
+ url = rule.url,
+ user = rule.user,
+ password = rule.password,
+ body = formatted,
+ callback = http_callback,
+ mime_type = rule.mime_type or settings.mime_type,
+ headers = hdrs,
+ })
+ end,
+ send_mail = function(task, formatted, rule, extra)
+ local lua_smtp = require "lua_smtp"
+ local function sendmail_cb(ret, err)
+ if not ret then
+ rspamd_logger.errx(task, 'SMTP export error: %s', err)
+ maybe_defer(task, rule)
+ end
+ end
+
+ lua_smtp.sendmail({
+ task = task,
+ host = rule.smtp,
+ port = rule.smtp_port or settings.smtp_port or 25,
+ from = rule.mail_from or settings.mail_from,
+ recipients = extra.mail_targets or rule.mail_to or settings.mail_to,
+ helo = rule.helo or settings.helo,
+ timeout = rule.timeout or settings.timeout,
+ }, formatted, sendmail_cb)
+ end,
+ json_raw_tcp = function(task, formatted, rule)
+ local function json_raw_tcp_callback(err, code)
+ if err then
+ rspamd_logger.errx(task, 'got error %s in json_raw_tcp callback', err)
+ return maybe_defer(task, rule)
+ end
+ return true
+ end
+ rspamd_tcp.request({
+ task=task,
+ host=rule.host,
+ port=rule.port,
+ data=formatted,
+ callback=json_raw_tcp_callback,
+ read=false,
+ })
+ end,
+}
+
+local opts = rspamd_config:get_all_opt(N)
+if not opts then
+ return
+end
+local process_settings = {
+ select = function(val)
+ selectors.custom = assert(load(val))()
+ end,
+ format = function(val)
+ formatters.custom = assert(load(val))()
+ end,
+ push = function(val)
+ pushers.custom = assert(load(val))()
+ end,
+ custom_push = function(val)
+ if type(val) == 'table' then
+ for k, v in pairs(val) do
+ pushers[k] = assert(load(v))()
+ end
+ end
+ end,
+ custom_select = function(val)
+ if type(val) == 'table' then
+ for k, v in pairs(val) do
+ selectors[k] = assert(load(v))()
+ end
+ end
+ end,
+ custom_format = function(val)
+ if type(val) == 'table' then
+ for k, v in pairs(val) do
+ formatters[k] = assert(load(v))()
+ end
+ end
+ end,
+ pusher_enabled = function(val)
+ if type(val) == 'string' then
+ if pushers[val] then
+ settings.pusher_enabled[val] = true
+ else
+ rspamd_logger.errx(rspamd_config, 'Pusher type: %s is invalid', val)
+ end
+ elseif type(val) == 'table' then
+ for _, v in ipairs(val) do
+ if pushers[v] then
+ settings.pusher_enabled[v] = true
+ else
+ rspamd_logger.errx(rspamd_config, 'Pusher type: %s is invalid', val)
+ end
+ end
+ end
+ end,
+}
+for k, v in pairs(opts) do
+ local f = process_settings[k]
+ if f then
+ f(opts[k])
+ else
+ settings[k] = v
+ end
+end
+if type(settings.rules) ~= 'table' then
+ -- Legacy config
+ settings.rules = {}
+ if not next(settings.pusher_enabled) then
+ if pushers.custom then
+ rspamd_logger.infox(rspamd_config, 'Custom pusher implicitly enabled')
+ settings.pusher_enabled.custom = true
+ else
+ -- Check legacy options
+ if settings.url then
+ rspamd_logger.warnx(rspamd_config, 'HTTP pusher implicitly enabled')
+ settings.pusher_enabled.http = true
+ end
+ if settings.channel then
+ rspamd_logger.warnx(rspamd_config, 'Redis Pubsub pusher implicitly enabled')
+ settings.pusher_enabled.redis_pubsub = true
+ end
+ if settings.smtp and settings.mail_to then
+ rspamd_logger.warnx(rspamd_config, 'SMTP pusher implicitly enabled')
+ settings.pusher_enabled.send_mail = true
+ end
+ end
+ end
+ if not next(settings.pusher_enabled) then
+ rspamd_logger.errx(rspamd_config, 'No push backend enabled')
+ return
+ end
+ if settings.formatter then
+ settings.format = formatters[settings.formatter]
+ if not settings.format then
+ rspamd_logger.errx(rspamd_config, 'No such formatter: %s', settings.formatter)
+ return
+ end
+ end
+ if settings.selector then
+ settings.select = selectors[settings.selector]
+ if not settings.select then
+ rspamd_logger.errx(rspamd_config, 'No such selector: %s', settings.selector)
+ return
+ end
+ end
+ for k in pairs(settings.pusher_enabled) do
+ local formatter = settings.pusher_format[k]
+ local selector = settings.pusher_select[k]
+ if not formatter then
+ settings.pusher_format[k] = settings.formatter or 'default'
+ rspamd_logger.infox(rspamd_config, 'Using default formatter for %s pusher', k)
+ else
+ if not formatters[formatter] then
+ rspamd_logger.errx(rspamd_config, 'No such formatter: %s - disabling %s', formatter, k)
+ settings.pusher_enabled.k = nil
+ end
+ end
+ if not selector then
+ settings.pusher_select[k] = settings.selector or 'default'
+ rspamd_logger.infox(rspamd_config, 'Using default selector for %s pusher', k)
+ else
+ if not selectors[selector] then
+ rspamd_logger.errx(rspamd_config, 'No such selector: %s - disabling %s', selector, k)
+ settings.pusher_enabled.k = nil
+ end
+ end
+ end
+ if settings.pusher_enabled.redis_pubsub then
+ redis_params = rspamd_parse_redis_server(N)
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'No redis servers are specified')
+ settings.pusher_enabled.redis_pubsub = nil
+ else
+ local r = {}
+ r.backend = 'redis_pubsub'
+ r.channel = settings.channel
+ r.defer = settings.defer
+ r.selector = settings.pusher_select.redis_pubsub
+ r.formatter = settings.pusher_format.redis_pubsub
+ r.timeout = redis_params.timeout
+ settings.rules[r.backend:upper()] = r
+ end
+ end
+ if settings.pusher_enabled.http then
+ if not settings.url then
+ rspamd_logger.errx(rspamd_config, 'No URL is specified')
+ settings.pusher_enabled.http = nil
+ else
+ local r = {}
+ r.backend = 'http'
+ r.url = settings.url
+ r.mime_type = settings.mime_type
+ r.defer = settings.defer
+ r.selector = settings.pusher_select.http
+ r.formatter = settings.pusher_format.http
+ r.timeout = settings.timeout or 0.0
+ settings.rules[r.backend:upper()] = r
+ end
+ end
+ if settings.pusher_enabled.send_mail then
+ if not (settings.mail_to and settings.smtp) then
+ rspamd_logger.errx(rspamd_config, 'No mail_to and/or smtp setting is specified')
+ settings.pusher_enabled.send_mail = nil
+ else
+ local r = {}
+ r.backend = 'send_mail'
+ r.mail_to = settings.mail_to
+ r.mail_from = settings.mail_from
+ r.helo = settings.hello
+ r.smtp = settings.smtp
+ r.smtp_port = settings.smtp_port
+ r.email_template = settings.email_template
+ r.defer = settings.defer
+ r.timeout = settings.timeout or 0.0
+ r.selector = settings.pusher_select.send_mail
+ r.formatter = settings.pusher_format.send_mail
+ settings.rules[r.backend:upper()] = r
+ end
+ end
+ if settings.pusher_enabled.json_raw_tcp then
+ if not (settings.host and settings.port) then
+ rspamd_logger.errx(rspamd_config, 'No host and/or port is specified')
+ settings.pusher_enabled.json_raw_tcp = nil
+ else
+ local r = {}
+ r.backend = 'json_raw_tcp'
+ r.host = settings.host
+ r.port = settings.port
+ r.defer = settings.defer
+ r.selector = settings.pusher_select.json_raw_tcp
+ r.formatter = settings.pusher_format.json_raw_tcp
+ settings.rules[r.backend:upper()] = r
+ end
+ end
+ if not next(settings.pusher_enabled) then
+ rspamd_logger.errx(rspamd_config, 'No push backend enabled')
+ return
+ end
+elseif not next(settings.rules) then
+ lua_util.debugm(N, rspamd_config, 'No rules enabled')
+ return
+end
+if not settings.rules or not next(settings.rules) then
+ rspamd_logger.errx(rspamd_config, 'No rules enabled')
+ return
+end
+local backend_required_elements = {
+ http = {
+ 'url',
+ },
+ smtp = {
+ 'mail_to',
+ 'smtp',
+ },
+ redis_pubsub = {
+ 'channel',
+ },
+ json_raw_tcp = {
+ 'host',
+ 'port',
+ },
+}
+local check_element = {
+ selector = function(k, v)
+ if not selectors[v] then
+ rspamd_logger.errx(rspamd_config, 'Rule %s has invalid selector %s', k, v)
+ return false
+ else
+ return true
+ end
+ end,
+ formatter = function(k, v)
+ if not formatters[v] then
+ rspamd_logger.errx(rspamd_config, 'Rule %s has invalid formatter %s', k, v)
+ return false
+ else
+ return true
+ end
+ end,
+}
+local backend_check = {
+ default = function(k, rule)
+ local reqset = backend_required_elements[rule.backend]
+ if reqset then
+ for _, e in ipairs(reqset) do
+ if not rule[e] then
+ rspamd_logger.errx(rspamd_config, 'Rule %s misses required setting %s', k, e)
+ settings.rules[k] = nil
+ end
+ end
+ end
+ for sett, v in pairs(rule) do
+ local f = check_element[sett]
+ if f then
+ if not f(sett, v) then
+ settings.rules[k] = nil
+ end
+ end
+ end
+ end,
+}
+backend_check.redis_pubsub = function(k, rule)
+ if not redis_params then
+ redis_params = rspamd_parse_redis_server(N)
+ end
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'No redis servers are specified')
+ settings.rules[k] = nil
+ else
+ backend_check.default(k, rule)
+ rule.timeout = redis_params.timeout
+ end
+end
+setmetatable(backend_check, {
+ __index = function()
+ return backend_check.default
+ end,
+})
+for k, v in pairs(settings.rules) do
+ if type(v) == 'table' then
+ local backend = v.backend
+ if not backend then
+ rspamd_logger.errx(rspamd_config, 'Rule %s has no backend', k)
+ settings.rules[k] = nil
+ elseif not pushers[backend] then
+ rspamd_logger.errx(rspamd_config, 'Rule %s has invalid backend %s', k, backend)
+ settings.rules[k] = nil
+ else
+ local f = backend_check[backend]
+ f(k, v)
+ end
+ else
+ rspamd_logger.errx(rspamd_config, 'Rule %s has bad type: %s', k, type(v))
+ settings.rules[k] = nil
+ end
+end
+
+local function gen_exporter(rule)
+ return function(task)
+ if task:has_flag('skip') then
+ return
+ end
+ local selector = rule.selector or 'default'
+ local selected = selectors[selector](task)
+ if selected then
+ lua_util.debugm(N, task, 'Message selected for processing')
+ local formatter = rule.formatter or 'default'
+ local formatted, extra = formatters[formatter](task, rule)
+ if formatted then
+ pushers[rule.backend](task, formatted, rule, extra)
+ else
+ lua_util.debugm(N, task, 'Formatter [%s] returned non-truthy value [%s]', formatter, formatted)
+ end
+ else
+ lua_util.debugm(N, task, 'Selector [%s] returned non-truthy value [%s]', selector, selected)
+ end
+ end
+end
+
+if not next(settings.rules) then
+ rspamd_logger.errx(rspamd_config, 'No rules enabled')
+ lua_util.disable_module(N, "config")
+end
+for k, r in pairs(settings.rules) do
+ rspamd_config:register_symbol({
+ name = 'EXPORT_METADATA_' .. k,
+ type = 'idempotent',
+ callback = gen_exporter(r),
+ flags = 'empty,explicit_disable,ignore_passthrough',
+ augmentations = { string.format("timeout=%f", r.timeout or 0.0) }
+ })
+end
diff --git a/src/plugins/lua/metric_exporter.lua b/src/plugins/lua/metric_exporter.lua
new file mode 100644
index 0000000..7588551
--- /dev/null
+++ b/src/plugins/lua/metric_exporter.lua
@@ -0,0 +1,252 @@
+--[[
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+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.
+]] --
+
+if confighelp then
+ return
+end
+
+local N = 'metric_exporter'
+local logger = require "rspamd_logger"
+local mempool = require "rspamd_mempool"
+local util = require "rspamd_util"
+local tcp = require "rspamd_tcp"
+local lua_util = require "lua_util"
+
+local pool
+local settings = {
+ interval = 120,
+ timeout = 15,
+ statefile = string.format('%s/%s', rspamd_paths['DBDIR'], 'metric_exporter_last_push')
+}
+
+local VAR_NAME = 'metric_exporter_last_push'
+
+local valid_metrics = {
+ 'actions.add header',
+ 'actions.greylist',
+ 'actions.no action',
+ 'actions.reject',
+ 'actions.rewrite subject',
+ 'actions.soft reject',
+ 'bytes_allocated',
+ 'chunks_allocated',
+ 'chunks_freed',
+ 'chunks_oversized',
+ 'connections',
+ 'control_connections',
+ 'ham_count',
+ 'learned',
+ 'pools_allocated',
+ 'pools_freed',
+ 'scanned',
+ 'shared_chunks_allocated',
+ 'spam_count',
+}
+
+local function validate_metrics(settings_metrics)
+ if type(settings_metrics) ~= 'table' or #settings_metrics == 0 then
+ logger.errx(rspamd_config, 'No metrics specified for collection')
+ return false
+ end
+ for _, v in ipairs(settings_metrics) do
+ local isvalid = false
+ for _, vm in ipairs(valid_metrics) do
+ if vm == v then
+ isvalid = true
+ break
+ end
+ end
+ if not isvalid then
+ logger.errx('Invalid metric: %s', v)
+ return false
+ end
+ local split = rspamd_str_split(v, '.')
+ if #split > 2 then
+ logger.errx('Too many dots in metric name: %s', v)
+ return false
+ end
+ end
+ return true
+end
+
+local function load_defaults(defaults)
+ for k, v in pairs(defaults) do
+ if settings[k] == nil then
+ settings[k] = v
+ end
+ end
+end
+
+local function graphite_config()
+ load_defaults({
+ host = 'localhost',
+ port = 2003,
+ metric_prefix = 'rspamd'
+ })
+ return validate_metrics(settings['metrics'])
+end
+
+local function graphite_push(kwargs)
+ local stamp
+ if kwargs['time'] then
+ stamp = math.floor(kwargs['time'])
+ else
+ stamp = math.floor(util.get_time())
+ end
+ local metrics_str = {}
+ for _, v in ipairs(settings['metrics']) do
+ local mvalue
+ local mname = string.format('%s.%s', settings['metric_prefix'], v:gsub(' ', '_'))
+ local split = rspamd_str_split(v, '.')
+ if #split == 1 then
+ mvalue = kwargs['stats'][v]
+ elseif #split == 2 then
+ mvalue = kwargs['stats'][split[1]][split[2]]
+ end
+ table.insert(metrics_str, string.format('%s %s %s', mname, mvalue, stamp))
+ end
+
+ metrics_str = table.concat(metrics_str, '\n')
+
+ tcp.request({
+ ev_base = kwargs['ev_base'],
+ config = rspamd_config,
+ host = settings['host'],
+ port = settings['port'],
+ timeout = settings['timeout'],
+ read = false,
+ data = {
+ metrics_str, '\n',
+ },
+ callback = (function(err)
+ if err then
+ logger.errx('Push failed: %1', err)
+ return
+ end
+ pool:set_variable(VAR_NAME, stamp)
+ end)
+ })
+end
+
+local backends = {
+ graphite = {
+ configure = graphite_config,
+ push = graphite_push,
+ },
+}
+
+local function configure_metric_exporter()
+ local opts = rspamd_config:get_all_opt(N)
+ local be = opts['backend']
+ if not be then
+ logger.debugm(N, rspamd_config, 'Backend is unspecified')
+ return
+ end
+ if not backends[be] then
+ logger.errx(rspamd_config, 'Backend is invalid: ' .. be)
+ return false
+ end
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+ return backends[be]['configure']()
+end
+
+if not configure_metric_exporter() then
+ lua_util.disable_module(N, "config")
+ return
+end
+
+rspamd_config:add_on_load(function(_, ev_base, worker)
+ -- Exit unless we're the first 'controller' worker
+ if not worker:is_primary_controller() then
+ return
+ end
+ -- Persist mempool variable to statefile on shutdown
+ pool = mempool.create()
+ rspamd_config:register_finish_script(function()
+ local stamp = pool:get_variable(VAR_NAME, 'double')
+ if not stamp then
+ logger.warn('No last metric exporter push to persist to disk')
+ return
+ end
+ local f, err = io.open(settings['statefile'], 'w')
+ if err then
+ logger.errx('Unable to write statefile to disk: %s', err)
+ return
+ end
+ if f then
+ f:write(pool:get_variable(VAR_NAME, 'double'))
+ f:close()
+ end
+ pool:destroy()
+ end)
+ -- Push metrics to backend
+ local function push_metrics(time)
+ logger.infox('Pushing metrics to %s backend', settings['backend'])
+ local args = {
+ ev_base = ev_base,
+ stats = worker:get_stat(),
+ }
+ if time then
+ table.insert(args, time)
+ end
+ backends[settings['backend']]['push'](args)
+ end
+ -- Push metrics at regular intervals
+ local function schedule_regular_push()
+ rspamd_config:add_periodic(ev_base, settings['interval'], function()
+ push_metrics()
+ return true
+ end)
+ end
+ -- Push metrics to backend and reschedule check
+ local function schedule_intermediate_push(when)
+ rspamd_config:add_periodic(ev_base, when, function()
+ push_metrics()
+ schedule_regular_push()
+ return false
+ end)
+ end
+ -- Try read statefile on startup
+ local stamp
+ local f, err = io.open(settings['statefile'], 'r')
+ if err then
+ logger.errx('Failed to open statefile: %s', err)
+ end
+ if f then
+ io.input(f)
+ stamp = tonumber(io.read())
+ pool:set_variable(VAR_NAME, stamp)
+ end
+ if not stamp then
+ logger.debugm(N, rspamd_config, 'No state found - pushing stats immediately')
+ push_metrics()
+ schedule_regular_push()
+ return
+ end
+ local time = util.get_time()
+ local delta = stamp - time + settings['interval']
+ if delta <= 0 then
+ logger.debugm(N, rspamd_config, 'Last push is too old - pushing stats immediately')
+ push_metrics(time)
+ schedule_regular_push()
+ return
+ end
+ logger.debugm(N, rspamd_config, 'Scheduling next push in %s seconds', delta)
+ schedule_intermediate_push(delta)
+end)
diff --git a/src/plugins/lua/mid.lua b/src/plugins/lua/mid.lua
new file mode 100644
index 0000000..b8650c8
--- /dev/null
+++ b/src/plugins/lua/mid.lua
@@ -0,0 +1,123 @@
+--[[
+Copyright (c) 2016, Alexander Moisseev <moiseev@mezonplus.ru>
+
+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.
+]]--
+
+--[[
+MID plugin - suppress INVALID_MSGID and MISSING_MID for messages originating
+from listed valid DKIM domains with missed or known proprietary Message-IDs
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_regexp = require "rspamd_regexp"
+local lua_util = require "lua_util"
+local N = "mid"
+
+local settings = {
+ url = '',
+ symbol_known_mid = 'KNOWN_MID',
+ symbol_known_no_mid = 'KNOWN_NO_MID',
+ symbol_invalid_msgid = 'INVALID_MSGID',
+ symbol_missing_mid = 'MISSING_MID',
+ symbol_dkim_allow = 'R_DKIM_ALLOW',
+ csymbol_invalid_msgid_allowed = 'INVALID_MSGID_ALLOWED',
+ csymbol_missing_mid_allowed = 'MISSING_MID_ALLOWED',
+}
+
+local map
+
+local E = {}
+
+local function known_mid_cb(task)
+ local re = {}
+ local header = task:get_header('Message-Id')
+ local das = task:get_symbol(settings['symbol_dkim_allow'])
+ if ((das or E)[1] or E).options then
+ for _, dkim_domain in ipairs(das[1]['options']) do
+ if dkim_domain then
+ local v = map:get_key(dkim_domain:match "[^:]+")
+ if v then
+ if v == '' then
+ if not header then
+ task:insert_result(settings['symbol_known_no_mid'], 1, dkim_domain)
+ return
+ end
+ else
+ re[dkim_domain] = rspamd_regexp.create_cached(v)
+ if header and re[dkim_domain] and re[dkim_domain]:match(header) then
+ task:insert_result(settings['symbol_known_mid'], 1, dkim_domain)
+ return
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('mid')
+if opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+
+ if not opts.source then
+ rspamd_logger.infox(rspamd_config, 'mid module requires "source" parameter')
+ lua_util.disable_module(N, "config")
+ return
+ end
+
+ map = rspamd_config:add_map {
+ url = opts.source,
+ description = "Message-IDs map",
+ type = 'map'
+ }
+ if map then
+ local id = rspamd_config:register_symbol({
+ name = 'KNOWN_MID_CALLBACK',
+ type = 'callback',
+ group = 'mid',
+ callback = known_mid_cb
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol_known_mid'],
+ parent = id,
+ group = 'mid',
+ type = 'virtual'
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol_known_no_mid'],
+ parent = id,
+ group = 'mid',
+ type = 'virtual'
+ })
+ rspamd_config:add_composite(settings['csymbol_invalid_msgid_allowed'],
+ string.format('~%s & ^%s',
+ settings['symbol_known_mid'],
+ settings['symbol_invalid_msgid']))
+ rspamd_config:add_composite(settings['csymbol_missing_mid_allowed'],
+ string.format('~%s & ^%s',
+ settings['symbol_known_no_mid'],
+ settings['symbol_missing_mid']))
+
+ rspamd_config:register_dependency('KNOWN_MID_CALLBACK', 'DKIM_CHECK')
+ else
+ rspamd_logger.infox(rspamd_config, 'source is not a valid map definition, disabling module')
+ lua_util.disable_module(N, "config")
+ end
+end
diff --git a/src/plugins/lua/milter_headers.lua b/src/plugins/lua/milter_headers.lua
new file mode 100644
index 0000000..b53a454
--- /dev/null
+++ b/src/plugins/lua/milter_headers.lua
@@ -0,0 +1,762 @@
+--[[
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- A plugin that provides common header manipulations
+
+local logger = require "rspamd_logger"
+local util = require "rspamd_util"
+local N = 'milter_headers'
+local lua_util = require "lua_util"
+local lua_maps = require "lua_maps"
+local lua_mime = require "lua_mime"
+local ts = require("tableshape").types
+local E = {}
+
+local HOSTNAME = util.get_hostname()
+
+local settings = {
+ remove_upstream_spam_flag = true;
+ skip_local = true,
+ skip_authenticated = true,
+ skip_all = false,
+ local_headers = {},
+ authenticated_headers = {},
+ headers_modify_mode = 'compat', -- To avoid compatibility issues on upgrade
+ default_headers_order = nil, -- Insert at the end (set 1 to insert just after the first received)
+ routines = {
+ ['remove-headers'] = {
+ headers = {},
+ },
+ ['add-headers'] = {
+ headers = {},
+ remove = 0,
+ },
+ ['remove-header'] = {
+ remove = 0,
+ },
+ ['x-spamd-result'] = {
+ header = 'X-Spamd-Result',
+ remove = 0,
+ stop_chars = ' ',
+ sort_by = 'score',
+ },
+ ['x-rspamd-server'] = {
+ header = 'X-Rspamd-Server',
+ remove = 0,
+ hostname = nil, -- Get the local computer host name
+ },
+ ['x-rspamd-queue-id'] = {
+ header = 'X-Rspamd-Queue-Id',
+ remove = 0,
+ },
+ ['x-rspamd-pre-result'] = {
+ header = 'X-Rspamd-Pre-Result',
+ remove = 0,
+ },
+ ['x-rspamd-action'] = {
+ header = 'X-Rspamd-Action',
+ remove = 0,
+ },
+ ['remove-spam-flag'] = {
+ header = 'X-Spam',
+ },
+ ['spam-header'] = {
+ header = 'Deliver-To',
+ value = 'Junk',
+ remove = 0,
+ },
+ ['x-virus'] = {
+ header = 'X-Virus',
+ remove = 0,
+ status_clean = nil,
+ status_infected = nil,
+ status_fail = nil,
+ symbols_fail = {},
+ symbols = {}, -- needs config
+ },
+ ['x-os-fingerprint'] = {
+ header = 'X-OS-Fingerprint',
+ remove = 0,
+ },
+ ['x-spamd-bar'] = {
+ header = 'X-Spamd-Bar',
+ positive = '+',
+ negative = '-',
+ neutral = '/',
+ remove = 0,
+ },
+ ['x-spam-level'] = {
+ header = 'X-Spam-Level',
+ char = '*',
+ remove = 0,
+ },
+ ['x-spam-status'] = {
+ header = 'X-Spam-Status',
+ remove = 0,
+ },
+ ['authentication-results'] = {
+ header = 'Authentication-Results',
+ remove = 0,
+ add_smtp_user = true,
+ stop_chars = ';',
+ },
+ ['stat-signature'] = {
+ header = 'X-Stat-Signature',
+ remove = 0,
+ },
+ ['fuzzy-hashes'] = {
+ header = 'X-Rspamd-Fuzzy',
+ },
+ },
+}
+
+local active_routines = {}
+local custom_routines = {}
+
+local function milter_headers(task)
+
+ -- Used to override wanted stuff by means of settings
+ local settings_override = false
+
+ local function skip_wanted(hdr)
+ if settings_override then
+ return true
+ end
+ -- Normal checks
+ local function match_extended_headers_rcpt()
+ local rcpts = task:get_recipients('smtp')
+ if not rcpts then
+ return false
+ end
+ local found
+ for _, r in ipairs(rcpts) do
+ found = false
+ -- Try full addr match
+ if r.addr and r.domain and r.user then
+ if settings.extended_headers_rcpt:get_key(r.addr) then
+ lua_util.debugm(N, task, 'found full addr in recipients for extended headers: %s',
+ r.addr)
+ found = true
+ end
+ -- Try user as plain match
+ if not found and settings.extended_headers_rcpt:get_key(r.user) then
+ lua_util.debugm(N, task, 'found user in recipients for extended headers: %s (%s)',
+ r.user, r.addr)
+ found = true
+ end
+ -- Try @domain to match domain
+ if not found and settings.extended_headers_rcpt:get_key('@' .. r.domain) then
+ lua_util.debugm(N, task, 'found domain in recipients for extended headers: @%s (%s)',
+ r.domain, r.addr)
+ found = true
+ end
+ end
+ if found then
+ break
+ end
+ end
+ return found
+ end
+
+ if settings.extended_headers_rcpt and match_extended_headers_rcpt() then
+ return false
+ end
+
+ if settings.skip_local and not settings.local_headers[hdr] then
+ local ip = task:get_ip()
+ if (ip and ip:is_local()) then
+ return true
+ end
+ end
+
+ if settings.skip_authenticated and not settings.authenticated_headers[hdr] then
+ if task:get_user() ~= nil then
+ return true
+ end
+ end
+
+ if settings.skip_all then
+ return true
+ end
+
+ return false
+
+ end
+
+ -- XXX: fix this crap one day
+ -- routines - are closures that encloses all environment including task
+ -- common - a common environment shared between routines
+ -- add - add headers table (filled by routines)
+ -- remove - remove headers table (filled by routines)
+ local routines, common, add, remove = {}, {}, {}, {}
+
+ local function add_header(name, value, stop_chars, order)
+ local hname = settings.routines[name].header
+ if not add[hname] then
+ add[hname] = {}
+ end
+ table.insert(add[hname], {
+ order = (order or settings.default_headers_order or -1),
+ value = lua_util.fold_header(task, hname, value, stop_chars)
+ })
+ end
+
+ routines['x-spamd-result'] = function()
+ local local_mod = settings.routines['x-spamd-result']
+ if skip_wanted('x-spamd-result') then
+ return
+ end
+ if not common.symbols then
+ common.symbols = task:get_symbols_all()
+ end
+ if not common['metric_score'] then
+ common['metric_score'] = task:get_metric_score()
+ end
+ if not common['metric_action'] then
+ common['metric_action'] = task:get_metric_action()
+ end
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+
+ local buf = {}
+ local verdict = string.format('default: %s [%.2f / %.2f]',
+ --TODO: (common.metric_action == 'no action') and 'False' or 'True',
+ (common.metric_action == 'reject') and 'True' or 'False',
+ common.metric_score[1], common.metric_score[2])
+ table.insert(buf, verdict)
+
+ -- Deal with symbols
+ table.sort(common.symbols, function(s1, s2)
+ local res
+ if local_mod.sort_by == 'name' then
+ res = s1.name < s2.name
+ else
+ -- inverse order to show important symbols first
+ res = math.abs(s1.score) > math.abs(s2.score)
+ end
+
+ return res
+ end)
+
+ for _, s in ipairs(common.symbols) do
+ local sym_str = string.format('%s(%.2f)[%s]',
+ s.name, s.score, table.concat(s.options or {}, ','))
+ table.insert(buf, sym_str)
+ end
+ add_header('x-spamd-result', table.concat(buf, '; '), ';')
+
+ local has_pr, action, message, module = task:has_pre_result()
+
+ if has_pr then
+ local pr_header = {}
+ if action then
+ table.insert(pr_header, string.format('action=%s', action))
+ end
+ if module then
+ table.insert(pr_header, string.format('module=%s', module))
+ end
+ if message then
+ table.insert(pr_header, message)
+ end
+ add_header('x-rspamd-pre-result', table.concat(pr_header, '; '), ';')
+ end
+ end
+
+ routines['x-rspamd-queue-id'] = function()
+ if skip_wanted('x-rspamd-queue-id') then
+ return
+ end
+ if common.queue_id ~= false then
+ common.queue_id = task:get_queue_id()
+ if not common.queue_id then
+ common.queue_id = false
+ end
+ end
+ if settings.routines['x-rspamd-queue-id'].remove then
+ remove[settings.routines['x-rspamd-queue-id'].header] = settings.routines['x-rspamd-queue-id'].remove
+ end
+ if common.queue_id then
+ add[settings.routines['x-rspamd-queue-id'].header] = common.queue_id
+ end
+ end
+
+ routines['remove-header'] = function()
+ if skip_wanted('remove-header') then
+ return
+ end
+ if settings.routines['remove-header'].header and settings.routines['remove-header'].remove then
+ remove[settings.routines['remove-header'].header] = settings.routines['remove-header'].remove
+ end
+ end
+
+ routines['remove-headers'] = function()
+ if skip_wanted('remove-headers') then
+ return
+ end
+ for h, r in pairs(settings.routines['remove-headers'].headers) do
+ remove[h] = r
+ end
+ end
+
+ routines['add-headers'] = function()
+ if skip_wanted('add-headers') then
+ return
+ end
+ for h, r in pairs(settings.routines['add-headers'].headers) do
+ add[h] = r
+ remove[h] = settings.routines['add-headers'].remove
+ end
+ end
+
+ routines['x-rspamd-server'] = function()
+ local local_mod = settings.routines['x-rspamd-server']
+ if skip_wanted('x-rspamd-server') then
+ return
+ end
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+ local hostname = local_mod.hostname
+ add[local_mod.header] = hostname and hostname or HOSTNAME
+ end
+
+ routines['x-spamd-bar'] = function()
+ local local_mod = settings.routines['x-spamd-bar']
+ if skip_wanted('x-rspamd-bar') then
+ return
+ end
+ if not common['metric_score'] then
+ common['metric_score'] = task:get_metric_score()
+ end
+ local score = common['metric_score'][1]
+ local spambar
+ if score <= -1 then
+ spambar = string.rep(local_mod.negative, math.floor(score * -1))
+ elseif score >= 1 then
+ spambar = string.rep(local_mod.positive, math.floor(score))
+ else
+ spambar = local_mod.neutral
+ end
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+ if spambar ~= '' then
+ add[local_mod.header] = spambar
+ end
+ end
+
+ routines['x-spam-level'] = function()
+ local local_mod = settings.routines['x-spam-level']
+ if skip_wanted('x-spam-level') then
+ return
+ end
+ if not common['metric_score'] then
+ common['metric_score'] = task:get_metric_score()
+ end
+ local score = common['metric_score'][1]
+ if score < 1 then
+ return nil, {}, {}
+ end
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+ add[local_mod.header] = string.rep(local_mod.char, math.floor(score))
+ end
+
+ routines['x-rspamd-action'] = function()
+ local local_mod = settings.routines['x-rspamd-action']
+ if skip_wanted('x-rspamd-action') then
+ return
+ end
+ if not common['metric_action'] then
+ common['metric_action'] = task:get_metric_action()
+ end
+ local action = common['metric_action']
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+ add[local_mod.header] = action
+ end
+
+ local function spam_header (class, name, value, remove_v)
+ if skip_wanted(class) then
+ return
+ end
+ if not common['metric_action'] then
+ common['metric_action'] = task:get_metric_action()
+ end
+ if remove_v then
+ remove[name] = remove_v
+ end
+ local action = common['metric_action']
+ if action ~= 'no action' and action ~= 'greylist' then
+ add[name] = value
+ end
+ end
+
+ routines['spam-header'] = function()
+ spam_header('spam-header',
+ settings.routines['spam-header'].header,
+ settings.routines['spam-header'].value,
+ settings.routines['spam-header'].remove)
+ end
+
+ routines['remove-spam-flag'] = function()
+ remove[settings.routines['remove-spam-flag'].header] = 0
+ end
+
+ routines['x-virus'] = function()
+ local local_mod = settings.routines['x-virus']
+ if skip_wanted('x-virus') then
+ return
+ end
+ if not common.symbols_hash then
+ if not common.symbols then
+ common.symbols = task:get_symbols_all()
+ end
+ local h = {}
+ for _, s in ipairs(common.symbols) do
+ h[s.name] = s
+ end
+ common.symbols_hash = h
+ end
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+ local virii = {}
+ for _, sym in ipairs(local_mod.symbols) do
+ local s = common.symbols_hash[sym]
+ if s then
+ if (s.options or E)[1] then
+ table.insert(virii, table.concat(s.options, ','))
+ elseif s then
+ table.insert(virii, 'unknown')
+ end
+ end
+ end
+ if #virii > 0 then
+ local virusstatus = table.concat(virii, ',')
+ if local_mod.status_infected then
+ virusstatus = local_mod.status_infected .. ', ' .. virusstatus
+ end
+ add_header('x-virus', virusstatus)
+ else
+ local failed = false
+ local fail_reason = 'unknown'
+ for _, sym in ipairs(local_mod.symbols_fail) do
+ local s = common.symbols_hash[sym]
+ if s then
+ failed = true
+ if (s.options or E)[1] then
+ fail_reason = table.concat(s.options, ',')
+ end
+ end
+ end
+ if not failed then
+ if local_mod.status_clean then
+ add_header('x-virus', local_mod.status_clean)
+ end
+ else
+ if local_mod.status_clean then
+ add_header('x-virus', string.format('%s(%s)',
+ local_mod.status_fail, fail_reason))
+ end
+ end
+ end
+ end
+
+ routines['x-os-fingerprint'] = function()
+ if skip_wanted('x-os-fingerprint') then
+ return
+ end
+ local local_mod = settings.routines['x-os-fingerprint']
+
+ local os_string, link_type, uptime_min, distance = task:get_mempool():get_variable('os_fingerprint',
+ 'string, string, double, double');
+
+ if not os_string then
+ return
+ end
+
+ local value = string.format('%s, (up: %i min), (distance %i, link: %s)',
+ os_string, uptime_min, distance, link_type)
+
+ if local_mod.remove then
+ remove[local_mod.header] = local_mod.remove
+ end
+
+ add_header('x-os-fingerprint', value)
+ end
+
+ routines['x-spam-status'] = function()
+ if skip_wanted('x-spam-status') then
+ return
+ end
+ if not common['metric_score'] then
+ common['metric_score'] = task:get_metric_score()
+ end
+ if not common['metric_action'] then
+ common['metric_action'] = task:get_metric_action()
+ end
+ local score = common['metric_score'][1]
+ local action = common['metric_action']
+ local is_spam
+ local spamstatus
+ if action ~= 'no action' and action ~= 'greylist' then
+ is_spam = 'Yes'
+ else
+ is_spam = 'No'
+ end
+ spamstatus = is_spam .. ', score=' .. string.format('%.2f', score)
+
+ if settings.routines['x-spam-status'].remove then
+ remove[settings.routines['x-spam-status'].header] = settings.routines['x-spam-status'].remove
+ end
+ add_header('x-spam-status', spamstatus)
+ end
+
+ routines['authentication-results'] = function()
+ if skip_wanted('authentication-results') then
+ return
+ end
+ local ar = require "lua_auth_results"
+
+ if settings.routines['authentication-results'].remove then
+ remove[settings.routines['authentication-results'].header] = settings.routines['authentication-results'].remove
+ end
+
+ local res = ar.gen_auth_results(task,
+ lua_util.override_defaults(ar.default_settings,
+ settings.routines['authentication-results']))
+
+ if res then
+ add_header('authentication-results', res, ';', 1)
+ end
+ end
+
+ routines['stat-signature'] = function()
+ if skip_wanted('stat-signature') then
+ return
+ end
+ if settings.routines['stat-signature'].remove then
+ remove[settings.routines['stat-signature'].header] = settings.routines['stat-signature'].remove
+ end
+ local res = task:get_mempool():get_variable("stat_signature")
+ if res then
+ add[settings.routines['stat-signature'].header] = res
+ end
+ end
+
+ routines['fuzzy-hashes'] = function()
+ local res = task:get_mempool():get_variable("fuzzy_hashes", "fstrings")
+
+ if res and #res > 0 then
+ for _, h in ipairs(res) do
+ add_header('fuzzy-hashes', h)
+ end
+ end
+ end
+
+ local routines_enabled = active_routines
+ local user_settings = task:cache_get('settings')
+ if user_settings and user_settings.plugins then
+ user_settings = user_settings.plugins.milter_headers or E
+ end
+
+ if user_settings and type(user_settings.routines) == 'table' then
+ lua_util.debugm(N, task, 'override routines to %s from user settings',
+ user_settings.routines)
+ routines_enabled = user_settings.routines
+ settings_override = true
+ end
+
+ for _, n in ipairs(routines_enabled) do
+ local ok, err
+ if custom_routines[n] then
+ local to_add, to_remove, common_in
+ ok, err, to_add, to_remove, common_in = pcall(custom_routines[n], task, common)
+ if ok then
+ for k, v in pairs(to_add) do
+ add[k] = v
+ end
+ for k, v in pairs(to_remove) do
+ remove[k] = v
+ end
+ for k, v in pairs(common_in) do
+ if type(v) == 'table' then
+ if not common[k] then
+ common[k] = {}
+ end
+ for kk, vv in pairs(v) do
+ common[k][kk] = vv
+ end
+ else
+ common[k] = v
+ end
+ end
+ end
+ else
+ ok, err = pcall(routines[n])
+ end
+ if not ok then
+ logger.errx(task, 'call to %s failed: %s', n, err)
+ end
+ end
+
+ if not next(add) then
+ add = nil
+ end
+ if not next(remove) then
+ remove = nil
+ end
+ if add or remove then
+
+ lua_mime.modify_headers(task, {
+ add = add,
+ remove = remove
+ }, settings.headers_modify_mode)
+ end
+end
+
+local config_schema = ts.shape({
+ use = ts.array_of(ts.string) + ts.string / function(s)
+ return { s }
+ end,
+ remove_upstream_spam_flag = ts.boolean:is_optional(),
+ extended_spam_headers = ts.boolean:is_optional(),
+ skip_local = ts.boolean:is_optional(),
+ skip_authenticated = ts.boolean:is_optional(),
+ local_headers = ts.array_of(ts.string):is_optional(),
+ authenticated_headers = ts.array_of(ts.string):is_optional(),
+ extended_headers_rcpt = lua_maps.map_schema:is_optional(),
+ custom = ts.map_of(ts.string, ts.string):is_optional(),
+}, {
+ extra_fields = ts.map_of(ts.string, ts.any)
+})
+
+local opts = rspamd_config:get_all_opt(N) or
+ rspamd_config:get_all_opt('rmilter_headers')
+
+if not opts then
+ return
+end
+
+-- Process config
+do
+ local res, err = config_schema:transform(opts)
+ if not res then
+ logger.errx(rspamd_config, 'invalid config for %s: %s', N, err)
+ return
+ else
+ opts = res
+ end
+end
+
+local have_routine = {}
+local function activate_routine(s)
+ if settings.routines[s] or custom_routines[s] then
+ if not have_routine[s] then
+ have_routine[s] = true
+ table.insert(active_routines, s)
+ if (opts.routines and opts.routines[s]) then
+ settings.routines[s] = lua_util.override_defaults(settings.routines[s],
+ opts.routines[s])
+ end
+ end
+ else
+ logger.errx(rspamd_config, 'routine "%s" does not exist', s)
+ end
+end
+
+if opts.remove_upstream_spam_flag ~= nil then
+ settings.remove_upstream_spam_flag = opts.remove_upstream_spam_flag
+end
+
+if opts.extended_spam_headers then
+ activate_routine('x-spamd-result')
+ activate_routine('x-rspamd-server')
+ activate_routine('x-rspamd-queue-id')
+ activate_routine('x-rspamd-action')
+end
+
+if opts.local_headers then
+ for _, h in ipairs(opts.local_headers) do
+ settings.local_headers[h] = true
+ end
+end
+if opts.authenticated_headers then
+ for _, h in ipairs(opts.authenticated_headers) do
+ settings.authenticated_headers[h] = true
+ end
+end
+if opts.custom then
+ for k, v in pairs(opts['custom']) do
+ local f, err = load(v)
+ if not f then
+ logger.errx(rspamd_config, 'could not load "%s": %s', k, err)
+ else
+ custom_routines[k] = f()
+ end
+ end
+end
+
+if type(opts['skip_local']) == 'boolean' then
+ settings.skip_local = opts['skip_local']
+end
+
+if type(opts['skip_authenticated']) == 'boolean' then
+ settings.skip_authenticated = opts['skip_authenticated']
+end
+
+if type(opts['skip_all']) == 'boolean' then
+ settings.skip_all = opts['skip_all']
+end
+
+for _, s in ipairs(opts['use']) do
+ if not have_routine[s] then
+ activate_routine(s)
+ end
+end
+
+if settings.remove_upstream_spam_flag then
+ activate_routine('remove-spam-flag')
+end
+
+if (#active_routines < 1) then
+ logger.errx(rspamd_config, 'no active routines')
+ return
+end
+
+logger.infox(rspamd_config, 'active routines [%s]',
+ table.concat(active_routines, ','))
+
+if opts.extended_headers_rcpt then
+ settings.extended_headers_rcpt = lua_maps.rspamd_map_add_from_ucl(opts.extended_headers_rcpt,
+ 'set', 'Extended headers recipients')
+end
+
+rspamd_config:register_symbol({
+ name = 'MILTER_HEADERS',
+ type = 'idempotent',
+ callback = milter_headers,
+ flags = 'empty,explicit_disable,ignore_passthrough',
+})
diff --git a/src/plugins/lua/mime_types.lua b/src/plugins/lua/mime_types.lua
new file mode 100644
index 0000000..167ed38
--- /dev/null
+++ b/src/plugins/lua/mime_types.lua
@@ -0,0 +1,737 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- This plugin implements mime types checks for mail messages
+local logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local rspamd_util = require "rspamd_util"
+local lua_maps = require "lua_maps"
+local lua_mime_types = require "lua_mime_types"
+local lua_magic_types = require "lua_magic/types"
+local fun = require "fun"
+
+local N = "mime_types"
+local settings = {
+ file = '',
+ symbol_unknown = 'MIME_UNKNOWN',
+ symbol_bad = 'MIME_BAD',
+ symbol_good = 'MIME_GOOD',
+ symbol_attachment = 'MIME_BAD_ATTACHMENT',
+ symbol_encrypted_archive = 'MIME_ENCRYPTED_ARCHIVE',
+ symbol_obfuscated_archive = 'MIME_OBFUSCATED_ARCHIVE',
+ symbol_exe_in_gen_split_rar = 'MIME_EXE_IN_GEN_SPLIT_RAR',
+ symbol_archive_in_archive = 'MIME_ARCHIVE_IN_ARCHIVE',
+ symbol_double_extension = 'MIME_DOUBLE_BAD_EXTENSION',
+ symbol_bad_extension = 'MIME_BAD_EXTENSION',
+ symbol_bad_unicode = 'MIME_BAD_UNICODE',
+ regexp = false,
+ extension_map = { -- extension -> mime_type
+ html = 'text/html',
+ htm = 'text/html',
+ pdf = 'application/pdf',
+ shtm = 'text/html',
+ shtml = 'text/html',
+ txt = 'text/plain'
+ },
+
+ bad_extensions = {
+ cue = 2,
+ exe = 1,
+ iso = 4,
+ jar = 2,
+ zpaq = 2,
+ -- In contrast to HTML MIME parts, dedicated HTML attachments are considered harmful
+ htm = 1,
+ html = 1,
+ shtm = 1,
+ shtml = 1,
+ -- Have you ever seen that in legit email?
+ ace = 4,
+ arj = 2,
+ aspx = 1,
+ asx = 2,
+ cab = 3,
+ dll = 4,
+ dqy = 2,
+ iqy = 2,
+ mht = 2,
+ mhtml = 2,
+ oqy = 2,
+ rqy = 2,
+ sfx = 2,
+ slk = 2,
+ vst = 2,
+ vss = 2,
+ wim = 2,
+ -- Additional bad extensions from Gmail
+ ade = 4,
+ adp = 4,
+ cmd = 4,
+ cpl = 4,
+ ins = 4,
+ isp = 4,
+ js = 4,
+ jse = 4,
+ lib = 4,
+ mde = 4,
+ msc = 4,
+ msi = 4,
+ msp = 4,
+ mst = 4,
+ nsh = 4,
+ pif = 4,
+ sct = 4,
+ shb = 4,
+ sys = 4,
+ vb = 4,
+ vbe = 4,
+ vbs = 4,
+ vxd = 4,
+ wsc = 4,
+ wsh = 4,
+ -- Additional bad extensions from Outlook
+ app = 4,
+ asp = 4,
+ bas = 4,
+ bat = 4,
+ chm = 4,
+ cnt = 4,
+ com = 4,
+ csh = 4,
+ diagcab = 4,
+ fxp = 4,
+ gadget = 4,
+ grp = 4,
+ hlp = 4,
+ hpj = 4,
+ hta = 4,
+ htc = 4,
+ inf = 4,
+ its = 4,
+ jnlp = 4,
+ lnk = 4,
+ ksh = 4,
+ mad = 4,
+ maf = 4,
+ mag = 4,
+ mam = 4,
+ maq = 4,
+ mar = 4,
+ mas = 4,
+ mat = 4,
+ mau = 4,
+ mav = 4,
+ maw = 4,
+ mcf = 4,
+ mda = 4,
+ mdb = 4,
+ mdt = 4,
+ mdw = 4,
+ mdz = 4,
+ msh = 4,
+ msh1 = 4,
+ msh2 = 4,
+ mshxml = 4,
+ msh1xml = 4,
+ msh2xml = 4,
+ msu = 4,
+ ops = 4,
+ osd = 4,
+ pcd = 4,
+ pl = 4,
+ plg = 4,
+ prf = 4,
+ prg = 4,
+ printerexport = 4,
+ ps1 = 4,
+ ps1xml = 4,
+ ps2 = 4,
+ ps2xml = 4,
+ psc1 = 4,
+ psc2 = 4,
+ psd1 = 4,
+ psdm1 = 4,
+ pst = 4,
+ pyc = 4,
+ pyo = 4,
+ pyw = 4,
+ pyz = 4,
+ pyzw = 4,
+ reg = 4,
+ scf = 4,
+ scr = 4,
+ shs = 4,
+ theme = 4,
+ url = 4,
+ vbp = 4,
+ vhd = 4,
+ vhdx = 4,
+ vsmacros = 4,
+ vsw = 4,
+ webpnp = 4,
+ website = 4,
+ ws = 4,
+ wsf = 4,
+ xbap = 4,
+ xll = 4,
+ xnk = 4,
+ },
+
+ -- Something that should not be in archive
+ bad_archive_extensions = {
+ docx = 0.1,
+ hta = 4,
+ jar = 3,
+ js = 0.5,
+ pdf = 0.1,
+ pptx = 0.1,
+ vbs = 4,
+ wsf = 4,
+ xlsx = 0.1,
+ },
+
+ archive_extensions = {
+ ['7z'] = 1,
+ ace = 1,
+ alz = 1,
+ arj = 1,
+ bz2 = 1,
+ cab = 1,
+ egg = 1,
+ lz = 1,
+ rar = 1,
+ xz = 1,
+ zip = 1,
+ zpaq = 1,
+ },
+
+ -- Not really archives
+ archive_exceptions = {
+ docx = true,
+ odp = true,
+ ods = true,
+ odt = true,
+ pptx = true,
+ vsdx = true,
+ xlsx = true,
+ -- jar = true,
+ },
+
+ -- Multiplier for full extension_map mismatch
+ other_extensions_mult = 0.4,
+}
+
+local map = nil
+
+local function check_mime_type(task)
+ local function gen_extension(fname)
+ local parts = lua_util.str_split(fname or '', '.')
+
+ local ext = {}
+ for n = 1, 2 do
+ ext[n] = #parts > n and string.lower(parts[#parts + 1 - n]) or nil
+ end
+
+ return ext[1], ext[2], parts
+ end
+
+ local function check_filename(fname, ct, is_archive, part, detected_ext, nfiles)
+
+ lua_util.debugm(N, task, "check filename: %s, ct=%s, is_archive=%s, detected_ext=%s, nfiles=%s",
+ fname, ct, is_archive, detected_ext, nfiles)
+ local has_bad_unicode, char, ch_pos = rspamd_util.has_obscured_unicode(fname)
+ if has_bad_unicode then
+ task:insert_result(settings.symbol_bad_unicode, 1.0,
+ string.format("0x%xd after %s", char,
+ fname:sub(1, ch_pos)))
+ end
+
+ -- Decode hex encoded characters
+ fname = string.gsub(fname, '%%(%x%x)',
+ function(hex)
+ return string.char(tonumber(hex, 16))
+ end)
+
+ -- Replace potentially bad characters with '?'
+ fname = fname:gsub('[^%s%g]', '?')
+
+ -- Check file is in filename whitelist
+ if settings.filename_whitelist and
+ settings.filename_whitelist:get_key(fname) then
+ logger.debugm("mime_types", task, "skip checking of %s - file is in filename whitelist",
+ fname)
+ return
+ end
+
+ local ext, ext2, parts = gen_extension(fname)
+ -- ext is the last extension, LOWERCASED
+ -- ext2 is the one before last extension LOWERCASED
+
+ local detected
+
+ if not is_archive and detected_ext then
+ detected = lua_magic_types[detected_ext]
+ end
+
+ if detected_ext and ((not ext) or ext ~= detected_ext) then
+ -- Try to find extension by real content type
+ check_filename('detected.' .. detected_ext, detected.ct,
+ false, part, nil, 1)
+ end
+
+ if not ext then
+ return
+ end
+
+ local function check_extension(badness_mult, badness_mult2)
+ if not badness_mult and not badness_mult2 then
+ return
+ end
+ if #parts > 2 then
+ -- We need to ensure that next-to-last extension is an extension,
+ -- so we check for its length and if it is not a number or date
+ if #ext2 > 0 and #ext2 <= 4 and not string.match(ext2, '^%d+[%]%)]?$') then
+
+ -- Use the greatest badness multiplier
+ if not badness_mult or
+ (badness_mult2 and badness_mult < badness_mult2) then
+ badness_mult = badness_mult2
+ end
+
+ -- Double extension + bad extension == VERY bad
+ task:insert_result(settings['symbol_double_extension'], badness_mult,
+ string.format(".%s.%s", ext2, ext))
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", part:get_id(), '-'))
+ return
+ end
+ end
+ if badness_mult then
+ -- Just bad extension
+ task:insert_result(settings['symbol_bad_extension'], badness_mult, ext)
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", part:get_id(), '-'))
+ end
+ end
+
+ -- Process settings
+ local extra_table = {}
+ local extra_archive_table = {}
+ local user_settings = task:cache_get('settings')
+ if user_settings and user_settings.plugins then
+ user_settings = user_settings.plugins.mime_types
+ end
+
+ if user_settings then
+ logger.infox(task, 'using special tables from user settings')
+ if user_settings.bad_extensions then
+ if user_settings.bad_extensions[1] then
+ -- Convert to a key-value map
+ extra_table = fun.tomap(
+ fun.map(function(e)
+ return e, 1.0
+ end,
+ user_settings.bad_extensions))
+ else
+ extra_table = user_settings.bad_extensions
+ end
+ end
+ if user_settings.bad_archive_extensions then
+ if user_settings.bad_archive_extensions[1] then
+ -- Convert to a key-value map
+ extra_archive_table = fun.tomap(fun.map(
+ function(e)
+ return e, 1.0
+ end,
+ user_settings.bad_archive_extensions))
+ else
+ extra_archive_table = user_settings.bad_archive_extensions
+ end
+ end
+ end
+
+ local function check_tables(e)
+ if is_archive then
+ return extra_archive_table[e] or (nfiles < 2 and settings.bad_archive_extensions[e]) or
+ extra_table[e] or settings.bad_extensions[e]
+ end
+
+ return extra_table[e] or settings.bad_extensions[e]
+ end
+
+ -- Also check for archive bad extension
+ if is_archive then
+ if ext2 then
+ local score1 = check_tables(ext)
+ local score2 = check_tables(ext2)
+ check_extension(score1, score2)
+ else
+ local score1 = check_tables(ext)
+ check_extension(score1, nil)
+ end
+
+ if settings['archive_extensions'][ext] then
+ -- Archive in archive
+ task:insert_result(settings['symbol_archive_in_archive'], 1.0, ext)
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", part:get_id(), '-'))
+ end
+ else
+ if ext2 then
+ local score1 = check_tables(ext)
+ local score2 = check_tables(ext2)
+ check_extension(score1, score2)
+ -- Check for archive cloaking like .zip.gz
+ if settings['archive_extensions'][ext2]
+ -- Exclude multipart archive extensions, e.g. .zip.001
+ and not string.match(ext, '^%d+$')
+ then
+ task:insert_result(settings['symbol_archive_in_archive'],
+ 1.0, string.format(".%s.%s", ext2, ext))
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", part:get_id(), '-'))
+ end
+ else
+ local score1 = check_tables(ext)
+ check_extension(score1, nil)
+ end
+ end
+
+ local mt = settings['extension_map'][ext]
+ if mt and ct and ct ~= 'application/octet-stream' then
+ local found
+ local mult
+ for _, v in ipairs(mt) do
+ mult = v.mult
+ if ct == v.ct then
+ found = true
+ break
+ end
+ end
+
+ if not found then
+ task:insert_result(settings['symbol_attachment'], mult, string.format('%s:%s',
+ ext, ct))
+ end
+ end
+ end
+
+ local parts = task:get_parts()
+
+ if parts then
+ for _, p in ipairs(parts) do
+ local mtype, subtype = p:get_type()
+
+ if not mtype then
+ lua_util.debugm(N, task, "no content type for part: %s", p:get_id())
+ task:insert_result(settings['symbol_unknown'], 1.0, 'missing content type')
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '~'))
+ else
+ -- Check for attachment
+ local filename = p:get_filename()
+ local ct = string.format('%s/%s', mtype, subtype):lower()
+ local detected_ext = p:get_detected_ext()
+
+ if filename then
+ check_filename(filename, ct, false, p, detected_ext, 1)
+ end
+
+ if p:is_archive() then
+ local check = true
+ if detected_ext then
+ local detected_type = lua_magic_types[detected_ext]
+
+ if detected_type.type ~= 'archive' then
+ logger.debugm("mime_types", task, "skip checking of %s as archive, %s is not archive but %s",
+ filename, detected_type.type)
+ check = false
+ end
+ end
+ if check and filename then
+ local ext = gen_extension(filename)
+
+ if ext and settings.archive_exceptions[ext] then
+ check = false
+ logger.debugm("mime_types", task, "skip checking of %s as archive, %s is whitelisted",
+ filename, ext)
+ end
+ end
+ local arch = p:get_archive()
+
+ -- TODO: migrate to flags once C part is ready
+ if arch:is_encrypted() then
+ task:insert_result(settings.symbol_encrypted_archive, 1.0, filename)
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '-'))
+ elseif arch:is_unreadable() then
+ task:insert_result(settings.symbol_encrypted_archive, 0.5, {
+ 'compressed header',
+ filename,
+ })
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '-'))
+ elseif arch:is_obfuscated() then
+ task:insert_result(settings.symbol_obfuscated_archive, 1.0, {
+ 'obfuscated archive',
+ filename,
+ })
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '-'))
+ end
+
+ if check then
+ local is_gen_split_rar = false
+ if filename then
+ local ext = gen_extension(filename)
+ is_gen_split_rar = ext and (string.match(ext, '^%d%d%d$')) and (arch:get_type() == 'rar')
+ end
+
+ local fl = arch:get_files_full(1000)
+
+ local nfiles = #fl
+
+ for _, f in ipairs(fl) do
+ if f['encrypted'] then
+ task:insert_result(settings['symbol_encrypted_archive'],
+ 1.0, f['name'])
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '-'))
+ end
+
+ if f['name'] then
+ if is_gen_split_rar and (gen_extension(f['name']) or '') == 'exe' then
+ task:insert_result(settings['symbol_exe_in_gen_split_rar'], 1.0, f['name'])
+ else
+ check_filename(f['name'], nil,
+ true, p, nil, nfiles)
+ end
+ end
+ end
+
+ if nfiles == 1 and fl[1].name then
+ -- We check that extension of the file inside archive is
+ -- the same as double extension of the file
+ local _, ext2 = gen_extension(filename)
+
+ if ext2 and #ext2 > 0 then
+ local enc_ext = gen_extension(fl[1].name)
+
+ if enc_ext
+ and settings['bad_extensions'][enc_ext]
+ and not tonumber(ext2)
+ and enc_ext ~= ext2 then
+ task:insert_result(settings['symbol_double_extension'], 2.0,
+ string.format("%s!=%s", ext2, enc_ext))
+ end
+ end
+ end
+ end
+ end
+
+ if map then
+ local v = map:get_key(ct)
+ local detected_different = false
+
+ local detected_type
+ if detected_ext then
+ detected_type = lua_magic_types[detected_ext]
+ end
+
+ if detected_type and detected_type.ct ~= ct then
+ local v_detected = map:get_key(detected_type.ct)
+ if not v or v_detected and v_detected > v then
+ v = v_detected
+ end
+ detected_different = true
+ end
+ if v then
+ local n = tonumber(v)
+
+ if n then
+ if n > 0 then
+ if detected_different then
+ -- Penalize case
+ n = n * 1.5
+ task:insert_result(settings['symbol_bad'], n,
+ string.format('%s:%s', ct, detected_type.ct))
+ else
+ task:insert_result(settings['symbol_bad'], n, ct)
+ end
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '-'))
+ elseif n < 0 then
+ task:insert_result(settings['symbol_good'], -n, ct)
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '+'))
+ else
+ -- Neutral content type
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '~'))
+ end
+ else
+ logger.warnx(task, 'unknown value: "%s" for content type %s in the map',
+ v, ct)
+ end
+ else
+ task:insert_result(settings['symbol_unknown'], 1.0, ct)
+ task:insert_result('MIME_TRACE', 0.0,
+ string.format("%s:%s", p:get_id(), '~'))
+ end
+ end
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('mime_types')
+if opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+
+ settings.filename_whitelist = lua_maps.rspamd_map_add('mime_types', 'filename_whitelist', 'regexp',
+ 'filename whitelist')
+
+ local function change_extension_map_entry(ext, ct, mult)
+ if type(ct) == 'table' then
+ local tbl = {}
+ for _, elt in ipairs(ct) do
+ table.insert(tbl, {
+ ct = elt,
+ mult = mult,
+ })
+ end
+ settings.extension_map[ext] = tbl
+ else
+ settings.extension_map[ext] = { [1] = {
+ ct = ct,
+ mult = mult
+ } }
+ end
+ end
+
+ -- Transform extension_map
+ for ext, ct in pairs(settings.extension_map) do
+ change_extension_map_entry(ext, ct, 1.0)
+ end
+
+ -- Add all extensions
+ for _, pair in ipairs(lua_mime_types.full_extensions_map) do
+ local ext, ct = pair[1], pair[2]
+ if not settings.extension_map[ext] then
+ change_extension_map_entry(ext, ct, settings.other_extensions_mult)
+ end
+ end
+
+ local map_type = 'map'
+ if settings['regexp'] then
+ map_type = 'regexp'
+ end
+ map = lua_maps.rspamd_map_add('mime_types', 'file', map_type,
+ 'mime types map')
+ if map then
+ local id = rspamd_config:register_symbol({
+ name = 'MIME_TYPES_CALLBACK',
+ callback = check_mime_type,
+ type = 'callback',
+ flags = 'nostat',
+ group = 'mime_types',
+ })
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_unknown'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_bad'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_good'],
+ flags = 'nice',
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_attachment'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_encrypted_archive'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_obfuscated_archive'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_exe_in_gen_split_rar'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_archive_in_archive'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_double_extension'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_bad_extension'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = settings['symbol_bad_unicode'],
+ parent = id,
+ group = 'mime_types',
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = 'MIME_TRACE',
+ parent = id,
+ group = 'mime_types',
+ flags = 'nostat',
+ score = 0,
+ })
+ else
+ lua_util.disable_module(N, "config")
+ end
+end
diff --git a/src/plugins/lua/multimap.lua b/src/plugins/lua/multimap.lua
new file mode 100644
index 0000000..53b2732
--- /dev/null
+++ b/src/plugins/lua/multimap.lua
@@ -0,0 +1,1403 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- Multimap is rspamd module designed to define and operate with different maps
+
+local rules = {}
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local rspamd_regexp = require "rspamd_regexp"
+local rspamd_expression = require "rspamd_expression"
+local rspamd_ip = require "rspamd_ip"
+local lua_util = require "lua_util"
+local lua_selectors = require "lua_selectors"
+local lua_maps = require "lua_maps"
+local redis_params
+local fun = require "fun"
+local N = 'multimap'
+
+local multimap_grammar
+-- Parse result in form: <symbol>:<score>|<symbol>|<score>
+local function parse_multimap_value(parse_rule, p_ret)
+ if p_ret and type(p_ret) == 'string' then
+ local lpeg = require "lpeg"
+
+ if not multimap_grammar then
+ local number = {}
+
+ local digit = lpeg.R("09")
+ number.integer = (lpeg.S("+-") ^ -1) *
+ (digit ^ 1)
+
+ -- Matches: .6, .899, .9999873
+ number.fractional = (lpeg.P(".")) *
+ (digit ^ 1)
+
+ -- Matches: 55.97, -90.8, .9
+ number.decimal = (number.integer * -- Integer
+ (number.fractional ^ -1)) + -- Fractional
+ (lpeg.S("+-") * number.fractional) -- Completely fractional number
+
+ local sym_start = lpeg.R("az", "AZ") + lpeg.S("_")
+ local sym_elt = sym_start + lpeg.R("09")
+ local symbol = sym_start * sym_elt ^ 0
+ local symbol_cap = lpeg.Cg(symbol, 'symbol')
+ local score_cap = lpeg.Cg(number.decimal, 'score')
+ local opts_cap = lpeg.Cg(lpeg.Ct(lpeg.C(symbol) * (lpeg.P(",") * lpeg.C(symbol)) ^ 0), 'opts')
+ local symscore_cap = (symbol_cap * lpeg.P(":") * score_cap)
+ local symscoreopt_cap = symscore_cap * lpeg.P(":") * opts_cap
+ local grammar = symscoreopt_cap + symscore_cap + symbol_cap + score_cap
+ multimap_grammar = lpeg.Ct(grammar)
+ end
+ local tbl = multimap_grammar:match(p_ret)
+
+ if tbl then
+ local sym
+ local score = 1.0
+ local opts = {}
+
+ if tbl.symbol then
+ sym = tbl.symbol
+ end
+ if tbl.score then
+ score = tonumber(tbl.score)
+ end
+ if tbl.opts then
+ opts = tbl.opts
+ end
+
+ return true, sym, score, opts
+ else
+ if p_ret ~= '' then
+ rspamd_logger.infox(rspamd_config, '%s: cannot parse string "%s"',
+ parse_rule.symbol, p_ret)
+ end
+
+ return true, nil, 1.0, {}
+ end
+ elseif type(p_ret) == 'boolean' then
+ return p_ret, nil, 1.0, {}
+ end
+
+ return false, nil, 0.0, {}
+end
+
+local value_types = {
+ ip = {
+ get_value = function(ip)
+ return ip:to_string()
+ end,
+ },
+ from = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ helo = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ header = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ rcpt = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ user = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ url = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ dnsbl = {
+ get_value = function(ip)
+ return ip:to_string()
+ end,
+ },
+ filename = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ content = {
+ get_value = function()
+ return nil
+ end,
+ },
+ hostname = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ asn = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ country = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ received = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ mempool = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ selector = {
+ get_value = function(val)
+ return val
+ end,
+ },
+ symbol_options = {
+ get_value = function(val)
+ return val
+ end,
+ },
+}
+
+local function ip_to_rbl(ip, rbl)
+ return table.concat(ip:inversed_str_octets(), ".") .. '.' .. rbl
+end
+
+local function apply_hostname_filter(task, filter, hostname, r)
+ if filter == 'tld' then
+ local tld = rspamd_util.get_tld(hostname)
+ return tld
+ elseif filter == 'top' then
+ local tld = rspamd_util.get_tld(hostname)
+ return tld:match('[^.]*$') or tld
+ else
+ if not r['re_filter'] then
+ local pat = string.match(filter, 'tld:regexp:(.+)')
+ if not pat then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ return
+ end
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'couldnt create regex: %s', pat)
+ return
+ end
+ end
+ local tld = rspamd_util.get_tld(hostname)
+ local res = r['re_filter']:search(tld)
+ if res then
+ return res[1]
+ else
+ return nil
+ end
+ end
+end
+
+local function apply_url_filter(task, filter, url, r)
+ if not filter then
+ return url:get_host()
+ end
+
+ if filter == 'tld' then
+ return url:get_tld()
+ elseif filter == 'top' then
+ local tld = url:get_tld()
+ return tld:match('[^.]*$') or tld
+ elseif filter == 'full' then
+ return url:get_text()
+ elseif filter == 'is_phished' then
+ if url:is_phished() then
+ return url:get_host()
+ else
+ return nil
+ end
+ elseif filter == 'is_redirected' then
+ if url:is_redirected() then
+ return url:get_host()
+ else
+ return nil
+ end
+ elseif filter == 'is_obscured' then
+ if url:is_obscured() then
+ return url:get_host()
+ else
+ return nil
+ end
+ elseif filter == 'path' then
+ return url:get_path()
+ elseif filter == 'query' then
+ return url:get_query()
+ elseif string.find(filter, 'tag:') then
+ local tags = url:get_tags()
+ local want_tag = string.match(filter, 'tag:(.*)')
+ for _, t in ipairs(tags) do
+ if t == want_tag then
+ return url:get_host()
+ end
+ end
+ return nil
+ elseif string.find(filter, 'tld:regexp:') then
+ if not r['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = r['re_filter']:search(url:get_tld())
+ if results then
+ return results[1]
+ else
+ return nil
+ end
+ end
+ elseif string.find(filter, 'full:regexp:') then
+ if not r['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = r['re_filter']:search(url:get_text())
+ if results then
+ return results[1]
+ else
+ return nil
+ end
+ end
+ elseif string.find(filter, 'regexp:') then
+ if not r['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = r['re_filter']:search(url:get_host())
+ if results then
+ return results[1]
+ else
+ return nil
+ end
+ end
+ elseif string.find(filter, '^template:') then
+ if not r['template'] then
+ r['template'] = string.match(filter, '^template:(.+)')
+ end
+
+ if r['template'] then
+ return lua_util.template(r['template'], url:to_table())
+ end
+ end
+
+ return url:get_host()
+end
+
+local function apply_addr_filter(task, filter, input, rule)
+ if filter == 'email:addr' or filter == 'email' then
+ local addr = rspamd_util.parse_mail_address(input, task:get_mempool(), 1024)
+ if addr and addr[1] then
+ return fun.totable(fun.map(function(a)
+ return a.addr
+ end, addr))
+ end
+ elseif filter == 'email:user' then
+ local addr = rspamd_util.parse_mail_address(input, task:get_mempool(), 1024)
+ if addr and addr[1] then
+ return fun.totable(fun.map(function(a)
+ return a.user
+ end, addr))
+ end
+ elseif filter == 'email:domain' then
+ local addr = rspamd_util.parse_mail_address(input, task:get_mempool(), 1024)
+ if addr and addr[1] then
+ return fun.totable(fun.map(function(a)
+ return a.domain
+ end, addr))
+ end
+ elseif filter == 'email:domain:tld' then
+ local addr = rspamd_util.parse_mail_address(input, task:get_mempool(), 1024)
+ if addr and addr[1] then
+ return fun.totable(fun.map(function(a)
+ return rspamd_util.get_tld(a.domain)
+ end, addr))
+ end
+ elseif filter == 'email:name' then
+ local addr = rspamd_util.parse_mail_address(input, task:get_mempool(), 1024)
+ if addr and addr[1] then
+ return fun.totable(fun.map(function(a)
+ return a.name
+ end, addr))
+ end
+ elseif filter == 'ip_addr' then
+ local ip_addr = rspamd_ip.from_string(input)
+
+ if ip_addr and ip_addr:is_valid() then
+ return ip_addr
+ end
+ else
+ -- regexp case
+ if not rule['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ rule['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not rule['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = rule['re_filter']:search(input)
+ if results then
+ return results[1]
+ end
+ end
+ end
+
+ return input
+end
+local function apply_filename_filter(task, filter, fn, r)
+ if filter == 'extension' or filter == 'ext' then
+ return string.match(fn, '%.([^.]+)$')
+ elseif string.find(filter, 'regexp:') then
+ if not r['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = r['re_filter']:search(fn)
+ if results then
+ return results[1]
+ else
+ return nil
+ end
+ end
+ end
+
+ return fn
+end
+
+local function apply_regexp_filter(task, filter, fn, r)
+ if string.find(filter, 'regexp:') then
+ if not r['re_filter'] then
+ local type, pat = string.match(filter, '(regexp:)(.+)')
+ if type and pat then
+ r['re_filter'] = rspamd_regexp.create_cached(pat)
+ end
+ end
+
+ if not r['re_filter'] then
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ else
+ local results = r['re_filter']:search(fn, false, true)
+ if results then
+ return results[1][2]
+ else
+ return nil
+ end
+ end
+ end
+
+ return fn
+end
+
+local function apply_content_filter(task, filter)
+ if filter == 'body' then
+ return { task:get_rawbody() }
+ elseif filter == 'full' then
+ return { task:get_content() }
+ elseif filter == 'headers' then
+ return { task:get_raw_headers() }
+ elseif filter == 'text' then
+ local ret = {}
+ for _, p in ipairs(task:get_text_parts()) do
+ table.insert(ret, p:get_content())
+ end
+ return ret
+ elseif filter == 'rawtext' then
+ local ret = {}
+ for _, p in ipairs(task:get_text_parts()) do
+ table.insert(ret, p:get_content('raw_parsed'))
+ end
+ return ret
+ elseif filter == 'oneline' then
+ local ret = {}
+ for _, p in ipairs(task:get_text_parts()) do
+ table.insert(ret, p:get_content_oneline())
+ end
+ return ret
+ else
+ rspamd_logger.errx(task, 'bad search filter: %s', filter)
+ end
+
+ return {}
+end
+
+local multimap_filters = {
+ from = apply_addr_filter,
+ rcpt = apply_addr_filter,
+ helo = apply_hostname_filter,
+ symbol_options = apply_regexp_filter,
+ header = apply_addr_filter,
+ url = apply_url_filter,
+ filename = apply_filename_filter,
+ mempool = apply_regexp_filter,
+ selector = apply_regexp_filter,
+ hostname = apply_hostname_filter,
+ --content = apply_content_filter, -- Content filters are special :(
+}
+
+local function multimap_query_redis(key, task, value, callback)
+ local cmd = 'HGET'
+ if type(value) == 'userdata' and value.class == 'rspamd{ip}' then
+ cmd = 'HMGET'
+ end
+
+ local srch = { key }
+
+ -- Insert all ips for some mask :(
+ if type(value) == 'userdata' and value.class == 'rspamd{ip}' then
+ srch[#srch + 1] = tostring(value)
+ -- IPv6 case
+ local maxbits = 128
+ local minbits = 64
+ if value:get_version() == 4 then
+ maxbits = 32
+ minbits = 8
+ end
+ for i = maxbits, minbits, -1 do
+ local nip = value:apply_mask(i):tostring() .. "/" .. i
+ srch[#srch + 1] = nip
+ end
+ else
+ srch[#srch + 1] = value
+ end
+
+ local function redis_map_cb(err, data)
+ lua_util.debugm(N, task, 'got reply from Redis when trying to get key %s: err=%s, data=%s',
+ key, err, data)
+ if not err and type(data) ~= 'userdata' then
+ callback(data)
+ end
+ end
+
+ return rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_map_cb, --callback
+ cmd, -- command
+ srch -- arguments
+ )
+end
+
+local function multimap_callback(task, rule)
+ local function match_element(r, value, callback)
+ if not value then
+ return false
+ end
+
+ local function get_key_callback(ret, err_or_data, err_code)
+ lua_util.debugm(N, task, 'got return "%s" (err code = %s) for multimap %s',
+ err_or_data,
+ err_code,
+ rule.symbol)
+
+ if ret then
+ if type(err_or_data) == 'table' then
+ for _, elt in ipairs(err_or_data) do
+ callback(elt)
+ end
+ else
+ callback(err_or_data)
+ end
+ elseif err_code ~= 404 then
+ rspamd_logger.infox(task, "map %s: get key returned error %s: %s",
+ rule.symbol, err_code, err_or_data)
+ end
+ end
+
+ lua_util.debugm(N, task, 'check value %s for multimap %s', value,
+ rule.symbol)
+
+ local ret = false
+
+ if r.redis_key then
+ -- Deal with hash name here: it can be either plain string or a selector
+ if type(r.redis_key) == 'string' then
+ ret = multimap_query_redis(r.redis_key, task, value, callback)
+ else
+ -- Here we have a selector
+ local results = r.redis_key(task)
+
+ -- Here we need to spill this function into multiple queries
+ if type(results) == 'table' then
+ for _, res in ipairs(results) do
+ ret = multimap_query_redis(res, task, value, callback)
+
+ if not ret then
+ break
+ end
+ end
+ else
+ ret = multimap_query_redis(results, task, value, callback)
+ end
+ end
+
+ return ret
+ elseif r.map_obj then
+ r.map_obj:get_key(value, get_key_callback, task)
+ end
+ end
+
+ local function insert_results(result, opt)
+ local _, symbol, score, opts = parse_multimap_value(rule, result)
+ local forced = false
+ if symbol then
+ if rule.symbols_set then
+ if not rule.symbols_set[symbol] then
+ rspamd_logger.infox(task, 'symbol %s is not registered for map %s, ' ..
+ 'replace it with just %s',
+ symbol, rule.symbol, rule.symbol)
+ symbol = rule.symbol
+ end
+ elseif rule.disable_multisymbol then
+ symbol = rule.symbol
+ if type(opt) == 'table' then
+ table.insert(opt, result)
+ elseif type(opt) ~= nil then
+ opt = { opt, result }
+ else
+ opt = { result }
+ end
+ else
+ forced = not rule.dynamic_symbols
+ end
+ else
+ symbol = rule.symbol
+ end
+
+ if opts and #opts > 0 then
+ -- Options come from the map itself
+ task:insert_result(forced, symbol, score, opts)
+ else
+ if opt then
+ if type(opt) == 'table' then
+ task:insert_result(forced, symbol, score, fun.totable(fun.map(tostring, opt)))
+ else
+ task:insert_result(forced, symbol, score, tostring(opt))
+ end
+
+ else
+ task:insert_result(forced, symbol, score)
+ end
+ end
+
+ if rule.action then
+ local message = rule.message
+ if rule.message_func then
+ message = rule.message_func(task, rule.symbol, opt)
+ end
+ if message then
+ task:set_pre_result(rule.action, message, N)
+ else
+ task:set_pre_result(rule.action, 'Matched map: ' .. rule.symbol, N)
+ end
+ end
+ end
+
+ -- Match a single value for against a single rule
+ local function match_rule(r, value)
+ local function rule_callback(result)
+ if result then
+ if type(result) == 'table' then
+ for _, rs in ipairs(result) do
+ if type(rs) ~= 'userdata' then
+ rule_callback(rs)
+ end
+ end
+ return
+ end
+ local opt = value_types[r['type']].get_value(value)
+ insert_results(result, opt)
+ end
+ end
+
+ if r.filter or r.type == 'url' then
+ local fn = multimap_filters[r.type]
+
+ if fn then
+
+ local filtered_value = fn(task, r.filter, value, r)
+ lua_util.debugm(N, task, 'apply filter %s for rule %s: %s -> %s',
+ r.filter, r.symbol, value, filtered_value)
+ value = filtered_value
+ end
+ end
+
+ if type(value) == 'table' then
+ fun.each(function(elt)
+ match_element(r, elt, rule_callback)
+ end, value)
+ else
+ match_element(r, value, rule_callback)
+ end
+ end
+
+ -- Match list of values according to the field
+ local function match_list(r, ls, fields)
+ if ls then
+ if fields then
+ fun.each(function(e)
+ local match = e[fields[1]]
+ if match then
+ if fields[2] then
+ match = fields[2](match)
+ end
+ match_rule(r, match)
+ end
+ end, ls)
+ else
+ fun.each(function(e)
+ match_rule(r, e)
+ end, ls)
+ end
+ end
+ end
+
+ local function match_addr(r, addr)
+ match_list(r, addr, { 'addr' })
+
+ if not r.filter then
+ match_list(r, addr, { 'domain' })
+ match_list(r, addr, { 'user' })
+ end
+ end
+
+ local function match_url(r, url)
+ match_rule(r, url)
+ end
+
+ local function match_hostname(r, hostname)
+ match_rule(r, hostname)
+ end
+
+ local function match_filename(r, fn)
+ match_rule(r, fn)
+ end
+
+ local function match_received_header(r, pos, total, h)
+ local use_tld = false
+ local filter = r['filter'] or 'real_ip'
+ if filter:match('^tld:') then
+ filter = filter:sub(5)
+ use_tld = true
+ end
+ local v = h[filter]
+ if v then
+ local min_pos = tonumber(r['min_pos'])
+ local max_pos = tonumber(r['max_pos'])
+ if min_pos then
+ if min_pos < 0 then
+ if min_pos == -1 then
+ if (pos ~= total) then
+ return
+ end
+ else
+ if pos <= (total - (min_pos * -1)) then
+ return
+ end
+ end
+ elseif pos < min_pos then
+ return
+ end
+ end
+ if max_pos then
+ if max_pos < -1 then
+ if (total - (max_pos * -1)) >= pos then
+ return
+ end
+ elseif max_pos > 0 then
+ if pos > max_pos then
+ return
+ end
+ end
+ end
+ local match_flags = r['flags']
+ local nmatch_flags = r['nflags']
+ if match_flags or nmatch_flags then
+ local got_flags = h['flags']
+ if match_flags then
+ for _, flag in ipairs(match_flags) do
+ if not got_flags[flag] then
+ return
+ end
+ end
+ end
+ if nmatch_flags then
+ for _, flag in ipairs(nmatch_flags) do
+ if got_flags[flag] then
+ return
+ end
+ end
+ end
+ end
+ if filter == 'real_ip' or filter == 'from_ip' then
+ if type(v) == 'string' then
+ v = rspamd_ip.from_string(v)
+ end
+ if v and v:is_valid() then
+ match_rule(r, v)
+ end
+ else
+ if use_tld and type(v) == 'string' then
+ v = rspamd_util.get_tld(v)
+ end
+ match_rule(r, v)
+ end
+ end
+ end
+
+ local function match_content(r)
+ local data
+
+ if r['filter'] then
+ data = apply_content_filter(task, r['filter'], r)
+ else
+ data = { task:get_content() }
+ end
+
+ for _, v in ipairs(data) do
+ match_rule(r, v)
+ end
+ end
+
+ if rule.expression and not rule.combined then
+ local res, trace = rule['expression']:process_traced(task)
+
+ if not res or res == 0 then
+ lua_util.debugm(N, task, 'condition is false for %s',
+ rule.symbol)
+ return
+ else
+ lua_util.debugm(N, task, 'condition is true for %s: %s',
+ rule.symbol,
+ trace)
+ end
+ end
+
+ local process_rule_funcs = {
+ ip = function()
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ match_rule(rule, ip)
+ end
+ end,
+ dnsbl = function()
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ local to_resolve = ip_to_rbl(ip, rule['map'])
+ local function dns_cb(_, _, results, err)
+ lua_util.debugm(N, rspamd_config,
+ 'resolve() finished: results=%1, err=%2, to_resolve=%3',
+ results, err, to_resolve)
+
+ if err and
+ (err ~= 'requested record is not found' and
+ err ~= 'no records with this name') then
+ rspamd_logger.errx(task, 'error looking up %s: %s', to_resolve, results)
+ elseif results then
+ task:insert_result(rule['symbol'], 1, rule['map'])
+ if rule.action then
+ task:set_pre_result(rule['action'],
+ 'Matched map: ' .. rule['symbol'], N)
+ end
+ end
+ end
+
+ task:get_resolver():resolve_a({
+ task = task,
+ name = to_resolve,
+ callback = dns_cb,
+ forced = true
+ })
+ end
+ end,
+ header = function()
+ if type(rule['header']) == 'table' then
+ for _, rh in ipairs(rule['header']) do
+ local hv = task:get_header_full(rh)
+ match_list(rule, hv, { 'decoded' })
+ end
+ else
+ local hv = task:get_header_full(rule['header'])
+ match_list(rule, hv, { 'decoded' })
+ end
+ end,
+ rcpt = function()
+ if task:has_recipients('smtp') then
+ local rcpts = task:get_recipients('smtp')
+ match_addr(rule, rcpts)
+ elseif task:has_recipients('mime') then
+ local rcpts = task:get_recipients('mime')
+ match_addr(rule, rcpts)
+ end
+ end,
+ from = function()
+ if task:has_from('smtp') then
+ local from = task:get_from('smtp')
+ match_addr(rule, from)
+ elseif task:has_from('mime') then
+ local from = task:get_from('mime')
+ match_addr(rule, from)
+ end
+ end,
+ helo = function()
+ local helo = task:get_helo()
+ if helo then
+ match_hostname(rule, helo)
+ end
+ end,
+ url = function()
+ if task:has_urls() then
+ local msg_urls = task:get_urls()
+
+ for _, url in ipairs(msg_urls) do
+ match_url(rule, url)
+ end
+ end
+ end,
+ user = function()
+ local user = task:get_user()
+ if user then
+ match_rule(rule, user)
+ end
+ end,
+ filename = function()
+ local parts = task:get_parts()
+
+ local function filter_parts(p)
+ return p:is_attachment() or (not p:is_text()) and (not p:is_multipart())
+ end
+
+ local function filter_archive(p)
+ local ext = p:get_detected_ext()
+ local det_type = 'unknown'
+
+ if ext then
+ local lua_magic_types = require "lua_magic/types"
+ local det_t = lua_magic_types[ext]
+
+ if det_t then
+ det_type = det_t.type
+ end
+ end
+
+ return p:is_archive() and det_type == 'archive' and not rule.skip_archives
+ end
+
+ for _, p in fun.iter(fun.filter(filter_parts, parts)) do
+ if filter_archive(p) then
+ local fnames = p:get_archive():get_files(1000)
+
+ for _, fn in ipairs(fnames) do
+ match_filename(rule, fn)
+ end
+ end
+
+ local fn = p:get_filename()
+ if fn then
+ match_filename(rule, fn)
+ end
+ -- Also deal with detected content type
+ if not rule.skip_detected then
+ local ext = p:get_detected_ext()
+
+ if ext then
+ local fake_fname = string.format('detected.%s', ext)
+ lua_util.debugm(N, task, 'detected filename %s',
+ fake_fname)
+ match_filename(rule, fake_fname)
+ end
+ end
+ end
+ end,
+
+ content = function()
+ match_content(rule)
+ end,
+ hostname = function()
+ local hostname = task:get_hostname()
+ if hostname then
+ match_hostname(rule, hostname)
+ end
+ end,
+ asn = function()
+ local asn = task:get_mempool():get_variable('asn')
+ if asn then
+ match_rule(rule, asn)
+ end
+ end,
+ country = function()
+ local country = task:get_mempool():get_variable('country')
+ if country then
+ match_rule(rule, country)
+ end
+ end,
+ mempool = function()
+ local var = task:get_mempool():get_variable(rule['variable'])
+ if var then
+ match_rule(rule, var)
+ end
+ end,
+ symbol_options = function()
+ local sym = task:get_symbol(rule['target_symbol'])
+ if sym and sym[1].options then
+ for _, o in ipairs(sym[1].options) do
+ match_rule(rule, o)
+ end
+ end
+ end,
+ received = function()
+ local hdrs = task:get_received_headers()
+ if hdrs and hdrs[1] then
+ if not rule['artificial'] then
+ hdrs = fun.filter(function(h)
+ return not h['flags']['artificial']
+ end, hdrs):totable()
+ end
+ for pos, h in ipairs(hdrs) do
+ match_received_header(rule, pos, #hdrs, h)
+ end
+ end
+ end,
+ selector = function()
+ local elts = rule.selector(task)
+
+ if elts then
+ if type(elts) == 'table' then
+ for _, elt in ipairs(elts) do
+ match_rule(rule, elt)
+ end
+ else
+ match_rule(rule, elts)
+ end
+ end
+ end,
+ combined = function()
+ local ret, trace = rule.combined:process(task)
+ if ret and ret ~= 0 then
+ for n, t in pairs(trace) do
+ insert_results(t.value, string.format("%s=%s",
+ n, t.matched))
+ end
+ end
+ end,
+ }
+
+ local rt = rule.type
+ local process_func = process_rule_funcs[rt]
+ if process_func then
+ process_func()
+ else
+ rspamd_logger.errx(task, 'Unrecognised rule type: %s', rt)
+ end
+end
+
+local function gen_multimap_callback(rule)
+ return function(task)
+ multimap_callback(task, rule)
+ end
+end
+
+local function multimap_on_load_gen(rule)
+ return function()
+ lua_util.debugm(N, rspamd_config, "loaded map object for rule %s", rule['symbol'])
+ local known_symbols = {}
+ rule.map_obj:foreach(function(key, value)
+ local r, symbol, score, _ = parse_multimap_value(rule, value)
+
+ if r and symbol and not known_symbols[symbol] then
+ lua_util.debugm(N, rspamd_config, "%s: adding new symbol %s (score = %s), triggered by %s",
+ rule.symbol, symbol, score, key)
+ rspamd_config:register_symbol {
+ name = value,
+ parent = rule.callback_id,
+ type = 'virtual',
+ score = score,
+ }
+ rspamd_config:set_metric_symbol({
+ group = N,
+ score = 1.0, -- In future, we will parse score from `get_value` and use it as multiplier
+ description = 'Automatic symbol generated by rule: ' .. rule.symbol,
+ name = value,
+ })
+ known_symbols[value] = true
+ end
+ end)
+ end
+end
+
+local function add_multimap_rule(key, newrule)
+ local ret = false
+
+ local function multimap_load_kv_map(rule)
+ if rule['regexp'] then
+ if rule['multi'] then
+ rule.map_obj = lua_maps.map_add_from_ucl(rule.map, 'regexp_multi',
+ rule.description)
+ else
+ rule.map_obj = lua_maps.map_add_from_ucl(rule.map, 'regexp',
+ rule.description)
+ end
+ elseif rule['glob'] then
+ if rule['multi'] then
+ rule.map_obj = lua_maps.map_add_from_ucl(rule.map, 'glob_multi',
+ rule.description)
+ else
+ rule.map_obj = lua_maps.map_add_from_ucl(rule.map, 'glob',
+ rule.description)
+ end
+ else
+ rule.map_obj = lua_maps.map_add_from_ucl(rule.map, 'hash',
+ rule.description)
+ end
+ end
+
+ local known_generic_types = {
+ header = true,
+ rcpt = true,
+ from = true,
+ helo = true,
+ symbol_options = true,
+ filename = true,
+ url = true,
+ user = true,
+ content = true,
+ hostname = true,
+ asn = true,
+ country = true,
+ mempool = true,
+ selector = true,
+ combined = true
+ }
+
+ if newrule['message_func'] then
+ newrule['message_func'] = assert(load(newrule['message_func']))()
+ end
+ if newrule['url'] and not newrule['map'] then
+ newrule['map'] = newrule['url']
+ end
+ if not (newrule.map or newrule.rules) then
+ rspamd_logger.errx(rspamd_config, 'incomplete rule, missing map')
+ return nil
+ end
+ if not newrule['symbol'] and key then
+ newrule['symbol'] = key
+ elseif not newrule['symbol'] then
+ rspamd_logger.errx(rspamd_config, 'incomplete rule, missing symbol')
+ return nil
+ end
+ if not newrule['description'] then
+ newrule['description'] = string.format('multimap, type %s: %s', newrule['type'],
+ newrule['symbol'])
+ end
+ if newrule['type'] == 'mempool' and not newrule['variable'] then
+ rspamd_logger.errx(rspamd_config, 'mempool map requires variable')
+ return nil
+ end
+ if newrule['type'] == 'selector' then
+ if not newrule['selector'] then
+ rspamd_logger.errx(rspamd_config, 'selector map requires selector definition')
+ return nil
+ else
+ local selector = lua_selectors.create_selector_closure(
+ rspamd_config, newrule['selector'], newrule['delimiter'] or "")
+
+ if not selector then
+ rspamd_logger.errx(rspamd_config, 'selector map has invalid selector: "%s", symbol: %s',
+ newrule['selector'], newrule['symbol'])
+ return nil
+ end
+
+ newrule.selector = selector
+ end
+ end
+ if type(newrule['map']) == 'string' and
+ string.find(newrule['map'], '^redis://.*$') then
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no redis servers are specified, ' ..
+ 'cannot add redis map %s: %s', newrule['symbol'], newrule['map'])
+ return nil
+ end
+
+ newrule['redis_key'] = string.match(newrule['map'], '^redis://(.*)$')
+
+ if newrule['redis_key'] then
+ ret = true
+ end
+ elseif type(newrule['map']) == 'string' and
+ string.find(newrule['map'], '^redis%+selector://.*$') then
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no redis servers are specified, ' ..
+ 'cannot add redis map %s: %s', newrule['symbol'], newrule['map'])
+ return nil
+ end
+
+ local selector_str = string.match(newrule['map'], '^redis%+selector://(.*)$')
+ local selector = lua_selectors.create_selector_closure(
+ rspamd_config, selector_str, newrule['delimiter'] or "")
+
+ if not selector then
+ rspamd_logger.errx(rspamd_config, 'redis selector map has invalid selector: "%s", symbol: %s',
+ selector_str, newrule['symbol'])
+ return nil
+ end
+
+ newrule['redis_key'] = selector
+ ret = true
+ elseif newrule.type == 'combined' then
+ local lua_maps_expressions = require "lua_maps_expressions"
+ newrule.combined = lua_maps_expressions.create(rspamd_config,
+ {
+ rules = newrule.rules,
+ expression = newrule.expression,
+ on_load = newrule.dynamic_symbols and multimap_on_load_gen(newrule) or nil,
+ }, N, 'Combined map for ' .. newrule.symbol)
+ if not newrule.combined then
+ rspamd_logger.errx(rspamd_config, 'cannot add combined map for %s', newrule.symbol)
+ else
+ ret = true
+ end
+ else
+ if newrule['type'] == 'ip' then
+ newrule.map_obj = lua_maps.map_add_from_ucl(newrule.map, 'radix',
+ newrule.description)
+ if newrule.map_obj then
+ ret = true
+ else
+ rspamd_logger.warnx(rspamd_config, 'Cannot add rule: map doesn\'t exists: %1',
+ newrule['map'])
+ end
+ elseif newrule['type'] == 'received' then
+ if type(newrule['flags']) == 'table' and newrule['flags'][1] then
+ newrule['flags'] = newrule['flags']
+ elseif type(newrule['flags']) == 'string' then
+ newrule['flags'] = { newrule['flags'] }
+ end
+ if type(newrule['nflags']) == 'table' and newrule['nflags'][1] then
+ newrule['nflags'] = newrule['nflags']
+ elseif type(newrule['nflags']) == 'string' then
+ newrule['nflags'] = { newrule['nflags'] }
+ end
+ local filter = newrule['filter'] or 'real_ip'
+ if filter == 'real_ip' or filter == 'from_ip' then
+ newrule.map_obj = lua_maps.map_add_from_ucl(newrule.map, 'radix',
+ newrule.description)
+ if newrule.map_obj then
+ ret = true
+ else
+ rspamd_logger.warnx(rspamd_config, 'Cannot add rule: map doesn\'t exists: %1',
+ newrule['map'])
+ end
+ else
+ multimap_load_kv_map(newrule)
+
+ if newrule.map_obj then
+ ret = true
+ else
+ rspamd_logger.warnx(rspamd_config, 'Cannot add rule: map doesn\'t exists: %1',
+ newrule['map'])
+ end
+ end
+ elseif known_generic_types[newrule.type] then
+
+ if newrule.filter == 'ip_addr' then
+ newrule.map_obj = lua_maps.map_add_from_ucl(newrule.map, 'radix',
+ newrule.description)
+ elseif not newrule.combined then
+ multimap_load_kv_map(newrule)
+ end
+
+ if newrule.map_obj then
+ ret = true
+ else
+ rspamd_logger.warnx(rspamd_config, 'Cannot add rule: map doesn\'t exists: %1',
+ newrule['map'])
+ end
+ elseif newrule['type'] == 'dnsbl' then
+ ret = true
+ end
+ end
+
+ if ret then
+ if newrule.map_obj and newrule.dynamic_symbols then
+ newrule.map_obj:on_load(multimap_on_load_gen(newrule))
+ end
+ if newrule['type'] == 'symbol_options' then
+ rspamd_config:register_dependency(newrule['symbol'], newrule['target_symbol'])
+ end
+ if newrule['require_symbols'] then
+ local atoms = {}
+
+ local function parse_atom(str)
+ local atom = table.concat(fun.totable(fun.take_while(function(c)
+ if string.find(', \t()><+!|&\n', c, 1, true) then
+ return false
+ end
+ return true
+ end, fun.iter(str))), '')
+ table.insert(atoms, atom)
+ return atom
+ end
+
+ local function process_atom(atom, task)
+ local f_ret = task:has_symbol(atom)
+ lua_util.debugm(N, rspamd_config, 'check for symbol %s: %s', atom, f_ret)
+
+ if f_ret then
+ return 1
+ end
+
+ return 0
+ end
+
+ local expression = rspamd_expression.create(newrule['require_symbols'],
+ { parse_atom, process_atom }, rspamd_config:get_mempool())
+ if expression then
+ newrule['expression'] = expression
+
+ fun.each(function(v)
+ lua_util.debugm(N, rspamd_config, 'add dependency %s -> %s',
+ newrule['symbol'], v)
+ rspamd_config:register_dependency(newrule['symbol'], v)
+ end, atoms)
+ end
+ end
+ return newrule
+ end
+
+ return nil
+end
+
+-- Registration
+local opts = rspamd_config:get_all_opt(N)
+if opts and type(opts) == 'table' then
+ redis_params = rspamd_parse_redis_server(N)
+ for k, m in pairs(opts) do
+ if type(m) == 'table' and m['type'] then
+ local rule = add_multimap_rule(k, m)
+ if not rule then
+ rspamd_logger.errx(rspamd_config, 'cannot add rule: "' .. k .. '"')
+ else
+ rspamd_logger.infox(rspamd_config, 'added multimap rule: %s (%s)',
+ k, rule.type)
+ table.insert(rules, rule)
+ end
+ end
+ end
+ -- add fake symbol to check all maps inside a single callback
+ fun.each(function(rule)
+ local augmentations = {}
+
+ if rule.action then
+ table.insert(augmentations, 'passthrough')
+ end
+
+ local id = rspamd_config:register_symbol({
+ type = 'normal',
+ name = rule['symbol'],
+ augmentations = augmentations,
+ callback = gen_multimap_callback(rule),
+ })
+
+ rule.callback_id = id
+
+ if rule['symbols'] then
+ -- Find allowed symbols by this map
+ rule['symbols_set'] = {}
+ fun.each(function(s)
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = s,
+ parent = id,
+ score = tonumber(rule.score or "0") or 0, -- Default score
+ })
+ rule['symbols_set'][s] = 1
+ end, rule['symbols'])
+ end
+ if not rule.score then
+ rspamd_logger.infox(rspamd_config, 'set default score 0 for multimap rule %s', rule.symbol)
+ rule.score = 0
+ end
+ if rule.score then
+ -- Register metric symbol
+ rule.name = rule.symbol
+ rule.description = rule.description or 'multimap symbol'
+ rule.group = rule.group or N
+
+ local tmp_flags
+ tmp_flags = rule.flags
+
+ if rule.type == 'received' and rule.flags then
+ -- XXX: hack to allow received flags/nflags
+ -- See issue #3526 on GH
+ rule.flags = nil
+ end
+
+ -- XXX: for combined maps we use trace, so flags must include one_shot to avoid scores multiplication
+ if rule.combined and not rule.flags then
+ rule.flags = 'one_shot'
+ end
+ rspamd_config:set_metric_symbol(rule)
+ rule.flags = tmp_flags
+ end
+ end, rules)
+
+ if #rules == 0 then
+ lua_util.disable_module(N, "config")
+ end
+end
diff --git a/src/plugins/lua/mx_check.lua b/src/plugins/lua/mx_check.lua
new file mode 100644
index 0000000..71892b9
--- /dev/null
+++ b/src/plugins/lua/mx_check.lua
@@ -0,0 +1,392 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- MX check plugin
+local rspamd_logger = require "rspamd_logger"
+local rspamd_tcp = require "rspamd_tcp"
+local rspamd_util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local N = "mx_check"
+local fun = require "fun"
+
+local settings = {
+ timeout = 1.0, -- connect timeout
+ symbol_bad_mx = 'MX_INVALID',
+ symbol_no_mx = 'MX_MISSING',
+ symbol_good_mx = 'MX_GOOD',
+ symbol_white_mx = 'MX_WHITE',
+ expire = 86400, -- 1 day by default
+ expire_novalid = 7200, -- 2 hours by default for no valid mxes
+ greylist_invalid = true, -- Greylist first message with invalid MX (require greylist plugin)
+ key_prefix = 'rmx',
+ max_mx_a_records = 5, -- Maximum number of A records to check per MX request
+ wait_for_greeting = false, -- Wait for SMTP greeting and emit `quit` command
+}
+local redis_params
+local exclude_domains
+
+local E = {}
+local CRLF = '\r\n'
+local mx_miss_cache_prefix = 'mx_miss:'
+
+local function mx_check(task)
+ local ip_addr = task:get_ip()
+ if task:get_user() or (ip_addr and ip_addr:is_local()) then
+ return
+ end
+
+ local from = task:get_from('smtp')
+ local mx_domain
+ if ((from or E)[1] or E).domain and not from[2] then
+ mx_domain = from[1]['domain']
+ else
+ mx_domain = task:get_helo()
+
+ if mx_domain then
+ mx_domain = rspamd_util.get_tld(mx_domain)
+ end
+ end
+
+ if not mx_domain then
+ return
+ end
+
+ if exclude_domains then
+ if exclude_domains:get_key(mx_domain) then
+ rspamd_logger.infox(task, 'skip mx check for %s, excluded', mx_domain)
+ task:insert_result(settings.symbol_white_mx, 1.0, mx_domain)
+ return
+ end
+ end
+
+ local valid = false
+
+ local function check_results(mxes)
+ if fun.all(function(_, elt)
+ return elt.checked
+ end, mxes) then
+ -- Save cache
+ local key = settings.key_prefix .. mx_domain
+ local function redis_cache_cb(err)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_cache_cb received error: %1', err)
+ return
+ end
+ end
+ if not valid then
+ -- Greylist message
+ if settings.greylist_invalid then
+ task:get_mempool():set_variable("grey_greylisted_required", "1")
+ lua_util.debugm(N, task, "advice to greylist a message")
+ task:insert_result(settings.symbol_bad_mx, 1.0, "greylisted")
+ else
+ task:insert_result(settings.symbol_bad_mx, 1.0)
+ end
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_cache_cb, --callback
+ 'SETEX', -- command
+ { key, tostring(settings.expire_novalid), '0' } -- arguments
+ )
+ lua_util.debugm(N, task, "set redis cache key: %s; invalid MX", key)
+ if not ret then
+ rspamd_logger.errx(task, 'got error connecting to redis')
+ end
+ else
+ local valid_mx = {}
+ fun.each(function(k)
+ table.insert(valid_mx, k)
+ end, fun.filter(function(_, elt)
+ return elt.working
+ end, mxes))
+ task:insert_result(settings.symbol_good_mx, 1.0, valid_mx)
+ local value = table.concat(valid_mx, ';')
+ if mxes[mx_domain] and type(mxes[mx_domain]) == 'table' and mxes[mx_domain].mx_missing then
+ value = mx_miss_cache_prefix .. value
+ end
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_cache_cb, --callback
+ 'SETEX', -- command
+ { key, tostring(settings.expire), value } -- arguments
+ )
+ lua_util.debugm(N, task, "set redis cache key: %s; %s", key, value)
+ if not ret then
+ rspamd_logger.errx(task, 'error connecting to redis')
+ end
+ end
+ end
+ end
+
+ local function gen_mx_a_callback(name, mxes)
+ return function(_, _, results, err)
+ lua_util.debugm(N, task, "got DNS results for %s: %s", name, results)
+ mxes[name].ips = results
+
+ local function io_cb(io_err, _, conn)
+ lua_util.debugm(N, task, "TCP IO callback for %s, error: %s", name, io_err)
+ if io_err then
+ mxes[name].checked = true
+ conn:close()
+ else
+ mxes[name].checked = true
+ mxes[name].working = true
+ valid = true
+ if settings.wait_for_greeting then
+ conn:add_write(function(_)
+ conn:close()
+ end, string.format('QUIT%s', CRLF))
+ end
+ end
+ check_results(mxes)
+ end
+ local function on_connect_cb(conn)
+ lua_util.debugm(N, task, "TCP connect callback for %s, error: %s", name, err)
+ if err then
+ mxes[name].checked = true
+ conn:close()
+ check_results(mxes)
+ else
+ mxes[name].checked = true
+ valid = true
+ mxes[name].working = true
+ end
+
+ -- Disconnect without SMTP dialog
+ if not settings.wait_for_greeting then
+ check_results(mxes)
+ conn:close()
+ end
+ end
+
+ if err or not results or #results == 0 then
+ mxes[name].checked = true
+ else
+ -- Try to open TCP connection to port 25 for a random IP address
+ -- see #3839 on GitHub
+ lua_util.shuffle(results)
+ local str_ip = results[1]:to_string()
+ lua_util.debugm(N, task, "trying to connect to IP %s", str_ip)
+ local t_ret = rspamd_tcp.new({
+ task = task,
+ host = str_ip,
+ callback = io_cb,
+ stop_pattern = CRLF,
+ on_connect = on_connect_cb,
+ timeout = settings.timeout,
+ port = 25
+ })
+
+ if not t_ret then
+ mxes[name].checked = true
+ end
+ end
+ check_results(mxes)
+ end
+ end
+
+ local function mx_callback(_, _, results, err)
+ local mxes = {}
+ if err or not results then
+ local r = task:get_resolver()
+ -- XXX: maybe add ipv6?
+ -- fallback to implicit mx
+ if not err and not results then
+ err = 'no MX records found'
+ end
+
+ lua_util.debugm(N, task, "cannot find MX record for %s: %s, use implicit fallback",
+ mx_domain, err)
+ mxes[mx_domain] = { checked = false, working = false, ips = {}, mx_missing = true }
+ r:resolve('a', {
+ name = mx_domain,
+ callback = gen_mx_a_callback(mx_domain, mxes),
+ task = task,
+ forced = true
+ })
+ task:insert_result(settings.symbol_no_mx, 1.0, err)
+ else
+ -- Inverse sort by priority
+ table.sort(results, function(r1, r2)
+ return r1['priority'] > r2['priority']
+ end)
+
+ local max_mx_to_resolve = math.min(#results, settings.max_mx_a_records)
+ lua_util.debugm(N, task, 'check %s MX records (%d actually returned)',
+ max_mx_to_resolve, #results)
+ for i = 1, max_mx_to_resolve do
+ local mx = results[i]
+ mxes[mx.name] = { checked = false, working = false, ips = {} }
+ local r = task:get_resolver()
+ -- XXX: maybe add ipv6?
+ r:resolve('a', {
+ name = mx.name,
+ callback = gen_mx_a_callback(mx.name, mxes),
+ task = task,
+ forced = true
+ })
+ end
+ check_results(mxes)
+ end
+ end
+
+ if not redis_params then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ else
+ local function redis_cache_get_cb(err, data)
+ if err or type(data) ~= 'string' then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ else
+ if data == '0' then
+ task:insert_result(settings.symbol_bad_mx, 1.0, 'cached')
+ else
+ if lua_util.str_startswith(data, mx_miss_cache_prefix) then
+ task:insert_result(settings.symbol_no_mx, 1.0, 'cached')
+ data = string.sub(data, #mx_miss_cache_prefix + 1)
+ end
+ local mxes = lua_util.str_split(data, ';')
+ task:insert_result(settings.symbol_good_mx, 1.0, 'cached: ' .. mxes[1])
+ end
+ end
+ end
+
+ local key = settings.key_prefix .. mx_domain
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_cache_get_cb, --callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+
+ if not ret then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ end
+ end
+end
+
+-- Module setup
+local opts = rspamd_config:get_all_opt('mx_check')
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'module is unconfigured')
+ return
+end
+if opts then
+ redis_params = lua_redis.parse_redis_server('mx_check')
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'no redis servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ return
+ end
+
+ settings = lua_util.override_defaults(settings, opts)
+ lua_redis.register_prefix(settings.key_prefix .. '*', N,
+ 'MX check cache', {
+ type = 'string',
+ })
+
+ local id = rspamd_config:register_symbol({
+ name = settings.symbol_bad_mx,
+ type = 'normal',
+ callback = mx_check,
+ flags = 'empty',
+ augmentations = { string.format("timeout=%f", settings.timeout + rspamd_config:get_dns_timeout() or 0.0) },
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_no_mx,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_good_mx,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_white_mx,
+ type = 'virtual',
+ parent = id
+ })
+
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_bad_mx,
+ score = 0.5,
+ description = 'Domain has no working MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_good_mx,
+ score = -0.01,
+ description = 'Domain has working MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_white_mx,
+ score = 0.0,
+ description = 'Domain is whitelisted from MX check',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_no_mx,
+ score = 3.5,
+ description = 'Domain has no resolvable MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+
+ if settings.exclude_domains then
+ exclude_domains = rspamd_config:add_map {
+ type = 'set',
+ description = 'Exclude specific domains from MX checks',
+ url = settings.exclude_domains,
+ }
+ end
+end
diff --git a/src/plugins/lua/neural.lua b/src/plugins/lua/neural.lua
new file mode 100644
index 0000000..f3b26f1
--- /dev/null
+++ b/src/plugins/lua/neural.lua
@@ -0,0 +1,1000 @@
+--[[
+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.
+]]--
+
+
+if confighelp then
+ return
+end
+
+local fun = require "fun"
+local lua_redis = require "lua_redis"
+local lua_util = require "lua_util"
+local lua_verdict = require "lua_verdict"
+local neural_common = require "plugins/neural"
+local rspamd_kann = require "rspamd_kann"
+local rspamd_logger = require "rspamd_logger"
+local rspamd_tensor = require "rspamd_tensor"
+local rspamd_text = require "rspamd_text"
+local rspamd_util = require "rspamd_util"
+local ts = require("tableshape").types
+
+local N = "neural"
+
+local settings = neural_common.settings
+
+local redis_profile_schema = ts.shape {
+ digest = ts.string,
+ symbols = ts.array_of(ts.string),
+ version = ts.number,
+ redis_key = ts.string,
+ distance = ts.number:is_optional(),
+}
+
+local has_blas = rspamd_tensor.has_blas()
+local text_cookie = rspamd_text.cookie
+
+-- Creates and stores ANN profile in Redis
+local function new_ann_profile(task, rule, set, version)
+ local ann_key = neural_common.new_ann_key(rule, set, version, settings)
+
+ local profile = {
+ symbols = set.symbols,
+ redis_key = ann_key,
+ version = version,
+ digest = set.digest,
+ distance = 0 -- Since we are using our own profile
+ }
+
+ local ucl = require "ucl"
+ local profile_serialized = ucl.to_format(profile, 'json-compact', true)
+
+ local function add_cb(err, _)
+ if err then
+ rspamd_logger.errx(task, 'cannot store ANN profile for %s:%s at %s : %s',
+ rule.prefix, set.name, profile.redis_key, err)
+ else
+ rspamd_logger.infox(task, 'created new ANN profile for %s:%s, data stored at prefix %s',
+ rule.prefix, set.name, profile.redis_key)
+ end
+ end
+
+ lua_redis.redis_make_request(task,
+ rule.redis,
+ nil,
+ true, -- is write
+ add_cb, --callback
+ 'ZADD', -- command
+ { set.prefix, tostring(rspamd_util.get_time()), profile_serialized }
+ )
+
+ return profile
+end
+
+
+-- ANN filter function, used to insert scores based on the existing symbols
+local function ann_scores_filter(task)
+
+ for _, rule in pairs(settings.rules) do
+ local sid = task:get_settings_id() or -1
+ local ann
+ local profile
+
+ local set = neural_common.get_rule_settings(task, rule)
+ if set then
+ if set.ann then
+ ann = set.ann.ann
+ profile = set.ann
+ else
+ lua_util.debugm(N, task, 'no ann loaded for %s:%s',
+ rule.prefix, set.name)
+ end
+ else
+ lua_util.debugm(N, task, 'no ann defined in %s for settings id %s',
+ rule.prefix, sid)
+ end
+
+ if ann then
+ local vec = neural_common.result_to_vector(task, profile)
+
+ local score
+ local out = ann:apply1(vec, set.ann.pca)
+ score = out[1]
+
+ local symscore = string.format('%.3f', score)
+ task:cache_set(rule.prefix .. '_neural_score', score)
+ lua_util.debugm(N, task, '%s:%s:%s ann score: %s',
+ rule.prefix, set.name, set.ann.version, symscore)
+
+ if score > 0 then
+ local result = score
+
+ -- If spam_score_threshold is defined, override all other thresholds.
+ local spam_threshold = 0
+ if rule.spam_score_threshold then
+ spam_threshold = rule.spam_score_threshold
+ elseif rule.roc_enabled and not set.ann.roc_thresholds then
+ spam_threshold = set.ann.roc_thresholds[1]
+ end
+
+ if result >= spam_threshold then
+ if rule.flat_threshold_curve then
+ task:insert_result(rule.symbol_spam, 1.0, symscore)
+ else
+ task:insert_result(rule.symbol_spam, result, symscore)
+ end
+ else
+ lua_util.debugm(N, task, '%s:%s:%s ann score: %s < %s (spam threshold)',
+ rule.prefix, set.name, set.ann.version, symscore,
+ spam_threshold)
+ end
+ else
+ local result = -(score)
+
+ -- If ham_score_threshold is defined, override all other thresholds.
+ local ham_threshold = 0
+ if rule.ham_score_threshold then
+ ham_threshold = rule.ham_score_threshold
+ elseif rule.roc_enabled and not set.ann.roc_thresholds then
+ ham_threshold = set.ann.roc_thresholds[2]
+ end
+
+ if result >= ham_threshold then
+ if rule.flat_threshold_curve then
+ task:insert_result(rule.symbol_ham, 1.0, symscore)
+ else
+ task:insert_result(rule.symbol_ham, result, symscore)
+ end
+ else
+ lua_util.debugm(N, task, '%s:%s:%s ann score: %s < %s (ham threshold)',
+ rule.prefix, set.name, set.ann.version, result,
+ ham_threshold)
+ end
+ end
+ end
+ end
+end
+
+local function ann_push_task_result(rule, task, verdict, score, set)
+ local train_opts = rule.train
+ local learn_spam, learn_ham
+ local skip_reason = 'unknown'
+
+ if not train_opts.store_pool_only and train_opts.autotrain then
+ if train_opts.spam_score then
+ learn_spam = score >= train_opts.spam_score
+
+ if not learn_spam then
+ skip_reason = string.format('score < spam_score: %f < %f',
+ score, train_opts.spam_score)
+ end
+ else
+ learn_spam = verdict == 'spam' or verdict == 'junk'
+
+ if not learn_spam then
+ skip_reason = string.format('verdict: %s',
+ verdict)
+ end
+ end
+
+ if train_opts.ham_score then
+ learn_ham = score <= train_opts.ham_score
+ if not learn_ham then
+ skip_reason = string.format('score > ham_score: %f > %f',
+ score, train_opts.ham_score)
+ end
+ else
+ learn_ham = verdict == 'ham'
+
+ if not learn_ham then
+ skip_reason = string.format('verdict: %s',
+ verdict)
+ end
+ end
+ else
+ -- Train by request header
+ local hdr = task:get_request_header('ANN-Train')
+
+ if hdr then
+ if hdr:lower() == 'spam' then
+ learn_spam = true
+ elseif hdr:lower() == 'ham' then
+ learn_ham = true
+ else
+ skip_reason = 'no explicit header'
+ end
+ elseif train_opts.store_pool_only then
+ local ucl = require "ucl"
+ learn_ham = false
+ learn_spam = false
+
+ -- Explicitly store tokens in cache
+ local vec = neural_common.result_to_vector(task, set)
+ task:cache_set(rule.prefix .. '_neural_vec_mpack', ucl.to_format(vec, 'msgpack'))
+ task:cache_set(rule.prefix .. '_neural_profile_digest', set.digest)
+ skip_reason = 'store_pool_only has been set'
+ end
+ end
+
+ if learn_spam or learn_ham then
+ local learn_type
+ if learn_spam then
+ learn_type = 'spam'
+ else
+ learn_type = 'ham'
+ end
+
+ local function vectors_len_cb(err, data)
+ if not err and type(data) == 'table' then
+ local nspam, nham = data[1], data[2]
+
+ if neural_common.can_push_train_vector(rule, task, learn_type, nspam, nham) then
+ local vec = neural_common.result_to_vector(task, set)
+
+ local str = rspamd_util.zstd_compress(table.concat(vec, ';'))
+ local target_key = set.ann.redis_key .. '_' .. learn_type .. '_set'
+
+ local function learn_vec_cb(redis_err)
+ if redis_err then
+ rspamd_logger.errx(task, 'cannot store train vector for %s:%s: %s',
+ rule.prefix, set.name, redis_err)
+ else
+ lua_util.debugm(N, task,
+ "add train data for ANN rule " ..
+ "%s:%s, save %s vector of %s elts in %s key; %s bytes compressed",
+ rule.prefix, set.name, learn_type, #vec, target_key, #str)
+ end
+ end
+
+ lua_redis.redis_make_request(task,
+ rule.redis,
+ nil,
+ true, -- is write
+ learn_vec_cb, --callback
+ 'SADD', -- command
+ { target_key, str } -- arguments
+ )
+ else
+ lua_util.debugm(N, task,
+ "do not add %s train data for ANN rule " ..
+ "%s:%s",
+ learn_type, rule.prefix, set.name)
+ end
+ else
+ if err then
+ rspamd_logger.errx(task, 'cannot check if we can train %s:%s : %s',
+ rule.prefix, set.name, err)
+ elseif type(data) == 'string' then
+ -- nil return value
+ rspamd_logger.infox(task, "cannot learn %s ANN %s:%s; redis_key: %s: locked for learning: %s",
+ learn_type, rule.prefix, set.name, set.ann.redis_key, data)
+ else
+ rspamd_logger.errx(task, 'cannot check if we can train %s:%s : type of Redis key %s is %s, expected table' ..
+ 'please remove this key from Redis manually if you perform upgrade from the previous version',
+ rule.prefix, set.name, set.ann.redis_key, type(data))
+ end
+ end
+ end
+
+ -- Check if we can learn
+ if set.can_store_vectors then
+ if not set.ann then
+ -- Need to create or load a profile corresponding to the current configuration
+ set.ann = new_ann_profile(task, rule, set, 0)
+ lua_util.debugm(N, task,
+ 'requested new profile for %s, set.ann is missing',
+ set.name)
+ end
+
+ lua_redis.exec_redis_script(neural_common.redis_script_id.vectors_len,
+ { task = task, is_write = false },
+ vectors_len_cb,
+ {
+ set.ann.redis_key,
+ })
+ else
+ lua_util.debugm(N, task,
+ 'do not push data: train condition not satisfied; reason: not checked existing ANNs')
+ end
+ else
+ lua_util.debugm(N, task,
+ 'do not push data to key %s: train condition not satisfied; reason: %s',
+ (set.ann or {}).redis_key,
+ skip_reason)
+ end
+end
+
+--- Offline training logic
+
+-- Utility to extract and split saved training vectors to a table of tables
+local function process_training_vectors(data)
+ return fun.totable(fun.map(function(tok)
+ local _, str = rspamd_util.zstd_decompress(tok)
+ return fun.totable(fun.map(tonumber, lua_util.str_split(tostring(str), ';')))
+ end, data))
+end
+
+-- This function does the following:
+-- * Tries to lock ANN
+-- * Loads spam and ham vectors
+-- * Spawn learning process
+local function do_train_ann(worker, ev_base, rule, set, ann_key)
+ local spam_elts = {}
+ local ham_elts = {}
+
+ local function redis_ham_cb(err, data)
+ if err or type(data) ~= 'table' then
+ rspamd_logger.errx(rspamd_config, 'cannot get ham tokens for ANN %s from redis: %s',
+ ann_key, err)
+ -- Unlock on error
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ true, -- is write
+ neural_common.gen_unlock_cb(rule, set, ann_key), --callback
+ 'HDEL', -- command
+ { ann_key, 'lock' }
+ )
+ else
+ -- Decompress and convert to numbers each training vector
+ ham_elts = process_training_vectors(data)
+ neural_common.spawn_train({ worker = worker, ev_base = ev_base,
+ rule = rule, set = set, ann_key = ann_key, ham_vec = ham_elts,
+ spam_vec = spam_elts })
+ end
+ end
+
+ -- Spam vectors received
+ local function redis_spam_cb(err, data)
+ if err or type(data) ~= 'table' then
+ rspamd_logger.errx(rspamd_config, 'cannot get spam tokens for ANN %s from redis: %s',
+ ann_key, err)
+ -- Unlock ANN on error
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ true, -- is write
+ neural_common.gen_unlock_cb(rule, set, ann_key), --callback
+ 'HDEL', -- command
+ { ann_key, 'lock' }
+ )
+ else
+ -- Decompress and convert to numbers each training vector
+ spam_elts = process_training_vectors(data)
+ -- Now get ham vectors...
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ false, -- is write
+ redis_ham_cb, --callback
+ 'SMEMBERS', -- command
+ { ann_key .. '_ham_set' }
+ )
+ end
+ end
+
+ local function redis_lock_cb(err, data)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'cannot call lock script for ANN %s from redis: %s',
+ ann_key, err)
+ elseif type(data) == 'number' and data == 1 then
+ -- ANN is locked, so we can extract SPAM and HAM vectors and spawn learning
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ false, -- is write
+ redis_spam_cb, --callback
+ 'SMEMBERS', -- command
+ { ann_key .. '_spam_set' }
+ )
+
+ rspamd_logger.infox(rspamd_config, 'lock ANN %s:%s (key name %s) for learning',
+ rule.prefix, set.name, ann_key)
+ else
+ local lock_tm = tonumber(data[1])
+ rspamd_logger.infox(rspamd_config, 'do not learn ANN %s:%s (key name %s), ' ..
+ 'locked by another host %s at %s', rule.prefix, set.name, ann_key,
+ data[2], os.date('%c', lock_tm))
+ end
+ end
+
+ -- Check if we are already learning this network
+ if set.learning_spawned then
+ rspamd_logger.infox(rspamd_config, 'do not learn ANN %s, already learning another ANN',
+ ann_key)
+ return
+ end
+
+ -- Call Redis script that tries to acquire a lock
+ -- This script returns either a boolean or a pair {'lock_time', 'hostname'} when
+ -- ANN is locked by another host (or a process, meh)
+ lua_redis.exec_redis_script(neural_common.redis_script_id.maybe_lock,
+ { ev_base = ev_base, is_write = true },
+ redis_lock_cb,
+ {
+ ann_key,
+ tostring(os.time()),
+ tostring(math.max(10.0, rule.watch_interval * 2)),
+ rspamd_util.get_hostname()
+ })
+end
+
+-- This function loads new ann from Redis
+-- This is based on `profile` attribute.
+-- ANN is loaded from `profile.redis_key`
+-- Rank of `profile` key is also increased, unfortunately, it means that we need to
+-- serialize profile one more time and set its rank to the current time
+-- set.ann fields are set according to Redis data received
+local function load_new_ann(rule, ev_base, set, profile, min_diff)
+ local ann_key = profile.redis_key
+
+ local function data_cb(err, data)
+ if err then
+ rspamd_logger.errx(rspamd_config, 'cannot get ANN data from key: %s; %s',
+ ann_key, err)
+ else
+ if type(data) == 'table' then
+ if type(data[1]) == 'userdata' and data[1].cookie == text_cookie then
+ local _err, ann_data = rspamd_util.zstd_decompress(data[1])
+ local ann
+
+ if _err or not ann_data then
+ rspamd_logger.errx(rspamd_config, 'cannot decompress ANN for %s from Redis key %s: %s',
+ rule.prefix .. ':' .. set.name, ann_key, _err)
+ return
+ else
+ ann = rspamd_kann.load(ann_data)
+
+ if ann then
+ set.ann = {
+ digest = profile.digest,
+ version = profile.version,
+ symbols = profile.symbols,
+ distance = min_diff,
+ redis_key = profile.redis_key
+ }
+
+ local ucl = require "ucl"
+ local profile_serialized = ucl.to_format(profile, 'json-compact', true)
+ set.ann.ann = ann -- To avoid serialization
+
+ local function rank_cb(_, _)
+ -- TODO: maybe add some logging
+ end
+ -- Also update rank for the loaded ANN to avoid removal
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ true, -- is write
+ rank_cb, --callback
+ 'ZADD', -- command
+ { set.prefix, tostring(rspamd_util.get_time()), profile_serialized }
+ )
+ rspamd_logger.infox(rspamd_config,
+ 'loaded ANN for %s:%s from %s; %s bytes compressed; version=%s',
+ rule.prefix, set.name, ann_key, #data[1], profile.version)
+ else
+ rspamd_logger.errx(rspamd_config,
+ 'cannot unpack/deserialise ANN for %s:%s from Redis key %s',
+ rule.prefix, set.name, ann_key)
+ end
+ end
+ else
+ lua_util.debugm(N, rspamd_config, 'missing ANN for %s:%s in Redis key %s',
+ rule.prefix, set.name, ann_key)
+ end
+
+ if set.ann and set.ann.ann and type(data[2]) == 'userdata' and data[2].cookie == text_cookie then
+ if rule.roc_enabled then
+ local ucl = require "ucl"
+ local parser = ucl.parser()
+ local ok, parse_err = parser:parse_text(data[2])
+ assert(ok, parse_err)
+ local roc_thresholds = parser:get_object()
+ set.ann.roc_thresholds = roc_thresholds
+ rspamd_logger.infox(rspamd_config,
+ 'loaded ROC thresholds for %s:%s; version=%s',
+ rule.prefix, set.name, profile.version)
+ rspamd_logger.debugx("ROC thresholds: %s", roc_thresholds)
+ end
+ end
+
+ if set.ann and set.ann.ann and type(data[3]) == 'userdata' and data[3].cookie == text_cookie then
+ -- PCA table
+ local _err, pca_data = rspamd_util.zstd_decompress(data[3])
+ if pca_data then
+ if rule.max_inputs then
+ -- We can use PCA
+ set.ann.pca = rspamd_tensor.load(pca_data)
+ rspamd_logger.infox(rspamd_config,
+ 'loaded PCA for ANN for %s:%s from %s; %s bytes compressed; version=%s',
+ rule.prefix, set.name, ann_key, #data[3], profile.version)
+ else
+ -- no need in pca, why is it there?
+ rspamd_logger.warnx(rspamd_config,
+ 'extra PCA for ANN for %s:%s from Redis key %s: no max inputs defined',
+ rule.prefix, set.name, ann_key)
+ end
+ else
+ -- pca can be missing merely if we have no max_inputs
+ if rule.max_inputs then
+ rspamd_logger.errx(rspamd_config, 'cannot unpack/deserialise ANN for %s:%s from Redis key %s: no PCA: %s',
+ rule.prefix, set.name, ann_key, _err)
+ set.ann.ann = nil
+ else
+ -- It is okay
+ set.ann.pca = nil
+ end
+ end
+ end
+
+ else
+ lua_util.debugm(N, rspamd_config, 'no ANN key for %s:%s in Redis key %s',
+ rule.prefix, set.name, ann_key)
+ end
+ end
+ end
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ false, -- is write
+ data_cb, --callback
+ 'HMGET', -- command
+ { ann_key, 'ann', 'roc_thresholds', 'pca' }, -- arguments
+ { opaque_data = true }
+ )
+end
+
+-- Used to check an element in Redis serialized as JSON
+-- for some specific rule + some specific setting
+-- This function tries to load more fresh or more specific ANNs in lieu of
+-- the existing ones.
+-- Use this function to load ANNs as `callback` parameter for `check_anns` function
+local function process_existing_ann(_, ev_base, rule, set, profiles)
+ local my_symbols = set.symbols
+ local min_diff = math.huge
+ local sel_elt
+
+ for _, elt in fun.iter(profiles) do
+ if elt and elt.symbols then
+ local dist = lua_util.distance_sorted(elt.symbols, my_symbols)
+ -- Check distance
+ if dist < #my_symbols * .3 then
+ if dist < min_diff then
+ min_diff = dist
+ sel_elt = elt
+ end
+ end
+ end
+ end
+
+ if sel_elt then
+ -- We can load element from ANN
+ if set.ann then
+ -- We have an existing ANN, probably the same...
+ if set.ann.digest == sel_elt.digest then
+ -- Same ANN, check version
+ if set.ann.version < sel_elt.version then
+ -- Load new ann
+ rspamd_logger.infox(rspamd_config, 'ann %s is changed, ' ..
+ 'our version = %s, remote version = %s',
+ rule.prefix .. ':' .. set.name,
+ set.ann.version,
+ sel_elt.version)
+ load_new_ann(rule, ev_base, set, sel_elt, min_diff)
+ else
+ lua_util.debugm(N, rspamd_config, 'ann %s is not changed, ' ..
+ 'our version = %s, remote version = %s',
+ rule.prefix .. ':' .. set.name,
+ set.ann.version,
+ sel_elt.version)
+ end
+ else
+ -- We have some different ANN, so we need to compare distance
+ if set.ann.distance > min_diff then
+ -- Load more specific ANN
+ rspamd_logger.infox(rspamd_config, 'more specific ann is available for %s, ' ..
+ 'our distance = %s, remote distance = %s',
+ rule.prefix .. ':' .. set.name,
+ set.ann.distance,
+ min_diff)
+ load_new_ann(rule, ev_base, set, sel_elt, min_diff)
+ else
+ lua_util.debugm(N, rspamd_config, 'ann %s is not changed or less specific, ' ..
+ 'our distance = %s, remote distance = %s',
+ rule.prefix .. ':' .. set.name,
+ set.ann.distance,
+ min_diff)
+ end
+ end
+ else
+ -- We have no ANN, load new one
+ load_new_ann(rule, ev_base, set, sel_elt, min_diff)
+ end
+ end
+end
+
+
+-- This function checks all profiles and selects if we can train our
+-- ANN. By our we mean that it has exactly the same symbols in profile.
+-- Use this function to train ANN as `callback` parameter for `check_anns` function
+local function maybe_train_existing_ann(worker, ev_base, rule, set, profiles)
+ local my_symbols = set.symbols
+ local sel_elt
+ local lens = {
+ spam = 0,
+ ham = 0,
+ }
+
+ for _, elt in fun.iter(profiles) do
+ if elt and elt.symbols then
+ local dist = lua_util.distance_sorted(elt.symbols, my_symbols)
+ -- Check distance
+ if dist == 0 then
+ sel_elt = elt
+ break
+ end
+ end
+ end
+
+ if sel_elt then
+ -- We have our ANN and that's train vectors, check if we can learn
+ local ann_key = sel_elt.redis_key
+
+ lua_util.debugm(N, rspamd_config, "check if ANN %s needs to be trained",
+ ann_key)
+
+ -- Create continuation closure
+ local redis_len_cb_gen = function(cont_cb, what, is_final)
+ return function(err, data)
+ if err then
+ rspamd_logger.errx(rspamd_config,
+ 'cannot get ANN %s trains %s from redis: %s', what, ann_key, err)
+ elseif data and type(data) == 'number' or type(data) == 'string' then
+ local ntrains = tonumber(data) or 0
+ lens[what] = ntrains
+ if is_final then
+ -- Ensure that we have the following:
+ -- one class has reached max_trains
+ -- other class(es) are at least as full as classes_bias
+ -- e.g. if classes_bias = 0.25 and we have 10 max_trains then
+ -- one class must have 10 or more trains whilst another should have
+ -- at least (10 * (1 - 0.25)) = 8 trains
+
+ local max_len = math.max(lua_util.unpack(lua_util.values(lens)))
+ local min_len = math.min(lua_util.unpack(lua_util.values(lens)))
+
+ if rule.train.learn_type == 'balanced' then
+ local len_bias_check_pred = function(_, l)
+ return l >= rule.train.max_trains * (1.0 - rule.train.classes_bias)
+ end
+ if max_len >= rule.train.max_trains and fun.all(len_bias_check_pred, lens) then
+ rspamd_logger.debugm(N, rspamd_config,
+ 'can start ANN %s learn as it has %s learn vectors; %s required, after checking %s vectors',
+ ann_key, lens, rule.train.max_trains, what)
+ cont_cb()
+ else
+ rspamd_logger.debugm(N, rspamd_config,
+ 'cannot learn ANN %s now: there are not enough %s learn vectors (has %s vectors; %s required)',
+ ann_key, what, lens, rule.train.max_trains)
+ end
+ else
+ -- Probabilistic mode, just ensure that at least one vector is okay
+ if min_len > 0 and max_len >= rule.train.max_trains then
+ rspamd_logger.debugm(N, rspamd_config,
+ 'can start ANN %s learn as it has %s learn vectors; %s required, after checking %s vectors',
+ ann_key, lens, rule.train.max_trains, what)
+ cont_cb()
+ else
+ rspamd_logger.debugm(N, rspamd_config,
+ 'cannot learn ANN %s now: there are not enough %s learn vectors (has %s vectors; %s required)',
+ ann_key, what, lens, rule.train.max_trains)
+ end
+ end
+
+ else
+ rspamd_logger.debugm(N, rspamd_config,
+ 'checked %s vectors in ANN %s: %s vectors; %s required, need to check other class vectors',
+ what, ann_key, ntrains, rule.train.max_trains)
+ cont_cb()
+ end
+ end
+ end
+
+ end
+
+ local function initiate_train()
+ rspamd_logger.infox(rspamd_config,
+ 'need to learn ANN %s after %s required learn vectors',
+ ann_key, lens)
+ do_train_ann(worker, ev_base, rule, set, ann_key)
+ end
+
+ -- Spam vector is OK, check ham vector length
+ local function check_ham_len()
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ false, -- is write
+ redis_len_cb_gen(initiate_train, 'ham', true), --callback
+ 'SCARD', -- command
+ { ann_key .. '_ham_set' }
+ )
+ end
+
+ lua_redis.redis_make_request_taskless(ev_base,
+ rspamd_config,
+ rule.redis,
+ nil,
+ false, -- is write
+ redis_len_cb_gen(check_ham_len, 'spam', false), --callback
+ 'SCARD', -- command
+ { ann_key .. '_spam_set' }
+ )
+ end
+end
+
+-- Used to deserialise ANN element from a list
+local function load_ann_profile(element)
+ local ucl = require "ucl"
+
+ local parser = ucl.parser()
+ local res, ucl_err = parser:parse_string(element)
+ if not res then
+ rspamd_logger.warnx(rspamd_config, 'cannot parse ANN from redis: %s',
+ ucl_err)
+ return nil
+ else
+ local profile = parser:get_object()
+ local checked, schema_err = redis_profile_schema:transform(profile)
+ if not checked then
+ rspamd_logger.errx(rspamd_config, "cannot parse profile schema: %s", schema_err)
+
+ return nil
+ end
+ return checked
+ end
+end
+
+-- Function to check or load ANNs from Redis
+local function check_anns(worker, cfg, ev_base, rule, process_callback, what)
+ for _, set in pairs(rule.settings) do
+ local function members_cb(err, data)
+ if err then
+ rspamd_logger.errx(cfg, 'cannot get ANNs list from redis: %s',
+ err)
+ set.can_store_vectors = true
+ elseif type(data) == 'table' then
+ lua_util.debugm(N, cfg, '%s: process element %s:%s',
+ what, rule.prefix, set.name)
+ process_callback(worker, ev_base, rule, set, fun.map(load_ann_profile, data))
+ set.can_store_vectors = true
+ end
+ end
+
+ if type(set) == 'table' then
+ -- Extract all profiles for some specific settings id
+ -- Get the last `max_profiles` recently used
+ -- Select the most appropriate to our profile but it should not differ by more
+ -- than 30% of symbols
+ lua_redis.redis_make_request_taskless(ev_base,
+ cfg,
+ rule.redis,
+ nil,
+ false, -- is write
+ members_cb, --callback
+ 'ZREVRANGE', -- command
+ { set.prefix, '0', tostring(settings.max_profiles) } -- arguments
+ )
+ end
+ end -- Cycle over all settings
+
+ return rule.watch_interval
+end
+
+-- Function to clean up old ANNs
+local function cleanup_anns(rule, cfg, ev_base)
+ for _, set in pairs(rule.settings) do
+ local function invalidate_cb(err, data)
+ if err then
+ rspamd_logger.errx(cfg, 'cannot exec invalidate script in redis: %s',
+ err)
+ elseif type(data) == 'table' then
+ for _, expired in ipairs(data) do
+ local profile = load_ann_profile(expired)
+ rspamd_logger.infox(cfg, 'invalidated ANN for %s; redis key: %s; version=%s',
+ rule.prefix .. ':' .. set.name,
+ profile.redis_key,
+ profile.version)
+ end
+ end
+ end
+
+ if type(set) == 'table' then
+ lua_redis.exec_redis_script(neural_common.redis_script_id.maybe_invalidate,
+ { ev_base = ev_base, is_write = true },
+ invalidate_cb,
+ { set.prefix, tostring(settings.max_profiles) })
+ end
+ end
+end
+
+local function ann_push_vector(task)
+ if task:has_flag('skip') then
+ lua_util.debugm(N, task, 'do not push data for skipped task')
+ return
+ end
+ if not settings.allow_local and lua_util.is_rspamc_or_controller(task) then
+ lua_util.debugm(N, task, 'do not push data for manual scan')
+ return
+ end
+
+ local verdict, score = lua_verdict.get_specific_verdict(N, task)
+
+ if verdict == 'passthrough' then
+ lua_util.debugm(N, task, 'ignore task as its verdict is %s(%s)',
+ verdict, score)
+
+ return
+ end
+
+ if score ~= score then
+ lua_util.debugm(N, task, 'ignore task as its score is nan (%s verdict)',
+ verdict)
+
+ return
+ end
+
+ for _, rule in pairs(settings.rules) do
+ local set = neural_common.get_rule_settings(task, rule)
+
+ if set then
+ ann_push_task_result(rule, task, verdict, score, set)
+ else
+ lua_util.debugm(N, task, 'settings not found in rule %s', rule.prefix)
+ end
+
+ end
+end
+
+
+-- Initialization part
+if not (neural_common.module_config and type(neural_common.module_config) == 'table')
+ or not neural_common.redis_params then
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+ lua_util.disable_module(N, "redis")
+ return
+end
+
+local rules = neural_common.module_config['rules']
+
+if not rules then
+ -- Use legacy configuration
+ rules = {}
+ rules['default'] = neural_common.module_config
+end
+
+local id = rspamd_config:register_symbol({
+ name = 'NEURAL_CHECK',
+ type = 'postfilter,callback',
+ flags = 'nostat',
+ priority = lua_util.symbols_priorities.medium,
+ callback = ann_scores_filter
+})
+
+neural_common.settings.rules = {} -- Reset unless validated further in the cycle
+
+if settings.blacklisted_symbols and settings.blacklisted_symbols[1] then
+ -- Transform to hash for simplicity
+ settings.blacklisted_symbols = lua_util.list_to_hash(settings.blacklisted_symbols)
+end
+
+-- Check all rules
+for k, r in pairs(rules) do
+ local rule_elt = lua_util.override_defaults(neural_common.default_options, r)
+ rule_elt['redis'] = neural_common.redis_params
+ rule_elt['anns'] = {} -- Store ANNs here
+
+ if not rule_elt.prefix then
+ rule_elt.prefix = k
+ end
+ if not rule_elt.name then
+ rule_elt.name = k
+ end
+ if rule_elt.train.max_train and not rule_elt.train.max_trains then
+ rule_elt.train.max_trains = rule_elt.train.max_train
+ end
+
+ if not rule_elt.profile then
+ rule_elt.profile = {}
+ end
+
+ if rule_elt.max_inputs and not has_blas then
+ rspamd_logger.errx('cannot set max inputs to %s as BLAS is not compiled in',
+ rule_elt.name, rule_elt.max_inputs)
+ rule_elt.max_inputs = nil
+ end
+
+ rspamd_logger.infox(rspamd_config, "register ann rule %s", k)
+ settings.rules[k] = rule_elt
+ rspamd_config:set_metric_symbol({
+ name = rule_elt.symbol_spam,
+ score = 0.0,
+ description = 'Neural network SPAM',
+ group = 'neural'
+ })
+ rspamd_config:register_symbol({
+ name = rule_elt.symbol_spam,
+ type = 'virtual',
+ flags = 'nostat',
+ parent = id
+ })
+
+ rspamd_config:set_metric_symbol({
+ name = rule_elt.symbol_ham,
+ score = -0.0,
+ description = 'Neural network HAM',
+ group = 'neural'
+ })
+ rspamd_config:register_symbol({
+ name = rule_elt.symbol_ham,
+ type = 'virtual',
+ flags = 'nostat',
+ parent = id
+ })
+end
+
+rspamd_config:register_symbol({
+ name = 'NEURAL_LEARN',
+ type = 'idempotent,callback',
+ flags = 'nostat,explicit_disable,ignore_passthrough',
+ callback = ann_push_vector
+})
+
+-- We also need to deal with settings
+rspamd_config:add_post_init(neural_common.process_rules_settings)
+
+-- Add training scripts
+for _, rule in pairs(settings.rules) do
+ neural_common.load_scripts(rule.redis)
+ -- This function will check ANNs in Redis when a worker is loaded
+ rspamd_config:add_on_load(function(cfg, ev_base, worker)
+ if worker:is_scanner() then
+ rspamd_config:add_periodic(ev_base, 0.0,
+ function(_, _)
+ return check_anns(worker, cfg, ev_base, rule, process_existing_ann,
+ 'try_load_ann')
+ end)
+ end
+
+ if worker:is_primary_controller() then
+ -- We also want to train neural nets when they have enough data
+ rspamd_config:add_periodic(ev_base, 0.0,
+ function(_, _)
+ -- Clean old ANNs
+ cleanup_anns(rule, cfg, ev_base)
+ return check_anns(worker, cfg, ev_base, rule, maybe_train_existing_ann,
+ 'try_train_ann')
+ end)
+ end
+ end)
+end
diff --git a/src/plugins/lua/once_received.lua b/src/plugins/lua/once_received.lua
new file mode 100644
index 0000000..2a5552a
--- /dev/null
+++ b/src/plugins/lua/once_received.lua
@@ -0,0 +1,230 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- 0 or 1 received: = spam
+
+local symbol = 'ONCE_RECEIVED'
+local symbol_rdns = 'RDNS_NONE'
+local symbol_rdns_dnsfail = 'RDNS_DNSFAIL'
+local symbol_mx = 'DIRECT_TO_MX'
+-- Symbol for strict checks
+local symbol_strict = nil
+local bad_hosts = {}
+local good_hosts = {}
+local whitelist = nil
+
+local rspamd_logger = require "rspamd_logger"
+local lua_util = require "lua_util"
+local fun = require "fun"
+local N = 'once_received'
+
+local check_local = false
+local check_authed = false
+
+local function check_quantity_received (task)
+ local recvh = task:get_received_headers()
+
+ local nreceived = fun.reduce(function(acc, _)
+ return acc + 1
+ end, 0, fun.filter(function(h)
+ return not h['flags']['artificial']
+ end, recvh))
+
+ local function recv_dns_cb(_, to_resolve, results, err)
+ if err and (err ~= 'requested record is not found' and err ~= 'no records with this name') then
+ rspamd_logger.errx(task, 'error looking up %s: %s', to_resolve, err)
+ task:insert_result(symbol_rdns_dnsfail, 1.0)
+ end
+
+ if not results then
+ if nreceived <= 1 then
+ task:insert_result(symbol, 1)
+ -- Avoid strict symbol inserting as the remaining symbols have already
+ -- quote a significant weight, so a message could be rejected by just
+ -- this property.
+ --task:insert_result(symbol_strict, 1)
+ -- Check for MUAs
+ local ua = task:get_header('User-Agent')
+ local xm = task:get_header('X-Mailer')
+ if (ua or xm) then
+ task:insert_result(symbol_mx, 1, (ua or xm))
+ end
+ end
+ task:insert_result(symbol_rdns, 1)
+ else
+ rspamd_logger.infox(task, 'source hostname has not been passed to Rspamd from MTA, ' ..
+ 'but we could resolve source IP address PTR %s as "%s"',
+ to_resolve, results[1])
+ task:set_hostname(results[1])
+
+ if good_hosts then
+ for _, gh in ipairs(good_hosts) do
+ if string.find(results[1], gh) then
+ return
+ end
+ end
+ end
+
+ if nreceived <= 1 then
+ task:insert_result(symbol, 1)
+ for _, h in ipairs(bad_hosts) do
+ if string.find(results[1], h) then
+
+ task:insert_result(symbol_strict, 1, h)
+ return
+ end
+ end
+ end
+ end
+ end
+
+ local task_ip = task:get_ip()
+
+ if ((not check_authed and task:get_user()) or
+ (not check_local and task_ip and task_ip:is_local())) then
+ rspamd_logger.infox(task, 'Skipping once_received for authenticated user or local network')
+ return
+ end
+ if whitelist and task_ip and whitelist:get_key(task_ip) then
+ rspamd_logger.infox(task, 'whitelisted mail from %s',
+ task_ip:to_string())
+ return
+ end
+
+ local hn = task:get_hostname()
+ -- Here we don't care about received
+ if (not hn) and task_ip and task_ip:is_valid() then
+ task:get_resolver():resolve_ptr({ task = task,
+ name = task_ip:to_string(),
+ callback = recv_dns_cb,
+ forced = true
+ })
+ return
+ end
+
+ if nreceived <= 1 then
+ local ret = true
+ local r = recvh[1]
+
+ if not r then
+ return
+ end
+
+ if r['real_hostname'] then
+ local rhn = string.lower(r['real_hostname'])
+ -- Check for good hostname
+ if rhn and good_hosts then
+ for _, gh in ipairs(good_hosts) do
+ if string.find(rhn, gh) then
+ ret = false
+ break
+ end
+ end
+ end
+ end
+
+ if ret then
+ -- Strict checks
+ if symbol_strict then
+ -- Unresolved host
+ task:insert_result(symbol, 1)
+
+ if not hn then
+ return
+ end
+ for _, h in ipairs(bad_hosts) do
+ if string.find(hn, h) then
+ task:insert_result(symbol_strict, 1, h)
+ return
+ end
+ end
+ else
+ task:insert_result(symbol, 1)
+ end
+ end
+ end
+end
+
+local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+check_local = auth_and_local_conf[1]
+check_authed = auth_and_local_conf[2]
+
+-- Configuration
+local opts = rspamd_config:get_all_opt(N)
+if opts then
+ if opts['symbol'] then
+ symbol = opts['symbol']
+
+ local id = rspamd_config:register_symbol({
+ name = symbol,
+ callback = check_quantity_received,
+ })
+
+ for n, v in pairs(opts) do
+ if n == 'symbol_strict' then
+ symbol_strict = v
+ elseif n == 'symbol_rdns' then
+ symbol_rdns = v
+ elseif n == 'symbol_rdns_dnsfail' then
+ symbol_rdns_dnsfail = v
+ elseif n == 'bad_host' then
+ if type(v) == 'string' then
+ bad_hosts[1] = v
+ else
+ bad_hosts = v
+ end
+ elseif n == 'good_host' then
+ if type(v) == 'string' then
+ good_hosts[1] = v
+ else
+ good_hosts = v
+ end
+ elseif n == 'whitelist' then
+ local lua_maps = require "lua_maps"
+ whitelist = lua_maps.map_add('once_received', 'whitelist', 'radix',
+ 'once received whitelist')
+ elseif n == 'symbol_mx' then
+ symbol_mx = v
+ end
+ end
+
+ rspamd_config:register_symbol({
+ name = symbol_rdns,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = symbol_rdns_dnsfail,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = symbol_strict,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = symbol_mx,
+ type = 'virtual',
+ parent = id
+ })
+ end
+end
diff --git a/src/plugins/lua/p0f.lua b/src/plugins/lua/p0f.lua
new file mode 100644
index 0000000..97757c2
--- /dev/null
+++ b/src/plugins/lua/p0f.lua
@@ -0,0 +1,124 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2019, Denis Paavilainen <denpa@denpa.pro>
+
+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.
+]]--
+
+-- Detect remote OS via passive fingerprinting
+
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local rspamd_logger = require "rspamd_logger"
+local p0f = require("lua_scanners").filter('p0f').p0f
+
+local N = 'p0f'
+
+if confighelp then
+ rspamd_config:add_example(nil, N,
+ 'Detect remote OS via passive fingerprinting',
+ [[
+ p0f {
+ # Enable module
+ enabled = true
+
+ # Path to the unix socket that p0f listens on
+ socket = '/var/run/p0f.sock';
+
+ # Connection timeout
+ timeout = 5s;
+
+ # If defined, insert symbol with lookup results
+ symbol = 'P0F';
+
+ # Patterns to match against results returned by p0f
+ # Symbol will be yielded on OS string, link type or distance matches
+ patterns = {
+ WINDOWS = '^Windows.*';
+ #DSL = '^DSL$';
+ #DISTANCE10 = '^distance:10$';
+ }
+
+ # Cache lifetime in seconds (default - 2 hours)
+ expire = 7200;
+
+ # Cache key prefix
+ prefix = 'p0f';
+ }
+ ]])
+ return
+end
+
+local rule
+
+local function check_p0f(task)
+ local ip = task:get_from_ip()
+
+ if not (ip and ip:is_valid()) or ip:is_local() then
+ return
+ end
+
+ p0f.check(task, ip, rule)
+end
+
+local opts = rspamd_config:get_all_opt(N)
+
+rule = p0f.configure(opts)
+
+if rule then
+ rule.redis_params = lua_redis.parse_redis_server(N)
+
+ lua_redis.register_prefix(rule.prefix .. '*', N,
+ 'P0f check cache', {
+ type = 'string',
+ })
+
+ local id = rspamd_config:register_symbol({
+ name = 'P0F_CHECK',
+ type = 'prefilter',
+ callback = check_p0f,
+ priority = lua_util.symbols_priorities.medium,
+ flags = 'empty,nostat',
+ group = N,
+ augmentations = { string.format("timeout=%f", rule.timeout or 0.0) },
+
+ })
+
+ if rule.symbol then
+ rspamd_config:register_symbol({
+ name = rule.symbol,
+ parent = id,
+ type = 'virtual',
+ flags = 'empty',
+ group = N
+ })
+ end
+
+ for sym in pairs(rule.patterns) do
+ rspamd_logger.debugm(N, rspamd_config, 'registering: %1', {
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ group = N
+ })
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ name = sym,
+ parent = id,
+ group = N
+ })
+ end
+else
+ lua_util.disable_module(N, 'config')
+ rspamd_logger.infox('p0f module not configured');
+end
diff --git a/src/plugins/lua/phishing.lua b/src/plugins/lua/phishing.lua
new file mode 100644
index 0000000..05e08c0
--- /dev/null
+++ b/src/plugins/lua/phishing.lua
@@ -0,0 +1,667 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_maps = require "lua_maps"
+
+-- Phishing detection interface for selecting phished urls and inserting corresponding symbol
+--
+--
+local N = 'phishing'
+local symbol = 'PHISHED_URL'
+local phishing_feed_exclusion_symbol = 'PHISHED_EXCLUDED'
+local generic_service_symbol = 'PHISHED_GENERIC_SERVICE'
+local openphish_symbol = 'PHISHED_OPENPHISH'
+local phishtank_symbol = 'PHISHED_PHISHTANK'
+local generic_service_name = 'generic service'
+local domains = nil
+local phishing_exceptions_maps = {}
+local anchor_exceptions_maps = {}
+local strict_domains_maps = {}
+local phishing_feed_exclusion_map = nil
+local generic_service_map = nil
+local openphish_map = 'https://www.openphish.com/feed.txt'
+local phishtank_suffix = 'phishtank.rspamd.com'
+-- Not enabled by default as their feed is quite large
+local openphish_premium = false
+-- Published via DNS
+local phishtank_enabled = false
+local phishing_feed_exclusion_hash
+local generic_service_hash
+local openphish_hash
+local phishing_feed_exclusion_data = {}
+local generic_service_data = {}
+local openphish_data = {}
+
+local opts = rspamd_config:get_all_opt(N)
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+ return
+end
+
+local function is_host_excluded(exclusion_map, host)
+ if exclusion_map and host then
+ local excluded = exclusion_map[host]
+ if excluded then
+ return true
+ end
+ return false
+ end
+end
+
+local function phishing_cb(task)
+ local function check_phishing_map(table)
+ local phishing_data = {}
+ for k,v in pairs(table) do
+ phishing_data[k] = v
+ end
+ local url = phishing_data.url
+ local host = url:get_host()
+
+ if is_host_excluded(phishing_data.exclusion_map, host) then
+ task:insert_result(phishing_data.excl_symbol, 1.0, host)
+ return
+ end
+
+ if host then
+ local elt = phishing_data.map[host]
+ local found_path = false
+ local found_query = false
+ local data = nil
+
+ if elt then
+ local path = url:get_path()
+ local query = url:get_query()
+
+ if path then
+ for _, d in ipairs(elt) do
+ if d['path'] == path then
+ found_path = true
+ data = d['data']
+
+ if query and d['query'] and query == d['query'] then
+ found_query = true
+ elseif not d['query'] then
+ found_query = true
+ end
+ end
+ end
+ else
+ for _, d in ipairs(elt) do
+ if not d['path'] then
+ found_path = true
+ end
+
+ if query and d['query'] and query == d['query'] then
+ found_query = true
+ elseif not d['query'] then
+ found_query = true
+ end
+ end
+ end
+
+ if found_path then
+ local args
+
+ if type(data) == 'table' then
+ args = {
+ data['tld'],
+ data['sector'],
+ data['brand'],
+ }
+ elseif type(data) == 'string' then
+ args = data
+ else
+ args = host
+ end
+
+ if found_query then
+ -- Query + path match
+ task:insert_result(phishing_data.phish_symbol, 1.0, args)
+ else
+ -- Host + path match
+ if path then
+ task:insert_result(phishing_data.phish_symbol, 0.3, args)
+ end
+ -- No path, no symbol
+ end
+ else
+ if url:is_phished() then
+ -- Only host matches
+ task:insert_result(phishing_data.phish_symbol, 0.1, host)
+ end
+ end
+ end
+ end
+ end
+
+ local function check_phishing_dns(table)
+ local phishing_data = {}
+ for k,v in pairs(table) do
+ phishing_data[k] = v
+ end
+ local url = phishing_data.url
+ local host = url:get_host()
+
+ if is_host_excluded(phishing_data.exclusion_map, host) then
+ task:insert_result(phishing_data.excl_symbol, 1.0, host)
+ return
+ end
+
+ local function compose_dns_query(elts)
+ local cr = require "rspamd_cryptobox_hash"
+ local h = cr.create()
+ for _, elt in ipairs(elts) do
+ h:update(elt)
+ end
+ return string.format("%s.%s", h:base32():sub(1, 32), phishing_data.dns_suffix)
+ end
+
+ local r = task:get_resolver()
+ local path = url:get_path()
+ local query = url:get_query()
+
+ if host and path then
+ local function host_host_path_cb(_, _, results, err)
+ if not err and results then
+ if not query then
+ task:insert_result(phishing_data.phish_symbol, 1.0, results)
+ else
+ task:insert_result(phishing_data.phish_symbol, 0.3, results)
+ end
+ end
+ end
+
+ local to_resolve_hp = compose_dns_query({ host, path })
+ rspamd_logger.debugm(N, task, 'try to resolve {%s, %s} -> %s',
+ host, path, to_resolve_hp)
+ r:resolve_txt({
+ task = task,
+ name = to_resolve_hp,
+ callback = host_host_path_cb })
+
+ if query then
+ local function host_host_path_query_cb(_, _, results, err)
+ if not err and results then
+ task:insert_result(phishing_data.phish_symbol, 1.0, results)
+ end
+ end
+
+ local to_resolve_hpq = compose_dns_query({ host, path, query })
+ rspamd_logger.debugm(N, task, 'try to resolve {%s, %s, %s} -> %s',
+ host, path, query, to_resolve_hpq)
+ r:resolve_txt({
+ task = task,
+ name = to_resolve_hpq,
+ callback = host_host_path_query_cb })
+ end
+
+ end
+ end
+
+ -- Process all urls
+ local dmarc_dom
+ local dsym = task:get_symbol('DMARC_POLICY_ALLOW')
+ if dsym then
+ dsym = dsym[1] -- legacy stuff, need to take the first element
+ if dsym.options then
+ dmarc_dom = dsym.options[1]
+ end
+ end
+
+ local urls = task:get_urls() or {}
+ for _, url_iter in ipairs(urls) do
+ local function do_loop_iter()
+ -- to emulate continue
+ local url = url_iter
+ local phishing_data = {}
+ phishing_data.url = url
+ phishing_data.exclusion_map = phishing_feed_exclusion_data
+ phishing_data.excl_symbol = phishing_feed_exclusion_symbol
+ if generic_service_hash then
+ phishing_data.map = generic_service_data
+ phishing_data.phish_symbol = generic_service_symbol
+ check_phishing_map(phishing_data)
+ end
+
+ if openphish_hash then
+ phishing_data.map = openphish_data
+ phishing_data.phish_symbol = openphish_symbol
+ check_phishing_map(phishing_data)
+ end
+
+ if phishtank_enabled then
+ phishing_data.dns_suffix = phishtank_suffix
+ phishing_data.phish_symbol = phishtank_symbol
+ check_phishing_dns(phishing_data)
+ end
+
+ if url:is_phished() then
+ local purl
+
+ if url:is_redirected() then
+ local rspamd_url = require "rspamd_url"
+ -- Examine the real redirect target instead of the url
+ local redirected_url = url:get_redirected()
+ if not redirected_url then
+ return
+ end
+
+ purl = rspamd_url.create(task:get_mempool(), url:get_visible())
+ url = redirected_url
+ else
+ purl = url:get_phished()
+ end
+
+ if not purl then
+ return
+ end
+
+ local tld = url:get_tld()
+ local ptld = purl:get_tld()
+
+ if not ptld or not tld then
+ return
+ end
+
+ if dmarc_dom and tld == dmarc_dom then
+ lua_util.debugm(N, 'exclude phishing from %s -> %s by dmarc domain', tld,
+ ptld)
+ return
+ end
+
+ -- Now we can safely remove the last dot component if it is the same
+ local b, _ = string.find(tld, '%.[^%.]+$')
+ local b1, _ = string.find(ptld, '%.[^%.]+$')
+
+ local stripped_tld, stripped_ptld = tld, ptld
+ if b1 and b then
+ if string.sub(tld, b) == string.sub(ptld, b1) then
+ stripped_ptld = string.gsub(ptld, '%.[^%.]+$', '')
+ stripped_tld = string.gsub(tld, '%.[^%.]+$', '')
+ end
+
+ if #ptld == 0 or #tld == 0 then
+ return false
+ end
+ end
+
+ local weight = 1.0
+ local spoofed, why = util.is_utf_spoofed(tld, ptld)
+ if spoofed then
+ lua_util.debugm(N, task, "confusable: %1 -> %2: %3", tld, ptld, why)
+ weight = 1.0
+ else
+ local dist = util.levenshtein_distance(stripped_tld, stripped_ptld, 2)
+ dist = 2 * dist / (#stripped_tld + #stripped_ptld)
+
+ if dist > 0.3 and dist <= 1.0 then
+ -- Use distance to penalize the total weight
+ weight = util.tanh(3 * (1 - dist + 0.1))
+ elseif dist > 1 then
+ -- We also check if two labels are in the same ascii/non-ascii representation
+ local a1, a2 = false, false
+
+ if string.match(tld, '^[\001-\127]*$') then
+ a1 = true
+ end
+ if string.match(ptld, '^[\001-\127]*$') then
+ a2 = true
+ end
+
+ if a1 ~= a2 then
+ weight = 1
+ lua_util.debugm(N, task, "confusable: %1 -> %2: different characters",
+ tld, ptld, why)
+ else
+ -- We have totally different strings in tld, so penalize it somehow
+ weight = 0.5
+ end
+ end
+
+ lua_util.debugm(N, task, "distance: %1 -> %2: %3", tld, ptld, dist)
+ end
+
+ local function is_url_in_map(map, furl)
+ for _, dn in ipairs({ furl:get_tld(), furl:get_host() }) do
+ if map:get_key(dn) then
+ return true, dn
+ end
+ end
+
+ return false
+ end
+ local function found_in_map(map, furl, sweight)
+ if not furl then
+ furl = url
+ end
+ if not sweight then
+ sweight = weight
+ end
+ if #map > 0 then
+ for _, rule in ipairs(map) do
+ local found, dn = is_url_in_map(rule.map, furl)
+ if found then
+ task:insert_result(rule.symbol, sweight, string.format("%s->%s:%s", ptld, tld, dn))
+ return true
+ end
+ end
+ end
+ end
+
+ found_in_map(strict_domains_maps, purl, 1.0)
+ if not found_in_map(anchor_exceptions_maps) then
+ if not found_in_map(phishing_exceptions_maps, purl, 1.0) then
+ if domains then
+ if is_url_in_map(domains, purl) then
+ task:insert_result(symbol, weight, ptld .. '->' .. tld)
+ end
+ else
+ task:insert_result(symbol, weight, ptld .. '->' .. tld)
+ end
+ end
+ end
+ end
+ end
+
+ do_loop_iter()
+ end
+end
+
+local function phishing_map(mapname, phishmap, id)
+ if opts[mapname] then
+ local xd
+ if type(opts[mapname]) == 'table' then
+ xd = opts[mapname]
+ else
+ rspamd_logger.errx(rspamd_config, 'invalid exception table')
+ end
+
+ for sym, map_data in pairs(xd) do
+ local rmap = lua_maps.map_add_from_ucl(map_data, 'set',
+ 'Phishing ' .. mapname .. ' map')
+ if rmap then
+ rspamd_config:register_virtual_symbol(sym, 1, id)
+ local rule = { symbol = sym, map = rmap }
+ table.insert(phishmap, rule)
+ else
+ rspamd_logger.infox(rspamd_config, 'cannot add map for symbol: %s', sym)
+ end
+ end
+ end
+end
+
+local function rspamd_str_split_fun(s, sep, func)
+ local lpeg = require "lpeg"
+ sep = lpeg.P(sep)
+ local elem = lpeg.P((1 - sep) ^ 0 / func)
+ local p = lpeg.P(elem * (sep * elem) ^ 0)
+ return p:match(s)
+end
+
+local function insert_url_from_string(pool, tbl, str, data)
+ local rspamd_url = require "rspamd_url"
+
+ local u = rspamd_url.create(pool, str)
+
+ if u then
+ local host = u:get_host()
+ if host then
+ local elt = {
+ data = data,
+ path = u:get_path(),
+ query = u:get_query()
+ }
+
+ if tbl[host] then
+ table.insert(tbl[host], elt)
+ else
+ tbl[host] = { elt }
+ end
+
+ return true
+ end
+ end
+
+ return false
+end
+
+local function phishing_feed_exclusion_plain_cb(string)
+ local nelts = 0
+ local new_data = {}
+ local rspamd_mempool = require "rspamd_mempool"
+ local pool = rspamd_mempool.create()
+
+ local function phishing_feed_exclusion_elt_parser(cap)
+ if insert_url_from_string(pool, new_data, cap, nil) then
+ nelts = nelts + 1
+ end
+ end
+
+ rspamd_str_split_fun(string, '\n', phishing_feed_exclusion_elt_parser)
+
+ phishing_feed_exclusion_data = new_data
+ rspamd_logger.infox(phishing_feed_exclusion_hash, "parsed %s elements from phishing feed exclusions",
+ nelts)
+ pool:destroy()
+end
+
+local function generic_service_plain_cb(string)
+ local nelts = 0
+ local new_data = {}
+ local rspamd_mempool = require "rspamd_mempool"
+ local pool = rspamd_mempool.create()
+
+ local function generic_service_elt_parser(cap)
+ if insert_url_from_string(pool, new_data, cap, nil) then
+ nelts = nelts + 1
+ end
+ end
+
+ rspamd_str_split_fun(string, '\n', generic_service_elt_parser)
+
+ generic_service_data = new_data
+ rspamd_logger.infox(generic_service_hash, "parsed %s elements from %s feed",
+ nelts, generic_service_name)
+ pool:destroy()
+end
+
+local function openphish_json_cb(string)
+ local ucl = require "ucl"
+ local rspamd_mempool = require "rspamd_mempool"
+ local nelts = 0
+ local new_json_map = {}
+ local valid = true
+
+ local pool = rspamd_mempool.create()
+
+ local function openphish_elt_parser(cap)
+ if valid then
+ local parser = ucl.parser()
+ local res, err = parser:parse_string(cap)
+ if not res then
+ valid = false
+ rspamd_logger.warnx(openphish_hash, 'cannot parse openphish map: ' .. err)
+ else
+ local obj = parser:get_object()
+
+ if obj['url'] then
+ if insert_url_from_string(pool, new_json_map, obj['url'], obj) then
+ nelts = nelts + 1
+ end
+ end
+ end
+ end
+ end
+
+ rspamd_str_split_fun(string, '\n', openphish_elt_parser)
+
+ if valid then
+ openphish_data = new_json_map
+ rspamd_logger.infox(openphish_hash, "parsed %s elements from openphish feed",
+ nelts)
+ end
+
+ pool:destroy()
+end
+
+local function openphish_plain_cb(s)
+ local nelts = 0
+ local new_data = {}
+ local rspamd_mempool = require "rspamd_mempool"
+ local pool = rspamd_mempool.create()
+
+ local function openphish_elt_parser(cap)
+ if insert_url_from_string(pool, new_data, cap, nil) then
+ nelts = nelts + 1
+ end
+ end
+
+ rspamd_str_split_fun(s, '\n', openphish_elt_parser)
+
+ openphish_data = new_data
+ rspamd_logger.infox(openphish_hash, "parsed %s elements from openphish feed",
+ nelts)
+ pool:destroy()
+end
+
+if opts then
+ local id
+ if opts['symbol'] then
+ symbol = opts['symbol']
+ -- Register symbol's callback
+ id = rspamd_config:register_symbol({
+ name = symbol,
+ callback = phishing_cb
+ })
+
+ -- To exclude from domains for dmarc verified messages
+ rspamd_config:register_dependency(symbol, 'DMARC_CHECK')
+
+ if opts['phishing_feed_exclusion_symbol'] then
+ phishing_feed_exclusion_symbol = opts['phishing_feed_exclusion_symbol']
+ end
+ if opts['phishing_feed_exclusion_map'] then
+ phishing_feed_exclusion_map = opts['phishing_feed_exclusion_map']
+ end
+
+ if opts['phishing_feed_exclusion_enabled'] then
+ phishing_feed_exclusion_hash = rspamd_config:add_map({
+ type = 'callback',
+ url = phishing_feed_exclusion_map,
+ callback = phishing_feed_exclusion_plain_cb,
+ description = 'Phishing feed exclusions'
+ })
+ end
+
+ if opts['generic_service_symbol'] then
+ generic_service_symbol = opts['generic_service_symbol']
+ end
+ if opts['generic_service_map'] then
+ generic_service_map = opts['generic_service_map']
+ end
+ if opts['generic_service_url'] then
+ generic_service_map = opts['generic_service_url']
+ end
+ if opts['generic_service_name'] then
+ generic_service_name = opts['generic_service_name']
+ end
+
+ if opts['generic_service_enabled'] then
+ generic_service_hash = rspamd_config:add_map({
+ type = 'callback',
+ url = generic_service_map,
+ callback = generic_service_plain_cb,
+ description = 'Generic feed'
+ })
+ end
+
+ if opts['openphish_map'] then
+ openphish_map = opts['openphish_map']
+ end
+ if opts['openphish_url'] then
+ openphish_map = opts['openphish_url']
+ end
+
+ if opts['openphish_premium'] then
+ openphish_premium = true
+ end
+
+ if opts['openphish_enabled'] then
+ if not openphish_premium then
+ openphish_hash = rspamd_config:add_map({
+ type = 'callback',
+ url = openphish_map,
+ callback = openphish_plain_cb,
+ description = 'Open phishing feed map (see https://www.openphish.com for details)',
+ opaque_data = true,
+ })
+ else
+ openphish_hash = rspamd_config:add_map({
+ type = 'callback',
+ url = openphish_map,
+ callback = openphish_json_cb,
+ opaque_data = true,
+ description = 'Open phishing premium feed map (see https://www.openphish.com for details)'
+ })
+ end
+ end
+
+ if opts['phishtank_enabled'] then
+ phishtank_enabled = true
+ if opts['phishtank_suffix'] then
+ phishtank_suffix = opts['phishtank_suffix']
+ end
+ end
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ parent = id,
+ name = generic_service_symbol,
+ })
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ parent = id,
+ name = phishing_feed_exclusion_symbol,
+ })
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ parent = id,
+ name = openphish_symbol,
+ })
+
+ rspamd_config:register_symbol({
+ type = 'virtual',
+ parent = id,
+ name = phishtank_symbol,
+ })
+ end
+ if opts['domains'] and type(opts['domains']) == 'string' then
+ domains = lua_maps.map_add_from_ucl(opts['domains'], 'set',
+ 'Phishing domains')
+ end
+ phishing_map('phishing_exceptions', phishing_exceptions_maps, id)
+ phishing_map('exceptions', anchor_exceptions_maps, id)
+ phishing_map('strict_domains', strict_domains_maps, id)
+end
diff --git a/src/plugins/lua/ratelimit.lua b/src/plugins/lua/ratelimit.lua
new file mode 100644
index 0000000..add5741
--- /dev/null
+++ b/src/plugins/lua/ratelimit.lua
@@ -0,0 +1,868 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016-2017, Andrew Lewis <nerf@judo.za.org>
+
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local rspamd_lua_utils = require "lua_util"
+local lua_redis = require "lua_redis"
+local fun = require "fun"
+local lua_maps = require "lua_maps"
+local lua_util = require "lua_util"
+local lua_verdict = require "lua_verdict"
+local rspamd_hash = require "rspamd_cryptobox_hash"
+local lua_selectors = require "lua_selectors"
+local ts = require("tableshape").types
+
+-- A plugin that implements ratelimits using redis
+
+local E = {}
+local N = 'ratelimit'
+local redis_params
+-- Senders that are considered as bounce
+local settings = {
+ bounce_senders = { 'postmaster', 'mailer-daemon', '', 'null', 'fetchmail-daemon', 'mdaemon' },
+ -- Do not check ratelimits for these recipients
+ whitelisted_rcpts = { 'postmaster', 'mailer-daemon' },
+ prefix = 'RL',
+ ham_factor_rate = 1.01,
+ spam_factor_rate = 0.99,
+ ham_factor_burst = 1.02,
+ spam_factor_burst = 0.98,
+ max_rate_mult = 5,
+ max_bucket_mult = 10,
+ expire = 60 * 60 * 24 * 2, -- 2 days by default
+ limits = {},
+ allow_local = false,
+ prefilter = true,
+}
+
+local bucket_check_script = "ratelimit_check.lua"
+local bucket_check_id
+
+local bucket_update_script = "ratelimit_update.lua"
+local bucket_update_id
+
+local bucket_cleanup_script = "ratelimit_cleanup_pending.lua"
+local bucket_cleanup_id
+
+-- message_func(task, limit_type, prefix, bucket, limit_key)
+local message_func = function(_, limit_type, _, _, _)
+ return string.format('Ratelimit "%s" exceeded', limit_type)
+end
+
+local function load_scripts(_, _)
+ bucket_check_id = lua_redis.load_redis_script_from_file(bucket_check_script, redis_params)
+ bucket_update_id = lua_redis.load_redis_script_from_file(bucket_update_script, redis_params)
+ bucket_cleanup_id = lua_redis.load_redis_script_from_file(bucket_cleanup_script, redis_params)
+end
+
+local limit_parser
+local function parse_string_limit(lim, no_error)
+ local function parse_time_suffix(s)
+ if s == 's' then
+ return 1
+ elseif s == 'm' then
+ return 60
+ elseif s == 'h' then
+ return 3600
+ elseif s == 'd' then
+ return 86400
+ end
+ end
+ local function parse_num_suffix(s)
+ if s == '' then
+ return 1
+ elseif s == 'k' then
+ return 1000
+ elseif s == 'm' then
+ return 1000000
+ elseif s == 'g' then
+ return 1000000000
+ end
+ end
+ local lpeg = require "lpeg"
+
+ if not limit_parser then
+ local digit = lpeg.R("09")
+ limit_parser = {}
+ limit_parser.integer = (lpeg.S("+-") ^ -1) *
+ (digit ^ 1)
+ limit_parser.fractional = (lpeg.P(".")) *
+ (digit ^ 1)
+ limit_parser.number = (limit_parser.integer *
+ (limit_parser.fractional ^ -1)) +
+ (lpeg.S("+-") * limit_parser.fractional)
+ limit_parser.time = lpeg.Cf(lpeg.Cc(1) *
+ (limit_parser.number / tonumber) *
+ ((lpeg.S("smhd") / parse_time_suffix) ^ -1),
+ function(acc, val)
+ return acc * val
+ end)
+ limit_parser.suffixed_number = lpeg.Cf(lpeg.Cc(1) *
+ (limit_parser.number / tonumber) *
+ ((lpeg.S("kmg") / parse_num_suffix) ^ -1),
+ function(acc, val)
+ return acc * val
+ end)
+ limit_parser.limit = lpeg.Ct(limit_parser.suffixed_number *
+ (lpeg.S(" ") ^ 0) * lpeg.S("/") * (lpeg.S(" ") ^ 0) *
+ limit_parser.time)
+ end
+ local t = lpeg.match(limit_parser.limit, lim)
+
+ if t and t[1] and t[2] and t[2] ~= 0 then
+ return t[2], t[1]
+ end
+
+ if not no_error then
+ rspamd_logger.errx(rspamd_config, 'bad limit: %s', lim)
+ end
+
+ return nil
+end
+
+local function str_to_rate(str)
+ local divider, divisor = parse_string_limit(str, false)
+
+ if not divisor then
+ rspamd_logger.errx(rspamd_config, 'bad rate string: %s', str)
+
+ return nil
+ end
+
+ return divisor / divider
+end
+
+local bucket_schema = ts.shape {
+ burst = ts.number + ts.string / lua_util.dehumanize_number,
+ rate = ts.number + ts.string / str_to_rate,
+ skip_recipients = ts.boolean:is_optional(),
+ symbol = ts.string:is_optional(),
+ message = ts.string:is_optional(),
+ skip_soft_reject = ts.boolean:is_optional(),
+}
+
+local function parse_limit(name, data)
+ if type(data) == 'table' then
+ -- 2 cases here:
+ -- * old limit in format [burst, rate]
+ -- * vector of strings in Andrew's string format (removed from 1.8.2)
+ -- * proper bucket table
+ if #data == 2 and tonumber(data[1]) and tonumber(data[2]) then
+ -- Old style ratelimit
+ rspamd_logger.warnx(rspamd_config, 'old style ratelimit for %s', name)
+ if tonumber(data[1]) > 0 and tonumber(data[2]) > 0 then
+ return {
+ burst = data[1],
+ rate = data[2]
+ }
+ elseif data[1] ~= 0 then
+ rspamd_logger.warnx(rspamd_config, 'invalid numbers for %s', name)
+ else
+ rspamd_logger.infox(rspamd_config, 'disable limit %s, burst is zero', name)
+ end
+
+ return nil
+ else
+ local parsed_bucket, err = bucket_schema:transform(data)
+
+ if not parsed_bucket or err then
+ rspamd_logger.errx(rspamd_config, 'cannot parse bucket for %s: %s; original value: %s',
+ name, err, data)
+ else
+ return parsed_bucket
+ end
+ end
+ elseif type(data) == 'string' then
+ local rep_rate, burst = parse_string_limit(data)
+ rspamd_logger.warnx(rspamd_config, 'old style rate bucket config detected for %s: %s',
+ name, data)
+ if rep_rate and burst then
+ return {
+ burst = burst,
+ rate = burst / rep_rate -- reciprocal
+ }
+ end
+ end
+
+ return nil
+end
+
+--- Check whether this addr is bounce
+local function check_bounce(from)
+ return fun.any(function(b)
+ return b == from
+ end, settings.bounce_senders)
+end
+
+local keywords = {
+ ['ip'] = {
+ ['get_value'] = function(task)
+ local ip = task:get_ip()
+ if ip and ip:is_valid() then
+ return tostring(ip)
+ end
+ return nil
+ end,
+ },
+ ['rip'] = {
+ ['get_value'] = function(task)
+ local ip = task:get_ip()
+ if ip and ip:is_valid() and not ip:is_local() then
+ return tostring(ip)
+ end
+ return nil
+ end,
+ },
+ ['from'] = {
+ ['get_value'] = function(task)
+ local from = task:get_from(0)
+ if ((from or E)[1] or E).addr then
+ return string.lower(from[1]['addr'])
+ end
+ return nil
+ end,
+ },
+ ['bounce'] = {
+ ['get_value'] = function(task)
+ local from = task:get_from(0)
+ if not ((from or E)[1] or E).user then
+ return '_'
+ end
+ if check_bounce(from[1]['user']) then
+ return '_'
+ else
+ return nil
+ end
+ end,
+ },
+ ['asn'] = {
+ ['get_value'] = function(task)
+ local asn = task:get_mempool():get_variable('asn')
+ if not asn then
+ return nil
+ else
+ return asn
+ end
+ end,
+ },
+ ['user'] = {
+ ['get_value'] = function(task)
+ local auser = task:get_user()
+ if not auser then
+ return nil
+ else
+ return auser
+ end
+ end,
+ },
+ ['to'] = {
+ ['get_value'] = function(task)
+ return task:get_principal_recipient()
+ end,
+ },
+ ['digest'] = {
+ ['get_value'] = function(task)
+ return task:get_digest()
+ end,
+ },
+ ['attachments'] = {
+ ['get_value'] = function(task)
+ local parts = task:get_parts() or E
+ local digests = {}
+
+ for _, p in ipairs(parts) do
+ if p:get_filename() then
+ table.insert(digests, p:get_digest())
+ end
+ end
+
+ if #digests > 0 then
+ return table.concat(digests, '')
+ end
+
+ return nil
+ end,
+ },
+ ['files'] = {
+ ['get_value'] = function(task)
+ local parts = task:get_parts() or E
+ local files = {}
+
+ for _, p in ipairs(parts) do
+ local fname = p:get_filename()
+ if fname then
+ table.insert(files, fname)
+ end
+ end
+
+ if #files > 0 then
+ return table.concat(files, ':')
+ end
+
+ return nil
+ end,
+ },
+}
+
+local function gen_rate_key(task, rtype, bucket)
+ local key_t = { tostring(lua_util.round(100000.0 / bucket.burst)) }
+ local key_keywords = lua_util.str_split(rtype, '_')
+ local have_user = false
+
+ for _, v in ipairs(key_keywords) do
+ local ret
+
+ if keywords[v] and type(keywords[v]['get_value']) == 'function' then
+ ret = keywords[v]['get_value'](task)
+ end
+ if not ret then
+ return nil
+ end
+ if v == 'user' then
+ have_user = true
+ end
+ if type(ret) ~= 'string' then
+ ret = tostring(ret)
+ end
+ table.insert(key_t, ret)
+ end
+
+ if have_user and not task:get_user() then
+ return nil
+ end
+
+ return table.concat(key_t, ":")
+end
+
+local function make_prefix(redis_key, name, bucket)
+ local hash_len = 24
+ if hash_len > #redis_key then
+ hash_len = #redis_key
+ end
+ local hash = settings.prefix ..
+ string.sub(rspamd_hash.create(redis_key):base32(), 1, hash_len)
+ -- Fill defaults
+ if not bucket.spam_factor_rate then
+ bucket.spam_factor_rate = settings.spam_factor_rate
+ end
+ if not bucket.ham_factor_rate then
+ bucket.ham_factor_rate = settings.ham_factor_rate
+ end
+ if not bucket.spam_factor_burst then
+ bucket.spam_factor_burst = settings.spam_factor_burst
+ end
+ if not bucket.ham_factor_burst then
+ bucket.ham_factor_burst = settings.ham_factor_burst
+ end
+
+ return {
+ bucket = bucket,
+ name = name,
+ hash = hash
+ }
+end
+
+local function limit_to_prefixes(task, k, v, prefixes)
+ local n = 0
+ for _, bucket in ipairs(v.buckets) do
+ if v.selector then
+ local selectors = lua_selectors.process_selectors(task, v.selector)
+ if selectors then
+ local combined = lua_selectors.combine_selectors(task, selectors, ':')
+ if type(combined) == 'string' then
+ prefixes[combined] = make_prefix(combined, k, bucket)
+ n = n + 1
+ else
+ fun.each(function(p)
+ prefixes[p] = make_prefix(p, k, bucket)
+ n = n + 1
+ end, combined)
+ end
+ end
+ else
+ local prefix = gen_rate_key(task, k, bucket)
+ if prefix then
+ if type(prefix) == 'string' then
+ prefixes[prefix] = make_prefix(prefix, k, bucket)
+ n = n + 1
+ else
+ fun.each(function(p)
+ prefixes[p] = make_prefix(p, k, bucket)
+ n = n + 1
+ end, prefix)
+ end
+ end
+ end
+ end
+
+ return n
+end
+
+local function ratelimit_cb(task)
+ if not settings.allow_local and
+ rspamd_lua_utils.is_rspamc_or_controller(task) then
+ lua_util.debugm(N, task, 'skip ratelimit for local request')
+ return
+ end
+
+ -- Get initial task data
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() and settings.whitelisted_ip then
+ if settings.whitelisted_ip:get_key(ip) then
+ -- Do not check whitelisted ip
+ rspamd_logger.infox(task, 'skip ratelimit for whitelisted IP')
+ return
+ end
+ end
+ -- Parse all rcpts
+ local rcpts = task:get_recipients()
+ local rcpts_user = {}
+ if rcpts then
+ fun.each(function(r)
+ fun.each(function(type)
+ table.insert(rcpts_user, r[type])
+ end, { 'user', 'addr' })
+ end, rcpts)
+
+ if fun.any(function(r)
+ return settings.whitelisted_rcpts:get_key(r)
+ end, rcpts_user) then
+ rspamd_logger.infox(task, 'skip ratelimit for whitelisted recipient')
+ return
+ end
+ end
+ -- Get user (authuser)
+ if settings.whitelisted_user then
+ local auser = task:get_user()
+ if settings.whitelisted_user:get_key(auser) then
+ rspamd_logger.infox(task, 'skip ratelimit for whitelisted user')
+ return
+ end
+ end
+ -- Now create all ratelimit prefixes
+ local prefixes = {}
+ local nprefixes = 0
+
+ for k, v in pairs(settings.limits) do
+ nprefixes = nprefixes + limit_to_prefixes(task, k, v, prefixes)
+ end
+
+ for k, hdl in pairs(settings.custom_keywords or E) do
+ local ret, redis_key, bd = pcall(hdl, task)
+
+ if ret then
+ local bucket = parse_limit(k, bd)
+ if bucket then
+ prefixes[redis_key] = make_prefix(redis_key, k, bucket)
+ end
+ nprefixes = nprefixes + 1
+ else
+ rspamd_logger.errx(task, 'cannot call handler for %s: %s',
+ k, redis_key)
+ end
+ end
+
+ local function gen_check_cb(prefix, bucket, lim_name, lim_key)
+ return function(err, data)
+ if err then
+ rspamd_logger.errx('cannot check limit %s: %s %s', prefix, err, data)
+ elseif type(data) == 'table' and data[1] then
+ lua_util.debugm(N, task,
+ "got reply for limit %s (%s / %s); %s burst, %s:%s dyn, %s leaked",
+ prefix, bucket.burst, bucket.rate,
+ data[2], data[3], data[4], data[5])
+
+ task:cache_set('ratelimit_bucket_touched', true)
+ if data[1] == 1 then
+ -- set symbol only and do NOT soft reject
+ if bucket.symbol then
+ -- Per bucket symbol
+ task:insert_result(bucket.symbol, 1.0,
+ string.format('%s(%s)', lim_name, lim_key))
+ else
+ if settings.symbol then
+ task:insert_result(settings.symbol, 1.0,
+ string.format('%s(%s)', lim_name, lim_key))
+ elseif settings.info_symbol then
+ task:insert_result(settings.info_symbol, 1.0,
+ string.format('%s(%s)', lim_name, lim_key))
+ end
+ end
+ rspamd_logger.infox(task,
+ 'ratelimit "%s(%s)" exceeded, (%s / %s): %s (%s:%s dyn); redis key: %s',
+ lim_name, prefix,
+ bucket.burst, bucket.rate,
+ data[2], data[3], data[4], lim_key)
+
+ if not (bucket.symbol or settings.symbol) and not bucket.skip_soft_reject then
+ if not bucket.message then
+ task:set_pre_result('soft reject',
+ message_func(task, lim_name, prefix, bucket, lim_key), N)
+ else
+ task:set_pre_result('soft reject', bucket.message)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- Don't do anything if pre-result has been already set
+ if task:has_pre_result() then
+ return
+ end
+
+ local _, nrcpt = task:has_recipients('smtp')
+ if not nrcpt or nrcpt <= 0 then
+ nrcpt = 1
+ end
+
+ if nprefixes > 0 then
+ -- Save prefixes to the cache to allow update
+ task:cache_set('ratelimit_prefixes', prefixes)
+ local now = rspamd_util.get_time()
+ now = lua_util.round(now * 1000.0) -- Get milliseconds
+ -- Now call check script for all defined prefixes
+
+ for pr, value in pairs(prefixes) do
+ local bucket = value.bucket
+ local rate = (bucket.rate) / 1000.0 -- Leak rate in messages/ms
+ local bincr = nrcpt
+ if bucket.skip_recipients then
+ bincr = 1
+ end
+
+ lua_util.debugm(N, task, "check limit %s:%s -> %s (%s/%s)",
+ value.name, pr, value.hash, bucket.burst, bucket.rate)
+ lua_redis.exec_redis_script(bucket_check_id,
+ { key = value.hash, task = task, is_write = true },
+ gen_check_cb(pr, bucket, value.name, value.hash),
+ { value.hash, tostring(now), tostring(rate), tostring(bucket.burst),
+ tostring(settings.expire), tostring(bincr) })
+ end
+ end
+end
+
+
+-- This function is used to clean up pending bucket when
+-- the task is somehow being skipped (e.g. greylisting/ratelimit/whatever)
+-- but the ratelimit buckets for this task are touched (e.g. pending has been increased)
+-- See https://github.com/rspamd/rspamd/issues/4467 for more context
+local function maybe_cleanup_pending(task)
+ if task:cache_get('ratelimit_bucket_touched') then
+ local prefixes = task:cache_get('ratelimit_prefixes')
+ if prefixes then
+ for k, v in pairs(prefixes) do
+ local bucket = v.bucket
+ local function cleanup_cb(err, data)
+ if err then
+ rspamd_logger.errx('cannot cleanup limit %s: %s %s', k, err, data)
+ else
+ lua_util.debugm(N, task, 'cleaned pending bucked for %s: %s', k, data)
+ end
+ end
+ local _, nrcpt = task:has_recipients('smtp')
+ if not nrcpt or nrcpt <= 0 then
+ nrcpt = 1
+ end
+ local bincr = nrcpt
+ if bucket.skip_recipients then
+ bincr = 1
+ end
+ local now = task:get_timeval(true)
+ now = lua_util.round(now * 1000.0) -- Get milliseconds
+ lua_redis.exec_redis_script(bucket_cleanup_id,
+ { key = v.hash, task = task, is_write = true },
+ cleanup_cb,
+ { v.hash, tostring(now), tostring(settings.expire), tostring(bincr) })
+ end
+ end
+ end
+end
+
+local function ratelimit_update_cb(task)
+ if task:has_flag('skip') then
+ maybe_cleanup_pending(task)
+ return
+ end
+ if not settings.allow_local and lua_util.is_rspamc_or_controller(task) then
+ maybe_cleanup_pending(task)
+ end
+
+ local prefixes = task:cache_get('ratelimit_prefixes')
+
+ if prefixes then
+ if task:has_pre_result() then
+ -- Already rate limited/greylisted, do nothing
+ lua_util.debugm(N, task, 'pre-action has been set, do not update')
+ maybe_cleanup_pending(task)
+ return
+ end
+
+ local verdict = lua_verdict.get_specific_verdict(N, task)
+ local _, nrcpt = task:has_recipients('smtp')
+ if not nrcpt or nrcpt <= 0 then
+ nrcpt = 1
+ end
+
+ -- Update each bucket
+ for k, v in pairs(prefixes) do
+ local bucket = v.bucket
+ local function update_bucket_cb(err, data)
+ if err then
+ rspamd_logger.errx(task, 'cannot update rate bucket %s: %s',
+ k, err)
+ else
+ lua_util.debugm(N, task,
+ "updated limit %s:%s -> %s (%s/%s), burst: %s, dyn_rate: %s, dyn_burst: %s",
+ v.name, k, v.hash,
+ bucket.burst, bucket.rate,
+ data[1], data[2], data[3])
+ end
+ end
+ local now = task:get_timeval(true)
+ now = lua_util.round(now * 1000.0) -- Get milliseconds
+ local mult_burst = 1.0
+ local mult_rate = 1.0
+
+ if verdict == 'spam' or verdict == 'junk' then
+ mult_burst = bucket.spam_factor_burst or 1.0
+ mult_rate = bucket.spam_factor_rate or 1.0
+ elseif verdict == 'ham' then
+ mult_burst = bucket.ham_factor_burst or 1.0
+ mult_rate = bucket.ham_factor_rate or 1.0
+ end
+
+ local bincr = nrcpt
+ if bucket.skip_recipients then
+ bincr = 1
+ end
+
+ lua_redis.exec_redis_script(bucket_update_id,
+ { key = v.hash, task = task, is_write = true },
+ update_bucket_cb,
+ { v.hash, tostring(now), tostring(mult_rate), tostring(mult_burst),
+ tostring(settings.max_rate_mult), tostring(settings.max_bucket_mult),
+ tostring(settings.expire), tostring(bincr) })
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt(N)
+if opts then
+
+ settings = lua_util.override_defaults(settings, opts)
+
+ if opts['limit'] then
+ rspamd_logger.errx(rspamd_config, 'Legacy ratelimit config format no longer supported')
+ end
+
+ if opts['rates'] and type(opts['rates']) == 'table' then
+ -- new way of setting limits
+ fun.each(function(t, lim)
+ local buckets = {}
+
+ if type(lim) == 'table' and lim.bucket then
+
+ if lim.bucket[1] then
+ for _, bucket in ipairs(lim.bucket) do
+ local b = parse_limit(t, bucket)
+
+ if not b then
+ rspamd_logger.errx(rspamd_config, 'bad ratelimit bucket for %s: "%s"',
+ t, b)
+ return
+ end
+
+ table.insert(buckets, b)
+ end
+ else
+ local bucket = parse_limit(t, lim.bucket)
+
+ if not bucket then
+ rspamd_logger.errx(rspamd_config, 'bad ratelimit bucket for %s: "%s"',
+ t, lim.bucket)
+ return
+ end
+
+ buckets = { bucket }
+ end
+
+ settings.limits[t] = {
+ buckets = buckets
+ }
+
+ if lim.selector then
+ local selector = lua_selectors.parse_selector(rspamd_config, lim.selector)
+ if not selector then
+ rspamd_logger.errx(rspamd_config, 'bad ratelimit selector for %s: "%s"',
+ t, lim.selector)
+ settings.limits[t] = nil
+ return
+ end
+
+ settings.limits[t].selector = selector
+ end
+ else
+ rspamd_logger.warnx(rspamd_config, 'old syntax for ratelimits: %s', lim)
+ buckets = parse_limit(t, lim)
+ if buckets then
+ settings.limits[t] = {
+ buckets = { buckets }
+ }
+ end
+ end
+ end, opts['rates'])
+ end
+
+ -- Display what's enabled
+ fun.each(function(s)
+ rspamd_logger.infox(rspamd_config, 'enabled ratelimit: %s', s)
+ end, fun.map(function(n, d)
+ return string.format('%s [%s]', n,
+ table.concat(fun.totable(fun.map(function(v)
+ return string.format('symbol: %s, %s msgs burst, %s msgs/sec rate',
+ v.symbol, v.burst, v.rate)
+ end, d.buckets)), '; ')
+ )
+ end, settings.limits))
+
+ -- Ret, ret, ret: stupid legacy stuff:
+ -- If we have a string with commas then load it as as static map
+ -- otherwise, apply normal logic of Rspamd maps
+
+ local wrcpts = opts['whitelisted_rcpts']
+ if type(wrcpts) == 'string' then
+ if string.find(wrcpts, ',') then
+ settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(
+ lua_util.rspamd_str_split(wrcpts, ','), 'set', 'Ratelimit whitelisted rcpts')
+ else
+ settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(wrcpts, 'set',
+ 'Ratelimit whitelisted rcpts')
+ end
+ elseif type(opts['whitelisted_rcpts']) == 'table' then
+ settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(wrcpts, 'set',
+ 'Ratelimit whitelisted rcpts')
+ else
+ -- Stupid default...
+ settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(
+ settings.whitelisted_rcpts, 'set', 'Ratelimit whitelisted rcpts')
+ end
+
+ if opts['whitelisted_ip'] then
+ settings.whitelisted_ip = lua_maps.rspamd_map_add('ratelimit', 'whitelisted_ip', 'radix',
+ 'Ratelimit whitelist ip map')
+ end
+
+ if opts['whitelisted_user'] then
+ settings.whitelisted_user = lua_maps.rspamd_map_add('ratelimit', 'whitelisted_user', 'set',
+ 'Ratelimit whitelist user map')
+ end
+
+ settings.custom_keywords = {}
+ if opts['custom_keywords'] then
+ local ret, res_or_err = pcall(loadfile(opts['custom_keywords']))
+
+ if ret then
+ opts['custom_keywords'] = {}
+ if type(res_or_err) == 'table' then
+ for k, hdl in pairs(res_or_err) do
+ settings['custom_keywords'][k] = hdl
+ end
+ elseif type(res_or_err) == 'function' then
+ settings['custom_keywords']['custom'] = res_or_err
+ end
+ else
+ rspamd_logger.errx(rspamd_config, 'cannot execute %s: %s',
+ opts['custom_keywords'], res_or_err)
+ settings['custom_keywords'] = {}
+ end
+ end
+
+ if opts['message_func'] then
+ message_func = assert(load(opts['message_func']))()
+ end
+
+ redis_params = lua_redis.parse_redis_server('ratelimit')
+
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ else
+ local s = {
+ type = settings.prefilter and 'prefilter' or 'callback',
+ name = 'RATELIMIT_CHECK',
+ priority = lua_util.symbols_priorities.medium,
+ callback = ratelimit_cb,
+ flags = 'empty,nostat',
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ }
+
+ local id = rspamd_config:register_symbol(s)
+
+ -- Register per bucket symbols
+ -- Display what's enabled
+ fun.each(function(set, lim)
+ if type(lim.buckets) == 'table' then
+ for _, b in ipairs(lim.buckets) do
+ if b.symbol then
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ name = b.symbol,
+ score = 0.0,
+ parent = id
+ }
+ end
+ end
+ end
+ end, settings.limits)
+
+ if settings.info_symbol then
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ name = settings.info_symbol,
+ score = 0.0,
+ parent = id
+ }
+ end
+ if settings.symbol then
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ name = settings.symbol,
+ score = 0.0, -- Might be overridden if needed
+ parent = id
+ }
+ end
+
+ rspamd_config:register_symbol {
+ type = 'idempotent',
+ name = 'RATELIMIT_UPDATE',
+ flags = 'explicit_disable,ignore_passthrough',
+ callback = ratelimit_update_cb,
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ }
+ end
+end
+
+rspamd_config:add_on_load(function(cfg, ev_base, _)
+ load_scripts(cfg, ev_base)
+end)
diff --git a/src/plugins/lua/rbl.lua b/src/plugins/lua/rbl.lua
new file mode 100644
index 0000000..b2ccf86
--- /dev/null
+++ b/src/plugins/lua/rbl.lua
@@ -0,0 +1,1425 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2013-2015, Andrew Lewis <nerf@judo.za.org>
+
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local hash = require 'rspamd_cryptobox_hash'
+local rspamd_logger = require 'rspamd_logger'
+local rspamd_util = require 'rspamd_util'
+local rspamd_ip = require "rspamd_ip"
+local fun = require 'fun'
+local lua_util = require 'lua_util'
+local selectors = require "lua_selectors"
+local bit = require 'bit'
+local lua_maps = require "lua_maps"
+local rbl_common = require "plugins/rbl"
+local rspamd_url = require "rspamd_url"
+
+-- This plugin implements various types of RBL checks
+-- Documentation can be found here:
+-- https://rspamd.com/doc/modules/rbl.html
+
+local E = {}
+local N = 'rbl'
+
+-- Checks that could be performed by rbl module
+local local_exclusions
+local white_symbols = {}
+local black_symbols = {}
+local monitored_addresses = {}
+local known_selectors = {} -- map from selector string to selector id
+local url_flag_bits = rspamd_url.flags
+
+local function get_monitored(rbl)
+ local function is_random_monitored()
+ -- Explicit definition
+ if type(rbl.random_monitored) == 'boolean' then
+ return rbl.random_monitored
+ end
+
+ -- We check 127.0.0.1 for merely RBLs with `from` or `received` and only if
+ -- they don't have `no_ip` attribute at the same time
+ --
+ -- Convert to a boolean variable using the common idiom
+ return (not (rbl.from or rbl.received)
+ or rbl.no_ip)
+ and true or false
+ end
+
+ local default_monitored = '1.0.0.127'
+ local ret = {
+ rcode = 'nxdomain',
+ prefix = default_monitored,
+ random = is_random_monitored(),
+ }
+
+ if rbl.monitored_address then
+ ret.prefix = rbl.monitored_address
+ end
+
+ lua_util.debugm(N, rspamd_config,
+ 'added monitored address: %s (%s random)',
+ ret.prefix, ret.random)
+
+ return ret
+end
+
+local function validate_dns(lstr)
+ if lstr:match('%.%.') then
+ -- two dots in a row
+ return false, "two dots in a row"
+ end
+ if not rspamd_util.is_valid_utf8(lstr) then
+ -- invalid utf8 detected
+ return false, "invalid utf8"
+ end
+ for v in lstr:gmatch('[^%.]+') do
+ if v:len() > 63 then
+ -- too long label
+ return false, "too long label"
+ end
+ if v:match('^-') or v:match('-$') then
+ -- dash at the beginning or end of label
+ return false, "dash at the beginning or end of label"
+ end
+ end
+ return true
+end
+
+local function maybe_make_hash(data, rule)
+ if rule.hash then
+ local h = hash.create_specific(rule.hash, data)
+ local s
+ if rule.hash_format then
+ if rule.hash_format == 'base32' then
+ s = h:base32()
+ elseif rule.hash_format == 'base64' then
+ s = h:base64()
+ else
+ s = h:hex()
+ end
+ else
+ s = h:hex()
+ end
+
+ if rule.hash_len then
+ s = s:sub(1, rule.hash_len)
+ end
+
+ return s
+ else
+ return data
+ end
+end
+
+local function is_excluded_ip(rip)
+ if local_exclusions and local_exclusions:get_key(rip) then
+ return true
+ end
+ return false
+end
+
+local function ip_to_rbl(ip)
+ return table.concat(ip:inversed_str_octets(), '.')
+end
+
+local function gen_check_rcvd_conditions(rbl, received_total)
+ local min_pos = tonumber(rbl.received_min_pos)
+ local max_pos = tonumber(rbl.received_max_pos)
+ local match_flags = rbl.received_flags
+ local nmatch_flags = rbl.received_nflags
+
+ local function basic_received_check(rh)
+ if not (rh.real_ip and rh.real_ip:is_valid()) then
+ return false
+ end
+ if ((rh.real_ip:get_version() == 6 and rbl.ipv6) or
+ (rh.real_ip:get_version() == 4 and rbl.ipv4)) and
+ ((rbl.exclude_local and not rh.real_ip:is_local() or is_excluded_ip(rh.real_ip)) or not rbl.exclude_local) then
+ return true
+ else
+ return false
+ end
+ end
+
+ local function positioned_received_check(rh, pos)
+ if not rh or not basic_received_check(rh) then
+ return false
+ end
+ local got_flags = rh.flags or E
+ if min_pos then
+ if min_pos < 0 then
+ if min_pos == -1 then
+ if (pos ~= received_total) then
+ return false
+ end
+ else
+ if pos <= (received_total - math.abs(min_pos)) then
+ return false
+ end
+ end
+ elseif pos < min_pos then
+ return false
+ end
+ end
+ if max_pos then
+ if max_pos < -1 then
+ if (received_total - math.abs(max_pos)) >= pos then
+ return false
+ end
+ elseif max_pos > 0 then
+ if pos > max_pos then
+ return false
+ end
+ end
+ end
+ if match_flags then
+ for _, flag in ipairs(match_flags) do
+ if not got_flags[flag] then
+ return false
+ end
+ end
+ end
+ if nmatch_flags then
+ for _, flag in ipairs(nmatch_flags) do
+ if got_flags[flag] then
+ return false
+ end
+ end
+ end
+ return true
+ end
+
+ if not (max_pos or min_pos or match_flags or nmatch_flags) then
+ return basic_received_check
+ else
+ return positioned_received_check
+ end
+end
+
+local matchers = {}
+
+matchers.radix = function(_, _, real_ip, map)
+ return map and map:get_key(real_ip) or false
+end
+
+matchers.equality = function(codes, to_match)
+ if type(codes) ~= 'table' then return codes == to_match end
+ for _, ip in ipairs(codes) do
+ if to_match == ip then
+ return true
+ end
+ end
+ return false
+end
+
+matchers.luapattern = function(codes, to_match)
+ if type(codes) ~= 'table' then
+ return string.find(to_match, '^' .. codes .. '$') and true or false
+ end
+ for _, pattern in ipairs(codes) do
+ if string.find(to_match, '^' .. pattern .. '$') then
+ return true
+ end
+ end
+ return false
+end
+
+matchers.regexp = function(_, to_match, _, map)
+ return map and map:get_key(to_match) or false
+end
+
+matchers.glob = function(_, to_match, _, map)
+ return map and map:get_key(to_match) or false
+end
+
+local function rbl_dns_process(task, rbl, to_resolve, results, err, resolve_table_elt, match)
+ local function make_option(ip, label)
+ if ip then
+ return string.format('%s:%s:%s',
+ resolve_table_elt.orig,
+ label,
+ ip)
+ else
+ return string.format('%s:%s',
+ resolve_table_elt.orig,
+ label)
+ end
+ end
+
+ local function insert_result(s, ip, label)
+ if rbl.symbols_prefixes then
+ local prefix = rbl.symbols_prefixes[label]
+
+ if not prefix then
+ rspamd_logger.warnx(task, 'unlisted symbol prefix for %s', label)
+ task:insert_result(s, 1.0, make_option(ip, label))
+ else
+ task:insert_result(prefix .. '_' .. s, 1.0, make_option(ip, label))
+ end
+ else
+ task:insert_result(s, 1.0, make_option(ip, label))
+ end
+ end
+
+ local function insert_results(s, ip)
+ for label in pairs(resolve_table_elt.what) do
+ insert_result(s, ip, label)
+ end
+ end
+
+ if err and (err ~= 'requested record is not found' and
+ err ~= 'no records with this name') then
+ rspamd_logger.infox(task, 'error looking up %s: %s', to_resolve, err)
+ task:insert_result(rbl.symbol .. '_FAIL', 1, string.format('%s:%s',
+ resolve_table_elt.orig, err))
+ return
+ end
+
+ if not results then
+ lua_util.debugm(N, task,
+ 'DNS RESPONSE: label=%1 results=%2 error=%3 rbl=%4',
+ to_resolve, false, err, rbl.symbol)
+ return
+ else
+ lua_util.debugm(N, task,
+ 'DNS RESPONSE: label=%1 results=%2 error=%3 rbl=%4',
+ to_resolve, true, err, rbl.symbol)
+ end
+
+ if rbl.returncodes == nil and rbl.returnbits == nil and rbl.symbol ~= nil then
+ insert_results(rbl.symbol)
+ return
+ end
+
+ local returncodes_maps = rbl.returncodes_maps or {}
+
+ for _, result in ipairs(results) do
+ local ipstr = result:to_string()
+ lua_util.debugm(N, task, '%s DNS result %s', to_resolve, ipstr)
+ local foundrc = false
+ -- Check return codes
+ if rbl.returnbits then
+ local ipnum = result:to_number()
+ for s, bits in pairs(rbl.returnbits) do
+ for _, check_bit in ipairs(bits) do
+ if bit.band(ipnum, check_bit) == check_bit then
+ foundrc = true
+ insert_results(s)
+ -- Here, we continue with other bits
+ end
+ end
+ end
+ elseif rbl.returncodes then
+ for s, codes in pairs(rbl.returncodes) do
+ local res = match(codes, ipstr, result, returncodes_maps[s])
+ if res then
+ foundrc = true
+ insert_results(s)
+ end
+ end
+ end
+
+ if not foundrc then
+ if rbl.unknown and rbl.symbol then
+ insert_results(rbl.symbol, ipstr)
+ else
+ lua_util.debugm(N, task, '%1 returned unknown result: %2',
+ to_resolve, ipstr)
+ end
+ end
+ end
+
+end
+
+local function gen_rbl_callback(rule)
+ local function is_whitelisted(task, req, req_str, whitelist, what)
+ if rule.ignore_whitelist then
+ lua_util.debugm(N, task,
+ 'ignore whitelisting checks to %s by %s: ignore whitelist is being set',
+ req_str, rule.symbol)
+ return false
+ end
+
+ if rule.whitelist then
+ if rule.whitelist:get_key(req) then
+ lua_util.debugm(N, task,
+ 'whitelisted %s on %s',
+ req_str, rule.symbol)
+
+ return true
+ end
+ end
+
+ -- Maybe whitelisted by some other rbl rule
+ if whitelist then
+ local wl = whitelist[req_str]
+ if wl then
+ lua_util.debugm(N, task,
+ 'whitelisted request to %s by %s (%s) rbl rule (%s checked type, %s whitelist type)',
+ req_str, wl.type, wl.symbol, what, wl.type)
+ if wl.type == what then
+ -- This was decided to be a bad idea as in case of whitelisting a request to blacklist
+ -- is not even sent
+ --task:adjust_result(wl.symbol, 0.0 / 0.0, rule.symbol)
+
+ return true
+ end
+ end
+ end
+
+ return false
+ end
+
+ local function add_dns_request(task, req, forced, is_ip, requests_table, label, whitelist)
+ local req_str = req
+ if is_ip then
+ req_str = tostring(req)
+ end
+
+ if whitelist and is_whitelisted(task, req, req_str, whitelist, label) then
+ return
+ end
+
+ if is_ip then
+ req = ip_to_rbl(req)
+ end
+
+ if requests_table[req] then
+ -- Duplicate request
+ local nreq = requests_table[req]
+ if forced and not nreq.forced then
+ nreq.forced = true
+ end
+ if not nreq.what[label] then
+ nreq.what[label] = true
+ end
+
+ return true, nreq -- Duplicate
+ else
+ local nreq
+
+ local resolve_ip = rule.resolve_ip and not is_ip
+ if rule.process_script then
+ local processed = rule.process_script(req, rule.rbl, task, resolve_ip)
+
+ if processed then
+ nreq = {
+ forced = forced,
+ n = processed,
+ orig = req_str,
+ resolve_ip = resolve_ip,
+ what = { [label] = true },
+ }
+ requests_table[req] = nreq
+ end
+ else
+ local to_resolve
+ local origin = req
+
+ if not resolve_ip then
+ origin = maybe_make_hash(req, rule)
+ to_resolve = string.format('%s.%s',
+ origin,
+ rule.rbl)
+ else
+ -- First, resolve origin stuff without hashing or anything
+ to_resolve = origin
+ end
+
+ nreq = {
+ forced = forced,
+ n = to_resolve,
+ orig = req_str,
+ resolve_ip = resolve_ip,
+ what = { [label] = true },
+ }
+ requests_table[req] = nreq
+ end
+ return false, nreq
+ end
+ end
+
+ -- Here, we have functional approach: we form a pipeline of functions
+ -- f1, f2, ... fn. Each function accepts task and return boolean value
+ -- that allows to process pipeline further
+ -- Each function in the pipeline can add something to `dns_req` vector as a side effect
+ local function is_alive(_, _)
+ if rule.monitored then
+ if not rule.monitored:alive() then
+ return false
+ end
+ end
+
+ return true
+ end
+
+ local function check_required_symbols(task, _)
+ if rule.require_symbols then
+ return fun.all(function(sym)
+ task:has_symbol(sym)
+ end, rule.require_symbols)
+ end
+
+ return true
+ end
+
+ local function check_user(task, _)
+ if task:get_user() then
+ return false
+ end
+
+ return true
+ end
+
+ local function check_local(task, _)
+ local ip = task:get_from_ip()
+
+ if ip and not ip:is_valid() then
+ ip = nil
+ end
+
+ if ip and ip:is_local() or is_excluded_ip(ip) then
+ return false
+ end
+
+ return true
+ end
+
+ local function check_helo(task, requests_table, whitelist)
+ local helo = task:get_helo()
+
+ if not helo then
+ -- Avoid pipeline breaking
+ return true
+ end
+
+ add_dns_request(task, helo, true, false, requests_table,
+ 'helo', whitelist)
+
+ return true
+ end
+
+ local function check_dkim(task, requests_table, whitelist)
+ local das = task:get_symbol('DKIM_TRACE')
+ local mime_from_domain
+
+ if das and das[1] and das[1].options then
+
+ if rule.dkim_match_from then
+ -- We check merely mime from
+ mime_from_domain = ((task:get_from('mime') or E)[1] or E).domain
+ if mime_from_domain then
+ local mime_from_domain_tld = rule.url_full_hostname and
+ mime_from_domain or rspamd_util.get_tld(mime_from_domain)
+
+ if rule.url_compose_map then
+ mime_from_domain = rule.url_compose_map:process_url(task, mime_from_domain_tld, mime_from_domain)
+ else
+ mime_from_domain = mime_from_domain_tld
+ end
+ end
+ end
+
+ for _, d in ipairs(das[1].options) do
+
+ local domain, result = d:match('^([^%:]*):([%+%-%~])$')
+
+ -- We must ignore bad signatures, omg
+ if domain and result and result == '+' then
+ if rule.dkim_match_from then
+ -- We check merely mime from
+ local domain_tld = domain
+ if not rule.dkim_domainonly then
+ -- Adjust
+ domain_tld = rspamd_util.get_tld(domain)
+
+ if rule.url_compose_map then
+ domain_tld = rule.url_compose_map:process_url(task, domain_tld, domain)
+ elseif rule.url_full_hostname then
+ domain_tld = domain
+ end
+ end
+
+ if mime_from_domain and mime_from_domain == domain_tld then
+ add_dns_request(task, domain_tld, true, false, requests_table,
+ 'dkim', whitelist)
+ end
+ else
+ if rule.dkim_domainonly then
+ local domain_tld = rspamd_util.get_tld(domain)
+ if rule.url_compose_map then
+ domain_tld = rule.url_compose_map:process_url(task, domain_tld, domain)
+ elseif rule.url_full_hostname then
+ domain_tld = domain
+ end
+ add_dns_request(task, domain_tld,
+ false, false, requests_table, 'dkim', whitelist)
+ else
+ add_dns_request(task, domain, false, false, requests_table,
+ 'dkim', whitelist)
+ end
+ end
+ end
+ end
+ end
+
+ return true
+ end
+
+ local function check_urls(task, requests_table, whitelist)
+ local esld_lim = 1
+
+ if rule.url_compose_map then
+ esld_lim = nil -- Avoid esld limit as we use custom composition rules
+ end
+ local ex_params = {
+ task = task,
+ limit = rule.requests_limit,
+ ignore_redirected = true,
+ ignore_ip = rule.no_ip,
+ need_images = rule.images,
+ need_emails = false,
+ need_content = rule.content_urls or false,
+ esld_limit = esld_lim,
+ no_cache = true,
+ }
+
+ if rule.numeric_urls then
+ if rule.content_urls then
+ if not rule.images then
+ ex_params.flags_mode = 'explicit'
+ ex_params.flags = { 'numeric' }
+ ex_params.filter = function(url)
+ return (bit.band(url:get_flags_num(), url_flag_bits.image) == 0)
+ end
+ else
+ ex_params.filter = function(url)
+ return (bit.band(url:get_flags_num(), url_flag_bits.numeric) ~= 0)
+ end
+ end
+ elseif rule.images then
+ ex_params.filter = function(url)
+ return (bit.band(url:get_flags_num(), url_flag_bits.numeric) ~= 0)
+ end
+ else
+ ex_params.flags_mode = 'explicit'
+ ex_params.flags = { 'numeric' }
+ ex_params.filter = function(url)
+ return (bit.band(url:get_flags_num(), url_flag_bits.content) == 0)
+ end
+ end
+ elseif not rule.urls and (rule.content_urls or rule.images) then
+ ex_params.flags_mode = 'explicit'
+ ex_params.flags = {}
+ if rule.content_urls then
+ table.insert(ex_params.flags, 'content')
+ end
+ if rule.images then
+ table.insert(ex_params.flags, 'image')
+ end
+ end
+
+ local urls = lua_util.extract_specific_urls(ex_params)
+
+ for _, u in ipairs(urls) do
+ local flags = u:get_flags_num()
+
+ if bit.band(flags, url_flag_bits.numeric) ~= 0 then
+ -- For numeric urls we convert data to the ip address and
+ -- reverse octets. See #3948 for details
+ local to_resolve = u:get_host()
+ local addr = rspamd_ip.from_string(to_resolve)
+
+ if addr then
+ to_resolve = table.concat(addr:inversed_str_octets(), ".")
+ end
+ add_dns_request(task, to_resolve, false,
+ false, requests_table, 'url', whitelist)
+ else
+ local url_hostname = u:get_host()
+ local url_tld = rule.url_full_hostname and url_hostname or u:get_tld()
+ if rule.url_compose_map then
+ url_tld = rule.url_compose_map:process_url(task, url_tld, url_hostname)
+ end
+ add_dns_request(task, url_tld, false,
+ false, requests_table, 'url', whitelist)
+ end
+ end
+
+ return true
+ end
+
+ local function check_from(task, requests_table, whitelist)
+ local ip = task:get_from_ip()
+
+ if not ip or not ip:is_valid() then
+ return true
+ end
+ if (ip:get_version() == 6 and rule.ipv6) or
+ (ip:get_version() == 4 and rule.ipv4) then
+ add_dns_request(task, ip, true, true,
+ requests_table, 'from',
+ whitelist)
+ end
+
+ return true
+ end
+
+ local function check_received(task, requests_table, whitelist)
+ local received = fun .filter(function(h)
+ return not h['flags']['artificial']
+ end, task:get_received_headers()):totable()
+
+ local received_total = #received
+ local check_conditions = gen_check_rcvd_conditions(rule, received_total)
+
+ for pos, rh in ipairs(received) do
+ if check_conditions(rh, pos) then
+ add_dns_request(task, rh.real_ip, false, true,
+ requests_table, 'received',
+ whitelist)
+ end
+ end
+
+ return true
+ end
+
+ local function check_rdns(task, requests_table, whitelist)
+ local hostname = task:get_hostname()
+ if hostname == nil or hostname == 'unknown' then
+ return true
+ end
+
+ add_dns_request(task, hostname, true, false,
+ requests_table, 'rdns', whitelist)
+
+ return true
+ end
+
+ local function check_selector(task, requests_table, whitelist)
+ for selector_label, selector in pairs(rule.selectors) do
+ local res = selector(task)
+
+ if res and type(res) == 'table' then
+ for _, r in ipairs(res) do
+ add_dns_request(task, r, false, false, requests_table,
+ selector_label, whitelist)
+ end
+ elseif res then
+ add_dns_request(task, res, false, false,
+ requests_table, selector_label, whitelist)
+ end
+ end
+
+ return true
+ end
+
+ local function check_email_table(task, email_tbl, requests_table, whitelist, what)
+ lua_util.remove_email_aliases(email_tbl)
+ email_tbl.domain = email_tbl.domain:lower()
+ email_tbl.user = email_tbl.user:lower()
+
+ if email_tbl.domain == '' or email_tbl.user == '' then
+ rspamd_logger.infox(task, "got an email with some empty parts: '%s@%s'; skip it in the checks",
+ email_tbl.user, email_tbl.domain)
+ return
+ end
+
+ if rule.emails_domainonly then
+ add_dns_request(task, email_tbl.domain, false, false, requests_table,
+ what, whitelist)
+ else
+ -- Also check WL for domain only
+ if is_whitelisted(task,
+ email_tbl.domain,
+ email_tbl.domain,
+ whitelist,
+ what) then
+ return
+ end
+ local delimiter = '.'
+ if rule.emails_delimiter then
+ delimiter = rule.emails_delimiter
+ else
+ if rule.hash then
+ delimiter = '@'
+ end
+ end
+ add_dns_request(task, string.format('%s%s%s',
+ email_tbl.user, delimiter, email_tbl.domain), false, false,
+ requests_table, what, whitelist)
+ end
+ end
+
+ local function check_emails(task, requests_table, whitelist)
+ local ex_params = {
+ task = task,
+ limit = rule.requests_limit,
+ filter = function(u)
+ return u:get_protocol() == 'mailto'
+ end,
+ need_emails = true,
+ prefix = 'rbl_email'
+ }
+
+ if rule.emails_domainonly then
+ if not rule.url_compose_map then
+ ex_params.esld_limit = 1
+ end
+ ex_params.prefix = 'rbl_email_domainonly'
+ end
+
+ local emails = lua_util.extract_specific_urls(ex_params)
+
+ for _, email in ipairs(emails) do
+ local domain
+ if rule.emails_domainonly and not rule.url_full_hostname then
+ if rule.url_compose_map then
+ domain = rule.url_compose_map:process_url(task, email:get_tld(), email:get_host())
+ else
+ domain = email:get_tld()
+ end
+ else
+ domain = email:get_host()
+ end
+
+ local email_tbl = {
+ domain = domain or '',
+ user = email:get_user() or '',
+ addr = tostring(email),
+ }
+ check_email_table(task, email_tbl, requests_table, whitelist, 'email')
+ end
+
+ return true
+ end
+
+ local function check_replyto(task, requests_table, whitelist)
+ local function get_raw_header(name)
+ return ((task:get_header_full(name) or {})[1] or {})['value']
+ end
+
+ local replyto = get_raw_header('Reply-To')
+ if replyto then
+ local rt = rspamd_util.parse_mail_address(replyto, task:get_mempool())
+ lua_util.debugm(N, task, 'check replyto %s', rt[1])
+
+ if rt and rt[1] and (rt[1].addr and #rt[1].addr > 0) then
+ check_email_table(task, rt[1], requests_table, whitelist, 'replyto')
+ end
+ end
+
+ return true
+ end
+
+ -- Create function pipeline depending on rbl settings
+ local pipeline = {
+ is_alive, -- check monitored status
+ check_required_symbols -- if we have require_symbols then check those symbols
+ }
+ local description = {
+ 'alive',
+ }
+
+ if rule.exclude_users then
+ pipeline[#pipeline + 1] = check_user
+ description[#description + 1] = 'user'
+ end
+
+ if rule.exclude_local then
+ pipeline[#pipeline + 1] = check_local
+ description[#description + 1] = 'local'
+ end
+
+ if rule.helo then
+ pipeline[#pipeline + 1] = check_helo
+ description[#description + 1] = 'helo'
+ end
+
+ if rule.dkim then
+ pipeline[#pipeline + 1] = check_dkim
+ description[#description + 1] = 'dkim'
+ end
+
+ if rule.emails then
+ pipeline[#pipeline + 1] = check_emails
+ description[#description + 1] = 'emails'
+ end
+ if rule.replyto then
+ pipeline[#pipeline + 1] = check_replyto
+ description[#description + 1] = 'replyto'
+ end
+
+ if rule.urls or rule.content_urls or rule.images or rule.numeric_urls then
+ pipeline[#pipeline + 1] = check_urls
+ description[#description + 1] = 'urls'
+ end
+
+ if rule.from then
+ pipeline[#pipeline + 1] = check_from
+ description[#description + 1] = 'ip'
+ end
+
+ if rule.received then
+ pipeline[#pipeline + 1] = check_received
+ description[#description + 1] = 'received'
+ end
+
+ if rule.rdns then
+ pipeline[#pipeline + 1] = check_rdns
+ description[#description + 1] = 'rdns'
+ end
+
+ if rule.selector then
+ pipeline[#pipeline + 1] = check_selector
+ description[#description + 1] = 'selector'
+ end
+
+ if not rule.returncodes_matcher then
+ rule.returncodes_matcher = 'equality'
+ end
+ local match = matchers[rule.returncodes_matcher]
+
+ local callback_f = function(task)
+ -- DNS requests to issue (might be hashed afterwards)
+ local dns_req = {}
+ local whitelist = task:cache_get('rbl_whitelisted') or {}
+
+ local function gen_rbl_dns_callback(resolve_table_elt)
+ return function(_, to_resolve, results, err)
+ rbl_dns_process(task, rule, to_resolve, results, err, resolve_table_elt, match)
+ end
+ end
+
+ -- Execute functions pipeline
+ for i, f in ipairs(pipeline) do
+ if not f(task, dns_req, whitelist) then
+ lua_util.debugm(N, task,
+ "skip rbl check: %s; pipeline condition %s returned false",
+ rule.symbol, i)
+ return
+ end
+ end
+
+ -- Now check all DNS requests pending and emit them
+ local r = task:get_resolver()
+ -- Used for 2 passes ip resolution
+ local resolved_req = {}
+ local nresolved = 0
+
+ -- This is called when doing resolve_ip phase...
+ local function gen_rbl_ip_dns_callback(orig_resolve_table_elt)
+ return function(_, _, results, err)
+ if not err then
+ for _, dns_res in ipairs(results) do
+ -- Check if we have rspamd{ip} userdata
+ if type(dns_res) == 'userdata' then
+ -- Add result as an actual RBL request
+ local label = next(orig_resolve_table_elt.what)
+ local dup, nreq = add_dns_request(task, dns_res, false, true,
+ resolved_req, label)
+ -- Add original name
+ if not dup then
+ nreq.orig = nreq.orig .. ':' .. orig_resolve_table_elt.n
+ end
+ end
+ end
+ end
+
+ nresolved = nresolved - 1
+
+ if nresolved == 0 then
+ -- Emit real RBL requests as there are no ip resolution requests
+ for name, req in pairs(resolved_req) do
+ local val_res, val_error = validate_dns(req.n)
+ if val_res then
+ lua_util.debugm(N, task, "rbl %s; resolve %s -> %s",
+ rule.symbol, name, req.n)
+ r:resolve_a({
+ task = task,
+ name = req.n,
+ callback = gen_rbl_dns_callback(req),
+ forced = req.forced
+ })
+ else
+ rspamd_logger.warnx(task, 'cannot send invalid DNS request %s for %s: %s',
+ req.n, rule.symbol, val_error)
+ end
+ end
+ end
+ end
+ end
+
+ for name, req in pairs(dns_req) do
+ local val_res, val_error = validate_dns(req.n)
+ if val_res then
+ lua_util.debugm(N, task, "rbl %s; resolve %s -> %s",
+ rule.symbol, name, req.n)
+
+ if req.resolve_ip then
+ -- Deal with both ipv4 and ipv6
+ -- Resolve names first
+ if r:resolve_a({
+ task = task,
+ name = req.n,
+ callback = gen_rbl_ip_dns_callback(req),
+ forced = req.forced
+ }) then
+ nresolved = nresolved + 1
+ end
+ if r:resolve('aaaa', {
+ task = task,
+ name = req.n,
+ callback = gen_rbl_ip_dns_callback(req),
+ forced = req.forced
+ }) then
+ nresolved = nresolved + 1
+ end
+ else
+ r:resolve_a({
+ task = task,
+ name = req.n,
+ callback = gen_rbl_dns_callback(req),
+ forced = req.forced
+ })
+ end
+
+ else
+ rspamd_logger.warnx(task, 'cannot send invalid DNS request %s for %s: %s',
+ req.n, rule.symbol, val_error)
+ end
+ end
+ end
+
+ return callback_f, string.format('checks: %s', table.concat(description, ','))
+end
+
+local map_match_types = {
+ glob = true,
+ radix = true,
+ regexp = true,
+}
+
+local function add_rbl(key, rbl, global_opts)
+ if not rbl.symbol then
+ rbl.symbol = key:upper()
+ end
+
+ local flags_tbl = { 'no_squeeze' }
+ if rbl.is_whitelist then
+ flags_tbl[#flags_tbl + 1] = 'nice'
+ end
+
+ -- Check if rbl is available for empty tasks
+ if not (rbl.emails or rbl.urls or rbl.dkim or rbl.received or rbl.selector or rbl.replyto) or
+ rbl.is_empty then
+ flags_tbl[#flags_tbl + 1] = 'empty'
+ end
+
+ if rbl.selector then
+
+ rbl.selectors = {}
+ if type(rbl.selector) ~= 'table' then
+ rbl.selector = { ['selector'] = rbl.selector }
+ end
+
+ for selector_label, selector in pairs(rbl.selector) do
+ if known_selectors[selector] then
+ lua_util.debugm(N, rspamd_config, 'reuse selector id %s',
+ known_selectors[selector].id)
+ rbl.selectors[selector_label] = known_selectors[selector].selector
+ else
+
+ if type(rbl.selector_flatten) ~= 'boolean' then
+ -- Fail-safety
+ rbl.selector_flatten = true
+ end
+ local sel = selectors.create_selector_closure(rspamd_config, selector, '',
+ rbl.selector_flatten)
+
+ if not sel then
+ rspamd_logger.errx('invalid selector for rbl rule %s: %s', key, selector)
+ return false
+ end
+
+ rbl.selector = sel
+ known_selectors[selector] = {
+ selector = sel,
+ id = #lua_util.keys(known_selectors) + 1,
+ }
+ rbl.selectors[selector_label] = known_selectors[selector].selector
+ end
+ end
+
+ end
+
+ if rbl.process_script then
+ local ret, f = lua_util.callback_from_string(rbl.process_script)
+
+ if ret then
+ rbl.process_script = f
+ else
+ rspamd_logger.errx(rspamd_config,
+ 'invalid process script for rbl rule %s: %s; %s',
+ key, rbl.process_script, f)
+ return false
+ end
+ end
+
+ if rbl.whitelist then
+ local def_type = 'set'
+ if rbl.from or rbl.received then
+ def_type = 'radix'
+ end
+ rbl.whitelist = lua_maps.map_add_from_ucl(rbl.whitelist, def_type,
+ 'RBL whitelist for ' .. rbl.symbol)
+ rspamd_logger.infox(rspamd_config, 'added %s whitelist for RBL %s',
+ def_type, rbl.symbol)
+ end
+
+ local match_type = rbl.returncodes_matcher
+ if match_type and rbl.returncodes and map_match_types[match_type] then
+ if not rbl.returncodes_maps then
+ rbl.returncodes_maps = {}
+ end
+ for label, v in pairs(rbl.returncodes) do
+ if type(v) ~= 'table' then
+ v = {v}
+ end
+ rbl.returncodes_maps[label] = lua_maps.map_add_from_ucl(v, match_type, string.format('%s_%s RBL returncodes', label, rbl.symbol))
+ end
+ end
+
+ if rbl.url_compose_map then
+ local lua_urls_compose = require "lua_urls_compose"
+ rbl.url_compose_map = lua_urls_compose.add_composition_map(rspamd_config, rbl.url_compose_map)
+
+ if rbl.url_compose_map then
+ rspamd_logger.infox(rspamd_config, 'added url composition map for RBL %s',
+ rbl.symbol)
+ end
+ end
+
+ if not rbl.whitelist and not rbl.ignore_url_whitelist and (global_opts.url_whitelist or rbl.url_whitelist) and
+ (rbl.urls or rbl.emails or rbl.dkim or rbl.replyto) and
+ not (rbl.from or rbl.received) then
+ local def_type = 'set'
+ rbl.whitelist = lua_maps.map_add_from_ucl(rbl.url_whitelist or global_opts.url_whitelist, def_type,
+ 'RBL url whitelist for ' .. rbl.symbol)
+ rspamd_logger.infox(rspamd_config, 'added URL whitelist for RBL %s',
+ rbl.symbol)
+ end
+
+ local callback, description = gen_rbl_callback(rbl)
+
+ if callback then
+ local id
+
+ if rbl.symbols_prefixes then
+ id = rspamd_config:register_symbol {
+ type = 'callback',
+ callback = callback,
+ groups = { 'rbl' },
+ name = rbl.symbol .. '_CHECK',
+ flags = table.concat(flags_tbl, ',')
+ }
+
+ for _, prefix in pairs(rbl.symbols_prefixes) do
+ -- For unknown results...
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ parent = id,
+ group = 'rbl',
+ score = 0,
+ name = prefix .. '_' .. rbl.symbol,
+ }
+ end
+ if not (rbl.is_whitelist or rbl.ignore_whitelist) then
+ table.insert(black_symbols, rbl.symbol .. '_CHECK')
+ else
+ lua_util.debugm(N, rspamd_config, 'rule %s ignores whitelists: rbl.is_whitelist = %s, ' ..
+ 'rbl.ignore_whitelist = %s',
+ rbl.symbol, rbl.is_whitelist, rbl.ignore_whitelist)
+ end
+ else
+ id = rspamd_config:register_symbol {
+ type = 'callback',
+ callback = callback,
+ name = rbl.symbol,
+ groups = { 'rbl' },
+ group = 'rbl',
+ score = 0,
+ flags = table.concat(flags_tbl, ',')
+ }
+ if not (rbl.is_whitelist or rbl.ignore_whitelist) then
+ table.insert(black_symbols, rbl.symbol)
+ else
+ lua_util.debugm(N, rspamd_config, 'rule %s ignores whitelists: rbl.is_whitelist = %s, ' ..
+ 'rbl.ignore_whitelist = %s',
+ rbl.symbol, rbl.is_whitelist, rbl.ignore_whitelist)
+ end
+ end
+
+ rspamd_logger.infox(rspamd_config, 'added rbl rule %s: %s',
+ rbl.symbol, description)
+ lua_util.debugm(N, rspamd_config, 'rule dump for %s: %s',
+ rbl.symbol, rbl)
+
+ local check_sym = rbl.symbols_prefixes and rbl.symbol .. '_CHECK' or rbl.symbol
+
+ if rbl.dkim then
+ rspamd_config:register_dependency(check_sym, 'DKIM_CHECK')
+ end
+
+ if rbl.require_symbols then
+ for _, dep in ipairs(rbl.require_symbols) do
+ rspamd_config:register_dependency(check_sym, dep)
+ end
+ end
+
+ -- Failure symbol
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ flags = 'nostat',
+ name = rbl.symbol .. '_FAIL',
+ parent = id,
+ score = 0.0,
+ }
+
+ local function process_return_code(suffix)
+ local function process_specific_suffix(s)
+ if s ~= rbl.symbol then
+ -- hack
+
+ rspamd_config:register_symbol {
+ type = 'virtual',
+ parent = id,
+ name = s,
+ group = 'rbl',
+ score = 0,
+ }
+ end
+ if rbl.is_whitelist then
+ if rbl.whitelist_exception then
+ local found_exception = false
+ for _, e in ipairs(rbl.whitelist_exception) do
+ if e == s then
+ found_exception = true
+ break
+ end
+ end
+ if not found_exception then
+ table.insert(white_symbols, s)
+ end
+ else
+ table.insert(white_symbols, s)
+ end
+ else
+ if not rbl.ignore_whitelist then
+ table.insert(black_symbols, s)
+ end
+ end
+ end
+
+ if rbl.symbols_prefixes then
+ for _, prefix in pairs(rbl.symbols_prefixes) do
+ process_specific_suffix(prefix .. '_' .. suffix)
+ end
+ else
+ process_specific_suffix(suffix)
+ end
+
+ end
+
+ if rbl.returncodes then
+ for s, _ in pairs(rbl.returncodes) do
+ process_return_code(s)
+ end
+ end
+
+ if rbl.returnbits then
+ for s, _ in pairs(rbl.returnbits) do
+ process_return_code(s)
+ end
+ end
+
+ -- Process monitored
+ if not rbl.disable_monitoring then
+ if not monitored_addresses[rbl.rbl] then
+ monitored_addresses[rbl.rbl] = true
+ rbl.monitored = rspamd_config:register_monitored(rbl.rbl, 'dns',
+ get_monitored(rbl))
+ end
+ end
+ return true
+ end
+
+ return false
+end
+
+-- Configuration
+local opts = rspamd_config:get_all_opt(N)
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+ lua_util.disable_module(N, "config")
+ return
+end
+
+-- Plugin defaults should not be changed - override these in config
+-- New defaults should not alter behaviour
+
+
+opts = lua_util.override_defaults(rbl_common.default_options, opts)
+
+if opts.rules and opts.rbls then
+ -- Common issue :(
+ rspamd_logger.infox(rspamd_config, 'merging `rules` and `rbls` keys for compatibility')
+ opts.rbls = lua_util.override_defaults(opts.rbls, opts.rules)
+end
+
+if (opts['local_exclude_ip_map'] ~= nil) then
+ local_exclusions = lua_maps.map_add(N, 'local_exclude_ip_map', 'radix',
+ 'RBL exclusions map')
+end
+
+-- TODO: this code should be universal for all modules that use selectors to allow
+-- maps usage from selectors registered for a specific module
+if type(opts.attached_maps) == 'table' then
+ opts.attached_maps_processed = {}
+ for i, map in ipairs(opts.attached_maps) do
+ -- Store maps in the configuration table to keep lifetime track
+ opts.attached_maps_processed[i] = lua_maps.map_add_from_ucl(map)
+ if opts.attached_maps_processed[i] == nil then
+ rspamd_logger.warnx(rspamd_config, "cannot parse attached map: %s", map)
+ end
+ end
+end
+
+for key, rbl in pairs(opts.rbls) do
+ if type(rbl) ~= 'table' or rbl.disabled == true or rbl.enabled == false then
+ rspamd_logger.infox(rspamd_config, 'disable rbl "%s"', key)
+ else
+ -- Aliases
+ if type(rbl.ignore_default) == 'boolean' then
+ rbl.ignore_defaults = rbl.ignore_default
+ end
+ if type(rbl.ignore_whitelists) == 'boolean' then
+ rbl.ignore_whitelist = rbl.ignore_whitelists
+ end
+ -- Propagate default options from opts to rule
+ if not rbl.ignore_defaults then
+ for default_opt_key, _ in pairs(rbl_common.default_options) do
+ local rbl_opt = default_opt_key:sub(#('default_') + 1)
+ if rbl[rbl_opt] == nil then
+ rbl[rbl_opt] = opts[default_opt_key]
+ end
+ end
+ end
+
+ if not rbl.requests_limit then
+ rbl.requests_limit = rspamd_config:get_dns_max_requests()
+ end
+
+ local res, err = rbl_common.rule_schema:transform(rbl)
+ if not res then
+ rspamd_logger.errx(rspamd_config, 'invalid config for %s: %s, RBL is DISABLED',
+ key, err)
+ else
+ res = rbl_common.convert_checks(res, rbl.symbol or key:upper())
+ -- Aliases
+ if res.return_codes then
+ res.returncodes = res.return_codes
+ end
+ if res.return_bits then
+ res.returnbits = res.return_bits
+ end
+
+ if not res then
+ rspamd_logger.errx(rspamd_config, 'invalid config for %s: %s, RBL is DISABLED',
+ key, err)
+ else
+ add_rbl(key, res, opts)
+ end
+ end
+ end -- rbl.enabled
+end
+
+-- We now create two symbols:
+-- * RBL_CALLBACK_WHITE that depends on all symbols white
+-- * RBL_CALLBACK that depends on all symbols black to participate in depends chains
+local function rbl_callback_white(task)
+ local whitelisted_elements = {}
+ for _, w in ipairs(white_symbols) do
+ local ws = task:get_symbol(w)
+ if ws and ws[1] then
+ ws = ws[1]
+ if not ws.options then
+ ws.options = {}
+ end
+ for _, opt in ipairs(ws.options) do
+ local elt, what = opt:match('^([^:]+):([^:]+)')
+ lua_util.debugm(N, task, 'found whitelist from %s: %s(%s)', w,
+ elt, what)
+ if elt and what then
+ whitelisted_elements[elt] = {
+ type = what,
+ symbol = w,
+ }
+ end
+ end
+ end
+ end
+
+ task:cache_set('rbl_whitelisted', whitelisted_elements)
+
+ lua_util.debugm(N, task, "finished rbl whitelists processing")
+end
+
+local function rbl_callback_fin(task)
+ -- Do nothing
+ lua_util.debugm(N, task, "finished rbl processing")
+end
+
+rspamd_config:register_symbol {
+ type = 'callback',
+ callback = rbl_callback_white,
+ name = 'RBL_CALLBACK_WHITE',
+ flags = 'nice,empty,no_squeeze',
+ groups = { 'rbl' },
+ augmentations = { string.format("timeout=%f", rspamd_config:get_dns_timeout() or 0.0) },
+}
+
+rspamd_config:register_symbol {
+ type = 'callback',
+ callback = rbl_callback_fin,
+ name = 'RBL_CALLBACK',
+ flags = 'empty,no_squeeze',
+ groups = { 'rbl' },
+ augmentations = { string.format("timeout=%f", rspamd_config:get_dns_timeout() or 0.0) },
+}
+
+for _, w in ipairs(white_symbols) do
+ rspamd_config:register_dependency('RBL_CALLBACK_WHITE', w)
+end
+
+for _, b in ipairs(black_symbols) do
+ rspamd_config:register_dependency(b, 'RBL_CALLBACK_WHITE')
+ rspamd_config:register_dependency('RBL_CALLBACK', b)
+end
diff --git a/src/plugins/lua/replies.lua b/src/plugins/lua/replies.lua
new file mode 100644
index 0000000..c4df9c9
--- /dev/null
+++ b/src/plugins/lua/replies.lua
@@ -0,0 +1,328 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require 'rspamd_logger'
+local hash = require 'rspamd_cryptobox_hash'
+local lua_util = require 'lua_util'
+local lua_redis = require 'lua_redis'
+local fun = require "fun"
+
+-- A plugin that implements replies check using redis
+
+-- Default port for redis upstreams
+local redis_params
+local settings = {
+ action = nil,
+ expire = 86400, -- 1 day by default
+ key_prefix = 'rr',
+ key_size = 20,
+ message = 'Message is reply to one we originated',
+ symbol = 'REPLY',
+ score = -4, -- Default score
+ use_auth = true,
+ use_local = true,
+ cookie = nil,
+ cookie_key = nil,
+ cookie_is_pattern = false,
+ cookie_valid_time = '2w', -- 2 weeks by default
+ min_message_id = 2, -- minimum length of the message-id header
+}
+
+local N = "replies"
+
+local function make_key(goop, sz, prefix)
+ local h = hash.create()
+ h:update(goop)
+ local key
+ if sz then
+ key = h:base32():sub(1, sz)
+ else
+ key = h:base32()
+ end
+
+ if prefix then
+ key = prefix .. key
+ end
+
+ return key
+end
+
+local function replies_check(task)
+ local in_reply_to
+ local function check_recipient(stored_rcpt)
+ local rcpts = task:get_recipients('mime')
+
+ if rcpts then
+ local filter_predicate = function(input_rcpt)
+ local real_rcpt_h = make_key(input_rcpt:lower(), 8)
+
+ return real_rcpt_h == stored_rcpt
+ end
+
+ if fun.any(filter_predicate, fun.map(function(rcpt)
+ return rcpt.addr or ''
+ end, rcpts)) then
+ lua_util.debugm(N, task, 'reply to %s validated', in_reply_to)
+ return true
+ end
+
+ rspamd_logger.infox(task, 'ignoring reply to %s as no recipients are matching hash %s',
+ in_reply_to, stored_rcpt)
+ else
+ rspamd_logger.infox(task, 'ignoring reply to %s as recipient cannot be detected for hash %s',
+ in_reply_to, stored_rcpt)
+ end
+
+ return false
+ end
+
+ local function redis_get_cb(err, data, addr)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_get_cb error when reading data from %s: %s', addr:get_addr(), err)
+ return
+ end
+ if data and type(data) == 'string' and check_recipient(data) then
+ -- Hash was found
+ task:insert_result(settings['symbol'], 1.0)
+ if settings['action'] ~= nil then
+ local ip_addr = task:get_ip()
+ if (settings.use_auth and
+ task:get_user()) or
+ (settings.use_local and ip_addr and ip_addr:is_local()) then
+ rspamd_logger.infox(task, "not forcing action for local network or authorized user");
+ else
+ task:set_pre_result(settings['action'], settings['message'], N)
+ end
+ end
+ end
+ end
+ -- If in-reply-to header not present return
+ in_reply_to = task:get_header_raw('in-reply-to')
+ if not in_reply_to then
+ return
+ end
+ -- Create hash of in-reply-to and query redis
+ local key = make_key(in_reply_to, settings.key_size, settings.key_prefix)
+
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_get_cb, --callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+end
+
+local function replies_set(task)
+ local function redis_set_cb(err, _, addr)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_set_cb error when writing data to %s: %s', addr:get_addr(), err)
+ end
+ end
+ -- If sender is unauthenticated return
+ local ip = task:get_ip()
+ if settings.use_auth and task:get_user() then
+ lua_util.debugm(N, task, 'sender is authenticated')
+ elseif settings.use_local and (ip and ip:is_local()) then
+ lua_util.debugm(N, task, 'sender is from local network')
+ else
+ return
+ end
+ -- If no message-id present return
+ local msg_id = task:get_header_raw('message-id')
+ if msg_id == nil or msg_id:len() <= (settings.min_message_id or 2) then
+ return
+ end
+ -- Create hash of message-id and store to redis
+ local key = make_key(msg_id, settings.key_size, settings.key_prefix)
+
+ local sender = task:get_reply_sender()
+
+ if sender then
+ local sender_hash = make_key(sender:lower(), 8)
+ lua_util.debugm(N, task, 'storing id: %s (%s), reply-to: %s (%s) for replies check',
+ msg_id, key, sender, sender_hash)
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'PSETEX', -- command
+ { key, tostring(math.floor(settings['expire'] * 1000)), sender_hash } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+ else
+ rspamd_logger.infox(task, "cannot find reply sender address")
+ end
+end
+
+local function replies_check_cookie(task)
+ local function cookie_matched(extra, ts)
+ local dt = task:get_date { format = 'connect', gmt = true }
+
+ if dt < ts then
+ rspamd_logger.infox(task, 'ignore cookie as its date is in future')
+
+ return
+ end
+
+ if settings.cookie_valid_time then
+ if dt - ts > settings.cookie_valid_time then
+ rspamd_logger.infox(task,
+ 'ignore cookie as its timestamp is too old: %s (%s current time)',
+ ts, dt)
+
+ return
+ end
+ end
+
+ if extra then
+ task:insert_result(settings['symbol'], 1.0,
+ string.format('cookie:%s:%s', extra, ts))
+ else
+ task:insert_result(settings['symbol'], 1.0,
+ string.format('cookie:%s', ts))
+ end
+ if settings['action'] ~= nil then
+ local ip_addr = task:get_ip()
+ if (settings.use_auth and
+ task:get_user()) or
+ (settings.use_local and ip_addr and ip_addr:is_local()) then
+ rspamd_logger.infox(task, "not forcing action for local network or authorized user");
+ else
+ task:set_pre_result(settings['action'], settings['message'], N)
+ end
+ end
+ end
+
+ -- If in-reply-to header not present return
+ local irt = task:get_header('in-reply-to')
+ if irt == nil then
+ return
+ end
+
+ local cr = require "rspamd_cryptobox"
+ -- Extract user part if needed
+ local extracted_cookie = irt:match('^%<?([^@]+)@.*$')
+ if not extracted_cookie then
+ -- Assume full message id as a cookie
+ extracted_cookie = irt
+ end
+
+ local dec_cookie, ts = cr.decrypt_cookie(settings.cookie_key, extracted_cookie)
+
+ if dec_cookie then
+ -- We have something that looks like a cookie
+ if settings.cookie_is_pattern then
+ local m = dec_cookie:match(settings.cookie)
+
+ if m then
+ cookie_matched(m, ts)
+ end
+ else
+ -- Direct match
+ if dec_cookie == settings.cookie then
+ cookie_matched(nil, ts)
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('replies')
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'module is unconfigured')
+ return
+end
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+ redis_params = lua_redis.parse_redis_server('replies')
+ if not redis_params then
+ if not (settings.cookie and settings.cookie_key) then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ else
+ -- Cookies mode
+ -- Check key sanity:
+ local pattern = { '^' }
+ for i = 1, 32 do
+ pattern[i + 1] = '[a-zA-Z0-9]'
+ end
+ pattern[34] = '$'
+ if not settings.cookie_key:match(table.concat(pattern, '')) then
+ rspamd_logger.errx(rspamd_config,
+ 'invalid cookies key: %s, must be 32 hex digits', settings.cookie_key)
+ lua_util.disable_module(N, "config")
+
+ return
+ end
+
+ if settings.cookie_valid_time then
+ settings.cookie_valid_time = lua_util.parse_time_interval(settings.cookie_valid_time)
+ end
+
+ local id = rspamd_config:register_symbol({
+ name = 'REPLIES_CHECK',
+ type = 'prefilter',
+ callback = replies_check_cookie,
+ flags = 'nostat',
+ priority = lua_util.symbols_priorities.medium,
+ group = "replies"
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol'],
+ parent = id,
+ type = 'virtual',
+ score = settings.score,
+ group = "replies",
+ })
+ end
+ else
+ rspamd_config:register_symbol({
+ name = 'REPLIES_SET',
+ type = 'idempotent',
+ callback = replies_set,
+ group = 'replies',
+ flags = 'explicit_disable,ignore_passthrough',
+ })
+ local id = rspamd_config:register_symbol({
+ name = 'REPLIES_CHECK',
+ type = 'prefilter',
+ flags = 'nostat',
+ callback = replies_check,
+ priority = lua_util.symbols_priorities.medium,
+ group = "replies"
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol'],
+ parent = id,
+ type = 'virtual',
+ score = settings.score,
+ group = "replies",
+ })
+ end
+end
diff --git a/src/plugins/lua/reputation.lua b/src/plugins/lua/reputation.lua
new file mode 100644
index 0000000..a3af26c
--- /dev/null
+++ b/src/plugins/lua/reputation.lua
@@ -0,0 +1,1390 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- A generic plugin for reputation handling
+
+local E = {}
+local N = 'reputation'
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_maps = require "lua_maps"
+local lua_maps_exprs = require "lua_maps_expressions"
+local hash = require 'rspamd_cryptobox_hash'
+local lua_redis = require "lua_redis"
+local fun = require "fun"
+local lua_selectors = require "lua_selectors"
+local ts = require("tableshape").types
+
+local redis_params = nil
+local default_expiry = 864000 -- 10 day by default
+local default_prefix = 'RR:' -- Rspamd Reputation
+
+local tanh = math.tanh or rspamd_util.tanh
+
+-- Get reputation from ham/spam/probable hits
+local function generic_reputation_calc(token, rule, mult, task)
+ local cfg = rule.selector.config or E
+ local reject_threshold = task:get_metric_score()[2] or 10.0
+
+ if cfg.score_calc_func then
+ return cfg.score_calc_func(rule, token, mult)
+ end
+
+ if tonumber(token[1]) < cfg.lower_bound then
+ lua_util.debugm(N, task, "not enough matches %s < %s for rule %s",
+ token[1], cfg.lower_bound, rule.symbol)
+ return 0
+ end
+
+ -- Get average score
+ local avg_score = fun.foldl(function(acc, v)
+ return acc + v
+ end, 0.0, fun.map(tonumber, token[2])) / #token[2]
+
+ -- Apply function tanh(x / reject_score * atanh(0.95) - atanh(0.5))
+ -- 1.83178 0.5493
+ local score = tanh(avg_score / reject_threshold * 1.83178 - 0.5493) * mult
+ lua_util.debugm(N, task, "got generic average score %s (reject threshold=%s, mult=%s) -> %s for rule %s",
+ avg_score, reject_threshold, mult, score, rule.symbol)
+ return score
+end
+
+local function add_symbol_score(task, rule, mult, params)
+ if not params then
+ params = { tostring(mult) }
+ end
+
+ if rule.selector.config.split_symbols then
+ local sym_spam = rule.symbol .. '_SPAM'
+ local sym_ham = rule.symbol .. '_HAM'
+ if not rule.static_symbols then
+ rule.static_symbols = {}
+ rule.static_symbols.ham = rspamd_config:get_symbol(sym_ham)
+ rule.static_symbols.spam = rspamd_config:get_symbol(sym_spam)
+ end
+ if mult >= 0 then
+ task:insert_result(sym_spam, mult, params)
+ else
+ -- Avoid multiplication of negative the `mult` by negative static score of the
+ -- ham symbol
+ if rule.static_symbols.ham and rule.static_symbols.ham.score then
+ if rule.static_symbols.ham.score < 0 then
+ mult = math.abs(mult)
+ end
+ end
+ task:insert_result(sym_ham, mult, params)
+ end
+ else
+ task:insert_result(rule.symbol, mult, params)
+ end
+end
+
+local function sub_symbol_score(task, rule, score)
+ local function sym_score(sym)
+ local s = task:get_symbol(sym)[1]
+ return s.score
+ end
+ if rule.selector.config.split_symbols then
+ local spam_sym = rule.symbol .. '_SPAM'
+ local ham_sym = rule.symbol .. '_HAM'
+
+ if task:has_symbol(spam_sym) then
+ score = score - sym_score(spam_sym)
+ elseif task:has_symbol(ham_sym) then
+ score = score - sym_score(ham_sym)
+ end
+ else
+ if task:has_symbol(rule.symbol) then
+ score = score - sym_score(rule.symbol)
+ end
+ end
+
+ return score
+end
+
+-- Extracts task score and subtracts score of the rule itself
+local function extract_task_score(task, rule)
+ local lua_verdict = require "lua_verdict"
+ local verdict, score = lua_verdict.get_specific_verdict(N, task)
+
+ if not score or verdict == 'passthrough' then
+ return nil
+ end
+
+ return sub_symbol_score(task, rule, score)
+end
+
+-- DKIM Selector functions
+local gr
+local function gen_dkim_queries(task, rule)
+ local dkim_trace = (task:get_symbol('DKIM_TRACE') or E)[1]
+ local lpeg = require 'lpeg'
+ local ret = {}
+
+ if not gr then
+ local semicolon = lpeg.P(':')
+ local domain = lpeg.C((1 - semicolon) ^ 1)
+ local res = lpeg.S '+-?~'
+
+ local function res_to_label(ch)
+ if ch == '+' then
+ return 'a'
+ elseif ch == '-' then
+ return 'r'
+ end
+
+ return 'u'
+ end
+
+ gr = domain * semicolon * (lpeg.C(res ^ 1) / res_to_label)
+ end
+
+ if dkim_trace and dkim_trace.options then
+ for _, opt in ipairs(dkim_trace.options) do
+ local dom, res = lpeg.match(gr, opt)
+
+ if dom and res then
+ local tld = rspamd_util.get_tld(dom)
+ ret[tld] = res
+ end
+ end
+ end
+
+ return ret
+end
+
+local function dkim_reputation_filter(task, rule)
+ local requests = gen_dkim_queries(task, rule)
+ local results = {}
+ local dkim_tlds = lua_util.keys(requests)
+ local requests_left = #dkim_tlds
+ local rep_accepted = 0.0
+
+ lua_util.debugm(N, task, 'dkim reputation tokens: %s', requests)
+
+ local function tokens_cb(err, token, values)
+ requests_left = requests_left - 1
+
+ if values then
+ results[token] = values
+ end
+
+ if requests_left == 0 then
+ for k, v in pairs(results) do
+ -- `k` in results is a prefixed and suffixed tld, so we need to look through
+ -- all requests to find any request with the matching tld
+ local sel_tld
+ for _, tld in ipairs(dkim_tlds) do
+ if k:find(tld, 1, true) then
+ sel_tld = tld
+ break
+ end
+ end
+
+ if sel_tld and requests[sel_tld] then
+ if requests[sel_tld] == 'a' then
+ rep_accepted = rep_accepted + generic_reputation_calc(v, rule, 1.0, task)
+ end
+ else
+ rspamd_logger.warnx(task, "cannot find the requested tld for a request: %s (%s tlds noticed)",
+ k, dkim_tlds)
+ end
+ end
+
+ -- Set local reputation symbol
+ local rep_accepted_abs = math.abs(rep_accepted or 0)
+ lua_util.debugm(N, task, "dkim reputation accepted: %s",
+ rep_accepted_abs)
+ if rep_accepted_abs then
+ local final_rep = rep_accepted
+ if rep_accepted > 1.0 then
+ final_rep = 1.0
+ end
+ if rep_accepted < -1.0 then
+ final_rep = -1.0
+ end
+ add_symbol_score(task, rule, final_rep)
+
+ -- Store results for future DKIM results adjustments
+ task:get_mempool():set_variable("dkim_reputation_accept", tostring(rep_accepted))
+ end
+ end
+ end
+
+ for dom, res in pairs(requests) do
+ -- tld + "." + check_result, e.g. example.com.+ - reputation for valid sigs
+ local query = string.format('%s.%s', dom, res)
+ rule.backend.get_token(task, rule, nil, query, tokens_cb, 'string')
+ end
+end
+
+local function dkim_reputation_idempotent(task, rule)
+ local requests = gen_dkim_queries(task, rule)
+ local sc = extract_task_score(task, rule)
+
+ if sc then
+ for dom, res in pairs(requests) do
+ -- tld + "." + check_result, e.g. example.com.+ - reputation for valid sigs
+ local query = string.format('%s.%s', dom, res)
+ rule.backend.set_token(task, rule, nil, query, sc)
+ end
+ end
+end
+
+local function dkim_reputation_postfilter(task, rule)
+ local sym_accepted = (task:get_symbol('R_DKIM_ALLOW') or E)[1]
+ local accept_adjustment = task:get_mempool():get_variable("dkim_reputation_accept")
+ local cfg = rule.selector.config or E
+
+ if sym_accepted and sym_accepted.score and
+ accept_adjustment and type(cfg.max_accept_adjustment) == 'number' then
+ local final_adjustment = cfg.max_accept_adjustment *
+ rspamd_util.tanh(tonumber(accept_adjustment) or 0)
+ lua_util.debugm(N, task, "adjust DKIM_ALLOW: " ..
+ "cfg.max_accept_adjustment=%s accept_adjustment=%s final_adjustment=%s sym_accepted.score=%s",
+ cfg.max_accept_adjustment, accept_adjustment, final_adjustment,
+ sym_accepted.score)
+
+ task:adjust_result('R_DKIM_ALLOW', sym_accepted.score + final_adjustment)
+ end
+end
+
+local dkim_selector = {
+ config = {
+ symbol = 'DKIM_SCORE', -- symbol to be inserted
+ lower_bound = 10, -- minimum number of messages to be scored
+ min_score = nil,
+ max_score = nil,
+ outbound = true,
+ inbound = true,
+ max_accept_adjustment = 2.0, -- How to adjust accepted DKIM score
+ },
+ dependencies = { "DKIM_TRACE" },
+ filter = dkim_reputation_filter, -- used to get scores
+ postfilter = dkim_reputation_postfilter, -- used to adjust DKIM scores
+ idempotent = dkim_reputation_idempotent, -- used to set scores
+}
+
+-- URL Selector functions
+
+local function gen_url_queries(task, rule)
+ local domains = {}
+
+ fun.each(function(u)
+ if u:is_redirected() then
+ local redir = u:get_redirected() -- get the original url
+ local redir_tld = redir:get_tld()
+ if domains[redir_tld] then
+ domains[redir_tld] = domains[redir_tld] - 1
+ end
+ end
+ local dom = u:get_tld()
+ if not domains[dom] then
+ domains[dom] = 1
+ else
+ domains[dom] = domains[dom] + 1
+ end
+ end, fun.filter(function(u)
+ return not u:is_html_displayed()
+ end,
+ task:get_urls(true)))
+
+ local results = {}
+ for k, v in lua_util.spairs(domains,
+ function(t, a, b)
+ return t[a] > t[b]
+ end, rule.selector.config.max_urls) do
+ if v > 0 then
+ table.insert(results, { k, v })
+ end
+ end
+
+ return results
+end
+
+local function url_reputation_filter(task, rule)
+ local requests = gen_url_queries(task, rule)
+ local url_keys = lua_util.keys(requests)
+ local requests_left = #url_keys
+ local results = {}
+
+ local function indexed_tokens_cb(err, index, values)
+ requests_left = requests_left - 1
+
+ if values then
+ results[index] = values
+ end
+
+ if requests_left == 0 then
+ -- Check the url with maximum hits
+ local mhits = 0
+
+ for i, res in ipairs(results) do
+ local req = requests[i]
+ if req then
+ local hits = tonumber(res[1])
+ if hits > mhits then
+ mhits = hits
+ end
+ else
+ rspamd_logger.warnx(task, "cannot find the requested response for a request: %s (%s requests noticed)",
+ i, #requests)
+ end
+ end
+
+ if mhits > 0 then
+ local score = 0
+ for i, res in pairs(results) do
+ local req = requests[i]
+ if req then
+ local url_score = generic_reputation_calc(res, rule,
+ req[2] / mhits, task)
+ lua_util.debugm(N, task, "score for url %s is %s, score=%s", req[1], url_score, score)
+ score = score + url_score
+ end
+ end
+
+ if math.abs(score) > 1e-3 then
+ -- TODO: add description
+ add_symbol_score(task, rule, score)
+ end
+ end
+ end
+ end
+
+ for i, req in ipairs(requests) do
+ local function tokens_cb(err, token, values)
+ indexed_tokens_cb(err, i, values)
+ end
+
+ rule.backend.get_token(task, rule, nil, req[1], tokens_cb, 'string')
+ end
+end
+
+local function url_reputation_idempotent(task, rule)
+ local requests = gen_url_queries(task, rule)
+ local sc = extract_task_score(task, rule)
+
+ if sc then
+ for _, tld in ipairs(requests) do
+ rule.backend.set_token(task, rule, nil, tld[1], sc)
+ end
+ end
+end
+
+local url_selector = {
+ config = {
+ symbol = 'URL_SCORE', -- symbol to be inserted
+ lower_bound = 10, -- minimum number of messages to be scored
+ min_score = nil,
+ max_score = nil,
+ max_urls = 10,
+ check_from = true,
+ outbound = true,
+ inbound = true,
+ },
+ filter = url_reputation_filter, -- used to get scores
+ idempotent = url_reputation_idempotent -- used to set scores
+}
+-- IP Selector functions
+
+local function ip_reputation_init(rule)
+ local cfg = rule.selector.config
+
+ if cfg.asn_cc_whitelist then
+ cfg.asn_cc_whitelist = lua_maps.map_add('reputation',
+ 'asn_cc_whitelist',
+ 'map',
+ 'IP score whitelisted ASNs/countries')
+ end
+
+ return true
+end
+
+local function ip_reputation_filter(task, rule)
+
+ local ip = task:get_from_ip()
+
+ if not ip or not ip:is_valid() then
+ return
+ end
+ if lua_util.is_rspamc_or_controller(task) then
+ return
+ end
+
+ local cfg = rule.selector.config
+
+ if ip:get_version() == 4 and cfg.ipv4_mask then
+ ip = ip:apply_mask(cfg.ipv4_mask)
+ elseif cfg.ipv6_mask then
+ ip = ip:apply_mask(cfg.ipv6_mask)
+ end
+
+ local pool = task:get_mempool()
+ local asn = pool:get_variable("asn")
+ local country = pool:get_variable("country")
+
+ if country and cfg.asn_cc_whitelist then
+ if cfg.asn_cc_whitelist:get_key(country) then
+ return
+ end
+ if asn and cfg.asn_cc_whitelist:get_key(asn) then
+ return
+ end
+ end
+
+ -- These variables are used to define if we have some specific token
+ local has_asn = not asn
+ local has_country = not country
+ local has_ip = false
+
+ local asn_stats, country_stats, ip_stats
+
+ local function ipstats_check()
+ local score = 0.0
+ local description_t = {}
+
+ if asn_stats then
+ local asn_score = generic_reputation_calc(asn_stats, rule, cfg.scores.asn, task)
+ score = score + asn_score
+ table.insert(description_t, string.format('asn: %s(%.2f)',
+ asn, asn_score))
+ end
+ if country_stats then
+ local country_score = generic_reputation_calc(country_stats, rule,
+ cfg.scores.country, task)
+ score = score + country_score
+ table.insert(description_t, string.format('country: %s(%.2f)',
+ country, country_score))
+ end
+ if ip_stats then
+ local ip_score = generic_reputation_calc(ip_stats, rule, cfg.scores.ip,
+ task)
+ score = score + ip_score
+ table.insert(description_t, string.format('ip: %s(%.2f)',
+ tostring(ip), ip_score))
+ end
+
+ if math.abs(score) > 0.001 then
+ add_symbol_score(task, rule, score, table.concat(description_t, ', '))
+ end
+ end
+
+ local function gen_token_callback(what)
+ return function(err, _, values)
+ if not err and values then
+ if what == 'asn' then
+ has_asn = true
+ asn_stats = values
+ elseif what == 'country' then
+ has_country = true
+ country_stats = values
+ elseif what == 'ip' then
+ has_ip = true
+ ip_stats = values
+ end
+ else
+ if what == 'asn' then
+ has_asn = true
+ elseif what == 'country' then
+ has_country = true
+ elseif what == 'ip' then
+ has_ip = true
+ end
+ end
+
+ if has_asn and has_country and has_ip then
+ -- Check reputation
+ ipstats_check()
+ end
+ end
+ end
+
+ if asn then
+ rule.backend.get_token(task, rule, cfg.asn_prefix, asn,
+ gen_token_callback('asn'), 'string')
+ end
+ if country then
+ rule.backend.get_token(task, rule, cfg.country_prefix, country,
+ gen_token_callback('country'), 'string')
+ end
+
+ rule.backend.get_token(task, rule, cfg.ip_prefix, ip,
+ gen_token_callback('ip'), 'ip')
+end
+
+-- Used to set scores
+local function ip_reputation_idempotent(task, rule)
+ if not rule.backend.set_token then
+ return
+ end -- Read only backend
+ local ip = task:get_from_ip()
+ local cfg = rule.selector.config
+
+ if not ip or not ip:is_valid() then
+ return
+ end
+
+ if lua_util.is_rspamc_or_controller(task) then
+ return
+ end
+
+ if ip:get_version() == 4 and cfg.ipv4_mask then
+ ip = ip:apply_mask(cfg.ipv4_mask)
+ elseif cfg.ipv6_mask then
+ ip = ip:apply_mask(cfg.ipv6_mask)
+ end
+
+ local pool = task:get_mempool()
+ local asn = pool:get_variable("asn")
+ local country = pool:get_variable("country")
+
+ if country and cfg.asn_cc_whitelist then
+ if cfg.asn_cc_whitelist:get_key(country) then
+ return
+ end
+ if asn and cfg.asn_cc_whitelist:get_key(asn) then
+ return
+ end
+ end
+ local sc = extract_task_score(task, rule)
+ if sc then
+ if asn then
+ rule.backend.set_token(task, rule, cfg.asn_prefix, asn, sc, nil, 'string')
+ end
+ if country then
+ rule.backend.set_token(task, rule, cfg.country_prefix, country, sc, nil, 'string')
+ end
+
+ rule.backend.set_token(task, rule, cfg.ip_prefix, ip, sc, nil, 'ip')
+ end
+end
+
+-- Selectors are used to extract reputation tokens
+local ip_selector = {
+ config = {
+ scores = { -- how each component is evaluated
+ ['asn'] = 0.4,
+ ['country'] = 0.01,
+ ['ip'] = 1.0
+ },
+ symbol = 'SENDER_REP', -- symbol to be inserted
+ split_symbols = true,
+ asn_prefix = 'a:', -- prefix for ASN hashes
+ country_prefix = 'c:', -- prefix for country hashes
+ ip_prefix = 'i:',
+ lower_bound = 10, -- minimum number of messages to be scored
+ min_score = nil,
+ max_score = nil,
+ score_divisor = 1,
+ outbound = false,
+ inbound = true,
+ ipv4_mask = 32, -- Mask bits for ipv4
+ ipv6_mask = 64, -- Mask bits for ipv6
+ },
+ --dependencies = {"ASN"}, -- ASN is a prefilter now...
+ init = ip_reputation_init,
+ filter = ip_reputation_filter, -- used to get scores
+ idempotent = ip_reputation_idempotent, -- used to set scores
+}
+
+-- SPF Selector functions
+
+local function spf_reputation_filter(task, rule)
+ local spf_record = task:get_mempool():get_variable('spf_record')
+ local spf_allow = task:has_symbol('R_SPF_ALLOW')
+
+ -- Don't care about bad/missing spf
+ if not spf_record or not spf_allow then
+ return
+ end
+
+ local cr = require "rspamd_cryptobox_hash"
+ local hkey = cr.create(spf_record):base32():sub(1, 32)
+
+ lua_util.debugm(N, task, 'check spf record %s -> %s', spf_record, hkey)
+
+ local function tokens_cb(err, token, values)
+ if values then
+ local score = generic_reputation_calc(values, rule, 1.0, task)
+
+ if math.abs(score) > 1e-3 then
+ -- TODO: add description
+ add_symbol_score(task, rule, score)
+ end
+ end
+ end
+
+ rule.backend.get_token(task, rule, nil, hkey, tokens_cb, 'string')
+end
+
+local function spf_reputation_idempotent(task, rule)
+ local sc = extract_task_score(task, rule)
+ local spf_record = task:get_mempool():get_variable('spf_record')
+ local spf_allow = task:has_symbol('R_SPF_ALLOW')
+
+ if not spf_record or not spf_allow or not sc then
+ return
+ end
+
+ local cr = require "rspamd_cryptobox_hash"
+ local hkey = cr.create(spf_record):base32():sub(1, 32)
+
+ lua_util.debugm(N, task, 'set spf record %s -> %s = %s',
+ spf_record, hkey, sc)
+ rule.backend.set_token(task, rule, nil, hkey, sc)
+end
+
+local spf_selector = {
+ config = {
+ symbol = 'SPF_REP', -- symbol to be inserted
+ split_symbols = true,
+ lower_bound = 10, -- minimum number of messages to be scored
+ min_score = nil,
+ max_score = nil,
+ outbound = true,
+ inbound = true,
+ },
+ dependencies = { "R_SPF_ALLOW" },
+ filter = spf_reputation_filter, -- used to get scores
+ idempotent = spf_reputation_idempotent, -- used to set scores
+}
+
+-- Generic selector based on lua_selectors framework
+
+local function generic_reputation_init(rule)
+ local cfg = rule.selector.config
+
+ if not cfg.selector then
+ rspamd_logger.errx(rspamd_config, 'cannot configure generic rule: no selector specified')
+ return false
+ end
+
+ local selector = lua_selectors.create_selector_closure(rspamd_config,
+ cfg.selector, cfg.delimiter)
+
+ if not selector then
+ rspamd_logger.errx(rspamd_config, 'cannot configure generic rule: bad selector: %s',
+ cfg.selector)
+ return false
+ end
+
+ cfg.selector = selector -- Replace with closure
+
+ if cfg.whitelist then
+ cfg.whitelist = lua_maps.map_add('reputation',
+ 'generic_whitelist',
+ 'map',
+ 'Whitelisted selectors')
+ end
+
+ return true
+end
+
+local function generic_reputation_filter(task, rule)
+ local cfg = rule.selector.config
+ local selector_res = cfg.selector(task)
+
+ local function tokens_cb(err, token, values)
+ if values then
+ local score = generic_reputation_calc(values, rule, 1.0, task)
+
+ if math.abs(score) > 1e-3 then
+ -- TODO: add description
+ add_symbol_score(task, rule, score)
+ end
+ end
+ end
+
+ if selector_res then
+ if type(selector_res) == 'table' then
+ fun.each(function(e)
+ lua_util.debugm(N, task, 'check generic reputation (%s) %s',
+ rule['symbol'], e)
+ rule.backend.get_token(task, rule, nil, e, tokens_cb, 'string')
+ end, selector_res)
+ else
+ lua_util.debugm(N, task, 'check generic reputation (%s) %s',
+ rule['symbol'], selector_res)
+ rule.backend.get_token(task, rule, nil, selector_res, tokens_cb, 'string')
+ end
+ end
+end
+
+local function generic_reputation_idempotent(task, rule)
+ local sc = extract_task_score(task, rule)
+ local cfg = rule.selector.config
+
+ local selector_res = cfg.selector(task)
+ if not selector_res then
+ return
+ end
+
+ if sc then
+ if type(selector_res) == 'table' then
+ fun.each(function(e)
+ lua_util.debugm(N, task, 'set generic selector (%s) %s = %s',
+ rule['symbol'], e, sc)
+ rule.backend.set_token(task, rule, nil, e, sc)
+ end, selector_res)
+ else
+ lua_util.debugm(N, task, 'set generic selector (%s) %s = %s',
+ rule['symbol'], selector_res, sc)
+ rule.backend.set_token(task, rule, nil, selector_res, sc)
+ end
+ end
+end
+
+local generic_selector = {
+ schema = ts.shape {
+ lower_bound = ts.number + ts.string / tonumber,
+ max_score = ts.number:is_optional(),
+ min_score = ts.number:is_optional(),
+ outbound = ts.boolean,
+ inbound = ts.boolean,
+ selector = ts.string,
+ delimiter = ts.string,
+ whitelist = ts.one_of(lua_maps.map_schema, lua_maps_exprs.schema):is_optional(),
+ },
+ config = {
+ lower_bound = 10, -- minimum number of messages to be scored
+ min_score = nil,
+ max_score = nil,
+ outbound = true,
+ inbound = true,
+ selector = nil,
+ delimiter = ':',
+ whitelist = nil
+ },
+ init = generic_reputation_init,
+ filter = generic_reputation_filter, -- used to get scores
+ idempotent = generic_reputation_idempotent -- used to set scores
+}
+
+local selectors = {
+ ip = ip_selector,
+ sender = ip_selector, -- Better name
+ url = url_selector,
+ dkim = dkim_selector,
+ spf = spf_selector,
+ generic = generic_selector
+}
+
+local function reputation_dns_init(rule, _, _, _)
+ if not rule.backend.config.list then
+ rspamd_logger.errx(rspamd_config, "rule %s with DNS backend has no `list` parameter defined",
+ rule.symbol)
+ return false
+ end
+
+ return true
+end
+
+local function gen_token_key(prefix, token, rule)
+ if prefix then
+ token = prefix .. token
+ end
+ local res = token
+ if rule.backend.config.hashed then
+ local hash_alg = rule.backend.config.hash_alg or "blake2"
+ local encoding = "base32"
+
+ if rule.backend.config.hash_encoding then
+ encoding = rule.backend.config.hash_encoding
+ end
+
+ local h = hash.create_specific(hash_alg, res)
+ if encoding == 'hex' then
+ res = h:hex()
+ elseif encoding == 'base64' then
+ res = h:base64()
+ else
+ res = h:base32()
+ end
+ end
+
+ if rule.backend.config.hashlen then
+ res = string.sub(res, 1, rule.backend.config.hashlen)
+ end
+
+ if rule.backend.config.prefix then
+ res = rule.backend.config.prefix .. res
+ end
+
+ return res
+end
+
+--[[
+-- Generic interface for get and set tokens functions:
+-- get_token(task, rule, prefix, token, continuation, token_type), where `continuation` is the following function:
+--
+-- function(err, token, values) ... end
+-- `err`: string value for error (similar to redis or DNS callbacks)
+-- `token`: string value of a token
+-- `values`: table of key=number, parsed from backend. It is selector's duty
+-- to deal with missing, invalid or other values
+--
+-- set_token(task, rule, token, values, continuation_cb)
+-- This function takes values, encodes them using whatever suitable format
+-- and calls for continuation:
+--
+-- function(err, token) ... end
+-- `err`: string value for error (similar to redis or DNS callbacks)
+-- `token`: string value of a token
+--
+-- example of tokens: {'s': 0, 'h': 0, 'p': 1}
+--]]
+
+local function reputation_dns_get_token(task, rule, prefix, token, continuation_cb, token_type)
+ -- local r = task:get_resolver()
+ -- In DNS we never ever use prefix as prefix, we use if as a suffix!
+ if token_type == 'ip' then
+ token = table.concat(token:inversed_str_octets(), '.')
+ end
+
+ local key = gen_token_key(nil, token, rule)
+ local dns_name = key .. '.' .. rule.backend.config.list
+
+ if prefix then
+ dns_name = string.format('%s.%s.%s', key, prefix,
+ rule.backend.config.list)
+ else
+ dns_name = string.format('%s.%s', key, rule.backend.config.list)
+ end
+
+ local function dns_cb(_, _, results, err)
+ if err and (err ~= 'requested record is not found' and
+ err ~= 'no records with this name') then
+ rspamd_logger.warnx(task, 'error looking up %s: %s', dns_name, err)
+ end
+
+ lua_util.debugm(N, task, 'DNS RESPONSE: label=%1 results=%2 err=%3 list=%4',
+ dns_name, results, err, rule.backend.config.list)
+
+ -- Now split tokens to list of values
+ if results and results[1] then
+ -- Format: num_messages;sc1;sc2...scn
+ local dns_tokens = lua_util.rspamd_str_split(results[1], ";")
+ -- Convert all to numbers excluding any possible non-numbers
+ dns_tokens = fun.totable(fun.filter(function(e)
+ return type(e) == 'number'
+ end,
+ fun.map(function(e)
+ local n = tonumber(e)
+ if n then
+ return n
+ end
+ return "BAD"
+ end, dns_tokens)))
+
+ if #dns_tokens < 2 then
+ rspamd_logger.warnx(task, 'cannot parse response for reputation token %s: %s',
+ dns_name, results[1])
+ continuation_cb(results, dns_name, nil)
+ else
+ local cnt = table.remove(dns_tokens, 1)
+ continuation_cb(nil, dns_name, { cnt, dns_tokens })
+ end
+ else
+ rspamd_logger.messagex(task, 'invalid response for reputation token %s: %s',
+ dns_name, results[1])
+ continuation_cb(results, dns_name, nil)
+ end
+ end
+
+ task:get_resolver():resolve_a({
+ task = task,
+ name = dns_name,
+ callback = dns_cb,
+ forced = true,
+ })
+end
+
+local function reputation_redis_init(rule, cfg, ev_base, worker)
+ local our_redis_params = {}
+
+ our_redis_params = lua_redis.try_load_redis_servers(rule.backend.config, rspamd_config,
+ true)
+ if not our_redis_params then
+ our_redis_params = redis_params
+ end
+ if not our_redis_params then
+ rspamd_logger.errx(rspamd_config, 'cannot init redis for reputation rule: %s',
+ rule)
+ return false
+ end
+ -- Init scripts for buckets
+ -- Redis script to extract data from Redis buckets
+ -- KEYS[1] - key to extract
+ -- Value returned - table of scores as a strings vector + number of scores
+ local redis_get_script_tpl = [[
+ local cnt = redis.call('HGET', KEYS[1], 'n')
+ local results = {}
+ if cnt then
+ {% for w in windows %}
+ local sc = tonumber(redis.call('HGET', KEYS[1], 'v' .. '{= w.name =}'))
+ table.insert(results, tostring(sc * {= w.mult =}))
+ {% endfor %}
+ else
+ {% for w in windows %}
+ table.insert(results, '0')
+ {% endfor %}
+ end
+
+ return {cnt or 0, results}
+ ]]
+
+ local get_script = lua_util.jinja_template(redis_get_script_tpl,
+ { windows = rule.backend.config.buckets })
+ rspamd_logger.debugm(N, rspamd_config, 'added extraction script %s', get_script)
+ rule.backend.script_get = lua_redis.add_redis_script(get_script, our_redis_params)
+
+ -- Redis script to update Redis buckets
+ -- KEYS[1] - key to update
+ -- KEYS[2] - current time in milliseconds
+ -- KEYS[3] - message score
+ -- KEYS[4] - expire for a bucket
+ -- Value returned - table of scores as a strings vector
+ local redis_adaptive_emea_script_tpl = [[
+ local last = redis.call('HGET', KEYS[1], 'l')
+ local score = tonumber(KEYS[3])
+ local now = tonumber(KEYS[2])
+ local scores = {}
+
+ if last then
+ {% for w in windows %}
+ local last_value = tonumber(redis.call('HGET', KEYS[1], 'v' .. '{= w.name =}'))
+ local window = {= w.time =}
+ -- Adjust alpha
+ local time_diff = now - last
+ if time_diff < 0 then
+ time_diff = 0
+ end
+ local alpha = 1.0 - math.exp((-time_diff) / (1000 * window))
+ local nscore = alpha * score + (1.0 - alpha) * last_value
+ table.insert(scores, tostring(nscore * {= w.mult =}))
+ {% endfor %}
+ else
+ {% for w in windows %}
+ table.insert(scores, tostring(score * {= w.mult =}))
+ {% endfor %}
+ end
+
+ local i = 1
+ {% for w in windows %}
+ redis.call('HSET', KEYS[1], 'v' .. '{= w.name =}', scores[i])
+ i = i + 1
+ {% endfor %}
+ redis.call('HSET', KEYS[1], 'l', now)
+ redis.call('HINCRBY', KEYS[1], 'n', 1)
+ redis.call('EXPIRE', KEYS[1], tonumber(KEYS[4]))
+
+ return scores
+]]
+
+ local set_script = lua_util.jinja_template(redis_adaptive_emea_script_tpl,
+ { windows = rule.backend.config.buckets })
+ rspamd_logger.debugm(N, rspamd_config, 'added emea update script %s', set_script)
+ rule.backend.script_set = lua_redis.add_redis_script(set_script, our_redis_params)
+
+ return true
+end
+
+local function reputation_redis_get_token(task, rule, prefix, token, continuation_cb, token_type)
+ if token_type and token_type == 'ip' then
+ token = tostring(token)
+ end
+ local key = gen_token_key(prefix, token, rule)
+
+ local function redis_get_cb(err, data)
+ if data then
+ if type(data) == 'table' then
+ lua_util.debugm(N, task, 'rule %s - got values for key %s -> %s',
+ rule['symbol'], key, data)
+ continuation_cb(nil, key, data)
+ else
+ rspamd_logger.errx(task, 'rule %s - invalid type while getting reputation keys %s: %s',
+ rule['symbol'], key, type(data))
+ continuation_cb("invalid type", key, nil)
+ end
+
+ elseif err then
+ rspamd_logger.errx(task, 'rule %s - got error while getting reputation keys %s: %s',
+ rule['symbol'], key, err)
+ continuation_cb(err, key, nil)
+ else
+ rspamd_logger.errx(task, 'rule %s - got error while getting reputation keys %s: %s',
+ rule['symbol'], key, "unknown error")
+ continuation_cb("unknown error", key, nil)
+ end
+ end
+
+ local ret = lua_redis.exec_redis_script(rule.backend.script_get,
+ { task = task, is_write = false },
+ redis_get_cb,
+ { key })
+ if not ret then
+ rspamd_logger.errx(task, 'cannot make redis request to check results')
+ end
+end
+
+local function reputation_redis_set_token(task, rule, prefix, token, sc, continuation_cb, token_type)
+ if token_type and token_type == 'ip' then
+ token = tostring(token)
+ end
+ local key = gen_token_key(prefix, token, rule)
+
+ local function redis_set_cb(err, data)
+ if err then
+ rspamd_logger.errx(task, 'rule %s - got error while setting reputation keys %s: %s',
+ rule['symbol'], key, err)
+ if continuation_cb then
+ continuation_cb(err, key)
+ end
+ else
+ if continuation_cb then
+ continuation_cb(nil, key)
+ end
+ end
+ end
+
+ lua_util.debugm(N, task, 'rule %s - set values for key %s -> %s',
+ rule['symbol'], key, sc)
+ local ret = lua_redis.exec_redis_script(rule.backend.script_set,
+ { task = task, is_write = true },
+ redis_set_cb,
+ { key, tostring(os.time() * 1000),
+ tostring(sc),
+ tostring(rule.backend.config.expiry) })
+ if not ret then
+ rspamd_logger.errx(task, 'got error while connecting to redis')
+ end
+end
+
+--[[ Backends are responsible for getting reputation tokens
+ -- Common config options:
+ -- `hashed`: if `true` then apply hash function to the key
+ -- `hash_alg`: use specific hash type (`blake2` by default)
+ -- `hash_len`: strip hash to this amount of bytes (no strip by default)
+ -- `hash_encoding`: use specific hash encoding (base32 by default)
+--]]
+local backends = {
+ redis = {
+ schema = lua_redis.enrich_schema({
+ prefix = ts.string:is_optional(),
+ expiry = (ts.number + ts.string / lua_util.parse_time_interval):is_optional(),
+ buckets = ts.array_of(ts.shape {
+ time = ts.number + ts.string / lua_util.parse_time_interval,
+ name = ts.string,
+ mult = ts.number + ts.string / tonumber
+ }) :is_optional(),
+ }),
+ config = {
+ expiry = default_expiry,
+ prefix = default_prefix,
+ buckets = {
+ {
+ time = 60 * 60 * 24 * 30,
+ name = '1m',
+ mult = 1.0,
+ }
+ }, -- What buckets should be used, default 1h and 1month
+ },
+ init = reputation_redis_init,
+ get_token = reputation_redis_get_token,
+ set_token = reputation_redis_set_token,
+ },
+ dns = {
+ schema = ts.shape {
+ list = ts.string,
+ },
+ config = {
+ -- list = rep.example.com
+ },
+ get_token = reputation_dns_get_token,
+ -- No set token for DNS
+ init = reputation_dns_init,
+ }
+}
+
+local function is_rule_applicable(task, rule)
+ local ip = task:get_from_ip()
+ if not (rule.selector.config.outbound and rule.selector.config.inbound) then
+ if rule.selector.config.outbound then
+ if not (task:get_user() or (ip and ip:is_local())) then
+ return false
+ end
+ elseif rule.selector.config.inbound then
+ if task:get_user() or (ip and ip:is_local()) then
+ return false
+ end
+ end
+ end
+
+ if rule.config.whitelist_map then
+ if rule.config.whitelist_map:process(task) then
+ return false
+ end
+ end
+
+ return true
+end
+
+local function reputation_filter_cb(task, rule)
+ if (is_rule_applicable(task, rule)) then
+ rule.selector.filter(task, rule, rule.backend)
+ end
+end
+
+local function reputation_postfilter_cb(task, rule)
+ if (is_rule_applicable(task, rule)) then
+ rule.selector.postfilter(task, rule, rule.backend)
+ end
+end
+
+local function reputation_idempotent_cb(task, rule)
+ if (is_rule_applicable(task, rule)) then
+ rule.selector.idempotent(task, rule, rule.backend)
+ end
+end
+
+local function callback_gen(cb, rule)
+ return function(task)
+ if rule.enabled then
+ cb(task, rule)
+ end
+ end
+end
+
+local function parse_rule(name, tbl)
+ local sel_type, sel_conf = fun.head(tbl.selector)
+ local selector = selectors[sel_type]
+
+ if not selector then
+ rspamd_logger.errx(rspamd_config, "unknown selector defined for rule %s: %s", name,
+ sel_type)
+ return false
+ end
+
+ local bk_type, bk_conf = fun.head(tbl.backend)
+
+ local backend = backends[bk_type]
+ if not backend then
+ rspamd_logger.errx(rspamd_config, "unknown backend defined for rule %s: %s", name,
+ tbl.backend.type)
+ return false
+ end
+ -- Allow config override
+ local rule = {
+ selector = lua_util.shallowcopy(selector),
+ backend = lua_util.shallowcopy(backend),
+ config = {}
+ }
+
+ -- Override default config params
+ rule.backend.config = lua_util.override_defaults(rule.backend.config, bk_conf)
+ if backend.schema then
+ local checked, schema_err = backend.schema:transform(rule.backend.config)
+ if not checked then
+ rspamd_logger.errx(rspamd_config, "cannot parse backend config for %s: %s",
+ sel_type, schema_err)
+
+ return false
+ end
+
+ rule.backend.config = checked
+ end
+
+ rule.selector.config = lua_util.override_defaults(rule.selector.config, sel_conf)
+ if selector.schema then
+ local checked, schema_err = selector.schema:transform(rule.selector.config)
+
+ if not checked then
+ rspamd_logger.errx(rspamd_config, "cannot parse selector config for %s: %s (%s)",
+ sel_type,
+ schema_err, sel_conf)
+ return
+ end
+
+ rule.selector.config = checked
+ end
+ -- Generic options
+ tbl.selector = nil
+ tbl.backend = nil
+ rule.config = lua_util.override_defaults(rule.config, tbl)
+
+ if rule.config.whitelist then
+ if lua_maps_exprs.schema(rule.config.whitelist) then
+ rule.config.whitelist_map = lua_maps_exprs.create(rspamd_config,
+ rule.config.whitelist, N)
+ elseif lua_maps.map_schema(rule.config.whitelist) then
+ local map = lua_maps.map_add_from_ucl(rule.config.whitelist,
+ 'radix',
+ sel_type .. ' reputation whitelist')
+
+ if not map then
+ rspamd_logger.errx(rspamd_config, "cannot parse whitelist map config for %s: (%s)",
+ sel_type,
+ rule.config.whitelist)
+ return
+ end
+
+ rule.config.whitelist_map = {
+ process = function(_, task)
+ -- Hack: we assume that it is an ip whitelist :(
+ local ip = task:get_from_ip()
+
+ if ip and map:get_key(ip) then
+ return true
+ end
+ return false
+ end
+ }
+ else
+ rspamd_logger.errx(rspamd_config, "cannot parse whitelist map config for %s: (%s)",
+ sel_type,
+ rule.config.whitelist)
+ return false
+ end
+ end
+
+ local symbol = rule.selector.config.symbol or name
+ if tbl.symbol then
+ symbol = tbl.symbol
+ end
+
+ rule.symbol = symbol
+ rule.enabled = true
+ if rule.selector.init then
+ rule.enabled = false
+ end
+ if rule.backend.init then
+ rule.enabled = false
+ end
+ -- Perform additional initialization if needed
+ rspamd_config:add_on_load(function(cfg, ev_base, worker)
+ if rule.selector.init then
+ if not rule.selector.init(rule, cfg, ev_base, worker) then
+ rule.enabled = false
+ rspamd_logger.errx(rspamd_config, 'Cannot init selector %s (backend %s) for symbol %s',
+ sel_type, bk_type, rule.symbol)
+ else
+ rule.enabled = true
+ end
+ end
+ if rule.backend.init then
+ if not rule.backend.init(rule, cfg, ev_base, worker) then
+ rule.enabled = false
+ rspamd_logger.errx(rspamd_config, 'Cannot init backend (%s) for rule %s for symbol %s',
+ bk_type, sel_type, rule.symbol)
+ else
+ rule.enabled = true
+ end
+ end
+
+ if rule.enabled then
+ rspamd_logger.infox(rspamd_config, 'Enable %s (%s backend) rule for symbol %s (split symbols: %s)',
+ sel_type, bk_type, rule.symbol,
+ rule.selector.config.split_symbols)
+ end
+ end)
+
+ -- We now generate symbol for checking
+ local rule_type = 'normal'
+ if rule.selector.config.split_symbols then
+ rule_type = 'callback'
+ end
+
+ local id = rspamd_config:register_symbol {
+ name = rule.symbol,
+ type = rule_type,
+ callback = callback_gen(reputation_filter_cb, rule),
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ }
+
+ if rule.selector.config.split_symbols then
+ rspamd_config:register_symbol {
+ name = rule.symbol .. '_HAM',
+ type = 'virtual',
+ parent = id,
+ }
+ rspamd_config:register_symbol {
+ name = rule.symbol .. '_SPAM',
+ type = 'virtual',
+ parent = id,
+ }
+ end
+
+ if rule.selector.dependencies then
+ fun.each(function(d)
+ rspamd_config:register_dependency(symbol, d)
+ end, rule.selector.dependencies)
+ end
+
+ if rule.selector.postfilter then
+ -- Also register a postfilter
+ rspamd_config:register_symbol {
+ name = rule.symbol .. '_POST',
+ type = 'postfilter',
+ flags = 'nostat,explicit_disable,ignore_passthrough',
+ callback = callback_gen(reputation_postfilter_cb, rule),
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ }
+ end
+
+ if rule.selector.idempotent then
+ -- Has also idempotent component (e.g. saving data to the backend)
+ rspamd_config:register_symbol {
+ name = rule.symbol .. '_IDEMPOTENT',
+ type = 'idempotent',
+ flags = 'explicit_disable,ignore_passthrough',
+ callback = callback_gen(reputation_idempotent_cb, rule),
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ }
+ end
+
+ return true
+end
+
+redis_params = lua_redis.parse_redis_server('reputation')
+local opts = rspamd_config:get_all_opt("reputation")
+
+-- Initialization part
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'Module is not configured, disabling it')
+ return
+end
+
+if opts['rules'] then
+ for k, v in pairs(opts['rules']) do
+ if not ((v or E).selector) then
+ rspamd_logger.errx(rspamd_config, "no selector defined for rule %s", k)
+ lua_util.config_utils.push_config_error(N, "no selector defined for rule: " .. k)
+ else
+ if not parse_rule(k, v) then
+ lua_util.config_utils.push_config_error(N, "reputation rule is misconfigured: " .. k)
+ end
+ end
+ end
+else
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/rspamd_update.lua b/src/plugins/lua/rspamd_update.lua
new file mode 100644
index 0000000..deda038
--- /dev/null
+++ b/src/plugins/lua/rspamd_update.lua
@@ -0,0 +1,161 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- This plugin implements dynamic updates for rspamd
+
+local ucl = require "ucl"
+local fun = require "fun"
+local rspamd_logger = require "rspamd_logger"
+local rspamd_config = rspamd_config
+local hash = require "rspamd_cryptobox_hash"
+local lua_util = require "lua_util"
+local N = "rspamd_update"
+local rspamd_version = rspamd_version
+local maps = {}
+local allow_rules = false -- Deny for now
+local global_priority = 1 -- Default for local rules
+
+local function process_symbols(obj, priority)
+ fun.each(function(sym, score)
+ rspamd_config:set_metric_symbol({
+ name = sym,
+ score = score,
+ priority = priority
+ })
+ end, obj)
+end
+
+local function process_actions(obj, priority)
+ fun.each(function(act, score)
+ rspamd_config:set_metric_action({
+ action = act,
+ score = score,
+ priority = priority
+ })
+ end, obj)
+end
+
+local function process_rules(obj)
+ fun.each(function(key, code)
+ local f = load(code)
+ if f then
+ f()
+ else
+ rspamd_logger(rspamd_config, 'cannot load rules for %s', key)
+ end
+ end, obj)
+end
+
+local function check_version(obj)
+ local ret = true
+
+ if not obj then
+ return false
+ end
+
+ if obj['min_version'] then
+ if rspamd_version('cmp', obj['min_version']) > 0 then
+ ret = false
+ rspamd_logger.errx(rspamd_config, 'updates require at least %s version of rspamd',
+ obj['min_version'])
+ end
+ end
+ if obj['max_version'] then
+ if rspamd_version('cmp', obj['max_version']) < 0 then
+ ret = false
+ rspamd_logger.errx(rspamd_config, 'updates require maximum %s version of rspamd',
+ obj['max_version'])
+ end
+ end
+
+ return ret
+end
+
+local function gen_callback()
+
+ return function(data)
+ local parser = ucl.parser()
+ local res, err = parser:parse_string(data)
+
+ if not res then
+ rspamd_logger.warnx(rspamd_config, 'cannot parse updates map: ' .. err)
+ else
+ local h = hash.create()
+ h:update(data)
+ local obj = parser:get_object()
+
+ if check_version(obj) then
+
+ if obj['symbols'] then
+ process_symbols(obj['symbols'], global_priority)
+ end
+ if obj['actions'] then
+ process_actions(obj['actions'], global_priority)
+ end
+ if allow_rules and obj['rules'] then
+ process_rules(obj['rules'])
+ end
+
+ rspamd_logger.infox(rspamd_config, 'loaded new rules with hash "%s"',
+ h:hex())
+ end
+ end
+
+ return res
+ end
+end
+
+-- Configuration part
+local section = rspamd_config:get_all_opt("rspamd_update")
+if section and section.rules then
+ local trusted_key
+ if section.key then
+ trusted_key = section.key
+ end
+
+ if type(section.rules) ~= 'table' then
+ section.rules = { section.rules }
+ end
+
+ fun.each(function(elt)
+ local map = rspamd_config:add_map(elt, "rspamd updates map", nil, "callback")
+ if not map then
+ rspamd_logger.errx(rspamd_config, 'cannot load updates from %1', elt)
+ else
+ map:set_callback(gen_callback(map))
+ maps['elt'] = map
+ end
+ end, section.rules)
+
+ fun.each(function(k, map)
+ -- Check sanity for maps
+ local proto = map:get_proto()
+ if (proto == 'http' or proto == 'https') and not map:get_sign_key() then
+ if trusted_key then
+ map:set_sign_key(trusted_key)
+ else
+ rspamd_logger.warnx(rspamd_config, 'Map %s is loaded by HTTP and it is not signed', k)
+ end
+ end
+ end, maps)
+else
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/settings.lua b/src/plugins/lua/settings.lua
new file mode 100644
index 0000000..69d31d3
--- /dev/null
+++ b/src/plugins/lua/settings.lua
@@ -0,0 +1,1437 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- This plugin implements user dynamic settings
+-- Settings documentation can be found here:
+-- https://rspamd.com/doc/configuration/settings.html
+
+local rspamd_logger = require "rspamd_logger"
+local lua_maps = require "lua_maps"
+local lua_util = require "lua_util"
+local rspamd_ip = require "rspamd_ip"
+local rspamd_regexp = require "rspamd_regexp"
+local lua_selectors = require "lua_selectors"
+local lua_settings = require "lua_settings"
+local ucl = require "ucl"
+local fun = require "fun"
+local rspamd_mempool = require "rspamd_mempool"
+
+local redis_params
+
+local settings = {}
+local N = "settings"
+local settings_initialized = false
+local max_pri = 0
+local module_sym_id -- Main module symbol
+
+local function apply_settings(task, to_apply, id, name)
+ local cached_name = task:cache_get('settings_name')
+ if cached_name then
+ local cached_settings = task:cache_get('settings')
+ rspamd_logger.warnx(task, "cannot apply settings rule %s (id=%s):" ..
+ " settings has been already applied by rule %s (id=%s)",
+ name, id, cached_name, cached_settings.id)
+ return false
+ end
+
+ task:set_settings(to_apply)
+ task:cache_set('settings', to_apply)
+ task:cache_set('settings_name', name or 'unknown')
+
+ if id then
+ task:set_settings_id(id)
+ end
+
+ if to_apply['add_headers'] or to_apply['remove_headers'] then
+ local rep = {
+ add_headers = to_apply['add_headers'] or {},
+ remove_headers = to_apply['remove_headers'] or {},
+ }
+ task:set_rmilter_reply(rep)
+ end
+
+ if to_apply.flags and type(to_apply.flags) == 'table' then
+ for _, fl in ipairs(to_apply.flags) do
+ task:set_flag(fl)
+ end
+ end
+
+ if to_apply.symbols then
+ -- Add symbols, specified in the settings
+ if #to_apply.symbols > 0 then
+ -- Array like symbols
+ for _, val in ipairs(to_apply.symbols) do
+ task:insert_result(val, 1.0)
+ end
+ else
+ -- Object like symbols
+ for k, v in pairs(to_apply.symbols) do
+ if type(v) == 'table' then
+ task:insert_result(k, v.score or 1.0, v.options or {})
+ elseif tonumber(v) then
+ task:insert_result(k, tonumber(v))
+ end
+ end
+ end
+ end
+
+ if to_apply.subject then
+ task:set_metric_subject(to_apply.subject)
+ end
+
+ -- E.g.
+ -- messages = { smtp_message = "5.3.1 Go away" }
+ if to_apply.messages and type(to_apply.messages) == 'table' then
+ fun.each(function(category, message)
+ task:append_message(message, category)
+ end, to_apply.messages)
+ end
+
+ return true
+end
+
+-- Checks for overridden settings within query params and returns 3 values:
+-- * Apply element
+-- * Settings ID element if found
+-- * Priority of the settings according to the place where it is found
+--
+-- If no override has been found, it returns `false`
+local function check_query_settings(task)
+ -- Try 'settings' attribute
+ local settings_id = task:get_settings_id()
+ local query_set = task:get_request_header('settings')
+ if query_set then
+
+ local parser = ucl.parser()
+ local res, err = parser:parse_text(query_set)
+ if res then
+ if settings_id then
+ rspamd_logger.warnx(task, "both settings-id '%s' and settings headers are presented, ignore settings-id; ",
+ tostring(settings_id))
+ end
+ local settings_obj = parser:get_object()
+
+ -- Treat as low priority
+ return settings_obj, nil, 1
+ else
+ rspamd_logger.errx(task, 'Parse error: %s', err)
+ end
+ end
+
+ local query_maxscore = task:get_request_header('maxscore')
+ local nset
+
+ if query_maxscore then
+ if settings_id then
+ rspamd_logger.infox(task, "both settings id '%s' and maxscore '%s' headers are presented, merge them; " ..
+ "settings id has priority",
+ tostring(settings_id), tostring(query_maxscore))
+ end
+ -- We have score limits redefined by request
+ local ms = tonumber(tostring(query_maxscore))
+ if ms then
+ nset = {
+ actions = {
+ reject = ms
+ }
+ }
+
+ local query_softscore = task:get_request_header('softscore')
+ if query_softscore then
+ local ss = tonumber(tostring(query_softscore))
+ nset.actions['add header'] = ss
+ end
+
+ if not settings_id then
+ rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions)
+ -- Maxscore is low priority
+ return nset, nil, 1
+ end
+ end
+ end
+
+ if settings_id and settings_initialized then
+ local cached = lua_settings.settings_by_id(settings_id)
+ lua_util.debugm(N, task, "check settings id for %s", settings_id)
+
+ if cached then
+ local elt = cached.settings
+ if elt['whitelist'] then
+ elt['apply'] = { whitelist = true }
+ end
+
+ if elt.apply then
+ if nset then
+ elt.apply = lua_util.override_defaults(nset, elt.apply)
+ end
+ end
+ return elt.apply, cached, cached.priority or 1
+ else
+ rspamd_logger.warnx(task, 'no settings id "%s" has been found', settings_id)
+ if nset then
+ rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions)
+ return nset, nil, 1
+ end
+ end
+ else
+ if nset then
+ rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions)
+ return nset, nil, 1
+ end
+ end
+
+ return false
+end
+
+local function check_addr_setting(expected, addr)
+ local function check_specific_addr(elt)
+ if expected.name then
+ if lua_maps.rspamd_maybe_check_map(expected.name, elt.addr) then
+ return true
+ end
+ end
+ if expected.user then
+ if lua_maps.rspamd_maybe_check_map(expected.user, elt.user) then
+ return true
+ end
+ end
+ if expected.domain and elt.domain then
+ if lua_maps.rspamd_maybe_check_map(expected.domain, elt.domain) then
+ return true
+ end
+ end
+ if expected.regexp then
+ if expected.regexp:match(elt.addr) then
+ return true
+ end
+ end
+ return false
+ end
+
+ for _, e in ipairs(addr) do
+ if check_specific_addr(e) then
+ return true
+ end
+ end
+
+ return false
+end
+
+local function check_string_setting(expected, str)
+ if expected.regexp then
+ if expected.regexp:match(str) then
+ return true
+ end
+ elseif expected.check then
+ if lua_maps.rspamd_maybe_check_map(expected.check, str) then
+ return true
+ end
+ end
+ return false
+end
+
+local function check_ip_setting(expected, ip)
+ if not expected[2] then
+ if lua_maps.rspamd_maybe_check_map(expected[1], ip:to_string()) then
+ return true
+ end
+ else
+ if expected[2] ~= 0 then
+ local nip = ip:apply_mask(expected[2])
+ if nip and nip:to_string() == expected[1] then
+ return true
+ end
+ elseif ip:to_string() == expected[1] then
+ return true
+ end
+ end
+
+ return false
+end
+
+local function check_map_setting(map, input)
+ return map:get_key(input)
+end
+
+local function priority_to_string(pri)
+ if pri then
+ if pri >= 3 then
+ return "high"
+ elseif pri >= 2 then
+ return "medium"
+ end
+ end
+
+ return "low"
+end
+
+-- Check limit for a task
+local function check_settings(task)
+ local function check_specific_setting(rule, matched)
+ local function process_atom(atom)
+ local elt = rule.checks[atom]
+
+ if elt then
+ local input = elt.extract(task)
+ if not input then
+ return false
+ end
+
+ if elt.check(input) then
+ matched[#matched + 1] = atom
+ return 1.0
+ end
+ else
+ rspamd_logger.errx(task, 'error in settings: check %s is not defined!', atom)
+ end
+
+ return 0
+ end
+
+ local res = rule.expression and rule.expression:process(process_atom) or rule.implicit
+
+ if res and res > 0 then
+ if rule['whitelist'] then
+ rule['apply'] = { whitelist = true }
+ end
+
+ return rule
+ end
+
+ return nil
+ end
+
+ -- Check if we have override as query argument
+ local query_apply, id_elt, priority = check_query_settings(task)
+
+ local function maybe_apply_query_settings()
+ if query_apply then
+ if id_elt then
+ apply_settings(task, query_apply, id_elt.id, id_elt.name)
+ rspamd_logger.infox(task, "applied settings id %s(%s); priority %s",
+ id_elt.name, id_elt.id, priority_to_string(priority))
+ else
+ apply_settings(task, query_apply, nil, 'HTTP query')
+ rspamd_logger.infox(task, "applied settings from query; priority %s",
+ priority_to_string(priority))
+ end
+ end
+ end
+
+ local min_pri = 1
+ if query_apply then
+ if priority >= min_pri then
+ -- Do not check lower or equal priorities
+ min_pri = priority + 1
+ end
+
+ if priority > max_pri then
+ -- Our internal priorities are lower then a priority from query, so no need to check
+ maybe_apply_query_settings()
+
+ return
+ end
+ elseif id_elt and type(id_elt.settings) == 'table' and id_elt.settings.external_map then
+ local external_map = id_elt.settings.external_map
+ local selector_result = external_map.selector(task)
+
+ if selector_result then
+ external_map.map:get_key(selector_result, nil, task)
+ -- No more selection logic
+ return
+ else
+ rspamd_logger.infox("cannot query selector to make external map request")
+ end
+ end
+
+ -- Do not waste resources
+ if not settings_initialized then
+ maybe_apply_query_settings()
+ return
+ end
+
+ -- Match rules according their order
+ local applied = false
+
+ for pri = max_pri, min_pri, -1 do
+ if not applied and settings[pri] then
+ for _, s in ipairs(settings[pri]) do
+ local matched = {}
+
+ local result = check_specific_setting(s.rule, matched)
+ lua_util.debugm(N, task, "check for settings element %s; result = %s",
+ s.name, result)
+ -- Can use xor here but more complicated for reading
+ if result then
+ if s.rule.apply then
+ if s.rule.id then
+ -- Extract static settings
+ local cached = lua_settings.settings_by_id(s.rule.id)
+
+ if not cached or not cached.settings or not cached.settings.apply then
+ rspamd_logger.errx(task, 'unregistered settings id found: %s!', s.rule.id)
+ else
+ rspamd_logger.infox(task, "<%s> apply static settings %s (id = %s); %s matched; priority %s",
+ task:get_message_id(),
+ cached.name, s.rule.id,
+ table.concat(matched, ','),
+ priority_to_string(pri))
+ apply_settings(task, cached.settings.apply, s.rule.id, s.name)
+ end
+
+ else
+ -- Dynamic settings
+ rspamd_logger.infox(task, "<%s> apply settings according to rule %s (%s matched)",
+ task:get_message_id(), s.name, table.concat(matched, ','))
+ apply_settings(task, s.rule.apply, nil, s.name)
+ end
+
+ applied = true
+ elseif s.rule.external_map then
+ local external_map = s.rule.external_map
+ local selector_result = external_map.selector(task)
+
+ if selector_result then
+ external_map.map:get_key(selector_result, nil, task)
+ -- No more selection logic
+ return
+ else
+ rspamd_logger.infox("cannot query selector to make external map request")
+ end
+ end
+ if s.rule['symbols'] then
+ -- Add symbols, specified in the settings
+ fun.each(function(val)
+ task:insert_result(val, 1.0)
+ end, s.rule['symbols'])
+ end
+ end
+ end
+ end
+ end
+
+ if not applied then
+ maybe_apply_query_settings()
+ end
+
+end
+
+local function convert_to_table(chk_elt, out)
+ if type(chk_elt) == 'string' then
+ return { out }
+ end
+
+ return out
+end
+
+local function gen_settings_external_cb(name)
+ return function(result, err_or_data, code, task)
+ if result then
+ local parser = ucl.parser()
+
+ local res, ucl_err = parser:parse_text(err_or_data)
+ if not res then
+ rspamd_logger.warnx(task, 'cannot parse settings from the external map %s: %s',
+ name, ucl_err)
+ else
+ local obj = parser:get_object()
+ rspamd_logger.infox(task, "<%s> apply settings according to the external map %s",
+ name, task:get_message_id())
+ apply_settings(task, obj, nil, 'external_map')
+ end
+ else
+ rspamd_logger.infox(task, "<%s> no settings returned from the external map %s: %s (code = %s)",
+ task:get_message_id(), name, err_or_data, code)
+ end
+ end
+end
+
+-- Process IP address: converted to a table {ip, mask}
+local function process_ip_condition(ip)
+ local out = {}
+
+ if type(ip) == "table" then
+ for _, v in ipairs(ip) do
+ table.insert(out, process_ip_condition(v))
+ end
+ elseif type(ip) == "string" then
+ local slash = string.find(ip, '/')
+
+ if not slash then
+ -- Just a plain IP address
+ local res = rspamd_ip.from_string(ip)
+
+ if res:is_valid() then
+ out[1] = res:to_string()
+ out[2] = 0
+ else
+ -- It can still be a map
+ out[1] = ip
+ end
+ else
+ local res = rspamd_ip.from_string(string.sub(ip, 1, slash - 1))
+ local mask = tonumber(string.sub(ip, slash + 1))
+
+ if res:is_valid() then
+ out[1] = res:to_string()
+ out[2] = mask
+ else
+ rspamd_logger.errx(rspamd_config, "bad IP address: " .. ip)
+ return nil
+ end
+ end
+ else
+ return nil
+ end
+
+ return out
+end
+
+-- Process email like condition, converted to a table with fields:
+-- name - full email (surprise!)
+-- user - user part
+-- domain - domain part
+-- regexp - full email regexp (yes, it sucks)
+local function process_email_condition(addr)
+ local out = {}
+ if type(addr) == "table" then
+ for _, v in ipairs(addr) do
+ table.insert(out, process_email_condition(v))
+ end
+ elseif type(addr) == "string" then
+ if string.sub(addr, 1, 4) == "map:" then
+ -- It is map, don't apply any extra logic
+ out['name'] = addr
+ else
+ local start = string.sub(addr, 1, 1)
+ if start == '/' then
+ -- It is a regexp
+ local re = rspamd_regexp.create(addr)
+ if re then
+ out['regexp'] = re
+ else
+ rspamd_logger.errx(rspamd_config, "bad regexp: " .. addr)
+ return nil
+ end
+
+ elseif start == '@' then
+ -- It is a domain if form @domain
+ out['domain'] = string.sub(addr, 2)
+ else
+ -- Check user@domain parts
+ local at = string.find(addr, '@')
+ if at then
+ -- It is full address
+ out['name'] = addr
+ else
+ -- It is a user
+ out['user'] = addr
+ end
+ end
+ end
+ else
+ return nil
+ end
+
+ return out
+end
+
+-- Convert a plain string condition to a table:
+-- check - string to match
+-- regexp - regexp to match
+local function process_string_condition(addr)
+ local out = {}
+ if type(addr) == "table" then
+ for _, v in ipairs(addr) do
+ table.insert(out, process_string_condition(v))
+ end
+ elseif type(addr) == "string" then
+ if string.sub(addr, 1, 4) == "map:" then
+ -- It is map, don't apply any extra logic
+ out['check'] = addr
+ else
+ local start = string.sub(addr, 1, 1)
+ if start == '/' then
+ -- It is a regexp
+ local re = rspamd_regexp.create(addr)
+ if re then
+ out['regexp'] = re
+ else
+ rspamd_logger.errx(rspamd_config, "bad regexp: " .. addr)
+ return nil
+ end
+
+ else
+ out['check'] = addr
+ end
+ end
+ else
+ return nil
+ end
+
+ return out
+end
+
+local function get_priority (elt)
+ local pri_tonum = function(p)
+ if p then
+ if type(p) == "number" then
+ return tonumber(p)
+ elseif type(p) == "string" then
+ if p == "high" then
+ return 3
+ elseif p == "medium" then
+ return 2
+ end
+
+ end
+
+ end
+
+ return 1
+ end
+
+ return pri_tonum(elt['priority'])
+end
+
+-- Used to create a checking closure: if value matches expected somehow, return true
+local function gen_check_closure(expected, check_func)
+ return function(value)
+ if not value then
+ return false
+ end
+
+ if type(value) == 'function' then
+ value = value()
+ end
+
+ if value then
+
+ if not check_func then
+ check_func = function(a, b)
+ return a == b
+ end
+ end
+
+ local ret
+ if type(expected) == 'table' then
+ ret = fun.any(function(d)
+ return check_func(d, value)
+ end, expected)
+ else
+ ret = check_func(expected, value)
+ end
+ if ret then
+ return true
+ end
+ end
+
+ return false
+ end
+end
+
+-- Process settings based on their priority
+local function process_settings_table(tbl, allow_ids, mempool, is_static)
+
+ -- Check the setting element internal data
+ local process_setting_elt = function(name, elt)
+
+ lua_util.debugm(N, rspamd_config, 'process settings "%s"', name)
+
+ local out = {}
+
+ local checks = {}
+ if elt.ip then
+ local ips_table = process_ip_condition(elt['ip'])
+
+ if ips_table then
+ lua_util.debugm(N, rspamd_config, 'added ip condition to "%s": %s',
+ name, ips_table)
+ checks.ip = {
+ check = gen_check_closure(convert_to_table(elt.ip, ips_table), check_ip_setting),
+ extract = function(task)
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ return ip
+ end
+ return nil
+ end,
+ }
+ end
+ end
+ if elt.ip_map then
+ local ips_map = lua_maps.map_add_from_ucl(elt.ip_map, 'radix',
+ 'settings ip map for ' .. name)
+
+ if ips_map then
+ lua_util.debugm(N, rspamd_config, 'added ip_map condition to "%s"',
+ name)
+ checks.ip_map = {
+ check = gen_check_closure(ips_map, check_map_setting),
+ extract = function(task)
+ local ip = task:get_from_ip()
+ if ip and ip:is_valid() then
+ return ip
+ end
+ return nil
+ end,
+ }
+ end
+ end
+
+ if elt.client_ip then
+ local client_ips_table = process_ip_condition(elt.client_ip)
+
+ if client_ips_table then
+ lua_util.debugm(N, rspamd_config, 'added client_ip condition to "%s": %s',
+ name, client_ips_table)
+ checks.client_ip = {
+ check = gen_check_closure(convert_to_table(elt.client_ip, client_ips_table),
+ check_ip_setting),
+ extract = function(task)
+ local ip = task:get_client_ip()
+ if ip:is_valid() then
+ return ip
+ end
+ return nil
+ end,
+ }
+ end
+ end
+ if elt.client_ip_map then
+ local ips_map = lua_maps.map_add_from_ucl(elt.ip_map, 'radix',
+ 'settings client ip map for ' .. name)
+
+ if ips_map then
+ lua_util.debugm(N, rspamd_config, 'added client ip_map condition to "%s"',
+ name)
+ checks.client_ip_map = {
+ check = gen_check_closure(ips_map, check_map_setting),
+ extract = function(task)
+ local ip = task:get_client_ip()
+ if ip and ip:is_valid() then
+ return ip
+ end
+ return nil
+ end,
+ }
+ end
+ end
+
+ if elt.from then
+ local from_condition = process_email_condition(elt.from)
+
+ if from_condition then
+ lua_util.debugm(N, rspamd_config, 'added from condition to "%s": %s',
+ name, from_condition)
+ checks.from = {
+ check = gen_check_closure(convert_to_table(elt.from, from_condition),
+ check_addr_setting),
+ extract = function(task)
+ return task:get_from(1)
+ end,
+ }
+ end
+ end
+
+ if elt.rcpt then
+ local rcpt_condition = process_email_condition(elt.rcpt)
+ if rcpt_condition then
+ lua_util.debugm(N, rspamd_config, 'added rcpt condition to "%s": %s',
+ name, rcpt_condition)
+ checks.rcpt = {
+ check = gen_check_closure(convert_to_table(elt.rcpt, rcpt_condition),
+ check_addr_setting),
+ extract = function(task)
+ return task:get_recipients(1)
+ end,
+ }
+ end
+ end
+
+ if elt.from_mime then
+ local from_mime_condition = process_email_condition(elt.from_mime)
+
+ if from_mime_condition then
+ lua_util.debugm(N, rspamd_config, 'added from_mime condition to "%s": %s',
+ name, from_mime_condition)
+ checks.from_mime = {
+ check = gen_check_closure(convert_to_table(elt.from_mime, from_mime_condition),
+ check_addr_setting),
+ extract = function(task)
+ return task:get_from(2)
+ end,
+ }
+ end
+ end
+
+ if elt.rcpt_mime then
+ local rcpt_mime_condition = process_email_condition(elt.rcpt_mime)
+ if rcpt_mime_condition then
+ lua_util.debugm(N, rspamd_config, 'added rcpt mime condition to "%s": %s',
+ name, rcpt_mime_condition)
+ checks.rcpt_mime = {
+ check = gen_check_closure(convert_to_table(elt.rcpt_mime, rcpt_mime_condition),
+ check_addr_setting),
+ extract = function(task)
+ return task:get_recipients(2)
+ end,
+ }
+ end
+ end
+
+ if elt.user then
+ local user_condition = process_email_condition(elt.user)
+ if user_condition then
+ lua_util.debugm(N, rspamd_config, 'added user condition to "%s": %s',
+ name, user_condition)
+ checks.user = {
+ check = gen_check_closure(convert_to_table(elt.user, user_condition),
+ check_addr_setting),
+ extract = function(task)
+ local uname = task:get_user()
+ local user = {}
+ if uname then
+ user[1] = {}
+ local localpart, domainpart = string.gmatch(uname, "(.+)@(.+)")()
+ if localpart then
+ user[1]["user"] = localpart
+ user[1]["domain"] = domainpart
+ user[1]["addr"] = uname
+ else
+ user[1]["user"] = uname
+ user[1]["addr"] = uname
+ end
+
+ return user
+ end
+
+ return nil
+ end,
+ }
+ end
+ end
+
+ if elt.hostname then
+ local hostname_condition = process_string_condition(elt.hostname)
+ if hostname_condition then
+ lua_util.debugm(N, rspamd_config, 'added hostname condition to "%s": %s',
+ name, hostname_condition)
+ checks.hostname = {
+ check = gen_check_closure(convert_to_table(elt.hostname, hostname_condition),
+ check_string_setting),
+ extract = function(task)
+ return task:get_hostname() or ''
+ end,
+ }
+ end
+ end
+
+ if elt.authenticated then
+ lua_util.debugm(N, rspamd_config, 'added authenticated condition to "%s"',
+ name)
+ checks.authenticated = {
+ check = function(value)
+ if value then
+ return true
+ end
+ return false
+ end,
+ extract = function(task)
+ return task:get_user()
+ end
+ }
+ end
+
+ if elt['local'] then
+ lua_util.debugm(N, rspamd_config, 'added local condition to "%s"',
+ name)
+ checks['local'] = {
+ check = function(value)
+ if value then
+ return true
+ end
+ return false
+ end,
+ extract = function(task)
+ local ip = task:get_from_ip()
+ if not ip or not ip:is_valid() then
+ return nil
+ end
+
+ if ip:is_local() then
+ return true
+ else
+ return nil
+ end
+ end
+ }
+ end
+
+ local aliases = {}
+ -- This function is used to convert compound condition with
+ -- generic type and specific part (e.g. `header`, `Content-Transfer-Encoding`)
+ -- to a set of usable check elements:
+ -- `generic:specific` - most common part
+ -- `generic:<order>` - e.g. `header:1` for the first header
+ -- `generic:safe` - replace unsafe stuff with safe + lowercase
+ -- also aliases entry is set to avoid implicit expression
+ local function process_compound_condition(cond, generic, specific)
+ local full_key = generic .. ':' .. specific
+ checks[full_key] = cond
+
+ -- Try numeric key
+ for i = 1, 1000 do
+ local num_key = generic .. ':' .. tostring(i)
+ if not checks[num_key] then
+ checks[num_key] = cond
+ aliases[num_key] = true
+ break
+ end
+ end
+
+ local safe_key = generic .. ':' ..
+ specific:gsub('[:%-+&|><]', '_')
+ :gsub('%(', '[')
+ :gsub('%)', ']')
+ :lower()
+
+ if not checks[safe_key] then
+ checks[safe_key] = cond
+ aliases[full_key] = true
+ end
+
+ return safe_key
+ end
+ -- Headers are tricky:
+ -- We create an closure with extraction function depending on header name
+ -- We also inserts it into `checks` table as an atom in form header:<hname>
+ -- Check function depends on the input:
+ -- * for something that looks like `header = "/bar/"` we create a regexp
+ -- * for something that looks like `header = true` we just check the existence
+ local function process_header_elt(table_element, extractor_func)
+ if elt[table_element] then
+ for k, v in pairs(elt[table_element]) do
+ if type(v) == 'string' then
+ local re = rspamd_regexp.create(v)
+ if re then
+ local cond = {
+ check = function(values)
+ return fun.any(function(c)
+ return re:match(c)
+ end, values)
+ end,
+ extract = extractor_func(k),
+ }
+ local skey = process_compound_condition(cond, table_element,
+ k)
+ lua_util.debugm(N, rspamd_config, 'added %s condition to "%s": %s =~ %s',
+ skey, name, k, v)
+ end
+ elseif type(v) == 'boolean' then
+ local cond = {
+ check = function(values)
+ if #values == 0 then
+ return (not v)
+ end
+ return v
+ end,
+ extract = extractor_func(k),
+ }
+
+ local skey = process_compound_condition(cond, table_element,
+ k)
+ lua_util.debugm(N, rspamd_config, 'added %s condition to "%s": %s == %s',
+ skey, name, k, v)
+ else
+ rspamd_logger.errx(rspamd_config, 'invalid %s %s = %s', table_element, k, v)
+ end
+ end
+ end
+ end
+
+ process_header_elt('request_header', function(hname)
+ return function(task)
+ local rh = task:get_request_header(hname)
+ if rh then
+ return { rh }
+ end
+ return {}
+ end
+ end)
+ process_header_elt('header', function(hname)
+ return function(task)
+ local rh = task:get_header_full(hname)
+ if rh then
+ return fun.totable(fun.map(function(h)
+ return h.decoded
+ end, rh))
+ end
+ return {}
+ end
+ end)
+
+ if elt.selector then
+ local sel = lua_selectors.create_selector_closure(rspamd_config, elt.selector,
+ elt.delimiter or "")
+
+ if sel then
+ local cond = {
+ check = function(values)
+ return fun.any(function(c)
+ return c
+ end, values)
+ end,
+ extract = sel,
+ }
+ local skey = process_compound_condition(cond, 'selector', elt.selector)
+ lua_util.debugm(N, rspamd_config, 'added selector condition to "%s": %s',
+ name, skey)
+ end
+
+ end
+
+ -- Special, special case!
+ local inverse = false
+ if elt.inverse then
+ lua_util.debugm(N, rspamd_config, 'added inverse condition to "%s"',
+ name)
+ inverse = true
+ end
+
+ -- Count checks and create Rspamd expression from a set of rules
+ local nchecks = 0
+ for k, _ in pairs(checks) do
+ if not aliases[k] then
+ nchecks = nchecks + 1
+ end
+ end
+
+ if nchecks > 0 then
+ -- Now we can deal with the expression!
+ if not elt.expression then
+ -- Artificial & expression to deal with the legacy parts
+ -- Here we get all keys and concatenate them with '&&'
+ local s = ' && '
+ -- By De Morgan laws
+ if inverse then
+ s = ' || '
+ end
+ -- Exclude aliases and join all checks by key
+ local expr_str = table.concat(lua_util.keys(fun.filter(
+ function(k, _)
+ return not aliases[k]
+ end,
+ checks)), s)
+
+ if inverse then
+ expr_str = string.format('!(%s)', expr_str)
+ end
+
+ elt.expression = expr_str
+ lua_util.debugm(N, rspamd_config, 'added implicit settings expression for %s: %s',
+ name, expr_str)
+ end
+
+ -- Parse expression's sanity
+ local function parse_atom(str)
+ local atom = table.concat(fun.totable(fun.take_while(function(c)
+ if string.find(', \t()><+!|&\n', c, 1, true) then
+ return false
+ end
+ return true
+ end, fun.iter(str))), '')
+
+ if checks[atom] then
+ return atom
+ end
+
+ rspamd_logger.errx(rspamd_config,
+ 'use of undefined element "%s" when parsing settings expression, known checks: %s',
+ atom, table.concat(fun.totable(fun.map(function(k, _)
+ return k
+ end, checks)), ','))
+
+ return nil
+ end
+
+ local rspamd_expression = require "rspamd_expression"
+ out.expression = rspamd_expression.create(elt.expression, parse_atom,
+ mempool)
+ out.checks = checks
+
+ if not out.expression then
+ rspamd_logger.errx(rspamd_config, 'cannot parse expression %s for %s',
+ elt.expression, name)
+ else
+ lua_util.debugm(N, rspamd_config, 'registered settings %s with %s checks',
+ name, nchecks)
+ end
+ else
+ if not elt.disabled and elt.external_map then
+ lua_util.debugm(N, rspamd_config, 'registered settings %s with no checks, assume it as implicit',
+ name)
+ out.implicit = 1
+ end
+ end
+
+ -- Process symbols part/apply part
+ if elt['symbols'] then
+ lua_util.debugm(N, rspamd_config, 'added symbols condition to "%s": %s',
+ name, elt.symbols)
+ out['symbols'] = elt['symbols']
+ end
+
+ --[[
+ external_map = {
+ map = { ... };
+ selector = "...";
+ }
+ --]]
+ if type(elt.external_map) == 'table'
+ and elt.external_map.map and elt.external_map.selector then
+ local maybe_external_map = {}
+ maybe_external_map.map = lua_maps.map_add_from_ucl(elt.external_map.map, "",
+ string.format("External map for settings element %s", name),
+ gen_settings_external_cb(name))
+ maybe_external_map.selector = lua_selectors.create_selector_closure_fn(rspamd_config,
+ rspamd_config, elt.external_map.selector, ";", lua_selectors.kv_table_from_pairs)
+
+ if maybe_external_map.map and maybe_external_map.selector then
+ rspamd_logger.infox(rspamd_config, "added external map for user's settings %s", name)
+ out.external_map = maybe_external_map
+ else
+ local incorrect_element
+ if not maybe_external_map.map then
+ incorrect_element = "map definition"
+ else
+ incorrect_element = "selector definition"
+ end
+ rspamd_logger.warnx(rspamd_config, "cannot add external map for user's settings; incorrect element: %s",
+ incorrect_element)
+ out.external_map = nil
+ end
+ end
+
+ if not elt.external_map then
+ if elt['apply'] then
+ -- Just insert all metric results to the action key
+ out['apply'] = elt['apply']
+ elseif elt['whitelist'] or elt['want_spam'] then
+ out['whitelist'] = true
+ else
+ rspamd_logger.errx(rspamd_config, "no actions in settings: " .. name)
+ return nil
+ end
+ end
+
+ if allow_ids then
+ if not elt.id then
+ elt.id = name
+ end
+
+ if elt['id'] then
+ -- We are here from a postload script
+ out.id = lua_settings.register_settings_id(elt.id, out, true)
+ lua_util.debugm(N, rspamd_config,
+ 'added settings id to "%s": %s -> %s',
+ name, elt.id, out.id)
+ end
+
+ if not is_static then
+ -- If we apply that from map
+ -- In fact, it is useless and evil but who cares...
+ if elt.apply and elt.apply.symbols then
+ -- Register virtual symbols
+ for k, v in pairs(elt.apply.symbols) do
+ local rtb = {
+ type = 'virtual',
+ parent = module_sym_id,
+ }
+ if type(k) == 'number' and type(v) == 'string' then
+ rtb.name = v
+ elseif type(k) == 'string' then
+ rtb.name = k
+ end
+ if out.id then
+ rtb.allowed_ids = tostring(elt.id)
+ end
+ rspamd_config:register_symbol(rtb)
+ end
+ end
+ end
+ else
+ if elt['id'] then
+ rspamd_logger.errx(rspamd_config,
+ 'cannot set static IDs from dynamic settings, please read the docs')
+ end
+ end
+
+ return out
+ end
+
+ settings_initialized = false
+ -- filter trash in the input
+ local ft = fun.filter(
+ function(_, elt)
+ if type(elt) == "table" then
+ return true
+ end
+ return false
+ end, tbl)
+
+ -- clear all settings
+ max_pri = 0
+ local nrules = 0
+ for k in pairs(settings) do
+ settings[k] = {}
+ end
+ -- fill new settings by priority
+ fun.for_each(function(k, v)
+ local pri = get_priority(v)
+ if pri > max_pri then
+ max_pri = pri
+ end
+ if not settings[pri] then
+ settings[pri] = {}
+ end
+ local s = process_setting_elt(k, v)
+ if s then
+ table.insert(settings[pri], { name = k, rule = s })
+ nrules = nrules + 1
+ end
+ end, ft)
+ -- sort settings with equal priorities in alphabetical order
+ for pri, _ in pairs(settings) do
+ table.sort(settings[pri], function(a, b)
+ return a.name < b.name
+ end)
+ end
+
+ settings_initialized = true
+ lua_settings.load_all_settings(true)
+ rspamd_logger.infox(rspamd_config, 'loaded %s elements of settings', nrules)
+
+ return true
+end
+
+-- Parse settings map from the ucl line
+local settings_map_pool
+
+local function process_settings_map(map_text)
+ local parser = ucl.parser()
+ local res, err = parser:parse_text(map_text)
+
+ if not res then
+ rspamd_logger.warnx(rspamd_config, 'cannot parse settings map: ' .. err)
+ else
+ if settings_map_pool then
+ settings_map_pool:destroy()
+ end
+
+ settings_map_pool = rspamd_mempool.create()
+ local obj = parser:get_object()
+ if obj['settings'] then
+ process_settings_table(obj['settings'], false,
+ settings_map_pool, false)
+ else
+ process_settings_table(obj, false, settings_map_pool,
+ false)
+ end
+ end
+
+ return res
+end
+
+local function gen_redis_callback(handler, id)
+ return function(task)
+ local key = handler(task)
+
+ local function redis_settings_cb(err, data)
+ if not err and type(data) == 'table' then
+ for _, d in ipairs(data) do
+ if type(d) == 'string' then
+ local parser = ucl.parser()
+ local res, ucl_err = parser:parse_text(d)
+ if not res then
+ rspamd_logger.warnx(rspamd_config, 'cannot parse settings from redis: %s',
+ ucl_err)
+ else
+ local obj = parser:get_object()
+ rspamd_logger.infox(task, "<%1> apply settings according to redis rule %2",
+ task:get_message_id(), id)
+ apply_settings(task, obj, nil, 'redis')
+ break
+ end
+ end
+ end
+ elseif err then
+ rspamd_logger.errx(task, 'Redis error: %1', err)
+ end
+ end
+
+ if not key then
+ lua_util.debugm(N, task, 'handler number %s returned nil', id)
+ return
+ end
+
+ local keys
+ if type(key) == 'table' then
+ keys = key
+ else
+ keys = { key }
+ end
+ key = keys[1]
+
+ local ret, _, _ = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_settings_cb, --callback
+ 'MGET', -- command
+ keys -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'Redis MGET failed: %s', ret)
+ end
+ end
+end
+
+local redis_section = rspamd_config:get_all_opt("settings_redis")
+local redis_key_handlers = {}
+
+if redis_section then
+ redis_params = rspamd_parse_redis_server('settings_redis')
+ if redis_params then
+ local handlers = redis_section.handlers
+
+ for id, h in pairs(handlers) do
+ local chunk, err = load(h)
+
+ if not chunk then
+ rspamd_logger.errx(rspamd_config, 'Cannot load handler from string: %s',
+ tostring(err))
+ else
+ local res, func = pcall(chunk)
+ if not res then
+ rspamd_logger.errx(rspamd_config, 'Cannot add handler from string: %s',
+ tostring(func))
+ else
+ redis_key_handlers[id] = func
+ end
+ end
+ end
+ end
+
+ fun.each(function(id, h)
+ rspamd_config:register_symbol({
+ name = 'REDIS_SETTINGS' .. tostring(id),
+ type = 'prefilter',
+ callback = gen_redis_callback(h, id),
+ priority = lua_util.symbols_priorities.top,
+ flags = 'empty,nostat',
+ augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) },
+ })
+ end, redis_key_handlers)
+end
+
+module_sym_id = rspamd_config:register_symbol({
+ name = 'SETTINGS_CHECK',
+ type = 'prefilter',
+ callback = check_settings,
+ priority = lua_util.symbols_priorities.top,
+ flags = 'empty,nostat,explicit_disable,ignore_passthrough',
+})
+
+local set_section = rspamd_config:get_all_opt("settings")
+
+if set_section and set_section[1] and type(set_section[1]) == "string" then
+ -- Just a map of ucl
+ local map_attrs = {
+ url = set_section[1],
+ description = "settings map",
+ callback = process_settings_map,
+ opaque_data = true
+ }
+ if not rspamd_config:add_map(map_attrs) then
+ rspamd_logger.errx(rspamd_config, 'cannot load settings from %1', set_section)
+ end
+elseif set_section and type(set_section) == "table" then
+ settings_map_pool = rspamd_mempool.create()
+ -- We need to check this table and register static symbols first
+ -- Postponed settings init is needed to ensure that all symbols have been
+ -- registered BEFORE settings plugin. Otherwise, we can have inconsistent settings expressions
+ fun.each(function(_, elt)
+ if elt.register_symbols then
+ for k, v in pairs(elt.register_symbols) do
+ local rtb = {
+ type = 'virtual',
+ parent = module_sym_id,
+ }
+ if type(k) == 'number' and type(v) == 'string' then
+ rtb.name = v
+ elseif type(k) == 'string' then
+ rtb.name = k
+ if type(v) == 'table' then
+ for kk, vv in pairs(v) do
+ -- Enrich table wih extra values
+ rtb[kk] = vv
+ end
+ end
+ end
+ rspamd_config:register_symbol(rtb)
+ end
+ end
+ if elt.apply and elt.apply.symbols then
+ -- Register virtual symbols
+ for k, v in pairs(elt.apply.symbols) do
+ local rtb = {
+ type = 'virtual',
+ parent = module_sym_id,
+ }
+ if type(k) == 'number' and type(v) == 'string' then
+ rtb.name = v
+ elseif type(k) == 'string' then
+ rtb.name = k
+ end
+ rspamd_config:register_symbol(rtb)
+ end
+ end
+ end,
+ -- Include only settings, exclude all maps
+ fun.filter(
+ function(_, elt)
+ if type(elt) == "table" then
+ return true
+ end
+ return false
+ end, set_section)
+ )
+
+ rspamd_config:add_post_init(function()
+ process_settings_table(set_section, true, settings_map_pool, true)
+ end, 100)
+end
+
+rspamd_config:add_config_unload(function()
+ if settings_map_pool then
+ settings_map_pool:destroy()
+ end
+end)
diff --git a/src/plugins/lua/spamassassin.lua b/src/plugins/lua/spamassassin.lua
new file mode 100644
index 0000000..3ea7944
--- /dev/null
+++ b/src/plugins/lua/spamassassin.lua
@@ -0,0 +1,1774 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- This plugin is intended to read and parse spamassassin rules with regexp
+-- rules. SA plugins or statistics are not supported
+
+local E = {}
+local N = 'spamassassin'
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_regexp = require "rspamd_regexp"
+local rspamd_expression = require "rspamd_expression"
+local rspamd_trie = require "rspamd_trie"
+local util = require "rspamd_util"
+local lua_util = require "lua_util"
+local fun = require "fun"
+
+-- Known plugins
+local known_plugins = {
+ 'Mail::SpamAssassin::Plugin::FreeMail',
+ 'Mail::SpamAssassin::Plugin::HeaderEval',
+ 'Mail::SpamAssassin::Plugin::ReplaceTags',
+ 'Mail::SpamAssassin::Plugin::RelayEval',
+ 'Mail::SpamAssassin::Plugin::MIMEEval',
+ 'Mail::SpamAssassin::Plugin::BodyEval',
+ 'Mail::SpamAssassin::Plugin::MIMEHeader',
+ 'Mail::SpamAssassin::Plugin::WLBLEval',
+ 'Mail::SpamAssassin::Plugin::HTMLEval',
+}
+
+-- Table that replaces SA symbol with rspamd equivalent
+-- Used for dependency resolution
+local symbols_replacements = {
+ -- SPF replacements
+ USER_IN_SPF_WHITELIST = 'WHITELIST_SPF',
+ USER_IN_DEF_SPF_WL = 'WHITELIST_SPF',
+ SPF_PASS = 'R_SPF_ALLOW',
+ SPF_FAIL = 'R_SPF_FAIL',
+ SPF_SOFTFAIL = 'R_SPF_SOFTFAIL',
+ SPF_HELO_PASS = 'R_SPF_ALLOW',
+ SPF_HELLO_FAIL = 'R_SPF_FAIL',
+ SPF_HELLO_SOFTFAIL = 'R_SPF_SOFTFAIL',
+ -- DKIM replacements
+ USER_IN_DKIM_WHITELIST = 'WHITELIST_DKIM',
+ USER_IN_DEF_DKIM_WL = 'WHITELIST_DKIM',
+ DKIM_VALID = 'R_DKIM_ALLOW',
+ -- SURBL replacements
+ URIBL_SBL_A = 'URIBL_SBL',
+ URIBL_DBL_SPAM = 'DBL_SPAM',
+ URIBL_DBL_PHISH = 'DBL_PHISH',
+ URIBL_DBL_MALWARE = 'DBL_MALWARE',
+ URIBL_DBL_BOTNETCC = 'DBL_BOTNET',
+ URIBL_DBL_ABUSE_SPAM = 'DBL_ABUSE',
+ URIBL_DBL_ABUSE_REDIR = 'DBL_ABUSE_REDIR',
+ URIBL_DBL_ABUSE_MALW = 'DBL_ABUSE_MALWARE',
+ URIBL_DBL_ABUSE_BOTCC = 'DBL_ABUSE_BOTNET',
+ URIBL_WS_SURBL = 'WS_SURBL_MULTI',
+ URIBL_PH_SURBL = 'PH_SURBL_MULTI',
+ URIBL_MW_SURBL = 'MW_SURBL_MULTI',
+ URIBL_CR_SURBL = 'CRACKED_SURBL',
+ URIBL_ABUSE_SURBL = 'ABUSE_SURBL',
+ -- Misc rules
+ BODY_URI_ONLY = 'R_EMPTY_IMAGE',
+ HTML_IMAGE_ONLY_04 = 'HTML_SHORT_LINK_IMG_1',
+ HTML_IMAGE_ONLY_08 = 'HTML_SHORT_LINK_IMG_1',
+ HTML_IMAGE_ONLY_12 = 'HTML_SHORT_LINK_IMG_1',
+ HTML_IMAGE_ONLY_16 = 'HTML_SHORT_LINK_IMG_2',
+ HTML_IMAGE_ONLY_20 = 'HTML_SHORT_LINK_IMG_2',
+ HTML_IMAGE_ONLY_24 = 'HTML_SHORT_LINK_IMG_3',
+ HTML_IMAGE_ONLY_28 = 'HTML_SHORT_LINK_IMG_3',
+ HTML_IMAGE_ONLY_32 = 'HTML_SHORT_LINK_IMG_3',
+}
+
+-- Internal variables
+local rules = {}
+local atoms = {}
+local scores = {}
+local scores_added = {}
+local external_deps = {}
+local freemail_domains = {}
+local pcre_only_regexps = {}
+local freemail_trie
+local replace = {
+ tags = {},
+ pre = {},
+ inter = {},
+ post = {},
+ rules = {},
+}
+local internal_regexp = {
+ date_shift = rspamd_regexp.create("^\\(\\s*'((?:-?\\d+)|(?:undef))'\\s*,\\s*'((?:-?\\d+)|(?:undef))'\\s*\\)$")
+}
+
+-- Mail::SpamAssassin::Plugin::WLBLEval plugin
+local sa_lists = {
+ from_blacklist = {},
+ from_whitelist = {},
+ from_def_whitelist = {},
+ to_blacklist = {},
+ to_whitelist = {},
+ elts = 0,
+}
+
+local func_cache = {}
+local section = rspamd_config:get_all_opt("spamassassin")
+if not (section and type(section) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+end
+
+-- Minimum score to treat symbols as meta
+local meta_score_alpha = 0.5
+
+-- Maximum size of regexp checked
+local match_limit = 0
+
+-- Default priority of the scores registered in the metric
+-- Historically this is set to 2 allowing SA scores to override Rspamd scores
+local scores_priority = 2
+
+local function split(str, delim)
+ local result = {}
+
+ if not delim then
+ delim = '[^%s]+'
+ end
+
+ for token in string.gmatch(str, delim) do
+ table.insert(result, token)
+ end
+
+ return result
+end
+
+local function replace_symbol(s)
+ local rspamd_symbol = symbols_replacements[s]
+ if not rspamd_symbol then
+ return s, false
+ end
+ return rspamd_symbol, true
+end
+
+local ffi
+if type(jit) == 'table' then
+ ffi = require("ffi")
+ ffi.cdef [[
+ int rspamd_re_cache_type_from_string (const char *str);
+ int rspamd_re_cache_process_ffi (void *ptask,
+ void *pre,
+ int type,
+ const char *type_data,
+ int is_strong);
+]]
+end
+
+local function process_regexp_opt(re, task, re_type, header, strong)
+ --[[
+ -- This is now broken with lua regexp conditions!
+ if type(jit) == 'table' then
+ -- Use ffi call
+ local itype = ffi.C.rspamd_re_cache_type_from_string(re_type)
+
+ if not strong then
+ strong = 0
+ else
+ strong = 1
+ end
+ local iret = ffi.C.rspamd_re_cache_process_ffi (task, re, itype, header, strong)
+
+ return tonumber(iret)
+ else
+ return task:process_regexp(re, re_type, header, strong)
+ end
+ --]]
+ return task:process_regexp(re, re_type, header, strong)
+end
+
+local function is_pcre_only(name)
+ if pcre_only_regexps[name] then
+ rspamd_logger.infox(rspamd_config, 'mark re %s as PCRE only', name)
+ return true
+ end
+ return false
+end
+
+local function handle_header_def(hline, cur_rule)
+ --Now check for modifiers inside header's name
+ local hdrs = split(hline, '[^|]+')
+ local hdr_params = {}
+ local cur_param = {}
+ -- Check if an re is an ordinary re
+ local ordinary = true
+
+ for _, h in ipairs(hdrs) do
+ if h == 'ALL' or h == 'ALL:raw' then
+ ordinary = false
+ cur_rule['type'] = 'function'
+ -- Pack closure
+ local re = cur_rule['re']
+ -- Rule to match all headers
+ rspamd_config:register_regexp({
+ re = re,
+ type = 'allheader',
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ cur_rule['function'] = function(task)
+ if not re then
+ rspamd_logger.errx(task, 're is missing for rule %1', h)
+ return 0
+ end
+
+ return process_regexp_opt(re, task, 'allheader')
+ end
+ else
+ local args = split(h, '[^:]+')
+ cur_param['strong'] = false
+ cur_param['raw'] = false
+ cur_param['header'] = args[1]
+
+ if args[2] then
+ -- We have some ops that are required for the header, so it's not ordinary
+ ordinary = false
+ end
+
+ fun.each(function(func)
+ if func == 'addr' then
+ cur_param['function'] = function(str)
+ local addr_parsed = util.parse_mail_address(str)
+ local ret = {}
+ if addr_parsed then
+ for _, elt in ipairs(addr_parsed) do
+ if elt['addr'] then
+ table.insert(ret, elt['addr'])
+ end
+ end
+ end
+
+ return ret
+ end
+ elseif func == 'name' then
+ cur_param['function'] = function(str)
+ local addr_parsed = util.parse_mail_address(str)
+ local ret = {}
+ if addr_parsed then
+ for _, elt in ipairs(addr_parsed) do
+ if elt['name'] then
+ table.insert(ret, elt['name'])
+ end
+ end
+ end
+
+ return ret
+ end
+ elseif func == 'raw' then
+ cur_param['raw'] = true
+ elseif func == 'case' then
+ cur_param['strong'] = true
+ else
+ rspamd_logger.warnx(rspamd_config, 'Function %1 is not supported in %2',
+ func, cur_rule['symbol'])
+ end
+ end, fun.tail(args))
+
+ local function split_hdr_param(param, headers)
+ for _, hh in ipairs(headers) do
+ local nparam = {}
+ for k, v in pairs(param) do
+ if k ~= 'header' then
+ nparam[k] = v
+ end
+ end
+
+ nparam['header'] = hh
+ table.insert(hdr_params, nparam)
+ end
+ end
+ -- Some header rules require splitting to check of multiple headers
+ if cur_param['header'] == 'MESSAGEID' then
+ -- Special case for spamassassin
+ ordinary = false
+ split_hdr_param(cur_param, {
+ 'Message-ID',
+ 'X-Message-ID',
+ 'Resent-Message-ID' })
+ elseif cur_param['header'] == 'ToCc' then
+ ordinary = false
+ split_hdr_param(cur_param, { 'To', 'Cc', 'Bcc' })
+ else
+ table.insert(hdr_params, cur_param)
+ end
+ end
+
+ cur_rule['ordinary'] = ordinary
+ cur_rule['header'] = hdr_params
+ end
+end
+
+local function freemail_search(input)
+ local res = 0
+ local function trie_callback(number, pos)
+ lua_util.debugm(N, rspamd_config, 'Matched pattern %1 at pos %2', freemail_domains[number], pos)
+ res = res + 1
+ end
+
+ if input then
+ freemail_trie:match(input, trie_callback, true)
+ end
+
+ return res
+end
+
+local function gen_eval_rule(arg)
+ local eval_funcs = {
+ { 'check_freemail_from', function(task)
+ local from = task:get_from('mime')
+ if from and from[1] then
+ return freemail_search(string.lower(from[1]['addr']))
+ end
+ return 0
+ end },
+ { 'check_freemail_replyto',
+ function(task)
+ return freemail_search(task:get_header('Reply-To'))
+ end
+ },
+ { 'check_freemail_header',
+ function(task, remain)
+ -- Remain here contains one or two args: header and regexp to match
+ local larg = string.match(remain, "^%(%s*['\"]([^%s]+)['\"]%s*%)$")
+ local re = nil
+ if not larg then
+ larg, re = string.match(remain, "^%(%s*['\"]([^%s]+)['\"]%s*,%s*['\"]([^%s]+)['\"]%s*%)$")
+ end
+
+ if larg then
+ local h
+ if larg == 'EnvelopeFrom' then
+ h = task:get_from('smtp')
+ if h then
+ h = h[1]['addr']
+ end
+ else
+ h = task:get_header(larg)
+ end
+ if h then
+ local hdr_freemail = freemail_search(string.lower(h))
+
+ if hdr_freemail > 0 and re then
+ local r = rspamd_regexp.create_cached(re)
+ if r then
+ if r:match(h) then
+ return 1
+ end
+ return 0
+ else
+ rspamd_logger.infox(rspamd_config, 'cannot create regexp %1', re)
+ return 0
+ end
+ end
+
+ return hdr_freemail
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_for_missing_to_header',
+ function(task)
+ local th = task:get_recipients('mime')
+ if not th or #th == 0 then
+ return 1
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_relays_unparseable',
+ function(task)
+ local rh_mime = task:get_header_full('Received')
+ local rh_parsed = task:get_received_headers()
+
+ local rh_cnt = 0
+ if rh_mime then
+ rh_cnt = #rh_mime
+ end
+ local parsed_cnt = 0
+ if rh_parsed then
+ parsed_cnt = #rh_parsed
+ end
+
+ return rh_cnt - parsed_cnt
+ end
+ },
+ {
+ 'check_for_shifted_date',
+ function(task, remain)
+ -- Remain here contains two args: start and end hours shift
+ local matches = internal_regexp['date_shift']:search(remain, true, true)
+ if matches and matches[1] then
+ local min_diff = matches[1][2]
+ local max_diff = matches[1][3]
+
+ if min_diff == 'undef' then
+ min_diff = 0
+ else
+ min_diff = tonumber(min_diff) * 3600
+ end
+ if max_diff == 'undef' then
+ max_diff = 0
+ else
+ max_diff = tonumber(max_diff) * 3600
+ end
+
+ -- Now get the difference between Date and message received date
+ local dm = task:get_date { format = 'message', gmt = true }
+ local dt = task:get_date { format = 'connect', gmt = true }
+ local diff = dm - dt
+
+ if (max_diff == 0 and diff >= min_diff) or
+ (min_diff == 0 and diff <= max_diff) or
+ (diff >= min_diff and diff <= max_diff) then
+ return 1
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_for_mime',
+ function(task, remain)
+ local larg = string.match(remain, "^%(%s*['\"]([^%s]+)['\"]%s*%)$")
+
+ if larg then
+ if larg == 'mime_attachment' then
+ local parts = task:get_parts()
+ if parts then
+ for _, p in ipairs(parts) do
+ if p:get_filename() then
+ return 1
+ end
+ end
+ end
+ else
+ rspamd_logger.infox(task, 'unimplemented mime check %1', arg)
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_from_in_blacklist',
+ function(task)
+ local from = task:get_from('mime')
+ if ((from or E)[1] or E).addr then
+ if sa_lists['from_blacklist'][string.lower(from[1]['addr'])] then
+ return 1
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_from_in_whitelist',
+ function(task)
+ local from = task:get_from('mime')
+ if ((from or E)[1] or E).addr then
+ if sa_lists['from_whitelist'][string.lower(from[1]['addr'])] then
+ return 1
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_from_in_default_whitelist',
+ function(task)
+ local from = task:get_from('mime')
+ if ((from or E)[1] or E).addr then
+ if sa_lists['from_def_whitelist'][string.lower(from[1]['addr'])] then
+ return 1
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_to_in_blacklist',
+ function(task)
+ local rcpt = task:get_recipients('mime')
+ if rcpt then
+ for _, r in ipairs(rcpt) do
+ if sa_lists['to_blacklist'][string.lower(r['addr'])] then
+ return 1
+ end
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'check_to_in_whitelist',
+ function(task)
+ local rcpt = task:get_recipients('mime')
+ if rcpt then
+ for _, r in ipairs(rcpt) do
+ if sa_lists['to_whitelist'][string.lower(r['addr'])] then
+ return 1
+ end
+ end
+ end
+
+ return 0
+ end
+ },
+ {
+ 'html_tag_exists',
+ function(task, remain)
+ local tp = task:get_text_parts()
+
+ for _, p in ipairs(tp) do
+ if p:is_html() then
+ local hc = p:get_html()
+
+ if hc:has_tag(remain) then
+ return 1
+ end
+ end
+ end
+
+ return 0
+ end
+ }
+ }
+
+ for _, f in ipairs(eval_funcs) do
+ local pat = string.format('^%s', f[1])
+ local first, last = string.find(arg, pat)
+
+ if first then
+ local func_arg = string.sub(arg, last + 1)
+ return function(task)
+ return f[2](task, func_arg)
+ end
+ end
+ end
+end
+
+-- Returns parser function or nil
+local function maybe_parse_sa_function(line)
+ local arg
+ local elts = split(line, '[^:]+')
+ arg = elts[2]
+
+ lua_util.debugm(N, rspamd_config, 'trying to parse SA function %1 with args %2',
+ elts[1], elts[2])
+ local substitutions = {
+ { '^exists:',
+ function(task)
+ -- filter
+ local hdrs_check
+ if arg == 'MESSAGEID' then
+ hdrs_check = {
+ 'Message-ID',
+ 'X-Message-ID',
+ 'Resent-Message-ID'
+ }
+ elseif arg == 'ToCc' then
+ hdrs_check = { 'To', 'Cc', 'Bcc' }
+ else
+ hdrs_check = { arg }
+ end
+
+ for _, h in ipairs(hdrs_check) do
+ if task:has_header(h) then
+ return 1
+ end
+ end
+ return 0
+ end,
+ },
+ { '^eval:',
+ function(task)
+ local func = func_cache[arg]
+ if not func then
+ func = gen_eval_rule(arg)
+ func_cache[arg] = func
+ end
+
+ if not func then
+ rspamd_logger.errx(task, 'cannot find appropriate eval rule for function %1',
+ arg)
+ else
+ return func(task)
+ end
+
+ return 0
+ end
+ },
+ }
+
+ for _, s in ipairs(substitutions) do
+ if string.find(line, s[1]) then
+ return s[2]
+ end
+ end
+
+ return nil
+end
+
+local function words_to_re(words, start)
+ return table.concat(fun.totable(fun.drop_n(start, words)), " ");
+end
+
+local function process_tflags(rule, flags)
+ fun.each(function(flag)
+ if flag == 'publish' then
+ rule['publish'] = true
+ elseif flag == 'multiple' then
+ rule['multiple'] = true
+ elseif string.match(flag, '^maxhits=(%d+)$') then
+ rule['maxhits'] = tonumber(string.match(flag, '^maxhits=(%d+)$'))
+ elseif flag == 'nice' then
+ rule['nice'] = true
+ end
+ end, fun.drop_n(1, flags))
+
+ if rule['re'] then
+ if rule['maxhits'] then
+ rule['re']:set_max_hits(rule['maxhits'])
+ elseif rule['multiple'] then
+ rule['re']:set_max_hits(0)
+ else
+ rule['re']:set_max_hits(1)
+ end
+ end
+end
+
+local function process_replace(words, tbl)
+ local re = words_to_re(words, 2)
+ tbl[words[2]] = re
+end
+
+local function process_sa_conf(f)
+ local cur_rule = {}
+ local valid_rule = false
+
+ local function insert_cur_rule()
+ if cur_rule['type'] ~= 'meta' and cur_rule['publish'] then
+ -- Create meta rule from this rule
+ local nsym = '__fake' .. cur_rule['symbol']
+ local nrule = {
+ type = 'meta',
+ symbol = cur_rule['symbol'],
+ score = cur_rule['score'],
+ meta = nsym,
+ description = cur_rule['description'],
+ }
+ rules[nrule['symbol']] = nrule
+ cur_rule['symbol'] = nsym
+ end
+ -- We have previous rule valid
+ if not cur_rule['symbol'] then
+ rspamd_logger.errx(rspamd_config, 'bad rule definition: %1', cur_rule)
+ end
+ rules[cur_rule['symbol']] = cur_rule
+ cur_rule = {}
+ valid_rule = false
+ end
+
+ local function parse_score(words)
+ if #words == 3 then
+ -- score rule <x>
+ lua_util.debugm(N, rspamd_config, 'found score for %1: %2', words[2], words[3])
+ return tonumber(words[3])
+ elseif #words == 6 then
+ -- score rule <x1> <x2> <x3> <x4>
+ -- we assume here that bayes and network are enabled and select <x4>
+ lua_util.debugm(N, rspamd_config, 'found score for %1: %2', words[2], words[6])
+ return tonumber(words[6])
+ else
+ rspamd_logger.errx(rspamd_config, 'invalid score for %1', words[2])
+ end
+
+ return 0
+ end
+
+ local skip_to_endif = false
+ local if_nested = 0
+ for l in f:lines() do
+ (function()
+ l = lua_util.rspamd_str_trim(l)
+ -- Replace bla=~/re/ with bla =~ /re/ (#2372)
+ l = l:gsub('([^%s])%s*([=!]~)%s*([^%s])', '%1 %2 %3')
+
+ if string.len(l) == 0 or string.sub(l, 1, 1) == '#' then
+ return
+ end
+
+ -- Unbalanced if/endif
+ if if_nested < 0 then
+ if_nested = 0
+ end
+ if skip_to_endif then
+ if string.match(l, '^endif') then
+ if_nested = if_nested - 1
+
+ if if_nested == 0 then
+ skip_to_endif = false
+ end
+ elseif string.match(l, '^if') then
+ if_nested = if_nested + 1
+ elseif string.match(l, '^else') then
+ -- Else counterpart for if
+ skip_to_endif = false
+ end
+ return
+ else
+ if string.match(l, '^ifplugin') then
+ local ls = split(l)
+
+ if not fun.any(function(pl)
+ if pl == ls[2] then
+ return true
+ end
+ return false
+ end, known_plugins) then
+ skip_to_endif = true
+ end
+ if_nested = if_nested + 1
+ elseif string.match(l, '^if !plugin%(') then
+ local pname = string.match(l, '^if !plugin%(([A-Za-z:]+)%)')
+ if fun.any(function(pl)
+ if pl == pname then
+ return true
+ end
+ return false
+ end, known_plugins) then
+ skip_to_endif = true
+ end
+ if_nested = if_nested + 1
+ elseif string.match(l, '^if') then
+ -- Unknown if
+ skip_to_endif = true
+ if_nested = if_nested + 1
+ elseif string.match(l, '^else') then
+ -- Else counterpart for if
+ skip_to_endif = true
+ elseif string.match(l, '^endif') then
+ if_nested = if_nested - 1
+ end
+ end
+
+ -- Skip comments
+ local words = fun.totable(fun.take_while(
+ function(w)
+ return string.sub(w, 1, 1) ~= '#'
+ end,
+ fun.filter(function(w)
+ return w ~= ""
+ end,
+ fun.iter(split(l)))))
+
+ if words[1] == "header" or words[1] == 'mimeheader' then
+ -- header SYMBOL Header ~= /regexp/
+ if valid_rule then
+ insert_cur_rule()
+ end
+ if words[4] and (words[4] == '=~' or words[4] == '!~') then
+ cur_rule['type'] = 'header'
+ cur_rule['symbol'] = words[2]
+
+ if words[4] == '!~' then
+ cur_rule['not'] = true
+ end
+
+ cur_rule['re_expr'] = words_to_re(words, 4)
+ local unset_comp = string.find(cur_rule['re_expr'], '%s+%[if%-unset:')
+ if unset_comp then
+ -- We have optional part that needs to be processed
+ local unset = string.match(string.sub(cur_rule['re_expr'], unset_comp),
+ '%[if%-unset:%s*([^%]%s]+)]')
+ cur_rule['unset'] = unset
+ -- Cut it down
+ cur_rule['re_expr'] = string.sub(cur_rule['re_expr'], 1, unset_comp - 1)
+ end
+
+ cur_rule['re'] = rspamd_regexp.create(cur_rule['re_expr'])
+
+ if not cur_rule['re'] then
+ rspamd_logger.warnx(rspamd_config, "Cannot parse regexp '%1' for %2",
+ cur_rule['re_expr'], cur_rule['symbol'])
+ else
+ cur_rule['re']:set_max_hits(1)
+ handle_header_def(words[3], cur_rule)
+ end
+
+ if cur_rule['unset'] then
+ cur_rule['ordinary'] = false
+ end
+
+ if words[1] == 'mimeheader' then
+ cur_rule['mime'] = true
+ else
+ cur_rule['mime'] = false
+ end
+
+ if cur_rule['re'] and cur_rule['symbol'] and
+ (cur_rule['header'] or cur_rule['function']) then
+ valid_rule = true
+ cur_rule['re']:set_max_hits(1)
+ if cur_rule['header'] and cur_rule['ordinary'] then
+ for _, h in ipairs(cur_rule['header']) do
+ if type(h) == 'string' then
+ if cur_rule['mime'] then
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'mimeheader',
+ header = h,
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ else
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'header',
+ header = h,
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ end
+ else
+ h['mime'] = cur_rule['mime']
+ if cur_rule['mime'] then
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'mimeheader',
+ header = h['header'],
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ else
+ if h['raw'] then
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'rawheader',
+ header = h['header'],
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ else
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'header',
+ header = h['header'],
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ end
+ end
+ end
+ end
+ cur_rule['re']:set_limit(match_limit)
+ cur_rule['re']:set_max_hits(1)
+ end
+ end
+ else
+ -- Maybe we know the function and can convert it
+ local args = words_to_re(words, 2)
+ local func = maybe_parse_sa_function(args)
+
+ if func then
+ cur_rule['type'] = 'function'
+ cur_rule['symbol'] = words[2]
+ cur_rule['function'] = func
+ valid_rule = true
+ else
+ rspamd_logger.infox(rspamd_config, 'unknown function %1', args)
+ end
+ end
+ elseif words[1] == "body" then
+ -- body SYMBOL /regexp/
+ if valid_rule then
+ insert_cur_rule()
+ end
+
+ cur_rule['symbol'] = words[2]
+ if words[3] and (string.sub(words[3], 1, 1) == '/'
+ or string.sub(words[3], 1, 1) == 'm') then
+ cur_rule['type'] = 'sabody'
+ cur_rule['re_expr'] = words_to_re(words, 2)
+ cur_rule['re'] = rspamd_regexp.create(cur_rule['re_expr'])
+ if cur_rule['re'] then
+
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'sabody',
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ valid_rule = true
+ cur_rule['re']:set_limit(match_limit)
+ cur_rule['re']:set_max_hits(1)
+ end
+ else
+ -- might be function
+ local args = words_to_re(words, 2)
+ local func = maybe_parse_sa_function(args)
+
+ if func then
+ cur_rule['type'] = 'function'
+ cur_rule['symbol'] = words[2]
+ cur_rule['function'] = func
+ valid_rule = true
+ else
+ rspamd_logger.infox(rspamd_config, 'unknown function %1', args)
+ end
+ end
+ elseif words[1] == "rawbody" then
+ -- body SYMBOL /regexp/
+ if valid_rule then
+ insert_cur_rule()
+ end
+
+ cur_rule['symbol'] = words[2]
+ if words[3] and (string.sub(words[3], 1, 1) == '/'
+ or string.sub(words[3], 1, 1) == 'm') then
+ cur_rule['type'] = 'sarawbody'
+ cur_rule['re_expr'] = words_to_re(words, 2)
+ cur_rule['re'] = rspamd_regexp.create(cur_rule['re_expr'])
+ if cur_rule['re'] then
+
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'sarawbody',
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ valid_rule = true
+ cur_rule['re']:set_limit(match_limit)
+ cur_rule['re']:set_max_hits(1)
+ end
+ else
+ -- might be function
+ local args = words_to_re(words, 2)
+ local func = maybe_parse_sa_function(args)
+
+ if func then
+ cur_rule['type'] = 'function'
+ cur_rule['symbol'] = words[2]
+ cur_rule['function'] = func
+ valid_rule = true
+ else
+ rspamd_logger.infox(rspamd_config, 'unknown function %1', args)
+ end
+ end
+ elseif words[1] == "full" then
+ -- body SYMBOL /regexp/
+ if valid_rule then
+ insert_cur_rule()
+ end
+
+ cur_rule['symbol'] = words[2]
+
+ if words[3] and (string.sub(words[3], 1, 1) == '/'
+ or string.sub(words[3], 1, 1) == 'm') then
+ cur_rule['type'] = 'message'
+ cur_rule['re_expr'] = words_to_re(words, 2)
+ cur_rule['re'] = rspamd_regexp.create(cur_rule['re_expr'])
+ cur_rule['raw'] = true
+ if cur_rule['re'] then
+ valid_rule = true
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'body',
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ cur_rule['re']:set_limit(match_limit)
+ cur_rule['re']:set_max_hits(1)
+ end
+ else
+ -- might be function
+ local args = words_to_re(words, 2)
+ local func = maybe_parse_sa_function(args)
+
+ if func then
+ cur_rule['type'] = 'function'
+ cur_rule['symbol'] = words[2]
+ cur_rule['function'] = func
+ valid_rule = true
+ else
+ rspamd_logger.infox(rspamd_config, 'unknown function %1', args)
+ end
+ end
+ elseif words[1] == "uri" then
+ -- uri SYMBOL /regexp/
+ if valid_rule then
+ insert_cur_rule()
+ end
+ cur_rule['type'] = 'uri'
+ cur_rule['symbol'] = words[2]
+ cur_rule['re_expr'] = words_to_re(words, 2)
+ cur_rule['re'] = rspamd_regexp.create(cur_rule['re_expr'])
+ if cur_rule['re'] and cur_rule['symbol'] then
+ valid_rule = true
+ rspamd_config:register_regexp({
+ re = cur_rule['re'],
+ type = 'url',
+ pcre_only = is_pcre_only(cur_rule['symbol']),
+ })
+ cur_rule['re']:set_limit(match_limit)
+ cur_rule['re']:set_max_hits(1)
+ end
+ elseif words[1] == "meta" then
+ -- meta SYMBOL expression
+ if valid_rule then
+ insert_cur_rule()
+ end
+ cur_rule['type'] = 'meta'
+ cur_rule['symbol'] = words[2]
+ cur_rule['meta'] = words_to_re(words, 2)
+ if cur_rule['meta'] and cur_rule['symbol']
+ and cur_rule['meta'] ~= '0' then
+ valid_rule = true
+ end
+ elseif words[1] == "describe" and valid_rule then
+ cur_rule['description'] = words_to_re(words, 2)
+ elseif words[1] == "score" then
+ scores[words[2]] = parse_score(words)
+ elseif words[1] == 'freemail_domains' then
+ fun.each(function(dom)
+ table.insert(freemail_domains, '@' .. dom)
+ end, fun.drop_n(1, words))
+ elseif words[1] == 'blacklist_from' then
+ sa_lists['from_blacklist'][words[2]] = 1
+ sa_lists['elts'] = sa_lists['elts'] + 1
+ elseif words[1] == 'whitelist_from' then
+ sa_lists['from_whitelist'][words[2]] = 1
+ sa_lists['elts'] = sa_lists['elts'] + 1
+ elseif words[1] == 'whitelist_to' then
+ sa_lists['to_whitelist'][words[2]] = 1
+ sa_lists['elts'] = sa_lists['elts'] + 1
+ elseif words[1] == 'blacklist_to' then
+ sa_lists['to_blacklist'][words[2]] = 1
+ sa_lists['elts'] = sa_lists['elts'] + 1
+ elseif words[1] == 'tflags' then
+ process_tflags(cur_rule, words)
+ elseif words[1] == 'replace_tag' then
+ process_replace(words, replace['tags'])
+ elseif words[1] == 'replace_pre' then
+ process_replace(words, replace['pre'])
+ elseif words[1] == 'replace_inter' then
+ process_replace(words, replace['inter'])
+ elseif words[1] == 'replace_post' then
+ process_replace(words, replace['post'])
+ elseif words[1] == 'replace_rules' then
+ fun.each(function(r)
+ table.insert(replace['rules'], r)
+ end,
+ fun.drop_n(1, words))
+ end
+ end)()
+ end
+ if valid_rule then
+ insert_cur_rule()
+ end
+end
+
+-- Now check all valid rules and add the according rspamd rules
+
+local function calculate_score(sym, rule)
+ if fun.all(function(c)
+ return c == '_'
+ end, fun.take_n(2, fun.iter(sym))) then
+ return 0.0
+ end
+
+ if rule['nice'] or (rule['score'] and rule['score'] < 0.0) then
+ return -1.0
+ end
+
+ return 1.0
+end
+
+local function add_sole_meta(sym, rule)
+ local r = {
+ type = 'meta',
+ meta = rule['symbol'],
+ score = rule['score'],
+ description = rule['description']
+ }
+ rules[sym] = r
+end
+
+local function sa_regexp_match(data, re, raw, rule)
+ local res = 0
+ if not re then
+ return 0
+ end
+ if rule['multiple'] then
+ local lim = -1
+ if rule['maxhits'] then
+ lim = rule['maxhits']
+ end
+ res = res + re:matchn(data, lim, raw)
+ else
+ if re:match(data, raw) then
+ res = 1
+ end
+ end
+
+ return res
+end
+
+local function apply_replacements(str)
+ local pre = ""
+ local post = ""
+ local inter = ""
+
+ local function check_specific_tag(prefix, s, tbl)
+ local replacement = nil
+ local ret = s
+ fun.each(function(n, t)
+ local ns, matches = string.gsub(s, string.format("<%s%s>", prefix, n), "")
+ if matches > 0 then
+ replacement = t
+ ret = ns
+ end
+ end, tbl)
+
+ return ret, replacement
+ end
+
+ local repl
+ str, repl = check_specific_tag("pre ", str, replace['pre'])
+ if repl then
+ pre = repl
+ end
+ str, repl = check_specific_tag("inter ", str, replace['inter'])
+ if repl then
+ inter = repl
+ end
+ str, repl = check_specific_tag("post ", str, replace['post'])
+ if repl then
+ post = repl
+ end
+
+ -- XXX: ugly hack
+ if inter then
+ str = string.gsub(str, "><", string.format(">%s<", inter))
+ end
+
+ local function replace_all_tags(s)
+ local sstr
+ sstr = s
+ fun.each(function(n, t)
+ local rep = string.format("%s%s%s", pre, t, post)
+ rep = string.gsub(rep, '%%', '%%%%')
+ sstr = string.gsub(sstr, string.format("<%s>", n), rep)
+ end, replace['tags'])
+
+ return sstr
+ end
+
+ local s = replace_all_tags(str)
+
+ if str ~= s then
+ return true, s
+ end
+
+ return false, str
+end
+
+local function parse_atom(str)
+ local atom = table.concat(fun.totable(fun.take_while(function(c)
+ if string.find(', \t()><+!|&\n', c, 1, true) then
+ return false
+ end
+ return true
+ end, fun.iter(str))), '')
+
+ return atom
+end
+
+local function gen_process_atom_cb(result_name, task)
+ return function(atom)
+ local atom_cb = atoms[atom]
+
+ if atom_cb then
+ local res = atom_cb(task, result_name)
+
+ if not res then
+ lua_util.debugm(N, task, 'metric: %s, atom: %s, NULL result', result_name, atom)
+ elseif res > 0 then
+ lua_util.debugm(N, task, 'metric: %s, atom: %s, result: %s', result_name, atom, res)
+ end
+ return res
+ else
+ -- This is likely external atom
+ local real_sym = atom
+ if symbols_replacements[atom] then
+ real_sym = symbols_replacements[atom]
+ end
+ if task:has_symbol(real_sym, result_name) then
+ lua_util.debugm(N, task, 'external atom: %s, result: 1, named_result: %s', real_sym, result_name)
+ return 1
+ end
+ lua_util.debugm(N, task, 'external atom: %s, result: 0, , named_result: %s', real_sym, result_name)
+ end
+ return 0
+ end
+end
+
+local function post_process()
+ -- Replace rule tags
+ local ntags = {}
+ local function rec_replace_tags(tag, tagv)
+ if ntags[tag] then
+ return ntags[tag]
+ end
+ fun.each(function(n, t)
+ if n ~= tag then
+ local s, matches = string.gsub(tagv, string.format("<%s>", n), t)
+ if matches > 0 then
+ ntags[tag] = rec_replace_tags(tag, s)
+ end
+ end
+ end, replace['tags'])
+
+ if not ntags[tag] then
+ ntags[tag] = tagv
+ end
+ return ntags[tag]
+ end
+
+ fun.each(function(n, t)
+ rec_replace_tags(n, t)
+ end, replace['tags'])
+ fun.each(function(n, t)
+ replace['tags'][n] = t
+ end, ntags)
+
+ fun.each(function(r)
+ local rule = rules[r]
+
+ if rule['re_expr'] and rule['re'] then
+ local res, nexpr = apply_replacements(rule['re_expr'])
+ if res then
+ local nre = rspamd_regexp.create(nexpr)
+ if not nre then
+ rspamd_logger.errx(rspamd_config, 'cannot apply replacement for rule %1', r)
+ --rule['re'] = nil
+ else
+ local old_max_hits = rule['re']:get_max_hits()
+ lua_util.debugm(N, rspamd_config, 'replace %1 -> %2', r, nexpr)
+ rspamd_config:replace_regexp({
+ old_re = rule['re'],
+ new_re = nre,
+ pcre_only = is_pcre_only(rule['symbol']),
+ })
+ rule['re'] = nre
+ rule['re_expr'] = nexpr
+ nre:set_limit(match_limit)
+ nre:set_max_hits(old_max_hits)
+ end
+ end
+ end
+ end, replace['rules'])
+
+ fun.each(function(key, score)
+ if rules[key] then
+ rules[key]['score'] = score
+ end
+ end, scores)
+
+ -- Header rules
+ fun.each(function(k, r)
+ local f = function(task)
+
+ local raw = false
+ local check = {}
+ -- Cached path for ordinary expressions
+ if r['ordinary'] then
+ local h = r['header'][1]
+ local t = 'header'
+
+ if h['raw'] then
+ t = 'rawheader'
+ end
+
+ if not r['re'] then
+ rspamd_logger.errx(task, 're is missing for rule %1 (%2 header)', k,
+ h['header'])
+ return 0
+ end
+
+ local ret = process_regexp_opt(r.re, task, t, h.header, h.strong)
+
+ if r['not'] then
+ if ret ~= 0 then
+ ret = 0
+ else
+ ret = 1
+ end
+ end
+
+ return ret
+ end
+
+ -- Slow path
+ fun.each(function(h)
+ local hname = h['header']
+
+ local hdr
+ if h['mime'] then
+ local parts = task:get_parts()
+ for _, p in ipairs(parts) do
+ local m_hdr = p:get_header_full(hname, h['strong'])
+
+ if m_hdr then
+ if not hdr then
+ hdr = {}
+ end
+ for _, mh in ipairs(m_hdr) do
+ table.insert(hdr, mh)
+ end
+ end
+ end
+ else
+ hdr = task:get_header_full(hname, h['strong'])
+ end
+
+ if hdr then
+ for _, rh in ipairs(hdr) do
+ -- Subject for optimization
+ local str
+ if h['raw'] then
+ str = rh['value']
+ raw = true
+ else
+ str = rh['decoded']
+ end
+ if not str then
+ return 0
+ end
+
+ if h['function'] then
+ str = h['function'](str)
+ end
+
+ if type(str) == 'string' then
+ table.insert(check, str)
+ else
+ for _, c in ipairs(str) do
+ table.insert(check, c)
+ end
+ end
+ end
+ elseif r['unset'] then
+ table.insert(check, r['unset'])
+ end
+ end, r['header'])
+
+ if #check == 0 then
+ if r['not'] then
+ return 1
+ end
+ return 0
+ end
+
+ local ret = 0
+ for _, c in ipairs(check) do
+ local match = sa_regexp_match(c, r['re'], raw, r)
+ if (match > 0 and not r['not']) or (match == 0 and r['not']) then
+ ret = 1
+ end
+ end
+
+ return ret
+ end
+ if r['score'] then
+ local real_score = r['score'] * calculate_score(k, r)
+ if math.abs(real_score) > meta_score_alpha then
+ add_sole_meta(k, r)
+ end
+ end
+ atoms[k] = f
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'header' and r['header']
+ end,
+ rules))
+
+ -- Custom function rules
+ fun.each(function(k, r)
+ local f = function(task)
+ local res = r['function'](task)
+ if res and res > 0 then
+ return res
+ end
+ return 0
+ end
+ if r['score'] then
+ local real_score = r['score'] * calculate_score(k, r)
+ if math.abs(real_score) > meta_score_alpha then
+ add_sole_meta(k, r)
+ end
+ end
+ atoms[k] = f
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'function' and r['function']
+ end,
+ rules))
+
+ -- Parts rules
+ fun.each(function(k, r)
+ local f = function(task)
+ if not r['re'] then
+ rspamd_logger.errx(task, 're is missing for rule %1', k)
+ return 0
+ end
+
+ local t = 'mime'
+ if r['raw'] then
+ t = 'rawmime'
+ end
+
+ return process_regexp_opt(r.re, task, t)
+ end
+ if r['score'] then
+ local real_score = r['score'] * calculate_score(k, r)
+ if math.abs(real_score) > meta_score_alpha then
+ add_sole_meta(k, r)
+ end
+ end
+ atoms[k] = f
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'part'
+ end, rules))
+
+ -- SA body rules
+ fun.each(function(k, r)
+ local f = function(task)
+ if not r['re'] then
+ rspamd_logger.errx(task, 're is missing for rule %1', k)
+ return 0
+ end
+
+ local t = r['type']
+
+ local ret = process_regexp_opt(r.re, task, t)
+ return ret
+ end
+ if r['score'] then
+ local real_score = r['score'] * calculate_score(k, r)
+ if math.abs(real_score) > meta_score_alpha then
+ add_sole_meta(k, r)
+ end
+ end
+ atoms[k] = f
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'sabody' or r['type'] == 'message' or r['type'] == 'sarawbody'
+ end, rules))
+
+ -- URL rules
+ fun.each(function(k, r)
+ local f = function(task)
+ if not r['re'] then
+ rspamd_logger.errx(task, 're is missing for rule %1', k)
+ return 0
+ end
+
+ return process_regexp_opt(r.re, task, 'url')
+ end
+ if r['score'] then
+ local real_score = r['score'] * calculate_score(k, r)
+ if math.abs(real_score) > meta_score_alpha then
+ add_sole_meta(k, r)
+ end
+ end
+ atoms[k] = f
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'uri'
+ end,
+ rules))
+ -- Meta rules
+ fun.each(function(k, r)
+ local expression = nil
+ -- Meta function callback
+ -- Here are dragons!
+ -- This function can be called from 2 DIFFERENT type of invocations:
+ -- 1) Invocation from Rspamd itself where `res_name` will be nil
+ -- 2) Invocation from other meta during expression:process_traced call
+ -- So we need to distinguish that and return different stuff to be able to deal with atoms
+ local meta_cb = function(task, res_name)
+ lua_util.debugm(N, task, 'meta callback for %s; result name: %s', k, res_name)
+ local cached = task:cache_get('sa_metas_processed')
+
+ -- We avoid many task methods invocations here (likely)
+ if not cached then
+ cached = {}
+ task:cache_set('sa_metas_processed', cached)
+ end
+
+ local already_processed = cached[k]
+
+ -- Exclude elements that are named in the same way as the symbol itself
+ local function exclude_sym_filter(sopt)
+ return sopt ~= k
+ end
+
+ if not (already_processed and already_processed[res_name or 'default']) then
+ -- Execute symbol
+ local function exec_symbol(cur_res)
+ local res, trace = expression:process_traced(gen_process_atom_cb(cur_res, task))
+ lua_util.debugm(N, task, 'meta result for %s: %s; result name: %s', k, res, cur_res)
+ if res > 0 then
+ -- Symbol should be one shot to make it working properly
+ task:insert_result_named(cur_res, k, res, fun.totable(fun.filter(exclude_sym_filter, trace)))
+ end
+
+ if not cached[k] then
+ cached[k] = {}
+ end
+
+ cached[k][cur_res] = res
+ end
+
+ if not res_name then
+ -- Invoke for all named results
+ local named_results = task:get_all_named_results()
+ for _, cur_res in ipairs(named_results) do
+ exec_symbol(cur_res)
+ end
+ else
+ -- Invoked from another meta
+ exec_symbol(res_name)
+ return cached[k][res_name] or 0
+ end
+ else
+ -- We have cached the result
+ local res = already_processed[res_name or 'default'] or 0
+ lua_util.debugm(N, task, 'cached meta result for %s: %s; result name: %s',
+ k, res, res_name)
+
+ if res_name then
+ return res
+ end
+ end
+
+ -- No return if invoked directly from Rspamd as we use task:insert_result_named directly
+ end
+
+ expression = rspamd_expression.create(r['meta'], parse_atom, rspamd_config:get_mempool())
+ if not expression then
+ rspamd_logger.errx(rspamd_config, 'Cannot parse expression ' .. r['meta'])
+ else
+
+ if r['score'] then
+ rspamd_config:set_metric_symbol {
+ name = k, score = r['score'],
+ description = r['description'],
+ priority = scores_priority,
+ one_shot = true
+ }
+ scores_added[k] = 1
+ rspamd_config:register_symbol {
+ name = k,
+ weight = calculate_score(k, r),
+ callback = meta_cb
+ }
+ else
+ -- Add 0 score to avoid issues
+ rspamd_config:register_symbol {
+ name = k,
+ weight = calculate_score(k, r),
+ callback = meta_cb,
+ score = 0,
+ }
+ end
+
+ r['expression'] = expression
+
+ if not atoms[k] then
+ atoms[k] = meta_cb
+ end
+ end
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'meta'
+ end,
+ rules))
+
+ -- Check meta rules for foreign symbols and register dependencies
+ -- First direct dependencies:
+ fun.each(function(k, r)
+ if r['expression'] then
+ local expr_atoms = r['expression']:atoms()
+
+ for _, a in ipairs(expr_atoms) do
+ if not atoms[a] then
+ local rspamd_symbol = replace_symbol(a)
+ if not external_deps[k] then
+ external_deps[k] = {}
+ end
+
+ if not external_deps[k][rspamd_symbol] then
+ rspamd_config:register_dependency(k, rspamd_symbol)
+ external_deps[k][rspamd_symbol] = true
+ lua_util.debugm(N, rspamd_config,
+ 'atom %1 is a direct foreign dependency, ' ..
+ 'register dependency for %2 on %3',
+ a, k, rspamd_symbol)
+ end
+ end
+ end
+ end
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'meta'
+ end,
+ rules))
+
+ -- ... And then indirect ones ...
+ local nchanges
+ repeat
+ nchanges = 0
+ fun.each(function(k, r)
+ if r['expression'] then
+ local expr_atoms = r['expression']:atoms()
+ for _, a in ipairs(expr_atoms) do
+ if type(external_deps[a]) == 'table' then
+ for dep in pairs(external_deps[a]) do
+ if not external_deps[k] then
+ external_deps[k] = {}
+ end
+ if not external_deps[k][dep] then
+ rspamd_config:register_dependency(k, dep)
+ external_deps[k][dep] = true
+ lua_util.debugm(N, rspamd_config,
+ 'atom %1 is an indirect foreign dependency, ' ..
+ 'register dependency for %2 on %3',
+ a, k, dep)
+ nchanges = nchanges + 1
+ end
+ end
+ else
+ local rspamd_symbol, replaced_symbol = replace_symbol(a)
+ if replaced_symbol then
+ external_deps[a] = { [rspamd_symbol] = true }
+ else
+ external_deps[a] = {}
+ end
+ end
+ end
+ end
+ end,
+ fun.filter(function(_, r)
+ return r['type'] == 'meta'
+ end,
+ rules))
+ until nchanges == 0
+
+ -- Set missing symbols
+ fun.each(function(key, score)
+ if not scores_added[key] then
+ rspamd_config:set_metric_symbol({
+ name = key, score = score,
+ priority = 2, flags = 'ignore' })
+ end
+ end, scores)
+
+ -- Logging output
+ if freemail_domains then
+ freemail_trie = rspamd_trie.create(freemail_domains)
+ rspamd_logger.infox(rspamd_config, 'loaded %1 freemail domains definitions',
+ #freemail_domains)
+ end
+ rspamd_logger.infox(rspamd_config, 'loaded %1 blacklist/whitelist elements',
+ sa_lists['elts'])
+end
+
+local has_rules = false
+
+if type(section) == "table" then
+ local keywords = {
+ pcre_only = { 'table', function(v)
+ pcre_only_regexps = lua_util.list_to_hash(v)
+ end },
+ alpha = { 'number', function(v)
+ meta_score_alpha = tonumber(v)
+ end },
+ match_limit = { 'number', function(v)
+ match_limit = tonumber(v)
+ end },
+ scores_priority = { 'number', function(v)
+ scores_priority = tonumber(v)
+ end },
+ }
+
+ for k, fn in pairs(section) do
+ local kw = keywords[k]
+ if kw and type(fn) == kw[1] then
+ kw[2](fn)
+ else
+ -- SA rule file
+ if type(fn) == 'table' then
+ for _, elt in ipairs(fn) do
+ local files = util.glob(elt)
+
+ if not files or #files == 0 then
+ rspamd_logger.errx(rspamd_config, "cannot find any files matching pattern %s", elt)
+ else
+ for _, matched in ipairs(files) do
+ local f = io.open(matched, "r")
+ if f then
+ rspamd_logger.infox(rspamd_config, 'loading SA rules from %s', matched)
+ process_sa_conf(f)
+ has_rules = true
+ else
+ rspamd_logger.errx(rspamd_config, "cannot open %1", matched)
+ end
+ end
+ end
+ end
+ else
+ -- assume string
+ local files = util.glob(fn)
+
+ if not files or #files == 0 then
+ rspamd_logger.errx(rspamd_config, "cannot find any files matching pattern %s", fn)
+ else
+ for _, matched in ipairs(files) do
+ local f = io.open(matched, "r")
+ if f then
+ rspamd_logger.infox(rspamd_config, 'loading SA rules from %s', matched)
+ process_sa_conf(f)
+ has_rules = true
+ else
+ rspamd_logger.errx(rspamd_config, "cannot open %1", matched)
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
+if has_rules then
+ post_process()
+else
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/spamtrap.lua b/src/plugins/lua/spamtrap.lua
new file mode 100644
index 0000000..cd3b296
--- /dev/null
+++ b/src/plugins/lua/spamtrap.lua
@@ -0,0 +1,200 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+
+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.
+]]--
+
+-- A plugin that triggers, if a spam trapped email address was detected
+
+local rspamd_logger = require "rspamd_logger"
+local redis_params
+local use_redis = false;
+local M = 'spamtrap'
+local lua_util = require "lua_util"
+local fun = require "fun"
+
+local settings = {
+ symbol = 'SPAMTRAP',
+ score = 0.0,
+ learn_fuzzy = false,
+ learn_spam = false,
+ fuzzy_flag = 1,
+ fuzzy_weight = 10.0,
+ key_prefix = 'sptr_',
+ allow_multiple_rcpts = false,
+}
+
+local check_authed = true
+local check_local = true
+
+local function spamtrap_cb(task)
+ local rcpts = task:get_recipients('smtp')
+ local authed_user = task:get_user()
+ local ip_addr = task:get_ip()
+ local called_for_domain = false
+
+ if ((not check_authed and authed_user) or
+ (not check_local and ip_addr and ip_addr:is_local())) then
+ rspamd_logger.infox(task, "skip spamtrap checks for local networks or authenticated user");
+ return
+ end
+
+ local function do_action(rcpt)
+ if settings['learn_fuzzy'] then
+ rspamd_plugins.fuzzy_check.learn(task,
+ settings['fuzzy_flag'],
+ settings['fuzzy_weight'])
+ end
+ local act_flags = ''
+ if settings['learn_spam'] then
+ task:set_flag("learn_spam")
+ -- Allow processing as we still need to learn and do other stuff
+ act_flags = 'process_all'
+ end
+ task:insert_result(settings['symbol'], 1, rcpt)
+
+ if settings.action then
+ rspamd_logger.infox(task, 'spamtrap found: <%s>', rcpt)
+ local smtp_message
+ if settings.smtp_message then
+ smtp_message = lua_util.template(settings.smtp_message, { rcpt = rcpt })
+ else
+ smtp_message = 'unknown error'
+ if settings.action == 'no action' then
+ smtp_message = 'message accepted'
+ elseif settings.action == 'reject' then
+ smtp_message = 'message rejected'
+ end
+ end
+ task:set_pre_result { action = settings.action,
+ message = smtp_message,
+ module = 'spamtrap',
+ flags = act_flags }
+ end
+
+ return true
+ end
+
+ local function gen_redis_spamtrap_cb(target)
+ return function(err, data)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_spamtrap_cb received error: %1', err)
+ return
+ end
+
+ if data and type(data) ~= 'userdata' then
+ do_action(target)
+ else
+ if not called_for_domain then
+ -- Recurse for @catchall domain
+ target = rcpts[1]['domain']:lower()
+ local key = settings['key_prefix'] .. '@' .. target
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ gen_redis_spamtrap_cb(target), -- callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+ called_for_domain = true
+ else
+ lua_util.debugm(M, task, 'skip spamtrap for %s', target)
+ end
+ end
+ end
+ end
+
+ -- Do not risk a FP by checking for more than one recipient
+ if rcpts and (#rcpts == 1 or (#rcpts > 0 and settings.allow_multiple_rcpts)) then
+ local targets = fun.map(function(r)
+ return r['addr']:lower()
+ end, rcpts)
+ if use_redis then
+ fun.each(function(target)
+ local key = settings['key_prefix'] .. target
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ gen_redis_spamtrap_cb(target), -- callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+ end, targets)
+
+ elseif settings['map'] then
+ local function check_map_functor(target)
+ if settings['map']:get_key(target) then
+ return do_action(target)
+ end
+ end
+ if not fun.any(check_map_functor, targets) then
+ lua_util.debugm(M, task, 'skip spamtrap')
+ end
+ end
+ end
+end
+
+-- Module setup
+
+local opts = rspamd_config:get_all_opt('spamtrap')
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'module is unconfigured')
+ return
+end
+
+local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, 'spamtrap',
+ false, false)
+check_local = auth_and_local_conf[1]
+check_authed = auth_and_local_conf[2]
+
+if opts then
+ for k, v in pairs(opts) do
+ settings[k] = v
+ end
+ if settings['map'] then
+ settings['map'] = rspamd_config:add_map {
+ url = settings['map'],
+ description = string.format("Spamtrap map for %s", settings['symbol']),
+ type = "regexp"
+ }
+ else
+ redis_params = rspamd_parse_redis_server('spamtrap')
+ if not redis_params then
+ rspamd_logger.errx(
+ rspamd_config, 'no redis servers are specified, disabling module')
+ return
+ end
+ use_redis = true;
+ end
+
+ local id = rspamd_config:register_symbol({
+ name = "SPAMTRAP_CHECK",
+ type = "callback,postfilter",
+ callback = spamtrap_cb
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol'],
+ parent = id,
+ type = 'virtual',
+ score = settings.score
+ })
+end
diff --git a/src/plugins/lua/spf.lua b/src/plugins/lua/spf.lua
new file mode 100644
index 0000000..5e15128
--- /dev/null
+++ b/src/plugins/lua/spf.lua
@@ -0,0 +1,242 @@
+--[[
+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 N = "spf"
+local lua_util = require "lua_util"
+local rspamd_spf = require "rspamd_spf"
+local bit = require "bit"
+local rspamd_logger = require "rspamd_logger"
+
+if confighelp then
+ rspamd_config:add_example(nil, N,
+ 'Performs SPF checks',
+ [[
+spf {
+ # Enable module
+ enabled = true
+ # Number of elements in the cache of parsed SPF records
+ spf_cache_size = 2048;
+ # Default max expire for an element in this cache
+ spf_cache_expire = 1d;
+ # Whitelist IPs from checks
+ whitelist = "/path/to/some/file";
+ # Maximum number of recursive DNS subrequests (e.g. includes chanin length)
+ max_dns_nesting = 10;
+ # Maximum count of DNS requests per record
+ max_dns_requests = 30;
+ # Minimum TTL enforced for all elements in SPF records
+ min_cache_ttl = 5m;
+ # Disable all IPv6 lookups
+ disable_ipv6 = false;
+ # Use IP address from a received header produced by this relay (using by attribute)
+ external_relay = ["192.168.1.1"];
+}
+ ]])
+ return
+end
+
+local symbols = {
+ fail = "R_SPF_FAIL",
+ softfail = "R_SPF_SOFTFAIL",
+ neutral = "R_SPF_NEUTRAL",
+ allow = "R_SPF_ALLOW",
+ dnsfail = "R_SPF_DNSFAIL",
+ permfail = "R_SPF_PERMFAIL",
+ na = "R_SPF_NA",
+}
+
+local default_config = {
+ spf_cache_size = 2048,
+ max_dns_nesting = 10,
+ max_dns_requests = 30,
+ whitelist = nil,
+ min_cache_ttl = 60 * 5,
+ disable_ipv6 = false,
+ symbols = symbols,
+ external_relay = nil,
+}
+
+local local_config = rspamd_config:get_all_opt('spf')
+local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+
+if local_config then
+ local_config = lua_util.override_defaults(default_config, local_config)
+else
+ local_config = default_config
+end
+
+local function spf_check_callback(task)
+
+ local ip
+
+ if local_config.external_relay then
+ -- Search received headers to get header produced by an external relay
+ local rh = task:get_received_headers() or {}
+ local found = false
+
+ for i, hdr in ipairs(rh) do
+ if hdr.real_ip and local_config.external_relay:get_key(hdr.real_ip) then
+ -- We can use the next header as a source of IP address
+ if rh[i + 1] then
+ local nhdr = rh[i + 1]
+ lua_util.debugm(N, task, 'found external relay %s at received header number %s -> %s',
+ local_config.external_relay, i, nhdr.real_ip)
+
+ if nhdr.real_ip then
+ ip = nhdr.real_ip
+ found = true
+ end
+ end
+
+ break
+ end
+ end
+ if not found then
+ ip = task:get_from_ip()
+ rspamd_logger.warnx(task,
+ "cannot find external relay for SPF checks in received headers; use the original IP: %s",
+ tostring(ip))
+ end
+ else
+ ip = task:get_from_ip()
+ end
+
+ local function flag_to_symbol(fl)
+ if bit.band(fl, rspamd_spf.flags.temp_fail) ~= 0 then
+ return local_config.symbols.dnsfail
+ elseif bit.band(fl, rspamd_spf.flags.perm_fail) ~= 0 then
+ return local_config.symbols.permfail
+ elseif bit.band(fl, rspamd_spf.flags.na) ~= 0 then
+ return local_config.symbols.na
+ end
+
+ return 'SPF_UNKNOWN'
+ end
+
+ local function policy_decode(res)
+ if res == rspamd_spf.policy.fail then
+ return local_config.symbols.fail, '-'
+ elseif res == rspamd_spf.policy.pass then
+ return local_config.symbols.allow, '+'
+ elseif res == rspamd_spf.policy.soft_fail then
+ return local_config.symbols.softfail, '~'
+ elseif res == rspamd_spf.policy.neutral then
+ return local_config.symbols.neutral, '?'
+ end
+
+ return 'SPF_UNKNOWN', '?'
+ end
+
+ local function spf_resolved_cb(record, flags, err)
+ lua_util.debugm(N, task, 'got spf results: %s flags, %s err',
+ flags, err)
+
+ if record then
+ local result, flag_or_policy, error_or_addr = record:check_ip(ip)
+
+ lua_util.debugm(N, task,
+ 'checked ip %s: result=%s, flag_or_policy=%s, error_or_addr=%s',
+ ip, flags, err, error_or_addr)
+
+ if result then
+ local sym, code = policy_decode(flag_or_policy)
+ local opt = string.format('%s%s', code, error_or_addr.str or '???')
+ if bit.band(flags, rspamd_spf.flags.cached) ~= 0 then
+ opt = opt .. ':c'
+ rspamd_logger.infox(task,
+ "use cached record for %s (0x%s) in LRU cache for %s seconds",
+ record:get_domain(),
+ record:get_digest(),
+ record:get_ttl() - math.floor(task:get_timeval(true) -
+ record:get_timestamp()));
+ end
+ task:insert_result(sym, 1.0, opt)
+ else
+ local sym = flag_to_symbol(flag_or_policy)
+ task:insert_result(sym, 1.0, error_or_addr)
+ end
+ else
+ local sym = flag_to_symbol(flags)
+ task:insert_result(sym, 1.0, err)
+ end
+ end
+
+ if ip then
+ if local_config.whitelist and ip and local_config.whitelist:get_key(ip) then
+ rspamd_logger.infox(task, 'whitelisted SPF checks from %s',
+ tostring(ip))
+ return
+ end
+
+ if lua_util.is_skip_local_or_authed(task, auth_and_local_conf, ip) then
+ rspamd_logger.infox(task, 'skip SPF checks for local networks and authorized users')
+ return
+ end
+
+ rspamd_spf.resolve(task, spf_resolved_cb)
+ else
+ lua_util.debugm(N, task, "spf checks are not possible as no source IP address is defined")
+ end
+
+ -- FIXME: we actually need to set this variable when we really checked SPF
+ -- However, the old C module has set it all the times
+ -- Hence, we follow the same rule for now. It should be better designed at some day
+ local mpool = task:get_mempool()
+ local dmarc_checks = mpool:get_variable('dmarc_checks', 'double') or 0
+ dmarc_checks = dmarc_checks + 1
+ mpool:set_variable('dmarc_checks', dmarc_checks)
+end
+
+-- Register all symbols and init rspamd_spf library
+rspamd_spf.config(local_config)
+local sym_id = rspamd_config:register_symbol {
+ name = 'SPF_CHECK',
+ type = 'callback',
+ flags = 'fine,empty',
+ groups = { 'policies', 'spf' },
+ score = 0.0,
+ callback = spf_check_callback,
+ -- We can merely estimate timeout here, as it is possible to construct an SPF record that would cause
+ -- many DNS requests. But we won't like to set the maximum value for that all the time, as
+ -- the majority of requests will typically have 1-4 subrequests
+ augmentations = { string.format("timeout=%f", rspamd_config:get_dns_timeout() * 4 or 0.0) },
+}
+
+if local_config.whitelist then
+ local lua_maps = require "lua_maps"
+
+ local_config.whitelist = lua_maps.map_add_from_ucl(local_config.whitelist,
+ "radix", "SPF whitelist map")
+end
+
+if local_config.external_relay then
+ local lua_maps = require "lua_maps"
+
+ local_config.external_relay = lua_maps.map_add_from_ucl(local_config.external_relay,
+ "radix", "External IP SPF map")
+end
+
+for _, sym in pairs(local_config.symbols) do
+ rspamd_config:register_symbol {
+ name = sym,
+ type = 'virtual',
+ parent = sym_id,
+ groups = { 'policies', 'spf' },
+ }
+end
+
+
diff --git a/src/plugins/lua/trie.lua b/src/plugins/lua/trie.lua
new file mode 100644
index 0000000..7ba4552
--- /dev/null
+++ b/src/plugins/lua/trie.lua
@@ -0,0 +1,184 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+-- Trie is rspamd module designed to define and operate with suffix trie
+
+local N = 'trie'
+local rspamd_logger = require "rspamd_logger"
+local rspamd_trie = require "rspamd_trie"
+local fun = require "fun"
+local lua_util = require "lua_util"
+
+local mime_trie
+local raw_trie
+local body_trie
+
+-- here we store all patterns as text
+local mime_patterns = {}
+local raw_patterns = {}
+local body_patterns = {}
+
+-- here we store params for each pattern, so for each i = 1..n patterns[i]
+-- should have corresponding params[i]
+local mime_params = {}
+local raw_params = {}
+local body_params = {}
+
+local function tries_callback(task)
+
+ local matched = {}
+
+ local function gen_trie_cb(type)
+ local patterns = mime_patterns
+ local params = mime_params
+ if type == 'rawmessage' then
+ patterns = raw_patterns
+ params = raw_params
+ elseif type == 'rawbody' then
+ patterns = body_patterns
+ params = body_params
+ end
+
+ return function(idx, pos)
+ local param = params[idx]
+ local pattern = patterns[idx]
+ local pattern_idx = pattern .. tostring(idx) .. type
+
+ if param['multi'] or not matched[pattern_idx] then
+ lua_util.debugm(N, task, "<%1> matched pattern %2 at pos %3",
+ task:get_message_id(), pattern, pos)
+ task:insert_result(param['symbol'], 1.0, type)
+ if not param['multi'] then
+ matched[pattern_idx] = true
+ end
+ end
+ end
+ end
+
+ if mime_trie then
+ mime_trie:search_mime(task, gen_trie_cb('mime'))
+ end
+ if raw_trie then
+ raw_trie:search_rawmsg(task, gen_trie_cb('rawmessage'))
+ end
+ if body_trie then
+ body_trie:search_rawbody(task, gen_trie_cb('rawbody'))
+ end
+end
+
+local function process_single_pattern(pat, symbol, cf)
+ if pat then
+ local multi = false
+ if cf['multi'] then
+ multi = true
+ end
+
+ if cf['raw'] then
+ table.insert(raw_patterns, pat)
+ table.insert(raw_params, { symbol = symbol, multi = multi })
+ elseif cf['body'] then
+ table.insert(body_patterns, pat)
+ table.insert(body_params, { symbol = symbol, multi = multi })
+ else
+ table.insert(mime_patterns, pat)
+ table.insert(mime_params, { symbol = symbol, multi = multi })
+ end
+ end
+end
+
+local function process_trie_file(symbol, cf)
+ local file = io.open(cf['file'])
+
+ if not file then
+ rspamd_logger.errx(rspamd_config, 'Cannot open trie file %1', cf['file'])
+ else
+ if cf['binary'] then
+ rspamd_logger.errx(rspamd_config, 'binary trie patterns are not implemented yet: %1',
+ cf['file'])
+ else
+ for line in file:lines() do
+ local pat = string.match(line, '^([^#].*[^%s])%s*$')
+ process_single_pattern(pat, symbol, cf)
+ end
+ end
+ end
+end
+
+local function process_trie_conf(symbol, cf)
+ if type(cf) ~= 'table' then
+ rspamd_logger.errx(rspamd_config, 'invalid value for symbol %1: "%2", expected table',
+ symbol, cf)
+ return
+ end
+
+ if cf['file'] then
+ process_trie_file(symbol, cf)
+ elseif cf['patterns'] then
+ fun.each(function(pat)
+ process_single_pattern(pat, symbol, cf)
+ end, cf['patterns'])
+ end
+end
+
+local opts = rspamd_config:get_all_opt("trie")
+if opts then
+ for sym, opt in pairs(opts) do
+ process_trie_conf(sym, opt)
+ end
+
+ if #raw_patterns > 0 then
+ raw_trie = rspamd_trie.create(raw_patterns)
+ rspamd_logger.infox(rspamd_config, 'registered raw search trie from %1 patterns', #raw_patterns)
+ end
+
+ if #mime_patterns > 0 then
+ mime_trie = rspamd_trie.create(mime_patterns)
+ rspamd_logger.infox(rspamd_config, 'registered mime search trie from %1 patterns', #mime_patterns)
+ end
+
+ if #body_patterns > 0 then
+ body_trie = rspamd_trie.create(body_patterns)
+ rspamd_logger.infox(rspamd_config, 'registered body search trie from %1 patterns', #body_patterns)
+ end
+
+ local id = -1
+ if mime_trie or raw_trie or body_trie then
+ id = rspamd_config:register_symbol({
+ name = 'TRIE_CALLBACK',
+ type = 'callback',
+ callback = tries_callback
+ })
+ else
+ rspamd_logger.infox(rspamd_config, 'no tries defined')
+ end
+
+ if id ~= -1 then
+ for sym in pairs(opts) do
+ rspamd_config:register_symbol({
+ name = sym,
+ type = 'virtual',
+ parent = id
+ })
+ end
+ end
+else
+ rspamd_logger.infox(rspamd_config, "Module is unconfigured")
+ lua_util.disable_module(N, "config")
+end
diff --git a/src/plugins/lua/url_redirector.lua b/src/plugins/lua/url_redirector.lua
new file mode 100644
index 0000000..10b5fb2
--- /dev/null
+++ b/src/plugins/lua/url_redirector.lua
@@ -0,0 +1,422 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_http = require "rspamd_http"
+local hash = require "rspamd_cryptobox_hash"
+local rspamd_url = require "rspamd_url"
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local N = "url_redirector"
+
+-- Some popular UA
+local default_ua = {
+ 'Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)',
+ 'Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)',
+ 'Wget/1.9.1',
+ 'Mozilla/5.0 (Android; Linux armv7l; rv:9.0) Gecko/20111216 Firefox/9.0 Fennec/9.0',
+ 'Mozilla/5.0 (Windows NT 5.2; RW; rv:7.0a1) Gecko/20091211 SeaMonkey/9.23a1pre',
+ 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko',
+ 'W3C-checklink/4.5 [4.160] libwww-perl/5.823',
+ 'Lynx/2.8.8dev.3 libwww-FM/2.14 SSL-MM/1.4.1',
+}
+
+local redis_params
+
+local settings = {
+ expire = 86400, -- 1 day by default
+ timeout = 10, -- 10 seconds by default
+ nested_limit = 5, -- How many redirects to follow
+ --proxy = "http://example.com:3128", -- Send request through proxy
+ key_prefix = 'rdr:', -- default hash name
+ check_ssl = false, -- check ssl certificates
+ max_urls = 5, -- how many urls to check
+ max_size = 10 * 1024, -- maximum body to process
+ user_agent = default_ua,
+ redirector_symbol = nil, -- insert symbol if redirected url has been found
+ redirector_symbol_nested = "URL_REDIRECTOR_NESTED", -- insert symbol if nested limit has been reached
+ redirectors_only = true, -- follow merely redirectors
+ top_urls_key = 'rdr:top_urls', -- key for top urls
+ top_urls_count = 200, -- how many top urls to save
+ redirector_hosts_map = nil -- check only those redirectors
+}
+
+local function adjust_url(task, orig_url, redir_url)
+ local mempool = task:get_mempool()
+ if type(redir_url) == 'string' then
+ redir_url = rspamd_url.create(mempool, redir_url, { 'redirect_target' })
+ end
+
+ if redir_url then
+ orig_url:set_redirected(redir_url, mempool)
+ task:inject_url(redir_url)
+ if settings.redirector_symbol then
+ task:insert_result(settings.redirector_symbol, 1.0,
+ string.format('%s->%s', orig_url:get_host(), redir_url:get_host()))
+ end
+ else
+ rspamd_logger.infox(task, 'bad url %s as redirection for %s', redir_url, orig_url)
+ end
+end
+
+local function cache_url(task, orig_url, url, key, prefix)
+ -- String representation
+ local str_orig_url = tostring(orig_url)
+ local str_url = tostring(url)
+
+ if str_url ~= str_orig_url then
+ -- Set redirected url
+ adjust_url(task, orig_url, url)
+ end
+
+ local function redis_trim_cb(err, _)
+ if err then
+ rspamd_logger.errx(task, 'got error while getting top urls count: %s', err)
+ else
+ rspamd_logger.infox(task, 'trimmed url set to %s elements',
+ settings.top_urls_count)
+ end
+ end
+
+ -- Cleanup logic
+ local function redis_card_cb(err, data)
+ if err then
+ rspamd_logger.errx(task, 'got error while getting top urls count: %s', err)
+ else
+ if data then
+ if tonumber(data) > settings.top_urls_count * 2 then
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_trim_cb, --callback
+ 'ZREMRANGEBYRANK', -- command
+ { settings.top_urls_key, '0',
+ tostring(-(settings.top_urls_count + 1)) } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'cannot trim top urls set')
+ else
+ rspamd_logger.infox(task, 'need to trim urls set from %s to %s elements',
+ data,
+ settings.top_urls_count)
+ return
+ end
+ end
+ end
+ end
+ end
+
+ local function redis_set_cb(err, _)
+ if err then
+ rspamd_logger.errx(task, 'got error while setting redirect keys: %s', err)
+ else
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_card_cb, --callback
+ 'ZCARD', -- command
+ { settings.top_urls_key } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'cannot make redis request to cache results')
+ end
+ end
+ end
+
+ if prefix then
+ -- Save url with prefix
+ str_url = string.format('^%s:%s', prefix, str_url)
+ end
+ local ret, conn, _ = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'SETEX', -- command
+ { key, tostring(settings.expire), str_url } -- arguments
+ )
+
+ if not ret then
+ rspamd_logger.errx(task, 'cannot make redis request to cache results')
+ else
+ conn:add_cmd('ZINCRBY', { settings.top_urls_key, '1', str_url })
+ end
+end
+
+-- Reduce length of a string to a given length (16 by default)
+local function maybe_trim_url(url, limit)
+ if not limit then
+ limit = 16
+ end
+ if #url > limit then
+ return string.sub(url, 1, limit) .. '...'
+ else
+ return url
+ end
+end
+
+-- Resolve maybe cached url
+-- Orig url is the original url object
+-- url should be a new url object...
+local function resolve_cached(task, orig_url, url, key, ntries)
+ local str_url = tostring(url or "")
+ local function resolve_url()
+ if ntries > settings.nested_limit then
+ -- We cannot resolve more, stop
+ rspamd_logger.debugm(N, task, 'cannot get more requests to resolve %s, stop on %s after %s attempts',
+ orig_url, url, ntries)
+ cache_url(task, orig_url, url, key, 'nested')
+ local str_orig_url = tostring(orig_url)
+ task:insert_result(settings.redirector_symbol_nested, 1.0,
+ string.format('%s->%s:%d', maybe_trim_url(str_orig_url), maybe_trim_url(str_url), ntries))
+
+ return
+ end
+
+ local redirection_codes = {
+ [301] = true, -- moved permanently
+ [302] = true, -- found
+ [303] = true, -- see other
+ [307] = true, -- temporary redirect
+ [308] = true, -- permanent redirect
+ }
+
+ local function http_callback(err, code, _, headers)
+ if err then
+ rspamd_logger.infox(task, 'found redirect error from %s to %s, err message: %s',
+ orig_url, url, err)
+ cache_url(task, orig_url, url, key)
+ else
+ if code == 200 then
+ if orig_url == url then
+ rspamd_logger.infox(task, 'direct url %s, err code 200',
+ url)
+ else
+ rspamd_logger.infox(task, 'found redirect from %s to %s, err code 200',
+ orig_url, url)
+ end
+
+ cache_url(task, orig_url, url, key)
+
+ elseif redirection_codes[code] then
+ local loc = headers['location']
+ local redir_url
+ if loc then
+ redir_url = rspamd_url.create(task:get_mempool(), loc)
+ end
+ rspamd_logger.debugm(N, task, 'found redirect from %s to %s, err code %s',
+ orig_url, loc, code)
+
+ if redir_url then
+ if settings.redirectors_only then
+ if settings.redirector_hosts_map:get_key(redir_url:get_host()) then
+ resolve_cached(task, orig_url, redir_url, key, ntries + 1)
+ else
+ lua_util.debugm(N, task,
+ "stop resolving redirects as %s is not a redirector", loc)
+ cache_url(task, orig_url, redir_url, key)
+ end
+ else
+ resolve_cached(task, orig_url, redir_url, key, ntries + 1)
+ end
+ else
+ rspamd_logger.debugm(N, task, "no location, headers: %s", headers)
+ cache_url(task, orig_url, url, key)
+ end
+ else
+ rspamd_logger.debugm(N, task, 'found redirect error from %s to %s, err code: %s',
+ orig_url, url, code)
+ cache_url(task, orig_url, url, key)
+ end
+ end
+ end
+
+ local ua
+ if type(settings.user_agent) == 'string' then
+ ua = settings.user_agent
+ else
+ ua = settings.user_agent[math.random(#settings.user_agent)]
+ end
+
+ lua_util.debugm(N, task, 'select user agent %s', ua)
+
+ rspamd_http.request {
+ headers = {
+ ['User-Agent'] = ua,
+ },
+ url = str_url,
+ task = task,
+ method = 'head',
+ max_size = settings.max_size,
+ timeout = settings.timeout,
+ opaque_body = true,
+ no_ssl_verify = not settings.check_ssl,
+ callback = http_callback
+ }
+ end
+ local function redis_get_cb(err, data)
+ if not err then
+ if type(data) == 'string' then
+ if data ~= 'processing' then
+ -- Got cached result
+ rspamd_logger.debugm(N, task, 'found cached redirect from %s to %s',
+ url, data)
+ if data:sub(1, 1) == '^' then
+ -- Prefixed url stored
+ local prefix, new_url = data:match('^%^(%a+):(.+)$')
+ if prefix == 'nested' then
+ task:insert_result(settings.redirector_symbol_nested, 1.0,
+ string.format('%s->%s:cached', maybe_trim_url(str_url), maybe_trim_url(new_url)))
+ end
+ data = new_url
+ end
+ if data ~= tostring(orig_url) then
+ adjust_url(task, orig_url, data)
+ end
+ return
+ end
+ end
+ end
+ local function redis_reserve_cb(nerr, ndata)
+ if nerr then
+ rspamd_logger.errx(task, 'got error while setting redirect keys: %s', nerr)
+ elseif ndata == 'OK' then
+ resolve_url()
+ end
+ end
+
+ if ntries == 1 then
+ -- Reserve key in Redis that we are processing this redirection
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_reserve_cb, --callback
+ 'SET', -- command
+ { key, 'processing', 'EX', tostring(settings.timeout * 2), 'NX' } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'Couldn\'t schedule SET')
+ end
+ else
+ -- Just continue resolving
+ resolve_url()
+ end
+
+ end
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_get_cb, --callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, 'cannot make redis request to check results')
+ end
+end
+
+local function url_redirector_process_url(task, url)
+ local url_str = url:get_raw()
+ -- 32 base32 characters are roughly 20 bytes of data or 160 bits
+ local key = settings.key_prefix .. hash.create(url_str):base32():sub(1, 32)
+ resolve_cached(task, url, url, key, 1)
+end
+
+local function url_redirector_handler(task)
+ local sp_urls = lua_util.extract_specific_urls({
+ task = task,
+ limit = settings.max_urls,
+ filter = function(url)
+ local host = url:get_host()
+ if settings.redirector_hosts_map:get_key(host) then
+ lua_util.debugm(N, task, 'check url %s', tostring(url))
+ return true
+ end
+ end,
+ no_cache = true,
+ need_content = true,
+ })
+
+ if sp_urls then
+ for _, u in ipairs(sp_urls) do
+ url_redirector_process_url(task, u)
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('url_redirector')
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+ redis_params = lua_redis.parse_redis_server('url_redirector', settings)
+
+ if not redis_params then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ else
+
+ if not settings.redirector_hosts_map then
+ rspamd_logger.infox(rspamd_config, 'no redirector_hosts_map option is specified, disabling module')
+ lua_util.disable_module(N, "config")
+ else
+ local lua_maps = require "lua_maps"
+ settings.redirector_hosts_map = lua_maps.map_add_from_ucl(settings.redirector_hosts_map,
+ 'set', 'Redirectors definitions')
+
+ lua_redis.register_prefix(settings.key_prefix .. '[a-z0-9]{32}', N,
+ 'URL redirector hashes', {
+ type = 'string',
+ })
+ if settings.top_urls_key then
+ lua_redis.register_prefix(settings.top_urls_key, N,
+ 'URL redirector top urls', {
+ type = 'zlist',
+ })
+ end
+ local id = rspamd_config:register_symbol {
+ name = 'URL_REDIRECTOR_CHECK',
+ type = 'callback,prefilter',
+ priority = lua_util.symbols_priorities.medium,
+ callback = url_redirector_handler,
+ -- In fact, the real timeout is nested_limit * timeout...
+ augmentations = { string.format("timeout=%f", settings.timeout) }
+ }
+
+ rspamd_config:register_symbol {
+ name = settings.redirector_symbol_nested,
+ type = 'virtual',
+ parent = id,
+ score = 0,
+ }
+
+ if settings.redirector_symbol then
+ rspamd_config:register_symbol {
+ name = settings.redirector_symbol,
+ type = 'virtual',
+ parent = id,
+ score = 0,
+ }
+ end
+ end
+ end
+end
diff --git a/src/plugins/lua/whitelist.lua b/src/plugins/lua/whitelist.lua
new file mode 100644
index 0000000..fa76da8
--- /dev/null
+++ b/src/plugins/lua/whitelist.lua
@@ -0,0 +1,443 @@
+--[[
+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.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require "rspamd_logger"
+local rspamd_util = require "rspamd_util"
+local fun = require "fun"
+local lua_util = require "lua_util"
+
+local N = "whitelist"
+
+local options = {
+ dmarc_allow_symbol = 'DMARC_POLICY_ALLOW',
+ spf_allow_symbol = 'R_SPF_ALLOW',
+ dkim_allow_symbol = 'R_DKIM_ALLOW',
+ check_local = false,
+ check_authed = false,
+ rules = {}
+}
+
+local E = {}
+
+local function whitelist_cb(symbol, rule, task)
+
+ local domains = {}
+
+ local function find_domain(dom, check)
+ local mult
+ local how = 'wl'
+
+ -- Can be overridden
+ if rule.blacklist then
+ how = 'bl'
+ end
+
+ local function parse_val(val)
+ local how_override
+ -- Strict is 'special'
+ if rule.strict then
+ how_override = 'both'
+ end
+ if val then
+ lua_util.debugm(N, task, "found whitelist key: %s=%s", dom, val)
+ if val == '' then
+ return (how_override or how), 1.0
+ elseif val:match('^bl:') then
+ return (how_override or 'bl'), (tonumber(val:sub(4)) or 1.0)
+ elseif val:match('^wl:') then
+ return (how_override or 'wl'), (tonumber(val:sub(4)) or 1.0)
+ elseif val:match('^both:') then
+ return (how_override or 'both'), (tonumber(val:sub(6)) or 1.0)
+ else
+ return (how_override or how), (tonumber(val) or 1.0)
+ end
+ end
+
+ return (how_override or how), 1.0
+ end
+
+ if rule['map'] then
+ local val = rule['map']:get_key(dom)
+ if val then
+ how, mult = parse_val(val)
+
+ if not domains[check] then
+ domains[check] = {}
+ end
+
+ domains[check] = {
+ [dom] = { how, mult }
+ }
+
+ lua_util.debugm(N, task, "final result: %s: %s->%s",
+ dom, how, mult)
+ return true, mult, how
+ end
+ elseif rule['maps'] then
+ for _, v in pairs(rule['maps']) do
+ local map = v.map
+ if map then
+ local val = map:get_key(dom)
+ if val then
+ how, mult = parse_val(val)
+
+ if not domains[check] then
+ domains[check] = {}
+ end
+
+ domains[check] = {
+ [dom] = { how, mult }
+ }
+
+ lua_util.debugm(N, task, "final result: %s: %s->%s",
+ dom, how, mult)
+ return true, mult, how
+ end
+ end
+ end
+ else
+ mult = rule['domains'][dom]
+ if mult then
+ if not domains[check] then
+ domains[check] = {}
+ end
+
+ domains[check] = {
+ [dom] = { how, mult }
+ }
+
+ return true, mult, how
+ end
+ end
+
+ return false, 0.0, how
+ end
+
+ local spf_violated = false
+ local dmarc_violated = false
+ local dkim_violated = false
+ local ip_addr = task:get_ip()
+
+ if rule.valid_spf then
+ if not task:has_symbol(options['spf_allow_symbol']) then
+ -- Not whitelisted
+ spf_violated = true
+ end
+ -- Now we can check from domain or helo
+ local from = task:get_from(1)
+
+ if ((from or E)[1] or E).domain then
+ local tld = rspamd_util.get_tld(from[1]['domain'])
+
+ if tld then
+ find_domain(tld, 'spf')
+ end
+ else
+ local helo = task:get_helo()
+
+ if helo then
+ local tld = rspamd_util.get_tld(helo)
+
+ if tld then
+ find_domain(tld, 'spf')
+ end
+ end
+ end
+ end
+
+ if rule.valid_dkim then
+ if task:has_symbol('DKIM_TRACE') then
+ local sym = task:get_symbol('DKIM_TRACE')
+ local dkim_opts = sym[1]['options']
+ if dkim_opts then
+ fun.each(function(val)
+ if val[2] == '+' then
+ local tld = rspamd_util.get_tld(val[1])
+ find_domain(tld, 'dkim_success')
+ elseif val[2] == '-' then
+ local tld = rspamd_util.get_tld(val[1])
+ find_domain(tld, 'dkim_fail')
+ end
+ end,
+ fun.map(function(s)
+ return lua_util.rspamd_str_split(s, ':')
+ end, dkim_opts))
+ end
+ end
+ end
+
+ if rule.valid_dmarc then
+ if not task:has_symbol(options.dmarc_allow_symbol) then
+ dmarc_violated = true
+ end
+
+ local from = task:get_from(2)
+
+ if ((from or E)[1] or E).domain then
+ local tld = rspamd_util.get_tld(from[1]['domain'])
+
+ if tld then
+ local found = find_domain(tld, 'dmarc')
+ if not found then
+ find_domain(from[1]['domain'], 'dmarc')
+ end
+ end
+ end
+ end
+
+ local final_mult = 1.0
+ local found_wl, found_bl = false, false
+ local opts = {}
+
+ if rule.valid_dkim then
+ dkim_violated = true
+
+ for dom, val in pairs(domains.dkim_success or E) do
+ if val[1] == 'wl' or val[1] == 'both' then
+ -- We have valid and whitelisted signature
+ table.insert(opts, dom .. ':d:+')
+ found_wl = true
+ dkim_violated = false
+
+ if not found_bl then
+ final_mult = val[2]
+ end
+ end
+ end
+
+ -- Blacklist counterpart
+ for dom, val in pairs(domains.dkim_fail or E) do
+ if val[1] == 'bl' or val[1] == 'both' then
+ -- We have valid and whitelisted signature
+ table.insert(opts, dom .. ':d:-')
+ found_bl = true
+ final_mult = val[2]
+ else
+ -- Even in the case of whitelisting we need to indicate dkim failure
+ dkim_violated = true
+ end
+ end
+ end
+
+ local function check_domain_violation(what, dom, val, violated)
+ if violated then
+ if val[1] == 'both' or val[1] == 'bl' then
+ found_bl = true
+ final_mult = val[2]
+ table.insert(opts, string.format("%s:%s:-", dom, what))
+ end
+ else
+ if val[1] == 'both' or val[1] == 'wl' then
+ found_wl = true
+ table.insert(opts, string.format("%s:%s:+", dom, what))
+ if not found_bl then
+ final_mult = val[2]
+ end
+ end
+ end
+ end
+
+ if rule.valid_dmarc then
+
+ found_wl = false
+
+ for dom, val in pairs(domains.dmarc or E) do
+ check_domain_violation('D', dom, val,
+ (dmarc_violated or dkim_violated))
+ end
+ end
+
+ if rule.valid_spf then
+ found_wl = false
+
+ for dom, val in pairs(domains.spf or E) do
+ check_domain_violation('s', dom, val,
+ (spf_violated or dkim_violated))
+ end
+ end
+
+ lua_util.debugm(N, task, "final mult: %s", final_mult)
+
+ local function add_symbol(violated, mult)
+ local sym = symbol
+
+ if violated then
+ if rule.inverse_symbol then
+ sym = rule.inverse_symbol
+ elseif not rule.blacklist then
+ mult = -mult
+ end
+
+ if rule.inverse_multiplier then
+ mult = mult * rule.inverse_multiplier
+ end
+
+ task:insert_result(sym, mult, opts)
+ else
+ task:insert_result(sym, mult, opts)
+ end
+ end
+
+ if found_bl then
+ if not ((not options.check_authed and task:get_user()) or
+ (not options.check_local and ip_addr and ip_addr:is_local())) then
+ add_symbol(true, final_mult)
+ else
+ if rule.valid_spf or rule.valid_dmarc then
+ rspamd_logger.infox(task, "skip DMARC/SPF blacklists for local networks and/or authorized users")
+ else
+ add_symbol(true, final_mult)
+ end
+ end
+ elseif found_wl then
+ add_symbol(false, final_mult)
+ end
+
+end
+
+local function gen_whitelist_cb(symbol, rule)
+ return function(task)
+ whitelist_cb(symbol, rule, task)
+ end
+end
+
+local configure_whitelist_module = function()
+ local opts = rspamd_config:get_all_opt('whitelist')
+ if opts then
+ for k, v in pairs(opts) do
+ options[k] = v
+ end
+
+ local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N,
+ false, false)
+ options.check_local = auth_and_local_conf[1]
+ options.check_authed = auth_and_local_conf[2]
+ else
+ rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
+ return
+ end
+
+ if options['rules'] then
+ fun.each(function(symbol, rule)
+ if rule['domains'] then
+ if type(rule['domains']) == 'string' then
+ rule['map'] = rspamd_config:add_map {
+ url = rule['domains'],
+ description = "Whitelist map for " .. symbol,
+ type = 'map'
+ }
+ elseif type(rule['domains']) == 'table' then
+ -- Transform ['domain1', 'domain2' ...] to indexes:
+ -- {'domain1' = 1, 'domain2' = 1 ...]
+ local is_domains_list = fun.all(function(v)
+ if type(v) == 'table' then
+ return true
+ elseif type(v) == 'string' and not (string.match(v, '^https?://') or
+ string.match(v, '^ftp://') or string.match(v, '^[./]')) then
+ return true
+ end
+
+ return false
+ end, rule.domains)
+
+ if is_domains_list then
+ rule['domains'] = fun.tomap(fun.map(function(d)
+ if type(d) == 'table' then
+ return d[1], d[2]
+ end
+
+ return d, 1.0
+ end, rule['domains']))
+ else
+ rule['map'] = rspamd_config:add_map {
+ url = rule['domains'],
+ description = "Whitelist map for " .. symbol,
+ type = 'map'
+ }
+ end
+ else
+ rspamd_logger.errx(rspamd_config, 'whitelist %s has bad "domains" value',
+ symbol)
+ return
+ end
+
+ local flags = 'nice,empty'
+ if rule['blacklist'] then
+ flags = 'empty'
+ end
+
+ local id = rspamd_config:register_symbol({
+ name = symbol,
+ flags = flags,
+ callback = gen_whitelist_cb(symbol, rule),
+ score = rule.score or 0,
+ })
+
+ if rule.inverse_symbol then
+ rspamd_config:register_symbol({
+ name = rule.inverse_symbol,
+ type = 'virtual',
+ parent = id,
+ score = rule.score and -(rule.score) or 0,
+ })
+ end
+
+ local spf_dep = false
+ local dkim_dep = false
+ if rule['valid_spf'] then
+ rspamd_config:register_dependency(symbol, options['spf_allow_symbol'])
+ spf_dep = true
+ end
+ if rule['valid_dkim'] then
+ rspamd_config:register_dependency(symbol, options['dkim_allow_symbol'])
+ dkim_dep = true
+ end
+ if rule['valid_dmarc'] then
+ if not spf_dep then
+ rspamd_config:register_dependency(symbol, options['spf_allow_symbol'])
+ end
+ if not dkim_dep then
+ rspamd_config:register_dependency(symbol, options['dkim_allow_symbol'])
+ end
+ rspamd_config:register_dependency(symbol, 'DMARC_CALLBACK')
+ end
+
+ if rule['score'] then
+ if not rule['group'] then
+ rule['group'] = 'whitelist'
+ end
+ rule['name'] = symbol
+ rspamd_config:set_metric_symbol(rule)
+
+ if rule.inverse_symbol then
+ local inv_rule = lua_util.shallowcopy(rule)
+ inv_rule.name = rule.inverse_symbol
+ inv_rule.score = -rule.score
+ rspamd_config:set_metric_symbol(inv_rule)
+ end
+ end
+ end
+ end, options['rules'])
+ else
+ lua_util.disable_module(N, "config")
+ end
+end
+
+configure_whitelist_module()