@blamejs/core 0.14.5 → 0.14.7

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 (93) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -2
  3. package/lib/agent-event-bus.js +4 -4
  4. package/lib/agent-idempotency.js +6 -6
  5. package/lib/agent-orchestrator.js +9 -9
  6. package/lib/agent-posture-chain.js +10 -10
  7. package/lib/agent-saga.js +6 -7
  8. package/lib/agent-snapshot.js +8 -8
  9. package/lib/agent-stream.js +3 -3
  10. package/lib/agent-tenant.js +4 -4
  11. package/lib/agent-trace.js +5 -5
  12. package/lib/ai-disclosure.js +3 -3
  13. package/lib/app.js +2 -2
  14. package/lib/archive-read.js +1 -1
  15. package/lib/archive-tar-read.js +1 -1
  16. package/lib/archive-wrap.js +5 -5
  17. package/lib/audit-tools.js +65 -5
  18. package/lib/audit.js +2 -2
  19. package/lib/auth/ciba.js +1 -1
  20. package/lib/auth/dpop.js +1 -1
  21. package/lib/auth/fal.js +1 -1
  22. package/lib/auth/fido-mds3.js +2 -3
  23. package/lib/auth/jwt-external.js +2 -2
  24. package/lib/auth/oauth.js +9 -9
  25. package/lib/auth/oid4vci.js +7 -7
  26. package/lib/auth/oid4vp.js +1 -1
  27. package/lib/auth/openid-federation.js +5 -5
  28. package/lib/auth/passkey.js +6 -6
  29. package/lib/auth/saml.js +1 -1
  30. package/lib/auth/sd-jwt-vc.js +3 -6
  31. package/lib/backup/index.js +18 -18
  32. package/lib/cache.js +4 -4
  33. package/lib/calendar.js +5 -5
  34. package/lib/circuit-breaker.js +1 -1
  35. package/lib/cms-codec.js +2 -2
  36. package/lib/compliance.js +14 -14
  37. package/lib/cra-report.js +3 -3
  38. package/lib/crypto-field.js +58 -21
  39. package/lib/crypto.js +5 -6
  40. package/lib/db-query.js +131 -9
  41. package/lib/db.js +106 -22
  42. package/lib/external-db.js +64 -16
  43. package/lib/framework-schema.js +4 -4
  44. package/lib/guard-list-id.js +2 -2
  45. package/lib/guard-list-unsubscribe.js +1 -2
  46. package/lib/incident-report.js +150 -0
  47. package/lib/mail-crypto-smime.js +1 -1
  48. package/lib/mail-deploy.js +3 -3
  49. package/lib/mail-server-managesieve.js +2 -2
  50. package/lib/mail-server-pop3.js +2 -2
  51. package/lib/mail-store.js +1 -1
  52. package/lib/metrics.js +8 -8
  53. package/lib/middleware/age-gate.js +20 -7
  54. package/lib/middleware/bearer-auth.js +36 -35
  55. package/lib/middleware/bot-guard.js +17 -5
  56. package/lib/middleware/cors.js +28 -12
  57. package/lib/middleware/csrf-protect.js +23 -15
  58. package/lib/middleware/daily-byte-quota.js +27 -13
  59. package/lib/middleware/deny-response.js +140 -0
  60. package/lib/middleware/dpop.js +37 -24
  61. package/lib/middleware/fetch-metadata.js +21 -12
  62. package/lib/middleware/host-allowlist.js +19 -8
  63. package/lib/middleware/idempotency-key.js +21 -22
  64. package/lib/middleware/index.js +3 -0
  65. package/lib/middleware/network-allowlist.js +24 -10
  66. package/lib/middleware/protected-resource-metadata.js +2 -2
  67. package/lib/middleware/rate-limit.js +22 -5
  68. package/lib/middleware/require-aal.js +25 -10
  69. package/lib/middleware/require-auth.js +32 -16
  70. package/lib/middleware/require-bound-key.js +49 -18
  71. package/lib/middleware/require-content-type.js +19 -8
  72. package/lib/middleware/require-methods.js +17 -7
  73. package/lib/middleware/require-mtls.js +27 -14
  74. package/lib/network-dns-resolver.js +2 -2
  75. package/lib/network-dns.js +1 -2
  76. package/lib/network-tls.js +0 -1
  77. package/lib/network.js +4 -4
  78. package/lib/outbox.js +1 -1
  79. package/lib/pqc-agent.js +1 -1
  80. package/lib/retention.js +1 -1
  81. package/lib/retry.js +1 -1
  82. package/lib/safe-archive.js +2 -2
  83. package/lib/safe-ical.js +2 -2
  84. package/lib/safe-mime.js +1 -1
  85. package/lib/self-update-standalone-verifier.js +1 -1
  86. package/lib/self-update.js +2 -2
  87. package/lib/static.js +1 -1
  88. package/lib/subject.js +2 -2
  89. package/lib/vault/index.js +64 -1
  90. package/lib/vault/rotate.js +19 -0
  91. package/lib/vendor-data.js +1 -1
  92. package/package.json +1 -1
  93. package/sbom.cdx.json +6 -6
