auditai-scan 0.5.0 → 0.6.1

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 +463 -57
  2. package/package.json +1 -1
@@ -78,20 +78,20 @@ var IllegalTransitionError = class extends Error {
78
78
  to;
79
79
  name = "IllegalTransitionError";
80
80
  };
81
- function transition(finding3, to, opts = {}) {
82
- if (!canTransition(finding3.status, to)) {
83
- throw new IllegalTransitionError(finding3.id, finding3.status, to);
81
+ function transition(finding4, to, opts = {}) {
82
+ if (!canTransition(finding4.status, to)) {
83
+ throw new IllegalTransitionError(finding4.id, finding4.status, to);
84
84
  }
85
85
  const requiresEvidence = to === "confirmed" || to === "verified";
86
86
  if (requiresEvidence && !opts.evidence) {
87
- throw new Error(`Finding ${finding3.id}: transition to ${to} requires evidence`);
87
+ throw new Error(`Finding ${finding4.id}: transition to ${to} requires evidence`);
88
88
  }
89
- if (to === "verified" && !isVerificationPassing(finding3.verification)) {
90
- throw new Error(`Finding ${finding3.id}: cannot mark verified without a passing verification`);
89
+ if (to === "verified" && !isVerificationPassing(finding4.verification)) {
90
+ throw new Error(`Finding ${finding4.id}: cannot mark verified without a passing verification`);
91
91
  }
92
- const evidence = opts.evidence ? [...finding3.evidence, opts.evidence] : finding3.evidence;
92
+ const evidence = opts.evidence ? [...finding4.evidence, opts.evidence] : finding4.evidence;
93
93
  return {
94
- ...finding3,
94
+ ...finding4,
95
95
  status: to,
96
96
  evidence,
97
97
  updatedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
@@ -100,8 +100,8 @@ function transition(finding3, to, opts = {}) {
100
100
  function isVerificationPassing(v) {
101
101
  return v !== void 0 && v.securityTestBefore === "failed" && v.securityTestAfter === "passed" && (v.existingTests === "passed" || v.existingTests === "skipped") && v.rescan === "passed";
102
102
  }
103
- function isDeterministic(finding3) {
104
- return finding3.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
103
+ function isDeterministic(finding4) {
104
+ return finding4.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
105
105
  }
106
106
  var DEFAULT_BLOCKING_POLICY = {
107
107
  minConfidence: 0.8,
@@ -109,14 +109,14 @@ var DEFAULT_BLOCKING_POLICY = {
109
109
  blockDeterministicCritical: true
110
110
  };
111
111
  var CONFIRMED_OR_LATER = ["confirmed", "fix_proposed", "fix_applied"];
112
- function isBlocking(finding3, policy = DEFAULT_BLOCKING_POLICY) {
113
- if (finding3.status === "suppressed") return false;
114
- if (finding3.status === "verified") return true;
115
- if (finding3.confidence < policy.minConfidence) return false;
116
- if (CONFIRMED_OR_LATER.includes(finding3.status) && policy.blockOnConfirmedSeverities.includes(finding3.severity)) {
112
+ function isBlocking(finding4, policy = DEFAULT_BLOCKING_POLICY) {
113
+ if (finding4.status === "suppressed") return false;
114
+ if (finding4.status === "verified") return true;
115
+ if (finding4.confidence < policy.minConfidence) return false;
116
+ if (CONFIRMED_OR_LATER.includes(finding4.status) && policy.blockOnConfirmedSeverities.includes(finding4.severity)) {
117
117
  return true;
118
118
  }
119
- if (policy.blockDeterministicCritical && finding3.severity === "critical" && isDeterministic(finding3) && finding3.status !== "unverified") {
119
+ if (policy.blockDeterministicCritical && finding4.severity === "critical" && isDeterministic(finding4) && finding4.status !== "unverified") {
120
120
  return true;
121
121
  }
122
122
  return false;
@@ -959,8 +959,23 @@ function secretScopeFor(fn, sf) {
959
959
  growNames(ownDeclarations(fn), scope);
960
960
  return scope;
961
961
  }
962
+ var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
963
+ function readsStoredSecret(e) {
964
+ let hit = false;
965
+ const visit = (n) => {
966
+ if (hit) return;
967
+ if (ts3.isPropertyAccessExpression(n) && STORED_SECRET_FIELD.test(n.name.text.toLowerCase())) {
968
+ hit = true;
969
+ return;
970
+ }
971
+ n.forEachChild(visit);
972
+ };
973
+ visit(e);
974
+ return hit;
975
+ }
962
976
  function isSecretish(e, scope) {
963
977
  if (envNamesIn(e).some(isSecretEnvName)) return true;
978
+ if (readsStoredSecret(e)) return true;
964
979
  for (const id of identifiersIn(e)) if (scope.names.has(id)) return true;
965
980
  let calls = false;
966
981
  const visit = (n) => {
@@ -984,7 +999,7 @@ var EQUALITY = /* @__PURE__ */ new Set([
984
999
  ts3.SyntaxKind.EqualsEqualsToken,
985
1000
  ts3.SyntaxKind.ExclamationEqualsToken
986
1001
  ]);
987
- var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?)$/i;
1002
+ var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?|secretsMatch|secretMatch|matchesSecret|tokensMatch|tokenMatches|sameSecret|checkSecret|validSecret|isValidSecret)$/i;
988
1003
  var VERIFY_CALLEE = /^(verify|verifySync|jwtVerify|constructEvent|constructEventAsync|verifySignature|verifyWebhook|validateSignature)$/i;
989
1004
  var THROWING_VERIFIER = /^(jwtVerify|constructEvent|constructEventAsync)$/;
990
1005
  var THROWING_VERIFY_RECEIVER = /jwt|jose|jsonwebtoken/i;
@@ -1035,11 +1050,39 @@ function gates(node, fnBody) {
1035
1050
  }
1036
1051
  return false;
1037
1052
  }
1053
+ function receiverSecret(callee, secretish, builtFromSecret) {
1054
+ if (!ts3.isPropertyAccessExpression(callee)) return false;
1055
+ const recv = callee.expression;
1056
+ if (ts3.isNewExpression(recv)) return (recv.arguments ?? []).some(secretish);
1057
+ if (ts3.isIdentifier(recv) && builtFromSecret.has(recv.text)) return true;
1058
+ return secretish(recv);
1059
+ }
1060
+ function instancesBuiltFromSecret(body, secretish) {
1061
+ const out = /* @__PURE__ */ new Set();
1062
+ for (const d of collect(body, ts3.isVariableDeclaration)) {
1063
+ if (!d.initializer || !ts3.isIdentifier(d.name) || !ts3.isNewExpression(d.initializer)) continue;
1064
+ if ((d.initializer.arguments ?? []).some(secretish)) out.add(d.name.text);
1065
+ }
1066
+ return out;
1067
+ }
1068
+ function gatesByThrow(node, fnBody) {
1069
+ let child = node;
1070
+ let cur = node.parent;
1071
+ while (cur && cur !== fnBody && !isFunctionLikeNode(cur)) {
1072
+ if (ts3.isTryStatement(cur) && cur.tryBlock === child && cur.catchClause) {
1073
+ return exitKind(cur.catchClause.block) !== null;
1074
+ }
1075
+ child = cur;
1076
+ cur = cur.parent;
1077
+ }
1078
+ return false;
1079
+ }
1038
1080
  function secretChecksIn(fn, sf) {
1039
1081
  if (!fn.body) return [];
1040
1082
  const body = fn.body;
1041
1083
  const secrets = secretScopeFor(fn, sf);
1042
1084
  const secretish = (e) => isSecretish(e, secrets);
1085
+ const builtFromSecret = instancesBuiltFromSecret(body, secretish);
1043
1086
  const out = [];
1044
1087
  const push = (n) => {
1045
1088
  out.push({ node: n, text: n.getText(sf).replace(/\s+/g, " ").slice(0, 160) });
@@ -1058,8 +1101,9 @@ function secretChecksIn(fn, sf) {
1058
1101
  const verify = VERIFY_CALLEE.test(name);
1059
1102
  if (!compare && !verify) return;
1060
1103
  const secretArgs = n.arguments.filter(secretish).length;
1061
- if (secretArgs === 0 || secretArgs === n.arguments.length) return;
1062
- if (verify && throwsOnBadCredential(n) || gates(n, body)) push(n);
1104
+ const receiverHoldsSecret = verify && receiverSecret(n.expression, secretish, builtFromSecret);
1105
+ if (!receiverHoldsSecret && (secretArgs === 0 || secretArgs === n.arguments.length)) return;
1106
+ if (verify && throwsOnBadCredential(n) || gates(n, body) || gatesByThrow(n, body)) push(n);
1063
1107
  });
1064
1108
  return out;
1065
1109
  }
@@ -1086,7 +1130,7 @@ function nextAuthSessionNames(stmt, nextAuthLocal) {
1086
1130
  }
1087
1131
  return out;
1088
1132
  }
1089
- var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret)$/;
1133
+ var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret|token|sessioncode|invitecode|accesscode|sharecode)$/;
1090
1134
  function isCredentialColumn(column) {
1091
1135
  return column !== null && CREDENTIAL_COLUMN.test(column.toLowerCase().replace(/_/g, ""));
1092
1136
  }
@@ -1604,7 +1648,7 @@ function isIdentPart(code) {
1604
1648
  function isDigit(code) {
1605
1649
  return code >= 48 && code <= 57;
1606
1650
  }
1607
- function readQuoted(text, from, quote, backslashEscapes) {
1651
+ function readQuoted(text, from, quote2, backslashEscapes) {
1608
1652
  let value = "";
1609
1653
  let j = from + 1;
1610
1654
  while (j < text.length) {
@@ -1615,9 +1659,9 @@ function readQuoted(text, from, quote, backslashEscapes) {
1615
1659
  j += 2;
1616
1660
  continue;
1617
1661
  }
1618
- if (c === quote) {
1619
- if (text.charAt(j + 1) === quote) {
1620
- value += quote;
1662
+ if (c === quote2) {
1663
+ if (text.charAt(j + 1) === quote2) {
1664
+ value += quote2;
1621
1665
  j += 2;
1622
1666
  continue;
1623
1667
  }
@@ -3302,6 +3346,10 @@ var CREATE_POLICY = new RegExp(
3302
3346
  String.raw`^create\s+policy\s+("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
3303
3347
  "i"
3304
3348
  );
3349
+ var DROP_POLICY = new RegExp(
3350
+ String.raw`^drop\s+policy\s+(?:if\s+exists\s+)?("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
3351
+ "i"
3352
+ );
3305
3353
  function isAppliedSqlFile(rel) {
3306
3354
  return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
3307
3355
  }
@@ -3320,10 +3368,18 @@ function warnDynamicSql(state, rel) {
3320
3368
  const w = `${rel}: a DO block runs dynamic SQL (a loop over a query, or an EXECUTE that is conditional or built from expressions); RLS, policies and privileges it sets are not seen`;
3321
3369
  if (!state.warnings.includes(w)) state.warnings.push(w);
3322
3370
  }
3371
+ var ROLE_NAME = '(?:"[A-Za-z_][A-Za-z0-9_]*"|[A-Za-z_][A-Za-z0-9_]*)';
3372
+ var ROLES = new RegExp(`\\bto\\s+(${ROLE_NAME}(?:\\s*,\\s*${ROLE_NAME})*)`, "i");
3373
+ function headEnd(rest) {
3374
+ const u = /\busing\s*\(/i.exec(rest);
3375
+ const c = /\bwith\s+check\s*\(/i.exec(rest);
3376
+ const ends = [u?.index, c?.index].filter((i) => i !== void 0);
3377
+ return ends.length === 0 ? rest.length : Math.min(...ends);
3378
+ }
3323
3379
  function applyStatement(state, stmt, rel) {
3324
3380
  const cp = CREATE_POLICY.exec(stmt.text);
3325
3381
  if (!cp?.[1] || !cp[4]) {
3326
- applySchemaStatement(state, stmt, rel);
3382
+ dropPolicy(state, stmt) || applySchemaStatement(state, stmt, rel);
3327
3383
  return;
3328
3384
  }
3329
3385
  const t = ensureTable(
@@ -3336,10 +3392,9 @@ function applyStatement(state, stmt, rel) {
3336
3392
  const rest = stmt.text.slice(cp[0].length);
3337
3393
  const cmdMatch = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(rest);
3338
3394
  const command = cmdMatch?.[1]?.toLowerCase() ?? "all";
3339
- const rolesMatch = /\bto\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)/i.exec(
3340
- rest
3341
- );
3342
- const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.toLowerCase()) : [];
3395
+ const head = rest.slice(0, headEnd(rest));
3396
+ const rolesMatch = ROLES.exec(head);
3397
+ const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.replace(/"/g, "").toLowerCase()) : [];
3343
3398
  let using = null;
3344
3399
  let check = null;
3345
3400
  const u = /\busing\s*\(/i.exec(rest);
@@ -3356,6 +3411,17 @@ function applyStatement(state, stmt, rel) {
3356
3411
  location: { file: rel, line: stmt.line }
3357
3412
  });
3358
3413
  }
3414
+ function dropPolicy(state, stmt) {
3415
+ const dp = DROP_POLICY.exec(stmt.text);
3416
+ if (!dp?.[1] || !dp[4]) return false;
3417
+ const name = dp[2] ?? dp[1].replace(/^"|"$/g, "");
3418
+ const t = state.tables.get(qualifiedKey({ schema: dp[3] ?? null, name: dp[4] }));
3419
+ if (t) {
3420
+ t.policies = t.policies.filter((p) => p !== name);
3421
+ t.policyDetails = t.policyDetails.filter((p) => p.name !== name);
3422
+ }
3423
+ return true;
3424
+ }
3359
3425
  function sqlSchemaFor(into) {
3360
3426
  return finishSchema(schemaStateFor(into));
3361
3427
  }
@@ -3816,10 +3882,20 @@ var LOGICAL = /* @__PURE__ */ new Set([
3816
3882
  ts10.SyntaxKind.BarBarToken,
3817
3883
  ts10.SyntaxKind.AmpersandAmpersandToken
3818
3884
  ]);
3885
+ var SCHEMA_PARSE = /^(parse|safeParse|parseAsync|safeParseAsync)$/;
3886
+ function isSchemaParse(call, cx) {
3887
+ const callee = call.expression;
3888
+ if (!ts10.isPropertyAccessExpression(callee) || !SCHEMA_PARSE.test(callee.name.text)) return false;
3889
+ const recv = callee.expression;
3890
+ if (isWholeInput(recv, cx)) return false;
3891
+ return cx.strippingSchema(recv);
3892
+ }
3819
3893
  function isWholeInput(e, cx) {
3820
3894
  const u = unwrap(e);
3821
3895
  if (ts10.isIdentifier(u)) return cx.wholeName(u.text);
3822
3896
  if (ts10.isPropertyAccessExpression(u) || ts10.isElementAccessExpression(u)) {
3897
+ const inner = unwrap(u.expression);
3898
+ if (ts10.isCallExpression(inner) && isSchemaParse(inner, cx)) return false;
3823
3899
  return isWholeInput(u.expression, cx);
3824
3900
  }
3825
3901
  if (ts10.isObjectLiteralExpression(u)) {
@@ -3839,6 +3915,7 @@ function isWholeInput(e, cx) {
3839
3915
  }
3840
3916
  function isWholeCall(call, cx) {
3841
3917
  if (cx.requestBody(call)) return true;
3918
+ if (isSchemaParse(call, cx)) return false;
3842
3919
  const callee = call.expression;
3843
3920
  if (ts10.isPropertyAccessExpression(callee)) {
3844
3921
  const method = callee.name.text;
@@ -3860,7 +3937,8 @@ function callbackKeepsWhole(cb, receiverWhole, cx) {
3860
3937
  const inner = {
3861
3938
  wholeName: (n) => elementNames.has(n) || cx.wholeName(n),
3862
3939
  requestBody: cx.requestBody,
3863
- requestName: cx.requestName
3940
+ requestName: cx.requestName,
3941
+ strippingSchema: cx.strippingSchema
3864
3942
  };
3865
3943
  return ownReturns(f).some((r) => isWholeInput(r, inner));
3866
3944
  }
@@ -4185,6 +4263,7 @@ function usesInput(frame, e) {
4185
4263
  });
4186
4264
  return hit;
4187
4265
  }
4266
+ var CALLER_READ = /\b(?:await\s+)?cookies\(\)\s*(?:\.|$)|cookieStore\s*\.\s*get\s*\(|\bcookies\s*\.\s*get\s*\(|\b(?:await\s+)?headers\(\)\s*\.\s*get\s*\(/;
4188
4267
  var REQUEST_MEMBER = /^(json|formData|text|arrayBuffer|blob|body|headers|cookies|url|nextUrl|query|params|ip|geo)$/;
4189
4268
  var REQUEST_CLIENT_CALLEE = /client|supabase|prisma|drizzle/i;
4190
4269
  function handsOverRequest(frame, call) {
@@ -4253,13 +4332,28 @@ function isRequestBodyCall(frame, call) {
4253
4332
  if (frame.reqNames.has(recv.text)) return true;
4254
4333
  return frame.depth === 0 && frame.reqNames.size === 0 && REQUEST_NAME.test(recv.text);
4255
4334
  }
4256
- function wholeContext(frame) {
4335
+ function wholeContext(p, frame) {
4257
4336
  return {
4258
4337
  wholeName: (n) => frame.wholeNames.has(n),
4259
4338
  requestBody: (c) => isRequestBodyCall(frame, c),
4260
- requestName: (n) => frame.reqNames.has(n)
4339
+ requestName: (n) => frame.reqNames.has(n),
4340
+ strippingSchema: (e) => isStrippingSchema(p, frame, e)
4261
4341
  };
4262
4342
  }
4343
+ var OBJECT_SCHEMA = /\b(?:z|zod|v|valibot|yup)\s*\.\s*object\s*\(/;
4344
+ var SCHEMA_KEEPS_UNKNOWN = /\.\s*(?:passthrough|catchall|nonstrict|unknown)\s*\(/;
4345
+ function isStrippingSchema(p, frame, e) {
4346
+ const u = unwrap(e);
4347
+ if (ts11.isCallExpression(u) || ts11.isPropertyAccessExpression(u)) {
4348
+ const text2 = u.getText();
4349
+ return OBJECT_SCHEMA.test(text2) && !SCHEMA_KEEPS_UNKNOWN.test(text2);
4350
+ }
4351
+ if (!ts11.isIdentifier(u)) return false;
4352
+ const sym = scopeOf(p, frame.facts).get(u.text);
4353
+ if (sym?.kind !== "var") return false;
4354
+ const text = sym.init.getText();
4355
+ return OBJECT_SCHEMA.test(text) && !SCHEMA_KEEPS_UNKNOWN.test(text);
4356
+ }
4263
4357
  function receiverTainted(frame, call) {
4264
4358
  const callee = call.expression;
4265
4359
  if (!ts11.isPropertyAccessExpression(callee) && !ts11.isElementAccessExpression(callee)) return false;
@@ -4314,7 +4408,7 @@ function argBinding(p, arg, frame) {
4314
4408
  client,
4315
4409
  instance,
4316
4410
  tainted: derivedIn(frame, arg),
4317
- whole: isWholeInput(arg, wholeContext(frame)),
4411
+ whole: isWholeInput(arg, wholeContext(p, frame)),
4318
4412
  isRequest
4319
4413
  };
4320
4414
  }
@@ -4444,6 +4538,11 @@ function bindDeclarations(p, frame, acc) {
4444
4538
  continue;
4445
4539
  }
4446
4540
  }
4541
+ if (CALLER_READ.test(text)) {
4542
+ if (frame.depth === 0) handlerInput("header", names, decl);
4543
+ else bindInput(names, false);
4544
+ continue;
4545
+ }
4447
4546
  if (ts11.isCallExpression(init)) {
4448
4547
  const inst = instanceOfCall(p, init, frame, scope);
4449
4548
  if (inst && ts11.isIdentifier(decl.name)) {
@@ -4456,7 +4555,7 @@ function bindDeclarations(p, frame, acc) {
4456
4555
  if (args.some((a) => a.tainted || a.isRequest) || receiverTainted(frame, init)) {
4457
4556
  const rt = returnTaint(p, init, frame, scope, 0);
4458
4557
  if (rt === null || rt.tainted && rt.props === null) {
4459
- bindInput(names, isWholeInput(init, wholeContext(frame)));
4558
+ bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4460
4559
  } else if (rt.tainted && rt.props !== null) {
4461
4560
  if (ts11.isIdentifier(decl.name))
4462
4561
  frame.partialInputs.set(decl.name.text, new Set(rt.props));
@@ -4493,7 +4592,7 @@ function bindDeclarations(p, frame, acc) {
4493
4592
  } else if (partial) bindPatternFrom(frame, decl.name, partial);
4494
4593
  }
4495
4594
  } else if (!isChainWithQuery(init) && derivedIn(frame, init)) {
4496
- bindInput(names, isWholeInput(init, wholeContext(frame)));
4595
+ bindInput(names, isWholeInput(init, wholeContext(p, frame)));
4497
4596
  }
4498
4597
  }
4499
4598
  if (frame.depth === 0) {
@@ -4597,7 +4696,7 @@ function analyzeFrame(p, frame, acc) {
4597
4696
  const payloadOf = (arg) => arg ? {
4598
4697
  text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
4599
4698
  inputDerived: derivedIn(frame, arg),
4600
- wholeInput: isWholeInput(arg, wholeContext(frame))
4699
+ wholeInput: isWholeInput(arg, wholeContext(p, frame))
4601
4700
  } : null;
4602
4701
  const storageHandles = storageBindingsIn(body);
4603
4702
  let callerScope = null;
@@ -4842,7 +4941,7 @@ function childFrame(p, call, target, frame) {
4842
4941
  pathPos: [...frame.pathPos, call.getStart(frame.sf)],
4843
4942
  exitPropagates: frame.exitPropagates && callResultChecked(call)
4844
4943
  };
4845
- const cx = wholeContext(frame);
4944
+ const cx = wholeContext(p, frame);
4846
4945
  target.fn.parameters.forEach((param, i) => {
4847
4946
  const arg = call.arguments[i];
4848
4947
  const ab = argBinding(p, arg, frame);
@@ -5135,6 +5234,51 @@ function calleePath(e) {
5135
5234
  if (ts11.isElementAccessExpression(u)) return `${calleePath(u.expression)}[]`;
5136
5235
  return "";
5137
5236
  }
5237
+ var EMPTY_GUARDS = { authChecks: [], roleChecks: [] };
5238
+ function layoutGuards(p, pageRel, cache) {
5239
+ const parts = pageRel.split("/");
5240
+ parts.pop();
5241
+ const out = { authChecks: [], roleChecks: [] };
5242
+ for (let i = parts.length; i > 0; i--) {
5243
+ const g = layoutGuardsFor(p, parts.slice(0, i).join("/"), cache);
5244
+ out.authChecks.push(...g.authChecks);
5245
+ out.roleChecks.push(...g.roleChecks);
5246
+ }
5247
+ return out;
5248
+ }
5249
+ var LAYOUT_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
5250
+ function layoutGuardsFor(p, dir, cache) {
5251
+ const hit = cache.get(dir);
5252
+ if (hit) return hit;
5253
+ let guards = EMPTY_GUARDS;
5254
+ for (const ext of LAYOUT_EXTENSIONS) {
5255
+ const rel = `${dir}/layout.${ext}`;
5256
+ const sf = p.sources.get(rel);
5257
+ const facts = p.registry.get(rel);
5258
+ if (!sf || !facts || isClientComponentFile(sf)) continue;
5259
+ const fn = pageHandlerIn(sf);
5260
+ if (!fn) continue;
5261
+ try {
5262
+ const analysed = analyzeHandler(p, {
5263
+ rel,
5264
+ sf,
5265
+ facts,
5266
+ kind: "page",
5267
+ route: dir,
5268
+ method: "PAGE",
5269
+ fn: fn.fn,
5270
+ node: fn.node,
5271
+ wrapper: fn.wrapper
5272
+ });
5273
+ guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
5274
+ } catch {
5275
+ guards = EMPTY_GUARDS;
5276
+ }
5277
+ break;
5278
+ }
5279
+ cache.set(dir, guards);
5280
+ return guards;
5281
+ }
5138
5282
  function analyzeHandler(p, h) {
5139
5283
  const { rel, sf, fn } = h;
5140
5284
  const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
@@ -5343,6 +5487,15 @@ function parseProject(rootInput, opts = {}) {
5343
5487
  storageBuckets: schema.storageBuckets
5344
5488
  };
5345
5489
  }
5490
+ var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
5491
+ function layoutCacheOf(p) {
5492
+ let m = LAYOUT_CACHE.get(p);
5493
+ if (!m) {
5494
+ m = /* @__PURE__ */ new Map();
5495
+ LAYOUT_CACHE.set(p, m);
5496
+ }
5497
+ return m;
5498
+ }
5346
5499
  function analyzeFile(project, rel, sf, facts, out) {
5347
5500
  const { routes, exposures, fileIgnores } = out;
5348
5501
  {
@@ -5401,6 +5554,11 @@ function analyzeFile(project, rel, sf, facts, out) {
5401
5554
  node: handler.node,
5402
5555
  wrapper: handler.wrapper
5403
5556
  });
5557
+ const guards = layoutGuards(project, rel, layoutCacheOf(project));
5558
+ analysed.authChecks.push(...guards.authChecks);
5559
+ if (guards.roleChecks.length > 0) {
5560
+ analysed.roleChecks = [...analysed.roleChecks ?? [], ...guards.roleChecks];
5561
+ }
5404
5562
  if (analysed.queries.length > 0 || analysed.inputs.length > 0) routes.push(analysed);
5405
5563
  }
5406
5564
  }
@@ -6074,6 +6232,253 @@ var supabaseAuthorizationPack = [
6074
6232
  rlsPolicyWithoutCallerPredicate
6075
6233
  ];
6076
6234
 
6235
+ // packages/rules/src/packs/supabase-sql-policies.ts
6236
+ var DATA_API = "Supabase Data API (PostgREST)";
6237
+ function locations2(...refs) {
6238
+ const out = [];
6239
+ for (const r of refs) {
6240
+ if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
6241
+ }
6242
+ return out;
6243
+ }
6244
+ function finding2(ctx, rule, body, severity) {
6245
+ return {
6246
+ id: ctx.nextId(),
6247
+ ruleId: rule.id,
6248
+ status: "likely",
6249
+ severity: severity ?? rule.severity,
6250
+ confidence: rule.confidence,
6251
+ cwe: rule.cwe,
6252
+ createdAt: ctx.now,
6253
+ updatedAt: ctx.now,
6254
+ ...body
6255
+ };
6256
+ }
6257
+ function publicTables(ctx) {
6258
+ return ctx.model.tables.filter((t) => !t.table.includes("."));
6259
+ }
6260
+ function commandLabel(p) {
6261
+ return p.command === "all" ? "for all commands" : `for ${p.command}`;
6262
+ }
6263
+ function roleLabel(p) {
6264
+ return p.roles.length === 0 ? "public (no TO clause)" : p.roles.join(", ");
6265
+ }
6266
+ function quote(expr, max = 200) {
6267
+ if (expr === null) return "(none)";
6268
+ const one = expr.replace(/\s+/g, " ").trim();
6269
+ return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
6270
+ }
6271
+ var USER_METADATA = /\buser_metadata\b/i;
6272
+ var RAW_USER_META = /\braw_user_meta_data\b/i;
6273
+ var JWT_SOURCE = /auth\s*\.\s*jwt\s*\(|request\.jwt\.claim/i;
6274
+ var AUTH_USERS = /\bauth\s*\.\s*users\b/i;
6275
+ function readsSelfWrittenMetadata(expr) {
6276
+ if (USER_METADATA.test(expr) && JWT_SOURCE.test(expr)) return true;
6277
+ return RAW_USER_META.test(expr) && AUTH_USERS.test(expr);
6278
+ }
6279
+ var rlsPolicyTrustsUserMetadata = {
6280
+ id: "supabase.rls-policy-trusts-user-metadata",
6281
+ title: "RLS policy reads user_metadata from the JWT",
6282
+ description: "A policy decides access with a claim under user_metadata (or raw_user_meta_data). Any signed-in user can write user_metadata with updateUser({ data }), and the claim lands in their next JWT without validation, so the policy grants itself. Move the claim to app_metadata, which only the service role writes, or read a roles table joined on auth.uid().",
6283
+ severity: "critical",
6284
+ confidence: 0.9,
6285
+ cwe: ["CWE-602", "CWE-863"],
6286
+ evaluate(ctx) {
6287
+ const out = [];
6288
+ for (const t of publicTables(ctx)) {
6289
+ for (const p of t.policyDetails) {
6290
+ const where2 = [];
6291
+ if (p.using !== null && readsSelfWrittenMetadata(p.using)) where2.push(["USING", p.using]);
6292
+ if (p.check !== null && readsSelfWrittenMetadata(p.check))
6293
+ where2.push(["WITH CHECK", p.check]);
6294
+ if (where2.length === 0) continue;
6295
+ const clause = where2.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
6296
+ const evidence = [
6297
+ {
6298
+ kind: "rule",
6299
+ summary: `Policy "${p.name}" on public.${t.table} (${commandLabel(p)}, to ${roleLabel(p)}) decides access from user_metadata: ${clause}. A signed-in user sets user_metadata themselves with supabase.auth.updateUser({ data: { ... } }); the value is copied into their next access token without any check, so the caller can grant themselves whatever this predicate asks for. app_metadata cannot be written this way, and a roles table joined on auth.uid() cannot either.`,
6300
+ locations: locations2(p.location, t.location),
6301
+ // No `deterministic: true`: a critical deterministic finding blocks the GitHub
6302
+ // check, and this rule has not been seen on a real repository yet (zero hits on the
6303
+ // 26-repository corpus of 13 Sept 2026). It blocks once measured, not before.
6304
+ data: {
6305
+ ruleId: this.id,
6306
+ table: t.table,
6307
+ policy: p.name,
6308
+ command: p.command
6309
+ }
6310
+ },
6311
+ {
6312
+ kind: "trace",
6313
+ summary: [
6314
+ "Any signed-in user",
6315
+ 'auth.updateUser({ data: { role: "admin" } })',
6316
+ "the claim is signed into the next JWT",
6317
+ `policy "${p.name}" reads it from user_metadata`,
6318
+ `public.${t.table} (${commandLabel(p)})`
6319
+ ].join(" -> ")
6320
+ }
6321
+ ];
6322
+ out.push(
6323
+ finding2(ctx, this, {
6324
+ title: `Policy "${p.name}" on "${t.table}" trusts user_metadata`,
6325
+ entrypoints: [DATA_API],
6326
+ sources: ["jwt:user_metadata"],
6327
+ sinks: [`supabase.policy:public.${t.table}`],
6328
+ path: [
6329
+ "Any signed-in user",
6330
+ "user_metadata written by the user",
6331
+ `policy "${p.name}"`,
6332
+ `public.${t.table}`
6333
+ ],
6334
+ evidence
6335
+ })
6336
+ );
6337
+ }
6338
+ }
6339
+ return out;
6340
+ }
6341
+ };
6342
+ var policiesWithoutRlsEnabled = {
6343
+ id: "supabase.policies-without-rls-enabled",
6344
+ title: "Policies exist but RLS is never enabled on the table",
6345
+ description: "A migration writes policies for a table but never runs `alter table ... enable row level security`, so the policies have no effect and the table stays fully readable and writable through the Data API. Every reviewer who sees the policies assumes the table is protected. Supabase's own lint (0007) reports the same shape.",
6346
+ severity: "high",
6347
+ confidence: 0.9,
6348
+ cwe: ["CWE-284"],
6349
+ evaluate(ctx) {
6350
+ const out = [];
6351
+ for (const t of publicTables(ctx)) {
6352
+ if (t.rlsEnabled || t.policyDetails.length === 0) continue;
6353
+ const names = t.policyDetails.map((p) => `"${p.name}" (${commandLabel(p)})`).join(", ");
6354
+ const evidence = [
6355
+ {
6356
+ kind: "rule",
6357
+ summary: `public.${t.table} has ${t.policyDetails.length} ${t.policyDetails.length === 1 ? "policy" : "policies"} \u2014 ${names} \u2014 and no "alter table public.${t.table} enable row level security" anywhere in the migrations. Policies only apply to a table with RLS on, so every one of them is dead and the table is fully readable and writable by anyone holding the public anon key. The policies say what the intent was, which is what makes this a defect rather than a choice.`,
6358
+ locations: locations2(t.location, t.policyDetails[0]?.location),
6359
+ data: {
6360
+ deterministic: true,
6361
+ ruleId: this.id,
6362
+ table: t.table,
6363
+ policies: t.policyDetails.map((p) => p.name)
6364
+ }
6365
+ },
6366
+ {
6367
+ kind: "trace",
6368
+ summary: [
6369
+ "Anyone with the public anon key",
6370
+ "PostgREST",
6371
+ `public.${t.table} (RLS never enabled)`,
6372
+ `${t.policyDetails.length} policies that never run`
6373
+ ].join(" -> ")
6374
+ }
6375
+ ];
6376
+ out.push(
6377
+ finding2(ctx, this, {
6378
+ title: `Table "${t.table}" has policies but RLS is off`,
6379
+ entrypoints: [DATA_API],
6380
+ sources: ["anon-key"],
6381
+ sinks: [`supabase.select:public.${t.table}`],
6382
+ path: [
6383
+ "Anyone with the public anon key",
6384
+ "PostgREST",
6385
+ `public.${t.table} (RLS off, policies inert)`
6386
+ ],
6387
+ evidence
6388
+ })
6389
+ );
6390
+ }
6391
+ return out;
6392
+ }
6393
+ };
6394
+ function isTautology(expr) {
6395
+ if (expr === null) return false;
6396
+ return /^\(*\s*true\s*\)*$/i.test(expr.trim());
6397
+ }
6398
+ var ANON_ROLES = /* @__PURE__ */ new Set(["anon", "public"]);
6399
+ function reachableByAnon(p) {
6400
+ if (p.roles.length === 0) return true;
6401
+ return p.roles.some((r) => ANON_ROLES.has(r.toLowerCase().replace(/^"|"$/g, "")));
6402
+ }
6403
+ var WRITE_COMMANDS = /* @__PURE__ */ new Set(["insert", "update", "delete", "all"]);
6404
+ var anonWritePolicy = {
6405
+ id: "supabase.anon-write-policy",
6406
+ title: "Write policy open to anon or every role",
6407
+ description: "An insert, update or delete policy targets anon (or omits the TO clause, which means PUBLIC) and decides with `true`. Anyone holding the public anon key writes the table directly through PostgREST, including rows that belong to signed-in users, whether or not the application has a form for it.",
6408
+ severity: "high",
6409
+ confidence: 0.85,
6410
+ cwe: ["CWE-284", "CWE-862"],
6411
+ evaluate(ctx) {
6412
+ const out = [];
6413
+ for (const t of publicTables(ctx)) {
6414
+ if (!t.rlsEnabled) continue;
6415
+ for (const p of t.policyDetails) {
6416
+ if (!WRITE_COMMANDS.has(p.command) || !reachableByAnon(p)) continue;
6417
+ const decides = p.command === "insert" ? [["WITH CHECK", p.check]] : p.command === "all" ? [
6418
+ ["USING", p.using],
6419
+ ["WITH CHECK", p.check]
6420
+ ] : [["USING", p.using]];
6421
+ const open = decides.filter(([, expr]) => isTautology(expr));
6422
+ if (open.length === 0) continue;
6423
+ const insertOnly = p.command === "insert";
6424
+ const severity = insertOnly ? "medium" : "high";
6425
+ const clause = open.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
6426
+ const verbs = p.command === "all" ? "insert, update and delete" : `${p.command} rows in`;
6427
+ const evidence = [
6428
+ {
6429
+ kind: "rule",
6430
+ summary: `Policy "${p.name}" on public.${t.table} is ${commandLabel(p)} to ${roleLabel(p)} and decides with a tautology: ${clause}. Anyone holding the public anon key can ${verbs} public.${t.table} straight through PostgREST, without going through this application.${insertOnly ? " An insert-only policy is how deliberate public forms are written, so this is reported as medium: check that the table is meant to accept rows from strangers and that a rate limit and a validation trigger exist." : " Rows that belong to signed-in users can be changed or deleted by a stranger."}`,
6431
+ locations: locations2(p.location, t.location),
6432
+ data: {
6433
+ deterministic: true,
6434
+ ruleId: this.id,
6435
+ table: t.table,
6436
+ policy: p.name,
6437
+ command: p.command,
6438
+ roles: p.roles
6439
+ }
6440
+ },
6441
+ {
6442
+ kind: "trace",
6443
+ summary: [
6444
+ "Anyone with the public anon key",
6445
+ "PostgREST",
6446
+ `policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)}, ${clause})`,
6447
+ `public.${t.table}`
6448
+ ].join(" -> ")
6449
+ }
6450
+ ];
6451
+ out.push(
6452
+ finding2(
6453
+ ctx,
6454
+ this,
6455
+ {
6456
+ title: `Policy "${p.name}" lets anyone ${p.command === "all" ? "write" : p.command} "${t.table}"`,
6457
+ entrypoints: [DATA_API],
6458
+ sources: ["anon-key"],
6459
+ sinks: [`supabase.${p.command === "all" ? "insert" : p.command}:public.${t.table}`],
6460
+ path: [
6461
+ "Anyone with the public anon key",
6462
+ "PostgREST",
6463
+ `policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)})`,
6464
+ `public.${t.table}`
6465
+ ],
6466
+ evidence
6467
+ },
6468
+ severity
6469
+ )
6470
+ );
6471
+ }
6472
+ }
6473
+ return out;
6474
+ }
6475
+ };
6476
+ var supabaseSqlPoliciesPack = [
6477
+ rlsPolicyTrustsUserMetadata,
6478
+ policiesWithoutRlsEnabled,
6479
+ anonWritePolicy
6480
+ ];
6481
+
6077
6482
  // packages/rules/src/packs/supabase-storage-rpc.ts
6078
6483
  function reaches(ctx) {
6079
6484
  const out = [];
@@ -6096,7 +6501,7 @@ function reaches(ctx) {
6096
6501
  }
6097
6502
  return out;
6098
6503
  }
6099
- function locations2(...refs) {
6504
+ function locations3(...refs) {
6100
6505
  const out = [];
6101
6506
  for (const r of refs) {
6102
6507
  if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
@@ -6106,7 +6511,7 @@ function locations2(...refs) {
6106
6511
  function unique(values) {
6107
6512
  return [...new Set(values)];
6108
6513
  }
6109
- function finding2(ctx, rule, body, severity) {
6514
+ function finding3(ctx, rule, body, severity) {
6110
6515
  return {
6111
6516
  id: ctx.nextId(),
6112
6517
  ruleId: rule.id,
@@ -6162,7 +6567,7 @@ var storageObjectAccessWithoutOwnerScope = {
6162
6567
  {
6163
6568
  kind: "rule",
6164
6569
  summary: `storage.from(${s.bucket === null ? "\u2026" : `"${s.bucket}"`}).${s.op}(${s.pathText}) runs through ${client}, a service-role client, so storage policies on storage.objects do not apply. The object path comes from the request and is neither prefixed with nor checked against the caller's user id, so any signed-in user can ${verb} any object in ${bucketLabel(s.bucket)}, including other users' files. The handler authenticates the caller but never ties the path to them.${via}`,
6165
- locations: locations2(r.handler.location, r.query.location, r.client?.location),
6570
+ locations: locations3(r.handler.location, r.query.location, r.client?.location),
6166
6571
  data: {
6167
6572
  deterministic: false,
6168
6573
  ruleId: this.id,
@@ -6175,7 +6580,7 @@ var storageObjectAccessWithoutOwnerScope = {
6175
6580
  { kind: "trace", summary: path.join(" -> ") }
6176
6581
  ];
6177
6582
  out.push(
6178
- finding2(ctx, this, {
6583
+ finding3(ctx, this, {
6179
6584
  title: `Cross-user storage ${s.op} in ${bucketLabel(s.bucket)} via service-role client`,
6180
6585
  entrypoints: [r.handlerData.entry],
6181
6586
  sources: r.inputs.map((i) => `${i.kind}:${i.name}`),
@@ -6282,7 +6687,7 @@ var storagePolicyWithoutOwnerCheck = {
6282
6687
  ];
6283
6688
  const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
6284
6689
  out.push(
6285
- finding2(ctx, this, {
6690
+ finding3(ctx, this, {
6286
6691
  title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
6287
6692
  entrypoints: [...entries, storageEntry],
6288
6693
  sources: unique([
@@ -6295,7 +6700,7 @@ var storagePolicyWithoutOwnerCheck = {
6295
6700
  {
6296
6701
  kind: "rule",
6297
6702
  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.`,
6298
- locations: locations2(p.location, ...reached.map((r) => r.query.location)),
6703
+ locations: locations3(p.location, ...reached.map((r) => r.query.location)),
6299
6704
  data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
6300
6705
  },
6301
6706
  { kind: "trace", summary: path.join(" -> ") }
@@ -6343,7 +6748,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
6343
6748
  "no auth.uid() / auth.jwt() check"
6344
6749
  ];
6345
6750
  out.push(
6346
- finding2(
6751
+ finding3(
6347
6752
  ctx,
6348
6753
  this,
6349
6754
  {
@@ -6359,7 +6764,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
6359
6764
  {
6360
6765
  kind: "rule",
6361
6766
  summary: `public.${fn.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.`,
6362
- locations: locations2(
6767
+ locations: locations3(
6363
6768
  fn.location,
6364
6769
  ...sites.flatMap((s) => [s.handler.location, s.query.location])
6365
6770
  ),
@@ -6412,9 +6817,9 @@ var PUBLIC_TABLE_RULES = /* @__PURE__ */ new Set([
6412
6817
  "supabase.rls-policy-without-caller-predicate"
6413
6818
  ]);
6414
6819
  var READ_SINK = /^supabase\.select:public\.(.+)$/;
6415
- function applyPublicTables(findings, publicTables, now) {
6416
- if (publicTables.length === 0) return findings;
6417
- const declared = new Set(publicTables.map((t) => t.toLowerCase()));
6820
+ function applyPublicTables(findings, publicTables2, now) {
6821
+ if (publicTables2.length === 0) return findings;
6822
+ const declared = new Set(publicTables2.map((t) => t.toLowerCase()));
6418
6823
  return findings.map((f) => {
6419
6824
  if (f.status === "suppressed" || !PUBLIC_TABLE_RULES.has(f.ruleId)) return f;
6420
6825
  if (f.sinks.length === 0) return f;
@@ -6462,7 +6867,8 @@ function applySuppressions(findings, model, now) {
6462
6867
  // packages/rules/src/index.ts
6463
6868
  var defaultRules = [
6464
6869
  ...supabaseAuthorizationPack,
6465
- ...supabaseStorageRpcPack
6870
+ ...supabaseStorageRpcPack,
6871
+ ...supabaseSqlPoliciesPack
6466
6872
  ];
6467
6873
 
6468
6874
  // packages/scanner/src/config.ts
@@ -6495,14 +6901,14 @@ function loadAuditConfig(root) {
6495
6901
  const obj = raw;
6496
6902
  const ignore = strings(obj.ignore);
6497
6903
  const migrations = strings(obj.migrations);
6498
- const publicTables = publicTableNames(obj.publicTables);
6904
+ const publicTables2 = publicTableNames(obj.publicTables);
6499
6905
  return {
6500
6906
  config: {
6501
6907
  ...ignore ? { ignore } : {},
6502
6908
  ...migrations ? { migrations } : {},
6503
- ...publicTables.tables ? { publicTables: publicTables.tables } : {}
6909
+ ...publicTables2.tables ? { publicTables: publicTables2.tables } : {}
6504
6910
  },
6505
- warnings: publicTables.warnings
6911
+ warnings: publicTables2.warnings
6506
6912
  };
6507
6913
  }
6508
6914
  function publicTableNames(value) {
@@ -6617,7 +7023,7 @@ function ensureDirectory(path) {
6617
7023
  }
6618
7024
  if (!isDirectory) throw new ScanError("path_not_found", path, `${path} is not a directory`);
6619
7025
  }
6620
- function summarize(model, rules, publicTables = []) {
7026
+ function summarize(model, rules, publicTables2 = []) {
6621
7027
  const apiTables = model.tables.filter((t) => !t.table.includes("."));
6622
7028
  return {
6623
7029
  root: model.root,
@@ -6628,7 +7034,7 @@ function summarize(model, rules, publicTables = []) {
6628
7034
  tablesWithRls: apiTables.filter((t) => t.rlsEnabled).length,
6629
7035
  rules,
6630
7036
  warnings: model.warnings,
6631
- publicTables: [...publicTables]
7037
+ publicTables: [...publicTables2]
6632
7038
  };
6633
7039
  }
6634
7040
  function runScan(path, opts = {}) {
@@ -6642,11 +7048,11 @@ function runScan(path, opts = {}) {
6642
7048
  ...ignore.globs.length > 0 ? { ignore: ignore.globs } : {}
6643
7049
  });
6644
7050
  const graph = buildGraph(model);
6645
- const publicTables = cfg.config.publicTables ?? [];
6646
- const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables };
7051
+ const publicTables2 = cfg.config.publicTables ?? [];
7052
+ const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
6647
7053
  const findings = runRules(defaultRules, model, graph, runOpts);
6648
7054
  const coverage = summarizeCoverage(findings);
6649
- const summary = summarize(model, defaultRules.length, publicTables);
7055
+ const summary = summarize(model, defaultRules.length, publicTables2);
6650
7056
  return {
6651
7057
  summary: {
6652
7058
  ...summary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditai-scan",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",