auditai-scan 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/auditai-scan.mjs +111 -40
  2. package/package.json +1 -1
@@ -198,12 +198,8 @@ function formatScanText(r) {
198
198
  import { statSync as statSync2 } from "node:fs";
199
199
 
200
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",
201
+ var PERSON_COLUMNS = ["user_id", "owner_id", "profile_id", "author_id", "created_by"];
202
+ var TENANT_COLUMNS = [
207
203
  "account_id",
208
204
  "tenant_id",
209
205
  "organization_id",
@@ -212,37 +208,64 @@ var OWNER_COLUMNS = [
212
208
  "team_id"
213
209
  ];
214
210
  var API_ROLES = "public, anon, authenticated";
211
+ var IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$/;
215
212
  function table(model, name) {
216
- if (!name)
213
+ if (!name || !IDENTIFIER.test(name))
217
214
  return void 0;
218
- const key = name.toLowerCase();
219
- return model.tables.find((t) => t.table.toLowerCase() === key);
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;
220
218
  }
221
- function ownerColumn(t) {
219
+ function ownershipOf(t) {
222
220
  if (!t)
223
- return null;
221
+ return { kind: "none" };
224
222
  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;
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
+ `;
229
237
  }
230
238
  function qualified(name) {
231
239
  return name.includes(".") ? name : `public.${name}`;
232
240
  }
233
241
  function fn(model, name) {
234
- if (!name)
242
+ if (!name || !IDENTIFIER.test(name))
235
243
  return void 0;
236
- const key = name.toLowerCase();
237
- return (model.sqlFunctions ?? []).find((f) => f.name.toLowerCase() === key);
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;
238
247
  }
239
248
  function signatureOf(f) {
240
249
  const name = f.name.includes(".") ? f.name : `public.${f.name}`;
241
250
  return f.args === void 0 ? `${name}(...)` : `${name}(${f.args})`;
242
251
  }
252
+ var COMMANDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "all"]);
243
253
  var HEADER = (title) => `-- ${title}
244
254
  -- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
245
255
  `;
256
+ function policedTables(f, model) {
257
+ const names = f.tables ?? [];
258
+ if (names.length === 0)
259
+ return null;
260
+ const out = [];
261
+ for (const n of names) {
262
+ const t = table(model, n);
263
+ if (!t || !t.rlsEnabled || t.policies.length === 0)
264
+ return null;
265
+ out.push(qualified(t.table));
266
+ }
267
+ return out;
268
+ }
246
269
  function sqlFunctionFix(finding4, model) {
247
270
  const name = finding4.evidence[0]?.data?.function;
248
271
  const f = fn(model, typeof name === "string" ? name : void 0);
@@ -250,6 +273,21 @@ function sqlFunctionFix(finding4, model) {
250
273
  return null;
251
274
  const sig = signatureOf(f);
252
275
  const ambiguous = sig.endsWith("(...)");
276
+ const policed = f.returns !== void 0 && f.returns !== "void" ? policedTables(f, model) : null;
277
+ if (policed && !ambiguous) {
278
+ return {
279
+ file: `fix_security_invoker_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
280
+ sql: [
281
+ HEADER(`Run ${f.name} with the caller's rights, so row level security applies inside it`),
282
+ `-- It reads ${policed.join(", ")}, and each of them has row level security with policies.
283
+ `,
284
+ `alter function ${sig} security invoker;
285
+ `
286
+ ].join(""),
287
+ summary: `Run ${f.name} with the caller's rights (security invoker)`,
288
+ rationale: `${f.name} only reads tables that already have row level security with policies (${policed.join(", ")}), so running it with the caller's rights lets those policies decide what it returns, and calls from your app keep working. If it must keep its owner's rights on purpose (for example it counts rows a caller may not see), revoke execute from public, anon and authenticated instead, or add a caller check inside the body.`
289
+ };
290
+ }
253
291
  const body = [
254
292
  HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
255
293
  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" : "",
@@ -267,17 +305,22 @@ function sqlFunctionFix(finding4, model) {
267
305
  };
268
306
  }
269
307
  function enableRlsFix(finding4, model, withPolicy) {
270
- const name = finding4.evidence[0]?.data?.table;
271
- if (typeof name !== "string")
308
+ const asked = finding4.evidence[0]?.data?.table;
309
+ const t = table(model, typeof asked === "string" ? asked : void 0);
310
+ if (!t)
272
311
  return null;
273
- const t = table(model, name);
312
+ const name = t.table;
274
313
  const full = qualified(name);
275
- const owner = ownerColumn(t);
314
+ const own = ownershipOf(t);
315
+ const owner = own.kind === "person" ? own.column : null;
276
316
  const lines = [HEADER(`Turn on row level security for ${full}`)];
277
317
  lines.push(`alter table ${full} enable row level security;
278
318
  `);
279
319
  if (withPolicy) {
280
- if (owner) {
320
+ if (own.kind === "tenant") {
321
+ lines.push(`
322
+ ${tenantNote(full, own.column)}`);
323
+ } else if (owner) {
281
324
  lines.push(`
282
325
  create policy "${name}: owner reads" on ${full}
283
326
  for select to authenticated
@@ -290,7 +333,7 @@ create policy "${name}: owner writes" on ${full}
290
333
  `);
291
334
  } else {
292
335
  lines.push(`
293
- -- No column of ${full} ties a row to a person (looked for ${OWNER_COLUMNS.slice(0, 4).join(", ")}\u2026),
336
+ -- No column of ${full} ties a row to a person (looked for ${PERSON_COLUMNS.slice(0, 4).join(", ")}\u2026),
294
337
  -- so no policy is proposed: with RLS on and no policy the table is readable only with the
295
338
  -- service role, which is the safe default. Add a policy once you decide who owns a row.
296
339
  `);
@@ -310,17 +353,25 @@ create policy "${name}: owner writes" on ${full}
310
353
  }
311
354
  function anonWriteFix(finding4, model) {
312
355
  const data = finding4.evidence[0]?.data ?? {};
313
- const name = data.table;
314
356
  const policy = data.policy;
315
357
  const command = data.command;
316
- if (typeof name !== "string" || typeof policy !== "string")
358
+ const t = table(model, typeof data.table === "string" ? data.table : void 0);
359
+ if (!t || typeof policy !== "string" || policy.length > 200)
360
+ return null;
361
+ if (command !== void 0 && !COMMANDS.has(String(command)))
317
362
  return null;
363
+ const name = t.table;
318
364
  const full = qualified(name);
319
- const owner = ownerColumn(table(model, name));
365
+ const own = ownershipOf(t);
366
+ const owner = own.kind === "person" ? own.column : null;
320
367
  const cmd = typeof command === "string" ? command : "all";
321
368
  const safeName = policy.replace(/"/g, '""');
322
369
  const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
323
- if (owner) {
370
+ if (own.kind === "tenant") {
371
+ lines.push(`drop policy "${safeName}" on ${full};
372
+ `, `
373
+ ${tenantNote(full, own.column)}`);
374
+ } else if (owner) {
324
375
  lines.push(`drop policy "${safeName}" on ${full};
325
376
  `, `
326
377
  create policy "${safeName}" on ${full}
@@ -340,15 +391,16 @@ create policy "${safeName}" on ${full}
340
391
  file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
341
392
  sql: lines.join(""),
342
393
  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.`
394
+ 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.`
344
395
  };
345
396
  }
346
- function userMetadataFix(finding4) {
397
+ function userMetadataFix(finding4, model) {
347
398
  const data = finding4.evidence[0]?.data ?? {};
348
- const name = data.table;
349
399
  const policy = data.policy;
350
- if (typeof name !== "string" || typeof policy !== "string")
400
+ const t = table(model, typeof data.table === "string" ? data.table : void 0);
401
+ if (!t || typeof policy !== "string" || policy.length > 200)
351
402
  return null;
403
+ const name = t.table;
352
404
  const full = qualified(name);
353
405
  return {
354
406
  file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
@@ -382,7 +434,7 @@ function sqlFixFor(finding4, model) {
382
434
  case "supabase.anon-write-policy":
383
435
  return anonWriteFix(finding4, model);
384
436
  case "supabase.rls-policy-trusts-user-metadata":
385
- return userMetadataFix(finding4);
437
+ return userMetadataFix(finding4, model);
386
438
  default:
387
439
  return null;
388
440
  }
@@ -2685,7 +2737,8 @@ function applyCreateFunction(reg, stmt, file) {
2685
2737
  const q = readQualifiedName(tk, i + 1);
2686
2738
  if (!q || !isPunct(tk[q.next], "(")) return;
2687
2739
  const argsEnd = groupEnd(tk, q.next);
2688
- const args = argumentTypes(stmt, tk, q.next, argsEnd);
2740
+ const params = readParams(stmt, tk, q.next, argsEnd);
2741
+ const args = params === null ? null : params.map((p) => p.type).join(", ");
2689
2742
  i = argsEnd + 1;
2690
2743
  let securityDefiner = false;
2691
2744
  let returns = null;
@@ -2725,6 +2778,7 @@ function applyCreateFunction(reg, stmt, file) {
2725
2778
  securityDefiner,
2726
2779
  returns,
2727
2780
  args,
2781
+ params,
2728
2782
  body: code,
2729
2783
  directCheck: CALLER_CHECKS.some((re) => re.test(code)),
2730
2784
  acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
@@ -2791,8 +2845,8 @@ var TYPE_WORD = /* @__PURE__ */ new Set([
2791
2845
  "void",
2792
2846
  "xml"
2793
2847
  ]);
2794
- function argumentTypes(stmt, tokens, open, close) {
2795
- if (close <= open + 1) return "";
2848
+ function readParams(stmt, tokens, open, close) {
2849
+ if (close <= open + 1) return [];
2796
2850
  const parts = [];
2797
2851
  let depth = 0;
2798
2852
  let start = open + 1;
@@ -2830,11 +2884,12 @@ function argumentTypes(stmt, tokens, open, close) {
2830
2884
  const first = words[typeStart];
2831
2885
  const last = words[words.length - 1];
2832
2886
  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
- );
2887
+ parts.push({
2888
+ name: named && firstWord ? firstWord.value.toLowerCase() : null,
2889
+ type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
2890
+ });
2836
2891
  }
2837
- return parts.join(", ");
2892
+ return parts;
2838
2893
  }
2839
2894
  function readFunctionList(tokens, from) {
2840
2895
  const keys = [];
@@ -3019,6 +3074,18 @@ function callees(reg, f, byName) {
3019
3074
  }
3020
3075
  return out;
3021
3076
  }
3077
+ var RELATION = /(?<![A-Za-z0-9_$])(?:from|join)\s+(?:only\s+)?(?:"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?(?![A-Za-z0-9_$"]|\s*[.(])/gi;
3078
+ function relationsOf(body) {
3079
+ const out = [];
3080
+ for (const m of body.matchAll(RELATION)) {
3081
+ const schema = m[1]?.toLowerCase();
3082
+ const name = m[2]?.toLowerCase();
3083
+ if (!name || name === "only" || name === "lateral") continue;
3084
+ const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3085
+ if (!out.includes(key)) out.push(key);
3086
+ }
3087
+ return out;
3088
+ }
3022
3089
  function effectiveRoles(acl) {
3023
3090
  const out = [];
3024
3091
  if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
@@ -3058,6 +3125,10 @@ function finishFunctions(reg) {
3058
3125
  };
3059
3126
  if (f.returns !== null) info.returns = f.returns;
3060
3127
  if (f.args !== null) info.args = f.args;
3128
+ if (f.params?.every((x) => x.name !== null))
3129
+ info.params = f.params.map((x) => ({ name: x.name ?? "", type: x.type }));
3130
+ const tables = relationsOf(f.body);
3131
+ if (tables.length > 0) info.tables = tables;
3061
3132
  return info;
3062
3133
  });
3063
3134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",