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.
Files changed (2) hide show
  1. package/dist/auditai-scan.mjs +315 -45
  2. package/package.json +1 -1
@@ -78,20 +78,20 @@ var IllegalTransitionError = class extends Error {
78
78
  to;
79
79
  name = "IllegalTransitionError";
80
80
  };
81
- function transition(finding3, to, opts = {}) {
82
- if (!canTransition(finding3.status, to)) {
83
- throw new IllegalTransitionError(finding3.id, finding3.status, to);
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 ${finding3.id}: transition to ${to} requires evidence`);
87
+ throw new Error(`Finding ${finding4.id}: transition to ${to} requires evidence`);
88
88
  }
89
- if (to === "verified" && !isVerificationPassing(finding3.verification)) {
90
- throw new Error(`Finding ${finding3.id}: cannot mark verified without a passing verification`);
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 ? [...finding3.evidence, opts.evidence] : finding3.evidence;
92
+ const evidence = opts.evidence ? [...finding4.evidence, opts.evidence] : finding4.evidence;
93
93
  return {
94
- ...finding3,
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(finding3) {
104
- return finding3.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
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(finding3, policy = DEFAULT_BLOCKING_POLICY) {
113
- if (finding3.status === "suppressed") return false;
114
- if (finding3.status === "verified") return true;
115
- if (finding3.confidence < policy.minConfidence) return false;
116
- if (CONFIRMED_OR_LATER.includes(finding3.status) && policy.blockOnConfirmedSeverities.includes(finding3.severity)) {
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 && finding3.severity === "critical" && isDeterministic(finding3) && finding3.status !== "unverified") {
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, quote, backslashEscapes) {
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 === quote) {
1619
- if (text.charAt(j + 1) === quote) {
1620
- value += quote;
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 rolesMatch = /\bto\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)/i.exec(
3340
- rest
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 locations2(...refs) {
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 finding2(ctx, rule, body, severity) {
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: locations2(r.handler.location, r.query.location, r.client?.location),
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
- finding2(ctx, this, {
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
- finding2(ctx, this, {
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: locations2(p.location, ...reached.map((r) => r.query.location)),
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
- finding2(
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: locations2(
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, publicTables, now) {
6416
- if (publicTables.length === 0) return findings;
6417
- const declared = new Set(publicTables.map((t) => t.toLowerCase()));
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 publicTables = publicTableNames(obj.publicTables);
6768
+ const publicTables2 = publicTableNames(obj.publicTables);
6499
6769
  return {
6500
6770
  config: {
6501
6771
  ...ignore ? { ignore } : {},
6502
6772
  ...migrations ? { migrations } : {},
6503
- ...publicTables.tables ? { publicTables: publicTables.tables } : {}
6773
+ ...publicTables2.tables ? { publicTables: publicTables2.tables } : {}
6504
6774
  },
6505
- warnings: publicTables.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, publicTables = []) {
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: [...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 publicTables = cfg.config.publicTables ?? [];
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, publicTables);
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.5.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",