@askdb/core 1.0.0-beta.42 → 1.0.0-beta.43
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/README.md +6 -4
- package/dist/ask.d.ts +92 -4
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +234 -23
- package/dist/ask.js.map +1 -1
- package/dist/errors.d.ts +41 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/schema/v2/index.d.ts +1 -1
- package/dist/schema/v2/index.d.ts.map +1 -1
- package/dist/schema/v2/loader.d.ts +9 -1
- package/dist/schema/v2/loader.d.ts.map +1 -1
- package/dist/schema/v2/loader.js +171 -49
- package/dist/schema/v2/loader.js.map +1 -1
- package/dist/schema/v2/normalized.d.ts +33 -0
- package/dist/schema/v2/normalized.d.ts.map +1 -1
- package/dist/schema/v2/parser.d.ts +9 -0
- package/dist/schema/v2/parser.d.ts.map +1 -1
- package/dist/schema/v2/parser.js +18 -2
- package/dist/schema/v2/parser.js.map +1 -1
- package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy-loader.js +7 -3
- package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
- package/dist/schema/v2/tenant-policy.d.ts +28 -21
- package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy.js +67 -11
- package/dist/schema/v2/tenant-policy.js.map +1 -1
- package/dist/sql/bind.d.ts +17 -15
- package/dist/sql/bind.d.ts.map +1 -1
- package/dist/sql/bind.js +34 -172
- package/dist/sql/bind.js.map +1 -1
- package/dist/sql/dialect-spec.d.ts +21 -2
- package/dist/sql/dialect-spec.d.ts.map +1 -1
- package/dist/sql/dialect-spec.js +74 -3
- package/dist/sql/dialect-spec.js.map +1 -1
- package/dist/sql/generate.d.ts +12 -0
- package/dist/sql/generate.d.ts.map +1 -1
- package/dist/sql/generate.js +24 -16
- package/dist/sql/generate.js.map +1 -1
- package/dist/sql/lexer.d.ts +114 -0
- package/dist/sql/lexer.d.ts.map +1 -0
- package/dist/sql/lexer.js +397 -0
- package/dist/sql/lexer.js.map +1 -0
- package/dist/sql/sensitive-guardrail.d.ts +18 -3
- package/dist/sql/sensitive-guardrail.d.ts.map +1 -1
- package/dist/sql/sensitive-guardrail.js +440 -153
- package/dist/sql/sensitive-guardrail.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +70 -8
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +799 -52
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/dist/sql/tenant-hierarchy.d.ts +19 -0
- package/dist/sql/tenant-hierarchy.d.ts.map +1 -0
- package/dist/sql/tenant-hierarchy.js +53 -0
- package/dist/sql/tenant-hierarchy.js.map +1 -0
- package/dist/sql/tenant-placeholders.d.ts +60 -10
- package/dist/sql/tenant-placeholders.d.ts.map +1 -1
- package/dist/sql/tenant-placeholders.js +229 -58
- package/dist/sql/tenant-placeholders.js.map +1 -1
- package/dist/sql/tenant-prompt.d.ts +4 -0
- package/dist/sql/tenant-prompt.d.ts.map +1 -1
- package/dist/sql/tenant-prompt.js +79 -11
- package/dist/sql/tenant-prompt.js.map +1 -1
- package/dist/sql/tenant-scope-validate.d.ts +3 -1
- package/dist/sql/tenant-scope-validate.d.ts.map +1 -1
- package/dist/sql/tenant-scope-validate.js +5 -2
- package/dist/sql/tenant-scope-validate.js.map +1 -1
- package/dist/sql/validate.d.ts +24 -4
- package/dist/sql/validate.d.ts.map +1 -1
- package/dist/sql/validate.js +123 -37
- package/dist/sql/validate.js.map +1 -1
- package/package.json +6 -5
- package/dist/exec/postgres.d.ts +0 -6
- package/dist/exec/postgres.d.ts.map +0 -1
- package/dist/exec/postgres.js +0 -30
- package/dist/exec/postgres.js.map +0 -1
- package/dist/modes/post-execute-log.d.ts +0 -9
- package/dist/modes/post-execute-log.d.ts.map +0 -1
- package/dist/modes/post-execute-log.js +0 -26
- package/dist/modes/post-execute-log.js.map +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SensitiveReferenceError, } from "../errors.js";
|
|
2
|
+
import { ENGINE_LEXER_PROFILES, GENERIC_LEXER, hasUnterminatedToken, lexSql, lexerProfileFor, } from "./lexer.js";
|
|
2
3
|
/**
|
|
3
4
|
* Check whether a SQL statement references tables/columns marked `sensitive`
|
|
4
5
|
* in the schema artifact.
|
|
@@ -14,16 +15,23 @@ import { SensitiveReferenceError, } from "../errors.js";
|
|
|
14
15
|
* are resolved first. When scope cannot be resolved (no resolvable table source, or a
|
|
15
16
|
* qualifier that binds to nothing known), the check fails conservatively — it widens
|
|
16
17
|
* matching and reports why in {@link SensitiveGuardrailResult.unresolvedScope} — rather
|
|
17
|
-
* than passing silently.
|
|
18
|
+
* than passing silently. A string, quoted identifier, or comment that never closes is
|
|
19
|
+
* reported the same way (`UNTERMINATED_TOKEN`).
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* `SELECT *`, `alias.*`, and whole-row references such as `row_to_json(alias)` count as
|
|
22
|
+
* referencing every sensitive column of the table they reach. They expand per query
|
|
23
|
+
* block: a bare `*` covers its own `SELECT`'s FROM/JOIN tables, and an alias resolves in
|
|
24
|
+
* its own block first, then in enclosing blocks. When the block structure cannot be
|
|
25
|
+
* read with confidence, they expand against every table in the statement.
|
|
26
|
+
*
|
|
27
|
+
* Heuristic, not a SQL parser, and not a security boundary. It is defense in depth for
|
|
28
|
+
* review and enforcement; database-side column privileges are the real control.
|
|
21
29
|
*/
|
|
22
30
|
export function validateSensitiveReferences(sql, schema, options) {
|
|
23
31
|
const mode = options?.mode ?? "warn";
|
|
24
32
|
const index = indexSchema(schema);
|
|
25
33
|
const result = index.hasSensitive
|
|
26
|
-
?
|
|
34
|
+
? scanSql(sql, index, options?.dialect)
|
|
27
35
|
: { passed: true, references: [] };
|
|
28
36
|
if (mode === "strict" && !result.passed) {
|
|
29
37
|
throw new SensitiveReferenceError(buildStrictMessage(result), ruleFor(result), result.references, result.unresolvedScope);
|
|
@@ -103,134 +111,48 @@ function indexSchema(schema) {
|
|
|
103
111
|
indexCache.set(schema, index);
|
|
104
112
|
return index;
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Map lexer tokens onto the scanner's simpler vocabulary. Comments are dropped (MySQL
|
|
116
|
+
* `/*! … *\/` bodies are already lexed as code).
|
|
117
|
+
*/
|
|
118
|
+
function toToks(tokens) {
|
|
109
119
|
const out = [];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (ch === "'") {
|
|
133
|
-
i++;
|
|
134
|
-
while (i < sql.length) {
|
|
135
|
-
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
136
|
-
i += 2;
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (sql[i] === "'") {
|
|
140
|
-
i++;
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
i++;
|
|
144
|
-
}
|
|
145
|
-
push("literal", "'");
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (ch === "$") {
|
|
149
|
-
const dollarQuote = /^\$(\w*)\$/.exec(sql.slice(i));
|
|
150
|
-
if (dollarQuote) {
|
|
151
|
-
const opener = dollarQuote[0];
|
|
152
|
-
const close = sql.indexOf(opener, i + opener.length);
|
|
153
|
-
i = close === -1 ? sql.length : close + opener.length;
|
|
154
|
-
push("literal", "$$");
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (/[0-9]/.test(sql[i + 1] ?? "")) {
|
|
158
|
-
let j = i + 1;
|
|
159
|
-
while (j < sql.length && /[0-9]/.test(sql[j]))
|
|
160
|
-
j++;
|
|
161
|
-
push("placeholder", sql.slice(i, j));
|
|
162
|
-
i = j;
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (ch === '"' || ch === "`") {
|
|
167
|
-
const close = ch;
|
|
168
|
-
i++;
|
|
169
|
-
let buf = "";
|
|
170
|
-
while (i < sql.length) {
|
|
171
|
-
if (sql[i] === close && sql[i + 1] === close) {
|
|
172
|
-
buf += close;
|
|
173
|
-
i += 2;
|
|
174
|
-
continue;
|
|
120
|
+
for (const t of tokens) {
|
|
121
|
+
switch (t.kind) {
|
|
122
|
+
case "comment":
|
|
123
|
+
break;
|
|
124
|
+
case "word":
|
|
125
|
+
out.push({ kind: "word", value: t.value, lower: t.lower, quoted: false });
|
|
126
|
+
break;
|
|
127
|
+
case "quoted_identifier":
|
|
128
|
+
out.push({ kind: "word", value: t.value, lower: t.lower, quoted: true });
|
|
129
|
+
break;
|
|
130
|
+
case "string":
|
|
131
|
+
// MySQL/MariaDB `"…"` is a string under the default sql_mode but an identifier
|
|
132
|
+
// under ANSI_QUOTES, and the lexer cannot know which the server runs. Reading it
|
|
133
|
+
// as an identifier means `SELECT "ssn"` is flagged: over-reporting is the safe
|
|
134
|
+
// failure for this check, while reading it as a string would be a bypass on
|
|
135
|
+
// ANSI_QUOTES servers. The tenant guardrail makes the mirror-image choice (a
|
|
136
|
+
// `"tenant_id"` predicate reads as a string, so it never counts as scoping),
|
|
137
|
+
// because there treating it as a string is what fails closed.
|
|
138
|
+
if (t.quote === '"') {
|
|
139
|
+
const inner = t.text.slice(1, t.unterminated ? undefined : -1);
|
|
140
|
+
out.push({ kind: "word", value: inner, lower: inner.toLowerCase(), quoted: true });
|
|
175
141
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
break;
|
|
142
|
+
else {
|
|
143
|
+
out.push({ kind: "literal", value: "'", lower: "'", quoted: false });
|
|
179
144
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
buf += sql[i];
|
|
191
|
-
i++;
|
|
192
|
-
}
|
|
193
|
-
if (i < sql.length)
|
|
194
|
-
i++;
|
|
195
|
-
push("word", buf, true);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
if (ch === ":" && sql[i + 1] === ":") {
|
|
199
|
-
push("punct", "::");
|
|
200
|
-
i += 2;
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if ((ch === ":" || ch === "@") && WORD_START.test(sql[i + 1] ?? "")) {
|
|
204
|
-
let j = i + 1;
|
|
205
|
-
while (j < sql.length && WORD_CHAR.test(sql[j]))
|
|
206
|
-
j++;
|
|
207
|
-
push("placeholder", sql.slice(i, j));
|
|
208
|
-
i = j;
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
if (/[0-9]/.test(ch)) {
|
|
212
|
-
let j = i;
|
|
213
|
-
while (j < sql.length && /[0-9]/.test(sql[j]))
|
|
214
|
-
j++;
|
|
215
|
-
if (sql[j] === "." && /[0-9]/.test(sql[j + 1] ?? "")) {
|
|
216
|
-
j++;
|
|
217
|
-
while (j < sql.length && /[0-9]/.test(sql[j]))
|
|
218
|
-
j++;
|
|
219
|
-
}
|
|
220
|
-
push("number", sql.slice(i, j));
|
|
221
|
-
i = j;
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (WORD_START.test(ch)) {
|
|
225
|
-
let j = i;
|
|
226
|
-
while (j < sql.length && WORD_CHAR.test(sql[j]))
|
|
227
|
-
j++;
|
|
228
|
-
push("word", sql.slice(i, j));
|
|
229
|
-
i = j;
|
|
230
|
-
continue;
|
|
145
|
+
break;
|
|
146
|
+
case "number":
|
|
147
|
+
out.push({ kind: "number", value: t.text, lower: t.lower, quoted: false });
|
|
148
|
+
break;
|
|
149
|
+
case "parameter":
|
|
150
|
+
out.push({ kind: "placeholder", value: t.text, lower: t.lower, quoted: false });
|
|
151
|
+
break;
|
|
152
|
+
case "punct":
|
|
153
|
+
out.push({ kind: "punct", value: t.text, lower: t.text, quoted: false });
|
|
154
|
+
break;
|
|
231
155
|
}
|
|
232
|
-
push("punct", ch);
|
|
233
|
-
i++;
|
|
234
156
|
}
|
|
235
157
|
return out;
|
|
236
158
|
}
|
|
@@ -267,17 +189,116 @@ const BARE_STOP = new Set([
|
|
|
267
189
|
const JOIN_MODIFIER = new Set([
|
|
268
190
|
"inner", "left", "right", "full", "outer", "cross", "natural", "straight_join",
|
|
269
191
|
]);
|
|
270
|
-
|
|
271
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Words that continue an expression rather than end one, on top of {@link ALIAS_STOP} and
|
|
194
|
+
* {@link BARE_STOP}. A word after one of these is an operand, never an implicit alias —
|
|
195
|
+
* `a DIV u`, `ts AT TIME ZONE u`, `json_object('k' VALUE u)`. Broad on purpose: a word
|
|
196
|
+
* wrongly listed here only costs an over-report.
|
|
197
|
+
*/
|
|
198
|
+
const EXPR_KEYWORDS = new Set([
|
|
199
|
+
"array", "asymmetric", "binary", "content", "default", "div", "document", "false",
|
|
200
|
+
"glob", "interval", "isnull", "match", "mod", "notnull", "operator", "overlaps",
|
|
201
|
+
"placing", "regexp", "rlike", "row", "sounds", "symmetric", "to", "true", "unique",
|
|
202
|
+
"value", "variadic", "xor", "zone",
|
|
203
|
+
]);
|
|
204
|
+
const isKeyword = (lower) => ALIAS_STOP.has(lower) || BARE_STOP.has(lower) || EXPR_KEYWORDS.has(lower);
|
|
205
|
+
/** Unquoted words that end a select list at its own nesting depth. */
|
|
206
|
+
const SELECT_LIST_END = new Set([
|
|
207
|
+
"except", "fetch", "for", "from", "group", "having", "intersect", "into", "limit",
|
|
208
|
+
"offset", "order", "union", "where", "window",
|
|
209
|
+
]);
|
|
210
|
+
const STANDARD_SELECT_WORDS = ["all", "distinct"];
|
|
211
|
+
const MYSQL_SELECT_WORDS = [
|
|
212
|
+
...STANDARD_SELECT_WORDS,
|
|
213
|
+
"distinctrow", "high_priority", "straight_join", "sql_small_result", "sql_big_result",
|
|
214
|
+
"sql_buffer_result", "sql_no_cache", "sql_cache", "sql_calc_found_rows",
|
|
215
|
+
];
|
|
216
|
+
const SELECT_MODIFIERS = {
|
|
217
|
+
postgres: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: true, top: false },
|
|
218
|
+
mysql: { words: new Set(MYSQL_SELECT_WORDS), distinctOn: false, top: false },
|
|
219
|
+
sqlserver: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: false, top: true },
|
|
220
|
+
sqlite: { words: new Set(STANDARD_SELECT_WORDS), distinctOn: false, top: false },
|
|
221
|
+
/** Unknown or no dialect: accept every engine's modifiers, so no wildcard slips past. */
|
|
222
|
+
any: { words: new Set(MYSQL_SELECT_WORDS), distinctOn: true, top: true },
|
|
223
|
+
};
|
|
224
|
+
function selectModifiersFor(dialect) {
|
|
225
|
+
switch (dialect?.id) {
|
|
226
|
+
case "postgres":
|
|
227
|
+
case "cockroachdb":
|
|
228
|
+
return SELECT_MODIFIERS.postgres;
|
|
229
|
+
case "mysql":
|
|
230
|
+
case "mariadb":
|
|
231
|
+
return SELECT_MODIFIERS.mysql;
|
|
232
|
+
case "sqlserver":
|
|
233
|
+
return SELECT_MODIFIERS.sqlserver;
|
|
234
|
+
case "sqlite":
|
|
235
|
+
return SELECT_MODIFIERS.sqlite;
|
|
236
|
+
default:
|
|
237
|
+
return SELECT_MODIFIERS.any;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Lex and scan. With a known dialect the statement is lexed once, the way that engine
|
|
242
|
+
* reads it. Without one (or for an unrecognized id) the generic reading decides scope
|
|
243
|
+
* issues, and references are unioned across every engine's reading that lexes cleanly —
|
|
244
|
+
* so a column hidden from one reading by an engine-specific quote or comment rule (for
|
|
245
|
+
* example MySQL `'\''` or Postgres `E'\''`) is still reported. Readings with an
|
|
246
|
+
* unterminated token are skipped: the engine would reject that SQL outright.
|
|
247
|
+
*/
|
|
248
|
+
function scanSql(sql, index, dialect) {
|
|
249
|
+
const profile = dialect ? lexerProfileFor(dialect) : undefined;
|
|
250
|
+
if (profile) {
|
|
251
|
+
return finalize(scanTokens(lexSql(sql, profile), index, selectModifiersFor(dialect)));
|
|
252
|
+
}
|
|
253
|
+
const modifiers = SELECT_MODIFIERS.any;
|
|
254
|
+
const primary = scanTokens(lexSql(sql, GENERIC_LEXER), index, modifiers);
|
|
255
|
+
for (const p of ENGINE_LEXER_PROFILES) {
|
|
256
|
+
const tokens = lexSql(sql, p);
|
|
257
|
+
if (hasUnterminatedToken(tokens))
|
|
258
|
+
continue;
|
|
259
|
+
for (const [key, ref] of scanTokens(tokens, index, modifiers).found) {
|
|
260
|
+
const existing = primary.found.get(key);
|
|
261
|
+
if (!existing)
|
|
262
|
+
primary.found.set(key, ref);
|
|
263
|
+
else if (ref.matchKind === "qualified")
|
|
264
|
+
existing.matchKind = "qualified";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return finalize(primary);
|
|
268
|
+
}
|
|
269
|
+
function finalize(outcome) {
|
|
270
|
+
const references = [...outcome.found.values()]
|
|
271
|
+
.sort((a, b) => a.tableOrder - b.tableOrder || a.columnOrder - b.columnOrder)
|
|
272
|
+
.map(({ tableOrder: _tableOrder, columnOrder: _columnOrder, ...ref }) => ref);
|
|
273
|
+
const unresolvedScope = outcome.issues.size > 0 ? buildScopeReport([...outcome.issues], outcome.widened) : undefined;
|
|
274
|
+
return {
|
|
275
|
+
passed: references.length === 0 && unresolvedScope === undefined,
|
|
276
|
+
references,
|
|
277
|
+
...(unresolvedScope ? { unresolvedScope } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function scanTokens(lexed, index, modifiers) {
|
|
281
|
+
const tokens = toToks(lexed);
|
|
272
282
|
const isWord = (t) => t?.kind === "word";
|
|
273
283
|
const isPunct = (t, v) => t?.kind === "punct" && t.value === v;
|
|
284
|
+
const isKeywordTok = (t, v) => t?.kind === "word" && !t.quoted && t.lower === v;
|
|
274
285
|
const canAlias = (t) => t?.kind === "word" && (t.quoted || !ALIAS_STOP.has(t.lower));
|
|
275
286
|
/** Token indices already accounted for as relation names or aliases. */
|
|
276
287
|
const consumed = new Set();
|
|
277
288
|
/** Relation names that exist but are not schema tables (CTEs, derived tables). */
|
|
278
289
|
const derived = new Set();
|
|
290
|
+
// FROM/JOIN bindings. `at` is the index of the FROM/JOIN keyword that introduced them,
|
|
291
|
+
// which places the binding in that clause's query block.
|
|
279
292
|
const sourcePaths = [];
|
|
280
293
|
const aliasDecls = [];
|
|
294
|
+
/** Aliases of derived tables and table functions — bound, but to no schema table. */
|
|
295
|
+
const derivedDecls = [];
|
|
296
|
+
/** The FROM/JOIN keyword whose items `parseTableRef` is reading. */
|
|
297
|
+
let bindingAt = -1;
|
|
298
|
+
const addDerived = (alias) => {
|
|
299
|
+
derived.add(alias);
|
|
300
|
+
derivedDecls.push({ alias, at: bindingAt });
|
|
301
|
+
};
|
|
281
302
|
let opaqueSource = false;
|
|
282
303
|
let sawTableSource = false;
|
|
283
304
|
const closeParenAfter = (open) => {
|
|
@@ -306,6 +327,113 @@ function scan(sql, index) {
|
|
|
306
327
|
}
|
|
307
328
|
return -1;
|
|
308
329
|
};
|
|
330
|
+
/**
|
|
331
|
+
* Index of the first select-list item after the `SELECT` at `select`, skipping the
|
|
332
|
+
* engine's modifiers: `DISTINCT`, `DISTINCT ON (x)`, MySQL `SQL_NO_CACHE`, SQL Server
|
|
333
|
+
* `TOP (5) PERCENT WITH TIES`, …. `PERCENT` / `TIES` only count inside a `TOP` clause,
|
|
334
|
+
* so `SELECT percent * rate` stays a multiplication.
|
|
335
|
+
*/
|
|
336
|
+
const selectListStart = (select) => {
|
|
337
|
+
let k = select + 1;
|
|
338
|
+
for (;;) {
|
|
339
|
+
const t = tokens[k];
|
|
340
|
+
if (t?.kind !== "word" || t.quoted)
|
|
341
|
+
return k;
|
|
342
|
+
if (modifiers.distinctOn &&
|
|
343
|
+
t.lower === "distinct" &&
|
|
344
|
+
isKeywordTok(tokens[k + 1], "on") &&
|
|
345
|
+
isPunct(tokens[k + 2], "(")) {
|
|
346
|
+
const close = closeParenAfter(k + 2);
|
|
347
|
+
if (close === -1)
|
|
348
|
+
return k;
|
|
349
|
+
k = close + 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (modifiers.words.has(t.lower)) {
|
|
353
|
+
k++;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (modifiers.top && t.lower === "top") {
|
|
357
|
+
let j = k + 1;
|
|
358
|
+
if (isPunct(tokens[j], "(")) {
|
|
359
|
+
const close = closeParenAfter(j);
|
|
360
|
+
if (close === -1)
|
|
361
|
+
return k;
|
|
362
|
+
j = close + 1;
|
|
363
|
+
}
|
|
364
|
+
else if (tokens[j]?.kind === "number" || tokens[j]?.kind === "placeholder") {
|
|
365
|
+
j++;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
return k; // `top` is an ordinary column name here
|
|
369
|
+
}
|
|
370
|
+
if (isKeywordTok(tokens[j], "percent"))
|
|
371
|
+
j++;
|
|
372
|
+
if (isKeywordTok(tokens[j], "with") && isKeywordTok(tokens[j + 1], "ties"))
|
|
373
|
+
j += 2;
|
|
374
|
+
k = j;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
return k;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
/** A token that can end a select-list expression, so a word right after it is an alias. */
|
|
381
|
+
const endsExpression = (k) => {
|
|
382
|
+
const t = tokens[k];
|
|
383
|
+
if (!t)
|
|
384
|
+
return false;
|
|
385
|
+
if (t.kind === "word")
|
|
386
|
+
return t.quoted || !isKeyword(t.lower);
|
|
387
|
+
if (t.kind === "number" || t.kind === "literal")
|
|
388
|
+
return true;
|
|
389
|
+
if (!isPunct(t, ")"))
|
|
390
|
+
return false;
|
|
391
|
+
// `a OPERATOR(pg_catalog.||) u` — the parenthesized operator takes a right operand.
|
|
392
|
+
return !isKeywordTok(tokens[openParenBefore(k) - 1], "operator");
|
|
393
|
+
};
|
|
394
|
+
const endsSelectList = (t) => t === undefined ||
|
|
395
|
+
isPunct(t, ")") ||
|
|
396
|
+
isPunct(t, ";") ||
|
|
397
|
+
(t.kind === "word" && !t.quoted && SELECT_LIST_END.has(t.lower));
|
|
398
|
+
/**
|
|
399
|
+
* Bare `*` projections, and select-list words that are implicit output aliases. `SELECT
|
|
400
|
+
* id u` names an output column `u`, while `SELECT u` and `SELECT id, u` read the row, so
|
|
401
|
+
* only a word between an expression-ending token and the end of its item (`,` or the end
|
|
402
|
+
* of the list) is an alias. Anything less certain stays a reference.
|
|
403
|
+
*/
|
|
404
|
+
const wildcards = new Set();
|
|
405
|
+
const implicitAliases = new Set();
|
|
406
|
+
for (let s = 0; s < tokens.length; s++) {
|
|
407
|
+
if (!isKeywordTok(tokens[s], "select"))
|
|
408
|
+
continue;
|
|
409
|
+
const start = selectListStart(s);
|
|
410
|
+
// `EXISTS (SELECT * …)` returns no columns.
|
|
411
|
+
const inExists = isPunct(tokens[s - 1], "(") && isKeywordTok(tokens[s - 2], "exists");
|
|
412
|
+
if (isPunct(tokens[start], "*") && !inExists)
|
|
413
|
+
wildcards.add(start);
|
|
414
|
+
let depth = 0;
|
|
415
|
+
for (let k = start; k < tokens.length; k++) {
|
|
416
|
+
const t = tokens[k];
|
|
417
|
+
if (isPunct(t, "("))
|
|
418
|
+
depth++;
|
|
419
|
+
else if (isPunct(t, ")")) {
|
|
420
|
+
if (depth === 0)
|
|
421
|
+
break;
|
|
422
|
+
depth--;
|
|
423
|
+
}
|
|
424
|
+
else if (depth > 0)
|
|
425
|
+
continue;
|
|
426
|
+
else if (endsSelectList(t))
|
|
427
|
+
break;
|
|
428
|
+
else if (k > start &&
|
|
429
|
+
t.kind === "word" &&
|
|
430
|
+
(t.quoted || !isKeyword(t.lower)) &&
|
|
431
|
+
endsExpression(k - 1) &&
|
|
432
|
+
(isPunct(tokens[k + 1], ",") || endsSelectList(tokens[k + 1]))) {
|
|
433
|
+
implicitAliases.add(k);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
309
437
|
/** Consume an optional `AS alias` / bare `alias`; returns the index after it. */
|
|
310
438
|
const takeAlias = (start, register) => {
|
|
311
439
|
let k = start;
|
|
@@ -343,7 +471,7 @@ function scan(sql, index) {
|
|
|
343
471
|
if (close === -1)
|
|
344
472
|
return j + 1;
|
|
345
473
|
sawTableSource = true;
|
|
346
|
-
return takeAlias(close + 1,
|
|
474
|
+
return takeAlias(close + 1, addDerived);
|
|
347
475
|
}
|
|
348
476
|
if (!canAlias(tokens[j])) {
|
|
349
477
|
// e.g. the operand in `EXTRACT(YEAR FROM 1)` — not a relation name.
|
|
@@ -370,11 +498,11 @@ function scan(sql, index) {
|
|
|
370
498
|
// Table function / table-valued expression — its columns are not schema columns.
|
|
371
499
|
opaqueSource = true;
|
|
372
500
|
const close = closeParenAfter(k);
|
|
373
|
-
return takeAlias(close === -1 ? k + 1 : close + 1,
|
|
501
|
+
return takeAlias(close === -1 ? k + 1 : close + 1, addDerived);
|
|
374
502
|
}
|
|
375
503
|
sawTableSource = true;
|
|
376
|
-
sourcePaths.push(path);
|
|
377
|
-
return takeAlias(k, (alias) => aliasDecls.push({ alias, path }));
|
|
504
|
+
sourcePaths.push({ path, at: bindingAt });
|
|
505
|
+
return takeAlias(k, (alias) => aliasDecls.push({ alias, path, at: bindingAt }));
|
|
378
506
|
};
|
|
379
507
|
// Pass A — resolve relation names, aliases, and CTE names.
|
|
380
508
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -400,6 +528,7 @@ function scan(sql, index) {
|
|
|
400
528
|
consumed.add(k);
|
|
401
529
|
}
|
|
402
530
|
}
|
|
531
|
+
bindingAt = i;
|
|
403
532
|
let j = i + 1;
|
|
404
533
|
for (;;) {
|
|
405
534
|
const next = parseTableRef(j);
|
|
@@ -413,9 +542,19 @@ function scan(sql, index) {
|
|
|
413
542
|
}
|
|
414
543
|
// Pass B — collect the column references the statement actually makes.
|
|
415
544
|
const qualifiedRefs = [];
|
|
545
|
+
const starRefs = [];
|
|
416
546
|
const bare = new Set();
|
|
547
|
+
/** Each bare word where it occurs: a table name or alias used as a value is the whole row. */
|
|
548
|
+
const wholeRowRefs = [];
|
|
549
|
+
const bareStars = [];
|
|
417
550
|
for (let i = 0; i < tokens.length; i++) {
|
|
418
551
|
const t = tokens[i];
|
|
552
|
+
// `SELECT *`, `SELECT DISTINCT *`, `SELECT TOP 5 *`, `SELECT a, *` — not `count(*)`
|
|
553
|
+
// or multiplication.
|
|
554
|
+
if (isPunct(t, "*") && (wildcards.has(i) || isPunct(tokens[i - 1], ","))) {
|
|
555
|
+
bareStars.push(i);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
419
558
|
if (t.kind !== "word")
|
|
420
559
|
continue;
|
|
421
560
|
if (isPunct(tokens[i - 1], "."))
|
|
@@ -424,6 +563,8 @@ function scan(sql, index) {
|
|
|
424
563
|
continue; // cast target type
|
|
425
564
|
if (isWord(tokens[i - 1]) && tokens[i - 1].lower === "as")
|
|
426
565
|
continue; // output alias
|
|
566
|
+
if (implicitAliases.has(i))
|
|
567
|
+
continue; // output alias without AS
|
|
427
568
|
if (isPunct(tokens[i + 1], ".")) {
|
|
428
569
|
const path = [t.lower];
|
|
429
570
|
let k = i + 1;
|
|
@@ -440,7 +581,15 @@ function scan(sql, index) {
|
|
|
440
581
|
path.push(tokens[k].lower);
|
|
441
582
|
k++;
|
|
442
583
|
}
|
|
443
|
-
if (
|
|
584
|
+
if (star && !consumed.has(i)) {
|
|
585
|
+
// `alias.*` / `schema.table.*` — every column of the qualifier's table.
|
|
586
|
+
starRefs.push({
|
|
587
|
+
qualifier: path[path.length - 1],
|
|
588
|
+
...(path.length >= 2 ? { qualifierSchema: path[path.length - 2] } : {}),
|
|
589
|
+
at: i,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
else if (!star && path.length >= 2 && !consumed.has(i)) {
|
|
444
593
|
qualifiedRefs.push({
|
|
445
594
|
column: path[path.length - 1],
|
|
446
595
|
qualifier: path[path.length - 2],
|
|
@@ -457,6 +606,7 @@ function scan(sql, index) {
|
|
|
457
606
|
if (!t.quoted && BARE_STOP.has(t.lower))
|
|
458
607
|
continue;
|
|
459
608
|
bare.add(t.lower);
|
|
609
|
+
wholeRowRefs.push({ word: t.lower, at: i });
|
|
460
610
|
}
|
|
461
611
|
// Bind relation names and aliases to schema tables.
|
|
462
612
|
const resolve = (path) => {
|
|
@@ -471,23 +621,69 @@ function scan(sql, index) {
|
|
|
471
621
|
// v1 schemas carry no namespace — a qualified reference still matches by name.
|
|
472
622
|
return candidates.filter((e) => e.schemaLower === undefined);
|
|
473
623
|
};
|
|
624
|
+
// Query blocks, for wildcard and whole-row expansion. When the block tree cannot be
|
|
625
|
+
// trusted — an unterminated token, unbalanced parentheses, a SELECT that neither nests
|
|
626
|
+
// nor follows a set operator, or a FROM/JOIN outside every block — every binding and
|
|
627
|
+
// reference goes into one block, which is the statement-global reading.
|
|
628
|
+
let tree = hasUnterminatedToken(lexed) ? undefined : buildQueryBlocks(tokens);
|
|
629
|
+
if (tree && [...sourcePaths, ...derivedDecls].some((b) => tree.blockOf[b.at] === -1)) {
|
|
630
|
+
tree = undefined;
|
|
631
|
+
}
|
|
632
|
+
const blockAt = (k) => (tree ? tree.blockOf[k] : 0);
|
|
633
|
+
const parentOf = (block) => (tree ? tree.parent[block] : -1);
|
|
634
|
+
/** Every table the statement reaches, and every qualifier it binds, in any block. */
|
|
474
635
|
const inScope = new Set();
|
|
475
636
|
const qualifiers = new Map();
|
|
476
|
-
const
|
|
477
|
-
|
|
637
|
+
const blockScopes = new Map();
|
|
638
|
+
const blockScope = (block) => {
|
|
639
|
+
let scope = blockScopes.get(block);
|
|
640
|
+
if (!scope) {
|
|
641
|
+
scope = { tables: new Set(), qualifiers: new Map() };
|
|
642
|
+
blockScopes.set(block, scope);
|
|
643
|
+
}
|
|
644
|
+
return scope;
|
|
645
|
+
};
|
|
646
|
+
const bindQualifier = (into, name, entries) => {
|
|
647
|
+
const bucket = into.get(name);
|
|
478
648
|
if (bucket)
|
|
479
649
|
bucket.push(...entries.filter((e) => !bucket.includes(e)));
|
|
480
650
|
else
|
|
481
|
-
|
|
651
|
+
into.set(name, [...entries]);
|
|
482
652
|
};
|
|
483
|
-
for (const path of sourcePaths) {
|
|
653
|
+
for (const { path, at } of sourcePaths) {
|
|
484
654
|
const entries = resolve(path);
|
|
485
|
-
|
|
655
|
+
const scope = blockScope(blockAt(at));
|
|
656
|
+
for (const e of entries) {
|
|
486
657
|
inScope.add(e);
|
|
487
|
-
|
|
658
|
+
scope.tables.add(e);
|
|
659
|
+
}
|
|
660
|
+
bindQualifier(qualifiers, path[path.length - 1], entries);
|
|
661
|
+
bindQualifier(scope.qualifiers, path[path.length - 1], entries);
|
|
662
|
+
}
|
|
663
|
+
for (const { alias, path, at } of aliasDecls) {
|
|
664
|
+
const entries = resolve(path);
|
|
665
|
+
bindQualifier(qualifiers, alias, entries);
|
|
666
|
+
bindQualifier(blockScope(blockAt(at)).qualifiers, alias, entries);
|
|
667
|
+
}
|
|
668
|
+
// A derived-table alias binds no schema table, but it still shadows outer bindings.
|
|
669
|
+
for (const { alias, at } of derivedDecls) {
|
|
670
|
+
bindQualifier(blockScope(blockAt(at)).qualifiers, alias, []);
|
|
488
671
|
}
|
|
489
|
-
|
|
490
|
-
|
|
672
|
+
/**
|
|
673
|
+
* The tables `name` denotes where it is written: its own block's bindings first, then
|
|
674
|
+
* each enclosing block's (correlated references). A name no enclosing block binds falls
|
|
675
|
+
* back to every binding in the statement, so a reference that cannot be placed is
|
|
676
|
+
* over-reported rather than dropped.
|
|
677
|
+
*/
|
|
678
|
+
const lookupQualifier = (name, block) => {
|
|
679
|
+
for (let b = block; b !== -1; b = parentOf(b)) {
|
|
680
|
+
const bound = blockScopes.get(b)?.qualifiers.get(name);
|
|
681
|
+
if (bound)
|
|
682
|
+
return bound;
|
|
683
|
+
}
|
|
684
|
+
return qualifiers.get(name);
|
|
685
|
+
};
|
|
686
|
+
// Collect findings.
|
|
491
687
|
const found = new Map();
|
|
492
688
|
const record = (table, column, columnOrder, matchKind) => {
|
|
493
689
|
const key = `${table.schemaLower ?? ""}.${table.lower}.${column.toLowerCase()}`;
|
|
@@ -558,22 +754,113 @@ function scan(sql, index) {
|
|
|
558
754
|
}
|
|
559
755
|
}
|
|
560
756
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
757
|
+
// Wildcards and whole-row references reach every column of the table, so they reach
|
|
758
|
+
// its sensitive columns. (Table-level-sensitive tables are already reported as "*".)
|
|
759
|
+
const recordAllSensitive = (table, matchKind) => {
|
|
760
|
+
if (table.sensitive)
|
|
761
|
+
return;
|
|
762
|
+
for (const column of table.columns) {
|
|
763
|
+
if (column.sensitive)
|
|
764
|
+
record(table, column.name, column.order, matchKind);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
// Bare `SELECT *` — every table its own block's FROM/JOIN reaches.
|
|
768
|
+
for (const at of bareStars) {
|
|
769
|
+
const block = blockAt(at);
|
|
770
|
+
const tables = block === -1 ? inScope : (blockScopes.get(block)?.tables ?? []);
|
|
771
|
+
for (const table of tables)
|
|
772
|
+
recordAllSensitive(table, "unqualified");
|
|
773
|
+
}
|
|
774
|
+
// `alias.*`
|
|
775
|
+
for (const ref of starRefs) {
|
|
776
|
+
const bound = lookupQualifier(ref.qualifier, blockAt(ref.at));
|
|
777
|
+
if (!bound) {
|
|
778
|
+
if (!derived.has(ref.qualifier))
|
|
779
|
+
issues.add("UNKNOWN_QUALIFIER");
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const scoped = ref.qualifierSchema
|
|
783
|
+
? bound.filter((e) => e.schemaLower === undefined || e.schemaLower === ref.qualifierSchema)
|
|
784
|
+
: bound;
|
|
785
|
+
for (const table of scoped)
|
|
786
|
+
recordAllSensitive(table, "qualified");
|
|
787
|
+
}
|
|
788
|
+
// A table name or alias used as a value — `row_to_json(u)`, `to_jsonb(u)`,
|
|
789
|
+
// `json_agg(u)`, Postgres `SELECT u FROM users u` — is the whole row.
|
|
790
|
+
for (const { word, at } of wholeRowRefs) {
|
|
791
|
+
for (const table of lookupQualifier(word, blockAt(at)) ?? []) {
|
|
792
|
+
recordAllSensitive(table, "qualified");
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (hasUnterminatedToken(lexed))
|
|
796
|
+
issues.add("UNTERMINATED_TOKEN");
|
|
797
|
+
return { found, issues, widened: noTableSource || unknownQualifierColumns.size > 0 };
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The statement's query blocks, read from parenthesis depth. Every `SELECT` opens a block
|
|
801
|
+
* that runs to the `)` closing its depth, a set operator (`UNION` / `INTERSECT` /
|
|
802
|
+
* `EXCEPT`) at its depth, or a `;`. A block's parent is the innermost block still open
|
|
803
|
+
* where it starts, so subqueries, derived tables, CTE bodies, and `EXISTS` / `IN`
|
|
804
|
+
* operands nest, and set-operation branches are siblings. Parentheses that do not start
|
|
805
|
+
* a `SELECT` (function calls, parenthesized joins) stay in the enclosing block.
|
|
806
|
+
*
|
|
807
|
+
* `blockOf[k]` is the block token `k` belongs to, or -1 outside every block (e.g. the
|
|
808
|
+
* tokens of a top-level `WITH` clause). Returns `undefined` when the tree cannot be
|
|
809
|
+
* trusted: unbalanced parentheses, a `;` inside parentheses, or a `SELECT` at a depth
|
|
810
|
+
* whose block is still open (two statements with no separator, or a construct this
|
|
811
|
+
* reader does not know).
|
|
812
|
+
*/
|
|
813
|
+
function buildQueryBlocks(tokens) {
|
|
814
|
+
const blockOf = [];
|
|
815
|
+
const parent = [];
|
|
816
|
+
/** `open[d]` is the block open at parenthesis depth `d`, or -1. */
|
|
817
|
+
const open = [-1];
|
|
818
|
+
const innermost = () => {
|
|
819
|
+
for (let d = open.length - 1; d >= 0; d--)
|
|
820
|
+
if (open[d] !== -1)
|
|
821
|
+
return open[d];
|
|
822
|
+
return -1;
|
|
571
823
|
};
|
|
824
|
+
for (const t of tokens) {
|
|
825
|
+
const depth = open.length - 1;
|
|
826
|
+
const punct = t.kind === "punct" ? t.value : undefined;
|
|
827
|
+
if (punct === "(") {
|
|
828
|
+
blockOf.push(innermost());
|
|
829
|
+
open.push(-1);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (punct === ")") {
|
|
833
|
+
if (depth === 0)
|
|
834
|
+
return undefined;
|
|
835
|
+
open.pop();
|
|
836
|
+
blockOf.push(innermost());
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (punct === ";") {
|
|
840
|
+
if (depth !== 0)
|
|
841
|
+
return undefined;
|
|
842
|
+
open[0] = -1;
|
|
843
|
+
blockOf.push(-1);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const keyword = t.kind === "word" && !t.quoted ? t.lower : undefined;
|
|
847
|
+
if (keyword === "select") {
|
|
848
|
+
if (open[depth] !== -1)
|
|
849
|
+
return undefined;
|
|
850
|
+
parent.push(innermost());
|
|
851
|
+
open[depth] = parent.length - 1;
|
|
852
|
+
}
|
|
853
|
+
blockOf.push(innermost());
|
|
854
|
+
if (keyword === "union" || keyword === "intersect" || keyword === "except")
|
|
855
|
+
open[depth] = -1;
|
|
856
|
+
}
|
|
857
|
+
return open.length === 1 ? { blockOf, parent } : undefined;
|
|
572
858
|
}
|
|
573
859
|
const SCOPE_ISSUE_TEXT = {
|
|
574
860
|
NO_TABLE_SOURCE: "no FROM/JOIN target could be resolved, so unqualified column names cannot be bound to a table",
|
|
575
861
|
UNKNOWN_QUALIFIER: "a qualified reference used a qualifier that is not a known table, alias, or CTE",
|
|
576
862
|
OPAQUE_TABLE_SOURCE: "a table source is not a relation name (table function or table-valued expression)",
|
|
863
|
+
UNTERMINATED_TOKEN: "a string, quoted identifier, or comment never closes, so the rest of the statement could not be scanned",
|
|
577
864
|
};
|
|
578
865
|
function buildScopeReport(issues, widened) {
|
|
579
866
|
const detail = issues.map((i) => SCOPE_ISSUE_TEXT[i]).join("; ");
|