package/lib/db-query.js CHANGED
@@ -33,6 +33,7 @@ var { generateToken } = require("./crypto");
33
33
  var safeJson = require("./safe-json");
34
34
  var safeJsonPath = require("./safe-jsonpath");
35
35
  var safeSql = require("./safe-sql");
36
+ var audit = require("./audit");
36
37
 
37
38
  // "@>" / "?" / "?|" / "?&" are JSONB containment + key-existence
38
39
  // operators. Routed through safeJsonPath validation before binding so
@@ -46,7 +47,7 @@ var JSONB_CONTAINMENT_OPS = new Set(["@>"]);
46
47
  var JSONB_KEY_OPS = new Set(["?", "?|", "?&"]);
47
48
 
48
49
  class Query {
49
- constructor(database, tableName) {
50
+ constructor(database, tableName, opts) {
50
51
  // Identifier safety: tableName flows into SQL via interpolation
51
52
  // (parameter placeholders only bind values, not names). Validate at
52
53
  // construction so an attacker-controlled name with embedded `"` or
@@ -84,6 +85,62 @@ class Query {
84
85
  this._orderBy = null;
85
86
  this._limit = null;
86
87
  this._offset = null;
88
+
89
+ // Column-membership gate. `db.from()` passes the table's
90
+ // declared columns + the configured gate mode so an operator-
91
+ // supplied column name that isn't a real column of the table is
92
+ // refused before it interpolates into SQL as an identifier
93
+ // (ORDER-BY / sealed-column-disclosure injection — CWE-89 /
94
+ // CWE-1336). A bare `new Query(db, name)` with no opts leaves the
95
+ // gate disabled (declaredColumns null), so direct/internal
96
+ // construction is unaffected.
97
+ opts = opts || {};
98
+ this._declaredColumns = (opts.declaredColumns instanceof Set) ? opts.declaredColumns
99
+ : (Array.isArray(opts.declaredColumns) ? new Set(opts.declaredColumns) : null);
100
+ this._columnGateMode = opts.columnGateMode || "reject";
101
+ this._allowedColumns = null;
102
+ }
103
+
104
+ // Restrict the operator-allowable columns to an explicit subset
105
+ // (tighter than the schema-declared set). Use when a query is built
106
+ // from request input and must only ever touch a known-safe list.
107
+ // Throws on a non-array or an invalid identifier.
108
+ allowedColumns(cols) {
109
+ if (!Array.isArray(cols) || cols.length === 0) {
110
+ throw new TypeError("allowedColumns(cols): expected a non-empty array of column names");
111
+ }
112
+ cols.forEach(_validateField);
113
+ this._allowedColumns = new Set(cols);
114
+ return this;
115
+ }
116
+
117
+ // Assert `field` is a member of the allowed/declared column set
118
+ // before it is interpolated into SQL as an identifier. The operator
119
+ // `allowedColumns()` set (when present) is ALWAYS enforced; the
120
+ // schema gate respects the configured mode ("reject" default
121
+ // throws | "warn" drop-silent audits + allows | "off" / no declared
122
+ // set skips).
123
+ _assertColumnMember(field, where) {
124
+ if (this._allowedColumns && !this._allowedColumns.has(field)) {
125
+ throw new Error("column '" + field + "' is not in the allowedColumns() set" +
126
+ (where ? " (" + where + ")" : ""));
127
+ }
128
+ if (this._declaredColumns === null || this._columnGateMode === "off") return;
129
+ if (this._declaredColumns.has(field)) return;
130
+ if (this._columnGateMode === "warn") {
131
+ try {
132
+ audit.safeEmit({
133
+ action: "db.query.unknown_column",
134
+ outcome: "failure",
135
+ metadata: { table: this._qualifiedKey, column: field, where: where || null },
136
+ });
137
+ } catch (_e) { /* drop-silent — observability sink, by design */ }
138
+ return;
139
+ }
140
+ throw new Error("column '" + field + "' is not a declared column of '" +
141
+ this._qualifiedKey + "'" + (where ? " (" + where + ")" : "") +
142
+ ". Declared columns: " + Array.from(this._declaredColumns).join(", ") +
143
+ ". Use .allowedColumns([...]) or db.init({ columnGate: 'off' }) to bypass.");
87
144
  }
88
145
 
89
146
  // Quoted SQL form: `"schema"."table"` if schema-qualified, else `"table"`.
@@ -114,7 +171,7 @@ class Query {
114
171
  if (!ALLOWED_OPS.has(op)) {
115
172
  throw new Error("invalid where operator: " + op);
116
173
  }
117
- // D-M4 — JSONB / JSON-path injection guard. Routes operator-
174
+ // JSONB / JSON-path injection guard. Routes operator-
118
175
  // supplied JSONB containment + key-existence values through
119
176
  // safe-jsonpath before they reach the engine. Bound via `?`
120
177
  // placeholder so the value still doesn't interpolate; this is
@@ -171,6 +228,10 @@ class Query {
171
228
  value = lookup.value;
172
229
  }
173
230
  cryptoField && _validateField(field);
231
+ // Gate the post-sealed-rewrite physical column (derived-hash
232
+ // columns are declared physical columns, so the rewrite target
233
+ // passes membership).
234
+ this._assertColumnMember(field, "where");
174
235
  if (op === "IN") {
175
236
  // node:sqlite ? does not support array-binding. Pre-v0.8.18
176
237
  // `where(field, "IN", [1,2,3])` silently bound the entire
@@ -228,10 +289,11 @@ class Query {
228
289
  // text used to build expressions the chainable .where() can't express
229
290
  // (compound OR, row-value comparison for cursor pagination, etc.).
230
291
  // Placeholder count must match params.length.
231
- whereRaw(sql, params) {
292
+ whereRaw(sql, params, opts) {
232
293
  if (typeof sql !== "string" || sql.length === 0) {
233
294
  throw new Error("whereRaw: sql must be a non-empty string");
234
295
  }
296
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql, "whereRaw");
235
297
  var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
236
298
  // Count `?` placeholders, but skip occurrences inside string
237
299
  // literals ('...' or "..."), line comments (-- to EOL), and
@@ -255,12 +317,15 @@ class Query {
255
317
  throw new Error("select() expects an array of column names");
256
318
  }
257
319
  columns.forEach(_validateField);
320
+ var self = this;
321
+ columns.forEach(function (c) { self._assertColumnMember(c, "select"); });
258
322
  this._select = columns.slice();
259
323
  return this;
260
324
  }
261
325
 
262
326
  orderBy(field, direction) {
263
327
  _validateField(field);
328
+ this._assertColumnMember(field, "orderBy");
264
329
  direction = (direction || "asc").toLowerCase();
265
330
  if (direction !== "asc" && direction !== "desc") {
266
331
  throw new Error("orderBy direction must be 'asc' or 'desc'");
@@ -348,7 +413,7 @@ class Query {
348
413
  // the bound table's sealedFields registration before it lands in the
349
414
  // operator's pipeline. For large result sets (audit exports, backup
350
415
  // table dumps) this avoids materializing the full rowset in memory.
351
- // D-M5 — streamLimit ceiling enforced from the module-level db
416
+ // StreamLimit ceiling enforced from the module-level db
352
417
  // config; per-call opts.streamLimit overrides for one-off bumps.
353
418
  stream(opts) {
354
419
  var sql = "SELECT " + this._projection() + " FROM " + this._quotedTable() +
@@ -455,6 +520,8 @@ class Query {
455
520
  throw new Error("update changes object is empty");
456
521
  }
457
522
  setKeys.forEach(_validateField);
523
+ var selfUpd = this;
524
+ setKeys.forEach(function (k) { selfUpd._assertColumnMember(k, "update"); });
458
525
  var setClause = setKeys.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
459
526
  var setValues = setKeys.map(function (k) { return sealed[k]; });
460
527
 
@@ -498,6 +565,7 @@ class Query {
498
565
  throw new Error("increment(column, delta): column must be a non-empty string");
499
566
  }
500
567
  _validateField(column);
568
+ this._assertColumnMember(column, "increment");
501
569
  if (delta === undefined) delta = 1;
502
570
  if (typeof delta !== "number" || !Number.isFinite(delta) || !Number.isInteger(delta)) {
503
571
  throw new Error("increment(column, delta): delta must be a finite integer (default 1)");
@@ -532,7 +600,7 @@ class Query {
532
600
  if (typeof closure !== "function") {
533
601
  throw new Error("whereGroup(closure): expected function (qb) => ...");
534
602
  }
535
- var sub = new WhereBuilder();
603
+ var sub = new WhereBuilder(this);
536
604
  closure(sub);
537
605
  var built = sub.build();
538
606
  if (!built.sql) return this;
@@ -551,7 +619,7 @@ class Query {
551
619
  throw new Error("orWhere(...): no prior where(...) — start the chain with where(...)");
552
620
  }
553
621
  if (typeof fieldOrObjOrFn === "function") {
554
- var sub = new WhereBuilder();
622
+ var sub = new WhereBuilder(this);
555
623
  fieldOrObjOrFn(sub);
556
624
  var built = sub.build();
557
625
  if (!built.sql) return this;
@@ -562,7 +630,7 @@ class Query {
562
630
  }
563
631
  // For non-closure shapes, build a transient single-leaf Query and
564
632
  // splice it. We compile to a `WhereBuilder` for symmetry.
565
- var sub2 = new WhereBuilder();
633
+ var sub2 = new WhereBuilder(this);
566
634
  if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" && !Array.isArray(fieldOrObjOrFn)) {
567
635
  Object.keys(fieldOrObjOrFn).forEach(function (k) { sub2.eq(k, fieldOrObjOrFn[k]); });
568
636
  } else if (op === undefined) {
@@ -592,6 +660,8 @@ class Query {
592
660
  throw new Error("search(fields, term): fields must be a non-empty array of column names");
593
661
  }
594
662
  fields.forEach(_validateField);
663
+ var selfS = this;
664
+ fields.forEach(function (f) { selfS._assertColumnMember(f, "search"); });
595
665
  if (term === undefined || term === null) return this;
596
666
  if (typeof term !== "string") {
597
667
  throw new Error("search(fields, term): term must be a string");
@@ -684,14 +754,18 @@ class Query {
684
754
  // `.build()` returns `{ sql, params }`. Empty builder → `{ sql: "",
685
755
  // params: [] }`.
686
756
  class WhereBuilder {
687
- constructor() {
757
+ constructor(gate) {
688
758
  this._parts = []; // [{ joiner: "AND"|"OR", sql: "...", params: [...] }]
759
+ // The owning Query, so grouped/OR sub-expressions enforce the
760
+ // same column-membership gate as the top-level chain.
761
+ this._gate = gate || null;
689
762
  }
690
763
  _push(joiner, field, op, value) {
691
764
  if (typeof field !== "string" || field.length === 0) {
692
765
  throw new Error("WhereBuilder: field must be a non-empty string");
693
766
  }
694
767
  _validateField(field);
768
+ if (this._gate) this._gate._assertColumnMember(field, "whereGroup");
695
769
  var qf = '"' + field + '"';
696
770
  if (op === "IN" || op === "NOT IN") {
697
771
  if (!Array.isArray(value) || value.length === 0) {
@@ -723,10 +797,11 @@ class WhereBuilder {
723
797
  orLte(f, v) { return this._push("OR", f, "<=", v); }
724
798
  orIn(f, vs) { return this._push("OR", f, "IN", vs); }
725
799
  orLike(f, v) { return this._push("OR", f, "LIKE", v); }
726
- raw(sql, params) {
800
+ raw(sql, params, opts) {
727
801
  if (typeof sql !== "string" || sql.length === 0) {
728
802
  throw new Error("WhereBuilder.raw: sql must be a non-empty string");
729
803
  }
804
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql, "WhereBuilder.raw");
730
805
  var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
731
806
  if (_countPlaceholders(sql) !== p.length) {
732
807
  throw new Error("WhereBuilder.raw: placeholder count mismatch");
@@ -752,6 +827,53 @@ class WhereBuilder {
752
827
  // Tracks SQL single-quoted, double-quoted, line-comment, and block-
753
828
  // comment state to avoid counting `?` characters that are part of
754
829
  // literal text the SQL engine never interprets as a binding marker.
830
+ // Refuse raw SQL fragments that embed a single-quoted string
831
+ // literal. A whereRaw / WhereBuilder.raw fragment is meant to be a
832
+ // STATIC template whose every value is bound through a `?` placeholder;
833
+ // an embedded `'...'` literal is the signature of operator input
834
+ // concatenated into the query (CWE-89 / CWE-564 — concat into a
835
+ // query builder). Double-quoted identifiers (`"col"`), line comments,
836
+ // and block comments are skipped. Operators with a deliberate static
837
+ // literal pass `{ allowLiterals: true }`. Shares the quote/comment
838
+ // scanning shape with _countPlaceholders.
839
+ function _assertRawNoStringLiteral(sql, where) {
840
+ var i = 0;
841
+ var len = sql.length;
842
+ while (i < len) {
843
+ var ch = sql.charAt(i);
844
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
845
+ if (ch === '"') {
846
+ i += 1;
847
+ while (i < len) {
848
+ if (sql.charAt(i) === '"') {
849
+ if (sql.charAt(i + 1) === '"') { i += 2; continue; }
850
+ i += 1; break;
851
+ }
852
+ i += 1;
853
+ }
854
+ continue;
855
+ }
856
+ if (ch === "-" && next === "-") {
857
+ while (i < len && sql.charAt(i) !== "\n") i += 1;
858
+ continue;
859
+ }
860
+ if (ch === "/" && next === "*") {
861
+ i += 2;
862
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
863
+ i += 2;
864
+ continue;
865
+ }
866
+ if (ch === "'") {
867
+ throw new safeSql.SafeSqlError(
868
+ where + ": raw SQL must not contain a string literal ('...') — bind every " +
869
+ "value with a ? placeholder, or pass { allowLiterals: true } when the literal " +
870
+ "is static and operator-controlled.",
871
+ "sql/raw-literal");
872
+ }
873
+ i += 1;
874
+ }
875
+ }
876
+
755
877
  function _countPlaceholders(sql) {
756
878
  var count = 0;
757
879
  var i = 0;
package/lib/db.js CHANGED
@@ -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,