@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
@@ -102,11 +102,12 @@ function resolvePaths(dataDir) {
102
102
  plaintext: nodePath.join(dataDir, "vault.key"),
103
103
  sealed: nodePath.join(dataDir, "vault.key.sealed"),
104
104
  derivedHashSalt: nodePath.join(dataDir, "vault.derived-hash-salt"),
105
+ derivedHashMacKey: nodePath.join(dataDir, "vault.derived-hash-mac.sealed"),
105
106
  };
106
107
  }
107
108
 
108
109
  // derivedHashSalt — per-deployment salt for crypto-field
109
- // derivedHashes (D-H1). Pre-v0.8.42 the deterministic
110
+ // derivedHashes. Pre-v0.8.42 the deterministic
110
111
  // sha3(namespace + plaintext) shape allowed cross-deployment
111
112
  // rainbow + cross-table correlation; binding a 32-byte
112
113
  // per-deployment salt closes that class without breaking
@@ -175,6 +176,66 @@ function getDerivedHashSalt() {
175
176
  return _cachedDerivedHashSalt;
176
177
  }
177
178
 
179
+ // derivedHashMacKey — per-deployment SECRET key for crypto-field's
180
+ // keyed (hmac-shake256) derived-hash mode. Unlike the salt, this is
181
+ // SEALED at rest (vault.derived-hash-mac.sealed), so an attacker with
182
+ // disk access alone cannot recompute the keyed digest and correlate
183
+ // low-entropy plaintexts. Like the salt, it is keypair-bound and
184
+ // survives a passphrase-only rotation; an ENVELOPE rotation re-seals it
185
+ // because it is registered in rotate's additionalSealed sweep.
186
+ function _readOrCreateDerivedHashMacKey() {
187
+ if (!paths) {
188
+ throw new VaultError("vault/not-initialized",
189
+ "vault.getDerivedHashMacKey() requires init()");
190
+ }
191
+ if (nodeFs.existsSync(paths.derivedHashMacKey)) {
192
+ var sealed = atomicFile.readSync(paths.derivedHashMacKey, { encoding: "utf8" }).trim();
193
+ var b64 = unseal(sealed);
194
+ var key = Buffer.from(b64, "base64");
195
+ if (key.length !== 32) { // 32-byte (256-bit) MAC key
196
+ throw new VaultError("vault/derived-hash-mac-key-corrupted",
197
+ "vault.derived-hash-mac key must unseal to exactly 32 bytes; got " + key.length);
198
+ }
199
+ return key;
200
+ }
201
+ var nodeCrypto = require("node:crypto");
202
+ var raw = nodeCrypto.randomBytes(32); // 32-byte MAC key
203
+ atomicFile.writeSync(paths.derivedHashMacKey, seal(raw.toString("base64")), { fileMode: 0o600 });
204
+ log("generated per-deployment derivedHash MAC key at " + paths.derivedHashMacKey);
205
+ return raw;
206
+ }
207
+
208
+ var _cachedDerivedHashMacKey = null;
209
+ /**
210
+ * @primitive b.vault.getDerivedHashMacKey
211
+ * @signature b.vault.getDerivedHashMacKey()
212
+ * @since 0.14.7
213
+ * @related b.vault.getDerivedHashSalt, b.cryptoField.registerTable
214
+ *
215
+ * Returns the 32-byte per-deployment SECRET key that backs crypto-
216
+ * field's keyed (`hmac-shake256`) derived-hash mode. Generated once on
217
+ * first use, SEALED at rest (`vault.derived-hash-mac.sealed`, mode
218
+ * `0o600`) so disk access alone does not expose it, and re-sealed by an
219
+ * envelope vault rotation. Distinct from `getDerivedHashSalt`, which is
220
+ * a non-secret salt stored in plaintext.
221
+ *
222
+ * Throws `VaultError("vault/not-initialized")` before `init()`, or
223
+ * `vault/derived-hash-mac-key-corrupted` if the sealed file does not
224
+ * unseal to exactly 32 bytes.
225
+ *
226
+ * @example
227
+ * await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
228
+ * var k = b.vault.getDerivedHashMacKey();
229
+ * k.length; // → 32
230
+ * Buffer.isBuffer(k); // → true
231
+ */
232
+ function getDerivedHashMacKey() {
233
+ if (_cachedDerivedHashMacKey === null) {
234
+ _cachedDerivedHashMacKey = _readOrCreateDerivedHashMacKey();
235
+ }
236
+ return _cachedDerivedHashMacKey;
237
+ }
238
+
178
239
  // ---- Init dispatch ----
179
240
 
