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.
- package/dist/auditai-scan.mjs +57 -33
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -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
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
219
|
+
function ownershipOf(t) {
|
|
222
220
|
if (!t)
|
|
223
|
-
return
|
|
221
|
+
return { kind: "none" };
|
|
224
222
|
const cols = t.columns.map((c) => c.toLowerCase());
|
|
225
|
-
for (const
|
|
226
|
-
if (cols.includes(
|
|
227
|
-
return
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
271
|
-
|
|
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
|
|
284
|
+
const name = t.table;
|
|
274
285
|
const full = qualified(name);
|
|
275
|
-
const
|
|
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 (
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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",
|