@blamejs/blamejs-shop 0.3.11 → 0.3.13

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 (94) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +157 -117
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/currency-rounding.js +2 -14
  5. package/lib/index.js +1 -0
  6. package/lib/text-guard.js +227 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  9. package/lib/vendor/blamejs/README.md +3 -2
  10. package/lib/vendor/blamejs/SECURITY.md +3 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +14 -2
  12. package/lib/vendor/blamejs/lib/agent-event-bus.js +4 -4
  13. package/lib/vendor/blamejs/lib/agent-idempotency.js +6 -6
  14. package/lib/vendor/blamejs/lib/agent-orchestrator.js +9 -9
  15. package/lib/vendor/blamejs/lib/agent-posture-chain.js +10 -10
  16. package/lib/vendor/blamejs/lib/agent-saga.js +6 -7
  17. package/lib/vendor/blamejs/lib/agent-snapshot.js +8 -8
  18. package/lib/vendor/blamejs/lib/agent-stream.js +3 -3
  19. package/lib/vendor/blamejs/lib/agent-tenant.js +4 -4
  20. package/lib/vendor/blamejs/lib/agent-trace.js +5 -5
  21. package/lib/vendor/blamejs/lib/ai-disclosure.js +3 -3
  22. package/lib/vendor/blamejs/lib/app.js +2 -2
  23. package/lib/vendor/blamejs/lib/archive-read.js +1 -1
  24. package/lib/vendor/blamejs/lib/archive-tar-read.js +1 -1
  25. package/lib/vendor/blamejs/lib/archive-wrap.js +5 -5
  26. package/lib/vendor/blamejs/lib/audit-tools.js +65 -5
  27. package/lib/vendor/blamejs/lib/audit.js +2 -2
  28. package/lib/vendor/blamejs/lib/auth/ciba.js +1 -1
  29. package/lib/vendor/blamejs/lib/auth/dpop.js +1 -1
  30. package/lib/vendor/blamejs/lib/auth/fal.js +1 -1
  31. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +2 -3
  32. package/lib/vendor/blamejs/lib/auth/jwt-external.js +2 -2
  33. package/lib/vendor/blamejs/lib/auth/oauth.js +9 -9
  34. package/lib/vendor/blamejs/lib/auth/oid4vci.js +7 -7
  35. package/lib/vendor/blamejs/lib/auth/oid4vp.js +1 -1
  36. package/lib/vendor/blamejs/lib/auth/openid-federation.js +5 -5
  37. package/lib/vendor/blamejs/lib/auth/passkey.js +6 -6
  38. package/lib/vendor/blamejs/lib/auth/saml.js +1 -1
  39. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -6
  40. package/lib/vendor/blamejs/lib/backup/index.js +18 -18
  41. package/lib/vendor/blamejs/lib/cache.js +4 -4
  42. package/lib/vendor/blamejs/lib/calendar.js +5 -5
  43. package/lib/vendor/blamejs/lib/circuit-breaker.js +1 -1
  44. package/lib/vendor/blamejs/lib/cms-codec.js +2 -2
  45. package/lib/vendor/blamejs/lib/compliance.js +14 -14
  46. package/lib/vendor/blamejs/lib/crypto-field.js +58 -21
  47. package/lib/vendor/blamejs/lib/crypto.js +5 -6
  48. package/lib/vendor/blamejs/lib/db-query.js +131 -9
  49. package/lib/vendor/blamejs/lib/db.js +106 -22
  50. package/lib/vendor/blamejs/lib/external-db.js +64 -16
  51. package/lib/vendor/blamejs/lib/framework-schema.js +4 -4
  52. package/lib/vendor/blamejs/lib/guard-list-id.js +2 -2
  53. package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -2
  54. package/lib/vendor/blamejs/lib/incident-report.js +150 -0
  55. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
  56. package/lib/vendor/blamejs/lib/mail-deploy.js +3 -3
  57. package/lib/vendor/blamejs/lib/mail-server-managesieve.js +2 -2
  58. package/lib/vendor/blamejs/lib/mail-server-pop3.js +2 -2
  59. package/lib/vendor/blamejs/lib/mail-store.js +1 -1
  60. package/lib/vendor/blamejs/lib/metrics.js +8 -8
  61. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +1 -1
  62. package/lib/vendor/blamejs/lib/middleware/dpop.js +5 -5
  63. package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +21 -22
  64. package/lib/vendor/blamejs/lib/middleware/protected-resource-metadata.js +2 -2
  65. package/lib/vendor/blamejs/lib/network-dns-resolver.js +2 -2
  66. package/lib/vendor/blamejs/lib/network-dns.js +1 -2
  67. package/lib/vendor/blamejs/lib/network-tls.js +0 -1
  68. package/lib/vendor/blamejs/lib/outbox.js +1 -1
  69. package/lib/vendor/blamejs/lib/pqc-agent.js +1 -1
  70. package/lib/vendor/blamejs/lib/retention.js +1 -1
  71. package/lib/vendor/blamejs/lib/retry.js +1 -1
  72. package/lib/vendor/blamejs/lib/safe-archive.js +2 -2
  73. package/lib/vendor/blamejs/lib/safe-ical.js +2 -2
  74. package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
  75. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +1 -1
  76. package/lib/vendor/blamejs/lib/self-update.js +2 -2
  77. package/lib/vendor/blamejs/lib/static.js +1 -1
  78. package/lib/vendor/blamejs/lib/subject.js +2 -2
  79. package/lib/vendor/blamejs/lib/vault/index.js +64 -1
  80. package/lib/vendor/blamejs/lib/vault/rotate.js +19 -0
  81. package/lib/vendor/blamejs/lib/vendor-data.js +1 -1
  82. package/lib/vendor/blamejs/package.json +1 -1
  83. package/lib/vendor/blamejs/release-notes/v0.14.7.json +77 -0
  84. package/lib/vendor/blamejs/scripts/release.js +28 -3
  85. package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-dual-control.test.js +115 -0
  86. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +163 -3
  87. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +102 -0
  88. package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +150 -0
  89. package/lib/vendor/blamejs/test/layer-0-primitives/db-key-aad.test.js +109 -0
  90. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +53 -0
  91. package/lib/vendor/blamejs/test/layer-0-primitives/incident-report.test.js +65 -0
  92. package/lib/webhook-subscriptions.js +11 -24
  93. package/lib/webhooks.js +12 -33
  94. package/package.json +1 -1
