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.
- package/dist/auditai-scan.mjs +111 -40
- 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,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
|
-
|
|
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
|
`;
|
|
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
|
|
271
|
-
|
|
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
|
|
312
|
+
const name = t.table;
|
|
274
313
|
const full = qualified(name);
|
|
275
|
-
const
|
|
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 (
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|