auditai-scan 0.7.1 → 0.7.3

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 +671 -69
  2. package/package.json +1 -1
@@ -253,6 +253,19 @@ var COMMANDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete",
253
253
  var HEADER = (title) => `-- ${title}
254
254
  -- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
255
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
+ }
256
269
  function sqlFunctionFix(finding4, model) {
257
270
  const name = finding4.evidence[0]?.data?.function;
258
271
  const f = fn(model, typeof name === "string" ? name : void 0);
@@ -260,6 +273,21 @@ function sqlFunctionFix(finding4, model) {
260
273
  return null;
261
274
  const sig = signatureOf(f);
262
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
+ }
263
291
  const body = [
264
292
  HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
265
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" : "",
@@ -1490,6 +1518,18 @@ function rowComparisons(tail) {
1490
1518
  const { parent } = outerOf(tail);
1491
1519
  if (!parent || !ts4.isVariableDeclaration(parent)) return [];
1492
1520
  const { data } = resultNames(parent.name);
1521
+ const fn2 = enclosingFunction(parent);
1522
+ const scope = fn2 ? fn2.body : parent.getSourceFile();
1523
+ if (scope) {
1524
+ walkOwn(scope, (n) => {
1525
+ if (!ts4.isVariableDeclaration(n) || !n.initializer || n.pos < parent.end) return;
1526
+ if (!ts4.isIdentifier(n.name)) return;
1527
+ const init = unwrap(n.initializer);
1528
+ if (!ts4.isPropertyAccessExpression(init) && !ts4.isElementAccessExpression(init)) return;
1529
+ const r = rootName(init);
1530
+ if (r !== null && data.has(r)) data.add(n.name.text);
1531
+ });
1532
+ }
1493
1533
  const out = [];
1494
1534
  for (const s of ifsAfter(parent)) {
1495
1535
  const exit = exitKind(s.thenStatement);
@@ -2647,6 +2687,14 @@ function newFunctionRegistry() {
2647
2687
  publicSchemaPublic: false
2648
2688
  };
2649
2689
  }
2690
+ function functionBodyOf(reg, q) {
2691
+ const direct = reg.byKey.get(qualifiedKey(q));
2692
+ if (direct) return direct.body;
2693
+ if (q.schema !== null) return null;
2694
+ const lower = q.name.toLowerCase();
2695
+ for (const f of reg.byKey.values()) if (f.name.toLowerCase() === lower) return f.body;
2696
+ return null;
2697
+ }
2650
2698
  var CALLER_CHECKS = [
2651
2699
  /"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
2652
2700
  /\bcurrent_setting\s*\(\s*'request\.jwt/i
@@ -2709,7 +2757,8 @@ function applyCreateFunction(reg, stmt, file) {
2709
2757
  const q = readQualifiedName(tk, i + 1);
2710
2758
  if (!q || !isPunct(tk[q.next], "(")) return;
2711
2759
  const argsEnd = groupEnd(tk, q.next);
2712
- const args = argumentTypes(stmt, tk, q.next, argsEnd);
2760
+ const params = readParams(stmt, tk, q.next, argsEnd);
2761
+ const args = params === null ? null : params.map((p) => p.type).join(", ");
2713
2762
  i = argsEnd + 1;
2714
2763
  let securityDefiner = false;
2715
2764
  let returns = null;
@@ -2749,6 +2798,7 @@ function applyCreateFunction(reg, stmt, file) {
2749
2798
  securityDefiner,
2750
2799
  returns,
2751
2800
  args,
2801
+ params,
2752
2802
  body: code,
2753
2803
  directCheck: CALLER_CHECKS.some((re) => re.test(code)),
2754
2804
  acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
@@ -2815,8 +2865,8 @@ var TYPE_WORD = /* @__PURE__ */ new Set([
2815
2865
  "void",
2816
2866
  "xml"
2817
2867
  ]);
2818
- function argumentTypes(stmt, tokens, open, close) {
2819
- if (close <= open + 1) return "";
2868
+ function readParams(stmt, tokens, open, close) {
2869
+ if (close <= open + 1) return [];
2820
2870
  const parts = [];
2821
2871
  let depth = 0;
2822
2872
  let start = open + 1;
@@ -2833,11 +2883,18 @@ function argumentTypes(stmt, tokens, open, close) {
2833
2883
  pieces.push([start, close]);
2834
2884
  for (const [from, to] of pieces) {
2835
2885
  const words = [];
2886
+ let hasDefault = false;
2887
+ let callerDefault = false;
2836
2888
  for (let i = from; i < to; i++) {
2837
2889
  const t = tokens[i];
2838
2890
  if (!t) continue;
2839
- if (isWord(t, "default")) break;
2840
- if (t.kind === "punct" && t.value === "=") break;
2891
+ if (isWord(t, "default") || t.kind === "punct" && t.value === "=") {
2892
+ hasDefault = true;
2893
+ const last2 = tokens[to - 1];
2894
+ const text = last2 ? stmt.text.slice(t.end - stmt.start, last2.end - stmt.start) : "";
2895
+ callerDefault = CALLER_CHECKS.some((re) => re.test(text));
2896
+ break;
2897
+ }
2841
2898
  words.push(t);
2842
2899
  }
2843
2900
  if (words.length === 0) return null;
@@ -2854,11 +2911,14 @@ function argumentTypes(stmt, tokens, open, close) {
2854
2911
  const first = words[typeStart];
2855
2912
  const last = words[words.length - 1];
2856
2913
  if (!first || !last) return null;
2857
- parts.push(
2858
- stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
2859
- );
2914
+ parts.push({
2915
+ name: named && firstWord ? firstWord.value.toLowerCase() : null,
2916
+ type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
2917
+ hasDefault,
2918
+ callerDefault
2919
+ });
2860
2920
  }
2861
- return parts.join(", ");
2921
+ return parts;
2862
2922
  }
2863
2923
  function readFunctionList(tokens, from) {
2864
2924
  const keys = [];
@@ -3033,16 +3093,187 @@ function applyDefaultPrivileges(reg, stmt) {
3033
3093
  }
3034
3094
  }
3035
3095
  var CALL = /(?<![A-Za-z0-9_$])(?:"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\(/g;
3096
+ function calledFunction(reg, m, byName) {
3097
+ const name = m[2];
3098
+ if (!name) return void 0;
3099
+ return reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
3100
+ }
3036
3101
  function callees(reg, f, byName) {
3037
3102
  const out = /* @__PURE__ */ new Set();
3038
3103
  for (const m of f.body.matchAll(CALL)) {
3039
- const name = m[2];
3040
- if (!name) continue;
3041
- const g = reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
3104
+ const g = calledFunction(reg, m, byName);
3042
3105
  if (g && g !== f) out.add(g);
3043
3106
  }
3044
3107
  return out;
3045
3108
  }
3109
+ var MAX_CALL_ARGS_CHARS = 4e3;
3110
+ function callArguments(body, from) {
3111
+ const args = [];
3112
+ let depth = 0;
3113
+ let quoted = false;
3114
+ let start = from;
3115
+ const end = Math.min(body.length, from + MAX_CALL_ARGS_CHARS);
3116
+ for (let i = from; i < end; i++) {
3117
+ const c = body[i];
3118
+ if (quoted) {
3119
+ if (c === "'") quoted = false;
3120
+ } else if (c === "'") {
3121
+ quoted = true;
3122
+ } else if (c === "(") {
3123
+ depth += 1;
3124
+ } else if (c === ")") {
3125
+ if (depth === 0) {
3126
+ const last = body.slice(start, i).trim();
3127
+ if (last !== "" || args.length > 0) args.push(last);
3128
+ return args;
3129
+ }
3130
+ depth -= 1;
3131
+ } else if (c === "," && depth === 0) {
3132
+ args.push(body.slice(start, i).trim());
3133
+ start = i + 1;
3134
+ }
3135
+ }
3136
+ return null;
3137
+ }
3138
+ function callsWithCallerDefault(reg, f, byName) {
3139
+ for (const m of f.body.matchAll(CALL)) {
3140
+ const g = calledFunction(reg, m, byName);
3141
+ if (!g || g === f || !g.params?.some((p) => p.callerDefault)) continue;
3142
+ const args = callArguments(f.body, (m.index ?? 0) + m[0].length);
3143
+ if (args === null) continue;
3144
+ const named = args.filter((a) => /=>|:=/.test(a)).map((a) => (a.split(/=>|:=/)[0] ?? "").trim().replace(/"/g, "").toLowerCase());
3145
+ const positional = args.length - named.length;
3146
+ const omitted = g.params.some(
3147
+ (p, i) => p.callerDefault && i >= positional && (p.name === null || !named.includes(p.name))
3148
+ );
3149
+ if (omitted) return true;
3150
+ }
3151
+ return false;
3152
+ }
3153
+ 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;
3154
+ function relationsOf(body) {
3155
+ const out = [];
3156
+ for (const m of body.matchAll(RELATION)) {
3157
+ const schema = m[1]?.toLowerCase();
3158
+ const name = m[2]?.toLowerCase();
3159
+ if (!name || name === "only" || name === "lateral") continue;
3160
+ const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3161
+ if (!out.includes(key)) out.push(key);
3162
+ }
3163
+ return out;
3164
+ }
3165
+ var IDENT_SRC = "[A-Za-z_][A-Za-z0-9_$]{0,62}";
3166
+ var WRITE = new RegExp(
3167
+ `(?<![A-Za-z0-9_$])(?:update|insert\\s+into|delete\\s+from)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?![A-Za-z0-9_$"]|\\s*\\.)`,
3168
+ "gi"
3169
+ );
3170
+ var NOT_A_RELATION = /* @__PURE__ */ new Set(["set", "of", "skip", "nowait", "only", "on"]);
3171
+ function writesOf(body) {
3172
+ const out = [];
3173
+ for (const m of body.matchAll(WRITE)) {
3174
+ const schema = m[1]?.toLowerCase();
3175
+ const name = m[2]?.toLowerCase();
3176
+ if (!name || NOT_A_RELATION.has(name)) continue;
3177
+ const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3178
+ if (!out.includes(key)) out.push(key);
3179
+ }
3180
+ return out;
3181
+ }
3182
+ var ALIASED = new RegExp(
3183
+ `(?<![A-Za-z0-9_$])(?:from|join|update|into)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?:\\s+(?:as\\s+)?"?(${IDENT_SRC})"?)?`,
3184
+ "gi"
3185
+ );
3186
+ var SQL_WORDS = /* @__PURE__ */ new Set([
3187
+ "and",
3188
+ "as",
3189
+ "conflict",
3190
+ "cross",
3191
+ "default",
3192
+ "do",
3193
+ "else",
3194
+ "elsif",
3195
+ "end",
3196
+ "except",
3197
+ "for",
3198
+ "from",
3199
+ "full",
3200
+ "group",
3201
+ "having",
3202
+ "if",
3203
+ "in",
3204
+ "inner",
3205
+ "intersect",
3206
+ "into",
3207
+ "is",
3208
+ "join",
3209
+ "lateral",
3210
+ "left",
3211
+ "limit",
3212
+ "loop",
3213
+ "natural",
3214
+ "not",
3215
+ "nowait",
3216
+ "of",
3217
+ "offset",
3218
+ "on",
3219
+ "only",
3220
+ "or",
3221
+ "order",
3222
+ "returning",
3223
+ "right",
3224
+ "select",
3225
+ "set",
3226
+ "skip",
3227
+ "then",
3228
+ "union",
3229
+ "using",
3230
+ "values",
3231
+ "when",
3232
+ "where",
3233
+ "window",
3234
+ "with"
3235
+ ]);
3236
+ var CLAUSE = /(?<![A-Za-z0-9_$])(set|where|on|and|or|when|if|elsif|having|values|select|returning|then|else)(?![A-Za-z0-9_$])/gi;
3237
+ var COMPARING = /* @__PURE__ */ new Set(["where", "on", "and", "or", "when", "if", "elsif", "having"]);
3238
+ var MAX_KEYED_PARAMS = 32;
3239
+ var CLAUSE_WINDOW = 400;
3240
+ function paramKeysOf(body, params) {
3241
+ const aliases = /* @__PURE__ */ new Map();
3242
+ const relations = [];
3243
+ for (const m of body.matchAll(ALIASED)) {
3244
+ const schema = m[1]?.toLowerCase();
3245
+ const name = m[2]?.toLowerCase();
3246
+ if (!name || SQL_WORDS.has(name)) continue;
3247
+ const table2 = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3248
+ if (!relations.includes(table2)) relations.push(table2);
3249
+ aliases.set(name, table2);
3250
+ const alias = m[3]?.toLowerCase();
3251
+ if (alias && !SQL_WORDS.has(alias)) aliases.set(alias, table2);
3252
+ }
3253
+ const side = `(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?`;
3254
+ const out = [];
3255
+ for (const param of params.slice(0, MAX_KEYED_PARAMS)) {
3256
+ if (!/^[a-z_][a-z0-9_]*$/.test(param)) continue;
3257
+ const re = new RegExp(
3258
+ `${side}\\s*(?<![<>!:])=\\s*"?${param}"?(?![A-Za-z0-9_$.(])|(?<![A-Za-z0-9_$."])"?${param}"?\\s*=(?!=)\\s*${side}(?!\\s*\\()`,
3259
+ "gi"
3260
+ );
3261
+ for (const m of body.matchAll(re)) {
3262
+ const at = m.index ?? 0;
3263
+ const clauses = [...body.slice(Math.max(0, at - CLAUSE_WINDOW), at).matchAll(CLAUSE)];
3264
+ const clause = clauses[clauses.length - 1]?.[1]?.toLowerCase();
3265
+ if (clause === void 0 || !COMPARING.has(clause)) continue;
3266
+ const qualifier = (m[1] ?? m[3])?.toLowerCase();
3267
+ const column = (m[2] ?? m[4])?.toLowerCase();
3268
+ if (!column || column === param || SQL_WORDS.has(column)) continue;
3269
+ const table2 = qualifier !== void 0 ? aliases.get(qualifier) : relations.length === 1 ? relations[0] : void 0;
3270
+ if (table2 === void 0) continue;
3271
+ if (!out.some((k) => k.param === param && k.table === table2 && k.column === column))
3272
+ out.push({ param, table: table2, column });
3273
+ }
3274
+ }
3275
+ return out;
3276
+ }
3046
3277
  function effectiveRoles(acl) {
3047
3278
  const out = [];
3048
3279
  if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
@@ -3060,10 +3291,15 @@ function finishFunctions(reg) {
3060
3291
  }
3061
3292
  }
