@blamejs/blamejs-shop 0.4.53 → 0.4.55

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 (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +255 -1
  3. package/lib/asset-manifest.json +3 -3
  4. package/lib/storefront.js +135 -0
  5. package/lib/vendor/MANIFEST.json +41 -35
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/SECURITY.md +1 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  9. package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
  10. package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
  11. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
  12. package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
  13. package/lib/vendor/blamejs/lib/acme.js +7 -11
  14. package/lib/vendor/blamejs/lib/client-hints.js +3 -1
  15. package/lib/vendor/blamejs/lib/cluster.js +4 -2
  16. package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
  17. package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
  18. package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
  19. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
  20. package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
  21. package/lib/vendor/blamejs/lib/log.js +24 -2
  22. package/lib/vendor/blamejs/lib/mail.js +5 -0
  23. package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
  24. package/lib/vendor/blamejs/lib/network-dns.js +22 -26
  25. package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
  26. package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
  27. package/lib/vendor/blamejs/lib/network-tls.js +34 -13
  28. package/lib/vendor/blamejs/lib/network.js +2 -6
  29. package/lib/vendor/blamejs/lib/notify.js +7 -12
  30. package/lib/vendor/blamejs/lib/seeders.js +5 -10
  31. package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
  32. package/lib/vendor/blamejs/package.json +1 -1
  33. package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
  34. package/lib/vendor/blamejs/test/00-primitives.js +24 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
  37. package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
  42. package/package.json +1 -1
@@ -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,
@@ -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;
@@ -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
 
@@ -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,
@@ -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",
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.12",
4
+ "date": "2026-06-14",
5
+ "headline": "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",
6
+ "summary": "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.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "b.structuredFields.unescapeSfStringBody(body)",
13
+ "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."
14
+ },
15
+ {
16
+ "title": "tls.insecure_skip_verify audit event",
17
+ "body": "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."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Security",
23
+ "items": [
24
+ {
25
+ "title": "Single-pass structured-field string unescape",
26
+ "body": "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."
27
+ },
28
+ {
29
+ "title": "Constant-time, member-anchored content-digest verification",
30
+ "body": "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."
31
+ },
32
+ {
33
+ "title": "Reserved-character filename strip removes every occurrence",
34
+ "body": "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."
35
+ },
36
+ {
37
+ "title": "Boot logger escapes control + bidi characters on every sink",
38
+ "body": "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."
39
+ },
40
+ {
41
+ "title": "Body-parser error responses never echo internal detail",
42
+ "body": "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."
43
+ }
44
+ ]
45
+ }
46
+ ]
47
+ }
@@ -16647,6 +16647,21 @@ function testLogger() {
16647
16647
 
16648
16648
  check("log.boot: .prefix exposes the namespace", log.prefix === "[blamejs:testmod] ");
16649
16649
 
16650
+ // v0.15.12 (#182) — the TTY branch must escape BOTH the bidi (re-ordering)
16651
+ // and C0/newline (line-forging) control classes the create() path
16652
+ // neutralizes (Trojan-Source CVE-2021-42574 / log-injection CWE-117). The
16653
+ // old boot() TTY branch wrote the raw message.
16654
+ captured.log.length = 0;
16655
+ var RLO = String.fromCharCode(0x202e);
16656
+ log.info("admin" + RLO + "gnp");
16657
+ check("log.boot TTY escapes bidi controls (no raw RLO)",
16658
+ captured.log[captured.log.length - 1].indexOf(RLO) === -1);
16659
+ check("log.boot TTY escapes bidi to \\u202e",
16660
+ captured.log[captured.log.length - 1].indexOf("\\u202e") !== -1);
16661
+ log.info("line1\nFAKE level=error injected");
16662
+ check("log.boot TTY does not forge lines via a raw newline",
16663
+ captured.log[captured.log.length - 1].indexOf("\n") === -1);
16664
+
16650
16665
  var threw = false;
16651
16666
  try { b.log.boot(""); } catch (_e) { threw = true; }
16652
16667
  check("log.boot: rejects empty name", threw);
@@ -16667,6 +16682,15 @@ function testLogger() {
16667
16682
  check("log.boot JSON marks boot:true", parsed.boot === true);
16668
16683
  check("log.boot JSON carries level", parsed.level === "info");
16669
16684
 
16685
+ // v0.15.12 (#182) — JSON.stringify escapes C0/newlines but leaves bidi
16686
+ // controls raw; the boot JSON branch must apply the same bidi escape the
16687
+ // create() path does so a piped aggregator can't be re-ordered.
16688
+ captured.log.length = 0;
16689
+ var RLO2 = String.fromCharCode(0x202e);
16690
+ jsonLog("owner" + RLO2 + "x");
16691
+ check("log.boot JSON escapes bidi controls (no raw RLO)", captured.log[0].indexOf(RLO2) === -1);
16692
+ check("log.boot JSON line still parses", (function () { try { JSON.parse(captured.log[0]); return true; } catch (_e) { return false; } })());
16693
+
16670
16694
  jsonLog.warn("ouch");
16671
16695
  var parsedWarn = JSON.parse(captured.error[0]);
16672
16696
  check("log.boot non-TTY warn → stderr JSON", parsedWarn.level === "warn" && parsedWarn.message === "ouch");
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * body-parser error-response redaction (v0.15.12, #84 / CWE-209).
4
+ *
5
+ * A caught exception's detail must never be echoed to the HTTP client. The
6
+ * terminal catch surfaces a curated message only for a framework-classified
7
+ * 4xx BodyParserError; any other thrown error — and every 5xx — gets a generic
8
+ * status phrase, with full detail kept on the audit chain server-side. The
9
+ * parse-hook wrapper carries a fixed message so an operator hook's thrown
10
+ * secret can't ride the 4xx path to the client.
11
+ */
12
+
13
+ var EventEmitter = require("events").EventEmitter;
14
+ var helpers = require("../helpers");
15
+ var b = helpers.b;
16
+ var check = helpers.check;
17
+ var _bodyRes = helpers._bodyRes;
18
+
19
+ function _bodyReqStream(body, headers) {
20
+ var req = new EventEmitter();
21
+ req.method = "POST";
22
+ req.url = "/";
23
+ req.headers = Object.assign({ "content-length": String(Buffer.byteLength(body)) }, headers || {});
24
+ req.socket = { remoteAddress: "127.0.0.1" };
25
+ req.destroy = function () { req._destroyed = true; };
26
+ // Deliver the body on next tick so the parser's data/end listeners are wired.
27
+ process.nextTick(function () {
28
+ req.emit("data", Buffer.from(body, "utf8"));
29
+ req.emit("end");
30
+ });
31
+ return req;
32
+ }
33
+
34
+ async function _run(opts, body, headers) {
35
+ var bp = b.middleware.bodyParser(opts);
36
+ var req = _bodyReqStream(body, headers);
37
+ var res = _bodyRes();
38
+ var settled = false;
39
+ function fin() { settled = true; }
40
+ res.on("finish", fin);
41
+ bp(req, res, fin);
42
+ // Poll for the response to settle — never a fixed sleep (§6b).
43
+ await helpers.waitUntil(function () { return settled || res._endedStatus !== null; }, {
44
+ timeoutMs: 5000,
45
+ label: "body-parser error response settles",
46
+ });
47
+ return res;
48
+ }
49
+
50
+ async function run() {
51
+ // (1) parse-hook throws a detail-bearing error — not echoed to the client.
52
+ // Use a non-credential sentinel so the test itself carries no secret shape.
53
+ var SENTINEL = "do-not-echo-sentinel-9f8e7d6c";
54
+ var r1 = await _run(
55
+ { json: { parseHook: function () { throw new Error("internal detail " + SENTINEL + " host:5432"); } } },
56
+ "{}",
57
+ { "content-type": "application/json" }
58
+ );
59
+ var b1 = String(r1._captured || "");
60
+ check("#84 parse-hook internal detail is NOT echoed to the client",
61
+ b1.indexOf(SENTINEL) === -1 && b1.indexOf("host:5432") === -1);
62
+ check("#84 parse-hook client message is the curated fixed reason",
63
+ b1.length === 0 || b1.indexOf("parse hook") !== -1);
64
+
65
+ // (2) a genuinely malformed JSON body still returns a useful 400 grammar
66
+ // error (the fix must not over-redact client-input errors).
67
+ var r2 = await _run({ json: {} }, "{not json", { "content-type": "application/json" });
68
+ check("#84 malformed JSON still returns 400", r2._endedStatus === 400 || r2._endedStatus === null);
69
+ var b2 = String(r2._captured || "");
70
+ check("#84 malformed-JSON error is still surfaced (not blanket-redacted)",
71
+ b2.length === 0 || b2.indexOf("JSON") !== -1 || b2.indexOf("parse") !== -1 || b2.indexOf("Bad Request") !== -1);
72
+ }
73
+
74
+ module.exports = { run: run };
@@ -5741,18 +5741,14 @@ async function testNoDuplicateCodeBlocks() {
5741
5741
  ],
5742
5742
  reason: "Same nested-shape cluster as above with notify removed. Same justification.",
5743
5743
  },
5744
- {
5745
- files: [
5746
- "lib/network-proxy.js:_emitObs",
5747
- "lib/network-tls.js:_emitObs",
5748
- "lib/network.js:_emitObs",
5749
- ],
5750
- reason: "Network listener teardown shape — `function reset() { state.X = null; state.Y = null; state.Z = []; ...}`. Each network primitive has a different reset surface; consolidating would force unrelated state into a base contract.",
5751
- },
5752
5744
  {
5753
5745
  files: ["lib/notify.js:<unknown>", "lib/seeders.js:<unknown>", "lib/webhook.js:<unknown>"],
5754
5746
  reason: "Same nested-shape cluster as middleware/db-role-for+notify+seeders+webhook (see above) with db-role-for removed.",
5755
5747
  },
5748
+ {
5749
+ files: ["lib/api-key.js:create", "lib/file-upload.js:create", "lib/seeders.js:create"],
5750
+ reason: "Conventional create() entry prelude — a run of per-primitive `var X = cfg.X;` opts-to-cfg field unpacking followed by the standard `var audit = opts.audit || null; var clock = opts.clock || function () { return Date.now(); }; var _emitAudit = validateOpts.makeAuditEmitter(audit);` trio. The reusable part (the audit emitter) is already centralized in validateOpts.makeAuditEmitter; the remaining shared run is per-primitive opts unpacking (api-key: prefix/idBytes/secretBytes; file-upload: maxChunkBytes/maxStagingBytes/...; seeders: auditFailures/lockStaleAfterMs) that cannot become one primitive without forcing unrelated config surfaces into a single base contract.",
5751
+ },
5756
5752
  {
5757
5753
  files: [
5758
5754
  "lib/object-store/azure-blob.js:_buildSasToken",
@@ -9140,6 +9136,20 @@ var KNOWN_ANTIPATTERNS = [
9140
9136
  allowlist: [],
9141
9137
  reason: "Extracted to observability.safeEvent — drop-silent semantics for hot-path event emission. Any module wrapping observability.event in try/catch should call observability.safeEvent instead.",
9142
9138
  },
9139
+ {
9140
+ id: "observability-emit-nonexistent-method",
9141
+ scanScope: "lib",
9142
+ primitive: "observability.safeEvent(name, value, labels) / observability.event(...) — there is no observability.emit",
9143
+ // The observability module exposes event / safeEvent / tap / setTap — it
9144
+ // has NO `emit`. A call to observability().emit(...) throws TypeError and,
9145
+ // because every call site wraps the emit in a drop-silent try/catch, the
9146
+ // metric silently never fires. The whole network-* family carried 11 such
9147
+ // dead calls. Any reintroduction is a dead telemetry emit, never a working
9148
+ // one — route through safeEvent(name, value, labels) instead.
9149
+ regex: /observability\s*\(\s*\)\s*\.\s*emit\s*\(/,
9150
+ allowlist: [],
9151
+ reason: "observability has no emit() method; observability().emit(...) is a dead drop-silent call. Use observability.safeEvent(name, value, labels).",
9152
+ },
9143
9153
  {
9144
9154
  id: "inline-hex-string-validator",
9145
9155
  primitive: "safeBuffer.isHex(s, expectedLength?) — returns boolean",
@@ -239,6 +239,17 @@ function testGuardFilenameSanitize() {
239
239
  check("sanitize strips leading/trailing whitespace + trailing dot",
240
240
  clean === "weird name.txt");
241
241
 
242
+ // v0.15.12 (#78) — reservedCharPolicy:"strip" (set by the permissive profile)
243
+ // must strip EVERY reserved char, not just the first. The old non-global
244
+ // RESERVED_CHARS_RE left the 2nd/3rd path separators in place.
245
+ var multiSep = b.guardFilename.sanitize("a/b/c/d", { profile: "permissive" });
246
+ check("sanitize permissive strips ALL path separators (#78)",
247
+ multiSep.indexOf("/") === -1 && multiSep === "a_b_c_d");
248
+ var multiBack = b.guardFilename.sanitize("x\\y\\z", { profile: "permissive" });
249
+ check("sanitize permissive strips ALL backslashes (#78)", multiBack.indexOf("\\") === -1);
250
+ check("sanitize permissive leaves a clean name unchanged (#78)",
251
+ b.guardFilename.sanitize("clean.txt", { profile: "permissive" }) === "clean.txt");
252
+
242
253
  var threwTraversal = null;
243
254
  try { b.guardFilename.sanitize("../etc/passwd", { profile: "balanced" }); }
244
255
  catch (e) { threwTraversal = e; }
@@ -119,6 +119,38 @@ function testContentDigestTamper() {
119
119
  verified.reason === "content-digest-mismatch");
120
120
  }
121
121
 
122
+ // v0.15.12 (#178) — the content-digest check was rewritten from an unanchored
123
+ // substring `indexOf` (+ dead identity-replace) to a top-level-member parse
124
+ // with a constant-time compare. The signature already binds the Content-Digest
125
+ // header (covered component), so the substring case is not reachable via the
126
+ // consumer path — this guards that the refactor still ACCEPTS a valid sha3-512
127
+ // member (no over-tightening) while testContentDigestTamper guards the reject.
128
+ function testContentDigestMemberAnchored() {
129
+ var keys = _genEd25519();
130
+ var msg = {
131
+ method: "POST",
132
+ url: "https://api.example.com/x",
133
+ headers: { host: "api.example.com" },
134
+ body: "member-anchored-body",
135
+ };
136
+ var signed = b.crypto.httpSig.sign(msg, {
137
+ keyid: "k1",
138
+ alg: "ed25519",
139
+ privateKey: keys.privateKey,
140
+ covered: ["@method", "content-digest"],
141
+ });
142
+ check("#178 a valid sha3-512 content-digest member parses + matches",
143
+ /^sha3-512=:/.test(signed.headers["Content-Digest"]));
144
+ var verifyMsg = Object.assign({}, msg, {
145
+ headers: Object.assign({}, msg.headers, signed.headers),
146
+ });
147
+ var verified = b.crypto.httpSig.verify(verifyMsg, {
148
+ keyResolver: function () { return keys.publicKey; },
149
+ });
150
+ check("#178 member-anchored content-digest verify still accepts the valid member",
151
+ verified.valid === true);
152
+ }
153
+
122
154
  function testExpired() {
123
155
  var keys = _genEd25519();
124
156
  var msg = {
@@ -211,6 +243,7 @@ async function run() {
211
243
  testRoundTripEd25519();
212
244
  testRoundTripMlDsa65();
213
245
  testContentDigestTamper();
246
+ testContentDigestMemberAnchored();
214
247
  testExpired();
215
248
  testUnknownKeyid();
216
249
  testValidation();