You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

misc.lua 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. -- Misc rules
  14. local E = {}
  15. local fun = require "fun"
  16. local rspamd_util = require "rspamd_util"
  17. local rspamd_parsers = require "rspamd_parsers"
  18. local rspamd_regexp = require "rspamd_regexp"
  19. local lua_util = require "lua_util"
  20. local bit = require "bit"
  21. local rspamd_url = require "rspamd_url"
  22. local url_flags_tab = rspamd_url.flags
  23. -- Different text parts
  24. rspamd_config.R_PARTS_DIFFER = {
  25. callback = function(task)
  26. local distance = task:get_mempool():get_variable('parts_distance', 'double')
  27. if distance then
  28. local nd = tonumber(distance)
  29. -- ND is relation of different words to total words
  30. if nd >= 0.5 then
  31. local tw = task:get_mempool():get_variable('total_words', 'int')
  32. if tw then
  33. local score
  34. if tw > 30 then
  35. -- We are confident about difference
  36. score = (nd - 0.5) * 2.0
  37. else
  38. -- We are not so confident about difference
  39. score = (nd - 0.5)
  40. end
  41. task:insert_result('R_PARTS_DIFFER', score,
  42. string.format('%.1f%%', tostring(100.0 * nd)))
  43. end
  44. end
  45. end
  46. return false
  47. end,
  48. score = 1.0,
  49. description = 'Text and HTML parts differ',
  50. group = 'body'
  51. }
  52. -- Date issues
  53. local date_id = rspamd_config:register_symbol({
  54. name = 'DATE_CB',
  55. type = 'callback,mime',
  56. callback = function(task)
  57. local date_time = task:get_header('Date')
  58. if date_time == nil or date_time == '' then
  59. task:insert_result('MISSING_DATE', 1.0)
  60. return
  61. end
  62. local dm, err = rspamd_parsers.parse_smtp_date(date_time)
  63. if err then
  64. task:insert_result('INVALID_DATE', 1.0)
  65. return
  66. end
  67. local dt = task:get_date({ format = 'connect', gmt = true })
  68. local date_diff = dt - dm
  69. if date_diff > 86400 then
  70. -- Older than a day
  71. task:insert_result('DATE_IN_PAST', 1.0, tostring(math.floor(date_diff / 3600)))
  72. elseif -date_diff > 7200 then
  73. -- More than 2 hours in the future
  74. task:insert_result('DATE_IN_FUTURE', 1.0, tostring(math.floor(-date_diff / 3600)))
  75. end
  76. end
  77. })
  78. rspamd_config:register_symbol({
  79. name = 'MISSING_DATE',
  80. score = 1.0,
  81. description = 'Date header is missing',
  82. group = 'headers',
  83. type = 'virtual',
  84. parent = date_id,
  85. })
  86. rspamd_config:register_symbol({
  87. name = 'INVALID_DATE',
  88. score = 1.5,
  89. description = 'Malformed Date header',
  90. group = 'headers',
  91. type = 'virtual',
  92. parent = date_id,
  93. })
  94. rspamd_config:register_symbol({
  95. name = 'DATE_IN_FUTURE',
  96. score = 4.0,
  97. description = 'Message date is in the future',
  98. group = 'headers',
  99. type = 'virtual',
  100. parent = date_id,
  101. })
  102. rspamd_config:register_symbol({
  103. name = 'DATE_IN_PAST',
  104. score = 1.0,
  105. description = 'Message date is in the past',
  106. group = 'headers',
  107. type = 'virtual',
  108. parent = date_id,
  109. })
  110. local obscured_id = rspamd_config:register_symbol {
  111. callback = function(task)
  112. local susp_urls = task:get_urls_filtered({ 'obscured', 'zw_spaces' })
  113. if susp_urls and susp_urls[1] then
  114. local obs_flag = url_flags_tab.obscured
  115. local zw_flag = url_flags_tab.zw_spaces
  116. for _, u in ipairs(susp_urls) do
  117. local fl = u:get_flags_num()
  118. if bit.band(fl, obs_flag) ~= 0 then
  119. task:insert_result('R_SUSPICIOUS_URL', 1.0, u:get_host())
  120. end
  121. if bit.band(fl, zw_flag) ~= 0 then
  122. task:insert_result('ZERO_WIDTH_SPACE_URL', 1.0, u:get_host())
  123. end
  124. end
  125. end
  126. return false
  127. end,
  128. name = 'R_SUSPICIOUS_URL',
  129. score = 5.0,
  130. one_shot = true,
  131. description = 'A message has been identified to contain an obfuscated or suspicious URL',
  132. group = 'url'
  133. }
  134. rspamd_config:register_symbol {
  135. type = 'virtual',
  136. name = 'ZERO_WIDTH_SPACE_URL',
  137. score = 7.0,
  138. one_shot = true,
  139. description = 'Zero width space in URL',
  140. group = 'url',
  141. parent = obscured_id,
  142. }
  143. rspamd_config.ENVFROM_PRVS = {
  144. callback = function(task)
  145. --[[
  146. Detect PRVS/BATV addresses to avoid FORGED_SENDER
  147. https://en.wikipedia.org/wiki/Bounce_Address_Tag_Validation
  148. Signature syntax:
  149. prvs=TAG=USER@example.com BATV draft (https://tools.ietf.org/html/draft-levine-smtp-batv-01)
  150. prvs=USER=TAG@example.com
  151. btv1==TAG==USER@example.com Barracuda appliance
  152. msprvs1=TAG=USER@example.com Sparkpost email delivery service
  153. ]]--
  154. if not (task:has_from(1) and task:has_from(2)) then
  155. return false
  156. end
  157. local envfrom = task:get_from(1)
  158. local re_text = '^(?:(prvs|msprvs1)=([^=]+)=|btv1==[^=]+==)(.+@(.+))$'
  159. local re = rspamd_regexp.create_cached(re_text)
  160. local c = re:search(envfrom[1].addr:lower(), false, true)
  161. if not c then
  162. return false
  163. end
  164. local ef = c[1][4]
  165. -- See if it matches the From header
  166. local from = task:get_from(2)
  167. if ef == from[1].addr:lower() then
  168. return true
  169. end
  170. -- Check for prvs=USER=TAG@example.com
  171. local t = c[1][2]
  172. if t == 'prvs' then
  173. local efr = c[1][3] .. '@' .. c[1][5]
  174. if efr == from[1].addr:lower() then
  175. return true
  176. end
  177. end
  178. return false
  179. end,
  180. score = 0.0,
  181. description = "Envelope From is a PRVS address that matches the From address",
  182. group = 'headers',
  183. type = 'mime',
  184. }
  185. rspamd_config.ENVFROM_VERP = {
  186. callback = function(task)
  187. if not (task:has_from(1) and task:has_recipients(1)) then
  188. return false
  189. end
  190. local envfrom = task:get_from(1)
  191. local envrcpts = task:get_recipients(1)
  192. -- VERP only works for single recipient messages
  193. if #envrcpts > 1 then
  194. return false
  195. end
  196. -- Get recipient and compute VERP address
  197. local rcpt = envrcpts[1].addr:lower()
  198. local verp = rcpt:gsub('@', '=')
  199. -- Get the user portion of the envfrom
  200. local ef_user = envfrom[1].user:lower()
  201. -- See if the VERP representation of the recipient appears in it
  202. if ef_user:find(verp, 1, true)
  203. and not ef_user:find('+caf_=' .. verp, 1, true) -- Google Forwarding
  204. and not ef_user:find('^srs[01]=') -- SRS
  205. then
  206. return true
  207. end
  208. return false
  209. end,
  210. score = 0.0,
  211. description = "Envelope From is a VERP address",
  212. group = "headers",
  213. type = 'mime',
  214. }
  215. local check_rcvd = rspamd_config:register_symbol {
  216. name = 'CHECK_RCVD',
  217. group = 'headers',
  218. callback = function(task)
  219. local rcvds = task:get_received_headers()
  220. if not rcvds or #rcvds == 0 then
  221. return false
  222. end
  223. local all_tls = fun.all(function(rc)
  224. return rc.flags and rc.flags['ssl']
  225. end, fun.filter(function(rc)
  226. return rc.by_hostname and rc.by_hostname ~= 'localhost'
  227. end, rcvds))
  228. -- See if only the last hop was encrypted
  229. if all_tls then
  230. task:insert_result('RCVD_TLS_ALL', 1.0)
  231. else
  232. local rcvd = rcvds[1]
  233. if rcvd.by_hostname and rcvd.by_hostname == 'localhost' then
  234. -- Ignore artificial header from Rmilter
  235. rcvd = rcvds[2] or {}
  236. end
  237. if rcvd.flags and rcvd.flags['ssl'] then
  238. task:insert_result('RCVD_TLS_LAST', 1.0)
  239. else
  240. task:insert_result('RCVD_NO_TLS_LAST', 1.0)
  241. end
  242. end
  243. local auth = fun.any(function(rc)
  244. return rc.flags and rc.flags['authenticated']
  245. end, rcvds)
  246. if auth then
  247. task:insert_result('RCVD_VIA_SMTP_AUTH', 1.0)
  248. end
  249. end,
  250. type = 'callback,mime',
  251. }
  252. rspamd_config:register_symbol {
  253. type = 'virtual',
  254. parent = check_rcvd,
  255. name = 'RCVD_TLS_ALL',
  256. description = 'All hops used encrypted transports',
  257. score = 0.0,
  258. group = 'headers'
  259. }
  260. rspamd_config:register_symbol {
  261. type = 'virtual',
  262. parent = check_rcvd,
  263. name = 'RCVD_TLS_LAST',
  264. description = 'Last hop used encrypted transports',
  265. score = 0.0,
  266. group = 'headers'
  267. }
  268. rspamd_config:register_symbol {
  269. type = 'virtual',
  270. parent = check_rcvd,
  271. name = 'RCVD_NO_TLS_LAST',
  272. description = 'Last hop did not use encrypted transports',
  273. score = 0.1,
  274. group = 'headers'
  275. }
  276. rspamd_config:register_symbol {
  277. type = 'virtual',
  278. parent = check_rcvd,
  279. name = 'RCVD_VIA_SMTP_AUTH',
  280. -- NB This does not mean sender was authenticated; see task:get_user()
  281. description = 'Authenticated hand-off was seen in Received headers',
  282. score = 0.0,
  283. group = 'headers'
  284. }
  285. rspamd_config.RCVD_HELO_USER = {
  286. callback = function(task)
  287. -- Check HELO argument from MTA
  288. local helo = task:get_helo()
  289. if (helo and helo:lower():find('^user$')) then
  290. return true
  291. end
  292. -- Check Received headers
  293. local rcvds = task:get_header_full('Received')
  294. if not rcvds then
  295. return false
  296. end
  297. for _, rcvd in ipairs(rcvds) do
  298. local r = rcvd['decoded']:lower()
  299. if (r:find("^%s*from%suser%s")) then
  300. return true
  301. end
  302. if (r:find("helo[%s=]user[%s%)]")) then
  303. return true
  304. end
  305. end
  306. end,
  307. description = 'HELO User spam pattern',
  308. group = 'headers',
  309. type = 'mime',
  310. score = 3.0
  311. }
  312. rspamd_config.URI_COUNT_ODD = {
  313. callback = function(task)
  314. local ct = task:get_header('Content-Type')
  315. if (ct and ct:lower():find('^multipart/alternative')) then
  316. local urls = task:get_urls_filtered(nil, { 'subject', 'html_displayed', 'special' }) or {}
  317. local nurls = fun.foldl(function(acc, val)
  318. return acc + val:get_count()
  319. end, 0, urls)
  320. if nurls % 2 == 1 then
  321. return true, 1.0, tostring(nurls)
  322. end
  323. end
  324. end,
  325. description = 'Odd number of URIs in multipart/alternative message',
  326. score = 1.0,
  327. group = 'url',
  328. }
  329. rspamd_config.HAS_ATTACHMENT = {
  330. callback = function(task)
  331. local parts = task:get_parts()
  332. if parts and #parts > 1 then
  333. for _, p in ipairs(parts) do
  334. local cd = p:get_header('Content-Disposition')
  335. if (cd and cd:lower():match('^attachment')) then
  336. return true
  337. end
  338. end
  339. end
  340. end,
  341. description = 'Message contains attachments',
  342. group = 'body',
  343. }
  344. -- Requires freemail maps loaded in multimap
  345. local function freemail_reply_neq_from(task)
  346. if not task:has_symbol('FREEMAIL_REPLYTO') or not task:has_symbol('FREEMAIL_FROM') then
  347. return false
  348. end
  349. local frt = task:get_symbol('FREEMAIL_REPLYTO')
  350. local ff = task:get_symbol('FREEMAIL_FROM')
  351. local frt_opts = frt[1]['options']
  352. local ff_opts = ff[1]['options']
  353. return (frt_opts and ff_opts and frt_opts[1] ~= ff_opts[1])
  354. end
  355. rspamd_config:register_symbol({
  356. name = 'FREEMAIL_REPLYTO_NEQ_FROM_DOM',
  357. callback = freemail_reply_neq_from,
  358. description = 'The From and Reply-To addresses in the email are from different freemail services',
  359. score = 3.0,
  360. group = 'headers',
  361. })
  362. rspamd_config:register_dependency('FREEMAIL_REPLYTO_NEQ_FROM_DOM', 'FREEMAIL_REPLYTO')
  363. rspamd_config:register_dependency('FREEMAIL_REPLYTO_NEQ_FROM_DOM', 'FREEMAIL_FROM')
  364. rspamd_config.OMOGRAPH_URL = {
  365. callback = function(task)
  366. local urls = task:get_urls()
  367. if urls then
  368. local bad_omographs = 0
  369. local single_bad_omograps = 0
  370. local bad_urls = {}
  371. local seen = {}
  372. fun.each(function(u)
  373. if u:is_phished() then
  374. local h1 = u:get_host()
  375. local h2 = u:get_phished()
  376. if h2 then
  377. -- Due to changes of the phished flag in 2.8
  378. h2 = h2:get_host()
  379. end
  380. if h1 and h2 then
  381. local selt = string.format('%s->%s', h1, h2)
  382. if not seen[selt] and rspamd_util.is_utf_spoofed(h1, h2) then
  383. bad_urls[#bad_urls + 1] = selt
  384. bad_omographs = bad_omographs + 1
  385. end
  386. seen[selt] = true
  387. end
  388. end
  389. if not u:is_html_displayed() then
  390. local h = u:get_tld()
  391. if h then
  392. if not seen[h] and rspamd_util.is_utf_spoofed(h) then
  393. bad_urls[#bad_urls + 1] = h
  394. single_bad_omograps = single_bad_omograps + 1
  395. end
  396. seen[h] = true
  397. end
  398. end
  399. end, urls)
  400. if bad_omographs > 0 then
  401. return true, 1.0, bad_urls
  402. elseif single_bad_omograps > 0 then
  403. return true, 0.5, bad_urls
  404. end
  405. end
  406. return false
  407. end,
  408. score = 5.0,
  409. group = 'url',
  410. description = 'URL contains both latin and non-latin characters'
  411. }
  412. rspamd_config.URL_IN_SUBJECT = {
  413. callback = function(task)
  414. local urls = task:get_urls()
  415. if urls then
  416. for _, u in ipairs(urls) do
  417. local flags = u:get_flags()
  418. if flags.subject then
  419. if flags.schemaless then
  420. return true, 0.1, u:get_host()
  421. end
  422. local subject = task:get_subject()
  423. if subject then
  424. if tostring(u) == subject then
  425. return true, 1.0, u:get_host()
  426. end
  427. end
  428. return true, 0.25, u:get_host()
  429. end
  430. end
  431. end
  432. return false
  433. end,
  434. score = 4.0,
  435. group = 'subject',
  436. type = 'mime',
  437. description = 'Subject contains URL'
  438. }
  439. local aliases_id = rspamd_config:register_symbol {
  440. type = 'prefilter',
  441. name = 'EMAIL_PLUS_ALIASES',
  442. callback = function(task)
  443. local function check_from(type)
  444. if task:has_from(type) then
  445. local addr = task:get_from(type)[1]
  446. local na, tags = lua_util.remove_email_aliases(addr)
  447. if na then
  448. task:set_from(type, addr, 'alias')
  449. task:insert_result('TAGGED_FROM', 1.0, fun.totable(
  450. fun.filter(function(t)
  451. return t and #t > 0
  452. end, tags)))
  453. end
  454. end
  455. end
  456. check_from('smtp')
  457. check_from('mime')
  458. local function check_rcpt(type)
  459. if task:has_recipients(type) then
  460. local modified = false
  461. local all_tags = {}
  462. local addrs = task:get_recipients(type)
  463. for _, addr in ipairs(addrs) do
  464. local na, tags = lua_util.remove_email_aliases(addr)
  465. if na then
  466. modified = true
  467. fun.each(function(t)
  468. table.insert(all_tags, t)
  469. end,
  470. fun.filter(function(t)
  471. return t and #t > 0
  472. end, tags))
  473. end
  474. end
  475. if modified then
  476. task:set_recipients(type, addrs, 'alias')
  477. task:insert_result('TAGGED_RCPT', 1.0, all_tags)
  478. end
  479. end
  480. end
  481. check_rcpt('smtp')
  482. check_rcpt('mime')
  483. end,
  484. priority = lua_util.symbols_priorities.top + 1,
  485. description = 'Removes plus aliases from the email',
  486. group = 'headers',
  487. }
  488. rspamd_config:register_symbol {
  489. type = 'virtual',
  490. parent = aliases_id,
  491. name = 'TAGGED_RCPT',
  492. description = 'SMTP recipients have plus tags',
  493. group = 'headers',
  494. score = 0.0,
  495. }
  496. rspamd_config:register_symbol {
  497. type = 'virtual',
  498. parent = aliases_id,
  499. name = 'TAGGED_FROM',
  500. description = 'SMTP from has plus tags',
  501. group = 'headers',
  502. score = 0.0,
  503. }
  504. local check_from_display_name = rspamd_config:register_symbol {
  505. type = 'callback,mime',
  506. name = 'FROM_DISPLAY_CALLBACK',
  507. callback = function(task)
  508. local from = task:get_from(2)
  509. if not (from and from[1] and from[1].name) then
  510. return false
  511. end
  512. -- See if we can parse an email address from the name
  513. local parsed = rspamd_parsers.parse_mail_address(from[1].name, task:get_mempool())
  514. if not parsed then
  515. return false
  516. end
  517. if not (parsed[1] and parsed[1]['addr']) then
  518. return false
  519. end
  520. -- Make sure we did not mistake e.g. <something>@<name> for an email address
  521. if not parsed[1]['domain'] or not parsed[1]['domain']:find('%.') then
  522. return false
  523. end
  524. -- See if the parsed domains differ
  525. if not rspamd_util.strequal_caseless(from[1]['domain'], parsed[1]['domain']) then
  526. -- See if the destination domain is the same as the spoof
  527. local mto = task:get_recipients(2)
  528. local sto = task:get_recipients(1)
  529. if mto then
  530. for _, to in ipairs(mto) do
  531. if to['domain'] ~= '' and rspamd_util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  532. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  533. return false
  534. end
  535. end
  536. end
  537. if sto then
  538. for _, to in ipairs(sto) do
  539. if to['domain'] ~= '' and rspamd_util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  540. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  541. return false
  542. end
  543. end
  544. end
  545. task:insert_result('FROM_NEQ_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  546. end
  547. return false
  548. end,
  549. group = 'headers',
  550. }
  551. rspamd_config:register_symbol {
  552. type = 'virtual',
  553. parent = check_from_display_name,
  554. name = 'SPOOF_DISPLAY_NAME',
  555. description = 'Display name is being used to spoof and trick the recipient',
  556. group = 'headers',
  557. score = 8.0,
  558. }
  559. rspamd_config:register_symbol {
  560. type = 'virtual',
  561. parent = check_from_display_name,
  562. name = 'FROM_NEQ_DISPLAY_NAME',
  563. group = 'headers',
  564. description = 'Display name contains an email address different to the From address',
  565. score = 4.0,
  566. }
  567. rspamd_config.SPOOF_REPLYTO = {
  568. callback = function(task)
  569. -- First check for a Reply-To header
  570. local rt = task:get_header_full('Reply-To')
  571. if not rt or not rt[1] then
  572. return false
  573. end
  574. -- Get From and To headers
  575. rt = rt[1]['value']
  576. local from = task:get_from(2)
  577. local to = task:get_recipients(2)
  578. if not (from and from[1] and from[1].addr) then
  579. return false
  580. end
  581. if (to and to[1] and to[1].addr) then
  582. -- Handle common case for Web Contact forms of From = To
  583. if rspamd_util.strequal_caseless(from[1].addr, to[1].addr) then
  584. return false
  585. end
  586. end
  587. -- SMTP recipients must contain From domain
  588. to = task:get_recipients(1)
  589. if not to then
  590. return false
  591. end
  592. -- Try mitigate some possible FPs on mailing list posts
  593. if #to == 1 and rspamd_util.strequal_caseless(to[1].addr, from[1].addr) then
  594. return false
  595. end
  596. local found_fromdom = false
  597. for _, t in ipairs(to) do
  598. if rspamd_util.strequal_caseless(t.domain, from[1].domain) then
  599. found_fromdom = true
  600. break
  601. end
  602. end
  603. if not found_fromdom then
  604. return false
  605. end
  606. -- Parse Reply-To header
  607. local parsed = ((rspamd_parsers.parse_mail_address(rt, task:get_mempool()) or E)[1] or E).domain
  608. if not parsed then
  609. return false
  610. end
  611. -- Reply-To domain must be different to From domain
  612. if not rspamd_util.strequal_caseless(parsed, from[1].domain) then
  613. return true, from[1].domain, parsed
  614. end
  615. return false
  616. end,
  617. group = 'headers',
  618. type = 'mime',
  619. description = 'Reply-To is being used to spoof and trick the recipient to send an off-domain reply',
  620. score = 6.0
  621. }
  622. rspamd_config.INFO_TO_INFO_LU = {
  623. callback = function(task)
  624. if not task:has_header('List-Unsubscribe') then
  625. return false
  626. end
  627. local from = task:get_from('mime')
  628. if not (from and from[1] and rspamd_util.strequal_caseless(from[1].user, 'info')) then
  629. return false
  630. end
  631. local to = task:get_recipients('smtp')
  632. if not to then
  633. return false
  634. end
  635. local found = false
  636. for _, r in ipairs(to) do
  637. if rspamd_util.strequal_caseless(r['user'], 'info') then
  638. found = true
  639. end
  640. end
  641. if found then
  642. return true
  643. end
  644. return false
  645. end,
  646. description = 'info@ From/To address with List-Unsubscribe headers',
  647. group = 'headers',
  648. score = 2.0,
  649. type = 'mime',
  650. }
  651. -- Detects bad content-transfer-encoding for text parts
  652. rspamd_config.R_BAD_CTE_7BIT = {
  653. callback = function(task)
  654. local tp = task:get_text_parts() or {}
  655. for _, p in ipairs(tp) do
  656. local cte = p:get_mimepart():get_cte() or ''
  657. if cte ~= '8bit' and p:has_8bit_raw() then
  658. local _, _, attrs = p:get_mimepart():get_type_full()
  659. local mul = 1.0
  660. local params = { cte }
  661. if attrs then
  662. if attrs.charset and attrs.charset:lower() == "utf-8" then
  663. -- Penalise rule as people don't know that utf8 is surprisingly
  664. -- eight bit encoding
  665. mul = 0.3
  666. table.insert(params, "utf8")
  667. end
  668. end
  669. return true, mul, params
  670. end
  671. end
  672. return false
  673. end,
  674. score = 3.5,
  675. description = 'Detects bad Content-Transfer-Encoding for text parts',
  676. group = 'headers',
  677. type = 'mime',
  678. }
  679. local check_encrypted_name = rspamd_config:register_symbol {
  680. name = 'BOGUS_ENCRYPTED_AND_TEXT',
  681. callback = function(task)
  682. local parts = task:get_parts() or {}
  683. local seen_encrypted, seen_text
  684. local opts = {}
  685. local function check_part(part)
  686. if part:is_multipart() then
  687. local children = part:get_children() or {}
  688. local text_kids = {}
  689. for _, cld in ipairs(children) do
  690. if cld:is_multipart() then
  691. check_part(cld)
  692. elseif cld:is_text() then
  693. seen_text = true
  694. text_kids[#text_kids + 1] = cld
  695. else
  696. local type, subtype, _ = cld:get_type_full()
  697. if type:lower() == 'application' then
  698. if string.find(subtype:lower(), 'pkcs7%-mime') then
  699. -- S/MIME encrypted part
  700. seen_encrypted = true
  701. table.insert(opts, 'smime part')
  702. task:insert_result('ENCRYPTED_SMIME', 1.0)
  703. elseif string.find(subtype:lower(), 'pkcs7%-signature') then
  704. task:insert_result('SIGNED_SMIME', 1.0)
  705. elseif string.find(subtype:lower(), 'pgp%-encrypted') then
  706. -- PGP/GnuPG encrypted part
  707. seen_encrypted = true
  708. table.insert(opts, 'pgp part')
  709. task:insert_result('ENCRYPTED_PGP', 1.0)
  710. elseif string.find(subtype:lower(), 'pgp%-signature') then
  711. task:insert_result('SIGNED_PGP', 1.0)
  712. end
  713. end
  714. end
  715. if seen_text and seen_encrypted then
  716. -- Ensure that our seen text is not really part of pgp #3205
  717. for _, tp in ipairs(text_kids) do
  718. local t, _ = tp:get_type()
  719. seen_text = false -- reset temporary
  720. if t and t == 'text' then
  721. seen_text = true
  722. break
  723. end
  724. end
  725. end
  726. end
  727. end
  728. end
  729. for _, part in ipairs(parts) do
  730. check_part(part)
  731. end
  732. if seen_text and seen_encrypted then
  733. return true, 1.0, opts
  734. end
  735. return false
  736. end,
  737. score = 10.0,
  738. description = 'Bogus mix of encrypted and text/html payloads',
  739. group = 'mime_types',
  740. }
  741. rspamd_config:register_symbol {
  742. type = 'virtual',
  743. parent = check_encrypted_name,
  744. name = 'ENCRYPTED_PGP',
  745. description = 'Message is encrypted with PGP',
  746. group = 'mime_types',
  747. score = -0.5,
  748. one_shot = true
  749. }
  750. rspamd_config:register_symbol {
  751. type = 'virtual',
  752. parent = check_encrypted_name,
  753. name = 'ENCRYPTED_SMIME',
  754. description = 'Message is encrypted with S/MIME',
  755. group = 'mime_types',
  756. score = -0.5,
  757. one_shot = true
  758. }
  759. rspamd_config:register_symbol {
  760. type = 'virtual',
  761. parent = check_encrypted_name,
  762. name = 'SIGNED_PGP',
  763. description = 'Message is signed with PGP',
  764. group = 'mime_types',
  765. score = -2.0,
  766. one_shot = true
  767. }
  768. rspamd_config:register_symbol {
  769. type = 'virtual',
  770. parent = check_encrypted_name,
  771. name = 'SIGNED_SMIME',
  772. description = 'Message is signed with S/MIME',
  773. group = 'mime_types',
  774. score = -2.0,
  775. one_shot = true
  776. }
  777. rspamd_config.COMPLETELY_EMPTY = {
  778. callback = function(task)
  779. return (task:get_size() == 0)
  780. end,
  781. flags = 'empty',
  782. group = 'blankspam',
  783. score = 15
  784. }