aboutsummaryrefslogtreecommitdiffstats
path: root/src/plugins/lua/ip_score.lua
blob: a1a88d4b2a84efedfc3e2f007b4c5392da3fa8fe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
--[[
Copyright (c) 2011-2015, Vsevolod Stakhov <vsevolod@highsecure.ru>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
]]--

if confighelp then
  return
end

-- IP score is a module that set ip score of specific ip, asn, country
local rspamd_logger = require "rspamd_logger"
local rspamd_regexp = require "rspamd_regexp"
local rspamd_util = require "rspamd_util"

-- Default settings
local redis_params = nil
local whitelist = nil
local asn_cc_whitelist = nil
local check_authed = false
local check_local = false

local options = {
  actions = { -- how each action is treated in scoring
    ['reject'] = 1.0,
    ['add header'] = 0.25,
    ['rewrite subject'] = 0.25,
    ['no action'] = 1.0
  },
  scores = { -- how each component is evaluated
    ['asn'] = 0.5,
    ['country'] = 0.1,
    ['ipnet'] = 0.8,
    ['ip'] = 1.0
  },
  symbol = 'IP_SCORE', -- symbol to be inserted
  hash = 'ip_score', -- hash table in redis used for storing scores
  asn_prefix = 'a:', -- prefix for ASN hashes
  country_prefix = 'c:', -- prefix for country hashes
  ipnet_prefix = 'n:', -- prefix for ipnet hashes
  servers = '', -- list of servers
  lower_bound = 10, -- minimum number of messages to be scored
  metric = 'default',
  min_score = nil,
  max_score = nil,
  score_divisor = 1,
}

local asn_re = rspamd_regexp.create_cached("[\\|\\s]")

local function ip_score_hash_key(asn, country, ipnet, ip)
  -- We use the most common attribute as hashing key
  if country then
    return country
  elseif asn then
    return asn
  elseif ipnet then
    return ipnet
  else
    return ip:to_string()
  end
end

local function ip_score_get_task_vars(task)
  local pool = task:get_mempool()
  local asn, country, ipnet
  if pool:get_variable("asn") then
    asn = pool:get_variable("asn")
  end
  if pool:get_variable("country") then
    country = pool:get_variable("country")
  end
  if pool:get_variable("ipnet") then
    ipnet = pool:get_variable("ipnet")
  end

  return asn, country, ipnet
end

local function normalize_score(sc, total, mult)
  if total < options['lower_bound'] then
    return 0
  end
  return mult * rspamd_util.tanh(2.718281 * sc / total)
end

-- Set score based on metric's action
local ip_score_set = function(task)
  local function new_score_set(score, old_score, old_total)
    local new_total
    if old_total == -1 or old_total ~= old_total then
      new_total = 1
    else
      new_total = old_total + 1
    end

    if score ~= score then
      score = 0
    end

    return old_score + score, new_total
  end

  local score_set_cb = function(err)
    if err then
      rspamd_logger.infox(task, 'got error while IP score changing: %1', err)
    end
  end

  local ip = task:get_from_ip()
  if not check_authed and task:get_user() then
    return
  end
  local action = task:get_metric_action(options['metric'])
  if not ip or not ip:is_valid() then
    return
  end
  if not check_local and ip:is_local() then
    return
  end

  local pool = task:get_mempool()
  local asn, country, ipnet = ip_score_get_task_vars(task)

  if not pool:has_variable('ip_score') or not asn or not country or not ipnet then
    return
  end

  local asn_score,total_asn,
        country_score,total_country,
        ipnet_score,total_ipnet,
        ip_score, total_ip = pool:get_variable('ip_score',
        'double,double,double,double,double,double,double,double')

  local score_mult = 0
  if options['actions'][action] then
    score_mult = options['actions'][action]
  end
  local score = task:get_metric_score(options['metric'])[1]
  if action == 'no action' and score > 0 then
    score_mult = 0
  end

  score = score_mult * rspamd_util.tanh (2.718281 * (score/options['score_divisor']))

  local hkey = ip_score_hash_key(asn, country, ipnet, ip)

  asn_score,total_asn = new_score_set(score, asn_score, total_asn)
  country_score,total_country = new_score_set(score, country_score, total_country)
  ipnet_score,total_ipnet = new_score_set(score, ipnet_score, total_ipnet)
  ip_score,total_ip = new_score_set(score, ip_score, total_ip)
  local redis_args = {options['hash'],
    options['asn_prefix'] .. asn, string.format('%f|%d', asn_score, total_asn),
    options['country_prefix'] .. country, string.format('%f|%d', country_score, total_country),
    options['ipnet_prefix'] .. ipnet, string.format('%f|%d', ipnet_score, total_ipnet),
    ip:to_string(), string.format('%f|%d', ip_score, total_ip)}

  local ret = rspamd_redis_make_request(task,
    redis_params, -- connect params
    hkey, -- hash key
    true, -- is write
    score_set_cb, --callback
    'HMSET', -- command
    redis_args -- arguments
  )
  if not ret then
    rspamd_logger.errx(task, 'error connecting to redis')
    return
  end

  -- Now insert final result
  asn_score = normalize_score(asn_score, total_asn, options['scores']['asn'])
  country_score = normalize_score(country_score, total_country,
      options['scores']['country'])
  ipnet_score = normalize_score(ipnet_score, total_ipnet,
       options['scores']['ipnet'])
  ip_score = normalize_score(ip_score, total_ip, options['scores']['ip'])

  local total_score = 0.0
  local description_t = {}

  if ip_score ~= 0 then
    total_score = total_score + ip_score
    table.insert(description_t, 'ip: ' .. '(' .. string.format('%.2f', ip_score * 10) .. ')')
  end
  if ipnet_score ~= 0 then
    total_score = total_score + ipnet_score
    table.insert(description_t, 'ipnet: ' .. ipnet .. '(' .. string.format('%.2f', ipnet_score * 10) .. ')')
  end
  if asn_score ~= 0 then
    total_score = total_score + asn_score
    table.insert(description_t, 'asn: ' .. asn .. '(' .. string.format('%.2f', asn_score * 10) .. ')')
  end
  if country_score ~= 0 then
    total_score = total_score + country_score
    table.insert(description_t, 'country: ' .. country .. '(' .. string.format('%.2f', country_score * 10) .. ')')
  end

  if options['max_score'] and (total_score*10) > options['max_score'] then
    total_score = options['max_score']/10
  end
  if options['min_score'] and (total_score*10) < options['min_score'] then
    total_score = options['min_score']/10
  end

  if total_score ~= 0 then
    task:insert_result(options['symbol'], total_score, table.concat(description_t, ', '))
  end