@@ -70,6 +70,7 @@ var safeJson = require("./safe-json");
70
70
  var safeSql = require("./safe-sql");
71
71
  var validateOpts = require("./validate-opts");
72
72
  var vault = require("./vault");
73
+ var vaultAad = require("./vault-aad");
73
74
 
74
75
  var DbError = defineClass("DbError", { alwaysPermanent: true });
75
76
  var WormViolationError = require("./framework-error").WormViolationError;
@@ -153,12 +154,18 @@ var initialized = false;
153
154
  var dataResidency = null; // operator's declared region config (validated by storage backends)
154
155
  var subjectTables = []; // [{ name, subjectField, personalDataCategories }] — for subject.export/erase
155
156
  var tableMetadata = {}; // table name → metadata snapshot (PK/FK/sealed/derived) for getTableMetadata
156
- // D-M5 — streamLimit ceiling. db.stream() / Query.stream() consult this
157
+ // StreamLimit ceiling. db.stream() / Query.stream() consult this
157
158
  // (overridden per-call via opts.streamLimit). Default cap matches a
158
159
  // generous-but-bounded 1M rows so an accidentally-unbounded export
159
160
  // surfaces a thrown error instead of OOM. v0.7.67's maxRowsPerQuery
160
161
  // bounds .all() / .first() — this is its streaming counterpart.
161
162
  var streamLimit = C.BYTES.bytes(1000000); // row-count ceiling, not bytes
163
+ // Column-membership gate mode, set by db.init({ columnGate }). Default
164
+ // "reject" (security-on): a query that orders / selects / filters on a
165
+ // column that is not declared in the table's schema is refused before
166
+ // the identifier interpolates into SQL. "warn" audits + allows; "off"
167
+ // disables the gate.
168
+ var columnGateMode = "reject";
162
169
 
163
170
  // ---- Framework-baked tables ----
164
171
  //
@@ -287,7 +294,7 @@ var FRAMEWORK_SCHEMA = [
287
294
  indexes: ["placedAt"],
288
295
  },
