← Back
Editing: expurgate.lua
--[[ Copyright (c) 2025, Halon Security AB <support@halon.io> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ]]-- --[[[ -- @module expurgate -- This module contains eXpurgate integration --]] local lua_util = require "lua_util" local lua_mime = require "lua_mime" local http = require "rspamd_http" local upstream_list = require "rspamd_upstream_list" local rspamd_logger = require "rspamd_logger" local ucl = require "ucl" local common = require "lua_scanners/common" local N = 'expurgate' local header_key_x_purgate_type = 'X-purgate-type' local header_key_x_purgate_id = 'X-purgate-ID' local function expurgate_config(opts) local symbols = {} local function add_symbol(symbol_name, score, expurgate_type) symbols[expurgate_type] = { symbol = symbol_name, score = score, } end add_symbol('EXPURGATE_UNKNOWN', 0.0, 'unknown') add_symbol('EXPURGATE_CLEAN', -1.0, 'clean') add_symbol('EXPURGATE_SUSPECT', 3.0, 'suspect') add_symbol('EXPURGATE_SPAM', 8.0, 'spam') add_symbol('EXPURGATE_BULK', -1.0, 'bulk') -- minor categories add_symbol('EXPURGATE_CLEAN_EMPTY', 5.0, 'clean.empty') add_symbol('EXPURGATE_CLEAN_EMPTY_BODY', -1.0, 'clean.empty-body') add_symbol('EXPURGATE_CLEAN_BOUNCE', -1.0, 'clean.bounce') add_symbol('EXPURGATE_BULK_ADVERTISING', 7.0, 'bulk.advertising') add_symbol('EXPURGATE_BULK_PORN', 7.0, 'bulk.porn') add_symbol('EXPURGATE_DANGEROUS_VIRUS', 8.0, 'dangerous.virus') add_symbol('EXPURGATE_DANGEROUS_ATTACHMENT', 5.0, 'dangerous.attachment') add_symbol('EXPURGATE_DANGEROUS_CODE', 8.0, 'dangerous.code') add_symbol('EXPURGATE_DANGEROUS_IFRAME', 5.0, 'dangerous.iframe') add_symbol('EXPURGATE_DANGEROUS_VIRUS_OUTBREAK', 8.0, 'dangerous.virus-outbreak') add_symbol('EXPURGATE_SUSPECT_URL', 3.0, 'suspect.url') add_symbol('EXPURGATE_SUSPECT_URL_COUNT', 3.0, 'suspect.url-count') add_symbol('EXPURGATE_SUSPECT_MAIL_COUNT', 3.0, 'suspect.mail-count') add_symbol('EXPURGATE_SUSPECT_SENDER', 3.0, 'suspect.sender') add_symbol('EXPURGATE_SPAM_PHISHING', 8.0, 'spam.phishing') -- Caching is intentionally disabled: eXpurgate analyzes sender/recipient -- context and reputation data that cannot be captured by content digest alone. -- Skipping cache ensures similar mails with different contexts are all analyzed. local expurgate_conf = { name = N, default_port = 783, url = '/checkv2', use_https = false, timeout = 5.0, retransmits = 1, log_spamcause = false, symbol_fail = 'EXPURGATE_FAIL', symbols = symbols, add_header_x_purgate_id = true, add_header_x_purgate_type = false, no_cache = true, -- default_score, action and log_clean are not used by this plugin. Changing them does not have any effect. default_score = 1, action = false, log_clean = false, } expurgate_conf = lua_util.override_defaults(expurgate_conf, opts) if not expurgate_conf.prefix then expurgate_conf.prefix = 'rs_' .. expurgate_conf.name .. '_' end if not expurgate_conf.log_prefix then if expurgate_conf.name:lower() == expurgate_conf.type:lower() then expurgate_conf.log_prefix = expurgate_conf.name else expurgate_conf.log_prefix = expurgate_conf.name .. ' (' .. expurgate_conf.type .. ')' end end if not expurgate_conf.servers then rspamd_logger.errx(rspamd_config, 'no servers defined') return nil end expurgate_conf.upstreams = upstream_list.create(rspamd_config, expurgate_conf.servers, expurgate_conf.default_port) if expurgate_conf.upstreams then lua_util.add_debug_alias('external_services', expurgate_conf.name) return expurgate_conf end rspamd_logger.errx(rspamd_config, 'cannot parse servers %s', expurgate_conf['servers']) return nil end local function header_value(header) if not header or not header.value or #header.value == 0 then return nil end return header.value end local function expurgate_check(task, content, digest, rule, maybe_part) local function expurgate_check_uncached() local function expurgate_spamd_url(addr) local url if rule.use_https then url = string.format('https://%s:%d%s', tostring(addr), rule.default_port, rule.url) else url = string.format('http://%s:%d%s', tostring(addr), rule.default_port, rule.url) end return url end local upstream = common.get_upstream_or_fail(task, rule, maybe_part) if not upstream then return end local addr = upstream:get_addr() local retransmits = rule.retransmits local url = expurgate_spamd_url(addr) local hdrs = {} local helo = task:get_helo() if helo then hdrs['Helo'] = helo end local hostname = task:get_hostname() if hostname then hdrs['Hostname'] = hostname end local mail_from = task:get_from('smtp') or {} if mail_from[1] and #mail_from[1].addr > 1 then hdrs['From'] = mail_from[1].addr end local rcpt_to = task:get_recipients('smtp') if rcpt_to then hdrs['Rcpt'] = {} for _, r in ipairs(rcpt_to) do table.insert(hdrs['Rcpt'], r.addr) end end local fip = task:get_from_ip() if fip and fip:is_valid() then hdrs['IP'] = tostring(fip) end local request_data = { task = task, url = url, body = task:get_content(), headers = hdrs, timeout = rule.timeout, } local function expurgate_callback(http_err, code, body, headers) local function expurgate_requery() -- set current upstream to fail because an error occurred upstream:fail() -- retry with another upstream until retransmits exceeds if retransmits > 0 then retransmits = retransmits - 1 lua_util.debugm(rule.name, task, '%s: Request Error: %s - retries left: %s', rule.log_prefix, http_err, retransmits) -- Select a different upstream! upstream = rule.upstreams:get_upstream_round_robin() if not upstream then common.yield_result(task, rule, 'no upstream available for retry', 0.0, 'fail', maybe_part) return end addr = upstream:get_addr() url = expurgate_spamd_url(addr) lua_util.debugm(rule.name, task, '%s: retry IP: %s:%s', rule.log_prefix, addr, addr:get_port()) request_data.url = url http.request(request_data) else rspamd_logger.errx(task, '%s: failed to scan, maximum retransmits ' .. 'exceed', rule.log_prefix) task:insert_result(rule['symbol_fail'], 0.0, 'failed to scan and ' .. 'retransmits exceed') end end if http_err then expurgate_requery() else -- Parse the response if upstream then upstream:ok() end if code ~= 200 then rspamd_logger.errx(task, 'invalid HTTP code: %s, body: %s, headers: %s', code, body, headers) task:insert_result(rule.symbol_fail, 1.0, 'Bad HTTP code: ' .. code) return end local parser = ucl.parser() local ret, err = parser:parse_string(body) if not ret then rspamd_logger.errx(task, 'expurgate: bad response body (raw): %s', body) task:insert_result(rule.symbol_fail, 1.0, 'Parser error: ' .. err) return end local obj = parser:get_object() if not obj.milter or type(obj.milter) ~= 'table' then rspamd_logger.errx(task, 'expurgate: bad response JSON (no object `milter`): %s', obj) task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `milter` element') return end if not obj.milter.add_headers or type(obj.milter.add_headers) ~= 'table' then rspamd_logger.errx(task, 'expurgate: bad response JSON (no object `milter.add_headers`): %s', obj) task:insert_result(rule.symbol_fail, 1.0, 'Bad JSON reply: no `milter.add_headers` element') return end local header_value_x_purgate_id = header_value(obj.milter.add_headers[header_key_x_purgate_id]) if header_value_x_purgate_id and rule.add_header_x_purgate_id then lua_mime.modify_headers(task, { add = { [header_key_x_purgate_id] = { order = 1, value = header_value_x_purgate_id } } }) end local header_value_x_purgate_type = header_value(obj.milter.add_headers[header_key_x_purgate_type]) if header_value_x_purgate_type and rule.add_header_x_purgate_type then lua_mime.modify_headers(task, { add = { [header_key_x_purgate_type] = { order = 1, value = header_value_x_purgate_type } } }) end local x_purgate_type = 'unknown' if header_value_x_purgate_type then x_purgate_type = string.lower(header_value_x_purgate_type) end local sym = rule.symbols[x_purgate_type] if not sym then sym = rule.symbols.unknown end local opts = {} if obj.score then table.insert(opts, 'score=' .. obj.score) end if header_value_x_purgate_id then table.insert(opts, 'x-purgate-id=' .. header_value_x_purgate_id) end if rule.log_spamcause and obj.spamcause then rspamd_logger.infox(task, 'expurgate type="%s", score=%s, spamcause="%s", message-id="%s"', x_purgate_type, obj.score, obj.spamcause, task:get_message_id()) else lua_util.debugm(rule.name, task, 'expurgate returned type="%s", score=%s, spamcause="%s"', x_purgate_type, obj.score, obj.spamcause) end task:insert_result(sym.symbol, 1.0, opts) end end request_data.callback = expurgate_callback http.request(request_data) end if common.condition_check_and_continue(task, content, rule, digest, expurgate_check_uncached, maybe_part) then return else expurgate_check_uncached() end end return { type = { N, 'scanner' }, description = 'eXpurgate AntiSpam Filter', configure = expurgate_config, check = expurgate_check, name = N }
Save File
Cancel