haraka-plugin-karma 2.1.8 → 2.3.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 +22 -0
- package/README.md +3 -2
- package/config/karma.ini +12 -0
- package/index.js +151 -157
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
4
4
|
|
|
5
5
|
### Unreleased
|
|
6
6
|
|
|
7
|
+
### [2.3.0] - 2026-05-05
|
|
8
|
+
|
|
9
|
+
- allow rspamd to greylist image-only spam
|
|
10
|
+
- allow rspamd to greylist Google Groups messages
|
|
11
|
+
|
|
12
|
+
### [2.2.0] - 2026-03-31
|
|
13
|
+
|
|
14
|
+
#### Added
|
|
15
|
+
|
|
16
|
+
- allow rspamd to greylist ASNs when SA & rspamd agree
|
|
17
|
+
|
|
18
|
+
#### Changed
|
|
19
|
+
|
|
20
|
+
- replace to_object with arrays
|
|
21
|
+
- deps(all): updated to latest
|
|
22
|
+
- style: ES2024 updates throughout
|
|
23
|
+
- test: test runner is now node:test
|
|
24
|
+
- test: coverage 58% -> 86%
|
|
25
|
+
- remove unnecessary done callbacks in synchronous tests (#65)
|
|
26
|
+
|
|
7
27
|
### [2.1.8] - 2025-10-27
|
|
8
28
|
|
|
9
29
|
- fix: use optional chaining in should_we_skip, fixes #63
|
|
@@ -149,3 +169,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
149
169
|
[2.1.6]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.6
|
|
150
170
|
[2.1.7]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.7
|
|
151
171
|
[2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
|
|
172
|
+
[2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
|
|
173
|
+
[2.3.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.3.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
|
@@ -63,6 +63,18 @@ plugins=send_email, tls, access, helo.checks, headers, rspamd, spamassassin, cla
|
|
|
63
63
|
hooks=rcpt, queue, queue_outbound
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
[greylist]
|
|
67
|
+
; allow rspamd to greylist (DENYSOFT) instead of karma intercepting the denial.
|
|
68
|
+
; This is only relevant when rspamd is NOT in [deny_excludes] plugins.
|
|
69
|
+
;
|
|
70
|
+
; asn[] = 55286 ; ASN numbers eligible for greylisting
|
|
71
|
+
; asn[] = 33182
|
|
72
|
+
; asn[] = 46717
|
|
73
|
+
;
|
|
74
|
+
; spamassassin_score = SpamAssassin hits score must exceed this value
|
|
75
|
+
; spamassassin_score = 5
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
[spammy_tlds]
|
|
67
79
|
; award negative karma to spammy TLDs
|
|
68
80
|
; caution, awarding karma > msg_negative_limit may blacklist that TLD
|
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
|
|
376
|
+
if (asn?.asn && asn.asn_score > 0) {
|
|
398
377
|
connection.logdebug(this, `${trg} neighbors: ${delay}`)
|
|
399
378
|
delay = delay - 2
|
|
400
379
|
}
|
|
@@ -414,6 +393,33 @@ exports.should_we_skip = function (connection) {
|
|
|
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 ?? 0,
|
|
406
|
+
)
|
|
407
|
+
const saThreshold = parseFloat(this.cfg.greylist?.spamassassin_score ?? 5)
|
|
408
|
+
if (saScore >= saThreshold) return true
|
|
409
|
+
|
|
410
|
+
if (connection.transaction.header.get('X-Google-Group-Id')) return true
|
|
411
|
+
|
|
412
|
+
// image-only spam: message body is a single embedded image
|
|
413
|
+
if (
|
|
414
|
+
connection.transaction.header
|
|
415
|
+
.get('content-type')
|
|
416
|
+
?.startsWith('multipart/related')
|
|
417
|
+
)
|
|
418
|
+
return true
|
|
419
|
+
|
|
420
|
+
return false
|
|
421
|
+
}
|
|
422
|
+
|
|
417
423
|
exports.should_we_deny = function (next, connection, hook) {
|
|
418
424
|
const r = connection.results.get('karma')
|
|
419
425
|
if (!r) return next()
|
|
@@ -435,7 +441,7 @@ exports.should_we_deny = function (next, connection, hook) {
|
|
|
435
441
|
if (score > negative_limit) {
|
|
436
442
|
return this.apply_tarpit(connection, hook, score, next)
|
|
437
443
|
}
|
|
438
|
-
if (!this.deny_hooks
|
|
444
|
+
if (!this.deny_hooks.includes(hook)) {
|
|
439
445
|
return this.apply_tarpit(connection, hook, score, next)
|
|
440
446
|
}
|
|
441
447
|
|
|
@@ -457,19 +463,21 @@ exports.should_we_deny = function (next, connection, hook) {
|
|
|
457
463
|
exports.hook_deny = function (next, connection, params) {
|
|
458
464
|
if (this.should_we_skip(connection)) return next()
|
|
459
465
|
|
|
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]
|
|
466
|
+
const [pi_rc, , pi_name, , , pi_hook] = params
|
|
466
467
|
|
|
467
468
|
// exceptions, whose 'DENY' should not be captured
|
|
468
469
|
if (pi_name) {
|
|
469
470
|
if (pi_name === 'karma') return next()
|
|
470
|
-
if (this.deny_exclude_plugins
|
|
471
|
+
if (this.deny_exclude_plugins.includes(pi_name)) return next()
|
|
472
|
+
// allow rspamd to greylist when configured ASN/score conditions are met
|
|
473
|
+
if (
|
|
474
|
+
pi_rc === constants.DENYSOFT &&
|
|
475
|
+
pi_name === 'rspamd' &&
|
|
476
|
+
this.should_rspamd_greylist(connection)
|
|
477
|
+
)
|
|
478
|
+
return next()
|
|
471
479
|
}
|
|
472
|
-
if (pi_hook && this.deny_exclude_hooks
|
|
480
|
+
if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
|
|
473
481
|
|
|
474
482
|
if (!connection.results) return next(constants.OK) // resume the connection
|
|
475
483
|
|
|
@@ -554,9 +562,7 @@ exports.hook_unrecognized_command = function (next, connection, params) {
|
|
|
554
562
|
return this.should_we_deny(next, connection, 'unrecognized_command')
|
|
555
563
|
}
|
|
556
564
|
|
|
557
|
-
exports.ip_history_from_redis = function (next, connection) {
|
|
558
|
-
const plugin = this
|
|
559
|
-
|
|
565
|
+
exports.ip_history_from_redis = async function (next, connection) {
|
|
560
566
|
if (this.should_we_skip(connection)) return next()
|
|
561
567
|
|
|
562
568
|
const expire = (this.cfg.redis.expire_days || 60) * 86400 // to days
|
|
@@ -565,48 +571,47 @@ exports.ip_history_from_redis = function (next, connection) {
|
|
|
565
571
|
// redis plugin is emitting errors, no need to here
|
|
566
572
|
if (!this.db) return next()
|
|
567
573
|
|
|
568
|
-
|
|
569
|
-
.hGetAll(dbkey)
|
|
570
|
-
.then((dbr) => {
|
|
571
|
-
if (dbr === null) {
|
|
572
|
-
plugin.init_ip(dbkey, connection.remote.ip, expire)
|
|
573
|
-
return next()
|
|
574
|
-
}
|
|
574
|
+
try {
|
|
575
|
+
const dbr = await this.db.hGetAll(dbkey)
|
|
575
576
|
|
|
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
|
-
}
|
|
577
|
+
if (dbr === null) {
|
|
578
|
+
this.init_ip(dbkey, connection.remote.ip, expire)
|
|
579
|
+
return next()
|
|
580
|
+
}
|
|
592
581
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
582
|
+
this.db
|
|
583
|
+
.multi()
|
|
584
|
+
.hIncrBy(dbkey, 'connections', 1) // increment total conn
|
|
585
|
+
.expire(dbkey, expire) // extend expiration
|
|
586
|
+
.exec()
|
|
587
|
+
.catch((err) => {
|
|
588
|
+
connection.results.add(this, { err })
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const results = {
|
|
592
|
+
good: dbr.good,
|
|
593
|
+
bad: dbr.bad,
|
|
594
|
+
connections: dbr.connections,
|
|
595
|
+
history: Number(dbr.good || 0) - Number(dbr.bad || 0),
|
|
596
|
+
emit: true,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Careful: don't become self-fulfilling prophecy.
|
|
600
|
+
if (Number(dbr.good || 0) > 5 && Number(dbr.bad || 0) === 0) {
|
|
601
|
+
results.pass = 'all_good'
|
|
602
|
+
}
|
|
603
|
+
if (Number(dbr.bad || 0) > 5 && Number(dbr.good || 0) === 0) {
|
|
604
|
+
results.fail = 'all_bad'
|
|
605
|
+
}
|
|
600
606
|
|
|
601
|
-
|
|
607
|
+
connection.results.add(this, results)
|
|
602
608
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
})
|
|
609
|
+
this.check_awards(connection)
|
|
610
|
+
next()
|
|
611
|
+
} catch (err) {
|
|
612
|
+
connection.results.add(this, { err })
|
|
613
|
+
next()
|
|
614
|
+
}
|
|
610
615
|
}
|
|
611
616
|
|
|
612
617
|
exports.hook_mail = function (next, connection, params) {
|
|
@@ -696,7 +701,7 @@ exports.hook_data_post = function (next, connection) {
|
|
|
696
701
|
return this.should_we_deny(next, connection, 'data_post')
|
|
697
702
|
}
|
|
698
703
|
|
|
699
|
-
exports.increment = function (connection, key,
|
|
704
|
+
exports.increment = function (connection, key, _val) {
|
|
700
705
|
if (!this.db) return
|
|
701
706
|
|
|
702
707
|
this.db.hIncrBy(`karma|${connection.remote.ip}`, key, 1)
|
|
@@ -739,19 +744,15 @@ exports.get_award_loc_from_note = function (connection, award) {
|
|
|
739
744
|
if (obj) return obj
|
|
740
745
|
}
|
|
741
746
|
|
|
742
|
-
// connection.logdebug(this, `no txn note: ${award}`);
|
|
743
747
|
const obj = this.assemble_note_obj(connection, award)
|
|
744
748
|
if (obj) return obj
|
|
745
|
-
|
|
746
|
-
// connection.logdebug(this, `no conn note: ${award}`);
|
|
747
|
-
return
|
|
748
749
|
}
|
|
749
750
|
|
|
750
751
|
exports.get_award_loc_from_results = function (connection, loc_bits) {
|
|
751
752
|
let pi_name = loc_bits[1]
|
|
752
753
|
let notekey = loc_bits[2]
|
|
753
754
|
|
|
754
|
-
if (phase_prefixes
|
|
755
|
+
if (phase_prefixes.includes(pi_name)) {
|
|
755
756
|
pi_name = `${loc_bits[1]}.${loc_bits[2]}`
|
|
756
757
|
notekey = loc_bits[3]
|
|
757
758
|
}
|
|
@@ -759,11 +760,9 @@ exports.get_award_loc_from_results = function (connection, loc_bits) {
|
|
|
759
760
|
let obj
|
|
760
761
|
if (connection.transaction) obj = connection.transaction.results.get(pi_name)
|
|
761
762
|
|
|
762
|
-
// connection.logdebug(this, `no txn results: ${pi_name}`);
|
|
763
763
|
if (!obj) obj = connection.results.get(pi_name)
|
|
764
764
|
if (!obj) return
|
|
765
765
|
|
|
766
|
-
// connection.logdebug(this, `found results for ${pi_name}, ${notekey}`);
|
|
767
766
|
if (notekey) return obj[notekey]
|
|
768
767
|
return obj
|
|
769
768
|
}
|
|
@@ -832,7 +831,7 @@ exports.check_awards = function (connection) {
|
|
|
832
831
|
// test the desired condition
|
|
833
832
|
const bits = award_terms.split(/\s+/)
|
|
834
833
|
const award = parseFloat(bits[0])
|
|
835
|
-
if (
|
|
834
|
+
if (bits[1] !== 'if') {
|
|
836
835
|
// no if conditions
|
|
837
836
|
if (!note) continue // failed truth test
|
|
838
837
|
if (!wants) {
|
|
@@ -844,8 +843,6 @@ exports.check_awards = function (connection) {
|
|
|
844
843
|
if (note !== wants) continue // didn't match
|
|
845
844
|
}
|
|
846
845
|
|
|
847
|
-
// connection.loginfo(this, `check_awards, case matching for: ${wants}`
|
|
848
|
-
|
|
849
846
|
// the matching logic here is inverted, weeding out misses (continue)
|
|
850
847
|
// Matches fall through (break) to the apply_award below.
|
|
851
848
|
const condition = bits[2]
|
|
@@ -861,7 +858,6 @@ exports.check_awards = function (connection) {
|
|
|
861
858
|
break
|
|
862
859
|
case 'match':
|
|
863
860
|
if (Array.isArray(note)) {
|
|
864
|
-
// connection.logerror(this, 'matching an array');
|
|
865
861
|
if (new RegExp(wants, 'i').test(note)) break
|
|
866
862
|
}
|
|
867
863
|
if (note.toString().match(new RegExp(wants, 'i'))) break
|
|
@@ -891,7 +887,6 @@ exports.check_awards = function (connection) {
|
|
|
891
887
|
break
|
|
892
888
|
}
|
|
893
889
|
case 'in': // if in pass whitelisted
|
|
894
|
-
// let list = bits[3];
|
|
895
890
|
if (bits[4]) {
|
|
896
891
|
wants = bits[4]
|
|
897
892
|
}
|
|
@@ -915,25 +910,21 @@ exports.apply_award = function (connection, nl, award) {
|
|
|
915
910
|
return
|
|
916
911
|
}
|
|
917
912
|
|
|
918
|
-
|
|
919
|
-
nl = bits[0] // strip off @... if present
|
|
913
|
+
nl = nl.split('@')[0] // strip off @... if present
|
|
920
914
|
|
|
921
915
|
connection.results.incr(this, { score: award })
|
|
922
916
|
connection.logdebug(this, `applied ${nl}:${award}`)
|
|
923
917
|
|
|
924
|
-
let trimmed
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
: nl.substring(0, 19) === 'transaction.results'
|
|
930
|
-
? nl.substring(20)
|
|
931
|
-
: nl
|
|
918
|
+
let trimmed
|
|
919
|
+
if (nl.startsWith('notes.')) trimmed = nl.slice(6)
|
|
920
|
+
else if (nl.startsWith('results.')) trimmed = nl.slice(8)
|
|
921
|
+
else if (nl.startsWith('transaction.results.')) trimmed = nl.slice(20)
|
|
922
|
+
else trimmed = nl
|
|
932
923
|
|
|
933
|
-
if (trimmed.
|
|
934
|
-
if (trimmed.
|
|
935
|
-
if (trimmed.
|
|
936
|
-
if (trimmed.
|
|
924
|
+
if (trimmed.startsWith('rcpt_to.')) trimmed = trimmed.slice(8)
|
|
925
|
+
else if (trimmed.startsWith('mail_from.')) trimmed = trimmed.slice(10)
|
|
926
|
+
else if (trimmed.startsWith('connect.')) trimmed = trimmed.slice(8)
|
|
927
|
+
else if (trimmed.startsWith('data.')) trimmed = trimmed.slice(5)
|
|
937
928
|
|
|
938
929
|
if (award > 0) connection.results.add(this, { pass: trimmed })
|
|
939
930
|
if (award < 0) connection.results.add(this, { fail: trimmed })
|
|
@@ -944,7 +935,6 @@ exports.check_spammy_tld = function (mail_from, connection) {
|
|
|
944
935
|
if (mail_from.isNull()) return // null sender (bounce)
|
|
945
936
|
|
|
946
937
|
const from_tld = mail_from.host.split('.').pop()
|
|
947
|
-
// connection.logdebug(this, `from_tld: ${from_tld}`);
|
|
948
938
|
|
|
949
939
|
const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
|
|
950
940
|
if (tld_penalty === 0) return
|
|
@@ -967,7 +957,7 @@ exports.assemble_note_obj = function (prefix, key) {
|
|
|
967
957
|
const parts = key.split('.')
|
|
968
958
|
while (parts.length > 0) {
|
|
969
959
|
let next = parts.shift()
|
|
970
|
-
if (phase_prefixes
|
|
960
|
+
if (phase_prefixes.includes(next)) {
|
|
971
961
|
next = `${next}.${parts.shift()}`
|
|
972
962
|
}
|
|
973
963
|
note = note[next]
|
|
@@ -976,45 +966,44 @@ exports.assemble_note_obj = function (prefix, key) {
|
|
|
976
966
|
return note
|
|
977
967
|
}
|
|
978
968
|
|
|
979
|
-
exports.check_asn = function (connection, asnkey) {
|
|
969
|
+
exports.check_asn = async function (connection, asnkey) {
|
|
980
970
|
if (!this.db) return
|
|
981
971
|
|
|
982
972
|
const report_as = { name: this.name }
|
|
983
973
|
if (this.cfg.asn.report_as) report_as.name = this.cfg.asn.report_as
|
|
984
974
|
|
|
985
|
-
|
|
986
|
-
.hGetAll(asnkey)
|
|
987
|
-
.then((res) => {
|
|
988
|
-
if (res === null) {
|
|
989
|
-
const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
|
|
990
|
-
this.init_asn(asnkey, expire)
|
|
991
|
-
return
|
|
992
|
-
}
|
|
975
|
+
try {
|
|
976
|
+
const res = await this.db.hGetAll(asnkey)
|
|
993
977
|
|
|
994
|
-
|
|
995
|
-
const
|
|
978
|
+
if (res === null) {
|
|
979
|
+
const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
|
|
980
|
+
this.init_asn(asnkey, expire)
|
|
981
|
+
return
|
|
982
|
+
}
|
|
996
983
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (asn_score < -5) {
|
|
1000
|
-
connection.results.add(report_as, { fail: 'asn:history' })
|
|
1001
|
-
} else if (asn_score > 5) {
|
|
1002
|
-
connection.results.add(report_as, { pass: 'asn:history' })
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
984
|
+
this.db.hIncrBy(asnkey, 'connections', 1)
|
|
985
|
+
const asn_score = Number(res.good || 0) - Number(res.bad || 0)
|
|
1005
986
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
987
|
+
if (asn_score) {
|
|
988
|
+
connection.results.add(report_as, { asn_score })
|
|
989
|
+
if (asn_score < -5) {
|
|
990
|
+
connection.results.add(report_as, { fail: 'asn:history' })
|
|
991
|
+
} else if (asn_score > 5) {
|
|
992
|
+
connection.results.add(report_as, { pass: 'asn:history' })
|
|
1011
993
|
}
|
|
994
|
+
}
|
|
1012
995
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
996
|
+
if (Number(res.bad || 0) > 5 && Number(res.good || 0) === 0) {
|
|
997
|
+
connection.results.add(report_as, { fail: 'asn:all_bad' })
|
|
998
|
+
}
|
|
999
|
+
if (Number(res.good || 0) > 5 && Number(res.bad || 0) === 0) {
|
|
1000
|
+
connection.results.add(report_as, { pass: 'asn:all_good' })
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
connection.results.add(report_as, { emit: true })
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
connection.results.add(this, { err })
|
|
1006
|
+
}
|
|
1018
1007
|
}
|
|
1019
1008
|
|
|
1020
1009
|
exports.init_ip = async function (dbkey, rip, expire) {
|
|
@@ -1042,3 +1031,8 @@ exports.init_asn = function (asnkey, expire) {
|
|
|
1042
1031
|
.expire(asnkey, expire * 2) // keep ASN longer
|
|
1043
1032
|
.exec()
|
|
1044
1033
|
}
|
|
1034
|
+
|
|
1035
|
+
exports.stringToArray = (input) => {
|
|
1036
|
+
if (typeof input !== 'string') throw new Error('Input must be a string')
|
|
1037
|
+
return input.split(/[\s,;]+/).filter((item) => item) // split and remove empty items
|
|
1038
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haraka-plugin-karma",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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,
|