aboutsummaryrefslogtreecommitdiffstats
path: root/lualib
diff options
context:
space:
mode:
Diffstat (limited to 'lualib')
-rw-r--r--lualib/lua_mime.lua278
-rw-r--r--lualib/rspamadm/mime.lua52
2 files changed, 330 insertions, 0 deletions
diff --git a/lualib/lua_mime.lua b/lualib/lua_mime.lua
index 795a803e5..1135f2b63 100644
--- a/lualib/lua_mime.lua
+++ b/lualib/lua_mime.lua
@@ -897,4 +897,282 @@ exports.remove_attachments = function(task, settings)
return state
end
+--[[[
+-- @function lua_mime.get_displayed_text_part(task)
+-- Returns the most relevant displayed content from an email
+-- @param {task} task Rspamd task object
+-- @return {text_part} a selected part
+--]]
+exports.get_displayed_text_part = function(task)
+ local text_parts = task:get_text_parts()
+ if not text_parts then
+ return nil
+ end
+
+ local html_part
+ local text_part
+ local html_attachment
+
+ -- First pass: categorize parts
+ for _, part in ipairs(text_parts) do
+ local mp = part:get_mimepart()
+ if not mp:is_attachment() then
+ if part:is_html() then
+ html_part = part
+ else
+ text_part = text_part or part
+ end
+ else
+ -- Check for HTML attachments
+ if part:is_html() and mp:get_length() < 102400 then
+ -- 100KB limit, as long ones are likely not something that we should check
+ html_attachment = part
+ end
+ end
+ end
+
+ -- Decision logic
+ if html_part then
+ local word_count = html_part:get_words_count() or 0
+ if word_count >= 10 then
+ -- Arbitrary minimum threshold, e.g. I believe it's minimum sane
+ return html_part
+ end
+ end
+
+ if text_part then
+ local word_count = text_part:get_words_count() or 0
+ if word_count >= 10 then
+ -- Arbitrary minimum threshold, e.g. I believe it's minimum sane
+ return text_part
+ end
+ end
+
+ if html_attachment then
+ return html_attachment
+ end
+
+ -- Only short parts, but still let's try our best
+ return html_part or text_part
+end
+
+--[[[
+-- @function lua_mime.anonymize_message(task, settings)
+-- Anonymizes message content by replacing sensitive data
+-- @param {task} task Rspamd task object
+-- @param {table} settings Table with the following fields:
+-- * strip_attachments: boolean, whether to strip all attachments
+-- * custom_header_process: table of header_name => function(orig_header) pairs
+-- @return {table} modified message state similar to other modification functions
+--]]
+exports.anonymize_message = function(task, settings)
+ local rspamd_re = require "rspamd_regexp"
+ local lua_util = require "lua_util"
+ -- We exclude words with digits, currency symbols and so on
+ local exclude_words_re = rspamd_re.create_cached([=[/^(?:\d+|\d+\D{1,3}|\p{Sc}.*|(\+?\d{1,3}[\s\-]?)?)$/(:?^[[:alpha:]]*\d{4,}.*$)/u]=])
+ local newline_s = newline(task)
+ local state = {
+ newline_s = newline_s
+ }
+ local out = {}
+
+ -- Default header processors
+ local function anonymize_email_header(hdr)
+ local addrs = rspamd_util.parse_mail_address(hdr.value, task:get_mempool())
+ if addrs and addrs[1] then
+ local modified = {}
+ for _, addr in ipairs(addrs) do
+ table.insert(modified, string.format('anonymous@%s', addr.domain or 'example.com'))
+ end
+
+ return table.concat(modified, ',')
+ end
+ return 'anonymous@example.com'
+ end
+
+ local function anonymize_received_header(hdr)
+ local processed = string.gsub(hdr.value, '%d+%.%d+%.%d+%.%d+', 'x.x.x.x')
+ processed = string.gsub(processed, '%x+:%x+:%x+:%x+:%x+:%x+:%x+:%x+', 'x:x:x:x:x:x:x:x')
+ return processed
+ end
+
+ local default_header_process = {
+ ['from'] = anonymize_email_header,
+ ['to'] = anonymize_email_header,
+ ['cc'] = anonymize_email_header,
+ ['bcc'] = anonymize_email_header,
+ ['received'] = anonymize_received_header,
+ }
+
+ -- Merge with custom processors
+ local header_processors = settings.custom_header_process or {}
+ for k, v in pairs(default_header_process) do
+ if not header_processors[k] then
+ header_processors[k] = v
+ end
+ end
+
+ -- Process headers
+ local all_include = true
+ local all_exclude = false
+
+ -- Convert strings list to a list of globs where possible
+ local function process_exceptions_list(list)
+ if list and #list > 0 then
+ for i, hdr in ipairs(list) do
+ local gl = rspamd_re.import_glob(hdr, 'i')
+ if gl then
+ list[i] = gl
+ end
+ end
+ return true
+ end
+ end
+
+ local function maybe_match_header(hdr, list)
+ if not list then
+ return false
+ end
+ for _, expr in ipairs(list) do
+ if type(expr) == 'userdata' then
+ if expr:match(hdr) then
+ return true
+ end
+ else
+ if expr:lower() == hdr:lower() then
+ return true
+ end
+ end
+ end
+ return false
+ end
+
+ if process_exceptions_list(settings.include_header) then
+ all_include = false
+ all_exclude = true
+ end
+ if process_exceptions_list(settings.exclude_header) then
+ all_exclude = true
+ end
+
+ local modified_headers = {}
+ local function process_hdr(name, hdr)
+ local include_hdr = (all_include and not maybe_match_header(name, settings.exclude_header)) or
+ (all_exclude and maybe_match_header(name, settings.include_header))
+ if include_hdr then
+ local processor = header_processors[name:lower()]
+ if processor then
+ local new_value = processor(hdr)
+ if new_value then
+ table.insert(modified_headers, {
+ name = name,
+ value = new_value
+ })
+ end
+ else
+ table.insert(modified_headers, {
+ name = name,
+ value = hdr.value
+ })
+ end
+ end
+ end
+
+ task:headers_foreach(process_hdr, { full = true })
+
+ -- Create new text content
+ local text_content = {}
+ local urls = {}
+ local emails = {}
+
+ local sel_part = exports.get_displayed_text_part(task)
+
+ if sel_part then
+ text_content = sel_part:get_words('norm')
+ for i, w in ipairs(text_content) do
+ if exclude_words_re:match(w) then
+ text_content[i] = string.rep('x', #w)
+ end
+ end
+ end
+
+ -- Process URLs
+ local function process_url(url)
+ local clean_url = url:get_host()
+ local path = url:get_path()
+ if path and path ~= "/" then
+ clean_url = string.format("%s/%s", clean_url, path)
+ end
+ return string.format('https://%s', clean_url)
+ end
+
+ for _, url in ipairs(task:get_urls(true)) do
+ urls[process_url(url)] = true
+ end
+
+ -- Process emails
+ local function process_email(email)
+ return string.format('nobody@%s', email.domain or 'example.com')
+ end
+
+ for _, email in ipairs(task:get_emails()) do
+ emails[process_email(email)] = true
+ end
+
+ -- Construct new message
+ table.insert(text_content, '\nurls:')
+ table.insert(text_content, table.concat(lua_util.keys(urls), ', '))
+ table.insert(text_content, '\nemails:')
+ table.insert(text_content, table.concat(lua_util.keys(emails), ', '))
+ local new_text = table.concat(text_content, ' ')
+
+ -- Create new message structure
+ local cur_boundary = '--XXX'
+
+ -- Add headers
+ out[#out + 1] = {
+ string.format('Content-Type: multipart/mixed; boundary="%s"', cur_boundary),
+ true
+ }
+ for _, hdr in ipairs(modified_headers) do
+ if hdr.name ~= 'Content-Type' then
+ out[#out + 1] = {
+ string.format('%s: %s', hdr.name, hdr.value),
+ true
+ }
+ end
+ end
+ out[#out + 1] = { '', true }
+
+ -- Add text part
+ out[#out + 1] = {
+ string.format('--%s', cur_boundary),
+ true
+ }
+ out[#out + 1] = {
+ 'Content-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: quoted-printable',
+ true
+ }
+ out[#out + 1] = { '', true }
+ out[#out + 1] = {
+ rspamd_util.encode_qp(new_text, 76, task:get_newlines_type()),
+ true
+ }
+
+ -- Close boundaries
+ out[#out + 1] = {
+ string.format('--%s--', cur_boundary),
+ true
+ }
+
+ state.out = out
+ state.need_rewrite_ct = true
+ state.new_ct = {
+ type = 'multipart',
+ subtype = 'mixed'
+ }
+
+ return state
+end
+
return exports
diff --git a/lualib/rspamadm/mime.lua b/lualib/rspamadm/mime.lua
index 7750c5a78..f8c7fc4f7 100644
--- a/lualib/rspamadm/mime.lua
+++ b/lualib/rspamadm/mime.lua
@@ -179,6 +179,21 @@ strip:option "--max-text-size"
:convert(tonumber)
:default(math.huge)
+local anonymize = parser:command "anonymize"
+ :description "Try to remove sensitive information from a message"
+anonymize:argument "file"
+ :description "File to process"
+ :argname "<file>"
+ :args "+"
+anonymize:option "--exclude-header -X"
+ :description "Exclude specific headers from anonymization"
+ :argname "<header>"
+ :count "*"
+anonymize:option "--include-header -I"
+ :description "Include specific headers from anonymization"
+ :argname "<header>"
+ :count "*"
+
local sign = parser:command "sign"
:description "Performs DKIM signing"
sign:argument "file"
@@ -968,6 +983,41 @@ local function strip_handler(opts)
end
end
+local function anonymize_handler(opts)
+ load_config(opts)
+ rspamd_url.init(rspamd_config:get_tld_path())
+
+ for _, fname in ipairs(opts.file) do
+ local task = load_task(opts, fname)
+ local newline_s = newline(task)
+
+ local rewrite = lua_mime.anonymize_message(task, opts) or {}
+
+ for _, o in ipairs(rewrite.out) do
+ if type(o) == 'string' then
+ io.write(o)
+ io.write(newline_s)
+ elseif type(o) == 'table' then
+ io.flush()
+ if type(o[1]) == 'string' then
+ io.write(o[1])
+ else
+ o[1]:save_in_file(1)
+ end
+
+ if o[2] then
+ io.write(newline_s)
+ end
+ else
+ o:save_in_file(1)
+ io.write(newline_s)
+ end
+ end
+
+ task:destroy() -- No automatic dtor
+ end
+end
+
-- Strips directories and .extensions (if present) from a filepath
local function filename_only(filepath)
local filename = filepath:match(".*%/([^%.]+)")
@@ -1076,6 +1126,8 @@ local function handler(args)
sign_handler(opts)
elseif command == 'dump' then
dump_handler(opts)
+ elseif command == 'anonymize' then
+ anonymize_handler(opts)
else
parser:error('command %s is not implemented', command)
end