@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.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +255 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/storefront.js +135 -0
- package/lib/vendor/MANIFEST.json +41 -35
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
- package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
- package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
- package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
- package/lib/vendor/blamejs/lib/acme.js +7 -11
- package/lib/vendor/blamejs/lib/client-hints.js +3 -1
- package/lib/vendor/blamejs/lib/cluster.js +4 -2
- package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
- package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
- package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
- package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
- package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
- package/lib/vendor/blamejs/lib/log.js +24 -2
- package/lib/vendor/blamejs/lib/mail.js +5 -0
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
- package/lib/vendor/blamejs/lib/network-dns.js +22 -26
- package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
- package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
- package/lib/vendor/blamejs/lib/network-tls.js +34 -13
- package/lib/vendor/blamejs/lib/network.js +2 -6
- package/lib/vendor/blamejs/lib/notify.js +7 -12
- package/lib/vendor/blamejs/lib/seeders.js +5 -10
- package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
- package/lib/vendor/blamejs/test/00-primitives.js +24 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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().
|
|
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().
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
@@ -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();
|