auditai-scan 0.7.3 → 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 +269 -20
  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}`],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.7.3",
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",