@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.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +157 -117
- package/lib/asset-manifest.json +1 -1
- package/lib/currency-rounding.js +2 -14
- package/lib/index.js +1 -0
- package/lib/text-guard.js +227 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +3 -2
- package/lib/vendor/blamejs/SECURITY.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/agent-event-bus.js +4 -4
- package/lib/vendor/blamejs/lib/agent-idempotency.js +6 -6
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +9 -9
- package/lib/vendor/blamejs/lib/agent-posture-chain.js +10 -10
- package/lib/vendor/blamejs/lib/agent-saga.js +6 -7
- package/lib/vendor/blamejs/lib/agent-snapshot.js +8 -8
- package/lib/vendor/blamejs/lib/agent-stream.js +3 -3
- package/lib/vendor/blamejs/lib/agent-tenant.js +4 -4
- package/lib/vendor/blamejs/lib/agent-trace.js +5 -5
- package/lib/vendor/blamejs/lib/ai-disclosure.js +3 -3
- package/lib/vendor/blamejs/lib/app.js +2 -2
- package/lib/vendor/blamejs/lib/archive-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-tar-read.js +1 -1
- package/lib/vendor/blamejs/lib/archive-wrap.js +5 -5
- package/lib/vendor/blamejs/lib/audit-tools.js +65 -5
- package/lib/vendor/blamejs/lib/audit.js +2 -2
- package/lib/vendor/blamejs/lib/auth/ciba.js +1 -1
- package/lib/vendor/blamejs/lib/auth/dpop.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fal.js +1 -1
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +2 -3
- package/lib/vendor/blamejs/lib/auth/jwt-external.js +2 -2
- package/lib/vendor/blamejs/lib/auth/oauth.js +9 -9
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +7 -7
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +1 -1
- package/lib/vendor/blamejs/lib/auth/openid-federation.js +5 -5
- package/lib/vendor/blamejs/lib/auth/passkey.js +6 -6
- package/lib/vendor/blamejs/lib/auth/saml.js +1 -1
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -6
- package/lib/vendor/blamejs/lib/backup/index.js +18 -18
- package/lib/vendor/blamejs/lib/cache.js +4 -4
- package/lib/vendor/blamejs/lib/calendar.js +5 -5
- package/lib/vendor/blamejs/lib/circuit-breaker.js +1 -1
- package/lib/vendor/blamejs/lib/cms-codec.js +2 -2
- package/lib/vendor/blamejs/lib/compliance.js +14 -14
- package/lib/vendor/blamejs/lib/crypto-field.js +58 -21
- package/lib/vendor/blamejs/lib/crypto.js +5 -6
- package/lib/vendor/blamejs/lib/db-query.js +131 -9
- package/lib/vendor/blamejs/lib/db.js +106 -22
- package/lib/vendor/blamejs/lib/external-db.js +64 -16
- package/lib/vendor/blamejs/lib/framework-schema.js +4 -4
- package/lib/vendor/blamejs/lib/guard-list-id.js +2 -2
- package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -2
- package/lib/vendor/blamejs/lib/incident-report.js +150 -0
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
- package/lib/vendor/blamejs/lib/mail-deploy.js +3 -3
- package/lib/vendor/blamejs/lib/mail-server-managesieve.js +2 -2
- package/lib/vendor/blamejs/lib/mail-server-pop3.js +2 -2
- package/lib/vendor/blamejs/lib/mail-store.js +1 -1
- package/lib/vendor/blamejs/lib/metrics.js +8 -8
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/dpop.js +5 -5
- package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +21 -22
- package/lib/vendor/blamejs/lib/middleware/protected-resource-metadata.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +2 -2
- package/lib/vendor/blamejs/lib/network-dns.js +1 -2
- package/lib/vendor/blamejs/lib/network-tls.js +0 -1
- package/lib/vendor/blamejs/lib/outbox.js +1 -1
- package/lib/vendor/blamejs/lib/pqc-agent.js +1 -1
- package/lib/vendor/blamejs/lib/retention.js +1 -1
- package/lib/vendor/blamejs/lib/retry.js +1 -1
- package/lib/vendor/blamejs/lib/safe-archive.js +2 -2
- package/lib/vendor/blamejs/lib/safe-ical.js +2 -2
- package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +1 -1
- package/lib/vendor/blamejs/lib/self-update.js +2 -2
- package/lib/vendor/blamejs/lib/static.js +1 -1
- package/lib/vendor/blamejs/lib/subject.js +2 -2
- package/lib/vendor/blamejs/lib/vault/index.js +64 -1
- package/lib/vendor/blamejs/lib/vault/rotate.js +19 -0
- package/lib/vendor/blamejs/lib/vendor-data.js +1 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.7.json +77 -0
- package/lib/vendor/blamejs/scripts/release.js +28 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-dual-control.test.js +115 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +163 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +150 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-key-aad.test.js +109 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/incident-report.test.js +65 -0
- package/lib/webhook-subscriptions.js +11 -24
- package/lib/webhooks.js +12 -33
- package/package.json +1 -1
|
@@ -52,7 +52,7 @@ var compliance = lazyRequire(function () { return require("./compliance"); })
|
|
|
52
52
|
var db = lazyRequire(function () { return require("./db"); });
|
|
53
53
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
54
54
|
|
|
55
|
-
//
|
|
55
|
+
// Posture cascade hook + erase-vacuum integration. Recording the
|
|
56
56
|
// posture lets eraseRow call b.db.vacuumAfterErase({ mode: "full" })
|
|
57
57
|
// automatically under postures whose POSTURE_DEFAULTS sets
|
|
58
58
|
// requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
|
|
@@ -113,7 +113,7 @@ function getActivePosture() { return _activePosture; }
|
|
|
113
113
|
// Per-table registry, populated by db.init()
|
|
114
114
|
var schemas = Object.create(null);
|
|
115
115
|
|
|
116
|
-
//
|
|
116
|
+
// Per-COLUMN data residency registry. Real GDPR / DPDP
|
|
117
117
|
// deployments have row-level mixed residency: a `users.name` column
|
|
118
118
|
// may be global, but `users.addressLine1` must stay in EU storage.
|
|
119
119
|
// db.init({ schema }) carries the operator's residency declaration
|
|
@@ -123,7 +123,7 @@ var schemas = Object.create(null);
|
|
|
123
123
|
// { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
|
|
124
124
|
var columnResidency = Object.create(null);
|
|
125
125
|
|
|
126
|
-
//
|
|
126
|
+
// Per-row key declaration registry. For tables that opt
|
|
127
127
|
// into per-row keying, b.subject.eraseHard deletes the wrapped K_row
|
|
128
128
|
// from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
|
|
129
129
|
// undecryptable.
|
|
@@ -152,7 +152,7 @@ var perRowKeyTables = Object.create(null);
|
|
|
152
152
|
* // b.vault.aad — AEAD-binds the ciphertext
|
|
153
153
|
* // to (table, rowIdField=primary key, column)
|
|
154
154
|
* // so a DB-write attacker can't copy a
|
|
155
|
-
* // sealed value between rows.
|
|
155
|
+
* // sealed value between rows.
|
|
156
156
|
* rowIdField: string, // when aad=true, the column name carrying
|
|
157
157
|
* // the row identity. Default "id". The row
|
|
158
158
|
* // passed to sealRow MUST already have this
|
|
@@ -173,7 +173,7 @@ var perRowKeyTables = Object.create(null);
|
|
|
173
173
|
* });
|
|
174
174
|
* b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
|
|
175
175
|
*
|
|
176
|
-
* // AAD-bound table (recommended for new schemas
|
|
176
|
+
* // AAD-bound table (recommended for new schemas).
|
|
177
177
|
* b.cryptoField.registerTable("idempotency_keys", {
|
|
178
178
|
* sealedFields: ["headers", "body"],
|
|
179
179
|
* aad: true,
|
|
@@ -185,16 +185,56 @@ function registerTable(name, opts) {
|
|
|
185
185
|
var rowIdField = typeof opts.rowIdField === "string" && opts.rowIdField.length > 0
|
|
186
186
|
? opts.rowIdField : "id";
|
|
187
187
|
var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
|
|
188
|
+
var derivedHashMode = opts.derivedHashMode || "salted-sha3";
|
|
189
|
+
if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
|
|
190
|
+
throw new Error("registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
|
|
191
|
+
"'hmac-shake256', got " + JSON.stringify(derivedHashMode));
|
|
192
|
+
}
|
|
193
|
+
var derivedHashes = Object.assign({}, opts.derivedHashes || {});
|
|
194
|
+
for (var col in derivedHashes) {
|
|
195
|
+
if (!Object.prototype.hasOwnProperty.call(derivedHashes, col)) continue;
|
|
196
|
+
var colMode = derivedHashes[col] && derivedHashes[col].mode;
|
|
197
|
+
if (colMode !== undefined && colMode !== "salted-sha3" && colMode !== "hmac-shake256") {
|
|
198
|
+
throw new Error("registerTable: derivedHashes." + col + ".mode must be " +
|
|
199
|
+
"'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
188
202
|
schemas[name] = {
|
|
189
|
-
sealedFields:
|
|
190
|
-
derivedHashes:
|
|
191
|
-
hashNamespaces:
|
|
192
|
-
aad:
|
|
193
|
-
rowIdField:
|
|
194
|
-
schemaVersion:
|
|
203
|
+
sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
|
|
204
|
+
derivedHashes: derivedHashes,
|
|
205
|
+
hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
|
|
206
|
+
aad: aadOn,
|
|
207
|
+
rowIdField: rowIdField,
|
|
208
|
+
schemaVersion: schemaVersion,
|
|
209
|
+
derivedHashMode: derivedHashMode,
|
|
195
210
|
};
|
|
196
211
|
}
|
|
197
212
|
|
|
213
|
+
// Derived-hash digest width for the keyed (hmac-shake256) mode: 32
|
|
214
|
+
// bytes -> 64 hex chars.
|
|
215
|
+
var DERIVED_HASH_BYTES = 32;
|
|
216
|
+
|
|
217
|
+
// Compute the indexed-lookup digest for a derived-hash column.
|
|
218
|
+
// - "salted-sha3" (default): SHA3-512 over <per-deployment salt> + ns
|
|
219
|
+
// + value (128 hex). Deterministic per deployment.
|
|
220
|
+
// - "hmac-shake256": SHAKE256(<vault-sealed MAC key> || ns + value)
|
|
221
|
+
// truncated to 32 bytes (64 hex). The key is a vault-derived secret,
|
|
222
|
+
// NOT a static salt, so an attacker who recovers the salt alone
|
|
223
|
+
// can't correlate two low-entropy plaintexts; the sponge has no
|
|
224
|
+
// length-extension weakness. (b.crypto.hmacSha3 (HMAC-SHA3-512) was
|
|
225
|
+
// considered; SHAKE256(key||msg) is chosen for the fixed-width keyed
|
|
226
|
+
// digest with the same MAC-grade guarantee.) FIPS 202; NIST SP
|
|
227
|
+
// 800-185; GDPR Art. 4(5) pseudonymisation; HIPAA 45 CFR 164.514(b).
|
|
228
|
+
function _computeDerivedHash(spec, tableMode, ns, normalized) {
|
|
229
|
+
var mode = (spec && spec.mode) || tableMode || "salted-sha3";
|
|
230
|
+
if (mode === "hmac-shake256") {
|
|
231
|
+
var macKey = vault.getDerivedHashMacKey();
|
|
232
|
+
return kdf(Buffer.concat([macKey, Buffer.from(ns + normalized, "utf8")]),
|
|
233
|
+
DERIVED_HASH_BYTES).toString("hex");
|
|
234
|
+
}
|
|
235
|
+
return sha3Hash(vault.getDerivedHashSalt().toString("hex") + ns + normalized);
|
|
236
|
+
}
|
|
237
|
+
|
|
198
238
|
/**
|
|
199
239
|
* @primitive b.cryptoField.getSchema
|
|
200
240
|
* @signature b.cryptoField.getSchema(table)
|
|
@@ -308,8 +348,7 @@ function computeDerived(table, sourceField, sourceValue) {
|
|
|
308
348
|
if (spec.from === sourceField) {
|
|
309
349
|
var ns = namespaceFor(table, sourceField, s.hashNamespaces);
|
|
310
350
|
var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
|
|
311
|
-
|
|
312
|
-
return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
|
|
351
|
+
return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
|
|
313
352
|
}
|
|
314
353
|
}
|
|
315
354
|
return null;
|
|
@@ -367,12 +406,11 @@ function sealRow(table, row) {
|
|
|
367
406
|
}
|
|
368
407
|
var ns = namespaceFor(table, spec.from, s.hashNamespaces);
|
|
369
408
|
var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
|
|
370
|
-
|
|
371
|
-
out[derivedField] = sha3Hash(saltHex2 + ns + normalized);
|
|
409
|
+
out[derivedField] = _computeDerivedHash(spec, s.derivedHashMode, ns, normalized);
|
|
372
410
|
}
|
|
373
411
|
}
|
|
374
412
|
|
|
375
|
-
//
|
|
413
|
+
// AAD-bound table requires the row's identity column to
|
|
376
414
|
// be populated BEFORE sealRow runs. Sealing under a placeholder /
|
|
377
415
|
// missing rowId produces ciphertext that no later unseal can open
|
|
378
416
|
// because the AAD on read is computed against the row's actual id.
|
|
@@ -390,7 +428,7 @@ function sealRow(table, row) {
|
|
|
390
428
|
// Seal fields. Plain mode: vault.seal (idempotent — already-sealed
|
|
391
429
|
// values pass through). AAD mode: vault.aad.seal binds the AEAD tag
|
|
392
430
|
// to (table, rowId, column, schemaVersion) — cross-row copy of a
|
|
393
|
-
// ciphertext fails Poly1305 on read.
|
|
431
|
+
// ciphertext fails Poly1305 on read.
|
|
394
432
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
395
433
|
var field = s.sealedFields[i];
|
|
396
434
|
if (out[field] !== undefined && out[field] !== null) {
|
|
@@ -573,7 +611,7 @@ function eraseRow(table, row) {
|
|
|
573
611
|
out[derivedField] = null;
|
|
574
612
|
}
|
|
575
613
|
}
|
|
576
|
-
//
|
|
614
|
+
// `__erasedAt` was previously a plaintext UTC ms integer.
|
|
577
615
|
// That value alone fingerprints the erasure event (audit-log
|
|
578
616
|
// exfiltration + cross-tenant correlation: "this row was erased
|
|
579
617
|
// 2.3s before that one"). Bucket the timestamp to a 1-day floor so
|
|
@@ -584,7 +622,7 @@ function eraseRow(table, row) {
|
|
|
584
622
|
var dayMs = TIME.days(1);
|
|
585
623
|
out.__erasedAt = Math.floor(Date.now() / dayMs) * dayMs;
|
|
586
624
|
|
|
587
|
-
//
|
|
625
|
+
// Under regulatory postures whose POSTURE_DEFAULTS sets
|
|
588
626
|
// requireVacuumAfterErase: true (gdpr / dpdp / pipl-cn / lgpd-br /
|
|
589
627
|
// hipaa), the B-tree index pages freed by the upcoming UPDATE/DELETE
|
|
590
628
|
// would otherwise linger with sealed-column ciphertext readable
|
|
@@ -662,8 +700,7 @@ function lookupHash(table, field, value) {
|
|
|
662
700
|
if (spec.from === field) {
|
|
663
701
|
var ns = namespaceFor(table, field, s.hashNamespaces);
|
|
664
702
|
var normalized = spec.normalize ? spec.normalize(value) : String(value);
|
|
665
|
-
|
|
666
|
-
return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
|
|
703
|
+
return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
|
|
667
704
|
}
|
|
668
705
|
}
|
|
669
706
|
return null;
|
|
@@ -168,7 +168,7 @@ function hashFile(filePath, algorithm) {
|
|
|
168
168
|
// `algorithms` entry. Used by hashFilesParallel below; not exported
|
|
169
169
|
// directly because the common case is the parallel-many shape.
|
|
170
170
|
//
|
|
171
|
-
//
|
|
171
|
+
// Hardening (v0.9.58):
|
|
172
172
|
// - lstat-then-stat so symlinks are detected before open; refused
|
|
173
173
|
// unless opts.followSymlinks === true (default false — a symlink-
|
|
174
174
|
// in-input-list attack lets a write-restricted caller hash files
|
|
@@ -269,9 +269,8 @@ function _hashFileMulti(filePath, algorithms, opts) {
|
|
|
269
269
|
* SBOM regeneration / vendor-data integrity sweeps / release-asset
|
|
270
270
|
* bundling — situations where N files each need both SHA-256 (legacy
|
|
271
271
|
* compat) and SHA-3-512 (PQC-first) digests and rolling a worker
|
|
272
|
-
* pool by hand
|
|
273
|
-
*
|
|
274
|
-
* every release.
|
|
272
|
+
* pool by hand means the same two-loop, capture-N-promises, settle-Q
|
|
273
|
+
* boilerplate every release.
|
|
275
274
|
*
|
|
276
275
|
* @opts
|
|
277
276
|
* algorithms?: string[], // default ["sha256", "sha3-512"]; any node:crypto-known digest
|
|
@@ -333,7 +332,7 @@ function hashFilesParallel(filePaths, opts) {
|
|
|
333
332
|
"crypto.hashFilesParallel: opts.onProgress must be a function when supplied"
|
|
334
333
|
));
|
|
335
334
|
}
|
|
336
|
-
//
|
|
335
|
+
// DoS cap. Default 1 GiB per file; operators with larger
|
|
337
336
|
// legitimate hashing workloads (firmware images, vendor packs)
|
|
338
337
|
// override per-call.
|
|
339
338
|
var maxBytesPerFile = opts.maxBytesPerFile !== undefined
|
|
@@ -1202,7 +1201,7 @@ function decrypt(ciphertext, privateKeys, opts) {
|
|
|
1202
1201
|
}
|
|
1203
1202
|
// Audit-emit every legacy decrypt so the migration window is
|
|
1204
1203
|
// visible. Emit success ONLY on actual decrypt success; emit
|
|
1205
|
-
// failure on throw.
|
|
1204
|
+
// failure on throw. Before this fix, the audit fired
|
|
1206
1205
|
// before decryptEnvelope() ran, so corrupted 0xE1 blobs / wrong
|
|
1207
1206
|
// private keys / unsupported KEMs got logged as successful legacy
|
|
1208
1207
|
// decrypts when the call actually threw, inflating real success
|
|
@@ -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;
|