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 +12 -1
- package/README.md +2 -2
- package/index.js +53 -28
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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 ===
|
|
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 ===
|
|
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
|