@blamejs/core 0.8.0 → 0.8.4

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 (63) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/audit-sign.js +1 -1
  3. package/lib/audit.js +62 -2
  4. package/lib/auth/jwt.js +13 -0
  5. package/lib/auth/lockout.js +16 -3
  6. package/lib/auth/oauth.js +15 -1
  7. package/lib/auth/password.js +22 -2
  8. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  9. package/lib/auth/sd-jwt-vc.js +7 -2
  10. package/lib/break-glass.js +53 -14
  11. package/lib/cache-redis.js +1 -1
  12. package/lib/cache.js +6 -1
  13. package/lib/cli.js +3 -3
  14. package/lib/cluster.js +24 -1
  15. package/lib/compliance-ai-act-logging.js +7 -3
  16. package/lib/compliance.js +10 -2
  17. package/lib/config-drift.js +2 -2
  18. package/lib/crypto-field.js +21 -1
  19. package/lib/crypto.js +82 -1
  20. package/lib/db.js +35 -4
  21. package/lib/dev.js +30 -3
  22. package/lib/dual-control.js +19 -1
  23. package/lib/external-db.js +10 -0
  24. package/lib/file-upload.js +30 -3
  25. package/lib/flag.js +1 -1
  26. package/lib/guard-all.js +33 -16
  27. package/lib/guard-csv.js +16 -2
  28. package/lib/guard-html.js +35 -0
  29. package/lib/guard-svg.js +20 -0
  30. package/lib/http-client.js +57 -11
  31. package/lib/inbox.js +34 -10
  32. package/lib/log-stream-syslog.js +8 -0
  33. package/lib/log-stream.js +1 -1
  34. package/lib/mail.js +40 -0
  35. package/lib/middleware/attach-user.js +25 -2
  36. package/lib/middleware/bearer-auth.js +71 -6
  37. package/lib/middleware/body-parser.js +13 -0
  38. package/lib/middleware/cors.js +10 -0
  39. package/lib/middleware/csrf-protect.js +34 -3
  40. package/lib/middleware/dpop.js +3 -3
  41. package/lib/middleware/host-allowlist.js +1 -1
  42. package/lib/middleware/require-aal.js +2 -2
  43. package/lib/middleware/trace-propagate.js +1 -1
  44. package/lib/mtls-ca.js +23 -29
  45. package/lib/mtls-engine-default.js +21 -1
  46. package/lib/network-tls.js +21 -6
  47. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  48. package/lib/observability-otlp-exporter.js +35 -2
  49. package/lib/outbox.js +3 -3
  50. package/lib/permissions.js +10 -1
  51. package/lib/pqc-agent.js +22 -1
  52. package/lib/pubsub.js +8 -4
  53. package/lib/redact.js +26 -1
  54. package/lib/retention.js +26 -0
  55. package/lib/router.js +1 -0
  56. package/lib/scheduler.js +57 -1
  57. package/lib/session.js +3 -3
  58. package/lib/ssrf-guard.js +19 -4
  59. package/lib/static.js +12 -0
  60. package/lib/totp.js +16 -0
  61. package/lib/ws-client.js +158 -9
  62. package/package.json +1 -1
  63. package/sbom.cyclonedx.json +6 -6
package/lib/mtls-ca.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * caCert: "ca.crt",
18
18
  * },
19
19
  * vault: b.vault, // optional; required when sealed
20
- * caKeySealedMode: "auto", // "auto" | "required" | "disabled"
20
+ * caKeySealedMode: "required", // "required" (default) | "disabled"
21
21
  * generation: 1, // current CA generation for OU=CAv{N}
22
22
  * engine: myCertEngine, // optional — defaults to b.mtlsEngine
23
23
  * });
@@ -27,10 +27,16 @@
27
27
  * ca.key CA private key (PEM, plaintext on disk)
28
28
  * ca.key.sealed CA private key (vault.seal of PEM bytes)
29
29
  *
30
- * caKeySealedMode:
31
- * "auto" load whichever exists (sealed if present, else plain)
32
- * "required" sealed file required; refuse plaintext
33
- * "disabled" plaintext required; refuse sealed
30
+ * caKeySealedMode (defaults to "required"):
31
+ * "required" sealed file required; refuse plaintext (default vault
32
+ * must be wired)
33
+ * "disabled" plaintext required; refuse sealed (dev-only opt-out;
34
+ * operator must justify with audited reason)
35
+ *
36
+ * The legacy "auto" mode (load whichever exists, fall back to plaintext
37
+ * when no sealed file is present) was removed; it defaulted to writing
38
+ * plaintext on a fresh install, which is the inverse of the framework's
39
+ * security-defaults-on posture for at-rest key material.
34
40
  *
35
41
  * Generation tagging: every CA cert issued by the framework embeds a
36
42
  * "OU=CAv{N}" RDN in its subject DN. Status reads that back so an
