@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. 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
- return String(value);
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
- atomicFile.writeSync(p, JSON.stringify(payload) + "\n", { fileMode: 0o644 });
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
- parsed = safeJson.parse(raw, { maxBytes: 4 * 1024 * 1024 }); // allow:raw-byte-literal — 4 MiB snapshot-file ceiling
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, gauge metrics
857
- * named with a configurable prefix; only top-level
858
- * numeric fields under `snap.fields` are emitted
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: "text" | "prometheus", // default: "text"
862
- * prefix: string, // prometheus-only; default: "blamejs"
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
- out.push("# TYPE " + metric + " gauge");
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";