@blamejs/core 0.15.11 → 0.15.12

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.12 (2026-06-14) — **Hardens a set of defense-in-depth seams: a single-pass structured-field unescape, a constant-time content-digest member match, complete reserved-character stripping, a Trojan-Source escape on the boot logger, generic body-parse error responses, and an audit trail whenever outbound TLS certificate validation is disabled.** A sweep of low-severity but real hardening items. RFC 8941 structured-field string values (HTTP Message Signatures, Client Hints, Cache-Control) were un-escaped with two chained replaces that mis-decoded an escaped backslash adjacent to another escape; they now use one left-to-right pass that decodes each escape exactly once. The HTTP Message Signature content-digest check dropped a dead identity-replace and now matches the sha3-512 member by an exact, top-level, constant-time comparison instead of an unanchored substring scan. b.guardFilename's reserved-character strip used a non-global regex that left every separator after the first; it now strips all of them. The boot logger's TTY branch wrote raw text, bypassing the Trojan-Source / control-character escape the main logger applies — it now escapes the bidi and C0/newline control classes on every sink. The body parser no longer echoes a caught exception's detail (an fs errno + temp path, or a parse hook's thrown message) to the HTTP client — the client gets a generic status phrase while the full detail stays on the audit chain. And any outbound TLS connection that runs with peer-certificate validation disabled (an explicit operator opt-in, never a default) now emits a tls.insecure_skip_verify audit + observability event so the degraded posture is visible for compliance and incident response. **Added:** *b.structuredFields.unescapeSfStringBody(body)* — A single-pass decode of the RFC 8941 §3.3.3 quoted-string backslash escapes (the bytes between the surrounding quotes). It replaces the chained two-`.replace()` form, which is not equivalent to one decode — whichever pass runs first can rewrite a backslash the other escape sequence owns, so a lone escaped backslash decoded to two. The HTTP Message Signature, Client Hints, and Cache-Control sf-string readers now route through it. · *tls.insecure_skip_verify audit event* — b.network.tls.auditInsecureTls(meta) emits an audit + observability event at the point an outbound TLS connection honors rejectUnauthorized:false / allowInsecure. The connectWithEch, OTLP-gRPC log stream, syslog-TLS log stream, and SMTP transports all emit it when an operator disables certificate validation — parallel to the existing tls.classical_downgrade audit. No default changes; the framework never disables validation itself. **Security:** *Single-pass structured-field string unescape* — The RFC 8941 sf-string readers in HTTP Message Signatures (Signature-Input covered-component names), Client Hints, and Cache-Control directive values un-escaped with `.replace(/\\\\/g,"\\").replace(/\\"/g,'"')` — two sequential passes that mis-decode adjacent escapes (a lone escaped backslash became two). All four sites now use the single-pass b.structuredFields.unescapeSfStringBody. It is fail-closed (a mis-decoded covered-component name just fails the signature check, never bypasses it); the fix restores RFC-conformant interop with peers that legitimately escape these values. · *Constant-time, member-anchored content-digest verification* — b.crypto.httpSig.verify's covered content-digest check dropped a dead no-op replace and now parses the Content-Digest header into its top-level members and matches the sha3-512 member EXACTLY, in constant time (b.crypto.timingSafeEqual), rather than scanning for the digest text as a substring anywhere in the header. The Content-Digest header is already bound by the signature, so the substring form was not reachably exploitable; the change removes the latent ambiguity and the timing channel. · *Reserved-character filename strip removes every occurrence* — b.guardFilename's reservedCharPolicy:"strip" (the permissive profile) used a non-global regex, so only the FIRST reserved character — including path separators — was replaced and the rest leaked through. The strip is now global: every reserved character is removed. Not a traversal bypass (the unconditional security floor still throws on `..`, null bytes, NTFS ADS, UNC, overlong UTF-8), but the strip is now complete and consistent. · *Boot logger escapes control + bidi characters on every sink* — The boot-time logger's TTY branch wrote the raw message, bypassing the Trojan-Source (bidi) and control-character escapes the main logger applies — a hostile message could forge extra log lines on a terminal (CWE-117) or re-order the visible line (CVE-2021-42574). Both boot branches now escape the bidi and C0/newline control classes, matching the create() path and the logger's advertised guarantee. · *Body-parser error responses never echo internal detail* — The body-parser's terminal error path surfaced a caught exception's message verbatim to the HTTP client — a multipart filesystem error leaked the errno + temp path, and a parse hook's thrown error (which can carry secrets) was echoed back. The client now gets a curated message only for a framework-classified 4xx error and a generic status phrase otherwise; the parse-hook wrapper carries a fixed message, and full diagnostics stay on the audit chain server-side (CWE-209). The cluster leader-discovery endpoint's error body is generalized the same way.
12
+
11
13
  - v0.15.11 (2026-06-14) — **Replaces a family of quadratic-time regexes that hostile input could use to stall a worker with linear scans, refuses a relocatable sealed-cell downgrade on the read side, fails closed when enabling row-level security behind a non-native driver, and scans the vendored crypto for known CVEs on every build.** Several text-handling primitives stripped trailing whitespace or extracted a mail address with a regex whose backtracking is quadratic in the input length on adversarial strings — a request body, a YAML document, a CSV cell, or a From header crafted as a long run of spaces could pin a worker's CPU. Each is now a linear character scan with identical output. The HTML-content check the MCP tool surface applies gained the vbscript: and data:text/html vectors it was missing. On the data-at-rest side, an AAD-bound (or per-row-key) column now refuses a plain, unbound vault cell on read — a relocatable envelope an attacker with write access could copy in from another row defeats the cross-row binding, so the field is nulled rather than surfaced; operators mid-migration opt back in with registerTable({ allowPlainMigration: true }). declareRowPolicy now treats row-level-security as enabled only on a value that unambiguously means true, so a non-native Postgres driver that returns the string "f" can no longer be read as "already on" and silently skip the ENABLE that protects the table's rows. Finally, because the framework's crypto is vendored rather than installed, npm audit and Dependabot never see it: every build now matches the vendored versions against the OSV vulnerability database, with a complementary Semgrep pass and workflow-file static analysis alongside. **Added:** *b.safeBuffer.indexAfterOpenTag(html, tagName)* — A linear helper that returns the offset just past a `<tag ...>` opening tag (case-insensitive), or -1 when absent or unterminated — the insertion point a response rewriter uses to splice content after <body> or <head> without a regex. It replaces the O(n^2) html.match(/<body[^>]*>/i) shape and is stricter than it: a real tag boundary is required after the name, so <bodyfoo> is not mistaken for <body>. **Security:** *Linear-time replacements across a family of quadratic regexes (ReDoS class)* — Several primitives located or stripped text with a regex whose backtracking is quadratic in V8 on adversarial input (CWE-1333): b.safeBuffer and the safe-env / safe-yaml / guard-csv parsers stripped trailing horizontal whitespace with /[ \t]+$/; b.mail extracted the address from a `Name <addr>` header with /<([^>]+)>/; the bot-disclosure and speculation-rules response middleware found the <body> insertion point with /<body[^>]*>/i; and the BIMI certificate-chain splitter walked PEM blocks with a lazy /BEGIN[\s\S]*?END/ scan. A crafted field — a long run of spaces, an unterminated bracket, a body carrying many <body starts with no closing >, a chain of BEGIN markers — could drive a worker's CPU to seconds of work. Each is now a linear scan: a shared b.safeBuffer.stripTrailingHspace (backward char walk), b.safeBuffer.indexAfterOpenTag (forward indexOf walk for the tag insertion point), a forward indexOf for address extraction, and an indexOf walk for the PEM split. Output is byte-identical (the tag-find is stricter — it no longer mistakes <bodyfoo> for <body>), and 400K-character adversarial inputs that took 8–85 seconds now complete in under 2 ms. · *MCP HTML-content check covers vbscript: and data:text/html* — The dangerous-markup check applied to text/html tool content matched <script>/<iframe>/<object>/<embed> and javascript: URLs but not the vbscript: scheme or data:text/html payloads. Both are now refused; data: URLs carrying non-HTML media (data:image/png and similar) are unaffected. · *AAD-bound columns refuse a plain sealed cell on read* — b.cryptoField.unsealRow now refuses a plain, unbound vault: envelope found on an AAD-bound (or per-row-key) column and nulls the field instead of returning it. A plain envelope carries no per-cell binding, so a writer who could place one — copied from anywhere under the same vault root — would otherwise relocate a value across rows or columns and defeat the copy-protection the AAD binding advertises. Operators migrating pre-AAD rows up to bound ciphertext opt into a bounded acceptance window with registerTable({ allowPlainMigration: true }) and clear it when migration completes. · *Row-level-security enablement fails closed on non-native drivers* — b.db.declareRowPolicy read pg_class.relrowsecurity to skip a redundant ENABLE ROW LEVEL SECURITY, but tested it with a bare truthiness check. A native pg driver returns a JS boolean; a proxy or ORM may return the string "f" for a disabled table — and "f" is truthy, so the check read it as already-enabled and silently skipped the ENABLE, leaving every row in the table unprotected while the migration reported success. RLS now counts as enabled only on a value that unambiguously means true (true, 1, or "t"/"true"/"1"/"on"/"yes"); every other shape re-issues ENABLE, a harmless no-op on an already-enabled table. · *Vendored-crypto CVE scanning, complementary SAST, and workflow static analysis in CI* — The framework ships zero npm runtime dependencies — its crypto (the noble suite, the WebAuthn server, the PKI layer) is vendored under lib/vendor/, where npm audit, Dependabot, and Socket cannot see it. Every build now generates a CycloneDX SBOM of the vendored tree (each library carrying an npm purl) and runs it through OSV-Scanner, matching the exact pinned version against the OSV vulnerability database; a published CVE or GHSA affecting a vendored version fails the build so the copy is refreshed before merge. A Semgrep pass (registry security-audit + javascript packs at ERROR severity) runs alongside CodeQL as complementary SAST, and actionlint statically checks the workflow files. All three install the OSS tool from its upstream release, matching the existing secret-scan gate's posture. **Detectors:** *Quadratic trailing-whitespace and tag-find regex detectors* — Two codebase-pattern detectors refuse reintroduction of the quadratic shapes: the /[ \t]+$/ trailing-whitespace strip (as .replace, .test, or via the named TRAILING_HSPACE_RE export) outside the linear helper that owns it, and the str.match(/<tag[^>]*>/) document-tag find that the response middleware must route through b.safeBuffer.indexAfterOpenTag. Each is proven to fire on the removed shape and stay silent on the linear replacement, so the ReDoS class cannot creep back into a new parser, guard, or response rewriter.
