HEX
Server: Apache/2
System: Linux nexus-01 4.18.0-553.120.1.el8_10.x86_64 #1 SMP Mon Apr 20 18:04:27 EDT 2026 x86_64
User: aglcoke (1118)
PHP: 8.2.31
Disabled: mail,exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
Upload Files
File: //usr/share/rspamd/lualib/lua_scanners/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
}