@@ -114,7 +120,7 @@ var DEFAULT_PATHS = {
114
120
  crl: "ca.crl",
115
121
  };
116
122
 
117
- var VALID_SEAL_MODES = { auto: 1, required: 1, disabled: 1 };
123
+ var VALID_SEAL_MODES = { required: 1, disabled: 1 };
118
124
 
119
125
  function _resolvePaths(dataDir, paths) {
120
126
  var p = Object.assign({}, DEFAULT_PATHS, paths || {});
@@ -161,10 +167,11 @@ function create(opts) {
161
167
  }
162
168
  var paths = _resolvePaths(opts.dataDir, opts.paths);
163
169
  var vault = opts.vault || null;
164
- var caKeySealedMode = (opts.caKeySealedMode || "auto").toLowerCase();
170
+ var caKeySealedMode = (opts.caKeySealedMode || "required").toLowerCase();
165
171
  if (!VALID_SEAL_MODES[caKeySealedMode]) {
166
172
  throw new MtlsCaError("mtls-ca/bad-mode",
167
- "caKeySealedMode must be 'auto', 'required', or 'disabled'");
173
+ "caKeySealedMode must be 'required' or 'disabled' " +
174
+ "(legacy 'auto' was removed — it defaulted to plaintext-on-disk)");
168
175
  }
169
176
  var generation = typeof opts.generation === "number" && opts.generation >= 1
170
177
  ? Math.floor(opts.generation) : 1;
@@ -230,23 +237,10 @@ function create(opts) {
230
237
  }
231
238
  return Buffer.from(pem, "utf8");
232
239
  }
233
- if (caKeySealedMode === "disabled") {
234
- if (!hasPlain) {
235
- throw new MtlsCaError("mtls-ca/plain-required",
236
- "CA_KEY_SEALED='disabled' but " + paths.caKey + " does not exist");
237
- }
238
- return fs.readFileSync(paths.caKey);
239
- }
240
- // auto: prefer sealed if it exists (defense-in-depth default)
241
- if (hasSealed) {
242
- _requireVault("sealed CA key load");
243
- var sealedBytesA = fs.readFileSync(paths.caKeySealed, "utf8").trim();
244
- var pemA = vault.unseal(sealedBytesA);
245
- if (!pemA) {
246
- throw new MtlsCaError("mtls-ca/unseal-failed",
247
- "vault.unseal of " + paths.caKeySealed + " returned empty");
248
- }
249
- return Buffer.from(pemA, "utf8");
240
+ // disabled: plaintext only.
241
+ if (!hasPlain) {
242
+ throw new MtlsCaError("mtls-ca/plain-required",
243
+ "caKeySealedMode='disabled' but " + paths.caKey + " does not exist");
250
244
  }
251
245
  return fs.readFileSync(paths.caKey);
252
246
  }
@@ -260,10 +254,10 @@ function create(opts) {
260
254
  }
261
255
 
262
256
  // Atomic commit: write .tmp + atomic rename for both key and cert.