12
14
 
13
15
  - v0.15.10 (2026-06-13) — **Makes S3 Object-Lock version erasure reachable through the object store, and pins the build toolchain's native binary to a reviewed hash.** The object store gains the versioned-delete surface its S3 Object Lock support always needed for real erasure. An unversioned delete on a versioning-enabled (Object-Lock) bucket only writes a delete-marker — the data version survives — so the framework's own delete could report success while a record protected for compliance, or one a data subject asked to erase, stayed on disk. b.objectStore / b.storage now carry a versionId: put and saveRaw return the version they created, deleteFile(key, { versionId, bypassGovernanceRetention }) targets a specific version (refused — not silently delete-markered — when it is under an active retention), and listVersions enumerates versions and delete-markers so an erasure workflow can find them. Backends with no version surface (the filesystem backend, and the current Azure and GCS adapters) refuse a versioned delete loudly rather than silently dropping the current object. Separately, the build toolchain's native bundler binary is now verified against a reviewed SHA-256 pin so a tampered or drifted binary is caught before it bundles the framework. **Added:** *Versioned object delete + listVersions for S3 Object-Lock erasure* — b.storage.deleteFile and the b.objectStore sigv4 backend now accept opts.versionId to erase a specific object version, and opts.bypassGovernanceRetention to lift a GOVERNANCE-mode retention for callers with the permission (COMPLIANCE stays immutable to everyone). b.storage.saveRaw and the backend put now return the versionId they created on a versioning-enabled bucket, and a new b.storage.listVersions(prefix) / backend listVersions enumerates every version and delete-marker (key, versionId, isLatest, deleteMarker, size, lastModified, etag) so a right-to-erasure or crypto-shred workflow can target prior versions. On a backend with no version surface, listVersions throws VERSIONS_UNSUPPORTED and a versioned delete throws VERSIONID_UNSUPPORTED rather than silently acting on the current object. · *b.localDb.thin reaches SQLite resource-limit parity (limits option)* — b.localDb.thin now opens its node:sqlite handle with the same parse-time statement-size cap as b.db and the CLI — a SQL statement over 1 MiB is rejected at parse time, the SQLITE_LIMIT_LENGTH floor that guards prepare()/exec() of raw SQL against an attacker-influenced megaquery (SQLite's default is 1 GB). The cap is on by default; a new limits option (e.g. { sqlLength: 2 * 1024 * 1024 } or other SQLITE_LIMIT_* keys) lets an operator raise or extend it. Previously the thin opener had no limits plumbing, so a consumer on that path could not reach parity with the rest of the framework's SQLite surface. **Fixed:** *S3 Object-Lock version erasure is reachable through the framework delete path* — On a versioning-enabled (Object-Lock) bucket, an unversioned DELETE only writes a delete-marker — the protected data version survives untouched — yet the framework's delete had no versionId surface, so it issued the unversioned form and reported success while the bytes the lock protects remained. A retention or legal hold could therefore look enforced to the framework caller while the operation WORM actually blocks was unreachable. The delete path now targets the exact version: deleting a version under a COMPLIANCE retention is refused (it throws, even with bypassGovernanceRetention), a no-retention version erases cleanly, and the enforcement is proven end-to-end against MinIO through the framework's own API. · *b.configDrift.verifyVendorIntegrity is now working-directory-independent* — The vendored-dependency integrity check resolved each manifest file path against process.cwd(), so it only worked when run from the application root. Run from anywhere else it read-failed every entry (reporting ok:false), and under a crafted working directory that happened to contain a clean vendor tree it could hash a different tree than the one actually loaded. It now resolves each file under the framework's own vendor directory by default — the tree loaded at runtime — and honors an explicit libVendorDir for verifying a deployed tree elsewhere, so the result no longer depends on where the process was started. **Security:** *Build toolchain native binary pinned to a reviewed hash* — The native bundler binary the build toolchain runs (esbuild's per-platform compiler, a development dependency that never ships in the runtime) is now verified against a SHA-256 pin captured by diffing the published package tarballs and hashing the binary. The build gate fails if the on-disk binary does not match the reviewed hash for its (version, platform); for a version that has not been reviewed it notes the gap and skips rather than trusting an unverified binary. A cross-artifact check keeps the version in agreement across package.json, the CI install step, and the hash map, so the gate can never quietly test a version that was never diffed — closing a real drift where CI had been installing an older patch than package.json declared. The reviewed diff is benign: version strings plus an installer size-bound and error-message hardening, no new install hooks, files, or network paths, and no runtime-dependency impact. **Detectors:** *Object-store erasure guard, esbuild-pin agreement, + structural re-anchoring of the lint detectors* — A new guard locks the object-store delete path to the versioned-erasure contract: b.storage.deleteFile must thread versionId to the backend, so it can never silently revert to the WORM-blind unversioned delete. A second guard enforces that the esbuild build-tool version agrees across package.json, the CI install step, and the binary-hash map, so a future bump can't update one and leave the gate testing an unreviewed version. Separately, the framework's internal codebase-pattern lint detectors were re-anchored from fixed character spans to structural code boundaries, so they keep matching the code they guard as those functions grow rather than aging out of range; reviving them surfaced a few internal validation and transaction sites that now route through shared helpers (a required positive-integer-with-range validator and an async transaction wrapper) instead of hand-rolling the check. No public API change.
package/lib/acme.js CHANGED
@@ -92,10 +92,6 @@ function _emitAudit(audit, action, outcome, metadata) {
92
92
  } catch (_e) { /* audit best-effort */ }
93
93
  }
94
94
 
95
- function _emitObs(name, fields) {
96
- try { observability().safeEvent(name, 1, fields || {}); } catch (_e) { /* obs best-effort */ }
97
- }
98
-
99
95
  // JWK shape for an ES256 (P-256) public key — RFC 7518 §6.2.1.
100
96
  function _publicJwkFromKeyObject(keyObject) {
101
97
  if (!keyObject || typeof keyObject.export !== "function") {
@@ -479,7 +475,7 @@ function create(opts) {
479
475
  state.directory = body;
480
476
  _emitAudit(audit, "acme.directory.fetched", "success",
481
477
  { directoryUrl: state.directoryUrl, hasAri: typeof body.renewalInfo === "string" });
482
- _emitObs("acme.directory.fetched", { hasAri: typeof body.renewalInfo === "string" });
478
+ observability().safeEvent("acme.directory.fetched", 1, { hasAri: typeof body.renewalInfo === "string" });
483
479
  return body;
484
480
  }
485
481
 
@@ -675,7 +671,7 @@ function create(opts) {
675
671
  }
676
672
  _emitAudit(audit, "acme.cert.issued", "success",
677
673
  { orderUrl: order.url, bytes: pem.length });
678
- _emitObs("acme.cert.issued", { bytes: pem.length });
674
+ observability().safeEvent("acme.cert.issued", 1, { bytes: pem.length });
679
675
  return pem;
680
676
  }
681
677
 
@@ -1042,7 +1038,7 @@ function create(opts) {
1042
1038
  windowEnd: ari.suggestedWindow.end,
1043
1039
  nowIso: new Date(nowMs).toISOString(),
1044
1040
  });
1045
- _emitObs("acme.cert.renew.skipped", { reason: "before-window" });
1041
+ observability().safeEvent("acme.cert.renew.skipped", 1, { reason: "before-window" });
1046
1042
  var ret = { shouldRenew: false, reason: "before-window", ari: ari };
1047
1043
  if (jitter) ret.renewAt = new Date(renewAtMs).toISOString();
1048
1044
  return ret;