289
296
  {
290
- // Per-row crypto-erasure key registry — F-RTBF-3 per-row keys.
297
+ // Per-row crypto-erasure key registry — per-row keys.
291
298
  // Each entry holds a sealed wrapped K_row keyed by (table,
292
299
  // rowId). b.subject.eraseHard deletes the entry, leaving WAL /
293
300
  // replica residuals undecryptable.
@@ -614,8 +621,8 @@ var FRAMEWORK_SCHEMA = [
614
621
  {
615
622
  // _blamejs_break_glass_grants — issued grants. Each successful
616
623
  // step-up creates one row; each row read decrements rowsRemaining.
617
- // Default maxRowsPerGrant=1 enforces "row by row" auth per the
618
- // operator-confirmed shape (each row access = its own grant).
624
+ // Default maxRowsPerGrant=1 enforces "row by row" auth
625
+ // (each row access = its own grant).
619
626
  // Sealed columns hold reason + scopeColumns so audit-readable
620
627
  // metadata doesn't leak in cleartext.
621
628
  name: "_blamejs_break_glass_grants",
@@ -661,25 +668,58 @@ function resolveTmpDir(optsTmpDir) {
661
668
 
662
669
  // ---- DB encryption key management ----
663
670
 
671
+ // AAD binds the sealed DB encryption key to the deployment's dataDir
672
+ // + key-file path, so a key file substituted from another deployment
673
+ // fails the AEAD tag check on unseal (cross-deployment ciphertext
674
+ // substitution / silent re-key — CWE-345 / CWE-441; the AAD itself is
675
+ // NIST SP 800-38D additional-authenticated-data over the XChaCha20-
676
+ // Poly1305 seal). nodePath.resolve (not realpathSync) — the key file
677
+ // may not exist yet at first-run seal.
678
+ function _dbKeyAad(dataDirPath, keyPath) {
679
+ return vaultAad.buildContextAad({
680
+ purpose: "blamejs/db-encryption-key/v1",
681
+ dataDir: nodePath.resolve(dataDirPath),
682
+ keyPath: nodePath.resolve(keyPath),
683
+ });
684
+ }
685
+
664
686
  function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
665
687
  // Operator opt: `opts.dbKeyPath` — useful when the encryption key
666
688
  // needs to live outside `dataDir` (e.g. a separate volume mounted
667
689
  // from a KMS-fronted secret store). Default places it next to the
668
690
  // encrypted DB so backup capture is one-tarball.
669
691
  var keyPath = keyPathOverride || nodePath.join(dataDirPath, "db.key.enc");
692
+ var aad = _dbKeyAad(dataDirPath, keyPath);
670
693
  if (nodeFs.existsSync(keyPath)) {
671
694
  var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
672
- var b64 = vault.unseal(sealed);
695
+ var b64;
696
+ // isAadSealed is checked FIRST and is load-bearing: AAD_PREFIX
697
+ // ("vault.aad:") is NOT a prefix of VAULT_PREFIX ("vault:"), so a
698
+ // plain vault.unseal would silently pass an AAD-sealed value
699
+ // through unchanged. AAD-bound keys verify the deployment context;
700
+ // a key file lifted from another deployment fails the tag check.
701
+ if (vaultAad.isAadSealed(sealed)) {
702
+ b64 = vaultAad.unseal(sealed, aad);
703
+ } else {
704
+ // Legacy plain-sealed key (pre-AAD): unseal with the classic
705
+ // path, then re-seal in place with the deployment-path binding so
706
+ // the next boot is AAD-verified. Read-migration preserves the key
707
+ // bytes — no re-key, no operator action.
708
+ b64 = vault.unseal(sealed);
709
+ if (b64) {
710
+ atomicFile.writeSync(keyPath, vaultAad.seal(b64, aad), { fileMode: 0o600 });
711
+ log("re-sealed DB encryption key with deployment-path binding at " + keyPath);
712
+ }
713
+ }
673
714
  if (!b64) {
674
715
  throw _dbErr("db/key-unseal-empty",
675
716
  "FATAL: db.key.enc unseal returned empty — vault may not be initialized or key file corrupted");
676
717
  }
677
718
  return Buffer.from(b64, "base64");
678
719
  }
679
- // First run — generate, seal, persist (atomic)
720
+ // First run — generate, AAD-seal, persist (atomic).
680
721
  var raw = generateBytes(C.BYTES.bytes(32));
681
- // allow:seal-without-aad whole-file DB encryption key, not a row column
682
- var sealedKey = vault.seal(raw.toString("base64"));
722
+ var sealedKey = vaultAad.seal(raw.toString("base64"), aad);
683
723
  atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
684
724
  log("generated DB encryption key at " + keyPath);
685
725
  return raw;
@@ -918,6 +958,7 @@ function cleanStaleTmpDbs(tmpDir) {
918
958
  * tmpDir: string, // override the encrypted-mode tmpfs path (default /dev/shm or BLAMEJS_TMPDIR)
919
959
  * migrationDir: string, // optional — path to ./migrations/ (run-once each)
920
960
  * streamLimit: number, // default 1_000_000 — db.stream row ceiling
961
+ * columnGate: "reject"|"warn"|"off", // default "reject" — refuse queries on columns not declared in the table schema
921
962
  * skipBootIntegrityCheck: boolean, // default false — skip PRAGMA integrity_check
922
963
  * skipIntegrityCheck: boolean, // default false — alias
923
964
  * auditSigning: { mode, algorithm }, // default { mode: "wrapped" }
@@ -966,7 +1007,7 @@ async function init(opts) {
966
1007
  throw new DbError("db/bad-at-rest",
967
1008
  "db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
968
1009
  }
969
- // D-M5 — operator-tunable streamLimit ceiling. Throw at config-time
1010
+ // Operator-tunable streamLimit ceiling. Throw at config-time
970
1011
  // on bad shape so a typo surfaces at boot rather than as an
971
1012
  // unbounded stream at first export.
972
1013
  if (opts.streamLimit !== undefined) {
@@ -978,6 +1019,15 @@ async function init(opts) {
978
1019
  }
979
1020
  streamLimit = opts.streamLimit;
980
1021
  }
1022
+ // Column-membership gate mode — throw at config-time on a typo so it
1023
+ // surfaces at boot, not as a query that silently bypasses the gate.
1024
+ if (opts.columnGate !== undefined &&
1025
+ opts.columnGate !== "reject" && opts.columnGate !== "warn" && opts.columnGate !== "off") {
1026
+ throw new DbError("db/bad-init",
1027
+ "db.init: columnGate must be 'reject' (default), 'warn', or 'off'; got " +
1028
+ JSON.stringify(opts.columnGate));
1029
+ }
1030
+ columnGateMode = opts.columnGate || "reject";
981
1031
  dataDir = opts.dataDir;
982
1032
  if (!nodeFs.existsSync(dataDir)) nodeFs.mkdirSync(dataDir, { recursive: true });
983
1033
 
@@ -990,7 +1040,7 @@ async function init(opts) {
990
1040
  }
991
1041
  if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true });
992
1042
 
993
- // D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
1043
+ // If the resolved tmpDir is NOT actually tmpfs, the
994
1044
  // plaintext DB file lives on persistent storage. We check that tmpDir
995
1045
  // resolves under /dev/shm or /run/shm on Linux as a heuristic; on other
996
1046
  // platforms we warn that the operator must verify tmpfs binding
@@ -1264,9 +1314,10 @@ async function init(opts) {
1264
1314
  for (var i = 0; i < fullSchema.length; i++) {
1265
1315
  var t = fullSchema[i];
1266
1316
  cryptoField.registerTable(t.name, {
1267
- sealedFields: t.sealedFields,
1268
- derivedHashes: t.derivedHashes,
1269
- hashNamespaces: t.hashNamespaces,
1317
+ sealedFields: t.sealedFields,
1318
+ derivedHashes: t.derivedHashes,
1319
+ hashNamespaces: t.hashNamespaces,
1320
+ derivedHashMode: t.derivedHashMode,
1270
1321
  });
1271
1322
  tableMetadata[t.name] = {
1272
1323
  primaryKey: _normalizePk(t),
@@ -1349,7 +1400,7 @@ async function init(opts) {
1349
1400
  // is BELOW tip — the DB was rolled back to an older snapshot. Refuse boot.
1350
1401
  _checkRollback(dataDir);
1351
1402
 
1352
- // ---- F-RET-2 — WORM posture assertion ----
1403
+ // ---- WORM posture assertion ----
1353
1404
  // Under sec-17a-4 / finra-4511 / fda-21cfr11 postures the operator
1354
1405
  // MUST have declared row-level WORM on at least one business-record
1355
1406
  // table. Refuse boot otherwise so missing-declaration drift is
@@ -1491,10 +1542,41 @@ async function init(opts) {
1491
1542
  */
1492
1543
  function from(tableName) {
1493
1544
  _requireInit();
1494
- return new Query(database, tableName);
1545
+ return new Query(database, tableName, {
1546
+ declaredColumns: getDeclaredColumns(tableName),
1547
+ columnGateMode: columnGateMode,
1548
+ });
1549
+ }
1550
+
1551
+ /**
1552
+ * @primitive b.db.getDeclaredColumns
1553
+ * @signature b.db.getDeclaredColumns(tableName)
1554
+ * @since 0.14.7
1555
+ * @status stable
1556
+ * @related b.db.from, b.db.getTableMetadata
1557
+ *
1558
+ * Returns the declared column names for a table as an array, or `null`
1559
+ * when the table has no registered schema metadata (a cross- or
1560
+ * attached-schema table — the column-membership gate is a no-op for
1561
+ * those). The declared set includes `_id` and any derived-hash columns,
1562
+ * so sealed-field queries (which rewrite to the hash column) and `_id`
1563
+ * lookups pass the gate. Backs the `db.init({ columnGate })` gate that
1564
+ * refuses queries ordering / selecting / filtering on an undeclared
1565
+ * column before the identifier interpolates into SQL.
1566
+ *
1567
+ * @example
1568
+ * b.db.getDeclaredColumns("orders");
1569
+ * // → ["_id", "customerId", "total", "createdAt"]
1570
+ */
1571
+ // The declared set includes `_id` and any derived-hash columns, so
1572
+ // sealed-field queries (which rewrite to the hash column) and `_id`
1573
+ // lookups pass membership.
1574
+ function getDeclaredColumns(tableName) {
1575
+ var md = tableMetadata[tableName];
1576
+ return (md && md.columns) ? Object.keys(md.columns) : null;
1495
1577
  }
1496
1578
 
1497
- // D-M6 — bounded prepared-statement cache for SQLite. Long-running
1579
+ // Bounded prepared-statement cache for SQLite. Long-running
1498
1580
  // daemons with diverse query shapes accumulate node:sqlite Statement
1499
1581
  // handles indefinitely; the LRU here caps at PREPARE_CACHE_MAX (256)
1500
1582
  // distinct SQL strings and finalizes the oldest when over. Reuse of
@@ -1611,7 +1693,7 @@ function stream(sql) {
1611
1693
  var table = opts && typeof opts.table === "string" ? opts.table : null;
1612
1694
  var unseal = table ? cryptoField : null;
1613
1695
 
1614
- // D-M5 — streamLimit ceiling. Per-call opts.streamLimit overrides
1696
+ // StreamLimit ceiling. Per-call opts.streamLimit overrides
1615
1697
  // the module-level default; bad shape throws at call time so the
1616
1698
  // typo surfaces instead of an unbounded stream.
1617
1699
  var perCallLimit = streamLimit;
@@ -1661,10 +1743,10 @@ function stream(sql) {
1661
1743
 
1662
1744
  // DDL_RE — case-insensitive prefix match for the eight statement
1663
1745
  // shapes that MUTATE schema. Audited individually so a forensic
1664
- // review can reconstruct schema evolution from the chain alone (D-M1).
1746
+ // review can reconstruct schema evolution from the chain alone.
1665
1747
  var DDL_RE = /^\s*(CREATE|DROP|ALTER|TRUNCATE|RENAME|ATTACH|DETACH|REINDEX)\b/i;
1666
1748
 
1667
- // D-L7 — slow-query observability buckets for the local SQLite nodePath.
1749
+ // Slow-query observability buckets for the local SQLite nodePath.
1668
1750
  // Highest matched bucket wins so the per-query emit is single-shot;
1669
1751
  // operators dashboard on the `bucket` label.
1670
1752
  var _SLOW_QUERY_BUCKETS_LOCAL = Object.freeze([
@@ -1712,7 +1794,7 @@ function execRaw(sql) {
1712
1794
  action: "db.ddl.executed",
1713
1795
  outcome: "success",
1714
1796
  metadata: {
1715
- // OTel db.* semconv (F-RFC-4) — emit framework-conventional
1797
+ // OTel db.* semconv — emit framework-conventional
1716
1798
  // attributes alongside the audit row so dashboards built on
1717
1799
  // OTel can correlate without an adapter.
1718
1800
  "db.system": "sqlite",
@@ -2701,7 +2783,7 @@ function vacuumAfterErase(opts) {
2701
2783
  } catch (_e) { /* audit best-effort */ }
2702
2784
  }
2703
2785
 
2704
- // F-POSTURE-1 — cascade-installed posture name. b.compliance.set(p)
2786
+ // Cascade-installed posture name. b.compliance.set(p)
2705
2787
  // calls applyPosture(p) which records the posture; the downstream
2706
2788
  // cryptoField.eraseRow path consults this via getActivePosture() to
2707
2789
  // auto-vacuum under postures whose POSTURE_DEFAULTS sets
@@ -3054,10 +3136,12 @@ module.exports = {
3054
3136
  getActivePosture: getActivePosture,
3055
3137
  vacuumAfterErase: vacuumAfterErase,
3056
3138
  from: from,
3139
+ getDeclaredColumns: getDeclaredColumns,
3140
+ _checkDualControlGate: _checkDualControlGate,
3057
3141
  collection: require("./db-collection").collection, // allow:inline-require — db-collection lazy-requires db.js back; the inline require here breaks the cycle without needing a stub
3058
3142
  prepare: prepare,
3059
3143
  stream: stream,
3060
- // D-M5 — runtime read-only accessor so Query.stream picks up the
3144
+ // Runtime read-only accessor so Query.stream picks up the
3061
3145
  // configured ceiling without re-importing module state.
3062
3146
  getStreamLimit: function () { return streamLimit; },
3063
3147
  runSql: execRaw,
@@ -57,7 +57,7 @@ function _emitMetric(name, value, labels) {
57
57
  catch (_e) { /* hot-path observability sink — drop silent by design */ }
58
58
  }
59
59
 
60
- // Statement-class classifier for auth-failure forensics (D-M2). Inspects
60
+ // Statement-class classifier for auth-failure forensics. Inspects
61
61
  // the leading keyword only so an attacker-controlled trailing fragment
62
62
  // can't smuggle a false classification. Skips leading whitespace plus
63
63
  // SQL line / block comments before reading the keyword.
@@ -83,8 +83,49 @@ function _classifyStatement(sql) {
83
83
  return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
84
84
  }
85
85
 
86
+ // Best-effort target-relation extractor for auth-failure forensics: the
87
+ // table the denied role attempted to touch, so the audit row records
88
+ // the OBJECT (SOC2 CC7.2 / NIST SP 800-53 AU-3 "what was accessed"),
89
+ // not just the statement class. Defensive reader — returns null on
90
+ // anything unparseable and NEVER throws: it runs in the live failure
91
+ // path and must not mask the real 28000 / 42501 error. The extracted
92
+ // identifier is captured into audit METADATA (a JSON string, never
93
+ // re-executed as SQL), so the only sink risk is log-injection: any
94
+ // segment carrying a control / NUL character is refused. Spaces and
95
+ // ordinary punctuation inside a quoted identifier are kept so a
96
+ // legitimately-quoted relation name still surfaces in the audit row.
97
+ var _RELATION_RE = /\b(?:FROM|INTO|UPDATE|JOIN|TABLE|COPY)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)/ig;
98
+ function _hasControlChar(s) {
99
+ for (var i = 0; i < s.length; i += 1) {
100
+ var c = s.charCodeAt(i);
101
+ if (c < 0x20 || c === 0x7f) return true;
102
+ }
103
+ return false;
104
+ }
105
+ function _extractTargetRelation(sql) {
106
+ if (typeof sql !== "string" || sql.length === 0) return null;
107
+ var clean = sql.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ");
108
+ _RELATION_RE.lastIndex = 0;
109
+ var m = _RELATION_RE.exec(clean);
110
+ if (!m) return null;
111
+ var segs = m[1].split(".").map(function (s) { return s.replace(/^["`]|["`]$/g, ""); });
112
+ for (var i = 0; i < segs.length; i += 1) {
113
+ if (segs[i].length === 0 || _hasControlChar(segs[i])) return null;
114
+ }
115
+ return segs.join(".");
116
+ }
117
+
118
+ function _countTargetRelations(sql) {
119
+ if (typeof sql !== "string") return 0;
120
+ var clean = sql.replace(/--[^\n]*/g, " ").replace(/\/\*[\s\S]*?\*\//g, " ");
121
+ _RELATION_RE.lastIndex = 0;
122
+ var n = 0;
123
+ while (_RELATION_RE.exec(clean) !== null) n += 1;
124
+ return n;
125
+ }
126
+
86
127
  // Postgres SQLSTATE classes that indicate authentication / authorization
87
- // failure at the DB level. SOC2 forensic gap (D-M2) — every match emits
128
+ // failure at the DB level. SOC2 forensic gap — every match emits
88
129
  // db.auth.failed with the SQL identity attempted, the database, and
89
130
  // the statement class.
90
131
  var _AUTH_FAILURE_CODES = Object.freeze({
@@ -97,18 +138,24 @@ function _emitAuthFailureAudit(backend, role, sql, e) {
97
138
  if (!e || !e.code) return;
98
139
  var kind = _AUTH_FAILURE_CODES[e.code];
99
140
  if (!kind) return;
141
+ var attemptedTable = _extractTargetRelation(sql);
142
+ var relationCount = _countTargetRelations(sql);
143
+ var resource = { kind: "db.backend", id: backend.name };
144
+ if (attemptedTable !== null) resource.attemptedTable = attemptedTable;
100
145
  audit().safeEmit({
101
146
  action: "db.auth.failed",
102
147
  actor: {},
103
- resource: { kind: "db.backend", id: backend.name },
148
+ resource: resource,
104
149
  outcome: "denied",
105
150
  reason: kind,
106
151
  metadata: {
107
- backend: backend.name,
108
- dialect: backend.dialect,
109
- sqlIdentity: role || null,
110
- sqlstate: e.code,
111
- statementClass: _classifyStatement(sql),
152
+ backend: backend.name,
153
+ dialect: backend.dialect,
154
+ sqlIdentity: role || null,
155
+ sqlstate: e.code,
156
+ statementClass: _classifyStatement(sql),
157
+ attemptedTable: attemptedTable,
158
+ attemptedRelationCount: relationCount,
112
159
  },
113
160
  });
114
161
  _emitMetric("db.auth.failed", 1, {
@@ -118,7 +165,7 @@ function _emitAuthFailureAudit(backend, role, sql, e) {
118
165
  });
119
166
  }
120
167
 
121
- // Slow-query bucket emitter (D-L7). Single-shot per query — highest
168
+ // Slow-query bucket emitter. Single-shot per query — highest
122
169
  // matched bucket wins. Operators dashboard on the `bucket` label
123
170
  // rather than separate counters per threshold.
124
171
  var _SLOW_QUERY_BUCKETS = Object.freeze([
@@ -628,7 +675,7 @@ async function query(sql, params, opts) {
628
675
  _emitMetric("db.role.denied", 1,
629
676
  { backend: b.name, role: role || "(none)" });
630
677
  }
631
- // D-M2 — DB-auth audit visibility. Every 28000 / 28P01 / 42501
678
+ // DB-auth audit visibility. Every 28000 / 28P01 / 42501
632
679
  // surfaces an auditable db.auth.failed row tagged with the SQL
633
680
  // identity and the statement class so SOC2 reviewers can
634
681
  // reconstruct the denial timeline.
@@ -693,13 +740,13 @@ async function transaction(fn, opts) {
693
740
  var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
694
741
 
695
742
  var t0 = Date.now();
696
- // D-H4 — per-statement timeout. SET LOCAL statement_timeout binds
697
- // the query-cancel ceiling to this transaction; D-M7 wires
743
+ // Per-statement timeout. SET LOCAL statement_timeout binds
744
+ // the query-cancel ceiling to this transaction; this wires
698
745
  // idle_in_transaction_session_timeout from the same opt. Both
699
746
  // emit at SET LOCAL scope so the next pool checkout starts clean.
700
747
  var stmtTimeoutMs = opts.statementTimeoutMs;
701
748
  var idleTimeoutMs = opts.idleInTransactionTimeoutMs;
702
- // D-M8 — deadlock-retry policy. 40P01 (deadlock_detected) and 40001
749
+ // Deadlock-retry policy. 40P01 (deadlock_detected) and 40001
703
750
  // (serialization_failure) are transient — retry with capped attempts
704
751
  // and a small jittered backoff. Operators tune retries via opts.deadlockRetries (default 3).
705
752
  // numeric-bounds doesn't have a non-negative-int helper; use a
@@ -771,7 +818,7 @@ async function transaction(fn, opts) {
771
818
  _emitMetric("db.role.denied", 1,
772
819
  { backend: b.name, role: role || "(none)" });
773
820
  }
774
- // D-M2 — DB-auth audit visibility on transaction-shaped denials.
821
+ // DB-auth audit visibility on transaction-shaped denials.
775
822
  // Statement class always reads as "TX" since the failure
776
823
  // surface inside a transaction body could be any statement;
777
824
  // operators correlate via the transaction's audit row.
@@ -1017,7 +1064,7 @@ function _requireInit() {
1017
1064
 
1018
1065
  var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
1019
1066
 
1020
- // F-CBT-2 — replica residency-tag compatibility.
1067
+ // Replica residency-tag compatibility.
1021
1068
  //
1022
1069
  // A primary tagged "EU" replicating to a "US" replica is a GDPR
1023
1070
  // Article 46 cross-border transfer; without an explicit operator
@@ -1194,7 +1241,7 @@ async function _readQuery(sql, params, opts) {
1194
1241
  _emitMetric("db.role.denied", 1,
1195
1242
  { backend: b.name, role: role || "(none)" });
1196
1243
  }
1197
- // D-M2 — DB-auth audit visibility for read-replica denials too.
1244
+ // DB-auth audit visibility for read-replica denials too.
1198
1245
  _emitAuthFailureAudit(b, role, sql, e);
1199
1246
  // Fallback to primary on a failed replica read when allowed.
1200
1247
  if (b.replicaFallbackToPrimary) {
@@ -1874,4 +1921,5 @@ module.exports = {
1874
1921
  migrate: externalDbMigrate,
1875
1922
  Pool: Pool,
1876
1923
  _resetForTest: _resetForTest,
1924
+ _extractTargetRelation: _extractTargetRelation,
1877
1925
  };
@@ -648,8 +648,8 @@ function _breakGlassPoliciesDDL(dialect) {
648
648
  }
649
649
 
650
650
  // _blamejs_break_glass_grants — issued grants. One row per successful
651
- // step-up. Default maxRowsPerGrant=1 enforces row-by-row auth per the
652
- // operator-confirmed shape ("each row access = its own grant").
651
+ // step-up. Default maxRowsPerGrant=1 enforces row-by-row auth
652
+ // ("each row access = its own grant").
653
653
  function _breakGlassGrantsDDL(dialect) {
654
654
  var t = _types(dialect);
655
655
  var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_grants;
@@ -766,7 +766,7 @@ async function ensureSchema(opts) {
766
766
  created.push(d.create.match(/CREATE TABLE IF NOT EXISTS\s+(\S+)/)[1]);
767
767
  }
768
768
 
769
- // D-M11 — append-only WORM enforcement on audit_log / consent_log /
769
+ // Append-only WORM enforcement on audit_log / consent_log /
770
770
  // audit_checkpoints in cluster mode. Local-SQLite path already
771
771
  // installs CREATE TRIGGER IF NOT EXISTS via lib/db.js's
772
772
  // _installAppendOnlyTriggers; Postgres needs equivalent rules
@@ -779,7 +779,7 @@ async function ensureSchema(opts) {
779
779
  return { tables: created };
780
780
  }
781
781
 
782
- // D-M11 — WORM enforcement helper. Idempotent: rebuilding triggers
782
+ // WORM enforcement helper. Idempotent: rebuilding triggers
783
783
  // per boot is cheap and any operator-applied DROP TRIGGER is restored
784
784
  // at the next ensureSchema pass.
785
785
  async function _installWormTriggers(backend, dialect) {
@@ -203,8 +203,8 @@ function validate(headerValue, opts) {
203
203
  // recover the boundary without Public Suffix List awareness
204
204
  // (`team.example.com` could be label=team / ns=example.com OR
205
205
  // label=team.example / ns=com). The earlier last-2-segment
206
- // heuristic produced empty `label` for 2-label IDs (Codex P1 on
207
- // PR #64), which violates RFC 2919 §2's required label "."
206
+ // heuristic produced empty `label` for 2-label IDs
207
+ // which violates RFC 2919 §2's required label "."
208
208
  // namespace decomposition.
209
209
  //
210
210
  // Drop the heuristic split — surface only the raw `listId` (and
@@ -349,8 +349,7 @@ function _extractUris(raw, maxUris) {
349
349
  // bracket pairs directly via String.matchAll so URIs containing
350
350
  // commas (legitimate, e.g. `<https://x/u?tags=a,b>`) parse
351
351
  // correctly. Earlier split(",")-based scan misclassified such
352
- // URIs as "no <URI> elements" and refused legitimate mail
353
- // (Codex P1 on PR #63).
352
+ // URIs as "no <URI> elements" and refused legitimate mail.
354
353
  var matches = raw.matchAll(/<([^<>]*)>/g); // allow:regex-no-length-cap — input length-bounded by maxBytes check upstream
355
354
  var uris = [];
356
355
  for (var m of matches) {
@@ -305,8 +305,158 @@ function create(opts) {
305
305
  };
306
306
  }
307
307
 
308
+ // Breach detection -> notification running clock. The reporter
309
+ // (`create`) computes the static per-stage deadlines; this clock turns
310
+ // them into a live escalation loop: it tracks open incident records and,
311
+ // on each tick, fires "approaching" warnings as a stage's deadline nears
312
+ // and a "passed" alert when it elapses — once per (incident, stage,
313
+ // state) so a busy tick interval can't storm the operator. It re-uses
314
+ // the reporter's REGIME_DEADLINES / dueBy timestamps and re-encodes no
315
+ // jurisdiction hour-counts (GDPR Art.33 72h, NIS2 Art.23(4) 24h, DORA
316
+ // Art.19 + RTS 2024/1772 4h, CRA Art.14, HIPAA 45 CFR 164.404/408).
317
+ // `approachThresholds` are unitless proportions of detected-to-due.
318
+ function createDeadlineClock(opts) {
319
+ opts = opts || {};
320
+ validateOpts(opts, [
321
+ "audit", "notify", "approachThresholds", "intervalMs", "autoStart", "now",
322
+ ], "incident.report.createDeadlineClock");
323
+
324
+ var auditOn = opts.audit !== false;
325
+ var notify = (opts.notify && typeof opts.notify.send === "function") ? opts.notify : null;
326
+ var thresholds = Array.isArray(opts.approachThresholds) ? opts.approachThresholds.slice() : [0.5, 0.75, 0.9];
327
+ for (var ti = 0; ti < thresholds.length; ti += 1) {
328
+ if (typeof thresholds[ti] !== "number" || !(thresholds[ti] > 0 && thresholds[ti] < 1)) {
329
+ throw new IncidentReportError("incident-report/bad-threshold",
330
+ "createDeadlineClock: approachThresholds must be numbers strictly between 0 and 1");
331
+ }
332
+ }
333
+ thresholds.sort(function (a, b) { return a - b; });
334
+ var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
335
+ var intervalMs = (typeof opts.intervalMs === "number" && isFinite(opts.intervalMs) && opts.intervalMs > 0)
336
+ ? opts.intervalMs : C.TIME.minutes(1);
337
+ var autoStart = opts.autoStart !== false;
338
+
339
+ var tracked = new Map(); // incidentId -> { detectedAt, dueBy, regime, acked, fired }
340
+ var timer = null;
341
+
342
+ function _emit(action, outcome, metadata) {
343
+ if (!auditOn) return;
344
+ try {
345
+ audit().safeEmit({ action: "incident.report.clock." + action, outcome: outcome, metadata: metadata || {} });
346
+ } catch (_e) { /* drop-silent — by design */ }
347
+ }
348
+ function _notify(payload) {
349
+ if (!notify) return;
350
+ try {
351
+ var r = notify.send(payload);
352
+ if (r && typeof r.then === "function") r.then(null, function () {});
353
+ } catch (_e) { /* drop-silent — escalation is best-effort, never crashes a tick */ }
354
+ }
355
+
356
+ function track(record) {
357
+ if (!record || typeof record !== "object" || typeof record.id !== "string" || record.id.length === 0) {
358
+ throw new IncidentReportError("incident-report/bad-record",
359
+ "createDeadlineClock.track: record must be an incident.report record with a string id");
360
+ }
361
+ if (!record.dueBy || typeof record.dueBy !== "object" ||
362
+ typeof record.detectedAt !== "number") {
363
+ throw new IncidentReportError("incident-report/bad-record",
364
+ "createDeadlineClock.track: record must carry detectedAt + dueBy { initial, intermediate, final }");
365
+ }
366
+ tracked.set(record.id, {
367
+ detectedAt: record.detectedAt,
368
+ dueBy: record.dueBy,
369
+ regime: record.regime || null,
370
+ acked: {},
371
+ fired: {},
372
+ });
373
+ return record.id;
374
+ }
375
+
376
+ function untrack(id) { return tracked.delete(id); }
377
+
378
+ function acknowledgeSubmission(id, stage, info) {
379
+ if (!VALID_STAGES[stage]) {
380
+ throw new IncidentReportError("incident-report/bad-stage",
381
+ "createDeadlineClock.acknowledgeSubmission: stage must be one of " + Object.keys(VALID_STAGES).join(", "));
382
+ }
383
+ var t = tracked.get(id);
384
+ if (!t) {
385
+ throw new IncidentReportError("incident-report/unknown-incident",
386
+ "createDeadlineClock.acknowledgeSubmission: no tracked incident '" + id + "'");
387
+ }
388
+ t.acked[stage] = true;
389
+ _emit("submission_acknowledged", "success", { incidentId: id, regime: t.regime, stage: stage, info: info || null });
390
+ return true;
391
+ }
392
+
393
+ // Pure evaluation seam — operators (and tests) can pass an explicit
394
+ // nowMs. Each (incident, stage, state) fires AT MOST once; a stage
395
+ // that has been acknowledged is skipped entirely.
396
+ function tick(nowMsArg) {
397
+ var nowMs = typeof nowMsArg === "number" ? nowMsArg : now();
398
+ tracked.forEach(function (t, id) {
399
+ var stages = ["initial", "intermediate", "final"];
400
+ for (var si = 0; si < stages.length; si += 1) {
401
+ var stage = stages[si];
402
+ if (t.acked[stage]) continue;
403
+ var due = t.dueBy[stage];
404
+ if (typeof due !== "number") continue;
405
+ var span = due - t.detectedAt;
406
+ if (span <= 0) continue;
407
+ if (nowMs >= due) {
408
+ var pk = stage + ":passed";
409
+ if (!t.fired[pk]) {
410
+ t.fired[pk] = true;
411
+ _emit("deadline_passed", "failure", { incidentId: id, regime: t.regime, stage: stage, dueBy: due });
412
+ _notify({ kind: "deadline_passed", incidentId: id, regime: t.regime, stage: stage, dueBy: due });
413
+ }
414
+ continue;
415
+ }
416
+ var proportion = (nowMs - t.detectedAt) / span;
417
+ for (var thi = thresholds.length - 1; thi >= 0; thi -= 1) {
418
+ if (proportion >= thresholds[thi]) {
419
+ var ak = stage + ":approaching:" + thresholds[thi];
420
+ if (!t.fired[ak]) {
421
+ t.fired[ak] = true;
422
+ _emit("deadline_approaching", "warning",
423
+ { incidentId: id, regime: t.regime, stage: stage, dueBy: due, threshold: thresholds[thi] });
424
+ _notify({ kind: "deadline_approaching", incidentId: id, regime: t.regime, stage: stage, dueBy: due, threshold: thresholds[thi] });
425
+ }
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ });
431
+ }
432
+
433
+ function start() {
434
+ if (timer) return;
435
+ timer = setInterval(function () { tick(); }, intervalMs);
436
+ if (timer && typeof timer.unref === "function") timer.unref();
437
+ }
438
+ function stop() {
439
+ if (timer) { clearInterval(timer); timer = null; }
440
+ }
441
+ function status() {
442
+ return { tracked: tracked.size, running: timer !== null, intervalMs: intervalMs };
443
+ }
444
+
445
+ if (autoStart) start();
446
+ return {
447
+ track: track,
448
+ untrack: untrack,
449
+ acknowledgeSubmission: acknowledgeSubmission,
450
+ tick: tick,
451
+ start: start,
452
+ stop: stop,
453
+ status: status,
454
+ };
455
+ }
456
+
308
457
  module.exports = {
309
458
  create: create,
459
+ createDeadlineClock: createDeadlineClock,
310
460
  IncidentReportError: IncidentReportError,
311
461
  REGIME_DEADLINES: REGIME_DEADLINES,
312
462
  DEFAULT_DEADLINES: DEFAULT_DEADLINES,
@@ -763,7 +763,7 @@ function checkCert(opts) {
763
763
  }
764
764
 
765
765
  // Validity window — refuse certs outside their notBefore / notAfter
766
- // window. Codex P1: checkCert's docstring promises this throws
766
+ // window. checkCert's docstring promises this throws
767
767
  // `mail-crypto/smime/expired-cert` but the impl was missing, letting
768
768
  // expired or not-yet-valid signing certs pass boot-time preflight
769
769
  // and fail interop later when peers verify signatures against the