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 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 plugin list:
60
- plugins=send_email, tls, access, helo.checks, headers, rspamd, spamassassin, clamd, attachment, limit
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
- [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.
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[] = 46717
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
- guru=-4
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
- info=-4
117
+ industries=-4
118
+ info=-5
95
119
  link=-3
96
- me=-1
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=-4
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=-4
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 | -2 | ASN reputation is very 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
- ; 080 = fcrdns | fcrdns | match | outlook.com | 1
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 | is_spam | equals | true | -2 | rspamd detected as spam
319
- 231 = rspamd | action | equals | greylist | -1 | rspamd suggested greylist
320
- 232 = rspamd | score | lt | 0 | 1 | rspamd positive score
321
- 233 = rspamd | score | gt | 6 | -1 | rspamd moderate score
322
- 234 = rspamd | score | gt | 10 | -1 | rspamd high score
323
- 235 = rspamd | is_spam | equals | false | 1 | rspamd detected as ham
324
- 236 = rspamd | action | match | reject | -2 | rspamd suggested reject
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 | hits | lt | 0 | 1 |
332
- 252 = spamassassin | hits | lt | -2 | 1 |
333
- 253 = spamassassin | hits | lt | -5 | 1 |
334
- 254 = spamassassin | hits | lt | -10 | 2 |
335
- 255 = spamassassin | hits | lt | -20 | 5 |
336
- 256 = spamassassin | hits | gt | 1 | -1 |
337
- 257 = spamassassin | hits | gt | 2 | -1 |
338
- 259 = spamassassin | hits | gt | 3 | -2 |
339
- 260 = spamassassin | flag | equals | Yes | -5 | SpamAssassin detected as spam
340
- ;261 = spamassassin | hits | gt | 6 | -2 |
341
- ;263 = spamassassin | hits | gt | 8 | -2 |
342
- 264 = spamassassin | hits | gt | 9 | -2 |
343
- 265 = spamassassin | hits | gt | 20 | -10 |
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
- this.deny_exclude_hooks = this.stringToArray(e.hooks)
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.greylist_asns = cfg.greylist?.asn ?? []
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.should_rspamd_greylist = function (connection) {
388
+ exports.should_rspamd_deny = function (connection) {
397
389
  // 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
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.greylist?.spamassassin_score ?? 5)
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
- 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
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 = 'very bad karma score: {score}'
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(constants.DENY, rejectMsg)
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
- // 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
- )
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 from_tld = mail_from.host.split('.').pop()
940
+ const labels = mail_from.host.split('.')
938
941
 
939
- const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
940
- if (tld_penalty === 0) return
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
- connection.results.incr(this, { score: tld_penalty })
943
- connection.results.add(this, { fail: 'spammy.TLD' })
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.0",
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.1"
42
+ "haraka-test-fixtures": "^1.4.3"
43
43
  },
44
44
  "prettier": {
45
45
  "singleQuote": true,