@blamejs/core 0.14.21 → 0.14.24
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 +6 -0
- package/README.md +2 -2
- package/index.js +5 -1
- package/lib/auth/jar.js +190 -28
- package/lib/auth/jwt-external.js +213 -0
- package/lib/auth/oauth.js +115 -101
- package/lib/compliance.js +37 -0
- package/lib/crypto-field.js +111 -5
- package/lib/db-query.js +123 -0
- package/lib/external-db-migrate.js +19 -7
- package/lib/external-db.js +508 -20
- package/lib/framework-error.js +6 -0
- package/lib/http-client.js +3 -4
- package/lib/lro.js +3 -4
- package/lib/mail-auth.js +236 -0
- package/lib/mail-dkim.js +1 -0
- package/lib/mail-server-mx.js +276 -7
- package/lib/mail.js +8 -4
- package/lib/middleware/deny-response.js +2 -10
- package/lib/middleware/health.js +1 -4
- package/lib/middleware/trace-log-correlation.js +3 -6
- package/lib/validate-opts.js +34 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/external-db.js
CHANGED
|
@@ -58,38 +58,293 @@ function _emitMetric(name, value, labels) {
|
|
|
58
58
|
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Statement-class classifier for auth-failure forensics
|
|
62
|
-
// the leading keyword
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
61
|
+
// Statement-class classifier for auth-failure forensics AND the
|
|
62
|
+
// residency write gate. Reads the leading keyword — but a leading
|
|
63
|
+
// keyword is not the whole story: `WITH ... INSERT`, `EXPLAIN ANALYZE
|
|
64
|
+
// INSERT`, `CALL`/`EXECUTE`/`DO`, `COPY ... FROM`, and `REPLACE` all
|
|
65
|
+
// place rows while their leading (or only) keyword reads as harmless.
|
|
66
|
+
// _classifyStatement unwraps WITH (CTE) and EXPLAIN [ANALYZE] prefixes
|
|
67
|
+
// to the effective verb so the gate sees the real statement class; an
|
|
68
|
+
// attacker-controlled trailing fragment still can't smuggle a false
|
|
69
|
+
// class because the multi-statement form is refused upstream
|
|
70
|
+
// (_hasTrailingStatement) and an unresolvable prefix classifies
|
|
71
|
+
// UNKNOWN (fail-closed on the gate's enforced path).
|
|
72
|
+
//
|
|
73
|
+
// Skips leading whitespace plus SQL line / block comments before
|
|
74
|
+
// reading the keyword. Linear (non-backtracking) comment/whitespace
|
|
75
|
+
// skip: each iteration of the outer group consumes exactly one
|
|
76
|
+
// whitespace char, one complete block comment (star-not-slash form,
|
|
77
|
+
// never a lazy `[\s\S]*?`), or one complete line comment — disjoint by
|
|
78
|
+
// first char, so a crafted SQL string of nested `/**/` or `*/--` runs
|
|
79
|
+
// cannot backtrack polynomially (CWE-1333 ReDoS).
|
|
71
80
|
var _STATEMENT_CLASS_RE = /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|--[^\n]*\n)*([A-Za-z]+)/;
|
|
72
81
|
var _STATEMENT_CLASS_MAP = Object.freeze({
|
|
73
|
-
SELECT: "SELECT",
|
|
74
|
-
|
|
82
|
+
SELECT: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
|
|
83
|
+
SHOW: "READ_INFO", DESCRIBE: "READ_INFO", DESC: "READ_INFO",
|
|
84
|
+
PRAGMA: "READ_INFO", USE: "READ_INFO",
|
|
85
|
+
INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML",
|
|
86
|
+
UPSERT: "DML", REPLACE: "DML",
|
|
75
87
|
CREATE: "DDL", DROP: "DDL", ALTER: "DDL", TRUNCATE: "DDL",
|
|
76
88
|
RENAME: "DDL", COMMENT: "DDL",
|
|
77
89
|
GRANT: "DCL", REVOKE: "DCL",
|
|
78
90
|
SET: "SESSION", RESET: "SESSION",
|
|
79
91
|
BEGIN: "TX", START: "TX", COMMIT: "TX", ROLLBACK: "TX",
|
|
80
92
|
SAVEPOINT: "TX", RELEASE: "TX",
|
|
81
|
-
CALL: "ROUTINE", EXECUTE: "ROUTINE",
|
|
93
|
+
CALL: "ROUTINE", EXECUTE: "ROUTINE", DO: "ROUTINE",
|
|
82
94
|
COPY: "BULK",
|
|
83
95
|
EXPLAIN: "META", ANALYZE: "META", VACUUM: "META",
|
|
84
96
|
});
|
|
85
97
|
|
|
98
|
+
// Main-statement keywords that may follow a WITH (CTE) clause list —
|
|
99
|
+
// the verb that decides the statement's effect. `WITH src AS (...)
|
|
100
|
+
// INSERT INTO ...` is a write; classifying it by its leading keyword
|
|
101
|
+
// would label it a read and wave it past the residency write gate.
|
|
102
|
+
var _CTE_MAIN_VERBS = Object.freeze({
|
|
103
|
+
SELECT: true, VALUES: true, TABLE: true,
|
|
104
|
+
INSERT: true, UPDATE: true, DELETE: true,
|
|
105
|
+
MERGE: true, UPSERT: true, REPLACE: true,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// SQL identifier character classes, as char-range predicates rather
|
|
109
|
+
// than `_isIdentChar(ch)` regex literals — faster per-char in
|
|
110
|
+
// the tight statement-scan loops and keeps the trivial word-char class
|
|
111
|
+
// out of the cross-file duplicate-regex catalog.
|
|
112
|
+
function _isIdentStart(ch) {
|
|
113
|
+
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
|
|
114
|
+
}
|
|
115
|
+
function _isIdentChar(ch) {
|
|
116
|
+
return _isIdentStart(ch) || (ch >= "0" && ch <= "9");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If sql[i] begins an opaque span — a string literal ('...' with
|
|
120
|
+
// doubled-quote escapes), a quoted identifier ("..." / `...` / [...]),
|
|
121
|
+
// a Postgres dollar-quoted body ($tag$...$tag$), or a SQL line / block
|
|
122
|
+
// comment — return the index just past its close; -1 if the span is
|
|
123
|
+
// unterminated; i unchanged if sql[i] does not begin a span. One
|
|
124
|
+
// linear scan per call, no backtracking regex (CWE-1333). Shared by
|
|
125
|
+
// every SQL leading-/effective-verb scanner below so the multi-
|
|
126
|
+
// statement, CTE, EXPLAIN, and COPY walkers all agree on what counts
|
|
127
|
+
// as data vs. structure across the Postgres / MySQL / SQLite dialects
|
|
128
|
+
// external-db targets. A doubled closing quote ('it''s') re-enters as
|
|
129
|
+
// a fresh empty span on the caller's next iteration, staying opaque.
|
|
130
|
+
function _skipOpaqueSpan(sql, i) {
|
|
131
|
+
var n = sql.length;
|
|
132
|
+
var ch = sql.charAt(i);
|
|
133
|
+
if (ch === "'" || ch === "\"" || ch === "`") {
|
|
134
|
+
var close = sql.indexOf(ch, i + 1);
|
|
135
|
+
return close === -1 ? -1 : close + 1;
|
|
136
|
+
}
|
|
137
|
+
if (ch === "[") { // SQLite / MSSQL bracket identifier
|
|
138
|
+
var rb = sql.indexOf("]", i + 1);
|
|
139
|
+
return rb === -1 ? -1 : rb + 1;
|
|
140
|
+
}
|
|
141
|
+
if (ch === "$") {
|
|
142
|
+
// $tag$ ... $tag$ dollar-quoted body; a bare $n placeholder has no
|
|
143
|
+
// second `$` after the run of word chars and is not a span.
|
|
144
|
+
var tagEnd = i + 1;
|
|
145
|
+
while (tagEnd < n && _isIdentChar(sql.charAt(tagEnd))) tagEnd += 1;
|
|
146
|
+
if (tagEnd < n && sql.charAt(tagEnd) === "$") {
|
|
147
|
+
var tag = sql.slice(i, tagEnd + 1);
|
|
148
|
+
var closeTag = sql.indexOf(tag, tagEnd + 1);
|
|
149
|
+
return closeTag === -1 ? -1 : closeTag + tag.length;
|
|
150
|
+
}
|
|
151
|
+
return i;
|
|
152
|
+
}
|
|
153
|
+
if (ch === "-" && sql.charAt(i + 1) === "-") {
|
|
154
|
+
var nl = sql.indexOf("\n", i + 2);
|
|
155
|
+
return nl === -1 ? -1 : nl + 1;
|
|
156
|
+
}
|
|
157
|
+
if (ch === "/" && sql.charAt(i + 1) === "*") {
|
|
158
|
+
var ce = sql.indexOf("*/", i + 2);
|
|
159
|
+
return ce === -1 ? -1 : ce + 2;
|
|
160
|
+
}
|
|
161
|
+
return i;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve the main-statement keyword of a WITH-prefixed (CTE)
|
|
165
|
+
// statement: walk past the CTE definition list to the first top-level
|
|
166
|
+
// main verb (opaque spans skipped via _skipOpaqueSpan, parens tracked
|
|
167
|
+
// by depth). `WITH src AS (...) INSERT INTO ...` is a write; the
|
|
168
|
+
// leading keyword would label it a read and wave it past the residency
|
|
169
|
+
// write gate. Returns the uppercased verb, or null when unresolvable
|
|
170
|
+
// (unterminated span, parenthesized main statement, stray close-paren,
|
|
171
|
+
// no verb found) — the gate REFUSES unresolvable statements on its
|
|
172
|
+
// enforced path, so a parse miss fails closed.
|
|
173
|
+
function _cteMainKeyword(sql, start) {
|
|
174
|
+
var n = sql.length;
|
|
175
|
+
var depth = 0;
|
|
176
|
+
var i = start;
|
|
177
|
+
while (i < n) {
|
|
178
|
+
var ch = sql.charAt(i);
|
|
179
|
+
var skipped = _skipOpaqueSpan(sql, i);
|
|
180
|
+
if (skipped === -1) return null;
|
|
181
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
182
|
+
if (ch === "(") { depth += 1; i += 1; continue; }
|
|
183
|
+
if (ch === ")") { depth -= 1; i += 1; continue; }
|
|
184
|
+
if (_isIdentStart(ch)) {
|
|
185
|
+
var we = i + 1;
|
|
186
|
+
while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
|
|
187
|
+
if (depth === 0) {
|
|
188
|
+
var word = sql.slice(i, we).toUpperCase();
|
|
189
|
+
if (_CTE_MAIN_VERBS[word] === true) return word;
|
|
190
|
+
// Top-level word that is not a main verb: CTE name, AS,
|
|
191
|
+
// RECURSIVE, [NOT] MATERIALIZED, or a SEARCH / CYCLE clause
|
|
192
|
+
// word — keep walking.
|
|
193
|
+
}
|
|
194
|
+
i = we;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
i += 1;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// EXPLAIN option words (Postgres parenthesized + legacy bare; MySQL
|
|
203
|
+
// bare). These precede the inner statement and never terminate the
|
|
204
|
+
// option list, so the verb scanner skips them; only ANALYZE flips the
|
|
205
|
+
// "this EXPLAIN actually executes the statement" bit.
|
|
206
|
+
var _EXPLAIN_OPTION_WORDS = Object.freeze({
|
|
207
|
+
ANALYZE: true, VERBOSE: true, COSTS: true, BUFFERS: true,
|
|
208
|
+
SETTINGS: true, WAL: true, TIMING: true, SUMMARY: true,
|
|
209
|
+
SERIALIZE: true, MEMORY: true, GENERIC_PLAN: true, FORMAT: true,
|
|
210
|
+
TEXT: true, JSON: true, YAML: true, XML: true, TREE: true,
|
|
211
|
+
EXTENDED: true, PARTITIONS: true,
|
|
212
|
+
ON: true, OFF: true, TRUE: true, FALSE: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Resolve an EXPLAIN prefix: skip the option list (a parenthesized
|
|
216
|
+
// `( ANALYZE, FORMAT JSON )` group and/or bare option words), noting
|
|
217
|
+
// whether ANALYZE is present, and return { hasAnalyze, innerStart }
|
|
218
|
+
// pointing at the wrapped statement's leading keyword. null when
|
|
219
|
+
// unresolvable (unterminated span, unbalanced option parens, no inner
|
|
220
|
+
// statement). EXPLAIN ANALYZE EXECUTES the wrapped statement, so an
|
|
221
|
+
// `EXPLAIN ANALYZE INSERT ...` is a real write the gate must see.
|
|
222
|
+
function _explainResolve(sql, start) {
|
|
223
|
+
var n = sql.length;
|
|
224
|
+
var hasAnalyze = false;
|
|
225
|
+
var i = start;
|
|
226
|
+
while (i < n) {
|
|
227
|
+
var ch = sql.charAt(i);
|
|
228
|
+
var skipped = _skipOpaqueSpan(sql, i);
|
|
229
|
+
if (skipped === -1) return null;
|
|
230
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
231
|
+
if (ch === "(") {
|
|
232
|
+
var depth = 0;
|
|
233
|
+
var j = i;
|
|
234
|
+
while (j < n) {
|
|
235
|
+
var c2 = sql.charAt(j);
|
|
236
|
+
var s2 = _skipOpaqueSpan(sql, j);
|
|
237
|
+
if (s2 === -1) return null;
|
|
238
|
+
if (s2 !== j) { j = s2; continue; }
|
|
239
|
+
if (c2 === "(") { depth += 1; j += 1; continue; }
|
|
240
|
+
if (c2 === ")") { depth -= 1; j += 1; if (depth === 0) break; continue; }
|
|
241
|
+
if (_isIdentStart(c2)) {
|
|
242
|
+
var oe = j + 1;
|
|
243
|
+
while (oe < n && _isIdentChar(sql.charAt(oe))) oe += 1;
|
|
244
|
+
if (sql.slice(j, oe).toUpperCase() === "ANALYZE") hasAnalyze = true;
|
|
245
|
+
j = oe;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
j += 1;
|
|
249
|
+
}
|
|
250
|
+
if (depth !== 0) return null;
|
|
251
|
+
i = j;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (_isIdentStart(ch)) {
|
|
255
|
+
var we = i + 1;
|
|
256
|
+
while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
|
|
257
|
+
var word = sql.slice(i, we).toUpperCase();
|
|
258
|
+
if (word === "ANALYZE") { hasAnalyze = true; i = we; continue; }
|
|
259
|
+
if (_EXPLAIN_OPTION_WORDS[word] === true) { i = we; continue; }
|
|
260
|
+
return { hasAnalyze: hasAnalyze, innerStart: i };
|
|
261
|
+
}
|
|
262
|
+
i += 1;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// COPY <target> FROM <source> LOADS rows (a write); COPY <target> /
|
|
268
|
+
// COPY (query) TO <dest> EXPORTS rows (a read). Find the first
|
|
269
|
+
// top-level FROM / TO keyword after COPY, skipping a parenthesized
|
|
270
|
+
// source query and opaque spans. FROM → true (load); TO → false
|
|
271
|
+
// (export); unresolvable or neither → true (fail-closed write).
|
|
272
|
+
function _copyLoadsRows(sql) {
|
|
273
|
+
var m = _STATEMENT_CLASS_RE.exec(sql);
|
|
274
|
+
if (!m) return true;
|
|
275
|
+
var n = sql.length;
|
|
276
|
+
var i = m.index + m[0].length;
|
|
277
|
+
while (i < n) {
|
|
278
|
+
var ch = sql.charAt(i);
|
|
279
|
+
var skipped = _skipOpaqueSpan(sql, i);
|
|
280
|
+
if (skipped === -1) return true;
|
|
281
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
282
|
+
if (ch === "(") {
|
|
283
|
+
var depth = 0;
|
|
284
|
+
var j = i;
|
|
285
|
+
while (j < n) {
|
|
286
|
+
var c2 = sql.charAt(j);
|
|
287
|
+
var s2 = _skipOpaqueSpan(sql, j);
|
|
288
|
+
if (s2 === -1) return true;
|
|
289
|
+
if (s2 !== j) { j = s2; continue; }
|
|
290
|
+
if (c2 === "(") { depth += 1; j += 1; continue; }
|
|
291
|
+
if (c2 === ")") { depth -= 1; j += 1; if (depth === 0) break; continue; }
|
|
292
|
+
j += 1;
|
|
293
|
+
}
|
|
294
|
+
i = j;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (_isIdentStart(ch)) {
|
|
298
|
+
var we = i + 1;
|
|
299
|
+
while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
|
|
300
|
+
var word = sql.slice(i, we).toUpperCase();
|
|
301
|
+
if (word === "FROM") return true;
|
|
302
|
+
if (word === "TO") return false;
|
|
303
|
+
i = we;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
i += 1;
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Forensic / gate statement class. Resolves WITH (CTE) and EXPLAIN
|
|
312
|
+
// [ANALYZE] prefixes to the effective verb so a write wearing a
|
|
313
|
+
// harmless leading keyword classifies as the write it is; unresolvable
|
|
314
|
+
// prefixes classify UNKNOWN (fail-closed at the gate).
|
|
86
315
|
function _classifyStatement(sql) {
|
|
87
316
|
if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
|
|
88
317
|
var m = _STATEMENT_CLASS_RE.exec(sql);
|
|
89
318
|
if (!m) return "UNKNOWN";
|
|
90
|
-
|
|
319
|
+
var kw = m[1].toUpperCase();
|
|
320
|
+
if (kw === "WITH") {
|
|
321
|
+
var main = _cteMainKeyword(sql, m.index + m[0].length);
|
|
322
|
+
return main === null ? "UNKNOWN" : (_STATEMENT_CLASS_MAP[main] || "OTHER");
|
|
323
|
+
}
|
|
324
|
+
if (kw === "EXPLAIN") {
|
|
325
|
+
// Plan-only EXPLAIN reads (META). EXPLAIN ANALYZE executes the
|
|
326
|
+
// wrapped statement, so its effective class is the inner one's.
|
|
327
|
+
var ex = _explainResolve(sql, m.index + m[0].length);
|
|
328
|
+
if (ex === null) return "UNKNOWN";
|
|
329
|
+
if (!ex.hasAnalyze) return "META";
|
|
330
|
+
return _classifyStatement(sql.slice(ex.innerStart));
|
|
331
|
+
}
|
|
332
|
+
return _STATEMENT_CLASS_MAP[kw] || "OTHER";
|
|
91
333
|
}
|
|
92
334
|
|
|
335
|
+
// Statement classes that place no rows on the backend, so the cross-
|
|
336
|
+
// border residency write gate lets them pass without a row tag. Every
|
|
337
|
+
// other class — DML, ROUTINE (CALL / EXECUTE / DO), a COPY ... FROM
|
|
338
|
+
// load, or an unmapped/unresolved statement — is treated as a write
|
|
339
|
+
// and must carry a residency tag on the enforced path. DDL (schema
|
|
340
|
+
// changes) and DCL (grants) move no row data across a border; the one
|
|
341
|
+
// edge they don't cover, CREATE TABLE AS SELECT / SELECT INTO, is a
|
|
342
|
+
// documented residency limitation rather than a silent bypass.
|
|
343
|
+
var _RESIDENCY_READ_CLASS = Object.freeze({
|
|
344
|
+
SELECT: true, READ_INFO: true, SESSION: true,
|
|
345
|
+
TX: true, DCL: true, DDL: true, META: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
93
348
|
// ---- OpenTelemetry database-client semantic conventions ----
|
|
94
349
|
//
|
|
95
350
|
// db.* span / metric attributes on the query / transaction / read emit
|
|
@@ -738,6 +993,7 @@ function _servesClassification(b, cls) {
|
|
|
738
993
|
* backend?: string, // explicit backend name; bypasses classification + role pick
|
|
739
994
|
* classification?: string, // route to first backend whose classifications include this value
|
|
740
995
|
* includeSqlInAudit?: boolean, // emit SQL text in audit metadata (off by default — may carry literal PII)
|
|
996
|
+
* rowResidencyTag?: string, // the row's residency region tag; required for a write (DML, CALL/EXECUTE/DO, COPY ... FROM, REPLACE, or a WITH/EXPLAIN-ANALYZE wrapping one) to a residency-tagged backend under a cross-border regulated posture (pass "global"/"unrestricted" for region-neutral rows)
|
|
741
997
|
*
|
|
742
998
|
* @example
|
|
743
999
|
* var res = await b.externalDb.query(
|
|
@@ -754,6 +1010,14 @@ async function query(sql, params, opts) {
|
|
|
754
1010
|
var b = _pickBackend(opts);
|
|
755
1011
|
var role = dbRoleContext.getRole();
|
|
756
1012
|
|
|
1013
|
+
// Per-row residency write gate — refuses a cross-border write before
|
|
1014
|
+
// the statement reaches the wire (see _assertRowResidency).
|
|
1015
|
+
var _resRefusal = _assertRowResidency(sql, opts, b);
|
|
1016
|
+
if (_resRefusal) {
|
|
1017
|
+
_emit("db.residency.gate.rejected", "denied", _resRefusal.metadata, _resRefusal.code);
|
|
1018
|
+
throw _err(_resRefusal.code, _resRefusal.message, true);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
757
1021
|
var t0 = Date.now();
|
|
758
1022
|
try {
|
|
759
1023
|
var result = await retryHelper.withRetry(function () {
|
|
@@ -854,6 +1118,7 @@ async function query(sql, params, opts) {
|
|
|
854
1118
|
* statementTimeoutMs?: number, // SET LOCAL statement_timeout
|
|
855
1119
|
* idleInTransactionTimeoutMs?: number, // SET LOCAL idle_in_transaction_session_timeout
|
|
856
1120
|
* deadlockRetries?: number, // retries for 40P01 / 40001 (default 3)
|
|
1121
|
+
* rowResidencyTag?: string, // residency tag applied to every statement; a per-call tx.query(sql, params, { rowResidencyTag }) overrides it for that statement
|
|
857
1122
|
*
|
|
858
1123
|
* @example
|
|
859
1124
|
* var summary = await b.externalDb.transaction(async function (tx) {
|
|
@@ -907,10 +1172,34 @@ async function transaction(fn, opts) {
|
|
|
907
1172
|
}
|
|
908
1173
|
var maxRetries = (typeof opts.deadlockRetries === "number")
|
|
909
1174
|
? Math.floor(opts.deadlockRetries) : 3; // allow:numeric-opt-Infinity
|
|
1175
|
+
// Validate the transaction-level residency tag shape at entry (the
|
|
1176
|
+
// sessionGucs / deadlockRetries discipline) so an empty-string tag
|
|
1177
|
+
// fails before BEGIN rather than at the first statement.
|
|
1178
|
+
if (opts.rowResidencyTag !== undefined && opts.rowResidencyTag !== null &&
|
|
1179
|
+
(typeof opts.rowResidencyTag !== "string" || opts.rowResidencyTag.length === 0)) {
|
|
1180
|
+
throw _err("INVALID_OPT",
|
|
1181
|
+
"transaction: opts.rowResidencyTag must be a non-empty string when supplied", true);
|
|
1182
|
+
}
|
|
910
1183
|
return await b.breaker.wrap(async function () {
|
|
911
1184
|
var client = await b.pool.acquire();
|
|
912
1185
|
var txClient = {
|
|
913
|
-
|
|
1186
|
+
// Per-statement residency gate inside the transaction: a
|
|
1187
|
+
// transaction-level opts.rowResidencyTag applies to every
|
|
1188
|
+
// statement; an optional per-call third argument overrides it
|
|
1189
|
+
// for that statement. A refusal throws into the operator's tx
|
|
1190
|
+
// body, which rolls the transaction back — no partial commit of
|
|
1191
|
+
// a cross-border write.
|
|
1192
|
+
query: function (sql, params, perCallOpts) {
|
|
1193
|
+
var effOpts = (perCallOpts && perCallOpts.rowResidencyTag !== undefined)
|
|
1194
|
+
? perCallOpts
|
|
1195
|
+
: { rowResidencyTag: opts.rowResidencyTag };
|
|
1196
|
+
var refusal = _assertRowResidency(sql, effOpts, b);
|
|
1197
|
+
if (refusal) {
|
|
1198
|
+
_emit("db.residency.gate.rejected", "denied", refusal.metadata, refusal.code);
|
|
1199
|
+
throw _err(refusal.code, refusal.message, true);
|
|
1200
|
+
}
|
|
1201
|
+
return b.query(client, sql, params || []);
|
|
1202
|
+
},
|
|
914
1203
|
};
|
|
915
1204
|
var committed = false;
|
|
916
1205
|
var attempt = 0;
|
|
@@ -1229,9 +1518,17 @@ var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
|
|
|
1229
1518
|
// - "unrestricted" tag on either side: compatible (operator
|
|
1230
1519
|
// declared no constraint).
|
|
1231
1520
|
// - Different tags: compatible only when allowCrossBorder is true.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1521
|
+
//
|
|
1522
|
+
// The regulated-posture set itself lives on b.compliance
|
|
1523
|
+
// (CROSS_BORDER_REGULATED_POSTURES) — one vocabulary shared with the
|
|
1524
|
+
// local db-query residency gate.
|
|
1525
|
+
function _crossBorderRegulated(posture) {
|
|
1526
|
+
if (posture === null || posture === undefined) return false;
|
|
1527
|
+
try {
|
|
1528
|
+
var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
|
|
1529
|
+
return compliance.isCrossBorderRegulated(posture);
|
|
1530
|
+
} catch (_e) { return false; }
|
|
1531
|
+
}
|
|
1235
1532
|
|
|
1236
1533
|
function _residencyCompatible(primaryTag, replicaTag) {
|
|
1237
1534
|
if (!primaryTag || !replicaTag) return true;
|
|
@@ -1240,6 +1537,54 @@ function _residencyCompatible(primaryTag, replicaTag) {
|
|
|
1240
1537
|
return false;
|
|
1241
1538
|
}
|
|
1242
1539
|
|
|
1540
|
+
// True when `sql` carries a non-comment, non-whitespace statement after
|
|
1541
|
+
// the first top-level semicolon — the multi-statement shape that would
|
|
1542
|
+
// let a trailing DML hide behind a harmless leading keyword. A `;`
|
|
1543
|
+
// inside any opaque span (string literal, quoted identifier, dollar-
|
|
1544
|
+
// quoted body, comment) is data, not a separator: the main scan skips
|
|
1545
|
+
// every span via _skipOpaqueSpan, so a `;` inside `$$ ... ; ... $$` or
|
|
1546
|
+
// a doubled-quote run can't be mistaken for a top-level separator (and
|
|
1547
|
+
// can't desync the scanner into missing a real one). Single linear
|
|
1548
|
+
// pass, no backtracking regex (CWE-1333).
|
|
1549
|
+
function _hasTrailingStatement(sql) {
|
|
1550
|
+
if (typeof sql !== "string") return false;
|
|
1551
|
+
var n = sql.length;
|
|
1552
|
+
var i = 0;
|
|
1553
|
+
while (i < n) {
|
|
1554
|
+
var ch = sql.charAt(i);
|
|
1555
|
+
var skipped = _skipOpaqueSpan(sql, i);
|
|
1556
|
+
if (skipped === -1) return false; // unterminated span — no top-level content beyond it
|
|
1557
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
1558
|
+
if (ch !== ";") { i += 1; continue; }
|
|
1559
|
+
// First top-level `;` decides: if everything after it is only
|
|
1560
|
+
// whitespace and SQL comments there is no second statement (a
|
|
1561
|
+
// single statement may end with `;`); any other character is one.
|
|
1562
|
+
// Comments-only skip here (not _skipOpaqueSpan) — a string / ident
|
|
1563
|
+
// after the `;` IS trailing content, so it must count, not be
|
|
1564
|
+
// skipped as a span.
|
|
1565
|
+
var j = i + 1;
|
|
1566
|
+
while (j < n) {
|
|
1567
|
+
var c = sql.charAt(j);
|
|
1568
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") { j += 1; continue; }
|
|
1569
|
+
if (c === "/" && sql.charAt(j + 1) === "*") {
|
|
1570
|
+
var end = sql.indexOf("*/", j + 2);
|
|
1571
|
+
if (end === -1) return false; // unterminated comment — no content
|
|
1572
|
+
j = end + 2;
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
if (c === "-" && sql.charAt(j + 1) === "-") {
|
|
1576
|
+
var nl = sql.indexOf("\n", j + 2);
|
|
1577
|
+
if (nl === -1) return false; // line comment to EOF — no content
|
|
1578
|
+
j = nl + 1;
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
return false;
|
|
1584
|
+
}
|
|
1585
|
+
return false;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1243
1588
|
function _activePosture() {
|
|
1244
1589
|
try {
|
|
1245
1590
|
var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
|
|
@@ -1247,6 +1592,115 @@ function _activePosture() {
|
|
|
1247
1592
|
} catch (_e) { return null; }
|
|
1248
1593
|
}
|
|
1249
1594
|
|
|
1595
|
+
// Per-row residency write gate (GDPR Art 44-46 / PIPL Art 38 / DPDP
|
|
1596
|
+
// §16 cross-border transfer restrictions). External-db takes raw SQL,
|
|
1597
|
+
// not row objects, so the row's residency tag travels as
|
|
1598
|
+
// `opts.rowResidencyTag` — the operator computes it from app logic
|
|
1599
|
+
// (session / declared user region), never inferred from request
|
|
1600
|
+
// metadata. Under a cross-border regulated posture, DML to a
|
|
1601
|
+
// residency-tagged backend REQUIRES the tag and refuses a mismatch;
|
|
1602
|
+
// untagged backends, unregulated postures, and non-DML statements
|
|
1603
|
+
// pass (with an advisory audit when a tag was supplied anyway).
|
|
1604
|
+
// Returns null on pass or { code, message, metadata } — the caller
|
|
1605
|
+
// throws via _err so the refusal carries permanent=true.
|
|
1606
|
+
function _assertRowResidency(sql, opts, backend) {
|
|
1607
|
+
var tag = opts && opts.rowResidencyTag;
|
|
1608
|
+
if (tag !== undefined && tag !== null &&
|
|
1609
|
+
(typeof tag !== "string" || tag.length === 0)) {
|
|
1610
|
+
return {
|
|
1611
|
+
code: "INVALID_OPT",
|
|
1612
|
+
message: "rowResidencyTag must be a non-empty string when supplied",
|
|
1613
|
+
metadata: { backend: backend.name, statementClass: _classifyStatement(sql) },
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
var backendTag = backend.residencyTag || "unrestricted";
|
|
1617
|
+
var posture = _activePosture();
|
|
1618
|
+
var regulated = _crossBorderRegulated(posture);
|
|
1619
|
+
// The gate only enforces on the cross-border-regulated + residency-
|
|
1620
|
+
// tagged-backend path. Everywhere else (unregulated posture,
|
|
1621
|
+
// unrestricted backend — including the framework's own coordination
|
|
1622
|
+
// stores) statements pass untouched; multi-statement SQL stays the
|
|
1623
|
+
// operator's business there.
|
|
1624
|
+
if (regulated && backendTag !== "unrestricted") {
|
|
1625
|
+
// A trailing statement after a top-level `;` could hide a write
|
|
1626
|
+
// behind a harmless prefix — refuse multi-statement SQL on the
|
|
1627
|
+
// enforced path so a residency-bound write cannot ride a SELECT.
|
|
1628
|
+
if (_hasTrailingStatement(sql)) {
|
|
1629
|
+
return {
|
|
1630
|
+
code: "MULTI_STATEMENT_REFUSED",
|
|
1631
|
+
message: "multi-statement SQL is not supported on the residency-gated " +
|
|
1632
|
+
"write path; pass one statement per query()",
|
|
1633
|
+
metadata: { backend: backend.name, backendTag: backendTag, posture: posture,
|
|
1634
|
+
statementClass: _classifyStatement(sql), scope: "external" },
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
var cls = _classifyStatement(sql);
|
|
1638
|
+
// Fail-closed: a statement whose effective class can't be resolved
|
|
1639
|
+
// (an unparseable WITH / EXPLAIN prefix or pathological quoting)
|
|
1640
|
+
// could be hiding a write, so refuse it rather than wave it through
|
|
1641
|
+
// as a read.
|
|
1642
|
+
if (cls === "UNKNOWN") {
|
|
1643
|
+
return {
|
|
1644
|
+
code: "STATEMENT_UNRESOLVED_REFUSED",
|
|
1645
|
+
message: "could not resolve the effective statement class on the " +
|
|
1646
|
+
"residency-gated write path; pass one plain statement per " +
|
|
1647
|
+
"query() (an unparseable WITH/EXPLAIN prefix or quoting)",
|
|
1648
|
+
metadata: { backend: backend.name, backendTag: backendTag, posture: posture,
|
|
1649
|
+
statementClass: cls, scope: "external" },
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
// The gate enforces on writes, not just DML: ROUTINE (CALL /
|
|
1653
|
+
// EXECUTE / DO), a COPY ... FROM load, and an unmapped (OTHER)
|
|
1654
|
+
// verb all place rows and must carry a tag. Recognized pure reads
|
|
1655
|
+
// (and a COPY ... TO export) place none and pass untagged.
|
|
1656
|
+
var isWrite = !(_RESIDENCY_READ_CLASS[cls] === true ||
|
|
1657
|
+
(cls === "BULK" && !_copyLoadsRows(sql)));
|
|
1658
|
+
if (!isWrite) return null;
|
|
1659
|
+
if (!tag) {
|
|
1660
|
+
return {
|
|
1661
|
+
code: "RESIDENCY_GATE_REQUIRED",
|
|
1662
|
+
message: "write to backend '" + backend.name + "' (residencyTag='" +
|
|
1663
|
+
backendTag + "') under '" + posture + "' posture requires " +
|
|
1664
|
+
"opts.rowResidencyTag. Pass { rowResidencyTag: \"" + backendTag +
|
|
1665
|
+
"\" } for rows belonging to this region, or declare per-row " +
|
|
1666
|
+
"residency via b.cryptoField.declarePerRowResidency for local tables",
|
|
1667
|
+
metadata: { backend: backend.name, backendTag: backendTag,
|
|
1668
|
+
rowTag: null, posture: posture, statementClass: cls,
|
|
1669
|
+
scope: "external" },
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
if (tag !== "global" && tag !== "unrestricted" &&
|
|
1673
|
+
!_residencyCompatible(tag, backendTag)) {
|
|
1674
|
+
return {
|
|
1675
|
+
code: "RESIDENCY_TAG_MISMATCH",
|
|
1676
|
+
message: "row residencyTag '" + tag + "' is not compatible with backend '" +
|
|
1677
|
+
backend.name + "' residencyTag '" + backendTag + "' under '" + posture +
|
|
1678
|
+
"' posture (cross-border transfer refused)",
|
|
1679
|
+
metadata: { backend: backend.name, backendTag: backendTag,
|
|
1680
|
+
rowTag: tag, posture: posture, statementClass: cls,
|
|
1681
|
+
scope: "external" },
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
if (tag) {
|
|
1687
|
+
// Unregulated posture or untagged backend with a tag supplied on a
|
|
1688
|
+
// write — pass, but surface the advisory so operators staging a
|
|
1689
|
+
// posture flip can see what WOULD be evaluated.
|
|
1690
|
+
var advisoryCls = _classifyStatement(sql);
|
|
1691
|
+
var advisoryWrite = advisoryCls !== "UNKNOWN" &&
|
|
1692
|
+
!(_RESIDENCY_READ_CLASS[advisoryCls] === true ||
|
|
1693
|
+
(advisoryCls === "BULK" && !_copyLoadsRows(sql)));
|
|
1694
|
+
if (advisoryWrite) {
|
|
1695
|
+
_emit("db.residency.gate.advisory", "info", {
|
|
1696
|
+
backend: backend.name, backendTag: backendTag, rowTag: tag,
|
|
1697
|
+
posture: posture || null, statementClass: advisoryCls, scope: "external",
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1250
1704
|
function _buildReplicas(backendName, cfg) {
|
|
1251
1705
|
if (!cfg.replicas) return null;
|
|
1252
1706
|
if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
|
|
@@ -1275,7 +1729,7 @@ function _buildReplicas(backendName, cfg) {
|
|
|
1275
1729
|
var replicaTag = r.residencyTag || "unrestricted";
|
|
1276
1730
|
var allowCrossBorder = r.allowCrossBorder === true;
|
|
1277
1731
|
if (!_residencyCompatible(primaryTag, replicaTag) && !allowCrossBorder) {
|
|
1278
|
-
var underPosture =
|
|
1732
|
+
var underPosture = _crossBorderRegulated(posture);
|
|
1279
1733
|
throw _err("RESIDENCY_MISMATCH",
|
|
1280
1734
|
"backend '" + backendName + "': replica[" + i +
|
|
1281
1735
|
"] residencyTag '" + replicaTag +
|
|
@@ -1286,7 +1740,12 @@ function _buildReplicas(backendName, cfg) {
|
|
|
1286
1740
|
"documented legal basis (SCCs / BCRs / adequacy decision) to suppress.", true);
|
|
1287
1741
|
}
|
|
1288
1742
|
if (!_residencyCompatible(primaryTag, replicaTag) && allowCrossBorder) {
|
|
1289
|
-
|
|
1743
|
+
// The action name MUST stay in the registered `db.` namespace and
|
|
1744
|
+
// lowercase — the audit validator refuses "externalDb.*" (the old
|
|
1745
|
+
// name silently dropped every cross-border-allowed event through
|
|
1746
|
+
// safeEmit, leaving no audit-chain record of the operator's
|
|
1747
|
+
// conscious opt-in). Mirrors the read-path event name below.
|
|
1748
|
+
_emit("db.residency.replica.cross_border_allowed", "warning",
|
|
1290
1749
|
{ backend: backendName, replicaIndex: i,
|
|
1291
1750
|
primaryTag: primaryTag, replicaTag: replicaTag,
|
|
1292
1751
|
legalBasis: r.legalBasis || null,
|
|
@@ -1344,6 +1803,35 @@ async function _readQuery(sql, params, opts) {
|
|
|
1344
1803
|
throw _err("ALL_REPLICAS_UNHEALTHY",
|
|
1345
1804
|
"backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
|
|
1346
1805
|
}
|
|
1806
|
+
// Replica residency check — when the caller identifies the row's
|
|
1807
|
+
// region (opts.rowResidencyTag) under a regulated posture, a read
|
|
1808
|
+
// routed to an incompatible replica is refused unless the replica
|
|
1809
|
+
// was explicitly configured allowCrossBorder (which is audited).
|
|
1810
|
+
if (opts.rowResidencyTag && typeof opts.rowResidencyTag === "string") {
|
|
1811
|
+
var _readPosture = _activePosture();
|
|
1812
|
+
if (_crossBorderRegulated(_readPosture) &&
|
|
1813
|
+
opts.rowResidencyTag !== "global" && opts.rowResidencyTag !== "unrestricted" &&
|
|
1814
|
+
!_residencyCompatible(opts.rowResidencyTag, replica.residencyTag)) {
|
|
1815
|
+
if (replica.allowCrossBorder) {
|
|
1816
|
+
_emit("db.residency.replica.cross_border", "warning", {
|
|
1817
|
+
backend: b.name, replicaIdx: replica.index,
|
|
1818
|
+
rowTag: opts.rowResidencyTag, replicaTag: replica.residencyTag,
|
|
1819
|
+
posture: _readPosture,
|
|
1820
|
+
});
|
|
1821
|
+
} else {
|
|
1822
|
+
_emit("db.residency.replica.incompatible", "denied", {
|
|
1823
|
+
backend: b.name, replicaIdx: replica.index,
|
|
1824
|
+
rowTag: opts.rowResidencyTag, replicaTag: replica.residencyTag,
|
|
1825
|
+
posture: _readPosture,
|
|
1826
|
+
});
|
|
1827
|
+
throw _err("REPLICA_RESIDENCY_INCOMPATIBLE",
|
|
1828
|
+
"read for row residencyTag '" + opts.rowResidencyTag + "' routed to replica " +
|
|
1829
|
+
replica.index + " of backend '" + b.name + "' (residencyTag='" +
|
|
1830
|
+
replica.residencyTag + "') under '" + _readPosture +
|
|
1831
|
+
"' posture; set allowCrossBorder on the replica to permit (audited)", true);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1347
1835
|
var role = dbRoleContext.getRole();
|
|
1348
1836
|
var t0 = Date.now();
|
|
1349
1837
|
try {
|
package/lib/framework-error.js
CHANGED
|
@@ -125,6 +125,11 @@ var QueueError = defineClass("QueueError");
|
|
|
125
125
|
// them and skips immediately rather than hammering a misconfig.
|
|
126
126
|
var RedisError = defineClass("RedisError");
|
|
127
127
|
var ExternalDbError = defineClass("ExternalDbError");
|
|
128
|
+
// DbQueryError covers the local-SQLite query-builder refusal paths
|
|
129
|
+
// (residency write gates, malformed-call shapes). Refusals pass the
|
|
130
|
+
// permanent flag explicitly — a residency mismatch never becomes valid
|
|
131
|
+
// on retry, while the class stays open for transient codes later.
|
|
132
|
+
var DbQueryError = defineClass("DbQueryError");
|
|
128
133
|
var ClusterError = defineClass("ClusterError");
|
|
129
134
|
var ClusterProviderError = defineClass("ClusterProviderError");
|
|
130
135
|
var HandlerError = defineClass("HandlerError", { withCause: true });
|
|
@@ -653,6 +658,7 @@ module.exports = {
|
|
|
653
658
|
QueueError: QueueError,
|
|
654
659
|
RedisError: RedisError,
|
|
655
660
|
ExternalDbError: ExternalDbError,
|
|
661
|
+
DbQueryError: DbQueryError,
|
|
656
662
|
ClusterError: ClusterError,
|
|
657
663
|
ClusterProviderError: ClusterProviderError,
|
|
658
664
|
HandlerError: HandlerError,
|
package/lib/http-client.js
CHANGED
|
@@ -673,13 +673,12 @@ function _buildMultipartBody(spec) {
|
|
|
673
673
|
var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
|
|
674
674
|
|
|
675
675
|
function _stripCrossOriginAuth(headers) {
|
|
676
|
-
var out = {};
|
|
677
676
|
var keys = Object.keys(headers);
|
|
677
|
+
var strip = [];
|
|
678
678
|
for (var i = 0; i < keys.length; i++) {
|
|
679
|
-
if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1)
|
|
680
|
-
out[keys[i]] = headers[keys[i]];
|
|
679
|
+
if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) strip.push(keys[i]);
|
|
681
680
|
}
|
|
682
|
-
return
|
|
681
|
+
return validateOpts.assignOwnEnumerable({}, headers, strip);
|
|
683
682
|
}
|
|
684
683
|
|
|
685
684
|
/**
|