haraka-plugin-karma 2.1.7 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +3 -2
- package/config/karma.ini +28 -11
- package/index.js +156 -166
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
4
4
|
|
|
5
5
|
### Unreleased
|
|
6
6
|
|
|
7
|
+
### [2.2.0] - 2026-03-31
|
|
8
|
+
|
|
9
|
+
#### Added
|
|
10
|
+
|
|
11
|
+
- allow rspamd to greylist ASNs when SA & rspamd agree
|
|
12
|
+
|
|
13
|
+
#### Changed
|
|
14
|
+
|
|
15
|
+
- replace to_object with arrays
|
|
16
|
+
- deps(all): updated to latest
|
|
17
|
+
- style: ES2024 updates throughout
|
|
18
|
+
- test: test runner is now node:test
|
|
19
|
+
- test: coverage 58% -> 86%
|
|
20
|
+
- remove unnecessary done callbacks in synchronous tests (#65)
|
|
21
|
+
|
|
22
|
+
### [2.1.8] - 2025-10-27
|
|
23
|
+
|
|
24
|
+
- fix: use optional chaining in should_we_skip, fixes #63
|
|
25
|
+
- reduce ASN details saved to results store
|
|
26
|
+
- config: update plugin names
|
|
27
|
+
|
|
7
28
|
### [2.1.7] - 2025-01-31
|
|
8
29
|
|
|
9
30
|
- replace utils.in_array with [].includes
|
|
@@ -141,3 +162,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
141
162
|
[2.1.4]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.4
|
|
142
163
|
[2.1.5]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.5
|
|
143
164
|
[2.1.6]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.6
|
|
165
|
+
[2.1.7]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.7
|
|
166
|
+
[2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
|
|
167
|
+
[2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[![Build Status][ci-img]][ci-url]
|
|
2
2
|
[![Code Climate][clim-img]][clim-url]
|
|
3
|
+
[![Code Coverage][cov-img]][cov-url]
|
|
3
4
|
|
|
4
5
|
# Karma - A heuristics based reputation engine for the Haraka MTA
|
|
5
6
|
|
|
@@ -197,5 +198,5 @@ Expect to use karma _with_ content filters.
|
|
|
197
198
|
[ci-url]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml
|
|
198
199
|
[cov-img]: https://codecov.io/github/haraka/haraka-plugin-karma/coverage.svg
|
|
199
200
|
[cov-url]: https://codecov.io/github/haraka/haraka-plugin-karma
|
|
200
|
-
[clim-img]: https://
|
|
201
|
-
[clim-url]: https://
|
|
201
|
+
[clim-img]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma/maintainability.svg
|
|
202
|
+
[clim-url]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma
|
package/config/karma.ini
CHANGED
|
@@ -57,12 +57,29 @@ hooks=unrecognized_command,data,data_post,queue,queue_outbound
|
|
|
57
57
|
; karma captures and scores deny requests from other plugins, permitting finer
|
|
58
58
|
; control over connection handling. For plugins that should be able to reject
|
|
59
59
|
; the connection, add their name to the plugin list:
|
|
60
|
-
plugins=send_email, tls, access, helo.checks,
|
|
60
|
+
plugins=send_email, tls, access, helo.checks, headers, rspamd, spamassassin, clamd, attachment, limit
|
|
61
61
|
|
|
62
62
|
; hooks whose DENY rejections should be not be captured.
|
|
63
63
|
hooks=rcpt, queue, queue_outbound
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
[greylist]
|
|
67
|
+
; When a connection comes from one of the listed ASNs and both SpamAssassin
|
|
68
|
+
; and rspamd scores exceed their respective thresholds, allow rspamd to
|
|
69
|
+
; greylist (DENYSOFT) instead of karma intercepting the denial.
|
|
70
|
+
; This is only relevant when rspamd is NOT in [deny_excludes] plugins.
|
|
71
|
+
;
|
|
72
|
+
; asn[] = 55286 ; ASN numbers eligible for greylisting
|
|
73
|
+
; asn[] = 33182
|
|
74
|
+
; asn[] = 46717
|
|
75
|
+
;
|
|
76
|
+
; spamassassin_score = SpamAssassin hits score must exceed this value
|
|
77
|
+
; spamassassin_score = 5
|
|
78
|
+
;
|
|
79
|
+
; rspamd_score = rspamd score must exceed this value
|
|
80
|
+
; rspamd_score = 6
|
|
81
|
+
|
|
82
|
+
|
|
66
83
|
[spammy_tlds]
|
|
67
84
|
; award negative karma to spammy TLDs
|
|
68
85
|
; caution, awarding karma > msg_negative_limit may blacklist that TLD
|
|
@@ -227,16 +244,16 @@ early_talker = -3
|
|
|
227
244
|
104 = access | pass | equals | rcpt_to.access.whitelist | 8
|
|
228
245
|
|
|
229
246
|
; Scores for specific DNSBLs
|
|
230
|
-
111 =
|
|
231
|
-
112 =
|
|
232
|
-
113 =
|
|
233
|
-
114 =
|
|
234
|
-
115 =
|
|
235
|
-
116 =
|
|
236
|
-
117 =
|
|
237
|
-
118 =
|
|
238
|
-
119 =
|
|
239
|
-
120 =
|
|
247
|
+
111 = dns-list | fail | equals | b.barracudacentral.org | -7 | DNS Blacklist | Disinfect your host/network
|
|
248
|
+
112 = dns-list | fail | equals | truncate.gbudb.net | -5 | DNS Blacklist | Disinfect your host/network
|
|
249
|
+
113 = dns-list | fail | equals | psbl.surriel.com | -6 | DNS Blacklist | Disinfect your host/network
|
|
250
|
+
114 = dns-list | fail | equals | bl.spamcop.net | -3 | DNS Blacklist | Disinfect your host/network
|
|
251
|
+
115 = dns-list | fail | equals | dnsbl-1.uceprotect.net | -3 | DNS Blacklist | Disinfect your host/network
|
|
252
|
+
116 = dns-list | fail | equals | zen.spamhaus.org | -5 | DNS Blacklist | Disinfect your host/network
|
|
253
|
+
117 = dns-list | fail | equals | xbl.spamhaus.org | -6 | DNS Blacklist | Disinfect your host/network
|
|
254
|
+
118 = dns-list | fail | equals | cbl.abuseat.org | -5 | DNS Blacklist | Disinfect your host/network
|
|
255
|
+
119 = dns-list | fail | equals | dnsbl.justspam.org | -1 | DNS Blacklist | Disinfect your host/network
|
|
256
|
+
120 = dns-list | fail | equals | dnsbl.sorbs.net | -2 | DNS Blacklist | Clean up DNSBL listing
|
|
240
257
|
|
|
241
258
|
130 = helo.checks | fail | match | valid_hostname | -1 | HELO host invalid | Use valid HELO hostname
|
|
242
259
|
131 = helo.checks | pass | match | forward_dns | 1 | HELO host has forward DNS
|
package/index.js
CHANGED
|
@@ -3,38 +3,31 @@
|
|
|
3
3
|
|
|
4
4
|
const constants = require('haraka-constants')
|
|
5
5
|
const redis = require('redis')
|
|
6
|
-
const utils = require('haraka-utils')
|
|
7
6
|
|
|
8
|
-
const phase_prefixes =
|
|
9
|
-
'connect',
|
|
10
|
-
'helo',
|
|
11
|
-
'mail_from',
|
|
12
|
-
'rcpt_to',
|
|
13
|
-
'data',
|
|
14
|
-
])
|
|
7
|
+
const phase_prefixes = ['connect', 'helo', 'mail_from', 'rcpt_to', 'data']
|
|
15
8
|
|
|
16
9
|
exports.register = function () {
|
|
17
10
|
this.inherits('haraka-plugin-redis')
|
|
18
11
|
|
|
19
12
|
// set up defaults
|
|
20
|
-
this.deny_hooks =
|
|
13
|
+
this.deny_hooks = [
|
|
21
14
|
'unrecognized_command',
|
|
22
15
|
'helo',
|
|
23
16
|
'data',
|
|
24
17
|
'data_post',
|
|
25
18
|
'queue',
|
|
26
19
|
'queue_outbound',
|
|
27
|
-
]
|
|
28
|
-
this.deny_exclude_hooks =
|
|
29
|
-
this.deny_exclude_plugins =
|
|
20
|
+
]
|
|
21
|
+
this.deny_exclude_hooks = ['rcpt_to', 'queue', 'queue_outbound']
|
|
22
|
+
this.deny_exclude_plugins = [
|
|
30
23
|
'access',
|
|
31
24
|
'helo.checks',
|
|
32
|
-
'
|
|
25
|
+
'headers',
|
|
33
26
|
'spamassassin',
|
|
34
27
|
'mail_from.is_resolvable',
|
|
35
28
|
'clamd',
|
|
36
29
|
'tls',
|
|
37
|
-
]
|
|
30
|
+
]
|
|
38
31
|
|
|
39
32
|
this.load_karma_ini()
|
|
40
33
|
|
|
@@ -46,38 +39,35 @@ exports.register = function () {
|
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
exports.load_karma_ini = function () {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
plugin.cfg = plugin.config.get(
|
|
42
|
+
this.cfg = this.config.get(
|
|
52
43
|
'karma.ini',
|
|
53
44
|
{
|
|
54
45
|
booleans: ['+asn.enable'],
|
|
55
46
|
},
|
|
56
|
-
|
|
57
|
-
plugin.load_karma_ini()
|
|
58
|
-
},
|
|
47
|
+
() => this.load_karma_ini(),
|
|
59
48
|
)
|
|
60
49
|
|
|
61
|
-
|
|
50
|
+
this.merge_redis_ini()
|
|
62
51
|
|
|
63
|
-
const cfg =
|
|
64
|
-
if (cfg.deny
|
|
65
|
-
|
|
52
|
+
const cfg = this.cfg
|
|
53
|
+
if (cfg.deny?.hooks) {
|
|
54
|
+
this.deny_hooks = this.stringToArray(cfg.deny.hooks)
|
|
66
55
|
}
|
|
67
56
|
|
|
68
57
|
const e = cfg.deny_excludes
|
|
69
|
-
if (e
|
|
70
|
-
|
|
58
|
+
if (e?.hooks) {
|
|
59
|
+
this.deny_exclude_hooks = this.stringToArray(e.hooks)
|
|
71
60
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
plugin.deny_exclude_plugins = utils.to_object(e.plugins)
|
|
61
|
+
if (e?.plugins) {
|
|
62
|
+
this.deny_exclude_plugins = this.stringToArray(e.plugins)
|
|
75
63
|
}
|
|
76
64
|
|
|
77
65
|
if (cfg.result_awards) {
|
|
78
|
-
|
|
66
|
+
this.preparse_result_awards()
|
|
79
67
|
}
|
|
80
68
|
|
|
69
|
+
this.greylist_asns = cfg.greylist?.asn ?? []
|
|
70
|
+
|
|
81
71
|
if (!cfg.redis) cfg.redis = {}
|
|
82
72
|
if (!cfg.redis.host && cfg.redis.server_ip) {
|
|
83
73
|
cfg.redis.host = cfg.redis.server_ip // backwards compat
|
|
@@ -103,8 +93,7 @@ exports.results_init = async function (next, connection) {
|
|
|
103
93
|
// When discovered, apply the awards value
|
|
104
94
|
const todo = {}
|
|
105
95
|
for (const key in this.cfg.awards) {
|
|
106
|
-
|
|
107
|
-
todo[key] = award
|
|
96
|
+
todo[key] = this.cfg.awards[key].toString()
|
|
108
97
|
}
|
|
109
98
|
connection.results.add(this, { score: 0, todo })
|
|
110
99
|
} else {
|
|
@@ -158,18 +147,16 @@ exports.preparse_result_awards = function () {
|
|
|
158
147
|
}
|
|
159
148
|
|
|
160
149
|
exports.check_result = function (connection, message) {
|
|
161
|
-
// connection.loginfo(this, message);
|
|
162
150
|
// {"plugin":"karma","result":{"fail":"spamassassin.hits"}}
|
|
163
151
|
// {"plugin":"geoip","result":{"country":"CN"}}
|
|
164
152
|
|
|
165
153
|
const m = JSON.parse(message)
|
|
166
|
-
if (m
|
|
154
|
+
if (m?.result?.asn) {
|
|
167
155
|
this.check_result_asn(m.result.asn, connection)
|
|
168
156
|
}
|
|
169
157
|
if (!this.result_awards[m.plugin]) return // no awards for plugin
|
|
170
158
|
|
|
171
159
|
for (const r of Object.keys(m.result)) {
|
|
172
|
-
// each result in mess
|
|
173
160
|
if (r === 'emit') continue // r: pass, fail, skip, err, ...
|
|
174
161
|
|
|
175
162
|
const pi_prop = this.result_awards[m.plugin][r]
|
|
@@ -185,7 +172,6 @@ exports.check_result = function (connection, message) {
|
|
|
185
172
|
|
|
186
173
|
// do any award conditions match this result?
|
|
187
174
|
for (const thisAward of pi_prop) {
|
|
188
|
-
// each award...
|
|
189
175
|
// { id: '011', operator: 'equals', value: 'all_bad', award: '-2'}
|
|
190
176
|
const thisResArr = this.result_as_array(thisResult)
|
|
191
177
|
switch (thisAward.operator) {
|
|
@@ -216,13 +202,7 @@ exports.result_as_array = function (result) {
|
|
|
216
202
|
if (typeof result === 'number') return [result]
|
|
217
203
|
if (typeof result === 'boolean') return [result]
|
|
218
204
|
if (Array.isArray(result)) return result
|
|
219
|
-
if (typeof result === 'object')
|
|
220
|
-
const array = []
|
|
221
|
-
Object.keys(result).forEach((tr) => {
|
|
222
|
-
array.push(result[tr])
|
|
223
|
-
})
|
|
224
|
-
return array
|
|
225
|
-
}
|
|
205
|
+
if (typeof result === 'object') return Object.values(result)
|
|
226
206
|
this.loginfo(`what format is result: ${result}`)
|
|
227
207
|
return result
|
|
228
208
|
}
|
|
@@ -288,7 +268,7 @@ exports.check_result_match = function (thisResult, thisAward, conn) {
|
|
|
288
268
|
|
|
289
269
|
exports.check_result_length = function (thisResult, thisAward, conn) {
|
|
290
270
|
for (const element of thisResult) {
|
|
291
|
-
const [operator, qty] = thisAward.value.split(/\s+/)
|
|
271
|
+
const [operator, qty] = thisAward.value.split(/\s+/)
|
|
292
272
|
|
|
293
273
|
switch (operator) {
|
|
294
274
|
case 'eq':
|
|
@@ -313,9 +293,8 @@ exports.check_result_length = function (thisResult, thisAward, conn) {
|
|
|
313
293
|
}
|
|
314
294
|
|
|
315
295
|
exports.check_result_exists = function (thisResult, thisAward, conn) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const [operator, qty] = thisAward.value.split(/\s+/)
|
|
296
|
+
for (const _r of thisResult) {
|
|
297
|
+
const [operator] = thisAward.value.split(/\s+/)
|
|
319
298
|
|
|
320
299
|
switch (operator) {
|
|
321
300
|
case 'any':
|
|
@@ -336,7 +315,7 @@ exports.apply_tarpit = function (connection, hook, score, next) {
|
|
|
336
315
|
|
|
337
316
|
// If tarpit is enabled on the reset_transaction hook, Haraka doesn't
|
|
338
317
|
// wait. Then bad things happen, like a Haraka crash.
|
|
339
|
-
if (
|
|
318
|
+
if (['reset_transaction', 'queue'].includes(hook)) return next()
|
|
340
319
|
|
|
341
320
|
// no delay for senders with good karma
|
|
342
321
|
const k = connection.results.get('karma')
|
|
@@ -394,7 +373,7 @@ exports.tarpit_delay_msa = function (connection, delay, k) {
|
|
|
394
373
|
// Reduce delay for good ASN history
|
|
395
374
|
let asn = connection.results.get('asn')
|
|
396
375
|
if (!asn) asn = connection.results.get('geoip')
|
|
397
|
-
if (asn && asn.
|
|
376
|
+
if (asn?.asn && asn.asn_score > 0) {
|
|
398
377
|
connection.logdebug(this, `${trg} neighbors: ${delay}`)
|
|
399
378
|
delay = delay - 2
|
|
400
379
|
}
|
|
@@ -409,11 +388,41 @@ exports.tarpit_delay_msa = function (connection, delay, k) {
|
|
|
409
388
|
}
|
|
410
389
|
|
|
411
390
|
exports.should_we_skip = function (connection) {
|
|
412
|
-
if (connection.remote
|
|
413
|
-
if (connection.notes
|
|
391
|
+
if (connection.remote?.is_private) return true
|
|
392
|
+
if (connection.notes?.disable_karma) return true
|
|
414
393
|
return false
|
|
415
394
|
}
|
|
416
395
|
|
|
396
|
+
exports.should_rspamd_greylist = function (connection) {
|
|
397
|
+
// check connection's ASN is in the configured list
|
|
398
|
+
let asnr = connection.results.get('asn')
|
|
399
|
+
if (!asnr?.asn) asnr = connection.results.get('geoip')
|
|
400
|
+
if (!asnr?.asn) return false
|
|
401
|
+
if (!this.greylist_asns.includes(String(asnr.asn))) return false
|
|
402
|
+
|
|
403
|
+
// check SpamAssassin score exceeds configured threshold
|
|
404
|
+
const saScore = parseFloat(
|
|
405
|
+
connection.transaction?.results?.get('spamassassin')?.hits,
|
|
406
|
+
)
|
|
407
|
+
const saThreshold = parseFloat(this.cfg.greylist?.spamassassin_score)
|
|
408
|
+
if (isNaN(saScore) || isNaN(saThreshold) || saScore <= saThreshold)
|
|
409
|
+
return false
|
|
410
|
+
|
|
411
|
+
// check rspamd score exceeds configured threshold
|
|
412
|
+
const rspamdScore = parseFloat(
|
|
413
|
+
connection.transaction?.results?.get('rspamd')?.score,
|
|
414
|
+
)
|
|
415
|
+
const rspamdThreshold = parseFloat(this.cfg.greylist?.rspamd_score)
|
|
416
|
+
if (
|
|
417
|
+
isNaN(rspamdScore) ||
|
|
418
|
+
isNaN(rspamdThreshold) ||
|
|
419
|
+
rspamdScore <= rspamdThreshold
|
|
420
|
+
)
|
|
421
|
+
return false
|
|
422
|
+
|
|
423
|
+
return true
|
|
424
|
+
}
|
|
425
|
+
|
|
417
426
|
exports.should_we_deny = function (next, connection, hook) {
|
|
418
427
|
const r = connection.results.get('karma')
|
|
419
428
|
if (!r) return next()
|
|
@@ -435,7 +444,7 @@ exports.should_we_deny = function (next, connection, hook) {
|
|
|
435
444
|
if (score > negative_limit) {
|
|
436
445
|
return this.apply_tarpit(connection, hook, score, next)
|
|
437
446
|
}
|
|
438
|
-
if (!this.deny_hooks
|
|
447
|
+
if (!this.deny_hooks.includes(hook)) {
|
|
439
448
|
return this.apply_tarpit(connection, hook, score, next)
|
|
440
449
|
}
|
|
441
450
|
|
|
@@ -457,19 +466,21 @@ exports.should_we_deny = function (next, connection, hook) {
|
|
|
457
466
|
exports.hook_deny = function (next, connection, params) {
|
|
458
467
|
if (this.should_we_skip(connection)) return next()
|
|
459
468
|
|
|
460
|
-
|
|
461
|
-
// let pi_message = params[1];
|
|
462
|
-
const pi_name = params[2]
|
|
463
|
-
// let pi_function = params[3];
|
|
464
|
-
// let pi_params = params[4];
|
|
465
|
-
const pi_hook = params[5]
|
|
469
|
+
const [pi_rc, , pi_name, , , pi_hook] = params
|
|
466
470
|
|
|
467
471
|
// exceptions, whose 'DENY' should not be captured
|
|
468
472
|
if (pi_name) {
|
|
469
473
|
if (pi_name === 'karma') return next()
|
|
470
|
-
if (this.deny_exclude_plugins
|
|
474
|
+
if (this.deny_exclude_plugins.includes(pi_name)) return next()
|
|
475
|
+
// allow rspamd to greylist when configured ASN/score conditions are met
|
|
476
|
+
if (
|
|
477
|
+
pi_rc === constants.DENYSOFT &&
|
|
478
|
+
pi_name === 'rspamd' &&
|
|
479
|
+
this.should_rspamd_greylist(connection)
|
|
480
|
+
)
|
|
481
|
+
return next()
|
|
471
482
|
}
|
|
472
|
-
if (pi_hook && this.deny_exclude_hooks
|
|
483
|
+
if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
|
|
473
484
|
|
|
474
485
|
if (!connection.results) return next(constants.OK) // resume the connection
|
|
475
486
|
|
|
@@ -554,9 +565,7 @@ exports.hook_unrecognized_command = function (next, connection, params) {
|
|
|
554
565
|
return this.should_we_deny(next, connection, 'unrecognized_command')
|
|
555
566
|
}
|
|
556
567
|
|
|
557
|
-
exports.ip_history_from_redis = function (next, connection) {
|
|
558
|
-
const plugin = this
|
|
559
|
-
|
|
568
|
+
exports.ip_history_from_redis = async function (next, connection) {
|
|
560
569
|
if (this.should_we_skip(connection)) return next()
|
|
561
570
|
|
|
562
571
|
const expire = (this.cfg.redis.expire_days || 60) * 86400 // to days
|
|
@@ -565,48 +574,47 @@ exports.ip_history_from_redis = function (next, connection) {
|
|
|
565
574
|
// redis plugin is emitting errors, no need to here
|
|
566
575
|
if (!this.db) return next()
|
|
567
576
|
|
|
568
|
-
|
|
569
|
-
.hGetAll(dbkey)
|
|
570
|
-
.then((dbr) => {
|
|
571
|
-
if (dbr === null) {
|
|
572
|
-
plugin.init_ip(dbkey, connection.remote.ip, expire)
|
|
573
|
-
return next()
|
|
574
|
-
}
|
|
577
|
+
try {
|
|
578
|
+
const dbr = await this.db.hGetAll(dbkey)
|
|
575
579
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
.exec()
|
|
581
|
-
.catch((err) => {
|
|
582
|
-
connection.results.add(plugin, { err })
|
|
583
|
-
})
|
|
584
|
-
|
|
585
|
-
const results = {
|
|
586
|
-
good: dbr.good,
|
|
587
|
-
bad: dbr.bad,
|
|
588
|
-
connections: dbr.connections,
|
|
589
|
-
history: parseInt((dbr.good || 0) - (dbr.bad || 0)),
|
|
590
|
-
emit: true,
|
|
591
|
-
}
|
|
580
|
+
if (dbr === null) {
|
|
581
|
+
this.init_ip(dbkey, connection.remote.ip, expire)
|
|
582
|
+
return next()
|
|
583
|
+
}
|
|
592
584
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
585
|
+
this.db
|
|
586
|
+
.multi()
|
|
587
|
+
.hIncrBy(dbkey, 'connections', 1) // increment total conn
|
|
588
|
+
.expire(dbkey, expire) // extend expiration
|
|
589
|
+
.exec()
|
|
590
|
+
.catch((err) => {
|
|
591
|
+
connection.results.add(this, { err })
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
const results = {
|
|
595
|
+
good: dbr.good,
|
|
596
|
+
bad: dbr.bad,
|
|
597
|
+
connections: dbr.connections,
|
|
598
|
+
history: Number(dbr.good || 0) - Number(dbr.bad || 0),
|
|
599
|
+
emit: true,
|
|
600
|
+
}
|
|
600
601
|
|
|
601
|
-
|
|
602
|
+
// Careful: don't become self-fulfilling prophecy.
|
|
603
|
+
if (Number(dbr.good || 0) > 5 && Number(dbr.bad || 0) === 0) {
|
|
604
|
+
results.pass = 'all_good'
|
|
605
|
+
}
|
|
606
|
+
if (Number(dbr.bad || 0) > 5 && Number(dbr.good || 0) === 0) {
|
|
607
|
+
results.fail = 'all_bad'
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
connection.results.add(this, results)
|
|
602
611
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
})
|
|
612
|
+
this.check_awards(connection)
|
|
613
|
+
next()
|
|
614
|
+
} catch (err) {
|
|
615
|
+
connection.results.add(this, { err })
|
|
616
|
+
next()
|
|
617
|
+
}
|
|
610
618
|
}
|
|
611
619
|
|
|
612
620
|
exports.hook_mail = function (next, connection, params) {
|
|
@@ -696,7 +704,7 @@ exports.hook_data_post = function (next, connection) {
|
|
|
696
704
|
return this.should_we_deny(next, connection, 'data_post')
|
|
697
705
|
}
|
|
698
706
|
|
|
699
|
-
exports.increment = function (connection, key,
|
|
707
|
+
exports.increment = function (connection, key, _val) {
|
|
700
708
|
if (!this.db) return
|
|
701
709
|
|
|
702
710
|
this.db.hIncrBy(`karma|${connection.remote.ip}`, key, 1)
|
|
@@ -739,19 +747,15 @@ exports.get_award_loc_from_note = function (connection, award) {
|
|
|
739
747
|
if (obj) return obj
|
|
740
748
|
}
|
|
741
749
|
|
|
742
|
-
// connection.logdebug(this, `no txn note: ${award}`);
|
|
743
750
|
const obj = this.assemble_note_obj(connection, award)
|
|
744
751
|
if (obj) return obj
|
|
745
|
-
|
|
746
|
-
// connection.logdebug(this, `no conn note: ${award}`);
|
|
747
|
-
return
|
|
748
752
|
}
|
|
749
753
|
|
|
750
754
|
exports.get_award_loc_from_results = function (connection, loc_bits) {
|
|
751
755
|
let pi_name = loc_bits[1]
|
|
752
756
|
let notekey = loc_bits[2]
|
|
753
757
|
|
|
754
|
-
if (phase_prefixes
|
|
758
|
+
if (phase_prefixes.includes(pi_name)) {
|
|
755
759
|
pi_name = `${loc_bits[1]}.${loc_bits[2]}`
|
|
756
760
|
notekey = loc_bits[3]
|
|
757
761
|
}
|
|
@@ -759,11 +763,9 @@ exports.get_award_loc_from_results = function (connection, loc_bits) {
|
|
|
759
763
|
let obj
|
|
760
764
|
if (connection.transaction) obj = connection.transaction.results.get(pi_name)
|
|
761
765
|
|
|
762
|
-
// connection.logdebug(this, `no txn results: ${pi_name}`);
|
|
763
766
|
if (!obj) obj = connection.results.get(pi_name)
|
|
764
767
|
if (!obj) return
|
|
765
768
|
|
|
766
|
-
// connection.logdebug(this, `found results for ${pi_name}, ${notekey}`);
|
|
767
769
|
if (notekey) return obj[notekey]
|
|
768
770
|
return obj
|
|
769
771
|
}
|
|
@@ -832,7 +834,7 @@ exports.check_awards = function (connection) {
|
|
|
832
834
|
// test the desired condition
|
|
833
835
|
const bits = award_terms.split(/\s+/)
|
|
834
836
|
const award = parseFloat(bits[0])
|
|
835
|
-
if (
|
|
837
|
+
if (bits[1] !== 'if') {
|
|
836
838
|
// no if conditions
|
|
837
839
|
if (!note) continue // failed truth test
|
|
838
840
|
if (!wants) {
|
|
@@ -844,8 +846,6 @@ exports.check_awards = function (connection) {
|
|
|
844
846
|
if (note !== wants) continue // didn't match
|
|
845
847
|
}
|
|
846
848
|
|
|
847
|
-
// connection.loginfo(this, `check_awards, case matching for: ${wants}`
|
|
848
|
-
|
|
849
849
|
// the matching logic here is inverted, weeding out misses (continue)
|
|
850
850
|
// Matches fall through (break) to the apply_award below.
|
|
851
851
|
const condition = bits[2]
|
|
@@ -861,7 +861,6 @@ exports.check_awards = function (connection) {
|
|
|
861
861
|
break
|
|
862
862
|
case 'match':
|
|
863
863
|
if (Array.isArray(note)) {
|
|
864
|
-
// connection.logerror(this, 'matching an array');
|
|
865
864
|
if (new RegExp(wants, 'i').test(note)) break
|
|
866
865
|
}
|
|
867
866
|
if (note.toString().match(new RegExp(wants, 'i'))) break
|
|
@@ -891,7 +890,6 @@ exports.check_awards = function (connection) {
|
|
|
891
890
|
break
|
|
892
891
|
}
|
|
893
892
|
case 'in': // if in pass whitelisted
|
|
894
|
-
// let list = bits[3];
|
|
895
893
|
if (bits[4]) {
|
|
896
894
|
wants = bits[4]
|
|
897
895
|
}
|
|
@@ -915,25 +913,21 @@ exports.apply_award = function (connection, nl, award) {
|
|
|
915
913
|
return
|
|
916
914
|
}
|
|
917
915
|
|
|
918
|
-
|
|
919
|
-
nl = bits[0] // strip off @... if present
|
|
916
|
+
nl = nl.split('@')[0] // strip off @... if present
|
|
920
917
|
|
|
921
918
|
connection.results.incr(this, { score: award })
|
|
922
919
|
connection.logdebug(this, `applied ${nl}:${award}`)
|
|
923
920
|
|
|
924
|
-
let trimmed
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
: nl.substring(0, 19) === 'transaction.results'
|
|
930
|
-
? nl.substring(20)
|
|
931
|
-
: nl
|
|
921
|
+
let trimmed
|
|
922
|
+
if (nl.startsWith('notes.')) trimmed = nl.slice(6)
|
|
923
|
+
else if (nl.startsWith('results.')) trimmed = nl.slice(8)
|
|
924
|
+
else if (nl.startsWith('transaction.results.')) trimmed = nl.slice(20)
|
|
925
|
+
else trimmed = nl
|
|
932
926
|
|
|
933
|
-
if (trimmed.
|
|
934
|
-
if (trimmed.
|
|
935
|
-
if (trimmed.
|
|
936
|
-
if (trimmed.
|
|
927
|
+
if (trimmed.startsWith('rcpt_to.')) trimmed = trimmed.slice(8)
|
|
928
|
+
else if (trimmed.startsWith('mail_from.')) trimmed = trimmed.slice(10)
|
|
929
|
+
else if (trimmed.startsWith('connect.')) trimmed = trimmed.slice(8)
|
|
930
|
+
else if (trimmed.startsWith('data.')) trimmed = trimmed.slice(5)
|
|
937
931
|
|
|
938
932
|
if (award > 0) connection.results.add(this, { pass: trimmed })
|
|
939
933
|
if (award < 0) connection.results.add(this, { fail: trimmed })
|
|
@@ -944,7 +938,6 @@ exports.check_spammy_tld = function (mail_from, connection) {
|
|
|
944
938
|
if (mail_from.isNull()) return // null sender (bounce)
|
|
945
939
|
|
|
946
940
|
const from_tld = mail_from.host.split('.').pop()
|
|
947
|
-
// connection.logdebug(this, `from_tld: ${from_tld}`);
|
|
948
941
|
|
|
949
942
|
const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
|
|
950
943
|
if (tld_penalty === 0) return
|
|
@@ -967,7 +960,7 @@ exports.assemble_note_obj = function (prefix, key) {
|
|
|
967
960
|
const parts = key.split('.')
|
|
968
961
|
while (parts.length > 0) {
|
|
969
962
|
let next = parts.shift()
|
|
970
|
-
if (phase_prefixes
|
|
963
|
+
if (phase_prefixes.includes(next)) {
|
|
971
964
|
next = `${next}.${parts.shift()}`
|
|
972
965
|
}
|
|
973
966
|
note = note[next]
|
|
@@ -976,52 +969,44 @@ exports.assemble_note_obj = function (prefix, key) {
|
|
|
976
969
|
return note
|
|
977
970
|
}
|
|
978
971
|
|
|
979
|
-
exports.check_asn = function (connection, asnkey) {
|
|
972
|
+
exports.check_asn = async function (connection, asnkey) {
|
|
980
973
|
if (!this.db) return
|
|
981
974
|
|
|
982
975
|
const report_as = { name: this.name }
|
|
983
|
-
|
|
984
976
|
if (this.cfg.asn.report_as) report_as.name = this.cfg.asn.report_as
|
|
985
977
|
|
|
986
|
-
|
|
987
|
-
.hGetAll(asnkey)
|
|
988
|
-
.then((res) => {
|
|
989
|
-
if (res === null) {
|
|
990
|
-
const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
|
|
991
|
-
this.init_asn(asnkey, expire)
|
|
992
|
-
return
|
|
993
|
-
}
|
|
978
|
+
try {
|
|
979
|
+
const res = await this.db.hGetAll(asnkey)
|
|
994
980
|
|
|
995
|
-
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
asn_good: res.good,
|
|
1001
|
-
asn_bad: res.bad,
|
|
1002
|
-
emit: true,
|
|
1003
|
-
}
|
|
981
|
+
if (res === null) {
|
|
982
|
+
const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
|
|
983
|
+
this.init_asn(asnkey, expire)
|
|
984
|
+
return
|
|
985
|
+
}
|
|
1004
986
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
asn_results.fail = 'asn:history'
|
|
1008
|
-
} else if (asn_score > 5) {
|
|
1009
|
-
asn_results.pass = 'asn:history'
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
987
|
+
this.db.hIncrBy(asnkey, 'connections', 1)
|
|
988
|
+
const asn_score = Number(res.good || 0) - Number(res.bad || 0)
|
|
1012
989
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
990
|
+
if (asn_score) {
|
|
991
|
+
connection.results.add(report_as, { asn_score })
|
|
992
|
+
if (asn_score < -5) {
|
|
993
|
+
connection.results.add(report_as, { fail: 'asn:history' })
|
|
994
|
+
} else if (asn_score > 5) {
|
|
995
|
+
connection.results.add(report_as, { pass: 'asn:history' })
|
|
1018
996
|
}
|
|
997
|
+
}
|
|
1019
998
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
999
|
+
if (Number(res.bad || 0) > 5 && Number(res.good || 0) === 0) {
|
|
1000
|
+
connection.results.add(report_as, { fail: 'asn:all_bad' })
|
|
1001
|
+
}
|
|
1002
|
+
if (Number(res.good || 0) > 5 && Number(res.bad || 0) === 0) {
|
|
1003
|
+
connection.results.add(report_as, { pass: 'asn:all_good' })
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
connection.results.add(report_as, { emit: true })
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
connection.results.add(this, { err })
|
|
1009
|
+
}
|
|
1025
1010
|
}
|
|
1026
1011
|
|
|
1027
1012
|
exports.init_ip = async function (dbkey, rip, expire) {
|
|
@@ -1049,3 +1034,8 @@ exports.init_asn = function (asnkey, expire) {
|
|
|
1049
1034
|
.expire(asnkey, expire * 2) // keep ASN longer
|
|
1050
1035
|
.exec()
|
|
1051
1036
|
}
|
|
1037
|
+
|
|
1038
|
+
exports.stringToArray = (input) => {
|
|
1039
|
+
if (typeof input !== 'string') throw new Error('Input must be a string')
|
|
1040
|
+
return input.split(/[\s,;]+/).filter((item) => item) // split and remove empty items
|
|
1041
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haraka-plugin-karma",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A heuristics scoring and reputation engine for SMTP connections",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
"config"
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
|
+
"clean": "rm -rf node_modules package-lock.json",
|
|
11
12
|
"format": "npm run prettier:fix && npm run lint:fix",
|
|
12
|
-
"lint": "npx eslint
|
|
13
|
-
"lint:fix": "npx eslint
|
|
13
|
+
"lint": "npx eslint *.js test",
|
|
14
|
+
"lint:fix": "npx eslint *.js test --fix",
|
|
14
15
|
"prettier": "npx prettier . --check",
|
|
15
16
|
"prettier:fix": "npx prettier . --write --log-level=warn",
|
|
16
|
-
"test": "
|
|
17
|
-
"
|
|
18
|
-
"versions
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"test:coverage": "npx c8 --reporter=text --reporter=text-summary npm test",
|
|
19
|
+
"versions": "npx npm-dep-mgr check",
|
|
20
|
+
"versions:fix": "npx npm-dep-mgr update"
|
|
19
21
|
},
|
|
20
22
|
"repository": {
|
|
21
23
|
"type": "git",
|
|
@@ -31,15 +33,13 @@
|
|
|
31
33
|
},
|
|
32
34
|
"homepage": "https://github.com/haraka/haraka-plugin-karma#readme",
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"address-rfc2821": "^2.1.
|
|
35
|
-
"haraka-constants": "^1.0.
|
|
36
|
-
"haraka-
|
|
37
|
-
"haraka-plugin-redis": "^2.0.6",
|
|
38
|
-
"redis": "^4.6.13"
|
|
36
|
+
"address-rfc2821": "^2.1.5",
|
|
37
|
+
"haraka-constants": "^1.0.7",
|
|
38
|
+
"haraka-plugin-redis": "^2.0.11"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@haraka/eslint-config": "^2.0.
|
|
42
|
-
"haraka-test-fixtures": "^1.
|
|
41
|
+
"@haraka/eslint-config": "^2.0.4",
|
|
42
|
+
"haraka-test-fixtures": "^1.4.1"
|
|
43
43
|
},
|
|
44
44
|
"prettier": {
|
|
45
45
|
"singleQuote": true,
|