end

-- Check score for ip in keystorage
local ip_score_check = function(task)
  local asn, country, ipnet = ip_score_get_task_vars(task)
  local ip = task:get_from_ip()

  local ip_score_redis_cb = function(err, data)
    if err then
      rspamd_logger.errx(task, 'Redis error: %s', err)
      -- XXX: upstreams
    end
    local function calculate_score(score)
      local parts = asn_re:split(score)
      local rep = tonumber(parts[1])
      local total = tonumber(parts[2])

      if rep ~= rep then rep = 0 end
      if total ~= total then total = 0 end

      return rep, total
    end

    if err then
      -- Key is not found or error occurred
      return
    elseif data then
      -- Scores and total number of messages per bucket
      local asn_score,total_asn,
        country_score,total_country,
        ipnet_score,total_ipnet,
        ip_score, total_ip = 0, -1, 0, -1, 0, -1, 0, -1
      if data[1] and type(data[1]) ~= 'userdata' then
        asn_score,total_asn = calculate_score(data[1])
      end
      if data[2] and type(data[2]) ~= 'userdata' then
        country_score,total_country = calculate_score(data[2])
      end
      if data[3] and type(data[3]) ~= 'userdata' then
        ipnet_score,total_ipnet = calculate_score(data[3])
      end
      if data[4] and type(data[4]) ~= 'userdata' then
        ip_score,total_ip = calculate_score(data[4])
      end
      -- Save everything for the post filter
      task:get_mempool():set_variable('ip_score', asn_score,total_asn,
        country_score,total_country,
        ipnet_score,total_ipnet,
        ip_score, total_ip)
    end
  end

  local function create_get_command()
    local cmd = 'HMGET'

    local args = {options['hash']}

    if asn then
      table.insert(args, options['asn_prefix'] .. asn)
    else
      -- fake arg
      table.insert(args, options['asn_prefix'])
    end
    if country then
      table.insert(args, options['country_prefix'] .. country)
    else
      -- fake arg
      table.insert(args, options['country_prefix'])
    end
    if ipnet then
      table.insert(args, options['ipnet_prefix'] .. ipnet)
    else
      -- fake arg
      table.insert(args, options['ipnet_prefix'])
    end

    table.insert(args, ip:to_string())

    return cmd, args
  end

  if task:get_user() or (ip and ip:is_local()) then
    rspamd_logger.infox(task, "skip IP Score for local networks and authorized users")
    return
  end
  if ip:is_valid() then
    -- Check IP whitelist
    if whitelist then
      if whitelist:get_key(task:get_from_ip()) then
        -- Address is whitelisted
        return
      end
    end
    -- Check ASN & country whitelist
    if asn_cc_whitelist then
      if asn_cc_whitelist:get_key(country) then
        return
      end
      if asn_cc_whitelist:get_key(asn) then
        return
      end
    end

    local cmd, args = create_get_command()

    local ret = rspamd_redis_make_request(task,
      redis_params, -- connect params
      ip_score_hash_key(asn, country, ipnet, ip), -- hash key
      false, -- is write
      ip_score_redis_cb, --callback
      cmd, -- command
      args -- arguments
    )
    if not ret then
      rspamd_logger.errx(task, 'error connecting to redis')
    end
  end
end


-- Configuration options
local configure_ip_score_module = function()
  local opts = rspamd_config:get_all_opt('options')
  if opts and type(opts) ~= 'table' then
    if type(opts['check_authed']) == 'boolean' then
      check_authed = opts['check_authed']
    end
    if type(opts['check_local']) == 'boolean' then
      check_local = opts['check_local']
    end
  end
  opts = rspamd_config:get_all_opt('ip_score')
  if not opts then return end
  for k,v in pairs(opts) do
    options[k] = v
  end
  redis_params = rspamd_parse_redis_server('ip_score')
  if not redis_params then
    rspamd_logger.infox(rspamd_config, 'no servers are specified')
    return
  end
  asn_cc_whitelist = rspamd_map_add('ip_score', 'asn_cc_whitelist', 'map',
    'IP score whitelisted ASNs/countries')
  whitelist = rspamd_map_add('ip_score', 'whitelist', 'radix',
    'IP score whitelisted ips')
  return true
end


if not configure_ip_score_module() then return end
if redis_params then
  -- Register ip_score module
  rspamd_config:register_symbol({
    name = 'IPSCORE_SAVE',
    type = 'postfilter',
    priority = 5,
    callback = ip_score_set,
  })
  rspamd_config:register_symbol({
    name = options['symbol'],
    callback = ip_score_check,
  })
end