263
- // Honors caKeySealedMode — when 'required', the key is vault-sealed
264
- // before the on-disk write so plaintext PEM never touches the
265
- // filesystem; when 'disabled', it goes to disk as PEM. 'auto'
266
- // defaults to plaintext-on-disk.
257
+ // Honors caKeySealedMode — when 'required' (the default), the key is
258
+ // vault-sealed before the on-disk write so plaintext PEM never touches
259
+ // the filesystem; when 'disabled', it goes to disk as PEM with the
260
+ // operator's audited reason on record.
267
261
  function commit(opts2) {
268
262
  if (!opts2 || typeof opts2.caKeyPem !== "string" || typeof opts2.caCertPem !== "string") {
269
263
  throw new MtlsCaError("mtls-ca/bad-commit",
@@ -134,7 +134,27 @@ async function _selectAlgorithm() {
134
134
  for (var i = 0; i < ALG_CANDIDATES.length; i++) {
135
135
  var c = ALG_CANDIDATES[i];
136
136
  var ok = await _probeCandidate(c);
137
- if (ok) { _selectedAlg = c; return c; }
137
+ if (ok) {
138
+ _selectedAlg = c;
139
+ // Emit an audit row at first probe so operators see which
140
+ // algorithm landed without having to call b.mtlsCa.status().
141
+ // Pre-PQC ecosystems land on the ECDSA-P384 bridge silently;
142
+ // this puts the choice on the chain so compliance dashboards
143
+ // alert when an operator's deployment hasn't yet picked up the
144
+ // PQ-signed-cert capability the framework would otherwise
145
+ // prefer.
146
+ setImmediate(function () {
147
+ try {
148
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense
149
+ auditMod.safeEmit({
150
+ action: "mtls.engine.algorithm_selected",
151
+ outcome: "success",
152
+ metadata: { label: c.label, posture: c.posture, candidatesProbed: i + 1 },
153
+ });
154
+ } catch (_e) { /* drop-silent */ }
155
+ });
156
+ return c;
157
+ }
138
158
  }
139
159
  // Should never happen — ECDSA-P384-SHA384 is universal.
140
160
  throw new MtlsEngineError("mtls-engine/no-algorithm",
@@ -26,7 +26,7 @@ var STATE = {
26
26
  cas: [],
27
27
  systemTrust: false,
28
28
  baselineFingerprints: null,
29
- tlsKeyShares: ["X25519MLKEM768", "X25519", "secp256r1"],
29
+ tlsKeyShares: ["SecP384r1MLKEM1024", "X25519MLKEM768", "X25519"],
30
30
  };
31
31
 
32
32
  function _normalizePem(pem) {
@@ -301,7 +301,7 @@ function expiryMonitor(opts) {
301
301
  try {
302
302
  audit().safeEmit({
303
303
  action: "network.tls.ca.expiring",
304
- outcome: "warn",
304
+ outcome: "success",
305
305
  metadata: {
306
306
  count: rows.length,
307
307
  labels: rows.map(function (r) { return r.label; }),
@@ -375,9 +375,9 @@ function applyToContext(opts) {
375
375
  // resetKeyShares() → restores default
376
376
 
377
377
  var DEFAULT_PQC_KEY_SHARES = Object.freeze([
378
- "X25519MLKEM768", // hybrid KEM, draft-kwiatkowski-tls-ecdhe-mlkem-02
379
- "X25519", // classical fallback
380
- "secp256r1", // legacy peers
378
+ "SecP384r1MLKEM1024", // highest-PQC hybrid (codepoint 0x11ED, draft-kwiatkowski-tls-ecdhe-mlkem-02)
379
+ "X25519MLKEM768", // mid-PQC hybrid (codepoint 0x11EC, IETF/Cloudflare/Chrome interop)
380
+ "X25519", // classical fallback (modern non-PQC peers)
381
381
  ]);
382
382
 
383
383
  function _validateKeyShare(name) {
@@ -990,9 +990,24 @@ function buildOcspRequest(opts) {
990
990
  var serial = _extractLeafSerial(opts.leafCertDer);
991
991
  // CertID hashes — SHA-1 per RFC 6960 §4.1.1 (the only universally
992
992
  // supported algorithm; SHA-256 in OCSP requests is RFC 6960 §4.3
993
- // optional and many responders reject).
993
+ // optional and many responders reject). The hash isn't security-
994
+ // critical here — it's a name/key lookup, not an integrity check —
995
+ // but operator compliance dashboards alerting on "anywhere in the
996
+ // framework that touches SHA-1" need a signal. Emit an audit row
997
+ // on every OCSP request build so the algorithm choice is visible
998
+ // in the chain.
994
999
  var nameHash = nodeCrypto.createHash("sha1").update(iss.issuerNameDer).digest();
995
1000
  var keyHash = nodeCrypto.createHash("sha1").update(iss.issuerKey).digest();
1001
+ setImmediate(function () {
1002
+ try {
1003
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports network-tls)
1004
+ auditMod.safeEmit({
1005
+ action: "network.tls.ocsp.certid_built",
1006
+ outcome: "success",
1007
+ metadata: { hashAlgorithm: "sha1", note: "RFC 6960 §4.1.1 — non-security-critical lookup hash" },
1008
+ });
1009
+ } catch (_e) { /* drop-silent */ }
1010
+ });
996
1011
  // hashAlgorithm AlgorithmIdentifier ::= SEQUENCE { algorithm OID, NULL }
997
1012
  var algId = asn1.writeSequence([asn1.writeOid(OID_SHA1), asn1.writeNull()]);
998
1013
  var certId = asn1.writeSequence([
@@ -897,6 +897,47 @@ function create(config) {
897
897
  _validateRetention(opts);
898
898
  validateOpts(opts, ["mode", "retainUntil", "bypassGovernance", "req", "actor"],
899
899
  "bucketOps.setObjectRetention");
900
+ // COMPLIANCE-mode defense-in-depth: refuse client-side when the
901
+ // operator (or attacker with the s3:PutObjectRetention permission)
902
+ // tries to shorten an existing COMPLIANCE retention or pass
903
+ // bypassGovernance against COMPLIANCE. Real S3 also refuses but
904
+ // MinIO and other S3-compatible backends are implementation-
905
+ // dependent; the framework's job is defense-in-depth, not
906
+ // passthrough. Adds one RTT (the GET) to every PUT — acceptable.
907
+ //
908
+ // The pre-check is a soft gate: when the backend can't surface the
909
+ // existing retention (parse error, no-such-object, etc.), the
910
+ // framework falls through to the PUT and lets the backend's own
911
+ // enforcement handle it. The pre-check is value-add, not
912
+ // load-bearing.
913
+ return getObjectRetention(name, key).then(function (existing) {
914
+ if (existing && existing.mode === "COMPLIANCE") {
915
+ if (opts.bypassGovernance === true) {
916
+ throw new ObjectStoreError("objectstore/compliance-bypass-refused",
917
+ "setObjectRetention: bypassGovernance refused — existing retention mode is COMPLIANCE (cannot be bypassed by anyone, including root)", true);
918
+ }
919
+ if (opts.retainUntil && existing.retainUntil &&
920
+ opts.retainUntil.getTime() < existing.retainUntil.getTime()) {
921
+ throw new ObjectStoreError("objectstore/compliance-shortening-refused",
922
+ "setObjectRetention: cannot shorten COMPLIANCE retention (existing=" +
923
+ existing.retainUntil.toISOString() + ", proposed=" +
924
+ opts.retainUntil.toISOString() + ")", true);
925
+ }
926
+ }
927
+ return _doSetRetention(name, key, opts);
928
+ }, function (e) {
929
+ // Re-throw the framework's own COMPLIANCE refusals; everything
930
+ // else (parse errors, transient network errors, malformed
931
+ // backend responses) falls through to the PUT.
932
+ if (e && typeof e.code === "string" &&
933
+ e.code.indexOf("objectstore/compliance-") === 0) {
934
+ throw e;
935
+ }
936
+ return _doSetRetention(name, key, opts);
937
+ });
938
+ }
939
+
940
+ function _doSetRetention(name, key, opts) {
900
941
  var bodyXml = _buildRetentionXml(opts);
901
942
  var bodyBuf = Buffer.from(bodyXml, "utf8");
902
943
  var url = _objectUrl(name, key, { retention: "" });
@@ -41,6 +41,33 @@ var { defineClass } = require("./framework-error");
41
41
  var OtlpExporterError = defineClass("OtlpExporterError", { alwaysPermanent: true });
42
42
 
43
43
  var observability = lazyRequire(function () { return require("./observability"); });
44
+ var httpClient = lazyRequire(function () { return require("./http-client"); });
45
+
46
+ // Default OTLP transport — uses the framework's own b.httpClient
47
+ // (node:https through the PQC-hybrid agent + cert-pinning + SSRF
48
+ // guard) rather than globalThis.fetch. Operators with a sidecar
49
+ // collector that must be addressed via fetch (Cloudflare Workers,
50
+ // Deno, fetch-only edge runtimes) override fetchImpl explicitly.
51
+ // Returning a fetch-shaped { ok, status } so the existing _post
52
+ // path stays the same regardless of which transport ran.
53
+ function _defaultFetchImpl(endpoint, init) {
54
+ var hc = httpClient();
55
+ return hc.request({
56
+ url: endpoint,
57
+ method: init && init.method ? init.method : "POST",
58
+ headers: init && init.headers ? init.headers : {},
59
+ body: init && init.body ? init.body : "",
60
+ timeoutMs: 0,
61
+ responseMode: "always-resolve",
62
+ allowInternal: true,
63
+ }).then(function (res) {
64
+ var status = res && res.statusCode;
65
+ return {
66
+ ok: status >= 200 && status < 300, // allow:raw-byte-literal — HTTP status ranges
67
+ status: status,
68
+ };
69
+ });
70
+ }
44
71
 
45
72
  var DEFAULT_BATCH_SIZE = 200; // allow:raw-byte-literal — OTLP recommended batch
46
73
  var DEFAULT_MAX_QUEUE_SIZE = 4096; // allow:raw-byte-literal — operator-side queue cap
@@ -206,10 +233,16 @@ function create(opts) {
206
233
  var maxAttempts = opts.maxAttempts || DEFAULT_MAX_ATTEMPTS;
207
234
  var backoffInitial = opts.backoffInitialMs || DEFAULT_BACKOFF_INITIAL_MS;
208
235
  var backoffMax = opts.backoffMaxMs || DEFAULT_BACKOFF_MAX_MS;
209
- var fetchImpl = opts.fetchImpl || ((typeof globalThis.fetch === "function") ? globalThis.fetch.bind(globalThis) : null);
236
+ // Default transport is the framework's b.httpClient (node:https +
237
+ // PQC-hybrid agent + SSRF guard). globalThis.fetch was the prior
238
+ // default; it leaked an outbound network surface that supply-chain
239
+ // scanners flagged because nothing in the framework's TLS posture
240
+ // wired through it. Operators on fetch-only runtimes still override
241
+ // by passing opts.fetchImpl.
242
+ var fetchImpl = opts.fetchImpl || _defaultFetchImpl;
210
243
  if (typeof fetchImpl !== "function") {
211
244
  throw new OtlpExporterError("otlp/no-fetch",
212
- "otlpExporter.create: fetchImpl required (globalThis.fetch unavailable)");
245
+ "otlpExporter.create: opts.fetchImpl must be a function (override the framework default)");
213
246
  }
214
247
 
215
248
  var queue = [];
package/lib/outbox.js CHANGED
@@ -308,7 +308,7 @@ function create(opts) {
308
308
  " SET status = 'dead', attempts = $1, last_error = $2 WHERE id = $3",
309
309
  [attempts + 1, String(errMsg).slice(0, 1024), id] // allow:raw-byte-literal — error-message char cap
310
310
  );
311
- _emitAudit("system.outbox.deadletter", "fail", { id: id, attempts: attempts + 1 });
311
+ _emitAudit("system.outbox.deadletter", "failure", { id: id, attempts: attempts + 1 });
312
312
  _emitMetric("dead-letter", 1);
313
313
  }
314
314
 
@@ -353,7 +353,7 @@ function create(opts) {
353
353
  .catch(function () { /* drop-silent — see _processOnce */ })
354
354
  .finally(function () { inFlight = null; });
355
355
  }, pollIntervalMs, { name: name + "-publisher" });
356
- _emitAudit("system.outbox.started", "ok", { name: name });
356
+ _emitAudit("system.outbox.started", "success", { name: name });
357
357
  }
358
358
 
359
359
  async function stop() {
@@ -365,7 +365,7 @@ function create(opts) {
365
365
  if (inFlight) {
366
366
  try { await inFlight; } catch (_e) { /* drop-silent */ }
367
367
  }
368
- _emitAudit("system.outbox.stopped", "ok", { name: name });
368
+ _emitAudit("system.outbox.stopped", "success", { name: name });
369
369
  }
370
370
 
371
371
  async function pendingCount() {
@@ -491,8 +491,17 @@ function create(opts) {
491
491
  }
492
492
 
493
493
  if (enforceMfa) {
494
+ // Window floor — when neither route nor role supplies an
495
+ // explicit mfaWindowMs, default to 15 minutes. Without this
496
+ // floor, a stolen long-lived cookie carrying an old `mfaAt`
497
+ // walks past every requireMfa: true gate. Operators who want
498
+ // an explicit no-window pass-through must say so via
499
+ // mfaWindowMs: Infinity (audited reason).
500
+ if (enforceWindowMs === null) {
501
+ enforceWindowMs = C.TIME.minutes(15);
502
+ }
494
503
  var mfaOk = actor.mfaAuthenticated === true;
495
- if (mfaOk && enforceWindowMs !== null) {
504
+ if (mfaOk && enforceWindowMs !== null && enforceWindowMs !== Infinity) {
496
505
  var mfaAt = typeof actor.mfaAt === "number" ? actor.mfaAt : 0;
497
506
  if (Date.now() - mfaAt > enforceWindowMs) {
498
507
  mfaOk = false;
package/lib/pqc-agent.js CHANGED
@@ -48,7 +48,28 @@ var DEFAULT_OPTS = {
48
48
  function _buildAgentOpts(opts) {
49
49
  opts = opts || {};
50
50
  var merged = Object.assign({}, DEFAULT_OPTS, opts);
51
- merged.ecdhCurve = C.TLS_GROUP_CURVE_STR;
51
+ // Caller may narrow the framework's curve preference list (drop a
52
+ // group, keep the remaining ones in framework-preferred order) but
53
+ // cannot widen it. A caller-supplied `ecdhCurve` string is parsed
54
+ // into groups and every group must appear in TLS_GROUP_PREFERENCE,
55
+ // otherwise the agent build refuses. The empty narrowing is a
56
+ // misconfig — TLS won't negotiate a key share — so reject too.
57
+ if (typeof opts.ecdhCurve === "string" && opts.ecdhCurve.length > 0) {
58
+ var requested = opts.ecdhCurve.split(":");
59
+ for (var rgi = 0; rgi < requested.length; rgi++) {
60
+ if (C.TLS_GROUP_PREFERENCE.indexOf(requested[rgi]) === -1) {
61
+ throw new TypeError(
62
+ "pqc-agent: opts.ecdhCurve='" + opts.ecdhCurve + "' includes '" +
63
+ requested[rgi] + "' which is not in the framework PQC-hybrid " +
64
+ "preference (" + C.TLS_GROUP_CURVE_STR + "); construct an " +
65
+ "https.Agent directly to negotiate weaker groups."
66
+ );
67
+ }
68
+ }
69
+ merged.ecdhCurve = requested.join(":");
70
+ } else {
71
+ merged.ecdhCurve = C.TLS_GROUP_CURVE_STR;
72
+ }
52
73
  merged.minVersion = "TLSv1.3";
53
74
  if (networkTls && typeof networkTls.applyToContext === "function") {
54
75
  merged = networkTls.applyToContext({ base: merged });
package/lib/pubsub.js CHANGED
@@ -379,16 +379,20 @@ function create(opts) {
379
379
  ? rv.remote : 1;
380
380
  } catch (e) {
381
381
  if (auditOn) {
382
- try { audit().safeEmit("system.pubsub.publish-failed", {
383
- channel: channel, error: (e && e.message) || String(e),
382
+ try { audit().safeEmit({
383
+ action: "system.pubsub.publish_failed",
384
+ outcome: "failure",
385
+ metadata: { channel: channel, error: (e && e.message) || String(e) },
384
386
  }); } catch (_e) { /* */ }
385
387
  }
386
388
  throw e;
387
389
  }
388
390
  }
389
391
  if (auditOn) {
390
- try { audit().safeEmit("system.pubsub.publish", {
391
- channel: channel, localDispatched: local, remoteWritten: remote,
392
+ try { audit().safeEmit({
393
+ action: "system.pubsub.publish",
394
+ outcome: "success",
395
+ metadata: { channel: channel, localDispatched: local, remoteWritten: remote },
392
396
  }); } catch (_e) { /* */ }
393
397
  }
394
398
  return { local: local, remote: remote };
package/lib/redact.js CHANGED
@@ -35,6 +35,14 @@ var SENSITIVE_FIELDS = [
35
35
  "card_number", "cardnumber", "cvc", "cvv", "pin",
36
36
  "privatekey", "private_key", "passphrase", "session", "sid",
37
37
  "_authtoken", "auth_token", "bearer", "cookie",
38
+ // Header-shaped variants of api-key — substring matching against a
39
+ // lowercased field name treats hyphen + underscore + dot as
40
+ // literal, so each header form needs its own entry.
41
+ "x-api-key", "x_api_key", "x-apikey", "api-key",
42
+ // DPoP / OAuth 2.1 / OIDC proof-of-possession + selective-disclosure
43
+ // fields — operator-error metadata logging often carries these.
44
+ "jwk", "dpop", "proof", "assertion", "client_assertion", "id_token_hint",
45
+ "code_verifier", "client_secret", "refresh_token", "access_token",
38
46
  // Vault-sealed values (don't log even though they're encrypted —
39
47
  // operational logs aren't a place to leak ciphertext shape either)
40
48
  // matched separately by value detector below
@@ -74,6 +82,20 @@ var VALUE_DETECTORS = [
74
82
  },
75
83
  replacement: "[REDACTED-JWT]",
76
84
  },
85
+ {
86
+ // URL with bearer-shaped query parameter — the parent field is
87
+ // typically `url` or `referer`, neither of which the field-name
88
+ // pass redacts. Replace the whole querystring after the marker
89
+ // so the path stays useful for log triage.
90
+ name: "url-bearer-query",
91
+ test: function (v) {
92
+ return typeof v === "string" &&
93
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]*[?&#](?:access_token|id_token|token|api_key|apikey)=/.test(v);
94
+ },
95
+ replacement: function (v) {
96
+ return String(v).replace(/(access_token|id_token|token|api_key|apikey)=[^&#]*/g, "$1=[REDACTED]");
97
+ },
98
+ },
77
99
  {
78
100
  name: "pem",
79
101
  test: function (v) { return typeof v === "string" && /-----BEGIN [A-Z ]+-----/.test(v); },
@@ -151,7 +173,10 @@ function _redactValue(value) {
151
173
  if (typeof value !== "string") return value;
152
174
  var allDetectors = VALUE_DETECTORS.concat(customDetectors);
153
175
  for (var i = 0; i < allDetectors.length; i++) {
154
- if (allDetectors[i].test(value)) return allDetectors[i].replacement;
176
+ if (allDetectors[i].test(value)) {
177
+ var rep = allDetectors[i].replacement;
178
+ return typeof rep === "function" ? rep(value) : rep;
179
+ }
155
180
  }
156
181
  return value;
157
182
  }
package/lib/retention.js CHANGED
@@ -58,6 +58,7 @@
58
58
  var C = require("./constants");
59
59
  var lazyRequire = require("./lazy-require");
60
60
  var validateOpts = require("./validate-opts");
61
+ var safeSql = require("./safe-sql");
61
62
  var { defineClass } = require("./framework-error");
62
63
 
63
64
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -66,6 +67,21 @@ var cryptoField = require("./crypto-field");
66
67
  var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
67
68
  var _err = RetentionError.factory;
68
69
 
70
+ // Identifier-level SQLi defense: every operator-supplied table name,
71
+ // column name, and cascade FK must pass safeSql.validateIdentifier
72
+ // before reaching SQL string concatenation. Without this gate a
73
+ // rule registered with `table: 'users"; DROP TABLE audit_log;--'`
74
+ // would break out of the quoted-identifier wrap and execute the
75
+ // embedded statement.
76
+ function _validateRuleIdentifier(value, label) {
77
+ try {
78
+ safeSql.validateIdentifier(value, { allowReserved: true });
79
+ } catch (e) {
80
+ throw _err("BAD_RULE",
81
+ label + " is not a safe SQL identifier: " + (e && e.message || String(e)));
82
+ }
83
+ }
84
+
69
85
  function _validateRule(rule) {
70
86
  if (!rule || typeof rule !== "object") {
71
87
  throw _err("BAD_RULE", "rule must be an object");
@@ -76,9 +92,11 @@ function _validateRule(rule) {
76
92
  if (typeof rule.table !== "string" || rule.table.length === 0) {
77
93
  throw _err("BAD_RULE", "rule.table (string) is required");
78
94
  }
95
+ _validateRuleIdentifier(rule.table, "rule.table");
79
96
  if (typeof rule.ageField !== "string" || rule.ageField.length === 0) {
80
97
  throw _err("BAD_RULE", "rule.ageField (string) is required");
81
98
  }
99
+ _validateRuleIdentifier(rule.ageField, "rule.ageField");
82
100
  if (typeof rule.ttlMs !== "number" || !isFinite(rule.ttlMs) || rule.ttlMs <= 0) {
83
101
  throw _err("BAD_RULE", "rule.ttlMs must be a positive finite number");
84
102
  }
@@ -99,10 +117,16 @@ function _validateRule(rule) {
99
117
  (typeof rule.softDeleteField !== "string" || rule.softDeleteField.length === 0)) {
100
118
  throw _err("BAD_RULE", "rule.softDeleteField must be a non-empty string");
101
119
  }
120
+ if (rule.softDeleteField !== undefined) {
121
+ _validateRuleIdentifier(rule.softDeleteField, "rule.softDeleteField");
122
+ }
102
123
  if (rule.legalHoldField !== undefined &&
103
124
  (typeof rule.legalHoldField !== "string" || rule.legalHoldField.length === 0)) {
104
125
  throw _err("BAD_RULE", "rule.legalHoldField must be a non-empty string");
105
126
  }
127
+ if (rule.legalHoldField !== undefined) {
128
+ _validateRuleIdentifier(rule.legalHoldField, "rule.legalHoldField");
129
+ }
106
130
  if (rule.cascade !== undefined) {
107
131
  if (!Array.isArray(rule.cascade) || rule.cascade.length === 0) {
108
132
  throw _err("BAD_RULE", "rule.cascade must be a non-empty array of { table, foreignKey } entries");
@@ -113,6 +137,8 @@ function _validateRule(rule) {
113
137
  typeof c.foreignKey !== "string" || c.foreignKey.length === 0) {
114
138
  throw _err("BAD_RULE", "rule.cascade[" + ci + "] must be { table: string, foreignKey: string }");
115
139
  }
140
+ _validateRuleIdentifier(c.table, "rule.cascade[" + ci + "].table");
141
+ _validateRuleIdentifier(c.foreignKey, "rule.cascade[" + ci + "].foreignKey");
116
142
  }
117
143
  }
118
144
  if (rule.stages !== undefined) {
package/lib/router.js CHANGED
@@ -651,6 +651,7 @@ class Router {
651
651
  maxHeaderListPairs: 100, // allow:raw-byte-literal — CVE-2024-27983 CONTINUATION-flood cap
652
652
  maxSettings: 32, // allow:raw-byte-literal — SETTINGS-frame entry ceiling
653
653
  peerMaxConcurrentStreams: 100, // allow:raw-byte-literal — peer-side stream cap
654
+ maxOutstandingPings: 10, // allow:raw-byte-literal — CVE-2019-9512 ping-flood cap (pin to Node default rather than letting it drift)
654
655
  unknownProtocolTimeout: C.TIME.seconds(10),
655
656
  }, tlsOptions), requestHandler);
656
657
  } else {
package/lib/scheduler.js CHANGED
@@ -682,8 +682,63 @@ function create(opts) {
682
682
  started = false;
683
683
  }
684
684
 
685
- return {
685
+ // Shorthand for the common interval-based registration shape:
686
+ // register("rotate-keys", C.TIME.minutes(5), runFn)
687
+ // is equivalent to schedule({ name, every: 300000, run: runFn }).
688
+ // Operators wanting cron expressions or job-queue dispatch keep
689
+ // using schedule() — register() is the every-N-ms direct-function
690
+ // path. Returns the scheduler instance for method chaining.
691
+ function register(name, intervalMs, fn) {
692
+ if (typeof name !== "string" || name.length === 0) {
693
+ throw _err("INVALID_NAME", "scheduler.register: name must be a non-empty string", true);
694
+ }
695
+ if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs < C.TIME.seconds(1)) {
696
+ throw _err("INVALID_SPEC",
697
+ "scheduler.register: intervalMs must be a finite number ≥ 1000", true);
698
+ }
699
+ if (typeof fn !== "function") {
700
+ throw _err("INVALID_SPEC", "scheduler.register: fn must be a function", true);
701
+ }
702
+ schedule({ name: name, every: intervalMs, run: fn });
703
+ return facade;
704
+ }
705
+
706
+ // Operator-facing health surface — every task with its lifecycle
707
+ // counters plus an aggregate. Probes / dashboards / readiness gates
708
+ // get a single object they can serialize. This is `list()` plus
709
+ // started state and aggregate stats.
710
+ function getStatus() {
711
+ var taskList = list();
712
+ var aggregate = {
713
+ total: taskList.length,
714
+ running: 0,
715
+ withErrors: 0,
716
+ totalFires: 0,
717
+ totalMisses: 0,
718
+ nonLeaderSkips: 0,
719
+ tickClaimLost: 0,
720
+ };
721
+ for (var i = 0; i < taskList.length; i++) {
722
+ var t = taskList[i];
723
+ if (t.running) aggregate.running += 1;
724
+ if (t.lastError) aggregate.withErrors += 1;
725
+ aggregate.totalFires += t.fires || 0;
726
+ aggregate.totalMisses += t.misses || 0;
727
+ aggregate.nonLeaderSkips += t.nonLeaderSkips || 0;
728
+ aggregate.tickClaimLost += t.tickClaimLost || 0;
729
+ }
730
+ return {
731
+ started: started,
732
+ isLeader: _isLeaderHere(),
733
+ tasks: taskList,
734
+ aggregate: aggregate,
735
+ };
736
+ }
737
+
738
+ var facade = {
686
739
  schedule: schedule,
740
+ register: register,
741
+ getStatus: getStatus,
687
742
  start: start,
688
743
  stop: stop,
689
744
  list: list,
@@ -695,6 +750,7 @@ function create(opts) {
695
750
  },
696
751
  _resetForTest: _resetForTest,
697
752
  };
753
+ return facade;
698
754
  }
699
755
 
700
756
  module.exports = {
package/lib/session.js CHANGED
@@ -238,7 +238,7 @@ async function verify(token, verifyOpts) {
238
238
  if ((nowMs - lastActivity) > idleMs) {
239
239
  try {
240
240
  audit.safeEmit({
241
- action: "auth.session.expired_idle", outcome: "warning",
241
+ action: "auth.session.expired_idle", outcome: "success",
242
242
  metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
243
243
  });
244
244
  } catch (_ignored) { /* audit best-effort */ }
@@ -253,7 +253,7 @@ async function verify(token, verifyOpts) {
253
253
  if ((nowMs - createdAt) > absMs) {
254
254
  try {
255
255
  audit.safeEmit({
256
- action: "auth.session.expired_absolute", outcome: "warning",
256
+ action: "auth.session.expired_absolute", outcome: "success",
257
257
  metadata: { ageMs: nowMs - createdAt, threshold: absMs },
258
258
  });
259
259
  } catch (_ignored) { /* audit best-effort */ }
@@ -334,7 +334,7 @@ async function verify(token, verifyOpts) {
334
334
  try {
335
335
  audit.safeEmit({
336
336
  action: "auth.session.fingerprint_drift",
337
- outcome: "warning",
337
+ outcome: "success",
338
338
  metadata: { hasUserId: !!unsealed.userId,
339
339
  anomalyScore: fingerprintAnomalyScore },
340
340
  });
package/lib/ssrf-guard.js CHANGED
@@ -140,10 +140,25 @@ var CLOUD_METADATA_IPS = [
140
140
 
141
141
  function _ipv4ToInt(ip) {
142
142
  var parts = ip.split(".");
143
- return ((parts[0] | 0) << 24 >>> 0) +
144
- ((parts[1] | 0) << 16) +
145
- ((parts[2] | 0) << 8) +
146
- (parts[3] | 0);
143
+ if (parts.length !== 4) return NaN;
144
+ var nums = [0, 0, 0, 0];
145
+ for (var i = 0; i < 4; i += 1) {
146
+ var s = parts[i];
147
+ // Strict octet validation: each segment must be 1-3 ASCII digits
148
+ // representing 0-255. The previous `parts[i] | 0` coerced
149
+ // anything non-numeric to 0 silently — exposed via cidrContains
150
+ // (network-allowlist) where a typo'd CIDR could collapse to
151
+ // 0.0.0.0/16 with no signal.
152
+ if (typeof s !== "string" || s.length === 0 || s.length > 3) return NaN;
153
+ if (!/^\d{1,3}$/.test(s)) return NaN;
154
+ var n = parseInt(s, 10);
155
+ if (n < 0 || n > 255) return NaN;
156
+ nums[i] = n;
157
+ }
158
+ return ((nums[0] << 24) >>> 0) +
159
+ (nums[1] << 16) +
160
+ (nums[2] << 8) +
161
+ nums[3];
147
162
  }
148
163
 
149
164
  function _ipv6ToBytes(ip) {