easyvibegate 0.4.4 → 0.6.2
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.en.md +5 -9
- package/README.md +30 -12
- package/dist/cli/index.js +40 -39
- package/dist/cli/wizard.js +51 -35
- package/dist/engine/checkers/backend/firebase.js +79 -55
- package/dist/engine/checkers/backend/supabase.js +117 -91
- package/dist/engine/checkers/deep/deps.js +87 -29
- package/dist/engine/checkers/live/endpoint-probe.js +29 -33
- package/dist/engine/checkers/live/http-checks.js +23 -15
- package/dist/engine/checkers/live/idor.js +65 -49
- package/dist/engine/checkers/static/client-exposure.js +58 -3
- package/dist/engine/checkers/static/config-risks.js +70 -10
- package/dist/engine/checkers/static/rls-migrations.js +706 -192
- package/dist/engine/checkers/static/secrets.js +259 -15
- package/dist/engine/config.js +131 -28
- package/dist/engine/detect.js +24 -12
- package/dist/engine/endpoints.js +219 -13
- package/dist/engine/i18n.js +6 -2
- package/dist/engine/net/http.js +68 -0
- package/dist/engine/report.js +11 -1
- package/dist/engine/scan.js +16 -7
- package/dist/engine/util/code-lex.js +260 -0
- package/dist/engine/util/git-exposure.js +33 -0
- package/dist/engine/util/mask.js +32 -44
- package/dist/engine/util/sql-lex.js +172 -0
- package/dist/engine/util/text.js +12 -0
- package/dist/engine/walk.js +52 -7
- package/dist/orchestrator/flow.js +137 -82
- package/package.json +1 -1
|
@@ -1,183 +1,590 @@
|
|
|
1
1
|
import { lineAt } from '../../util/text.js';
|
|
2
|
-
|
|
3
|
-
const QUALIFIED = `(?:(${IDENT})\\s*\\.\\s*)?(${IDENT})`;
|
|
4
|
-
const CREATE_TABLE = new RegExp(`create\\s+table\\s+(if\\s+not\\s+exists\\s+)?${QUALIFIED}`, 'gi');
|
|
5
|
-
const RLS_STMT = new RegExp(`alter\\s+table\\s+(?:only\\s+)?${QUALIFIED}\\s+(enable|disable)\\s+row\\s+level\\s+security`, 'gi');
|
|
6
|
-
const DROP_TABLE = new RegExp(`drop\\s+table\\s+(?:if\\s+exists\\s+)?${QUALIFIED}`, 'gi');
|
|
7
|
-
// SELECT ... INTO <table> also creates a table.
|
|
8
|
-
const SELECT_INTO = new RegExp(`\\bselect\\b[^;]{0,400}?\\binto\\s+${QUALIFIED}`, 'gis');
|
|
2
|
+
import { lexSql } from '../../util/sql-lex.js';
|
|
9
3
|
/** Normalize one SQL identifier: quoted keeps case, unquoted folds to lowercase. */
|
|
10
|
-
function normIdent(raw) {
|
|
11
|
-
|
|
12
|
-
return raw.slice(1, -1);
|
|
13
|
-
if (raw.startsWith('`') && raw.endsWith('`'))
|
|
14
|
-
return raw.slice(1, -1);
|
|
15
|
-
return raw.toLowerCase();
|
|
4
|
+
function normIdent(raw, quoted) {
|
|
5
|
+
return quoted ? raw : raw.toLowerCase();
|
|
16
6
|
}
|
|
17
|
-
const keyOf = (schema, table) => `${schema ? normIdent(schema) : 'public'}.${normIdent(table)}`;
|
|
18
|
-
const displayOf = (schema, table) => (schema ? `${schema}.${table}` : table);
|
|
19
7
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* or inside a comment is never mistaken for an executed statement.
|
|
23
|
-
* Handles -- and block comments, '...' with '' escapes (and E'...'), and
|
|
24
|
-
* $$ / $tag$ dollar-quoted strings. Double-quoted identifiers are kept.
|
|
8
|
+
* A table key is `schema NUL table`. Joining with `.` let `public."a.b"` and
|
|
9
|
+
* `"public.a".b` collide into one string; NUL can never occur in an identifier.
|
|
25
10
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
11
|
+
const SEP = '\u0000';
|
|
12
|
+
/**
|
|
13
|
+
* Session-private namespace. PostgreSQL looks in pg_temp FIRST for an existing
|
|
14
|
+
* table, so after `CREATE TEMP TABLE orders` an unqualified `DROP TABLE orders`
|
|
15
|
+
* removes the temp one and leaves the permanent `public.orders` — and its RLS
|
|
16
|
+
* state — untouched. Temp tables are modelled for that shadowing only; they are
|
|
17
|
+
* never reported (PostgREST cannot see them).
|
|
18
|
+
*/
|
|
19
|
+
const TEMP_SCHEMA = 'pg_temp';
|
|
20
|
+
const keyFor = (schema, table) => `${schema}${SEP}${table}`;
|
|
21
|
+
const isTempKey = (key) => key.startsWith(TEMP_SCHEMA + SEP);
|
|
22
|
+
const isWord = (t, w) => !!t && t.type === 'word' && t.value.toUpperCase() === w;
|
|
23
|
+
const isWordIn = (t, ws) => !!t && t.type === 'word' && ws.includes(t.value.toUpperCase());
|
|
24
|
+
const isPunct = (t, p) => !!t && t.type === 'punct' && t.value === p;
|
|
25
|
+
const isName = (t) => !!t && (t.type === 'word' || t.type === 'quotedIdent');
|
|
26
|
+
const isLiteral = (t) => !!t && (t.type === 'string' || t.type === 'dollarString');
|
|
27
|
+
const nameOf = (t) => ({ text: t.value, quoted: t.type === 'quotedIdent' });
|
|
28
|
+
/** Read `[schema .] table` at `i`; returns the names and the index after them. */
|
|
29
|
+
function readQualified(ts, i) {
|
|
30
|
+
const first = ts[i];
|
|
31
|
+
if (!isName(first))
|
|
32
|
+
return null;
|
|
33
|
+
const dot = ts[i + 1];
|
|
34
|
+
const second = ts[i + 2];
|
|
35
|
+
if (dot && dot.type === 'punct' && dot.value === '.' && isName(second)) {
|
|
36
|
+
return { schema: nameOf(first), table: nameOf(second), next: i + 3 };
|
|
37
|
+
}
|
|
38
|
+
return { table: nameOf(first), next: i + 1 };
|
|
39
|
+
}
|
|
40
|
+
/** Skip an optional `IF EXISTS` / `IF NOT EXISTS`; returns [nextIndex, seen]. */
|
|
41
|
+
function skipIfExists(ts, i) {
|
|
42
|
+
if (!isWord(ts[i], 'IF'))
|
|
43
|
+
return [i, false];
|
|
44
|
+
if (isWord(ts[i + 1], 'NOT') && isWord(ts[i + 2], 'EXISTS'))
|
|
45
|
+
return [i + 3, true];
|
|
46
|
+
if (isWord(ts[i + 1], 'EXISTS'))
|
|
47
|
+
return [i + 2, true];
|
|
48
|
+
return [i, false];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Token spans inside a DO body whose statements are not known to take effect.
|
|
52
|
+
* No interpreter is needed to know that a branch may not be taken, a loop may
|
|
53
|
+
* run zero times, nothing after `RETURN` in the same block runs, and a block
|
|
54
|
+
* with an `EXCEPTION` section is rolled back entirely if the handler fires.
|
|
55
|
+
* Constructs nest, so they are matched with a stack. Returns index ranges.
|
|
56
|
+
*/
|
|
57
|
+
function guardedRanges(ts) {
|
|
58
|
+
const ranges = [];
|
|
59
|
+
const stack = [];
|
|
60
|
+
const close = (f, i) => {
|
|
61
|
+
// A plain BEGIN block is straight-line code; only an EXCEPTION section
|
|
62
|
+
// turns its whole body (and the handlers) into a maybe.
|
|
63
|
+
if (f.kind !== 'BEGIN' || f.exception)
|
|
64
|
+
ranges.push([f.at, i]);
|
|
65
|
+
if (f.returnAt !== undefined)
|
|
66
|
+
ranges.push([f.returnAt, i]);
|
|
33
67
|
};
|
|
34
|
-
let i = 0;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
for (let i = 0; i < ts.length; i++) {
|
|
69
|
+
const t = ts[i];
|
|
70
|
+
if (!t || t.type !== 'word')
|
|
71
|
+
continue;
|
|
72
|
+
const W = t.value.toUpperCase();
|
|
73
|
+
if (W === 'END') {
|
|
74
|
+
// `END IF` / `END LOOP` / `END CASE` name what they close; a bare END
|
|
75
|
+
// closes the innermost BEGIN block or CASE expression.
|
|
76
|
+
const f = stack.pop();
|
|
77
|
+
if (f)
|
|
78
|
+
close(f, i);
|
|
79
|
+
if (isWordIn(ts[i + 1], ['IF', 'LOOP', 'CASE']))
|
|
80
|
+
i++;
|
|
44
81
|
continue;
|
|
45
82
|
}
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
83
|
+
if (W === 'IF') {
|
|
84
|
+
// `IF EXISTS` / `IF NOT EXISTS` is a DDL clause, not a branch — reading it
|
|
85
|
+
// as one would leave a guard open to the end of the body.
|
|
86
|
+
if (isWord(ts[i + 1], 'EXISTS') || (isWord(ts[i + 1], 'NOT') && isWord(ts[i + 2], 'EXISTS')))
|
|
87
|
+
continue;
|
|
88
|
+
stack.push({ kind: 'IF', at: i });
|
|
51
89
|
continue;
|
|
52
90
|
}
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
if (W === 'LOOP' || W === 'CASE' || W === 'BEGIN') {
|
|
92
|
+
stack.push({ kind: W, at: i });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (W === 'EXCEPTION' && !isWord(ts[i - 1], 'RAISE')) {
|
|
96
|
+
// The section belongs to the innermost BEGIN block, not to an IF inside it.
|
|
97
|
+
for (let d = stack.length - 1; d >= 0; d--) {
|
|
98
|
+
const f = stack[d];
|
|
99
|
+
if (f && f.kind === 'BEGIN') {
|
|
100
|
+
f.exception = true;
|
|
61
101
|
break;
|
|
62
102
|
}
|
|
63
|
-
j++;
|
|
64
103
|
}
|
|
65
|
-
const end = Math.min(j + 1, n);
|
|
66
|
-
blank(i, end);
|
|
67
|
-
i = end;
|
|
68
104
|
continue;
|
|
69
105
|
}
|
|
70
|
-
if (
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
const close = sql.indexOf(tag, i + tag.length);
|
|
75
|
-
const end = close === -1 ? n : close + tag.length;
|
|
76
|
-
// `DO $$ ... $$` is executed procedural code: its DDL is real, so keep the
|
|
77
|
-
// body visible. Other dollar-quoted strings (EXECUTE format(...)) are blanked.
|
|
78
|
-
const isDoBlock = /\bdo\s*$/i.test(sql.slice(Math.max(0, i - 8), i));
|
|
79
|
-
if (isDoBlock) {
|
|
80
|
-
const bodyStart = i + tag.length;
|
|
81
|
-
const bodyEnd = close === -1 ? n : close;
|
|
82
|
-
// Mask inside the body too (a string literal there is still a string),
|
|
83
|
-
// then jump past the CLOSING delimiter — scanning from just after the
|
|
84
|
-
// opening one would read that closing `$$` as a new opening tag and
|
|
85
|
-
// blank the entire rest of the file, hiding every later statement.
|
|
86
|
-
const inner = maskSql(sql.slice(bodyStart, bodyEnd));
|
|
87
|
-
for (let k = 0; k < inner.length; k++)
|
|
88
|
-
out[bodyStart + k] = inner[k];
|
|
89
|
-
blank(i, bodyStart);
|
|
90
|
-
blank(bodyEnd, end);
|
|
91
|
-
i = end;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
blank(i, end);
|
|
95
|
-
i = end;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
106
|
+
if (W === 'RETURN') {
|
|
107
|
+
const f = stack[stack.length - 1];
|
|
108
|
+
if (f && f.returnAt === undefined)
|
|
109
|
+
f.returnAt = i;
|
|
98
110
|
}
|
|
99
|
-
i++;
|
|
100
111
|
}
|
|
101
|
-
|
|
112
|
+
while (stack.length)
|
|
113
|
+
close(stack.pop(), ts.length);
|
|
114
|
+
return ranges;
|
|
102
115
|
}
|
|
103
116
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
117
|
+
* Flatten a file into tokens, splicing every `DO … ` body in place so its DDL
|
|
118
|
+
* is analyzed as executed code while the surrounding file keeps its own order.
|
|
119
|
+
* Returns the tokens plus the guarded index ranges.
|
|
107
120
|
*/
|
|
108
|
-
function
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
i = end;
|
|
121
|
+
function flatten(sql) {
|
|
122
|
+
const top = lexSql(sql).filter((t) => t.type !== 'comment');
|
|
123
|
+
const ts = [];
|
|
124
|
+
const guards = [];
|
|
125
|
+
/** Token index ranges that came from a DO body — plpgsql, not plain SQL. */
|
|
126
|
+
const bodies = [];
|
|
127
|
+
for (let i = 0; i < top.length; i++) {
|
|
128
|
+
const t = top[i];
|
|
129
|
+
// A DO body is executed procedural code whether it is dollar-quoted or a
|
|
130
|
+
// plain `'…'` literal: lex it and inline it. `DO $$…$$`, `DO 'BEGIN … END'`,
|
|
131
|
+
// `DO LANGUAGE plpgsql $$…$$` — the language clause may come first.
|
|
132
|
+
const beforeBody = isWord(top[i - 2], 'LANGUAGE') && top[i - 1]?.type === 'word' ? i - 3 : i - 1;
|
|
133
|
+
if (isLiteral(t) && isWord(top[beforeBody], 'DO')) {
|
|
134
|
+
// In a plain literal a quote is written doubled; undo that so the body
|
|
135
|
+
// lexes as the SQL PostgreSQL will actually run.
|
|
136
|
+
const body = t.type === 'string' ? t.value.replace(/''/g, "'") : t.value;
|
|
137
|
+
const inner = lexSql(body, t.bodyStart ?? t.start).filter((x) => x.type !== 'comment');
|
|
138
|
+
const base = ts.length;
|
|
139
|
+
for (const [a, b] of guardedRanges(inner))
|
|
140
|
+
guards.push([base + a, base + b]);
|
|
141
|
+
ts.push(...inner);
|
|
142
|
+
bodies.push([base, ts.length]);
|
|
131
143
|
continue;
|
|
132
144
|
}
|
|
133
|
-
|
|
145
|
+
ts.push(t);
|
|
134
146
|
}
|
|
135
|
-
return
|
|
147
|
+
return { ts, guards, bodies };
|
|
136
148
|
}
|
|
137
149
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* needs an interpreter — so statements in these spans are reported as
|
|
141
|
-
* "cannot be confirmed" rather than assumed to have run.
|
|
142
|
-
*
|
|
143
|
-
* IFs nest, so this matches them with a stack: taking the first `END IF` as the
|
|
144
|
-
* outer block's terminator ends the guard early and lets a statement after the
|
|
145
|
-
* inner `END IF` look unconditional. `ELSIF` is not an opener (`\bif\b` does not
|
|
146
|
-
* match inside it). Offsets are preserved, so they line up with `maskSql` output.
|
|
150
|
+
* Text in a dynamic-SQL literal that can change what this checker models: a
|
|
151
|
+
* table's existence, its RLS state, its name, or how unqualified names resolve.
|
|
147
152
|
*/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
153
|
+
const STATE_TEXT = /row\s+level\s+security|create\s+(?:\w+\s+)*table\b|drop\s+table\b|alter\s+table\b|search_path/i;
|
|
154
|
+
/**
|
|
155
|
+
* Extract CREATE / ENABLE / DISABLE / DROP / RENAME / SELECT INTO events from
|
|
156
|
+
* one file, statement by statement. Anything that could change a table's
|
|
157
|
+
* existence, name or RLS state and is NOT understood is returned in `unparsed`
|
|
158
|
+
* — it must surface as partial coverage, never vanish into a `continue`.
|
|
159
|
+
*/
|
|
160
|
+
function readStatements(sql) {
|
|
161
|
+
const { ts, guards, bodies } = flatten(sql);
|
|
162
|
+
const events = [];
|
|
163
|
+
const unparsed = [];
|
|
164
|
+
const guarded = (i) => guards.some(([a, b]) => i >= a && i < b);
|
|
165
|
+
const inBody = (i) => bodies.some(([a, b]) => i >= a && i < b);
|
|
166
|
+
// Inside plpgsql, `BEGIN ALTER TABLE …` and `IF x THEN ALTER TABLE …` are one
|
|
167
|
+
// `;`-terminated run whose real statement starts after the structure word.
|
|
168
|
+
// Only inside DO bodies: plain SQL uses THEN/ELSE/END in CASE expressions.
|
|
169
|
+
const PLPGSQL_BOUNDARY = ['BEGIN', 'DECLARE', 'THEN', 'ELSE', 'ELSIF', 'LOOP', 'END'];
|
|
170
|
+
const isBoundary = (i) => isPunct(ts[i], ';') || (inBody(i) && isWordIn(ts[i], PLPGSQL_BOUNDARY));
|
|
171
|
+
const mentionsWord = (from, to, w) => {
|
|
172
|
+
for (let m = from; m < to; m++)
|
|
173
|
+
if (isWord(ts[m], w))
|
|
174
|
+
return true;
|
|
175
|
+
return false;
|
|
176
|
+
};
|
|
177
|
+
// --- Name resolution ------------------------------------------------------
|
|
178
|
+
// An unqualified name resolves to the first schema of search_path: `public`
|
|
179
|
+
// until a `SET search_path` earlier in this file says otherwise. SET LOCAL
|
|
180
|
+
// lasts to the end of the transaction. Two `orders` tables in different
|
|
181
|
+
// schemas used to merge into one state because everything resolved to public.
|
|
182
|
+
// An EMPTY search_path (`SET search_path = ''`, what `pg_dump` emits) is a
|
|
183
|
+
// known state too: every name must then be qualified, so an unqualified one
|
|
184
|
+
// cannot be resolved and is reported rather than guessed.
|
|
185
|
+
let sessionSchema = 'public';
|
|
186
|
+
let localSchema = null;
|
|
187
|
+
const currentSchema = () => localSchema ?? sessionSchema;
|
|
188
|
+
/** TEMP tables created so far in this file: normalized name → created under a guard. */
|
|
189
|
+
const tempTables = new Map();
|
|
190
|
+
const resolve = (q, at, opts = {}) => {
|
|
191
|
+
const table = normIdent(q.table.text, q.table.quoted);
|
|
192
|
+
if (q.schema) {
|
|
193
|
+
const schema = normIdent(q.schema.text, q.schema.quoted);
|
|
194
|
+
return { key: keyFor(schema, table), schema, table, display: `${q.schema.text}.${q.table.text}`, shadowed: false };
|
|
195
|
+
}
|
|
196
|
+
if (opts.temp)
|
|
197
|
+
return { key: keyFor(TEMP_SCHEMA, table), schema: TEMP_SCHEMA, table, display: q.table.text, shadowed: false };
|
|
198
|
+
// An existing temp table shadows the permanent one for DROP/ALTER (CREATE
|
|
199
|
+
// always targets the search_path schema). If the temp CREATE itself was
|
|
200
|
+
// guarded, which table this statement hits cannot be known.
|
|
201
|
+
const tempGuarded = tempTables.get(table);
|
|
202
|
+
if (!opts.forCreate && tempGuarded === false) {
|
|
203
|
+
return { key: keyFor(TEMP_SCHEMA, table), schema: TEMP_SCHEMA, table, display: q.table.text, shadowed: false };
|
|
204
|
+
}
|
|
205
|
+
const schema = currentSchema();
|
|
206
|
+
if (schema === '')
|
|
207
|
+
unparsed.push({ offset: at, why: `unqualified table name "${q.table.text}" with an empty search_path cannot be resolved to a schema` });
|
|
208
|
+
const display = schema === 'public' ? q.table.text : `${schema}.${q.table.text}`;
|
|
209
|
+
return { key: keyFor(schema, table), schema, table, display, shadowed: tempGuarded === true };
|
|
210
|
+
};
|
|
211
|
+
const add = (kind, r, at, idx, extra = {}) => {
|
|
212
|
+
events.push({
|
|
213
|
+
kind, key: r.key, ifNotExists: false, display: r.display, offset: at,
|
|
214
|
+
conditional: guarded(idx) || r.shadowed, ...extra,
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
const noteTemp = (r, idx) => { if (isTempKey(r.key))
|
|
218
|
+
tempTables.set(r.table, guarded(idx)); };
|
|
219
|
+
// --- Transactions ---------------------------------------------------------
|
|
220
|
+
// Top-level `BEGIN … ROLLBACK` undoes every statement in between; a
|
|
221
|
+
// transaction still open at end of file is committed by some runners and
|
|
222
|
+
// rolled back by others (psql). Either way those statements are "unconfirmed",
|
|
223
|
+
// not a confident PASS and not a confident critical.
|
|
224
|
+
let txStart = null; // index into `events`
|
|
225
|
+
const savepoints = new Map();
|
|
226
|
+
const undoSince = (i) => { for (let m = i; m < events.length; m++)
|
|
227
|
+
events[m].conditional = true; };
|
|
228
|
+
const endTx = () => { txStart = null; savepoints.clear(); localSchema = null; };
|
|
229
|
+
/**
|
|
230
|
+
* Adopt a new search_path given as its schema list (`''` entries are what
|
|
231
|
+
* `SET search_path = ''` leaves: an empty path). `"$user"` depends on the
|
|
232
|
+
* connecting role and a leading pg_temp would make every unqualified CREATE
|
|
233
|
+
* a temp table — neither is decidable here, so those become partial.
|
|
234
|
+
*/
|
|
235
|
+
const setSearchPath = (names, local, at, why) => {
|
|
236
|
+
const first = names.map((n) => n.trim()).filter((n) => n !== '')[0] ?? '';
|
|
237
|
+
if (first.startsWith('$') || first === TEMP_SCHEMA) {
|
|
238
|
+
unparsed.push({ offset: at, why });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (local)
|
|
242
|
+
localSchema = first;
|
|
243
|
+
else
|
|
244
|
+
sessionSchema = first;
|
|
245
|
+
};
|
|
246
|
+
/** `SET [LOCAL|SESSION] search_path {TO|=} a [, b …]`. Anything fancier → partial. */
|
|
247
|
+
const applySearchPath = (from, to) => {
|
|
248
|
+
const at = ts[from].start;
|
|
249
|
+
const why = 'SET search_path in a form the analyzer cannot follow; unqualified table names can no longer be resolved';
|
|
250
|
+
const bail = () => unparsed.push({ offset: at, why });
|
|
251
|
+
let j = from + 1;
|
|
252
|
+
const local = isWord(ts[j], 'LOCAL');
|
|
253
|
+
if (local || isWord(ts[j], 'SESSION'))
|
|
254
|
+
j++;
|
|
255
|
+
j++; // search_path
|
|
256
|
+
if (!(isWord(ts[j], 'TO') || isPunct(ts[j], '=')))
|
|
257
|
+
return bail();
|
|
258
|
+
j++;
|
|
259
|
+
const names = [];
|
|
260
|
+
if (isWord(ts[j], 'DEFAULT') && j + 1 === to)
|
|
261
|
+
names.push('public');
|
|
262
|
+
else {
|
|
263
|
+
for (;;) {
|
|
264
|
+
const t = ts[j];
|
|
265
|
+
if (isName(t))
|
|
266
|
+
names.push(normIdent(t.value, t.type === 'quotedIdent'));
|
|
267
|
+
// `'a, b'` as one literal is a whole list PostgreSQL would split; do not guess it.
|
|
268
|
+
else if (t && t.type === 'string' && !t.value.includes(','))
|
|
269
|
+
names.push(t.value);
|
|
270
|
+
else
|
|
271
|
+
return bail();
|
|
272
|
+
j++;
|
|
273
|
+
if (j >= to)
|
|
274
|
+
break;
|
|
275
|
+
if (!isPunct(ts[j], ','))
|
|
276
|
+
return bail();
|
|
277
|
+
j++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
setSearchPath(names, local, at, why);
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* `[SELECT] [pg_catalog.]set_config('search_path', '<list>', is_local)` — the
|
|
284
|
+
* form `pg_dump` emits. A literal list is followed like SET; any other value
|
|
285
|
+
* expression is unknown → partial.
|
|
286
|
+
*/
|
|
287
|
+
const applySetConfig = (from, to) => {
|
|
288
|
+
const at = ts[from].start;
|
|
289
|
+
const why = 'search_path changed via set_config(); unqualified table names can no longer be resolved';
|
|
290
|
+
let m = from;
|
|
291
|
+
while (m < to && !isWord(ts[m], 'SET_CONFIG'))
|
|
292
|
+
m++;
|
|
293
|
+
const [open, key, comma, val, comma2, flag, close] = ts.slice(m + 1, m + 8);
|
|
294
|
+
const literal = isPunct(open, '(') && key?.type === 'string' && isPunct(comma, ',') && val?.type === 'string' && isPunct(comma2, ',')
|
|
295
|
+
&& isWordIn(flag, ['TRUE', 'FALSE']) && isPunct(close, ')');
|
|
296
|
+
if (!literal || !val)
|
|
297
|
+
return unparsed.push({ offset: at, why });
|
|
298
|
+
// Only the list is unquoted and split; a quoted entry inside it is left as
|
|
299
|
+
// written, the same way `SET search_path TO "App"` keeps case.
|
|
300
|
+
const names = val.value.split(',').map((n) => n.trim()).map((n) => (n.startsWith('"') && n.endsWith('"') ? n.slice(1, -1) : n.toLowerCase()));
|
|
301
|
+
setSearchPath(names, isWord(flag, 'TRUE'), at, why);
|
|
302
|
+
};
|
|
303
|
+
/** ENABLE|DISABLE ROW LEVEL SECURITY at `m`? Returns the verb or null. */
|
|
304
|
+
const rlsVerbAt = (m) => {
|
|
305
|
+
const v = ts[m];
|
|
306
|
+
if (!v || v.type !== 'word')
|
|
307
|
+
return null;
|
|
308
|
+
const V = v.value.toUpperCase();
|
|
309
|
+
if (V !== 'ENABLE' && V !== 'DISABLE')
|
|
310
|
+
return null;
|
|
311
|
+
return isWord(ts[m + 1], 'ROW') && isWord(ts[m + 2], 'LEVEL') && isWord(ts[m + 3], 'SECURITY') ? (V === 'DISABLE' ? 'disable' : 'enable') : null;
|
|
312
|
+
};
|
|
313
|
+
const mentionsRls = (from, to) => {
|
|
314
|
+
for (let m = from; m + 2 < to; m++)
|
|
315
|
+
if (isWord(ts[m], 'ROW') && isWord(ts[m + 1], 'LEVEL') && isWord(ts[m + 2], 'SECURITY'))
|
|
316
|
+
return true;
|
|
317
|
+
return false;
|
|
318
|
+
};
|
|
319
|
+
/** `set_config('search_path', …)` anywhere in the statement. */
|
|
320
|
+
const callsSetConfigSearchPath = (from, to) => {
|
|
321
|
+
if (!mentionsWord(from, to, 'SET_CONFIG'))
|
|
322
|
+
return false;
|
|
323
|
+
for (let m = from; m < to; m++) {
|
|
324
|
+
const t = ts[m];
|
|
325
|
+
if (t.type === 'string' && t.value.trim().toLowerCase() === 'search_path')
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
const interpret = (from, to) => {
|
|
331
|
+
const first = ts[from];
|
|
332
|
+
const w = first.type === 'word' ? first.value.toUpperCase() : '';
|
|
333
|
+
const body = inBody(from);
|
|
334
|
+
// From here on unqualified names resolve differently — or, inside a DO
|
|
335
|
+
// block whose control flow is not followed, unknowably.
|
|
336
|
+
if (callsSetConfigSearchPath(from, to)) {
|
|
337
|
+
if (body)
|
|
338
|
+
return unparsed.push({ offset: first.start, why: 'search_path changed via set_config() inside a DO block; unqualified table names can no longer be resolved' });
|
|
339
|
+
return applySetConfig(from, to);
|
|
340
|
+
}
|
|
341
|
+
if (w === 'SET') {
|
|
342
|
+
if (!mentionsWord(from, to, 'SEARCH_PATH'))
|
|
343
|
+
return; // other GUCs do not affect the model
|
|
344
|
+
if (body)
|
|
345
|
+
return unparsed.push({ offset: first.start, why: 'SET search_path inside a DO block; unqualified table names can no longer be resolved' });
|
|
346
|
+
return applySearchPath(from, to);
|
|
347
|
+
}
|
|
348
|
+
if (w === 'RESET' && !body) {
|
|
349
|
+
if (isWordIn(ts[from + 1], ['SEARCH_PATH', 'ALL'])) {
|
|
350
|
+
sessionSchema = 'public';
|
|
351
|
+
localSchema = null;
|
|
164
352
|
}
|
|
165
|
-
|
|
166
|
-
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!body) {
|
|
356
|
+
if (w === 'BEGIN' || (w === 'START' && isWord(ts[from + 1], 'TRANSACTION'))) {
|
|
357
|
+
if (txStart === null)
|
|
358
|
+
txStart = events.length;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (w === 'COMMIT' || w === 'END') {
|
|
362
|
+
endTx();
|
|
363
|
+
return;
|
|
364
|
+
} // top-level END is COMMIT
|
|
365
|
+
if (w === 'SAVEPOINT' && isName(ts[from + 1])) {
|
|
366
|
+
const n = ts[from + 1];
|
|
367
|
+
savepoints.set(normIdent(n.value, n.type === 'quotedIdent'), events.length);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (w === 'ROLLBACK' || w === 'ABORT') {
|
|
371
|
+
if (isWord(ts[from + 1], 'TO')) {
|
|
372
|
+
// ROLLBACK TO [SAVEPOINT] name keeps the transaction open; only the
|
|
373
|
+
// work after the savepoint is undone. Unknown savepoint → undo all.
|
|
374
|
+
let j = from + 2;
|
|
375
|
+
if (isWord(ts[j], 'SAVEPOINT'))
|
|
376
|
+
j++;
|
|
377
|
+
const n = ts[j];
|
|
378
|
+
const at = isName(n) ? savepoints.get(normIdent(n.value, n.type === 'quotedIdent')) : undefined;
|
|
379
|
+
undoSince(at ?? txStart ?? events.length);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
undoSince(txStart ?? events.length);
|
|
383
|
+
endTx();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (w === 'CREATE') {
|
|
388
|
+
let j = from + 1;
|
|
389
|
+
let temp = false;
|
|
390
|
+
while (isWordIn(ts[j], ['UNLOGGED', 'TEMP', 'TEMPORARY', 'GLOBAL', 'LOCAL'])) {
|
|
391
|
+
if (/^TEMP/i.test(ts[j].value))
|
|
392
|
+
temp = true;
|
|
393
|
+
j++;
|
|
394
|
+
}
|
|
395
|
+
if (!isWord(ts[j], 'TABLE'))
|
|
396
|
+
return dynamic(from, to); // CREATE INDEX/POLICY/… — not a table
|
|
397
|
+
const [k, ine] = skipIfExists(ts, j + 1);
|
|
398
|
+
const q = readQualified(ts, k);
|
|
399
|
+
if (!q)
|
|
400
|
+
return unparsed.push({ offset: first.start, why: 'CREATE TABLE with a table name the analyzer cannot read' });
|
|
401
|
+
const r = resolve(q, first.start, { forCreate: true, temp });
|
|
402
|
+
noteTemp(r, from);
|
|
403
|
+
add('create', r, first.start, from, { ifNotExists: ine });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (w === 'DROP' && isWord(ts[from + 1], 'TABLE')) {
|
|
407
|
+
let [k] = skipIfExists(ts, from + 2);
|
|
408
|
+
// DROP TABLE takes a list: every name in it is gone, not only the first.
|
|
409
|
+
for (;;) {
|
|
410
|
+
const q = readQualified(ts, k);
|
|
411
|
+
if (!q)
|
|
412
|
+
return unparsed.push({ offset: first.start, why: 'DROP TABLE with a table name the analyzer cannot read' });
|
|
413
|
+
const r = resolve(q, first.start);
|
|
414
|
+
add('drop', r, first.start, from);
|
|
415
|
+
if (isTempKey(r.key) && !guarded(from))
|
|
416
|
+
tempTables.delete(r.table);
|
|
417
|
+
k = q.next;
|
|
418
|
+
if (!isPunct(ts[k], ','))
|
|
419
|
+
break;
|
|
420
|
+
k++;
|
|
421
|
+
}
|
|
422
|
+
while (isWordIn(ts[k], ['CASCADE', 'RESTRICT']))
|
|
423
|
+
k++;
|
|
424
|
+
if (k < to)
|
|
425
|
+
unparsed.push({ offset: first.start, why: 'DROP TABLE has a trailing clause the analyzer cannot read' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (w === 'ALTER' && isWord(ts[from + 1], 'TABLE')) {
|
|
429
|
+
// ALTER TABLE [IF EXISTS] [ONLY] name [*] action [, action ...]
|
|
430
|
+
let [j] = skipIfExists(ts, from + 2);
|
|
431
|
+
if (isWord(ts[j], 'ONLY'))
|
|
432
|
+
j++;
|
|
433
|
+
const q = readQualified(ts, j);
|
|
434
|
+
if (!q)
|
|
435
|
+
return unparsed.push({ offset: first.start, why: 'ALTER TABLE with a table name the analyzer cannot read' });
|
|
436
|
+
const r = resolve(q, first.start);
|
|
437
|
+
let k = q.next;
|
|
438
|
+
if (isPunct(ts[k], '*'))
|
|
439
|
+
k++;
|
|
440
|
+
// RENAME TO / SET SCHEMA move the table's identity. The state must follow
|
|
441
|
+
// it, or a later DISABLE under the new name is never matched to the table.
|
|
442
|
+
if (isWord(ts[k], 'RENAME') && isWord(ts[k + 1], 'TO')) {
|
|
443
|
+
const nq = readQualified(ts, k + 2);
|
|
444
|
+
if (!nq || nq.schema || nq.next !== to)
|
|
445
|
+
return unparsed.push({ offset: first.start, why: 'ALTER TABLE RENAME TO with a name the analyzer cannot read' });
|
|
446
|
+
const table = normIdent(nq.table.text, nq.table.quoted);
|
|
447
|
+
const toDisplay = r.schema === 'public' || r.schema === TEMP_SCHEMA ? nq.table.text : `${r.schema}.${nq.table.text}`;
|
|
448
|
+
add('rename', r, first.start, from, { toKey: keyFor(r.schema, table), toDisplay });
|
|
449
|
+
if (isTempKey(r.key) && !guarded(from)) {
|
|
450
|
+
tempTables.delete(r.table);
|
|
451
|
+
tempTables.set(table, false);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (isWord(ts[k], 'SET') && isWord(ts[k + 1], 'SCHEMA')) {
|
|
456
|
+
const s = ts[k + 2];
|
|
457
|
+
if (!isName(s) || k + 3 !== to)
|
|
458
|
+
return unparsed.push({ offset: first.start, why: 'ALTER TABLE SET SCHEMA with a name the analyzer cannot read' });
|
|
459
|
+
const schema = normIdent(s.value, s.type === 'quotedIdent');
|
|
460
|
+
add('rename', r, first.start, from, { toKey: keyFor(schema, r.table), toDisplay: `${s.value}.${q.table.text}` });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Actions are comma-separated; an RLS toggle may be any of them, so scan
|
|
464
|
+
// the whole statement rather than only the first action.
|
|
465
|
+
let handled = false;
|
|
466
|
+
for (let m = k; m < to; m++) {
|
|
467
|
+
const verb = rlsVerbAt(m);
|
|
468
|
+
if (verb) {
|
|
469
|
+
add(verb, r, first.start, from);
|
|
470
|
+
handled = true;
|
|
471
|
+
m += 3;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// FORCE / NO FORCE ROW LEVEL SECURITY change owner bypass, not whether RLS is on.
|
|
475
|
+
if (isWord(ts[m], 'FORCE') && isWord(ts[m + 1], 'ROW')) {
|
|
476
|
+
handled = true;
|
|
477
|
+
m += 3;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!handled && mentionsRls(k, to)) {
|
|
481
|
+
unparsed.push({ offset: first.start, why: 'ALTER TABLE mentions ROW LEVEL SECURITY in a form the analyzer does not understand' });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// A role- or database-level default applies to every LATER session, i.e.
|
|
486
|
+
// to the migrations that follow this one.
|
|
487
|
+
if (w === 'ALTER' && isWordIn(ts[from + 1], ['ROLE', 'USER', 'DATABASE']) && mentionsWord(from, to, 'SEARCH_PATH')) {
|
|
488
|
+
return unparsed.push({ offset: first.start, why: 'ALTER ROLE/DATABASE … SET search_path changes name resolution for later migrations; unqualified table names can no longer be resolved' });
|
|
489
|
+
}
|
|
490
|
+
if (w === 'SELECT') {
|
|
491
|
+
// Top-level `SELECT … INTO <table>` creates a table. Inside plpgsql the
|
|
492
|
+
// same syntax is ALWAYS a variable assignment (a table is made with
|
|
493
|
+
// CREATE TABLE AS), so there it must not be read as a table.
|
|
494
|
+
if (body)
|
|
495
|
+
return;
|
|
496
|
+
for (let m = from + 1; m < to; m++) {
|
|
497
|
+
const u = ts[m];
|
|
498
|
+
if (isWord(u, 'FROM'))
|
|
499
|
+
break;
|
|
500
|
+
if (isWord(u, 'INTO')) {
|
|
501
|
+
let n = m + 1;
|
|
502
|
+
let temp = false;
|
|
503
|
+
while (isWordIn(ts[n], ['TEMP', 'TEMPORARY', 'UNLOGGED', 'TABLE'])) {
|
|
504
|
+
if (/^TEMP/i.test(ts[n].value))
|
|
505
|
+
temp = true;
|
|
506
|
+
n++;
|
|
507
|
+
}
|
|
508
|
+
const q = readQualified(ts, n);
|
|
509
|
+
if (q) {
|
|
510
|
+
const r = resolve(q, first.start, { forCreate: true, temp });
|
|
511
|
+
noteTemp(r, from);
|
|
512
|
+
add('create', r, first.start, from);
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
return dynamic(from, to);
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* Dynamic SQL — `EXECUTE 'ALTER TABLE …'`, `EXECUTE cmd`, `EXECUTE format(…)`
|
|
523
|
+
* — builds the statement at run time. A single literal can at least be read
|
|
524
|
+
* and is flagged only when it talks about tables, RLS or search_path. A
|
|
525
|
+
* variable, a `||` concatenation or a function call is unknown by
|
|
526
|
+
* construction: whatever it does to table/RLS state cannot be known here, so
|
|
527
|
+
* it is always missing coverage.
|
|
528
|
+
*/
|
|
529
|
+
const dynamic = (from, to) => {
|
|
530
|
+
// Only a leading EXECUTE (or `FOR … IN EXECUTE`) runs SQL; `CREATE TRIGGER
|
|
531
|
+
// … EXECUTE FUNCTION f()` merely names a function.
|
|
532
|
+
let ex = -1;
|
|
533
|
+
for (let m = from; m < to; m++) {
|
|
534
|
+
if (isWord(ts[m], 'EXECUTE') && (m === from || isWord(ts[m - 1], 'IN')) && !isWordIn(ts[m + 1], ['FUNCTION', 'PROCEDURE'])) {
|
|
535
|
+
ex = m;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (ex === -1)
|
|
540
|
+
return;
|
|
541
|
+
let end = to;
|
|
542
|
+
for (let m = ex + 1; m < to; m++)
|
|
543
|
+
if (isWordIn(ts[m], ['INTO', 'USING'])) {
|
|
544
|
+
end = m;
|
|
545
|
+
break;
|
|
167
546
|
}
|
|
547
|
+
const arg = ts.slice(ex + 1, end);
|
|
548
|
+
const one = arg[0];
|
|
549
|
+
if (arg.length === 1 && one && isLiteral(one)) {
|
|
550
|
+
if (STATE_TEXT.test(one.value))
|
|
551
|
+
unparsed.push({ offset: one.start, why: 'dynamic SQL (EXECUTE with a string) changes table/RLS state and cannot be interpreted statically' });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Top-level EXECUTE runs a PREPAREd statement, which cannot be DDL.
|
|
555
|
+
if (!inBody(ex))
|
|
556
|
+
return;
|
|
557
|
+
// Joining the literal pieces of `'ALTER TABLE x DISABLE ROW ' || 'LEVEL
|
|
558
|
+
// SECURITY'` only sharpens the message; the verdict is the same.
|
|
559
|
+
const pieces = arg.filter(isLiteral).map((t) => t.value).join('');
|
|
560
|
+
unparsed.push({
|
|
561
|
+
offset: ts[ex].start,
|
|
562
|
+
why: STATE_TEXT.test(pieces)
|
|
563
|
+
? 'dynamic SQL (EXECUTE with a run-time built string that mentions tables/RLS) cannot be interpreted statically'
|
|
564
|
+
: 'dynamic SQL (EXECUTE with a variable or expression) has an effect on table/RLS state that cannot be known statically',
|
|
565
|
+
});
|
|
566
|
+
};
|
|
567
|
+
// Split into statements and interpret each one.
|
|
568
|
+
let s = 0;
|
|
569
|
+
for (let i = 0; i <= ts.length; i++) {
|
|
570
|
+
if (i === ts.length || isBoundary(i)) {
|
|
571
|
+
if (i > s)
|
|
572
|
+
interpret(s, i);
|
|
573
|
+
s = i + 1;
|
|
168
574
|
}
|
|
169
|
-
// An IF left unterminated guards everything to the end of the block.
|
|
170
|
-
for (const from of open)
|
|
171
|
-
ranges.push([bodyStart + from, bodyEnd]);
|
|
172
575
|
}
|
|
173
|
-
|
|
576
|
+
// Left open at end of file: committed or rolled back depending on the runner.
|
|
577
|
+
if (txStart !== null)
|
|
578
|
+
undoSince(txStart);
|
|
579
|
+
return { events, unparsed };
|
|
174
580
|
}
|
|
175
581
|
/**
|
|
176
582
|
* Flags each table whose latest state after replaying the migrations is
|
|
177
583
|
* "created and RLS not enabled". Migrations are replayed in apply order
|
|
178
|
-
* (files by name, statements by offset); CREATE / ENABLE / DISABLE / DROP
|
|
179
|
-
* all modeled; CREATE IF NOT EXISTS on an existing table is a
|
|
180
|
-
* and string literals are ignored; quoted and schema-qualified
|
|
584
|
+
* (files by name, statements by offset); CREATE / ENABLE / DISABLE / DROP /
|
|
585
|
+
* RENAME are all modeled; CREATE IF NOT EXISTS on an existing table is a
|
|
586
|
+
* no-op; comments and string literals are ignored; quoted and schema-qualified
|
|
587
|
+
* names work.
|
|
181
588
|
*/
|
|
182
589
|
export const rlsMigrationsChecker = {
|
|
183
590
|
id: 'rls-migrations',
|
|
@@ -185,45 +592,139 @@ export const rlsMigrationsChecker = {
|
|
|
185
592
|
level: 0,
|
|
186
593
|
run(ctx) {
|
|
187
594
|
const sqlFiles = ctx.files.filter((f) => f.ext === '.sql').sort((a, b) => a.rel.localeCompare(b.rel));
|
|
188
|
-
if (sqlFiles.length === 0)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
595
|
+
if (sqlFiles.length === 0) {
|
|
596
|
+
// A project that clearly talks to Supabase but ships no .sql migrations
|
|
597
|
+
// (schema managed from the dashboard, or migrations live in another repo)
|
|
598
|
+
// used to just say nothing — a silent, undeserved clean score on the exact
|
|
599
|
+
// check the tool exists for. Say what could not be checked instead.
|
|
600
|
+
const looksSupabase = ctx.detection.backends.includes('supabase') ||
|
|
601
|
+
ctx.files.some((f) => !/\.(md|mdx|txt|rst)$/i.test(f.rel) &&
|
|
602
|
+
/(SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_|\/rest\/v1\b|postgrest)/i.test(f.content));
|
|
603
|
+
if (!looksSupabase)
|
|
604
|
+
return [];
|
|
605
|
+
return [{
|
|
606
|
+
id: 'rls_unverifiable_no_migrations',
|
|
607
|
+
severity: 'info',
|
|
608
|
+
title: 'No SQL migrations found — RLS could not be checked statically',
|
|
609
|
+
detail: 'This project talks to Supabase, but no .sql migration files exist in this repository (the schema may be managed from the dashboard, or migrations live in a different repo). A static scan can only see table/RLS state from files it can read, so no table here could be verified this way — this is missing information, not a clean result.',
|
|
610
|
+
fix: 'Run the live probe (consent + --i-own-this) to enumerate which tables the anon key can read, or check pg_class.relrowsecurity and pg_policies directly in the Supabase SQL editor.',
|
|
611
|
+
checker: 'rls-migrations',
|
|
612
|
+
level: 0,
|
|
613
|
+
}];
|
|
614
|
+
}
|
|
615
|
+
// RLS is a PostgreSQL feature. The engine is decided per FILE, not per
|
|
616
|
+
// project: a monorepo can hold a Postgres schema next to a Cloudflare D1
|
|
617
|
+
// (SQLite) one, and a SQLite schema must never be told to enable RLS — that
|
|
618
|
+
// engine has no such thing. A real D1 project once got five "critical" RLS
|
|
619
|
+
// findings and a 0/100 because the old gate only knew MySQL's
|
|
620
|
+
// `auto_increment` (with the underscore) and never heard of D1/wrangler.
|
|
621
|
+
// A monorepo's dependencies often live in apps/*/package.json, not the
|
|
622
|
+
// workspace root. Reading only the root file missed a D1/SQLite worker's
|
|
623
|
+
// `wrangler`/`better-sqlite3` signal whenever the SQL itself carried no
|
|
624
|
+
// dialect marker either, and defaulted it to Postgres — a phantom critical
|
|
625
|
+
// on a database that has no RLS to enable.
|
|
626
|
+
const pkg = ctx.files.filter((f) => f.rel.endsWith('package.json')).map((f) => f.content).join('\n');
|
|
627
|
+
const reqs = ctx.files.filter((f) => f.rel === 'requirements.txt' || f.rel.endsWith('/requirements.txt')).map((f) => f.content).join('\n');
|
|
628
|
+
// Checked with .some() against the files themselves, not a pre-joined
|
|
629
|
+
// string: joining nearly the whole project's non-doc/non-SQL content into
|
|
630
|
+
// one string on every scan doubled peak memory for the full scanned corpus
|
|
631
|
+
// to answer what is, per project, a handful of existence checks that can
|
|
632
|
+
// each stop at the first matching file.
|
|
633
|
+
const codeFiles = ctx.files.filter((f) => !/\.(md|mdx|txt|rst|sql)$/i.test(f.rel));
|
|
634
|
+
const matchesAnyCodeFile = (re) => codeFiles.some((f) => re.test(f.content));
|
|
635
|
+
const pgProject = ctx.detection.backends.includes('supabase') ||
|
|
636
|
+
/\b(pg|postgres|postgresql|@supabase\/|postgres\.js|node-postgres|pg-promise)\b/i.test(pkg) ||
|
|
637
|
+
/\b(psycopg|asyncpg|sqlalchemy\+postgres)\b/i.test(reqs);
|
|
638
|
+
const hasD1 = ctx.files.some((f) => /(^|\/)wrangler\.(toml|jsonc?)$/.test(f.rel) && /d1_databases/.test(f.content));
|
|
639
|
+
const sqliteProject = hasD1 ||
|
|
640
|
+
/"(better-sqlite3|sqlite3|@libsql\/client)"/i.test(pkg) ||
|
|
641
|
+
matchesAnyCodeFile(/from\s+['"](bun:sqlite|drizzle-orm\/(d1|better-sqlite3|libsql))['"]/);
|
|
642
|
+
const mysqlProject = /"(mysql2?|mariadb)"/i.test(pkg);
|
|
643
|
+
const sqliteLabel = hasD1 ? 'Cloudflare D1 (SQLite)' : 'SQLite';
|
|
644
|
+
const PG_MARK = /(row\s+level\s+security|gen_random_uuid|\b(?:big)?serial\b|::\s*\w+|\bjsonb\b|\bplpgsql\b|create\s+policy|uuid_generate)/i;
|
|
645
|
+
const SQLITE_MARK = /(\bautoincrement\b|integer\s+primary\s+key|\bpragma\b|without\s+rowid)/i;
|
|
646
|
+
const MYSQL_MARK = /(engine\s*=\s*innodb|\bauto_increment\b|`\w+`\s*varchar)/i;
|
|
647
|
+
/** The engine a file is written for, or null when it is (or may as well be) PostgreSQL. */
|
|
648
|
+
const foreignEngine = (sql) => {
|
|
649
|
+
if (PG_MARK.test(sql))
|
|
650
|
+
return null;
|
|
651
|
+
if (SQLITE_MARK.test(sql))
|
|
652
|
+
return sqliteLabel;
|
|
653
|
+
if (MYSQL_MARK.test(sql))
|
|
654
|
+
return 'MySQL';
|
|
655
|
+
// No dialect marker in the file: fall back to what the project uses. With
|
|
656
|
+
// no signal either way this is treated as Postgres — the tool's home turf.
|
|
657
|
+
if (sqliteProject && !pgProject)
|
|
658
|
+
return sqliteLabel;
|
|
659
|
+
if (mysqlProject && !pgProject)
|
|
660
|
+
return 'MySQL';
|
|
661
|
+
return null;
|
|
662
|
+
};
|
|
663
|
+
const skipped = [];
|
|
664
|
+
const analyzed = sqlFiles.filter((f) => {
|
|
665
|
+
const engine = foreignEngine(f.content);
|
|
666
|
+
if (engine)
|
|
667
|
+
skipped.push({ file: f.rel, engine });
|
|
668
|
+
return !engine;
|
|
669
|
+
});
|
|
670
|
+
// Skipping is visible, not silent: an info finding names the files and says
|
|
671
|
+
// where access control lives for that engine. Info does not move the gate —
|
|
672
|
+
// the check does not apply, so the verdict must not become "incomplete".
|
|
673
|
+
const notApplicable = skipped.length === 0 ? [] : [{
|
|
674
|
+
id: 'rls_not_applicable',
|
|
675
|
+
severity: 'info',
|
|
676
|
+
title: `RLS check skipped for ${skipped.length} ${[...new Set(skipped.map((s) => s.engine))].join('/')} schema file(s) — Row Level Security is PostgreSQL-only`,
|
|
677
|
+
detail: `${skipped.slice(0, 5).map((s) => s.file).join(', ')}${skipped.length > 5 ? ` (+${skipped.length - 5} more)` : ''}: this engine has no row-level security, so "table without RLS" does not apply here. Access control for this data must live in your server/Worker code.`,
|
|
678
|
+
fix: 'Nothing to change in the schema. Make sure every query your API/Worker runs checks the caller\'s identity before reading or writing rows — that is where access control lives for this database.',
|
|
679
|
+
checker: 'rls-migrations',
|
|
680
|
+
level: 0,
|
|
681
|
+
file: skipped[0]?.file ?? '',
|
|
682
|
+
line: 1,
|
|
683
|
+
}];
|
|
684
|
+
if (analyzed.length === 0)
|
|
685
|
+
return notApplicable;
|
|
686
|
+
// "No RLS" is a hole only when untrusted clients reach the database directly
|
|
687
|
+
// (Supabase/PostgREST with the anon key, Hasura). A Postgres that only server
|
|
688
|
+
// code talks to has no RLS by default and is not exposed by that — a critical
|
|
689
|
+
// there would be noise. With no signal either way the exposed reading wins:
|
|
690
|
+
// the tool's audience is mostly Supabase, and unknown must not read as safe.
|
|
691
|
+
const exposed = ctx.detection.backends.includes('supabase') ||
|
|
692
|
+
/@supabase\//i.test(pkg) ||
|
|
693
|
+
matchesAnyCodeFile(/(\/rest\/v1\b|postgrest|hasura|SUPABASE_(?:ANON|PUBLISHABLE)_KEY|sb_publishable_)/i);
|
|
694
|
+
const serverOnly = !exposed &&
|
|
695
|
+
(/"(pg|postgres|pg-promise|@prisma\/client|drizzle-orm|knex|kysely|typeorm|sequelize)"/i.test(pkg) ||
|
|
696
|
+
/\b(psycopg2?|asyncpg|sqlalchemy)\b/i.test(reqs));
|
|
697
|
+
const missingSeverity = exposed || !serverOnly ? 'critical' : 'warning';
|
|
201
698
|
const events = [];
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
kind, key: keyOf(schema, table), ifNotExists, file: f.rel, line: lineAt(f.content, offset),
|
|
210
|
-
display: displayOf(schema, table), fileIdx, offset,
|
|
211
|
-
conditional: guards.some(([a, b]) => offset >= a && offset < b),
|
|
212
|
-
});
|
|
213
|
-
for (const m of masked.matchAll(CREATE_TABLE))
|
|
214
|
-
push('create', m[2], m[3] ?? '', m.index ?? 0, !!m[1]);
|
|
215
|
-
for (const m of masked.matchAll(RLS_STMT))
|
|
216
|
-
push((m[3] ?? '').toLowerCase() === 'disable' ? 'disable' : 'enable', m[1], m[2] ?? '', m.index ?? 0);
|
|
217
|
-
for (const m of masked.matchAll(DROP_TABLE))
|
|
218
|
-
push('drop', m[1], m[2] ?? '', m.index ?? 0);
|
|
219
|
-
for (const m of masked.matchAll(SELECT_INTO))
|
|
220
|
-
push('create', m[1], m[2] ?? '', m.index ?? 0);
|
|
699
|
+
const notUnderstood = [];
|
|
700
|
+
analyzed.forEach((f, fileIdx) => {
|
|
701
|
+
const { events: evs, unparsed } = readStatements(f.content);
|
|
702
|
+
for (const e of evs)
|
|
703
|
+
events.push({ ...e, file: f.rel, fileIdx, line: lineAt(f.content, e.offset) });
|
|
704
|
+
for (const u of unparsed)
|
|
705
|
+
notUnderstood.push(`${f.rel}:${lineAt(f.content, u.offset)} — ${u.why}`);
|
|
221
706
|
});
|
|
222
707
|
// True apply order: by migration file, then by statement position in the file.
|
|
223
708
|
events.sort((a, b) => a.fileIdx - b.fileIdx || a.offset - b.offset);
|
|
224
709
|
const state = new Map();
|
|
225
710
|
for (const e of events) {
|
|
226
|
-
const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false };
|
|
711
|
+
const cur = state.get(e.key) ?? { created: false, enabled: false, file: e.file, line: e.line, display: e.display, stateFile: e.file, guarded: false, keys: new Set([e.key]) };
|
|
712
|
+
if (e.kind === 'rename') {
|
|
713
|
+
const to = e.toKey;
|
|
714
|
+
cur.keys.add(to);
|
|
715
|
+
if (e.conditional) {
|
|
716
|
+
// Which name the table ends up under is unknown: keep it under both, both in doubt.
|
|
717
|
+
cur.guarded = true;
|
|
718
|
+
cur.stateFile = e.file;
|
|
719
|
+
state.set(e.key, cur);
|
|
720
|
+
if (!state.has(to))
|
|
721
|
+
state.set(to, { ...cur, keys: new Set(cur.keys), display: e.toDisplay ?? cur.display });
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
state.delete(e.key);
|
|
725
|
+
state.set(to, { ...cur, display: e.toDisplay ?? cur.display, stateFile: e.file });
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
227
728
|
// Doubt is a property of ANY guarded statement, not just a guarded ENABLE.
|
|
228
729
|
// A conditional DROP used to delete the table from the model outright, so
|
|
229
730
|
// a table left unprotected vanished from the report entirely. A later
|
|
@@ -290,35 +791,48 @@ export const rlsMigrationsChecker = {
|
|
|
290
791
|
const turnsOff = (k) => k === 'disable' || k === 'create';
|
|
291
792
|
const findings = [];
|
|
292
793
|
for (const [key, s] of state) {
|
|
293
|
-
if (!s.created)
|
|
294
|
-
continue;
|
|
295
|
-
const ambiguous = events.some((e) => e.key
|
|
794
|
+
if (!s.created || isTempKey(key))
|
|
795
|
+
continue; // temp tables are session-only: PostgREST never sees them
|
|
796
|
+
const ambiguous = events.some((e) => s.keys.has(e.key) && dirOf(e.file) !== dirOf(s.stateFile) && (s.enabled ? turnsOff(e.kind) : e.kind === 'enable'));
|
|
296
797
|
if (s.enabled && !ambiguous && !s.guarded)
|
|
297
798
|
continue; // provably protected
|
|
298
799
|
// `guarded` means a statement we had to GUESS about decided this table's
|
|
299
|
-
// state — a conditional ENABLE, DISABLE, CREATE or
|
|
300
|
-
// "critical" would be the same false confidence as calling it clean,
|
|
301
|
-
// is reported as unconfirmed. A warning still fails CI (exit 1); it
|
|
302
|
-
// does not claim to know what only the database can tell.
|
|
800
|
+
// state — a conditional ENABLE, DISABLE, CREATE, DROP or RENAME. Calling
|
|
801
|
+
// that "critical" would be the same false confidence as calling it clean,
|
|
802
|
+
// so it is reported as unconfirmed. A warning still fails CI (exit 1); it
|
|
803
|
+
// just does not claim to know what only the database can tell.
|
|
303
804
|
const kind = ambiguous ? 'order' : s.guarded ? 'guarded' : 'missing';
|
|
304
805
|
findings.push({
|
|
305
806
|
id: 'rls_missing',
|
|
306
|
-
severity: kind === 'missing' ?
|
|
307
|
-
title: kind === 'missing'
|
|
807
|
+
severity: kind === 'missing' ? missingSeverity : 'warning',
|
|
808
|
+
title: kind === 'missing'
|
|
809
|
+
? (missingSeverity === 'critical'
|
|
810
|
+
? `Table "${s.display}" created without RLS`
|
|
811
|
+
: `Table "${s.display}" has no RLS (server-only database)`)
|
|
308
812
|
: kind === 'order' ? `Table "${s.display}" may end up without RLS (migration order unclear)`
|
|
309
813
|
: `Table "${s.display}" has an unconfirmed RLS state (conditional block)`,
|
|
310
814
|
detail: kind === 'missing'
|
|
311
|
-
?
|
|
815
|
+
? (missingSeverity === 'critical'
|
|
816
|
+
? `"${s.display}" is created in a migration and its latest state does not enable Row Level Security. On Supabase the anon role has full table privileges by default, so with RLS off the public key can READ, INSERT, UPDATE and DELETE every row through the REST API. Static view only: this covers tables created by migrations in this repository — tables created from the dashboard or elsewhere are not listed here; the live probe enumerates all of them.`
|
|
817
|
+
: `"${s.display}" is created without Row Level Security. Only server code (pg/Prisma/…) appears to talk to this database, so nothing hands its rows to clients directly — that is normal, not a hole. It becomes CRITICAL the moment a client-facing data API (Supabase/PostgREST anon key, Hasura) is put in front of the same database.`)
|
|
312
818
|
: kind === 'order'
|
|
313
819
|
? `"${s.display}" has statements in directories other than "${s.stateFile}" that contradict its final RLS state. Files in separate directories have no reliable apply order, so this cannot be decided statically — check the deployed state.`
|
|
314
|
-
: `"${s.display}" has RLS
|
|
315
|
-
fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). This is a static hint — confirm the deployed state.`,
|
|
820
|
+
: `"${s.display}" has a table or RLS statement whose execution cannot be confirmed statically: inside an IF/LOOP branch or a block with an EXCEPTION handler, after a RETURN, in a rolled-back or unterminated transaction, or on a name a TEMP table may shadow. Whether it took effect cannot be decided without running the migration, so its RLS state is NOT confirmed — check the deployed state.`,
|
|
821
|
+
fix: `ALTER TABLE ${s.display} ENABLE ROW LEVEL SECURITY; then add an owner/tenant policy, and drop any permissive "USING (true)" policy (policies are OR-ed). Until policies exist, stop the bleeding without breaking reads: REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${s.display} FROM anon; This is a static hint — confirm the deployed state.`,
|
|
316
822
|
checker: 'rls-migrations',
|
|
317
823
|
level: 0,
|
|
318
824
|
file: s.file,
|
|
319
825
|
line: s.line,
|
|
320
826
|
});
|
|
321
827
|
}
|
|
322
|
-
|
|
828
|
+
const all = [...findings, ...notApplicable];
|
|
829
|
+
if (notUnderstood.length === 0)
|
|
830
|
+
return all;
|
|
831
|
+
// Statements that can change table or RLS state and were not understood are
|
|
832
|
+
// missing coverage. Returning them as `partial` makes the verdict incomplete
|
|
833
|
+
// instead of letting an unknown construct read as clean.
|
|
834
|
+
const shown = notUnderstood.slice(0, 5).join('; ');
|
|
835
|
+
const more = notUnderstood.length > 5 ? ` (+${notUnderstood.length - 5} more)` : '';
|
|
836
|
+
return { findings: all, partial: `${notUnderstood.length} SQL statement(s) affecting table/RLS state could not be interpreted: ${shown}${more}` };
|
|
323
837
|
},
|
|
324
838
|
};
|