haraka-plugin-karma 2.3.0 → 2.4.1
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 +13 -5
- package/config/karma.ini +76 -35
- package/index.js +52 -45
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
4
4
|
|
|
5
5
|
### Unreleased
|
|
6
6
|
|
|
7
|
+
### [2.4.1] - 2026-05-13
|
|
8
|
+
|
|
9
|
+
- fix: after trapping a denysoft, return denysoft later (vs deny)
|
|
10
|
+
- config: update spammy TLDs
|
|
11
|
+
- fix(config): spamassassin hits -> score
|
|
12
|
+
|
|
13
|
+
### [2.4.0] - 2026-05-06
|
|
14
|
+
|
|
15
|
+
- extend spammy_tlds to support multi-label suffixes
|
|
16
|
+
- allow rspamd to deny(soft) image-only spam from specific ASNs
|
|
17
|
+
|
|
7
18
|
### [2.3.0] - 2026-05-05
|
|
8
19
|
|
|
9
20
|
- allow rspamd to greylist image-only spam
|
|
@@ -11,12 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
11
22
|
|
|
12
23
|
### [2.2.0] - 2026-03-31
|
|
13
24
|
|
|
14
|
-
#### Added
|
|
15
|
-
|
|
16
25
|
- allow rspamd to greylist ASNs when SA & rspamd agree
|
|
17
|
-
|
|
18
|
-
#### Changed
|
|
19
|
-
|
|
20
26
|
- replace to_object with arrays
|
|
21
27
|
- deps(all): updated to latest
|
|
22
28
|
- style: ES2024 updates throughout
|
|
@@ -171,3 +177,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
171
177
|
[2.1.8]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.1.8
|
|
172
178
|
[2.2.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.2.0
|
|
173
179
|
[2.3.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.3.0
|
|
180
|
+
[2.4.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.0
|
|
181
|
+
[2.4.1]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.1
|
package/config/karma.ini
CHANGED
|
@@ -56,63 +56,102 @@ hooks=unrecognized_command,data,data_post,queue,queue_outbound
|
|
|
56
56
|
[deny_excludes]
|
|
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
|
-
; the connection, add their name to the
|
|
60
|
-
plugins=
|
|
59
|
+
; the connection, add their name to the plugins list:
|
|
60
|
+
plugins=access, attachment, clamd, headers, helo.checks, limit, rspamd, spamassassin, send_email, tls
|
|
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
|
-
[
|
|
67
|
-
; allow rspamd to greylist (DENYSOFT) instead of karma intercepting
|
|
68
|
-
;
|
|
66
|
+
[rspamd_deny]
|
|
67
|
+
; allow rspamd to greylist (DENYSOFT) or reject (DENY) instead of karma intercepting
|
|
68
|
+
; the denial. Only relevant when rspamd is NOT in [deny_excludes] plugins.
|
|
69
69
|
;
|
|
70
70
|
; asn[] = 55286 ; ASN numbers eligible for greylisting
|
|
71
71
|
; asn[] = 33182
|
|
72
|
-
; asn[] =
|
|
72
|
+
; asn[] = 15169
|
|
73
73
|
;
|
|
74
74
|
; spamassassin_score = SpamAssassin hits score must exceed this value
|
|
75
75
|
; spamassassin_score = 5
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
[spammy_tlds]
|
|
79
|
-
; award negative karma to spammy TLDs
|
|
79
|
+
; award negative karma to spammy TLDs or second-level domains
|
|
80
|
+
; multi-label suffixes are supported, e.g.: sa.com=-3
|
|
80
81
|
; caution, awarding karma > msg_negative_limit may blacklist that TLD
|
|
82
|
+
archi=-5
|
|
83
|
+
baby=-5
|
|
81
84
|
bid=-3
|
|
82
85
|
biz=-2
|
|
83
86
|
bet=-4
|
|
87
|
+
best=-4
|
|
84
88
|
bio=-4
|
|
89
|
+
bond=-4
|
|
90
|
+
boston=-5
|
|
85
91
|
buzz=-3
|
|
92
|
+
cam=-3
|
|
93
|
+
city=-5
|
|
94
|
+
cc=-3
|
|
95
|
+
co=-3
|
|
86
96
|
club=-3
|
|
97
|
+
coach=-5
|
|
98
|
+
codes=-5
|
|
99
|
+
coffee=-5
|
|
87
100
|
company=-4
|
|
101
|
+
coupons=-5
|
|
88
102
|
directory=-4
|
|
103
|
+
dog=-4
|
|
104
|
+
energy=-4
|
|
89
105
|
eu=-4
|
|
106
|
+
financial=-5
|
|
90
107
|
fun=-5
|
|
91
|
-
|
|
108
|
+
fund=-5
|
|
109
|
+
futbol=-5
|
|
110
|
+
gay=-5
|
|
111
|
+
global=-4
|
|
112
|
+
guide=-4
|
|
113
|
+
guru=-5
|
|
92
114
|
gury=-3
|
|
115
|
+
help=-4
|
|
93
116
|
icu=-6
|
|
94
|
-
|
|
117
|
+
industries=-4
|
|
118
|
+
info=-5
|
|
95
119
|
link=-3
|
|
96
|
-
|
|
120
|
+
live=-5
|
|
121
|
+
love=-6
|
|
122
|
+
makeup=-5
|
|
123
|
+
me=-2
|
|
124
|
+
mobi=-6
|
|
97
125
|
monster=-5
|
|
98
126
|
mom=-5
|
|
99
127
|
na=-6
|
|
100
128
|
ninja=-3
|
|
101
|
-
one=-
|
|
129
|
+
one=-5
|
|
130
|
+
onl=-5
|
|
102
131
|
pics=-5
|
|
132
|
+
pro=-5
|
|
103
133
|
pw=-2
|
|
134
|
+
rest=-5
|
|
104
135
|
rocks=-3
|
|
136
|
+
rodeo=-5
|
|
105
137
|
ru=-2
|
|
106
|
-
sbs=-
|
|
138
|
+
sbs=-5
|
|
107
139
|
science=-6
|
|
140
|
+
shop=-6
|
|
141
|
+
site=-8
|
|
142
|
+
skin=-4
|
|
143
|
+
store=-5
|
|
108
144
|
stream=-3
|
|
109
145
|
studio=-5
|
|
146
|
+
support=-5
|
|
147
|
+
tax=-5
|
|
110
148
|
top=-5
|
|
111
149
|
trade=-3
|
|
112
150
|
us=-4
|
|
151
|
+
vin=-5
|
|
113
152
|
work=-4
|
|
114
153
|
xyz=-6
|
|
115
|
-
|
|
154
|
+
zone=-5
|
|
116
155
|
|
|
117
156
|
[tls]
|
|
118
157
|
; awards based on whether the sender opportunistically encrypted
|
|
@@ -201,7 +240,7 @@ early_talker = -3
|
|
|
201
240
|
008 = karma | pass | equals | asn | 1 | ASN reputation is good
|
|
202
241
|
009 = karma | fail | equals | asn | -1 | ASN reputation is bad
|
|
203
242
|
010 = karma | pass | equals | asn_all_good | 2 | ASN reputation is very good
|
|
204
|
-
011 = karma | fail | equals | asn_all_bad | -
|
|
243
|
+
011 = karma | fail | equals | asn_all_bad | -1 | ASN reputation is very bad
|
|
205
244
|
|
|
206
245
|
012 = karma | fail | equals | rfc5321.MailFrom | -1 | RFC Ignorant MTA | Use a RFC compliant MTA
|
|
207
246
|
013 = karma | fail | equals | rfc5321.RcptTo | -1 | RFC Ignorant MTA | Use a RFC compliant MTA
|
|
@@ -219,7 +258,7 @@ early_talker = -3
|
|
|
219
258
|
032 = p0f | os_flavor | equals | XP | -2 | Windows XP, likely infected by malware | Upgrade to a supported OS
|
|
220
259
|
|
|
221
260
|
; give back the point penalized for running windows
|
|
222
|
-
|
|
261
|
+
080 = fcrdns | fcrdns | match | outlook.com | 1
|
|
223
262
|
; 081 = fcrdns | fcrdns@1 = 1 if length gt 0
|
|
224
263
|
; 082 = fcrdns | err@1 = -1 if length gt 0
|
|
225
264
|
; 083 = fcrdns | fail@1 = -1 if length gt 0
|
|
@@ -315,32 +354,34 @@ early_talker = -3
|
|
|
315
354
|
218 = clamd | fail | match | spam | -2 | Clam AntiVirus Spam
|
|
316
355
|
;219 = clamd | pass | equals | clean | 1 | Clam AntiVirus Executable
|
|
317
356
|
|
|
318
|
-
230 = rspamd |
|
|
319
|
-
231 = rspamd | action | equals | greylist | -1 | rspamd suggested greylist
|
|
320
|
-
232 = rspamd | score | lt | 0 | 1 |
|
|
321
|
-
233 = rspamd | score |
|
|
322
|
-
234 = rspamd | score |
|
|
323
|
-
235 = rspamd |
|
|
324
|
-
236 = rspamd |
|
|
357
|
+
;230 = rspamd | action | match | reject | -2 | rspamd suggested reject
|
|
358
|
+
;231 = rspamd | action | equals | greylist | -1 | rspamd suggested greylist
|
|
359
|
+
232 = rspamd | score | lt | 0 | 1 |
|
|
360
|
+
233 = rspamd | score | lt | -5 | 1 |
|
|
361
|
+
234 = rspamd | score | lt | -10 | 1 |
|
|
362
|
+
235 = rspamd | score | gt | 6 | -1 |
|
|
363
|
+
236 = rspamd | score | gt | 10 | -1 |
|
|
364
|
+
237 = rspamd | is_spam | equals | true | -2 | rspamd detected as spam
|
|
365
|
+
238 = rspamd | is_spam | equals | false | 1 | rspamd detected as ham
|
|
325
366
|
|
|
326
367
|
237 = rspamd | symbols | match | DMARC_POLICY_ALLOW | 1 | DMARC policy allow
|
|
327
368
|
238 = rspamd | symbols | match | DMARC_POLICY_REJECT | -6 | DMARC policy reject
|
|
328
369
|
239 = rspamd | symbols | match | DMARC_POLICY_QUARANTINE | -4 | DMARC policy reject
|
|
329
370
|
240 = rspamd | symbols | match | DMARC_POLICY_SOFTFAIL | -2 | DMARC policy softfail
|
|
330
371
|
|
|
331
|
-
251 = spamassassin |
|
|
332
|
-
252 = spamassassin |
|
|
333
|
-
253 = spamassassin |
|
|
334
|
-
254 = spamassassin |
|
|
335
|
-
255 = spamassassin |
|
|
336
|
-
256 = spamassassin |
|
|
337
|
-
257 = spamassassin |
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
372
|
+
251 = spamassassin | score | lt | 0 | 1 |
|
|
373
|
+
252 = spamassassin | score | lt | -2 | 1 |
|
|
374
|
+
253 = spamassassin | score | lt | -5 | 1 |
|
|
375
|
+
254 = spamassassin | score | lt | -10 | 2 |
|
|
376
|
+
255 = spamassassin | score | lt | -20 | 5 |
|
|
377
|
+
256 = spamassassin | score | gt | 1 | -1 |
|
|
378
|
+
257 = spamassassin | score | gt | 2 | -1 |
|
|
379
|
+
258 = spamassassin | score | gt | 3 | -2 |
|
|
380
|
+
;259 = spamassassin | score | gt | 6 | -2 |
|
|
381
|
+
;260 = spamassassin | score | gt | 8 | -2 |
|
|
382
|
+
261 = spamassassin | score | gt | 9 | -2 |
|
|
383
|
+
262 = spamassassin | score | gt | 20 | -10 |
|
|
384
|
+
;263 = spamassassin | flag | equals | Yes | -5 | SpamAssassin detected as spam
|
|
344
385
|
|
|
345
386
|
280 = known-senders | pass | equals | wks | 5 | Known Sender
|
|
346
387
|
281 = known-senders | pass | length | gt 0 | 5 | Known Sender
|
package/index.js
CHANGED
|
@@ -50,23 +50,15 @@ exports.load_karma_ini = function () {
|
|
|
50
50
|
this.merge_redis_ini()
|
|
51
51
|
|
|
52
52
|
const cfg = this.cfg
|
|
53
|
-
if (cfg.deny?.hooks)
|
|
54
|
-
this.deny_hooks = this.stringToArray(cfg.deny.hooks)
|
|
55
|
-
}
|
|
53
|
+
if (cfg.deny?.hooks) this.deny_hooks = this.stringToArray(cfg.deny.hooks)
|
|
56
54
|
|
|
57
55
|
const e = cfg.deny_excludes
|
|
58
|
-
if (e?.hooks)
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
if (e?.plugins) {
|
|
62
|
-
this.deny_exclude_plugins = this.stringToArray(e.plugins)
|
|
63
|
-
}
|
|
56
|
+
if (e?.hooks) this.deny_exclude_hooks = this.stringToArray(e.hooks)
|
|
57
|
+
if (e?.plugins) this.deny_exclude_plugins = this.stringToArray(e.plugins)
|
|
64
58
|
|
|
65
|
-
if (cfg.result_awards)
|
|
66
|
-
this.preparse_result_awards()
|
|
67
|
-
}
|
|
59
|
+
if (cfg.result_awards) this.preparse_result_awards()
|
|
68
60
|
|
|
69
|
-
this.
|
|
61
|
+
this.rspamdDenyAsns = cfg.rspamd_deny?.asn ?? cfg.greylist?.asn ?? []
|
|
70
62
|
|
|
71
63
|
if (!cfg.redis) cfg.redis = {}
|
|
72
64
|
if (!cfg.redis.host && cfg.redis.server_ip) {
|
|
@@ -393,29 +385,24 @@ exports.should_we_skip = function (connection) {
|
|
|
393
385
|
return false
|
|
394
386
|
}
|
|
395
387
|
|
|
396
|
-
exports.
|
|
388
|
+
exports.should_rspamd_deny = function (connection) {
|
|
397
389
|
// check connection's ASN is in the configured list
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (!
|
|
401
|
-
if (!this.greylist_asns.includes(String(asnr.asn))) return false
|
|
390
|
+
const cr = connection.results
|
|
391
|
+
const asn = cr.get('asn')?.asn ?? cr.get('geoip')?.asn
|
|
392
|
+
if (!this.rspamdDenyAsns.includes(String(asn))) return false
|
|
402
393
|
|
|
403
394
|
// check SpamAssassin score exceeds configured threshold
|
|
404
395
|
const saScore = parseFloat(
|
|
405
396
|
connection.transaction?.results?.get('spamassassin')?.hits ?? 0,
|
|
406
397
|
)
|
|
407
|
-
const saThreshold = parseFloat(this.cfg.
|
|
408
|
-
if (saScore >= saThreshold) return true
|
|
398
|
+
const saThreshold = parseFloat(this.cfg.rspamd_deny?.spamassassin_score ?? 5)
|
|
399
|
+
if (saScore >= saThreshold) return true // SA & rspamd agree
|
|
409
400
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
.get('content-type')
|
|
416
|
-
?.startsWith('multipart/related')
|
|
417
|
-
)
|
|
418
|
-
return true
|
|
401
|
+
const gGroups = connection.transaction.header.get('X-Google-Group-Id')
|
|
402
|
+
const imgOnly = connection.transaction.header
|
|
403
|
+
.get('content-type')
|
|
404
|
+
?.startsWith('multipart/related')
|
|
405
|
+
if (gGroups && imgOnly) return true
|
|
419
406
|
|
|
420
407
|
return false
|
|
421
408
|
}
|
|
@@ -445,18 +432,17 @@ exports.should_we_deny = function (next, connection, hook) {
|
|
|
445
432
|
return this.apply_tarpit(connection, hook, score, next)
|
|
446
433
|
}
|
|
447
434
|
|
|
448
|
-
let rejectMsg =
|
|
449
|
-
if (this.cfg.deny && this.cfg.deny.message) {
|
|
450
|
-
rejectMsg = this.cfg.deny.message
|
|
451
|
-
}
|
|
435
|
+
let rejectMsg = this.cfg.deny?.message ?? `very bad karma score: ${score}`
|
|
452
436
|
|
|
453
437
|
if (/\{/.test(rejectMsg)) {
|
|
454
438
|
rejectMsg = rejectMsg.replace(/\{score\}/, score)
|
|
455
439
|
rejectMsg = rejectMsg.replace(/\{uuid\}/, connection.uuid)
|
|
456
440
|
}
|
|
457
441
|
|
|
442
|
+
const deny_rc = r.deny_rc ?? constants.DENY
|
|
443
|
+
|
|
458
444
|
return this.apply_tarpit(connection, hook, score, () => {
|
|
459
|
-
next(
|
|
445
|
+
next(deny_rc, rejectMsg)
|
|
460
446
|
})
|
|
461
447
|
}
|
|
462
448
|
|
|
@@ -469,12 +455,7 @@ exports.hook_deny = function (next, connection, params) {
|
|
|
469
455
|
if (pi_name) {
|
|
470
456
|
if (pi_name === 'karma') return next()
|
|
471
457
|
if (this.deny_exclude_plugins.includes(pi_name)) return next()
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
pi_rc === constants.DENYSOFT &&
|
|
475
|
-
pi_name === 'rspamd' &&
|
|
476
|
-
this.should_rspamd_greylist(connection)
|
|
477
|
-
)
|
|
458
|
+
if (pi_name === 'rspamd' && this.should_rspamd_deny(connection))
|
|
478
459
|
return next()
|
|
479
460
|
}
|
|
480
461
|
if (pi_hook && this.deny_exclude_hooks.includes(pi_hook)) return next()
|
|
@@ -485,9 +466,31 @@ exports.hook_deny = function (next, connection, params) {
|
|
|
485
466
|
connection.results.add(this, { msg: `deny: ${pi_name}` })
|
|
486
467
|
connection.results.incr(this, { score: -2 })
|
|
487
468
|
|
|
469
|
+
// Remember the worst-severity intercepted code so a later karma-issued
|
|
470
|
+
// deny doesn't escalate a soft reject (e.g. rspamd greylist) to a hard
|
|
471
|
+
// reject.
|
|
472
|
+
this.track_intercepted_rc(connection, pi_rc)
|
|
473
|
+
|
|
488
474
|
next(constants.OK) // resume the connection
|
|
489
475
|
}
|
|
490
476
|
|
|
477
|
+
exports.track_intercepted_rc = function (connection, pi_rc) {
|
|
478
|
+
const k = connection.results.get('karma')
|
|
479
|
+
if (!k) return
|
|
480
|
+
|
|
481
|
+
if (pi_rc === constants.DENY || pi_rc === constants.DENYDISCONNECT) {
|
|
482
|
+
connection.results.add(this, { deny_rc: constants.DENY })
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (
|
|
487
|
+
(pi_rc === constants.DENYSOFT || pi_rc === constants.DENYSOFTDISCONNECT) &&
|
|
488
|
+
k.deny_rc !== constants.DENY
|
|
489
|
+
) {
|
|
490
|
+
connection.results.add(this, { deny_rc: constants.DENYSOFT })
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
491
494
|
exports.hook_connect = function (next, connection) {
|
|
492
495
|
if (this.should_we_skip(connection)) return next()
|
|
493
496
|
|
|
@@ -934,13 +937,17 @@ exports.check_spammy_tld = function (mail_from, connection) {
|
|
|
934
937
|
if (!this.cfg.spammy_tlds) return
|
|
935
938
|
if (mail_from.isNull()) return // null sender (bounce)
|
|
936
939
|
|
|
937
|
-
const
|
|
940
|
+
const labels = mail_from.host.split('.')
|
|
938
941
|
|
|
939
|
-
|
|
940
|
-
|
|
942
|
+
for (let i = 0; i < labels.length; i++) {
|
|
943
|
+
const suffix = labels.slice(i).join('.')
|
|
944
|
+
const tld_penalty = parseFloat(this.cfg.spammy_tlds[suffix] || 0)
|
|
945
|
+
if (tld_penalty === 0) continue
|
|
941
946
|
|
|
942
|
-
|
|
943
|
-
|
|
947
|
+
connection.results.incr(this, { score: tld_penalty })
|
|
948
|
+
connection.results.add(this, { fail: 'spammy.TLD' })
|
|
949
|
+
return
|
|
950
|
+
}
|
|
944
951
|
}
|
|
945
952
|
|
|
946
953
|
exports.check_syntax_RcptTo = function (connection) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haraka-plugin-karma",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "A heuristics scoring and reputation engine for SMTP connections",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@haraka/eslint-config": "^2.0.4",
|
|
42
|
-
"haraka-test-fixtures": "^1.4.
|
|
42
|
+
"haraka-test-fixtures": "^1.4.3"
|
|
43
43
|
},
|
|
44
44
|
"prettier": {
|
|
45
45
|
"singleQuote": true,
|