@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.
Files changed (85) hide show
  1. package/README.md +6 -4
  2. package/dist/ask.d.ts +92 -4
  3. package/dist/ask.d.ts.map +1 -1
  4. package/dist/ask.js +234 -23
  5. package/dist/ask.js.map +1 -1
  6. package/dist/errors.d.ts +41 -5
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +9 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +5 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/schema/v2/index.d.ts +1 -1
  15. package/dist/schema/v2/index.d.ts.map +1 -1
  16. package/dist/schema/v2/loader.d.ts +9 -1
  17. package/dist/schema/v2/loader.d.ts.map +1 -1
  18. package/dist/schema/v2/loader.js +171 -49
  19. package/dist/schema/v2/loader.js.map +1 -1
  20. package/dist/schema/v2/normalized.d.ts +33 -0
  21. package/dist/schema/v2/normalized.d.ts.map +1 -1
  22. package/dist/schema/v2/parser.d.ts +9 -0
  23. package/dist/schema/v2/parser.d.ts.map +1 -1
  24. package/dist/schema/v2/parser.js +18 -2
  25. package/dist/schema/v2/parser.js.map +1 -1
  26. package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
  27. package/dist/schema/v2/tenant-policy-loader.js +7 -3
  28. package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
  29. package/dist/schema/v2/tenant-policy.d.ts +28 -21
  30. package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
  31. package/dist/schema/v2/tenant-policy.js +67 -11
  32. package/dist/schema/v2/tenant-policy.js.map +1 -1
  33. package/dist/sql/bind.d.ts +17 -15
  34. package/dist/sql/bind.d.ts.map +1 -1
  35. package/dist/sql/bind.js +34 -172
  36. package/dist/sql/bind.js.map +1 -1
  37. package/dist/sql/dialect-spec.d.ts +21 -2
  38. package/dist/sql/dialect-spec.d.ts.map +1 -1
  39. package/dist/sql/dialect-spec.js +74 -3
  40. package/dist/sql/dialect-spec.js.map +1 -1
  41. package/dist/sql/generate.d.ts +12 -0
  42. package/dist/sql/generate.d.ts.map +1 -1
  43. package/dist/sql/generate.js +24 -16
  44. package/dist/sql/generate.js.map +1 -1
  45. package/dist/sql/lexer.d.ts +114 -0
  46. package/dist/sql/lexer.d.ts.map +1 -0
  47. package/dist/sql/lexer.js +397 -0
  48. package/dist/sql/lexer.js.map +1 -0
  49. package/dist/sql/sensitive-guardrail.d.ts +18 -3
  50. package/dist/sql/sensitive-guardrail.d.ts.map +1 -1
  51. package/dist/sql/sensitive-guardrail.js +440 -153
  52. package/dist/sql/sensitive-guardrail.js.map +1 -1
  53. package/dist/sql/tenant-guardrail.d.ts +70 -8
  54. package/dist/sql/tenant-guardrail.d.ts.map +1 -1
  55. package/dist/sql/tenant-guardrail.js +799 -52
  56. package/dist/sql/tenant-guardrail.js.map +1 -1
  57. package/dist/sql/tenant-hierarchy.d.ts +19 -0
  58. package/dist/sql/tenant-hierarchy.d.ts.map +1 -0
  59. package/dist/sql/tenant-hierarchy.js +53 -0
  60. package/dist/sql/tenant-hierarchy.js.map +1 -0
  61. package/dist/sql/tenant-placeholders.d.ts +60 -10
  62. package/dist/sql/tenant-placeholders.d.ts.map +1 -1
  63. package/dist/sql/tenant-placeholders.js +229 -58
  64. package/dist/sql/tenant-placeholders.js.map +1 -1
  65. package/dist/sql/tenant-prompt.d.ts +4 -0
  66. package/dist/sql/tenant-prompt.d.ts.map +1 -1
  67. package/dist/sql/tenant-prompt.js +79 -11
  68. package/dist/sql/tenant-prompt.js.map +1 -1
  69. package/dist/sql/tenant-scope-validate.d.ts +3 -1
  70. package/dist/sql/tenant-scope-validate.d.ts.map +1 -1
  71. package/dist/sql/tenant-scope-validate.js +5 -2
  72. package/dist/sql/tenant-scope-validate.js.map +1 -1
  73. package/dist/sql/validate.d.ts +24 -4
  74. package/dist/sql/validate.d.ts.map +1 -1
  75. package/dist/sql/validate.js +123 -37
  76. package/dist/sql/validate.js.map +1 -1
  77. package/package.json +6 -5
  78. package/dist/exec/postgres.d.ts +0 -6
  79. package/dist/exec/postgres.d.ts.map +0 -1
  80. package/dist/exec/postgres.js +0 -30
  81. package/dist/exec/postgres.js.map +0 -1
  82. package/dist/modes/post-execute-log.d.ts +0 -9
  83. package/dist/modes/post-execute-log.d.ts.map +0 -1
  84. package/dist/modes/post-execute-log.js +0 -26
  85. 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