3062
3293
  const callers = /* @__PURE__ */ new Map();
3294
+ const calls = /* @__PURE__ */ new Map();
3063
3295
  for (const f of fns) {
3064
- for (const g of callees(reg, f, byName)) callers.set(g, [...callers.get(g) ?? [], f]);
3296
+ const called = callees(reg, f, byName);
3297
+ calls.set(f, called);
3298
+ for (const g of called) callers.set(g, [...callers.get(g) ?? [], f]);
3065
3299
  }
3066
- const checks = new Set(fns.filter((f) => f.directCheck));
3300
+ const checks = new Set(
3301
+ fns.filter((f) => f.directCheck || callsWithCallerDefault(reg, f, byName))
3302
+ );
3067
3303
  const queue = [...checks];
3068
3304
  for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
3069
3305
  for (const f of callers.get(g) ?? []) {
@@ -3082,6 +3318,25 @@ function finishFunctions(reg) {
3082
3318
  };
3083
3319
  if (f.returns !== null) info.returns = f.returns;
3084
3320
  if (f.args !== null) info.args = f.args;
3321
+ if (f.params?.every((x) => x.name !== null)) {
3322
+ const params = f.params.map((x) => ({
3323
+ name: x.name ?? "",
3324
+ type: x.type,
3325
+ ...x.hasDefault ? { default: true } : {}
3326
+ }));
3327
+ info.params = params;
3328
+ const keys = paramKeysOf(
3329
+ f.body,
3330
+ params.map((x) => x.name)
3331
+ );
3332
+ if (keys.length > 0) info.keys = keys;
3333
+ }
3334
+ const tables = relationsOf(f.body);
3335
+ if (tables.length > 0) info.tables = tables;
3336
+ const called = [...calls.get(f) ?? []].map((g) => qualifiedKey(g));
3337
+ if (called.length > 0) info.calls = called;
3338
+ const writes = writesOf(f.body);
3339
+ if (writes.length > 0) info.writes = writes;
3085
3340
  return info;
3086
3341
  });
