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

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