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 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  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 util = require "rspamd_util"
  17. local rspamd_parsers = require "rspamd_parsers"
  18. local rspamd_regexp = require "rspamd_regexp"
  19. local rspamd_lua_utils = 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 = 'Message date 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 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 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 = 'Obfuscated or suspicious URL has been found in a message',
  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 return false end
  162. local ef = c[1][4]
  163. -- See if it matches the From header
  164. local from = task:get_from(2)
  165. if ef == from[1].addr:lower() then
  166. return true
  167. end
  168. -- Check for prvs=USER=TAG@example.com
  169. local t = c[1][2]
  170. if t == 'prvs' then
  171. local efr = c[1][3] .. '@' .. c[1][5]
  172. if efr == from[1].addr:lower() then
  173. return true
  174. end
  175. end
  176. return false
  177. end,
  178. score = 0.0,
  179. description = "Envelope From is a PRVS address that matches the From address",
  180. group = 'headers',
  181. type = 'mime',
  182. }
  183. rspamd_config.ENVFROM_VERP = {
  184. callback = function (task)
  185. if not (task:has_from(1) and task:has_recipients(1)) then
  186. return false
  187. end
  188. local envfrom = task:get_from(1)
  189. local envrcpts = task:get_recipients(1)
  190. -- VERP only works for single recipient messages
  191. if #envrcpts > 1 then return false end
  192. -- Get recipient and compute VERP address
  193. local rcpt = envrcpts[1].addr:lower()
  194. local verp = rcpt:gsub('@','=')
  195. -- Get the user portion of the envfrom
  196. local ef_user = envfrom[1].user:lower()
  197. -- See if the VERP representation of the recipient appears in it
  198. if ef_user:find(verp, 1, true)
  199. and not ef_user:find('+caf_=' .. verp, 1, true) -- Google Forwarding
  200. and not ef_user:find('^srs[01]=') -- SRS
  201. then
  202. return true
  203. end
  204. return false
  205. end,
  206. score = 0.0,
  207. description = "Envelope From is a VERP address",
  208. group = "headers",
  209. type = 'mime',
  210. }
  211. local check_rcvd = rspamd_config:register_symbol{
  212. name = 'CHECK_RCVD',
  213. group = 'headers',
  214. callback = function (task)
  215. local rcvds = task:get_received_headers()
  216. if not rcvds or #rcvds == 0 then return false end
  217. local all_tls = fun.all(function(rc)
  218. return rc.flags and rc.flags['ssl']
  219. end, fun.filter(function(rc)
  220. return rc.by_hostname and rc.by_hostname ~= 'localhost'
  221. end, rcvds))
  222. -- See if only the last hop was encrypted
  223. if all_tls then
  224. task:insert_result('RCVD_TLS_ALL', 1.0)
  225. else
  226. local rcvd = rcvds[1]
  227. if rcvd.by_hostname and rcvd.by_hostname == 'localhost' then
  228. -- Ignore artificial header from Rmilter
  229. rcvd = rcvds[2] or {}
  230. end
  231. if rcvd.flags and rcvd.flags['ssl'] then
  232. task:insert_result('RCVD_TLS_LAST', 1.0)
  233. else
  234. task:insert_result('RCVD_NO_TLS_LAST', 1.0)
  235. end
  236. end
  237. local auth = fun.any(function(rc)
  238. return rc.flags and rc.flags['authenticated']
  239. end, rcvds)
  240. if auth then
  241. task:insert_result('RCVD_VIA_SMTP_AUTH', 1.0)
  242. end
  243. end,
  244. type = 'callback,mime',
  245. }
  246. rspamd_config:register_symbol{
  247. type = 'virtual',
  248. parent = check_rcvd,
  249. name = 'RCVD_TLS_ALL',
  250. description = 'All hops used encrypted transports',
  251. score = 0.0,
  252. group = 'headers'
  253. }
  254. rspamd_config:register_symbol{
  255. type = 'virtual',
  256. parent = check_rcvd,
  257. name = 'RCVD_TLS_LAST',
  258. description = 'Last hop used encrypted transports',
  259. score = 0.0,
  260. group = 'headers'
  261. }
  262. rspamd_config:register_symbol{
  263. type = 'virtual',
  264. parent = check_rcvd,
  265. name = 'RCVD_NO_TLS_LAST',
  266. description = 'Last hop did not use encrypted transports',
  267. score = 0.1,
  268. group = 'headers'
  269. }
  270. rspamd_config:register_symbol{
  271. type = 'virtual',
  272. parent = check_rcvd,
  273. name = 'RCVD_VIA_SMTP_AUTH',
  274. -- NB This does not mean sender was authenticated; see task:get_user()
  275. description = 'Authenticated hand-off was seen in Received headers',
  276. score = 0.0,
  277. group = 'headers'
  278. }
  279. rspamd_config.RCVD_HELO_USER = {
  280. callback = function (task)
  281. -- Check HELO argument from MTA
  282. local helo = task:get_helo()
  283. if (helo and helo:lower():find('^user$')) then
  284. return true
  285. end
  286. -- Check Received headers
  287. local rcvds = task:get_header_full('Received')
  288. if not rcvds then return false end
  289. for _, rcvd in ipairs(rcvds) do
  290. local r = rcvd['decoded']:lower()
  291. if (r:find("^%s*from%suser%s")) then return true end
  292. if (r:find("helo[%s=]user[%s%)]")) then return true end
  293. end
  294. end,
  295. description = 'HELO User spam pattern',
  296. group = 'headers',
  297. type = 'mime',
  298. score = 3.0
  299. }
  300. rspamd_config.URI_COUNT_ODD = {
  301. callback = function (task)
  302. local ct = task:get_header('Content-Type')
  303. if (ct and ct:lower():find('^multipart/alternative')) then
  304. local urls = task:get_urls_filtered(nil, {'subject', 'html_displayed', 'special'}) or {}
  305. local nurls = fun.foldl(function(acc, val) return acc + val:get_count() end, 0, urls)
  306. if nurls % 2 == 1 then
  307. return true, 1.0, tostring(nurls)
  308. end
  309. end
  310. end,
  311. description = 'Odd number of URIs in multipart/alternative message',
  312. score = 1.0,
  313. group = 'url',
  314. }
  315. rspamd_config.HAS_ATTACHMENT = {
  316. callback = function (task)
  317. local parts = task:get_parts()
  318. if parts and #parts > 1 then
  319. for _, p in ipairs(parts) do
  320. local cd = p:get_header('Content-Disposition')
  321. if (cd and cd:lower():match('^attachment')) then
  322. return true
  323. end
  324. end
  325. end
  326. end,
  327. description = 'Message contains attachments',
  328. group = 'body',
  329. }
  330. -- Requires freemail maps loaded in multimap
  331. local function freemail_reply_neq_from(task)
  332. if not task:has_symbol('FREEMAIL_REPLYTO') or not task:has_symbol('FREEMAIL_FROM') then
  333. return false
  334. end
  335. local frt = task:get_symbol('FREEMAIL_REPLYTO')
  336. local ff = task:get_symbol('FREEMAIL_FROM')
  337. local frt_opts = frt[1]['options']
  338. local ff_opts = ff[1]['options']
  339. return ( frt_opts and ff_opts and frt_opts[1] ~= ff_opts[1] )
  340. end
  341. rspamd_config:register_symbol({
  342. name = 'FREEMAIL_REPLYTO_NEQ_FROM_DOM',
  343. callback = freemail_reply_neq_from,
  344. description = 'Freemail From and Reply-To, but to different Freemail services',
  345. score = 3.0,
  346. group = 'headers',
  347. })
  348. rspamd_config:register_dependency('FREEMAIL_REPLYTO_NEQ_FROM_DOM', 'FREEMAIL_REPLYTO')
  349. rspamd_config:register_dependency('FREEMAIL_REPLYTO_NEQ_FROM_DOM', 'FREEMAIL_FROM')
  350. rspamd_config.OMOGRAPH_URL = {
  351. callback = function(task)
  352. local urls = task:get_urls()
  353. if urls then
  354. local bad_omographs = 0
  355. local single_bad_omograps = 0
  356. local bad_urls = {}
  357. local seen = {}
  358. fun.each(function(u)
  359. if u:is_phished() then
  360. local h1 = u:get_host()
  361. local h2 = u:get_phished()
  362. if h2 then -- Due to changes of the phished flag in 2.8
  363. h2 = h2:get_host()
  364. end
  365. if h1 and h2 then
  366. local selt = string.format('%s->%s', h1, h2)
  367. if not seen[selt] and util.is_utf_spoofed(h1, h2) then
  368. bad_urls[#bad_urls + 1] = selt
  369. bad_omographs = bad_omographs + 1
  370. end
  371. seen[selt] = true
  372. end
  373. end
  374. if not u:is_html_displayed() then
  375. local h = u:get_tld()
  376. if h then
  377. if not seen[h] and util.is_utf_spoofed(h) then
  378. bad_urls[#bad_urls + 1] = h
  379. single_bad_omograps = single_bad_omograps + 1
  380. end
  381. seen[h] = true
  382. end
  383. end
  384. end, urls)
  385. if bad_omographs > 0 then
  386. return true, 1.0, bad_urls
  387. elseif single_bad_omograps > 0 then
  388. return true, 0.5, bad_urls
  389. end
  390. end
  391. return false
  392. end,
  393. score = 5.0,
  394. group = 'url',
  395. description = 'Url contains both latin and non-latin characters'
  396. }
  397. rspamd_config.URL_IN_SUBJECT = {
  398. callback = function(task)
  399. local urls = task:get_urls()
  400. if urls then
  401. for _,u in ipairs(urls) do
  402. local flags = u:get_flags()
  403. if flags.subject then
  404. if flags.schemaless then
  405. return true,0.1,u:get_host()
  406. end
  407. local subject = task:get_subject()
  408. if subject then
  409. if tostring(u) == subject then
  410. return true,1.0,u:get_host()
  411. end
  412. end
  413. return true,0.25,u:get_host()
  414. end
  415. end
  416. end
  417. return false
  418. end,
  419. score = 4.0,
  420. group = 'subject',
  421. type = 'mime',
  422. description = 'URL found in Subject'
  423. }
  424. local aliases_id = rspamd_config:register_symbol{
  425. type = 'prefilter',
  426. name = 'EMAIL_PLUS_ALIASES',
  427. callback = function(task)
  428. local function check_from(type)
  429. if task:has_from(type) then
  430. local addr = task:get_from(type)[1]
  431. local na,tags = rspamd_lua_utils.remove_email_aliases(addr)
  432. if na then
  433. task:set_from(type, addr, 'alias')
  434. task:insert_result('TAGGED_FROM', 1.0, fun.totable(
  435. fun.filter(function(t) return t and #t > 0 end, tags)))
  436. end
  437. end
  438. end
  439. check_from('smtp')
  440. check_from('mime')
  441. local function check_rcpt(type)
  442. if task:has_recipients(type) then
  443. local modified = false
  444. local all_tags = {}
  445. local addrs = task:get_recipients(type)
  446. for _, addr in ipairs(addrs) do
  447. local na,tags = rspamd_lua_utils.remove_email_aliases(addr)
  448. if na then
  449. modified = true
  450. fun.each(function(t) table.insert(all_tags, t) end,
  451. fun.filter(function(t) return t and #t > 0 end, tags))
  452. end
  453. end
  454. if modified then
  455. task:set_recipients(type, addrs, 'alias')
  456. task:insert_result('TAGGED_RCPT', 1.0, all_tags)
  457. end
  458. end
  459. end
  460. check_rcpt('smtp')
  461. check_rcpt('mime')
  462. end,
  463. priority = 150,
  464. description = 'Removes plus aliases from the email',
  465. group = 'headers',
  466. }
  467. rspamd_config:register_symbol{
  468. type = 'virtual',
  469. parent = aliases_id,
  470. name = 'TAGGED_RCPT',
  471. description = 'SMTP recipients have plus tags',
  472. group = 'headers',
  473. score = 0.0,
  474. }
  475. rspamd_config:register_symbol{
  476. type = 'virtual',
  477. parent = aliases_id,
  478. name = 'TAGGED_FROM',
  479. description = 'SMTP from has plus tags',
  480. group = 'headers',
  481. score = 0.0,
  482. }
  483. local check_from_display_name = rspamd_config:register_symbol{
  484. type = 'callback,mime',
  485. name = 'FROM_DISPLAY_CALLBACK',
  486. callback = function (task)
  487. local from = task:get_from(2)
  488. if not (from and from[1] and from[1].name) then return false end
  489. -- See if we can parse an email address from the name
  490. local parsed = rspamd_parsers.parse_mail_address(from[1].name, task:get_mempool())
  491. if not parsed then return false end
  492. if not (parsed[1] and parsed[1]['addr']) then return false end
  493. -- Make sure we did not mistake e.g. <something>@<name> for an email address
  494. if not parsed[1]['domain'] or not parsed[1]['domain']:find('%.') then return false end
  495. -- See if the parsed domains differ
  496. if not util.strequal_caseless(from[1]['domain'], parsed[1]['domain']) then
  497. -- See if the destination domain is the same as the spoof
  498. local mto = task:get_recipients(2)
  499. local sto = task:get_recipients(1)
  500. if mto then
  501. for _, to in ipairs(mto) do
  502. if to['domain'] ~= '' and util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  503. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  504. return false
  505. end
  506. end
  507. end
  508. if sto then
  509. for _, to in ipairs(sto) do
  510. if to['domain'] ~= '' and util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  511. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  512. return false
  513. end
  514. end
  515. end
  516. task:insert_result('FROM_NEQ_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  517. end
  518. return false
  519. end,
  520. group = 'headers',
  521. }
  522. rspamd_config:register_symbol{
  523. type = 'virtual',
  524. parent = check_from_display_name,
  525. name = 'SPOOF_DISPLAY_NAME',
  526. description = 'Display name is being used to spoof and trick the recipient',
  527. group = 'headers',
  528. score = 8.0,
  529. }
  530. rspamd_config:register_symbol{
  531. type = 'virtual',
  532. parent = check_from_display_name,
  533. name = 'FROM_NEQ_DISPLAY_NAME',
  534. group = 'headers',
  535. description = 'Display name contains an email address different to the From address',
  536. score = 4.0,
  537. }
  538. rspamd_config.SPOOF_REPLYTO = {
  539. callback = function (task)
  540. -- First check for a Reply-To header
  541. local rt = task:get_header_full('Reply-To')
  542. if not rt or not rt[1] then return false end
  543. -- Get From and To headers
  544. rt = rt[1]['value']
  545. local from = task:get_from(2)
  546. local to = task:get_recipients(2)
  547. if not (from and from[1] and from[1].addr) then return false end
  548. if (to and to[1] and to[1].addr) then
  549. -- Handle common case for Web Contact forms of From = To
  550. if util.strequal_caseless(from[1].addr, to[1].addr) then
  551. return false
  552. end
  553. end
  554. -- SMTP recipients must contain From domain
  555. to = task:get_recipients(1)
  556. if not to then return false end
  557. -- Try mitigate some possible FPs on mailing list posts
  558. if #to == 1 and util.strequal_caseless(to[1].addr, from[1].addr) then return false end
  559. local found_fromdom = false
  560. for _, t in ipairs(to) do
  561. if util.strequal_caseless(t.domain, from[1].domain) then
  562. found_fromdom = true
  563. break
  564. end
  565. end
  566. if not found_fromdom then return false end
  567. -- Parse Reply-To header
  568. local parsed = ((rspamd_parsers.parse_mail_address(rt, task:get_mempool()) or E)[1] or E).domain
  569. if not parsed then return false end
  570. -- Reply-To domain must be different to From domain
  571. if not util.strequal_caseless(parsed, from[1].domain) then
  572. return true, from[1].domain, parsed
  573. end
  574. return false
  575. end,
  576. group = 'headers',
  577. type = 'mime',
  578. description = 'Reply-To is being used to spoof and trick the recipient to send an off-domain reply',
  579. score = 6.0
  580. }
  581. rspamd_config.INFO_TO_INFO_LU = {
  582. callback = function(task)
  583. if not task:has_header('List-Unsubscribe') then
  584. return false
  585. end
  586. local from = task:get_from('mime')
  587. if not (from and from[1] and util.strequal_caseless(from[1].user, 'info')) then
  588. return false
  589. end
  590. local to = task:get_recipients('smtp')
  591. if not to then return false end
  592. local found = false
  593. for _,r in ipairs(to) do
  594. if util.strequal_caseless(r['user'], 'info') then
  595. found = true
  596. end
  597. end
  598. if found then return true end
  599. return false
  600. end,
  601. description = 'info@ From/To address with List-Unsubscribe headers',
  602. group = 'headers',
  603. score = 2.0,
  604. type = 'mime',
  605. }
  606. -- Detects bad content-transfer-encoding for text parts
  607. rspamd_config.R_BAD_CTE_7BIT = {
  608. callback = function(task)
  609. local tp = task:get_text_parts() or {}
  610. for _,p in ipairs(tp) do
  611. local cte = p:get_mimepart():get_cte() or ''
  612. if cte ~= '8bit' and p:has_8bit_raw() then
  613. local _,_,attrs = p:get_mimepart():get_type_full()
  614. local mul = 1.0
  615. local params = {cte}
  616. if attrs then
  617. if attrs.charset and attrs.charset:lower() == "utf-8" then
  618. -- Penalise rule as people don't know that utf8 is surprisingly
  619. -- eight bit encoding
  620. mul = 0.3
  621. table.insert(params, "utf8")
  622. end
  623. end
  624. return true,mul,params
  625. end
  626. end
  627. return false
  628. end,
  629. score = 3.5,
  630. description = 'Detects bad content-transfer-encoding for text parts',
  631. group = 'headers',
  632. type = 'mime',
  633. }
  634. local check_encrypted_name = rspamd_config:register_symbol{
  635. name = 'BOGUS_ENCRYPTED_AND_TEXT',
  636. callback = function(task)
  637. local parts = task:get_parts() or {}
  638. local seen_encrypted, seen_text
  639. local opts = {}
  640. local function check_part(part)
  641. if part:is_multipart() then
  642. local children = part:get_children() or {}
  643. local text_kids = {}
  644. for _,cld in ipairs(children) do
  645. if cld:is_multipart() then
  646. check_part(cld)
  647. elseif cld:is_text() then
  648. seen_text = true
  649. text_kids[#text_kids + 1] = cld
  650. else
  651. local type,subtype,_ = cld:get_type_full()
  652. if type:lower() == 'application' then
  653. if string.find(subtype:lower(), 'pkcs7%-mime') then
  654. -- S/MIME encrypted part
  655. seen_encrypted = true
  656. table.insert(opts, 'smime part')
  657. task:insert_result('ENCRYPTED_SMIME', 1.0)
  658. elseif string.find(subtype:lower(), 'pkcs7%-signature') then
  659. task:insert_result('SIGNED_SMIME', 1.0)
  660. elseif string.find(subtype:lower(), 'pgp%-encrypted') then
  661. -- PGP/GnuPG encrypted part
  662. seen_encrypted = true
  663. table.insert(opts, 'pgp part')
  664. task:insert_result('ENCRYPTED_PGP', 1.0)
  665. elseif string.find(subtype:lower(), 'pgp%-signature') then
  666. task:insert_result('SIGNED_PGP', 1.0)
  667. end
  668. end
  669. end
  670. if seen_text and seen_encrypted then
  671. -- Ensure that our seen text is not really part of pgp #3205
  672. for _,tp in ipairs(text_kids) do
  673. local t,_ = tp:get_type()
  674. seen_text = false -- reset temporary
  675. if t and t == 'text' then
  676. seen_text = true
  677. break
  678. end
  679. end
  680. end
  681. end
  682. end
  683. end
  684. for _,part in ipairs(parts) do
  685. check_part(part)
  686. end
  687. if seen_text and seen_encrypted then
  688. return true, 1.0, opts
  689. end
  690. return false
  691. end,
  692. score = 10.0,
  693. description = 'Bogus mix of encrypted and text/html payloads',
  694. group = 'mime_types',
  695. }
  696. rspamd_config:register_symbol{
  697. type = 'virtual',
  698. parent = check_encrypted_name,
  699. name = 'ENCRYPTED_PGP',
  700. description = 'Message is encrypted with pgp',
  701. group = 'mime_types',
  702. score = -0.5,
  703. one_shot = true
  704. }
  705. rspamd_config:register_symbol{
  706. type = 'virtual',
  707. parent = check_encrypted_name,
  708. name = 'ENCRYPTED_SMIME',
  709. description = 'Message is encrypted with smime',
  710. group = 'mime_types',
  711. score = -0.5,
  712. one_shot = true
  713. }
  714. rspamd_config:register_symbol{
  715. type = 'virtual',
  716. parent = check_encrypted_name,
  717. name = 'SIGNED_PGP',
  718. description = 'Message is signed with pgp',
  719. group = 'mime_types',
  720. score = -2.0,
  721. one_shot = true
  722. }
  723. rspamd_config:register_symbol{
  724. type = 'virtual',
  725. parent = check_encrypted_name,
  726. name = 'SIGNED_SMIME',
  727. description = 'Message is signed with smime',
  728. group = 'mime_types',
  729. score = -2.0,
  730. one_shot = true
  731. }