@blamejs/core 0.7.74 → 0.7.76
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 +4 -0
- package/lib/audit.js +21 -3
- package/lib/log.js +27 -0
- package/lib/redact.js +18 -0
- package/lib/static.js +50 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.76** (2026-05-06) — CVE-class web hardening sweep — Trojan Source (CVE-2021-42574) log defense + drive-by-MIME attachment opt for `staticServe`. **Trojan Source defense** in `b.log` output: every log line now post-processes Unicode bidi / format-control characters (U+061C / U+200E-200F / U+202A-202E / U+2066-2069) into their `\uXXXX` literal escape on the wire so a hostile log message can't re-order the visible line in a TTY / syslog / file reader. JSON.stringify alone does NOT escape these codepoints, so a captured-error message containing `U+202E` (RIGHT-TO-LEFT OVERRIDE) survives into the log surface and silently flips visible field/key associations. The escape applies to the entire serialized JSON line including any extras / bound-context fields. **`staticServe.create({ safeAttachmentForRiskyMimes: true })`** — opt-in flag that adds `Content-Disposition: attachment` to responses whose Content-Type is in the risky-inline-MIME set (`text/html`, `text/xml`, `application/xml`, `application/xhtml+xml`, `image/svg+xml`, `application/javascript`, `text/javascript`, `application/x-javascript`). Defends against drive-by execution of user-uploaded HTML / JS / SVG (CVE-2017-15012 SVG XSS / CVE-2009-1312 HTML drive-by class). Default `false` — operators serving framework asset bundles continue to render inline; operators serving user-content directories opt in. Filename is RFC 5987-encoded (ASCII filename + `filename*=UTF-8''...`) so non-ASCII filenames survive without allowing CR/LF header injection.
|
|
12
|
+
|
|
13
|
+
- **0.7.75** (2026-05-06) — Audit-emit redaction pipeline. `b.audit.safeEmit` now scrubs the `actor` / `reason` / `metadata` fields through `b.redact.redact()` before they hit the audit handler. Operators who pass `metadata: { reason: e.message }` from a caught error could land DB connection strings, bearer tokens, JWT compact-serialization fixtures, AWS access keys, PEM private keys, SSH keys, credit cards, and SSNs in audit rows; the redact pipeline catches the common shapes (sensitive field names + value-shape detectors) and replaces them with markers (`[REDACTED]`, `[REDACTED-CONN-STRING]`, `[REDACTED-JWT]`, `[REDACTED-PEM]`, `[REDACTED-AWS-KEY]`, `[REDACTED-CC]`, `[REDACTED-SSN]`, `[REDACTED-SEALED]`, `[REDACTED-SSH-KEY]`). The `b.redact` primitive existed in lib/ since v0.4.x but was dead code — `audit.safeEmit` previously passed metadata through unchanged. New connection-string detector matches `protocol://user:pass@host` shapes that surface in error messages from external-DB / SMTP / HTTP drivers (RFC 3986 generic syntax). Drop-silent on redact failure — the pipeline never breaks the caller's audit attempt.
|
|
14
|
+
|
|
11
15
|
- **0.7.74** (2026-05-06) — Email receive-side parity: DMARC aggregate (RUA) report parser + ARC trust evaluation + Authentication-Results header builder + TLS-RPT receive-side report parser. Closes the "framework can send mail compliantly but can't receive compliantly" gap. **`b.mail.dmarc.parseAggregateReport(xmlBytes, { contentType? })`** parses RFC 7489 §7.2 aggregate XML reports through the framework's existing `lib/parsers/safe-xml.js` (the existing security-focused XML parser handles XXE / DOCTYPE / entity-expansion defenses by default). Auto-detects gzip via magic bytes (`0x1f 0x8b`) or `Content-Type: application/gzip`. Returns `{ reportMetadata, policyPublished, records, totals }` with per-record source-IP / count / policy-evaluated dispositions / identifiers / DKIM + SPF auth results, plus aggregated `messages` / `aligned` / `notAligned` totals operators want for dashboards. Caps report size at 8 MiB and records-per-report at 10 000. **`b.mail.arc.evaluate(rfc822, { trustedSealers })`** wraps the existing `arc.verify` cryptographic chain check with the operator-side trust decision: given a passing chain, did any hop in the chain belong to a sealer the operator trusts? Returns `{ chainStatus, trusted, trustedHop, trustedDomain }` walking hops most-recent-first so the deepest trusted sealer wins. **`b.mail.authResults.emit({ authservId, results, fold? })`** builds the RFC 8601 Authentication-Results header value — operators consume per-method results from `b.mail.spf.verify` / `b.mail.dmarc.evaluate` / `b.mail.arc.verify`, hand them to `.emit`, and the framework formats the conformant header string with method-specific properties (`smtp.mailfrom`, `header.d`, `header.from`, `policy.iprev`, `policy.ip`, `policy.tls`). Refuses unknown methods / results at config-mistake time. **`b.network.smtp.tlsRpt.parseReport(body, { contentType? })`** is the receive-side counterpart to `tlsRpt.recordShape` / `tlsRpt.submit` — accepts a Buffer or string, auto-detects gzip, parses JSON, validates the RFC 8460 §4.4 required-fields shape (`organization-name`, `date-range`, `report-id`, `policies`), aggregates `total-successful-session-count` / `total-failure-session-count` across policies. Caps report size at 8 MiB and policies-per-report at 1024.
|
|
12
16
|
|
|
13
17
|
- **0.7.73** (2026-05-06) — `b.auth.aal` + `b.middleware.requireAal({ minimum })` — NIST SP 800-63-4 Authentication Assurance Level bands. **`b.auth.aal.fromMethods({ password, totp, webauthn, ... })`** combines a set of operator-asserted authenticator methods into the resulting band: `AAL1` (single factor — memorized secret OR single-factor cryptographic), `AAL2` (multi-factor — memorized secret + OTP/SMS/hardware/mTLS), `AAL3` (phishing-resistant multi-factor — WebAuthn / passkey / hardware-+-PIN). Recognized methods: `password`, `pin`, `totp`, `sms`, `webauthn`, `passkey`, `hardware`, `mtls`. **`b.middleware.requireAal({ minimum, getAal?, audit?, realm? })`** gates routes by the request's AAL band — reads `req.user.aal` by default (or operator-supplied `getAal(req)`), compares against the minimum, returns 401 with `WWW-Authenticate: AAL-StepUp realm="...", required="AAL2"` on insufficient assurance. The bespoke scheme name signals to the operator's frontend that a step-up flow should be triggered (re-prompt for TOTP / passkey) without reusing the generic `Bearer` challenge namespace. Audit emits `auth.aal.granted` / `auth.aal.denied` (drop-silent on observability sink failure). The framework leaves AMR/ACR claim emission to the operator's IdP; the new `b.auth.aal.AMR` constants object provides consistent OIDC-conformant strings for operators emitting access tokens with AAL info. Also exposes `b.auth.aal.meets(actual, required)` for ad-hoc band comparisons outside the middleware. **CI vendor-manifest gate fix**: vendor files now have an explicit `.gitattributes` `lib/vendor/** -text binary` declaration so git never rewrites line endings between Windows and Linux checkouts. The `lib/vendor/noble-ciphers.cjs` file was renormalized to LF on disk and re-hashed in `lib/vendor/MANIFEST.json`. Closes the v0.7.65–0.7.72 npm-publish.yml smoke-test failures (`vendor manifest: @noble/ciphers :: server hash matches`).
|
package/lib/audit.js
CHANGED
|
@@ -44,6 +44,7 @@ var { generateToken } = require("./crypto");
|
|
|
44
44
|
var cryptoField = require("./crypto-field");
|
|
45
45
|
var handlers = require("./handlers");
|
|
46
46
|
var { boot } = require("./log");
|
|
47
|
+
var redact = require("./redact");
|
|
47
48
|
var safeAsync = require("./safe-async");
|
|
48
49
|
var C = require("./constants");
|
|
49
50
|
var lazyRequire = require("./lazy-require");
|
|
@@ -734,13 +735,30 @@ function safeEmit(event) {
|
|
|
734
735
|
if (!event || typeof event !== "object") return;
|
|
735
736
|
if (typeof event.action !== "string") return; // can't emit without an action
|
|
736
737
|
try {
|
|
738
|
+
// Scrub credentials before they hit the audit handler. Operators
|
|
739
|
+
// who pass `metadata: { reason: e.message }` from a caught error
|
|
740
|
+
// can land DB connection strings, bearer tokens, and JWT compact-
|
|
741
|
+
// serialization fixtures in audit rows; redact.redact() catches the
|
|
742
|
+
// common shapes (sensitive field names + value-shape detectors:
|
|
743
|
+
// credit-card / JWT / PEM / AWS key / SSN / connection string) and
|
|
744
|
+
// replaces them with markers. Same pass also applies to actor +
|
|
745
|
+
// reason so the entire event surface is consistent. Drop-silent on
|
|
746
|
+
// redact failure — never break the caller's audit attempt.
|
|
747
|
+
var actor = event.actor || {};
|
|
748
|
+
var reason = event.reason || null;
|
|
749
|
+
var metadata = event.metadata || null;
|
|
750
|
+
try {
|
|
751
|
+
actor = redact.redact(actor);
|
|
752
|
+
if (reason !== null) reason = redact.redact(reason);
|
|
753
|
+
if (metadata !== null) metadata = redact.redact(metadata);
|
|
754
|
+
} catch (_e) { /* fall through with original values */ }
|
|
737
755
|
_ensureHandler().emit({
|
|
738
|
-
actor:
|
|
756
|
+
actor: actor,
|
|
739
757
|
action: event.action,
|
|
740
758
|
resource: event.resource || null,
|
|
741
759
|
outcome: event.outcome || "success",
|
|
742
|
-
reason:
|
|
743
|
-
metadata:
|
|
760
|
+
reason: reason,
|
|
761
|
+
metadata: metadata,
|
|
744
762
|
requestId: event.requestId || null,
|
|
745
763
|
});
|
|
746
764
|
} catch (_e) { /* audit best-effort — never break the caller */ }
|
package/lib/log.js
CHANGED
|
@@ -279,6 +279,14 @@ function create(opts) {
|
|
|
279
279
|
_logError: "extras not serializable",
|
|
280
280
|
}) + "\n";
|
|
281
281
|
}
|
|
282
|
+
// Trojan-Source defense (CVE-2021-42574). JSON.stringify does NOT
|
|
283
|
+
// escape Unicode bidi / format controls, so a hostile log message
|
|
284
|
+
// containing U+202E (RIGHT-TO-LEFT OVERRIDE) survives into TTY /
|
|
285
|
+
// syslog / file sinks where it can re-order the visible line —
|
|
286
|
+
// forging which fields appear under which keys when the operator
|
|
287
|
+
// reads the log. Escape the entire bidi/format-control set to
|
|
288
|
+
// `\uXXXX` literals on the wire.
|
|
289
|
+
line = _escapeBidiControls(line);
|
|
282
290
|
|
|
283
291
|
var lvlNum = LEVELS[levelName];
|
|
284
292
|
for (var s = 0; s < sinks.length; s++) {
|
|
@@ -479,6 +487,25 @@ function makeViaOrFallback(operatorLog, fallbackLog) {
|
|
|
479
487
|
|
|
480
488
|
// Boot-time minimum level (debug suppressed unless explicitly enabled).
|
|
481
489
|
// Uses raw process.env per the documented load-cycle exception: log.js
|
|
490
|
+
// Trojan-Source defense (CVE-2021-42574). Replace Unicode bidi /
|
|
491
|
+
// format-control characters with their `\uXXXX` literal escape so a
|
|
492
|
+
// hostile log message can't re-order the visible line in a TTY /
|
|
493
|
+
// syslog reader. Set covers:
|
|
494
|
+
// U+061C — Arabic Letter Mark
|
|
495
|
+
// U+200E/U+200F — LRM/RLM
|
|
496
|
+
// U+202A-U+202E — LRE/RLE/PDF/LRO/RLO
|
|
497
|
+
// U+2066-U+2069 — LRI/RLI/FSI/PDI
|
|
498
|
+
var _BIDI_CONTROL_RE = /[]/g;
|
|
499
|
+
|
|
500
|
+
function _escapeBidiControls(s) {
|
|
501
|
+
if (typeof s !== "string" || s.length === 0) return s;
|
|
502
|
+
return s.replace(_BIDI_CONTROL_RE, function (ch) {
|
|
503
|
+
var code = ch.charCodeAt(0).toString(16); // allow:raw-byte-literal — Unicode hex radix
|
|
504
|
+
while (code.length < 4) code = "0" + code;
|
|
505
|
+
return "\\u" + code;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
482
509
|
// runs before safeEnv on the boot path; safeEnv requires log, so log
|
|
483
510
|
// can't go through safeEnv to read its own level.
|
|
484
511
|
function _bootMinLevel() {
|
package/lib/redact.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* redact.MARKER → '[REDACTED]'
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
var C = require("./constants");
|
|
27
|
+
|
|
26
28
|
var DEFAULT_MARKER = "[REDACTED]";
|
|
27
29
|
|
|
28
30
|
// Field names that are always redacted, regardless of value contents.
|
|
@@ -97,6 +99,22 @@ var VALUE_DETECTORS = [
|
|
|
97
99
|
test: function (v) { return typeof v === "string" && /^\d{3}-?\d{2}-?\d{4}$/.test(v); },
|
|
98
100
|
replacement: "[REDACTED-SSN]",
|
|
99
101
|
},
|
|
102
|
+
{
|
|
103
|
+
// Connection-string credential leak — matches `protocol://user:pass@host`
|
|
104
|
+
// shapes that surface in error messages / metadata.reason fields when
|
|
105
|
+
// an external-DB driver drops its connect URL into an Error.message.
|
|
106
|
+
// Replacement preserves the host part for operator triage but redacts
|
|
107
|
+
// the credentials.
|
|
108
|
+
name: "connection-string",
|
|
109
|
+
test: function (v) {
|
|
110
|
+
if (typeof v !== "string" || v.length < C.BYTES.bytes(8)) return false; // bound BEFORE regex test
|
|
111
|
+
if (v.length > C.BYTES.kib(8)) return false;
|
|
112
|
+
// user may be empty (e.g. redis://:password@host); password
|
|
113
|
+
// segment is required to flag this as a credentialed URI.
|
|
114
|
+
return /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:/?#]*:[^\s@/?#]+@/.test(v);
|
|
115
|
+
},
|
|
116
|
+
replacement: "[REDACTED-CONN-STRING]",
|
|
117
|
+
},
|
|
100
118
|
];
|
|
101
119
|
|
|
102
120
|
var sensitiveFieldsSet = new Set(SENSITIVE_FIELDS);
|
package/lib/static.js
CHANGED
|
@@ -208,6 +208,46 @@ function _contentTypeFor(filePath, table) {
|
|
|
208
208
|
return (table && table[ext]) || DEFAULT_CONTENT_TYPES[ext] || "application/octet-stream";
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// Risky MIMEs that the browser executes inline. When safeAttachment is
|
|
212
|
+
// on, the framework forces Content-Disposition: attachment so
|
|
213
|
+
// drive-by uploads can't be executed in the user's browser. Operators
|
|
214
|
+
// serving trusted assets (CSS / JS bundle / SVG icon) opt out by NOT
|
|
215
|
+
// setting safeAttachmentForRiskyMimes — leaving the existing inline
|
|
216
|
+
// default. The defense is per-CVE-2017-15012 (SVG XSS) /
|
|
217
|
+
// CVE-2009-1312 (HTML drive-by) class.
|
|
218
|
+
var RISKY_INLINE_MIMES = {
|
|
219
|
+
"text/html": true,
|
|
220
|
+
"text/xml": true,
|
|
221
|
+
"application/xml": true,
|
|
222
|
+
"application/xhtml+xml": true,
|
|
223
|
+
"image/svg+xml": true,
|
|
224
|
+
"application/javascript": true,
|
|
225
|
+
"text/javascript": true,
|
|
226
|
+
"application/x-javascript": true,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
function _isRiskyInlineMime(contentType) {
|
|
230
|
+
if (typeof contentType !== "string" || contentType.length === 0) return false;
|
|
231
|
+
// Strip parameters like "; charset=utf-8".
|
|
232
|
+
var semi = contentType.indexOf(";");
|
|
233
|
+
var bare = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase();
|
|
234
|
+
return RISKY_INLINE_MIMES[bare] === true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Build a safe Content-Disposition value for an attachment. The
|
|
238
|
+
// filename is RFC 5987-encoded so non-ASCII characters survive without
|
|
239
|
+
// allowing CR/LF header injection.
|
|
240
|
+
function _attachmentDisposition(filePath) {
|
|
241
|
+
var name = path.basename(filePath);
|
|
242
|
+
// Refuse CR/LF/NUL outright — they're already filtered upstream by
|
|
243
|
+
// the path-traversal guard, but defense-in-depth here.
|
|
244
|
+
if (/[\r\n\0]/.test(name)) name = "download";
|
|
245
|
+
// ASCII-safe filename (replace non-ASCII with _) plus filename* = UTF-8 form.
|
|
246
|
+
var asciiName = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
247
|
+
var encName = encodeURIComponent(name);
|
|
248
|
+
return 'attachment; filename="' + asciiName + '"; filename*=UTF-8\'\'' + encName;
|
|
249
|
+
}
|
|
250
|
+
|
|
211
251
|
// _parseRangeHeader — RFC 7233 single-range parser. Returns null when:
|
|
212
252
|
// - header absent
|
|
213
253
|
// - syntactically malformed (not `bytes=`, multi-range, suffix syntax
|
|
@@ -331,6 +371,8 @@ function _validateCreateOpts(opts) {
|
|
|
331
371
|
validateOpts.optionalBoolean(opts.acceptRanges, "staticServe.create: acceptRanges", StaticServeError);
|
|
332
372
|
validateOpts.optionalBoolean(opts.auditSuccess, "staticServe.create: auditSuccess", StaticServeError);
|
|
333
373
|
validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
|
|
374
|
+
validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
|
|
375
|
+
"staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
|
|
334
376
|
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesPerActorPerWindowMs,
|
|
335
377
|
"staticServe.create: maxBytesPerActorPerWindowMs", StaticServeError, "BAD_OPT");
|
|
336
378
|
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesAllActorsPerWindowMs,
|
|
@@ -507,6 +549,7 @@ function create(opts) {
|
|
|
507
549
|
var auditSuccess = cfg.auditSuccess;
|
|
508
550
|
var auditFailures = cfg.auditFailures;
|
|
509
551
|
var acceptRanges = cfg.acceptRanges;
|
|
552
|
+
var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
|
|
510
553
|
var perActorCap = cfg.maxBytesPerActorPerWindowMs;
|
|
511
554
|
var globalCap = cfg.maxBytesAllActorsPerWindowMs;
|
|
512
555
|
var bandwidthWindowMs = cfg.bandwidthWindowMs;
|
|
@@ -851,6 +894,13 @@ function create(opts) {
|
|
|
851
894
|
"Last-Modified": meta.lastModified,
|
|
852
895
|
"X-Integrity": meta.integrity,
|
|
853
896
|
};
|
|
897
|
+
// Drive-by-execution defense — when safeAttachmentForRiskyMimes is
|
|
898
|
+
// on, force Content-Disposition: attachment for HTML / JS / SVG /
|
|
899
|
+
// XML so the browser downloads instead of rendering. Operator's
|
|
900
|
+
// onServe hook can override.
|
|
901
|
+
if (safeAttachment && _isRiskyInlineMime(headers["Content-Type"])) {
|
|
902
|
+
headers["Content-Disposition"] = _attachmentDisposition(absPath);
|
|
903
|
+
}
|
|
854
904
|
if (acceptRanges) headers["Accept-Ranges"] = "bytes";
|
|
855
905
|
if (range) headers["Content-Range"] = "bytes " + range.start + "-" + range.end + "/" + meta.size;
|
|
856
906
|
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:16490b30-e9d2-4135-a820-485954fc745f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-06T04:28:11.034Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.76",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.76",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.76",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.76",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|