- * Heuristic, not a SQL parser. It is a review/enforcement aid, not a substitute for
20
- * database-side column privileges.
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
- ? scan(sql, index)
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
- const WORD_START = /[A-Za-z_\u0080-\uFFFF]/;
107
- const WORD_CHAR = /[A-Za-z0-9_$\u0080-\uFFFF]/;
108
- function tokenize(sql) {
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
- const push = (kind, value, quoted = false) => {
111
- out.push({ kind, value, lower: value.toLowerCase(), quoted });
112
- };
113
- let i = 0;
114
- while (i < sql.length) {
115
- const ch = sql[i];
116
- if (/\s/.test(ch)) {
117
- i++;
118
- continue;
119
- }
120
- if (ch === "-" && sql[i + 1] === "-") {
121
- while (i < sql.length && sql[i] !== "\n")
122
- i++;
123
- continue;
124
- }
125
- if (ch === "/" && sql[i + 1] === "*") {
126
- i += 2;
127
- while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/"))
128
- i++;
129
- i = Math.min(i + 2, sql.length);
130
- continue;
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
- if (sql[i] === close) {
177
- i++;
178
- break;
142
+ else {
143
+ out.push({ kind: "literal", value: "'", lower: "'", quoted: false });
179
144
  }
180
- buf += sql[i];
181
- i++;
182
- }
183
- push("word", buf, true);
184
- continue;
185
- }
186
- if (ch === "[") {
187
- i++;
188
- let buf = "";
189
- while (i < sql.length && sql[i] !== "]") {
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
- function scan(sql, index) {
271
- const tokens = tokenize(sql);
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, (alias) => derived.add(alias));
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, (alias) => derived.add(alias));
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 (!star && path.length >= 2 && !consumed.has(i)) {
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 bindQualifier = (name, entries) => {
477
- const bucket = qualifiers.get(name);
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
- qualifiers.set(name, [...entries]);
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
- for (const e of entries)
655
+ const scope = blockScope(blockAt(at));
656
+ for (const e of entries) {
486
657
  inScope.add(e);
487
- bindQualifier(path[path.length - 1], entries);
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
- for (const decl of aliasDecls)
490
- bindQualifier(decl.alias, resolve(decl.path));
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
- const references = [...found.values()]
562
- .sort((a, b) => a.tableOrder - b.tableOrder || a.columnOrder - b.columnOrder)
563
- .map(({ tableOrder: _tableOrder, columnOrder: _columnOrder, ...ref }) => ref);
564
- const unresolvedScope = issues.size > 0
565
- ? buildScopeReport([...issues], noTableSource || unknownQualifierColumns.size > 0)
566
- : undefined;
567
- return {
568
- passed: references.length === 0 && unresolvedScope === undefined,
569
- references,
570
- ...(unresolvedScope ? { unresolvedScope } : {}),
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("; ");