auditai-scan 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/auditai-scan.mjs +597 -124
  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,222 @@ function formatScanText(r) {
190
197
  // packages/scanner/src/scan.ts
191
198
  import { statSync as statSync2 } from "node:fs";
192
199
 
200
+ // packages/fixes/dist/sql-fixes.js
201
+ var OWNER_COLUMNS = [
202
+ "user_id",
203
+ "owner_id",
204
+ "profile_id",
205
+ "author_id",
206
+ "created_by",
207
+ "account_id",
208
+ "tenant_id",
209
+ "organization_id",
210
+ "org_id",
211
+ "workspace_id",
212
+ "team_id"
213
+ ];
214
+ var API_ROLES = "public, anon, authenticated";
215
+ function table(model, name) {
216
+ if (!name)
217
+ return void 0;
218
+ const key = name.toLowerCase();
219
+ return model.tables.find((t) => t.table.toLowerCase() === key);
220
+ }
221
+ function ownerColumn(t) {
222
+ if (!t)
223
+ return null;
224
+ const cols = t.columns.map((c) => c.toLowerCase());
225
+ for (const candidate of OWNER_COLUMNS)
226
+ if (cols.includes(candidate))
227
+ return candidate;
228
+ return null;
229
+ }
230
+ function qualified(name) {
231
+ return name.includes(".") ? name : `public.${name}`;
232
+ }
233
+ function fn(model, name) {
234
+ if (!name)
235
+ return void 0;
236
+ const key = name.toLowerCase();
237
+ return (model.sqlFunctions ?? []).find((f) => f.name.toLowerCase() === key);
238
+ }
239
+ function signatureOf(f) {
240
+ const name = f.name.includes(".") ? f.name : `public.${f.name}`;
241
+ return f.args === void 0 ? `${name}(...)` : `${name}(${f.args})`;
242
+ }
243
+ var HEADER = (title) => `-- ${title}
244
+ -- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
245
+ `;
246
+ function sqlFunctionFix(finding4, model) {
247
+ const name = finding4.evidence[0]?.data?.function;
248
+ const f = fn(model, typeof name === "string" ? name : void 0);
249
+ if (!f)
250
+ return null;
251
+ const sig = signatureOf(f);
252
+ const ambiguous = sig.endsWith("(...)");
253
+ const body = [
254
+ HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
255
+ ambiguous ? "-- The argument types could not be read from the migrations; put the real signature in\n-- place of (...) before applying. `\\df public.*` in psql prints it.\n" : "",
256
+ `revoke execute on function ${sig} from ${API_ROLES};
257
+ `,
258
+ "-- Leave this line out if nothing calls the function with the service role.\n",
259
+ `grant execute on function ${sig} to service_role;
260
+ `
261
+ ].join("");
262
+ return {
263
+ file: `fix_revoke_execute_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
264
+ sql: body,
265
+ summary: `Revoke execute on ${f.name} from the API roles`,
266
+ rationale: "The function runs as its creator, so row level security does not apply inside it, and Supabase grants execute on new functions in schema public to anon and authenticated directly. Revoking from PUBLIC alone does not remove those grants, which is why a function that looks locked down is still callable with the public key. If a browser is supposed to call it, do not apply this: add a caller check inside the body instead (`where owner_id = (select auth.uid())`)."
267
+ };
268
+ }
269
+ function enableRlsFix(finding4, model, withPolicy) {
270
+ const name = finding4.evidence[0]?.data?.table;
271
+ if (typeof name !== "string")
272
+ return null;
273
+ const t = table(model, name);
274
+ const full = qualified(name);
275
+ const owner = ownerColumn(t);
276
+ const lines = [HEADER(`Turn on row level security for ${full}`)];
277
+ lines.push(`alter table ${full} enable row level security;
278
+ `);
279
+ if (withPolicy) {
280
+ if (owner) {
281
+ lines.push(`
282
+ create policy "${name}: owner reads" on ${full}
283
+ for select to authenticated
284
+ using (${owner} = (select auth.uid()));
285
+ `, `
286
+ create policy "${name}: owner writes" on ${full}
287
+ for all to authenticated
288
+ using (${owner} = (select auth.uid()))
289
+ with check (${owner} = (select auth.uid()));
290
+ `);
291
+ } else {
292
+ lines.push(`
293
+ -- No column of ${full} ties a row to a person (looked for ${OWNER_COLUMNS.slice(0, 4).join(", ")}\u2026),
294
+ -- so no policy is proposed: with RLS on and no policy the table is readable only with the
295
+ -- service role, which is the safe default. Add a policy once you decide who owns a row.
296
+ `);
297
+ }
298
+ } else {
299
+ lines.push(`
300
+ -- The policies this table already has start applying the moment row level security is on;
301
+ -- read them once before applying, because until now they have never run.
302
+ `);
303
+ }
304
+ return {
305
+ file: `fix_enable_rls_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
306
+ sql: lines.join(""),
307
+ summary: `Enable row level security on ${full}`,
308
+ rationale: withPolicy ? "Without row level security the anon key reads and writes every row of the table through PostgREST. Turning it on denies everything by default, so apply this together with a policy, and check that your own server code uses the service role where it needs full access." : "The table already carries policies, which is what makes this a defect rather than a choice: they have no effect until row level security is on. Read them once before applying \u2014 they have never run, so they may not say what their author believed."
309
+ };
310
+ }
311
+ function anonWriteFix(finding4, model) {
312
+ const data = finding4.evidence[0]?.data ?? {};
313
+ const name = data.table;
314
+ const policy = data.policy;
315
+ const command = data.command;
316
+ if (typeof name !== "string" || typeof policy !== "string")
317
+ return null;
318
+ const full = qualified(name);
319
+ const owner = ownerColumn(table(model, name));
320
+ const cmd = typeof command === "string" ? command : "all";
321
+ const safeName = policy.replace(/"/g, '""');
322
+ const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
323
+ if (owner) {
324
+ lines.push(`drop policy "${safeName}" on ${full};
325
+ `, `
326
+ create policy "${safeName}" on ${full}
327
+ for ${cmd} to authenticated
328
+ `, cmd === "insert" ? ` with check (${owner} = (select auth.uid()));
329
+ ` : ` using (${owner} = (select auth.uid()))${cmd === "all" ? `
330
+ with check (${owner} = (select auth.uid()))` : ""};
331
+ `);
332
+ } else {
333
+ lines.push(`-- No column of ${full} ties a row to a person, so there is nothing to compare the caller
334
+ -- with. Either add one, or take the policy away and let your server write the table with the
335
+ -- service role after it has checked the caller itself.
336
+ `, `drop policy "${safeName}" on ${full};
337
+ `);
338
+ }
339
+ return {
340
+ file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
341
+ sql: lines.join(""),
342
+ summary: `Tie the write policy on ${full} to the caller`,
343
+ rationale: owner ? `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. The replacement keeps the same command and ties the row to the signed-in caller through ${owner}. If this table is meant to accept rows from strangers (a contact form, a newsletter), keep the insert open but give it a predicate on the row's shape and a rate limit.` : `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. Nothing in the table identifies an owner, so the honest fix is to remove the policy and write the table from your server after it has checked the caller.`
344
+ };
345
+ }
346
+ function userMetadataFix(finding4) {
347
+ const data = finding4.evidence[0]?.data ?? {};
348
+ const name = data.table;
349
+ const policy = data.policy;
350
+ if (typeof name !== "string" || typeof policy !== "string")
351
+ return null;
352
+ const full = qualified(name);
353
+ return {
354
+ file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
355
+ sql: [
356
+ HEADER(`Stop the policy "${policy}" on ${full} from trusting user_metadata`),
357
+ "-- Re-create the policy with the claim read from app_metadata, which only the service role\n",
358
+ "-- writes. Copy the predicate from your own migration and change the one word: replace\n",
359
+ "-- (select auth.jwt()) -> 'user_metadata' ->> '<claim>'\n",
360
+ "-- with\n",
361
+ "-- (select auth.jwt()) -> 'app_metadata' ->> '<claim>'\n",
362
+ `--
363
+ -- drop policy "${policy.replace(/"/g, '""')}" on ${full};
364
+ `,
365
+ `-- create policy "${policy.replace(/"/g, '""')}" on ${full} ... using (...);
366
+ `,
367
+ "\n-- Then set the claim where the user cannot reach it, from a server with the service role:\n",
368
+ "-- await admin.auth.admin.updateUserById(id, { app_metadata: { is_admin: true } });\n"
369
+ ].join(""),
370
+ summary: `Move the claim behind "${policy}" from user_metadata to app_metadata`,
371
+ rationale: "user_metadata is written by the user themselves with supabase.auth.updateUser({ data }), and it is copied into their next access token without review, so a policy that reads it grants itself. app_metadata can only be set with the service role. This fix is a template rather than a finished statement: the predicate belongs to your policy, and moving the claim only helps once something server-side actually sets app_metadata."
372
+ };
373
+ }
374
+ function sqlFixFor(finding4, model) {
375
+ switch (finding4.ruleId) {
376
+ case "supabase.security-definer-function-without-caller-check":
377
+ return sqlFunctionFix(finding4, model);
378
+ case "supabase.table-without-rls":
379
+ return enableRlsFix(finding4, model, true);
380
+ case "supabase.policies-without-rls-enabled":
381
+ return enableRlsFix(finding4, model, false);
382
+ case "supabase.anon-write-policy":
383
+ return anonWriteFix(finding4, model);
384
+ case "supabase.rls-policy-trusts-user-metadata":
385
+ return userMetadataFix(finding4);
386
+ default:
387
+ return null;
388
+ }
389
+ }
390
+ function addFileDiff(path, body) {
391
+ const lines = body.replace(/\n$/, "").split("\n");
392
+ return [
393
+ `diff --git a/${path} b/${path}`,
394
+ "new file mode 100644",
395
+ "--- /dev/null",
396
+ `+++ b/${path}`,
397
+ `@@ -0,0 +1,${lines.length} @@`,
398
+ ...lines.map((l) => `+${l}`),
399
+ ""
400
+ ].join("\n");
401
+ }
402
+ function deterministicFix(finding4, model, migrationsDir = "supabase/migrations") {
403
+ const fix = sqlFixFor(finding4, model);
404
+ if (!fix)
405
+ return null;
406
+ const stamp = (finding4.createdAt ?? "").replace(/\D/g, "").slice(0, 14) || "00000000000000";
407
+ const path = `${migrationsDir}/${stamp}_${fix.file}`;
408
+ return {
409
+ summary: fix.summary,
410
+ diff: addFileDiff(path, fix.sql),
411
+ touchedFiles: [path],
412
+ rationale: fix.rationale
413
+ };
414
+ }
415
+
193
416
  // packages/graph/src/graph.ts
194
417
  var SecurityGraph = class {
195
418
  nodes = /* @__PURE__ */ new Map();
@@ -221,10 +444,10 @@ var SecurityGraph = class {
221
444
  function buildGraph(model) {
222
445
  const g = new SecurityGraph();
223
446
  const tableInfo = new Map(model.tables.map((t) => [t.table, t]));
224
- const tableNode = (table) => {
225
- const info = tableInfo.get(table.toLowerCase());
447
+ const tableNode = (table2) => {
448
+ const info = tableInfo.get(table2.toLowerCase());
226
449
  const data = {
227
- table,
450
+ table: table2,
228
451
  known: info !== void 0,
229
452
  rlsEnabled: info?.rlsEnabled ?? false,
230
453
  policies: info?.policies ?? [],
@@ -233,19 +456,19 @@ function buildGraph(model) {
233
456
  };
234
457
  const node = g.addNode(
235
458
  info ? {
236
- id: `table:${table}`,
459
+ id: `table:${table2}`,
237
460
  kind: "Table",
238
- label: `public.${table}`,
461
+ label: `public.${table2}`,
239
462
  data: { ...data },
240
463
  location: info.location
241
- } : { id: `table:${table}`, kind: "Table", label: `public.${table}`, data: { ...data } }
464
+ } : { id: `table:${table2}`, kind: "Table", label: `public.${table2}`, data: { ...data } }
242
465
  );
243
466
  for (const p of data.policies) {
244
467
  const pn = g.addNode({
245
- id: `policy:${table}:${p}`,
468
+ id: `policy:${table2}:${p}`,
246
469
  kind: "RLSPolicy",
247
470
  label: p,
248
- data: { table, name: p }
471
+ data: { table: table2, name: p }
249
472
  });
250
473
  g.addEdge(node.id, pn.id, "GUARDED_BY");
251
474
  }
@@ -746,11 +969,11 @@ function walkOwn(body, visit) {
746
969
  };
747
970
  go(body);
748
971
  }
749
- function ownReturns(fn) {
750
- if (!fn.body) return [];
751
- if (!ts.isBlock(fn.body)) return [fn.body];
972
+ function ownReturns(fn2) {
973
+ if (!fn2.body) return [];
974
+ if (!ts.isBlock(fn2.body)) return [fn2.body];
752
975
  const out = [];
753
- walkOwn(fn.body, (n) => {
976
+ walkOwn(fn2.body, (n) => {
754
977
  if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
755
978
  });
756
979
  return out;
@@ -921,10 +1144,10 @@ function growNames(decls, scope) {
921
1144
  if (scope.names.size === before) break;
922
1145
  }
923
1146
  }
924
- function ownDeclarations(fn) {
1147
+ function ownDeclarations(fn2) {
925
1148
  const out = [];
926
- if (fn.body) {
927
- walkOwn(fn.body, (n) => {
1149
+ if (fn2.body) {
1150
+ walkOwn(fn2.body, (n) => {
928
1151
  if (ts3.isVariableDeclaration(n)) out.push(n);
929
1152
  });
930
1153
  }
@@ -953,14 +1176,29 @@ function moduleSecretScope(sf) {
953
1176
  moduleScopes.set(sf, scope);
954
1177
  return scope;
955
1178
  }
956
- function secretScopeFor(fn, sf) {
1179
+ function secretScopeFor(fn2, sf) {
957
1180
  const mod = moduleSecretScope(sf);
958
1181
  const scope = { names: new Set(mod.names), fns: mod.fns };
959
- growNames(ownDeclarations(fn), scope);
1182
+ growNames(ownDeclarations(fn2), scope);
960
1183
  return scope;
961
1184
  }
1185
+ var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
1186
+ function readsStoredSecret(e) {
1187
+ let hit = false;
1188
+ const visit = (n) => {
1189
+ if (hit) return;
1190
+ if (ts3.isPropertyAccessExpression(n) && STORED_SECRET_FIELD.test(n.name.text.toLowerCase())) {
1191
+ hit = true;
1192
+ return;
1193
+ }
1194
+ n.forEachChild(visit);
1195
+ };
1196
+ visit(e);
1197
+ return hit;
1198
+ }
962
1199
  function isSecretish(e, scope) {
963
1200
  if (envNamesIn(e).some(isSecretEnvName)) return true;
1201
+ if (readsStoredSecret(e)) return true;
964
1202
  for (const id of identifiersIn(e)) if (scope.names.has(id)) return true;
965
1203
  let calls = false;
966
1204
  const visit = (n) => {
@@ -984,7 +1222,7 @@ var EQUALITY = /* @__PURE__ */ new Set([
984
1222
  ts3.SyntaxKind.EqualsEqualsToken,
985
1223
  ts3.SyntaxKind.ExclamationEqualsToken
986
1224
  ]);
987
- var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?)$/i;
1225
+ var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?|secretsMatch|secretMatch|matchesSecret|tokensMatch|tokenMatches|sameSecret|checkSecret|validSecret|isValidSecret)$/i;
988
1226
  var VERIFY_CALLEE = /^(verify|verifySync|jwtVerify|constructEvent|constructEventAsync|verifySignature|verifyWebhook|validateSignature)$/i;
989
1227
  var THROWING_VERIFIER = /^(jwtVerify|constructEvent|constructEventAsync)$/;
990
1228
  var THROWING_VERIFY_RECEIVER = /jwt|jose|jsonwebtoken/i;
@@ -1035,11 +1273,39 @@ function gates(node, fnBody) {
1035
1273
  }
1036
1274
  return false;
1037
1275
  }
1038
- function secretChecksIn(fn, sf) {
1039
- if (!fn.body) return [];
1040
- const body = fn.body;
1041
- const secrets = secretScopeFor(fn, sf);
1276
+ function receiverSecret(callee, secretish, builtFromSecret) {
1277
+ if (!ts3.isPropertyAccessExpression(callee)) return false;
1278
+ const recv = callee.expression;
1279
+ if (ts3.isNewExpression(recv)) return (recv.arguments ?? []).some(secretish);
1280
+ if (ts3.isIdentifier(recv) && builtFromSecret.has(recv.text)) return true;
1281
+ return secretish(recv);
1282
+ }
1283
+ function instancesBuiltFromSecret(body, secretish) {
1284
+ const out = /* @__PURE__ */ new Set();
1285
+ for (const d of collect(body, ts3.isVariableDeclaration)) {
1286
+ if (!d.initializer || !ts3.isIdentifier(d.name) || !ts3.isNewExpression(d.initializer)) continue;
1287
+ if ((d.initializer.arguments ?? []).some(secretish)) out.add(d.name.text);
1288
+ }
1289
+ return out;
1290
+ }
1291
+ function gatesByThrow(node, fnBody) {
1292
+ let child = node;
1293
+ let cur = node.parent;
1294
+ while (cur && cur !== fnBody && !isFunctionLikeNode(cur)) {
1295
+ if (ts3.isTryStatement(cur) && cur.tryBlock === child && cur.catchClause) {
1296
+ return exitKind(cur.catchClause.block) !== null;
1297
+ }
1298
+ child = cur;
1299
+ cur = cur.parent;
1300
+ }
1301
+ return false;
1302
+ }
1303
+ function secretChecksIn(fn2, sf) {
1304
+ if (!fn2.body) return [];
1305
+ const body = fn2.body;
1306
+ const secrets = secretScopeFor(fn2, sf);
1042
1307
  const secretish = (e) => isSecretish(e, secrets);
1308
+ const builtFromSecret = instancesBuiltFromSecret(body, secretish);
1043
1309
  const out = [];
1044
1310
  const push = (n) => {
1045
1311
  out.push({ node: n, text: n.getText(sf).replace(/\s+/g, " ").slice(0, 160) });
@@ -1058,8 +1324,9 @@ function secretChecksIn(fn, sf) {
1058
1324
  const verify = VERIFY_CALLEE.test(name);
1059
1325
  if (!compare && !verify) return;
1060
1326
  const secretArgs = n.arguments.filter(secretish).length;
1061
- if (secretArgs === 0 || secretArgs === n.arguments.length) return;
1062
- if (verify && throwsOnBadCredential(n) || gates(n, body)) push(n);
1327
+ const receiverHoldsSecret = verify && receiverSecret(n.expression, secretish, builtFromSecret);
1328
+ if (!receiverHoldsSecret && (secretArgs === 0 || secretArgs === n.arguments.length)) return;
1329
+ if (verify && throwsOnBadCredential(n) || gates(n, body) || gatesByThrow(n, body)) push(n);
1063
1330
  });
1064
1331
  return out;
1065
1332
  }
@@ -1086,7 +1353,7 @@ function nextAuthSessionNames(stmt, nextAuthLocal) {
1086
1353
  }
1087
1354
  return out;
1088
1355
  }
1089
- var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret)$/;
1356
+ var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret|token|sessioncode|invitecode|accesscode|sharecode)$/;
1090
1357
  function isCredentialColumn(column) {
1091
1358
  return column !== null && CREDENTIAL_COLUMN.test(column.toLowerCase().replace(/_/g, ""));
1092
1359
  }
@@ -1149,8 +1416,8 @@ function mentions(cond, names) {
1149
1416
  return false;
1150
1417
  }
1151
1418
  function ifsAfter(node) {
1152
- const fn = enclosingFunction(node);
1153
- const scope = fn ? fn.body : node.getSourceFile();
1419
+ const fn2 = enclosingFunction(node);
1420
+ const scope = fn2 ? fn2.body : node.getSourceFile();
1154
1421
  const out = [];
1155
1422
  if (!scope) return out;
1156
1423
  walkOwn(scope, (n) => {
@@ -2417,7 +2684,9 @@ function applyCreateFunction(reg, stmt, file) {
2417
2684
  if (!isWord(tk[i], "function")) return;
2418
2685
  const q = readQualifiedName(tk, i + 1);
2419
2686
  if (!q || !isPunct(tk[q.next], "(")) return;
2420
- i = groupEnd(tk, q.next) + 1;
2687
+ const argsEnd = groupEnd(tk, q.next);
2688
+ const args = argumentTypes(stmt, tk, q.next, argsEnd);
2689
+ i = argsEnd + 1;
2421
2690
  let securityDefiner = false;
2422
2691
  let returns = null;
2423
2692
  let body = "";
@@ -2450,17 +2719,122 @@ function applyCreateFunction(reg, stmt, file) {
2450
2719
  }
2451
2720
  const key = qualifiedKey(q);
2452
2721
  const code = maskSqlComments(body);
2453
- const fn = {
2722
+ const fn2 = {
2454
2723
  schema: q.schema,
2455
2724
  name: q.name,
2456
2725
  securityDefiner,
2457
2726
  returns,
2727
+ args,
2458
2728
  body: code,
2459
2729
  directCheck: CALLER_CHECKS.some((re) => re.test(code)),
2460
2730
  acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
2461
2731
  location: { file, line: stmt.line }
2462
2732
  };
2463
- reg.byKey.set(key, fn);
2733
+ reg.byKey.set(key, fn2);
2734
+ }
2735
+ var TYPE_WORD = /* @__PURE__ */ new Set([
2736
+ "anyarray",
2737
+ "anyelement",
2738
+ "bigint",
2739
+ "bigserial",
2740
+ "bit",
2741
+ "bool",
2742
+ "boolean",
2743
+ "box",
2744
+ "bytea",
2745
+ "char",
2746
+ "character",
2747
+ "cidr",
2748
+ "circle",
2749
+ "date",
2750
+ "decimal",
2751
+ "double",
2752
+ "float",
2753
+ "float4",
2754
+ "float8",
2755
+ "inet",
2756
+ "int",
2757
+ "int2",
2758
+ "int4",
2759
+ "int8",
2760
+ "integer",
2761
+ "interval",
2762
+ "json",
2763
+ "jsonb",
2764
+ "line",
2765
+ "lseg",
2766
+ "macaddr",
2767
+ "money",
2768
+ "name",
2769
+ "numeric",
2770
+ "oid",
2771
+ "path",
2772
+ "point",
2773
+ "polygon",
2774
+ "real",
2775
+ "record",
2776
+ "regclass",
2777
+ "serial",
2778
+ "smallint",
2779
+ "smallserial",
2780
+ "text",
2781
+ "time",
2782
+ "timestamp",
2783
+ "timestamptz",
2784
+ "timetz",
2785
+ "trigger",
2786
+ "tsquery",
2787
+ "tsvector",
2788
+ "uuid",
2789
+ "varbit",
2790
+ "varchar",
2791
+ "void",
2792
+ "xml"
2793
+ ]);
2794
+ function argumentTypes(stmt, tokens, open, close) {
2795
+ if (close <= open + 1) return "";
2796
+ const parts = [];
2797
+ let depth = 0;
2798
+ let start = open + 1;
2799
+ const pieces = [];
2800
+ for (let i = open + 1; i < close; i++) {
2801
+ const t = tokens[i];
2802
+ if (isPunct(t, "(") || isPunct(t, "[")) depth += 1;
2803
+ else if (isPunct(t, ")") || isPunct(t, "]")) depth -= 1;
2804
+ else if (depth === 0 && isPunct(t, ",")) {
2805
+ pieces.push([start, i]);
2806
+ start = i + 1;
2807
+ }
2808
+ }
2809
+ pieces.push([start, close]);
2810
+ for (const [from, to] of pieces) {
2811
+ const words = [];
2812
+ for (let i = from; i < to; i++) {
2813
+ const t = tokens[i];
2814
+ if (!t) continue;
2815
+ if (isWord(t, "default")) break;
2816
+ if (t.kind === "punct" && t.value === "=") break;
2817
+ words.push(t);
2818
+ }
2819
+ if (words.length === 0) return null;
2820
+ let k = 0;
2821
+ if (isWord(words[k], "in") || isWord(words[k], "out") || isWord(words[k], "inout")) {
2822
+ if (isWord(words[k], "out")) continue;
2823
+ k += 1;
2824
+ } else if (isWord(words[k], "variadic")) {
2825
+ k += 1;
2826
+ }
2827
+ const firstWord = words[k];
2828
+ const named = firstWord !== void 0 && firstWord.kind === "word" && !TYPE_WORD.has(firstWord.value.toLowerCase()) && words.slice(k + 1).some((w) => w.kind === "word");
2829
+ const typeStart = named ? k + 1 : k;
2830
+ const first = words[typeStart];
2831
+ const last = words[words.length - 1];
2832
+ if (!first || !last) return null;
2833
+ parts.push(
2834
+ stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
2835
+ );
2836
+ }
2837
+ return parts.join(", ");
2464
2838
  }
2465
2839
  function readFunctionList(tokens, from) {
2466
2840
  const keys = [];
@@ -2486,28 +2860,28 @@ function applyAlterFunction(reg, stmt) {
2486
2860
  const tk = stmt.tokens;
2487
2861
  const list = readFunctionList(tk, 2);
2488
2862
  const key = list.keys[0];
2489
- const fn = key === void 0 ? void 0 : reg.byKey.get(key);
2490
- if (!fn || key === void 0) return;
2863
+ const fn2 = key === void 0 ? void 0 : reg.byKey.get(key);
2864
+ if (!fn2 || key === void 0) return;
2491
2865
  let i = list.next;
2492
2866
  if (isWord(tk[i], "external")) i += 1;
2493
2867
  if (isWord(tk[i], "security")) {
2494
- if (isWord(tk[i + 1], "definer")) fn.securityDefiner = true;
2495
- else if (isWord(tk[i + 1], "invoker")) fn.securityDefiner = false;
2868
+ if (isWord(tk[i + 1], "definer")) fn2.securityDefiner = true;
2869
+ else if (isWord(tk[i + 1], "invoker")) fn2.securityDefiner = false;
2496
2870
  return;
2497
2871
  }
2498
2872
  let moved = null;
2499
2873
  if (isWord(tk[i], "rename") && isWord(tk[i + 1], "to")) {
2500
2874
  const name = identOf(tk[i + 2]);
2501
- if (name) moved = { schema: fn.schema, name, next: 0 };
2875
+ if (name) moved = { schema: fn2.schema, name, next: 0 };
2502
2876
  } else if (isWord(tk[i], "set") && isWord(tk[i + 1], "schema")) {
2503
2877
  const schema = identOf(tk[i + 2]);
2504
- if (schema) moved = { schema, name: fn.name, next: 0 };
2878
+ if (schema) moved = { schema, name: fn2.name, next: 0 };
2505
2879
  }
2506
2880
  if (!moved) return;
2507
2881
  reg.byKey.delete(key);
2508
- fn.schema = moved.schema;
2509
- fn.name = moved.name;
2510
- reg.byKey.set(qualifiedKey(moved), fn);
2882
+ fn2.schema = moved.schema;
2883
+ fn2.name = moved.name;
2884
+ reg.byKey.set(qualifiedKey(moved), fn2);
2511
2885
  }
2512
2886
  function readRoles(tokens, from) {
2513
2887
  const roles = [];
@@ -2562,8 +2936,8 @@ function applyGrantRevoke(reg, stmt) {
2562
2936
  if (isWord(tk[i], "function") || isWord(tk[i], "routine")) {
2563
2937
  const list = readFunctionList(tk, i + 1);
2564
2938
  targets = list.keys.flatMap((k) => {
2565
- const fn = reg.byKey.get(k);
2566
- return fn ? [fn] : [];
2939
+ const fn2 = reg.byKey.get(k);
2940
+ return fn2 ? [fn2] : [];
2567
2941
  });
2568
2942
  i = list.next;
2569
2943
  } else if (isWord(tk[i], "all") && (isWord(tk[i + 1], "functions") || isWord(tk[i + 1], "routines")) && isWord(tk[i + 2], "in") && isWord(tk[i + 3], "schema")) {
@@ -2578,7 +2952,7 @@ function applyGrantRevoke(reg, stmt) {
2578
2952
  i += 1;
2579
2953
  }
2580
2954
  targets = [...reg.byKey.values()].filter(
2581
- (fn) => schemas.includes((fn.schema ?? "public").toLowerCase())
2955
+ (fn2) => schemas.includes((fn2.schema ?? "public").toLowerCase())
2582
2956
  );
2583
2957
  } else {
2584
2958
  return;
@@ -2586,7 +2960,7 @@ function applyGrantRevoke(reg, stmt) {
2586
2960
  const [start, end] = roleClause(tk, i, grant ? "to" : "from");
2587
2961
  if (start < 0) return;
2588
2962
  const roles = readRoles(tk.slice(0, end), start);
2589
- for (const fn of targets) applyToAcl(fn.acl, grant, roles);
2963
+ for (const fn2 of targets) applyToAcl(fn2.acl, grant, roles);
2590
2964
  }
2591
2965
  function applyDefaultPrivileges(reg, stmt) {
2592
2966
  const tk = stmt.tokens;
@@ -2683,6 +3057,7 @@ function finishFunctions(reg) {
2683
3057
  location: f.location
2684
3058
  };
2685
3059
  if (f.returns !== null) info.returns = f.returns;
3060
+ if (f.args !== null) info.args = f.args;
2686
3061
  return info;
2687
3062
  });
2688
3063
  }
@@ -2882,9 +3257,9 @@ function sync(state, key) {
2882
3257
  function findColumn(meta, name) {
2883
3258
  return meta.columns.find((c) => c.name === name);
2884
3259
  }
2885
- function pkOf(state, table) {
2886
- if (table === "auth.users") return ["id"];
2887
- return state.meta.get(table)?.pk ?? [];
3260
+ function pkOf(state, table2) {
3261
+ if (table2 === "auth.users") return ["id"];
3262
+ return state.meta.get(table2)?.pk ?? [];
2888
3263
  }
2889
3264
  function resolveRefs(state, meta) {
2890
3265
  for (const col of meta.columns) {
@@ -2893,12 +3268,12 @@ function resolveRefs(state, meta) {
2893
3268
  }
2894
3269
  }
2895
3270
  }
2896
- function clearRefsTo(state, table, column) {
3271
+ function clearRefsTo(state, table2, column) {
2897
3272
  for (const [key, meta] of state.meta) {
2898
3273
  let touched = false;
2899
3274
  for (const col of meta.columns) {
2900
3275
  const r = col.references;
2901
- if (!r || r.table !== table || column !== void 0 && r.column !== column) continue;
3276
+ if (!r || r.table !== table2 || column !== void 0 && r.column !== column) continue;
2902
3277
  col.references = null;
2903
3278
  col.fkName = null;
2904
3279
  touched = true;
@@ -3748,9 +4123,9 @@ function analyzeModule(rel, sf) {
3748
4123
  if (!ts9.isCallExpression(init) && !ts9.isNewExpression(init)) continue;
3749
4124
  if (ts9.isCallExpression(init)) {
3750
4125
  const callee = init.expression;
3751
- const fn = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
4126
+ const fn2 = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
3752
4127
  const tableName = stringLiteralValue(init.arguments[0]);
3753
- if ((DRIZZLE_TABLE_FNS.has(fn) || fn === "table") && tableName !== null) {
4128
+ if ((DRIZZLE_TABLE_FNS.has(fn2) || fn2 === "table") && tableName !== null) {
3754
4129
  drizzleTables.set(d.name.text, tableName);
3755
4130
  continue;
3756
4131
  }
@@ -3838,10 +4213,20 @@ var LOGICAL = /* @__PURE__ */ new Set([
3838
4213
  ts10.SyntaxKind.BarBarToken,
3839
4214
  ts10.SyntaxKind.AmpersandAmpersandToken
3840
4215
  ]);
4216
+ var SCHEMA_PARSE = /^(parse|safeParse|parseAsync|safeParseAsync)$/;
4217
+ function isSchemaParse(call, cx) {
4218
+ const callee = call.expression;
4219
+ if (!ts10.isPropertyAccessExpression(callee) || !SCHEMA_PARSE.test(callee.name.text)) return false;
4220
+ const recv = callee.expression;
4221
+ if (isWholeInput(recv, cx)) return false;
4222
+ return cx.strippingSchema(recv);
4223
+ }
3841
4224
  function isWholeInput(e, cx) {
3842
4225
  const u = unwrap(e);
3843
4226
  if (ts10.isIdentifier(u)) return cx.wholeName(u.text);
3844
4227
  if (ts10.isPropertyAccessExpression(u) || ts10.isElementAccessExpression(u)) {
4228
+ const inner = unwrap(u.expression);
4229
+ if (ts10.isCallExpression(inner) && isSchemaParse(inner, cx)) return false;
3845
4230
  return isWholeInput(u.expression, cx);
3846
4231
  }
3847
4232
  if (ts10.isObjectLiteralExpression(u)) {
@@ -3861,6 +4246,7 @@ function isWholeInput(e, cx) {
3861
4246
  }
3862
4247
  function isWholeCall(call, cx) {
3863
4248
  if (cx.requestBody(call)) return true;
4249
+ if (isSchemaParse(call, cx)) return false;
3864
4250
  const callee = call.expression;
3865
4251
  if (ts10.isPropertyAccessExpression(callee)) {
3866
4252
  const method = callee.name.text;
@@ -3882,7 +4268,8 @@ function callbackKeepsWhole(cb, receiverWhole, cx) {
3882
4268
  const inner = {
3883
4269
  wholeName: (n) => elementNames.has(n) || cx.wholeName(n),
3884
4270
  requestBody: cx.requestBody,
3885
- requestName: cx.requestName
4271
+ requestName: cx.requestName,
4272
+ strippingSchema: cx.strippingSchema
3886
4273
  };
3887
4274
  return ownReturns(f).some((r) => isWholeInput(r, inner));
3888
4275
  }
@@ -3940,12 +4327,12 @@ var NO_ARG = {
3940
4327
  whole: false,
3941
4328
  isRequest: false
3942
4329
  };
3943
- function ownFunctionSym(facts, name, fn) {
4330
+ function ownFunctionSym(facts, name, fn2) {
3944
4331
  const factory = facts.clientFactories.find((c) => c.name === name);
3945
4332
  if (factory) return { kind: "factory", factory };
3946
4333
  const helper = facts.authHelpers.find((a) => a.name === name);
3947
- if (helper) return { kind: "auth", helper, fn, facts };
3948
- return { kind: "function", name, fn, facts };
4334
+ if (helper) return { kind: "auth", helper, fn: fn2, facts };
4335
+ return { kind: "function", name, fn: fn2, facts };
3949
4336
  }
3950
4337
  function exportedSym(p, tf, name, depth) {
3951
4338
  if (depth > 5) return null;
@@ -3992,7 +4379,7 @@ function scopeOf(p, facts) {
3992
4379
  for (const [name, v] of facts.moduleVars) {
3993
4380
  scope.set(name, { kind: "var", name, init: v.init, facts });
3994
4381
  }
3995
- for (const [name, table] of facts.drizzleTables) scope.set(name, { kind: "table", table });
4382
+ for (const [name, table2] of facts.drizzleTables) scope.set(name, { kind: "table", table: table2 });
3996
4383
  for (const [name, v] of facts.authVars) scope.set(name, { kind: "auth", helper: v.helper });
3997
4384
  for (const [local, ref] of facts.imports) {
3998
4385
  const target = p.resolver.resolve(ref.spec, facts.file);
@@ -4018,9 +4405,9 @@ function scopeOf(p, facts) {
4018
4405
  }
4019
4406
  if (!/^[.~]|^@\//.test(ref.spec)) continue;
4020
4407
  for (const f of p.registry.values()) {
4021
- const fn = f.functions.get(ref.imported);
4022
- if (!fn?.exported) continue;
4023
- const sym = ownFunctionSym(f, ref.imported, fn.fn);
4408
+ const fn2 = f.functions.get(ref.imported);
4409
+ if (!fn2?.exported) continue;
4410
+ const sym = ownFunctionSym(f, ref.imported, fn2.fn);
4024
4411
  if (sym.kind === "factory" || sym.kind === "auth") {
4025
4412
  scope.set(local, sym);
4026
4413
  p.warnings.push(
@@ -4040,10 +4427,10 @@ function symOfCallee(p, callee, scope) {
4040
4427
  }
4041
4428
  return void 0;
4042
4429
  }
4043
- function returnedExpressions(fn) {
4044
- if (!fn.body) return [];
4045
- if (!ts11.isBlock(fn.body)) return [fn.body];
4046
- return collect(fn.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
4430
+ function returnedExpressions(fn2) {
4431
+ if (!fn2.body) return [];
4432
+ if (!ts11.isBlock(fn2.body)) return [fn2.body];
4433
+ return collect(fn2.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
4047
4434
  }
4048
4435
  function factoryOfFunction(p, sym, depth) {
4049
4436
  const key = `${sym.facts.file}#${sym.name}`;
@@ -4207,6 +4594,7 @@ function usesInput(frame, e) {
4207
4594
  });
4208
4595
  return hit;
4209
4596
  }
4597
+ var CALLER_READ = /\b(?:await\s+)?cookies\(\)\s*(?:\.|$)|cookieStore\s*\.\s*get\s*\(|\bcookies\s*\.\s*get\s*\(|\b(?:await\s+)?headers\(\)\s*\.\s*get\s*\(/;
4210
4598
  var REQUEST_MEMBER = /^(json|formData|text|arrayBuffer|blob|body|headers|cookies|url|nextUrl|query|params|ip|geo)$/;
4211
4599
  var REQUEST_CLIENT_CALLEE = /client|supabase|prisma|drizzle/i;
4212
4600
  function handsOverRequest(frame, call) {
@@ -4275,13 +4663,28 @@ function isRequestBodyCall(frame, call) {
4275
4663
  if (frame.reqNames.has(recv.text)) return true;
4276
4664
  return frame.depth === 0 && frame.reqNames.size === 0 && REQUEST_NAME.test(recv.text);
4277
4665
  }
4278
- function wholeContext(frame) {
4666
+ function wholeContext(p, frame) {
4279
4667
  return {
4280
4668
  wholeName: (n) => frame.wholeNames.has(n),
4281
4669
  requestBody: (c) => isRequestBodyCall(frame, c),
4282
- requestName: (n) => frame.reqNames.has(n)
4670
+ requestName: (n) => frame.reqNames.has(n),
4671
+ strippingSchema: (e) => isStrippingSchema(p, frame, e)
4283
4672
  };
4284
4673
  }
4674
+ var OBJECT_SCHEMA = /\b(?:z|zod|v|valibot|yup)\s*\.\s*object\s*\(/;
4675
+ var SCHEMA_KEEPS_UNKNOWN = /\.\s*(?:passthrough|catchall|nonstrict|unknown)\s*\(/;
4676
+ function isStrippingSchema(p, frame, e) {
4677
+ const u = unwrap(e);
4678
+ if (ts11.isCallExpression(u) || ts11.isPropertyAccessExpression(u)) {
4679
+ const text2 = u.getText();
4680
+ return OBJECT_SCHEMA.test(text2) && !SCHEMA_KEEPS_UNKNOWN.test(text2);
4681
+ }
4682
+ if (!ts11.isIdentifier(u)) return false;
4683
+ const sym = scopeOf(p, frame.facts).get(u.text);
4684
+ if (sym?.kind !== "var") return false;
4685
+ const text = sym.init.getText();
4686
+ return OBJECT_SCHEMA.test(text) && !SCHEMA_KEEPS_UNKNOWN.test(text);
4687
+ }
4285
4688
  function receiverTainted(frame, call) {
4286
4689
  const callee = call.expression;
4287
4690
  if (!ts11.isPropertyAccessExpression(callee) && !ts11.isElementAccessExpression(callee)) return false;
@@ -4336,7 +4739,7 @@ function argBinding(p, arg, frame) {
4336
4739
  client,
4337
4740
  instance,
4338
4741
  tainted: derivedIn(frame, arg),
4339
- whole: isWholeInput(arg, wholeContext(frame)),
4742
+ whole: isWholeInput(arg, wholeContext(p, frame)),
4340
4743
  isRequest
4341
4744
  };
4342
4745
  }
@@ -4405,10 +4808,10 @@ function callTarget(p, call, frame, scope) {
4405
4808
  return null;
4406
4809
  }
4407
4810
  function bindDeclarations(p, frame, acc) {
4408
- const { rel, sf, fn } = frame;
4811
+ const { rel, sf, fn: fn2 } = frame;
4409
4812
  const scope = scopeOf(p, frame.facts);
4410
4813
  const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
4411
- const body = fn.body ?? fn;
4814
+ const body = fn2.body ?? fn2;
4412
4815
  for (const [name, sym] of scope) {
4413
4816
  if (sym.kind !== "var" || frame.clients.has(name) || frame.instances.has(name)) continue;
4414
4817
  const vb = varBinding(p, sym);
@@ -4466,6 +4869,11 @@ function bindDeclarations(p, frame, acc) {
4466
4869
  continue;
4467
4870
  }
4468
4871
  }
4872
+ if (CALLER_READ.test(text)) {
4873
+ if (frame.depth === 0) handlerInput("header", names, decl);
4874
+ else bindInput(names, false);
4875
+ continue;
4876
+ }
4469
4877
  if (ts11.isCallExpression(init)) {
4470
4878
  const inst = instanceOfCall(p, init, frame, scope);
4471
4879
  if (inst && ts11.isIdentifier(decl.name)) {
@@ -4478,7 +4886,7 @@ function bindDeclarations(p, frame, acc) {
4478
4886
  if (args.some((a) => a.tainted || a.isRequest) || receiverTainted(frame, init)) {
4479
4887
  const rt = returnTaint(p, init, frame, scope, 0);
4480
4888
  if (rt === null || rt.tainted && rt.props === null) {
4481
- bindInput(names, isWholeInput(init, wholeContext(frame)));
4889
+ bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4482
4890
  } else if (rt.tainted && rt.props !== null) {
4483
4891
  if (ts11.isIdentifier(decl.name))
4484
4892
  frame.partialInputs.set(decl.name.text, new Set(rt.props));
@@ -4515,7 +4923,7 @@ function bindDeclarations(p, frame, acc) {
4515
4923
  } else if (partial) bindPatternFrom(frame, decl.name, partial);
4516
4924
  }
4517
4925
  } else if (!isChainWithQuery(init) && derivedIn(frame, init)) {
4518
- bindInput(names, isWholeInput(init, wholeContext(frame)));
4926
+ bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4519
4927
  }
4520
4928
  }
4521
4929
  if (frame.depth === 0) {
@@ -4528,10 +4936,10 @@ function bindDeclarations(p, frame, acc) {
4528
4936
  }
4529
4937
  }
4530
4938
  function analyzeFrame(p, frame, acc) {
4531
- const { rel, sf, fn } = frame;
4939
+ const { rel, sf, fn: fn2 } = frame;
4532
4940
  const scope = scopeOf(p, frame.facts);
4533
4941
  const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
4534
- const body = fn.body ?? fn;
4942
+ const body = fn2.body ?? fn2;
4535
4943
  bindDeclarations(p, frame, acc);
4536
4944
  for (const call of collect(body, ts11.isCallExpression)) {
4537
4945
  const callee = call.expression;
@@ -4539,8 +4947,8 @@ function analyzeFrame(p, frame, acc) {
4539
4947
  continue;
4540
4948
  if (!ts11.isIdentifier(callee.expression)) continue;
4541
4949
  const outer = frame.clients.get(callee.expression.text);
4542
- const fn2 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
4543
- const param = fn2 && (ts11.isArrowFunction(fn2) || ts11.isFunctionExpression(fn2)) ? fn2.parameters[0] : void 0;
4950
+ const fn3 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
4951
+ const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
4544
4952
  if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
4545
4953
  }
4546
4954
  const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
@@ -4555,7 +4963,7 @@ function analyzeFrame(p, frame, acc) {
4555
4963
  text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
4556
4964
  });
4557
4965
  }
4558
- for (const check of secretChecksIn(fn, sf)) {
4966
+ for (const check of secretChecksIn(fn2, sf)) {
4559
4967
  acc.authChecks.push({ ...loc2(check.node), kind: "secret" });
4560
4968
  }
4561
4969
  for (const pa of collect(body, ts11.isPropertyAccessExpression)) {
@@ -4619,7 +5027,7 @@ function analyzeFrame(p, frame, acc) {
4619
5027
  const payloadOf = (arg) => arg ? {
4620
5028
  text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
4621
5029
  inputDerived: derivedIn(frame, arg),
4622
- wholeInput: isWholeInput(arg, wholeContext(frame))
5030
+ wholeInput: isWholeInput(arg, wholeContext(p, frame))
4623
5031
  } : null;
4624
5032
  const storageHandles = storageBindingsIn(body);
4625
5033
  let callerScope = null;
@@ -4864,7 +5272,7 @@ function childFrame(p, call, target, frame) {
4864
5272
  pathPos: [...frame.pathPos, call.getStart(frame.sf)],
4865
5273
  exitPropagates: frame.exitPropagates && callResultChecked(call)
4866
5274
  };
4867
- const cx = wholeContext(frame);
5275
+ const cx = wholeContext(p, frame);
4868
5276
  target.fn.parameters.forEach((param, i) => {
4869
5277
  const arg = call.arguments[i];
4870
5278
  const ab = argBinding(p, arg, frame);
@@ -5060,16 +5468,16 @@ function enclosingCondition(node, body) {
5060
5468
  return null;
5061
5469
  }
5062
5470
  var normalizeColumn = (c) => (c ?? "").toLowerCase().replace(/_/g, "");
5063
- function singular(table) {
5064
- if (table.endsWith("ies")) return `${table.slice(0, -3)}y`;
5065
- if (table.endsWith("ses") || table.endsWith("xes")) return table.slice(0, -2);
5066
- return table.endsWith("s") ? table.slice(0, -1) : table;
5471
+ function singular(table2) {
5472
+ if (table2.endsWith("ies")) return `${table2.slice(0, -3)}y`;
5473
+ if (table2.endsWith("ses") || table2.endsWith("xes")) return table2.slice(0, -2);
5474
+ return table2.endsWith("s") ? table2.slice(0, -1) : table2;
5067
5475
  }
5068
- function columnNamesTable(column, table) {
5476
+ function columnNamesTable(column, table2) {
5069
5477
  const c = normalizeColumn(column);
5070
5478
  if (!c.endsWith("id") || c === "id") return false;
5071
5479
  const stem = c.slice(0, -2);
5072
- const t = normalizeColumn(table);
5480
+ const t = normalizeColumn(table2);
5073
5481
  return stem === t || stem === singular(t);
5074
5482
  }
5075
5483
  function guardMatch(q, g, tables) {
@@ -5157,8 +5565,53 @@ function calleePath(e) {
5157
5565
  if (ts11.isElementAccessExpression(u)) return `${calleePath(u.expression)}[]`;
5158
5566
  return "";
5159
5567
  }
5568
+ var EMPTY_GUARDS = { authChecks: [], roleChecks: [] };
5569
+ function layoutGuards(p, pageRel, cache) {
5570
+ const parts = pageRel.split("/");
5571
+ parts.pop();
5572
+ const out = { authChecks: [], roleChecks: [] };
5573
+ for (let i = parts.length; i > 0; i--) {
5574
+ const g = layoutGuardsFor(p, parts.slice(0, i).join("/"), cache);
5575
+ out.authChecks.push(...g.authChecks);
5576
+ out.roleChecks.push(...g.roleChecks);
5577
+ }
5578
+ return out;
5579
+ }
5580
+ var LAYOUT_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
5581
+ function layoutGuardsFor(p, dir, cache) {
5582
+ const hit = cache.get(dir);
5583
+ if (hit) return hit;
5584
+ let guards = EMPTY_GUARDS;
5585
+ for (const ext of LAYOUT_EXTENSIONS) {
5586
+ const rel = `${dir}/layout.${ext}`;
5587
+ const sf = p.sources.get(rel);
5588
+ const facts = p.registry.get(rel);
5589
+ if (!sf || !facts || isClientComponentFile(sf)) continue;
5590
+ const fn2 = pageHandlerIn(sf);
5591
+ if (!fn2) continue;
5592
+ try {
5593
+ const analysed = analyzeHandler(p, {
5594
+ rel,
5595
+ sf,
5596
+ facts,
5597
+ kind: "page",
5598
+ route: dir,
5599
+ method: "PAGE",
5600
+ fn: fn2.fn,
5601
+ node: fn2.node,
5602
+ wrapper: fn2.wrapper
5603
+ });
5604
+ guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
5605
+ } catch {
5606
+ guards = EMPTY_GUARDS;
5607
+ }
5608
+ break;
5609
+ }
5610
+ cache.set(dir, guards);
5611
+ return guards;
5612
+ }
5160
5613
  function analyzeHandler(p, h) {
5161
- const { rel, sf, fn } = h;
5614
+ const { rel, sf, fn: fn2 } = h;
5162
5615
  const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
5163
5616
  const acc = {
5164
5617
  inputs: [],
@@ -5173,7 +5626,7 @@ function analyzeHandler(p, h) {
5173
5626
  rel,
5174
5627
  sf,
5175
5628
  facts: h.facts,
5176
- fn,
5629
+ fn: fn2,
5177
5630
  depth: 0,
5178
5631
  via: [],
5179
5632
  inputNames: /* @__PURE__ */ new Set(["params", "searchParams"]),
@@ -5191,13 +5644,13 @@ function analyzeHandler(p, h) {
5191
5644
  pathPos: [],
5192
5645
  exitPropagates: true
5193
5646
  };
5194
- const first = fn.parameters[0];
5647
+ const first = fn2.parameters[0];
5195
5648
  if (h.kind === "route" && first) {
5196
5649
  if (ts11.isIdentifier(first.name)) frame.reqNames.add(first.name.text);
5197
5650
  else for (const nm of boundNames(first.name)) if (REQUEST_NAME.test(nm)) frame.reqNames.add(nm);
5198
5651
  }
5199
5652
  if (h.kind === "server_action") {
5200
- const params = h.wrapper ? fn.parameters.slice(0, 1) : fn.parameters;
5653
+ const params = h.wrapper ? fn2.parameters.slice(0, 1) : fn2.parameters;
5201
5654
  for (const prm of params) {
5202
5655
  for (const nm of boundNames(prm.name)) {
5203
5656
  if (!acc.inputs.some((i) => i.kind === "action_arg" && i.name === nm)) {
@@ -5365,6 +5818,15 @@ function parseProject(rootInput, opts = {}) {
5365
5818
  storageBuckets: schema.storageBuckets
5366
5819
  };
5367
5820
  }
5821
+ var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
5822
+ function layoutCacheOf(p) {
5823
+ let m = LAYOUT_CACHE.get(p);
5824
+ if (!m) {
5825
+ m = /* @__PURE__ */ new Map();
5826
+ LAYOUT_CACHE.set(p, m);
5827
+ }
5828
+ return m;
5829
+ }
5368
5830
  function analyzeFile(project, rel, sf, facts, out) {
5369
5831
  const { routes, exposures, fileIgnores } = out;
5370
5832
  {
@@ -5423,6 +5885,11 @@ function analyzeFile(project, rel, sf, facts, out) {
5423
5885
  node: handler.node,
5424
5886
  wrapper: handler.wrapper
5425
5887
  });
5888
+ const guards = layoutGuards(project, rel, layoutCacheOf(project));
5889
+ analysed.authChecks.push(...guards.authChecks);
5890
+ if (guards.roleChecks.length > 0) {
5891
+ analysed.roleChecks = [...analysed.roleChecks ?? [], ...guards.roleChecks];
5892
+ }
5426
5893
  if (analysed.queries.length > 0 || analysed.inputs.length > 0) routes.push(analysed);
5427
5894
  }
5428
5895
  }
@@ -5442,7 +5909,7 @@ function callerCheckingFunctions(model) {
5442
5909
  );
5443
5910
  }
5444
5911
  var CALL2 = /(?:"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\(/g;
5445
- function qualified(schema, name) {
5912
+ function qualified2(schema, name) {
5446
5913
  const s = (schema ?? "public").toLowerCase();
5447
5914
  const n = (name ?? "").toLowerCase();
5448
5915
  return s === "public" ? n : `${s}.${n}`;
@@ -5450,7 +5917,7 @@ function qualified(schema, name) {
5450
5917
  function callsFunctionIn(expr, names) {
5451
5918
  if (names.size === 0) return false;
5452
5919
  for (const c of expr.replace(/'(?:[^']|'')*'/g, "''").matchAll(CALL2)) {
5453
- if (names.has(qualified(c[1], c[2]))) return true;
5920
+ if (names.has(qualified2(c[1], c[2]))) return true;
5454
5921
  }
5455
5922
  return false;
5456
5923
  }
@@ -5518,14 +5985,14 @@ function handlerViews(ctx) {
5518
5985
  function queryViews(ctx, handler) {
5519
5986
  return ctx.graph.out(handler.id, "CALLS").map((query) => {
5520
5987
  const client = ctx.graph.out(query.id, "USES_CLIENT")[0];
5521
- const table = ctx.graph.out(query.id, "TARGETS")[0];
5988
+ const table2 = ctx.graph.out(query.id, "TARGETS")[0];
5522
5989
  return {
5523
5990
  query,
5524
5991
  data: query.data,
5525
5992
  client,
5526
5993
  clientData: client?.data,
5527
- table,
5528
- tableData: table?.data
5994
+ table: table2,
5995
+ tableData: table2?.data
5529
5996
  };
5530
5997
  });
5531
5998
  }
@@ -5560,16 +6027,16 @@ function anonReadPolicy(t) {
5560
6027
  (p) => (p.command === "select" || p.command === "all") && (p.using ?? "").replace(/[\s()]/g, "").toLowerCase() === "true" && (p.roles.length === 0 || p.roles.some((r) => r === "anon" || r === "public"))
5561
6028
  );
5562
6029
  }
5563
- function publicReadNote(p, table) {
5564
- 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.`;
6030
+ function publicReadNote(p, table2) {
6031
+ return ` public.${table2} is readable by the anon role through RLS policy "${p.name}" (${p.location.file}:${p.location.line}), so this read leaks nothing beyond what the anon key already returns; the repository may not have intended that policy, so the finding stays at medium.`;
5565
6032
  }
5566
6033
  function adminOnly(ctx, h, t) {
5567
6034
  const check = h.roleChecks[0];
5568
6035
  if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
5569
6036
  return check;
5570
6037
  }
5571
- function singleTenantTable(ctx, table) {
5572
- const info = ctx.model.tables.find((x) => x.table === table.toLowerCase());
6038
+ function singleTenantTable(ctx, table2) {
6039
+ const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
5573
6040
  if (!info || info.columns.some((c) => isScopeColumn(c))) return false;
5574
6041
  for (const col of info.columnInfo ?? []) {
5575
6042
  if (!col.references || col.nullable) continue;
@@ -5578,16 +6045,16 @@ function singleTenantTable(ctx, table) {
5578
6045
  }
5579
6046
  return true;
5580
6047
  }
5581
- function adminOnlyNote(check, table) {
5582
- 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.`;
6048
+ function adminOnlyNote(check, table2) {
6049
+ return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${table2} has no tenant or owner column, so the row is shared site content rather than a tenant's; verify the admin check cannot be self-granted.`;
5583
6050
  }
5584
- function tableDataOf(ctx, table) {
5585
- return ctx.graph.nodes.get(`table:${table}`)?.data;
6051
+ function tableDataOf(ctx, table2) {
6052
+ return ctx.graph.nodes.get(`table:${table2}`)?.data;
5586
6053
  }
5587
6054
  function callerCheck(checks) {
5588
6055
  return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
5589
6056
  }
5590
- function guardTiesRowToCaller(guard, table, callerFns) {
6057
+ function guardTiesRowToCaller(guard, table2, callerFns) {
5591
6058
  const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
5592
6059
  if (callerFilter) {
5593
6060
  return {
@@ -5611,10 +6078,10 @@ function guardTiesRowToCaller(guard, table, callerFns) {
5611
6078
  why: `the read runs with a ${guard.client === "anon" ? "public anon" : "privileged"} client and filters by no owner column, so it returns the row for anyone`
5612
6079
  };
5613
6080
  }
5614
- const reads = (table?.policyDetails ?? []).filter(
6081
+ const reads = (table2?.policyDetails ?? []).filter(
5615
6082
  (p) => p.command === "select" || p.command === "all"
5616
6083
  );
5617
- const scoped = table?.known === true && table.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
6084
+ const scoped = table2?.known === true && table2.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
5618
6085
  return {
5619
6086
  tied: scoped,
5620
6087
  how: `through ${guard.clientName ?? "a user-scoped client"}, so RLS applied`,
@@ -5732,7 +6199,7 @@ var tableWithoutRls = {
5732
6199
  ],
5733
6200
  summary: `public.${t.table} has no "enable row level security" in migrations but is queried with a ${c.kind} client. Anyone holding the public anon key can read every row directly through PostgREST.`,
5734
6201
  title: `Table "${t.table}" is exposed without RLS`,
5735
- data: { deterministic: true, ruleId: this.id },
6202
+ data: { deterministic: true, ruleId: this.id, table: t.table },
5736
6203
  tail: locations(v.table?.location)
5737
6204
  }));
5738
6205
  addReach(g, h, v, `supabase.${v.data.operation}:public.${t.table}`);
@@ -6576,7 +7043,7 @@ var storagePolicyWithoutOwnerCheck = {
6576
7043
  return out;
6577
7044
  }
6578
7045
  };
6579
- var API_ROLES = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
7046
+ var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
6580
7047
  var securityDefinerFunctionWithoutCallerCheck = {
6581
7048
  id: "supabase.security-definer-function-without-caller-check",
6582
7049
  title: "SECURITY DEFINER function without a caller check",
@@ -6593,22 +7060,22 @@ var securityDefinerFunctionWithoutCallerCheck = {
6593
7060
  rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
6594
7061
  }
6595
7062
  const out = [];
6596
- for (const fn of fns) {
6597
- if (!fn.securityDefiner || fn.checksCaller) continue;
6598
- if (fn.name.includes(".")) continue;
6599
- if (fn.returns === "trigger" || fn.returns === "event_trigger") continue;
6600
- const roles = fn.grantedTo.filter((r) => API_ROLES.has(r));
7063
+ for (const fn2 of fns) {
7064
+ if (!fn2.securityDefiner || fn2.checksCaller) continue;
7065
+ if (fn2.name.includes(".")) continue;
7066
+ if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
7067
+ const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
6601
7068
  if (roles.length === 0) continue;
6602
7069
  const anonymous = roles.includes("anon") || roles.includes("public");
6603
- const sites = rpcByName.get(fn.name) ?? [];
7070
+ const sites = rpcByName.get(fn2.name) ?? [];
6604
7071
  const entries = unique(sites.map((s) => s.handlerData.entry));
6605
- const endpoint = `POST /rest/v1/rpc/${fn.name}`;
7072
+ const endpoint = `POST /rest/v1/rpc/${fn2.name}`;
6606
7073
  const who = anonymous ? "Anonymous visitors can call it with the public anon key" : "Any signed-in user can call it";
6607
- const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${fn.name}") from ${entries.join(", ")}.` : "";
7074
+ const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${fn2.name}") from ${entries.join(", ")}.` : "";
6608
7075
  const path = [
6609
7076
  entries[0] ?? endpoint,
6610
- `supabase.rpc("${fn.name}") (EXECUTE: ${roles.join(", ")})`,
6611
- `public.${fn.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
7077
+ `supabase.rpc("${fn2.name}") (EXECUTE: ${roles.join(", ")})`,
7078
+ `public.${fn2.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
6612
7079
  "no auth.uid() / auth.jwt() check"
6613
7080
  ];
6614
7081
  out.push(
@@ -6616,27 +7083,27 @@ var securityDefinerFunctionWithoutCallerCheck = {
6616
7083
  ctx,
6617
7084
  this,
6618
7085
  {
6619
- title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${fn.name}" without a caller check`,
7086
+ title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${fn2.name}" without a caller check`,
6620
7087
  entrypoints: [...entries, endpoint],
6621
7088
  sources: unique([
6622
7089
  ...sites.flatMap((s) => s.inputs.map((i) => `${i.kind}:${i.name}`)),
6623
7090
  "rpc arguments"
6624
7091
  ]),
6625
- sinks: [`postgres.function:public.${fn.name}`],
7092
+ sinks: [`postgres.function:public.${fn2.name}`],
6626
7093
  path,
6627
7094
  evidence: [
6628
7095
  {
6629
7096
  kind: "rule",
6630
- 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.`,
7097
+ summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
6631
7098
  locations: locations3(
6632
- fn.location,
7099
+ fn2.location,
6633
7100
  ...sites.flatMap((s) => [s.handler.location, s.query.location])
6634
7101
  ),
6635
7102
  data: {
6636
7103
  deterministic: false,
6637
7104
  ruleId: this.id,
6638
- function: fn.name,
6639
- grantedTo: [...fn.grantedTo]
7105
+ function: fn2.name,
7106
+ grantedTo: [...fn2.grantedTo]
6640
7107
  }
6641
7108
  },
6642
7109
  { kind: "trace", summary: path.join(" -> ") }
@@ -6689,9 +7156,9 @@ function applyPublicTables(findings, publicTables2, now) {
6689
7156
  if (f.sinks.length === 0) return f;
6690
7157
  const tables = [];
6691
7158
  for (const sink of f.sinks) {
6692
- const table = READ_SINK.exec(sink)?.[1];
6693
- if (table === void 0 || !declared.has(table.toLowerCase())) return f;
6694
- if (!tables.includes(table)) tables.push(table);
7159
+ const table2 = READ_SINK.exec(sink)?.[1];
7160
+ if (table2 === void 0 || !declared.has(table2.toLowerCase())) return f;
7161
+ if (!tables.includes(table2)) tables.push(table2);
6695
7162
  }
6696
7163
  const command = f.evidence[0]?.data?.command;
6697
7164
  if (f.ruleId === "supabase.rls-policy-without-caller-predicate" && command !== "select") {
@@ -6914,7 +7381,13 @@ function runScan(path, opts = {}) {
6914
7381
  const graph = buildGraph(model);
6915
7382
  const publicTables2 = cfg.config.publicTables ?? [];
6916
7383
  const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
6917
- const findings = runRules(defaultRules, model, graph, runOpts);
7384
+ const rulesFindings = runRules(defaultRules, model, graph, runOpts);
7385
+ const migrationsDir = repoDirs.dirs[0] ?? "supabase/migrations";
7386
+ const findings = rulesFindings.map((f) => {
7387
+ if (f.status === "suppressed") return f;
7388
+ const fix = deterministicFix(f, model, migrationsDir);
7389
+ return fix ? { ...f, fix } : f;
7390
+ });
6918
7391
  const coverage = summarizeCoverage(findings);
6919
7392
  const summary = summarize(model, defaultRules.length, publicTables2);
6920
7393
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, service-role misuse, mass assignment. No account, no model, seconds.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",