auditai-scan 0.7.0 → 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.
@@ -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,34 +208,48 @@ 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
  `;
@@ -267,17 +277,22 @@ function sqlFunctionFix(finding4, model) {
267
277
  };
268
278
  }
269
279
  function enableRlsFix(finding4, model, withPolicy) {
270
- const name = finding4.evidence[0]?.data?.table;
271
- if (typeof name !== "string")
280
+ const asked = finding4.evidence[0]?.data?.table;
281
+ const t = table(model, typeof asked === "string" ? asked : void 0);
282
+ if (!t)
272
283
  return null;
273
- const t = table(model, name);
284
+ const name = t.table;
274
285
  const full = qualified(name);
275
- const owner = ownerColumn(t);
286
+ const own = ownershipOf(t);
287
+ const owner = own.kind === "person" ? own.column : null;
276
288
  const lines = [HEADER(`Turn on row level security for ${full}`)];
277
289
  lines.push(`alter table ${full} enable row level security;
278
290
  `);
279
291
  if (withPolicy) {
280
- if (owner) {
292
+ if (own.kind === "tenant") {
293
+ lines.push(`
294
+ ${tenantNote(full, own.column)}`);
295
+ } else if (owner) {
281
296
  lines.push(`
282
297
  create policy "${name}: owner reads" on ${full}
283
298
  for select to authenticated
@@ -290,7 +305,7 @@ create policy "${name}: owner writes" on ${full}
290
305
  `);
291
306
  } else {
292
307
  lines.push(`
293
- -- No column of ${full} ties a row to a person (looked for ${OWNER_COLUMNS.slice(0, 4).join(", ")}\u2026),
308
+ -- No column of ${full} ties a row to a person (looked for ${PERSON_COLUMNS.slice(0, 4).join(", ")}\u2026),
294
309
  -- so no policy is proposed: with RLS on and no policy the table is readable only with the
295
310
  -- service role, which is the safe default. Add a policy once you decide who owns a row.
296
311
  `);
@@ -310,17 +325,25 @@ create policy "${name}: owner writes" on ${full}
310
325
  }
311
326
  function anonWriteFix(finding4, model) {
312
327
  const data = finding4.evidence[0]?.data ?? {};
313
- const name = data.table;
314
328
  const policy = data.policy;
315
329
  const command = data.command;
316
- if (typeof name !== "string" || typeof policy !== "string")
330
+ const t = table(model, typeof data.table === "string" ? data.table : void 0);
331
+ if (!t || typeof policy !== "string" || policy.length > 200)
317
332
  return null;
333
+ if (command !== void 0 && !COMMANDS.has(String(command)))
334
+ return null;
335
+ const name = t.table;
318
336
  const full = qualified(name);
319
- const owner = ownerColumn(table(model, name));
337
+ const own = ownershipOf(t);
338
+ const owner = own.kind === "person" ? own.column : null;
320
339
  const cmd = typeof command === "string" ? command : "all";
321
340
  const safeName = policy.replace(/"/g, '""');
322
341
  const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
323
- if (owner) {
342
+ if (own.kind === "tenant") {
343
+ lines.push(`drop policy "${safeName}" on ${full};
344
+ `, `
345
+ ${tenantNote(full, own.column)}`);
346
+ } else if (owner) {
324
347
  lines.push(`drop policy "${safeName}" on ${full};
325
348
  `, `
326
349
  create policy "${safeName}" on ${full}
@@ -340,15 +363,16 @@ create policy "${safeName}" on ${full}
340
363
  file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
341
364
  sql: lines.join(""),
342
365
  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.`
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.`
344
367
  };
345
368
  }
346
- function userMetadataFix(finding4) {
369
+ function userMetadataFix(finding4, model) {
347
370
  const data = finding4.evidence[0]?.data ?? {};
348
- const name = data.table;
349
371
  const policy = data.policy;
350
- if (typeof name !== "string" || typeof policy !== "string")
372
+ const t = table(model, typeof data.table === "string" ? data.table : void 0);
373
+ if (!t || typeof policy !== "string" || policy.length > 200)
351
374
  return null;
375
+ const name = t.table;
352
376
  const full = qualified(name);
353
377
  return {
354
378
  file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
@@ -382,7 +406,7 @@ function sqlFixFor(finding4, model) {
382
406
  case "supabase.anon-write-policy":
383
407
  return anonWriteFix(finding4, model);
384
408
  case "supabase.rls-policy-trusts-user-metadata":
385
- return userMetadataFix(finding4);
409
+ return userMetadataFix(finding4, model);
386
410
  default:
387
411
  return null;
388
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, service-role misuse, mass assignment. No account, no model, seconds.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",