180
241
  /**
@@ -620,6 +681,7 @@ module.exports = {
620
681
  seal: seal,
621
682
  unseal: unseal,
622
683
  getDerivedHashSalt: getDerivedHashSalt,
684
+ getDerivedHashMacKey: getDerivedHashMacKey,
623
685
  _zeroizeAndReplace: _zeroizeAndReplace,
624
686
  aad: vaultAad,
625
687
  getKeysJson: getKeysJson,
@@ -633,6 +695,7 @@ module.exports = {
633
695
  _resetForTest: function () {
634
696
  if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);
635
697
  keys = null; initialized = false; currentPassphrase = null; paths = null; currentMode = null;
698
+ _cachedDerivedHashSalt = null; _cachedDerivedHashMacKey = null;
636
699
  },
637
700
  _getKeysForTest: function () { return keys; },
638
701
  _getPathsForTest: function () { return paths; },
@@ -651,6 +651,25 @@ async function rotate(opts) {
651
651
  _reSealValue(current, oldKeys, newKeys), { mode: 0o600 });
652
652
  }
653
653
 
654
+ // 3b. Framework-managed crypto-field derived-hash files — always
655
+ // rotated regardless of operator opts.paths, so the staging copy is
656
+ // complete. The plaintext salt is copied verbatim; the SEALED MAC key
657
+ // (keyed hmac-shake256 mode) is re-sealed under the new keypair so an
658
+ // envelope rotation doesn't orphan it (a passphrase-only rotation
659
+ // re-seals to the same value since the keypair is unchanged).
660
+ var saltSrc = nodePath.join(dataDir, "vault.derived-hash-salt");
661
+ if (nodeFs.existsSync(saltSrc)) {
662
+ nodeFs.copyFileSync(saltSrc, nodePath.join(stagingDir, "vault.derived-hash-salt"));
663
+ }
664
+ var macSrc = nodePath.join(dataDir, "vault.derived-hash-mac.sealed");
665
+ if (nodeFs.existsSync(macSrc)) {
666
+ var macCurrent = nodeFs.readFileSync(macSrc, "utf8").trim();
667
+ if (macCurrent.indexOf(C.VAULT_PREFIX) === 0) {
668
+ nodeFs.writeFileSync(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
669
+ _reSealValue(macCurrent, oldKeys, newKeys), { mode: 0o600 });
670
+ }
671
+ }
672
+
654
673
  // 4. decrypt + rotate + re-encrypt db.enc
655
674
  _emit(progress, { phase: "rotate_db" });
656
675
  var encDbPath = nodePath.join(dataDir, paths.encryptedDb);
@@ -306,7 +306,7 @@ function _loadAndVerify(name) {
306
306
  // Memoized — "sha256:" + sha256(pemToRaw(PUBKEY_PEM)). Matches the
307
307
  // canonical fingerprint shape `scripts/vendor-data-gen.js` writes into
308
308
  // each .data.js's `metadata.publicKeyFingerprint`. Computed lazily on
309
- // first verify (also lazily by verifyAll at boot). CRYPTO-11 — every
309
+ // first verify (also lazily by verifyAll at boot). Every
310
310
  // per-entry verify cross-checks this against the entry's declared
311
311
  // `meta.publicKeyFingerprint` so a pubkey-swap attack fails before
312
312
  // signature verify even runs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.6",
3
+ "version": "0.14.7",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.7",
4
+ "date": "2026-05-30",
5
+ "headline": "Storage and audit-trail hardening: queries are gated to declared columns, raw SQL refuses embedded literals, the database key is bound to its location, sealed-column lookup hashes gain a keyed mode, audit-chain purges can require dual control, and breach deadlines ship a running clock",
6
+ "summary": "This release tightens the data and audit layers against a set of failure modes that were previously reachable. Database queries are now checked against the columns a table declared in its schema: a reference to an undeclared column fails closed by default instead of silently matching nothing, and the `whereRaw` escape hatch refuses an embedded string literal so values bind through placeholders. The database encryption key is sealed with its purpose, data directory, and key path as additional authenticated data, so a key file cannot be relocated to another deployment and unsealed there; an older key without that binding upgrades itself on first load. Sealed-column equality-lookup hashes can now be computed as a keyed MAC (HMAC-SHAKE256) off a per-deployment key, making the lookup hash unforgeable without that key, while the salted-SHA3 default is unchanged. Purging the tamper-evident audit chain can be placed under a two-authorizer dual-control grant so one operator cannot erase it alone, and database credential-rejection audits now record which relation the rejected credential tried to reach. Finally, breach-notification deadlines get a running clock that raises approaching and passed alerts as each regime's window elapses. One behavior change to note: the column gate defaults to reject — if a service issues queries against columns it did not declare in its schema, set `db.init({ columnGate: \"warn\" })` (audited, allowed) or `\"off\"` while the schema is reconciled.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "Column-membership gate on every query",
13
+ "body": "`b.db.from(table)` now checks each referenced column against the table's declared schema. The mode is set with `db.init({ columnGate: \"reject\" | \"warn\" | \"off\" })` (default `reject`), and `query.allowedColumns([...])` narrows a single query to an explicit allowlist that is always enforced. `b.db.getDeclaredColumns(table)` returns a table's declared column names (or `null` for an unknown table). This is defense in depth against typo'd or caller-influenced column names reaching the SQL layer (CWE-89)."
14
+ },
15
+ {
16
+ "title": "Keyed mode for sealed-column lookup hashes",
17
+ "body": "Equality-lookup (\"derived\") hashes for sealed columns can be computed as `hmac-shake256` — a keyed MAC over a per-deployment key — instead of the default `salted-sha3`. Set it per table with `cryptoField.registerTable(name, { derivedHashMode: \"hmac-shake256\" })` or per column with `{ from, mode: \"hmac-shake256\" }`. The keyed hash is unforgeable and un-correlatable without the deployment's MAC key, which raises the bar against offline lookup-table attacks on low-entropy sealed values (CWE-916). `b.vault.getDerivedHashMacKey()` exposes the 32-byte per-deployment key; it is created on first use and re-sealed across key rotation automatically."
18
+ },
19
+ {
20
+ "title": "Dual-control gate on audit-chain purge",
21
+ "body": "`b.auditTools.purge` accepts `dualControlGrant`. When `audit_log` is placed under dual control, a verified archive and `confirm: true` are no longer sufficient: the purge additionally requires a consumed m-of-n grant whose action is bound to the purge, so a grant minted for another operation cannot be replayed and one operator cannot erase the tamper-evident chain alone (NIST SP 800-53 AU-9, separation of duties)."
22
+ },
23
+ {
24
+ "title": "Running clock for breach-notification deadlines",
25
+ "body": "`b.incident.report.createDeadlineClock({ notify, approachThresholds })` tracks open incidents and raises `deadline_approaching` and `deadline_passed` alerts as each regime's window elapses (GDPR 72h, DORA, NIS2, and the rest of the registry). Alerts are deduplicated per incident and stage, suppressed once a submission stage is acknowledged, and the clock can run on an interval or be ticked manually."
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "heading": "Changed",
31
+ "items": [
32
+ {
33
+ "title": "Queries against undeclared columns now fail closed by default",
34
+ "body": "The column gate defaults to `reject`: a query that references a column the table did not declare throws rather than silently matching nothing. A service that intentionally queries undeclared columns can set `db.init({ columnGate: \"warn\" })` to audit and allow, or `\"off\"` to disable the gate, while its schema is reconciled. Framework-declared columns (including `_id` and derived-hash columns) are always members."
35
+ }
36
+ ]
37
+ },
38
+ {
39
+ "heading": "Security",
40
+ "items": [
41
+ {
42
+ "title": "Database encryption key bound to its location",
43
+ "body": "`db.key.enc` is sealed with additional authenticated data over its purpose, resolved data directory, and resolved key path. A sealed key copied to a different deployment or path no longer unseals there — the AEAD authentication fails — which prevents silent key relocation. A legacy key sealed without this binding is detected and re-sealed in the bound format on first load, with no operator action required."
44
+ },
45
+ {
46
+ "title": "`whereRaw` refuses embedded string literals",
47
+ "body": "`whereRaw(sql, params)` and `WhereBuilder.raw(sql, params)` reject a raw fragment containing a string literal (`'...'`); values must bind through the `params` array. A static, operator-controlled literal can opt in with `{ allowLiterals: true }`. This closes a path where a value concatenated into a raw fragment would reintroduce SQL injection (CWE-89)."
48
+ },
49
+ {
50
+ "title": "Credential-rejection audits record the attempted relation",
51
+ "body": "A `db.auth.failed` audit row (SQLSTATE 28000 / 28P01 / 42501) now carries `attemptedTable`, the relation the rejected credential tried to reach, extracted defensively from the statement. Triage can scope the blast radius of a credential-abuse event without correlating back to the raw SQL log (CWE-778)."
52
+ }
53
+ ]
54
+ },
55
+ {
56
+ "heading": "Detectors",
57
+ "items": [
58
+ {
59
+ "title": "Audit-purge dual-control gate",
60
+ "body": "A new check fails the build if a call to `purgeAuditChain` appears in a file that does not also route through the dual-control gate, so a future caller cannot physically delete chain rows without two-authorizer enforcement."
61
+ },
62
+ {
63
+ "title": "Raw-SQL literal/interpolation guard",
64
+ "body": "A new check fails the build on a `whereRaw` / `.raw` call whose SQL argument is built by template interpolation or string concatenation, keeping the bound-params discipline enforceable in framework code."
65
+ },
66
+ {
67
+ "title": "Hand-rolled lookup-hash guard",
68
+ "body": "A new check fails the build if a sealed-column lookup hash is derived from the per-deployment salt outside the canonical helper, so call sites cannot bypass the keyed-mode and per-column mode policy."
69
+ },
70
+ {
71
+ "title": "Auth-audit attempted-relation guard",
72
+ "body": "A new check fails the build if a `db.auth.failed` audit is emitted in a file that does not name `attemptedTable`, so the forensic field cannot be dropped from a future emitter."
73
+ }
74
+ ]
75
+ }
76
+ ]
77
+ }
@@ -88,16 +88,41 @@ function _bumpMinor(version) {
88
88
  return parts[0] + "." + (parts[1] + 1) + ".0";
89
89
  }
90
90
 
91
+ // Quote a single argument for a Windows cmd.exe command line. Tokens
92
+ // made only of safe characters pass through unquoted; anything else is
93
+ // double-quoted with embedded quotes doubled.
94
+ function _quoteWinArg(a) {
95
+ a = String(a);
96
+ if (/^[A-Za-z0-9_@.\-/:=]+$/.test(a)) return a;
97
+ return '"' + a.replace(/"/g, '""') + '"';
98
+ }
99
+
91
100
  function _run(cmd, args, opts) {
92
101
  opts = opts || {};
93
- var rv = childProcess.spawnSync(cmd, args, {
102
+ args = args || [];
103
+ var spawnCmd = cmd;
104
+ var spawnArgs = args;
105
+ var useShell = false;
106
+ if (_needsShell(cmd)) {
107
+ // Windows resolves npm / npx through .cmd shims that can only be
108
+ // launched via a shell (the CVE-2024-27980 mitigation refuses to
109
+ // spawn .cmd files without one). Node 26's DEP0190 deprecates
110
+ // pairing an args ARRAY with shell:true because the args would be
111
+ // concatenated onto the command line without escaping — a quoting /
112
+ // injection hazard. Build a single, explicitly-quoted command
113
+ // string and pass NO args array, which is the supported shape.
114
+ spawnCmd = [cmd].concat(args.map(_quoteWinArg)).join(" ");
115
+ spawnArgs = undefined;
116
+ useShell = true;
117
+ }
118
+ var rv = childProcess.spawnSync(spawnCmd, spawnArgs, {
94
119
  cwd: opts.cwd || ROOT,
95
120
  stdio: opts.stdio || "inherit",
96
121
  env: Object.assign({}, process.env, opts.env || {}),
97
- shell: _needsShell(cmd),
122
+ shell: useShell,
98
123
  });
99
124
  if (rv.status !== 0 && !opts.allowFail) {
100
- throw new Error("release: " + cmd + " " + (args || []).join(" ") +
125
+ throw new Error("release: " + cmd + " " + args.join(" ") +
101
126
  " failed with status " + rv.status);
102
127
  }
103
128
  return rv;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ /**
3
+ * b.auditTools.purge dual-control gate.
4
+ *
5
+ * When audit_log is placed under dual control, physically purging the
6
+ * audit chain requires a consumed m-of-n grant in addition to a
7
+ * verified archive and confirm:true — one operator must not be able to
8
+ * erase the tamper-evident chain alone. The grant's action is also
9
+ * bound, so a grant minted for a different operation can't be replayed
10
+ * against a purge.
11
+ */
12
+
13
+ var helpers = require("../helpers");
14
+ var b = helpers.b;
15
+ var check = helpers.check;
16
+ var fs = helpers.fs;
17
+ var os = helpers.os;
18
+ var path = helpers.path;
19
+ var setupTestDb = helpers.setupTestDb;
20
+ var teardownTestDb = helpers.teardownTestDb;
21
+
22
+ var PASS = Buffer.from("operator-passphrase");
23
+ var PURGE_ACTION = "auditTools.purge";
24
+
25
+ async function _seedAuditRows(count) {
26
+ b.audit.registerNamespace("test");
27
+ for (var i = 0; i < count; i++) {
28
+ await b.audit.record({
29
+ actor: { userId: "u-" + i }, action: "test.seeded", outcome: "success", metadata: { i: i },
30
+ });
31
+ }
32
+ }
33
+
34
+ async function _expectCode(fn, code) {
35
+ var threw = null;
36
+ try { await fn(); } catch (e) { threw = e; }
37
+ return threw && threw.code === code;
38
+ }
39
+
40
+ async function run() {
41
+ var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-at-dc-"));
42
+ try {
43
+ await setupTestDb(dir);
44
+ await _seedAuditRows(5);
45
+ await b.audit.checkpoint();
46
+
47
+ var out = path.join(dir, "bundles", "archive-dc");
48
+ await b.auditTools.archive({ before: Date.now() + 1000, out: out, passphrase: PASS });
49
+
50
+ // Simulate audit_log being under dual control (m-of-n). The gate
51
+ // override returns a gate descriptor for the audit_log table; the
52
+ // denial branches all fire before any chain mutation, so the same
53
+ // verified bundle is reused across them.
54
+ var sawTable = null;
55
+ var gate = function (table) { sawTable = table; return { m: 2, n: 3 }; };
56
+
57
+ // No grant → refused.
58
+ check("purge under dual control refuses without a grant",
59
+ await _expectCode(function () {
60
+ return b.auditTools.purge({ archive: out, passphrase: PASS, confirm: true, checkDualControlGate: gate });
61
+ }, "audit-tools/dual-control-required"));
62
+ check("gate was consulted for the audit_log table", sawTable === "audit_log");
63
+
64
+ // Grant not ready (not a consumed m-of-n grant) → refused.
65
+ check("purge refuses a not-ready grant",
66
+ await _expectCode(function () {
67
+ return b.auditTools.purge({
68
+ archive: out, passphrase: PASS, confirm: true, checkDualControlGate: gate,
69
+ dualControlGrant: { ready: false, action: PURGE_ACTION },
70
+ });
71
+ }, "audit-tools/dual-control-grant-not-ready"));
72
+
73
+ // Grant minted for a different action → refused (action binding).
74
+ check("purge refuses a grant bound to a different action",
75
+ await _expectCode(function () {
76
+ return b.auditTools.purge({
77
+ archive: out, passphrase: PASS, confirm: true, checkDualControlGate: gate,
78
+ dualControlGrant: { ready: true, action: "db.eraseHard" },
79
+ });
80
+ }, "audit-tools/dual-control-grant-mismatch"));
81
+
82
+ // Confirm the chain is still intact — no denial mutated it.
83
+ var beforeRows = await b.clusterStorage.executeAll("SELECT COUNT(*) as c FROM audit_log");
84
+ check("audit_log untouched by denied purges", Number(beforeRows[0].c) >= 5);
85
+
86
+ // Valid consumed grant for the purge action → proceeds.
87
+ var pres = await b.auditTools.purge({
88
+ archive: out, passphrase: PASS, confirm: true, checkDualControlGate: gate,
89
+ dualControlGrant: { ready: true, action: PURGE_ACTION },
90
+ });
91
+ check("purge with a valid grant succeeds", pres.purged === true);
92
+ check("purge reports dual-control was consumed", pres.dualControlConsumed === true);
93
+ check("purge deleted rows", pres.rowsDeleted > 0);
94
+
95
+ // No gate (audit_log not under dual control) → confirm + archive
96
+ // alone is sufficient (no grant required) — the gate is opt-in.
97
+ var noGateResult = b.auditTools.purge;
98
+ check("purge surface present", typeof noGateResult === "function");
99
+ } finally {
100
+ await teardownTestDb(dir);
101
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
102
+ }
103
+
104
+ console.log("OK — audit-tools dual-control tests");
105
+ }
106
+
107
+ module.exports = { run: run };
108
+ if (require.main === module) {
109
+ // Rethrow on failure so Node surfaces the error and exits non-zero,
110
+ // instead of logging the caught error object — a taint analyzer traces
111
+ // a logged error back to the test passphrase fixture (a non-secret
112
+ // constant) and raises a false clear-text-logging alert.
113
+ run().then(function () { process.exit(0); })
114
+ .catch(function (err) { process.exitCode = 1; throw err; });
115
+ }
@@ -309,6 +309,7 @@ var VALID_ALLOW_CLASSES = {
309
309
  "inline-require": 1,
310
310
  "inline-require-non-empty-string-validation": 1,
311
311
  "internal-binding-in-prose": 1,
312
+ "internal-narrative-comment": 1,
312
313
  "list-without-pagination": 1,
313
314
  "math-random-noncrypto": 1,
314
315
  "no-number-money-arithmetic": 1,
@@ -7951,6 +7952,101 @@ var KNOWN_ANTIPATTERNS = [
7951
7952
  reason: "Codex P1 on v0.11.4 PR #109 — b.audit.useStore({ record }) inlined await on operator-supplied callback. A stalled network call neither resolves nor rejects ⇒ b.audit.record() never returns, emit/safeEmit drains stall behind it; hot-path observability emission is supposed to be drop-silent on hangs. Defense: wrap every external-callback await in safeAsync.withTimeout. Detector catches future operator-supplied async hooks on the same shape (_externalStore / _userStore / _externalSink / _externalCb / _externalCallback / _externalHook).",
7952
7953
  },
7953
7954
 
7955
+ {
7956
+ // v0.14.7 — physically deleting audit-chain rows is a
7957
+ // single-operator-can-erase-evidence risk. `auditTools.purge`
7958
+ // (and the low-level `db.purgeAuditChain` it composes) MUST
7959
+ // consult a dual-control gate — two distinct authorizers — before
7960
+ // the destructive delete runs, so one compromised credential can't
7961
+ // silently truncate the tamper-evident chain. The gate primitive
7962
+ // is `db._checkDualControlGate(table)` (resolved at runtime); the
7963
+ // purge path threads it via `_resolveDualControlGate(opts)` and
7964
+ // refuses with `dual-control-required` when no grant is present.
7965
+ //
7966
+ // Detector: any lib/ call to `purgeAuditChain(` MUST appear in a
7967
+ // file that also names the gate (`_checkDualControlGate` /
7968
+ // `_resolveDualControlGate` / `dualControlGrant`). A new caller
7969
+ // that physically deletes chain rows without routing through the
7970
+ // gate trips here.
7971
+ id: "audit-purge-without-dual-control-gate",
7972
+ primitive: "auditTools.purge({ dualControlGrant }) / db.purgeAuditChain — physical audit-chain deletion MUST consult the dual-control gate (_checkDualControlGate / _resolveDualControlGate); one operator must not be able to erase the tamper-evident chain alone",
7973
+ regex: /\bpurgeAuditChain\s*\(/,
7974
+ requires: /_checkDualControlGate|_resolveDualControlGate|dualControlGrant/,
7975
+ skipCommentLines: true,
7976
+ allowlist: [],
7977
+ reason: "v0.14.7 — audit-chain purge is irreversible and tamper-evidence-destroying. The discipline: the chain may only be truncated under a two-authorizer dual-control grant. db.js defines the gate (_checkDualControlGate) and audit-tools.js purge() resolves+enforces it (_resolveDualControlGate) before calling db().purgeAuditChain; both files satisfy the companion check. A future file that calls purgeAuditChain without naming the gate would let a single operator delete evidence — exactly the shape this blocks.",
7978
+ },
7979
+
7980
+ {
7981
+ // v0.14.7 — raw-SQL fragments passed to `whereRaw` / the
7982
+ // WhereBuilder `.raw(sql, params)` escape hatch must parameterize
7983
+ // values via the `params` array, NOT interpolate or concatenate
7984
+ // them into the SQL string. `_assertRawNoStringLiteral` refuses an
7985
+ // embedded `'...'` literal at runtime (unless the caller opts in
7986
+ // with `allowLiterals`), but the static discipline is stronger:
7987
+ // lib/ must never BUILD the raw SQL by `${...}` template
7988
+ // interpolation or `"..." + value` string concatenation — that is
7989
+ // the injection shape the bound-params API exists to prevent.
7990
+ //
7991
+ // Detector fires on a whereRaw/.raw call whose first argument is a
7992
+ // template literal containing `${` or a string-concat expression.
7993
+ // A no-arg `bodyParser.raw()` (different `.raw`) does not match —
7994
+ // the regex requires an interpolated/concatenated string argument.
7995
+ id: "whereraw-interpolated-or-concatenated-sql",
7996
+ primitive: "whereRaw(sql, params) / qb.raw(sql, params) — pass values through the bound `params` array; never `${...}`-interpolate or `+`-concatenate them into the SQL string (that is the injection shape the params API prevents)",
7997
+ regex: /(?:whereRaw|\.raw)\s*\(\s*(?:`[^`]*\$\{|["'][^"']*["']\s*\+|[A-Za-z_$][\w$]*\s*\+\s*["'`])/,
7998
+ skipCommentLines: true,
7999
+ allowlist: [],
8000
+ reason: "v0.14.7 — b.db whereRaw / WhereBuilder.raw accept an operator SQL fragment plus a bound-params array. Concatenating or template-interpolating a value into the fragment defeats the placeholder binding and reintroduces SQL injection. lib/ has zero such call sites today; the detector keeps it that way (the runtime _assertRawNoStringLiteral gate is the operator-facing backstop, this is the framework-internal one). No-argument `.raw()` mountings (e.g. bodyParser.raw()) don't match — an interpolated/concatenated string argument is required.",
8001
+ },
8002
+
8003
+ {
8004
+ // v0.14.7 — equality-lookup ("derived") hashes for sealed columns
8005
+ // must be computed through cryptoField._computeDerivedHash, which
8006
+ // honours the per-table / per-column mode policy (salted-sha3 by
8007
+ // default, hmac-shake256 opt-in keyed off vault.getDerivedHashMacKey).
8008
+ // Hand-rolling `sha3Hash(vault.getDerivedHashSalt() + ns + value)`
8009
+ // at a new site bypasses the keyed-MAC option AND the mode policy,
8010
+ // and risks a static-salt regression if the salt source is changed
8011
+ // for one caller but not the canonical helper.
8012
+ //
8013
+ // Detector: direct use of `getDerivedHashSalt().toString("hex") +`
8014
+ // outside crypto-field.js (the canonical helper). The helper itself
8015
+ // is allowlisted.
8016
+ id: "derived-hash-handrolled-outside-crypto-field",
8017
+ primitive: "cryptoField derived/lookup hashes — compute via _computeDerivedHash (honours salted-sha3 vs hmac-shake256 mode + vault.getDerivedHashMacKey); do not hand-roll sha3Hash(getDerivedHashSalt() + ns + value) at call sites",
8018
+ regex: /getDerivedHashSalt\s*\(\s*\)\s*\.toString\s*\(\s*["']hex["']\s*\)\s*\+/,
8019
+ skipCommentLines: true,
8020
+ allowlist: [
8021
+ // The canonical helper — _computeDerivedHash branches on mode here.
8022
+ "lib/crypto-field.js",
8023
+ ],
8024
+ reason: "v0.14.7 — derived-hash equality lookups gained a keyed mode (hmac-shake256 off vault.getDerivedHashMacKey) alongside the salted-sha3 default. The mode decision lives in cryptoField._computeDerivedHash. A site that hand-derives the hash with getDerivedHashSalt() bypasses the keyed option and the per-column mode policy; only the canonical helper (crypto-field.js) may name the salt directly.",
8025
+ },
8026
+
8027
+ {
8028
+ // v0.14.7 — a `db.auth.failed` audit row must record WHICH
8029
+ // relation the rejected credential attempted to reach
8030
+ // (`attemptedTable`), so an operator triaging a credential-abuse
8031
+ // event can scope blast radius without correlating to the raw SQL
8032
+ // log. external-db extracts the target relation defensively
8033
+ // (_extractTargetRelation) and stamps both resource.attemptedTable
8034
+ // and the audit metadata. Emitting the auth-failure audit without
8035
+ // it loses the forensic signal.
8036
+ //
8037
+ // Detector: an `action: "db.auth.failed"` audit emit MUST appear in
8038
+ // a file that also names `attemptedTable`. The metric emitter
8039
+ // (_emitMetric("db.auth.failed", ...)) uses a positional shape, not
8040
+ // `action:`, so it doesn't match.
8041
+ id: "db-auth-failed-audit-without-attempted-relation",
8042
+ primitive: "db.auth.failed audit — stamp the attempted relation (attemptedTable, via _extractTargetRelation) on credential-rejection audit rows so blast radius is triageable without the raw SQL log",
8043
+ regex: /action\s*:\s*["']db\.auth\.failed["']/,
8044
+ requires: /attemptedTable/,
8045
+ skipCommentLines: true,
8046
+ allowlist: [],
8047
+ reason: "v0.14.7 — external-db credential-rejection audits (SQLSTATE 28000 / 28P01 / 42501) now carry attemptedTable, the relation the rejected identity tried to touch, extracted defensively from the SQL. The detector requires any file emitting an action:'db.auth.failed' audit to also name attemptedTable so a future emitter can't drop the forensic field.",
8048
+ },
8049
+
7954
8050
  ];
7955
8051
 
7956
8052
  // @example placeholder detection lives in
@@ -10565,9 +10661,9 @@ function testPrimitiveReachability() {
10565
10661
  // onDeny / problemDetails opts. A new deny-path
10566
10662
  // middleware that hardcodes
10567
10663
  // `res.writeHead(<4xx/5xx>, { "Content-Type": ... })`
10568
- // locks consumers out of the response shape — the
10569
- // integration-friction class that blocked downstream
10570
- // adoption (the rate-limit text/plain 429). ----
10664
+ // locks consumers out of the response shape — which is
10665
+ // what pinned rate-limit's 429 to text/plain before this
10666
+ // convention existed. ----
10571
10667
  function testDenyPathComposesDenyResponse() {
10572
10668
  // class: deny-path-hardcoded-response
10573
10669
  var MW_ROOT = path.resolve(LIB_ROOT, "middleware");
@@ -10610,9 +10706,73 @@ function testDenyPathComposesDenyResponse() {
10610
10706
  violations);
10611
10707
  }
10612
10708
 
10709
+ // ---- Pattern: operator-facing source comments must describe the code,
10710
+ // not the internal authoring process. These shapes are
10711
+ // internal slice / bug / plan IDs, code-review-process
10712
+ // residue (Codex P-levels, PR numbers), and dated
10713
+ // decision parentheticals — none of which an operator
10714
+ // reading the shipped source can map to anything in their
10715
+ // own checkout. Genuinely operator-meaningful references
10716
+ // (RFC / CVE / NIST / CWE, "since vX.Y.Z", established
10717
+ // terse markers like D-M4 / AUTH-32) are NOT matched.
10718
+ // Allowlist a false positive with `// allow:internal-
10719
+ // narrative-comment`. ----
10720
+ function testNoInternalNarrativeComments() {
10721
+ // class: internal-narrative-comment
10722
+ var NARRATIVE = [
10723
+ { re: /\b(?:SUBSTRATE|BUG|MAIL)-\d+\b/, what: "internal slice/bug ID" },
10724
+ { re: /\b(?:D-[MLH]\d+|AUTH-\d+|CRYPTO-\d+|SUPPLY-\d+)\b/, what: "internal domain/slice ID" },
10725
+ { re: /\bCodex\s+P\d/, what: "code-review-process reference (Codex P#)" },
10726
+ { re: /\bF-[A-Z]{2,}-\d+\b/, what: "internal feature/plan item ID" },
10727
+ { re: /\bPR\s+#\d+\b/, what: "pull-request number (process residue)" },
10728
+ { re: /\b[Aa]udit\s+\d{4}-\d{2}-\d{2}/, what: "dated audit/decision residue" },
10729
+ { re: /\bReported\s+\d{4}-\d{2}-\d{2}/, what: "dated report residue" },
10730
+ { re: /\bCore Rule\s+§\d/, what: "internal CLAUDE.md rule-number citation" },
10731
+ ];
10732
+ var files = _libFiles();
10733
+ var bad = [];
10734
+ var jsdocLineRe = /^\s*\*/;
10735
+ for (var fi = 0; fi < files.length; fi++) {
10736
+ var rel = _relPath(files[fi]);
10737
+ var src = fs.readFileSync(files[fi], "utf8");
10738
+ var lines = src.split("\n");
10739
+ for (var li = 0; li < lines.length; li++) {
10740
+ var line = lines[li];
10741
+ // Scan the comment portion only: a ` * `-prefixed JSDoc line in
10742
+ // full, or the text after a line's first `//`.
10743
+ var comment = null;
10744
+ if (jsdocLineRe.test(line)) {
10745
+ comment = line;
10746
+ } else {
10747
+ var idx = line.indexOf("//");
10748
+ if (idx !== -1) comment = line.slice(idx);
10749
+ }
10750
+ if (comment === null) continue;
10751
+ for (var p = 0; p < NARRATIVE.length; p++) {
10752
+ var m = comment.match(NARRATIVE[p].re);
10753
+ if (m) {
10754
+ bad.push({
10755
+ file: rel,
10756
+ line: li + 1,
10757
+ content: NARRATIVE[p].what + ": `" + m[0] + "` in an operator-facing comment — " +
10758
+ "describe the change, not the internal process (strip the label / drop the " +
10759
+ "dated parenthetical / cite the public reason instead)",
10760
+ });
10761
+ break;
10762
+ }
10763
+ }
10764
+ }
10765
+ }
10766
+ bad = _filterMarkers(bad, "internal-narrative-comment");
10767
+ _report("operator-facing source comments must not carry internal-process narrative " +
10768
+ "(slice / bug / plan IDs, Codex / PR references, dated decision parentheticals)",
10769
+ bad);
10770
+ }
10771
+
10613
10772
  async function run() {
10614
10773
  testPrimitiveReachability();
10615
10774
  testDenyPathComposesDenyResponse();
10775
+ testNoInternalNarrativeComments();
10616
10776
  testNoOrphanAllowClass();
10617
10777
  testNoRawByteLiterals();
10618
10778
  testNoRawTimeLiterals();