diff options
Diffstat (limited to 'rules/misc.lua')
-rw-r--r-- | rules/misc.lua | 582 |
1 files changed, 582 insertions, 0 deletions
diff --git a/rules/misc.lua b/rules/misc.lua index 89ba97fb1..9e30fe23f 100644 --- a/rules/misc.lua +++ b/rules/misc.lua @@ -395,3 +395,585 @@ rspamd_config.MISSING_FROM = { group = 'header', description = 'Missing From: header' } + +rspamd_config.RCVD_HELO_USER = { + callback = function (task) + -- Check HELO argument from MTA + local helo = task:get_helo() + if (helo and helo:lower():find('^user$')) then + return true + end + -- Check Received headers + local rcvds = task:get_header_full('Received') + if not rcvds then return false end + for _, rcvd in ipairs(rcvds) do + local r = rcvd['decoded']:lower() + if (r:find("^%s*from%suser%s")) then return true end + if (r:find("helo[%s=]user[%s%)]")) then return true end + end + end, + description = 'HELO User spam pattern', + score = 3.0 +} + +rspamd_config.URI_COUNT_ODD = { + callback = function (task) + local ct = task:get_header('Content-Type') + if (ct and ct:lower():find('^multipart/alternative')) then + local urls = task:get_urls() + if (urls and (#urls % 2 == 1)) then + return true + end + end + end, + description = 'Odd number of URIs in multipart/alternative message', + score = 1.0 +} + +rspamd_config.HAS_ATTACHMENT = { + callback = function (task) + local parts = task:get_parts() + if parts and #parts > 1 then + for _, p in ipairs(parts) do + local cd = p:get_header('Content-Disposition') + if (cd and cd:lower():match('^attachment')) then + return true + end + end + end + end, + description = 'Message contains attachments' +} + +rspamd_config.MV_CASE = { + callback = function (task) + local mv = task:get_header('Mime-Version', true) + if (mv) then return true end + end, + description = 'Mime-Version .vs. MIME-Version', + score = 0.5 +} + +rspamd_config.FAKE_REPLY = { + callback = function (task) + local subject = task:get_header('Subject') + if (subject and subject:lower():find('^re:')) then + local ref = task:get_header('References') + local rt = task:get_header('In-Reply-To') + if (not (ref or rt)) then return true end + end + return false + end, + description = 'Fake reply', + score = 1.0 +} + +rspamd_config.CHECK_FROM = { + callback = function(task) + local envfrom = task:get_from(1) + local from = task:get_from(2) + if (from and from[1] and not from[1].name) then + task:insert_result('FROM_NO_DN', 1.0) + elseif (from and from[1] and from[1].name and + from[1].name:lower() == from[1].addr:lower()) then + task:insert_result('FROM_DN_EQ_ADDR', 1.0) + elseif (from and from[1] and from[1].name) then + task:insert_result('FROM_HAS_DN', 1.0) + -- Look for Mr/Mrs/Dr titles + local n = from[1].name:lower() + if (n:find('^mrs?[%.%s]') or n:find('^dr[%.%s]')) then + task:insert_result('FROM_NAME_HAS_TITLE', 1.0) + end + end + if (envfrom and from and envfrom[1] and from[1] and + envfrom[1].addr:lower() == from[1].addr:lower()) + then + task:insert_result('FROM_EQ_ENVFROM', 1.0) + elseif (envfrom and envfrom[1] and envfrom[1].addr) then + task:insert_result('FROM_NEQ_ENVFROM', 1.0, from[1].addr, envfrom[1].addr) + end + + local to = task:get_recipients(2) + if not (to and to[1]) then return false end + -- Check if FROM == TO + if (#to == 1 and to[1].addr:lower() == from[1].addr:lower()) then + task:insert_result('TO_EQ_FROM', 1.0) + elseif (#to == 1 and to[1].domain:lower() == from[1].domain:lower()) then + task:insert_result('TO_DOM_EQ_FROM_DOM', 1.0) + end + end +} + +rspamd_config.FROM_NO_DN = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From header does not have a display name', + score = 0.0 +} + +rspamd_config.FROM_DN_EQ_ADDR = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From header display name is the same as the address', + score = 1.0 +} + +rspamd_config.FROM_HAS_DN = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From header has a display name', + score = 0.0 +} + +rspamd_config.FROM_NAME_HAS_TITLE = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From header display name has a title (Mr/Mrs/Dr)', + score = 1.0 +} + +rspamd_config.FROM_EQ_ENVFROM = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From address is the same as the envelope', + score = 0.0 +} + +rspamd_config.FROM_NEQ_ENVFROM = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'From address is different to the envelope', + score = 0.0 +} + +rspamd_config.TO_EQ_FROM = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'To address matches the From address', + score = 0.0 +} + +rspamd_config.TO_DOM_EQ_FROM_DOM = { + callback = function() + -- Set by CHECK_FROM + end, + description = 'To domain is the same as the From domain', + score = 0.0 +} + + +rspamd_config.CHECK_TO_CC = { + callback = function(task) + local rcpts = task:get_recipients(1) + local to = task:get_recipients(2) + local to_match_envrcpt = 0 + if (not to) then return false end + -- Add symbol for recipient count + if (#to > 50) then + task:insert_result('RCPT_COUNT_GT_50', 1.0) + else + task:insert_result('RCPT_COUNT_' .. #to, 1.0) + end + -- Check for display names + local to_dn_count = 0 + local to_dn_eq_addr_count = 0 + for _, toa in ipairs(to) do + -- To: Recipients <noreply@dropbox.com> + if (toa['name'] and (toa['name']:lower() == 'recipient' + or toa['name']:lower() == 'recipients')) then + task:insert_result('TO_DN_RECIPIENTS', 1.0) + end + if (toa['name'] and toa['name']:lower() == toa['addr']:lower()) then + to_dn_eq_addr_count = to_dn_eq_addr_count + 1 + elseif (toa['name']) then + to_dn_count = to_dn_count + 1 + end + -- See if header recipients match envrcpts + if (rcpts) then + for _, rcpt in ipairs(rcpts) do + if (toa and toa['addr'] and rcpt and rcpt['addr'] and + rcpt['addr']:lower() == toa['addr']:lower()) + then + to_match_envrcpt = to_match_envrcpt + 1 + end + end + end + end + if (to_dn_count == 0 and to_dn_eq_addr_count == 0) then + task:insert_result('TO_DN_NONE', 1.0) + elseif (to_dn_count == #to) then + task:insert_result('TO_DN_ALL', 1.0) + elseif (to_dn_count > 0) then + task:insert_result('TO_DN_SOME', 1.0) + end + if (to_dn_eq_addr_count == #to) then + task:insert_result('TO_DN_EQ_ADDR_ALL', 1.0) + elseif (to_dn_eq_addr_count > 0) then + task:insert_result('TO_DN_EQ_ADDR_SOME', 1.0) + end + + -- See if header recipients match envelope recipients + if (to_match_envrcpt == #to) then + task:insert_result('TO_MATCH_ENVRCPT_ALL', 1.0) + elseif (to_match_envrcpt > 0) then + task:insert_result('TO_MATCH_ENVRCPT_SOME', 1.0) + end + end +} + +rspamd_config.TO_DN_RECIPIENTS = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'To header display name is "Recipients"', + score = 2.0 +} + +rspamd_config.TO_DN_NONE = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'None of the recipients have display names', + score = 0.0 +} + +rspamd_config.TO_DN_ALL = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'All of the recipients have display names', + score = 0.0 +} + +rspamd_config.TO_DN_SOME = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'Some of the recipients have display names', + score = 0.0 +} + +rspamd_config.TO_DN_EQ_ADDR_ALL = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'All of the recipients have display names that are the same as their address', + score = 0.0 +} + +rspamd_config.TO_DN_EQ_ADDR_SOME = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'Some of the recipients have display names that are the same as their address', + score = 0.0 +} + +rspamd_config.TO_MATCH_ENVRCPT_ALL = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'All of the recipients match the envelope', + score = 0.0 +} + +rspamd_config.TO_MATCH_ENVRCPT_SOME = { + callback = function() + -- Set by CHECK_TO_CC + end, + description = 'Some of the recipients match the envelope', + score = 0.0 +} + + +rspamd_config.CHECK_MID = { + callback = function (task) + local mid = task:get_header('Message-ID') + if not mid then return false end + -- Check for 'bare' IP addresses in RHS + if mid:find("@%d+%.%d+%.%d+%.%d+>$") then + task:insert_result('MID_BARE_IP', 1.0) + end + -- Check for non-FQDN RHS + if mid:find("@[^%.]+>?$") then + task:insert_result('MID_RHS_NOT_FQDN', 1.0) + end + -- Check for missing <>'s + if not mid:find('^<[^>]+>$') then + task:insert_result('MID_MISSING_BRACKETS', 1.0) + end + -- Check for IP literal in RHS + if mid:find("@%[%d+%.%d+%.%d+%.%d+%]") then + task:insert_result('MID_RHS_IP_LITERAL', 1.0) + end + -- Check From address atrributes against MID + local from = task:get_from(2) + if (from and from[1] and from[1].domain) then + local fd = from[1].domain:lower() + local _,_,md = mid:find("@([^>]+)>?$") + -- See if all or part of the From address + -- can be found in the Message-ID + if (mid:lower():find(from[1].addr:lower(),1,true)) then + task:insert_result('MID_CONTAINS_FROM', 1.0) + elseif (md and fd == md:lower()) then + task:insert_result('MID_RHS_MATCH_FROM', 1.0) + end + end + end +} + + +rspamd_config.MID_BARE_IP = { + callback = function() + -- Set by CHECK_MID + end, + description = 'Message-ID RHS is a bare IP address', + score = 2.0 +} + +rspamd_config.MID_RHS_NOT_FQDN = { + callback = function() + -- Set by CHECK_MID + end, + description = 'Message-ID RHS is not a fully-qualified domain name', + score = 0.5 +} + +rspamd_config.MID_MISSING_BRACKETS = { + callback = function() + -- Set by CHECK_MID + end, + description = 'Message-ID is missing <>\'s', + score = 0.5 +} + +rspamd_config.MID_RHS_IP_LITERAL = { + callback = function () + -- Set by CHECK_MID + end, + description = 'Message-ID RHS is an IP-literal', + score = 0.5 +} + +rspamd_config.MID_CONTAINS_FROM = { + callback = function () + -- Set by CHECK_MID + end, + description = 'Message-ID contains From address', + score = 1.0 +} + +rspamd_config.MID_RHS_MATCH_FROM = { + callback = function () + -- Set by CHECK_MID + end, + description = 'Message-ID RHS matches From domain', + score = 1.0 +} + +rspamd_config.CHECK_RECEIVED = { + callback = function (task) + local received = task:get_received_headers() + task:insert_result('RCVD_COUNT_' .. #received, 1.0) + end +} + +rspamd_config.HAS_X_PRIO = { + callback = function (task) + local xprio = task:get_header('X-Priority'); + if not xprio then return false end + local _,_,x = xprio:find('^%s?(%d+)'); + if (x) then + task:insert_result('HAS_X_PRIO_' .. x, 1.0) + end + end +} + +rspamd_config.CHECK_REPLYTO = { + callback = function (task) + local replyto = task:get_header('Reply-To') + if not replyto then return false end + local rt = util.parse_mail_address(replyto) + if not (rt and rt[1]) then + task:insert_result('REPLYTO_UNPARSEABLE', 1.0) + return false + else + task:insert_result('HAS_REPLYTO', 1.0) + end + + -- See if Reply-To matches From in some way + local from = task:get_from(2) + local from_h = task:get_header('From') + if not (from and from[1]) then return false end + if (from_h and from_h == replyto) then + -- From and Reply-To are identical + task:insert_result('REPLYTO_EQ_FROM', 1.0) + else + if (from and from[1]) then + -- See if From and Reply-To addresses match + if (from[1].addr:lower() == rt[1].addr:lower()) then + task:insert_result('REPLYTO_ADDR_EQ_FROM', 1.0) + elseif (from[1].domain:lower() == rt[1].addr:lower()) then + task:insert_result('REPLYTO_DOM_EQ_FROM_DOM', 1.0) + elseif (from[1].domain:lower() ~= rt[1].domain:lower()) then + task:insert_result('REPLYTO_DOM_NEQ_FROM_DOM', 1.0) + end + -- See if the Display Names match + if (from[1].name and rt[1].name and from[1].name:lower() == rt[1].name:lower()) then + task:insert_result('REPLYTO_DN_EQ_FROM_DN', 1.0) + end + end + end + end +} + +rspamd_config.REPLYTO_UNPARSEABLE = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To header could not be parsed', + score = 1.0 +} + +rspamd_config.HAS_REPLYTO = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Has Reply-To header', + score = 0.0 +} + +rspamd_config.REPLYTO_EQ_FROM = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To header is identical to From header', + score = 0.0 +} + +rspamd_config.REPLYTO_ADDR_EQ_FROM = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To address is the same as From', + score = 0.0 +} + +rspamd_config.REPLYTO_DOM_EQ_FROM_DOM = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To domain matches the From domain', + score = 0.0 +} + +rspamd_config.REPLYTO_DOM_NEQ_FROM_DOM = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To domain does not match the From domain', + score = 0.0 +} + +rspamd_config.REPLYTO_DN_EQ_FROM_DN = { + callback = function () + -- Set by CHECK_REPLYTO + end, + description = 'Reply-To display name matches From', + score = 0.0 +} + +rspamd_config.CHECK_MIME = { + callback = function (task) + local parts = task:get_parts() + if not parts then return false end + + -- Make sure there is a MIME-Version header + local mv = task:get_header('MIME-Version') + if (not mv) then + task:insert_result('MISSING_MIME_VERSION', 1.0) + end + + local found_ma = false + local found_plain = false + local found_html = false + + for _,p in ipairs(parts) do + local mtype,subtype = p:get_type() + local ctype = mtype:lower() .. '/' .. subtype:lower() + if (ctype == 'multipart/alternative') then + found_ma = true + end + if (ctype == 'text/plain') then + found_plain = true + end + if (ctype == 'text/html') then + found_html = true + end + end + + if (found_ma) then + if (not found_plain) then + task:insert_result('MIME_MA_MISSING_TEXT', 1.0) + end + if (not found_html) then + task:insert_result('MIME_MA_MISSING_HTML', 1.0) + end + end + end +} + +rspamd_config.MISSING_MIME_VERSION = { + callback = function () + -- Set by CHECK_MIME + end, + description = 'MIME-Version header is missing', + score = 2.0 +} + +rspamd_config.MIME_MA_MISSING_TEXT = { + callback = function () + -- Set by CHECK_MIME + end, + description = 'MIME multipart/alternative missing text/plain part', + score = 2.0 +} + +rspamd_config.MIME_NA_MISSING_HTML = { + callback = function () + -- Set by CHECK_MIME + end, + description = 'MIME multipart/alternative missing text/html part', + score = 2.0 +} + +-- Used to be called IS_LIST +rspamd_config.PREVIOUSLY_DELIVERED = { + callback = function(task) + if not task:has_recipients(2) then return false end + local to = task:get_recipients(2) + local rcvds = task:get_header_full('Received') + if not rcvds then return false end + for _, rcvd in ipairs(rcvds) do + local _,_,addr = rcvd['decoded']:lower():find("%sfor%s<(.-)>") + if addr then + for _, toa in ipairs(to) do + if toa and toa.addr:lower() == addr then + return true, addr + end + end + return false + end + end + end, + description = 'Message either to a list or was forwarded', + score = 0.0 +} + |