auditai-scan 0.7.3 → 0.7.5

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 +301 -23
  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
  }
@@ -1568,6 +1568,45 @@ function callResultChecked(call) {
1568
1568
  }
1569
1569
  return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child && exitKind(cur.thenStatement) !== null;
1570
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
+ }
1571
1610
  function compareOrder(a, b) {
1572
1611
  const n = Math.min(a.length, b.length);
1573
1612
  for (let i = 0; i < n; i++) {
@@ -4072,6 +4111,8 @@ var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
4072
4111
  "examples",
4073
4112
  "old"
4074
4113
  ]);
4114
+ var CLI_MIGRATION = /(?:^|\/)supabase\/migrations\/[0-9]+_[^/]*\.sql$/;
4115
+ var CLI_SEED = /(?:^|\/)supabase\/seed\.sql$/;
4075
4116
  function appliedSqlFiles(rels) {
4076
4117
  const applied = rels.filter(isAppliedSqlFile);
4077
4118
  const slashed = (rel) => rel.split("\\").join("/");
@@ -4079,9 +4120,16 @@ function appliedSqlFiles(rels) {
4079
4120
  (rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
4080
4121
  );
4081
4122
  if (!hasMigrations) return applied;
4082
- return applied.filter(
4123
+ const kept = applied.filter(
4083
4124
  (rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
4084
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];
4085
4133
  }
4086
4134
  function parseSqlForRls(rel, text, into) {
4087
4135
  if (!isAppliedSqlFile(rel)) return;
@@ -5640,14 +5688,23 @@ function analyzeFrame(p, frame, acc) {
5640
5688
  call,
5641
5689
  chain.segments.map((s) => s.name)
5642
5690
  );
5643
- const checks = rowComparisons(call).filter((c) => c.exit === "throw" || frame.exitPropagates).map((c) => ({
5644
- method: "compare",
5645
- column: c.column,
5646
- valueText: c.value.getText(sf).replace(/\s+/g, " "),
5647
- inputDerived: derivedIn(frame, c.value),
5648
- ...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
5649
- }));
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
+ ];
5650
5704
  if (checks.length > 0) query.ownerChecks = checks;
5705
+ if (exits && query.operation === "select") {
5706
+ membershipChecks(acc.reads, query, filterValues, frame);
5707
+ }
5651
5708
  acc.reads.push({
5652
5709
  query,
5653
5710
  keys: query.filters.map((f) => {
@@ -5655,8 +5712,10 @@ function analyzeFrame(p, frame, acc) {
5655
5712
  return v ? valueKey(frame, v) : null;
5656
5713
  }),
5657
5714
  order: [...frame.pathPos, parsed.anchor.getStart(sf)],
5658
- exits: rowExit === "throw" || rowExit === "return" && frame.exitPropagates,
5659
- checks
5715
+ exits,
5716
+ checks,
5717
+ frame: frame.key,
5718
+ rows: rowNamesOf(call)
5660
5719
  });
5661
5720
  bindBuilder(call, query);
5662
5721
  }
@@ -5676,6 +5735,187 @@ function analyzeFrame(p, frame, acc) {
5676
5735
  analyzeFrame(p, child, acc);
5677
5736
  }
5678
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
+ }
5679
5919
  function childFrame(p, call, target, frame) {
5680
5920
  const tsf = p.sources.get(target.facts.file);
5681
5921
  if (!tsf) return null;
@@ -6618,7 +6858,10 @@ function tableDataOf(ctx, table2) {
6618
6858
  return ctx.graph.nodes.get(`table:${table2}`)?.data;
6619
6859
  }
6620
6860
  function callerCheck(checks) {
6621
- return checks?.find((c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column)));
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];
6622
6865
  }
6623
6866
  function guardTiesRowToCaller(guard, table2, callerFns) {
6624
6867
  const callerFilter = guard.filters.find(
@@ -6636,7 +6879,8 @@ function guardTiesRowToCaller(guard, table2, callerFns) {
6636
6879
  return {
6637
6880
  tied: true,
6638
6881
  how: `its ${compared.column} compared with ${compared.valueText} in code, stopping otherwise`,
6639
- why: ""
6882
+ why: "",
6883
+ ...compared.bypass !== void 0 ? { bypass: compared.bypass } : {}
6640
6884
  };
6641
6885
  }
6642
6886
  if (guard.client !== "user_scoped") {
@@ -6682,20 +6926,23 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6682
6926
  (f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
6683
6927
  );
6684
6928
  if (!idFilter || scoped) continue;
6685
- if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
6929
+ const own = q.operation === "select" ? callerCheck(q.ownerChecks) : void 0;
6930
+ if (own && own.bypass === void 0) continue;
6686
6931
  const guard = q.guard;
6687
6932
  const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
6688
6933
  const tied = guard ? guardTiesRowToCaller(guard, guardTable, callerFns) : null;
6689
- 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;
6690
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}"`;
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}.` : "";
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}.` : "";
6692
6939
  const tableName = v.tableData?.table ?? q.table;
6693
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.";
6694
6941
  const gate = adminOnly(ctx, h, v.tableData);
6695
6942
  const admin = gate.check;
6696
6943
  const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
6697
- const downgrade = admin || anonPolicy ? "medium" : void 0;
6698
- const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (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.` : "");
6699
6946
  const path = [
6700
6947
  h.data.kind === "server_action" ? "Server action call" : "HTTP request",
6701
6948
  h.data.entry,
@@ -6721,18 +6968,20 @@ var serviceRoleObjectAccessWithoutTenantScope = {
6721
6968
  authenticated: h.authenticated,
6722
6969
  query: q.text,
6723
6970
  ...admin ? { adminOnly: true, roleCheck: admin.source } : {},
6724
- ...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {}
6971
+ ...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {},
6972
+ ...bypass !== void 0 ? { roleBypass: bypass } : {}
6725
6973
  }
6726
6974
  },
6727
6975
  { kind: "trace", summary: path.join(" -> ") }
6728
6976
  ];
6729
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)" : "";
6730
6979
  out.push(
6731
6980
  finding(
6732
6981
  ctx,
6733
6982
  this,
6734
6983
  {
6735
- 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}`,
6736
6985
  entrypoints: [h.data.entry],
6737
6986
  sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
6738
6987
  sinks: [`supabase.${q.operation}:public.${tableName}`],
@@ -7667,6 +7916,31 @@ function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
7667
7916
  });
7668
7917
  }
