@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.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -2
- package/lib/agent-event-bus.js +4 -4
- package/lib/agent-idempotency.js +6 -6
- package/lib/agent-orchestrator.js +9 -9
- package/lib/agent-posture-chain.js +10 -10
- package/lib/agent-saga.js +6 -7
- package/lib/agent-snapshot.js +8 -8
- package/lib/agent-stream.js +3 -3
- package/lib/agent-tenant.js +4 -4
- package/lib/agent-trace.js +5 -5
- package/lib/ai-disclosure.js +3 -3
- package/lib/app.js +2 -2
- package/lib/archive-read.js +1 -1
- package/lib/archive-tar-read.js +1 -1
- package/lib/archive-wrap.js +5 -5
- package/lib/audit-tools.js +65 -5
- package/lib/audit.js +2 -2
- package/lib/auth/ciba.js +1 -1
- package/lib/auth/dpop.js +1 -1
- package/lib/auth/fal.js +1 -1
- package/lib/auth/fido-mds3.js +2 -3
- package/lib/auth/jwt-external.js +2 -2
- package/lib/auth/oauth.js +9 -9
- package/lib/auth/oid4vci.js +7 -7
- package/lib/auth/oid4vp.js +1 -1
- package/lib/auth/openid-federation.js +5 -5
- package/lib/auth/passkey.js +6 -6
- package/lib/auth/saml.js +1 -1
- package/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/backup/index.js +18 -18
- package/lib/cache.js +4 -4
- package/lib/calendar.js +5 -5
- package/lib/circuit-breaker.js +1 -1
- package/lib/cms-codec.js +2 -2
- package/lib/compliance.js +14 -14
- package/lib/cra-report.js +3 -3
- package/lib/crypto-field.js +58 -21
- package/lib/crypto.js +5 -6
- package/lib/db-query.js +131 -9
- package/lib/db.js +106 -22
- package/lib/external-db.js +64 -16
- package/lib/framework-schema.js +4 -4
- package/lib/guard-list-id.js +2 -2
- package/lib/guard-list-unsubscribe.js +1 -2
- package/lib/incident-report.js +150 -0
- package/lib/mail-crypto-smime.js +1 -1
- package/lib/mail-deploy.js +3 -3
- package/lib/mail-server-managesieve.js +2 -2
- package/lib/mail-server-pop3.js +2 -2
- package/lib/mail-store.js +1 -1
- package/lib/metrics.js +8 -8
- package/lib/middleware/age-gate.js +20 -7
- package/lib/middleware/bearer-auth.js +36 -35
- package/lib/middleware/bot-guard.js +17 -5
- package/lib/middleware/cors.js +28 -12
- package/lib/middleware/csrf-protect.js +23 -15
- package/lib/middleware/daily-byte-quota.js +27 -13
- package/lib/middleware/deny-response.js +140 -0
- package/lib/middleware/dpop.js +37 -24
- package/lib/middleware/fetch-metadata.js +21 -12
- package/lib/middleware/host-allowlist.js +19 -8
- package/lib/middleware/idempotency-key.js +21 -22
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/network-allowlist.js +24 -10
- package/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/middleware/rate-limit.js +22 -5
- package/lib/middleware/require-aal.js +25 -10
- package/lib/middleware/require-auth.js +32 -16
- package/lib/middleware/require-bound-key.js +49 -18
- package/lib/middleware/require-content-type.js +19 -8
- package/lib/middleware/require-methods.js +17 -7
- package/lib/middleware/require-mtls.js +27 -14
- package/lib/network-dns-resolver.js +2 -2
- package/lib/network-dns.js +1 -2
- package/lib/network-tls.js +0 -1
- package/lib/network.js +4 -4
- package/lib/outbox.js +1 -1
- package/lib/pqc-agent.js +1 -1
- package/lib/retention.js +1 -1
- package/lib/retry.js +1 -1
- package/lib/safe-archive.js +2 -2
- package/lib/safe-ical.js +2 -2
- package/lib/safe-mime.js +1 -1
- package/lib/self-update-standalone-verifier.js +1 -1
- package/lib/self-update.js +2 -2
- package/lib/static.js +1 -1
- package/lib/subject.js +2 -2
- package/lib/vault/index.js +64 -1
- package/lib/vault/rotate.js +19 -0
- package/lib/vendor-data.js +1 -1
- package/package.json +1 -1
- 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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 —
|
|
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
|
|
618
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
1268
|
-
derivedHashes:
|
|
1269
|
-
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
|
-
// ----
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,
|