@@ -1053,7 +1049,7 @@ function create(opts) {
1053
1049
  reason: "past-window",
1054
1050
  windowEnd: ari.suggestedWindow.end,
1055
1051
  });
1056
- _emitObs("acme.cert.renew.scheduled", { reason: "past-window" });
1052
+ observability().safeEvent("acme.cert.renew.scheduled", 1, { reason: "past-window" });
1057
1053
  var rp = { shouldRenew: true, reason: "past-window", ari: ari };
1058
1054
  if (jitter) rp.renewAt = new Date(renewAtMs).toISOString();
1059
1055
  return rp;
@@ -1064,7 +1060,7 @@ function create(opts) {
1064
1060
  windowEnd: ari.suggestedWindow.end,
1065
1061
  renewAt: jitter ? new Date(renewAtMs).toISOString() : null,
1066
1062
  });
1067
- _emitObs("acme.cert.renew.scheduled", { reason: "in-window" });
1063
+ observability().safeEvent("acme.cert.renew.scheduled", 1, { reason: "in-window" });
1068
1064
  var ri = { shouldRenew: true, reason: "in-window", ari: ari };
1069
1065
  if (jitter) ri.renewAt = new Date(renewAtMs).toISOString();
1070
1066
  return ri;
@@ -1140,7 +1136,7 @@ function create(opts) {
1140
1136
  "revokeCert returned " + rsp.statusCode, true, rsp.statusCode);
1141
1137
  }
1142
1138
  _emitAudit(audit, "acme.cert.revoked", "success", { reason: ropts.reason || null });
1143
- _emitObs("acme.cert.revoked", { reason: ropts.reason || 0 });
1139
+ observability().safeEvent("acme.cert.revoked", 1, { reason: ropts.reason || 0 });
1144
1140
  return true;
1145
1141
  }
1146
1142
 
@@ -1190,7 +1186,7 @@ function create(opts) {
1190
1186
  privateKey = newPrivateKey;
1191
1187
  publicJwk = newPublicJwk;
1192
1188
  _emitAudit(audit, "acme.account.key_rotated", "success", { accountUrl: state.accountUrl });
1193
- _emitObs("acme.account.key_rotated", {});
1189
+ observability().safeEvent("acme.account.key_rotated", 1, {});
1194
1190
  return true;
1195
1191
  }
1196
1192
 
@@ -106,7 +106,9 @@ function _parseSfString(s) {
106
106
  if (t.length === 0) return "";
107
107
  if (t.charAt(0) === "\"") {
108
108
  if (t.charAt(t.length - 1) !== "\"") return null;
109
- return t.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
109
+ // Single-pass RFC 8941 unescape — chained .replace() mis-decodes
110
+ // an escaped backslash adjacent to another escape.
111
+ return structuredFields.unescapeSfStringBody(t.slice(1, -1));
110
112
  }
111
113
  return t;
112
114
  }
package/lib/cluster.js CHANGED
@@ -1089,8 +1089,10 @@ function discoveryHandler() {
1089
1089
  body = { leader: null, self: selfInfo };
1090
1090
  status = 503;
1091
1091
  }
1092
- } catch (e) {
1093
- body = { leader: null, self: selfInfo, error: e.message };
1092
+ } catch (_e) {
1093
+ // Generic client-facing reason the caught error's message (a DB error
1094
+ // detail / DSN / host:port) is not echoed to the client (CWE-209).
1095
+ body = { leader: null, self: selfInfo, error: "leader lookup unavailable" };
1094
1096
  status = 503;
1095
1097
  }
1096
1098
  var json = JSON.stringify(body);
@@ -80,7 +80,10 @@ var _err = GuardFilenameError.factory;
80
80
  // Windows: < > : " / \ | ? *
81
81
  // Unix: /
82
82
  // Both: null and C0 controls (handled separately via codepoint-class)
