@blamejs/core 0.15.2 → 0.15.4
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/backup/index.js +32 -0
- package/lib/crypto-field.js +34 -0
- package/lib/db-query.js +154 -1
- package/lib/db-schema.js +25 -6
- package/lib/db.js +26 -1
- package/lib/dsr.js +10 -6
- package/lib/external-db.js +19 -2
- package/lib/middleware/dpop.js +10 -1
- package/lib/network-proxy.js +24 -1
- package/lib/observability-otlp-exporter.js +9 -1
- package/lib/observability.js +37 -0
- package/lib/otel-export.js +12 -27
- package/lib/safe-sql.js +88 -0
- package/lib/session.js +59 -5
- package/lib/sql.js +61 -104
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.4 (2026-06-12) — **Telemetry attribute values are redacted before they leave the process, per-row data residency is enforced on every write and export path, DDL routes through the single-statement gate, the DPoP middleware requires its replay store, and session rotation re-keys the device binding.** This release closes a set of egress, data-residency, and session-binding gaps. OTLP span, span-event, and resource attributes are now scrubbed through the telemetry redactor before serialization, on both the JSON and protobuf paths, matching the metric exporter - an attribute value holding a bearer token, password, or API key no longer reaches the collector verbatim. Per-row data residency, previously enforced only at the structured query builder, is now enforced on the three paths that bypassed it: raw SQL writes, read-replica fan-out, and backup export. Every CREATE TABLE / ALTER TABLE the schema reconciler and the DSR store emit now passes through the same single-statement gate the query builder uses. The DPoP middleware now requires its replay store at mount time instead of silently mounting a proof-of-possession gate that performed no replay check. And session rotation re-keys the device fingerprint to the new session id, so a rotated session stays bound to its device instead of falsely reporting drift on the next request. **Fixed:** *Session rotation re-keys the device fingerprint to the new session id* — A session's optional device fingerprint is keyed to its session id, so that a stolen database cannot replay the binding. `b.session.rotate` moved the session id but left the stored fingerprint keyed to the old id, so the next `verify` recomputed the fingerprint against the new id and mismatched - reporting a false `fingerprintDrift` (which destroys the session under strict operators, logging the user out on every rotation) or silently breaking the binding. Rotation now re-keys the fingerprint to the new session id from the live request: pass the same `{ req, fingerprintFields }` to `rotate`. A fingerprint-bound session rotated without `req` now throws, because the binding cannot otherwise follow the new id; unbound sessions are unaffected. **Security:** *OTLP exporter redacts span, event, and resource attribute values before egress* — Span, span-event, and resource attributes were serialized to the OTLP collector verbatim on both the JSON and protobuf encodings - the metric exporter scrubbed its attributes through the telemetry redactor, but the span exporter did not. An attribute value carrying a secret or PII (a bearer token in `authorization`, a `password`, an `api_key`) was therefore shipped in clear to whatever collector the deployment points at (CWE-532). Every attribute-map encoder now runs each value through `b.observability.redactAttrs` (default composes `b.redact.redact`, dropping any attribute whose redactor throws) before the wire payload, so telemetry is redacted like the log and audit egress paths. The new `b.observability.redactAttrs(attrs)` is available for operators building custom exporters. · *Per-row data residency is enforced on raw writes, read replicas, and backups* — Per-row residency was enforced only at the structured query builder. Three paths reached storage or left the deployment without it: raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, INSERT and UPDATE forms) bypassed the local residency check entirely, so a cross-border row could be written under a regulated posture with no refusal; read-replica fan-out dropped the row-residency tag, routing a regulated read with no row region identified to a residency-tagged, non-cross-border replica with no check; and `b.backup.create`'s residency check compared only the single deployment region to the destination, blind to a per-row-residency table that admits rows from several regions. Raw writes now parse the target table and residency value and apply the same gate the builder does; the replica read now fails closed when the row region is unidentified; and backup now emits a per-row cross-border advisory for any declared residency table whose admitted regions differ from the backup destination. · *Schema and DSR DDL routes through the single-statement gate* — The CREATE TABLE / ALTER TABLE statements emitted by the schema reconciler and the DSR ticket store were assembled and run without the single-statement / NUL / unterminated-quote / unbalanced-paren gate that every query the builder emits already passes. That gate is now a shared check both the builder's catalog emitter and the schema/DSR DDL path call, so a terminator, comment marker, or unbalanced quote that reached a DDL fragment is refused at emit time on every backend. · *DPoP middleware requires its replay store at mount time* — `b.middleware.dpop` documented `replayStore` as required, but the factory read it optionally and gated the jti-replay check behind its presence - omitting it mounted a proof-of-possession gate that performed no replay check, so a captured DPoP proof could be replayed indefinitely (RFC 9449 §11.1). The middleware now requires the store at config time: a missing store, or a store lacking `checkAndInsert`, throws when the middleware is created instead of failing open at request time. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track jti themselves.
|
|
12
|
+
|
|
13
|
+
- v0.15.3 (2026-06-12) — **DDL hardening in b.sql, schema-confined column introspection on Postgres and MySQL, and a classical-downgrade audit on proxy-tunneled TLS.** This release hardens the data layer and closes a transport audit gap. The b.sql builder refuses an unrecognised column type that carries a statement terminator, quote, or comment marker - the one position in an otherwise quote-by-construction DDL builder where a verbatim string reached the emitted statement - and routes the finished CREATE TABLE through the same single-statement gate every other verb uses. The schema reconciler's column introspection is now confined to the schema or database the bare-named CREATE TABLE actually writes into (current_schema() on Postgres, DATABASE() on MySQL), so a same-named table in another schema no longer pollutes the column set, silently skipping an ADD COLUMN or fabricating false schema drift that refuses a regulated-posture boot. Two further builder gaps are fixed: a column-level primary key combined with a composite primaryKey now fails at build time with a clear error instead of producing invalid DDL, and a MySQL upsert read-back keyed by a cast or a server-evaluated function now renders the cast (or refuses the function) instead of binding an internal wrapper. Finally, an HTTPS request sent through a configured proxy now emits the tls.classical_downgrade audit when the handshake falls back to a classical group, the same as a direct connection. **Fixed:** *Schema reconciliation reads columns from the right schema on Postgres and MySQL* — The reconciler's column introspection queried information_schema with no schema filter, so on a Postgres instance or MySQL server hosting more than one schema/database with a same-named table, the live column set was the union across schemas. That could silently skip an ADD COLUMN the table needed, or report false drift that refuses a boot under a pinned regulated posture. Introspection is now confined to current_schema() (Postgres) / DATABASE() (MySQL) - the schema the bare-named CREATE TABLE lands in. SQLite (PRAGMA, per-file) is unchanged. · *createTable rejects a contradictory primary-key declaration at build time* — Declaring both a column-level primary key (primaryKey / autoIncrement / serial) and a composite opts.primaryKey emitted two PRIMARY KEY clauses, which every dialect rejects at the driver mid-migration. The builder now refuses the contradiction at build time with a clear error; a single column PK, or a composite primaryKey with no column-level PK, is unaffected. · *MySQL upsert read-back resolves a cast or function conflict key instead of binding a wrapper* — On MySQL, an upsert whose conflict key was a b.sql.cast(...) or b.sql.fn(...) built a read-back SELECT that bound the wrapper object, so the read-back matched no rows. A cast conflict key now renders as CAST(? AS type) binding the inner value; a server-evaluated function conflict key (which has no stable read-back identity) is refused with a clear error. Plain scalar conflict keys are unchanged. · *Proxy-tunneled TLS emits the classical-downgrade audit* — An HTTPS upstream reached through a configured proxy performed its TLS handshake without emitting the tls.classical_downgrade audit on a classical-group fallback, leaving the post-quantum-readiness inventory incomplete for proxied requests. Both the upstream handshake and the proxy-leg handshake now emit the audit on a classical fallback, matching the direct connection path. The handshake itself is unchanged (still hybrid-preferred TLSv1.3). **Security:** *b.sql refuses an injection-bearing verbatim column type and gates every CREATE TABLE* — An unrecognised column type passed to b.sql.createTable / alterTable was emitted into the DDL verbatim - the single raw-emission position in a builder that otherwise quotes every identifier and guards every constraint fragment. A type such as "text); DROP TABLE secrets; --" could therefore smuggle a stacked statement. The builder now refuses, at build time, a verbatim type carrying a statement terminator or comment marker, and routes the finished CREATE TABLE / ALTER TABLE statement through the same single-statement / NUL / unterminated-quote / unbalanced-paren gate every SELECT / INSERT / UPDATE / DELETE / UPSERT already used - so an unbalanced quote is caught there. Legitimate types are unaffected: VARCHAR(255), NUMERIC(10,2), DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, and MySQL ENUM('a','b') / SET(...) (which need balanced quotes) all still pass.
|
|
14
|
+
|
|
11
15
|
- v0.15.2 (2026-06-12) — **Object keys with a space, +, &, or other reserved characters now sign correctly against S3-compatible stores and Google Cloud Storage.** The SigV4 request signer (and the Google Cloud Storage V4 signer that shares it) URI-encoded the object-key path a second time when building the canonical request it signs, while the request on the wire carried the path encoded once. Amazon S3 — and every S3-compatible store such as MinIO, Cloudflare R2, Wasabi, and Backblaze B2, plus GCS — signs the canonical path encoded exactly once, so any object key containing a space, a +, an &, parentheses, or a non-ASCII character was signed over a path the server never received and the request was rejected with HTTP 403 SignatureDoesNotMatch. Keys built only from unreserved characters were unaffected, which is why the regression went unnoticed. This release makes the canonical-path encoding match the service: S3 and GCS encode the path once, while the AWS services that genuinely require a second encoding (SQS, CloudWatch Logs, SNS) keep it. Object reads, writes, deletes, listings, presigned URLs, and backup or restore through the object-store adapter now succeed for keys with reserved characters. Separately, the bucket-operations key encoder now uses the AWS reserved-character set, so a key containing !, *, ', or ( is escaped to match the bytes the store signs over. **Fixed:** *SigV4 and GCS V4 sign object-key paths with the single encoding S3 and GCS expect* — A request to read, write, delete, list, or presign an object whose key contained a space, +, &, (, ), or a non-ASCII character failed with 403 SignatureDoesNotMatch against S3, every S3-compatible store, and Google Cloud Storage, because the canonical request double-encoded the path the wire carried once. The signer is now service-aware: S3 and GCS sign the path encoded once (matching the wire and the store's own canonicalization), while SQS, CloudWatch Logs, and SNS keep the second encoding the AWS spec requires for those services. No configuration change or migration is needed — object operations and presigned URLs for keys with reserved characters simply start working. Object keys made only of unreserved characters are byte-for-byte unchanged. · *Bucket-operations key encoder escapes the AWS reserved set* — The bucket-level object operations encoded key path segments with a generic URI encoder that left !, *, ', and ( unescaped. Those now route through the same AWS encoder the read and write paths use, so a key containing one of those characters is escaped consistently and signs correctly.
|
|
12
16
|
|
|
13
17
|
- v0.15.1 (2026-06-11) — **Sealed-column lookups find rows written before the v0.15.0 hash change, and API-key secrets re-hash to the active algorithm on verify.** v0.15.0 changed the default derived hash — the blind index a sealed column is looked up by — from an unkeyed salted hash to a keyed MAC, and promised a transparent migration via a dual read. But no lookup path actually performed the dual read, so on a deployment that already held data, a lookup by a sealed column (a session's user id, an API key's owner, an audit actor or resource, a consent or data-subject id, a mail thread) computed only the new keyed digest and missed every row written before the upgrade. This release wires the dual read into every lookup: a sealed-column equality now matches both the active keyed digest and the legacy salted digest, so pre-upgrade rows are found again. That restores two correctness guarantees the gap had quietly broken — revoking all of a user's sessions no longer skips sessions created before the upgrade, and a subject erasure no longer leaves pre-upgrade rows behind. Separately, the framework's API-key store now re-hashes a stored secret to the active hash algorithm on the next successful verify, the transparent rotation the credential-hash primitive documented but the store had never performed. **Fixed:** *Sealed-column lookups match rows written before the v0.15.0 keyed-MAC change* — After v0.15.0 flipped the default derived-hash mode to a keyed MAC, a lookup by a sealed column computed only the new keyed digest, so rows written under the previous salted-hash default were no longer found — a silent index miss on every existing deployment. Every framework lookup path now dual-reads: `b.db.from(...).where(sealedField, value)` and the framework's own api-key, session, audit, consent, data-subject, and mail-thread lookups match the column against both the active keyed digest and the legacy salted digest (`b.db.hashCandidatesFor` exposes the candidate list for operator code). No migration or operator action is required; rows re-hash to the keyed form on read over time and the candidate set collapses back to a single value. Two correctness consequences are restored: revoking all of a user's sessions now also revokes sessions created before the upgrade, and a data-subject erasure now also deletes (and crypto-shreds) the subject's pre-upgrade rows. · *API-key secret hashes upgrade to the active algorithm on verify* — The framework's API-key store now re-hashes a stored secret to the configured hash algorithm on the next successful verify (leader-gated, best-effort, primary-match only), emitting an `apikey.secret_rehash` audit and observability event. This is the transparent rotation `b.credentialHash` documents — a key stored under an older algorithm or parameter set silently moves to the current one as it is used, with no change to the verify result or the returned record.
|
package/lib/backup/index.js
CHANGED
|
@@ -66,6 +66,7 @@ var compliance = lazyRequire(function () { return require("../compliance"); });
|
|
|
66
66
|
// module graph (CLI tools, stand-alone backup runners). The db()
|
|
67
67
|
// callable resolves on first access.
|
|
68
68
|
var dbModuleLazy = lazyRequire(function () { return require("../db"); });
|
|
69
|
+
var cryptoField = lazyRequire(function () { return require("../crypto-field"); });
|
|
69
70
|
var archiveLazy = lazyRequire(function () { return require("../archive"); });
|
|
70
71
|
var archiveAdaptersLazy = lazyRequire(function () { return require("../archive-adapters"); });
|
|
71
72
|
var { defineClass } = require("../framework-error");
|
|
@@ -417,6 +418,37 @@ function create(opts) {
|
|
|
417
418
|
});
|
|
418
419
|
} catch (_e) { /* drop-silent */ }
|
|
419
420
|
}
|
|
421
|
+
// Per-row residency blind spot: the deployment-level check above only
|
|
422
|
+
// compares the single DB region to the destination. A per-row-residency
|
|
423
|
+
// table is DECLARED (cryptoField.declarePerRowResidency) to admit rows in
|
|
424
|
+
// several regions; rows tagged to a region other than the backup
|
|
425
|
+
// destination are a per-row cross-border transfer the deployment compare
|
|
426
|
+
// cannot see. Surface the declared cross-border regions (policy-based —
|
|
427
|
+
// no row scan) so the bundle's residency exposure is visible.
|
|
428
|
+
if (backupResidencyTag) {
|
|
429
|
+
try {
|
|
430
|
+
var perRowTables = cryptoField().listPerRowResidency();
|
|
431
|
+
var perRowCrossBorder = [];
|
|
432
|
+
for (var pri = 0; pri < perRowTables.length; pri++) {
|
|
433
|
+
var prt = perRowTables[pri];
|
|
434
|
+
var offending = (prt.allowedTags || []).filter(function (tag) {
|
|
435
|
+
return tag !== "global" && tag !== "unrestricted" && tag !== backupResidencyTag;
|
|
436
|
+
});
|
|
437
|
+
if (offending.length) perRowCrossBorder.push({ table: prt.table, regions: offending });
|
|
438
|
+
}
|
|
439
|
+
if (perRowCrossBorder.length) {
|
|
440
|
+
audit().safeEmit({
|
|
441
|
+
action: "backup.residency.per_row_cross_border",
|
|
442
|
+
outcome: "success",
|
|
443
|
+
metadata: {
|
|
444
|
+
severity: "warning", scope: "per-row", posture: posture,
|
|
445
|
+
destination: backupResidencyTag, tables: perRowCrossBorder,
|
|
446
|
+
recommendation: "a per-row-residency table admits rows in regions other than the backup destination; confirm the cross-border transfer is permitted (allowCrossBorder + documented legalBasis) or restrict the destination region",
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
} catch (_e) { /* drop-silent — advisory only */ }
|
|
451
|
+
}
|
|
420
452
|
}
|
|
421
453
|
|
|
422
454
|
var dataDir = opts.dataDir;
|
package/lib/crypto-field.js
CHANGED
|
@@ -1823,6 +1823,39 @@ function getPerRowResidency(table) {
|
|
|
1823
1823
|
return { residencyColumn: spec.residencyColumn, allowedTags: spec.allowedTags.slice() };
|
|
1824
1824
|
}
|
|
1825
1825
|
|
|
1826
|
+
/**
|
|
1827
|
+
* @primitive b.cryptoField.listPerRowResidency
|
|
1828
|
+
* @signature b.cryptoField.listPerRowResidency()
|
|
1829
|
+
* @since 0.15.4
|
|
1830
|
+
* @related b.cryptoField.getPerRowResidency, b.cryptoField.declarePerRowResidency
|
|
1831
|
+
*
|
|
1832
|
+
* Enumerate every table opted into per-row residency. Returns one entry per
|
|
1833
|
+
* declared table — `{ table, residencyColumn, allowedTags }` — where
|
|
1834
|
+
* `allowedTags` lists the regions that table's rows may be tagged to.
|
|
1835
|
+
* Read-only. Consumers that must reason about residency across the whole
|
|
1836
|
+
* deployment rather than one table use this: `b.backup.create` enumerates it
|
|
1837
|
+
* to surface the per-row cross-border regions a deployment-level region
|
|
1838
|
+
* compare is blind to.
|
|
1839
|
+
*
|
|
1840
|
+
* @example
|
|
1841
|
+
* b.cryptoField.declarePerRowResidency("residents", {
|
|
1842
|
+
* residencyColumn: "region",
|
|
1843
|
+
* allowedTags: ["eu-west-1", "us-east-1"],
|
|
1844
|
+
* });
|
|
1845
|
+
* b.cryptoField.listPerRowResidency();
|
|
1846
|
+
* // → [ { table: "residents", residencyColumn: "region",
|
|
1847
|
+
* // allowedTags: ["eu-west-1", "us-east-1"] } ]
|
|
1848
|
+
*/
|
|
1849
|
+
function listPerRowResidency() {
|
|
1850
|
+
return Object.keys(perRowResidency).map(function (t) {
|
|
1851
|
+
return {
|
|
1852
|
+
table: t,
|
|
1853
|
+
residencyColumn: perRowResidency[t].residencyColumn,
|
|
1854
|
+
allowedTags: perRowResidency[t].allowedTags.slice(),
|
|
1855
|
+
};
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1826
1859
|
/**
|
|
1827
1860
|
* @primitive b.cryptoField.declarePerRowKey
|
|
1828
1861
|
* @signature b.cryptoField.declarePerRowKey(table, opts)
|
|
@@ -2099,6 +2132,7 @@ module.exports = {
|
|
|
2099
2132
|
assertColumnResidency: assertColumnResidency,
|
|
2100
2133
|
declarePerRowResidency: declarePerRowResidency,
|
|
2101
2134
|
getPerRowResidency: getPerRowResidency,
|
|
2135
|
+
listPerRowResidency: listPerRowResidency,
|
|
2102
2136
|
declarePerRowKey: declarePerRowKey,
|
|
2103
2137
|
hasPerRowKey: hasPerRowKey,
|
|
2104
2138
|
materializePerRowKey: materializePerRowKey,
|
package/lib/db-query.js
CHANGED
|
@@ -1365,4 +1365,157 @@ function _validateField(field) {
|
|
|
1365
1365
|
}
|
|
1366
1366
|
}
|
|
1367
1367
|
|
|
1368
|
-
|
|
1368
|
+
// ---- raw-write residency gate (execRaw / prepared-statement execution) ----
|
|
1369
|
+
// The structured builder runs every insert/update through _assertLocalResidency.
|
|
1370
|
+
// The raw paths (b.db.runSql / execRaw, b.db.prepare(sql).run(...)) bypass it, so
|
|
1371
|
+
// a cross-border row could land straight on disk under a regulated posture. These
|
|
1372
|
+
// helpers extract the residency-column value from a raw INSERT / UPDATE / REPLACE
|
|
1373
|
+
// and run it through the SAME gate; a write to a residency table the framework
|
|
1374
|
+
// cannot parse fails CLOSED (refused) - a raw write never skips the check.
|
|
1375
|
+
var _RAW_WRITE_KEYWORD_RE = /^\s*(?:INSERT|REPLACE|UPDATE)\b/i;
|
|
1376
|
+
var _RAW_INSERT_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)\s*;?\s*$/i;
|
|
1377
|
+
var _RAW_UPDATE_RE = /^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\s+([\s\S]+?)\s*;?\s*$/i;
|
|
1378
|
+
var _RAW_TABLE_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?|^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?/i;
|
|
1379
|
+
|
|
1380
|
+
function _unquoteIdent(s) {
|
|
1381
|
+
s = String(s).trim();
|
|
1382
|
+
if (s.length >= 2 &&
|
|
1383
|
+
(s.charAt(0) === '"' || s.charAt(0) === "'" || s.charAt(0) === "`") &&
|
|
1384
|
+
s.charAt(s.length - 1) === s.charAt(0)) {
|
|
1385
|
+
return s.slice(1, -1);
|
|
1386
|
+
}
|
|
1387
|
+
return s;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function _rawWriteTable(sql) {
|
|
1391
|
+
// Both regexes are ^-anchored (leading write keyword + table head): they scan
|
|
1392
|
+
// only the statement head, so they are constant-time regardless of SQL length.
|
|
1393
|
+
if (typeof sql !== "string" || !_RAW_WRITE_KEYWORD_RE.test(sql)) return null; // allow:regex-no-length-cap
|
|
1394
|
+
var m = _RAW_TABLE_RE.exec(sql); // allow:regex-no-length-cap
|
|
1395
|
+
return m ? _unquoteIdent(m[1] || m[2]) : null;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Cheap prepare-time pre-check so only writes to a residency table get wrapped.
|
|
1399
|
+
function _isRawWriteToResidencyTable(sql) {
|
|
1400
|
+
var table = _rawWriteTable(sql);
|
|
1401
|
+
if (!table) return false;
|
|
1402
|
+
return !!(cryptoField.getPerRowResidency(table) || cryptoField.getColumnResidency(table));
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function _splitTopLevelCommas(s) {
|
|
1406
|
+
var out = [], depth = 0, cur = "", q = null;
|
|
1407
|
+
for (var i = 0; i < s.length; i++) {
|
|
1408
|
+
var c = s.charAt(i);
|
|
1409
|
+
if (q) {
|
|
1410
|
+
cur += c;
|
|
1411
|
+
if (c === q) { if (s.charAt(i + 1) === q) { cur += s.charAt(++i); } else { q = null; } }
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (c === "'" || c === '"' || c === "`") { q = c; cur += c; continue; }
|
|
1415
|
+
if (c === "(") { depth += 1; cur += c; continue; }
|
|
1416
|
+
if (c === ")") { depth -= 1; cur += c; continue; }
|
|
1417
|
+
if (c === "," && depth === 0) { out.push(cur); cur = ""; continue; }
|
|
1418
|
+
cur += c;
|
|
1419
|
+
}
|
|
1420
|
+
if (cur.trim() !== "") out.push(cur);
|
|
1421
|
+
return out.map(function (x) { return x.trim(); });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Quote/paren-aware: return the SET-clause text up to the first top-level
|
|
1425
|
+
// WHERE keyword that is NOT inside a string literal or parenthesised
|
|
1426
|
+
// subexpression. A WHERE embedded in a quoted value (SET note='x WHERE
|
|
1427
|
+
// y', ...) is skipped, so a residency-column assignment after it is still
|
|
1428
|
+
// parsed and gated. Linear scan; fixed 5-char keyword peek, no per-char slice.
|
|
1429
|
+
function _setClauseBeforeWhere(s) {
|
|
1430
|
+
var depth = 0, q = null, n = s.length;
|
|
1431
|
+
for (var i = 0; i < n; i++) {
|
|
1432
|
+
var c = s.charAt(i);
|
|
1433
|
+
if (q) {
|
|
1434
|
+
if (c === q) { if (s.charAt(i + 1) === q) { i++; } else { q = null; } }
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (c === "'" || c === '"' || c === "\x60") { q = c; continue; }
|
|
1438
|
+
if (c === "(") { depth += 1; continue; }
|
|
1439
|
+
if (c === ")") { depth -= 1; continue; }
|
|
1440
|
+
if (depth === 0 && (c === " " || c === "\t" || c === "\n" || c === "\r")) {
|
|
1441
|
+
var j = i;
|
|
1442
|
+
while (j < n && /\s/.test(s.charAt(j))) j += 1;
|
|
1443
|
+
if (s.substr(j, 5).toLowerCase() === "where" && !/\w/.test(s.charAt(j + 5) || "")) {
|
|
1444
|
+
return s.slice(0, i);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return s;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function _rawValue(tok, boundParams, pc) {
|
|
1452
|
+
tok = tok.trim();
|
|
1453
|
+
if (tok === "?") { return boundParams[pc.i++]; }
|
|
1454
|
+
if (tok.length >= 2 && (tok.charAt(0) === "'" || tok.charAt(0) === '"')) {
|
|
1455
|
+
var qc = tok.charAt(0);
|
|
1456
|
+
return tok.slice(1, -1).split(qc + qc).join(qc);
|
|
1457
|
+
}
|
|
1458
|
+
if (/^null$/i.test(tok)) return null;
|
|
1459
|
+
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return Number(tok);
|
|
1460
|
+
return tok; // bare expression / named param: opaque -> fails the allowedTags check -> refused
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function _flattenRunParams(argsLike) {
|
|
1464
|
+
var a = Array.prototype.slice.call(argsLike || []);
|
|
1465
|
+
if (a.length === 1 && Array.isArray(a[0])) return a[0];
|
|
1466
|
+
return a;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function _assertRawWriteResidency(sql, boundParams) {
|
|
1470
|
+
var table = _rawWriteTable(sql);
|
|
1471
|
+
if (!table) return;
|
|
1472
|
+
if (!cryptoField.getPerRowResidency(table) && !cryptoField.getColumnResidency(table)) return;
|
|
1473
|
+
boundParams = _flattenRunParams(boundParams);
|
|
1474
|
+
|
|
1475
|
+
// The INSERT/UPDATE body regexes below scan with [\s\S]+; bound the input
|
|
1476
|
+
// first and fail CLOSED on an over-long statement - a residency write the
|
|
1477
|
+
// framework cannot safely parse must be refused, never let past the gate.
|
|
1478
|
+
if (sql.length > 100000) {
|
|
1479
|
+
throw new DbQueryError("db-query/row-residency-raw-unparseable",
|
|
1480
|
+
"raw write to residency table '" + table + "' exceeds the parse limit (" +
|
|
1481
|
+
sql.length + " chars) - use b.db.from(\"" + table + "\") so residency is validated", true);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
var mi = _RAW_INSERT_RE.exec(sql); // allow:regex-no-length-cap — input length-capped above
|
|
1485
|
+
var mu = mi ? null : _RAW_UPDATE_RE.exec(sql); // allow:regex-no-length-cap — input length-capped above
|
|
1486
|
+
if (!mi && !mu) {
|
|
1487
|
+
throw new DbQueryError("db-query/row-residency-raw-unparseable",
|
|
1488
|
+
"raw write to residency table '" + table + "' cannot be parsed to validate its " +
|
|
1489
|
+
"residency tag - use b.db.from(\"" + table + "\").insertOne / .updateOne so the tag is checked", true);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
var plaintextRow = {};
|
|
1493
|
+
var pc = { i: 0 };
|
|
1494
|
+
if (mi) {
|
|
1495
|
+
var cols = _splitTopLevelCommas(mi[2]).map(_unquoteIdent);
|
|
1496
|
+
var vals = _splitTopLevelCommas(mi[3]);
|
|
1497
|
+
if (cols.length !== vals.length) {
|
|
1498
|
+
throw new DbQueryError("db-query/row-residency-raw-unparseable",
|
|
1499
|
+
"raw insert to residency table '" + table + "' has an unmodelled VALUES shape " +
|
|
1500
|
+
"(multi-row / expression) - use the structured builder so residency is validated", true);
|
|
1501
|
+
}
|
|
1502
|
+
for (var ci = 0; ci < cols.length; ci++) {
|
|
1503
|
+
plaintextRow[cols[ci]] = _rawValue(vals[ci], boundParams, pc);
|
|
1504
|
+
}
|
|
1505
|
+
_assertLocalResidency(table, plaintextRow, "insert");
|
|
1506
|
+
} else {
|
|
1507
|
+
var assigns = _splitTopLevelCommas(_setClauseBeforeWhere(mu[2]));
|
|
1508
|
+
for (var ai = 0; ai < assigns.length; ai++) {
|
|
1509
|
+
var eq = assigns[ai].indexOf("=");
|
|
1510
|
+
if (eq === -1) continue;
|
|
1511
|
+
plaintextRow[_unquoteIdent(assigns[ai].slice(0, eq))] = _rawValue(assigns[ai].slice(eq + 1), boundParams, pc);
|
|
1512
|
+
}
|
|
1513
|
+
_assertLocalResidency(table, plaintextRow, "update");
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
module.exports = {
|
|
1518
|
+
Query: Query,
|
|
1519
|
+
_isRawWriteToResidencyTable: _isRawWriteToResidencyTable,
|
|
1520
|
+
_assertRawWriteResidency: _assertRawWriteResidency,
|
|
1521
|
+
};
|
package/lib/db-schema.js
CHANGED
|
@@ -261,14 +261,18 @@ function reconcileTable(database, table, opts) {
|
|
|
261
261
|
// Every identifier here is validated (validateIdent) + quoted by
|
|
262
262
|
// construction, so quote-by-construction safety is preserved.
|
|
263
263
|
// allow:hand-rolled-sql — operator verbatim column DDL + composite FK clauses outside b.sql.createTable's structured API
|
|
264
|
-
runSql(database,
|
|
264
|
+
runSql(database, safeSql.assertSingleStatement(
|
|
265
|
+
"CREATE TABLE IF NOT EXISTS " + q(name) + " (" + colDefs.join(", ") + ")",
|
|
266
|
+
{ label: "schema.reconcile" }));
|
|
265
267
|
|
|
266
268
|
var existingCols = listColumns(database, name);
|
|
267
269
|
for (var newCol in table.columns) {
|
|
268
270
|
if (!existingCols.has(newCol)) {
|
|
269
271
|
try {
|
|
270
272
|
// allow:hand-rolled-sql — operator verbatim ADD COLUMN DDL (validated + quoted identifier); type string is operator-controlled
|
|
271
|
-
runSql(database,
|
|
273
|
+
runSql(database, safeSql.assertSingleStatement(
|
|
274
|
+
"ALTER TABLE " + q(name) + " ADD COLUMN " + q(newCol) + " " + table.columns[newCol],
|
|
275
|
+
{ label: "schema.reconcile" }));
|
|
272
276
|
} catch (e) {
|
|
273
277
|
throw new Error("failed to add column '" + newCol + "' to '" + name + "': " + e.message);
|
|
274
278
|
}
|
|
@@ -479,8 +483,8 @@ function keyTextType(database) {
|
|
|
479
483
|
// it throws "syntax error at PRAGMA" on the others). The table name binds
|
|
480
484
|
// as a `?` parameter (never concatenated into the SQL text), so an operator
|
|
481
485
|
// table name with metacharacters can't break the introspection query. On
|
|
482
|
-
// Postgres the
|
|
483
|
-
//
|
|
486
|
+
// Postgres / MySQL the introspection is confined to current_schema() /
|
|
487
|
+
// DATABASE() (where the bare-named CREATE TABLE lands); an operator running
|
|
484
488
|
// multiple schemas qualifies via the `schema.table` handle convention
|
|
485
489
|
// elsewhere — listColumns reconciles by bare name here, matching the
|
|
486
490
|
// reconciler's CREATE TABLE (which is also bare-named).
|
|
@@ -498,9 +502,24 @@ function listColumns(database, tableName) {
|
|
|
498
502
|
// information_schema.columns view (a schema-qualified system table b.sql's
|
|
499
503
|
// verb builders don't model); the ONLY value (table name) binds as a `?`,
|
|
500
504
|
// every column/table reference is a static literal — no injection surface.
|
|
505
|
+
// The schema predicate confines introspection to the schema/database the
|
|
506
|
+
// reconciler's bare-named CREATE TABLE actually writes into (Postgres
|
|
507
|
+
// current_schema() = the first writable schema on the search_path; MySQL
|
|
508
|
+
// DATABASE() = the connection's default database). Without it a same-named
|
|
509
|
+
// table in another schema/database pollutes the column set - silently skipping
|
|
510
|
+
// a needed ADD COLUMN or fabricating a drift "extra" that refuses a regulated-
|
|
511
|
+
// posture boot. Both are zero-arg SQL functions in predicate position, so the
|
|
512
|
+
// table name stays the single bound parameter (no new placeholder).
|
|
513
|
+
// Two fully-static introspection strings, one per dialect: DATABASE() /
|
|
514
|
+
// current_schema() are SQL functions baked into the literal (never a
|
|
515
|
+
// concatenated value), so the only bound value remains the table name `?`.
|
|
501
516
|
// allow:hand-rolled-sql — static information_schema introspection, single bound param
|
|
502
|
-
var infoSql =
|
|
503
|
-
|
|
517
|
+
var infoSql = dialect === "mysql"
|
|
518
|
+
? "SELECT column_name FROM information_schema.columns " +
|
|
519
|
+
"WHERE table_schema = DATABASE() AND table_name = ?"
|
|
520
|
+
// allow:hand-rolled-sql — Postgres branch, same static-introspection shape
|
|
521
|
+
: "SELECT column_name FROM information_schema.columns " +
|
|
522
|
+
"WHERE table_schema = current_schema() AND table_name = ?";
|
|
504
523
|
var stmt = database.prepare(infoSql);
|
|
505
524
|
var irows = stmt.all.apply(stmt, [tableName]);
|
|
506
525
|
for (var j = 0; j < irows.length; j++) {
|
package/lib/db.js
CHANGED
|
@@ -57,7 +57,7 @@ var { generateToken, generateBytes, encryptPacked, decryptPacked, sha3Hash } = r
|
|
|
57
57
|
var cryptoField = require("./crypto-field");
|
|
58
58
|
var dbDeclareRowPolicy = require("./db-declare-row-policy");
|
|
59
59
|
var dbDeclareView = require("./db-declare-view");
|
|
60
|
-
var { Query } = require("./db-query");
|
|
60
|
+
var { Query, _isRawWriteToResidencyTable, _assertRawWriteResidency } = require("./db-query");
|
|
61
61
|
var dbSchema = require("./db-schema");
|
|
62
62
|
var { defineClass } = require("./framework-error");
|
|
63
63
|
var frameworkFiles = require("./framework-files");
|
|
@@ -1682,6 +1682,27 @@ var _prepareCache = new Map();
|
|
|
1682
1682
|
* typeof row.total;
|
|
1683
1683
|
* // → "object"
|
|
1684
1684
|
*/
|
|
1685
|
+
// Wrap a prepared statement that writes to a per-row-residency table so its
|
|
1686
|
+
// execution (run / get / all / iterate) validates the residency tag of the
|
|
1687
|
+
// bound row through the same gate the structured builder uses. Only residency
|
|
1688
|
+
// writes are wrapped (cheap prepare-time pre-check) so the common path is
|
|
1689
|
+
// untouched.
|
|
1690
|
+
function _gatedResidencyStmt(stmt, sql) {
|
|
1691
|
+
var EXEC = { run: true, get: true, all: true, iterate: true };
|
|
1692
|
+
return new Proxy(stmt, {
|
|
1693
|
+
get: function (target, prop) {
|
|
1694
|
+
var v = target[prop];
|
|
1695
|
+
if (typeof prop === "string" && EXEC[prop] && typeof v === "function") {
|
|
1696
|
+
return function () {
|
|
1697
|
+
_assertRawWriteResidency(sql, Array.prototype.slice.call(arguments));
|
|
1698
|
+
return v.apply(target, arguments);
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
1702
|
+
},
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1685
1706
|
function prepare(sql) {
|
|
1686
1707
|
_requireInit();
|
|
1687
1708
|
if (_prepareCache.has(sql)) {
|
|
@@ -1692,6 +1713,7 @@ function prepare(sql) {
|
|
|
1692
1713
|
return hit;
|
|
1693
1714
|
}
|
|
1694
1715
|
var stmt = database.prepare(sql);
|
|
1716
|
+
if (_isRawWriteToResidencyTable(sql)) stmt = _gatedResidencyStmt(stmt, sql);
|
|
1695
1717
|
_prepareCache.set(sql, stmt);
|
|
1696
1718
|
if (_prepareCache.size > PREPARE_CACHE_MAX) {
|
|
1697
1719
|
var oldestKey = _prepareCache.keys().next().value;
|
|
@@ -1854,6 +1876,9 @@ function _reportSlowSqlite(durationMs, statement) {
|
|
|
1854
1876
|
|
|
1855
1877
|
function execRaw(sql) {
|
|
1856
1878
|
_requireInit();
|
|
1879
|
+
// Raw writes bypass the structured builder's residency gate; validate the
|
|
1880
|
+
// residency tag of an INSERT / UPDATE to a per-row-residency table here too.
|
|
1881
|
+
_assertRawWriteResidency(sql);
|
|
1857
1882
|
var startedAt = Date.now();
|
|
1858
1883
|
var auditMod = (function () { try { return require("./audit"); } catch (_e) { return null; } })(); // allow:inline-require — circular-load defense (audit imports db)
|
|
1859
1884
|
// DDL_RE only matches the leading keyword — bounded by `/\s*(KEYWORD)\b/`
|
package/lib/dsr.js
CHANGED
|
@@ -1065,7 +1065,9 @@ function dbTicketStore(opts) {
|
|
|
1065
1065
|
var createCols = Object.keys(SCHEMA_COLUMNS).map(function (c) {
|
|
1066
1066
|
return c + " " + SCHEMA_COLUMNS[c];
|
|
1067
1067
|
}).join(", ");
|
|
1068
|
-
db.runSql(
|
|
1068
|
+
db.runSql(safeSql.assertSingleStatement(
|
|
1069
|
+
"CREATE TABLE IF NOT EXISTS " + qTable + " (" + createCols + ")",
|
|
1070
|
+
{ label: "dsr.schema" }));
|
|
1069
1071
|
// Reconcile an existing (older-shape) table — add any column the
|
|
1070
1072
|
// current schema declares that the live table lacks. PRAGMA table_info
|
|
1071
1073
|
// returns one row per existing column.
|
|
@@ -1086,12 +1088,14 @@ function dbTicketStore(opts) {
|
|
|
1086
1088
|
var addType = /NOT NULL/.test(SCHEMA_COLUMNS[col])
|
|
1087
1089
|
? SCHEMA_COLUMNS[col].replace(/PRIMARY KEY/g, "") + " DEFAULT ''"
|
|
1088
1090
|
: SCHEMA_COLUMNS[col];
|
|
1089
|
-
db.runSql(
|
|
1091
|
+
db.runSql(safeSql.assertSingleStatement(
|
|
1092
|
+
"ALTER TABLE " + qTable + " ADD COLUMN " + col + " " + addType.trim(),
|
|
1093
|
+
{ label: "dsr.schema" }));
|
|
1090
1094
|
}
|
|
1091
|
-
db.runSql("CREATE INDEX IF NOT EXISTS " + qEmailIdx + " ON " +
|
|
1092
|
-
qTable + " (subject_email_hash)");
|
|
1093
|
-
db.runSql("CREATE INDEX IF NOT EXISTS " + qStatusIdx + " ON " +
|
|
1094
|
-
qTable + " (status)");
|
|
1095
|
+
db.runSql(safeSql.assertSingleStatement("CREATE INDEX IF NOT EXISTS " + qEmailIdx + " ON " +
|
|
1096
|
+
qTable + " (subject_email_hash)", { label: "dsr.schema" }));
|
|
1097
|
+
db.runSql(safeSql.assertSingleStatement("CREATE INDEX IF NOT EXISTS " + qStatusIdx + " ON " +
|
|
1098
|
+
qTable + " (status)", { label: "dsr.schema" }));
|
|
1095
1099
|
// Backfill legacy / vault-less rows. A row written before the sealed-store
|
|
1096
1100
|
// upgrade (or while no vault was configured) holds its subject identifiers
|
|
1097
1101
|
// in plaintext with NULL derived-hash columns. Once a vault is present,
|
package/lib/external-db.js
CHANGED
|
@@ -1811,8 +1811,25 @@ async function _readQuery(sql, params, opts) {
|
|
|
1811
1811
|
// region (opts.rowResidencyTag) under a regulated posture, a read
|
|
1812
1812
|
// routed to an incompatible replica is refused unless the replica
|
|
1813
1813
|
// was explicitly configured allowCrossBorder (which is audited).
|
|
1814
|
-
|
|
1815
|
-
|
|
1814
|
+
var _readPosture = _activePosture();
|
|
1815
|
+
var _tagPresent = opts.rowResidencyTag && typeof opts.rowResidencyTag === "string";
|
|
1816
|
+
// Fail CLOSED when the row's region is not identified: a regulated read to a
|
|
1817
|
+
// residency-tagged replica without opts.rowResidencyTag would otherwise route
|
|
1818
|
+
// residency-restricted rows to an arbitrary-region replica with no check at
|
|
1819
|
+
// all (symmetric with the write gate's RESIDENCY_GATE_REQUIRED).
|
|
1820
|
+
if (!_tagPresent && _crossBorderRegulated(_readPosture) &&
|
|
1821
|
+
replica.residencyTag && !replica.allowCrossBorder) {
|
|
1822
|
+
_emit("db.residency.replica.tag_required", "denied", {
|
|
1823
|
+
backend: b.name, replicaIdx: replica.index,
|
|
1824
|
+
replicaTag: replica.residencyTag, posture: _readPosture,
|
|
1825
|
+
});
|
|
1826
|
+
throw _err("REPLICA_RESIDENCY_TAG_REQUIRED",
|
|
1827
|
+
"read routed to residency-tagged replica " + replica.index + " of backend '" +
|
|
1828
|
+
b.name + "' (residencyTag='" + replica.residencyTag + "') under '" + _readPosture +
|
|
1829
|
+
"' posture without opts.rowResidencyTag - identify the row's region or set " +
|
|
1830
|
+
"allowCrossBorder on the replica (audited)", true);
|
|
1831
|
+
}
|
|
1832
|
+
if (_tagPresent) {
|
|
1816
1833
|
if (_crossBorderRegulated(_readPosture) &&
|
|
1817
1834
|
opts.rowResidencyTag !== "global" && opts.rowResidencyTag !== "unrestricted" &&
|
|
1818
1835
|
!_residencyCompatible(opts.rowResidencyTag, replica.residencyTag)) {
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -239,6 +239,15 @@ function create(opts) {
|
|
|
239
239
|
var auditOn = opts.audit !== false;
|
|
240
240
|
var algorithms = opts.algorithms;
|
|
241
241
|
var iatWindowSec = opts.iatWindowSec;
|
|
242
|
+
// replayStore is the jti-replay defense (RFC 9449 §11.1) — REQUIRED. Reading
|
|
243
|
+
// it optionally and gating the check behind `if (replayStore)` would silently
|
|
244
|
+
// mount a proof-of-possession gate that performs no replay check, letting a
|
|
245
|
+
// captured proof replay indefinitely. Fail closed at config time: a missing
|
|
246
|
+
// store and a store lacking checkAndInsert both throw here, not at the first
|
|
247
|
+
// request. (The low-level b.auth.dpop.verify primitive keeps replayStore
|
|
248
|
+
// optional for advanced callers that track jti themselves.)
|
|
249
|
+
validateOpts.requireMethods(opts.replayStore, ["checkAndInsert"],
|
|
250
|
+
"middleware.dpop: opts.replayStore", AuthError, "auth-dpop/replay-store-required");
|
|
242
251
|
var replayStore = opts.replayStore;
|
|
243
252
|
var requireNonce = opts.requireNonce === true;
|
|
244
253
|
|
|
@@ -328,7 +337,7 @@ function create(opts) {
|
|
|
328
337
|
if (iatWindowSec !== undefined) verifyOpts.iatWindowSec = iatWindowSec;
|
|
329
338
|
if (accessToken) verifyOpts.accessToken = accessToken;
|
|
330
339
|
if (nonce) verifyOpts.nonce = nonce;
|
|
331
|
-
|
|
340
|
+
verifyOpts.replayStore = replayStore; // required at create() — always present
|
|
332
341
|
|
|
333
342
|
var result;
|
|
334
343
|
try { result = await dpop().verify(proofHeader, verifyOpts); }
|
package/lib/network-proxy.js
CHANGED
|
@@ -20,6 +20,11 @@ var DEFAULT_HTTPS_PORT = 443; // RFC 9110 §4.2.2
|
|
|
20
20
|
var DEFAULT_HTTP_PORT = C.BYTES.bytes(80); // RFC 9110 §4.2.1
|
|
21
21
|
|
|
22
22
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
23
|
+
// Lazy so pqc-agent's TLS/audit graph isn't pulled into every process that
|
|
24
|
+
// imports network-proxy but never proxies an https upstream. Used only to audit
|
|
25
|
+
// a classical-group fallback on a proxy-tunneled TLS handshake (the direct path
|
|
26
|
+
// audits in pqc-agent.create()).
|
|
27
|
+
var pqcAgent = lazyRequire(function () { return require("./pqc-agent"); });
|
|
23
28
|
|
|
24
29
|
var STATE = {
|
|
25
30
|
http: null,
|
|
@@ -167,6 +172,13 @@ function _connectThroughTunnel(proxyUrl, targetHost, targetPort, callback) {
|
|
|
167
172
|
function done(err, sock) { if (settled) return; settled = true; callback(err, sock); }
|
|
168
173
|
proxySocket.on("error", function (e) { done(e); });
|
|
169
174
|
proxySocket.on(proxyUrl.protocol === "https:" ? "secureConnect" : "connect", function () {
|
|
175
|
+
if (proxyUrl.protocol === "https:") {
|
|
176
|
+
// The CONNECT-tunnel leg to an https proxy is itself a TLS handshake;
|
|
177
|
+
// audit a classical fallback to the proxy too, not only to the upstream.
|
|
178
|
+
pqcAgent()._auditClassicalDowngrade(proxySocket, {
|
|
179
|
+
host: proxyUrl.hostname, port: proxyPort,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
170
182
|
var lines = [
|
|
171
183
|
"CONNECT " + targetHost + ":" + targetPort + " HTTP/1.1",
|
|
172
184
|
"Host: " + targetHost + ":" + targetPort,
|
|
@@ -218,7 +230,18 @@ function agentFor(targetUrl) {
|
|
|
218
230
|
minVersion: "TLSv1.3",
|
|
219
231
|
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
220
232
|
ALPNProtocols: options.ALPNProtocols,
|
|
221
|
-
}, function () {
|
|
233
|
+
}, function () {
|
|
234
|
+
// Audit a classical-group fallback on the upstream (target) handshake
|
|
235
|
+
// reached through the proxy tunnel, so the "every outbound TLS path
|
|
236
|
+
// emits tls.classical_downgrade" guarantee holds for proxied requests
|
|
237
|
+
// too (the direct path audits in pqc-agent.create). Drop-silent; the
|
|
238
|
+
// handshake itself is unchanged (still hybrid-preferred TLSv1.3).
|
|
239
|
+
pqcAgent()._auditClassicalDowngrade(secure, {
|
|
240
|
+
host: options.servername || options.host,
|
|
241
|
+
port: options.port,
|
|
242
|
+
});
|
|
243
|
+
cb(null, secure);
|
|
244
|
+
});
|
|
222
245
|
secure.on("error", function (e) { cb(e); });
|
|
223
246
|
});
|
|
224
247
|
};
|
|
@@ -98,6 +98,11 @@ var KIND_TO_OTLP = Object.freeze({
|
|
|
98
98
|
function _attrToOtlp(attrs) {
|
|
99
99
|
// OTLP attribute shape: [{ key, value: { stringValue | intValue |
|
|
100
100
|
// doubleValue | boolValue | arrayValue: { values: [...] } } }, ...]
|
|
101
|
+
// Telemetry is a first-class EGRESS sink — scrub every value through the
|
|
102
|
+
// active redactor BEFORE serialization so a secret/PII attribute never
|
|
103
|
+
// reaches the collector (CWE-532). Redaction is baked into the encoder, not
|
|
104
|
+
// the call site, so no span/event/resource path can forget it.
|
|
105
|
+
attrs = observability().redactAttrs(attrs);
|
|
101
106
|
var out = [];
|
|
102
107
|
if (!attrs || typeof attrs !== "object") return out;
|
|
103
108
|
var keys = Object.keys(attrs);
|
|
@@ -316,7 +321,10 @@ function _keyValueToProto(kvObj) {
|
|
|
316
321
|
function _attrsToProto(attrs) {
|
|
317
322
|
// attrs is the raw `{ key: value }` operator attribute object; OTLP
|
|
318
323
|
// KeyValue gets emitted per entry with field 9 (attributes) on Span,
|
|
319
|
-
// field 1 (attributes) on Resource, etc.
|
|
324
|
+
// field 1 (attributes) on Resource, etc. Scrub every value through the
|
|
325
|
+
// active redactor BEFORE building the wire intermediate — the protobuf path
|
|
326
|
+
// is the same EGRESS sink as the JSON path and must not leak (CWE-532).
|
|
327
|
+
attrs = observability().redactAttrs(attrs);
|
|
320
328
|
if (!attrs || typeof attrs !== "object") return [];
|
|
321
329
|
var keys = Object.keys(attrs);
|
|
322
330
|
var out = new Array(keys.length);
|
package/lib/observability.js
CHANGED
|
@@ -191,6 +191,42 @@ function getRedactor() {
|
|
|
191
191
|
return _telemetryRedactor;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* @primitive b.observability.redactAttrs
|
|
196
|
+
* @signature b.observability.redactAttrs(attrs)
|
|
197
|
+
* @since 0.15.4
|
|
198
|
+
* @related b.observability.getRedactor, b.observability.setRedactor
|
|
199
|
+
*
|
|
200
|
+
* Run every value of a telemetry attribute map through the active redactor and
|
|
201
|
+
* return a NEW `{ key: redactedValue }` object. The OTLP exporters call this on
|
|
202
|
+
* span, span-event, metric, and resource attributes before serialization so no
|
|
203
|
+
* attribute value crosses the egress boundary unscrubbed (CWE-532: insertion of
|
|
204
|
+
* sensitive information into an externally-shipped sink). A key whose redactor
|
|
205
|
+
* throws is DROPPED — failing toward dropping, never exporting the raw value;
|
|
206
|
+
* `null` / `undefined` values pass through for the type-encoder to handle.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* b.observability.redactAttrs({ "http.method": "GET", authorization: "Bearer x" });
|
|
210
|
+
* // → { "http.method": "GET", authorization: "[REDACTED]" }
|
|
211
|
+
*/
|
|
212
|
+
function redactAttrs(attrs) {
|
|
213
|
+
var out = {};
|
|
214
|
+
if (!attrs || typeof attrs !== "object") return out;
|
|
215
|
+
var redactor = getRedactor();
|
|
216
|
+
var keys = Object.keys(attrs);
|
|
217
|
+
for (var i = 0; i < keys.length; i++) {
|
|
218
|
+
var k = keys[i];
|
|
219
|
+
try {
|
|
220
|
+
out[k] = redactor(attrs[k], k);
|
|
221
|
+
} catch (_e) {
|
|
222
|
+
// redactor threw on the export hot path — drop the attribute rather than
|
|
223
|
+
// fall through to the raw value. A throwing redactor must never widen the
|
|
224
|
+
// egress surface, and must never crash the request that produced the span.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
/**
|
|
195
231
|
* @primitive b.observability.tap
|
|
196
232
|
* @signature b.observability.tap(name, attrs, fn)
|
|
@@ -837,6 +873,7 @@ module.exports = {
|
|
|
837
873
|
setTap: setTap,
|
|
838
874
|
setRedactor: setRedactor,
|
|
839
875
|
getRedactor: getRedactor,
|
|
876
|
+
redactAttrs: redactAttrs,
|
|
840
877
|
SEMCONV: SEMCONV,
|
|
841
878
|
traceContext: traceContext,
|
|
842
879
|
baggage: baggage,
|
package/lib/otel-export.js
CHANGED
|
@@ -65,38 +65,23 @@ var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
|
|
|
65
65
|
// receiving backend handles the running sum.
|
|
66
66
|
var TEMPORALITY_DELTA = 1;
|
|
67
67
|
|
|
68
|
-
// Run a single attribute value through the active telemetry redactor.
|
|
69
|
-
// Telemetry is a first-class EGRESS sink — an attribute value holding a
|
|
70
|
-
// user email, bearer token, or vault-sealed ciphertext would otherwise
|
|
71
|
-
// be serialized verbatim onto the OTLP wire (CWE-532: insertion of
|
|
72
|
-
// sensitive information into an externally-shipped sink). The redactor is
|
|
73
|
-
// resolved per-call from b.observability so an operator-installed
|
|
74
|
-
// override (setRedactor) takes effect without re-creating the exporter.
|
|
75
|
-
//
|
|
76
|
-
// Drop-silent by design: this runs on the export hot path, where a throw
|
|
77
|
-
// from the redactor must never crash the request that produced the span.
|
|
78
|
-
// On a throw we DROP the attribute (signalled by the `_DROP` sentinel)
|
|
79
|
-
// rather than fall through to the raw value — failing toward dropping,
|
|
80
|
-
// not leaking.
|
|
81
|
-
var _DROP = {};
|
|
82
|
-
function _redactAttrValue(key, value) {
|
|
83
|
-
try {
|
|
84
|
-
return observability.getRedactor()(value, key);
|
|
85
|
-
} catch (_e) {
|
|
86
|
-
return _DROP; // redactor threw — drop the attribute, never export raw
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
68
|
// ---- attribute encoding ----
|
|
91
69
|
// OTLP attributes are KeyValue with typed `value` fields:
|
|
92
70
|
// { key, value: { stringValue | intValue | doubleValue | boolValue } }
|
|
71
|
+
// Telemetry is a first-class EGRESS sink — an attribute value holding a user
|
|
72
|
+
// email, bearer token, or vault-sealed ciphertext would otherwise be serialized
|
|
73
|
+
// verbatim onto the OTLP wire (CWE-532). observability.redactAttrs scrubs every
|
|
74
|
+
// value through the active redactor (operator overrides via setRedactor take
|
|
75
|
+
// effect without re-creating the exporter) and drops any key whose redactor
|
|
76
|
+
// throws — failing toward dropping, never leaking, on the export hot path.
|
|
93
77
|
function _attrsToOtlp(attrs) {
|
|
94
|
-
|
|
78
|
+
attrs = observability.redactAttrs(attrs);
|
|
95
79
|
var out = [];
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
80
|
+
if (!attrs || typeof attrs !== "object") return out;
|
|
81
|
+
var keys = Object.keys(attrs);
|
|
82
|
+
for (var i = 0; i < keys.length; i++) {
|
|
83
|
+
var k = keys[i];
|
|
84
|
+
var v = attrs[k];
|
|
100
85
|
var kv;
|
|
101
86
|
if (typeof v === "string") kv = { stringValue: v };
|
|
102
87
|
else if (typeof v === "number") {
|
package/lib/safe-sql.js
CHANGED
|
@@ -470,8 +470,96 @@ function countPlaceholders(sql) {
|
|
|
470
470
|
* // → 63
|
|
471
471
|
*/
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* @primitive b.safeSql.assertSingleStatement
|
|
475
|
+
* @signature b.safeSql.assertSingleStatement(sql, opts?)
|
|
476
|
+
* @since 0.15.4
|
|
477
|
+
* @status stable
|
|
478
|
+
* @related b.safeSql.quoteIdentifier, b.safeSql.countPlaceholders, b.sql
|
|
479
|
+
*
|
|
480
|
+
* The one quote/comment-aware single-statement gate for any FINISHED SQL
|
|
481
|
+
* string that reaches a driver. Refuses a NUL, a lone surrogate, a
|
|
482
|
+
* top-level ';' (stacked statement), an unterminated quote, and unbalanced
|
|
483
|
+
* parentheses - while CORRECTLY allowing those characters inside a balanced
|
|
484
|
+
* quoted label (e.g. a MySQL ENUM('a;b')). Hand-rolled DDL (schema
|
|
485
|
+
* reconcile, the DSR store, migrations) and the b.sql builder's own output
|
|
486
|
+
* gates route through this single scan so the injection backstop cannot
|
|
487
|
+
* drift between the structured builder and the raw-DDL paths. Returns the
|
|
488
|
+
* input string so a caller can wrap inline:
|
|
489
|
+
* runSql(db, safeSql.assertSingleStatement(ddl, { label: "schema" }));
|
|
490
|
+
*
|
|
491
|
+
* @opts
|
|
492
|
+
* label: string, // message prefix (default: "sql")
|
|
493
|
+
* makeError: function, // (message, codeSuffix) => Error (default: SafeSqlError "sql/<suffix>")
|
|
494
|
+
*
|
|
495
|
+
* @example
|
|
496
|
+
* var ddl = b.safeSql.assertSingleStatement("CREATE TABLE t (id INTEGER)", { label: "schema" });
|
|
497
|
+
* // returns the input string; throws sql/stacked-statement on a stacked DDL
|
|
498
|
+
*/
|
|
499
|
+
function assertSingleStatement(sql, opts) {
|
|
500
|
+
opts = opts || {};
|
|
501
|
+
var label = typeof opts.label === "string" ? opts.label : "sql";
|
|
502
|
+
var mkErr = typeof opts.makeError === "function"
|
|
503
|
+
? opts.makeError
|
|
504
|
+
: function (msg, suffix) { return new SafeSqlError(msg, "sql/" + suffix); };
|
|
505
|
+
// Backtick written via its code point so no NUL byte can reach this source.
|
|
506
|
+
var BACKTICK = String.fromCharCode(96);
|
|
507
|
+
if (typeof sql !== "string" || sql.length === 0) {
|
|
508
|
+
throw mkErr(label + ": SQL must be a non-empty string", "empty-sql");
|
|
509
|
+
}
|
|
510
|
+
if (sql.indexOf(String.fromCharCode(0)) !== -1) {
|
|
511
|
+
throw mkErr(label + ": SQL contains a NUL byte - rejected", "null-byte-sql");
|
|
512
|
+
}
|
|
513
|
+
if (typeof sql.isWellFormed === "function" && !sql.isWellFormed()) {
|
|
514
|
+
throw mkErr(label + ": SQL contains invalid Unicode (lone surrogates) - rejected",
|
|
515
|
+
"invalid-encoding-sql");
|
|
516
|
+
}
|
|
517
|
+
var i = 0;
|
|
518
|
+
var len = sql.length;
|
|
519
|
+
var depth = 0;
|
|
520
|
+
while (i < len) {
|
|
521
|
+
var ch = sql.charAt(i);
|
|
522
|
+
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
523
|
+
if (ch === "'" || ch === '"' || ch === BACKTICK) {
|
|
524
|
+
var qch = ch;
|
|
525
|
+
var closed = false;
|
|
526
|
+
i += 1;
|
|
527
|
+
while (i < len) {
|
|
528
|
+
if (sql.charAt(i) === qch) {
|
|
529
|
+
if (sql.charAt(i + 1) === qch) { i += 2; continue; } // doubled quote = escaped literal
|
|
530
|
+
i += 1; closed = true; break;
|
|
531
|
+
}
|
|
532
|
+
i += 1;
|
|
533
|
+
}
|
|
534
|
+
if (!closed) {
|
|
535
|
+
throw mkErr(label + ": unterminated quote in SQL (quote-jump / breakout risk)",
|
|
536
|
+
"unterminated-quote");
|
|
537
|
+
}
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
541
|
+
if (ch === "/" && next === "*") {
|
|
542
|
+
i += 2;
|
|
543
|
+
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
544
|
+
i += 2;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (ch === "(") { depth += 1; }
|
|
548
|
+
else if (ch === ")") { depth -= 1; }
|
|
549
|
+
else if (ch === ";") {
|
|
550
|
+
throw mkErr(label + ": emitted a top-level ';' - exactly one statement", "stacked-statement");
|
|
551
|
+
}
|
|
552
|
+
i += 1;
|
|
553
|
+
}
|
|
554
|
+
if (depth !== 0) {
|
|
555
|
+
throw mkErr(label + ": unbalanced parentheses in SQL", "unbalanced");
|
|
556
|
+
}
|
|
557
|
+
return sql;
|
|
558
|
+
}
|
|
559
|
+
|
|
473
560
|
module.exports = {
|
|
474
561
|
validateIdentifier: validateIdentifier,
|
|
562
|
+
assertSingleStatement: assertSingleStatement,
|
|
475
563
|
quoteIdentifier: quoteIdentifier,
|
|
476
564
|
quoteQualified: quoteQualified,
|
|
477
565
|
quoteList: quoteList,
|
package/lib/session.js
CHANGED
|
@@ -846,11 +846,20 @@ async function touch(token, opts) {
|
|
|
846
846
|
* the new token verifies. Audit event `auth.session.rotate` fires
|
|
847
847
|
* best-effort with `metadata.reason`.
|
|
848
848
|
*
|
|
849
|
+
* Device binding: when the session was created with `{ req, fingerprintFields }`
|
|
850
|
+
* the bound fingerprint is keyed to the sid, so rotation re-keys it to the new
|
|
851
|
+
* sid from the live request. Pass the same `{ req, fingerprintFields }` to
|
|
852
|
+
* `rotate` — a fingerprint-bound session rotated without `req` throws, because
|
|
853
|
+
* the binding cannot follow the sid otherwise (it would silently break or make
|
|
854
|
+
* the next `verify` falsely report drift).
|
|
855
|
+
*
|
|
849
856
|
* @opts
|
|
850
857
|
* {
|
|
851
|
-
* data?:
|
|
852
|
-
* ttlMs?:
|
|
853
|
-
* reason?:
|
|
858
|
+
* data?: object, // replacement session data (re-sealed)
|
|
859
|
+
* ttlMs?: number, // new TTL; if absent, existing expiresAt preserved
|
|
860
|
+
* reason?: string, // audit metadata ("login", "mfa", "role-change")
|
|
861
|
+
* req?: IncomingMessage, // re-key the device fingerprint to the new sid
|
|
862
|
+
* fingerprintFields?: Array<string|fn>, // default ["clientIp","userAgent","acceptLanguage"]
|
|
854
863
|
* }
|
|
855
864
|
*
|
|
856
865
|
* @example
|
|
@@ -882,8 +891,53 @@ async function rotate(oldToken, opts) {
|
|
|
882
891
|
|
|
883
892
|
var setCols = { sidHash: newSidHash, lastActivity: nowMs };
|
|
884
893
|
|
|
885
|
-
|
|
886
|
-
|
|
894
|
+
// Re-key the device binding to the NEW sid. __bj_fingerprint is sid-keyed
|
|
895
|
+
// (_hashFingerprint(sid, inputs), so a stolen DB can't replay it); a rotated
|
|
896
|
+
// session that kept the old-sid hash would make verify(newToken, sameReq)
|
|
897
|
+
// recompute against the new sid and mismatch — a false fingerprintDrift
|
|
898
|
+
// (strict operators destroy the session on every rotation) or a silently
|
|
899
|
+
// broken binding. Read the live row to learn whether the session was bound
|
|
900
|
+
// and to carry its payload forward when opts.data is not supplied.
|
|
901
|
+
var fpFields = Array.isArray(opts.fingerprintFields) && opts.fingerprintFields.length > 0
|
|
902
|
+
? opts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
|
|
903
|
+
var existingData = null;
|
|
904
|
+
var rotSelBuilt = sql.select(_sessionSqlTable(), _sessionSqlOpts())
|
|
905
|
+
.columns(["data"])
|
|
906
|
+
.where("sidHash", oldSidHash)
|
|
907
|
+
.where("expiresAt", ">=", nowMs)
|
|
908
|
+
.toSql();
|
|
909
|
+
var existingRow = await _currentStore().executeOne(rotSelBuilt.sql, rotSelBuilt.params);
|
|
910
|
+
if (!existingRow) return null; // unknown / expired old session
|
|
911
|
+
try {
|
|
912
|
+
var unsealedExisting = cryptoField.unsealRow(SESSION_TABLE, existingRow);
|
|
913
|
+
if (unsealedExisting.data) existingData = safeJson.parse(unsealedExisting.data);
|
|
914
|
+
} catch (_e) { existingData = null; }
|
|
915
|
+
var wasBound = existingData && typeof existingData === "object" &&
|
|
916
|
+
typeof existingData.__bj_fingerprint === "string";
|
|
917
|
+
|
|
918
|
+
if (opts.data !== undefined || wasBound) {
|
|
919
|
+
// opts.data REPLACES the payload (documented rotate semantics); otherwise
|
|
920
|
+
// carry the existing payload forward. The reserved __bj_fingerprint is
|
|
921
|
+
// never copied verbatim (it is old-sid-keyed) — it is recomputed below.
|
|
922
|
+
var newDataObj;
|
|
923
|
+
if (opts.data !== undefined) {
|
|
924
|
+
newDataObj = (opts.data && typeof opts.data === "object") ? Object.assign({}, opts.data) : null;
|
|
925
|
+
} else {
|
|
926
|
+
newDataObj = (existingData && typeof existingData === "object") ? Object.assign({}, existingData) : null;
|
|
927
|
+
}
|
|
928
|
+
if (newDataObj) delete newDataObj.__bj_fingerprint;
|
|
929
|
+
|
|
930
|
+
if (wasBound) {
|
|
931
|
+
if (!opts.req) {
|
|
932
|
+
throw _err("ROTATE_FINGERPRINT_REQ_REQUIRED",
|
|
933
|
+
"session.rotate: this session is fingerprint-bound; pass { req, fingerprintFields } " +
|
|
934
|
+
"so the device binding can be re-keyed to the new session id", true);
|
|
935
|
+
}
|
|
936
|
+
if (!newDataObj) newDataObj = {};
|
|
937
|
+
newDataObj.__bj_fingerprint = _hashFingerprint(newSid, _buildFingerprintInputs(opts.req, fpFields));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
var dataJson = newDataObj ? JSON.stringify(newDataObj) : null;
|
|
887
941
|
var sealedRow = cryptoField.sealRow(SESSION_TABLE, { data: dataJson });
|
|
888
942
|
setCols.data = sealedRow.data;
|
|
889
943
|
}
|
package/lib/sql.js
CHANGED
|
@@ -229,9 +229,17 @@ function _ddlType(logical, dialect) {
|
|
|
229
229
|
if (key === "JSON") {
|
|
230
230
|
return dialect === "postgres" ? "JSONB" : (dialect === "mysql" ? "JSON" : "TEXT");
|
|
231
231
|
}
|
|
232
|
-
// Unrecognised: a verbatim dialect-specific type
|
|
233
|
-
//
|
|
234
|
-
//
|
|
232
|
+
// Unrecognised: a verbatim dialect-specific type (VARCHAR(255), GEOGRAPHY,
|
|
233
|
+
// NUMERIC(10,2), DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, MySQL
|
|
234
|
+
// ENUM('a','b') / SET(...), ...). It follows a quoted identifier so it is in
|
|
235
|
+
// type position, never identifier position. Injection safety for the type
|
|
236
|
+
// token is enforced at the statement level: createTable / alterTable route
|
|
237
|
+
// the finished DDL through _assertCatalogEmittable, whose quote-aware
|
|
238
|
+
// single-statement scan refuses a top-level ';', a comment marker, an
|
|
239
|
+
// unbalanced quote, an unbalanced paren, and a NUL - while CORRECTLY allowing
|
|
240
|
+
// those same characters when they sit inside a balanced quoted label (e.g.
|
|
241
|
+
// ENUM('needs;review')). A non-quote-aware pre-scan here would over-reject
|
|
242
|
+
// such valid labels, so the one quote-aware gate is the right place to check.
|
|
235
243
|
return logical;
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -1377,57 +1385,10 @@ function _assertEmittable(sql, params) {
|
|
|
1377
1385
|
" '?' placeholder(s) but " + n + " param(s); emitting this would " +
|
|
1378
1386
|
"misalign bound values across columns", "sql-builder/param-mismatch");
|
|
1379
1387
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
var ch = sql.charAt(i);
|
|
1385
|
-
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
1386
|
-
if (ch === "'" || ch === '"' || ch === "`") {
|
|
1387
|
-
// Quote context. A string literal / quoted identifier escapes its own
|
|
1388
|
-
// quote char by DOUBLING it; an UNTERMINATED quote is the signature of
|
|
1389
|
-
// a quote-jump breakout - a value or identifier whose embedded quote
|
|
1390
|
-
// escaped its context and ran to the end of the statement. (Backtick
|
|
1391
|
-
// covers MySQL identifier quoting; ' and " cover string literals and
|
|
1392
|
-
// ANSI / Postgres / SQLite identifiers.)
|
|
1393
|
-
var q = ch;
|
|
1394
|
-
var closed = false;
|
|
1395
|
-
i += 1;
|
|
1396
|
-
while (i < len) {
|
|
1397
|
-
if (sql.charAt(i) === q) {
|
|
1398
|
-
if (sql.charAt(i + 1) === q) { i += 2; continue; }
|
|
1399
|
-
i += 1; closed = true; break;
|
|
1400
|
-
}
|
|
1401
|
-
i += 1;
|
|
1402
|
-
}
|
|
1403
|
-
if (!closed) {
|
|
1404
|
-
throw _err("toSql: unterminated " +
|
|
1405
|
-
(q === "'" ? "string literal" : "quoted identifier") +
|
|
1406
|
-
" in emitted SQL - a quote escaped its context " +
|
|
1407
|
-
"(quote-jump / breakout risk)", "sql-builder/unterminated-quote");
|
|
1408
|
-
}
|
|
1409
|
-
continue;
|
|
1410
|
-
}
|
|
1411
|
-
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
1412
|
-
if (ch === "/" && next === "*") {
|
|
1413
|
-
i += 2;
|
|
1414
|
-
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
1415
|
-
i += 2;
|
|
1416
|
-
continue;
|
|
1417
|
-
}
|
|
1418
|
-
if (ch === "(") { depth += 1; }
|
|
1419
|
-
else if (ch === ")") { depth -= 1; }
|
|
1420
|
-
else if (ch === ";") {
|
|
1421
|
-
throw _err("toSql: builder emitted a top-level ';' - exactly one " +
|
|
1422
|
-
"statement per build; a stacked statement is never valid output",
|
|
1423
|
-
"sql-builder/stacked-statement");
|
|
1424
|
-
}
|
|
1425
|
-
i += 1;
|
|
1426
|
-
}
|
|
1427
|
-
if (depth !== 0) {
|
|
1428
|
-
throw _err("toSql: unbalanced parentheses in emitted SQL (builder bug)",
|
|
1429
|
-
"sql-builder/unbalanced");
|
|
1430
|
-
}
|
|
1388
|
+
safeSql.assertSingleStatement(sql, {
|
|
1389
|
+
label: "toSql",
|
|
1390
|
+
makeError: function (m, suffix) { return _err(m, "sql-builder/" + suffix); },
|
|
1391
|
+
});
|
|
1431
1392
|
}
|
|
1432
1393
|
|
|
1433
1394
|
// Terminal wrapper: validate then return the { sql, params } shape every
|
|
@@ -2486,8 +2447,25 @@ class UpsertBuilder extends Builder {
|
|
|
2486
2447
|
throw _err("upsert readback: conflict key '" + keys[i] + "' is not in the value set",
|
|
2487
2448
|
"sql-builder/bad-conflict");
|
|
2488
2449
|
}
|
|
2489
|
-
|
|
2490
|
-
|
|
2450
|
+
var keyVal = this._values[idx];
|
|
2451
|
+
if (keyVal instanceof SqlFunction) {
|
|
2452
|
+
// A server-evaluated function (NOW() / CURRENT_TIMESTAMP / ...) as a
|
|
2453
|
+
// conflict key has no stable readback identity: the row holds the value
|
|
2454
|
+
// the server computed at INSERT time, which a fresh evaluation in this
|
|
2455
|
+
// WHERE would never equal, so the readback would silently match zero
|
|
2456
|
+
// rows. Refuse rather than return a wrong (empty) result.
|
|
2457
|
+
throw _err("upsert readback: conflict key '" + keys[i] + "' is a " +
|
|
2458
|
+
"server-evaluated function (b.sql.fn) with no stable readback identity " +
|
|
2459
|
+
"- use a literal/cast conflict key or read the row back explicitly",
|
|
2460
|
+
"sql-builder/bad-conflict");
|
|
2461
|
+
}
|
|
2462
|
+
// Resolve the key value through the same cell renderer the VALUES tuple
|
|
2463
|
+
// uses, so a b.sql.cast(...) conflict key emits `col = CAST(? AS type)`
|
|
2464
|
+
// (Postgres `col = ?::type`) binding the inner value, and a plain scalar
|
|
2465
|
+
// still emits `col = ?` binding the value unchanged.
|
|
2466
|
+
var cell = _renderValueCell(keyVal, dialect);
|
|
2467
|
+
conds.push(_quoteId(keys[i], dialect) + " = " + cell.sql);
|
|
2468
|
+
for (var cp = 0; cp < cell.params.length; cp++) params.push(cell.params[cp]);
|
|
2491
2469
|
}
|
|
2492
2470
|
sql += " WHERE " + conds.join(" AND ");
|
|
2493
2471
|
return { sql: sql, params: params };
|
|
@@ -2614,6 +2592,20 @@ function createTable(name, columns, opts) {
|
|
|
2614
2592
|
return def;
|
|
2615
2593
|
});
|
|
2616
2594
|
if (Array.isArray(opts.primaryKey) && opts.primaryKey.length > 0) {
|
|
2595
|
+
// A column-level primary key (primaryKey / autoIncrement / serial) and a
|
|
2596
|
+
// composite opts.primaryKey are mutually exclusive: emitting both produces
|
|
2597
|
+
// two PRIMARY KEY clauses, which sqlite / Postgres / MySQL all reject at the
|
|
2598
|
+
// driver. Catch the contradiction at build time with a clear error rather
|
|
2599
|
+
// than a cryptic "multiple primary keys" failure mid-migration. Lives in the
|
|
2600
|
+
// shared composer so defineTable is covered too.
|
|
2601
|
+
var colHasPk = columns.some(function (c) {
|
|
2602
|
+
return c && (c.primaryKey || c.autoIncrement || c.serial);
|
|
2603
|
+
});
|
|
2604
|
+
if (colHasPk) {
|
|
2605
|
+
throw _err("createTable: a column-level primary key (primaryKey / " +
|
|
2606
|
+
"autoIncrement / serial) and a composite opts.primaryKey are mutually " +
|
|
2607
|
+
"exclusive", "sql-builder/bad-column");
|
|
2608
|
+
}
|
|
2617
2609
|
opts.primaryKey.forEach(_validateColumn);
|
|
2618
2610
|
pieces.push("PRIMARY KEY (" + opts.primaryKey.map(function (k) {
|
|
2619
2611
|
return _quoteId(k, dialect);
|
|
@@ -2621,7 +2613,11 @@ function createTable(name, columns, opts) {
|
|
|
2621
2613
|
}
|
|
2622
2614
|
var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
|
2623
2615
|
var sql = "CREATE TABLE " + ifNot + ref.ref(dialect) + " (" + pieces.join(", ") + ")";
|
|
2624
|
-
|
|
2616
|
+
// Route the finished DDL through the same emittable gate every SELECT /
|
|
2617
|
+
// INSERT / UPDATE / DELETE verb uses: it refuses a stacked top-level ';', a
|
|
2618
|
+
// NUL, an unterminated quote, and unbalanced parens - a defence-in-depth
|
|
2619
|
+
// backstop behind the per-column type / constraint guards.
|
|
2620
|
+
return _assertCatalogEmittable(sql, []);
|
|
2625
2621
|
}
|
|
2626
2622
|
|
|
2627
2623
|
// DDL DEFAULT renderer - numeric / boolean / null inline; a string
|
|
@@ -2767,11 +2763,11 @@ function alterTable(name, change, opts) {
|
|
|
2767
2763
|
if (col.notNull) def += " NOT NULL";
|
|
2768
2764
|
if (col.unique) def += " UNIQUE";
|
|
2769
2765
|
if (col.default !== undefined) def += " DEFAULT " + _ddlDefault(col.default);
|
|
2770
|
-
return
|
|
2766
|
+
return _assertCatalogEmittable(head + "ADD COLUMN " + def, []);
|
|
2771
2767
|
}
|
|
2772
2768
|
if (change.dropColumn) {
|
|
2773
2769
|
_validateColumn(change.dropColumn);
|
|
2774
|
-
return
|
|
2770
|
+
return _assertCatalogEmittable(head + "DROP COLUMN " + _quoteId(change.dropColumn, dialect), []);
|
|
2775
2771
|
}
|
|
2776
2772
|
if (change.renameColumn) {
|
|
2777
2773
|
var rc = change.renameColumn;
|
|
@@ -2780,10 +2776,8 @@ function alterTable(name, change, opts) {
|
|
|
2780
2776
|
}
|
|
2781
2777
|
_validateColumn(rc.from);
|
|
2782
2778
|
_validateColumn(rc.to);
|
|
2783
|
-
return
|
|
2784
|
-
|
|
2785
|
-
params: [],
|
|
2786
|
-
};
|
|
2779
|
+
return _assertCatalogEmittable(
|
|
2780
|
+
head + "RENAME COLUMN " + _quoteId(rc.from, dialect) + " TO " + _quoteId(rc.to, dialect), []);
|
|
2787
2781
|
}
|
|
2788
2782
|
throw _err("alterTable change must be addColumn / dropColumn / renameColumn",
|
|
2789
2783
|
"sql-builder/bad-alter");
|
|
@@ -3255,47 +3249,10 @@ function _assertCatalogEmittable(sql, params) {
|
|
|
3255
3249
|
// Quote/comment-aware single-statement + balanced-paren scan, identical
|
|
3256
3250
|
// to _assertEmittable's tail. A stacked top-level ';' / unterminated
|
|
3257
3251
|
// quote is refused here too.
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
var ch = sql.charAt(i);
|
|
3263
|
-
var next = i + 1 < len ? sql.charAt(i + 1) : "";
|
|
3264
|
-
if (ch === "'" || ch === '"' || ch === "`") {
|
|
3265
|
-
var q = ch;
|
|
3266
|
-
var closed = false;
|
|
3267
|
-
i += 1;
|
|
3268
|
-
while (i < len) {
|
|
3269
|
-
if (sql.charAt(i) === q) {
|
|
3270
|
-
if (sql.charAt(i + 1) === q) { i += 2; continue; }
|
|
3271
|
-
i += 1; closed = true; break;
|
|
3272
|
-
}
|
|
3273
|
-
i += 1;
|
|
3274
|
-
}
|
|
3275
|
-
if (!closed) {
|
|
3276
|
-
throw _err("catalog: unterminated quote in emitted SQL (quote-jump / breakout risk)",
|
|
3277
|
-
"sql-builder/unterminated-quote");
|
|
3278
|
-
}
|
|
3279
|
-
continue;
|
|
3280
|
-
}
|
|
3281
|
-
if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
|
|
3282
|
-
if (ch === "/" && next === "*") {
|
|
3283
|
-
i += 2;
|
|
3284
|
-
while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
|
|
3285
|
-
i += 2;
|
|
3286
|
-
continue;
|
|
3287
|
-
}
|
|
3288
|
-
if (ch === "(") { depth += 1; }
|
|
3289
|
-
else if (ch === ")") { depth -= 1; }
|
|
3290
|
-
else if (ch === ";") {
|
|
3291
|
-
throw _err("catalog: emitted a top-level ';' - exactly one statement",
|
|
3292
|
-
"sql-builder/stacked-statement");
|
|
3293
|
-
}
|
|
3294
|
-
i += 1;
|
|
3295
|
-
}
|
|
3296
|
-
if (depth !== 0) {
|
|
3297
|
-
throw _err("catalog: unbalanced parentheses in emitted SQL (builder bug)", "sql-builder/unbalanced");
|
|
3298
|
-
}
|
|
3252
|
+
safeSql.assertSingleStatement(sql, {
|
|
3253
|
+
label: "catalog",
|
|
3254
|
+
makeError: function (m, suffix) { return _err(m, "sql-builder/" + suffix); },
|
|
3255
|
+
});
|
|
3299
3256
|
return { sql: sql, params: params };
|
|
3300
3257
|
}
|
|
3301
3258
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "The Node framework that owns its stack.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"check:vendor-currency": "node scripts/check-vendor-currency.js"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"esbuild": "0.28.
|
|
78
|
+
"esbuild": "0.28.1",
|
|
79
79
|
"postject": "1.0.0-alpha.6"
|
|
80
80
|
}
|
|
81
81
|
}
|
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:58fea242-8071-4e16-965b-d1ea85e285ba",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-13T02:42:24.654Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.4",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.4",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.4",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.4",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|