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'
}