auditai-scan 0.6.1 → 0.7.1
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 +478 -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,246 @@ 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 PERSON_COLUMNS = ["user_id", "owner_id", "profile_id", "author_id", "created_by"];
|
|
202
|
+
var TENANT_COLUMNS = [
|
|
203
|
+
"account_id",
|
|
204
|
+
"tenant_id",
|
|
205
|
+
"organization_id",
|
|
206
|
+
"org_id",
|
|
207
|
+
"workspace_id",
|
|
208
|
+
"team_id"
|
|
209
|
+
];
|
|
210
|
+
var API_ROLES = "public, anon, authenticated";
|
|
211
|
+
var IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$/;
|
|
212
|
+
function table(model, name) {
|
|
213
|
+
if (!name || !IDENTIFIER.test(name))
|
|
214
|
+
return void 0;
|
|
215
|
+
const key = name.toLowerCase().replace(/^public\./, "");
|
|
216
|
+
const t = model.tables.find((x) => x.table.toLowerCase() === key);
|
|
217
|
+
return t && IDENTIFIER.test(t.table) ? t : void 0;
|
|
218
|
+
}
|
|
219
|
+
function ownershipOf(t) {
|
|
220
|
+
if (!t)
|
|
221
|
+
return { kind: "none" };
|
|
222
|
+
const cols = t.columns.map((c) => c.toLowerCase());
|
|
223
|
+
for (const c of PERSON_COLUMNS)
|
|
224
|
+
if (cols.includes(c))
|
|
225
|
+
return { kind: "person", column: c };
|
|
226
|
+
for (const c of TENANT_COLUMNS)
|
|
227
|
+
if (cols.includes(c))
|
|
228
|
+
return { kind: "tenant", column: c };
|
|
229
|
+
return { kind: "none" };
|
|
230
|
+
}
|
|
231
|
+
function tenantNote(full, column) {
|
|
232
|
+
return `-- ${full} belongs to a tenant through ${column}, not to one person. Comparing ${column} with
|
|
233
|
+
-- auth.uid() would lock every member out of their own rows, so no policy is proposed here.
|
|
234
|
+
-- Write one that looks the caller's membership up, for example:
|
|
235
|
+
-- using (${column} in (select ${column} from public.<memberships> where user_id = (select auth.uid())))
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
function qualified(name) {
|
|
239
|
+
return name.includes(".") ? name : `public.${name}`;
|
|
240
|
+
}
|
|
241
|
+
function fn(model, name) {
|
|
242
|
+
if (!name || !IDENTIFIER.test(name))
|
|
243
|
+
return void 0;
|
|
244
|
+
const key = name.toLowerCase().replace(/^public\./, "");
|
|
245
|
+
const f = (model.sqlFunctions ?? []).find((x) => x.name.toLowerCase() === key);
|
|
246
|
+
return f && IDENTIFIER.test(f.name) ? f : void 0;
|
|
247
|
+
}
|
|
248
|
+
function signatureOf(f) {
|
|
249
|
+
const name = f.name.includes(".") ? f.name : `public.${f.name}`;
|
|
250
|
+
return f.args === void 0 ? `${name}(...)` : `${name}(${f.args})`;
|
|
251
|
+
}
|
|
252
|
+
var COMMANDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "all"]);
|
|
253
|
+
var HEADER = (title) => `-- ${title}
|
|
254
|
+
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
|
|
255
|
+
`;
|
|
256
|
+
function sqlFunctionFix(finding4, model) {
|
|
257
|
+
const name = finding4.evidence[0]?.data?.function;
|
|
258
|
+
const f = fn(model, typeof name === "string" ? name : void 0);
|
|
259
|
+
if (!f)
|
|
260
|
+
return null;
|
|
261
|
+
const sig = signatureOf(f);
|
|
262
|
+
const ambiguous = sig.endsWith("(...)");
|
|
263
|
+
const body = [
|
|
264
|
+
HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
|
|
265
|
+
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" : "",
|
|
266
|
+
`revoke execute on function ${sig} from ${API_ROLES};
|
|
267
|
+
`,
|
|
268
|
+
"-- Leave this line out if nothing calls the function with the service role.\n",
|
|
269
|
+
`grant execute on function ${sig} to service_role;
|
|
270
|
+
`
|
|
271
|
+
].join("");
|
|
272
|
+
return {
|
|
273
|
+
file: `fix_revoke_execute_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
274
|
+
sql: body,
|
|
275
|
+
summary: `Revoke execute on ${f.name} from the API roles`,
|
|
276
|
+
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())`)."
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function enableRlsFix(finding4, model, withPolicy) {
|
|
280
|
+
const asked = finding4.evidence[0]?.data?.table;
|
|
281
|
+
const t = table(model, typeof asked === "string" ? asked : void 0);
|
|
282
|
+
if (!t)
|
|
283
|
+
return null;
|
|
284
|
+
const name = t.table;
|
|
285
|
+
const full = qualified(name);
|
|
286
|
+
const own = ownershipOf(t);
|
|
287
|
+
const owner = own.kind === "person" ? own.column : null;
|
|
288
|
+
const lines = [HEADER(`Turn on row level security for ${full}`)];
|
|
289
|
+
lines.push(`alter table ${full} enable row level security;
|
|
290
|
+
`);
|
|
291
|
+
if (withPolicy) {
|
|
292
|
+
if (own.kind === "tenant") {
|
|
293
|
+
lines.push(`
|
|
294
|
+
${tenantNote(full, own.column)}`);
|
|
295
|
+
} else if (owner) {
|
|
296
|
+
lines.push(`
|
|
297
|
+
create policy "${name}: owner reads" on ${full}
|
|
298
|
+
for select to authenticated
|
|
299
|
+
using (${owner} = (select auth.uid()));
|
|
300
|
+
`, `
|
|
301
|
+
create policy "${name}: owner writes" on ${full}
|
|
302
|
+
for all to authenticated
|
|
303
|
+
using (${owner} = (select auth.uid()))
|
|
304
|
+
with check (${owner} = (select auth.uid()));
|
|
305
|
+
`);
|
|
306
|
+
} else {
|
|
307
|
+
lines.push(`
|
|
308
|
+
-- No column of ${full} ties a row to a person (looked for ${PERSON_COLUMNS.slice(0, 4).join(", ")}\u2026),
|
|
309
|
+
-- so no policy is proposed: with RLS on and no policy the table is readable only with the
|
|
310
|
+
-- service role, which is the safe default. Add a policy once you decide who owns a row.
|
|
311
|
+
`);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
lines.push(`
|
|
315
|
+
-- The policies this table already has start applying the moment row level security is on;
|
|
316
|
+
-- read them once before applying, because until now they have never run.
|
|
317
|
+
`);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
file: `fix_enable_rls_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
321
|
+
sql: lines.join(""),
|
|
322
|
+
summary: `Enable row level security on ${full}`,
|
|
323
|
+
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."
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function anonWriteFix(finding4, model) {
|
|
327
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
328
|
+
const policy = data.policy;
|
|
329
|
+
const command = data.command;
|
|
330
|
+
const t = table(model, typeof data.table === "string" ? data.table : void 0);
|
|
331
|
+
if (!t || typeof policy !== "string" || policy.length > 200)
|
|
332
|
+
return null;
|
|
333
|
+
if (command !== void 0 && !COMMANDS.has(String(command)))
|
|
334
|
+
return null;
|
|
335
|
+
const name = t.table;
|
|
336
|
+
const full = qualified(name);
|
|
337
|
+
const own = ownershipOf(t);
|
|
338
|
+
const owner = own.kind === "person" ? own.column : null;
|
|
339
|
+
const cmd = typeof command === "string" ? command : "all";
|
|
340
|
+
const safeName = policy.replace(/"/g, '""');
|
|
341
|
+
const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
|
|
342
|
+
if (own.kind === "tenant") {
|
|
343
|
+
lines.push(`drop policy "${safeName}" on ${full};
|
|
344
|
+
`, `
|
|
345
|
+
${tenantNote(full, own.column)}`);
|
|
346
|
+
} else if (owner) {
|
|
347
|
+
lines.push(`drop policy "${safeName}" on ${full};
|
|
348
|
+
`, `
|
|
349
|
+
create policy "${safeName}" on ${full}
|
|
350
|
+
for ${cmd} to authenticated
|
|
351
|
+
`, cmd === "insert" ? ` with check (${owner} = (select auth.uid()));
|
|
352
|
+
` : ` using (${owner} = (select auth.uid()))${cmd === "all" ? `
|
|
353
|
+
with check (${owner} = (select auth.uid()))` : ""};
|
|
354
|
+
`);
|
|
355
|
+
} else {
|
|
356
|
+
lines.push(`-- No column of ${full} ties a row to a person, so there is nothing to compare the caller
|
|
357
|
+
-- with. Either add one, or take the policy away and let your server write the table with the
|
|
358
|
+
-- service role after it has checked the caller itself.
|
|
359
|
+
`, `drop policy "${safeName}" on ${full};
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
|
|
364
|
+
sql: lines.join(""),
|
|
365
|
+
summary: `Tie the write policy on ${full} to the caller`,
|
|
366
|
+
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.` : own.kind === "tenant" ? `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. Rows belong to a tenant through ${own.column}, and only your schema knows how a user becomes a member, so the migration removes the open policy and shows the shape of the membership check to write instead.` : `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.`
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function userMetadataFix(finding4, model) {
|
|
370
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
371
|
+
const policy = data.policy;
|
|
372
|
+
const t = table(model, typeof data.table === "string" ? data.table : void 0);
|
|
373
|
+
if (!t || typeof policy !== "string" || policy.length > 200)
|
|
374
|
+
return null;
|
|
375
|
+
const name = t.table;
|
|
376
|
+
const full = qualified(name);
|
|
377
|
+
return {
|
|
378
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
|
|
379
|
+
sql: [
|
|
380
|
+
HEADER(`Stop the policy "${policy}" on ${full} from trusting user_metadata`),
|
|
381
|
+
"-- Re-create the policy with the claim read from app_metadata, which only the service role\n",
|
|
382
|
+
"-- writes. Copy the predicate from your own migration and change the one word: replace\n",
|
|
383
|
+
"-- (select auth.jwt()) -> 'user_metadata' ->> '<claim>'\n",
|
|
384
|
+
"-- with\n",
|
|
385
|
+
"-- (select auth.jwt()) -> 'app_metadata' ->> '<claim>'\n",
|
|
386
|
+
`--
|
|
387
|
+
-- drop policy "${policy.replace(/"/g, '""')}" on ${full};
|
|
388
|
+
`,
|
|
389
|
+
`-- create policy "${policy.replace(/"/g, '""')}" on ${full} ... using (...);
|
|
390
|
+
`,
|
|
391
|
+
"\n-- Then set the claim where the user cannot reach it, from a server with the service role:\n",
|
|
392
|
+
"-- await admin.auth.admin.updateUserById(id, { app_metadata: { is_admin: true } });\n"
|
|
393
|
+
].join(""),
|
|
394
|
+
summary: `Move the claim behind "${policy}" from user_metadata to app_metadata`,
|
|
395
|
+
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."
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function sqlFixFor(finding4, model) {
|
|
399
|
+
switch (finding4.ruleId) {
|
|
400
|
+
case "supabase.security-definer-function-without-caller-check":
|
|
401
|
+
return sqlFunctionFix(finding4, model);
|
|
402
|
+
case "supabase.table-without-rls":
|
|
403
|
+
return enableRlsFix(finding4, model, true);
|
|
404
|
+
case "supabase.policies-without-rls-enabled":
|
|
405
|
+
return enableRlsFix(finding4, model, false);
|
|
406
|
+
case "supabase.anon-write-policy":
|
|
407
|
+
return anonWriteFix(finding4, model);
|
|
408
|
+
case "supabase.rls-policy-trusts-user-metadata":
|
|
409
|
+
return userMetadataFix(finding4, model);
|
|
410
|
+
default:
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function addFileDiff(path, body) {
|
|
415
|
+
const lines = body.replace(/\n$/, "").split("\n");
|
|
416
|
+
return [
|
|
417
|
+
`diff --git a/${path} b/${path}`,
|
|
418
|
+
"new file mode 100644",
|
|
419
|
+
"--- /dev/null",
|
|
420
|
+
`+++ b/${path}`,
|
|
421
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
422
|
+
...lines.map((l) => `+${l}`),
|
|
423
|
+
""
|
|
424
|
+
].join("\n");
|
|
425
|
+
}
|
|
426
|
+
function deterministicFix(finding4, model, migrationsDir = "supabase/migrations") {
|
|
427
|
+
const fix = sqlFixFor(finding4, model);
|
|
428
|
+
if (!fix)
|
|
429
|
+
return null;
|
|
430
|
+
const stamp = (finding4.createdAt ?? "").replace(/\D/g, "").slice(0, 14) || "00000000000000";
|
|
431
|
+
const path = `${migrationsDir}/${stamp}_${fix.file}`;
|
|
432
|
+
return {
|
|
433
|
+
summary: fix.summary,
|
|
434
|
+
diff: addFileDiff(path, fix.sql),
|
|
435
|
+
touchedFiles: [path],
|
|
436
|
+
rationale: fix.rationale
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
193
440
|
// packages/graph/src/graph.ts
|
|
194
441
|
var SecurityGraph = class {
|
|
195
442
|
nodes = /* @__PURE__ */ new Map();
|
|
@@ -221,10 +468,10 @@ var SecurityGraph = class {
|
|
|
221
468
|
function buildGraph(model) {
|
|
222
469
|
const g = new SecurityGraph();
|
|
223
470
|
const tableInfo = new Map(model.tables.map((t) => [t.table, t]));
|
|
224
|
-
const tableNode = (
|
|
225
|
-
const info = tableInfo.get(
|
|
471
|
+
const tableNode = (table2) => {
|
|
472
|
+
const info = tableInfo.get(table2.toLowerCase());
|
|
226
473
|
const data = {
|
|
227
|
-
table,
|
|
474
|
+
table: table2,
|
|
228
475
|
known: info !== void 0,
|
|
229
476
|
rlsEnabled: info?.rlsEnabled ?? false,
|
|
230
477
|
policies: info?.policies ?? [],
|
|
@@ -233,19 +480,19 @@ function buildGraph(model) {
|
|
|
233
480
|
};
|
|
234
481
|
const node = g.addNode(
|
|
235
482
|
info ? {
|
|
236
|
-
id: `table:${
|
|
483
|
+
id: `table:${table2}`,
|
|
237
484
|
kind: "Table",
|
|
238
|
-
label: `public.${
|
|
485
|
+
label: `public.${table2}`,
|
|
239
486
|
data: { ...data },
|
|
240
487
|
location: info.location
|
|
241
|
-
} : { id: `table:${
|
|
488
|
+
} : { id: `table:${table2}`, kind: "Table", label: `public.${table2}`, data: { ...data } }
|
|
242
489
|
);
|
|
243
490
|
for (const p of data.policies) {
|
|
244
491
|
const pn = g.addNode({
|
|
245
|
-
id: `policy:${
|
|
492
|
+
id: `policy:${table2}:${p}`,
|
|
246
493
|
kind: "RLSPolicy",
|
|
247
494
|
label: p,
|
|
248
|
-
data: { table, name: p }
|
|
495
|
+
data: { table: table2, name: p }
|
|
249
496
|
});
|
|
250
497
|
g.addEdge(node.id, pn.id, "GUARDED_BY");
|
|
251
498
|
}
|
|
@@ -746,11 +993,11 @@ function walkOwn(body, visit) {
|
|
|
746
993
|
};
|
|
747
994
|
go(body);
|
|
748
995
|
}
|
|
749
|
-
function ownReturns(
|
|
750
|
-
if (!
|
|
751
|
-
if (!ts.isBlock(
|
|
996
|
+
function ownReturns(fn2) {
|
|
997
|
+
if (!fn2.body) return [];
|
|
998
|
+
if (!ts.isBlock(fn2.body)) return [fn2.body];
|
|
752
999
|
const out = [];
|
|
753
|
-
walkOwn(
|
|
1000
|
+
walkOwn(fn2.body, (n) => {
|
|
754
1001
|
if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
|
|
755
1002
|
});
|
|
756
1003
|
return out;
|
|
@@ -921,10 +1168,10 @@ function growNames(decls, scope) {
|
|
|
921
1168
|
if (scope.names.size === before) break;
|
|
922
1169
|
}
|
|
923
1170
|
}
|
|
924
|
-
function ownDeclarations(
|
|
1171
|
+
function ownDeclarations(fn2) {
|
|
925
1172
|
const out = [];
|
|
926
|
-
if (
|
|
927
|
-
walkOwn(
|
|
1173
|
+
if (fn2.body) {
|
|
1174
|
+
walkOwn(fn2.body, (n) => {
|
|
928
1175
|
if (ts3.isVariableDeclaration(n)) out.push(n);
|
|
929
1176
|
});
|
|
930
1177
|
}
|
|
@@ -953,10 +1200,10 @@ function moduleSecretScope(sf) {
|
|
|
953
1200
|
moduleScopes.set(sf, scope);
|
|
954
1201
|
return scope;
|
|
955
1202
|
}
|
|
956
|
-
function secretScopeFor(
|
|
1203
|
+
function secretScopeFor(fn2, sf) {
|
|
957
1204
|
const mod = moduleSecretScope(sf);
|
|
958
1205
|
const scope = { names: new Set(mod.names), fns: mod.fns };
|
|
959
|
-
growNames(ownDeclarations(
|
|
1206
|
+
growNames(ownDeclarations(fn2), scope);
|
|
960
1207
|
return scope;
|
|
961
1208
|
}
|
|
962
1209
|
var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
|
|
@@ -1077,10 +1324,10 @@ function gatesByThrow(node, fnBody) {
|
|
|
1077
1324
|
}
|
|
1078
1325
|
return false;
|
|
1079
1326
|
}
|
|
1080
|
-
function secretChecksIn(
|
|
1081
|
-
if (!
|
|
1082
|
-
const body =
|
|
1083
|
-
const secrets = secretScopeFor(
|
|
1327
|
+
function secretChecksIn(fn2, sf) {
|
|
1328
|
+
if (!fn2.body) return [];
|
|
1329
|
+
const body = fn2.body;
|
|
1330
|
+
const secrets = secretScopeFor(fn2, sf);
|
|
1084
1331
|
const secretish = (e) => isSecretish(e, secrets);
|
|
1085
1332
|
const builtFromSecret = instancesBuiltFromSecret(body, secretish);
|
|
1086
1333
|
const out = [];
|
|
@@ -1193,8 +1440,8 @@ function mentions(cond, names) {
|
|
|
1193
1440
|
return false;
|
|
1194
1441
|
}
|
|
1195
1442
|
function ifsAfter(node) {
|
|
1196
|
-
const
|
|
1197
|
-
const scope =
|
|
1443
|
+
const fn2 = enclosingFunction(node);
|
|
1444
|
+
const scope = fn2 ? fn2.body : node.getSourceFile();
|
|
1198
1445
|
const out = [];
|
|
1199
1446
|
if (!scope) return out;
|
|
1200
1447
|
walkOwn(scope, (n) => {
|
|
@@ -2461,7 +2708,9 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2461
2708
|
if (!isWord(tk[i], "function")) return;
|
|
2462
2709
|
const q = readQualifiedName(tk, i + 1);
|
|
2463
2710
|
if (!q || !isPunct(tk[q.next], "(")) return;
|
|
2464
|
-
|
|
2711
|
+
const argsEnd = groupEnd(tk, q.next);
|
|
2712
|
+
const args = argumentTypes(stmt, tk, q.next, argsEnd);
|
|
2713
|
+
i = argsEnd + 1;
|
|
2465
2714
|
let securityDefiner = false;
|
|
2466
2715
|
let returns = null;
|
|
2467
2716
|
let body = "";
|
|
@@ -2494,17 +2743,122 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2494
2743
|
}
|
|
2495
2744
|
const key = qualifiedKey(q);
|
|
2496
2745
|
const code = maskSqlComments(body);
|
|
2497
|
-
const
|
|
2746
|
+
const fn2 = {
|
|
2498
2747
|
schema: q.schema,
|
|
2499
2748
|
name: q.name,
|
|
2500
2749
|
securityDefiner,
|
|
2501
2750
|
returns,
|
|
2751
|
+
args,
|
|
2502
2752
|
body: code,
|
|
2503
2753
|
directCheck: CALLER_CHECKS.some((re) => re.test(code)),
|
|
2504
2754
|
acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
|
|
2505
2755
|
location: { file, line: stmt.line }
|
|
2506
2756
|
};
|
|
2507
|
-
reg.byKey.set(key,
|
|
2757
|
+
reg.byKey.set(key, fn2);
|
|
2758
|
+
}
|
|
2759
|
+
var TYPE_WORD = /* @__PURE__ */ new Set([
|
|
2760
|
+
"anyarray",
|
|
2761
|
+
"anyelement",
|
|
2762
|
+
"bigint",
|
|
2763
|
+
"bigserial",
|
|
2764
|
+
"bit",
|
|
2765
|
+
"bool",
|
|
2766
|
+
"boolean",
|
|
2767
|
+
"box",
|
|
2768
|
+
"bytea",
|
|
2769
|
+
"char",
|
|
2770
|
+
"character",
|
|
2771
|
+
"cidr",
|
|
2772
|
+
"circle",
|
|
2773
|
+
"date",
|
|
2774
|
+
"decimal",
|
|
2775
|
+
"double",
|
|
2776
|
+
"float",
|
|
2777
|
+
"float4",
|
|
2778
|
+
"float8",
|
|
2779
|
+
"inet",
|
|
2780
|
+
"int",
|
|
2781
|
+
"int2",
|
|
2782
|
+
"int4",
|
|
2783
|
+
"int8",
|
|
2784
|
+
"integer",
|
|
2785
|
+
"interval",
|
|
2786
|
+
"json",
|
|
2787
|
+
"jsonb",
|
|
2788
|
+
"line",
|
|
2789
|
+
"lseg",
|
|
2790
|
+
"macaddr",
|
|
2791
|
+
"money",
|
|
2792
|
+
"name",
|
|
2793
|
+
"numeric",
|
|
2794
|
+
"oid",
|
|
2795
|
+
"path",
|
|
2796
|
+
"point",
|
|
2797
|
+
"polygon",
|
|
2798
|
+
"real",
|
|
2799
|
+
"record",
|
|
2800
|
+
"regclass",
|
|
2801
|
+
"serial",
|
|
2802
|
+
"smallint",
|
|
2803
|
+
"smallserial",
|
|
2804
|
+
"text",
|
|
2805
|
+
"time",
|
|
2806
|
+
"timestamp",
|
|
2807
|
+
"timestamptz",
|
|
2808
|
+
"timetz",
|
|
2809
|
+
"trigger",
|
|
2810
|
+
"tsquery",
|
|
2811
|
+
"tsvector",
|
|
2812
|
+
"uuid",
|
|
2813
|
+
"varbit",
|
|
2814
|
+
"varchar",
|
|
2815
|
+
"void",
|
|
2816
|
+
"xml"
|
|
2817
|
+
]);
|
|
2818
|
+
function argumentTypes(stmt, tokens, open, close) {
|
|
2819
|
+
if (close <= open + 1) return "";
|
|
2820
|
+
const parts = [];
|
|
2821
|
+
let depth = 0;
|
|
2822
|
+
let start = open + 1;
|
|
2823
|
+
const pieces = [];
|
|
2824
|
+
for (let i = open + 1; i < close; i++) {
|
|
2825
|
+
const t = tokens[i];
|
|
2826
|
+
if (isPunct(t, "(") || isPunct(t, "[")) depth += 1;
|
|
2827
|
+
else if (isPunct(t, ")") || isPunct(t, "]")) depth -= 1;
|
|
2828
|
+
else if (depth === 0 && isPunct(t, ",")) {
|
|
2829
|
+
pieces.push([start, i]);
|
|
2830
|
+
start = i + 1;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
pieces.push([start, close]);
|
|
2834
|
+
for (const [from, to] of pieces) {
|
|
2835
|
+
const words = [];
|
|
2836
|
+
for (let i = from; i < to; i++) {
|
|
2837
|
+
const t = tokens[i];
|
|
2838
|
+
if (!t) continue;
|
|
2839
|
+
if (isWord(t, "default")) break;
|
|
2840
|
+
if (t.kind === "punct" && t.value === "=") break;
|
|
2841
|
+
words.push(t);
|
|
2842
|
+
}
|
|
2843
|
+
if (words.length === 0) return null;
|
|
2844
|
+
let k = 0;
|
|
2845
|
+
if (isWord(words[k], "in") || isWord(words[k], "out") || isWord(words[k], "inout")) {
|
|
2846
|
+
if (isWord(words[k], "out")) continue;
|
|
2847
|
+
k += 1;
|
|
2848
|
+
} else if (isWord(words[k], "variadic")) {
|
|
2849
|
+
k += 1;
|
|
2850
|
+
}
|
|
2851
|
+
const firstWord = words[k];
|
|
2852
|
+
const named = firstWord !== void 0 && firstWord.kind === "word" && !TYPE_WORD.has(firstWord.value.toLowerCase()) && words.slice(k + 1).some((w) => w.kind === "word");
|
|
2853
|
+
const typeStart = named ? k + 1 : k;
|
|
2854
|
+
const first = words[typeStart];
|
|
2855
|
+
const last = words[words.length - 1];
|
|
2856
|
+
if (!first || !last) return null;
|
|
2857
|
+
parts.push(
|
|
2858
|
+
stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
return parts.join(", ");
|
|
2508
2862
|
}
|
|
2509
2863
|
function readFunctionList(tokens, from) {
|
|
2510
2864
|
const keys = [];
|
|
@@ -2530,28 +2884,28 @@ function applyAlterFunction(reg, stmt) {
|
|
|
2530
2884
|
const tk = stmt.tokens;
|
|
2531
2885
|
const list = readFunctionList(tk, 2);
|
|
2532
2886
|
const key = list.keys[0];
|
|
2533
|
-
const
|
|
2534
|
-
if (!
|
|
2887
|
+
const fn2 = key === void 0 ? void 0 : reg.byKey.get(key);
|
|
2888
|
+
if (!fn2 || key === void 0) return;
|
|
2535
2889
|
let i = list.next;
|
|
2536
2890
|
if (isWord(tk[i], "external")) i += 1;
|
|
2537
2891
|
if (isWord(tk[i], "security")) {
|
|
2538
|
-
if (isWord(tk[i + 1], "definer"))
|
|
2539
|
-
else if (isWord(tk[i + 1], "invoker"))
|
|
2892
|
+
if (isWord(tk[i + 1], "definer")) fn2.securityDefiner = true;
|
|
2893
|
+
else if (isWord(tk[i + 1], "invoker")) fn2.securityDefiner = false;
|
|
2540
2894
|
return;
|
|
2541
2895
|
}
|
|
2542
2896
|
let moved = null;
|
|
2543
2897
|
if (isWord(tk[i], "rename") && isWord(tk[i + 1], "to")) {
|
|
2544
2898
|
const name = identOf(tk[i + 2]);
|
|
2545
|
-
if (name) moved = { schema:
|
|
2899
|
+
if (name) moved = { schema: fn2.schema, name, next: 0 };
|
|
2546
2900
|
} else if (isWord(tk[i], "set") && isWord(tk[i + 1], "schema")) {
|
|
2547
2901
|
const schema = identOf(tk[i + 2]);
|
|
2548
|
-
if (schema) moved = { schema, name:
|
|
2902
|
+
if (schema) moved = { schema, name: fn2.name, next: 0 };
|
|
2549
2903
|
}
|
|
2550
2904
|
if (!moved) return;
|
|
2551
2905
|
reg.byKey.delete(key);
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
reg.byKey.set(qualifiedKey(moved),
|
|
2906
|
+
fn2.schema = moved.schema;
|
|
2907
|
+
fn2.name = moved.name;
|
|
2908
|
+
reg.byKey.set(qualifiedKey(moved), fn2);
|
|
2555
2909
|
}
|
|
2556
2910
|
function readRoles(tokens, from) {
|
|
2557
2911
|
const roles = [];
|
|
@@ -2606,8 +2960,8 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2606
2960
|
if (isWord(tk[i], "function") || isWord(tk[i], "routine")) {
|
|
2607
2961
|
const list = readFunctionList(tk, i + 1);
|
|
2608
2962
|
targets = list.keys.flatMap((k) => {
|
|
2609
|
-
const
|
|
2610
|
-
return
|
|
2963
|
+
const fn2 = reg.byKey.get(k);
|
|
2964
|
+
return fn2 ? [fn2] : [];
|
|
2611
2965
|
});
|
|
2612
2966
|
i = list.next;
|
|
2613
2967
|
} 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 +2976,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2622
2976
|
i += 1;
|
|
2623
2977
|
}
|
|
2624
2978
|
targets = [...reg.byKey.values()].filter(
|
|
2625
|
-
(
|
|
2979
|
+
(fn2) => schemas.includes((fn2.schema ?? "public").toLowerCase())
|
|
2626
2980
|
);
|
|
2627
2981
|
} else {
|
|
2628
2982
|
return;
|
|
@@ -2630,7 +2984,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2630
2984
|
const [start, end] = roleClause(tk, i, grant ? "to" : "from");
|
|
2631
2985
|
if (start < 0) return;
|
|
2632
2986
|
const roles = readRoles(tk.slice(0, end), start);
|
|
2633
|
-
for (const
|
|
2987
|
+
for (const fn2 of targets) applyToAcl(fn2.acl, grant, roles);
|
|
2634
2988
|
}
|
|
2635
2989
|
function applyDefaultPrivileges(reg, stmt) {
|
|
2636
2990
|
const tk = stmt.tokens;
|
|
@@ -2727,6 +3081,7 @@ function finishFunctions(reg) {
|
|
|
2727
3081
|
location: f.location
|
|
2728
3082
|
};
|
|
2729
3083
|
if (f.returns !== null) info.returns = f.returns;
|
|
3084
|
+
if (f.args !== null) info.args = f.args;
|
|
2730
3085
|
return info;
|
|
2731
3086
|
});
|
|
2732
3087
|
}
|
|
@@ -2926,9 +3281,9 @@ function sync(state, key) {
|
|
|
2926
3281
|
function findColumn(meta, name) {
|
|
2927
3282
|
return meta.columns.find((c) => c.name === name);
|
|
2928
3283
|
}
|
|
2929
|
-
function pkOf(state,
|
|
2930
|
-
if (
|
|
2931
|
-
return state.meta.get(
|
|
3284
|
+
function pkOf(state, table2) {
|
|
3285
|
+
if (table2 === "auth.users") return ["id"];
|
|
3286
|
+
return state.meta.get(table2)?.pk ?? [];
|
|
2932
3287
|
}
|
|
2933
3288
|
function resolveRefs(state, meta) {
|
|
2934
3289
|
for (const col of meta.columns) {
|
|
@@ -2937,12 +3292,12 @@ function resolveRefs(state, meta) {
|
|
|
2937
3292
|
}
|
|
2938
3293
|
}
|
|
2939
3294
|
}
|
|
2940
|
-
function clearRefsTo(state,
|
|
3295
|
+
function clearRefsTo(state, table2, column) {
|
|
2941
3296
|
for (const [key, meta] of state.meta) {
|
|
2942
3297
|
let touched = false;
|
|
2943
3298
|
for (const col of meta.columns) {
|
|
2944
3299
|
const r = col.references;
|
|
2945
|
-
if (!r || r.table !==
|
|
3300
|
+
if (!r || r.table !== table2 || column !== void 0 && r.column !== column) continue;
|
|
2946
3301
|
col.references = null;
|
|
2947
3302
|
col.fkName = null;
|
|
2948
3303
|
touched = true;
|
|
@@ -3792,9 +4147,9 @@ function analyzeModule(rel, sf) {
|
|
|
3792
4147
|
if (!ts9.isCallExpression(init) && !ts9.isNewExpression(init)) continue;
|
|
3793
4148
|
if (ts9.isCallExpression(init)) {
|
|
3794
4149
|
const callee = init.expression;
|
|
3795
|
-
const
|
|
4150
|
+
const fn2 = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
|
|
3796
4151
|
const tableName = stringLiteralValue(init.arguments[0]);
|
|
3797
|
-
if ((DRIZZLE_TABLE_FNS.has(
|
|
4152
|
+
if ((DRIZZLE_TABLE_FNS.has(fn2) || fn2 === "table") && tableName !== null) {
|
|
3798
4153
|
drizzleTables.set(d.name.text, tableName);
|
|
3799
4154
|
continue;
|
|
3800
4155
|
}
|
|
@@ -3996,12 +4351,12 @@ var NO_ARG = {
|
|
|
3996
4351
|
whole: false,
|
|
3997
4352
|
isRequest: false
|
|
3998
4353
|
};
|
|
3999
|
-
function ownFunctionSym(facts, name,
|
|
4354
|
+
function ownFunctionSym(facts, name, fn2) {
|
|
4000
4355
|
const factory = facts.clientFactories.find((c) => c.name === name);
|
|
4001
4356
|
if (factory) return { kind: "factory", factory };
|
|
4002
4357
|
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 };
|
|
4358
|
+
if (helper) return { kind: "auth", helper, fn: fn2, facts };
|
|
4359
|
+
return { kind: "function", name, fn: fn2, facts };
|
|
4005
4360
|
}
|
|
4006
4361
|
function exportedSym(p, tf, name, depth) {
|
|
4007
4362
|
if (depth > 5) return null;
|
|
@@ -4048,7 +4403,7 @@ function scopeOf(p, facts) {
|
|
|
4048
4403
|
for (const [name, v] of facts.moduleVars) {
|
|
4049
4404
|
scope.set(name, { kind: "var", name, init: v.init, facts });
|
|
4050
4405
|
}
|
|
4051
|
-
for (const [name,
|
|
4406
|
+
for (const [name, table2] of facts.drizzleTables) scope.set(name, { kind: "table", table: table2 });
|
|
4052
4407
|
for (const [name, v] of facts.authVars) scope.set(name, { kind: "auth", helper: v.helper });
|
|
4053
4408
|
for (const [local, ref] of facts.imports) {
|
|
4054
4409
|
const target = p.resolver.resolve(ref.spec, facts.file);
|
|
@@ -4074,9 +4429,9 @@ function scopeOf(p, facts) {
|
|
|
4074
4429
|
}
|
|
4075
4430
|
if (!/^[.~]|^@\//.test(ref.spec)) continue;
|
|
4076
4431
|
for (const f of p.registry.values()) {
|
|
4077
|
-
const
|
|
4078
|
-
if (!
|
|
4079
|
-
const sym = ownFunctionSym(f, ref.imported,
|
|
4432
|
+
const fn2 = f.functions.get(ref.imported);
|
|
4433
|
+
if (!fn2?.exported) continue;
|
|
4434
|
+
const sym = ownFunctionSym(f, ref.imported, fn2.fn);
|
|
4080
4435
|
if (sym.kind === "factory" || sym.kind === "auth") {
|
|
4081
4436
|
scope.set(local, sym);
|
|
4082
4437
|
p.warnings.push(
|
|
@@ -4096,10 +4451,10 @@ function symOfCallee(p, callee, scope) {
|
|
|
4096
4451
|
}
|
|
4097
4452
|
return void 0;
|
|
4098
4453
|
}
|
|
4099
|
-
function returnedExpressions(
|
|
4100
|
-
if (!
|
|
4101
|
-
if (!ts11.isBlock(
|
|
4102
|
-
return collect(
|
|
4454
|
+
function returnedExpressions(fn2) {
|
|
4455
|
+
if (!fn2.body) return [];
|
|
4456
|
+
if (!ts11.isBlock(fn2.body)) return [fn2.body];
|
|
4457
|
+
return collect(fn2.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
|
|
4103
4458
|
}
|
|
4104
4459
|
function factoryOfFunction(p, sym, depth) {
|
|
4105
4460
|
const key = `${sym.facts.file}#${sym.name}`;
|
|
@@ -4477,10 +4832,10 @@ function callTarget(p, call, frame, scope) {
|
|
|
4477
4832
|
return null;
|
|
4478
4833
|
}
|
|
4479
4834
|
function bindDeclarations(p, frame, acc) {
|
|
4480
|
-
const { rel, sf, fn } = frame;
|
|
4835
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4481
4836
|
const scope = scopeOf(p, frame.facts);
|
|
4482
4837
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4483
|
-
const body =
|
|
4838
|
+
const body = fn2.body ?? fn2;
|
|
4484
4839
|
for (const [name, sym] of scope) {
|
|
4485
4840
|
if (sym.kind !== "var" || frame.clients.has(name) || frame.instances.has(name)) continue;
|
|
4486
4841
|
const vb = varBinding(p, sym);
|
|
@@ -4605,10 +4960,10 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4605
4960
|
}
|
|
4606
4961
|
}
|
|
4607
4962
|
function analyzeFrame(p, frame, acc) {
|
|
4608
|
-
const { rel, sf, fn } = frame;
|
|
4963
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4609
4964
|
const scope = scopeOf(p, frame.facts);
|
|
4610
4965
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4611
|
-
const body =
|
|
4966
|
+
const body = fn2.body ?? fn2;
|
|
4612
4967
|
bindDeclarations(p, frame, acc);
|
|
4613
4968
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
4614
4969
|
const callee = call.expression;
|
|
@@ -4616,8 +4971,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4616
4971
|
continue;
|
|
4617
4972
|
if (!ts11.isIdentifier(callee.expression)) continue;
|
|
4618
4973
|
const outer = frame.clients.get(callee.expression.text);
|
|
4619
|
-
const
|
|
4620
|
-
const param =
|
|
4974
|
+
const fn3 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
|
|
4975
|
+
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
4621
4976
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
4622
4977
|
}
|
|
4623
4978
|
const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
|
|
@@ -4632,7 +4987,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4632
4987
|
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
|
|
4633
4988
|
});
|
|
4634
4989
|
}
|
|
4635
|
-
for (const check of secretChecksIn(
|
|
4990
|
+
for (const check of secretChecksIn(fn2, sf)) {
|
|
4636
4991
|
acc.authChecks.push({ ...loc2(check.node), kind: "secret" });
|
|
4637
4992
|
}
|
|
4638
4993
|
for (const pa of collect(body, ts11.isPropertyAccessExpression)) {
|
|
@@ -5137,16 +5492,16 @@ function enclosingCondition(node, body) {
|
|
|
5137
5492
|
return null;
|
|
5138
5493
|
}
|
|
5139
5494
|
var normalizeColumn = (c) => (c ?? "").toLowerCase().replace(/_/g, "");
|
|
5140
|
-
function singular(
|
|
5141
|
-
if (
|
|
5142
|
-
if (
|
|
5143
|
-
return
|
|
5495
|
+
function singular(table2) {
|
|
5496
|
+
if (table2.endsWith("ies")) return `${table2.slice(0, -3)}y`;
|
|
5497
|
+
if (table2.endsWith("ses") || table2.endsWith("xes")) return table2.slice(0, -2);
|
|
5498
|
+
return table2.endsWith("s") ? table2.slice(0, -1) : table2;
|
|
5144
5499
|
}
|
|
5145
|
-
function columnNamesTable(column,
|
|
5500
|
+
function columnNamesTable(column, table2) {
|
|
5146
5501
|
const c = normalizeColumn(column);
|
|
5147
5502
|
if (!c.endsWith("id") || c === "id") return false;
|
|
5148
5503
|
const stem = c.slice(0, -2);
|
|
5149
|
-
const t = normalizeColumn(
|
|
5504
|
+
const t = normalizeColumn(table2);
|
|
5150
5505
|
return stem === t || stem === singular(t);
|
|
5151
5506
|
}
|
|
5152
5507
|
function guardMatch(q, g, tables) {
|
|
@@ -5256,8 +5611,8 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5256
5611
|
const sf = p.sources.get(rel);
|
|
5257
5612
|
const facts = p.registry.get(rel);
|
|
5258
5613
|
if (!sf || !facts || isClientComponentFile(sf)) continue;
|
|
5259
|
-
const
|
|
5260
|
-
if (!
|
|
5614
|
+
const fn2 = pageHandlerIn(sf);
|
|
5615
|
+
if (!fn2) continue;
|
|
5261
5616
|
try {
|
|
5262
5617
|
const analysed = analyzeHandler(p, {
|
|
5263
5618
|
rel,
|
|
@@ -5266,9 +5621,9 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5266
5621
|
kind: "page",
|
|
5267
5622
|
route: dir,
|
|
5268
5623
|
method: "PAGE",
|
|
5269
|
-
fn:
|
|
5270
|
-
node:
|
|
5271
|
-
wrapper:
|
|
5624
|
+
fn: fn2.fn,
|
|
5625
|
+
node: fn2.node,
|
|
5626
|
+
wrapper: fn2.wrapper
|
|
5272
5627
|
});
|
|
5273
5628
|
guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
|
|
5274
5629
|
} catch {
|
|
@@ -5280,7 +5635,7 @@ function layoutGuardsFor(p, dir, cache) {
|
|
|
5280
5635
|
return guards;
|
|
5281
5636
|
}
|
|
5282
5637
|
function analyzeHandler(p, h) {
|
|
5283
|
-
const { rel, sf, fn } = h;
|
|
5638
|
+
const { rel, sf, fn: fn2 } = h;
|
|
5284
5639
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
5285
5640
|
const acc = {
|
|
5286
5641
|
inputs: [],
|
|
@@ -5295,7 +5650,7 @@ function analyzeHandler(p, h) {
|
|
|
5295
5650
|
rel,
|
|
5296
5651
|
sf,
|
|
5297
5652
|
facts: h.facts,
|
|
5298
|
-
fn,
|
|
5653
|
+
fn: fn2,
|
|
5299
5654
|
depth: 0,
|
|
5300
5655
|
via: [],
|
|
5301
5656
|
inputNames: /* @__PURE__ */ new Set(["params", "searchParams"]),
|
|
@@ -5313,13 +5668,13 @@ function analyzeHandler(p, h) {
|
|
|
5313
5668
|
pathPos: [],
|
|
5314
5669
|
exitPropagates: true
|
|
5315
5670
|
};
|
|
5316
|
-
const first =
|
|
5671
|
+
const first = fn2.parameters[0];
|
|
5317
5672
|
if (h.kind === "route" && first) {
|
|
5318
5673
|
if (ts11.isIdentifier(first.name)) frame.reqNames.add(first.name.text);
|
|
5319
5674
|
else for (const nm of boundNames(first.name)) if (REQUEST_NAME.test(nm)) frame.reqNames.add(nm);
|
|
5320
5675
|
}
|
|
5321
5676
|
if (h.kind === "server_action") {
|
|
5322
|
-
const params = h.wrapper ?
|
|
5677
|
+
const params = h.wrapper ? fn2.parameters.slice(0, 1) : fn2.parameters;
|
|
5323
5678
|
for (const prm of params) {
|
|
5324
5679
|
for (const nm of boundNames(prm.name)) {
|
|
5325
5680
|
if (!acc.inputs.some((i) => i.kind === "action_arg" && i.name === nm)) {
|
|
@@ -5578,7 +5933,7 @@ function callerCheckingFunctions(model) {
|
|
|
5578
5933
|
);
|
|
5579
5934
|
}
|
|
5580
5935
|
var CALL2 = /(?:"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\(/g;
|
|
5581
|
-
function
|
|
5936
|
+
function qualified2(schema, name) {
|
|
5582
5937
|
const s = (schema ?? "public").toLowerCase();
|
|
5583
5938
|
const n = (name ?? "").toLowerCase();
|
|
5584
5939
|
return s === "public" ? n : `${s}.${n}`;
|
|
@@ -5586,7 +5941,7 @@ function qualified(schema, name) {
|
|
|
5586
5941
|
function callsFunctionIn(expr, names) {
|
|
5587
5942
|
if (names.size === 0) return false;
|
|
5588
5943
|
for (const c of expr.replace(/'(?:[^']|'')*'/g, "''").matchAll(CALL2)) {
|
|
5589
|
-
if (names.has(
|
|
5944
|
+
if (names.has(qualified2(c[1], c[2]))) return true;
|
|
5590
5945
|
}
|
|
5591
5946
|
return false;
|
|
5592
5947
|
}
|
|
@@ -5654,14 +6009,14 @@ function handlerViews(ctx) {
|
|
|
5654
6009
|
function queryViews(ctx, handler) {
|
|
5655
6010
|
return ctx.graph.out(handler.id, "CALLS").map((query) => {
|
|
5656
6011
|
const client = ctx.graph.out(query.id, "USES_CLIENT")[0];
|
|
5657
|
-
const
|
|
6012
|
+
const table2 = ctx.graph.out(query.id, "TARGETS")[0];
|
|
5658
6013
|
return {
|
|
5659
6014
|
query,
|
|
5660
6015
|
data: query.data,
|
|
5661
6016
|
client,
|
|
5662
6017
|
clientData: client?.data,
|
|
5663
|
-
table,
|
|
5664
|
-
tableData:
|
|
6018
|
+
table: table2,
|
|
6019
|
+
tableData: table2?.data
|
|
5665
6020
|
};
|
|
5666
6021
|
});
|
|
5667
6022
|
}
|
|
@@ -5696,16 +6051,16 @@ function anonReadPolicy(t) {
|
|
|
5696
6051
|
(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
6052
|
);
|
|
5698
6053
|
}
|
|
5699
|
-
function publicReadNote(p,
|
|
5700
|
-
return ` public.${
|
|
6054
|
+
function publicReadNote(p, table2) {
|
|
6055
|
+
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
6056
|
}
|
|
5702
6057
|
function adminOnly(ctx, h, t) {
|
|
5703
6058
|
const check = h.roleChecks[0];
|
|
5704
6059
|
if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
|
|
5705
6060
|
return check;
|
|
5706
6061
|
}
|
|
5707
|
-
function singleTenantTable(ctx,
|
|
5708
|
-
const info = ctx.model.tables.find((x) => x.table ===
|
|
6062
|
+
function singleTenantTable(ctx, table2) {
|
|
6063
|
+
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
5709
6064
|
if (!info || info.columns.some((c) => isScopeColumn(c))) return false;
|
|
5710
6065
|
for (const col of info.columnInfo ?? []) {
|
|
5711
6066
|
if (!col.references || col.nullable) continue;
|
|
@@ -5714,16 +6069,16 @@ function singleTenantTable(ctx, table) {
|
|
|
5714
6069
|
}
|
|
5715
6070
|
return true;
|
|
5716
6071
|
}
|
|
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.${
|
|
6072
|
+
function adminOnlyNote(check, table2) {
|
|
6073
|
+
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
6074
|
}
|
|
5720
|
-
function tableDataOf(ctx,
|
|
5721
|
-
return ctx.graph.nodes.get(`table:${
|
|
6075
|
+
function tableDataOf(ctx, table2) {
|
|
6076
|
+
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
5722
6077
|
}
|
|
5723
6078
|
function callerCheck(checks) {
|
|
5724
6079
|
return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
|
|
5725
6080
|
}
|
|
5726
|
-
function guardTiesRowToCaller(guard,
|
|
6081
|
+
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
5727
6082
|
const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
|
|
5728
6083
|
if (callerFilter) {
|
|
5729
6084
|
return {
|
|
@@ -5747,10 +6102,10 @@ function guardTiesRowToCaller(guard, table, callerFns) {
|
|
|
5747
6102
|
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
6103
|
};
|
|
5749
6104
|
}
|
|
5750
|
-
const reads = (
|
|
6105
|
+
const reads = (table2?.policyDetails ?? []).filter(
|
|
5751
6106
|
(p) => p.command === "select" || p.command === "all"
|
|
5752
6107
|
);
|
|
5753
|
-
const scoped =
|
|
6108
|
+
const scoped = table2?.known === true && table2.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
|
|
5754
6109
|
return {
|
|
5755
6110
|
tied: scoped,
|
|
5756
6111
|
how: `through ${guard.clientName ?? "a user-scoped client"}, so RLS applied`,
|
|
@@ -5868,7 +6223,7 @@ var tableWithoutRls = {
|
|
|
5868
6223
|
],
|
|
5869
6224
|
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
6225
|
title: `Table "${t.table}" is exposed without RLS`,
|
|
5871
|
-
data: { deterministic: true, ruleId: this.id },
|
|
6226
|
+
data: { deterministic: true, ruleId: this.id, table: t.table },
|
|
5872
6227
|
tail: locations(v.table?.location)
|
|
5873
6228
|
}));
|
|
5874
6229
|
addReach(g, h, v, `supabase.${v.data.operation}:public.${t.table}`);
|
|
@@ -6712,7 +7067,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6712
7067
|
return out;
|
|
6713
7068
|
}
|
|
6714
7069
|
};
|
|
6715
|
-
var
|
|
7070
|
+
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
6716
7071
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
6717
7072
|
id: "supabase.security-definer-function-without-caller-check",
|
|
6718
7073
|
title: "SECURITY DEFINER function without a caller check",
|
|
@@ -6729,22 +7084,22 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6729
7084
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
6730
7085
|
}
|
|
6731
7086
|
const out = [];
|
|
6732
|
-
for (const
|
|
6733
|
-
if (!
|
|
6734
|
-
if (
|
|
6735
|
-
if (
|
|
6736
|
-
const roles =
|
|
7087
|
+
for (const fn2 of fns) {
|
|
7088
|
+
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
7089
|
+
if (fn2.name.includes(".")) continue;
|
|
7090
|
+
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7091
|
+
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
6737
7092
|
if (roles.length === 0) continue;
|
|
6738
7093
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
6739
|
-
const sites = rpcByName.get(
|
|
7094
|
+
const sites = rpcByName.get(fn2.name) ?? [];
|
|
6740
7095
|
const entries = unique(sites.map((s) => s.handlerData.entry));
|
|
6741
|
-
const endpoint = `POST /rest/v1/rpc/${
|
|
7096
|
+
const endpoint = `POST /rest/v1/rpc/${fn2.name}`;
|
|
6742
7097
|
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("${
|
|
7098
|
+
const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${fn2.name}") from ${entries.join(", ")}.` : "";
|
|
6744
7099
|
const path = [
|
|
6745
7100
|
entries[0] ?? endpoint,
|
|
6746
|
-
`supabase.rpc("${
|
|
6747
|
-
`public.${
|
|
7101
|
+
`supabase.rpc("${fn2.name}") (EXECUTE: ${roles.join(", ")})`,
|
|
7102
|
+
`public.${fn2.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
|
|
6748
7103
|
"no auth.uid() / auth.jwt() check"
|
|
6749
7104
|
];
|
|
6750
7105
|
out.push(
|
|
@@ -6752,27 +7107,27 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6752
7107
|
ctx,
|
|
6753
7108
|
this,
|
|
6754
7109
|
{
|
|
6755
|
-
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${
|
|
7110
|
+
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${fn2.name}" without a caller check`,
|
|
6756
7111
|
entrypoints: [...entries, endpoint],
|
|
6757
7112
|
sources: unique([
|
|
6758
7113
|
...sites.flatMap((s) => s.inputs.map((i) => `${i.kind}:${i.name}`)),
|
|
6759
7114
|
"rpc arguments"
|
|
6760
7115
|
]),
|
|
6761
|
-
sinks: [`postgres.function:public.${
|
|
7116
|
+
sinks: [`postgres.function:public.${fn2.name}`],
|
|
6762
7117
|
path,
|
|
6763
7118
|
evidence: [
|
|
6764
7119
|
{
|
|
6765
7120
|
kind: "rule",
|
|
6766
|
-
summary: `public.${
|
|
7121
|
+
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
7122
|
locations: locations3(
|
|
6768
|
-
|
|
7123
|
+
fn2.location,
|
|
6769
7124
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
6770
7125
|
),
|
|
6771
7126
|
data: {
|
|
6772
7127
|
deterministic: false,
|
|
6773
7128
|
ruleId: this.id,
|
|
6774
|
-
function:
|
|
6775
|
-
grantedTo: [...
|
|
7129
|
+
function: fn2.name,
|
|
7130
|
+
grantedTo: [...fn2.grantedTo]
|
|
6776
7131
|
}
|
|
6777
7132
|
},
|
|
6778
7133
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
@@ -6825,9 +7180,9 @@ function applyPublicTables(findings, publicTables2, now) {
|
|
|
6825
7180
|
if (f.sinks.length === 0) return f;
|
|
6826
7181
|
const tables = [];
|
|
6827
7182
|
for (const sink of f.sinks) {
|
|
6828
|
-
const
|
|
6829
|
-
if (
|
|
6830
|
-
if (!tables.includes(
|
|
7183
|
+
const table2 = READ_SINK.exec(sink)?.[1];
|
|
7184
|
+
if (table2 === void 0 || !declared.has(table2.toLowerCase())) return f;
|
|
7185
|
+
if (!tables.includes(table2)) tables.push(table2);
|
|
6831
7186
|
}
|
|
6832
7187
|
const command = f.evidence[0]?.data?.command;
|
|
6833
7188
|
if (f.ruleId === "supabase.rls-policy-without-caller-predicate" && command !== "select") {
|
|
@@ -7050,7 +7405,13 @@ function runScan(path, opts = {}) {
|
|
|
7050
7405
|
const graph = buildGraph(model);
|
|
7051
7406
|
const publicTables2 = cfg.config.publicTables ?? [];
|
|
7052
7407
|
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
|
|
7053
|
-
const
|
|
7408
|
+
const rulesFindings = runRules(defaultRules, model, graph, runOpts);
|
|
7409
|
+
const migrationsDir = repoDirs.dirs[0] ?? "supabase/migrations";
|
|
7410
|
+
const findings = rulesFindings.map((f) => {
|
|
7411
|
+
if (f.status === "suppressed") return f;
|
|
7412
|
+
const fix = deterministicFix(f, model, migrationsDir);
|
|
7413
|
+
return fix ? { ...f, fix } : f;
|
|
7414
|
+
});
|
|
7054
7415
|
const coverage = summarizeCoverage(findings);
|
|
7055
7416
|
const summary = summarize(model, defaultRules.length, publicTables2);
|
|
7056
7417
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|