auditai-scan 0.7.2 → 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 +620 -65
  2. package/package.json +1 -1
@@ -1518,6 +1518,18 @@ function rowComparisons(tail) {
1518
1518
  const { parent } = outerOf(tail);
1519
1519
  if (!parent || !ts4.isVariableDeclaration(parent)) return [];
1520
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
+ }
1521
1533
  const out = [];
1522
1534
  for (const s of ifsAfter(parent)) {
1523
1535
  const exit = exitKind(s.thenStatement);
@@ -2675,6 +2687,14 @@ function newFunctionRegistry() {
2675
2687
  publicSchemaPublic: false
2676
2688
  };
2677
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
+ }
2678
2698
  var CALLER_CHECKS = [
2679
2699
  /"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
2680
2700
  /\bcurrent_setting\s*\(\s*'request\.jwt/i
@@ -2863,11 +2883,18 @@ function readParams(stmt, tokens, open, close) {
2863
2883
  pieces.push([start, close]);
2864
2884
  for (const [from, to] of pieces) {
2865
2885
  const words = [];
2886
+ let hasDefault = false;
2887
+ let callerDefault = false;
2866
2888
  for (let i = from; i < to; i++) {
2867
2889
  const t = tokens[i];
2868
2890
  if (!t) continue;
2869
- if (isWord(t, "default")) break;
2870
- 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
+ }
2871
2898
  words.push(t);
2872
2899
  }
2873
2900
  if (words.length === 0) return null;
@@ -2886,7 +2913,9 @@ function readParams(stmt, tokens, open, close) {
2886
2913
  if (!first || !last) return null;
2887
2914
  parts.push({
2888
2915
  name: named && firstWord ? firstWord.value.toLowerCase() : null,
2889
- type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
2916
+ type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
2917
+ hasDefault,
2918
+ callerDefault
2890
2919
  });
2891
2920
  }
2892
2921
  return parts;
@@ -3064,16 +3093,63 @@ function applyDefaultPrivileges(reg, stmt) {
3064
3093
  }
3065
3094
  }
3066
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
+ }
3067
3101
  function callees(reg, f, byName) {
3068
3102
  const out = /* @__PURE__ */ new Set();
3069
3103
  for (const m of f.body.matchAll(CALL)) {
3070
- const name = m[2];
3071
- if (!name) continue;
3072
- 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);
3073
3105
  if (g && g !== f) out.add(g);
3074
3106
  }
3075
3107
  return out;
3076
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
+ }
3077
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;
3078
3154
  function relationsOf(body) {
3079
3155
  const out = [];
@@ -3086,6 +3162,118 @@ function relationsOf(body) {
3086
3162
  }
3087
3163
  return out;
3088
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
+ }
3089
3277
  function effectiveRoles(acl) {
3090
3278
  const out = [];
3091
3279
  if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
@@ -3103,10 +3291,15 @@ function finishFunctions(reg) {
3103
3291
  }
3104
3292
  }
3105
3293
  const callers = /* @__PURE__ */ new Map();
3294
+ const calls = /* @__PURE__ */ new Map();
3106
3295
  for (const f of fns) {
3107
- 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]);
3108
3299
  }
3109
- 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
+ );
3110
3303
  const queue = [...checks];
3111
3304
  for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
3112
3305
  for (const f of callers.get(g) ?? []) {
@@ -3125,10 +3318,25 @@ function finishFunctions(reg) {
3125
3318
  };
3126
3319
  if (f.returns !== null) info.returns = f.returns;
3127
3320
  if (f.args !== null) info.args = f.args;
3128
- if (f.params?.every((x) => x.name !== null))
3129
- info.params = f.params.map((x) => ({ name: x.name ?? "", type: x.type }));
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
+ }
3130
3334
  const tables = relationsOf(f.body);
3131
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;
3132
3340
  return info;
3133
3341
  });
3134
3342
  }
