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.
Files changed (2) hide show
  1. package/dist/auditai-scan.mjs +478 -117
  2. package/package.json +1 -1
@@ -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 = (table) => {
225
- const info = tableInfo.get(table.toLowerCase());
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:${table}`,
483
+ id: `table:${table2}`,
237
484
  kind: "Table",
238
- label: `public.${table}`,
485
+ label: `public.${table2}`,
239
486
  data: { ...data },
240
487
  location: info.location
241
- } : { id: `table:${table}`, kind: "Table", label: `public.${table}`, data: { ...data } }
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:${table}:${p}`,
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(fn) {
750
- if (!fn.body) return [];
751
- if (!ts.isBlock(fn.body)) return [fn.body];
996
+ function ownReturns(fn2) {
997
+ if (!fn2.body) return [];
998
+ if (!ts.isBlock(fn2.body)) return [fn2.body];
752
999
  const out = [];
753
- walkOwn(fn.body, (n) => {
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(fn) {
1171
+ function ownDeclarations(fn2) {
925
1172
  const out = [];
926
- if (fn.body) {
927
- walkOwn(fn.body, (n) => {
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(fn, sf) {
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(fn), scope);
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(fn, sf) {
1081
- if (!fn.body) return [];
1082
- const body = fn.body;
1083
- const secrets = secretScopeFor(fn, sf);
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 fn = enclosingFunction(node);
1197
- const scope = fn ? fn.body : node.getSourceFile();
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
- i = groupEnd(tk, q.next) + 1;
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 fn = {
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, fn);
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 fn = key === void 0 ? void 0 : reg.byKey.get(key);
2534
- if (!fn || key === void 0) return;
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")) fn.securityDefiner = true;
2539
- else if (isWord(tk[i + 1], "invoker")) fn.securityDefiner = false;
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: fn.schema, name, next: 0 };
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: fn.name, next: 0 };
2902
+ if (schema) moved = { schema, name: fn2.name, next: 0 };
2549
2903
  }
2550
2904
  if (!moved) return;
2551
2905
  reg.byKey.delete(key);
2552
- fn.schema = moved.schema;
2553
- fn.name = moved.name;
2554
- reg.byKey.set(qualifiedKey(moved), fn);
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 fn = reg.byKey.get(k);
2610
- return fn ? [fn] : [];
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
- (fn) => schemas.includes((fn.schema ?? "public").toLowerCase())
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 fn of targets) applyToAcl(fn.acl, grant, roles);
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, table) {
2930
- if (table === "auth.users") return ["id"];
2931
- return state.meta.get(table)?.pk ?? [];
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, table, column) {
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 !== table || column !== void 0 && r.column !== column) continue;
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 fn = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
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(fn) || fn === "table") && tableName !== null) {
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, fn) {
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, table] of facts.drizzleTables) scope.set(name, { kind: "table", table });
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 fn = f.functions.get(ref.imported);
4078
- if (!fn?.exported) continue;
4079
- const sym = ownFunctionSym(f, ref.imported, fn.fn);
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(fn) {
4100
- if (!fn.body) return [];
4101
- if (!ts11.isBlock(fn.body)) return [fn.body];
4102
- return collect(fn.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
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 = fn.body ?? fn;
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 = fn.body ?? fn;
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 fn2 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
4620
- const param = fn2 && (ts11.isArrowFunction(fn2) || ts11.isFunctionExpression(fn2)) ? fn2.parameters[0] : void 0;
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(fn, sf)) {
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(table) {
5141
- if (table.endsWith("ies")) return `${table.slice(0, -3)}y`;
5142
- if (table.endsWith("ses") || table.endsWith("xes")) return table.slice(0, -2);
5143
- return table.endsWith("s") ? table.slice(0, -1) : table;
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, table) {
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(table);
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 fn = pageHandlerIn(sf);
5260
- if (!fn) continue;
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: fn.fn,
5270
- node: fn.node,
5271
- wrapper: fn.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 = fn.parameters[0];
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 ? fn.parameters.slice(0, 1) : fn.parameters;
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 qualified(schema, name) {
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(qualified(c[1], c[2]))) return true;
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 table = ctx.graph.out(query.id, "TARGETS")[0];
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: table?.data
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, table) {
5700
- return ` public.${table} 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.`;
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, table) {
5708
- const info = ctx.model.tables.find((x) => x.table === table.toLowerCase());
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, table) {
5718
- return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${table} 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.`;
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, table) {
5721
- return ctx.graph.nodes.get(`table:${table}`)?.data;
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, table, callerFns) {
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 = (table?.policyDetails ?? []).filter(
6105
+ const reads = (table2?.policyDetails ?? []).filter(
5751
6106
  (p) => p.command === "select" || p.command === "all"
5752
6107
  );
5753
- const scoped = table?.known === true && table.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
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 API_ROLES = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
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 fn of fns) {
6733
- if (!fn.securityDefiner || fn.checksCaller) continue;
6734
- if (fn.name.includes(".")) continue;
6735
- if (fn.returns === "trigger" || fn.returns === "event_trigger") continue;
6736
- const roles = fn.grantedTo.filter((r) => API_ROLES.has(r));
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(fn.name) ?? [];
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/${fn.name}`;
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("${fn.name}") from ${entries.join(", ")}.` : "";
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("${fn.name}") (EXECUTE: ${roles.join(", ")})`,
6747
- `public.${fn.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
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 "${fn.name}" without a caller check`,
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.${fn.name}`],
7116
+ sinks: [`postgres.function:public.${fn2.name}`],
6762
7117
  path,
6763
7118
  evidence: [
6764
7119
  {
6765
7120
  kind: "rule",
6766
- summary: `public.${fn.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.`,
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
- fn.location,
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: fn.name,
6775
- grantedTo: [...fn.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 table = READ_SINK.exec(sink)?.[1];
6829
- if (table === void 0 || !declared.has(table.toLowerCase())) return f;
6830
- if (!tables.includes(table)) tables.push(table);
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 findings = runRules(defaultRules, model, graph, runOpts);
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.6.1",
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",