haraka-plugin-karma 2.5.0 → 2.5.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,7 +4,17 @@ 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.1] - 2026-05-24
8
+
9
+ - fix: guard pubsub JSON.parse and validate payload shape in check_result
10
+ - fix: guard new RegExp() in check_result_match against invalid config
11
+ - fix: results_init fast-path now calls next() when notes.redis exists
12
+ - fix: preserve originating plugin on awards so AUTH repeat-scoring works
13
+ - fix: check_result_length measures collection length per karma.ini docs
14
+ - fix: detect empty hGetAll ({}) in ip_history_from_redis and check_asn (node-redis v4)
15
+ - docs: README threshold wording matches deny boundary
16
+
17
+ ### [2.5.0] - 2026-05-24
8
18
 
9
19
  - dep(address-rfc2821): -> @haraka/email-address
10
20
 
@@ -184,3 +194,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
184
194
  [2.4.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.0
185
195
  [2.4.1]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.4.1
186
196
  [2.5.0]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.5.0
197
+ [2.5.1]: https://github.com/haraka/haraka-plugin-karma/releases/tag/v2.5.1
package/README.md CHANGED
@@ -20,7 +20,7 @@ In order to score a plugins results, plugins must save their results to the [Res
20
20
 
21
21
  ## How Karma Works
22
22
 
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.
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 reaches the threshold.negative score (default: -8), karma rejects it at the next [deny]hook.
24
24
 
25
25
  The scoring mechanism is not dissimilar to [SpamAssassin][sa-url], but Karma has some particular advantages:
26
26
 
@@ -51,7 +51,7 @@ Karma performs checks early and often, maximizing the penality it can exact upon
51
51
 
52
52
  ### Deny / Reject
53
53
 
54
- When connections become worse than [thresholds]negative, they are denied during the next [deny]hook.
54
+ When a connection's score reaches [thresholds]negative (equal or lower), it is denied during the next [deny]hook.
55
55
 
56
56
  ### History
57
57
 
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
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.1",
4
4
  "description": "A heuristics scoring and reputation engine for SMTP connections",
5
5
  "main": "index.js",
6
6
  "files": [