auditai-scan 0.5.0 → 0.6.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.
- package/dist/auditai-scan.mjs +315 -45
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -78,20 +78,20 @@ var IllegalTransitionError = class extends Error {
|
|
|
78
78
|
to;
|
|
79
79
|
name = "IllegalTransitionError";
|
|
80
80
|
};
|
|
81
|
-
function transition(
|
|
82
|
-
if (!canTransition(
|
|
83
|
-
throw new IllegalTransitionError(
|
|
81
|
+
function transition(finding4, to, opts = {}) {
|
|
82
|
+
if (!canTransition(finding4.status, to)) {
|
|
83
|
+
throw new IllegalTransitionError(finding4.id, finding4.status, to);
|
|
84
84
|
}
|
|
85
85
|
const requiresEvidence = to === "confirmed" || to === "verified";
|
|
86
86
|
if (requiresEvidence && !opts.evidence) {
|
|
87
|
-
throw new Error(`Finding ${
|
|
87
|
+
throw new Error(`Finding ${finding4.id}: transition to ${to} requires evidence`);
|
|
88
88
|
}
|
|
89
|
-
if (to === "verified" && !isVerificationPassing(
|
|
90
|
-
throw new Error(`Finding ${
|
|
89
|
+
if (to === "verified" && !isVerificationPassing(finding4.verification)) {
|
|
90
|
+
throw new Error(`Finding ${finding4.id}: cannot mark verified without a passing verification`);
|
|
91
91
|
}
|
|
92
|
-
const evidence = opts.evidence ? [...
|
|
92
|
+
const evidence = opts.evidence ? [...finding4.evidence, opts.evidence] : finding4.evidence;
|
|
93
93
|
return {
|
|
94
|
-
...
|
|
94
|
+
...finding4,
|
|
95
95
|
status: to,
|
|
96
96
|
evidence,
|
|
97
97
|
updatedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -100,8 +100,8 @@ function transition(finding3, to, opts = {}) {
|
|
|
100
100
|
function isVerificationPassing(v) {
|
|
101
101
|
return v !== void 0 && v.securityTestBefore === "failed" && v.securityTestAfter === "passed" && (v.existingTests === "passed" || v.existingTests === "skipped") && v.rescan === "passed";
|
|
102
102
|
}
|
|
103
|
-
function isDeterministic(
|
|
104
|
-
return
|
|
103
|
+
function isDeterministic(finding4) {
|
|
104
|
+
return finding4.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
|
|
105
105
|
}
|
|
106
106
|
var DEFAULT_BLOCKING_POLICY = {
|
|
107
107
|
minConfidence: 0.8,
|
|
@@ -109,14 +109,14 @@ var DEFAULT_BLOCKING_POLICY = {
|
|
|
109
109
|
blockDeterministicCritical: true
|
|
110
110
|
};
|
|
111
111
|
var CONFIRMED_OR_LATER = ["confirmed", "fix_proposed", "fix_applied"];
|
|
112
|
-
function isBlocking(
|
|
113
|
-
if (
|
|
114
|
-
if (
|
|
115
|
-
if (
|
|
116
|
-
if (CONFIRMED_OR_LATER.includes(
|
|
112
|
+
function isBlocking(finding4, policy = DEFAULT_BLOCKING_POLICY) {
|
|
113
|
+
if (finding4.status === "suppressed") return false;
|
|
114
|
+
if (finding4.status === "verified") return true;
|
|
115
|
+
if (finding4.confidence < policy.minConfidence) return false;
|
|
116
|
+
if (CONFIRMED_OR_LATER.includes(finding4.status) && policy.blockOnConfirmedSeverities.includes(finding4.severity)) {
|
|
117
117
|
return true;
|
|
118
118
|
}
|
|
119
|
-
if (policy.blockDeterministicCritical &&
|
|
119
|
+
if (policy.blockDeterministicCritical && finding4.severity === "critical" && isDeterministic(finding4) && finding4.status !== "unverified") {
|
|
120
120
|
return true;
|
|
121
121
|
}
|
|
122
122
|
return false;
|
|
@@ -1604,7 +1604,7 @@ function isIdentPart(code) {
|
|
|
1604
1604
|
function isDigit(code) {
|
|
1605
1605
|
return code >= 48 && code <= 57;
|
|
1606
1606
|
}
|
|
1607
|
-
function readQuoted(text, from,
|
|
1607
|
+
function readQuoted(text, from, quote2, backslashEscapes) {
|
|
1608
1608
|
let value = "";
|
|
1609
1609
|
let j = from + 1;
|
|
1610
1610
|
while (j < text.length) {
|
|
@@ -1615,9 +1615,9 @@ function readQuoted(text, from, quote, backslashEscapes) {
|
|
|
1615
1615
|
j += 2;
|
|
1616
1616
|
continue;
|
|
1617
1617
|
}
|
|
1618
|
-
if (c ===
|
|
1619
|
-
if (text.charAt(j + 1) ===
|
|
1620
|
-
value +=
|
|
1618
|
+
if (c === quote2) {
|
|
1619
|
+
if (text.charAt(j + 1) === quote2) {
|
|
1620
|
+
value += quote2;
|
|
1621
1621
|
j += 2;
|
|
1622
1622
|
continue;
|
|
1623
1623
|
}
|
|
@@ -3302,6 +3302,10 @@ var CREATE_POLICY = new RegExp(
|
|
|
3302
3302
|
String.raw`^create\s+policy\s+("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
|
|
3303
3303
|
"i"
|
|
3304
3304
|
);
|
|
3305
|
+
var DROP_POLICY = new RegExp(
|
|
3306
|
+
String.raw`^drop\s+policy\s+(?:if\s+exists\s+)?("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
|
|
3307
|
+
"i"
|
|
3308
|
+
);
|
|
3305
3309
|
function isAppliedSqlFile(rel) {
|
|
3306
3310
|
return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
|
|
3307
3311
|
}
|
|
@@ -3320,10 +3324,18 @@ function warnDynamicSql(state, rel) {
|
|
|
3320
3324
|
const w = `${rel}: a DO block runs dynamic SQL (a loop over a query, or an EXECUTE that is conditional or built from expressions); RLS, policies and privileges it sets are not seen`;
|
|
3321
3325
|
if (!state.warnings.includes(w)) state.warnings.push(w);
|
|
3322
3326
|
}
|
|
3327
|
+
var ROLE_NAME = '(?:"[A-Za-z_][A-Za-z0-9_]*"|[A-Za-z_][A-Za-z0-9_]*)';
|
|
3328
|
+
var ROLES = new RegExp(`\\bto\\s+(${ROLE_NAME}(?:\\s*,\\s*${ROLE_NAME})*)`, "i");
|
|
3329
|
+
function headEnd(rest) {
|
|
3330
|
+
const u = /\busing\s*\(/i.exec(rest);
|
|
3331
|
+
const c = /\bwith\s+check\s*\(/i.exec(rest);
|
|
3332
|
+
const ends = [u?.index, c?.index].filter((i) => i !== void 0);
|
|
3333
|
+
return ends.length === 0 ? rest.length : Math.min(...ends);
|
|
3334
|
+
}
|
|
3323
3335
|
function applyStatement(state, stmt, rel) {
|
|
3324
3336
|
const cp = CREATE_POLICY.exec(stmt.text);
|
|
3325
3337
|
if (!cp?.[1] || !cp[4]) {
|
|
3326
|
-
applySchemaStatement(state, stmt, rel);
|
|
3338
|
+
dropPolicy(state, stmt) || applySchemaStatement(state, stmt, rel);
|
|
3327
3339
|
return;
|
|
3328
3340
|
}
|
|
3329
3341
|
const t = ensureTable(
|
|
@@ -3336,10 +3348,9 @@ function applyStatement(state, stmt, rel) {
|
|
|
3336
3348
|
const rest = stmt.text.slice(cp[0].length);
|
|
3337
3349
|
const cmdMatch = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(rest);
|
|
3338
3350
|
const command = cmdMatch?.[1]?.toLowerCase() ?? "all";
|
|
3339
|
-
const
|
|
3340
|
-
|
|
3341
|
-
);
|
|
3342
|
-
const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.toLowerCase()) : [];
|
|
3351
|
+
const head = rest.slice(0, headEnd(rest));
|
|
3352
|
+
const rolesMatch = ROLES.exec(head);
|
|
3353
|
+
const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.replace(/"/g, "").toLowerCase()) : [];
|
|
3343
3354
|
let using = null;
|
|
3344
3355
|
let check = null;
|
|
3345
3356
|
const u = /\busing\s*\(/i.exec(rest);
|
|
@@ -3356,6 +3367,17 @@ function applyStatement(state, stmt, rel) {
|
|
|
3356
3367
|
location: { file: rel, line: stmt.line }
|
|
3357
3368
|
});
|
|
3358
3369
|
}
|
|
3370
|
+
function dropPolicy(state, stmt) {
|
|
3371
|
+
const dp = DROP_POLICY.exec(stmt.text);
|
|
3372
|
+
if (!dp?.[1] || !dp[4]) return false;
|
|
3373
|
+
const name = dp[2] ?? dp[1].replace(/^"|"$/g, "");
|
|
3374
|
+
const t = state.tables.get(qualifiedKey({ schema: dp[3] ?? null, name: dp[4] }));
|
|
3375
|
+
if (t) {
|
|
3376
|
+
t.policies = t.policies.filter((p) => p !== name);
|
|
3377
|
+
t.policyDetails = t.policyDetails.filter((p) => p.name !== name);
|
|
3378
|
+
}
|
|
3379
|
+
return true;
|
|
3380
|
+
}
|
|
3359
3381
|
function sqlSchemaFor(into) {
|
|
3360
3382
|
return finishSchema(schemaStateFor(into));
|
|
3361
3383
|
}
|
|
@@ -6074,6 +6096,253 @@ var supabaseAuthorizationPack = [
|
|
|
6074
6096
|
rlsPolicyWithoutCallerPredicate
|
|
6075
6097
|
];
|
|
6076
6098
|
|
|
6099
|
+
// packages/rules/src/packs/supabase-sql-policies.ts
|
|
6100
|
+
var DATA_API = "Supabase Data API (PostgREST)";
|
|
6101
|
+
function locations2(...refs) {
|
|
6102
|
+
const out = [];
|
|
6103
|
+
for (const r of refs) {
|
|
6104
|
+
if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
|
|
6105
|
+
}
|
|
6106
|
+
return out;
|
|
6107
|
+
}
|
|
6108
|
+
function finding2(ctx, rule, body, severity) {
|
|
6109
|
+
return {
|
|
6110
|
+
id: ctx.nextId(),
|
|
6111
|
+
ruleId: rule.id,
|
|
6112
|
+
status: "likely",
|
|
6113
|
+
severity: severity ?? rule.severity,
|
|
6114
|
+
confidence: rule.confidence,
|
|
6115
|
+
cwe: rule.cwe,
|
|
6116
|
+
createdAt: ctx.now,
|
|
6117
|
+
updatedAt: ctx.now,
|
|
6118
|
+
...body
|
|
6119
|
+
};
|
|
6120
|
+
}
|
|
6121
|
+
function publicTables(ctx) {
|
|
6122
|
+
return ctx.model.tables.filter((t) => !t.table.includes("."));
|
|
6123
|
+
}
|
|
6124
|
+
function commandLabel(p) {
|
|
6125
|
+
return p.command === "all" ? "for all commands" : `for ${p.command}`;
|
|
6126
|
+
}
|
|
6127
|
+
function roleLabel(p) {
|
|
6128
|
+
return p.roles.length === 0 ? "public (no TO clause)" : p.roles.join(", ");
|
|
6129
|
+
}
|
|
6130
|
+
function quote(expr, max = 200) {
|
|
6131
|
+
if (expr === null) return "(none)";
|
|
6132
|
+
const one = expr.replace(/\s+/g, " ").trim();
|
|
6133
|
+
return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
|
|
6134
|
+
}
|
|
6135
|
+
var USER_METADATA = /\buser_metadata\b/i;
|
|
6136
|
+
var RAW_USER_META = /\braw_user_meta_data\b/i;
|
|
6137
|
+
var JWT_SOURCE = /auth\s*\.\s*jwt\s*\(|request\.jwt\.claim/i;
|
|
6138
|
+
var AUTH_USERS = /\bauth\s*\.\s*users\b/i;
|
|
6139
|
+
function readsSelfWrittenMetadata(expr) {
|
|
6140
|
+
if (USER_METADATA.test(expr) && JWT_SOURCE.test(expr)) return true;
|
|
6141
|
+
return RAW_USER_META.test(expr) && AUTH_USERS.test(expr);
|
|
6142
|
+
}
|
|
6143
|
+
var rlsPolicyTrustsUserMetadata = {
|
|
6144
|
+
id: "supabase.rls-policy-trusts-user-metadata",
|
|
6145
|
+
title: "RLS policy reads user_metadata from the JWT",
|
|
6146
|
+
description: "A policy decides access with a claim under user_metadata (or raw_user_meta_data). Any signed-in user can write user_metadata with updateUser({ data }), and the claim lands in their next JWT without validation, so the policy grants itself. Move the claim to app_metadata, which only the service role writes, or read a roles table joined on auth.uid().",
|
|
6147
|
+
severity: "critical",
|
|
6148
|
+
confidence: 0.9,
|
|
6149
|
+
cwe: ["CWE-602", "CWE-863"],
|
|
6150
|
+
evaluate(ctx) {
|
|
6151
|
+
const out = [];
|
|
6152
|
+
for (const t of publicTables(ctx)) {
|
|
6153
|
+
for (const p of t.policyDetails) {
|
|
6154
|
+
const where2 = [];
|
|
6155
|
+
if (p.using !== null && readsSelfWrittenMetadata(p.using)) where2.push(["USING", p.using]);
|
|
6156
|
+
if (p.check !== null && readsSelfWrittenMetadata(p.check))
|
|
6157
|
+
where2.push(["WITH CHECK", p.check]);
|
|
6158
|
+
if (where2.length === 0) continue;
|
|
6159
|
+
const clause = where2.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
|
|
6160
|
+
const evidence = [
|
|
6161
|
+
{
|
|
6162
|
+
kind: "rule",
|
|
6163
|
+
summary: `Policy "${p.name}" on public.${t.table} (${commandLabel(p)}, to ${roleLabel(p)}) decides access from user_metadata: ${clause}. A signed-in user sets user_metadata themselves with supabase.auth.updateUser({ data: { ... } }); the value is copied into their next access token without any check, so the caller can grant themselves whatever this predicate asks for. app_metadata cannot be written this way, and a roles table joined on auth.uid() cannot either.`,
|
|
6164
|
+
locations: locations2(p.location, t.location),
|
|
6165
|
+
// No `deterministic: true`: a critical deterministic finding blocks the GitHub
|
|
6166
|
+
// check, and this rule has not been seen on a real repository yet (zero hits on the
|
|
6167
|
+
// 26-repository corpus of 13 Sept 2026). It blocks once measured, not before.
|
|
6168
|
+
data: {
|
|
6169
|
+
ruleId: this.id,
|
|
6170
|
+
table: t.table,
|
|
6171
|
+
policy: p.name,
|
|
6172
|
+
command: p.command
|
|
6173
|
+
}
|
|
6174
|
+
},
|
|
6175
|
+
{
|
|
6176
|
+
kind: "trace",
|
|
6177
|
+
summary: [
|
|
6178
|
+
"Any signed-in user",
|
|
6179
|
+
'auth.updateUser({ data: { role: "admin" } })',
|
|
6180
|
+
"the claim is signed into the next JWT",
|
|
6181
|
+
`policy "${p.name}" reads it from user_metadata`,
|
|
6182
|
+
`public.${t.table} (${commandLabel(p)})`
|
|
6183
|
+
].join(" -> ")
|
|
6184
|
+
}
|
|
6185
|
+
];
|
|
6186
|
+
out.push(
|
|
6187
|
+
finding2(ctx, this, {
|
|
6188
|
+
title: `Policy "${p.name}" on "${t.table}" trusts user_metadata`,
|
|
6189
|
+
entrypoints: [DATA_API],
|
|
6190
|
+
sources: ["jwt:user_metadata"],
|
|
6191
|
+
sinks: [`supabase.policy:public.${t.table}`],
|
|
6192
|
+
path: [
|
|
6193
|
+
"Any signed-in user",
|
|
6194
|
+
"user_metadata written by the user",
|
|
6195
|
+
`policy "${p.name}"`,
|
|
6196
|
+
`public.${t.table}`
|
|
6197
|
+
],
|
|
6198
|
+
evidence
|
|
6199
|
+
})
|
|
6200
|
+
);
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6203
|
+
return out;
|
|
6204
|
+
}
|
|
6205
|
+
};
|
|
6206
|
+
var policiesWithoutRlsEnabled = {
|
|
6207
|
+
id: "supabase.policies-without-rls-enabled",
|
|
6208
|
+
title: "Policies exist but RLS is never enabled on the table",
|
|
6209
|
+
description: "A migration writes policies for a table but never runs `alter table ... enable row level security`, so the policies have no effect and the table stays fully readable and writable through the Data API. Every reviewer who sees the policies assumes the table is protected. Supabase's own lint (0007) reports the same shape.",
|
|
6210
|
+
severity: "high",
|
|
6211
|
+
confidence: 0.9,
|
|
6212
|
+
cwe: ["CWE-284"],
|
|
6213
|
+
evaluate(ctx) {
|
|
6214
|
+
const out = [];
|
|
6215
|
+
for (const t of publicTables(ctx)) {
|
|
6216
|
+
if (t.rlsEnabled || t.policyDetails.length === 0) continue;
|
|
6217
|
+
const names = t.policyDetails.map((p) => `"${p.name}" (${commandLabel(p)})`).join(", ");
|
|
6218
|
+
const evidence = [
|
|
6219
|
+
{
|
|
6220
|
+
kind: "rule",
|
|
6221
|
+
summary: `public.${t.table} has ${t.policyDetails.length} ${t.policyDetails.length === 1 ? "policy" : "policies"} \u2014 ${names} \u2014 and no "alter table public.${t.table} enable row level security" anywhere in the migrations. Policies only apply to a table with RLS on, so every one of them is dead and the table is fully readable and writable by anyone holding the public anon key. The policies say what the intent was, which is what makes this a defect rather than a choice.`,
|
|
6222
|
+
locations: locations2(t.location, t.policyDetails[0]?.location),
|
|
6223
|
+
data: {
|
|
6224
|
+
deterministic: true,
|
|
6225
|
+
ruleId: this.id,
|
|
6226
|
+
table: t.table,
|
|
6227
|
+
policies: t.policyDetails.map((p) => p.name)
|
|
6228
|
+
}
|
|
6229
|
+
},
|
|
6230
|
+
{
|
|
6231
|
+
kind: "trace",
|
|
6232
|
+
summary: [
|
|
6233
|
+
"Anyone with the public anon key",
|
|
6234
|
+
"PostgREST",
|
|
6235
|
+
`public.${t.table} (RLS never enabled)`,
|
|
6236
|
+
`${t.policyDetails.length} policies that never run`
|
|
6237
|
+
].join(" -> ")
|
|
6238
|
+
}
|
|
6239
|
+
];
|
|
6240
|
+
out.push(
|
|
6241
|
+
finding2(ctx, this, {
|
|
6242
|
+
title: `Table "${t.table}" has policies but RLS is off`,
|
|
6243
|
+
entrypoints: [DATA_API],
|
|
6244
|
+
sources: ["anon-key"],
|
|
6245
|
+
sinks: [`supabase.select:public.${t.table}`],
|
|
6246
|
+
path: [
|
|
6247
|
+
"Anyone with the public anon key",
|
|
6248
|
+
"PostgREST",
|
|
6249
|
+
`public.${t.table} (RLS off, policies inert)`
|
|
6250
|
+
],
|
|
6251
|
+
evidence
|
|
6252
|
+
})
|
|
6253
|
+
);
|
|
6254
|
+
}
|
|
6255
|
+
return out;
|
|
6256
|
+
}
|
|
6257
|
+
};
|
|
6258
|
+
function isTautology(expr) {
|
|
6259
|
+
if (expr === null) return false;
|
|
6260
|
+
return /^\(*\s*true\s*\)*$/i.test(expr.trim());
|
|
6261
|
+
}
|
|
6262
|
+
var ANON_ROLES = /* @__PURE__ */ new Set(["anon", "public"]);
|
|
6263
|
+
function reachableByAnon(p) {
|
|
6264
|
+
if (p.roles.length === 0) return true;
|
|
6265
|
+
return p.roles.some((r) => ANON_ROLES.has(r.toLowerCase().replace(/^"|"$/g, "")));
|
|
6266
|
+
}
|
|
6267
|
+
var WRITE_COMMANDS = /* @__PURE__ */ new Set(["insert", "update", "delete", "all"]);
|
|
6268
|
+
var anonWritePolicy = {
|
|
6269
|
+
id: "supabase.anon-write-policy",
|
|
6270
|
+
title: "Write policy open to anon or every role",
|
|
6271
|
+
description: "An insert, update or delete policy targets anon (or omits the TO clause, which means PUBLIC) and decides with `true`. Anyone holding the public anon key writes the table directly through PostgREST, including rows that belong to signed-in users, whether or not the application has a form for it.",
|
|
6272
|
+
severity: "high",
|
|
6273
|
+
confidence: 0.85,
|
|
6274
|
+
cwe: ["CWE-284", "CWE-862"],
|
|
6275
|
+
evaluate(ctx) {
|
|
6276
|
+
const out = [];
|
|
6277
|
+
for (const t of publicTables(ctx)) {
|
|
6278
|
+
if (!t.rlsEnabled) continue;
|
|
6279
|
+
for (const p of t.policyDetails) {
|
|
6280
|
+
if (!WRITE_COMMANDS.has(p.command) || !reachableByAnon(p)) continue;
|
|
6281
|
+
const decides = p.command === "insert" ? [["WITH CHECK", p.check]] : p.command === "all" ? [
|
|
6282
|
+
["USING", p.using],
|
|
6283
|
+
["WITH CHECK", p.check]
|
|
6284
|
+
] : [["USING", p.using]];
|
|
6285
|
+
const open = decides.filter(([, expr]) => isTautology(expr));
|
|
6286
|
+
if (open.length === 0) continue;
|
|
6287
|
+
const insertOnly = p.command === "insert";
|
|
6288
|
+
const severity = insertOnly ? "medium" : "high";
|
|
6289
|
+
const clause = open.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
|
|
6290
|
+
const verbs = p.command === "all" ? "insert, update and delete" : `${p.command} rows in`;
|
|
6291
|
+
const evidence = [
|
|
6292
|
+
{
|
|
6293
|
+
kind: "rule",
|
|
6294
|
+
summary: `Policy "${p.name}" on public.${t.table} is ${commandLabel(p)} to ${roleLabel(p)} and decides with a tautology: ${clause}. Anyone holding the public anon key can ${verbs} public.${t.table} straight through PostgREST, without going through this application.${insertOnly ? " An insert-only policy is how deliberate public forms are written, so this is reported as medium: check that the table is meant to accept rows from strangers and that a rate limit and a validation trigger exist." : " Rows that belong to signed-in users can be changed or deleted by a stranger."}`,
|
|
6295
|
+
locations: locations2(p.location, t.location),
|
|
6296
|
+
data: {
|
|
6297
|
+
deterministic: true,
|
|
6298
|
+
ruleId: this.id,
|
|
6299
|
+
table: t.table,
|
|
6300
|
+
policy: p.name,
|
|
6301
|
+
command: p.command,
|
|
6302
|
+
roles: p.roles
|
|
6303
|
+
}
|
|
6304
|
+
},
|
|
6305
|
+
{
|
|
6306
|
+
kind: "trace",
|
|
6307
|
+
summary: [
|
|
6308
|
+
"Anyone with the public anon key",
|
|
6309
|
+
"PostgREST",
|
|
6310
|
+
`policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)}, ${clause})`,
|
|
6311
|
+
`public.${t.table}`
|
|
6312
|
+
].join(" -> ")
|
|
6313
|
+
}
|
|
6314
|
+
];
|
|
6315
|
+
out.push(
|
|
6316
|
+
finding2(
|
|
6317
|
+
ctx,
|
|
6318
|
+
this,
|
|
6319
|
+
{
|
|
6320
|
+
title: `Policy "${p.name}" lets anyone ${p.command === "all" ? "write" : p.command} "${t.table}"`,
|
|
6321
|
+
entrypoints: [DATA_API],
|
|
6322
|
+
sources: ["anon-key"],
|
|
6323
|
+
sinks: [`supabase.${p.command === "all" ? "insert" : p.command}:public.${t.table}`],
|
|
6324
|
+
path: [
|
|
6325
|
+
"Anyone with the public anon key",
|
|
6326
|
+
"PostgREST",
|
|
6327
|
+
`policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)})`,
|
|
6328
|
+
`public.${t.table}`
|
|
6329
|
+
],
|
|
6330
|
+
evidence
|
|
6331
|
+
},
|
|
6332
|
+
severity
|
|
6333
|
+
)
|
|
6334
|
+
);
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
return out;
|
|
6338
|
+
}
|
|
6339
|
+
};
|
|
6340
|
+
var supabaseSqlPoliciesPack = [
|
|
6341
|
+
rlsPolicyTrustsUserMetadata,
|
|
6342
|
+
policiesWithoutRlsEnabled,
|
|
6343
|
+
anonWritePolicy
|
|
6344
|
+
];
|
|
6345
|
+
|
|
6077
6346
|
// packages/rules/src/packs/supabase-storage-rpc.ts
|
|
6078
6347
|
function reaches(ctx) {
|
|
6079
6348
|
const out = [];
|
|
@@ -6096,7 +6365,7 @@ function reaches(ctx) {
|
|
|
6096
6365
|
}
|
|
6097
6366
|
return out;
|
|
6098
6367
|
}
|
|
6099
|
-
function
|
|
6368
|
+
function locations3(...refs) {
|
|
6100
6369
|
const out = [];
|
|
6101
6370
|
for (const r of refs) {
|
|
6102
6371
|
if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
|
|
@@ -6106,7 +6375,7 @@ function locations2(...refs) {
|
|
|
6106
6375
|
function unique(values) {
|
|
6107
6376
|
return [...new Set(values)];
|
|
6108
6377
|
}
|
|
6109
|
-
function
|
|
6378
|
+
function finding3(ctx, rule, body, severity) {
|
|
6110
6379
|
return {
|
|
6111
6380
|
id: ctx.nextId(),
|
|
6112
6381
|
ruleId: rule.id,
|
|
@@ -6162,7 +6431,7 @@ var storageObjectAccessWithoutOwnerScope = {
|
|
|
6162
6431
|
{
|
|
6163
6432
|
kind: "rule",
|
|
6164
6433
|
summary: `storage.from(${s.bucket === null ? "\u2026" : `"${s.bucket}"`}).${s.op}(${s.pathText}) runs through ${client}, a service-role client, so storage policies on storage.objects do not apply. The object path comes from the request and is neither prefixed with nor checked against the caller's user id, so any signed-in user can ${verb} any object in ${bucketLabel(s.bucket)}, including other users' files. The handler authenticates the caller but never ties the path to them.${via}`,
|
|
6165
|
-
locations:
|
|
6434
|
+
locations: locations3(r.handler.location, r.query.location, r.client?.location),
|
|
6166
6435
|
data: {
|
|
6167
6436
|
deterministic: false,
|
|
6168
6437
|
ruleId: this.id,
|
|
@@ -6175,7 +6444,7 @@ var storageObjectAccessWithoutOwnerScope = {
|
|
|
6175
6444
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
6176
6445
|
];
|
|
6177
6446
|
out.push(
|
|
6178
|
-
|
|
6447
|
+
finding3(ctx, this, {
|
|
6179
6448
|
title: `Cross-user storage ${s.op} in ${bucketLabel(s.bucket)} via service-role client`,
|
|
6180
6449
|
entrypoints: [r.handlerData.entry],
|
|
6181
6450
|
sources: r.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
@@ -6282,7 +6551,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6282
6551
|
];
|
|
6283
6552
|
const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
|
|
6284
6553
|
out.push(
|
|
6285
|
-
|
|
6554
|
+
finding3(ctx, this, {
|
|
6286
6555
|
title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
|
|
6287
6556
|
entrypoints: [...entries, storageEntry],
|
|
6288
6557
|
sources: unique([
|
|
@@ -6295,7 +6564,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6295
6564
|
{
|
|
6296
6565
|
kind: "rule",
|
|
6297
6566
|
summary: `Policy "${p.name}" for ${p.command} on storage.objects ${clause} (${expr}) checks only the bucket: no auth.uid(), no storage.foldername(name) ownership, no owner column. So ${whoLong} can ${verb} every object in ${where2}, including other users' files, straight through the Storage API.${privateNote}${reachNote} Scope it to the owner, e.g. bucket_id = '${buckets[0] ?? "<bucket>"}' and (storage.foldername(name))[1] = (select auth.uid())::text.`,
|
|
6298
|
-
locations:
|
|
6567
|
+
locations: locations3(p.location, ...reached.map((r) => r.query.location)),
|
|
6299
6568
|
data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
|
|
6300
6569
|
},
|
|
6301
6570
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
@@ -6343,7 +6612,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6343
6612
|
"no auth.uid() / auth.jwt() check"
|
|
6344
6613
|
];
|
|
6345
6614
|
out.push(
|
|
6346
|
-
|
|
6615
|
+
finding3(
|
|
6347
6616
|
ctx,
|
|
6348
6617
|
this,
|
|
6349
6618
|
{
|
|
@@ -6359,7 +6628,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6359
6628
|
{
|
|
6360
6629
|
kind: "rule",
|
|
6361
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.`,
|
|
6362
|
-
locations:
|
|
6631
|
+
locations: locations3(
|
|
6363
6632
|
fn.location,
|
|
6364
6633
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
6365
6634
|
),
|
|
@@ -6412,9 +6681,9 @@ var PUBLIC_TABLE_RULES = /* @__PURE__ */ new Set([
|
|
|
6412
6681
|
"supabase.rls-policy-without-caller-predicate"
|
|
6413
6682
|
]);
|
|
6414
6683
|
var READ_SINK = /^supabase\.select:public\.(.+)$/;
|
|
6415
|
-
function applyPublicTables(findings,
|
|
6416
|
-
if (
|
|
6417
|
-
const declared = new Set(
|
|
6684
|
+
function applyPublicTables(findings, publicTables2, now) {
|
|
6685
|
+
if (publicTables2.length === 0) return findings;
|
|
6686
|
+
const declared = new Set(publicTables2.map((t) => t.toLowerCase()));
|
|
6418
6687
|
return findings.map((f) => {
|
|
6419
6688
|
if (f.status === "suppressed" || !PUBLIC_TABLE_RULES.has(f.ruleId)) return f;
|
|
6420
6689
|
if (f.sinks.length === 0) return f;
|
|
@@ -6462,7 +6731,8 @@ function applySuppressions(findings, model, now) {
|
|
|
6462
6731
|
// packages/rules/src/index.ts
|
|
6463
6732
|
var defaultRules = [
|
|
6464
6733
|
...supabaseAuthorizationPack,
|
|
6465
|
-
...supabaseStorageRpcPack
|
|
6734
|
+
...supabaseStorageRpcPack,
|
|
6735
|
+
...supabaseSqlPoliciesPack
|
|
6466
6736
|
];
|
|
6467
6737
|
|
|
6468
6738
|
// packages/scanner/src/config.ts
|
|
@@ -6495,14 +6765,14 @@ function loadAuditConfig(root) {
|
|
|
6495
6765
|
const obj = raw;
|
|
6496
6766
|
const ignore = strings(obj.ignore);
|
|
6497
6767
|
const migrations = strings(obj.migrations);
|
|
6498
|
-
const
|
|
6768
|
+
const publicTables2 = publicTableNames(obj.publicTables);
|
|
6499
6769
|
return {
|
|
6500
6770
|
config: {
|
|
6501
6771
|
...ignore ? { ignore } : {},
|
|
6502
6772
|
...migrations ? { migrations } : {},
|
|
6503
|
-
...
|
|
6773
|
+
...publicTables2.tables ? { publicTables: publicTables2.tables } : {}
|
|
6504
6774
|
},
|
|
6505
|
-
warnings:
|
|
6775
|
+
warnings: publicTables2.warnings
|
|
6506
6776
|
};
|
|
6507
6777
|
}
|
|
6508
6778
|
function publicTableNames(value) {
|
|
@@ -6617,7 +6887,7 @@ function ensureDirectory(path) {
|
|
|
6617
6887
|
}
|
|
6618
6888
|
if (!isDirectory) throw new ScanError("path_not_found", path, `${path} is not a directory`);
|
|
6619
6889
|
}
|
|
6620
|
-
function summarize(model, rules,
|
|
6890
|
+
function summarize(model, rules, publicTables2 = []) {
|
|
6621
6891
|
const apiTables = model.tables.filter((t) => !t.table.includes("."));
|
|
6622
6892
|
return {
|
|
6623
6893
|
root: model.root,
|
|
@@ -6628,7 +6898,7 @@ function summarize(model, rules, publicTables = []) {
|
|
|
6628
6898
|
tablesWithRls: apiTables.filter((t) => t.rlsEnabled).length,
|
|
6629
6899
|
rules,
|
|
6630
6900
|
warnings: model.warnings,
|
|
6631
|
-
publicTables: [...
|
|
6901
|
+
publicTables: [...publicTables2]
|
|
6632
6902
|
};
|
|
6633
6903
|
}
|
|
6634
6904
|
function runScan(path, opts = {}) {
|
|
@@ -6642,11 +6912,11 @@ function runScan(path, opts = {}) {
|
|
|
6642
6912
|
...ignore.globs.length > 0 ? { ignore: ignore.globs } : {}
|
|
6643
6913
|
});
|
|
6644
6914
|
const graph = buildGraph(model);
|
|
6645
|
-
const
|
|
6646
|
-
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables };
|
|
6915
|
+
const publicTables2 = cfg.config.publicTables ?? [];
|
|
6916
|
+
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
|
|
6647
6917
|
const findings = runRules(defaultRules, model, graph, runOpts);
|
|
6648
6918
|
const coverage = summarizeCoverage(findings);
|
|
6649
|
-
const summary = summarize(model, defaultRules.length,
|
|
6919
|
+
const summary = summarize(model, defaultRules.length, publicTables2);
|
|
6650
6920
|
return {
|
|
6651
6921
|
summary: {
|
|
6652
6922
|
...summary,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|