83
- var RESERVED_CHARS_RE = /[<>:"/\\|?*]/;
83
+ // Global so reservedCharPolicy:"strip" replaces EVERY reserved char, not just
84
+ // the first (CodeQL js/incomplete-multi-character-sanitization). Only consumer
85
+ // is the .replace() in _sanitize — no stateful .test()/.exec() lastIndex hazard.
86
+ var RESERVED_CHARS_RE = /[<>:"/\\|?*]/g;
84
87
 
85
88
  // Windows reserved device names (case-insensitive). Match either the
86
89
  // bare name or `<name>.<anything>`.
@@ -586,8 +589,9 @@ function _sanitize(input, opts) {
586
589
 
587
590
  // Strip reserved chars when policy says strip.
588
591
  if (opts.reservedCharPolicy === "strip") {
592
+ // Single global strip — RESERVED_CHARS_RE (now /g) covers the whole
593
+ // reserved class INCLUDING path separators, so no second pass is needed.
589
594
  name = name.replace(RESERVED_CHARS_RE, "_"); // allow:dynamic-regex — RESERVED_CHARS_RE is a compile-time literal
590
- name = name.replace(/[<>:"|?*]/g, "_");
591
595
  } else if (opts.reservedCharPolicy === "reject") {
592
596
  if (/[<>:"|?*]/.test(name)) {
593
597
  throw _err("filename.reserved-char", "filename contains reserved character");
@@ -107,7 +107,9 @@ function _parseCacheControl(value) {
107
107
  else { k = p.slice(0, eq).trim(); v = p.slice(eq + 1).trim(); }
108
108
  // Strip surrounding quotes from value.
109
109
  if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
110
- v = v.slice(1, v.length - 1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
110
+ // Single-pass RFC 8941 unescape (chained .replace() mis-decodes
111
+ // an escaped backslash adjacent to another escape).
112
+ v = structuredFields.unescapeSfStringBody(v.slice(1, v.length - 1));
111
113
  }
112
114
  out[k.toLowerCase()] = v;
113
115
  }
@@ -60,6 +60,7 @@
60
60
  */
61
61
 
62
62
  var nodeCrypto = require("node:crypto");
63
+ var bCrypto = require("./crypto");
63
64
  var safeUrl = require("./safe-url");
64
65
  var safeBuffer = require("./safe-buffer");
65
66
  var C = require("./constants");
@@ -406,7 +407,9 @@ function _parseSignatureInput(headerValue) {
406
407
  throw _err("BAD_HEADER",
407
408
  "httpSig: Signature-Input: unterminated quoted token");
408
409
  }
409
- var bareName = coveredRaw.slice(qStart, qEnd).replace(/\\\\/g, "\\").replace(/\\"/g, "\"");
410
+ // Single-pass RFC 8941 §3.3.3 unescape — NOT two chained .replace() passes,
411
+ // which mis-decode an escaped backslash adjacent to another escape.
412
+ var bareName = structuredFields.unescapeSfStringBody(coveredRaw.slice(qStart, qEnd));
410
413
  i2 = qEnd + 1;
411
414
  // Optional ;param=value;param=... suffix immediately following.
412
415
  var suffixStart = i2;
@@ -525,13 +528,27 @@ function verify(msg, opts) {
525
528
  if (!presented) {
526
529
  return { valid: false, reason: "content-digest-header-missing" };
527
530
  }
528
- var actual = contentDigest(m.body);
529
- // RFC 9530 allows multiple algorithms in one header (sha-512=...,
530
- // sha-256=...). For SHA3-512 specifically exact substring match
531
- // against the presented header. For peer-supplied SHA-512 / SHA-256
532
- // identifiers the operator is responsible for re-validating; this
533
- // primitive only auto-checks SHA3-512.
534
- if (presented.indexOf(actual.replace(/^sha3-512=/, "sha3-512=")) === -1) {
531
+ // contentDigest() returns the canonical structured-field form
532
+ // `sha3-512=:<base64>:`. RFC 9530 permits a multi-member header
533
+ // (e.g. `sha-256=:...:, sha3-512=:...:`); split on top-level commas and
534
+ // match the sha3-512 member EXACTLY, in constant time, rather than by an
535
+ // unanchored substring scan that could spuriously match the digest text
536
+ // buried inside another member's value or parameters. Peer-supplied
537
+ // sha-512 / sha-256 identifiers stay the operator's responsibility.
538
+ var expectedDigest = contentDigest(m.body); // "sha3-512=:<b64>:"
539
+ var matchedDigest = false;
540
+ var digestMembers = structuredFields.splitTopLevel(presented, ",");
541
+ for (var di = 0; di < digestMembers.length; di++) {
542
+ var member = digestMembers[di].trim();
543
+ var deq = member.indexOf("=");
544
+ if (deq < 1) continue;
545
+ if (member.slice(0, deq).trim().toLowerCase() !== "sha3-512") continue;
546
+ var memberCanonical = "sha3-512=" + member.slice(deq + 1).trim();
547
+ // crypto.timingSafeEqual is the length-tolerant constant-time wrapper
548
+ // (returns false for unequal lengths without leaking via a length branch).
549
+ if (bCrypto.timingSafeEqual(memberCanonical, expectedDigest)) { matchedDigest = true; break; }
550
+ }
551
+ if (!matchedDigest) {
535
552
  return { valid: false, reason: "content-digest-mismatch" };
536
553
  }
537
554
  }
@@ -38,6 +38,9 @@ var lazyRequire = require("./lazy-require");
38
38
  // scrub attribute values through the telemetry redactor before they cross the
39
39
  // OTLP egress boundary (CWE-532).
40
40
  var observability = lazyRequire(function () { return require("./observability"); });
41
+ // Lazy — network-tls is widely required; audit an insecure (cert-validation-
42
+ // disabled) outbound TLS session at honor time, same surface as connectWithEch.
43
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
41
44
 
42
45
  var _err = LogStreamError.factory;
43
46
  var _log = boot("log-stream-otlp-grpc");
@@ -215,7 +218,14 @@ function _makeClient(cfg) {
215
218
  var sessionOpts = {};
216
219
  if (cfg.ca) sessionOpts.ca = cfg.ca;
217
220
  if (cfg.servername) sessionOpts.servername = cfg.servername;
218
- if (cfg.allowInsecure) sessionOpts.rejectUnauthorized = false;
221
+ if (cfg.allowInsecure && url.protocol === "https:") {
222
+ // allowInsecure only has meaning on a TLS session. For an h2c endpoint
223
+ // (http://, cleartext HTTP/2) there is no certificate to validate and
224
+ // nothing to skip, so neither rejectUnauthorized nor the insecure-TLS
225
+ // audit applies — emitting it there would be a false security event.
226
+ sessionOpts.rejectUnauthorized = false;
227
+ networkTls().auditInsecureTls({ host: authority, source: "log-stream.otlp-grpc" });
228
+ }
219
229
  var session = http2.connect(authority, sessionOpts);
220
230
  session.on("error", function () { /* surfaced through request err */ });
221
231
  if (typeof session.unref === "function") session.unref();
@@ -409,6 +419,7 @@ module.exports = {
409
419
  create: create,
410
420
  // Exposed for layer-0 tests that verify the wire encoding without
411
421
  // standing up an HTTP/2 server.
422
+ _makeClient: _makeClient,
412
423
  _encodeAnyValue: _encodeAnyValue,
413
424
  _encodeKeyValue: _encodeKeyValue,
414
425
  _encodeLogRecord: _encodeLogRecord,
@@ -37,6 +37,9 @@ var safeAsync = require("./safe-async");
37
37
  var safeBuffer = require("./safe-buffer");
38
38
  var safeUrl = require("./safe-url");
39
39
  var { LogStreamError } = require("./framework-error");
40
+ var lazyRequire = require("./lazy-require");
41
+ // Lazy — audit a cert-validation-disabled syslog/TLS session at honor time.
42
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
40
43
 
41
44
  var _err = LogStreamError.factory;
42
45
  var log = boot("log-stream-syslog");
@@ -223,6 +226,9 @@ function create(config) {
223
226
  });
224
227
  if (cfg.ca) tlsOpts.ca = cfg.ca;
225
228
  if (cfg.servername) tlsOpts.servername = cfg.servername;
229
+ if (cfg.rejectUnauthorized === false) {
230
+ networkTls().auditInsecureTls({ host: cfg.host, port: cfg.port, source: "log-stream.syslog" });
231
+ }
226
232
  sock = nodeTls.connect(tlsOpts, onConnect);
227
233
  } else {
228
234
  sock = net.connect(connectOpts, onConnect);
package/lib/log.js CHANGED
@@ -464,7 +464,11 @@ function boot(name) {
464
464
  var stream = (LEVELS[levelName] >= LEVELS.warn) ? process.stderr : process.stdout;
465
465
  var isTty = !!(stream && stream.isTTY);
466
466
  if (isTty) {
467
- sink(prefix + String(msg));
467
+ // Raw human-readable line — escape BOTH the C0/newline (line-forging)
468
+ // and bidi (re-ordering) control classes the create() path neutralizes,
469
+ // so a hostile boot message can't inject lines or re-order the visible
470
+ // line on a TTY / syslog reader (CWE-117 / Trojan-Source CVE-2021-42574).
471
+ sink(_escapeBidiControls(_escapeC0Controls(prefix + String(msg))));
468
472
  return;
469
473
  }
470
474
  var entry = {
@@ -474,7 +478,9 @@ function boot(name) {
474
478
  component: name,
475
479
  boot: true,
476
480
  };
477
- sink(JSON.stringify(entry));
481
+ // JSON.stringify already escapes C0/newlines; bidi/format controls survive
482
+ // raw into a piped aggregator, so apply the same bidi escape create() uses.
483
+ sink(_escapeBidiControls(JSON.stringify(entry)));
478
484
  }
479
485
 
480
486
  function debug(msg, fields) {
@@ -560,6 +566,22 @@ function _escapeBidiControls(s) {
560
566
  });
561
567
  }
562
568
 
569
+ // C0 control chars (incl. CR / LF / TAB) + DEL — escaped to `\uXXXX` so a
570
+ // hostile message can't forge extra log lines on a raw (non-JSON) TTY sink
571
+ // (log-injection, CWE-117). The create() path gets this for free from
572
+ // JSON.stringify; the boot() TTY branch writes raw text and needs it
573
+ // explicitly. Pairs with _escapeBidiControls (which only covers the bidi set).
574
+ var _C0_CONTROL_RE = /[\u0000-\u001f\u007f]/g; // eslint-disable-line no-control-regex -- the C0/DEL set is what we escape
575
+
576
+ function _escapeC0Controls(s) {
577
+ if (typeof s !== "string" || s.length === 0) return s;
578
+ return s.replace(_C0_CONTROL_RE, function (ch) {
579
+ var code = ch.charCodeAt(0).toString(16);
580
+ while (code.length < 4) code = "0" + code;
581
+ return "\\u" + code;
582
+ });
583
+ }
584
+
563
585
  // runs before safeEnv on the boot path; safeEnv requires log, so log
564
586
  // can't go through safeEnv to read its own level.
565
587
  function _bootMinLevel() {
package/lib/mail.js CHANGED
@@ -76,6 +76,8 @@ var networkDns = lazyRequire(function () { return require("./network-dns"); });
76
76
  var nodeUrl = require("node:url");
77
77
  var numericBounds = require("./numeric-bounds");
78
78
  var nodeTls = lazyRequire(function () { return require("node:tls"); });
79
+ // Lazy — audit a cert-validation-disabled SMTP/TLS session at honor time.
80
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
79
81
  var safeJson = require("./safe-json");
80
82
  var safeSchema = require("./safe-schema");
81
83
  var validateOpts = require("./validate-opts");
@@ -778,6 +780,9 @@ function smtpTransport(opts) {
778
780
  var port = opts.port || 587;
779
781
  var useImplicitTLS = port === 465 || opts.implicitTls === true;
780
782
  var rejectUnauthorized = opts.rejectUnauthorized !== false;
783
+ if (rejectUnauthorized === false) {
784
+ networkTls().auditInsecureTls({ host: opts.host, port: port, source: "mail.smtp" });
785
+ }
781
786
  var ehloName = opts.ehloName || "blamejs";
782
787
  // GHSA-c7w3-x93f-qmm8 / GHSA-vvjj-xcjg-gr5g (nodemailer CRLF-injection
783
788
  // class) — any string concatenated into an outbound SMTP wire command
@@ -375,6 +375,19 @@ function _detectSmuggling(req) {
375
375
  return null;
376
376
  }
377
377
 
378
+ // Generic, operator-safe status phrase for a client error body — used when the
379
+ // thrown error is NOT a framework-classified BodyParserError (or is a 5xx), so
380
+ // an internal exception's message (fs errno + tmp path, a parse hook's thrown
381
+ // detail) is never echoed to the client (CWE-209 / CodeQL js/stack-trace-exposure).
382
+ var _GENERIC_REASON = {};
383
+ _GENERIC_REASON[HTTP_STATUS.BAD_REQUEST] = "Bad Request";
384
+ _GENERIC_REASON[HTTP_STATUS.PAYLOAD_TOO_LARGE] = "Payload Too Large";
385
+ _GENERIC_REASON[HTTP_STATUS.UNSUPPORTED_MEDIA_TYPE] = "Unsupported Media Type";
386
+ _GENERIC_REASON[HTTP_STATUS.INTERNAL_SERVER_ERROR] = "Internal Server Error";
387
+ function _genericReason(status) {
388
+ return _GENERIC_REASON[status] || (status >= 500 ? "Internal Server Error" : "Bad Request");
389
+ }
390
+
378
391
  function _writeError(res, status, message, code) {
379
392
  if (res.headersSent) return;
380
393
  var body = JSON.stringify({ error: message, code: code });
@@ -469,10 +482,13 @@ async function _parseJson(req, opts) {
469
482
  }
470
483
  if (typeof opts.parseHook === "function") {
471
484
  try { parsed = opts.parseHook(parsed); }
472
- catch (e) {
485
+ catch (_e) {
473
486
  throw new BodyParserError(
474
487
  "body-parser/json-hook",
475
- "JSON parseHook failed: " + ((e && e.message) || String(e)),
488
+ // Operator parseHook threw surface a fixed, safe message; the hook's
489
+ // own thrown detail (which may carry secrets) is the operator's to log,
490
+ // never echoed to the client (CWE-209).
491
+ "request body rejected by parse hook",
476
492
  true, HTTP_STATUS.BAD_REQUEST
477
493
  );
478
494
  }
@@ -1476,8 +1492,32 @@ function create(opts) {
1476
1492
  }
1477
1493
  var status = (e && typeof e.statusCode === "number") ? e.statusCode : HTTP_STATUS.BAD_REQUEST;
1478
1494
  var code = (e && typeof e.code === "string") ? e.code : "body-parser/error";
1479
- var message = (e && e.message) ? e.message : String(e);
1480
- _writeError(res, status, message, code);
1495
+ // Only a framework-classified 4xx BodyParserError carries a curated,
1496
+ // operator-safe message (malformed JSON, poisoned key, smuggling). Any
1497
+ // other thrown error — and every 5xx — gets a generic status phrase, so
1498
+ // an internal exception (fs errno + tmp path, a parse hook's thrown
1499
+ // secret) is never echoed to the client (CWE-209). The full diagnostic
1500
+ // stays on the audit chain, redacted by safeEmit.
1501
+ // Object(e) tolerates a non-object throw (throw null, or a string from
1502
+ // an operator parse hook) without a truthiness guard on the caught
1503
+ // binding — the caught value is always defined to CodeQL, so `e && …`
1504
+ // reads as a dead sub-condition; this keeps the null-safety explicit.
1505
+ var eo = Object(e);
1506
+ var clientMessage = (eo.isBodyParserError === true && status < 500 && typeof eo.message === "string")
1507
+ ? eo.message
1508
+ : _genericReason(status);
1509
+ try {
1510
+ audit().safeEmit({
1511
+ action: "body-parser.error",
1512
+ outcome: status >= 500 ? "failure" : "denied",
1513
+ metadata: {
1514
+ status: status,
1515
+ code: code,
1516
+ message: (e && e.message) ? String(e.message).slice(0, 256) : "",
1517
+ },
1518
+ });
1519
+ } catch (_e) { /* audit best-effort — never mask the response */ }
1520
+ _writeError(res, status, clientMessage, code);
1481
1521
  }
1482
1522
  };
1483
1523
  }
@@ -1501,9 +1541,11 @@ async function _parseJsonFromBuf(buf, opts) {
1501
1541
  }
1502
1542
  if (typeof opts.parseHook === "function") {
1503
1543
  try { parsed = opts.parseHook(parsed); }
1504
- catch (e) {
1544
+ catch (_e) {
1545
+ // Operator parseHook threw — fixed safe message; the hook's thrown
1546
+ // detail (possible secrets) is never echoed to the client (CWE-209).
1505
1547
  throw new BodyParserError("body-parser/json-hook",
1506
- "JSON parseHook failed: " + ((e && e.message) || String(e)), true, HTTP_STATUS.BAD_REQUEST);
1548
+ "request body rejected by parse hook", true, HTTP_STATUS.BAD_REQUEST);
1507
1549
  }
1508
1550
  }
1509
1551
  return parsed;
@@ -149,7 +149,7 @@ function setServers(serverList) {
149
149
  throw new DnsError("dns/setservers-failed", "dns.setServers failed: " + e.message);
150
150
  }
151
151
  _clearCache();
152
- _emitObs("network.dns.servers.set", { count: serverList.length });
152
+ observability().safeEvent("network.dns.servers.set", 1, { count: serverList.length });
153
153
  }
154
154
 
155
155
  function getServers() {
@@ -169,7 +169,7 @@ function setResultOrder(order) {
169
169
  try { dns.setDefaultResultOrder(order); } catch (_e) { /* node may not support setter on this version — best-effort */ }
170
170
  }
171
171
  _clearCache();
172
- _emitObs("network.dns.result_order.set", { order: order });
172
+ observability().safeEvent("network.dns.result_order.set", 1, { order: order });
173
173
  }
174
174
 
175
175
  function setFamily(fam) {
@@ -215,7 +215,7 @@ function useSystemResolver() {
215
215
  STATE.systemResolver = true;
216
216
  _resetDotPool();
217
217
  _clearCache();
218
- _emitObs("network.dns.system_resolver.set", {});
218
+ observability().safeEvent("network.dns.system_resolver.set", 1, {});
219
219
  }
220
220
 
221
221
  function useDnsOverHttps(opts) {
@@ -246,7 +246,7 @@ function useDnsOverHttps(opts) {
246
246
  }
247
247
  STATE.doh = { url: url, method: method, ca: opts.ca || null };
248
248
  _clearCache();
249
- _emitObs("network.dns.doh.set", { url: url, method: method || "auto" });
249
+ observability().safeEvent("network.dns.doh.set", 1, { url: url, method: method || "auto" });
250
250
  }
251
251
 
252
252
  function useDnsOverTls(opts) {
@@ -267,7 +267,7 @@ function useDnsOverTls(opts) {
267
267
  };
268
268
  _resetDotPool();
269
269
  _clearCache();
270
- _emitObs("network.dns.dot.set", { host: STATE.dot.host, port: STATE.dot.port });
270
+ observability().safeEvent("network.dns.dot.set", 1, { host: STATE.dot.host, port: STATE.dot.port });
271
271
  }
272
272
 
273
273
  function _withTimeout(promise, ms, host) {
@@ -1178,13 +1178,13 @@ async function _querySvcbLike(host, qtype, opts) {
1178
1178
  throw new DnsError("dns/bad-transport",
1179
1179
  "dns.querySvcb: transport must be 'doh' | 'dot' | 'system' | undefined");
1180
1180
  }
1181
- _emitObs("network.dns.svcb.requested", { qtype: qtype, transport: opts.transport || "auto" });
1181
+ observability().safeEvent("network.dns.svcb.requested", 1, { qtype: qtype, transport: opts.transport || "auto" });
1182
1182
  var startMs = _now();
1183
1183
  var reply;
1184
1184
  try {
1185
1185
  reply = await _rawQuery(host, qtype, opts.transport);
1186
1186
  } catch (e) {
1187
- _emitObs("network.dns.svcb.failure", {
1187
+ observability().safeEvent("network.dns.svcb.failure", 1, {
1188
1188
  latencyMs: _now() - startMs,
1189
1189
  code: e.code || "unknown",
1190
1190
  });
@@ -1198,7 +1198,7 @@ async function _querySvcbLike(host, qtype, opts) {
1198
1198
  records.push(_parseSvcbRdata(decoded.msg, ans.rdataOff, ans.rdlen));
1199
1199
  }
1200
1200
  records.sort(function (a, b) { return a.priority - b.priority; });
1201
- _emitObs("network.dns.svcb.success", {
1201
+ observability().safeEvent("network.dns.svcb.success", 1, {
1202
1202
  latencyMs: _now() - startMs,
1203
1203
  count: records.length,
1204
1204
  qtype: qtype,
@@ -1322,7 +1322,7 @@ async function discoverEncrypted(opts) {
1322
1322
  try {
1323
1323
  records = await _querySvcbLike(name, DNS_QTYPE_SVCB, { transport: transport });
1324
1324
  } catch (e) {
1325
- _emitObs("network.dns.ddr.failure", {
1325
+ observability().safeEvent("network.dns.ddr.failure", 1, {
1326
1326
  latencyMs: _now() - startMs,
1327
1327
  code: e.code || "unknown",
1328
1328
  });
@@ -1333,7 +1333,7 @@ async function discoverEncrypted(opts) {
1333
1333
  throw e;
1334
1334
  }
1335
1335
  if (records.length === 0) {
1336
- _emitObs("network.dns.ddr.empty", { latencyMs: _now() - startMs });
1336
+ observability().safeEvent("network.dns.ddr.empty", 1, { latencyMs: _now() - startMs });
1337
1337
  throw new DnsError("dns/ddr-not-discovered",
1338
1338
  "dns.discoverEncrypted: resolver returned empty DDR record set at " + name);
1339
1339
  }
@@ -1364,7 +1364,7 @@ async function discoverEncrypted(opts) {
1364
1364
  throw new DnsError("dns/ddr-not-discovered",
1365
1365
  "dns.discoverEncrypted: DDR records present but none advertised a recognized transport (alpn=dot/h2/h3)");
1366
1366
  }
1367
- _emitObs("network.dns.ddr.success", {
1367
+ observability().safeEvent("network.dns.ddr.success", 1, {
1368
1368
  latencyMs: _now() - startMs,
1369
1369
  count: resolvers.length,
1370
1370
  });
@@ -1454,7 +1454,7 @@ function useDesignatedResolvers(list) {
1454
1454
  });
1455
1455
  }
1456
1456
  _designatedResolvers = validated.slice();
1457
- _emitObs("network.dns.dnr.set", {
1457
+ observability().safeEvent("network.dns.dnr.set", 1, {
1458
1458
  count: validated.length,
1459
1459
  active: j,
1460
1460
  transport: v.transport,
@@ -1462,7 +1462,7 @@ function useDesignatedResolvers(list) {
1462
1462
  return { active: j, count: validated.length };
1463
1463
  } catch (e) {
1464
1464
  lastErr = e;
1465
- _emitObs("network.dns.dnr.entry_failed", {
1465
+ observability().safeEvent("network.dns.dnr.entry_failed", 1, {
1466
1466
  index: j,
1467
1467
  transport: v.transport,
1468
1468
  code: e.code || "unknown",
@@ -1511,7 +1511,7 @@ async function lookup(host, opts) {
1511
1511
  if (cached.error) throw cached.error;
1512
1512
  return opts.all ? cached.value : cached.value[0];
1513
1513
  }
1514
- _emitObs("network.dns.lookup.requested", { family: cacheKey });
1514
+ observability().safeEvent("network.dns.lookup.requested", 1, { family: cacheKey });
1515
1515
  var startMs = _now();
1516
1516
  // Resolve secure-DNS default on first use. Idempotent.
1517
1517
  _ensureSecureDefault();
@@ -1546,11 +1546,11 @@ async function lookup(host, opts) {
1546
1546
  throw new DnsError("dns/no-result", "dns lookup of '" + host + "' returned no addresses");
1547
1547
  }
1548
1548
  _cachePutPositive(host, cacheKey, normalized);
1549
- _emitObs("network.dns.lookup.success", { latencyMs: _now() - startMs, count: normalized.length });
1549
+ observability().safeEvent("network.dns.lookup.success", 1, { latencyMs: _now() - startMs, count: normalized.length });
1550
1550
  return opts.all ? normalized : normalized[0];
1551
1551
  } catch (e) {
1552
1552
  _cachePutNegative(host, cacheKey, e);
1553
- _emitObs("network.dns.lookup.failure", { latencyMs: _now() - startMs, code: e.code || "unknown" });
1553
+ observability().safeEvent("network.dns.lookup.failure", 1, { latencyMs: _now() - startMs, code: e.code || "unknown" });
1554
1554
  throw e;
1555
1555
  }
1556
1556
  }
@@ -1571,7 +1571,7 @@ async function _resolveProtocol(host, family, opts) {
1571
1571
  throw new DnsError("dns/bad-transport",
1572
1572
  "dns.resolve" + family + ": transport must be 'doh' | 'dot' | 'system' | undefined");
1573
1573
  }
1574
- _emitObs("network.dns.resolve.requested", { family: family, transport: opts.transport || "auto" });
1574
+ observability().safeEvent("network.dns.resolve.requested", 1, { family: family, transport: opts.transport || "auto" });
1575
1575
  var startMs = _now();
1576
1576
  try {
1577
1577
  var addrs;
@@ -1597,10 +1597,10 @@ async function _resolveProtocol(host, family, opts) {
1597
1597
  if (normalized.length === 0) {
1598
1598
  throw new DnsError("dns/no-result", "dns.resolve" + family + " of '" + host + "' returned no addresses");
1599
1599
  }
1600
- _emitObs("network.dns.resolve.success", { family: family, latencyMs: _now() - startMs, count: normalized.length });
1600
+ observability().safeEvent("network.dns.resolve.success", 1, { family: family, latencyMs: _now() - startMs, count: normalized.length });
1601
1601
  return normalized;
1602
1602
  } catch (e) {
1603
- _emitObs("network.dns.resolve.failure", { family: family, latencyMs: _now() - startMs, code: e.code || "unknown" });
1603
+ observability().safeEvent("network.dns.resolve.failure", 1, { family: family, latencyMs: _now() - startMs, code: e.code || "unknown" });
1604
1604
  if (e instanceof DnsError) throw e;
1605
1605
  throw new DnsError("dns/resolve-failed",
1606
1606
  "dns.resolve" + family + " of '" + host + "' failed: " + (e.message || String(e)));
@@ -1643,16 +1643,16 @@ async function reverse(ip) {
1643
1643
  throw new DnsError("dns/bad-ip",
1644
1644
  "dns.reverse: '" + ip + "' is not a valid IPv4 or IPv6 address");
1645
1645
  }
1646
- _emitObs("network.dns.reverse.requested", { family: net.isIPv6(ip) ? 6 : 4 });
1646
+ observability().safeEvent("network.dns.reverse.requested", 1, { family: net.isIPv6(ip) ? 6 : 4 });
1647
1647
  var startMs = _now();
1648
1648
  try {
1649
1649
  var ptrs = await _withTimeout(dnsPromises.reverse(ip), STATE.lookupTimeoutMs, ip);
1650
- _emitObs("network.dns.reverse.success", {
1650
+ observability().safeEvent("network.dns.reverse.success", 1, {
1651
1651
  latencyMs: _now() - startMs, count: Array.isArray(ptrs) ? ptrs.length : 0,
1652
1652
  });
1653
1653
  return Array.isArray(ptrs) ? ptrs : [];
1654
1654
  } catch (e) {
1655
- _emitObs("network.dns.reverse.failure", {
1655
+ observability().safeEvent("network.dns.reverse.failure", 1, {
1656
1656
  latencyMs: _now() - startMs, code: e.code || "unknown",
1657
1657
  });
1658
1658
  if (e instanceof DnsError) throw e;
@@ -1674,10 +1674,6 @@ function nodeLookup(host, options, callback) {
1674
1674
  );
1675
1675
  }
1676
1676
 
1677
- function _emitObs(name, fields) {
1678
- try { observability().emit(name, fields || {}); } catch (_e) { /* obs best-effort */ }
1679
- }
1680
-
1681
1677
  function _stateForTest() { return STATE; }
1682
1678
  function _resetForTest() {
1683
1679
  STATE.servers = null; STATE.resultOrder = null; STATE.family = 0;
@@ -259,7 +259,7 @@ function statuses() {
259
259
 
260
260
  function _emitObsProbe(entry, result) {
261
261
  try {
262
- observability().emit("network.heartbeat.probe", {
262
+ observability().safeEvent("network.heartbeat.probe", 1, {
263
263
  name: entry.target.name,
264
264
  type: entry.target.type,
265
265
  ok: result.ok,
@@ -381,7 +381,7 @@ function passive(opts) {
381
381
 
382
382
  function _emitObsPong(state) {
383
383
  try {
384
- observability().emit("network.heartbeat.passive.pong", {
384
+ observability().safeEvent("network.heartbeat.passive.pong", 1, {
385
385
  pongCount: state.pongCount,
386
386
  timeoutMs: state.timeoutMs,
387
387
  });
@@ -390,7 +390,7 @@ function _emitObsPong(state) {
390
390
 
391
391
  function _emitObsTimeout(state) {
392
392
  try {
393
- observability().emit("network.heartbeat.passive.timeout", {
393
+ observability().safeEvent("network.heartbeat.passive.timeout", 1, {
394
394
  pongCount: state.pongCount,
395
395
  lastPongMs: state.lastPongMs,
396
396
  timeoutMs: state.timeoutMs,
@@ -70,7 +70,7 @@ function set(opts) {
70
70
  if (opts.no !== undefined) STATE.noProxy = _parseNoProxy(opts.no);
71
71
  if (opts.auth !== undefined) STATE.auth = opts.auth ? _parseAuth(opts.auth) : null;
72
72
  STATE.agentCache.clear();
73
- _emitObs("network.proxy.set", {
73
+ observability().safeEvent("network.proxy.set", 1, {
74
74
  httpSet: !!STATE.http,
75
75
  httpsSet: !!STATE.https,
76
76
  noProxyCount: STATE.noProxy.length,
@@ -93,7 +93,7 @@ function fromEnv(envObj) {
93
93
  if (authEnv) { STATE.auth = _parseAuth(authEnv); changed = true; }
94
94
  if (changed) {
95
95
  STATE.agentCache.clear();
96
- _emitObs("network.proxy.from_env", {
96
+ observability().safeEvent("network.proxy.from_env", 1, {
97
97
  httpSet: !!STATE.http,
98
98
  httpsSet: !!STATE.https,
99
99
  noProxyCount: STATE.noProxy.length,
@@ -255,7 +255,7 @@ function agentFor(targetUrl) {
255
255
  };
256
256
  }
257
257
  STATE.agentCache.set(key, agent);
258
- _emitObs("network.proxy.agent.created", { protocol: u.protocol });
258
+ observability().safeEvent("network.proxy.agent.created", 1, { protocol: u.protocol });
259
259
  return agent;
260
260
  }
261
261
 
@@ -268,10 +268,6 @@ function snapshot() {
268
268
  };
269
269
  }
270
270
 
271
- function _emitObs(name, fields) {
272
- try { observability().emit(name, fields || {}); } catch (_e) { /* obs best-effort */ }
273
- }
274
-
275
271
  function _resetForTest() {
276
272
  STATE.http = null; STATE.https = null; STATE.noProxy = []; STATE.auth = null;
277
273
  STATE.agentCache.clear();
@@ -23,6 +23,29 @@ var networkDns = lazyRequire(function () { return require("./network-dns"); });
23
23
  var httpClient = lazyRequire(function () { return require("./http-client"); });
24
24
  var asn1 = require("./asn1-der");
25
25
 
26
+ // Audit + observability emit for an outbound TLS connection that runs with
27
+ // peer-certificate validation DISABLED (an explicit operator opt-in —
28
+ // rejectUnauthorized:false / allowInsecure — never a framework default).
29
+ // Emitted at the point the disable is HONORED so the degraded posture is
30
+ // observable (compliance evidence + incident response), parallel to the
31
+ // tls.classical_downgrade audit. Drop-silent best-effort (§8 hot-path sink) —
32
+ // an audit-sink failure must never break the TLS connect itself.
33
+ function auditInsecureTls(meta) {
34
+ meta = meta || {};
35
+ try {
36
+ observability().safeEvent("tls.insecure_skip_verify", 1, {
37
+ host: meta.host || null, port: meta.port || null, source: meta.source || null,
38
+ });
39
+ } catch (_e) { /* drop-silent */ }
40
+ try {
41
+ audit().safeEmit({
42
+ action: "tls.insecure_skip_verify",
43
+ outcome: "success",
44
+ metadata: { host: meta.host || null, port: meta.port || null, source: meta.source || null },
45
+ });
46
+ } catch (_e) { /* drop-silent — audit best-effort, never break TLS */ }
47
+ }
48
+
26
49
  // STATE.tlsKeyShares is initialized to the default PQC group list at
27
50
  // module load — operator setKeyShares() overrides; resetKeyShares()
28
51
  // restores the default. Empty array means "fall back to Node's TLS
@@ -144,7 +167,7 @@ function addCa(pemOrPath, opts) {
144
167
  added.push(meta);
145
168
  }
146
169
  _emitAuditAdd(added, opts);
147
- _emitObs("network.tls.ca.added", { count: added.length });
170
+ observability().safeEvent("network.tls.ca.added", 1, { count: added.length });
148
171
  return added;
149
172
  }
150
173
 
@@ -154,7 +177,7 @@ function addCaBundle(p, opts) {
154
177
 
155
178
  function useSystemTrust(enable) {
156
179
  STATE.systemTrust = enable !== false;
157
- _emitObs("network.tls.system_trust.set", { enabled: STATE.systemTrust });
180
+ observability().safeEvent("network.tls.system_trust.set", 1, { enabled: STATE.systemTrust });
158
181
  }
159
182
 
160
183
  function isSystemTrustEnabled() { return !!STATE.systemTrust; }
@@ -216,7 +239,7 @@ function removeCa(fingerprint256, opts) {
216
239
  });
217
240
  if (removed.length === 0) return 0;
218
241
  if (!opts || opts.audit !== false) _emitAuditRemove(removed, "operator-remove");
219
- _emitObs("network.tls.ca.removed", { count: removed.length, reason: "operator" });
242
+ observability().safeEvent("network.tls.ca.removed", 1, { count: removed.length, reason: "operator" });
220
243
  return removed.length;
221
244
  }
222
245
 
@@ -234,7 +257,7 @@ function removeCaByLabel(label, opts) {
234
257
  });
235
258
  if (removed.length === 0) return 0;
236
259
  if (!opts || opts.audit !== false) _emitAuditRemove(removed, "operator-remove-by-label");
237
- _emitObs("network.tls.ca.removed", { count: removed.length, reason: "label" });
260
+ observability().safeEvent("network.tls.ca.removed", 1, { count: removed.length, reason: "label" });
238
261
  return removed.length;
239
262
  }
240
263
 
@@ -243,7 +266,7 @@ function clearAll(opts) {
243
266
  var removed = STATE.cas.map(function (e) { return Object.assign({ label: e.label }, e.meta); });
244
267
  STATE.cas = [];
245
268
  if (!opts || opts.audit !== false) _emitAuditRemove(removed, "operator-clear-all");
246
- _emitObs("network.tls.ca.cleared", { count: removed.length });
269
+ observability().safeEvent("network.tls.ca.cleared", 1, { count: removed.length });
247
270
  return removed.length;
248
271
  }
249
272
 
@@ -260,7 +283,7 @@ function purgeExpired(opts) {
260
283
  });
261
284
  if (removed.length === 0) return 0;
262
285
  if (!opts || opts.audit !== false) _emitAuditRemove(removed, "expired");
263
- _emitObs("network.tls.ca.purged_expired", { count: removed.length });
286
+ observability().safeEvent("network.tls.ca.purged_expired", 1, { count: removed.length });
264
287
  return removed.length;
265
288
  }
266
289
 
@@ -705,10 +728,6 @@ function _emitAuditAdd(metaList, opts) {
705
728
  }
706
729
  }
707
730
 
708
- function _emitObs(name, fields) {
709
- try { observability().emit(name, fields || {}); } catch (_e) { /* obs best-effort */ }
710
- }
711
-
712
731
  function _resetForTest() {
713
732
  STATE.cas = [];
714
733
  STATE.systemTrust = false;
@@ -2725,6 +2744,7 @@ function connectWithEch(opts) {
2725
2744
  }
2726
2745
  if (opts.rejectUnauthorized === false) {
2727
2746
  connectOpts.rejectUnauthorized = false;
2747
+ auditInsecureTls({ host: opts.host, port: port, source: "network.tls.connectWithEch" });
2728
2748
  }
2729
2749
  var echAttached = false;
2730
2750
  if (echConfigBuf && nodeSupportsEch) {
@@ -2735,7 +2755,7 @@ function connectWithEch(opts) {
2735
2755
  // gracefully with a one-shot warn so operators know they're
2736
2756
  // sending an outer-only ClientHello.
2737
2757
  try {
2738
- observability().emit("network.tls.ech.unsupported", {
2758
+ observability().safeEvent("network.tls.ech.unsupported", 1, {
2739
2759
  host: opts.host, source: sourceLabel,
2740
2760
  });
2741
2761
  } catch (_e) { /* drop-silent */ }
@@ -2772,7 +2792,7 @@ function connectWithEch(opts) {
2772
2792
  settled = true;
2773
2793
  if (to) clearTimeout(to);
2774
2794
  try {
2775
- observability().emit("network.tls.ech.connected", {
2795
+ observability().safeEvent("network.tls.ech.connected", 1, {
2776
2796
  host: opts.host, echAttached: echAttached, source: sourceLabel,
2777
2797
  });
2778
2798
  } catch (_e) { /* drop-silent */ }
@@ -2832,7 +2852,7 @@ function connectWithEch(opts) {
2832
2852
  // operator still gets a working TLS session. Emit obs so the
2833
2853
  // operator sees the degradation.
2834
2854
  try {
2835
- observability().emit("network.tls.ech.dns_failed", {
2855
+ observability().safeEvent("network.tls.ech.dns_failed", 1, {
2836
2856
  host: opts.host, error: (e && e.message) || String(e),
2837
2857
  });
2838
2858
  } catch (_e) { /* drop-silent */ }
@@ -3174,6 +3194,7 @@ function wrapSNICallback(operatorCb) {
3174
3194
  }
3175
3195
 
3176
3196
  module.exports = {
3197
+ auditInsecureTls: auditInsecureTls,
3177
3198
  addCa: addCa,
3178
3199
  addCaBundle: addCaBundle,
3179
3200
  removeCa: removeCa,
package/lib/network.js CHANGED
@@ -151,7 +151,7 @@ var ntpFacade = {
151
151
  throw new NetworkError("ntp/bad-servers", "ntp.setServers: expected non-empty array");
152
152
  }
153
153
  ntpFacade._defaultServers = list.slice();
154
- _emitObs("network.ntp.servers.set", { count: list.length });
154
+ observability().safeEvent("network.ntp.servers.set", 1, { count: list.length });
155
155
  },
156
156
  getServers: function () {
157
157
  return (ntpFacade._defaultServers || ntpCheck.DEFAULT_SERVERS).slice();
@@ -262,7 +262,7 @@ function bootFromEnv(opts) {
262
262
  } catch (_e) { /* audit best-effort — never break boot */ }
263
263
  }
264
264
  }
265
- _emitObs("network.boot.from_env", { source: "env" });
265
+ observability().safeEvent("network.boot.from_env", 1, { source: "env" });
266
266
  return applied;
267
267
  }
268
268
 
@@ -301,10 +301,6 @@ function snapshot() {
301
301
  };
302
302
  }
303
303
 
304
- function _emitObs(name, fields) {
305
- try { observability().emit(name, fields || {}); } catch (_e) { /* obs best-effort */ }
306
- }
307
-
308
304
  function _resetForTest() {
309
305
  ntpFacade._defaultServers = null;
310
306
  ntpFacade._defaultTimeoutMs = null;
package/lib/notify.js CHANGED
@@ -408,11 +408,6 @@ function create(opts) {
408
408
  channels[n] = registry;
409
409
  }
410
410
 
411
- function _emitObs(name, labels) {
412
- try { observability().event(name, 1, labels || {}); }
413
- catch (_e) { /* drop-silent — observability sink must not crash send() */ }
414
- }
415
-
416
411
  var _emitAudit = validateOpts.makeAuditEmitter(audit);
417
412
 
418
413
  function _actor(callerOpts) {
@@ -465,7 +460,7 @@ function create(opts) {
465
460
  // mechanism — no double-retry inside the breaker or transport).
466
461
  async function _oneAttempt(attemptIdx) {
467
462
  attemptCount = attemptIdx;
468
- _emitObs("notify.send.attempt", { channel: channel, attempt: attemptIdx });
463
+ observability().safeEvent("notify.send.attempt", 1, { channel: channel, attempt: attemptIdx });
469
464
  var sendPromise = entry.transport.send(message, input.sendOpts || null);
470
465
  // withTimeout from b.safeAsync — never re-implement timer races.
471
466
  var timed = (perCallTimeoutMs > 0)
@@ -478,7 +473,7 @@ function create(opts) {
478
473
  // classifies it correctly (operators can still opt OUT by setting
479
474
  // err.permanent in their transport).
480
475
  if (e && e.code === "async/timeout") {
481
- _emitObs("notify.send.timeout", { channel: channel });
476
+ observability().safeEvent("notify.send.timeout", 1, { channel: channel });
482
477
  var te = _err("TIMEOUT",
483
478
  "notify.send: '" + channel + "' transport timed out after " + perCallTimeoutMs + "ms");
484
479
  // Mark transient via a NETWORK-style code so b.retry.isRetryable
@@ -497,7 +492,7 @@ function create(opts) {
497
492
  return await entry.breaker.wrap(function () { return _oneAttempt(attemptIdx); });
498
493
  } catch (e) {
499
494
  if (e && e.code === "CIRCUIT_OPEN") {
500
- _emitObs("notify.send.breaker.open", { channel: channel });
495
+ observability().safeEvent("notify.send.breaker.open", 1, { channel: channel });
501
496
  }
502
497
  throw e;
503
498
  }
@@ -524,7 +519,7 @@ function create(opts) {
524
519
  }, perCallRetry);
525
520
 
526
521
  var durationMs = clock() - startedAt;
527
- _emitObs("notify.send.success", { channel: channel, durationMs: durationMs });
522
+ observability().safeEvent("notify.send.success", 1, { channel: channel, durationMs: durationMs });
528
523
  if (auditSuccess) {
529
524
  _emitAudit("notify.send.success", {
530
525
  actor: _actor(input),
@@ -543,7 +538,7 @@ function create(opts) {
543
538
  durationMs: durationMs,
544
539
  });
545
540
  } catch (e) {
546
- _emitObs("notify.send.failure", {
541
+ observability().safeEvent("notify.send.failure", 1, {
547
542
  channel: channel,
548
543
  reason: (e && e.code) || "unknown",
549
544
  });
@@ -594,7 +589,7 @@ function create(opts) {
594
589
  if (results[i] && results[i].isNotifyError) failed++;
595
590
  else ok++;
596
591
  }
597
- _emitObs("notify.batch", { size: inputs.length, ok: ok, failed: failed });
592
+ observability().safeEvent("notify.batch", 1, { size: inputs.length, ok: ok, failed: failed });
598
593
  return results;
599
594
  }
600
595
 
@@ -626,7 +621,7 @@ function create(opts) {
626
621
  } catch (_e) { /* operator may register their own handler */ }
627
622
  }
628
623
  var jobId = await q.enqueue(queueName, input);
629
- _emitObs("notify.queue.enqueued", { channel: input.channel, queueName: queueName });
624
+ observability().safeEvent("notify.queue.enqueued", 1, { channel: input.channel, queueName: queueName });
630
625
  return { jobId: jobId };
631
626
  }
632
627
 
package/lib/seeders.js CHANGED
@@ -438,11 +438,6 @@ function create(opts) {
438
438
  var audit = opts.audit || null;
439
439
  var clock = opts.clock || function () { return Date.now(); };
440
440
 
441
- function _emitObs(name, labels) {
442
- try { observability().event(name, 1, labels || {}); }
443
- catch (_e) { /* drop-silent — observability sink must not crash seeders */ }
444
- }
445
-
446
441
  var _emitAudit = validateOpts.makeAuditEmitter(audit);
447
442
 
448
443
  function _actor(callerOpts) {
@@ -512,7 +507,7 @@ function create(opts) {
512
507
  }
513
508
 
514
509
  var startedAt = clock();
515
- _emitObs("seeders.run.start", { env: env, count: loaded.ordered.length });
510
+ observability().safeEvent("seeders.run.start", 1, { env: env, count: loaded.ordered.length });
516
511
 
517
512
  var holder = _acquireLock(db, lockStaleAfterMs, clock);
518
513
  try {
@@ -538,7 +533,7 @@ function create(opts) {
538
533
 
539
534
  if (!shouldRun) {
540
535
  skipped.push(name);
541
- _emitObs("seeders.skipped", { env: env, name: name, reason: "already-applied" });
536
+ observability().safeEvent("seeders.skipped", 1, { env: env, name: name, reason: "already-applied" });
542
537
  continue;
543
538
  }
544
539
 
@@ -585,7 +580,7 @@ function create(opts) {
585
580
 
586
581
  var auditAction = (alreadyApplied && force) ? "seeders.force_applied" : "seeders.applied";
587
582
  var auditEvt = { env: env, name: name };
588
- _emitObs(auditAction, auditEvt);
583
+ observability().safeEvent(auditAction, 1, auditEvt);
589
584
  if (auditApplied) {
590
585
  _emitAudit(auditAction, {
591
586
  actor: _actor(callerOpts),
@@ -598,7 +593,7 @@ function create(opts) {
598
593
  failed = name;
599
594
  var msg = (e && e.message) || String(e);
600
595
  var code = (e && e.code) || "RUN_FAILED";
601
- _emitObs("seeders.failed", { env: env, name: name });
596
+ observability().safeEvent("seeders.failed", 1, { env: env, name: name });
602
597
  if (auditFailures) {
603
598
  _emitAudit("seeders.failed", {
604
599
  actor: _actor(callerOpts),
@@ -622,7 +617,7 @@ function create(opts) {
622
617
  failed: failed,
623
618
  durationMs: clock() - startedAt,
624
619
  };
625
- _emitObs("seeders.run.completed", {
620
+ observability().safeEvent("seeders.run.completed", 1, {
626
621
  env: env,
627
622
  applied: applied.length,
628
623
  skipped: skipped.length,
@@ -204,7 +204,43 @@ function unquoteSfString(s) {
204
204
  if (t.length === 0) return "";
205
205
  if (t.charAt(0) !== "\"") return t;
206
206
  if (t.length < 2 || t.charAt(t.length - 1) !== "\"") return null;
207
- return t.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
207
+ return unescapeSfStringBody(t.slice(1, -1));
208
+ }
209
+
210
+ /**
211
+ * @primitive b.structuredFields.unescapeSfStringBody
212
+ * @signature b.structuredFields.unescapeSfStringBody(body)
213
+ * @since 0.15.12
214
+ * @related b.structuredFields.unquoteSfString
215
+ *
216
+ * Undo the RFC 8941 §3.3.3 quoted-string backslash-escapes from the BODY of
217
+ * an sf-string (the bytes BETWEEN the surrounding double quotes). Only `\\\\`
218
+ * and `\\"` are legal escapes; every other backslash is literal.
219
+ *
220
+ * This is a single left-to-right scan, NOT two chained `.replace()` passes.
221
+ * The two-pass form (`.replace(/\\\\/g,"\\").replace(/\\"/g,'"')`, in either
222
+ * order) is not equivalent to a single decode: whichever pass runs first can
223
+ * rewrite a backslash the other escape sequence legitimately owns, so a lone
224
+ * escaped backslash (`\\\\`) decodes to two backslashes instead of one. The
225
+ * single pass consumes each escape exactly once. Non-string input passes
226
+ * through unchanged.
227
+ *
228
+ * @example
229
+ * b.structuredFields.unescapeSfStringBody('a\\"b\\\\c');
230
+ * // → 'a"b\c'
231
+ */
232
+ function unescapeSfStringBody(body) {
233
+ if (typeof body !== "string") return body;
234
+ var out = "";
235
+ for (var i = 0; i < body.length; i++) {
236
+ var c = body.charAt(i);
237
+ if (c === "\\" && i + 1 < body.length) {
238
+ var n = body.charAt(i + 1);
239
+ if (n === "\\" || n === "\"") { out += n; i += 1; continue; }
240
+ }
241
+ out += c;
242
+ }
243
+ return out;
208
244
  }
209
245
 
210
246
  /**
@@ -674,6 +710,7 @@ module.exports = {
674
710
  refuseControlBytes: refuseControlBytes,
675
711
  containsControlBytes: containsControlBytes,
676
712
  unquoteSfString: unquoteSfString,
713
+ unescapeSfStringBody: unescapeSfStringBody,
677
714
  parse: parse,
678
715
  serialize: serialize,
679
716
  Token: SfToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.11",
3
+ "version": "0.15.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.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:693c533d-2a9b-43e7-93e5-01669defbb95",
5
+ "serialNumber": "urn:uuid:caf34b84-1358-4d28-a517-e360819dc35a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-14T07:19:57.070Z",
8
+ "timestamp": "2026-06-14T16:52:10.733Z",
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.15.11",
22
+ "bom-ref": "@blamejs/core@0.15.12",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.11",
25
+ "version": "0.15.12",
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.15.11",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.12",
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.15.11",
57
+ "ref": "@blamejs/core@0.15.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]