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.
- package/CHANGELOG.md +18 -1
- package/README.md +12 -12
- package/index.js +55 -30
- 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.
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
@@ -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
|
-
.
|
|
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
|
-
.
|
|
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.
|
|
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-
|
|
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.
|
|
38
|
-
"haraka-plugin-redis": "^2.0
|
|
39
|
+
"haraka-constants": "^1.0.8",
|
|
40
|
+
"haraka-plugin-redis": "^2.1.0"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
|
-
"@haraka/email-address": "^3.1.
|
|
42
|
-
"@haraka/eslint-config": "^
|
|
43
|
-
"haraka-test-fixtures": "^1.
|
|
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,
|