Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

reputation.lua 34KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221
  1. --[[
  2. Copyright (c) 2017-2018, 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. if confighelp then
  14. return
  15. end
  16. -- A generic plugin for reputation handling
  17. local E = {}
  18. local N = 'reputation'
  19. local rspamd_logger = require "rspamd_logger"
  20. local rspamd_util = require "rspamd_util"
  21. local lua_util = require "lua_util"
  22. local lua_maps = require "lua_maps"
  23. local hash = require 'rspamd_cryptobox_hash'
  24. local lua_redis = require "lua_redis"
  25. local fun = require "fun"
  26. local lua_selectors = require "lua_selectors"
  27. local ts = require("tableshape").types
  28. local redis_params = nil
  29. local default_expiry = 864000 -- 10 day by default
  30. local default_prefix = 'RR:' -- Rspamd Reputation
  31. -- Get reputation from ham/spam/probable hits
  32. local function generic_reputation_calc(token, rule, mult)
  33. local cfg = rule.selector.config or E
  34. if cfg.score_calc_func then
  35. return cfg.score_calc_func(rule, token, mult)
  36. end
  37. if token[1] < cfg.lower_bound then return 0 end
  38. local score = fun.foldl(function(acc, v)
  39. return acc + v
  40. end, 0.0, fun.map(tonumber, token[2])) / #token[2]
  41. return score
  42. end
  43. local function add_symbol_score(task, rule, mult, params)
  44. if not params then params = {tostring(mult)};
  45. end
  46. if rule.config.split_symbols then
  47. if mult >= 0 then
  48. task:insert_result(rule.symbol .. '_SPAM', mult, params)
  49. else
  50. task:insert_result(rule.symbol .. '_HAM', mult, params)
  51. end
  52. else
  53. task:insert_result(rule.symbol, mult, params)
  54. end
  55. end
  56. local function sub_symbol_score(task, rule, score)
  57. local function sym_score(sym)
  58. local s = task:get_symbol(sym)[1]
  59. return s.score
  60. end
  61. if rule.config.split_symbols then
  62. local spam_sym = rule.symbol .. '_SPAM'
  63. local ham_sym = rule.symbol .. '_HAM'
  64. if task:has_symbol(spam_sym) then
  65. score = score - sym_score(spam_sym)
  66. elseif task:has_symbol(ham_sym) then
  67. score = score - sym_score(ham_sym)
  68. end
  69. else
  70. if task:has_symbol(rule.symbol) then
  71. score = score - sym_score(rule.symbol)
  72. end
  73. end
  74. return score
  75. end
  76. -- Extracts task score and subtracts score of the rule itself
  77. local function extract_task_score(task, rule)
  78. local _,score = lua_util.get_task_verdict(task)
  79. if not score then return nil end
  80. return sub_symbol_score(task, rule, score)
  81. end
  82. -- DKIM Selector functions
  83. local gr
  84. local function gen_dkim_queries(task, rule)
  85. local dkim_trace = (task:get_symbol('DKIM_TRACE') or E)[1]
  86. local lpeg = require 'lpeg'
  87. local ret = {}
  88. if not gr then
  89. local semicolon = lpeg.P(':')
  90. local domain = lpeg.C((1 - semicolon)^1)
  91. local res = lpeg.S'+-?~'
  92. local function res_to_label(ch)
  93. if ch == '+' then return 'a'
  94. elseif ch == '-' then return 'r'
  95. end
  96. return 'u'
  97. end
  98. gr = domain * semicolon * (lpeg.C(res^1) / res_to_label)
  99. end
  100. if dkim_trace and dkim_trace.options then
  101. for _,opt in ipairs(dkim_trace.options) do
  102. local dom,res = lpeg.match(gr, opt)
  103. if dom and res then
  104. local tld = rspamd_util.get_tld(dom)
  105. ret[tld] = res
  106. end
  107. end
  108. end
  109. return ret
  110. end
  111. local function dkim_reputation_filter(task, rule)
  112. local requests = gen_dkim_queries(task, rule)
  113. local results = {}
  114. local nchecked = 0
  115. local rep_accepted = 0.0
  116. local rep_rejected = 0.0
  117. lua_util.debugm(N, task, 'dkim reputation tokens: %s', requests)
  118. local function tokens_cb(err, token, values)
  119. nchecked = nchecked + 1
  120. if values then
  121. results[token] = values
  122. end
  123. if nchecked == #requests then
  124. for k,v in pairs(results) do
  125. if requests[k] == 'a' then
  126. rep_accepted = rep_accepted + generic_reputation_calc(v, rule, 1.0)
  127. elseif requests[k] == 'r' then
  128. rep_rejected = rep_rejected + generic_reputation_calc(v, rule, 1.0)
  129. end
  130. end
  131. -- Set local reputation symbol
  132. if rep_accepted > 0 or rep_rejected > 0 then
  133. if rep_accepted > rep_rejected then
  134. add_symbol_score(task, rule, -(rep_accepted - rep_rejected))
  135. else
  136. add_symbol_score(task, rule, (rep_rejected - rep_accepted))
  137. end
  138. -- Store results for future DKIM results adjustments
  139. task:get_mempool():set_variable("dkim_reputation_accept", tostring(rep_accepted))
  140. task:get_mempool():set_variable("dkim_reputation_reject", tostring(rep_rejected))
  141. end
  142. end
  143. end
  144. for dom,res in pairs(requests) do
  145. -- tld + "." + check_result, e.g. example.com.+ - reputation for valid sigs
  146. local query = string.format('%s.%s', dom, res)
  147. rule.backend.get_token(task, rule, query, tokens_cb)
  148. end
  149. end
  150. local function dkim_reputation_idempotent(task, rule)
  151. local requests = gen_dkim_queries(task, rule)
  152. local sc = extract_task_score(task, rule)
  153. if sc then
  154. for dom,res in pairs(requests) do
  155. -- tld + "." + check_result, e.g. example.com.+ - reputation for valid sigs
  156. local query = string.format('%s.%s', dom, res)
  157. rule.backend.set_token(task, rule, query, sc)
  158. end
  159. end
  160. end
  161. local function dkim_reputation_postfilter(task, rule)
  162. local sym_accepted = task:get_symbol('R_DKIM_ALLOW')
  163. local accept_adjustment = task:get_mempool():get_variable("dkim_reputation_accept")
  164. if sym_accepted and accept_adjustment then
  165. local final_adjustment = rule.config.max_accept_adjustment *
  166. rspamd_util.tanh(tonumber(accept_adjustment))
  167. task:adjust_result('R_DKIM_ALLOW', sym_accepted.score * final_adjustment)
  168. end
  169. local sym_rejected = task:get_symbol('R_DKIM_REJECT')
  170. local reject_adjustment = task:get_mempool():get_variable("dkim_reputation_reject")
  171. if sym_rejected and reject_adjustment then
  172. local final_adjustment = rule.config.max_reject_adjustment *
  173. rspamd_util.tanh(tonumber(reject_adjustment))
  174. task:adjust_result('R_DKIM_REJECT', sym_rejected.score * final_adjustment)
  175. end
  176. end
  177. local dkim_selector = {
  178. config = {
  179. symbol = 'DKIM_SCORE', -- symbol to be inserted
  180. lower_bound = 10, -- minimum number of messages to be scored
  181. min_score = nil,
  182. max_score = nil,
  183. outbound = true,
  184. inbound = true,
  185. max_accept_adjustment = 2.0, -- How to adjust accepted DKIM score
  186. max_reject_adjustment = 3.0 -- How to adjust rejected DKIM score
  187. },
  188. dependencies = {"DKIM_TRACE"},
  189. filter = dkim_reputation_filter, -- used to get scores
  190. postfilter = dkim_reputation_postfilter, -- used to adjust DKIM scores
  191. idempotent = dkim_reputation_idempotent -- used to set scores
  192. }
  193. -- URL Selector functions
  194. local function gen_url_queries(task, rule)
  195. local domains = {}
  196. fun.each(function(u)
  197. if u:is_redirected() then
  198. local redir = u:get_redirected() -- get the original url
  199. local redir_tld = redir:get_tld()
  200. if domains[redir_tld] then
  201. domains[redir_tld] = domains[redir_tld] - 1
  202. end
  203. end
  204. local dom = u:get_tld()
  205. if not domains[dom] then
  206. domains[dom] = 1
  207. else
  208. domains[dom] = domains[dom] + 1
  209. end
  210. end, fun.filter(function(u) return not u:is_html_displayed() end,
  211. task:get_urls(true)))
  212. local results = {}
  213. for k,v in lua_util.spairs(domains,
  214. function(t, a, b) return t[a] > t[b] end, rule.selector.config.max_urls) do
  215. if v > 0 then
  216. table.insert(results, {k,v})
  217. end
  218. end
  219. return results
  220. end
  221. local function url_reputation_filter(task, rule)
  222. local requests = lua_util.extract_specific_urls(task, rule.selector.config.max_urls)
  223. local results = {}
  224. local nchecked = 0
  225. local function tokens_cb(err, token, values)
  226. nchecked = nchecked + 1
  227. if values then
  228. results[token] = values
  229. end
  230. if nchecked == #requests then
  231. -- Check the url with maximum hits
  232. local mhits = 0
  233. for k,_ in pairs(results) do
  234. if requests[k][2] > mhits then
  235. mhits = requests[k][2]
  236. end
  237. end
  238. if mhits > 0 then
  239. local score = 0
  240. for k,v in pairs(results) do
  241. score = score + generic_reputation_calc(v, rule, requests[k][2] / mhits)
  242. end
  243. if math.abs(score) > 1e-3 then
  244. -- TODO: add description
  245. add_symbol_score(task, rule, score)
  246. end
  247. end
  248. end
  249. end
  250. for _,u in ipairs(requests) do
  251. rule.backend.get_token(task, rule, u:get_tld(), tokens_cb)
  252. end
  253. end
  254. local function url_reputation_idempotent(task, rule)
  255. local requests = gen_url_queries(task, rule)
  256. local sc = extract_task_score(task, rule)
  257. if sc then
  258. for _,tld in ipairs(requests) do
  259. rule.backend.set_token(task, rule, tld[1], sc)
  260. end
  261. end
  262. end
  263. local url_selector = {
  264. config = {
  265. symbol = 'URL_SCORE', -- symbol to be inserted
  266. lower_bound = 10, -- minimum number of messages to be scored
  267. min_score = nil,
  268. max_score = nil,
  269. max_urls = 10,
  270. check_from = true,
  271. outbound = true,
  272. inbound = true,
  273. },
  274. dependencies = {"SURBL_REDIRECTOR_CALLBACK"},
  275. filter = url_reputation_filter, -- used to get scores
  276. idempotent = url_reputation_idempotent -- used to set scores
  277. }
  278. -- IP Selector functions
  279. local function ip_reputation_init(rule)
  280. local cfg = rule.selector.config
  281. if cfg.asn_cc_whitelist then
  282. cfg.asn_cc_whitelist = rspamd_map_add('reputation',
  283. 'asn_cc_whitelist',
  284. 'map',
  285. 'IP score whitelisted ASNs/countries')
  286. end
  287. return true
  288. end
  289. local function ip_reputation_filter(task, rule)
  290. local ip = task:get_from_ip()
  291. if not ip or not ip:is_valid() then return end
  292. if lua_util.is_rspamc_or_controller(task) then return end
  293. local cfg = rule.selector.config
  294. local pool = task:get_mempool()
  295. local asn = pool:get_variable("asn")
  296. local country = pool:get_variable("country")
  297. if country and cfg.asn_cc_whitelist then
  298. if cfg.asn_cc_whitelist:get_key(country) then
  299. return
  300. end
  301. if asn and cfg.asn_cc_whitelist:get_key(asn) then
  302. return
  303. end
  304. end
  305. -- These variables are used to define if we have some specific token
  306. local has_asn = not asn
  307. local has_country = not country
  308. local has_ip = false
  309. local asn_stats, country_stats, ip_stats
  310. local function ipstats_check()
  311. local score = 0.0
  312. local description_t = {}
  313. if asn_stats then
  314. local asn_score = generic_reputation_calc(asn_stats, rule, cfg.scores.asn)
  315. score = score + asn_score
  316. table.insert(description_t, string.format('asn: %s(%.2f)', asn, asn_score))
  317. end
  318. if country_stats then
  319. local country_score = generic_reputation_calc(country_stats, rule, cfg.scores.country)
  320. score = score + country_score
  321. table.insert(description_t, string.format('country: %s(%.2f)', country, country_score))
  322. end
  323. if ip_stats then
  324. local ip_score = generic_reputation_calc(ip_stats, rule, cfg.scores.ip)
  325. score = score + ip_score
  326. table.insert(description_t, string.format('ip: %s(%.2f)', ip, ip_score))
  327. end
  328. if math.abs(score) > 0.001 then
  329. add_symbol_score(task, rule, score, table.concat(description_t, ', '))
  330. end
  331. end
  332. local function gen_token_callback(what)
  333. return function(err, _, values)
  334. if not err and values then
  335. if what == 'asn' then
  336. has_asn = true
  337. asn_stats = values
  338. elseif what == 'country' then
  339. has_country = true
  340. country_stats = values
  341. elseif what == 'ip' then
  342. has_ip = true
  343. ip_stats = values
  344. end
  345. else
  346. if what == 'asn' then
  347. has_asn = true
  348. elseif what == 'country' then
  349. has_country = true
  350. elseif what == 'ip' then
  351. has_ip = true
  352. end
  353. end
  354. if has_asn and has_country and has_ip then
  355. -- Check reputation
  356. ipstats_check()
  357. end
  358. end
  359. end
  360. if asn then
  361. rule.backend.get_token(task, rule, cfg.asn_prefix .. asn, gen_token_callback('asn'))
  362. end
  363. if country then
  364. rule.backend.get_token(task, rule, cfg.country_prefix .. country, gen_token_callback('country'))
  365. end
  366. rule.backend.get_token(task, rule, cfg.ip_prefix .. tostring(ip), gen_token_callback('ip'))
  367. end
  368. -- Used to set scores
  369. local function ip_reputation_idempotent(task, rule)
  370. if not rule.backend.set_token then return end -- Read only backend
  371. local ip = task:get_from_ip()
  372. if not ip or not ip:is_valid() then return end
  373. if lua_util.is_rspamc_or_controller(task) then return end
  374. local cfg = rule.selector.config
  375. local pool = task:get_mempool()
  376. local asn = pool:get_variable("asn")
  377. local country = pool:get_variable("country")
  378. if country and cfg.asn_cc_whitelist then
  379. if cfg.asn_cc_whitelist:get_key(country) then
  380. return
  381. end
  382. if asn and cfg.asn_cc_whitelist:get_key(asn) then
  383. return
  384. end
  385. end
  386. local sc = extract_task_score(task, rule)
  387. if asn then
  388. rule.backend.set_token(task, rule, cfg.asn_prefix .. asn, sc)
  389. end
  390. if country then
  391. rule.backend.set_token(task, rule, cfg.country_prefix .. country, sc)
  392. end
  393. rule.backend.set_token(task, rule, cfg.ip_prefix .. tostring(ip), sc)
  394. end
  395. -- Selectors are used to extract reputation tokens
  396. local ip_selector = {
  397. config = {
  398. scores = { -- how each component is evaluated
  399. ['asn'] = 0.4,
  400. ['country'] = 0.01,
  401. ['ip'] = 1.0
  402. },
  403. symbol = 'IP_SCORE', -- symbol to be inserted
  404. asn_prefix = 'a:', -- prefix for ASN hashes
  405. country_prefix = 'c:', -- prefix for country hashes
  406. ip_prefix = 'i:',
  407. lower_bound = 10, -- minimum number of messages to be scored
  408. min_score = nil,
  409. max_score = nil,
  410. score_divisor = 1,
  411. outbound = false,
  412. inbound = true,
  413. },
  414. --dependencies = {"ASN"}, -- ASN is a prefilter now...
  415. init = ip_reputation_init,
  416. filter = ip_reputation_filter, -- used to get scores
  417. idempotent = ip_reputation_idempotent -- used to set scores
  418. }
  419. -- SPF Selector functions
  420. local function spf_reputation_filter(task, rule)
  421. local spf_record = task:get_mempool():get_variable('spf_record')
  422. local spf_allow = task:has_symbol('R_SPF_ALLOW')
  423. -- Don't care about bad/missing spf
  424. if not spf_record or not spf_allow then return end
  425. local cr = require "rspamd_cryptobox_hash"
  426. local hkey = cr.create(spf_record):base32():sub(1, 32)
  427. lua_util.debugm(N, task, 'check spf record %s -> %s', spf_record, hkey)
  428. local function tokens_cb(err, token, values)
  429. if values then
  430. local score = generic_reputation_calc(values, rule, 1.0)
  431. if math.abs(score) > 1e-3 then
  432. -- TODO: add description
  433. add_symbol_score(task, rule, score)
  434. end
  435. end
  436. end
  437. rule.backend.get_token(task, rule, hkey, tokens_cb)
  438. end
  439. local function spf_reputation_idempotent(task, rule)
  440. local sc = extract_task_score(task, rule)
  441. local spf_record = task:get_mempool():get_variable('spf_record')
  442. local spf_allow = task:has_symbol('R_SPF_ALLOW')
  443. if not spf_record or not spf_allow or not sc then return end
  444. local cr = require "rspamd_cryptobox_hash"
  445. local hkey = cr.create(spf_record):base32():sub(1, 32)
  446. lua_util.debugm(N, task, 'set spf record %s -> %s = %s',
  447. spf_record, hkey, token)
  448. rule.backend.set_token(task, rule, hkey, sc)
  449. end
  450. local spf_selector = {
  451. config = {
  452. symbol = 'SPF_SCORE', -- symbol to be inserted
  453. lower_bound = 10, -- minimum number of messages to be scored
  454. min_score = nil,
  455. max_score = nil,
  456. outbound = true,
  457. inbound = true,
  458. max_accept_adjustment = 2.0, -- How to adjust accepted DKIM score
  459. max_reject_adjustment = 3.0 -- How to adjust rejected DKIM score
  460. },
  461. dependencies = {"R_SPF_ALLOW"},
  462. filter = spf_reputation_filter, -- used to get scores
  463. idempotent = spf_reputation_idempotent -- used to set scores
  464. }
  465. -- Generic selector based on lua_selectors framework
  466. local function generic_reputation_init(rule)
  467. local cfg = rule.selector.config
  468. if not cfg.selector then
  469. rspamd_logger.errx(rspamd_config, 'cannot configure generic rule: no selector specified')
  470. return false
  471. end
  472. local selector = lua_selectors.create_selector_closure(rspamd_config,
  473. cfg.selector, cfg.delimiter)
  474. if not selector then
  475. rspamd_logger.errx(rspamd_config, 'cannot configure generic rule: bad selector: %s',
  476. cfg.selector)
  477. return false
  478. end
  479. cfg.selector = selector -- Replace with closure
  480. if cfg.whitelist then
  481. cfg.whitelist = lua_maps.map_add('reputation',
  482. 'generic_whitelist',
  483. 'map',
  484. 'Whitelisted selectors')
  485. end
  486. return true
  487. end
  488. local function generic_reputation_filter(task, rule)
  489. local cfg = rule.selector.config
  490. local selector_res = cfg.selector(task)
  491. local function tokens_cb(err, token, values)
  492. if values then
  493. local score = generic_reputation_calc(values, rule, 1.0)
  494. if math.abs(score) > 1e-3 then
  495. -- TODO: add description
  496. add_symbol_score(task, rule, score)
  497. end
  498. end
  499. end
  500. if selector_res then
  501. if type(selector_res) == 'table' then
  502. fun.each(function(e)
  503. lua_util.debugm(N, task, 'check generic reputation (%s) %s',
  504. rule['symbol'], e)
  505. rule.backend.get_token(task, rule, e, tokens_cb)
  506. end, selector_res)
  507. else
  508. lua_util.debugm(N, task, 'check generic reputation (%s) %s',
  509. rule['symbol'], selector_res)
  510. rule.backend.get_token(task, rule, selector_res, tokens_cb)
  511. end
  512. end
  513. end
  514. local function generic_reputation_idempotent(task, rule)
  515. local sc = extract_task_score(task, rule)
  516. local cfg = rule.selector.config
  517. local selector_res = cfg.selector(task)
  518. if not selector_res then return end
  519. if sc then
  520. if type(selector_res) == 'table' then
  521. fun.each(function(e)
  522. lua_util.debugm(N, task, 'set generic selector (%s) %s = %s',
  523. rule['symbol'], e, sc)
  524. rule.backend.set_token(task, rule, e, sc)
  525. end, selector_res)
  526. else
  527. lua_util.debugm(N, task, 'set generic selector (%s) %s = %s',
  528. rule['symbol'], selector_res, sc)
  529. rule.backend.set_token(task, rule, selector_res, sc)
  530. end
  531. end
  532. end
  533. local generic_selector = {
  534. schema = ts.shape{
  535. lower_bound = ts.number + ts.string / tonumber,
  536. max_score = ts.number:is_optional(),
  537. min_score = ts.number:is_optional(),
  538. outbound = ts.boolean,
  539. inbound = ts.boolean,
  540. selector = ts.string,
  541. delimiter = ts.string,
  542. whitelist = ts.string:is_optional(),
  543. },
  544. config = {
  545. lower_bound = 10, -- minimum number of messages to be scored
  546. min_score = nil,
  547. max_score = nil,
  548. outbound = true,
  549. inbound = true,
  550. selector = nil,
  551. delimiter = ':',
  552. whitelist = nil
  553. },
  554. init = generic_reputation_init,
  555. filter = generic_reputation_filter, -- used to get scores
  556. idempotent = generic_reputation_idempotent -- used to set scores
  557. }
  558. local selectors = {
  559. ip = ip_selector,
  560. url = url_selector,
  561. dkim = dkim_selector,
  562. spf = spf_selector,
  563. generic = generic_selector
  564. }
  565. local function reputation_dns_init(rule, _, _, _)
  566. if not rule.backend.config.list then
  567. rspamd_logger.errx(rspamd_config, "rule %s with DNS backend has no `list` parameter defined",
  568. rule.symbol)
  569. return false
  570. end
  571. return true
  572. end
  573. local function gen_token_key(token, rule)
  574. local res = token
  575. if rule.backend.config.hashed then
  576. local hash_alg = rule.backend.config.hash_alg or "blake2"
  577. local encoding = "base32"
  578. if rule.backend.config.hash_encoding then
  579. encoding = rule.backend.config.hash_encoding
  580. end
  581. local h = hash.create_specific(hash_alg, res)
  582. if encoding == 'hex' then
  583. res = h:hex()
  584. elseif encoding == 'base64' then
  585. res = h:base64()
  586. else
  587. res = h:base32()
  588. end
  589. end
  590. if rule.backend.config.hashlen then
  591. res = string.sub(res, 1, rule.backend.config.hashlen)
  592. end
  593. if rule.backend.config.prefix then
  594. res = rule.backend.config.prefix .. res
  595. end
  596. return res
  597. end
  598. --[[
  599. -- Generic interface for get and set tokens functions:
  600. -- get_token(task, rule, token, continuation), where `continuation` is the following function:
  601. --
  602. -- function(err, token, values) ... end
  603. -- `err`: string value for error (similar to redis or DNS callbacks)
  604. -- `token`: string value of a token
  605. -- `values`: table of key=number, parsed from backend. It is selector's duty
  606. -- to deal with missing, invalid or other values
  607. --
  608. -- set_token(task, rule, token, values, continuation_cb)
  609. -- This function takes values, encodes them using whatever suitable format
  610. -- and calls for continuation:
  611. --
  612. -- function(err, token) ... end
  613. -- `err`: string value for error (similar to redis or DNS callbacks)
  614. -- `token`: string value of a token
  615. --
  616. -- example of tokens: {'s': 0, 'h': 0, 'p': 1}
  617. --]]
  618. local function reputation_dns_get_token(task, rule, token, continuation_cb)
  619. -- local r = task:get_resolver()
  620. local key = gen_token_key(token, rule)
  621. local dns_name = key .. '.' .. rule.backend.config.list
  622. local function dns_cb(_, _, results, err)
  623. if err and (err ~= 'requested record is not found' and
  624. err ~= 'no records with this name') then
  625. rspamd_logger.errx(task, 'error looking up %s: %s', dns_name, err)
  626. end
  627. lua_util.debugm(N, task, 'DNS RESPONSE: label=%1 results=%2 err=%3 list=%4',
  628. dns_name, results, err, rule.backend.config.list)
  629. -- Now split tokens to list of values
  630. if results then
  631. local values = {}
  632. -- Format: key1=num1;key2=num2...keyn=numn
  633. fun.each(function(e)
  634. local vals = lua_util.rspamd_str_split(e, "=")
  635. if vals and #vals == 2 then
  636. local nv = tonumber(vals[2])
  637. if nv then
  638. values[vals[1]] = nv
  639. end
  640. end
  641. end,
  642. lua_util.rspamd_str_split(results[1], ";"))
  643. continuation_cb(nil, dns_name, values)
  644. else
  645. continuation_cb(results, dns_name, nil)
  646. end
  647. end
  648. task:get_resolver():resolve_a({
  649. task = task,
  650. name = dns_name,
  651. callback = dns_cb,
  652. forced = true,
  653. })
  654. end
  655. local function reputation_redis_init(rule, cfg, ev_base, worker)
  656. local our_redis_params = {}
  657. our_redis_params = lua_redis.try_load_redis_servers(rule.backend.config, rspamd_config,
  658. true)
  659. if not our_redis_params then
  660. our_redis_params = redis_params
  661. end
  662. if not our_redis_params then
  663. rspamd_logger.errx(rspamd_config, 'cannot init redis for reputation rule: %s',
  664. rule)
  665. return false
  666. end
  667. -- Init scripts for buckets
  668. -- Redis script to extract data from Redis buckets
  669. -- KEYS[1] - key to extract
  670. -- Value returned - table of scores as a strings vector + number of scores
  671. local redis_get_script_tpl = [[
  672. local cnt = redis.call('HGET', KEYS[1], 'n')
  673. local results = {}
  674. if cnt then
  675. {% for w in windows %}
  676. local sc = tonumber(redis.call('HGET', KEYS[1], 'v' .. '{= w.name =}'))
  677. table.insert(results, tostring(sc * {= w.mult =}))
  678. {% endfor %}
  679. else
  680. {% for w in windows %}
  681. table.insert(results, '0')
  682. {% endfor %}
  683. end
  684. return results,cnt
  685. ]]
  686. local get_script = lua_util.jinja_template(redis_get_script_tpl,
  687. {windows = rule.backend.config.buckets})
  688. rspamd_logger.debugm(N, rspamd_config, 'added extraction script %s', get_script)
  689. rule.backend.script_get = lua_redis.add_redis_script(get_script, our_redis_params)
  690. -- Redis script to update Redis buckets
  691. -- KEYS[1] - key to update
  692. -- KEYS[2] - current time in milliseconds
  693. -- KEYS[3] - message score
  694. -- KEYS[4] - expire for a bucket
  695. -- Value returned - table of scores as a strings vector
  696. local redis_adaptive_emea_script_tpl = [[
  697. local last = redis.call('HGET', KEYS[1], 'l')
  698. local score = tonumber(KEYS[3])
  699. local now = tonumber(KEYS[2])
  700. local scores = {}
  701. if last then
  702. {% for w in windows %}
  703. local last_value = tonumber(redis.call('HGET', KEYS[1], 'v' .. '{= w.name =}'))
  704. local window = {= w.time =}
  705. -- Adjust alpha
  706. local time_diff = now - last_value
  707. if time_diff > 0 then
  708. time_diff = 0
  709. end
  710. local alpha = 1.0 - math.exp((-time_diff) / (1000 * window))
  711. local nscore = alpha * score + (1.0 - alpha) * last_value
  712. table.insert(scores, tostring(nscore * {= w.mult =}))
  713. {% endfor %}
  714. else
  715. {% for w in windows %}
  716. table.insert(scores, tostring(score * {= w.mult =}))
  717. {% endfor %}
  718. end
  719. local i = 1
  720. {% for w in windows %}
  721. redis.call('HSET', KEYS[1], 'v' .. '{= w.name =}', scores[i])
  722. i = i + 1
  723. {% endfor %}
  724. redis.call('HSET', KEYS[1], 'l', now)
  725. redis.call('HINCRBY', KEYS[1], 'n', 1)
  726. redis.call('EXPIRE', KEYS[1], tonumber(KEYS[4]))
  727. return scores
  728. ]]
  729. local set_script = lua_util.jinja_template(redis_adaptive_emea_script_tpl,
  730. {windows = rule.backend.config.buckets})
  731. rspamd_logger.debugm(N, rspamd_config, 'added emea update script %s', set_script)
  732. rule.backend.script_set = lua_redis.add_redis_script(set_script, our_redis_params)
  733. return true
  734. end
  735. local function reputation_redis_get_token(task, rule, token, continuation_cb)
  736. local key = gen_token_key(token, rule)
  737. local function redis_get_cb(err, data)
  738. if data then
  739. if type(data) == 'table' then
  740. local values = {}
  741. for i=1,#data,2 do
  742. local ndata = tonumber(data[i + 1])
  743. if ndata then
  744. values[data[i]] = ndata
  745. end
  746. end
  747. lua_util.debugm(N, task, 'rule %s - got values for key %s -> %s',
  748. rule['symbol'], key, values)
  749. continuation_cb(nil, key, values)
  750. else
  751. rspamd_logger.errx(task, 'rule %s - invalid type while getting reputation keys %s: %s',
  752. rule['symbol'], key, type(data))
  753. continuation_cb("invalid type", key, nil)
  754. end
  755. elseif err then
  756. rspamd_logger.errx(task, 'rule %s - got error while getting reputation keys %s: %s',
  757. rule['symbol'], key, err)
  758. continuation_cb(err, key, nil)
  759. else
  760. rspamd_logger.errx(task, 'rule %s - got error while getting reputation keys %s: %s',
  761. rule['symbol'], key, "unknown error")
  762. continuation_cb("unknown error", key, nil)
  763. end
  764. end
  765. local ret = lua_redis.exec_redis_script(rule.backend.script_get,
  766. {task = task, is_write = false},
  767. redis_get_cb,
  768. {key})
  769. if not ret then
  770. rspamd_logger.errx(task, 'cannot make redis request to check results')
  771. end
  772. end
  773. local function reputation_redis_set_token(task, rule, token, sc, continuation_cb)
  774. local key = gen_token_key(token, rule)
  775. local function redis_set_cb(err, data)
  776. if err then
  777. rspamd_logger.errx(task, 'rule %s - got error while setting reputation keys %s: %s',
  778. rule['symbol'], key, err)
  779. if continuation_cb then
  780. continuation_cb(err, key)
  781. end
  782. else
  783. if continuation_cb then
  784. continuation_cb(nil, key)
  785. end
  786. end
  787. end
  788. lua_util.debugm(N, task, 'rule %s - set values for key %s -> %s',
  789. rule['symbol'], key, sc)
  790. local ret = lua_redis.exec_redis_script(rule.backend.script_set,
  791. {task = task, is_write = true},
  792. redis_set_cb,
  793. {key, tostring(os.time() * 1000),
  794. tonumber(sc),
  795. tostring(rule.backend.config.expiry)})
  796. if not ret then
  797. rspamd_logger.errx(task, 'got error while connecting to redis')
  798. end
  799. end
  800. --[[ Backends are responsible for getting reputation tokens
  801. -- Common config options:
  802. -- `hashed`: if `true` then apply hash function to the key
  803. -- `hash_alg`: use specific hash type (`blake2` by default)
  804. -- `hash_len`: strip hash to this amount of bytes (no strip by default)
  805. -- `hash_encoding`: use specific hash encoding (base32 by default)
  806. --]]
  807. local backends = {
  808. redis = {
  809. schema = ts.shape({
  810. prefix = ts.string,
  811. expiry = ts.number + ts.string / lua_util.parse_time_interval,
  812. buckets = ts.array_of(ts.shape{
  813. time = ts.number + ts.string / lua_util.parse_time_interval,
  814. name = ts.string,
  815. mult = ts.number + ts.string / tonumber
  816. }),
  817. }, {extra_fields = lua_redis.config_schema}),
  818. config = {
  819. expiry = default_expiry,
  820. prefix = default_prefix,
  821. buckets = {
  822. {
  823. time = 60 * 60,
  824. name = '1h',
  825. mult = 1.5,
  826. },
  827. {
  828. time = 60 * 60 * 24 * 30,
  829. name = '1m',
  830. mult = 1.0,
  831. }
  832. }, -- What buckets should be used, default 1h and 1month
  833. },
  834. init = reputation_redis_init,
  835. get_token = reputation_redis_get_token,
  836. set_token = reputation_redis_set_token,
  837. },
  838. dns = {
  839. schema = ts.shape{
  840. list = ts.string,
  841. },
  842. config = {
  843. -- list = rep.example.com
  844. },
  845. get_token = reputation_dns_get_token,
  846. -- No set token for DNS
  847. init = reputation_dns_init,
  848. }
  849. }
  850. local function is_rule_applicable(task, rule)
  851. local ip = task:get_from_ip()
  852. if not (rule.selector.config.outbound and rule.selector.config.inbound) then
  853. if rule.selector.config.outbound then
  854. if not (task:get_user() or (ip and ip:is_local())) then
  855. return false
  856. end
  857. elseif rule.selector.config.inbound then
  858. if task:get_user() or (ip and ip:is_local()) then
  859. return false
  860. end
  861. end
  862. end
  863. if rule.selector.config.whitelisted_ip_map then
  864. if rule.config.whitelisted_ip_map:get_key(ip) then
  865. return false
  866. end
  867. end
  868. return true
  869. end
  870. local function reputation_filter_cb(task, rule)
  871. if (is_rule_applicable(task, rule)) then
  872. rule.selector.filter(task, rule, rule.backend)
  873. end
  874. end
  875. local function reputation_postfilter_cb(task, rule)
  876. if (is_rule_applicable(task, rule)) then
  877. rule.selector.postfilter(task, rule, rule.backend)
  878. end
  879. end
  880. local function reputation_idempotent_cb(task, rule)
  881. if (is_rule_applicable(task, rule)) then
  882. rule.selector.idempotent(task, rule, rule.backend)
  883. end
  884. end
  885. local function callback_gen(cb, rule)
  886. return function(task)
  887. if rule.enabled then
  888. cb(task, rule)
  889. end
  890. end
  891. end
  892. local function parse_rule(name, tbl)
  893. local sel_type,sel_conf = fun.head(tbl.selector)
  894. local selector = selectors[sel_type]
  895. if not selector then
  896. rspamd_logger.errx(rspamd_config, "unknown selector defined for rule %s: %s", name,
  897. sel_type)
  898. return
  899. end
  900. local bk_type,bk_conf = fun.head(tbl.backend)
  901. local backend = backends[bk_type]
  902. if not backend then
  903. rspamd_logger.errx(rspamd_config, "unknown backend defined for rule %s: %s", name,
  904. tbl.backend.type)
  905. return
  906. end
  907. -- Allow config override
  908. local rule = {
  909. selector = lua_util.shallowcopy(selector),
  910. backend = lua_util.shallowcopy(backend),
  911. config = {}
  912. }
  913. -- Override default config params
  914. rule.backend.config = lua_util.override_defaults(rule.backend.config, bk_conf)
  915. if backend.schema then
  916. local checked,schema_err = backend.schema:transform(rule.backend.config)
  917. if not checked then
  918. rspamd_logger.errx(rspamd_config, "cannot parse backend config for %s: %s",
  919. sel_type, schema_err)
  920. return
  921. end
  922. rule.backend.config = checked
  923. end
  924. rule.selector.config = lua_util.override_defaults(rule.selector.config, sel_conf)
  925. if selector.schema then
  926. local checked,schema_err = selector.schema:transform(rule.selector.config)
  927. if not checked then
  928. rspamd_logger.errx(rspamd_config, "cannot parse selector config for %s: %s (%s)",
  929. sel_type,
  930. schema_err, sel_conf)
  931. return
  932. end
  933. rule.selector.config = checked
  934. end
  935. -- Generic options
  936. tbl.selector = nil
  937. tbl.backend = nil
  938. rule.config = lua_util.override_defaults(rule.config, tbl)
  939. if rule.config.whitelisted_ip then
  940. rule.config.whitelisted_ip_map = lua_maps.rspamd_map_add_from_ucl(rule.whitelisted_ip,
  941. 'radix',
  942. 'Reputation whitelist for ' .. name)
  943. end
  944. local symbol = name
  945. if tbl.symbol then
  946. symbol = tbl.symbol
  947. end
  948. rule.symbol = symbol
  949. rule.enabled = true
  950. if rule.selector.init then
  951. rule.enabled = false
  952. end
  953. if rule.backend.init then
  954. rule.enabled = false
  955. end
  956. -- Perform additional initialization if needed
  957. rspamd_config:add_on_load(function(cfg, ev_base, worker)
  958. if rule.selector.init then
  959. if not rule.selector.init(rule, cfg, ev_base, worker) then
  960. rule.enabled = false
  961. rspamd_logger.errx(rspamd_config, 'Cannot init selector %s (backend %s) for symbol %s',
  962. sel_type, bk_type, rule.symbol)
  963. else
  964. rule.enabled = true
  965. end
  966. end
  967. if rule.backend.init then
  968. if not rule.backend.init(rule, cfg, ev_base, worker) then
  969. rule.enabled = false
  970. rspamd_logger.errx(rspamd_config, 'Cannot init backend (%s) for rule %s for symbol %s',
  971. bk_type, sel_type, rule.symbol)
  972. else
  973. rule.enabled = true
  974. end
  975. end
  976. if rule.enabled then
  977. rspamd_logger.infox(rspamd_config, 'Enable %s (%s backend) rule for symbol %s',
  978. sel_type, bk_type, rule.symbol)
  979. end
  980. end)
  981. -- We now generate symbol for checking
  982. local id = rspamd_config:register_symbol{
  983. name = symbol,
  984. type = 'normal',
  985. callback = callback_gen(reputation_filter_cb, rule),
  986. }
  987. if rule.config.split_symbols then
  988. rspamd_config:register_symbol{
  989. name = symbol .. '_HAM',
  990. type = 'virtual',
  991. parent = id,
  992. }
  993. rspamd_config:register_symbol{
  994. name = symbol .. '_SPAM',
  995. type = 'virtual',
  996. parent = id,
  997. }
  998. end
  999. if rule.selector.dependencies then
  1000. fun.each(function(d)
  1001. rspamd_config:register_dependency(symbol, d)
  1002. end, rule.selector.dependencies)
  1003. end
  1004. if rule.selector.postfilter then
  1005. -- Also register a postfilter
  1006. rspamd_config:register_symbol{
  1007. name = symbol .. '_POST',
  1008. type = 'postfilter,nostat',
  1009. callback = callback_gen(reputation_postfilter_cb, rule),
  1010. }
  1011. end
  1012. if rule.selector.idempotent then
  1013. -- Has also idempotent component (e.g. saving data to the backend)
  1014. rspamd_config:register_symbol{
  1015. name = symbol .. '_IDEMPOTENT',
  1016. type = 'idempotent',
  1017. callback = callback_gen(reputation_idempotent_cb, rule),
  1018. }
  1019. end
  1020. end
  1021. redis_params = lua_redis.parse_redis_server('reputation')
  1022. local opts = rspamd_config:get_all_opt("reputation")
  1023. -- Initialization part
  1024. if not (opts and type(opts) == 'table') then
  1025. rspamd_logger.infox(rspamd_config, 'Module is unconfigured')
  1026. return
  1027. end
  1028. if opts['rules'] then
  1029. for k,v in pairs(opts['rules']) do
  1030. if not ((v or E).selector) then
  1031. rspamd_logger.errx(rspamd_config, "no selector defined for rule %s", k)
  1032. else
  1033. parse_rule(k, v)
  1034. end
  1035. end
  1036. else
  1037. lua_util.disable_module(N, "config")
  1038. end