@askdb/core 1.0.0-beta.41 → 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 +44 -4
- package/dist/ask.d.ts +129 -2
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +391 -15
- package/dist/ask.js.map +1 -1
- package/dist/errors.d.ts +87 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/logging/log-events.d.ts +13 -0
- package/dist/logging/log-events.d.ts.map +1 -1
- package/dist/logging/log-events.js +13 -0
- package/dist/logging/log-events.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 +102 -0
- package/dist/sql/bind.d.ts.map +1 -0
- package/dist/sql/bind.js +422 -0
- package/dist/sql/bind.js.map +1 -0
- package/dist/sql/dialect-spec.d.ts +24 -1
- package/dist/sql/dialect-spec.d.ts.map +1 -1
- package/dist/sql/dialect-spec.js +82 -3
- package/dist/sql/dialect-spec.js.map +1 -1
- package/dist/sql/extract-sql.d.ts +8 -1
- package/dist/sql/extract-sql.d.ts.map +1 -1
- package/dist/sql/extract-sql.js +27 -4
- package/dist/sql/extract-sql.js.map +1 -1
- package/dist/sql/generate.d.ts +22 -0
- package/dist/sql/generate.d.ts.map +1 -1
- package/dist/sql/generate.js +92 -16
- package/dist/sql/generate.js.map +1 -1
- package/dist/sql/index.d.ts +4 -2
- package/dist/sql/index.d.ts.map +1 -1
- package/dist/sql/index.js +4 -2
- package/dist/sql/index.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/parameter-manifest.d.ts +34 -0
- package/dist/sql/parameter-manifest.d.ts.map +1 -0
- package/dist/sql/parameter-manifest.js +150 -0
- package/dist/sql/parameter-manifest.js.map +1 -0
- package/dist/sql/prompt.d.ts +7 -1
- package/dist/sql/prompt.d.ts.map +1 -1
- package/dist/sql/prompt.js +24 -1
- package/dist/sql/prompt.js.map +1 -1
- package/dist/sql/sensitive-guardrail.d.ts +62 -0
- package/dist/sql/sensitive-guardrail.d.ts.map +1 -0
- package/dist/sql/sensitive-guardrail.js +876 -0
- package/dist/sql/sensitive-guardrail.js.map +1 -0
- 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 -9
- package/dist/sql/tenant-placeholders.d.ts.map +1 -1
- package/dist/sql/tenant-placeholders.js +237 -63
- 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 -83
- package/dist/sql/validate.js.map +1 -1
- package/package.json +9 -7
- 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
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import { SensitiveReferenceError, } from "../errors.js";
|
|
2
|
+
import { ENGINE_LEXER_PROFILES, GENERIC_LEXER, hasUnterminatedToken, lexSql, lexerProfileFor, } from "./lexer.js";
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a SQL statement references tables/columns marked `sensitive`
|
|
5
|
+
* in the schema artifact.
|
|
6
|
+
*
|
|
7
|
+
* `sensitive: true` is otherwise a **prompt-level** marker: `formatSchemaForNlToSql`
|
|
8
|
+
* either tags identifiers `(sensitive)` or withholds them entirely. Both act on what
|
|
9
|
+
* the model *sees*. This function is the **enforcement** path — it inspects SQL that
|
|
10
|
+
* is about to execute, so hosts that cache, store, or replay statements should call it
|
|
11
|
+
* on every execution path, not only at generation time.
|
|
12
|
+
*
|
|
13
|
+
* Unlike a bare substring scan, an unqualified column name only counts when the owning
|
|
14
|
+
* table is actually in the statement's scope: `FROM`/`JOIN` targets and their aliases
|
|
15
|
+
* are resolved first. When scope cannot be resolved (no resolvable table source, or a
|
|
16
|
+
* qualifier that binds to nothing known), the check fails conservatively — it widens
|
|
17
|
+
* matching and reports why in {@link SensitiveGuardrailResult.unresolvedScope} — rather
|
|
18
|
+
* than passing silently. A string, quoted identifier, or comment that never closes is
|
|
19
|
+
* reported the same way (`UNTERMINATED_TOKEN`).
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
export function validateSensitiveReferences(sql, schema, options) {
|
|
31
|
+
const mode = options?.mode ?? "warn";
|
|
32
|
+
const index = indexSchema(schema);
|
|
33
|
+
const result = index.hasSensitive
|
|
34
|
+
? scanSql(sql, index, options?.dialect)
|
|
35
|
+
: { passed: true, references: [] };
|
|
36
|
+
if (mode === "strict" && !result.passed) {
|
|
37
|
+
throw new SensitiveReferenceError(buildStrictMessage(result), ruleFor(result), result.references, result.unresolvedScope);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True when the schema marks at least one table or column `sensitive`. Cheap pre-check
|
|
43
|
+
* for hosts (and `ask()`) that skip the guardrail entirely on schemas with no markers.
|
|
44
|
+
*/
|
|
45
|
+
export function schemaHasSensitiveIdentifiers(schema) {
|
|
46
|
+
return indexSchema(schema).hasSensitive;
|
|
47
|
+
}
|
|
48
|
+
/** Render a reference as `schema.table.column` (or `table.column` when the schema has no namespace). */
|
|
49
|
+
export function formatSensitiveReference(ref) {
|
|
50
|
+
const table = ref.schema ? `${ref.schema}.${ref.table}` : ref.table;
|
|
51
|
+
return `${table}.${ref.column}`;
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Strict-mode messaging
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function ruleFor(result) {
|
|
57
|
+
if (result.references.some((r) => r.matchKind === "table"))
|
|
58
|
+
return "SENSITIVE_TABLE_REFERENCED";
|
|
59
|
+
if (result.references.length > 0)
|
|
60
|
+
return "SENSITIVE_COLUMN_REFERENCED";
|
|
61
|
+
return "UNRESOLVED_TABLE_SCOPE";
|
|
62
|
+
}
|
|
63
|
+
function buildStrictMessage(result) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (result.references.length > 0) {
|
|
66
|
+
parts.push(`SQL references sensitive identifiers: ${result.references.map(formatSensitiveReference).join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
if (result.unresolvedScope)
|
|
69
|
+
parts.push(result.unresolvedScope.message);
|
|
70
|
+
return `Sensitive-identifier guardrail failed (strict mode): ${parts.join("; ")}`;
|
|
71
|
+
}
|
|
72
|
+
/** Loaded schemas are long-lived and re-used across calls; index once per object. */
|
|
73
|
+
const indexCache = new WeakMap();
|
|
74
|
+
function indexSchema(schema) {
|
|
75
|
+
const cached = indexCache.get(schema);
|
|
76
|
+
if (cached)
|
|
77
|
+
return cached;
|
|
78
|
+
const tables = [];
|
|
79
|
+
const byName = new Map();
|
|
80
|
+
let hasSensitive = false;
|
|
81
|
+
const rawTables = schema.tables;
|
|
82
|
+
rawTables.forEach((t, order) => {
|
|
83
|
+
const schemaName = typeof t.schema === "string" && t.schema !== "" ? t.schema : undefined;
|
|
84
|
+
const tableSensitive = t.sensitive === true;
|
|
85
|
+
if (tableSensitive)
|
|
86
|
+
hasSensitive = true;
|
|
87
|
+
const columns = (t.columns ?? []).map((c, ci) => {
|
|
88
|
+
const sensitive = tableSensitive || c.sensitive === true;
|
|
89
|
+
if (sensitive)
|
|
90
|
+
hasSensitive = true;
|
|
91
|
+
return { name: c.name, lower: c.name.toLowerCase(), sensitive, order: ci };
|
|
92
|
+
});
|
|
93
|
+
const entry = {
|
|
94
|
+
name: t.name,
|
|
95
|
+
...(schemaName ? { schema: schemaName } : {}),
|
|
96
|
+
lower: t.name.toLowerCase(),
|
|
97
|
+
...(schemaName ? { schemaLower: schemaName.toLowerCase() } : {}),
|
|
98
|
+
sensitive: tableSensitive,
|
|
99
|
+
order,
|
|
100
|
+
columns,
|
|
101
|
+
byColumn: new Map(columns.map((c) => [c.lower, c])),
|
|
102
|
+
};
|
|
103
|
+
tables.push(entry);
|
|
104
|
+
const bucket = byName.get(entry.lower);
|
|
105
|
+
if (bucket)
|
|
106
|
+
bucket.push(entry);
|
|
107
|
+
else
|
|
108
|
+
byName.set(entry.lower, [entry]);
|
|
109
|
+
});
|
|
110
|
+
const index = { tables, byName, hasSensitive };
|
|
111
|
+
indexCache.set(schema, index);
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
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) {
|
|
119
|
+
const out = [];
|
|
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 });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out.push({ kind: "literal", value: "'", lower: "'", quoted: false });
|
|
144
|
+
}
|
|
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;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Keyword sets
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Words that can never be a relation alias. Deliberately broad — a wrongly-taken
|
|
164
|
+
* "alias" would bind a qualifier to the wrong table.
|
|
165
|
+
*/
|
|
166
|
+
const ALIAS_STOP = new Set([
|
|
167
|
+
"all", "and", "any", "as", "asc", "at", "between", "by", "case", "collate", "cross",
|
|
168
|
+
"desc", "distinct", "else", "end", "escape", "except", "exists", "fetch", "filter",
|
|
169
|
+
"first", "for", "from", "full", "group", "having", "ilike", "in", "inner", "intersect",
|
|
170
|
+
"into", "is", "join", "last", "lateral", "left", "like", "limit", "natural", "next",
|
|
171
|
+
"not", "null", "nulls", "offset", "on", "only", "or", "order", "outer", "over",
|
|
172
|
+
"partition", "returning", "right", "rows", "select", "set", "similar", "some",
|
|
173
|
+
"straight_join", "tablesample", "then", "union", "unknown", "using", "values", "when",
|
|
174
|
+
"where", "window", "with", "within",
|
|
175
|
+
]);
|
|
176
|
+
/**
|
|
177
|
+
* Words that are never plausible column names, so they are excluded from the
|
|
178
|
+
* unqualified-candidate set. Deliberately *narrow* — words like `key`, `first`,
|
|
179
|
+
* `time`, `filter`, or `rows` are real column names in real schemas and must stay
|
|
180
|
+
* eligible so a sensitive column named `key` is still caught.
|
|
181
|
+
*/
|
|
182
|
+
const BARE_STOP = new Set([
|
|
183
|
+
"and", "as", "asc", "between", "by", "case", "cross", "desc", "distinct", "else",
|
|
184
|
+
"escape", "except", "exists", "false", "from", "group", "having", "ilike", "in",
|
|
185
|
+
"inner", "intersect", "is", "join", "lateral", "like", "limit", "natural", "not",
|
|
186
|
+
"null", "nulls", "offset", "on", "or", "order", "outer", "over", "partition",
|
|
187
|
+
"recursive", "select", "then", "true", "union", "using", "when", "where", "with",
|
|
188
|
+
]);
|
|
189
|
+
const JOIN_MODIFIER = new Set([
|
|
190
|
+
"inner", "left", "right", "full", "outer", "cross", "natural", "straight_join",
|
|
191
|
+
]);
|
|
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);
|
|
282
|
+
const isWord = (t) => t?.kind === "word";
|
|
283
|
+
const isPunct = (t, v) => t?.kind === "punct" && t.value === v;
|
|
284
|
+
const isKeywordTok = (t, v) => t?.kind === "word" && !t.quoted && t.lower === v;
|
|
285
|
+
const canAlias = (t) => t?.kind === "word" && (t.quoted || !ALIAS_STOP.has(t.lower));
|
|
286
|
+
/** Token indices already accounted for as relation names or aliases. */
|
|
287
|
+
const consumed = new Set();
|
|
288
|
+
/** Relation names that exist but are not schema tables (CTEs, derived tables). */
|
|
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.
|
|
292
|
+
const sourcePaths = [];
|
|
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
|
+
};
|
|
302
|
+
let opaqueSource = false;
|
|
303
|
+
let sawTableSource = false;
|
|
304
|
+
const closeParenAfter = (open) => {
|
|
305
|
+
let depth = 0;
|
|
306
|
+
for (let k = open; k < tokens.length; k++) {
|
|
307
|
+
if (isPunct(tokens[k], "("))
|
|
308
|
+
depth++;
|
|
309
|
+
else if (isPunct(tokens[k], ")")) {
|
|
310
|
+
depth--;
|
|
311
|
+
if (depth === 0)
|
|
312
|
+
return k;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return -1;
|
|
316
|
+
};
|
|
317
|
+
const openParenBefore = (close) => {
|
|
318
|
+
let depth = 0;
|
|
319
|
+
for (let k = close; k >= 0; k--) {
|
|
320
|
+
if (isPunct(tokens[k], ")"))
|
|
321
|
+
depth++;
|
|
322
|
+
else if (isPunct(tokens[k], "(")) {
|
|
323
|
+
depth--;
|
|
324
|
+
if (depth === 0)
|
|
325
|
+
return k;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return -1;
|
|
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
|
+
}
|
|
437
|
+
/** Consume an optional `AS alias` / bare `alias`; returns the index after it. */
|
|
438
|
+
const takeAlias = (start, register) => {
|
|
439
|
+
let k = start;
|
|
440
|
+
if (isWord(tokens[k]) && tokens[k].lower === "as" && isWord(tokens[k + 1])) {
|
|
441
|
+
consumed.add(k);
|
|
442
|
+
k++;
|
|
443
|
+
}
|
|
444
|
+
if (canAlias(tokens[k])) {
|
|
445
|
+
register(tokens[k].lower);
|
|
446
|
+
consumed.add(k);
|
|
447
|
+
k++;
|
|
448
|
+
// Optional derived-column alias list: `… AS t(a, b)`
|
|
449
|
+
if (isPunct(tokens[k], "(")) {
|
|
450
|
+
const close = closeParenAfter(k);
|
|
451
|
+
if (close !== -1) {
|
|
452
|
+
for (let x = k + 1; x < close; x++)
|
|
453
|
+
if (isWord(tokens[x]))
|
|
454
|
+
consumed.add(x);
|
|
455
|
+
k = close + 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return k;
|
|
460
|
+
};
|
|
461
|
+
const parseTableRef = (start) => {
|
|
462
|
+
let j = start;
|
|
463
|
+
while (isWord(tokens[j]) && (tokens[j].lower === "lateral" || tokens[j].lower === "only")) {
|
|
464
|
+
consumed.add(j);
|
|
465
|
+
j++;
|
|
466
|
+
}
|
|
467
|
+
// Derived table / parenthesized join. The body is deliberately not skipped: the
|
|
468
|
+
// outer walk still visits its FROM/JOINs, so nested base tables land in scope too.
|
|
469
|
+
if (isPunct(tokens[j], "(")) {
|
|
470
|
+
const close = closeParenAfter(j);
|
|
471
|
+
if (close === -1)
|
|
472
|
+
return j + 1;
|
|
473
|
+
sawTableSource = true;
|
|
474
|
+
return takeAlias(close + 1, addDerived);
|
|
475
|
+
}
|
|
476
|
+
if (!canAlias(tokens[j])) {
|
|
477
|
+
// e.g. the operand in `EXTRACT(YEAR FROM 1)` — not a relation name.
|
|
478
|
+
return j;
|
|
479
|
+
}
|
|
480
|
+
const path = [];
|
|
481
|
+
const pathIdx = [];
|
|
482
|
+
let k = j;
|
|
483
|
+
for (;;) {
|
|
484
|
+
if (!isWord(tokens[k]))
|
|
485
|
+
break;
|
|
486
|
+
path.push(tokens[k].lower);
|
|
487
|
+
pathIdx.push(k);
|
|
488
|
+
k++;
|
|
489
|
+
if (isPunct(tokens[k], ".")) {
|
|
490
|
+
k++;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
for (const x of pathIdx)
|
|
496
|
+
consumed.add(x);
|
|
497
|
+
if (isPunct(tokens[k], "(")) {
|
|
498
|
+
// Table function / table-valued expression — its columns are not schema columns.
|
|
499
|
+
opaqueSource = true;
|
|
500
|
+
const close = closeParenAfter(k);
|
|
501
|
+
return takeAlias(close === -1 ? k + 1 : close + 1, addDerived);
|
|
502
|
+
}
|
|
503
|
+
sawTableSource = true;
|
|
504
|
+
sourcePaths.push({ path, at: bindingAt });
|
|
505
|
+
return takeAlias(k, (alias) => aliasDecls.push({ alias, path, at: bindingAt }));
|
|
506
|
+
};
|
|
507
|
+
// Pass A — resolve relation names, aliases, and CTE names.
|
|
508
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
509
|
+
const t = tokens[i];
|
|
510
|
+
if (t.kind !== "word" || t.quoted)
|
|
511
|
+
continue;
|
|
512
|
+
// `name AS (` / `name (cols) AS (` → CTE or named subquery.
|
|
513
|
+
if (t.lower === "as" && isPunct(tokens[i + 1], "(")) {
|
|
514
|
+
let back = i - 1;
|
|
515
|
+
if (isPunct(tokens[back], ")"))
|
|
516
|
+
back = openParenBefore(back) - 1;
|
|
517
|
+
if (canAlias(tokens[back])) {
|
|
518
|
+
derived.add(tokens[back].lower);
|
|
519
|
+
consumed.add(back);
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (t.lower !== "from" && t.lower !== "join")
|
|
524
|
+
continue;
|
|
525
|
+
consumed.add(i);
|
|
526
|
+
if (t.lower === "join") {
|
|
527
|
+
for (let k = i - 1; k >= 0 && isWord(tokens[k]) && JOIN_MODIFIER.has(tokens[k].lower); k--) {
|
|
528
|
+
consumed.add(k);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
bindingAt = i;
|
|
532
|
+
let j = i + 1;
|
|
533
|
+
for (;;) {
|
|
534
|
+
const next = parseTableRef(j);
|
|
535
|
+
// Old-style comma-separated FROM lists; JOIN takes exactly one reference.
|
|
536
|
+
if (t.lower === "from" && next > j && isPunct(tokens[next], ",")) {
|
|
537
|
+
j = next + 1;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Pass B — collect the column references the statement actually makes.
|
|
544
|
+
const qualifiedRefs = [];
|
|
545
|
+
const starRefs = [];
|
|
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 = [];
|
|
550
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
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
|
+
}
|
|
558
|
+
if (t.kind !== "word")
|
|
559
|
+
continue;
|
|
560
|
+
if (isPunct(tokens[i - 1], "."))
|
|
561
|
+
continue; // tail of a path, handled at its head
|
|
562
|
+
if (isPunct(tokens[i - 1], "::"))
|
|
563
|
+
continue; // cast target type
|
|
564
|
+
if (isWord(tokens[i - 1]) && tokens[i - 1].lower === "as")
|
|
565
|
+
continue; // output alias
|
|
566
|
+
if (implicitAliases.has(i))
|
|
567
|
+
continue; // output alias without AS
|
|
568
|
+
if (isPunct(tokens[i + 1], ".")) {
|
|
569
|
+
const path = [t.lower];
|
|
570
|
+
let k = i + 1;
|
|
571
|
+
let star = false;
|
|
572
|
+
while (isPunct(tokens[k], ".")) {
|
|
573
|
+
k++;
|
|
574
|
+
if (isPunct(tokens[k], "*")) {
|
|
575
|
+
star = true;
|
|
576
|
+
k++;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
if (!isWord(tokens[k]))
|
|
580
|
+
break;
|
|
581
|
+
path.push(tokens[k].lower);
|
|
582
|
+
k++;
|
|
583
|
+
}
|
|
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)) {
|
|
593
|
+
qualifiedRefs.push({
|
|
594
|
+
column: path[path.length - 1],
|
|
595
|
+
qualifier: path[path.length - 2],
|
|
596
|
+
...(path.length >= 3 ? { qualifierSchema: path[path.length - 3] } : {}),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
i = k - 1;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (consumed.has(i))
|
|
603
|
+
continue;
|
|
604
|
+
if (isPunct(tokens[i + 1], "("))
|
|
605
|
+
continue; // function call
|
|
606
|
+
if (!t.quoted && BARE_STOP.has(t.lower))
|
|
607
|
+
continue;
|
|
608
|
+
bare.add(t.lower);
|
|
609
|
+
wholeRowRefs.push({ word: t.lower, at: i });
|
|
610
|
+
}
|
|
611
|
+
// Bind relation names and aliases to schema tables.
|
|
612
|
+
const resolve = (path) => {
|
|
613
|
+
const name = path[path.length - 1];
|
|
614
|
+
const qualifier = path.length >= 2 ? path[path.length - 2] : undefined;
|
|
615
|
+
const candidates = index.byName.get(name) ?? [];
|
|
616
|
+
if (!qualifier)
|
|
617
|
+
return candidates;
|
|
618
|
+
const exact = candidates.filter((e) => e.schemaLower === qualifier);
|
|
619
|
+
if (exact.length > 0)
|
|
620
|
+
return exact;
|
|
621
|
+
// v1 schemas carry no namespace — a qualified reference still matches by name.
|
|
622
|
+
return candidates.filter((e) => e.schemaLower === undefined);
|
|
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. */
|
|
635
|
+
const inScope = new Set();
|
|
636
|
+
const qualifiers = new Map();
|
|
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);
|
|
648
|
+
if (bucket)
|
|
649
|
+
bucket.push(...entries.filter((e) => !bucket.includes(e)));
|
|
650
|
+
else
|
|
651
|
+
into.set(name, [...entries]);
|
|
652
|
+
};
|
|
653
|
+
for (const { path, at } of sourcePaths) {
|
|
654
|
+
const entries = resolve(path);
|
|
655
|
+
const scope = blockScope(blockAt(at));
|
|
656
|
+
for (const e of entries) {
|
|
657
|
+
inScope.add(e);
|
|
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, []);
|
|
671
|
+
}
|
|
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.
|
|
687
|
+
const found = new Map();
|
|
688
|
+
const record = (table, column, columnOrder, matchKind) => {
|
|
689
|
+
const key = `${table.schemaLower ?? ""}.${table.lower}.${column.toLowerCase()}`;
|
|
690
|
+
const existing = found.get(key);
|
|
691
|
+
if (existing) {
|
|
692
|
+
// A qualified hit is the stronger explanation of the same reference.
|
|
693
|
+
if (matchKind === "qualified")
|
|
694
|
+
existing.matchKind = "qualified";
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
found.set(key, {
|
|
698
|
+
table: table.name,
|
|
699
|
+
...(table.schema ? { schema: table.schema } : {}),
|
|
700
|
+
column,
|
|
701
|
+
matchKind,
|
|
702
|
+
tableOrder: table.order,
|
|
703
|
+
columnOrder,
|
|
704
|
+
});
|
|
705
|
+
};
|
|
706
|
+
// Sensitive tables reached as a FROM/JOIN target — catches `SELECT * FROM secrets`.
|
|
707
|
+
for (const table of inScope) {
|
|
708
|
+
if (table.sensitive)
|
|
709
|
+
record(table, "*", -1, "table");
|
|
710
|
+
}
|
|
711
|
+
const issues = new Set();
|
|
712
|
+
if (opaqueSource)
|
|
713
|
+
issues.add("OPAQUE_TABLE_SOURCE");
|
|
714
|
+
// Qualified references.
|
|
715
|
+
const unknownQualifierColumns = new Set();
|
|
716
|
+
for (const ref of qualifiedRefs) {
|
|
717
|
+
const bound = qualifiers.get(ref.qualifier);
|
|
718
|
+
if (!bound) {
|
|
719
|
+
if (derived.has(ref.qualifier))
|
|
720
|
+
continue; // CTE / derived relation — a known namespace
|
|
721
|
+
issues.add("UNKNOWN_QUALIFIER");
|
|
722
|
+
unknownQualifierColumns.add(ref.column);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const scoped = ref.qualifierSchema
|
|
726
|
+
? bound.filter((e) => e.schemaLower === undefined || e.schemaLower === ref.qualifierSchema)
|
|
727
|
+
: bound;
|
|
728
|
+
for (const table of scoped) {
|
|
729
|
+
const column = table.byColumn.get(ref.column);
|
|
730
|
+
if (column?.sensitive)
|
|
731
|
+
record(table, column.name, column.order, "qualified");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Unqualified references — counted only when the owning table is in scope.
|
|
735
|
+
const noTableSource = !sawTableSource && bare.size > 0;
|
|
736
|
+
if (noTableSource)
|
|
737
|
+
issues.add("NO_TABLE_SOURCE");
|
|
738
|
+
const bareScope = noTableSource ? index.tables : [...inScope];
|
|
739
|
+
for (const table of bareScope) {
|
|
740
|
+
for (const column of table.columns) {
|
|
741
|
+
if (column.sensitive && bare.has(column.lower)) {
|
|
742
|
+
record(table, column.name, column.order, "unqualified");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// A qualifier we could not bind: its column could belong to anything, so match it
|
|
747
|
+
// against every sensitive column rather than drop it.
|
|
748
|
+
if (unknownQualifierColumns.size > 0) {
|
|
749
|
+
for (const table of index.tables) {
|
|
750
|
+
for (const column of table.columns) {
|
|
751
|
+
if (column.sensitive && unknownQualifierColumns.has(column.lower)) {
|
|
752
|
+
record(table, column.name, column.order, "unqualified");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
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;
|
|
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;
|
|
858
|
+
}
|
|
859
|
+
const SCOPE_ISSUE_TEXT = {
|
|
860
|
+
NO_TABLE_SOURCE: "no FROM/JOIN target could be resolved, so unqualified column names cannot be bound to a table",
|
|
861
|
+
UNKNOWN_QUALIFIER: "a qualified reference used a qualifier that is not a known table, alias, or CTE",
|
|
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",
|
|
864
|
+
};
|
|
865
|
+
function buildScopeReport(issues, widened) {
|
|
866
|
+
const detail = issues.map((i) => SCOPE_ISSUE_TEXT[i]).join("; ");
|
|
867
|
+
return {
|
|
868
|
+
issues,
|
|
869
|
+
widened,
|
|
870
|
+
message: `Could not resolve the statement's table scope: ${detail}.` +
|
|
871
|
+
(widened
|
|
872
|
+
? " Unqualified names were matched against every sensitive column in the schema, so some references may be over-reported."
|
|
873
|
+
: ""),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
//# sourceMappingURL=sensitive-guardrail.js.map
|