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/rspamadm/mapstats.lua
--[[
Copyright (c) 2026, 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 argparse = require "argparse"
local rspamd_regexp = require "rspamd_regexp"
local rspamd_ip = require "rspamd_ip"
local log_utils = require "lua_log_utils"
local ansicolors = require "ansicolors"

local parser = argparse()
  :name "rspamadm mapstats"
  :description "Count Rspamd multimap matches by parsing log files"
  :help_description_margin(32)

parser:argument "log"
  :description "Log file or directory to read (stdin if omitted)"
  :args "?"
  :default ""
parser:option "-c --config"
  :description "Path to config file"
  :argname "<file>"
  :default(rspamd_paths and rspamd_paths["CONFDIR"] and
    (rspamd_paths["CONFDIR"] .. "/" .. "rspamd.conf") or
    "/etc/rspamd/rspamd.conf")
parser:option "--start"
  :description "Starting time for log parsing"
  :argname "<time>"
  :default ""
parser:option "--end"
  :description "Ending time for log parsing"
  :argname "<time>"
parser:option "-n --num-logs"
  :description "Number of recent logfiles to analyze"
  :argname "<n>"
  :convert(tonumber)
parser:option "-x --exclude-logs"
  :description "Number of latest logs to exclude"
  :argname "<n>"
  :default "0"
  :convert(tonumber)

local re_non_file_url = rspamd_regexp.create('/^.*(?<!file):\\/\\//')
local re_regexp_line = rspamd_regexp.create('/^\\/(.+)\\/(\\S?)(?:\\s+(\\d+\\.?\\d*))?$/')
local re_plain_line = rspamd_regexp.create('/^(\\S+)(?:\\s+(\\d+\\.?\\d*))?$/')
local re_sym_with_opts = rspamd_regexp.create('/([^(]+)\\([.0-9]+\\)\\{([^;]+);\\}/')

local function get_multimap_config(config_path)
  local _r, err = rspamd_config:load_ucl(config_path)
  if not _r then
    io.stderr:write(string.format("Cannot load config %s: %s\n", config_path, err))
    os.exit(1)
  end
  _r, err = rspamd_config:parse_rcl({ 'logging', 'worker' })
  if not _r then
    io.stderr:write(string.format("Cannot parse config %s: %s\n", config_path, err))
    os.exit(1)
  end

  local multimap_opts = rspamd_config:get_all_opt('multimap')
  if not multimap_opts then
    io.stderr:write("No multimap configuration found.\n")
    os.exit(1)
  end

  return multimap_opts
end

local function validate_regex_flags(flags, map_file, line_num)
  if flags and #flags > 0 then
    local bad = flags:match('[^imsxurOL]')
    if bad then
      io.stderr:write(string.format(
        "Invalid regex flag in %s at line %d: '%s' (supported: imsxurOL)\n",
        map_file, line_num, flags))
      return false
    end
  end
  return true
end

local function get_map(symbol_cfg, map_file)
  local fh, err = io.open(map_file, 'r')
  if not fh then
    io.stderr:write(string.format("Cannot open map file %s: %s\n", map_file, err or 'unknown'))
    return {}
  end

  local entries = {}
  local line_num = 0
  local is_regexp = symbol_cfg.regexp and true or false

  for line in fh:lines() do
    line_num = line_num + 1

    local trimmed = line:match('^%s*(.-)%s*$')
    if trimmed == '' or trimmed:match('^#') then
      table.insert(entries, {
        line_num = line_num,
        is_comment = true,
        content = line,
      })
    else
      -- Extract inline comment before regex parsing to avoid
      -- rspamd_regexp capture truncation on unmatched optional groups
      local comment = nil
      local body = trimmed
      if is_regexp then
        -- For regexes, comment must come after the closing / and optional flags/score
        -- Find the last # that's preceded by whitespace (outside the regex pattern)
        local close_slash = trimmed:find('/', 2)
        if close_slash then
          local after = trimmed:sub(close_slash + 1)
          local cmt_pos = after:find('%s+#%s*')
          if cmt_pos then
            -- Find actual position of # in after
            local hash_pos = after:find('#', cmt_pos)
            if hash_pos then
              comment = after:sub(hash_pos + 1):match('^%s*(.*)')
              body = trimmed:sub(1, close_slash) .. after:sub(1, cmt_pos - 1)
            end
          end
        end
      else
        local pre, cmt = trimmed:match('^(.-)%s+#%s*(.*)$')
        if pre and cmt then
          comment = cmt
          body = pre
        end
      end

      if is_regexp then
        local results = re_regexp_line:search(body, false, true)
        if not results or #results == 0 then
          io.stderr:write(string.format("Syntax error in %s at line %d\n", map_file, line_num))
          fh:close()
          return {}
        end
        local caps = results[1]
        if not caps or #caps < 2 then
          io.stderr:write(string.format("Syntax error in %s at line %d\n", map_file, line_num))
          fh:close()
          return {}
        end

        local pattern = tostring(caps[2])
        local flags = caps[3] and tostring(caps[3]) or ''
        local result = caps[4] and tostring(caps[4]) or nil

        if not validate_regex_flags(flags, map_file, line_num) then
          fh:close()
          return {}
        end

        -- Compile with rspamd_regexp (handles all rspamd flags natively)
        local re_pattern = '/' .. pattern .. '/' .. flags
        local compiled = rspamd_regexp.create(re_pattern)
        if not compiled then
          io.stderr:write(string.format("Invalid regex in %s at line %d\n", map_file, line_num))
          fh:close()
          return {}
        end

        table.insert(entries, {
          line_num = line_num,
          pattern = pattern,
          flag = flags,
          compiled = compiled,
          result = result,
          comment = comment,
          count = 0,
        })
      else
        local results = re_plain_line:search(body, false, true)
        if not results or #results == 0 then
          io.stderr:write(string.format("Syntax error in %s at line %d\n", map_file, line_num))
          fh:close()
          return {}
        end
        local caps = results[1]
        if not caps or #caps < 2 then
          io.stderr:write(string.format("Syntax error in %s at line %d\n", map_file, line_num))
          fh:close()
          return {}
        end

        local pattern = tostring(caps[2])
        local result = caps[3] and tostring(caps[3]) or nil

        table.insert(entries, {
          line_num = line_num,
          pattern = pattern,
          result = result,
          comment = comment,
          count = 0,
        })
      end
    end
  end

  fh:close()
  return entries
end

local function ip_within(ip_obj, cidr_str)
  -- Extract mask from CIDR notation before parsing
  local ip_part, mask_str = cidr_str:match('^(.+)/(%d+)$')
  if ip_part then
    local cidr_ip = rspamd_ip.from_string(ip_part)
    if not cidr_ip or not cidr_ip:is_valid() then
      return false
    end
    local mask = tonumber(mask_str)
    -- apply_mask returns a new IP object with the mask applied
    local ip_masked = ip_obj:apply_mask(mask)
    local cidr_masked = cidr_ip:apply_mask(mask)
    if not ip_masked or not cidr_masked then
      return false
    end
    return ip_masked == cidr_masked
  else
    local cidr_ip = rspamd_ip.from_string(cidr_str)
    if not cidr_ip or not cidr_ip:is_valid() then
      return false
    end
    return ip_obj == cidr_ip
  end
end

local function handler(args)
  local res = parser:parse(args)

  local multimap = get_multimap_config(res['config'])

  local map = {}
  local symbols_search = {}
  local unmatched = {}

  for symbol, cfg in pairs(multimap) do
    if type(cfg) ~= 'table' then
      goto continue_sym
    end

    local maps_list = cfg['map']
    if not maps_list then
      goto continue_sym
    end

    if type(maps_list) ~= 'table' then
      maps_list = { maps_list }
    elseif maps_list[1] == nil then
      -- It's a single map object, not an array
      maps_list = { maps_list }
    end

    map[symbol] = {
      type = cfg['type'] or 'string',
      is_regexp = cfg['regexp'] and true or false,
      maps = {},
    }

    local has_valid_maps = false
    for _, map_source in ipairs(maps_list) do
      if type(map_source) == 'table' then
        map_source = map_source['url'] or map_source['name'] or ''
      end
      if type(map_source) ~= 'string' then
        goto continue_map
      end

      -- Skip non-file maps
      if re_non_file_url:match(map_source) then
        io.write(string.format("%s: %s %s\n",
          ansicolors.bright .. symbol .. ansicolors.reset, map_source,
          ansicolors.yellow .. "[SKIPPED]" .. ansicolors.reset))
        goto continue_map
      end

      -- Strip file:// prefix
      local file_path = map_source:gsub('^fallback%+', ''):gsub('^file://', '')

      local entries = get_map(cfg, file_path)
      if #entries == 0 then
        io.write(string.format("%s: %s %s\n",
          ansicolors.bright .. symbol .. ansicolors.reset, map_source,
          ansicolors.red .. "[FAILED]" .. ansicolors.reset))
        goto continue_map
      end

      local entry_count = 0
      for _, e in ipairs(entries) do
        if not e.is_comment then
          entry_count = entry_count + 1
        end
      end
      io.write(string.format("%s: %s %s - %d entries\n",
        ansicolors.bright .. symbol .. ansicolors.reset, map_source,
        ansicolors.green .. "[OK]" .. ansicolors.reset, entry_count))

      table.insert(map[symbol].maps, {
        source = map_source,
        entries = entries,
      })
      has_valid_maps = true

      ::continue_map::
    end

    if has_valid_maps then
      table.insert(symbols_search, symbol)
    end

    ::continue_sym::
  end

  if #symbols_search == 0 then
    io.stderr:write("No file-based multimap symbols found. Nothing to analyze.\n")
    os.exit(1)
  end

  io.write("====== maps added =====\n")

  -- Process logs
  local function process_callback(ts, act, score, symbols_str, scan_time)
    if symbols_str == '' then
      return
    end

    local symbols_raw = {}
    for sym in symbols_str:gmatch('[^,]+') do
      table.insert(symbols_raw, sym)
    end

    for _, s in ipairs(symbols_search) do
      for _, sym in ipairs(symbols_raw) do
        if not sym:find(s, 1, true) then
          goto continue_inner
        end

        local results = re_sym_with_opts:search(sym, false, true)
        if not results or #results == 0 then
          unmatched[sym] = (unmatched[sym] or 0) + 1
          goto continue_inner
        end
        local caps = results[1]
        if not caps or #caps < 3 then
          unmatched[sym] = (unmatched[sym] or 0) + 1
          goto continue_inner
        end

        local sym_name = tostring(caps[2])
        local sym_opt = tostring(caps[3])

        if sym_name ~= s then
          goto continue_inner
        end

        local ip_obj
        if map[sym_name].type == 'ip' then
          ip_obj = rspamd_ip.from_string(sym_opt)
          if not ip_obj or not ip_obj:is_valid() then
            io.stderr:write(string.format("Invalid IP address in symbol %s: %s\n", sym_name, sym_opt))
            goto continue_inner
          end
        end

        local matched = false
        for _, map_entry in ipairs(map[sym_name].maps) do
          for _, entry in ipairs(map_entry.entries) do
            if entry.is_comment then
              goto continue_entry
            end

            if map[sym_name].type == 'ip' then
              if ip_obj and ip_within(ip_obj, entry.pattern) then
                entry.count = entry.count + 1
                matched = true
                break
              end
            elseif map[sym_name].is_regexp then
              if entry.compiled:match(sym_opt) then
                entry.count = entry.count + 1
                matched = true
                break
              end
            else
              if sym_opt == entry.pattern then
                entry.count = entry.count + 1
                matched = true
                break
              end
            end

            ::continue_entry::
          end
          if matched then break end
        end

        if not matched then
          unmatched[sym] = (unmatched[sym] or 0) + 1
        end

        ::continue_inner::
      end
    end
  end

  log_utils.process_logs(res['log'], res['start'] or '', res['end'], process_callback, {
    num_logs = res['num_logs'],
    exclude_logs = res['exclude_logs'],
  })

  -- Output results
  for _, symbol in ipairs(symbols_search) do
    io.write(string.format("%s:\n", ansicolors.bright .. symbol .. ansicolors.reset))
    io.write(string.format("    type=%s\n", map[symbol].type))

    for _, map_entry in ipairs(map[symbol].maps) do
      io.write(string.format("\nMap: %s\n", map_entry.source))
      io.write("Pattern\t\t\tMatches\t\tComment\n")
      io.write(string.rep('-', 80) .. '\n')

      for _, entry in ipairs(map_entry.entries) do
        if entry.is_comment then
          io.write(entry.content .. '\n')
        else
          if map[symbol].is_regexp then
            io.write(string.format("%-23s", '/' .. entry.pattern .. '/' .. entry.flag))
          else
            io.write(string.format("%-23s", entry.pattern))
          end

          if entry.count and entry.count > 0 then
            io.write(string.format("\t%s",
              ansicolors.green .. tostring(entry.count) .. ansicolors.reset))
          else
            io.write("\t-")
          end

          if entry.comment then
            io.write(string.format("\t\t# %s", entry.comment))
          end

          io.write('\n')
        end
      end
    end

    io.write(string.rep('=', 80) .. '\n')
  end

  -- Unmatched report
  if next(unmatched) then
    io.write(string.format("\n%s\n",
      ansicolors.yellow .. "Symbols with unmatched values:" .. ansicolors.reset))
    io.write(string.rep('-', 80) .. '\n')

    local grouped = {}
    for key, count in pairs(unmatched) do
      local sym_name = key:match('^(%w+)%(')
      if sym_name then
        if not grouped[sym_name] then
          grouped[sym_name] = {}
        end
        table.insert(grouped[sym_name], { full = key, count = count })
      end
    end

    local sorted_groups = {}
    for sym_name in pairs(grouped) do
      table.insert(sorted_groups, sym_name)
    end
    table.sort(sorted_groups)

    for _, symbol in ipairs(sorted_groups) do
      local entries = grouped[symbol]
      table.sort(entries, function(a, b) return a.count > b.count end)

      io.write(string.format("\n%s: %s\n",
        ansicolors.bright .. symbol .. ansicolors.reset,
        ansicolors.yellow .. string.format("%d unmatched value(s)", #entries) .. ansicolors.reset))
      local limit = math.min(#entries, 5)
      for i = 1, limit do
        io.write(string.format("  %dx: %s\n", entries[i].count, entries[i].full))
      end
      if #entries > 5 then
        io.write("  ...\n")
      end
    end
  end
end

return {
  handler = handler,
  description = parser._description,
  name = 'mapstats'
}