auditai-scan 0.6.1 → 0.7.0
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/dist/auditai-scan.mjs +454 -117
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -138,6 +138,9 @@ function where(f) {
|
|
|
138
138
|
for (const e of f.evidence) for (const l of e.locations ?? []) seen.add(`${l.file}:${l.line}`);
|
|
139
139
|
return [...seen].join(", ");
|
|
140
140
|
}
|
|
141
|
+
function fixBody(diff) {
|
|
142
|
+
return diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
|
|
143
|
+
}
|
|
141
144
|
function formatFinding(f) {
|
|
142
145
|
const why = f.evidence.find((e) => e.kind === "rule")?.summary ?? "";
|
|
143
146
|
const lines = [
|
|
@@ -152,6 +155,10 @@ function formatFinding(f) {
|
|
|
152
155
|
const s = [...f.evidence].reverse().find((e) => e.data?.suppressed === true);
|
|
153
156
|
if (s) lines.push(` Ignored ${s.summary}`);
|
|
154
157
|
}
|
|
158
|
+
if (f.fix) {
|
|
159
|
+
lines.push(` Fix ${f.fix.summary} (${f.fix.touchedFiles.join(", ")})`);
|
|
160
|
+
for (const line of fixBody(f.fix.diff)) lines.push(` ${line}`);
|
|
161
|
+
}
|
|
155
162
|
return lines.join("\n");
|
|
156
163
|
}
|
|
157
164
|
function formatScanText(r) {
|
|
@@ -190,6 +197,222 @@ function formatScanText(r) {
|
|
|
190
197
|
// packages/scanner/src/scan.ts
|
|
191
198
|
import { statSync as statSync2 } from "node:fs";
|
|
192
199
|
|
|
200
|
+
// packages/fixes/dist/sql-fixes.js
|
|
201
|
+
var OWNER_COLUMNS = [
|
|
202
|
+
"user_id",
|
|
203
|
+
"owner_id",
|
|
204
|
+
"profile_id",
|
|
205
|
+
"author_id",
|
|
206
|
+
"created_by",
|
|
207
|
+
"account_id",
|
|
208
|
+
"tenant_id",
|
|
209
|
+
"organization_id",
|
|
210
|
+
"org_id",
|
|
211
|
+
"workspace_id",
|
|
212
|
+
"team_id"
|
|
213
|
+
];
|
|
214
|
+
var API_ROLES = "public, anon, authenticated";
|
|
215
|
+
function table(model, name) {
|
|
216
|
+
if (!name)
|
|
217
|
+
return void 0;
|
|
218
|
+
const key = name.toLowerCase();
|
|
219
|
+
return model.tables.find((t) => t.table.toLowerCase() === key);
|
|
220
|
+
}
|
|
221
|
+
function ownerColumn(t) {
|
|
222
|
+
if (!t)
|
|
223
|
+
return null;
|
|
224
|
+
const cols = t.columns.map((c) => c.toLowerCase());
|
|
225
|
+
for (const candidate of OWNER_COLUMNS)
|
|
226
|
+
if (cols.includes(candidate))
|
|
227
|
+
return candidate;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function qualified(name) {
|
|
231
|
+
return name.includes(".") ? name : `public.${name}`;
|
|
232
|
+
}
|
|
233
|
+
function fn(model, name) {
|
|
234
|
+
if (!name)
|
|
235
|
+
return void 0;
|
|
236
|
+
const key = name.toLowerCase();
|
|
237
|
+
return (model.sqlFunctions ?? []).find((f) => f.name.toLowerCase() === key);
|
|
238
|
+
}
|
|
239
|
+
function signatureOf(f) {
|
|
240
|
+
const name = f.name.includes(".") ? f.name : `public.${f.name}`;
|
|
241
|
+
return f.args === void 0 ? `${name}(...)` : `${name}(${f.args})`;
|
|
242
|
+
}
|
|
243
|
+
var HEADER = (title) => `-- ${title}
|
|
244
|
+
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
|
|
245
|
+
`;
|
|
246
|
+
function sqlFunctionFix(finding4, model) {
|
|
247
|
+
const name = finding4.evidence[0]?.data?.function;
|
|
248
|
+
const f = fn(model, typeof name === "string" ? name : void 0);
|
|
249
|
+
if (!f)
|
|
250
|
+
return null;
|
|
251
|
+
const sig = signatureOf(f);
|
|
252
|
+
const ambiguous = sig.endsWith("(...)");
|
|
253
|
+
const body = [
|
|
254
|
+
HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
|
|
255
|
+
ambiguous ? "-- The argument types could not be read from the migrations; put the real signature in\n-- place of (...) before applying. `\\df public.*` in psql prints it.\n" : "",
|
|
256
|
+
`revoke execute on function ${sig} from ${API_ROLES};
|
|
257
|
+
`,
|
|
258
|
+
"-- Leave this line out if nothing calls the function with the service role.\n",
|
|
259
|
+
`grant execute on function ${sig} to service_role;
|
|
260
|
+
`
|
|
261
|
+
].join("");
|
|
262
|
+
return {
|
|
263
|
+
file: `fix_revoke_execute_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
264
|
+
sql: body,
|
|
265
|
+
summary: `Revoke execute on ${f.name} from the API roles`,
|
|
266
|
+
rationale: "The function runs as its creator, so row level security does not apply inside it, and Supabase grants execute on new functions in schema public to anon and authenticated directly. Revoking from PUBLIC alone does not remove those grants, which is why a function that looks locked down is still callable with the public key. If a browser is supposed to call it, do not apply this: add a caller check inside the body instead (`where owner_id = (select auth.uid())`)."
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function enableRlsFix(finding4, model, withPolicy) {
|
|
270
|
+
const name = finding4.evidence[0]?.data?.table;
|
|
271
|
+
if (typeof name !== "string")
|
|
272
|
+
return null;
|
|
273
|
+
const t = table(model, name);
|
|
274
|
+
const full = qualified(name);
|
|
275
|
+
const owner = ownerColumn(t);
|
|
276
|
+
const lines = [HEADER(`Turn on row level security for ${full}`)];
|
|
277
|
+
lines.push(`alter table ${full} enable row level security;
|
|
278
|
+
`);
|
|
279
|
+
if (withPolicy) {
|
|
280
|
+
if (owner) {
|
|
281
|
+
lines.push(`
|
|
282
|
+
create policy "${name}: owner reads" on ${full}
|
|
283
|
+
for select to authenticated
|
|
284
|
+
using (${owner} = (select auth.uid()));
|
|
285
|
+
`, `
|
|
286
|
+
create policy "${name}: owner writes" on ${full}
|
|
287
|
+
for all to authenticated
|
|
288
|
+
using (${owner} = (select auth.uid()))
|
|
289
|
+
with check (${owner} = (select auth.uid()));
|
|
290
|
+
`);
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(`
|
|
293
|
+
-- No column of ${full} ties a row to a person (looked for ${OWNER_COLUMNS.slice(0, 4).join(", ")}\u2026),
|
|
294
|
+
-- so no policy is proposed: with RLS on and no policy the table is readable only with the
|
|
295
|
+
-- service role, which is the safe default. Add a policy once you decide who owns a row.
|
|
296
|
+
`);
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
lines.push(`
|
|
300
|
+
-- The policies this table already has start applying the moment row level security is on;
|
|
301
|
+
-- read them once before applying, because until now they have never run.
|
|
302
|
+
`);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
file: `fix_enable_rls_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
306
|
+
sql: lines.join(""),
|
|
307
|
+
summary: `Enable row level security on ${full}`,
|
|
308
|
+
rationale: withPolicy ? "Without row level security the anon key reads and writes every row of the table through PostgREST. Turning it on denies everything by default, so apply this together with a policy, and check that your own server code uses the service role where it needs full access." : "The table already carries policies, which is what makes this a defect rather than a choice: they have no effect until row level security is on. Read them once before applying \u2014 they have never run, so they may not say what their author believed."
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function anonWriteFix(finding4, model) {
|
|
312
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
313
|
+
const name = data.table;
|
|
314
|
+
const policy = data.policy;
|
|
315
|
+
const command = data.command;
|
|
316
|
+
if (typeof name !== "string" || typeof policy !== "string")
|
|
317
|
+
return null;
|
|
318
|
+
const full = qualified(name);
|
|
319
|
+
const owner = ownerColumn(table(model, name));
|
|
320
|
+
const cmd = typeof command === "string" ? command : "all";
|
|
321
|
+
const safeName = policy.replace(/"/g, '""');
|
|
322
|
+
const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
|
|
323
|
+
if (owner) {
|
|
324
|
+
lines.push(`drop policy "${safeName}" on ${full};
|
|
325
|
+
`, `
|
|
326
|
+
create policy "${safeName}" on ${full}
|
|
327
|
+
for ${cmd} to authenticated
|
|
328
|
+
`, cmd === "insert" ? ` with check (${owner} = (select auth.uid()));
|
|
329
|
+
` : ` using (${owner} = (select auth.uid()))${cmd === "all" ? `
|
|
330
|
+
with check (${owner} = (select auth.uid()))` : ""};
|
|
331
|
+
`);
|
|
332
|
+
} else {
|
|
333
|
+
lines.push(`-- No column of ${full} ties a row to a person, so there is nothing to compare the caller
|
|
334
|
+
-- with. Either add one, or take the policy away and let your server write the table with the
|
|
335
|
+
-- service role after it has checked the caller itself.
|
|
336
|
+
`, `drop policy "${safeName}" on ${full};
|
|
337
|
+
`);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
|
|
341
|
+
sql: lines.join(""),
|
|
342
|
+
summary: `Tie the write policy on ${full} to the caller`,
|
|
343
|
+
rationale: owner ? `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. The replacement keeps the same command and ties the row to the signed-in caller through ${owner}. If this table is meant to accept rows from strangers (a contact form, a newsletter), keep the insert open but give it a predicate on the row's shape and a rate limit.` : `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. Nothing in the table identifies an owner, so the honest fix is to remove the policy and write the table from your server after it has checked the caller.`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function userMetadataFix(finding4) {
|
|
347
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
348
|
+
const name = data.table;
|
|
349
|
+
const policy = data.policy;
|
|
350
|
+
if (typeof name !== "string" || typeof policy !== "string")
|
|
351
|
+
return null;
|
|
352
|
+
const full = qualified(name);
|
|
353
|
+
return {
|
|
354
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
|
|
355
|
+
sql: [
|
|
356
|
+
HEADER(`Stop the policy "${policy}" on ${full} from trusting user_metadata`),
|
|
357
|
+
"-- Re-create the policy with the claim read from app_metadata, which only the service role\n",
|
|
358
|
+
"-- writes. Copy the predicate from your own migration and change the one word: replace\n",
|
|
359
|
+
"-- (select auth.jwt()) -> 'user_metadata' ->> '<claim>'\n",
|
|
360
|
+
"-- with\n",
|
|
361
|
+
"-- (select auth.jwt()) -> 'app_metadata' ->> '<claim>'\n",
|
|
362
|
+
`--
|
|
363
|
+
-- drop policy "${policy.replace(/"/g, '""')}" on ${full};
|
|
364
|
+
`,
|
|
365
|
+
`-- create policy "${policy.replace(/"/g, '""')}" on ${full} ... using (...);
|
|
366
|
+
`,
|
|
367
|
+
"\n-- Then set the claim where the user cannot reach it, from a server with the service role:\n",
|
|
368
|
+
"-- await admin.auth.admin.updateUserById(id, { app_metadata: { is_admin: true } });\n"
|
|
369
|
+
].join(""),
|
|
370
|
+
summary: `Move the claim behind "${policy}" from user_metadata to app_metadata`,
|
|
371
|
+
rationale: "user_metadata is written by the user themselves with supabase.auth.updateUser({ data }), and it is copied into their next access token without review, so a policy that reads it grants itself. app_metadata can only be set with the service role. This fix is a template rather than a finished statement: the predicate belongs to your policy, and moving the claim only helps once something server-side actually sets app_metadata."
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function sqlFixFor(finding4, model) {
|
|
375
|
+
switch (finding4.ruleId) {
|
|
376
|
+
case "supabase.security-definer-function-without-caller-check":
|
|
377
|
+
return sqlFunctionFix(finding4, model);
|
|
378
|
+
case "supabase.table-without-rls":
|
|
379
|
+
return enableRlsFix(finding4, model, true);
|
|
380
|
+
case "supabase.policies-without-rls-enabled":
|
|
381
|
+
return enableRlsFix(finding4, model, false);
|
|
382
|
+
case "supabase.anon-write-policy":
|
|
383
|
+
return anonWriteFix(finding4, model);
|
|
384
|
+
case "supabase.rls-policy-trusts-user-metadata":
|
|
385
|
+
return userMetadataFix(finding4);
|
|
386
|
+
default:
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function addFileDiff(path, body) {
|
|
391
|
+
const lines = body.replace(/\n$/, "").split("\n");
|
|
392
|
+
return [
|
|
393
|
+
`diff --git a/${path} b/${path}`,
|
|
394
|
+
"new file mode 100644",
|
|
395
|
+
"--- /dev/null",
|
|
396
|
+
`+++ b/${path}`,
|
|
397
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
398
|
+
...lines.map((l) => `+${l}`),
|
|
399
|
+
""
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
function deterministicFix(finding4, model, migrationsDir = "supabase/migrations") {
|
|
403
|
+
const fix = sqlFixFor(finding4, model);
|
|
404
|
+
if (!fix)
|
|
405
|
+
return null;
|
|
406
|
+
const stamp = (finding4.createdAt ?? "").replace(/\D/g, "").slice(0, 14) || "00000000000000";
|
|
407
|
+
const path = `${migrationsDir}/${stamp}_${fix.file}`;
|
|
408
|
+
return {
|
|
409
|
+
summary: fix.summary,
|
|
410
|
+
diff: addFileDiff(path, fix.sql),
|
|
411
|
+
touchedFiles: [path],
|
|
412
|
+
rationale: fix.rationale
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
193
416
|
// packages/graph/src/graph.ts
|
|
194
417
|
var SecurityGraph = class {
|
|
195
418
|
nodes = /* @__PURE__ */ new Map();
|
|
@@ -221,10 +444,10 @@ var SecurityGraph = class {
|
|
|
221
444
|
function buildGraph(model) {
|
|
222
445
|
const g = new SecurityGraph();
|
|
223
446
|
const tableInfo = new Map(model.tables.map((t) => [t.table, t]));
|
|
224
|
-
const tableNode = (
|
|
225
|
-
const info = tableInfo.get(
|
|
447
|
+
const tableNode = (table2) => {
|
|
448
|
+
const info = tableInfo.get(table2.toLowerCase());
|
|
226
449
|
const data = {
|
|
227
|
-
table,
|
|
450
|
+
table: table2,
|
|
228
451
|
known: info !== void 0,
|
|
229
452
|
rlsEnabled: info?.rlsEnabled ?? false,
|
|
230
453
|
policies: info?.policies ?? [],
|
|
@@ -233,19 +456,19 @@ function buildGraph(model) {
|
|
|
233
456
|
};
|
|
234
457
|
const node = g.addNode(
|
|
235
458
|
info ? {
|
|
236
|
-
id: `table:${
|
|
459
|
+
id: `table:${table2}`,
|
|
237
460
|
kind: "Table",
|
|
238
|
-
label: `public.${
|
|
461
|
+
label: `public.${table2}`,
|
|
239
462
|
data: { ...data },
|
|
240
463
|
location: info.location
|
|
241
|
-
} : { id: `table:${
|
|
464
|
+
} : { id: `table:${table2}`, kind: "Table", label: `public.${table2}`, data: { ...data } }
|
|
242
465
|
);
|
|
243
466
|
for (const p of data.policies) {
|
|
244
467
|
const pn = g.addNode({
|
|
245
|
-
id: `policy:${
|
|
468
|
+
id: `policy:${table2}:${p}`,
|
|
246
469
|
kind: "RLSPolicy",
|
|
247
470
|
label: p,
|
|
248
|
-
data: { table, name: p }
|
|
471
|
+
data: { table: table2, name: p }
|
|
249
472
|
});
|
|
250
473
|
g.addEdge(node.id, pn.id, "GUARDED_BY");
|
|
251
474
|
}
|
|
@@ -746,11 +969,11 @@ function walkOwn(body, visit) {
|
|
|
746
969
|
};
|
|
747
970
|
go(body);
|
|
748
971
|
}
|
|
749
|
-
function ownReturns(
|
|
750
|
-
if (!
|
|
751
|
-
if (!ts.isBlock(
|
|
972
|
+
function ownReturns(fn2) {
|
|
973
|
+
if (!fn2.body) return [];
|
|
974
|
+
if (!ts.isBlock(fn2.body)) return [fn2.body];
|
|
752
975
|
const out = [];
|
|
753
|
-
walkOwn(
|
|
976
|
+
walkOwn(fn2.body, (n) => {
|
|
754
977
|
if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
|
|
755
978
|
});
|
|
756
979
|
return out;
|
|
@@ -921,10 +1144,10 @@ function growNames(decls, scope) {
|
|
|
921
1144
|
if (scope.names.size === before) break;
|
|
922
1145
|
}
|
|
923
1146
|
}
|
|
924
|
-
function ownDeclarations(
|
|
1147
|
+
function ownDeclarations(fn2) {
|
|
925
1148
|
const out = [];
|
|
926
|
-
if (
|
|
927
|
-
walkOwn(
|
|
1149
|
+
if (fn2.body) {
|
|
1150
|
+
walkOwn(fn2.body, (n) => {
|
|
928
1151
|
if (ts3.isVariableDeclaration(n)) out.push(n);
|
|
929
1152
|
});
|
|
930
1153
|
}
|
|
@@ -953,10 +1176,10 @@ function moduleSecretScope(sf) {
|
|
|
953
1176
|
moduleScopes.set(sf, scope);
|
|
954
1177
|
return scope;
|
|
955
1178
|
}
|
|
956
|
-
function secretScopeFor(
|
|
1179
|
+
function secretScopeFor(fn2, sf) {
|
|
957
1180
|
const mod = moduleSecretScope(sf);
|
|
958
1181
|
const scope = { names: new Set(mod.names), fns: mod.fns };
|
|
959
|
-
growNames(ownDeclarations(
|
|
1182
|
+
growNames(ownDeclarations(fn2), scope);
|
|
960
1183
|
return scope;
|
|
961
1184
|
}
|
|
962
1185
|
var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
|
|
@@ -1077,10 +1300,10 @@ function gatesByThrow(node, fnBody) {
|
|
|
1077
1300
|
}
|
|
1078
1301
|
return false;
|
|
1079
1302
|
}
|
|
1080
|
-
function secretChecksIn(
|
|
1081
|
-
if (!
|
|
1082
|
-
const body =
|
|
1083
|
-
const secrets = secretScopeFor(
|
|
1303
|
+
function secretChecksIn(fn2, sf) {
|
|
1304
|
+
if (!fn2.body) return [];
|
|
1305
|
+
const body = fn2.body;
|
|
1306
|
+
const secrets = secretScopeFor(fn2, sf);
|
|
1084
1307
|
const secretish = (e) => isSecretish(e, secrets);
|
|
1085
1308
|
const builtFromSecret = instancesBuiltFromSecret(body, secretish);
|
|
1086
1309
|
const out = [];
|
|
@@ -1193,8 +1416,8 @@ function mentions(cond, names) {
|
|
|
1193
1416
|
return false;
|
|
1194
1417
|
}
|
|
1195
1418
|
function ifsAfter(node) {
|
|
1196
|
-
const
|
|
1197
|
-
const scope =
|
|
1419
|
+
const fn2 = enclosingFunction(node);
|
|
1420
|
+
const scope = fn2 ? fn2.body : node.getSourceFile();
|
|
1198
1421
|
const out = [];
|
|
1199
1422
|
if (!scope) return out;
|
|
1200
1423
|
walkOwn(scope, (n) => {
|
|
@@ -2461,7 +2684,9 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2461
2684
|
if (!isWord(tk[i], "function")) return;
|
|
2462
2685
|
const q = readQualifiedName(tk, i + 1);
|
|
2463
2686
|
if (!q || !isPunct(tk[q.next], "(")) return;
|
|
2464
|
-
|
|
2687
|
+
const argsEnd = groupEnd(tk, q.next);
|
|
2688
|
+
const args = argumentTypes(stmt, tk, q.next, argsEnd);
|
|
2689
|
+
i = argsEnd + 1;
|
|
2465
2690
|
let securityDefiner = false;
|
|
2466
2691
|
let returns = null;
|
|
2467
2692
|
let body = "";
|
|
@@ -2494,17 +2719,122 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2494
2719
|
}
|
|
2495
2720
|
const key = qualifiedKey(q);
|
|
2496
2721
|
const code = maskSqlComments(body);
|
|
2497
|
-
const
|
|
2722
|
+
const fn2 = {
|
|
2498
2723
|
schema: q.schema,
|
|
2499
2724
|
name: q.name,
|
|
2500
2725
|
securityDefiner,
|
|
2501
2726
|
returns,
|
|
2727
|
+
args,
|
|
2502
2728
|
body: code,
|
|
2503
2729
|
directCheck: CALLER_CHECKS.some((re) => re.test(code)),
|
|
2504
2730
|
acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
|
|
2505
2731
|
location: { file, line: stmt.line }
|
|
2506
2732
|
};
|
|
2507
|
-
reg.byKey.set(key,
|
|
2733
|
+
reg.byKey.set(key, fn2);
|
|
2734
|
+
}
|
|
2735
|
+
var TYPE_WORD = /* @__PURE__ */ new Set([
|
|
2736
|
+
"anyarray",
|
|
2737
|
+
"anyelement",
|
|
2738
|
+
"bigint",
|
|
2739
|
+
"bigserial",
|
|
2740
|
+
"bit",
|
|
2741
|
+
"bool",
|
|
2742
|
+
"boolean",
|
|
2743
|
+
"box",
|
|
2744
|
+
"bytea",
|
|
2745
|
+
"char",
|
|
2746
|
+
"character",
|
|
2747
|
+
"cidr",
|
|
2748
|
+
"circle",
|
|
2749
|
+
"date",
|
|
2750
|
+
"decimal",
|
|
2751
|
+
"double",
|
|
2752
|
+
"float",
|
|
2753
|
+
"float4",
|
|
2754
|
+
"float8",
|
|
2755
|
+
"inet",
|
|
2756
|
+
"int",
|
|
2757
|
+
"int2",
|
|
2758
|
+
"int4",
|
|
2759
|
+
"int8",
|
|
2760
|
+
"integer",
|
|
2761
|
+
"interval",
|
|
2762
|
+
"json",
|
|
2763
|
+
"jsonb",
|
|
2764
|
+
"line",
|
|
2765
|
+
"lseg",
|
|
2766
|
+
"macaddr",
|
|
2767
|
+
"money",
|
|
2768
|
+
"name",
|
|
2769
|
+
"numeric",
|
|
2770
|
+
"oid",
|
|
2771
|
+
"path",
|
|
2772
|
+
"point",
|
|
2773
|
+
"polygon",
|
|
2774
|
+
"real",
|
|
2775
|
+
"record",
|
|
2776
|
+
"regclass",
|
|
2777
|
+
"serial",
|
|
2778
|
+
"smallint",
|
|
2779
|
+
"smallserial",
|
|
2780
|
+
"text",
|
|
2781
|
+
"time",
|
|
2782
|
+
"timestamp",
|
|
2783
|
+
"timestamptz",
|
|
2784
|
+
"timetz",
|
|
2785
|
+
"trigger",
|
|
2786
|
+
"tsquery",
|
|
2787
|
+
"tsvector",
|
|
2788
|
+
"uuid",
|
|
2789
|
+
"varbit",
|
|
2790
|
+
"varchar",
|
|
2791
|
+
"void",
|
|
2792
|
+
"xml"
|
|
2793
|
+
]);
|
|
2794
|
+
function argumentTypes(stmt, tokens, open, close) {
|
|
2795
|
+
if (close <= open + 1) return "";
|
|
2796
|
+
const parts = [];
|
|
2797
|
+
let depth = 0;
|
|
2798
|
+
let start = open + 1;
|
|
2799
|
+
const pieces = [];
|
|
2800
|
+
for (let i = open + 1; i < close; i++) {
|
|
2801
|
+
const t = tokens[i];
|
|
2802
|
+
if (isPunct(t, "(") || isPunct(t, "[")) depth += 1;
|
|
2803
|
+
else if (isPunct(t, ")") || isPunct(t, "]")) depth -= 1;
|
|
2804
|
+
else if (depth === 0 && isPunct(t, ",")) {
|
|
2805
|
+
pieces.push([start, i]);
|
|
2806
|
+
start = i + 1;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
pieces.push([start, close]);
|
|
2810
|
+
for (const [from, to] of pieces) {
|
|
2811
|
+
const words = [];
|
|
2812
|
+
for (let i = from; i < to; i++) {
|
|
2813
|
+
const t = tokens[i];
|
|
2814
|
+
if (!t) continue;
|
|
2815
|
+
if (isWord(t, "default")) break;
|
|
2816
|
+
if (t.kind === "punct" && t.value === "=") break;
|
|
2817
|
+
words.push(t);
|
|
2818
|
+
}
|
|
2819
|
+
if (words.length === 0) return null;
|
|
2820
|
+
let k = 0;
|
|
2821
|
+
if (isWord(words[k], "in") || isWord(words[k], "out") || isWord(words[k], "inout")) {
|
|
2822
|
+
if (isWord(words[k], "out")) continue;
|
|
2823
|
+
k += 1;
|
|
2824
|
+
} else if (isWord(words[k], "variadic")) {
|
|
2825
|
+
k += 1;
|
|
2826
|
+
}
|
|
2827
|
+
const firstWord = words[k];
|
|
2828
|
+
const named = firstWord !== void 0 && firstWord.kind === "word" && !TYPE_WORD.has(firstWord.value.toLowerCase()) && words.slice(k + 1).some((w) => w.kind === "word");
|
|
2829
|
+
const typeStart = named ? k + 1 : k;
|
|
2830
|
+
const first = words[typeStart];
|
|
2831
|
+
const last = words[words.length - 1];
|
|
2832
|
+
if (!first || !last) return null;
|
|
2833
|
+
parts.push(
|
|
2834
|
+
stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return parts.join(", ");
|
|
2508
2838
|
}
|
|
2509
2839
|
function readFunctionList(tokens, from) {
|
|
2510
2840
|
const keys = [];
|
|
@@ -2530,28 +2860,28 @@ function applyAlterFunction(reg, stmt) {
|
|
|
2530
2860
|
const tk = stmt.tokens;
|
|
2531
2861
|
const list = readFunctionList(tk, 2);
|
|
2532
2862
|
const key = list.keys[0];
|
|
2533
|
-
const
|
|
2534
|
-
if (!
|
|
2863
|
+
const fn2 = key === void 0 ? void 0 : reg.byKey.get(key);
|
|
2864
|
+
if (!fn2 || key === void 0) return;
|
|
2535
2865
|
let i = list.next;
|
|
2536
2866
|
if (isWord(tk[i], "external")) i += 1;
|
|
2537
2867
|
if (isWord(tk[i], "security")) {
|
|
2538
|
-
if (isWord(tk[i + 1], "definer"))
|
|
2539
|
-
else if (isWord(tk[i + 1], "invoker"))
|
|
2868
|
+
if (isWord(tk[i + 1], "definer")) fn2.securityDefiner = true;
|
|
2869
|
+
else if (isWord(tk[i + 1], "invoker")) fn2.securityDefiner = false;
|
|
2540
2870
|
return;
|
|
2541
2871
|
}
|
|
2542
2872
|
let moved = null;
|
|
2543
2873
|
if (isWord(tk[i], "rename") && isWord(tk[i + 1], "to")) {
|
|
2544
2874
|
const name = identOf(tk[i + 2]);
|
|
2545
|
-
if (name) moved = { schema:
|
|
2875
|
+
if (name) moved = { schema: fn2.schema, name, next: 0 };
|
|
2546
2876
|
} else if (isWord(tk[i], "set") && isWord(tk[i + 1], "schema")) {
|
|
2547
2877
|
const schema = identOf(tk[i + 2]);
|
|
2548
|
-
if (schema) moved = { schema, name:
|
|
2878
|
+
if (schema) moved = { schema, name: fn2.name, next: 0 };
|
|
2549
2879
|
}
|
|
2550
2880
|
if (!moved) return;
|
|
2551
2881
|
reg.byKey.delete(key);
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
reg.byKey.set(qualifiedKey(moved),
|
|
2882
|
+
fn2.schema = moved.schema;
|
|
2883
|
+
fn2.name = moved.name;
|
|
2884
|
+
reg.byKey.set(qualifiedKey(moved), fn2);
|
|
2555
2885
|
}
|
|
2556
2886
|
function readRoles(tokens, from) {
|
|
2557
2887
|
const roles = [];
|
|
@@ -2606,8 +2936,8 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2606
2936
|
if (isWord(tk[i], "function") || isWord(tk[i], "routine")) {
|
|
2607
2937
|
const list = readFunctionList(tk, i + 1);
|
|
2608
2938
|
targets = list.keys.flatMap((k) => {
|
|
2609
|
-
const
|
|
2610
|
-
return
|
|
2939
|
+
const fn2 = reg.byKey.get(k);
|
|
2940
|
+
return fn2 ? [fn2] : [];
|
|
2611
2941
|
});
|
|
2612
2942
|
i = list.next;
|
|
2613
2943
|
} else if (isWord(tk[i], "all") && (isWord(tk[i + 1], "functions") || isWord(tk[i + 1], "routines")) && isWord(tk[i + 2], "in") && isWord(tk[i + 3], "schema")) {
|
|
@@ -2622,7 +2952,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2622
2952
|
i += 1;
|
|
2623
2953
|
}
|
|
2624
2954
|
targets = [...reg.byKey.values()].filter(
|
|
2625
|
-
(
|
|
2955
|
+
(fn2) => schemas.includes((fn2.schema ?? "public").toLowerCase())
|
|
2626
2956
|
);
|
|
2627
2957
|
} else {
|
|
2628
2958
|
return;
|
|
@@ -2630,7 +2960,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2630
2960
|
const [start, end] = roleClause(tk, i, grant ? "to" : "from");
|
|
2631
2961
|
if (start < 0) return;
|
|
2632
2962
|
const roles = readRoles(tk.slice(0, end), start);
|
|
2633
|
-
for (const
|
|
2963
|
+
for (const fn2 of targets) applyToAcl(fn2.acl, grant, roles);
|
|
2634
2964
|
}
|
|
2635
2965
|
function applyDefaultPrivileges(reg, stmt) {
|
|
2636
2966
|
const tk = stmt.tokens;
|
|
@@ -2727,6 +3057,7 @@ function finishFunctions(reg) {
|
|
|
2727
3057
|
location: f.location
|
|
2728
3058
|
};
|
|
2729
3059
|
if (f.returns !== null) info.returns = f.returns;
|
|
3060
|
+
if (f.args !== null) info.args = f.args;
|
|
2730
3061
|
return info;
|
|
2731
3062
|
});
|
|
2732
3063
|
}
|
|
@@ -2926,9 +3257,9 @@ function sync(state, key) {
|
|
|
2926
3257
|
function findColumn(meta, name) {
|
|
2927
3258
|
return meta.columns.find((c) => c.name === name);
|
|
2928
3259
|
}
|
|
2929
|
-
function pkOf(state,
|
|
2930
|
-
if (
|
|
2931
|
-
return state.meta.get(
|
|
3260
|
+
function pkOf(state, table2) {
|
|
3261
|
+
if (table2 === "auth.users") return ["id"];
|
|
3262
|
+
return state.meta.get(table2)?.pk ?? [];
|
|
2932
3263
|
}
|
|
2933
3264
|
function resolveRefs(state, meta) {
|
|
2934
3265
|
for (const col of meta.columns) {
|
|
@@ -2937,12 +3268,12 @@ function resolveRefs(state, meta) {
|
|
|
2937
3268
|
}
|
|
2938
3269
|
}
|
|
2939
3270
|
}
|
|
2940
|
-
function clearRefsTo(state,
|
|
3271
|
+
function clearRefsTo(state, table2, column) {
|
|
2941
3272
|
for (const [key, meta] of state.meta) {
|
|
2942
3273
|
let touched = false;
|
|
2943
3274
|
for (const col of meta.columns) {
|
|
2944
3275
|
const r = col.references;
|
|
2945
|
-
if (!r || r.table !==
|
|
3276
|
+
if (!r || r.table !== table2 || column !== void 0 && r.column !== column) continue;
|
|
2946
3277
|
col.references = null;
|
|
2947
3278
|
col.fkName = null;
|
|
2948
3279
|
touched = true;
|
|
@@ -3792,9 +4123,9 @@ function analyzeModule(rel, sf) {
|
|
|
3792
4123
|
if (!ts9.isCallExpression(init) && !ts9.isNewExpression(init)) continue;
|
|
3793
4124
|
if (ts9.isCallExpression(init)) {
|
|
3794
4125
|
const callee = init.expression;
|
|
3795
|
-
const
|
|
4126
|
+
const fn2 = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
|
|
3796
4127
|
const tableName = stringLiteralValue(init.arguments[0]);
|
|
3797
|
-
if ((DRIZZLE_TABLE_FNS.has(
|
|
4128
|
+
if ((DRIZZLE_TABLE_FNS.has(fn2) || fn2 === "table") && tableName !== null) {
|
|
3798
4129
|
drizzleTables.set(d.name.text, tableName);
|
|
3799
4130
|
continue;
|
|
3800
4131
|
}
|
|
@@ -3996,12 +4327,12 @@ var NO_ARG = {
|
|
|
3996
4327
|
whole: false,
|
|
3997
4328
|
isRequest: false
|
|
3998
4329
|
};
|
|
3999
|
-
function ownFunctionSym(facts, name,
|
|
4330
|
+
function ownFunctionSym(facts, name, fn2) {
|
|
4000
4331
|
const factory = facts.clientFactories.find((c) => c.name === name);
|
|
4001
4332
|
if (factory) return { kind: "factory", factory };
|
|
4002
4333
|
const helper = facts.authHelpers.find((a) => a.name === name);
|
|
4003
|
-
if (helper) return { kind: "auth", helper, fn, facts };
|
|
4004
|
-
return { kind: "function", name, fn, facts };
|
|
4334
|
+
if (helper) return { kind: "auth", helper, fn: fn2, facts };
|
|
4335
|
+
return { kind: "function", name, fn: fn2, facts };
|
|
4005
4336
|
}
|
|
4006
4337
|
function exportedSym(p, tf, name, depth) {
|
|
4007
4338
|
if (depth > 5) return null;
|
|
@@ -4048,7 +4379,7 @@ function scopeOf(p, facts) {
|
|
|
4048
4379
|
for (const [name, v] of facts.moduleVars) {
|
|
4049
4380
|
scope.set(name, { kind: "var", name, init: v.init, facts });
|
|
4050
4381
|
}
|
|
4051
|
-
for (const [name,
|
|
4382
|
+
for (const [name, table2] of facts.drizzleTables) scope.set(name, { kind: "table", table: table2 });
|
|
4052
4383
|
for (const [name, v] of facts.authVars) scope.set(name, { kind: "auth", helper: v.helper });
|
|
4053
4384
|
for (const [local, ref] of facts.imports) {
|
|
4054
4385
|
const target = p.resolver.resolve(ref.spec, facts.file);
|
|
@@ -4074,9 +4405,9 @@ function scopeOf(p, facts) {
|
|
|
4074
4405
|
}
|
|
4075
4406
|
if (!/^[.~]|^@\//.test(ref.spec)) continue;
|
|
4076
4407
|
for (const f of p.registry.values()) {
|
|
4077
|
-
const
|
|
4078
|
-
if (!
|
|
4079
|
-
const sym = ownFunctionSym(f, ref.imported,
|
|
4408
|
+
const fn2 = f.functions.get(ref.imported);
|
|
4409
|
+
if (!fn2?.exported) continue;
|
|
4410
|
+
const sym = ownFunctionSym(f, ref.imported, fn2.fn);
|
|
4080
4411
|
if (sym.kind === "factory" || sym.kind === "auth") {
|
|
4081
4412
|
scope.set(local, sym);
|
|
4082
4413
|
p.warnings.push(
|
|
@@ -4096,10 +4427,10 @@ function symOfCallee(p, callee, scope) {
|
|
|
4096
4427
|
}
|
|
4097
4428
|
return void 0;
|
|
4098
4429
|
}
|
|
4099
|
-
function returnedExpressions(
|
|
4100
|
-
if (!
|
|
4101
|
-
if (!ts11.isBlock(
|
|
4102
|
-
return collect(
|
|
4430
|
+
function returnedExpressions(fn2) {
|
|
4431
|
+
if (!fn2.body) return [];
|
|
4432
|
+
if (!ts11.isBlock(fn2.body)) return [fn2.body];
|
|
4433
|
+
return collect(fn2.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
|
|
4103
4434
|
}
|
|
4104
4435
|
function factoryOfFunction(p, sym, depth) {
|
|
4105
4436
|
const key = `${sym.facts.file}#${sym.name}`;
|
|
@@ -4477,10 +4808,10 @@ function callTarget(p, call, frame, scope) {
|
|
|
4477
4808
|
return null;
|
|
4478
4809
|
}
|
|
4479
4810
|
function bindDeclarations(p, frame, acc) {
|
|
4480
|
-
const { rel, sf, fn } = frame;
|
|
4811
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4481
4812
|
const scope = scopeOf(p, frame.facts);
|
|
4482
4813
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4483
|
-
const body =
|
|
4814
|
+
const body = fn2.body ?? fn2;
|
|
4484
4815
|
for (const [name, sym] of scope) {
|
|
4485
4816
|
if (sym.kind !== "var" || frame.clients.has(name) || frame.instances.has(name)) continue;
|
|
4486
4817
|
const vb = varBinding(p, sym);
|
|
@@ -4605,10 +4936,10 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4605
4936
|
}
|
|
4606
4937
|
}
|
|
4607
4938
|
function analyzeFrame(p, frame, acc) {
|
|
4608
|
-
const { rel, sf, fn } = frame;
|
|
4939
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4609
4940
|
const scope = scopeOf(p, frame.facts);
|
|
4610
4941
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4611
|
-
const body =
|
|
4942
|
+
const body = fn2.body ?? fn2;
|
|
4612
4943
|
bindDeclarations(p, frame, acc);
|
|
4613
4944
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
4614
4945
|
const callee = call.expression;
|
|
@@ -4616,8 +4947,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4616
4947
|
continue;
|
|
4617
4948
|
if (!ts11.isIdentifier(callee.expression)) continue;
|
|
4618
4949
|
const outer = frame.clients.get(callee.expression.text);
|
|
4619
|
-
const
|
|
4620
|
-
const param =
|
|
4950
|
+
const fn3 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
|
|
4951
|
+
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
4621
4952
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
4622
4953
|
}
|
|
4623
4954
|
const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
|
|
@@ -4632,7 +4963,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4632
4963
|
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
|
|
4633
4964
|
});
|
|
4634
4965
|
}
|
|
4635
|
-
for (const check of secretChecksIn(
|
|
4966
|
+
for (const check of secretChecksIn(fn2, sf)) {
|
|
4636
4967
|
acc.authChecks.push({ ...loc2(check.node), kind: "secret" });
|
|
4637
4968
|
}
|
|
4638
4969
|
for (const pa of collect(body, ts11.isPropertyAccessExpression)) {
|
|
@@ -5137,16 +5468,16 @@ function enclosingCondition(node, body) {
|
|
|
5137
5468
|
return null;
|
|
5138
5469
|
}
|
|
5139
5470
|
var normalizeColumn = (c) => (c ?? "").toLowerCase().replace(/_/g, "");
|
|
5140
|
-
function singular(
|
|
5141
|
-
if (
|
|
5142
|
-
if (
|
|
5143
|
-
return
|
|
5471
|
+
function singular(table2) {
|
|
5472
|
+
if (table2.endsWith("ies")) return `${table2.slice(0, -3)}y`;
|
|
5473
|
+
if (table2.endsWith("ses") || table2.endsWith("xes")) return table2.slice(0, -2);
|
|
5474
|
+
return table2.endsWith("s") ? table2.slice(0, -1) : table2;
|
|
5144
5475
|
}
|
|
5145
|
-
function columnNamesTable(column,
|
|
5476
|
+
function columnNamesTable(column, table2) {
|
|
5146
5477
|
const c = normalizeColumn(column);
|
|
5147
5478
|
if (!c.endsWith("id") || c === "id") return false;
|
|
5148
5479
|
const stem = c.slice(0, -2);
|
|
5149
|
-
const t = normalizeColumn(
|
|
5480
|
+
const t = normalizeColumn(table2);
|
|
5150
5481
|
return stem === t || stem === singular(t);
|
|
5151
5482
|
}
|
|
5152
5483
|
function guardMatch(q, g, tables) {
|
|
@@ -5256,8 +5587,8 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5256
5587
|
const sf = p.sources.get(rel);
|
|
5257
5588
|
const facts = p.registry.get(rel);
|
|
5258
5589
|
if (!sf || !facts || isClientComponentFile(sf)) continue;
|
|
5259
|
-
const
|
|
5260
|
-
if (!
|
|
5590
|
+
const fn2 = pageHandlerIn(sf);
|
|
5591
|
+
if (!fn2) continue;
|
|
5261
5592
|
try {
|
|
5262
5593
|
const analysed = analyzeHandler(p, {
|
|
5263
5594
|
rel,
|
|
@@ -5266,9 +5597,9 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5266
5597
|
kind: "page",
|
|
5267
5598
|
route: dir,
|
|
5268
5599
|
method: "PAGE",
|
|
5269
|
-
fn:
|
|
5270
|
-
node:
|
|
5271
|
-
wrapper:
|
|
5600
|
+
fn: fn2.fn,
|
|
5601
|
+
node: fn2.node,
|
|
5602
|
+
wrapper: fn2.wrapper
|
|
5272
5603
|
});
|
|
5273
5604
|
guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
|
|
5274
5605
|
} catch {
|
|
@@ -5280,7 +5611,7 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5280
5611
|
return guards;
|
|
5281
5612
|
}
|
|
5282
5613
|
function analyzeHandler(p, h) {
|
|
5283
|
-
const { rel, sf, fn } = h;
|
|
5614
|
+
const { rel, sf, fn: fn2 } = h;
|
|
5284
5615
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
5285
5616
|
const acc = {
|
|
5286
5617
|
inputs: [],
|
|
@@ -5295,7 +5626,7 @@ function analyzeHandler(p, h) {
|
|
|
5295
5626
|
rel,
|
|
5296
5627
|
sf,
|
|
5297
5628
|
facts: h.facts,
|
|
5298
|
-
fn,
|
|
5629
|
+
fn: fn2,
|
|
5299
5630
|
depth: 0,
|
|
5300
5631
|
via: [],
|
|
5301
5632
|
inputNames: /* @__PURE__ */ new Set(["params", "searchParams"]),
|
|
@@ -5313,13 +5644,13 @@ function analyzeHandler(p, h) {
|
|
|
5313
5644
|
pathPos: [],
|
|
5314
5645
|
exitPropagates: true
|
|
5315
5646
|
};
|
|
5316
|
-
const first =
|
|
5647
|
+
const first = fn2.parameters[0];
|
|
5317
5648
|
if (h.kind === "route" && first) {
|
|
5318
5649
|
if (ts11.isIdentifier(first.name)) frame.reqNames.add(first.name.text);
|
|
5319
5650
|
else for (const nm of boundNames(first.name)) if (REQUEST_NAME.test(nm)) frame.reqNames.add(nm);
|
|
5320
5651
|
}
|
|
5321
5652
|
if (h.kind === "server_action") {
|
|
5322
|
-
const params = h.wrapper ?
|
|
5653
|
+
const params = h.wrapper ? fn2.parameters.slice(0, 1) : fn2.parameters;
|
|
5323
5654
|
for (const prm of params) {
|
|
5324
5655
|
for (const nm of boundNames(prm.name)) {
|
|
5325
5656
|
if (!acc.inputs.some((i) => i.kind === "action_arg" && i.name === nm)) {
|
|
@@ -5578,7 +5909,7 @@ function callerCheckingFunctions(model) {
|
|
|
5578
5909
|
);
|
|
5579
5910
|
}
|
|
5580
5911
|
var CALL2 = /(?:"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\(/g;
|
|
5581
|
-
function
|
|
5912
|
+
function qualified2(schema, name) {
|
|
5582
5913
|
const s = (schema ?? "public").toLowerCase();
|
|
5583
5914
|
const n = (name ?? "").toLowerCase();
|
|
5584
5915
|
return s === "public" ? n : `${s}.${n}`;
|
|
@@ -5586,7 +5917,7 @@ function qualified(schema, name) {
|
|
|
5586
5917
|
function callsFunctionIn(expr, names) {
|
|
5587
5918
|
if (names.size === 0) return false;
|
|
5588
5919
|
for (const c of expr.replace(/'(?:[^']|'')*'/g, "''").matchAll(CALL2)) {
|
|
5589
|
-
if (names.has(
|
|
5920
|
+
if (names.has(qualified2(c[1], c[2]))) return true;
|
|
5590
5921
|
}
|
|
5591
5922
|
return false;
|
|
5592
5923
|
}
|
|
@@ -5654,14 +5985,14 @@ function handlerViews(ctx) {
|
|
|
5654
5985
|
function queryViews(ctx, handler) {
|
|
5655
5986
|
return ctx.graph.out(handler.id, "CALLS").map((query) => {
|
|
5656
5987
|
const client = ctx.graph.out(query.id, "USES_CLIENT")[0];
|
|
5657
|
-
const
|
|
5988
|
+
const table2 = ctx.graph.out(query.id, "TARGETS")[0];
|
|
5658
5989
|
return {
|
|
5659
5990
|
query,
|
|
5660
5991
|
data: query.data,
|
|
5661
5992
|
client,
|
|
5662
5993
|
clientData: client?.data,
|
|
5663
|
-
table,
|
|
5664
|
-
tableData:
|
|
5994
|
+
table: table2,
|
|
5995
|
+
tableData: table2?.data
|
|
5665
5996
|
};
|
|
5666
5997
|
});
|
|
5667
5998
|
}
|
|
@@ -5696,16 +6027,16 @@ function anonReadPolicy(t) {
|
|
|
5696
6027
|
(p) => (p.command === "select" || p.command === "all") && (p.using ?? "").replace(/[\s()]/g, "").toLowerCase() === "true" && (p.roles.length === 0 || p.roles.some((r) => r === "anon" || r === "public"))
|
|
5697
6028
|
);
|
|
5698
6029
|
}
|
|
5699
|
-
function publicReadNote(p,
|
|
5700
|
-
return ` public.${
|
|
6030
|
+
function publicReadNote(p, table2) {
|
|
6031
|
+
return ` public.${table2} is readable by the anon role through RLS policy "${p.name}" (${p.location.file}:${p.location.line}), so this read leaks nothing beyond what the anon key already returns; the repository may not have intended that policy, so the finding stays at medium.`;
|
|
5701
6032
|
}
|
|
5702
6033
|
function adminOnly(ctx, h, t) {
|
|
5703
6034
|
const check = h.roleChecks[0];
|
|
5704
6035
|
if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
|
|
5705
6036
|
return check;
|
|
5706
6037
|
}
|
|
5707
|
-
function singleTenantTable(ctx,
|
|
5708
|
-
const info = ctx.model.tables.find((x) => x.table ===
|
|
6038
|
+
function singleTenantTable(ctx, table2) {
|
|
6039
|
+
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
5709
6040
|
if (!info || info.columns.some((c) => isScopeColumn(c))) return false;
|
|
5710
6041
|
for (const col of info.columnInfo ?? []) {
|
|
5711
6042
|
if (!col.references || col.nullable) continue;
|
|
@@ -5714,16 +6045,16 @@ function singleTenantTable(ctx, table) {
|
|
|
5714
6045
|
}
|
|
5715
6046
|
return true;
|
|
5716
6047
|
}
|
|
5717
|
-
function adminOnlyNote(check,
|
|
5718
|
-
return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${
|
|
6048
|
+
function adminOnlyNote(check, table2) {
|
|
6049
|
+
return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${table2} has no tenant or owner column, so the row is shared site content rather than a tenant's; verify the admin check cannot be self-granted.`;
|
|
5719
6050
|
}
|
|
5720
|
-
function tableDataOf(ctx,
|
|
5721
|
-
return ctx.graph.nodes.get(`table:${
|
|
6051
|
+
function tableDataOf(ctx, table2) {
|
|
6052
|
+
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
5722
6053
|
}
|
|
5723
6054
|
function callerCheck(checks) {
|
|
5724
6055
|
return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
|
|
5725
6056
|
}
|
|
5726
|
-
function guardTiesRowToCaller(guard,
|
|
6057
|
+
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
5727
6058
|
const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
|
|
5728
6059
|
if (callerFilter) {
|
|
5729
6060
|
return {
|
|
@@ -5747,10 +6078,10 @@ function guardTiesRowToCaller(guard, table, callerFns) {
|
|
|
5747
6078
|
why: `the read runs with a ${guard.client === "anon" ? "public anon" : "privileged"} client and filters by no owner column, so it returns the row for anyone`
|
|
5748
6079
|
};
|
|
5749
6080
|
}
|
|
5750
|
-
const reads = (
|
|
6081
|
+
const reads = (table2?.policyDetails ?? []).filter(
|
|
5751
6082
|
(p) => p.command === "select" || p.command === "all"
|
|
5752
6083
|
);
|
|
5753
|
-
const scoped =
|
|
6084
|
+
const scoped = table2?.known === true && table2.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
|
|
5754
6085
|
return {
|
|
5755
6086
|
tied: scoped,
|
|
5756
6087
|
how: `through ${guard.clientName ?? "a user-scoped client"}, so RLS applied`,
|
|
@@ -5868,7 +6199,7 @@ var tableWithoutRls = {
|
|
|
5868
6199
|
],
|
|
5869
6200
|
summary: `public.${t.table} has no "enable row level security" in migrations but is queried with a ${c.kind} client. Anyone holding the public anon key can read every row directly through PostgREST.`,
|
|
5870
6201
|
title: `Table "${t.table}" is exposed without RLS`,
|
|
5871
|
-
data: { deterministic: true, ruleId: this.id },
|
|
6202
|
+
data: { deterministic: true, ruleId: this.id, table: t.table },
|
|
5872
6203
|
tail: locations(v.table?.location)
|
|
5873
6204
|
}));
|
|
5874
6205
|
addReach(g, h, v, `supabase.${v.data.operation}:public.${t.table}`);
|
|
@@ -6712,7 +7043,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6712
7043
|
return out;
|
|
6713
7044
|
}
|
|
6714
7045
|
};
|
|
6715
|
-
var
|
|
7046
|
+
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
6716
7047
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
6717
7048
|
id: "supabase.security-definer-function-without-caller-check",
|
|
6718
7049
|
title: "SECURITY DEFINER function without a caller check",
|
|
@@ -6729,22 +7060,22 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6729
7060
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
6730
7061
|
}
|
|
6731
7062
|
const out = [];
|
|
6732
|
-
for (const
|
|
6733
|
-
if (!
|
|
6734
|
-
if (
|
|
6735
|
-
if (
|
|
6736
|
-
const roles =
|
|
7063
|
+
for (const fn2 of fns) {
|
|
7064
|
+
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
7065
|
+
if (fn2.name.includes(".")) continue;
|
|
7066
|
+
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7067
|
+
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
6737
7068
|
if (roles.length === 0) continue;
|
|
6738
7069
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
6739
|
-
const sites = rpcByName.get(
|
|
7070
|
+
const sites = rpcByName.get(fn2.name) ?? [];
|
|
6740
7071
|
const entries = unique(sites.map((s) => s.handlerData.entry));
|
|
6741
|
-
const endpoint = `POST /rest/v1/rpc/${
|
|
7072
|
+
const endpoint = `POST /rest/v1/rpc/${fn2.name}`;
|
|
6742
7073
|
const who = anonymous ? "Anonymous visitors can call it with the public anon key" : "Any signed-in user can call it";
|
|
6743
|
-
const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${
|
|
7074
|
+
const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${fn2.name}") from ${entries.join(", ")}.` : "";
|
|
6744
7075
|
const path = [
|
|
6745
7076
|
entries[0] ?? endpoint,
|
|
6746
|
-
`supabase.rpc("${
|
|
6747
|
-
`public.${
|
|
7077
|
+
`supabase.rpc("${fn2.name}") (EXECUTE: ${roles.join(", ")})`,
|
|
7078
|
+
`public.${fn2.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
|
|
6748
7079
|
"no auth.uid() / auth.jwt() check"
|
|
6749
7080
|
];
|
|
6750
7081
|
out.push(
|
|
@@ -6752,27 +7083,27 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6752
7083
|
ctx,
|
|
6753
7084
|
this,
|
|
6754
7085
|
{
|
|
6755
|
-
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${
|
|
7086
|
+
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${fn2.name}" without a caller check`,
|
|
6756
7087
|
entrypoints: [...entries, endpoint],
|
|
6757
7088
|
sources: unique([
|
|
6758
7089
|
...sites.flatMap((s) => s.inputs.map((i) => `${i.kind}:${i.name}`)),
|
|
6759
7090
|
"rpc arguments"
|
|
6760
7091
|
]),
|
|
6761
|
-
sinks: [`postgres.function:public.${
|
|
7092
|
+
sinks: [`postgres.function:public.${fn2.name}`],
|
|
6762
7093
|
path,
|
|
6763
7094
|
evidence: [
|
|
6764
7095
|
{
|
|
6765
7096
|
kind: "rule",
|
|
6766
|
-
summary: `public.${
|
|
7097
|
+
summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
|
|
6767
7098
|
locations: locations3(
|
|
6768
|
-
|
|
7099
|
+
fn2.location,
|
|
6769
7100
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
6770
7101
|
),
|
|
6771
7102
|
data: {
|
|
6772
7103
|
deterministic: false,
|
|
6773
7104
|
ruleId: this.id,
|
|
6774
|
-
function:
|
|
6775
|
-
grantedTo: [...
|
|
7105
|
+
function: fn2.name,
|
|
7106
|
+
grantedTo: [...fn2.grantedTo]
|
|
6776
7107
|
}
|
|
6777
7108
|
},
|
|
6778
7109
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
@@ -6825,9 +7156,9 @@ function applyPublicTables(findings, publicTables2, now) {
|
|
|
6825
7156
|
if (f.sinks.length === 0) return f;
|
|
6826
7157
|
const tables = [];
|
|
6827
7158
|
for (const sink of f.sinks) {
|
|
6828
|
-
const
|
|
6829
|
-
if (
|
|
6830
|
-
if (!tables.includes(
|
|
7159
|
+
const table2 = READ_SINK.exec(sink)?.[1];
|
|
7160
|
+
if (table2 === void 0 || !declared.has(table2.toLowerCase())) return f;
|
|
7161
|
+
if (!tables.includes(table2)) tables.push(table2);
|
|
6831
7162
|
}
|
|
6832
7163
|
const command = f.evidence[0]?.data?.command;
|
|
6833
7164
|
if (f.ruleId === "supabase.rls-policy-without-caller-predicate" && command !== "select") {
|
|
@@ -7050,7 +7381,13 @@ function runScan(path, opts = {}) {
|
|
|
7050
7381
|
const graph = buildGraph(model);
|
|
7051
7382
|
const publicTables2 = cfg.config.publicTables ?? [];
|
|
7052
7383
|
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
|
|
7053
|
-
const
|
|
7384
|
+
const rulesFindings = runRules(defaultRules, model, graph, runOpts);
|
|
7385
|
+
const migrationsDir = repoDirs.dirs[0] ?? "supabase/migrations";
|
|
7386
|
+
const findings = rulesFindings.map((f) => {
|
|
7387
|
+
if (f.status === "suppressed") return f;
|
|
7388
|
+
const fix = deterministicFix(f, model, migrationsDir);
|
|
7389
|
+
return fix ? { ...f, fix } : f;
|
|
7390
|
+
});
|
|
7054
7391
|
const coverage = summarizeCoverage(findings);
|
|
7055
7392
|
const summary = summarize(model, defaultRules.length, publicTables2);
|
|
7056
7393
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, service-role misuse, mass assignment. No account, no model, seconds.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|