@@ -3250,6 +3458,7 @@ function schemaStateFor(tables) {
3250
3458
  enums: /* @__PURE__ */ new Map(),
3251
3459
  functions: newFunctionRegistry(),
3252
3460
  buckets: newBucketRegistry(),
3461
+ triggers: /* @__PURE__ */ new Map(),
3253
3462
  warnings: []
3254
3463
  };
3255
3464
  STATES.set(tables, state);
@@ -3692,6 +3901,96 @@ function doBlock(state, stmt, file) {
3692
3901
  }
3693
3902
  }
3694
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
+ }
3695
3994
  function applySchemaStatement(state, stmt, file) {
3696
3995
  const tk = stmt.tokens;
3697
3996
  const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
@@ -3702,6 +4001,7 @@ function applySchemaStatement(state, stmt, file) {
3702
4001
  else if (kind === "type") createType(state, stmt);
3703
4002
  else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
3704
4003
  else if (kind === "unique") createUniqueIndex(state, stmt);
4004
+ else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
3705
4005
  } else if (w0 === "alter") {
3706
4006
  if (w1 === "table") alterTable(state, stmt, file, false);
3707
4007
  else if (w1 === "type") alterType(state, stmt);
@@ -3711,6 +4011,7 @@ function applySchemaStatement(state, stmt, file) {
3711
4011
  if (w1 === "table") dropTable(state, stmt);
3712
4012
  else if (w1 === "type") dropType(state, stmt);
3713
4013
  else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
4014
+ else if (w1 === "trigger") dropTrigger(state, stmt);
3714
4015
  } else if (w0 === "grant" || w0 === "revoke") {
3715
4016
  applyGrantRevoke(state.functions, stmt);
3716
4017
  } else if (w0 === "do") {
@@ -3725,6 +4026,7 @@ function finishSchema(state) {
3725
4026
  enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
3726
4027
  sqlFunctions: finishFunctions(state.functions),
3727
4028
  storageBuckets: finishBuckets(state.buckets),
4029
+ triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
3728
4030
  warnings: [...state.warnings]
3729
4031
  };
3730
4032
  }
@@ -3755,6 +4057,32 @@ var DROP_POLICY = new RegExp(
3755
4057
  function isAppliedSqlFile(rel) {
3756
4058
  return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
3757
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
+ }
3758
4086
  function parseSqlForRls(rel, text, into) {
3759
4087
  if (!isAppliedSqlFile(rel)) return;
3760
4088
  const state = schemaStateFor(into);
@@ -3830,7 +4158,7 @@ function sqlSchemaFor(into) {
3830
4158
 
3831
4159
  // packages/parser/src/role-gates.ts
3832
4160
  import ts7 from "typescript";
3833
- 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;
3834
4162
  var EMAIL_PROPERTY = /^email$/i;
3835
4163
  function propertyPath(e) {
3836
4164
  const u = unwrap(e);
@@ -4916,6 +5244,9 @@ function bindDeclarations(p, frame, acc) {
4916
5244
  const names = boundNames(decl.name);
4917
5245
  const text = init.getText(sf);
4918
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);
4919
5250
  const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
4920
5251
  if (client && ts11.isIdentifier(decl.name)) {
4921
5252
  frame.clients.set(decl.name.text, client);
@@ -4997,6 +5328,21 @@ function bindDeclarations(p, frame, acc) {
4997
5328
  bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4998
5329
  }
4999
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
+ }
5000
5346
  if (frame.depth === 0) {
5001
5347
  walk(body, (n) => {
5002
5348
  if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
@@ -5022,16 +5368,22 @@ function analyzeFrame(p, frame, acc) {
5022
5368
  const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
5023
5369
  if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
5024
5370
  }
5025
- 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);
5026
5372
  for (const call of collect(body, ts11.isCallExpression)) {
5027
5373
  if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
5028
5374
  }
5029
- 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)) {
5030
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];
5031
5382
  acc.roleChecks.push({
5032
5383
  ...loc2(gate.node),
5033
5384
  source: gate.source,
5034
- 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 } : {}
5035
5387
  });
5036
5388
  }
5037
5389
  for (const check of secretChecksIn(fn2, sf)) {
@@ -5091,7 +5443,8 @@ function analyzeFrame(p, frame, acc) {
5091
5443
  method: f.method,
5092
5444
  column: f.column,
5093
5445
  valueText: f.value ? f.value.getText(sf) : f.text,
5094
- 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 } : {}
5095
5448
  },
5096
5449
  f.value
5097
5450
  );
@@ -5141,7 +5494,8 @@ function analyzeFrame(p, frame, acc) {
5141
5494
  method: "match",
5142
5495
  column: pr.name.getText(sf).replace(/['"]/g, ""),
5143
5496
  valueText: pr.initializer.getText(sf),
5144
- inputDerived: derivedIn(frame, pr.initializer)
5497
+ inputDerived: derivedIn(frame, pr.initializer),
5498
+ ...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
5145
5499
  };
5146
5500
  filters.push(valued(f2, pr.initializer));
5147
5501
  }
@@ -5153,7 +5507,8 @@ function analyzeFrame(p, frame, acc) {
5153
5507
  method: s.name,
5154
5508
  column: stringLiteralValue(firstArg),
5155
5509
  valueText: val ? val.getText(sf) : "",
5156
- inputDerived: val ? derivedIn(frame, val) : false
5510
+ inputDerived: val ? derivedIn(frame, val) : false,
5511
+ ...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
5157
5512
  };
5158
5513
  filters.push(valued(f, val));
5159
5514
  }
@@ -5289,7 +5644,8 @@ function analyzeFrame(p, frame, acc) {
5289
5644
  method: "compare",
5290
5645
  column: c.column,
5291
5646
  valueText: c.value.getText(sf).replace(/\s+/g, " "),
5292
- inputDerived: derivedIn(frame, c.value)
5647
+ inputDerived: derivedIn(frame, c.value),
5648
+ ...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
5293
5649
  }));
5294
5650
  if (checks.length > 0) query.ownerChecks = checks;
5295
5651
  acc.reads.push({
@@ -5341,7 +5697,8 @@ function childFrame(p, call, target, frame) {
5341
5697
  key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
5342
5698
  aliases: /* @__PURE__ */ new Map(),
5343
5699
  pathPos: [...frame.pathPos, call.getStart(frame.sf)],
5344
- exitPropagates: frame.exitPropagates && callResultChecked(call)
5700
+ exitPropagates: frame.exitPropagates && callResultChecked(call),
5701
+ identities: /* @__PURE__ */ new Map()
5345
5702
  };
5346
5703
  const cx = wholeContext(p, frame);
5347
5704
  target.fn.parameters.forEach((param, i) => {
@@ -5356,6 +5713,9 @@ function childFrame(p, call, target, frame) {
5356
5713
  child.instances.set(head, ab.instance);
5357
5714
  }
5358
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);
5359
5719
  if (ab.tainted) {
5360
5720
  bindParamTaint(child, param.name, arg, frame);
5361
5721
  for (const nm of wholeParamNames(param.name, arg, cx)) {
@@ -5373,6 +5733,7 @@ function frameSignature(child) {
5373
5733
  ...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
5374
5734
  ...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
5375
5735
  ...[...child.reqNames].map((n) => `${n}?`),
5736
+ ...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
5376
5737
  ...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
5377
5738
  ...[...child.aliases].map(([n, k]) => `${n}~${k}`),
5378
5739
  `exit=${child.exitPropagates}`
@@ -5628,6 +5989,111 @@ function returnsIdentity(p, call, scope) {
5628
5989
  if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
5629
5990
  return IDENTITY_CALLEE.test(calleePath(call.expression));
5630
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
+ }
5631
6097
  function calleePath(e) {
5632
6098
  const u = unwrap(e);
5633
6099
  if (ts11.isIdentifier(u)) return u.text;
@@ -5713,7 +6179,8 @@ function analyzeHandler(p, h) {
5713
6179
  key: "h",
5714
6180
  aliases: /* @__PURE__ */ new Map(),
5715
6181
  pathPos: [],
5716
- exitPropagates: true
6182
+ exitPropagates: true,
6183
+ identities: /* @__PURE__ */ new Map()
5717
6184
  };
5718
6185
  const first = fn2.parameters[0];
5719
6186
  if (h.kind === "route" && first) {
@@ -5822,7 +6289,7 @@ function parseProject(rootInput, opts = {}) {
5822
6289
  }
5823
6290
  }
5824
6291
  const tables = /* @__PURE__ */ new Map();
5825
- for (const rel of sql) {
6292
+ for (const rel of appliedSqlFiles(sql)) {
5826
6293
  try {
5827
6294
  parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
5828
6295
  } catch (e) {
@@ -5853,6 +6320,7 @@ function parseProject(rootInput, opts = {}) {
5853
6320
  drizzleTablesByExport,
5854
6321
  prismaModels,
5855
6322
  returnTaints: /* @__PURE__ */ new Map(),
6323
+ returnIdentities: /* @__PURE__ */ new Map(),
5856
6324
  tables,
5857
6325
  warnings
5858
6326
  };
@@ -5886,7 +6354,8 @@ function parseProject(rootInput, opts = {}) {
5886
6354
  warnings,
5887
6355
  enums: schema.enums,
5888
6356
  sqlFunctions: schema.sqlFunctions,
5889
- storageBuckets: schema.storageBuckets
6357
+ storageBuckets: schema.storageBuckets,
6358
+ ...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
5890
6359
  };
5891
6360
  }
5892
6361
  var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
@@ -6102,9 +6571,35 @@ function publicReadNote(p, table2) {
6102
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.`;
6103
6572
  }
6104
6573
  function adminOnly(ctx, h, t) {
6105
- const check = h.roleChecks[0];
6106
- if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
6107
- 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`;
6108
6603
  }
6109
6604
  function singleTenantTable(ctx, table2) {
6110
6605
  const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
@@ -6123,10 +6618,12 @@ function tableDataOf(ctx, table2) {
6123
6618
  return ctx.graph.nodes.get(`table:${table2}`)?.data;
6124
6619
  }
6125
6620
  function callerCheck(checks) {
6126
- return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
6621
+ return checks?.find((c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column)));
6127
6622
  }
6128
6623
  function guardTiesRowToCaller(guard, table2, callerFns) {
6129
- 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
+ );
6130
6627
  if (callerFilter) {
6131
6628
  return {
6132
6629
  tied: true,
@@ -6181,7 +6678,10 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6181
6678
  if (!["select", "update", "delete"].includes(q.operation)) continue;
6182
6679
  const filters = q.filters;
6183
6680
  const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
6184
- 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;
6185
6685
  if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
6186
6686
  const guard = q.guard;
6187
6687
  const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
@@ -6191,10 +6691,11 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6191
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}.` : "";
6192
6692
  const tableName = v.tableData?.table ?? q.table;
6193
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.";
6194
- const admin = adminOnly(ctx, h, v.tableData);
6694
+ const gate = adminOnly(ctx, h, v.tableData);
6695
+ const admin = gate.check;
6195
6696
  const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
6196
6697
  const downgrade = admin || anonPolicy ? "medium" : void 0;
6197
- 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) : "");
6198
6699
  const path = [
6199
6700
  h.data.kind === "server_action" ? "Server action call" : "HTTP request",
6200
6701
  h.data.entry,
@@ -6323,6 +6824,7 @@ function emitGroups(ctx, rule, groups) {
6323
6824
  }
6324
6825
  return out;
6325
6826
  }
6827
+ var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
6326
6828
  var rlsPolicyWithoutCallerPredicate = {
6327
6829
  id: "supabase.rls-policy-without-caller-predicate",
6328
6830
  title: "RLS policy grants rows without a caller predicate",
@@ -6343,6 +6845,7 @@ var rlsPolicyWithoutCallerPredicate = {
6343
6845
  const op = v.data.operation === "unknown" ? "select" : v.data.operation;
6344
6846
  for (const p of t.policyDetails) {
6345
6847
  if (p.command !== "all" && p.command !== op) continue;
6848
+ if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
6346
6849
  const expr = op === "insert" ? p.check : p.using;
6347
6850
  if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
6348
6851
  continue;
@@ -6575,6 +7078,26 @@ var serviceRoleQueryWithoutAuthentication = {
6575
7078
  return out;
6576
7079
  }
6577
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
+ }
6578
7101
  var massAssignmentFromRequestBody = {
6579
7102
  id: "supabase.mass-assignment-from-request-body",
6580
7103
  title: "Request body written to a table without an allow-list",
@@ -6593,6 +7116,11 @@ var massAssignmentFromRequestBody = {
6593
7116
  const sensitive = cols.filter(
6594
7117
  (c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
6595
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) : "";
6596
7124
  const path = [
6597
7125
  "HTTP request",
6598
7126
  h.data.entry,
@@ -6601,22 +7129,27 @@ var massAssignmentFromRequestBody = {
6601
7129
  `public.${tableName}.${v.data.operation}`
6602
7130
  ];
6603
7131
  out.push(
6604
- finding(ctx, this, {
6605
- title: `Mass assignment into "${tableName}" from the request body`,
6606
- entrypoints: [h.data.entry],
6607
- sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
6608
- sinks: [`supabase.${v.data.operation}:public.${tableName}`],
6609
- path,
6610
- evidence: [
6611
- {
6612
- kind: "rule",
6613
- 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.`,
6614
- locations: locations(h.handler.location, v.query.location),
6615
- data: { deterministic: false, ruleId: this.id, payload: p.text }
6616
- },
6617
- { kind: "trace", summary: path.join(" -> ") }
6618
- ]
6619
- })
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
+ )
6620
7153
  );
6621
7154
  }
6622
7155
  }
@@ -7088,32 +7621,51 @@ var storagePolicyWithoutOwnerCheck = {
7088
7621
  `every object in ${where2}`
7089
7622
  ];
7090
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." : "";
7091
7626
  out.push(
7092
- finding3(ctx, this, {
7093
- title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
7094
- entrypoints: [...entries, storageEntry],
7095
- sources: unique([
7096
- ...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
7097
- "storage API (caller's own session)"
7098
- ]),
7099
- sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
7100
- path,
7101
- evidence: [
7102
- {
7103
- kind: "rule",
7104
- 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.`,
7105
- locations: locations3(p.location, ...reached.map((r) => r.query.location)),
7106
- data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
7107
- },
7108
- { kind: "trace", summary: path.join(" -> ") }
7109
- ]
7110
- })
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
+ )
7111
7651
  );
7112
7652
  }
7113
7653
  }
7114
7654
  return out;
7115
7655
  }
7116
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
+ }
7117
7669
  var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
7118
7670
  var securityDefinerFunctionWithoutCallerCheck = {
7119
7671
  id: "supabase.security-definer-function-without-caller-check",
@@ -7130,6 +7682,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
7130
7682
  const key = r.data.table.toLowerCase();
7131
7683
  rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
7132
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));
7133
7687
  const out = [];
7134
7688
  for (const fn2 of fns) {
7135
7689
  if (!fn2.securityDefiner || fn2.checksCaller) continue;
@@ -7137,6 +7691,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
7137
7691
  if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
7138
7692
  const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
7139
7693
  if (roles.length === 0) continue;
7694
+ if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
7140
7695
  const anonymous = roles.includes("anon") || roles.includes("public");
7141
7696
  const sites = rpcByName.get(fn2.name) ?? [];
7142
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.2",
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",