@blamejs/core 0.9.46 → 0.10.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 +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.spamScore
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail Spam Score
|
|
6
|
+
* @order 557
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Operator-supplied spam scorer facade. The framework deliberately
|
|
10
|
+
* does NOT vendor a spam-classifier engine — the bayes corpora,
|
|
11
|
+
* URIBL caches, neural models, and per-recipient training are all
|
|
12
|
+
* operator state. Instead, `b.mail.spamScore.create` wraps the
|
|
13
|
+
* operator's chosen scorer (SpamAssassin via spamc, Rspamd HTTP
|
|
14
|
+
* API, Cloudmark, Vade, in-house) in a uniform threshold-driven
|
|
15
|
+
* verdict pipeline that the MX listener (v0.9.45) and submission
|
|
16
|
+
* listener (v0.9.47) consume.
|
|
17
|
+
*
|
|
18
|
+
* ## Operator-supplied scorer contract
|
|
19
|
+
*
|
|
20
|
+
* ```
|
|
21
|
+
* async function scorer({ rawBytes, headers, envelope }) {
|
|
22
|
+
* // call out to SpamAssassin / Rspamd / commercial scorer
|
|
23
|
+
* return { score: 7.3, reasons: ["BAYES_99", "URIBL_RED"] };
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* - `score` MUST be a finite number (any range; the threshold is
|
|
28
|
+
* operator-tuned). Negative scores mean "ham-shaped"; positive
|
|
29
|
+
* scores mean "spam-shaped". Convention matches SpamAssassin.
|
|
30
|
+
* - `reasons` MUST be an Array of short ASCII tags. The facade caps
|
|
31
|
+
* each tag at 256 bytes and refuses control bytes; the cap protects
|
|
32
|
+
* audit storage + outbound headers (`X-Spam-Status: ...`) from
|
|
33
|
+
* hostile expansion via a compromised scorer.
|
|
34
|
+
*
|
|
35
|
+
* ## Thresholds
|
|
36
|
+
*
|
|
37
|
+
* - **strict** — 5.0 (matches SpamAssassin's default `required_score`).
|
|
38
|
+
* - **balanced** — 7.5.
|
|
39
|
+
* - **permissive** — 10.0.
|
|
40
|
+
*
|
|
41
|
+
* Operators tune via `opts.threshold` per-instance. The verdict is
|
|
42
|
+
* `"accept"` (score < threshold), `"score-tag"` (score === threshold —
|
|
43
|
+
* add `X-Spam-Status` header but deliver), or `"refuse"`
|
|
44
|
+
* (score > threshold — return SMTP 550).
|
|
45
|
+
*
|
|
46
|
+
* ## Composition
|
|
47
|
+
*
|
|
48
|
+
* - **`b.audit`** receives every `score` / `accept` / `score_tag` /
|
|
49
|
+
* `refuse` decision. Audit failure is drop-silent (hot path).
|
|
50
|
+
*
|
|
51
|
+
* ## Threat model
|
|
52
|
+
*
|
|
53
|
+
* - **Hostile reason-tag** (compromised scorer injects CRLF into a
|
|
54
|
+
* tag, smuggling extra `X-Spam-*` headers into the outbound
|
|
55
|
+
* wrapper): defended by per-tag length cap + control-byte refusal.
|
|
56
|
+
* - **NaN / Infinity score** (scorer bug): refused as
|
|
57
|
+
* `mail-spam-score/bad-score`; the listener treats the message as
|
|
58
|
+
* unscanned (operator's tempfail policy applies).
|
|
59
|
+
* - **Slow scorer DoS**: the scorer function is operator code, so
|
|
60
|
+
* timing belongs to the operator. The listener wraps the
|
|
61
|
+
* `.score()` promise in its own per-connection deadline.
|
|
62
|
+
*
|
|
63
|
+
* @card
|
|
64
|
+
* Threshold-driven spam-scorer facade. Operator wires SpamAssassin /
|
|
65
|
+
* Rspamd / commercial scorer; the framework owns the verdict
|
|
66
|
+
* pipeline + reason-tag hardening + audit emission. Three default
|
|
67
|
+
* thresholds (strict 5.0 / balanced 7.5 / permissive 10.0).
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var { defineClass } = require("./framework-error");
|
|
71
|
+
var lazyRequire = require("./lazy-require");
|
|
72
|
+
var validateOpts = require("./validate-opts");
|
|
73
|
+
|
|
74
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
75
|
+
|
|
76
|
+
var MailSpamScoreError = defineClass("MailSpamScoreError", { alwaysPermanent: true });
|
|
77
|
+
|
|
78
|
+
var DEFAULT_PROFILE = "strict";
|
|
79
|
+
|
|
80
|
+
// allow:raw-byte-literal — reason-tag length cap defends outbound
|
|
81
|
+
// header / audit-store from hostile expansion via compromised scorer.
|
|
82
|
+
var MAX_REASON_BYTES = 256;
|
|
83
|
+
|
|
84
|
+
// allow:raw-byte-literal — reason-list count cap, defends audit volume.
|
|
85
|
+
var MAX_REASONS = 32;
|
|
86
|
+
|
|
87
|
+
var PROFILES = Object.freeze({
|
|
88
|
+
strict: { threshold: 5.0, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES }, // allow:raw-byte-literal — matches SpamAssassin default required_score
|
|
89
|
+
balanced: { threshold: 7.5, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES },
|
|
90
|
+
permissive: { threshold: 10.0, maxReasons: MAX_REASONS, maxReasonBytes: MAX_REASON_BYTES },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
94
|
+
hipaa: "strict",
|
|
95
|
+
"pci-dss": "strict",
|
|
96
|
+
gdpr: "strict",
|
|
97
|
+
soc2: "strict",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @primitive b.mail.spamScore.create
|
|
102
|
+
* @signature b.mail.spamScore.create(opts)
|
|
103
|
+
* @since 0.9.81
|
|
104
|
+
* @status stable
|
|
105
|
+
* @related b.mail.scan.create
|
|
106
|
+
*
|
|
107
|
+
* Build a spam-score handle. Returns `{ score(message, opts),
|
|
108
|
+
* threshold, profile, MailSpamScoreError }` where `.score` resolves to
|
|
109
|
+
* `{ score, reasons, verdict }`. `verdict` is `"accept"` /
|
|
110
|
+
* `"score-tag"` / `"refuse"` based on threshold comparison.
|
|
111
|
+
*
|
|
112
|
+
* @opts
|
|
113
|
+
* scorer: async fn({ rawBytes, headers, envelope }) → { score, reasons } — required
|
|
114
|
+
* threshold: number — overrides profile default
|
|
115
|
+
* profile: "strict" | "balanced" | "permissive"
|
|
116
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2"
|
|
117
|
+
* audit: b.audit instance
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* var spam = b.mail.spamScore.create({
|
|
121
|
+
* scorer: async function (ctx) {
|
|
122
|
+
* return await callSpamAssassin(ctx.rawBytes);
|
|
123
|
+
* },
|
|
124
|
+
* });
|
|
125
|
+
* var v = await spam.score({ rawBytes: msg });
|
|
126
|
+
* if (v.verdict === "refuse") refuseConnection(v.reasons.join(","));
|
|
127
|
+
*/
|
|
128
|
+
function create(opts) {
|
|
129
|
+
opts = validateOpts.requireObject(opts || {}, "mail.spamScore.create",
|
|
130
|
+
MailSpamScoreError, "mail-spam-score/bad-opts");
|
|
131
|
+
validateOpts(opts, [
|
|
132
|
+
"scorer", "threshold", "profile", "posture", "audit",
|
|
133
|
+
], "mail.spamScore.create");
|
|
134
|
+
if (typeof opts.scorer !== "function") {
|
|
135
|
+
throw new MailSpamScoreError("mail-spam-score/bad-scorer",
|
|
136
|
+
"mail.spamScore.create.scorer must be a function; got " + (typeof opts.scorer));
|
|
137
|
+
}
|
|
138
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
139
|
+
if (!PROFILES[profile]) {
|
|
140
|
+
throw new MailSpamScoreError("mail-spam-score/bad-profile",
|
|
141
|
+
"mail.spamScore.create.profile: unknown '" + profile +
|
|
142
|
+
"' (valid: strict / balanced / permissive)");
|
|
143
|
+
}
|
|
144
|
+
var caps = PROFILES[profile];
|
|
145
|
+
var threshold;
|
|
146
|
+
if (opts.threshold !== undefined) {
|
|
147
|
+
if (typeof opts.threshold !== "number" || !isFinite(opts.threshold)) {
|
|
148
|
+
throw new MailSpamScoreError("mail-spam-score/bad-threshold",
|
|
149
|
+
"mail.spamScore.create.threshold must be a finite number; got " +
|
|
150
|
+
(typeof opts.threshold) + " " + String(opts.threshold));
|
|
151
|
+
}
|
|
152
|
+
threshold = opts.threshold;
|
|
153
|
+
} else {
|
|
154
|
+
threshold = caps.threshold;
|
|
155
|
+
}
|
|
156
|
+
var auditImpl = opts.audit || audit();
|
|
157
|
+
|
|
158
|
+
async function score(message) {
|
|
159
|
+
if (!message || typeof message !== "object") {
|
|
160
|
+
throw new MailSpamScoreError("mail-spam-score/bad-input",
|
|
161
|
+
"mail.spamScore.score: message must be an object with rawBytes/headers/envelope");
|
|
162
|
+
}
|
|
163
|
+
var rv;
|
|
164
|
+
try {
|
|
165
|
+
rv = await opts.scorer({
|
|
166
|
+
rawBytes: message.rawBytes,
|
|
167
|
+
headers: message.headers || {},
|
|
168
|
+
envelope: message.envelope || {},
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
_emitAudit(auditImpl, "mail.spam_score.error", "failure", {
|
|
172
|
+
message: (e && e.message) || String(e),
|
|
173
|
+
});
|
|
174
|
+
throw new MailSpamScoreError("mail-spam-score/scorer-threw",
|
|
175
|
+
"mail.spamScore.score: scorer threw: " + ((e && e.message) || e));
|
|
176
|
+
}
|
|
177
|
+
if (!rv || typeof rv !== "object") {
|
|
178
|
+
throw new MailSpamScoreError("mail-spam-score/bad-result",
|
|
179
|
+
"mail.spamScore.score: scorer must return { score, reasons }; got " +
|
|
180
|
+
(typeof rv));
|
|
181
|
+
}
|
|
182
|
+
if (typeof rv.score !== "number" || !isFinite(rv.score)) {
|
|
183
|
+
throw new MailSpamScoreError("mail-spam-score/bad-score",
|
|
184
|
+
"mail.spamScore.score: scorer returned non-finite score=" + String(rv.score));
|
|
185
|
+
}
|
|
186
|
+
var reasons = _sanitizeReasons(rv.reasons, caps);
|
|
187
|
+
|
|
188
|
+
var verdict;
|
|
189
|
+
if (rv.score < threshold) verdict = "accept";
|
|
190
|
+
else if (rv.score === threshold) verdict = "score-tag";
|
|
191
|
+
else verdict = "refuse";
|
|
192
|
+
|
|
193
|
+
_emitAudit(auditImpl, "mail.spam_score.score", "success", {
|
|
194
|
+
score: rv.score, threshold: threshold, verdict: verdict, reasons: reasons,
|
|
195
|
+
});
|
|
196
|
+
if (verdict === "accept") {
|
|
197
|
+
_emitAudit(auditImpl, "mail.spam_score.accept", "success", { score: rv.score });
|
|
198
|
+
} else if (verdict === "score-tag") {
|
|
199
|
+
_emitAudit(auditImpl, "mail.spam_score.score_tag", "success",
|
|
200
|
+
{ score: rv.score, reasons: reasons });
|
|
201
|
+
} else {
|
|
202
|
+
_emitAudit(auditImpl, "mail.spam_score.refuse", "success",
|
|
203
|
+
{ score: rv.score, reasons: reasons });
|
|
204
|
+
}
|
|
205
|
+
return { score: rv.score, reasons: reasons, verdict: verdict };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
score: score,
|
|
210
|
+
threshold: threshold,
|
|
211
|
+
profile: profile,
|
|
212
|
+
MailSpamScoreError: MailSpamScoreError,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @primitive b.mail.spamScore.compliancePosture
|
|
218
|
+
* @signature b.mail.spamScore.compliancePosture(posture)
|
|
219
|
+
* @since 0.9.81
|
|
220
|
+
* @status stable
|
|
221
|
+
*
|
|
222
|
+
* Return the effective profile name for a compliance posture, or
|
|
223
|
+
* `null` for unknown posture names.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* b.mail.spamScore.compliancePosture("hipaa"); // → "strict"
|
|
227
|
+
*/
|
|
228
|
+
function compliancePosture(posture) {
|
|
229
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _sanitizeReasons(reasons, caps) {
|
|
233
|
+
if (reasons === undefined || reasons === null) return [];
|
|
234
|
+
if (!Array.isArray(reasons)) {
|
|
235
|
+
throw new MailSpamScoreError("mail-spam-score/bad-reasons",
|
|
236
|
+
"mail.spamScore.score: scorer returned non-array reasons (" + (typeof reasons) + ")");
|
|
237
|
+
}
|
|
238
|
+
if (reasons.length > caps.maxReasons) {
|
|
239
|
+
throw new MailSpamScoreError("mail-spam-score/too-many-reasons",
|
|
240
|
+
"mail.spamScore.score: scorer returned " + reasons.length +
|
|
241
|
+
" reasons; cap is " + caps.maxReasons);
|
|
242
|
+
}
|
|
243
|
+
var out = [];
|
|
244
|
+
for (var i = 0; i < reasons.length; i += 1) {
|
|
245
|
+
var r = reasons[i];
|
|
246
|
+
if (typeof r !== "string" || r.length === 0) {
|
|
247
|
+
throw new MailSpamScoreError("mail-spam-score/bad-reason",
|
|
248
|
+
"mail.spamScore.score: reasons[" + i + "] must be a non-empty string");
|
|
249
|
+
}
|
|
250
|
+
if (Buffer.byteLength(r, "utf8") > caps.maxReasonBytes) {
|
|
251
|
+
throw new MailSpamScoreError("mail-spam-score/oversize-reason",
|
|
252
|
+
"mail.spamScore.score: reasons[" + i + "] exceeds " + caps.maxReasonBytes + " bytes");
|
|
253
|
+
}
|
|
254
|
+
// Refuse control bytes (CR / LF / NUL / etc.) — a compromised
|
|
255
|
+
// scorer could try to smuggle CRLF into an outbound X-Spam-Status
|
|
256
|
+
// header.
|
|
257
|
+
for (var c = 0; c < r.length; c += 1) {
|
|
258
|
+
var cc = r.charCodeAt(c);
|
|
259
|
+
if (cc < 0x20 || cc === 0x7f) { // allow:raw-byte-literal — RFC 5234 CTL refusal range
|
|
260
|
+
throw new MailSpamScoreError("mail-spam-score/control-byte",
|
|
261
|
+
"mail.spamScore.score: reasons[" + i + "] contains control byte 0x" +
|
|
262
|
+
cc.toString(16)); // allow:raw-byte-literal — hex radix
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
out.push(r);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function _emitAudit(auditImpl, action, outcome, metadata) {
|
|
271
|
+
try {
|
|
272
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
273
|
+
auditImpl.safeEmit({ action: action, outcome: outcome, metadata: metadata });
|
|
274
|
+
}
|
|
275
|
+
} catch (_e) { /* drop-silent — audit failures don't break score path */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
create: create,
|
|
280
|
+
compliancePosture: compliancePosture,
|
|
281
|
+
PROFILES: PROFILES,
|
|
282
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
283
|
+
MailSpamScoreError: MailSpamScoreError,
|
|
284
|
+
};
|
package/lib/mail.js
CHANGED
|
@@ -59,6 +59,7 @@ var C = require("./constants");
|
|
|
59
59
|
var bCrypto = require("./crypto");
|
|
60
60
|
var lazyRequire = require("./lazy-require");
|
|
61
61
|
var safeBuffer = require("./safe-buffer");
|
|
62
|
+
var guardDomain = require("./guard-domain");
|
|
62
63
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
63
64
|
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
64
65
|
var guardEmail = lazyRequire(function () { return require("./guard-email"); });
|
|
@@ -770,6 +771,28 @@ function smtpTransport(opts) {
|
|
|
770
771
|
var useImplicitTLS = port === 465 || opts.implicitTls === true;
|
|
771
772
|
var rejectUnauthorized = opts.rejectUnauthorized !== false;
|
|
772
773
|
var ehloName = opts.ehloName || "blamejs";
|
|
774
|
+
// GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g (nodemailer CRLF-injection
|
|
775
|
+
// class) — any string concatenated into an outbound SMTP wire command
|
|
776
|
+
// MUST be CRLF/NUL-free, otherwise an attacker who can shape ehloName /
|
|
777
|
+
// user / pass / host (via config injection or template indirection)
|
|
778
|
+
// gets to inject a fresh EHLO / MAIL FROM / RCPT TO line. Refuse at
|
|
779
|
+
// config-time so the operator's boot dies at the misconfiguration line
|
|
780
|
+
// rather than silently emitting a smuggled command at first send.
|
|
781
|
+
function _refuseCtlBytes(label, val) {
|
|
782
|
+
if (val === undefined || val === null) return;
|
|
783
|
+
if (typeof val !== "string") return;
|
|
784
|
+
if (/[\r\n\0]/.test(val)) { // allow:regex-no-length-cap — CRLF/NUL is a 3-codepoint class
|
|
785
|
+
throw new MailError("mail/smtp-misconfigured",
|
|
786
|
+
"smtp transport: opts." + label + " contains CR/LF/NUL bytes " +
|
|
787
|
+
"(SMTP command-injection class — GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g)",
|
|
788
|
+
true);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
_refuseCtlBytes("ehloName", ehloName);
|
|
792
|
+
_refuseCtlBytes("user", opts.user);
|
|
793
|
+
_refuseCtlBytes("pass", opts.pass);
|
|
794
|
+
_refuseCtlBytes("host", opts.host);
|
|
795
|
+
_refuseCtlBytes("servername", opts.servername);
|
|
773
796
|
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
774
797
|
var tlsOpts = {
|
|
775
798
|
rejectUnauthorized: rejectUnauthorized,
|
|
@@ -1557,6 +1580,7 @@ function create(opts) {
|
|
|
1557
1580
|
validateOpts(opts, [
|
|
1558
1581
|
"transport", "defaults", "audit",
|
|
1559
1582
|
"commercial", "postalAddress", "footerSeparator", "footerHtml", "regulated",
|
|
1583
|
+
"guardDomain", "profile",
|
|
1560
1584
|
], "mail");
|
|
1561
1585
|
var transport = opts.transport || consoleTransport();
|
|
1562
1586
|
if (typeof transport === "function") {
|
|
@@ -1569,6 +1593,68 @@ function create(opts) {
|
|
|
1569
1593
|
var defaults = opts.defaults || {};
|
|
1570
1594
|
var auditOn = opts.audit !== false;
|
|
1571
1595
|
|
|
1596
|
+
// Default-on guardDomain hardening for every outbound recipient + the
|
|
1597
|
+
// sender address. Refuses CVE-2017-5469-class IDN homograph spoofs in
|
|
1598
|
+
// recipient or from domains, RFC 6761 special-use domain names
|
|
1599
|
+
// (`.localhost`, `.test`, `.invalid`, `.example`) in production sends,
|
|
1600
|
+
// RFC 1035 §2.3.4 label-length violations, and CVE-2021-22931-class
|
|
1601
|
+
// bare-IP-as-domain (DNS-rebinding allowlist-bypass class). Operators
|
|
1602
|
+
// sending to address literals (`<x@[1.2.3.4]>`) — rare; mostly mailing-
|
|
1603
|
+
// list internals — pass `guardDomain: false` to opt out, or pass
|
|
1604
|
+
// `guardDomain: { profile: "permissive" }` to relax the rules.
|
|
1605
|
+
var guardDomainProfileName;
|
|
1606
|
+
if (opts.guardDomain === false) {
|
|
1607
|
+
guardDomainProfileName = null;
|
|
1608
|
+
} else {
|
|
1609
|
+
guardDomainProfileName = opts.guardDomain && typeof opts.guardDomain === "object"
|
|
1610
|
+
? (opts.guardDomain.profile || opts.profile || "strict")
|
|
1611
|
+
: (opts.profile || "strict");
|
|
1612
|
+
}
|
|
1613
|
+
function _validateAddrDomain(addr, label) {
|
|
1614
|
+
if (!guardDomainProfileName) return;
|
|
1615
|
+
if (typeof addr !== "string") return;
|
|
1616
|
+
// RFC 5322 §3.4 angle-bracket address (`name <local@dom>`) — extract
|
|
1617
|
+
// the inner address via indexOf/lastIndexOf rather than a regex so
|
|
1618
|
+
// we stay linear on input shape (CodeQL js/polynomial-redos class).
|
|
1619
|
+
var ltIdx = addr.indexOf("<");
|
|
1620
|
+
var gtIdx = addr.lastIndexOf(">");
|
|
1621
|
+
var rawAddr = (ltIdx !== -1 && gtIdx > ltIdx)
|
|
1622
|
+
? addr.slice(ltIdx + 1, gtIdx)
|
|
1623
|
+
: addr;
|
|
1624
|
+
var atIdx = rawAddr.lastIndexOf("@");
|
|
1625
|
+
if (atIdx === -1) return;
|
|
1626
|
+
var domain = rawAddr.slice(atIdx + 1).trim();
|
|
1627
|
+
// RFC 5321 §4.1.3 address-literal form `[1.2.3.4]` / `[IPv6:...]`
|
|
1628
|
+
// — already a syntactic constraint via the brackets; b.guardDomain
|
|
1629
|
+
// refuses bare IPs without brackets which is the security-relevant
|
|
1630
|
+
// shape (CVE-2021-22931 DNS rebinding allowlist-bypass).
|
|
1631
|
+
if (domain.length === 0 || domain[0] === "[") return;
|
|
1632
|
+
// RFC 5891 ToASCII — convert any IDN labels to Punycode BEFORE
|
|
1633
|
+
// guardDomain validation so EAI (RFC 6531) addresses like
|
|
1634
|
+
// `<x@münchen.example>` pass under strict (which refuses raw
|
|
1635
|
+
// Unicode labels per RFC 5891 §4.2 transport-safety rule). The
|
|
1636
|
+
// SMTPUTF8 wire encoding is the transport's concern; the gate
|
|
1637
|
+
// here runs on a transport-safe form.
|
|
1638
|
+
var asciiDomain = toAscii(domain) || domain;
|
|
1639
|
+
// Override punycodePolicy — `xn--…` labels are RFC 5891-encoded
|
|
1640
|
+
// IDNs and the whole point of EAI (RFC 6531) is to deliver to
|
|
1641
|
+
// them. The strict profile defaults to refusing Punycode (the
|
|
1642
|
+
// generic "operator typed a homograph" defense); for mail.send
|
|
1643
|
+
// we've already gone through RFC 5891 ToASCII, so the Punycode
|
|
1644
|
+
// is structural, not a homograph attempt. All other strict
|
|
1645
|
+
// defenses (mixed-script, BIDI, control, IP-literal, special-
|
|
1646
|
+
// use, wildcard, DGA, raw-unicode pre-conversion) remain.
|
|
1647
|
+
var verdict = guardDomain.validate(asciiDomain, {
|
|
1648
|
+
profile: guardDomainProfileName,
|
|
1649
|
+
punycodePolicy: "allow",
|
|
1650
|
+
});
|
|
1651
|
+
if (!verdict.ok) {
|
|
1652
|
+
throw new MailError("mail/recipient-domain-refused",
|
|
1653
|
+
"mail.send: " + label + " domain '" + domain + "' refused by b.guardDomain (" +
|
|
1654
|
+
(verdict.issues && verdict.issues[0] && verdict.issues[0].kind) + ")", true);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1572
1658
|
// CAN-SPAM Act §7704(a)(5) — every commercial-content message MUST
|
|
1573
1659
|
// include the sender's valid physical postal address. Validate the
|
|
1574
1660
|
// address shape at create() so a typo / blank field surfaces at boot,
|
|
@@ -1698,6 +1784,19 @@ function create(opts) {
|
|
|
1698
1784
|
|
|
1699
1785
|
_validateMessage(merged);
|
|
1700
1786
|
|
|
1787
|
+
// Default-on guardDomain hardening on every recipient + the sender
|
|
1788
|
+
// address (see closure setup above). Skipped when operator opts out
|
|
1789
|
+
// via guardDomain:false.
|
|
1790
|
+
if (guardDomainProfileName) {
|
|
1791
|
+
_validateAddrDomain(merged.from, "from");
|
|
1792
|
+
var _toArr = _normalizeRecipientList(merged.to, "to");
|
|
1793
|
+
var _ccArr = _normalizeRecipientList(merged.cc, "cc");
|
|
1794
|
+
var _bccArr = _normalizeRecipientList(merged.bcc, "bcc");
|
|
1795
|
+
for (var _ti = 0; _ti < _toArr.length; _ti += 1) _validateAddrDomain(_toArr[_ti], "to");
|
|
1796
|
+
for (var _ci = 0; _ci < _ccArr.length; _ci += 1) _validateAddrDomain(_ccArr[_ci], "cc");
|
|
1797
|
+
for (var _bi = 0; _bi < _bccArr.length; _bi += 1) _validateAddrDomain(_bccArr[_bi], "bcc");
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1701
1800
|
var t0 = Date.now();
|
|
1702
1801
|
try {
|
|
1703
1802
|
var result = await transport.send(merged);
|
package/lib/metrics.js
CHANGED
|
@@ -142,12 +142,70 @@ function _normalizeLabelArg(callLabels, value, defaultValue) {
|
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// CRYPTO-18 — credential-shape detector. Operators routinely tap their
|
|
146
|
+
// own observability with `{ token: req.headers.authorization }` or
|
|
147
|
+
// `{ apiKey: req.headers["x-api-key"] }`, which then leak through the
|
|
148
|
+
// /metrics scrape surface to any reader of the metrics endpoint. The
|
|
149
|
+
// detector refuses (replaces with `[REDACTED-CREDENTIAL]`) any value
|
|
150
|
+
// matching well-known credential shapes:
|
|
151
|
+
//
|
|
152
|
+
// - "Bearer <token>" / "Basic <base64>" / "Negotiate <token>" — RFC
|
|
153
|
+
// 6750 / 7617 / 4559 wire forms
|
|
154
|
+
// - "Token <opaque>" — common GitLab / Trello convention
|
|
155
|
+
// - "sk-" / "pk-" / "rk-" prefixes — Stripe, OpenAI, modern issuers
|
|
156
|
+
// - "ghp_" / "ghs_" / "github_pat_" — GitHub
|
|
157
|
+
// - JWT shape: header.payload.signature (each segment base64url with
|
|
158
|
+
// length >= 8)
|
|
159
|
+
// - High-entropy long strings (>= 40 chars, hex / base64-shape) are
|
|
160
|
+
// a heuristic fallback so unknown-issuer tokens still get caught
|
|
161
|
+
var _CRED_PREFIX_RE = /^(?:Bearer|Basic|Negotiate|Token|Digest)\s+\S/i;
|
|
162
|
+
var _CRED_ISSUER_RE = /^(?:sk-|pk-|rk-|ghp_|ghs_|gho_|github_pat_|xoxb-|xoxa-|xoxp-|xoxr-|xapp-)/;
|
|
163
|
+
var _CRED_JWT_RE = /^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/; // allow:raw-byte-literal — JWT segment min length
|
|
164
|
+
var _CRED_ENTROPY_RE = /^[A-Za-z0-9_+/=-]{40,}$/; // allow:raw-byte-literal — high-entropy length floor
|
|
165
|
+
|
|
166
|
+
// CRED_MAX_SCAN — upper bound on the byte slice the credential
|
|
167
|
+
// detector inspects. Operator-supplied label values longer than this
|
|
168
|
+
// are still REDACTED (a 4 KiB token that opens with a Bearer prefix is
|
|
169
|
+
// still a credential), but the regex tests run on the prefix slice so
|
|
170
|
+
// a 1 GB string can't ReDoS the scanner. Counter cardinality stays
|
|
171
|
+
// stable: the same long string always maps to the same prefix slice.
|
|
172
|
+
var CRED_MAX_SCAN = 256; // allow:raw-byte-literal — prefix-scan length cap
|
|
173
|
+
|
|
174
|
+
function _looksLikeCredential(str) {
|
|
175
|
+
if (typeof str !== "string") return false;
|
|
176
|
+
if (str.length < 8) return false; // allow:raw-byte-literal — minimum credential length floor
|
|
177
|
+
// Clamp to the prefix slice so a hostile label value can't push the
|
|
178
|
+
// regex into superlinear time. All four credential shapes have
|
|
179
|
+
// signature in the first ~256 bytes; Stripe / GitHub / OpenAI tokens
|
|
180
|
+
// are <64 bytes, JWTs are typically <2 KiB but the header + first
|
|
181
|
+
// payload segment fit in the prefix.
|
|
182
|
+
var clamped = str.length > CRED_MAX_SCAN ? str.slice(0, CRED_MAX_SCAN) : str;
|
|
183
|
+
// CRED_MIN_LEN — credential shapes shorter than 8 chars don't carry
|
|
184
|
+
// enough entropy to be real tokens; hoisted to a named constant so
|
|
185
|
+
// every test() has its length floor visible at the call site
|
|
186
|
+
// (testFormatValidatorLengthCap convention).
|
|
187
|
+
var CRED_MIN_LEN = 8; // allow:raw-byte-literal — minimum credential length floor
|
|
188
|
+
if (clamped.length >= CRED_MIN_LEN && _CRED_PREFIX_RE.test(clamped)) return true;
|
|
189
|
+
if (clamped.length >= CRED_MIN_LEN && _CRED_ISSUER_RE.test(clamped)) return true;
|
|
190
|
+
if (clamped.length >= CRED_MIN_LEN && _CRED_JWT_RE.test(clamped)) return true;
|
|
191
|
+
if (clamped.length >= CRED_MIN_LEN && _CRED_ENTROPY_RE.test(clamped)) return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
145
195
|
function _validateLabelValue(value) {
|
|
146
196
|
// Prometheus exposition: label values are quoted strings; backslash,
|
|
147
197
|
// newline, double-quote get escaped at serialize time. Coerce here so
|
|
148
198
|
// counters indexed by various input types still work.
|
|
149
199
|
if (value === null || value === undefined) return "";
|
|
150
|
-
|
|
200
|
+
var coerced = String(value);
|
|
201
|
+
// CRYPTO-18 — credential-shape detector. Operators who tap their
|
|
202
|
+
// observability with raw header values leak bearer tokens / API
|
|
203
|
+
// keys through /metrics to every scrape reader. Refuse the value
|
|
204
|
+
// and surface a redaction marker so the metric still labels (so
|
|
205
|
+
// counter cardinality doesn't collapse to a single empty-string
|
|
206
|
+
// bucket) but the bytes themselves never reach the scrape stream.
|
|
207
|
+
if (_looksLikeCredential(coerced)) return "[REDACTED-CREDENTIAL]";
|
|
208
|
+
return coerced;
|
|
151
209
|
}
|
|
152
210
|
|
|
153
211
|
// Serialize a labels object to a canonical Map key. Routed through
|
|
@@ -725,6 +783,7 @@ function _resetForTest() {
|
|
|
725
783
|
* path: string, // absolute path to write the snapshot
|
|
726
784
|
* intervalMs: number, // milliseconds between flushes (>=100)
|
|
727
785
|
* fields: Function, // returns an object — written as JSON
|
|
786
|
+
* fileMode: number, // POSIX mode (default 0o640 — owner rw, group r)
|
|
728
787
|
*
|
|
729
788
|
* @example
|
|
730
789
|
* var stop = b.metrics.snapshot.startWriter({
|
|
@@ -757,6 +816,17 @@ function snapshotStartWriter(opts) {
|
|
|
757
816
|
var p = opts.path;
|
|
758
817
|
var fieldsFn = opts.fields;
|
|
759
818
|
var intervalMs = opts.intervalMs;
|
|
819
|
+
// CRYPTO-6 — file mode for the atomic write. Default 0o640
|
|
820
|
+
// (owner rw, group r, world none). Operators with a sidecar
|
|
821
|
+
// reader in a different group override to 0o644; multi-tenant
|
|
822
|
+
// hosts may even tighten to 0o600.
|
|
823
|
+
var fileMode = opts.fileMode !== undefined ? opts.fileMode : 0o640; // allow:raw-byte-literal — POSIX file mode octal
|
|
824
|
+
if (typeof fileMode !== "number" || !isFinite(fileMode) ||
|
|
825
|
+
fileMode < 0 || fileMode > 0o777 || Math.floor(fileMode) !== fileMode) {
|
|
826
|
+
throw new MetricsError("metrics-snapshot/bad-file-mode",
|
|
827
|
+
"metrics.snapshot.startWriter: opts.fileMode must be a POSIX file-mode integer in [0, 0o777], got " +
|
|
828
|
+
fileMode);
|
|
829
|
+
}
|
|
760
830
|
|
|
761
831
|
var doFlush = function () {
|
|
762
832
|
var snap;
|
|
@@ -775,7 +845,12 @@ function snapshotStartWriter(opts) {
|
|
|
775
845
|
fields: snap,
|
|
776
846
|
};
|
|
777
847
|
try {
|
|
778
|
-
|
|
848
|
+
// CRYPTO-6 — default 0o640 (owner rw, group r, world none) so
|
|
849
|
+
// operator-supplied snapshot fields aren't world-readable on a
|
|
850
|
+
// multi-tenant host. Operators with a sidecar reader running as
|
|
851
|
+
// a different group override via opts.fileMode at startWriter
|
|
852
|
+
// construction.
|
|
853
|
+
atomicFile.writeSync(p, JSON.stringify(payload) + "\n", { fileMode: fileMode });
|
|
779
854
|
} catch (e) {
|
|
780
855
|
log("snapshot.writeSync failed: " + (e && e.message ? e.message : String(e)));
|
|
781
856
|
}
|
|
@@ -829,7 +904,9 @@ function snapshotRead(p) {
|
|
|
829
904
|
// is well above the framework's expected snapshot size (~5-50 KiB)
|
|
830
905
|
// and the safeJson absolute cap stays within reach.
|
|
831
906
|
try {
|
|
832
|
-
|
|
907
|
+
// CRYPTO-21 — route through C.BYTES.mib(4); the raw byte literal
|
|
908
|
+
// was a drift smell flagged by codebase-patterns.
|
|
909
|
+
parsed = safeJson.parse(raw, { maxBytes: C.BYTES.mib(4) });
|
|
833
910
|
} catch (e) {
|
|
834
911
|
throw new MetricsError("metrics-snapshot/bad-json",
|
|
835
912
|
"metrics.snapshot.read: " + p + " contains invalid JSON: " + (e && e.message ? e.message : String(e)));
|
|
@@ -853,18 +930,43 @@ function snapshotRead(p) {
|
|
|
853
930
|
* Format a snapshot object for human or machine consumption.
|
|
854
931
|
*
|
|
855
932
|
* format: "text" — operator-readable lines, one field per row (default)
|
|
856
|
-
* format: "prometheus" — Prometheus 0.0.4 text format
|
|
857
|
-
*
|
|
858
|
-
*
|
|
933
|
+
* format: "prometheus" — Prometheus 0.0.4 text format
|
|
934
|
+
*
|
|
935
|
+
* ## Type detection (`prometheus` format only)
|
|
936
|
+
*
|
|
937
|
+
* Per Prometheus naming convention + OpenMetrics 1.0.0 §6.2, counter
|
|
938
|
+
* metric families MUST carry the `_total` suffix; every other numeric
|
|
939
|
+
* field renders as a gauge. The renderer auto-detects by suffix:
|
|
940
|
+
*
|
|
941
|
+
* - field name ends in `_total` → `# TYPE <name> counter`
|
|
942
|
+
* - everything else → `# TYPE <name> gauge`
|
|
943
|
+
*
|
|
944
|
+
* Operators with metrics that don't fit the convention (e.g. a counter
|
|
945
|
+
* named `bytes_sent` without the `_total` suffix, or a gauge that
|
|
946
|
+
* happens to end in `_total`) opt the right type via `opts.fieldTypes`:
|
|
947
|
+
*
|
|
948
|
+
* render(snap, { format: "prometheus", fieldTypes: {
|
|
949
|
+
* bytes_sent: "counter", // override default gauge
|
|
950
|
+
* ratio_total: "gauge", // override default counter
|
|
951
|
+
* }});
|
|
952
|
+
*
|
|
953
|
+
* Pre-v0.9.47 every field rendered as gauge regardless of name, which
|
|
954
|
+
* broke `rate()` queries against counter-shaped series. Operators
|
|
955
|
+
* scraping a long-running deployment will see `rate(*_total[5m])`
|
|
956
|
+
* queries start returning the right answer once the new types reach
|
|
957
|
+
* the scrape target.
|
|
859
958
|
*
|
|
860
959
|
* @opts
|
|
861
|
-
* format:
|
|
862
|
-
* prefix:
|
|
960
|
+
* format: "text" | "prometheus", // default: "text"
|
|
961
|
+
* prefix: string, // prometheus-only; default: "blamejs"
|
|
962
|
+
* fieldTypes: Object, // prometheus-only; per-field type override
|
|
963
|
+
* // map. Values: "counter" | "gauge".
|
|
863
964
|
*
|
|
864
965
|
* @example
|
|
865
966
|
* var snap = b.metrics.snapshot.read("/run/blamejs/metrics.json");
|
|
866
967
|
* process.stdout.write(b.metrics.snapshot.render(snap));
|
|
867
|
-
* // or for Prometheus scraping
|
|
968
|
+
* // or for Prometheus scraping (auto-detects http_requests_total
|
|
969
|
+
* // as a counter via the _total suffix):
|
|
868
970
|
* res.setHeader("Content-Type", "text/plain; version=0.0.4");
|
|
869
971
|
* res.end(b.metrics.snapshot.render(snap, { format: "prometheus", prefix: "myapp" }));
|
|
870
972
|
*/
|
|
@@ -899,6 +1001,11 @@ function snapshotRender(snap, opts) {
|
|
|
899
1001
|
throw new MetricsError("metrics-snapshot/bad-prefix",
|
|
900
1002
|
"metrics.snapshot.render: prometheus prefix must match [a-zA-Z_][a-zA-Z0-9_]*, got '" + prefix + "'");
|
|
901
1003
|
}
|
|
1004
|
+
var fieldTypes = opts.fieldTypes || {};
|
|
1005
|
+
if (typeof fieldTypes !== "object" || fieldTypes === null || Array.isArray(fieldTypes)) {
|
|
1006
|
+
throw new MetricsError("metrics-snapshot/bad-field-types",
|
|
1007
|
+
"metrics.snapshot.render: opts.fieldTypes must be an object mapping field-name → 'counter' | 'gauge'");
|
|
1008
|
+
}
|
|
902
1009
|
var out = [];
|
|
903
1010
|
// allow:bare-canonicalize-walk — sort is for stable Prometheus
|
|
904
1011
|
// exposition output ordering, not canonicalize-for-hashing
|
|
@@ -909,7 +1016,20 @@ function snapshotRender(snap, opts) {
|
|
|
909
1016
|
if (typeof v2 !== "number" || !isFinite(v2)) continue; // only numeric scalars
|
|
910
1017
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k2)) continue; // skip prom-incompatible names
|
|
911
1018
|
var metric = prefix + "_" + k2;
|
|
912
|
-
|
|
1019
|
+
var declared = fieldTypes[k2];
|
|
1020
|
+
var fieldType;
|
|
1021
|
+
if (declared !== undefined) {
|
|
1022
|
+
if (declared !== "counter" && declared !== "gauge") {
|
|
1023
|
+
throw new MetricsError("metrics-snapshot/bad-field-type",
|
|
1024
|
+
"metrics.snapshot.render: opts.fieldTypes." + k2 + " must be 'counter' or 'gauge', got '" + declared + "'");
|
|
1025
|
+
}
|
|
1026
|
+
fieldType = declared;
|
|
1027
|
+
} else {
|
|
1028
|
+
// Prometheus naming convention + OpenMetrics 1.0.0 §6.2:
|
|
1029
|
+
// counter family names carry the _total suffix.
|
|
1030
|
+
fieldType = /_total$/.test(k2) ? "counter" : "gauge";
|
|
1031
|
+
}
|
|
1032
|
+
out.push("# TYPE " + metric + " " + fieldType);
|
|
913
1033
|
out.push(metric + " " + v2);
|
|
914
1034
|
}
|
|
915
1035
|
return out.join("\n") + "\n";
|