auditai-scan 0.7.2 → 0.7.4

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 +884 -80
  2. package/package.json +1 -1
@@ -260,7 +260,7 @@ function policedTables(f, model) {
260
260
  const out = [];
261
261
  for (const n of names) {
262
262
  const t = table(model, n);
263
- if (!t || !t.rlsEnabled || t.policies.length === 0)
263
+ if (!t?.rlsEnabled || t.policies.length === 0)
264
264
  return null;
265
265
  out.push(qualified(t.table));
266
266
  }
@@ -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);
@@ -1556,6 +1568,45 @@ function callResultChecked(call) {
1556
1568
  }
1557
1569
  return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child && exitKind(cur.thenStatement) !== null;
1558
1570
  }
1571
+ function rowNamesOf(tail) {
1572
+ const { parent } = outerOf(tail);
1573
+ if (!parent || !ts4.isVariableDeclaration(parent)) return /* @__PURE__ */ new Set();
1574
+ const rows = resultNames(parent.name).data;
1575
+ const fn2 = enclosingFunction(parent);
1576
+ const scope = fn2 ? fn2.body : parent.getSourceFile();
1577
+ if (scope) {
1578
+ walkOwn(scope, (n) => {
1579
+ if (!ts4.isVariableDeclaration(n) || !n.initializer || n.pos < parent.end) return;
1580
+ const init = unwrap(n.initializer);
1581
+ if (!ts4.isIdentifier(init) || !rows.has(init.text) || ts4.isIdentifier(n.name)) return;
1582
+ for (const name of resultNames(n.name).data) rows.add(name);
1583
+ });
1584
+ }
1585
+ return rows;
1586
+ }
1587
+ function checkedResultExit(call) {
1588
+ const { node, parent } = outerOf(call);
1589
+ if (!parent) return null;
1590
+ if (ts4.isVariableDeclaration(parent) && parent.initializer === node) {
1591
+ const names = new Set(boundNames(parent.name));
1592
+ for (const s of ifsAfter(parent)) {
1593
+ if (!checksMissing(s.expression, names)) continue;
1594
+ const kind = exitKind(s.thenStatement);
1595
+ if (kind) return kind;
1596
+ }
1597
+ return null;
1598
+ }
1599
+ if (!ts4.isPrefixUnaryExpression(parent) || parent.operator !== ts4.SyntaxKind.ExclamationToken) {
1600
+ return null;
1601
+ }
1602
+ let cur = parent.parent;
1603
+ let child = parent;
1604
+ while (cur && ts4.isParenthesizedExpression(cur)) {
1605
+ child = cur;
1606
+ cur = cur.parent;
1607
+ }
1608
+ return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child ? exitKind(cur.thenStatement) : null;
1609
+ }
1559
1610
  function compareOrder(a, b) {
1560
1611
  const n = Math.min(a.length, b.length);
1561
1612
  for (let i = 0; i < n; i++) {
@@ -2675,6 +2726,14 @@ function newFunctionRegistry() {
2675
2726
  publicSchemaPublic: false
2676
2727
  };
2677
2728
  }
2729
+ function functionBodyOf(reg, q) {
2730
+ const direct = reg.byKey.get(qualifiedKey(q));
2731
+ if (direct) return direct.body;
2732
+ if (q.schema !== null) return null;
2733
+ const lower = q.name.toLowerCase();
2734
+ for (const f of reg.byKey.values()) if (f.name.toLowerCase() === lower) return f.body;
2735
+ return null;
2736
+ }
2678
2737
  var CALLER_CHECKS = [
2679
2738
  /"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
2680
2739
  /\bcurrent_setting\s*\(\s*'request\.jwt/i
@@ -2863,11 +2922,18 @@ function readParams(stmt, tokens, open, close) {
2863
2922
  pieces.push([start, close]);
2864
2923
  for (const [from, to] of pieces) {
2865
2924
  const words = [];
2925
+ let hasDefault = false;
2926
+ let callerDefault = false;
2866
2927
  for (let i = from; i < to; i++) {
2867
2928
  const t = tokens[i];
2868
2929
  if (!t) continue;
2869
- if (isWord(t, "default")) break;
2870
- if (t.kind === "punct" && t.value === "=") break;
2930
+ if (isWord(t, "default") || t.kind === "punct" && t.value === "=") {
2931
+ hasDefault = true;
2932
+ const last2 = tokens[to - 1];
2933
+ const text = last2 ? stmt.text.slice(t.end - stmt.start, last2.end - stmt.start) : "";
2934
+ callerDefault = CALLER_CHECKS.some((re) => re.test(text));
2935
+ break;
2936
+ }
2871
2937
  words.push(t);
2872
2938
  }
2873
2939
  if (words.length === 0) return null;
@@ -2886,7 +2952,9 @@ function readParams(stmt, tokens, open, close) {
2886
2952
  if (!first || !last) return null;
2887
2953
  parts.push({
2888
2954
  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()
2955
+ type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
2956
+ hasDefault,
2957
+ callerDefault
2890
2958
  });
2891
2959
  }
2892
2960
  return parts;
@@ -3064,16 +3132,63 @@ function applyDefaultPrivileges(reg, stmt) {
3064
3132
  }
3065
3133
  }
3066
3134
  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;
3135
+ function calledFunction(reg, m, byName) {
3136
+ const name = m[2];
3137
+ if (!name) return void 0;
3138
+ return reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
3139
+ }
3067
3140
  function callees(reg, f, byName) {
3068
3141
  const out = /* @__PURE__ */ new Set();
3069
3142
  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);
3143
+ const g = calledFunction(reg, m, byName);
3073
3144
  if (g && g !== f) out.add(g);
3074
3145
  }
3075
3146
  return out;
3076
3147
  }
3148
+ var MAX_CALL_ARGS_CHARS = 4e3;
3149
+ function callArguments(body, from) {
3150
+ const args = [];
3151
+ let depth = 0;
3152
+ let quoted = false;
3153
+ let start = from;
3154
+ const end = Math.min(body.length, from + MAX_CALL_ARGS_CHARS);
3155
+ for (let i = from; i < end; i++) {
3156
+ const c = body[i];
3157
+ if (quoted) {
3158
+ if (c === "'") quoted = false;
3159
+ } else if (c === "'") {
3160
+ quoted = true;
3161
+ } else if (c === "(") {
3162
+ depth += 1;
3163
+ } else if (c === ")") {
3164
+ if (depth === 0) {
3165
+ const last = body.slice(start, i).trim();
3166
+ if (last !== "" || args.length > 0) args.push(last);
3167
+ return args;
3168
+ }
3169
+ depth -= 1;
3170
+ } else if (c === "," && depth === 0) {
3171
+ args.push(body.slice(start, i).trim());
3172
+ start = i + 1;
3173
+ }
3174
+ }
3175
+ return null;
3176
+ }
3177
+ function callsWithCallerDefault(reg, f, byName) {
3178
+ for (const m of f.body.matchAll(CALL)) {
3179
+ const g = calledFunction(reg, m, byName);
3180
+ if (!g || g === f || !g.params?.some((p) => p.callerDefault)) continue;
3181
+ const args = callArguments(f.body, (m.index ?? 0) + m[0].length);
3182
+ if (args === null) continue;
3183
+ const named = args.filter((a) => /=>|:=/.test(a)).map((a) => (a.split(/=>|:=/)[0] ?? "").trim().replace(/"/g, "").toLowerCase());
3184
+ const positional = args.length - named.length;
3185
+ const omitted = g.params.some(
3186
+ (p, i) => p.callerDefault && i >= positional && (p.name === null || !named.includes(p.name))
3187
+ );
3188
+ if (omitted) return true;
3189
+ }
3190
+ return false;
3191
+ }
3077
3192
  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
3193
  function relationsOf(body) {
3079
3194
  const out = [];
@@ -3086,6 +3201,118 @@ function relationsOf(body) {
3086
3201
  }
3087
3202
  return out;
3088
3203
  }
3204
+ var IDENT_SRC = "[A-Za-z_][A-Za-z0-9_$]{0,62}";
3205
+ var WRITE = new RegExp(
3206
+ `(?<![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*\\.)`,
3207
+ "gi"
3208
+ );
3209
+ var NOT_A_RELATION = /* @__PURE__ */ new Set(["set", "of", "skip", "nowait", "only", "on"]);
3210
+ function writesOf(body) {
3211
+ const out = [];
3212
+ for (const m of body.matchAll(WRITE)) {
3213
+ const schema = m[1]?.toLowerCase();
3214
+ const name = m[2]?.toLowerCase();
3215
+ if (!name || NOT_A_RELATION.has(name)) continue;
3216
+ const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3217
+ if (!out.includes(key)) out.push(key);
3218
+ }
3219
+ return out;
3220
+ }
3221
+ var ALIASED = new RegExp(
3222
+ `(?<![A-Za-z0-9_$])(?:from|join|update|into)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?:\\s+(?:as\\s+)?"?(${IDENT_SRC})"?)?`,
3223
+ "gi"
3224
+ );
3225
+ var SQL_WORDS = /* @__PURE__ */ new Set([
3226
+ "and",
3227
+ "as",
3228
+ "conflict",
3229
+ "cross",
3230
+ "default",
3231
+ "do",
3232
+ "else",
3233
+ "elsif",
3234
+ "end",
3235
+ "except",
3236
+ "for",
3237
+ "from",
3238
+ "full",
3239
+ "group",
3240
+ "having",
3241
+ "if",
3242
+ "in",
3243
+ "inner",
3244
+ "intersect",
3245
+ "into",
3246
+ "is",
3247
+ "join",
3248
+ "lateral",
3249
+ "left",
3250
+ "limit",
3251
+ "loop",
3252
+ "natural",
3253
+ "not",
3254
+ "nowait",
3255
+ "of",
3256
+ "offset",
3257
+ "on",
3258
+ "only",
3259
+ "or",
3260
+ "order",
3261
+ "returning",
3262
+ "right",
3263
+ "select",
3264
+ "set",
3265
+ "skip",
3266
+ "then",
3267
+ "union",
3268
+ "using",
3269
+ "values",
3270
+ "when",
3271
+ "where",
3272
+ "window",
3273
+ "with"
3274
+ ]);
3275
+ 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;
3276
+ var COMPARING = /* @__PURE__ */ new Set(["where", "on", "and", "or", "when", "if", "elsif", "having"]);
3277
+ var MAX_KEYED_PARAMS = 32;
3278
+ var CLAUSE_WINDOW = 400;
3279
+ function paramKeysOf(body, params) {
3280
+ const aliases = /* @__PURE__ */ new Map();
3281
+ const relations = [];
3282
+ for (const m of body.matchAll(ALIASED)) {
3283
+ const schema = m[1]?.toLowerCase();
3284
+ const name = m[2]?.toLowerCase();
3285
+ if (!name || SQL_WORDS.has(name)) continue;
3286
+ const table2 = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
3287
+ if (!relations.includes(table2)) relations.push(table2);
3288
+ aliases.set(name, table2);
3289
+ const alias = m[3]?.toLowerCase();
3290
+ if (alias && !SQL_WORDS.has(alias)) aliases.set(alias, table2);
3291
+ }
3292
+ const side = `(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?`;
3293
+ const out = [];
3294
+ for (const param of params.slice(0, MAX_KEYED_PARAMS)) {
3295
+ if (!/^[a-z_][a-z0-9_]*$/.test(param)) continue;
3296
+ const re = new RegExp(
3297
+ `${side}\\s*(?<![<>!:])=\\s*"?${param}"?(?![A-Za-z0-9_$.(])|(?<![A-Za-z0-9_$."])"?${param}"?\\s*=(?!=)\\s*${side}(?!\\s*\\()`,
3298
+ "gi"
3299
+ );
3300
+ for (const m of body.matchAll(re)) {
3301
+ const at = m.index ?? 0;
3302
+ const clauses = [...body.slice(Math.max(0, at - CLAUSE_WINDOW), at).matchAll(CLAUSE)];
3303
+ const clause = clauses[clauses.length - 1]?.[1]?.toLowerCase();
3304
+ if (clause === void 0 || !COMPARING.has(clause)) continue;
3305
+ const qualifier = (m[1] ?? m[3])?.toLowerCase();
3306
+ const column = (m[2] ?? m[4])?.toLowerCase();
3307
+ if (!column || column === param || SQL_WORDS.has(column)) continue;
3308
+ const table2 = qualifier !== void 0 ? aliases.get(qualifier) : relations.length === 1 ? relations[0] : void 0;
3309
+ if (table2 === void 0) continue;
3310
+ if (!out.some((k) => k.param === param && k.table === table2 && k.column === column))
3311
+ out.push({ param, table: table2, column });
3312
+ }
3313
+ }
3314
+ return out;
3315
+ }
3089
3316
  function effectiveRoles(acl) {
3090
3317
  const out = [];
3091
3318
  if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
@@ -3103,10 +3330,15 @@ function finishFunctions(reg) {
3103
3330
  }
3104
3331
  }
3105
3332
  const callers = /* @__PURE__ */ new Map();
3333
+ const calls = /* @__PURE__ */ new Map();
3106
3334
  for (const f of fns) {
3107
- for (const g of callees(reg, f, byName)) callers.set(g, [...callers.get(g) ?? [], f]);
3335
+ const called = callees(reg, f, byName);
3336
+ calls.set(f, called);
3337
+ for (const g of called) callers.set(g, [...callers.get(g) ?? [], f]);
3108
3338
  }
3109
- const checks = new Set(fns.filter((f) => f.directCheck));
3339
+ const checks = new Set(
3340
+ fns.filter((f) => f.directCheck || callsWithCallerDefault(reg, f, byName))
3341
+ );
3110
3342
  const queue = [...checks];
3111
3343
  for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
3112
3344
  for (const f of callers.get(g) ?? []) {
@@ -3125,10 +3357,25 @@ function finishFunctions(reg) {
3125
3357
  };
3126
3358
  if (f.returns !== null) info.returns = f.returns;
3127
3359
  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 }));
3360
+ if (f.params?.every((x) => x.name !== null)) {
3361
+ const params = f.params.map((x) => ({
3362
+ name: x.name ?? "",
3363
+ type: x.type,
3364
+ ...x.hasDefault ? { default: true } : {}
3365
+ }));
3366
+ info.params = params;
3367
+ const keys = paramKeysOf(
3368
+ f.body,
3369
+ params.map((x) => x.name)
3370
+ );
3371
+ if (keys.length > 0) info.keys = keys;
3372
+ }
3130
3373
  const tables = relationsOf(f.body);
3131
3374
  if (tables.length > 0) info.tables = tables;
3375
+ const called = [...calls.get(f) ?? []].map((g) => qualifiedKey(g));
3376
+ if (called.length > 0) info.calls = called;
3377
+ const writes = writesOf(f.body);
3378
+ if (writes.length > 0) info.writes = writes;
3132
3379
  return info;
3133
3380
  });
