haraka-plugin-karma 2.5.0 → 2.5.2

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +12 -12
  3. package/index.js +55 -30
  4. package/package.json +10 -8
package/CHANGELOG.md CHANGED
@@ -4,7 +4,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
5
  ### Unreleased
6
6
 
7
- ### [2.5.0] - 2026-05-17
7
+ ### [2.5.2] - 2026-06-07
8
+
9
+ - fix: update redis hmSet -> hSet
10
+ - test: refactored against test-fixtures 1.7.0 (#72)
11
+
12
+ ### [2.5.1] - 2026-05-24
13
+
14
+ - fix: guard pubsub JSON.parse and validate payload shape in check_result
15
+ - fix: guard new RegExp() in check_result_match against invalid config
16
+ - fix: results_init fast-path now calls next() when notes.redis exists
17
+ - fix: preserve originating plugin on awards so AUTH repeat-scoring works
18
+ - fix: check_result_length measures collection length per karma.ini docs
19
+ - fix: detect empty hGetAll ({}) in ip_history_from_redis and check_asn (node-redis v4)
20
+ - docs: README threshold wording matches deny boundary
21
+
22
+ ### [2.5.0] - 2026-05-24
8
23
 
9
24
  - dep(address-rfc2821): -> @haraka/email-address
10
25
 
@@ -184,3 +199,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
184
199
  [2.4.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.0
185
200
  [2.4.1]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.1
186
201
  [2.5.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.5.0
202
+ [2.5.1]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.5.1
203
+ [2.5.2]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.5.2
package/README.md CHANGED
@@ -1,9 +1,7 @@
1
- [![Build Status][ci-img]][ci-url]
2
- [![Code Climate][clim-img]][clim-url]
3
- [![Code Coverage][cov-img]][cov-url]
4
-
5
1
  # Karma - A heuristics based reputation engine for the Haraka MTA
6
2
 
3
+ [![Test][ci-img]][ci-url] [![Cover][cov-img]][cov-url] [![Qlty][qlty-img]][qlty-url]
4
+
7
5
  Karma is a heuristic scoring engine that uses connection metadata and other Haraka plugin data as inputs. Connections scoring in excess of specified thresholds are rewarded or [penalized](#penalties) in proportionate ways.
8
6
 
9
7
  ## Description
@@ -20,7 +18,7 @@ In order to score a plugins results, plugins must save their results to the [Res
20
18
 
21
19
  ## How Karma Works
22
20
 
23
- Karma takes a holistic view of **connections**. During the connection, karma collects these results and applies the [result_awards](#awards) defined in `karma.ini`. Once a connection/message exceeds the threshold.negative score (default: -8), karma rejects it at the next [deny]hook.
21
+ Karma takes a holistic view of **connections**. During the connection, karma collects these results and applies the [result_awards](#awards) defined in `karma.ini`. Once a connection/message reaches the threshold.negative score (default: -8), karma rejects it at the next [deny]hook.
24
22
 
25
23
  The scoring mechanism is not dissimilar to [SpamAssassin][sa-url], but Karma has some particular advantages:
26
24
 
@@ -51,7 +49,7 @@ Karma performs checks early and often, maximizing the penality it can exact upon
51
49
 
52
50
  ### Deny / Reject
53
51
 
54
- When connections become worse than [thresholds]negative, they are denied during the next [deny]hook.
52
+ When a connection's score reaches [thresholds]negative (equal or lower), it is denied during the next [deny]hook.
55
53
 
56
54
  ### History
57
55
 
@@ -186,6 +184,14 @@ Karma is most effective at filtering mail delivered by bots and rogue servers.
186
184
  Spam delivered by servers with good reputations normally pass karma's checks.
187
185
  Expect to use karma _with_ content filters.
188
186
 
187
+ <!-- leave these buried at the bottom of the document -->
188
+
189
+ [ci-img]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml/badge.svg
190
+ [ci-url]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml
191
+ [cov-img]: https://codecov.io/github/haraka/haraka-plugin-karma/coverage.svg
192
+ [cov-url]: https://codecov.io/github/haraka/haraka-plugin-karma
193
+ [qlty-img]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma/maintainability.svg
194
+ [qlty-url]: https://qlty.sh/gh/haraka/projects/haraka-plugin-karma
189
195
  [p0f-url]: /manual/plugins/connect.p0f.html
190
196
  [geoip-url]: https://github.com/haraka/haraka-plugin-geoip
191
197
  [dnsbl-url]: /manual/plugins/dnsbl.html
@@ -194,9 +200,3 @@ Expect to use karma _with_ content filters.
194
200
  [sa-url]: http://haraka.github.io/manual/plugins/spamassassin.html
195
201
  [snf-url]: http://haraka.github.io/manual/plugins/messagesniffer.html
196
202
  [results-url]: http://haraka.github.io/manual/Results.html
197
- [ci-img]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml/badge.svg
198
- [ci-url]: https://github.com/haraka/haraka-plugin-karma/actions/workflows/ci.yml
199
- [cov-img]: https://codecov.io/github/haraka/haraka-plugin-karma/coverage.svg
200
- [cov-url]: https://codecov.io/github/haraka/haraka-plugin-karma
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/index.js CHANGED
@@ -101,7 +101,7 @@ exports.results_init = async function (next, connection) {
101
101
 
102
102
  if (connection.notes.redis) {
103
103
  connection.logdebug(this, `redis already subscribed`)
104
- return // another plugin has already called this.
104
+ return next() // another plugin has already called this.
105
105
  }
106
106
 
107
107
  connection.notes.redis = redis.createClient(this.redisCfg.pubsub)
@@ -134,7 +134,15 @@ exports.preparse_result_awards = function () {
134
134
 
135
135
  if (!ra[pi_name][prop]) ra[pi_name][prop] = []
136
136
 
137
- ra[pi_name][prop].push({ id: anum, operator, value, award, reason, resolv })
137
+ ra[pi_name][prop].push({
138
+ id: anum,
139
+ plugin: pi_name,
140
+ operator,
141
+ value,
142
+ award,
143
+ reason,
144
+ resolv,
145
+ })
138
146
  }
139
147
  }
140
148
 
@@ -142,8 +150,17 @@ exports.check_result = function (connection, message) {
142
150
  // {"plugin":"karma","result":{"fail":"spamassassin.hits"}}
143
151
  // {"plugin":"geoip","result":{"country":"CN"}}
144
152
 
145
- const m = JSON.parse(message)
146
- if (m?.result?.asn) {
153
+ let m
154
+ try {
155
+ m = JSON.parse(message)
156
+ } catch (err) {
157
+ connection.logerror(this, `invalid pubsub payload: ${err.message}`)
158
+ return
159
+ }
160
+ if (!m || typeof m !== 'object' || typeof m.plugin !== 'string') return
161
+ if (!m.result || typeof m.result !== 'object') return
162
+
163
+ if (m.result.asn) {
147
164
  this.check_result_asn(m.result.asn, connection)
148
165
  }
149
166
  if (!this.result_awards[m.plugin]) return // no awards for plugin
@@ -247,7 +264,13 @@ exports.check_result_equal = function (thisResult, thisAward, conn) {
247
264
  }
248
265
 
249
266
  exports.check_result_match = function (thisResult, thisAward, conn) {
250
- const re = new RegExp(thisAward.value, 'i')
267
+ let re
268
+ try {
269
+ re = new RegExp(thisAward.value, 'i')
270
+ } catch {
271
+ conn.results.add(this, { err: `invalid regex: ${thisAward.value}` })
272
+ return
273
+ }
251
274
 
252
275
  for (const element of thisResult) {
253
276
  if (!re.test(element)) continue
@@ -259,29 +282,31 @@ exports.check_result_match = function (thisResult, thisAward, conn) {
259
282
  }
260
283
 
261
284
  exports.check_result_length = function (thisResult, thisAward, conn) {
262
- for (const element of thisResult) {
263
- const [operator, qty] = thisAward.value.split(/\s+/)
285
+ const [operator, qty] = thisAward.value.split(/\s+/)
286
+ const len = thisResult.length
287
+ const threshold = parseFloat(qty)
288
+
289
+ switch (operator) {
290
+ case 'eq':
291
+ case 'equal':
292
+ case 'equals':
293
+ if (len !== threshold) return
294
+ break
295
+ case 'gt':
296
+ if (len <= threshold) return
297
+ break
298
+ case 'lt':
299
+ if (len >= threshold) return
300
+ break
301
+ default:
302
+ conn.results.add(this, { err: `invalid operator: ${operator}` })
303
+ return
304
+ }
264
305
 
265
- switch (operator) {
266
- case 'eq':
267
- case 'equal':
268
- case 'equals':
269
- if (parseInt(element, 10) != parseInt(qty, 10)) continue
270
- break
271
- case 'gt':
272
- if (parseInt(element, 10) <= parseInt(qty, 10)) continue
273
- break
274
- case 'lt':
275
- if (parseInt(element, 10) >= parseInt(qty, 10)) continue
276
- break
277
- default:
278
- conn.results.add(this, { err: `invalid operator: ${operator}` })
279
- continue
280
- }
306
+ if (conn.results.has('karma', 'awards', thisAward.id)) return
281
307
 
282
- conn.results.incr(this, { score: thisAward.award })
283
- conn.results.push(this, { awards: thisAward.id })
284
- }
308
+ conn.results.incr(this, { score: thisAward.award })
309
+ conn.results.push(this, { awards: thisAward.id })
285
310
  }
286
311
 
287
312
  exports.check_result_exists = function (thisResult, thisAward, conn) {
@@ -577,7 +602,7 @@ exports.ip_history_from_redis = async function (next, connection) {
577
602
  try {
578
603
  const dbr = await this.db.hGetAll(dbkey)
579
604
 
580
- if (dbr === null) {
605
+ if (!dbr || Object.keys(dbr).length === 0) {
581
606
  this.init_ip(dbkey, connection.remote.ip, expire)
582
607
  return next()
583
608
  }
@@ -982,7 +1007,7 @@ exports.check_asn = async function (connection, asnkey) {
982
1007
  try {
983
1008
  const res = await this.db.hGetAll(asnkey)
984
1009
 
985
- if (res === null) {
1010
+ if (!res || Object.keys(res).length === 0) {
986
1011
  const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
987
1012
  this.init_asn(asnkey, expire)
988
1013
  return
@@ -1017,7 +1042,7 @@ exports.init_ip = async function (dbkey, rip, expire) {
1017
1042
  if (!this.db) return
1018
1043
  await this.db
1019
1044
  .multi()
1020
- .hmSet(dbkey, { bad: 0, good: 0, connections: 1 })
1045
+ .hSet(dbkey, { bad: 0, good: 0, connections: 1 })
1021
1046
  .expire(dbkey, expire)
1022
1047
  .exec()
1023
1048
  }
@@ -1034,7 +1059,7 @@ exports.init_asn = function (asnkey, expire) {
1034
1059
  if (!this.db) return
1035
1060
  this.db
1036
1061
  .multi()
1037
- .hmSet(asnkey, { bad: 0, good: 0, connections: 1 })
1062
+ .hSet(asnkey, { bad: 0, good: 0, connections: 1 })
1038
1063
  .expire(asnkey, expire * 2) // keep ASN longer
1039
1064
  .exec()
1040
1065
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haraka-plugin-karma",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "A heuristics scoring and reputation engine for SMTP connections",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -8,15 +8,17 @@
8
8
  "config"
9
9
  ],
10
10
  "scripts": {
11
+ "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true",
11
12
  "clean": "rm -rf node_modules package-lock.json",
12
13
  "format": "npm run prettier:fix && npm run lint:fix",
13
14
  "lint": "npx eslint *.js test",
14
15
  "lint:fix": "npx eslint *.js test --fix",
15
16
  "prettier": "npx prettier . --check",
16
17
  "prettier:fix": "npx prettier . --write --log-level=warn",
18
+ "qlty": "qlty smells --all",
17
19
  "test": "node --test",
18
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=package.json --test-coverage-exclude=test/*.js",
19
- "test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/*.js",
20
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=index.js",
21
+ "test:coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-coverage-include=index.js --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/*.js",
20
22
  "versions": "npx npm-dep-mgr check",
21
23
  "versions:fix": "npx npm-dep-mgr update"
22
24
  },
@@ -34,13 +36,13 @@
34
36
  },
35
37
  "homepage": "https://github.com/haraka/haraka-plugin-karma#readme",
36
38
  "dependencies": {
37
- "haraka-constants": "^1.0.7",
38
- "haraka-plugin-redis": "^2.0.11"
39
+ "haraka-constants": "^1.0.8",
40
+ "haraka-plugin-redis": "^2.1.0"
39
41
  },
40
42
  "devDependencies": {
41
- "@haraka/email-address": "^3.1.2",
42
- "@haraka/eslint-config": "^2.0.4",
43
- "haraka-test-fixtures": "^1.4.3"
43
+ "@haraka/email-address": "^3.1.6",
44
+ "@haraka/eslint-config": "^3.0.0",
45
+ "haraka-test-fixtures": "^1.7.1"
44
46
  },
45
47
  "prettier": {
46
48
  "singleQuote": true,