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

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