3134
3381
  }
@@ -3250,6 +3497,7 @@ function schemaStateFor(tables) {
3250
3497
  enums: /* @__PURE__ */ new Map(),
3251
3498
  functions: newFunctionRegistry(),
3252
3499
  buckets: newBucketRegistry(),
3500
+ triggers: /* @__PURE__ */ new Map(),
3253
3501
  warnings: []
3254
3502
  };
3255
3503
  STATES.set(tables, state);
@@ -3692,6 +3940,96 @@ function doBlock(state, stmt, file) {
3692
3940
  }
3693
3941
  }
3694
3942
  }
3943
+ var isTriggerEvent = (v) => v === "insert" || v === "update" || v === "delete" || v === "truncate";
3944
+ function createTrigger(state, stmt, file) {
3945
+ const tk = stmt.tokens;
3946
+ let i = 1;
3947
+ if (isWord(tk[i], "or") && isWord(tk[i + 1], "replace")) i += 2;
3948
+ if (isWord(tk[i], "constraint")) i += 1;
3949
+ if (!isWord(tk[i], "trigger")) return;
3950
+ const name = identOf(tk[i + 1]);
3951
+ if (name === null || name === "") return;
3952
+ i += 2;
3953
+ let timing;
3954
+ if (isWord(tk[i], "before")) timing = "before";
3955
+ else if (isWord(tk[i], "after")) timing = "after";
3956
+ else if (isWord(tk[i], "instead") && isWord(tk[i + 1], "of")) {
3957
+ timing = "instead of";
3958
+ i += 1;
3959
+ } else return;
3960
+ i += 1;
3961
+ const events = [];
3962
+ const ofColumns = [];
3963
+ while (i < tk.length && !isWord(tk[i], "on")) {
3964
+ const t = tk[i];
3965
+ if (t?.kind === "word" && isTriggerEvent(t.value)) {
3966
+ events.push(t.value);
3967
+ i += 1;
3968
+ } else if (isWord(t, "of")) {
3969
+ i += 1;
3970
+ while (i < tk.length && !isWord(tk[i], "or") && !isWord(tk[i], "on")) {
3971
+ const col = identOf(tk[i]);
3972
+ if (col !== null && col !== "") ofColumns.push(col.toLowerCase());
3973
+ i += 1;
3974
+ }
3975
+ } else {
3976
+ i += 1;
3977
+ }
3978
+ }
3979
+ const table2 = readQualifiedName(tk, i + 1);
3980
+ if (!table2 || events.length === 0) return;
3981
+ let j = table2.next;
3982
+ while (j < tk.length && !isWord(tk[j], "execute")) j += 1;
3983
+ if (!isWord(tk[j + 1], "function") && !isWord(tk[j + 1], "procedure")) return;
3984
+ const fn2 = readQualifiedName(tk, j + 2);
3985
+ if (!fn2) return;
3986
+ const key = qualifiedKey(table2);
3987
+ state.triggers.set(`${key}#${name.toLowerCase()}`, {
3988
+ name: name.toLowerCase(),
3989
+ table: key,
3990
+ timing,
3991
+ events,
3992
+ ofColumns,
3993
+ fn: { schema: fn2.schema, name: fn2.name },
3994
+ location: { file, line: stmt.line }
3995
+ });
3996
+ }
3997
+ function dropTrigger(state, stmt) {
3998
+ const tk = stmt.tokens;
3999
+ let i = 2;
4000
+ if (isWord(tk[i], "if") && isWord(tk[i + 1], "exists")) i += 2;
4001
+ const name = identOf(tk[i]);
4002
+ if (name === null || !isWord(tk[i + 1], "on")) return;
4003
+ const table2 = readQualifiedName(tk, i + 2);
4004
+ if (table2) state.triggers.delete(`${qualifiedKey(table2)}#${name.toLowerCase()}`);
4005
+ }
4006
+ var NEW_COLUMN = /(?<![A-Za-z0-9_$])new\s*\.\s*"?([A-Za-z_][A-Za-z0-9_]{0,62})"?/gi;
4007
+ var MAX_TRIGGER_COLUMNS = 200;
4008
+ function finishTrigger(state, t) {
4009
+ const body = functionBodyOf(state.functions, t.fn) ?? "";
4010
+ const raises = /(?<![A-Za-z0-9_$])raise(?![A-Za-z0-9_$])/i.test(body);
4011
+ const read = /* @__PURE__ */ new Set();
4012
+ for (const m of body.matchAll(NEW_COLUMN)) {
4013
+ const col = m[1]?.toLowerCase();
4014
+ if (col) read.add(col);
4015
+ if (read.size >= MAX_TRIGGER_COLUMNS) break;
4016
+ }
4017
+ const checked = new Set(t.ofColumns);
4018
+ for (const col of read) {
4019
+ const old = new RegExp(`(?<![A-Za-z0-9_$])old\\s*\\.\\s*"?${col}"?(?![A-Za-z0-9_$])`, "i");
4020
+ const assigned = new RegExp(`(?<![A-Za-z0-9_$])new\\s*\\.\\s*"?${col}"?\\s*:=`, "i");
4021
+ if (raises || old.test(body) || assigned.test(body)) checked.add(col);
4022
+ }
4023
+ return {
4024
+ name: t.name,
4025
+ table: t.table,
4026
+ timing: t.timing,
4027
+ events: [...t.events],
4028
+ checkedColumns: [...checked],
4029
+ function: qualifiedKey(t.fn),
4030
+ location: t.location
4031
+ };
4032
+ }
3695
4033
  function applySchemaStatement(state, stmt, file) {
3696
4034
  const tk = stmt.tokens;
3697
4035
  const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
@@ -3702,6 +4040,7 @@ function applySchemaStatement(state, stmt, file) {
3702
4040
  else if (kind === "type") createType(state, stmt);
3703
4041
  else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
3704
4042
  else if (kind === "unique") createUniqueIndex(state, stmt);
4043
+ else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
3705
4044
  } else if (w0 === "alter") {
3706
4045
  if (w1 === "table") alterTable(state, stmt, file, false);
3707
4046
  else if (w1 === "type") alterType(state, stmt);
@@ -3711,6 +4050,7 @@ function applySchemaStatement(state, stmt, file) {
3711
4050
  if (w1 === "table") dropTable(state, stmt);
3712
4051
  else if (w1 === "type") dropType(state, stmt);
3713
4052
  else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
4053
+ else if (w1 === "trigger") dropTrigger(state, stmt);
3714
4054
  } else if (w0 === "grant" || w0 === "revoke") {
3715
4055
  applyGrantRevoke(state.functions, stmt);
3716
4056
  } else if (w0 === "do") {
@@ -3725,6 +4065,7 @@ function finishSchema(state) {
3725
4065
  enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
3726
4066
  sqlFunctions: finishFunctions(state.functions),
3727
4067
  storageBuckets: finishBuckets(state.buckets),
4068
+ triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
3728
4069
  warnings: [...state.warnings]
3729
4070
  };
3730
4071
  }
@@ -3755,6 +4096,41 @@ var DROP_POLICY = new RegExp(
3755
4096
  function isAppliedSqlFile(rel) {
3756
4097
  return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
3757
4098
  }
4099
+ var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
4100
+ "doc",
4101
+ "docs",
4102
+ "documentation",
4103
+ "legacy",
4104
+ "archive",
4105
+ "archives",
4106
+ "archived",
4107
+ "backup",
4108
+ "backups",
4109
+ "deprecated",
4110
+ "example",
4111
+ "examples",
4112
+ "old"
4113
+ ]);
4114
+ var CLI_MIGRATION = /(?:^|\/)supabase\/migrations\/[0-9]+_[^/]*\.sql$/;
4115
+ var CLI_SEED = /(?:^|\/)supabase\/seed\.sql$/;
4116
+ function appliedSqlFiles(rels) {
4117
+ const applied = rels.filter(isAppliedSqlFile);
4118
+ const slashed = (rel) => rel.split("\\").join("/");
4119
+ const hasMigrations = applied.some(
4120
+ (rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
4121
+ );
4122
+ if (!hasMigrations) return applied;
4123
+ const kept = applied.filter(
4124
+ (rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
4125
+ );
4126
+ const migrations = kept.filter((rel) => CLI_MIGRATION.test(slashed(rel)));
4127
+ if (migrations.length === 0) return kept;
4128
+ const seeds = kept.filter((rel) => CLI_SEED.test(slashed(rel)));
4129
+ const byHand = kept.filter(
4130
+ (rel) => !CLI_MIGRATION.test(slashed(rel)) && !CLI_SEED.test(slashed(rel))
4131
+ );
4132
+ return [...byHand, ...migrations, ...seeds];
4133
+ }
3758
4134
  function parseSqlForRls(rel, text, into) {
3759
4135
  if (!isAppliedSqlFile(rel)) return;
3760
4136
  const state = schemaStateFor(into);
@@ -3830,7 +4206,7 @@ function sqlSchemaFor(into) {
3830
4206
 
3831
4207
  // packages/parser/src/role-gates.ts
3832
4208
  import ts7 from "typescript";
3833
- var ROLE_PROPERTY = /^(role|roles|app_metadata|is_?admin|is_?superuser|permissions?|claims?)$/i;
4209
+ var ROLE_PROPERTY = /^(role|roles|rolle|rol|user_?role|access_?level|app_metadata|admin|is_?admin|superuser|is_?superuser|permissions?|claims?)$/i;
3834
4210
  var EMAIL_PROPERTY = /^email$/i;
3835
4211
  function propertyPath(e) {
3836
4212
  const u = unwrap(e);
@@ -4916,6 +5292,9 @@ function bindDeclarations(p, frame, acc) {
4916
5292
  const names = boundNames(decl.name);
4917
5293
  const text = init.getText(sf);
4918
5294
  aliasLocal(frame, decl.name, init);
5295
+ const who = identityBinding(p, frame, init, scope);
5296
+ if (who !== void 0)
5297
+ for (const nm of identityNamesOf(decl.name)) frame.identities.set(nm, who);
4919
5298
  const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
4920
5299
  if (client && ts11.isIdentifier(decl.name)) {
4921
5300
  frame.clients.set(decl.name.text, client);
@@ -4997,6 +5376,21 @@ function bindDeclarations(p, frame, acc) {
4997
5376
  bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4998
5377
  }
4999
5378
  }
5379
+ const assigned = /* @__PURE__ */ new Map();
5380
+ for (const asg of collect(body, ts11.isBinaryExpression)) {
5381
+ if (asg.operatorToken.kind !== ts11.SyntaxKind.EqualsToken || !ts11.isIdentifier(asg.left))
5382
+ continue;
5383
+ if (isLiteralValue(unwrap(asg.right))) continue;
5384
+ const list = assigned.get(asg.left.text) ?? [];
5385
+ list.push(identityBinding(p, frame, asg.right, scope));
5386
+ assigned.set(asg.left.text, list);
5387
+ }
5388
+ for (const [name, whos] of assigned) {
5389
+ const [first] = whos;
5390
+ if (first !== void 0 && whos.every((w) => w !== void 0))
5391
+ frame.identities.set(name, first);
5392
+ else frame.identities.delete(name);
5393
+ }
5000
5394
  if (frame.depth === 0) {
5001
5395
  walk(body, (n) => {
5002
5396
  if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
@@ -5022,16 +5416,22 @@ function analyzeFrame(p, frame, acc) {
5022
5416
  const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
5023
5417
  if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
5024
5418
  }
5025
- const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
5419
+ const isSessionCall = (call) => callsSession(p, call, sf, scope);
5026
5420
  for (const call of collect(body, ts11.isCallExpression)) {
5027
5421
  if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
5028
5422
  }
5029
- for (const gate of roleGatesIn(body, sessionNamesIn(body, isSessionCall))) {
5423
+ const sessionNames = sessionNamesIn(body, isSessionCall);
5424
+ for (const name of frame.identities.keys()) sessionNames.add(name);
5425
+ for (const gate of roleGatesIn(body, sessionNames)) {
5030
5426
  if (gate.exit === "return" && !frame.exitPropagates) continue;
5427
+ const [root, ...rest] = gate.source.split(".");
5428
+ const table2 = root === void 0 ? void 0 : frame.identities.get(root);
5429
+ const column = rest[rest.length - 1];
5031
5430
  acc.roleChecks.push({
5032
5431
  ...loc2(gate.node),
5033
5432
  source: gate.source,
5034
- text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
5433
+ text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160),
5434
+ ...typeof table2 === "string" && column !== void 0 ? { table: table2, column } : {}
5035
5435
  });
5036
5436
  }
5037
5437
  for (const check of secretChecksIn(fn2, sf)) {
@@ -5091,7 +5491,8 @@ function analyzeFrame(p, frame, acc) {
5091
5491
  method: f.method,
5092
5492
  column: f.column,
5093
5493
  valueText: f.value ? f.value.getText(sf) : f.text,
5094
- inputDerived: f.value ? derivedIn(frame, f.value) : false
5494
+ inputDerived: f.value ? derivedIn(frame, f.value) : false,
5495
+ ...f.value && identityOf(frame, f.value) !== void 0 ? { identity: true } : {}
5095
5496
  },
5096
5497
  f.value
5097
5498
  );
@@ -5141,7 +5542,8 @@ function analyzeFrame(p, frame, acc) {
5141
5542
  method: "match",
5142
5543
  column: pr.name.getText(sf).replace(/['"]/g, ""),
5143
5544
  valueText: pr.initializer.getText(sf),
5144
- inputDerived: derivedIn(frame, pr.initializer)
5545
+ inputDerived: derivedIn(frame, pr.initializer),
5546
+ ...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
5145
5547
  };
5146
5548
  filters.push(valued(f2, pr.initializer));
5147
5549
  }
@@ -5153,7 +5555,8 @@ function analyzeFrame(p, frame, acc) {
5153
5555
  method: s.name,
5154
5556
  column: stringLiteralValue(firstArg),
5155
5557
  valueText: val ? val.getText(sf) : "",
5156
- inputDerived: val ? derivedIn(frame, val) : false
5558
+ inputDerived: val ? derivedIn(frame, val) : false,
5559
+ ...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
5157
5560
  };
5158
5561
  filters.push(valued(f, val));
5159
5562
  }
@@ -5285,13 +5688,23 @@ function analyzeFrame(p, frame, acc) {
5285
5688
  call,
5286
5689
  chain.segments.map((s) => s.name)
5287
5690
  );
5288
- const checks = rowComparisons(call).filter((c) => c.exit === "throw" || frame.exitPropagates).map((c) => ({
5289
- method: "compare",
5290
- column: c.column,
5291
- valueText: c.value.getText(sf).replace(/\s+/g, " "),
5292
- inputDerived: derivedIn(frame, c.value)
5293
- }));
5691
+ const exits = rowExit === "throw" || rowExit === "return" && frame.exitPropagates;
5692
+ const checks = [
5693
+ // `if (!row || row.user_id !== user.id) return 404`: ownership checked in code after the read.
5694
+ ...rowComparisons(call).filter((c) => c.exit === "throw" || frame.exitPropagates).map((c) => ({
5695
+ method: "compare",
5696
+ column: c.column,
5697
+ valueText: c.value.getText(sf).replace(/\s+/g, " "),
5698
+ inputDerived: derivedIn(frame, c.value),
5699
+ ...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
5700
+ })),
5701
+ // `if (!(await canAccessRequest(user, request.org_id))) return null`: checked by a helper.
5702
+ ...helperRowChecks(p, call, frame, scope)
5703
+ ];
5294
5704
  if (checks.length > 0) query.ownerChecks = checks;
5705
+ if (exits && query.operation === "select") {
5706
+ membershipChecks(acc.reads, query, filterValues, frame);
5707
+ }
5295
5708
  acc.reads.push({
5296
5709
  query,
5297
5710
  keys: query.filters.map((f) => {
@@ -5299,8 +5712,10 @@ function analyzeFrame(p, frame, acc) {
5299
5712
  return v ? valueKey(frame, v) : null;
5300
5713
  }),
5301
5714
  order: [...frame.pathPos, parsed.anchor.getStart(sf)],
5302
- exits: rowExit === "throw" || rowExit === "return" && frame.exitPropagates,
5303
- checks
5715
+ exits,
5716
+ checks,
5717
+ frame: frame.key,
5718
+ rows: rowNamesOf(call)
5304
5719
  });
5305
5720
  bindBuilder(call, query);
5306
5721
  }
@@ -5320,6 +5735,187 @@ function analyzeFrame(p, frame, acc) {
5320
5735
  analyzeFrame(p, child, acc);
5321
5736
  }
5322
5737
  }
5738
+ function paramRole(e, roles) {
5739
+ let u = unwrap(e);
5740
+ while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
5741
+ if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return null;
5742
+ u = unwrap(u.expression);
5743
+ }
5744
+ return ts11.isIdentifier(u) ? roles.get(u.text) ?? null : null;
5745
+ }
5746
+ var EQUALS = /* @__PURE__ */ new Set([
5747
+ ts11.SyntaxKind.EqualsEqualsEqualsToken,
5748
+ ts11.SyntaxKind.EqualsEqualsToken
5749
+ ]);
5750
+ var NOT_EQUALS = /* @__PURE__ */ new Set([
5751
+ ts11.SyntaxKind.ExclamationEqualsEqualsToken,
5752
+ ts11.SyntaxKind.ExclamationEqualsToken
5753
+ ]);
5754
+ function comparesRowAndCaller(e, roles, ops) {
5755
+ const u = unwrap(e);
5756
+ if (!ts11.isBinaryExpression(u) || !ops.has(u.operatorToken.kind)) return false;
5757
+ const a = paramRole(u.left, roles);
5758
+ const b = paramRole(u.right, roles);
5759
+ return a === "row" && b === "caller" || a === "caller" && b === "row";
5760
+ }
5761
+ function isCallerRoleTest(e, roles) {
5762
+ const u = unwrap(e);
5763
+ const onCaller = (x) => {
5764
+ const ux = unwrap(x);
5765
+ return ts11.isPropertyAccessExpression(ux) && paramRole(ux, roles) === "caller";
5766
+ };
5767
+ if (ts11.isBinaryExpression(u)) {
5768
+ const op = u.operatorToken.kind;
5769
+ if (op === ts11.SyntaxKind.BarBarToken || op === ts11.SyntaxKind.AmpersandAmpersandToken) {
5770
+ return isCallerRoleTest(u.left, roles) && isCallerRoleTest(u.right, roles);
5771
+ }
5772
+ if (!EQUALS.has(op)) return false;
5773
+ return onCaller(u.left) && ts11.isStringLiteralLike(unwrap(u.right)) || onCaller(u.right) && ts11.isStringLiteralLike(unwrap(u.left));
5774
+ }
5775
+ if (!ts11.isCallExpression(u) || !ts11.isPropertyAccessExpression(u.expression) || u.expression.name.text !== "includes") {
5776
+ return false;
5777
+ }
5778
+ const list = unwrap(u.expression.expression);
5779
+ const arg = u.arguments[0];
5780
+ return ts11.isArrayLiteralExpression(list) && list.elements.length > 0 && list.elements.every((el) => ts11.isStringLiteralLike(el)) && u.arguments.length === 1 && arg !== void 0 && onCaller(arg);
5781
+ }
5782
+ function isDenyValue(e) {
5783
+ const u = unwrap(e);
5784
+ return u.kind === ts11.SyntaxKind.FalseKeyword || u.kind === ts11.SyntaxKind.NullKeyword || ts11.isIdentifier(u) && u.text === "undefined";
5785
+ }
5786
+ function operandsOf(e, op) {
5787
+ const u = unwrap(e);
5788
+ return ts11.isBinaryExpression(u) && u.operatorToken.kind === op ? [...operandsOf(u.left, op), ...operandsOf(u.right, op)] : [u];
5789
+ }
5790
+ function thenBranchIf(ret) {
5791
+ let child = ret;
5792
+ for (let cur = ret.parent; cur && !isFunctionLikeNode(cur); cur = cur.parent) {
5793
+ if (ts11.isIfStatement(cur)) return cur.thenStatement === child ? cur : null;
5794
+ child = cur;
5795
+ }
5796
+ return null;
5797
+ }
5798
+ function onlyDenies(stmt) {
5799
+ let stops = false;
5800
+ let allows = false;
5801
+ walkOwn(stmt, (n) => {
5802
+ if (ts11.isThrowStatement(n)) stops = true;
5803
+ else if (ts11.isReturnStatement(n)) {
5804
+ if (!n.expression || isDenyValue(n.expression)) stops = true;
5805
+ else allows = true;
5806
+ }
5807
+ });
5808
+ return stops && !allows;
5809
+ }
5810
+ function helperComparison(fn2, rowIndex, callerIndexes, sf) {
5811
+ const roles = /* @__PURE__ */ new Map();
5812
+ fn2.parameters.forEach((param, i) => {
5813
+ if (!ts11.isIdentifier(param.name)) return;
5814
+ if (i === rowIndex) roles.set(param.name.text, "row");
5815
+ else if (callerIndexes.includes(i)) roles.set(param.name.text, "caller");
5816
+ });
5817
+ const kinds = new Set(roles.values());
5818
+ if (!kinds.has("row") || !kinds.has("caller") || !fn2.body) return null;
5819
+ let denied = false;
5820
+ if (ts11.isBlock(fn2.body)) {
5821
+ walkOwn(fn2.body, (n) => {
5822
+ if (ts11.isIfStatement(n) && operandsOf(n.expression, ts11.SyntaxKind.BarBarToken).some(
5823
+ (c) => comparesRowAndCaller(c, roles, NOT_EQUALS)
5824
+ ) && onlyDenies(n.thenStatement)) {
5825
+ denied = true;
5826
+ }
5827
+ });
5828
+ }
5829
+ const bypasses = [];
5830
+ let compared = false;
5831
+ for (const ret of ownReturns(fn2)) {
5832
+ if (isDenyValue(ret)) continue;
5833
+ if (unwrap(ret).kind === ts11.SyntaxKind.TrueKeyword) {
5834
+ const gate = thenBranchIf(ret);
5835
+ if (gate && isCallerRoleTest(gate.expression, roles)) {
5836
+ bypasses.push(gate.expression.getText(sf).replace(/\s+/g, " "));
5837
+ continue;
5838
+ }
5839
+ if (gate === null && denied) {
5840
+ compared = true;
5841
+ continue;
5842
+ }
5843
+ return null;
5844
+ }
5845
+ for (const alt of operandsOf(ret, ts11.SyntaxKind.BarBarToken)) {
5846
+ const conjuncts = operandsOf(alt, ts11.SyntaxKind.AmpersandAmpersandToken);
5847
+ if (conjuncts.some((c) => comparesRowAndCaller(c, roles, EQUALS))) compared = true;
5848
+ else if (isCallerRoleTest(alt, roles)) bypasses.push(alt.getText(sf).replace(/\s+/g, " "));
5849
+ else return null;
5850
+ }
5851
+ }
5852
+ if (!compared) return null;
5853
+ return bypasses.length > 0 ? { bypass: bypasses.join(" || ") } : {};
5854
+ }
5855
+ function helperRowChecks(p, tail, frame, scope) {
5856
+ const rows = rowNamesOf(tail);
5857
+ const decl = outerOf(tail).parent;
5858
+ const fn2 = decl ? enclosingFunction(decl) : null;
5859
+ if (rows.size === 0 || !decl || !fn2?.body) return [];
5860
+ const out = [];
5861
+ walkOwn(fn2.body, (n) => {
5862
+ if (!ts11.isCallExpression(n) || n.pos < decl.end) return;
5863
+ const rowIndex = n.arguments.findIndex((a) => {
5864
+ const u = unwrap(a);
5865
+ if (!ts11.isPropertyAccessExpression(u)) return false;
5866
+ const base = unwrap(u.expression);
5867
+ return ts11.isIdentifier(base) && rows.has(base.text);
5868
+ });
5869
+ const rowArg = unwrap(n.arguments[rowIndex] ?? n);
5870
+ if (rowIndex < 0 || !ts11.isPropertyAccessExpression(rowArg)) return;
5871
+ const exit = checkedResultExit(n);
5872
+ if (exit === null || exit === "return" && !frame.exitPropagates) return;
5873
+ const callerIndexes = n.arguments.flatMap(
5874
+ (a, i) => i !== rowIndex && identityOf(frame, a) !== void 0 ? [i] : []
5875
+ );
5876
+ if (callerIndexes.length === 0) return;
5877
+ const target = callTarget(p, n, frame, scope);
5878
+ const targetSf = target ? p.sources.get(target.facts.file) : void 0;
5879
+ if (!target || !targetSf) return;
5880
+ const verdict = helperComparison(target.fn, rowIndex, callerIndexes, targetSf);
5881
+ if (!verdict) return;
5882
+ out.push({
5883
+ method: "compare",
5884
+ column: rowArg.name.text,
5885
+ valueText: n.getText(frame.sf).replace(/\s+/g, " ").slice(0, 160),
5886
+ inputDerived: false,
5887
+ identity: true,
5888
+ ...verdict.bypass !== void 0 ? { bypass: verdict.bypass } : {}
5889
+ });
5890
+ });
5891
+ return out;
5892
+ }
5893
+ function membershipChecks(reads, query, values, frame) {
5894
+ if (!query.filters.some((f) => f.identity === true && !f.inputDerived && f.method === "eq")) {
5895
+ return;
5896
+ }
5897
+ for (const f of query.filters) {
5898
+ if (f.identity === true || f.inputDerived || f.method !== "eq") continue;
5899
+ const v = values.get(f);
5900
+ const u = v ? unwrap(v) : void 0;
5901
+ if (!u || !ts11.isPropertyAccessExpression(u)) continue;
5902
+ const base = unwrap(u.expression);
5903
+ if (!ts11.isIdentifier(base) || normalizeColumn(u.name.text) !== normalizeColumn(f.column)) {
5904
+ continue;
5905
+ }
5906
+ const row = [...reads].reverse().find((r) => r.frame === frame.key && r.rows.has(base.text));
5907
+ if (!row) continue;
5908
+ const check = {
5909
+ method: "compare",
5910
+ column: u.name.text,
5911
+ valueText: `a ${query.table} row of the caller (${query.location.file}:${query.location.line})`,
5912
+ inputDerived: false,
5913
+ identity: true
5914
+ };
5915
+ row.checks.push(check);
5916
+ row.query.ownerChecks = [...row.query.ownerChecks ?? [], check];
5917
+ }
5918
+ }
5323
5919
  function childFrame(p, call, target, frame) {
5324
5920
  const tsf = p.sources.get(target.facts.file);
5325
5921
  if (!tsf) return null;
@@ -5341,7 +5937,8 @@ function childFrame(p, call, target, frame) {
5341
5937
  key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
5342
5938
  aliases: /* @__PURE__ */ new Map(),
5343
5939
  pathPos: [...frame.pathPos, call.getStart(frame.sf)],
5344
- exitPropagates: frame.exitPropagates && callResultChecked(call)
5940
+ exitPropagates: frame.exitPropagates && callResultChecked(call),
5941
+ identities: /* @__PURE__ */ new Map()
5345
5942
  };
5346
5943
  const cx = wholeContext(p, frame);
5347
5944
  target.fn.parameters.forEach((param, i) => {
@@ -5356,6 +5953,9 @@ function childFrame(p, call, target, frame) {
5356
5953
  child.instances.set(head, ab.instance);
5357
5954
  }
5358
5955
  for (const nm of names) if (ab.isRequest) child.reqNames.add(nm);
5956
+ const who = arg ? identityOf(frame, arg) : void 0;
5957
+ if (who !== void 0 && ts11.isIdentifier(param.name))
5958
+ child.identities.set(param.name.text, who);
5359
5959
  if (ab.tainted) {
5360
5960
  bindParamTaint(child, param.name, arg, frame);
5361
5961
  for (const nm of wholeParamNames(param.name, arg, cx)) {
@@ -5373,6 +5973,7 @@ function frameSignature(child) {
5373
5973
  ...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
5374
5974
  ...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
5375
5975
  ...[...child.reqNames].map((n) => `${n}?`),
5976
+ ...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
5376
5977
  ...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
5377
5978
  ...[...child.aliases].map(([n, k]) => `${n}~${k}`),
5378
5979
  `exit=${child.exitPropagates}`
@@ -5628,6 +6229,111 @@ function returnsIdentity(p, call, scope) {
5628
6229
  if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
5629
6230
  return IDENTITY_CALLEE.test(calleePath(call.expression));
5630
6231
  }
6232
+ function callsSession(p, call, sf, scope) {
6233
+ return /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
6234
+ }
6235
+ function identityOf(frame, e) {
6236
+ if (frame.identities.size === 0 || derivedIn(frame, e)) return void 0;
6237
+ let u = unwrap(e);
6238
+ let part = false;
6239
+ while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
6240
+ if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return void 0;
6241
+ u = unwrap(u.expression);
6242
+ part = true;
6243
+ }
6244
+ if (!ts11.isIdentifier(u) || !frame.identities.has(u.text)) return void 0;
6245
+ const who = frame.identities.get(u.text) ?? null;
6246
+ return part && who !== null ? "" : who;
6247
+ }
6248
+ function identityNamesOf(name) {
6249
+ if (ts11.isIdentifier(name)) return [name.text];
6250
+ if (ts11.isArrayBindingPattern(name)) {
6251
+ const first = name.elements[0];
6252
+ return first && !ts11.isOmittedExpression(first) ? boundNames(first.name) : [];
6253
+ }
6254
+ const data = name.elements.find((el) => propertyKeyOf(el.propertyName ?? el.name) === "data");
6255
+ if (data) return boundNames(data.name);
6256
+ return name.elements.filter((el) => propertyKeyOf(el.propertyName ?? el.name) !== "error").flatMap((el) => boundNames(el.name));
6257
+ }
6258
+ function identityRowOf(frame, call) {
6259
+ const { segments } = flattenChain(call);
6260
+ const table2 = stringLiteralValue(segments.find((s) => s.name === "from")?.args[0]);
6261
+ if (table2 === null) return void 0;
6262
+ const keyed = (column, value) => value !== void 0 && (identityOf(frame, value) !== void 0 || isCredentialColumn(column) && derivedIn(frame, value));
6263
+ for (const s of segments) {
6264
+ if (s.name === "eq" && keyed(stringLiteralValue(s.args[0]), s.args[1])) return table2;
6265
+ const obj = s.name === "match" ? s.args[0] : void 0;
6266
+ if (!obj || !ts11.isObjectLiteralExpression(obj)) continue;
6267
+ for (const pr of obj.properties) {
6268
+ if (ts11.isPropertyAssignment(pr) && keyed(propertyKeyOf(pr.name), pr.initializer))
6269
+ return table2;
6270
+ }
6271
+ }
6272
+ return void 0;
6273
+ }
6274
+ function identityBinding(p, frame, init, scope) {
6275
+ let out;
6276
+ for (const leaf of branchesOf(init)) {
6277
+ if (isLiteralValue(leaf)) continue;
6278
+ const who = identityLeaf(p, frame, leaf, scope);
6279
+ if (who === void 0) return void 0;
6280
+ if (out === void 0) out = who;
6281
+ }
6282
+ return out;
6283
+ }
6284
+ function identityLeaf(p, frame, leaf, scope) {
6285
+ const u = unwrap(leaf);
6286
+ if (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
6287
+ let base = u;
6288
+ while (ts11.isPropertyAccessExpression(base) || ts11.isElementAccessExpression(base)) {
6289
+ if (ts11.isPropertyAccessExpression(base) && base.name.text === "user_metadata")
6290
+ return void 0;
6291
+ base = unwrap(base.expression);
6292
+ }
6293
+ if (!ts11.isCallExpression(base)) return identityOf(frame, u);
6294
+ if (derivedIn(frame, u)) return void 0;
6295
+ const who = identityLeaf(p, frame, base, scope);
6296
+ return who === void 0 || who === null ? who : "";
6297
+ }
6298
+ if (ts11.isIdentifier(u)) return identityOf(frame, u);
6299
+ if (!ts11.isCallExpression(u)) return void 0;
6300
+ if (callsSession(p, u, frame.sf, scope)) return null;
6301
+ if (isChainWithQuery(u)) return identityRowOf(frame, u);
6302
+ if (isDbChain(u, frame)) return void 0;
6303
+ return returnIdentity(p, u, frame, scope);
6304
+ }
6305
+ function returnIdentity(p, call, frame, scope) {
6306
+ if (frame.depth >= MAX_DEPTH) return void 0;
6307
+ const target = callTarget(p, call, frame, scope);
6308
+ if (!target) return void 0;
6309
+ const child = childFrame(p, call, target, frame);
6310
+ if (!child) return void 0;
6311
+ const key = `${target.facts.file}#${target.name}#${frameSignature(child)}`;
6312
+ const cached = p.returnIdentities.get(key);
6313
+ if (cached !== void 0) return cached === false ? void 0 : cached.who;
6314
+ p.returnIdentities.set(key, false);
6315
+ bindDeclarations(p, child, {
6316
+ inputs: [],
6317
+ authChecks: [],
6318
+ roleChecks: [],
6319
+ queries: [],
6320
+ metadataAccesses: [],
6321
+ visited: /* @__PURE__ */ new Set(),
6322
+ reads: []
6323
+ });
6324
+ const childScope = scopeOf(p, child.facts);
6325
+ let out = false;
6326
+ for (const ret of ownReturns(target.fn)) {
6327
+ for (const leaf of branchesOf(ret)) {
6328
+ if (isLiteralValue(leaf)) continue;
6329
+ const who = identityLeaf(p, child, leaf, childScope);
6330
+ if (who === void 0) return void 0;
6331
+ if (out === false) out = { who };
6332
+ }
6333
+ }
6334
+ p.returnIdentities.set(key, out);
6335
+ return out === false ? void 0 : out.who;
6336
+ }
5631
6337
  function calleePath(e) {
5632
6338
  const u = unwrap(e);
5633
6339
  if (ts11.isIdentifier(u)) return u.text;
@@ -5713,7 +6419,8 @@ function analyzeHandler(p, h) {
5713
6419
  key: "h",
5714
6420
  aliases: /* @__PURE__ */ new Map(),
5715
6421
  pathPos: [],
5716
- exitPropagates: true
6422
+ exitPropagates: true,
6423
+ identities: /* @__PURE__ */ new Map()
5717
6424
  };
5718
6425
  const first = fn2.parameters[0];
5719
6426
  if (h.kind === "route" && first) {
@@ -5822,7 +6529,7 @@ function parseProject(rootInput, opts = {}) {
5822
6529
  }
5823
6530
  }
5824
6531
  const tables = /* @__PURE__ */ new Map();
5825
- for (const rel of sql) {
6532
+ for (const rel of appliedSqlFiles(sql)) {
5826
6533
  try {
5827
6534
  parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
5828
6535
  } catch (e) {
@@ -5853,6 +6560,7 @@ function parseProject(rootInput, opts = {}) {
5853
6560
  drizzleTablesByExport,
5854
6561
  prismaModels,
5855
6562
  returnTaints: /* @__PURE__ */ new Map(),
6563
+ returnIdentities: /* @__PURE__ */ new Map(),
5856
6564
  tables,
5857
6565
  warnings
5858
6566
  };
@@ -5886,7 +6594,8 @@ function parseProject(rootInput, opts = {}) {
5886
6594
  warnings,
5887
6595
  enums: schema.enums,
5888
6596
  sqlFunctions: schema.sqlFunctions,
5889
- storageBuckets: schema.storageBuckets
6597
+ storageBuckets: schema.storageBuckets,
6598
+ ...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
5890
6599
  };
5891
6600
  }
5892
6601
  var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
@@ -6102,9 +6811,35 @@ function publicReadNote(p, table2) {
6102
6811
  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
6812
  }
6104
6813
  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;
6814
+ if (h.roleChecks.length === 0 || !t?.known || !singleTenantTable(ctx, t.table)) return {};
6815
+ let selfGrant;
6816
+ for (const check of h.roleChecks) {
6817
+ const open = selfGrantable(ctx, check);
6818
+ if (open === null) return { check };
6819
+ selfGrant ??= open;
6820
+ }
6821
+ return selfGrant === void 0 ? {} : { selfGrant };
6822
+ }
6823
+ function selfGrantable(ctx, check) {
6824
+ if (check.table === void 0 || check.column === void 0) return null;
6825
+ const column = check.column.toLowerCase();
6826
+ if (check.table === "")
6827
+ return `the role is read off a row whose table could not be told (${check.source}), so nothing shows users cannot change it`;
6828
+ const where2 = `public.${check.table}.${column}`;
6829
+ const info = ctx.model.tables.find((x) => x.table === check.table?.toLowerCase());
6830
+ if (!info)
6831
+ return `the role comes from ${where2}, which the migrations do not define, so nothing shows users cannot change it`;
6832
+ if (!info.rlsEnabled)
6833
+ return `the role comes from ${where2}, and RLS is off on public.${info.table}, so a signed-in user can rewrite it`;
6834
+ const writable = info.policyDetails.find(
6835
+ (pl) => (pl.command === "update" || pl.command === "all") && (pl.roles.length === 0 || pl.roles.some((r) => r === "authenticated" || r === "public")) && policyScopesToCaller(pl.using)
6836
+ );
6837
+ if (!writable) return null;
6838
+ const guarded = (ctx.model.sqlTriggers ?? []).some(
6839
+ (tr) => tr.table === info.table && tr.timing === "before" && tr.events.includes("update") && tr.checkedColumns.includes(column)
6840
+ );
6841
+ if (guarded) return null;
6842
+ 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
6843
  }
6109
6844
  function singleTenantTable(ctx, table2) {
6110
6845
  const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
@@ -6123,10 +6858,15 @@ function tableDataOf(ctx, table2) {
6123
6858
  return ctx.graph.nodes.get(`table:${table2}`)?.data;
6124
6859
  }
6125
6860
  function callerCheck(checks) {
6126
- return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
6861
+ const tying = (checks ?? []).filter(
6862
+ (c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column))
6863
+ );
6864
+ return tying.find((c) => c.bypass === void 0) ?? tying[0];
6127
6865
  }
6128
6866
  function guardTiesRowToCaller(guard, table2, callerFns) {
6129
- const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
6867
+ const callerFilter = guard.filters.find(
6868
+ (f) => !f.inputDerived && (f.identity === true || isScopeColumn(f.column))
6869
+ );
6130
6870
  if (callerFilter) {
6131
6871
  return {
6132
6872
  tied: true,
@@ -6139,7 +6879,8 @@ function guardTiesRowToCaller(guard, table2, callerFns) {
6139
6879
  return {
6140
6880
  tied: true,
6141
6881
  how: `its ${compared.column} compared with ${compared.valueText} in code, stopping otherwise`,
6142
- why: ""
6882
+ why: "",
6883
+ ...compared.bypass !== void 0 ? { bypass: compared.bypass } : {}
6143
6884
  };
6144
6885
  }
6145
6886
  if (guard.client !== "user_scoped") {
@@ -6181,20 +6922,27 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6181
6922
  if (!["select", "update", "delete"].includes(q.operation)) continue;
6182
6923
  const filters = q.filters;
6183
6924
  const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
6184
- if (!idFilter || filters.some((f) => isScopeColumn(f.column))) continue;
6185
- if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
6925
+ const scoped = filters.some(
6926
+ (f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
6927
+ );
6928
+ if (!idFilter || scoped) continue;
6929
+ const own = q.operation === "select" ? callerCheck(q.ownerChecks) : void 0;
6930
+ if (own && own.bypass === void 0) continue;
6186
6931
  const guard = q.guard;
6187
6932
  const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
6188
6933
  const tied = guard ? guardTiesRowToCaller(guard, guardTable, callerFns) : null;
6189
- if (guard && tied?.tied && guard.exitsWhenMissing) continue;
6934
+ if (guard && tied?.tied && guard.exitsWhenMissing && tied.bypass === void 0) continue;
6935
+ const bypassed = own ?? (guard && tied?.tied && guard.exitsWhenMissing ? callerCheck(guard.checks) : void 0);
6936
+ const bypass = bypassed?.bypass;
6190
6937
  const guardWhat = guard?.parent ? `the parent row ${guard.parent.table}.${guard.parent.column} (${q.table}.${guard.column} refers to it by ${guard.parent.how})` : `the same "${guard?.column}"`;
6191
- 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}.` : "";
6938
+ const guardNote = guard && tied && bypass === void 0 && (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
6939
  const tableName = v.tableData?.table ?? q.table;
6193
6940
  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);
6941
+ const gate = adminOnly(ctx, h, v.tableData);
6942
+ const admin = gate.check;
6195
6943
  const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
6196
- const downgrade = admin || anonPolicy ? "medium" : void 0;
6197
- const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
6944
+ const downgrade = admin || anonPolicy || bypass !== void 0 ? "medium" : void 0;
6945
+ const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "") + (bypassed && bypass !== void 0 ? ` The row's ${bypassed.column} is checked against the caller (${bypassed.valueText}), but ${bypass} lets the caller through without that check, so the finding is lowered rather than dropped: verify those roles belong to the service's staff and a user cannot grant them to themselves.` : "");
6198
6946
  const path = [
6199
6947
  h.data.kind === "server_action" ? "Server action call" : "HTTP request",
6200
6948
  h.data.entry,
@@ -6220,18 +6968,20 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6220
6968
  authenticated: h.authenticated,
6221
6969
  query: q.text,
6222
6970
  ...admin ? { adminOnly: true, roleCheck: admin.source } : {},
6223
- ...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {}
6971
+ ...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {},
6972
+ ...bypass !== void 0 ? { roleBypass: bypass } : {}
6224
6973
  }
6225
6974
  },
6226
6975
  { kind: "trace", summary: path.join(" -> ") }
6227
6976
  ];
6228
6977
  const who = admin ? "Admin-only" : h.authenticated ? "Cross-tenant" : "Unauthenticated";
6978
+ const verify = admin ? " (verify the admin check)" : bypass !== void 0 ? " (verify the role exception)" : "";
6229
6979
  out.push(
6230
6980
  finding(
6231
6981
  ctx,
6232
6982
  this,
6233
6983
  {
6234
- title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${admin ? " (verify the admin check)" : ""}`,
6984
+ title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${verify}`,
6235
6985
  entrypoints: [h.data.entry],
6236
6986
  sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
6237
6987
  sinks: [`supabase.${q.operation}:public.${tableName}`],
@@ -6323,6 +7073,7 @@ function emitGroups(ctx, rule, groups) {
6323
7073
  }
6324
7074
  return out;
6325
7075
  }
7076
+ var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
6326
7077
  var rlsPolicyWithoutCallerPredicate = {
6327
7078
  id: "supabase.rls-policy-without-caller-predicate",
6328
7079
  title: "RLS policy grants rows without a caller predicate",
@@ -6343,6 +7094,7 @@ var rlsPolicyWithoutCallerPredicate = {
6343
7094
  const op = v.data.operation === "unknown" ? "select" : v.data.operation;
6344
7095
  for (const p of t.policyDetails) {
6345
7096
  if (p.command !== "all" && p.command !== op) continue;
7097
+ if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
6346
7098
  const expr = op === "insert" ? p.check : p.using;
6347
7099
  if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
6348
7100
  continue;
@@ -6575,6 +7327,26 @@ var serviceRoleQueryWithoutAuthentication = {
6575
7327
  return out;
6576
7328
  }
6577
7329
  };
7330
+ function rlsOnWrite(t, operation) {
7331
+ if (!t?.known || !t.rlsEnabled) return void 0;
7332
+ const commands = operation === "upsert" ? ["insert", "update"] : [operation];
7333
+ const covering = t.policyDetails.filter(
7334
+ (pl) => (pl.command === "all" || commands.includes(pl.command)) && !(pl.roles.length > 0 && pl.roles.every((r) => r === "service_role"))
7335
+ );
7336
+ if (covering.length === 0) return "refused";
7337
+ return covering.every((pl) => /auth\.uid\(\)|auth\.jwt\(\)/i.test(pinExpression(pl))) ? covering : void 0;
7338
+ }
7339
+ function pinExpression(pl) {
7340
+ return pl.check ?? (pl.command === "insert" ? "" : pl.using ?? "");
7341
+ }
7342
+ function mentionsColumn(expr, column) {
7343
+ const c = column.replace(/[^A-Za-z0-9_]/g, "");
7344
+ return c !== "" && new RegExp(`(?<![A-Za-z0-9_])"?${c}"?(?![A-Za-z0-9_])`, "i").test(expr);
7345
+ }
7346
+ function ownedWriteNote(policies, table2, pinned) {
7347
+ const names = policies.map((pl) => `"${pl.name}" (${pl.location.file}:${pl.location.line})`).join(", ");
7348
+ 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.`;
7349
+ }
6578
7350
  var massAssignmentFromRequestBody = {
6579
7351
  id: "supabase.mass-assignment-from-request-body",
6580
7352
  title: "Request body written to a table without an allow-list",
@@ -6593,6 +7365,11 @@ var massAssignmentFromRequestBody = {
6593
7365
  const sensitive = cols.filter(
6594
7366
  (c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
6595
7367
  );
7368
+ const kind = v.clientData?.kind;
7369
+ const rls = kind === "anon" || kind === "user_scoped" ? rlsOnWrite(v.tableData, v.data.operation) : void 0;
7370
+ if (rls === "refused") continue;
7371
+ const pinned = rls ? cols.filter((c) => rls.some((pl) => mentionsColumn(pinExpression(pl), c))) : [];
7372
+ const ownedNote = rls && sensitive.every((c) => pinned.includes(c)) ? ownedWriteNote(rls, tableName, pinned) : "";
6596
7373
  const path = [
6597
7374
  "HTTP request",
6598
7375
  h.data.entry,
@@ -6601,22 +7378,27 @@ var massAssignmentFromRequestBody = {
6601
7378
  `public.${tableName}.${v.data.operation}`
6602
7379
  ];
6603
7380
  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
- })
7381
+ finding(
7382
+ ctx,
7383
+ this,
7384
+ {
7385
+ title: `Mass assignment into "${tableName}" from the request body`,
7386
+ entrypoints: [h.data.entry],
7387
+ sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
7388
+ sinks: [`supabase.${v.data.operation}:public.${tableName}`],
7389
+ path,
7390
+ evidence: [
7391
+ {
7392
+ kind: "rule",
7393
+ 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.`,
7394
+ locations: locations(h.handler.location, v.query.location),
7395
+ data: { deterministic: false, ruleId: this.id, payload: p.text }
7396
+ },
7397
+ { kind: "trace", summary: path.join(" -> ") }
7398
+ ]
7399
+ },
7400
+ ownedNote ? "medium" : void 0
7401
+ )
6620
7402
  );
6621
7403
  }
6622
7404
  }
@@ -7088,32 +7870,51 @@ var storagePolicyWithoutOwnerCheck = {
7088
7870
  `every object in ${where2}`
7089
7871
  ];
7090
7872
  const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
7873
+ const insertOnly = p.command === "insert";
7874
+ 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
7875
  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
- })
7876
+ finding3(
7877
+ ctx,
7878
+ this,
7879
+ {
7880
+ title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
7881
+ entrypoints: [...entries, storageEntry],
7882
+ sources: unique([
7883
+ ...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
7884
+ "storage API (caller's own session)"
7885
+ ]),
7886
+ sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
7887
+ path,
7888
+ evidence: [
7889
+ {
7890
+ kind: "rule",
7891
+ 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.`,
7892
+ locations: locations3(p.location, ...reached.map((r) => r.query.location)),
7893
+ data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
7894
+ },
7895
+ { kind: "trace", summary: path.join(" -> ") }
7896
+ ]
7897
+ },
7898
+ insertOnly ? "medium" : void 0
7899
+ )
7111
7900
  );
7112
7901
  }
7113
7902
  }
7114
7903
  return out;
7115
7904
  }
7116
7905
  };
7906
+ var EVERY_ROW = /^\(*\s*true\s*\)*$/i;
7907
+ var MIGRATION_SQL = /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i;
7908
+ function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
7909
+ if ((fn2.writes?.length ?? 0) > 0 || (fn2.calls?.length ?? 0) > 0) return false;
7910
+ const read = fn2.tables ?? [];
7911
+ return read.length > 0 && read.every((name) => {
7912
+ const t = tables.get(name);
7913
+ return t?.rlsEnabled === true && t.policyDetails.some(
7914
+ (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))
7915
+ );
7916
+ });
7917
+ }
7117
7918
  var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
7118
7919
  var securityDefinerFunctionWithoutCallerCheck = {
7119
7920
  id: "supabase.security-definer-function-without-caller-check",
@@ -7130,6 +7931,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
7130
7931
  const key = r.data.table.toLowerCase();
7131
7932
  rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
7132
7933
  }
7934
+ const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
7935
+ const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
7133
7936
  const out = [];
7134
7937
  for (const fn2 of fns) {
7135
7938
  if (!fn2.securityDefiner || fn2.checksCaller) continue;
@@ -7137,6 +7940,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
7137
7940
  if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
7138
7941
  const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
7139
7942
  if (roles.length === 0) continue;
7943
+ if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
7140
7944
  const anonymous = roles.includes("anon") || roles.includes("public");
7141
7945
  const sites = rpcByName.get(fn2.name) ?? [];
7142
7946
  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.4",
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",