diff options
author | Steve Freegard <steve@stevefreegard.com> | 2016-11-21 12:55:14 +0000 |
---|---|---|
committer | Steve Freegard <steve@stevefreegard.com> | 2016-11-21 12:55:14 +0000 |
commit | 5c669479a0e0630f822929714332b615f11210a6 (patch) | |
tree | 80db15bb85dfc64df81c92b4369480eca9aafe2a | |
parent | 919cbd477d499804b17c87656a435db6067ca31e (diff) | |
download | rspamd-5c669479a0e0630f822929714332b615f11210a6.tar.gz rspamd-5c669479a0e0630f822929714332b615f11210a6.zip |
Rules updates
-rw-r--r-- | rules/misc.lua | 582 | ||||
-rw-r--r-- | rules/regexp/headers.lua | 91 | ||||
-rw-r--r-- | rules/regexp/misc.lua | 39 | ||||
-rw-r--r-- | rules/rspamd.lua | 1 |
4 files changed, 713 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 +} + diff --git a/rules/regexp/headers.lua b/rules/regexp/headers.lua index ef0adc6b1..e5bce8cea 100644 --- a/rules/regexp/headers.lua +++ b/rules/regexp/headers.lua @@ -255,6 +255,22 @@ reconf['CC_EXCESS_QP'] = { group = 'excessqp' } +local subj_encoded_b64 = 'Subject=/\\=\\?\\S+\\?B\\?/iX' +local subj_needs_mime = 'Subject=/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\xff]/Hr' +reconf['SUBJ_EXCESS_BASE64'] = { + re = string.format('%s & !%s', subj_encoded_b64, subj_needs_mime), + score = 1.5, + description = 'Subject is unnecessarily encoded in base64', + group = 'excessb64' +} + +local subj_encoded_qp = 'Subject=/\\=\\?\\S+\\?Q\\?/iX' +reconf['SUBJ_EXCESS_QP'] = { + re = string.format('%s & !%s', subj_encoded_qp, subj_needs_mime), + score = 1.2, + description = 'Subect is unnecessarily encoded in quoted-printable', + group = 'excessqp' +} -- Detect forged outlook headers -- OE X-Mailer header @@ -803,3 +819,78 @@ reconf['GOOGLE_FORWARDING_MID_BROKEN'] = { description = "Message had invalid Message-ID pre-forwarding", group = 'header' } + +reconf['CTE_CASE'] = { + re = 'Content-Transfer-Encoding=/^[78]BsX', + description = '[78]Bit .vs. [78]bit', + score = 0.5, + group = header' +} + +reconf['HAS_INTERSPIRE_SIG'] = { + re = string.format('((%s) & (%s) & (%s) & (%s)) | (%s)', + 'header_exists(X-Mailer-LID)', + 'header_exists(X-Mailer-RecptId)', + 'header_exists(X-Mailer-SID)', + 'header_exists(X-Mailer-Sent-By)', + 'List-Unsubscribe=/\\/unsubscribe\\.php\\?M=[^&]+&C=[^&]+&L=[^&]+&N=[^>]+>$/Xi'), + description = "Has Interspire fingerprint", + score = 3.0, + group = 'header' +} + +reconf['CT_EXTRA_SEMI'] = { + re = 'Content-Type=/;$/X', + description = 'Content-Type ends with a semi-colon', + score = 1.0, + group = 'header' +} + +reconf['SUBJECT_ENDS_EXCLAIM'] = { + re = 'Subject=/!\\s*$/H', + description = 'Subject ends with an exclaimation', + score = 1.0, + group = 'headers' +} + +reconf['SUBJECT_HAS_EXCLAIM'] = { + re = string.format('%s & !%s', 'Subject=/!/H', 'Subject=/!\\s*$/H'), + description = 'Subject contains an exclaimation', + score = 0.0, + group = 'headers' +} + +reconf['SUBJECT_ENDS_QUESTION'] = { + re = 'Subject=/\\?\\s*$/H', + description = 'Subject ends with a question', + score = 1.0, + group = 'headers' +} + +reconf['SUBJECT_HAS_QUESTION'] = { + re = string.format('%s & !%s', 'Subject=/\\?/H', 'Subject=/\\?\\s*$/H'), + description = 'Subject contains a question', + score = 0.0, + group = 'headers' +} + +reconf['SUBJECT_HAS_CURRENCY'] = { + re = 'Subject=/$€$¢¥₽/H', + description = 'Subject contains currency', + score = 1.0, + group = 'headers' +} + +reconf['SUBJECT_ENDS_SPACES'] = { + re = 'Subject=/\\s+$/H', + description = 'Subject ends with space characters', + score = 0.5, + group = 'headers' +} + +reconf['HAS_ORG_HEADER'] = { + re = string.format('%s || %s', 'header_exists(Organization)', 'header_exists(Organisation)'), + description = 'Has Organization header', + score = 0.0, + group = 'headers' +} diff --git a/rules/regexp/misc.lua b/rules/regexp/misc.lua new file mode 100644 index 000000000..a819ec729 --- /dev/null +++ b/rules/regexp/misc.lua @@ -0,0 +1,39 @@ +--[[ +Copyright (c) 2011-2016, Vsevolod Stakhov <vsevolod@highsecure.ru> + +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 reconf = config['regexp'] + +reconf['HTML_META_REFRESH_URL'] = { + -- Requires options { check_attachements = true; } + re = '/<meta\\s+http-equiv="refresh"\\s+content="\\d+;url=/{sa_raw_body}i', + description = "Has HTML Meta refresh URL", + score = 5.0 +} + +reconf['HAS_DATA_URI'] = { + -- Requires options { check_attachements = true; } + re = '/data:[^\\/]+\\/[^; ]+;base64,/{sa_raw_body}i', + description = "Has Data URI encoding" +} + +reconf['DATA_URI_OBFU'] = { + -- Requires options { check_attachements = true; } + re = '/data:text\\/(?:plain|html);base64,/{sa_raw_body}i', + description = "Uses Data URI encoding to obfuscate plain or HTML in base64", + score = 2.0 +} + diff --git a/rules/rspamd.lua b/rules/rspamd.lua index f5a5ed14e..b02e6b1af 100644 --- a/rules/rspamd.lua +++ b/rules/rspamd.lua @@ -25,6 +25,7 @@ dofile(local_rules .. '/regexp/headers.lua') dofile(local_rules .. '/regexp/lotto.lua') dofile(local_rules .. '/regexp/fraud.lua') dofile(local_rules .. '/regexp/drugs.lua') +dofile(local_rules .. '/regexp/misc.lua') dofile(local_rules .. '/regexp/upstream_spam_filters.lua') dofile(local_rules .. '/regexp/compromised_hosts.lua') dofile(local_rules .. '/html.lua') |