3087
3342
  }
@@ -3203,6 +3458,7 @@ function schemaStateFor(tables) {
3203
3458
  enums: /* @__PURE__ */ new Map(),
3204
3459
  functions: newFunctionRegistry(),
3205
3460
  buckets: newBucketRegistry(),
3461
+ triggers: /* @__PURE__ */ new Map(),
3206
3462
  warnings: []
3207
3463
  };
3208
3464
  STATES.set(tables, state);
@@ -3645,6 +3901,96 @@ function doBlock(state, stmt, file) {
3645
3901
  }
3646
3902
  }
3647
3903
  }
3904
+ var isTriggerEvent = (v) => v === "insert" || v === "update" || v === "delete" || v === "truncate";
3905
+ function createTrigger(state, stmt, file) {
3906
+ const tk = stmt.tokens;
3907
+ let i = 1;
3908
+ if (isWord(tk[i], "or") && isWord(tk[i + 1], "replace")) i += 2;
3909
+ if (isWord(tk[i], "constraint")) i += 1;
3910
+ if (!isWord(tk[i], "trigger")) return;
3911
+ const name = identOf(tk[i + 1]);
3912
+ if (name === null || name === "") return;
3913
+ i += 2;
3914
+ let timing;
3915
+ if (isWord(tk[i], "before")) timing = "before";
3916
+ else if (isWord(tk[i], "after")) timing = "after";
3917
+ else if (isWord(tk[i], "instead") && isWord(tk[i + 1], "of")) {
3918
+ timing = "instead of";
3919
+ i += 1;
3920
+ } else return;
3921
+ i += 1;
3922
+ const events = [];
3923
+ const ofColumns = [];
3924
+ while (i < tk.length && !isWord(tk[i], "on")) {
3925
+ const t = tk[i];
3926
+ if (t?.kind === "word" && isTriggerEvent(t.value)) {
3927
+ events.push(t.value);
3928
+ i += 1;
3929
+ } else if (isWord(t, "of")) {
3930
+ i += 1;
3931
+ while (i < tk.length && !isWord(tk[i], "or") && !isWord(tk[i], "on")) {
3932
+ const col = identOf(tk[i]);
3933
+ if (col !== null && col !== "") ofColumns.push(col.toLowerCase());
3934
+ i += 1;
3935
+ }
3936
+ } else {
3937
+ i += 1;
3938
+ }
3939
+ }
3940
+ const table2 = readQualifiedName(tk, i + 1);
3941
+ if (!table2 || events.length === 0) return;
3942
+ let j = table2.next;
3943
+ while (j < tk.length && !isWord(tk[j], "execute")) j += 1;
3944
+ if (!isWord(tk[j + 1], "function") && !isWord(tk[j + 1], "procedure")) return;
3945
+ const fn2 = readQualifiedName(tk, j + 2);
3946
+ if (!fn2) return;
3947
+ const key = qualifiedKey(table2);
3948
+ state.triggers.set(`${key}#${name.toLowerCase()}`, {
3949
+ name: name.toLowerCase(),
3950
+ table: key,
3951
+ timing,
3952
+ events,
3953
+ ofColumns,
3954
+ fn: { schema: fn2.schema, name: fn2.name },
3955
+ location: { file, line: stmt.line }
3956
+ });
3957
+ }
3958
+ function dropTrigger(state, stmt) {
3959
+ const tk = stmt.tokens;
3960
+ let i = 2;
3961
+ if (isWord(tk[i], "if") && isWord(tk[i + 1], "exists")) i += 2;
3962
+ const name = identOf(tk[i]);
3963
+ if (name === null || !isWord(tk[i + 1], "on")) return;
3964
+ const table2 = readQualifiedName(tk, i + 2);
3965
+ if (table2) state.triggers.delete(`${qualifiedKey(table2)}#${name.toLowerCase()}`);
3966
+ }
3967
+ var NEW_COLUMN = /(?<![A-Za-z0-9_$])new\s*\.\s*"?([A-Za-z_][A-Za-z0-9_]{0,62})"?/gi;
3968
+ var MAX_TRIGGER_COLUMNS = 200;
3969
+ function finishTrigger(state, t) {
3970
+ const body = functionBodyOf(state.functions, t.fn) ?? "";
3971
+ const raises = /(?<![A-Za-z0-9_$])raise(?![A-Za-z0-9_$])/i.test(body);
3972
+ const read = /* @__PURE__ */ new Set();
3973
+ for (const m of body.matchAll(NEW_COLUMN)) {
3974
+ const col = m[1]?.toLowerCase();
3975
+ if (col) read.add(col);
3976
+ if (read.size >= MAX_TRIGGER_COLUMNS) break;
3977
+ }
3978
+ const checked = new Set(t.ofColumns);
3979
+ for (const col of read) {
3980
+ const old = new RegExp(`(?<![A-Za-z0-9_$])old\\s*\\.\\s*"?${col}"?(?![A-Za-z0-9_$])`, "i");
3981
+ const assigned = new RegExp(`(?<![A-Za-z0-9_$])new\\s*\\.\\s*"?${col}"?\\s*:=`, "i");
3982
+ if (raises || old.test(body) || assigned.test(body)) checked.add(col);
3983
+ }
3984
+ return {
3985
+ name: t.name,
3986
+ table: t.table,
3987
+ timing: t.timing,
3988
+ events: [...t.events],
3989
+ checkedColumns: [...checked],
3990
+ function: qualifiedKey(t.fn),
3991
+ location: t.location
3992
+ };
3993
+ }
3648
3994
  function applySchemaStatement(state, stmt, file) {
3649
3995
  const tk = stmt.tokens;
3650
3996
  const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
@@ -3655,6 +4001,7 @@ function applySchemaStatement(state, stmt, file) {
3655
4001
  else if (kind === "type") createType(state, stmt);
3656
4002
  else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
3657
4003
  else if (kind === "unique") createUniqueIndex(state, stmt);
4004
+ else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
3658
4005
  } else if (w0 === "alter") {
3659
4006
  if (w1 === "table") alterTable(state, stmt, file, false);
3660
4007
  else if (w1 === "type") alterType(state, stmt);
@@ -3664,6 +4011,7 @@ function applySchemaStatement(state, stmt, file) {
3664
4011
  if (w1 === "table") dropTable(state, stmt);
3665
4012
  else if (w1 === "type") dropType(state, stmt);
3666
4013
  else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
4014
+ else if (w1 === "trigger") dropTrigger(state, stmt);
3667
4015
  } else if (w0 === "grant" || w0 === "revoke") {
3668
4016
  applyGrantRevoke(state.functions, stmt);
3669
4017
  } else if (w0 === "do") {
@@ -3678,6 +4026,7 @@ function finishSchema(state) {
3678
4026
  enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
3679
4027
  sqlFunctions: finishFunctions(state.functions),
3680
4028
  storageBuckets: finishBuckets(state.buckets),
4029
+ triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
3681
4030
  warnings: [...state.warnings]
3682
4031
  };
3683
4032
  }
@@ -3708,6 +4057,32 @@ var DROP_POLICY = new RegExp(
3708
4057
  function isAppliedSqlFile(rel) {
3709
4058
  return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
3710
4059
  }
4060
+ var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
4061
+ "doc",
4062
+ "docs",
4063
+ "documentation",
4064
+ "legacy",
4065
+ "archive",
4066
+ "archives",
4067
+ "archived",
4068
+ "backup",
4069
+ "backups",
4070
+ "deprecated",
4071
+ "example",
4072
+ "examples",
4073
+ "old"
4074
+ ]);
4075
+ function appliedSqlFiles(rels) {
4076
+ const applied = rels.filter(isAppliedSqlFile);
4077
+ const slashed = (rel) => rel.split("\\").join("/");
4078
+ const hasMigrations = applied.some(
4079
+ (rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
4080
+ );
4081
+ if (!hasMigrations) return applied;
4082
+ return applied.filter(
4083
+ (rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
4084
+ );
4085
+ }
3711
4086
  function parseSqlForRls(rel, text, into) {
3712
4087
  if (!isAppliedSqlFile(rel)) return;
3713
4088
  const state = schemaStateFor(into);
@@ -3783,7 +4158,7 @@ function sqlSchemaFor(into) {
3783
4158
 
3784
4159
  // packages/parser/src/role-gates.ts
3785
4160
  import ts7 from "typescript";
3786
- var ROLE_PROPERTY = /^(role|roles|app_metadata|is_?admin|is_?superuser|permissions?|claims?)$/i;
4161
+ var ROLE_PROPERTY = /^(role|roles|rolle|rol|user_?role|access_?level|app_metadata|admin|is_?admin|superuser|is_?superuser|permissions?|claims?)$/i;
3787
4162
  var EMAIL_PROPERTY = /^email$/i;
3788
4163
  function propertyPath(e) {
3789
4164
  const u = unwrap(e);
@@ -4869,6 +5244,9 @@ function bindDeclarations(p, frame, acc) {
4869
5244
  const names = boundNames(decl.name);
4870
5245
  const text = init.getText(sf);
4871
5246
  aliasLocal(frame, decl.name, init);
5247
+ const who = identityBinding(p, frame, init, scope);
5248
+ if (who !== void 0)
5249
+ for (const nm of identityNamesOf(decl.name)) frame.identities.set(nm, who);
4872
5250
  const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
4873
5251
  if (client && ts11.isIdentifier(decl.name)) {
4874
5252
  frame.clients.set(decl.name.text, client);
@@ -4950,6 +5328,21 @@ function bindDeclarations(p, frame, acc) {
4950
5328
  bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4951
5329
  }
4952
5330
  }
5331
+ const assigned = /* @__PURE__ */ new Map();
5332
+ for (const asg of collect(body, ts11.isBinaryExpression)) {
5333
+ if (asg.operatorToken.kind !== ts11.SyntaxKind.EqualsToken || !ts11.isIdentifier(asg.left))
5334
+ continue;
5335
+ if (isLiteralValue(unwrap(asg.right))) continue;
5336
+ const list = assigned.get(asg.left.text) ?? [];
5337
+ list.push(identityBinding(p, frame, asg.right, scope));
5338
+ assigned.set(asg.left.text, list);
5339
+ }
5340
+ for (const [name, whos] of assigned) {
5341
+ const [first] = whos;
5342
+ if (first !== void 0 && whos.every((w) => w !== void 0))
5343
+ frame.identities.set(name, first);
5344
+ else frame.identities.delete(name);
5345
+ }
4953
5346
  if (frame.depth === 0) {
4954
5347
  walk(body, (n) => {
4955
5348
  if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
@@ -4975,16 +5368,22 @@ function analyzeFrame(p, frame, acc) {
4975
5368
  const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
4976
5369
  if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
4977
5370
  }
4978
- const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
5371
+ const isSessionCall = (call) => callsSession(p, call, sf, scope);
4979
5372
  for (const call of collect(body, ts11.isCallExpression)) {
4980
5373
  if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
4981
5374
  }
4982
- for (const gate of roleGatesIn(body, sessionNamesIn(body, isSessionCall))) {
5375
+ const sessionNames = sessionNamesIn(body, isSessionCall);
5376
+ for (const name of frame.identities.keys()) sessionNames.add(name);
5377
+ for (const gate of roleGatesIn(body, sessionNames)) {
4983
5378
  if (gate.exit === "return" && !frame.exitPropagates) continue;
5379
+ const [root, ...rest] = gate.source.split(".");
5380
+ const table2 = root === void 0 ? void 0 : frame.identities.get(root);
5381
+ const column = rest[rest.length - 1];
4984
5382
  acc.roleChecks.push({
4985
5383
  ...loc2(gate.node),
4986
5384
  source: gate.source,
4987
- text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
5385
+ text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160),
5386
+ ...typeof table2 === "string" && column !== void 0 ? { table: table2, column } : {}
4988
5387
  });
4989
5388
  }
4990
5389
  for (const check of secretChecksIn(fn2, sf)) {
@@ -5044,7 +5443,8 @@ function analyzeFrame(p, frame, acc) {
5044
5443
  method: f.method,
5045
5444
  column: f.column,
5046
5445
  valueText: f.value ? f.value.getText(sf) : f.text,
5047
- inputDerived: f.value ? derivedIn(frame, f.value) : false
5446
+ inputDerived: f.value ? derivedIn(frame, f.value) : false,
5447
+ ...f.value && identityOf(frame, f.value) !== void 0 ? { identity: true } : {}
5048
5448
  },
5049
5449
  f.value
5050
5450
  );
@@ -5094,7 +5494,8 @@ function analyzeFrame(p, frame, acc) {
5094
5494
  method: "match",
5095
5495
  column: pr.name.getText(sf).replace(/['"]/g, ""),
5096
5496
  valueText: pr.initializer.getText(sf),
5097
- inputDerived: derivedIn(frame, pr.initializer)
5497
+ inputDerived: derivedIn(frame, pr.initializer),
5498
+ ...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
5098
5499
  };
5099
5500
  filters.push(valued(f2, pr.initializer));
5100
5501
  }
@@ -5106,7 +5507,8 @@ function analyzeFrame(p, frame, acc) {
5106
5507
  method: s.name,
5107
5508
  column: stringLiteralValue(firstArg),
5108
5509
  valueText: val ? val.getText(sf) : "",
5109
- inputDerived: val ? derivedIn(frame, val) : false
5510
+ inputDerived: val ? derivedIn(frame, val) : false,
5511
+ ...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
5110
5512
  };
5111
5513
  filters.push(valued(f, val));
5112
5514
  }
@@ -5242,7 +5644,8 @@ function analyzeFrame(p, frame, acc) {
5242
5644
  method: "compare",
5243
5645
  column: c.column,
5244
5646
  valueText: c.value.getText(sf).replace(/\s+/g, " "),
5245
- inputDerived: derivedIn(frame, c.value)
5647
+ inputDerived: derivedIn(frame, c.value),
5648
+ ...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
5246
5649
  }));
5247
5650
  if (checks.length > 0) query.ownerChecks = checks;
5248
5651
  acc.reads.push({
@@ -5294,7 +5697,8 @@ function childFrame(p, call, target, frame) {
5294
5697
  key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
5295
5698
  aliases: /* @__PURE__ */ new Map(),
5296
5699
  pathPos: [...frame.pathPos, call.getStart(frame.sf)],
5297
- exitPropagates: frame.exitPropagates && callResultChecked(call)
5700
+ exitPropagates: frame.exitPropagates && callResultChecked(call),
5701
+ identities: /* @__PURE__ */ new Map()
5298
5702
  };
5299
5703
  const cx = wholeContext(p, frame);
5300
5704
  target.fn.parameters.forEach((param, i) => {
@@ -5309,6 +5713,9 @@ function childFrame(p, call, target, frame) {
5309
5713
  child.instances.set(head, ab.instance);
5310
5714
  }
5311
5715
  for (const nm of names) if (ab.isRequest) child.reqNames.add(nm);
5716
+ const who = arg ? identityOf(frame, arg) : void 0;
5717
+ if (who !== void 0 && ts11.isIdentifier(param.name))
5718
+ child.identities.set(param.name.text, who);
5312
5719
  if (ab.tainted) {
5313
5720
  bindParamTaint(child, param.name, arg, frame);
5314
5721
  for (const nm of wholeParamNames(param.name, arg, cx)) {
@@ -5326,6 +5733,7 @@ function frameSignature(child) {
5326
5733
  ...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
5327
5734
  ...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
5328
5735
  ...[...child.reqNames].map((n) => `${n}?`),
5736
+ ...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
5329
5737
  ...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
5330
5738
  ...[...child.aliases].map(([n, k]) => `${n}~${k}`),
5331
5739
  `exit=${child.exitPropagates}`
@@ -5581,6 +5989,111 @@ function returnsIdentity(p, call, scope) {
5581
5989
  if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
5582
5990
  return IDENTITY_CALLEE.test(calleePath(call.expression));
5583
5991
  }
5992
+ function callsSession(p, call, sf, scope) {
5993
+ return /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
5994
+ }
5995
+ function identityOf(frame, e) {
5996
+ if (frame.identities.size === 0 || derivedIn(frame, e)) return void 0;
5997
+ let u = unwrap(e);
5998
+ let part = false;
5999
+ while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
6000
+ if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return void 0;
6001
+ u = unwrap(u.expression);
6002
+ part = true;
6003
+ }
6004
+ if (!ts11.isIdentifier(u) || !frame.identities.has(u.text)) return void 0;
6005
+ const who = frame.identities.get(u.text) ?? null;
6006
+ return part && who !== null ? "" : who;
6007
+ }
6008
+ function identityNamesOf(name) {
6009
+ if (ts11.isIdentifier(name)) return [name.text];
6010
+ if (ts11.isArrayBindingPattern(name)) {
6011
+ const first = name.elements[0];
6012
+ return first && !ts11.isOmittedExpression(first) ? boundNames(first.name) : [];
6013
+ }
6014
+ const data = name.elements.find((el) => propertyKeyOf(el.propertyName ?? el.name) === "data");
6015
+ if (data) return boundNames(data.name);
6016
+ return name.elements.filter((el) => propertyKeyOf(el.propertyName ?? el.name) !== "error").flatMap((el) => boundNames(el.name));
6017
+ }
6018
+ function identityRowOf(frame, call) {
6019
+ const { segments } = flattenChain(call);
6020
+ const table2 = stringLiteralValue(segments.find((s) => s.name === "from")?.args[0]);
6021
+ if (table2 === null) return void 0;
6022
+ const keyed = (column, value) => value !== void 0 && (identityOf(frame, value) !== void 0 || isCredentialColumn(column) && derivedIn(frame, value));
6023
+ for (const s of segments) {
6024
+ if (s.name === "eq" && keyed(stringLiteralValue(s.args[0]), s.args[1])) return table2;
6025
+ const obj = s.name === "match" ? s.args[0] : void 0;
6026
+ if (!obj || !ts11.isObjectLiteralExpression(obj)) continue;
6027
+ for (const pr of obj.properties) {
6028
+ if (ts11.isPropertyAssignment(pr) && keyed(propertyKeyOf(pr.name), pr.initializer))
6029
+ return table2;
6030
+ }
6031
+ }
6032
+ return void 0;
6033
+ }
6034
+ function identityBinding(p, frame, init, scope) {
6035
+ let out;
6036
+ for (const leaf of branchesOf(init)) {
6037
+ if (isLiteralValue(leaf)) continue;
6038
+ const who = identityLeaf(p, frame, leaf, scope);
6039
+ if (who === void 0) return void 0;
6040
+ if (out === void 0) out = who;
6041
+ }
6042
+ return out;
6043
+ }
6044
+ function identityLeaf(p, frame, leaf, scope) {
6045
+ const u = unwrap(leaf);
6046
+ if (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
6047
+ let base = u;
6048
+ while (ts11.isPropertyAccessExpression(base) || ts11.isElementAccessExpression(base)) {
6049
+ if (ts11.isPropertyAccessExpression(base) && base.name.text === "user_metadata")
6050
+ return void 0;
6051
+ base = unwrap(base.expression);
6052
+ }
6053
+ if (!ts11.isCallExpression(base)) return identityOf(frame, u);
6054
+ if (derivedIn(frame, u)) return void 0;
6055
+ const who = identityLeaf(p, frame, base, scope);
6056
+ return who === void 0 || who === null ? who : "";
6057
+ }
6058
+ if (ts11.isIdentifier(u)) return identityOf(frame, u);
6059
+ if (!ts11.isCallExpression(u)) return void 0;
6060
+ if (callsSession(p, u, frame.sf, scope)) return null;
6061
+ if (isChainWithQuery(u)) return identityRowOf(frame, u);
6062
+ if (isDbChain(u, frame)) return void 0;
6063
+ return returnIdentity(p, u, frame, scope);
6064
+ }
6065
+ function returnIdentity(p, call, frame, scope) {
6066
+ if (frame.depth >= MAX_DEPTH) return void 0;
6067
+ const target = callTarget(p, call, frame, scope);
6068
+ if (!target) return void 0;
6069
+ const child = childFrame(p, call, target, frame);
6070
+ if (!child) return void 0;
6071
+ const key = `${target.facts.file}#${target.name}#${frameSignature(child)}`;
6072
+ const cached = p.returnIdentities.get(key);
6073
+ if (cached !== void 0) return cached === false ? void 0 : cached.who;
6074
+ p.returnIdentities.set(key, false);
6075
+ bindDeclarations(p, child, {
6076
+ inputs: [],
6077
+ authChecks: [],
6078
+ roleChecks: [],
6079
+ queries: [],
6080
+ metadataAccesses: [],
6081
+ visited: /* @__PURE__ */ new Set(),
6082
+ reads: []
6083
+ });
6084
+ const childScope = scopeOf(p, child.facts);
6085
+ let out = false;
6086
+ for (const ret of ownReturns(target.fn)) {
6087
+ for (const leaf of branchesOf(ret)) {
6088
+ if (isLiteralValue(leaf)) continue;
6089
+ const who = identityLeaf(p, child, leaf, childScope);
6090
+ if (who === void 0) return void 0;
6091
+ if (out === false) out = { who };
6092
+ }
6093
+ }
6094
+ p.returnIdentities.set(key, out);
6095
+ return out === false ? void 0 : out.who;
6096
+ }
5584
6097
  function calleePath(e) {
5585
6098
  const u = unwrap(e);
5586
6099
  if (ts11.isIdentifier(u)) return u.text;
@@ -5666,7 +6179,8 @@ function analyzeHandler(p, h) {
5666
6179
  key: "h",
5667
6180
  aliases: /* @__PURE__ */ new Map(),
5668
6181
  pathPos: [],
5669
- exitPropagates: true
6182
+ exitPropagates: true,
6183
+ identities: /* @__PURE__ */ new Map()
5670
6184
  };
5671
6185
  const first = fn2.parameters[0];
5672
6186
  if (h.kind === "route" && first) {
@@ -5775,7 +6289,7 @@ function parseProject(rootInput, opts = {}) {
5775
6289
  }
5776
6290
  }
5777
6291
  const tables = /* @__PURE__ */ new Map();
5778
- for (const rel of sql) {
6292
+ for (const rel of appliedSqlFiles(sql)) {
5779
6293
  try {
5780
6294
  parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
5781
6295
  } catch (e) {
@@ -5806,6 +6320,7 @@ function parseProject(rootInput, opts = {}) {
5806
6320
  drizzleTablesByExport,
5807
6321
  prismaModels,
5808
6322
  returnTaints: /* @__PURE__ */ new Map(),
6323
+ returnIdentities: /* @__PURE__ */ new Map(),
5809
6324
  tables,
5810
6325
  warnings
5811
6326
  };
@@ -5839,7 +6354,8 @@ function parseProject(rootInput, opts = {}) {
5839
6354
  warnings,
5840
6355
  enums: schema.enums,
5841
6356
  sqlFunctions: schema.sqlFunctions,
5842
- storageBuckets: schema.storageBuckets
6357
+ storageBuckets: schema.storageBuckets,
6358
+ ...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
5843
6359
  };
5844
6360
  }
5845
6361
  var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
@@ -6055,9 +6571,35 @@ function publicReadNote(p, table2) {
6055
6571
  return ` public.${table2} is readable by the anon role through RLS policy "${p.name}" (${p.location.file}:${p.location.line}), so this read leaks nothing beyond what the anon key already returns; the repository may not have intended that policy, so the finding stays at medium.`;
6056
6572
  }
6057
6573
  function adminOnly(ctx, h, t) {
6058
- const check = h.roleChecks[0];
6059
- if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
6060
- return check;
6574
+ if (h.roleChecks.length === 0 || !t?.known || !singleTenantTable(ctx, t.table)) return {};
6575
+ let selfGrant;
6576
+ for (const check of h.roleChecks) {
6577
+ const open = selfGrantable(ctx, check);
6578
+ if (open === null) return { check };
6579
+ selfGrant ??= open;
6580
+ }
6581
+ return selfGrant === void 0 ? {} : { selfGrant };
6582
+ }
6583
+ function selfGrantable(ctx, check) {
6584
+ if (check.table === void 0 || check.column === void 0) return null;
6585
+ const column = check.column.toLowerCase();
6586
+ if (check.table === "")
6587
+ return `the role is read off a row whose table could not be told (${check.source}), so nothing shows users cannot change it`;
6588
+ const where2 = `public.${check.table}.${column}`;
6589
+ const info = ctx.model.tables.find((x) => x.table === check.table?.toLowerCase());
6590
+ if (!info)
6591
+ return `the role comes from ${where2}, which the migrations do not define, so nothing shows users cannot change it`;
6592
+ if (!info.rlsEnabled)
6593
+ return `the role comes from ${where2}, and RLS is off on public.${info.table}, so a signed-in user can rewrite it`;
6594
+ const writable = info.policyDetails.find(
6595
+ (pl) => (pl.command === "update" || pl.command === "all") && (pl.roles.length === 0 || pl.roles.some((r) => r === "authenticated" || r === "public")) && policyScopesToCaller(pl.using)
6596
+ );
6597
+ if (!writable) return null;
6598
+ const guarded = (ctx.model.sqlTriggers ?? []).some(
6599
+ (tr) => tr.table === info.table && tr.timing === "before" && tr.events.includes("update") && tr.checkedColumns.includes(column)
6600
+ );
6601
+ if (guarded) return null;
6602
+ return `the role comes from ${where2}, policy "${writable.name}" (${writable.location.file}:${writable.location.line}) lets a user update their own row, and no BEFORE UPDATE trigger in the migrations checks ${column}, so the admin check may be self-granted`;
6061
6603
  }
6062
6604
  function singleTenantTable(ctx, table2) {
6063
6605
  const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
@@ -6076,10 +6618,12 @@ function tableDataOf(ctx, table2) {
6076
6618
  return ctx.graph.nodes.get(`table:${table2}`)?.data;
6077
6619
  }
6078
6620
  function callerCheck(checks) {
6079
- return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
6621
+ return checks?.find((c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column)));
6080
6622
  }
6081
6623
  function guardTiesRowToCaller(guard, table2, callerFns) {
6082
- const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
6624
+ const callerFilter = guard.filters.find(
6625
+ (f) => !f.inputDerived && (f.identity === true || isScopeColumn(f.column))
6626
+ );
6083
6627
  if (callerFilter) {
6084
6628
  return {
6085
6629
  tied: true,
@@ -6134,7 +6678,10 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6134
6678
  if (!["select", "update", "delete"].includes(q.operation)) continue;
6135
6679
  const filters = q.filters;
6136
6680
  const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
6137
- if (!idFilter || filters.some((f) => isScopeColumn(f.column))) continue;
6681
+ const scoped = filters.some(
6682
+ (f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
6683
+ );
6684
+ if (!idFilter || scoped) continue;
6138
6685
  if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
6139
6686
  const guard = q.guard;
6140
6687
  const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
@@ -6144,10 +6691,11 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6144
6691
  const guardNote = guard && tied && (tied.tied || guard.client === "user_scoped") ? ` An earlier read of ${guardWhat} at ${guard.location.file}:${guard.location.line} (${tied.how}) could be an ownership check, but ${tied.tied ? "the entry point does not stop when it finds no row" : tied.why}.` : "";
6145
6692
  const tableName = v.tableData?.table ?? q.table;
6146
6693
  const authNote = h.authenticated ? "The handler authenticates the caller but never checks that the row belongs to them." : "The handler does not authenticate the caller at all.";
6147
- const admin = adminOnly(ctx, h, v.tableData);
6694
+ const gate = adminOnly(ctx, h, v.tableData);
6695
+ const admin = gate.check;
6148
6696
  const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
6149
6697
  const downgrade = admin || anonPolicy ? "medium" : void 0;
6150
- const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
6698
+ const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
6151
6699
  const path = [
6152
6700
  h.data.kind === "server_action" ? "Server action call" : "HTTP request",
6153
6701
  h.data.entry,
@@ -6276,6 +6824,7 @@ function emitGroups(ctx, rule, groups) {
6276
6824
  }
6277
6825
  return out;
6278
6826
  }
6827
+ var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
6279
6828
  var rlsPolicyWithoutCallerPredicate = {
6280
6829
  id: "supabase.rls-policy-without-caller-predicate",
6281
6830
  title: "RLS policy grants rows without a caller predicate",
@@ -6296,6 +6845,7 @@ var rlsPolicyWithoutCallerPredicate = {
6296
6845
  const op = v.data.operation === "unknown" ? "select" : v.data.operation;
6297
6846
  for (const p of t.policyDetails) {
6298
6847
  if (p.command !== "all" && p.command !== op) continue;
6848
+ if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
6299
6849
  const expr = op === "insert" ? p.check : p.using;
6300
6850
  if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
6301
6851
  continue;
@@ -6528,6 +7078,26 @@ var serviceRoleQueryWithoutAuthentication = {
6528
7078
  return out;
6529
7079
  }
6530
7080
  };
7081
+ function rlsOnWrite(t, operation) {
7082
+ if (!t?.known || !t.rlsEnabled) return void 0;
7083
+ const commands = operation === "upsert" ? ["insert", "update"] : [operation];
7084
+ const covering = t.policyDetails.filter(
7085
+ (pl) => (pl.command === "all" || commands.includes(pl.command)) && !(pl.roles.length > 0 && pl.roles.every((r) => r === "service_role"))
7086
+ );
7087
+ if (covering.length === 0) return "refused";
7088
+ return covering.every((pl) => /auth\.uid\(\)|auth\.jwt\(\)/i.test(pinExpression(pl))) ? covering : void 0;
7089
+ }
7090
+ function pinExpression(pl) {
7091
+ return pl.check ?? (pl.command === "insert" ? "" : pl.using ?? "");
7092
+ }
7093
+ function mentionsColumn(expr, column) {
7094
+ const c = column.replace(/[^A-Za-z0-9_]/g, "");
7095
+ return c !== "" && new RegExp(`(?<![A-Za-z0-9_])"?${c}"?(?![A-Za-z0-9_])`, "i").test(expr);
7096
+ }
7097
+ function ownedWriteNote(policies, table2, pinned) {
7098
+ const names = policies.map((pl) => `"${pl.name}" (${pl.location.file}:${pl.location.line})`).join(", ");
7099
+ return ` RLS still binds this client: ${policies.length === 1 ? "policy" : "policies"} ${names} only accept rows tied to the caller${pinned.length > 0 ? ` (${pinned.join(", ")})` : ""}, and public.${table2} has no other column that grants a role, a price or a status, so the caller can only fill in the ordinary columns of their own row.`;
7100
+ }
6531
7101
  var massAssignmentFromRequestBody = {
6532
7102
  id: "supabase.mass-assignment-from-request-body",
6533
7103
  title: "Request body written to a table without an allow-list",
@@ -6546,6 +7116,11 @@ var massAssignmentFromRequestBody = {
6546
7116
  const sensitive = cols.filter(
6547
7117
  (c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
6548
7118
  );
7119
+ const kind = v.clientData?.kind;
7120
+ const rls = kind === "anon" || kind === "user_scoped" ? rlsOnWrite(v.tableData, v.data.operation) : void 0;
7121
+ if (rls === "refused") continue;
7122
+ const pinned = rls ? cols.filter((c) => rls.some((pl) => mentionsColumn(pinExpression(pl), c))) : [];
7123
+ const ownedNote = rls && sensitive.every((c) => pinned.includes(c)) ? ownedWriteNote(rls, tableName, pinned) : "";
6549
7124
  const path = [
6550
7125
  "HTTP request",
6551
7126
  h.data.entry,
@@ -6554,22 +7129,27 @@ var massAssignmentFromRequestBody = {
6554
7129
  `public.${tableName}.${v.data.operation}`
6555
7130
  ];
6556
7131
  out.push(
6557
- finding(ctx, this, {
6558
- title: `Mass assignment into "${tableName}" from the request body`,
6559
- entrypoints: [h.data.entry],
6560
- sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
6561
- sinks: [`supabase.${v.data.operation}:public.${tableName}`],
6562
- path,
6563
- evidence: [
6564
- {
6565
- kind: "rule",
6566
- summary: `${v.data.operation} on public.${tableName} writes ${p.text} directly. ${sensitive.length > 0 ? `Columns the caller could set: ${sensitive.join(", ")}.` : "Every column of the table is writable by the caller."} Pick the allowed fields explicitly.`,
6567
- locations: locations(h.handler.location, v.query.location),
6568
- data: { deterministic: false, ruleId: this.id, payload: p.text }
6569
- },
6570
- { kind: "trace", summary: path.join(" -> ") }
6571
- ]
6572
- })
7132
+ finding(
7133
+ ctx,
7134
+ this,
7135
+ {
7136
+ title: `Mass assignment into "${tableName}" from the request body`,
7137
+ entrypoints: [h.data.entry],
7138
+ sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
7139
+ sinks: [`supabase.${v.data.operation}:public.${tableName}`],
7140
+ path,
7141
+ evidence: [
7142
+ {
7143
+ kind: "rule",
7144
+ summary: `${v.data.operation} on public.${tableName} writes ${p.text} directly.${ownedNote || ` ${sensitive.length > 0 ? `Columns the caller could set: ${sensitive.join(", ")}.` : "Every column of the table is writable by the caller."}`} Pick the allowed fields explicitly.`,
7145
+ locations: locations(h.handler.location, v.query.location),
7146
+ data: { deterministic: false, ruleId: this.id, payload: p.text }
7147
+ },
7148
+ { kind: "trace", summary: path.join(" -> ") }
7149
+ ]
7150
+ },
7151
+ ownedNote ? "medium" : void 0
7152
+ )
6573
7153
  );
6574
7154
  }
6575
7155
  }
@@ -7041,32 +7621,51 @@ var storagePolicyWithoutOwnerCheck = {
7041
7621
  `every object in ${where2}`
7042
7622
  ];
7043
7623
  const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
7624
+ const insertOnly = p.command === "insert";
7625
+ const insertNote = insertOnly ? " An INSERT policy cannot read, replace or delete existing files, so this is reported as medium: what it allows is placing files in other users' folders." : "";
7044
7626
  out.push(
7045
- finding3(ctx, this, {
7046
- title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
7047
- entrypoints: [...entries, storageEntry],
7048
- sources: unique([
7049
- ...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
7050
- "storage API (caller's own session)"
7051
- ]),
7052
- sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
7053
- path,
7054
- evidence: [
7055
- {
7056
- kind: "rule",
7057
- 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.`,
7058
- locations: locations3(p.location, ...reached.map((r) => r.query.location)),
7059
- data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
7060
- },
7061
- { kind: "trace", summary: path.join(" -> ") }
7062
- ]
7063
- })
7627
+ finding3(
7628
+ ctx,
7629
+ this,
7630
+ {
7631
+ title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
7632
+ entrypoints: [...entries, storageEntry],
7633
+ sources: unique([
7634
+ ...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
7635
+ "storage API (caller's own session)"
7636
+ ]),
7637
+ sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
7638
+ path,
7639
+ evidence: [
7640
+ {
7641
+ kind: "rule",
7642
+ 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}${insertNote} Scope it to the owner, e.g. bucket_id = '${buckets[0] ?? "<bucket>"}' and (storage.foldername(name))[1] = (select auth.uid())::text.`,
7643
+ locations: locations3(p.location, ...reached.map((r) => r.query.location)),
7644
+ data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
7645
+ },
7646
+ { kind: "trace", summary: path.join(" -> ") }
7647
+ ]
7648
+ },
7649
+ insertOnly ? "medium" : void 0
7650
+ )
7064
7651
  );
7065
7652
  }
7066
7653
  }
7067
7654
  return out;
7068
7655
  }
7069
7656
  };
7657
+ var EVERY_ROW = /^\(*\s*true\s*\)*$/i;
7658
+ var MIGRATION_SQL = /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i;
7659
+ function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
7660
+ if ((fn2.writes?.length ?? 0) > 0 || (fn2.calls?.length ?? 0) > 0) return false;
7661
+ const read = fn2.tables ?? [];
7662
+ return read.length > 0 && read.every((name) => {
7663
+ const t = tables.get(name);
7664
+ return t?.rlsEnabled === true && t.policyDetails.some(
7665
+ (p) => (p.command === "select" || p.command === "all") && opensToEveryone(p) && p.using !== null && EVERY_ROW.test(p.using.trim()) && (!fromMigrationsOnly || MIGRATION_SQL.test(p.location.file))
7666
+ );
7667
+ });
7668
+ }
7070
7669
  var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
7071
7670
  var securityDefinerFunctionWithoutCallerCheck = {
7072
7671
  id: "supabase.security-definer-function-without-caller-check",
@@ -7083,6 +7682,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
7083
7682
  const key = r.data.table.toLowerCase();
7084
7683
  rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
7085
7684
  }
7685
+ const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
7686
+ const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
7086
7687
  const out = [];
7087
7688
  for (const fn2 of fns) {
7088
7689
  if (!fn2.securityDefiner || fn2.checksCaller) continue;
@@ -7090,6 +7691,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
7090
7691
  if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
7091
7692
  const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
7092
7693
  if (roles.length === 0) continue;
7694
+ if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
7093
7695
  const anonymous = roles.includes("anon") || roles.includes("public");
7094
7696
  const sites = rpcByName.get(fn2.name) ?? [];
7095
7697
  const entries = unique(sites.map((s) => s.handlerData.entry));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",