7669
7918
  var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
7919
+ var NAMES_A_PERSON = /(^|_)(user|uid|profile|member|person|owner|customer|employee|student|patient|email)(_?id)?$/i;
7920
+ var CARRIES_CREDENTIAL = /(^|_)(pin|password|passwd|secret|token|otp|code|hash)$/i;
7921
+ function isCatalogRelation(name) {
7922
+ return name.startsWith("information_schema.") || name.startsWith("pg_catalog.") || /^pg_[a-z_]+$/.test(name);
7923
+ }
7924
+ function writesNothing(fn2, byName, seen = /* @__PURE__ */ new Set()) {
7925
+ if ((fn2.writes?.length ?? 0) > 0) return false;
7926
+ if (seen.has(fn2.name)) return true;
7927
+ seen.add(fn2.name);
7928
+ return (fn2.calls ?? []).every((c) => {
7929
+ const callee = byName.get(c);
7930
+ return callee !== void 0 && writesNothing(callee, byName, seen);
7931
+ });
7932
+ }
7933
+ function narrowResult(fn2, byName) {
7934
+ if (!writesNothing(fn2, byName)) return null;
7935
+ if (fn2.returns === "boolean") {
7936
+ if (fn2.params === void 0 && fn2.args !== "") return null;
7937
+ const about = (fn2.params ?? []).map((p) => p.name);
7938
+ return about.some((n) => NAMES_A_PERSON.test(n) || CARRIES_CREDENTIAL.test(n)) ? null : "boolean";
7939
+ }
7940
+ const read = fn2.tables ?? [];
7941
+ if (fn2.returns !== "void" && read.length > 0 && read.every(isCatalogRelation)) return "catalog";
7942
+ return null;
7943
+ }
7670
7944
  var securityDefinerFunctionWithoutCallerCheck = {
7671
7945
  id: "supabase.security-definer-function-without-caller-check",
7672
7946
  title: "SECURITY DEFINER function without a caller check",
@@ -7684,6 +7958,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
7684
7958
  }
7685
7959
  const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
7686
7960
  const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
7961
+ const byName = new Map(fns.map((f) => [f.name, f]));
7687
7962
  const out = [];
7688
7963
  for (const fn2 of fns) {
7689
7964
  if (!fn2.securityDefiner || fn2.checksCaller) continue;
@@ -7692,6 +7967,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
7692
7967
  const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
7693
7968
  if (roles.length === 0) continue;
7694
7969
  if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
7970
+ const narrow = narrowResult(fn2, byName);
7971
+ const narrowNote = narrow === "boolean" ? " It changes nothing and returns only true or false, so it is reported as medium: a yes/no answer about ids the caller supplies can still confirm that a record exists or that a user holds a role." : narrow === "catalog" ? " It changes nothing and reads only the system catalog, so it is reported as medium: it describes the schema, not anyone's rows." : "";
7695
7972
  const anonymous = roles.includes("anon") || roles.includes("public");
7696
7973
  const sites = rpcByName.get(fn2.name) ?? [];
7697
7974
  const entries = unique(sites.map((s) => s.handlerData.entry));
@@ -7720,7 +7997,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
7720
7997
  evidence: [
7721
7998
  {
7722
7999
  kind: "rule",
7723
- summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
8000
+ summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote}${narrowNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
7724
8001
  locations: locations3(
7725
8002
  fn2.location,
7726
8003
  ...sites.flatMap((s) => [s.handler.location, s.query.location])
@@ -7729,13 +8006,14 @@ var securityDefinerFunctionWithoutCallerCheck = {
7729
8006
  deterministic: false,
7730
8007
  ruleId: this.id,
7731
8008
  function: fn2.name,
7732
- grantedTo: [...fn2.grantedTo]
8009
+ grantedTo: [...fn2.grantedTo],
8010
+ ...narrow ? { narrowResult: narrow } : {}
7733
8011
  }
7734
8012
  },
7735
8013
  { kind: "trace", summary: path.join(" -> ") }
7736
8014
  ]
7737
8015
  },
7738
- anonymous ? "critical" : "high"
8016
+ narrow ? "medium" : anonymous ? "critical" : "high"
7739
8017
  )
7740
8018
  );
7741
8019
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",