canship 0.4.0 → 0.5.0

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.
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { resolve as resolve2 } from "path";
8
8
  var IRRELEVANT_HOSTS = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|.*\.?example\.(?:com|org|net)|.*\.(?:test|invalid|localhost))$/i;
9
9
  var JWT_SOURCE = String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`;
10
10
  var SB_SECRET_SOURCE = String.raw`sb_secret_[A-Za-z0-9_-]{8,}`;
11
+ var PRIVATE_KEY_BEGIN_SOURCE = String.raw`-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----`;
11
12
  var SECRET_PATTERNS = [
12
13
  {
13
14
  id: "openai",
@@ -133,7 +134,7 @@ var SECRET_PATTERNS = [
133
134
  id: "private-key",
134
135
  name: "Private key file contents",
135
136
  rotateLabel: "private key",
136
- pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
137
+ pattern: new RegExp(PRIVATE_KEY_BEGIN_SOURCE, "g"),
137
138
  impact: "A private key in source code can be used to impersonate your server, decrypt traffic, or log into your machines."
138
139
  },
139
140
  {
@@ -214,13 +215,29 @@ function redactSecret(secret) {
214
215
  return `${head}\u2026(${secret.length} chars)\u2026${tail}`;
215
216
  }
216
217
  function redactLine(line, secret) {
217
- const trimmed = line.trim();
218
+ const trimmed = redactPrivateKeys(line.trim());
218
219
  if (!secret) return trimmed;
219
220
  return trimmed.split(secret).join(redactSecret(secret));
220
221
  }
221
222
  var JWT_SHAPED = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
223
+ function redactPrivateKeys(text) {
224
+ const starts = new RegExp(PRIVATE_KEY_BEGIN_SOURCE, "g");
225
+ const parts = [];
226
+ let from = 0;
227
+ let match;
228
+ while ((match = starts.exec(text)) !== null) {
229
+ const closing = match[0].replace("BEGIN ", "END ");
230
+ const end = text.indexOf(closing, starts.lastIndex);
231
+ parts.push(text.slice(from, match.index), "[REDACTED PRIVATE KEY]");
232
+ from = end === -1 ? text.length : end + closing.length;
233
+ starts.lastIndex = from;
234
+ if (end === -1) break;
235
+ }
236
+ parts.push(text.slice(from));
237
+ return parts.join("");
238
+ }
222
239
  function redactAll(text) {
223
- let out = text;
240
+ let out = redactPrivateKeys(text);
224
241
  for (const pat of SECRET_PATTERNS) {
225
242
  const re = new RegExp(pat.pattern.source, pat.pattern.flags);
226
243
  out = out.replace(re, (match) => redactSecret(match));
@@ -235,8 +252,8 @@ function truncate(s, max = 120) {
235
252
  import { basename as basename2 } from "path";
236
253
 
237
254
  // src/walker.ts
238
- import { readdirSync, readFileSync as readFileSync2, statSync as statSync2, lstatSync as lstatSync2, openSync, readSync, closeSync } from "fs";
239
- import { join as join2, relative as relative2, sep as sep2, extname, basename } from "path";
255
+ import { opendirSync, readFileSync as readFileSync2, statSync as statSync2, lstatSync as lstatSync2, realpathSync as realpathSync2, openSync, readSync, closeSync } from "fs";
256
+ import { join as join2, relative as relative2, isAbsolute as isAbsolute2, sep as sep2, extname, basename } from "path";
240
257
 
241
258
  // src/git.ts
242
259
  import { execFileSync } from "child_process";
@@ -473,6 +490,7 @@ function execGitBatch(executable, root, args, input, options = {}) {
473
490
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
474
491
  var MAX_SCAN_BYTES = 128 * 1024 * 1024;
475
492
  var MAX_SCAN_FILES = 1e4;
493
+ var MAX_WALK_ENTRIES = 5e4;
476
494
  var MAX_WALK_DEPTH = 16;
477
495
  var SKIP_DIRS = /* @__PURE__ */ new Set([
478
496
  "node_modules",
@@ -649,16 +667,16 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
649
667
  ".sqlite3",
650
668
  ".mo"
651
669
  ]);
652
- function probeFileType(absPath) {
670
+ function probeFileType(absPath, limit) {
653
671
  let fd = null;
654
672
  try {
655
673
  fd = openSync(absPath, "r");
656
- const buf = Buffer.alloc(PROBE_BYTES);
657
- const read = readSync(fd, buf, 0, PROBE_BYTES, 0);
674
+ const buf = Buffer.alloc(limit);
675
+ const read = readSync(fd, buf, 0, limit, 0);
658
676
  const head = buf.subarray(0, read);
659
677
  const hasTextBom = head.length >= 2 && head[0] === 255 && head[1] === 254 || head.length >= 2 && head[0] === 254 && head[1] === 255 || head.length >= 3 && head[0] === 239 && head[1] === 187 && head[2] === 191;
660
- if (hasTextBom) return { kind: "text" };
661
- return head.includes(0) ? { kind: "binary" } : { kind: "text" };
678
+ if (hasTextBom) return { kind: "text", bytes: read };
679
+ return { kind: head.includes(0) ? "binary" : "text", bytes: read };
662
680
  } catch (err) {
663
681
  return {
664
682
  kind: "unreadable",
@@ -731,10 +749,13 @@ function listViaGit(root, gitExecutable) {
731
749
  return null;
732
750
  }
733
751
  }
734
- function walkTree(root, skipped, wantAll) {
752
+ function walkTree(root, skipped, wantAll, maxEntries) {
735
753
  const all = [];
736
754
  const found = [];
755
+ let visited = 0;
756
+ let exhausted = false;
737
757
  const walk = (dir, depth) => {
758
+ if (exhausted) return;
738
759
  if (depth > MAX_WALK_DEPTH) {
739
760
  skipped.push({
740
761
  path: relative2(root, dir).split(sep2).join("/") || ".",
@@ -745,7 +766,7 @@ function walkTree(root, skipped, wantAll) {
745
766
  }
746
767
  let entries;
747
768
  try {
748
- entries = readdirSync(dir, { withFileTypes: true });
769
+ entries = opendirSync(dir);
749
770
  } catch (err) {
750
771
  if (!isMissing(err)) {
751
772
  skipped.push({
@@ -756,31 +777,57 @@ function walkTree(root, skipped, wantAll) {
756
777
  }
757
778
  return;
758
779
  }
759
- for (const entry of entries) {
760
- const full = join2(dir, entry.name);
761
- const rel = relative2(root, full).split(sep2).join("/");
762
- if (entry.isSymbolicLink()) {
763
- if (!SKIP_DIRS.has(entry.name)) {
764
- skipped.push({ path: rel, reason: "symlink", detail: "symbolic links are not followed" });
780
+ try {
781
+ while (!exhausted) {
782
+ const entry = entries.readSync();
783
+ if (entry === null) break;
784
+ if (visited >= maxEntries) {
785
+ exhausted = true;
786
+ skipped.push({
787
+ path: relative2(root, dir).split(sep2).join("/") || ".",
788
+ reason: "directory-unreadable",
789
+ detail: `directory entry budget exceeded (${maxEntries} entries)`
790
+ });
791
+ break;
765
792
  }
766
- continue;
767
- }
768
- if (entry.isDirectory()) {
769
- if (SKIP_DIRS.has(entry.name)) continue;
770
- walk(full, depth + 1);
771
- continue;
772
- }
773
- if (!entry.isFile()) continue;
774
- if (wantAll) all.push(rel);
775
- let isCandidate = isEnvFile(entry.name) || CREDENTIAL_FILENAMES.has(entry.name) || CREDENTIAL_EXTENSIONS.has(extname(entry.name).toLowerCase());
776
- if (!isCandidate && isWorthProbing(rel)) {
777
- const probe = probeFileType(full);
778
- if (probe.kind === "text") isCandidate = true;
779
- else if (probe.kind === "unreadable") {
780
- skipped.push({ path: rel, reason: "unreadable", detail: probe.detail });
793
+ visited++;
794
+ const full = join2(dir, entry.name);
795
+ const rel = relative2(root, full).split(sep2).join("/");
796
+ let metadata;
797
+ try {
798
+ metadata = lstatSync2(full);
799
+ } catch (err) {
800
+ if (!isMissing(err)) skipped.push({
801
+ path: rel,
802
+ reason: "unreadable",
803
+ detail: String(err instanceof Error ? err.message : err)
804
+ });
805
+ continue;
806
+ }
807
+ if (metadata.isSymbolicLink()) {
808
+ if (!SKIP_DIRS.has(entry.name)) {
809
+ skipped.push({ path: rel, reason: "symlink", detail: "symbolic links are not followed" });
810
+ }
811
+ continue;
812
+ }
813
+ if (metadata.isDirectory()) {
814
+ if (SKIP_DIRS.has(entry.name)) continue;
815
+ walk(full, depth + 1);
816
+ continue;
781
817
  }
818
+ if (!metadata.isFile()) continue;
819
+ if (wantAll) all.push(rel);
820
+ const isCandidate = isEnvFile(entry.name) || CREDENTIAL_FILENAMES.has(entry.name) || CREDENTIAL_EXTENSIONS.has(extname(entry.name).toLowerCase()) || isWorthProbing(rel);
821
+ if (isCandidate) found.push(rel);
782
822
  }
783
- if (isCandidate) found.push(rel);
823
+ } catch (err) {
824
+ if (!isMissing(err)) skipped.push({
825
+ path: relative2(root, dir).split(sep2).join("/") || ".",
826
+ reason: "directory-unreadable",
827
+ detail: String(err instanceof Error ? err.message : err)
828
+ });
829
+ } finally {
830
+ entries.closeSync();
784
831
  }
785
832
  };
786
833
  walk(root, 0);
@@ -824,11 +871,41 @@ function isExampleContext(relPath) {
824
871
  if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
825
872
  return false;
826
873
  }
874
+ function readablePath(root, path, skipped) {
875
+ const rel = relative2(root, path);
876
+ if (!rel || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
877
+ skipped.push({ path: ".", reason: "unreadable", detail: "candidate path is outside the scan root" });
878
+ return false;
879
+ }
880
+ let current = root;
881
+ for (const segment of rel.split(sep2)) {
882
+ current = join2(current, segment);
883
+ const stat = lstatSync2(current);
884
+ if (stat.isSymbolicLink()) {
885
+ const linked = relative2(root, current).split(sep2).join("/");
886
+ if (!skipped.some((item) => item.path === linked && item.reason === "symlink")) {
887
+ skipped.push({ path: linked, reason: "symlink", detail: "symbolic links are not followed" });
888
+ }
889
+ return false;
890
+ }
891
+ }
892
+ const real = relative2(root, realpathSync2(path));
893
+ if (real === ".." || real.startsWith(`..${sep2}`) || isAbsolute2(real)) {
894
+ skipped.push({ path: rel.split(sep2).join("/"), reason: "unreadable", detail: "resolved path is outside the scan root" });
895
+ return false;
896
+ }
897
+ if (!lstatSync2(path).isFile()) {
898
+ skipped.push({ path: rel.split(sep2).join("/"), reason: "unreadable", detail: "not a regular file" });
899
+ return false;
900
+ }
901
+ return true;
902
+ }
827
903
  function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
904
+ root = realpathSync2(root);
828
905
  const skipped = [];
829
906
  const ignored = [];
830
907
  const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
831
- const walked = walkTree(root, skipped, fromGit === null);
908
+ const walked = walkTree(root, skipped, fromGit === null, limits.maxEntries ?? MAX_WALK_ENTRIES);
832
909
  const listed = fromGit?.files ?? walked.all;
833
910
  const candidates = /* @__PURE__ */ new Set();
834
911
  let vendored = 0;
@@ -862,14 +939,10 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
862
939
  const absPath = join2(root, relPath);
863
940
  let content;
864
941
  try {
865
- if (lstatSync2(absPath).isSymbolicLink()) {
866
- if (!skipped.some((entry) => entry.path === relPath && entry.reason === "symlink")) {
867
- skipped.push({ path: relPath, reason: "symlink", detail: "symbolic links are not followed" });
868
- }
869
- continue;
870
- }
942
+ if (!readablePath(root, absPath, skipped)) continue;
871
943
  const size = statSync2(absPath).size;
872
- if (size > MAX_FILE_BYTES) {
944
+ const needsProbe = isWorthProbing(relPath);
945
+ if (!needsProbe && size > MAX_FILE_BYTES) {
873
946
  skipped.push({
874
947
  path: relPath,
875
948
  reason: "too-large",
@@ -877,7 +950,8 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
877
950
  });
878
951
  continue;
879
952
  }
880
- if (filesRead >= maxFiles || bytesRead + size > maxBytes) {
953
+ const initialBytes = needsProbe ? Math.min(size, PROBE_BYTES) : size;
954
+ if (filesRead >= maxFiles || bytesRead + initialBytes > maxBytes) {
881
955
  skipped.push({
882
956
  path: relPath,
883
957
  reason: "too-large",
@@ -885,9 +959,30 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
885
959
  });
886
960
  break;
887
961
  }
962
+ filesRead++;
963
+ if (needsProbe) {
964
+ const probe = probeFileType(absPath, initialBytes);
965
+ if (probe.kind === "unreadable") {
966
+ skipped.push({ path: relPath, reason: "unreadable", detail: probe.detail });
967
+ continue;
968
+ }
969
+ bytesRead += probe.bytes;
970
+ if (probe.kind === "binary") continue;
971
+ if (size > MAX_FILE_BYTES) {
972
+ skipped.push({
973
+ path: relPath,
974
+ reason: "too-large",
975
+ detail: `${Math.round(size / 1024)} KB, cap is ${MAX_FILE_BYTES / 1024} KB`
976
+ });
977
+ continue;
978
+ }
979
+ if (bytesRead + size > maxBytes) {
980
+ skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded after file probe" });
981
+ break;
982
+ }
983
+ }
888
984
  const bytes = readFileSync2(absPath);
889
985
  bytesRead += bytes.length;
890
- filesRead++;
891
986
  if (bytesRead > maxBytes) {
892
987
  skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
893
988
  break;
@@ -997,6 +1092,69 @@ function endOfString(src, start, quote) {
997
1092
  }
998
1093
  return length;
999
1094
  }
1095
+ var REGEX_PREFIX_WORDS = /* @__PURE__ */ new Set(["return", "throw", "case", "delete", "void", "typeof", "instanceof", "in", "of", "new", "yield", "await", "else", "do"]);
1096
+ var CONTROL_WORDS = /* @__PURE__ */ new Set(["if", "while", "for", "with", "switch", "catch"]);
1097
+ var regexContext = () => ({ allowed: true, control: false, member: false, parentheses: [], failedUntil: -1 });
1098
+ function valueEnded(context) {
1099
+ context.allowed = false;
1100
+ context.control = false;
1101
+ context.member = false;
1102
+ }
1103
+ function regexAt(src, start, context) {
1104
+ if (!context.allowed || start < context.failedUntil || src[start + 1] === "=") return null;
1105
+ let inClass = false;
1106
+ for (let i = start + 1; i < src.length; i++) {
1107
+ const ch = src[i];
1108
+ if (ch === "\n" || ch === "\r") {
1109
+ context.failedUntil = i;
1110
+ return null;
1111
+ }
1112
+ if (ch === "\\") {
1113
+ i++;
1114
+ continue;
1115
+ }
1116
+ if (ch === "[") inClass = true;
1117
+ else if (ch === "]") inClass = false;
1118
+ else if (ch === "/" && !inClass) {
1119
+ let end = i + 1;
1120
+ while (end < src.length && /[A-Za-z]/.test(src[end])) end++;
1121
+ valueEnded(context);
1122
+ return { close: i, end };
1123
+ }
1124
+ }
1125
+ context.failedUntil = src.length;
1126
+ return null;
1127
+ }
1128
+ function advanceCode(src, at, context) {
1129
+ const ch = src[at];
1130
+ if (/\s/.test(ch)) return at + 1;
1131
+ if (/[A-Za-z_$\u0080-\uffff]/.test(ch)) {
1132
+ let end = at + 1;
1133
+ while (end < src.length && /[\w$\u0080-\uffff]/.test(src[end])) end++;
1134
+ const word = src.slice(at, end);
1135
+ context.control = !context.member && CONTROL_WORDS.has(word);
1136
+ context.allowed = !context.member && REGEX_PREFIX_WORDS.has(word);
1137
+ context.member = false;
1138
+ return end;
1139
+ }
1140
+ if (ch === "(") {
1141
+ context.parentheses.push(context.control);
1142
+ context.allowed = true;
1143
+ } else if (ch === ")") context.allowed = context.parentheses.pop() ?? false;
1144
+ else if (ch === "." || ch === "?" && src[at + 1] === ".") {
1145
+ context.allowed = false;
1146
+ context.control = false;
1147
+ context.member = true;
1148
+ return at + (ch === "?" ? 2 : 1);
1149
+ } else if ((ch === "+" || ch === "-") && src[at + 1] === ch) {
1150
+ context.control = false;
1151
+ context.member = false;
1152
+ return at + 2;
1153
+ } else context.allowed = !/[\d\]}]/.test(ch);
1154
+ context.control = false;
1155
+ context.member = false;
1156
+ return at + 1;
1157
+ }
1000
1158
  function maskTemplate(src, out, start, maskStrings = true) {
1001
1159
  const length = src.length;
1002
1160
  const stack = [{ kind: "literal", from: start + 1 }];
@@ -1012,12 +1170,14 @@ function maskTemplate(src, out, start, maskStrings = true) {
1012
1170
  if (ch === BACKTICK) {
1013
1171
  if (maskStrings) blank(out, frame.from, i);
1014
1172
  stack.pop();
1173
+ const parent = stack[stack.length - 1];
1174
+ if (parent?.kind === "expression") valueEnded(parent.lexical);
1015
1175
  i++;
1016
1176
  continue;
1017
1177
  }
1018
1178
  if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
1019
1179
  if (maskStrings) blank(out, frame.from, i);
1020
- stack.push({ kind: "expression", depth: 1 });
1180
+ stack.push({ kind: "expression", depth: 1, lexical: regexContext() });
1021
1181
  i += 2;
1022
1182
  continue;
1023
1183
  }
@@ -1033,10 +1193,17 @@ function maskTemplate(src, out, start, maskStrings = true) {
1033
1193
  i = stop;
1034
1194
  continue;
1035
1195
  }
1196
+ const regex = regexAt(src, i, frame.lexical);
1197
+ if (regex) {
1198
+ if (maskStrings) blank(out, i + 1, regex.close);
1199
+ i = regex.end;
1200
+ continue;
1201
+ }
1036
1202
  }
1037
1203
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1038
1204
  const stop = endOfString(src, i, ch);
1039
1205
  if (maskStrings) blank(out, i + 1, stop - 1);
1206
+ valueEnded(frame.lexical);
1040
1207
  i = stop;
1041
1208
  continue;
1042
1209
  }
@@ -1054,7 +1221,7 @@ function maskTemplate(src, out, start, maskStrings = true) {
1054
1221
  if (parent.kind === "literal") parent.from = i + 1;
1055
1222
  }
1056
1223
  }
1057
- i++;
1224
+ i = advanceCode(src, i, frame.lexical);
1058
1225
  }
1059
1226
  const last = stack[stack.length - 1];
1060
1227
  if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
@@ -1064,6 +1231,7 @@ function maskJsComments(src) {
1064
1231
  const length = src.length;
1065
1232
  const out = codesOf(src);
1066
1233
  let i = 0;
1234
+ const lexical = regexContext();
1067
1235
  while (i < length) {
1068
1236
  const ch = src.charCodeAt(i);
1069
1237
  if (ch === SLASH) {
@@ -1083,15 +1251,24 @@ function maskJsComments(src) {
1083
1251
  continue;
1084
1252
  }
1085
1253
  }
1254
+ if (ch === SLASH) {
1255
+ const regex = regexAt(src, i, lexical);
1256
+ if (regex) {
1257
+ i = regex.end;
1258
+ continue;
1259
+ }
1260
+ }
1086
1261
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1087
1262
  i = endOfString(src, i, ch);
1263
+ valueEnded(lexical);
1088
1264
  continue;
1089
1265
  }
1090
1266
  if (ch === BACKTICK) {
1091
1267
  i = maskTemplate(src, out, i, false);
1268
+ valueEnded(lexical);
1092
1269
  continue;
1093
1270
  }
1094
- i++;
1271
+ i = advanceCode(src, i, lexical);
1095
1272
  }
1096
1273
  return stringOf(out);
1097
1274
  }
@@ -1099,6 +1276,7 @@ function maskJsNoise(src) {
1099
1276
  const length = src.length;
1100
1277
  const out = codesOf(src);
1101
1278
  let i = 0;
1279
+ const lexical = regexContext();
1102
1280
  while (i < length) {
1103
1281
  const ch = src.charCodeAt(i);
1104
1282
  if (ch === SLASH) {
@@ -1118,17 +1296,27 @@ function maskJsNoise(src) {
1118
1296
  continue;
1119
1297
  }
1120
1298
  }
1299
+ if (ch === SLASH) {
1300
+ const regex = regexAt(src, i, lexical);
1301
+ if (regex) {
1302
+ blank(out, i + 1, regex.close);
1303
+ i = regex.end;
1304
+ continue;
1305
+ }
1306
+ }
1121
1307
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1122
1308
  const stop = endOfString(src, i, ch);
1123
1309
  blank(out, i + 1, stop - 1);
1310
+ valueEnded(lexical);
1124
1311
  i = stop;
1125
1312
  continue;
1126
1313
  }
1127
1314
  if (ch === BACKTICK) {
1128
1315
  i = maskTemplate(src, out, i);
1316
+ valueEnded(lexical);
1129
1317
  continue;
1130
1318
  }
1131
- i++;
1319
+ i = advanceCode(src, i, lexical);
1132
1320
  }
1133
1321
  return stringOf(out);
1134
1322
  }
@@ -2562,7 +2750,7 @@ function policyFindings(ctx, policies, live, keyOf, buckets) {
2562
2750
  ...p.command === "select" || p.command === "insert" ? [`Public read-only tables and open forms can be intentional. If this one is, keep it and consider restricting the columns the API exposes.`] : []
2563
2751
  ],
2564
2752
  fix: [
2565
- `Replace the condition with one that ties each row to its owner, e.g. USING ((select auth.uid()) = user_id)${p.command === "insert" || p.command === "update" || p.command === "all" ? " and WITH CHECK ((select auth.uid()) = user_id)" : ""}.`,
2753
+ `Tie each row to its owner; adapt user_id to your schema. For ${p.command.toUpperCase()}, use ` + (p.command === "insert" ? "WITH CHECK ((select auth.uid()) = user_id)." : `USING ((select auth.uid()) = user_id)${p.command === "update" || p.command === "all" ? " and WITH CHECK ((select auth.uid()) = user_id)" : ""}.`),
2566
2754
  `If a broad policy is needed as a base, add an AS RESTRICTIVE policy alongside it to limit which rows it reaches.`,
2567
2755
  `Keep the change in a migration, so the rule travels with your code.`
2568
2756
  ],
@@ -2664,7 +2852,7 @@ function checkRealtimeRules(file, ctx) {
2664
2852
  ]
2665
2853
  ],
2666
2854
  fix: [
2667
- `Require sign-in and ownership instead, for example: ".read": "auth !== null && auth.uid === $uid" under a "$uid" node.`,
2855
+ `Require sign-in and ownership for ${[openRead ? ".read" : "", openWrite ? ".write" : ""].filter(Boolean).join(" and ")}. For a per-user "$uid" node, use "auth !== null && auth.uid === $uid" for each of those rule values; adapt the path to your data model.`,
2668
2856
  `Test the new rules with the Firebase emulator before deploying.`,
2669
2857
  ...openWrite ? [`If this database has been open for a while, assume the data has already been copied or changed.`] : []
2670
2858
  ]
@@ -2718,6 +2906,18 @@ var WRITE_OPS = /* @__PURE__ */ new Set(["write", "create", "update", "delete"])
2718
2906
  function parseOps(raw) {
2719
2907
  return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2720
2908
  }
2909
+ function ownershipAdvice(product, rawOps) {
2910
+ const prefix = `Replace the open or date-based condition for ${rawOps.trim()} with authentication and ownership checks. `;
2911
+ if (product !== "Firestore") {
2912
+ return prefix + "Bind request.auth.uid to the owner in the matched path or trusted metadata; use rules for your Firebase product and data model. Do not grant additional operations.";
2913
+ }
2914
+ const ops = new Set(parseOps(rawOps));
2915
+ const checks = ["Require request.auth != null. Adapt userId to your ownership field."];
2916
+ if (ops.has("create") || ops.has("write")) checks.push("For create, check request.resource.data.userId == request.auth.uid; no existing resource is available.");
2917
+ if (ops.has("update") || ops.has("write")) checks.push("For update, check resource.data.userId == request.auth.uid and request.resource.data.userId == resource.data.userId to prevent ownership changes.");
2918
+ if (["read", "get", "list", "delete", "write"].some((op) => ops.has(op))) checks.push("For read or delete operations already allowed here, check resource.data.userId == request.auth.uid.");
2919
+ return prefix + checks.join(" ") + " Do not grant additional operations.";
2920
+ }
2721
2921
  function neutralizePathWildcards(content) {
2722
2922
  return content.replace(/\{[a-zA-Z_]\w*(?:\s*=\s*\*\*)?\}/g, (m) => "_".repeat(m.length));
2723
2923
  }
@@ -2816,7 +3016,7 @@ var firebaseRulesRule = {
2816
3016
  `Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
2817
3017
  ],
2818
3018
  fix: canWrite ? [
2819
- `Decide who should actually have access. For per-user data the usual rule is: allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;`,
3019
+ ownershipAdvice(product, rawOps),
2820
3020
  `For data that is genuinely public, restrict it to reads only: allow read: if true; allow write: if false;`,
2821
3021
  `Test your rules with the Firebase emulator before deploying, so you do not lock yourself out.`,
2822
3022
  `If this database has been open for a while, assume the data has already been copied.`
@@ -2854,7 +3054,7 @@ var firebaseRulesRule = {
2854
3054
  `Neither state is what you want in production.`
2855
3055
  ],
2856
3056
  fix: [
2857
- `Replace the date check with a real authorisation rule. For per-user data: allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;`,
3057
+ ownershipAdvice(product, rawOps),
2858
3058
  `Test the new rules with the Firebase emulator before deploying.`,
2859
3059
  expired ? `Note that your app is currently denied access here, so fixing this also fixes whatever stopped working.` : `Do this before ${dateStr}, otherwise your app breaks on that date.`
2860
3060
  ]
@@ -2907,6 +3107,256 @@ function namePattern(names) {
2907
3107
  return [...names].map((name) => name.replace(/\$/g, "\\$")).join("|");
2908
3108
  }
2909
3109
 
3110
+ // src/rules/auth-values.ts
3111
+ var limited = /* @__PURE__ */ new WeakSet();
3112
+ var cache2 = /* @__PURE__ */ new WeakMap();
3113
+ var reverseCache = /* @__PURE__ */ new WeakMap();
3114
+ var identityFlowLimited = (file) => limited.has(file);
3115
+ function bindingNames(pattern, path = []) {
3116
+ const text = pattern.trim();
3117
+ if (path.length > 8) return (text.match(/[A-Za-z_$][\w$]*/g) ?? []).map((name) => ({ name, path: null }));
3118
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) return [{ name: text, path }];
3119
+ if (text.startsWith("{") && text.endsWith("}")) {
3120
+ const parts = [];
3121
+ let from = 1, depth = 0;
3122
+ for (let i = 1; i < text.length - 1; i++) {
3123
+ if ("{[(".includes(text[i])) depth++;
3124
+ else if ("}])".includes(text[i])) depth--;
3125
+ else if (text[i] === "," && depth === 0) {
3126
+ parts.push(text.slice(from, i));
3127
+ from = i + 1;
3128
+ }
3129
+ }
3130
+ parts.push(text.slice(from, -1));
3131
+ const result = [];
3132
+ for (const part of parts) {
3133
+ if (!part.trim()) continue;
3134
+ const property = /^\s*([A-Za-z_$][\w$]*)(?:\s*:\s*([^=]+))?\s*$/.exec(part);
3135
+ if (property) result.push(...bindingNames(property[2] ?? property[1], [...path, property[1]]));
3136
+ else for (const name of part.match(/[A-Za-z_$][\w$]*/g) ?? []) result.push({ name, path: null });
3137
+ }
3138
+ return result;
3139
+ }
3140
+ return (text.match(/[A-Za-z_$][\w$]*/g) ?? []).map((name) => ({ name, path: null }));
3141
+ }
3142
+ function argumentExpressions(code, source, start, end, pairs) {
3143
+ const result = [];
3144
+ let from = start;
3145
+ for (let i = start; i < end; i++) {
3146
+ if (code[i] === ",") {
3147
+ result.push(source.slice(from, i).trim());
3148
+ from = i + 1;
3149
+ }
3150
+ const close = pairs.get(i);
3151
+ if (close !== void 0) i = close;
3152
+ }
3153
+ if (from < end) result.push(source.slice(from, end).trim());
3154
+ return result;
3155
+ }
3156
+ function authValuesOf(file, body, bodies, pairs) {
3157
+ let contexts = cache2.get(file);
3158
+ if (!contexts) {
3159
+ contexts = /* @__PURE__ */ new Map();
3160
+ cache2.set(file, contexts);
3161
+ }
3162
+ let found = contexts.get(body.start);
3163
+ if (!found) {
3164
+ found = new AuthValues(file, body, bodies, pairs);
3165
+ contexts.set(body.start, found);
3166
+ }
3167
+ return found;
3168
+ }
3169
+ var AuthValues = class {
3170
+ constructor(file, body, bodies, pairs) {
3171
+ this.file = file;
3172
+ const code = noiseMaskedOf(file);
3173
+ const source = commentsMaskedOf(file);
3174
+ let open = code.indexOf("(", body.declaration);
3175
+ let close = pairs.get(open);
3176
+ if (code.startsWith("=>", body.declaration)) {
3177
+ const window = code.slice(Math.max(0, body.declaration - 300), body.declaration);
3178
+ const before = window.trimEnd();
3179
+ if (before.endsWith(")")) {
3180
+ close = body.declaration - (window.length - before.length) - 1;
3181
+ let reverse = reverseCache.get(file);
3182
+ if (!reverse) {
3183
+ reverse = new Map([...pairs].map(([left, right]) => [right, left]));
3184
+ reverseCache.set(file, reverse);
3185
+ }
3186
+ open = reverse.get(close) ?? -1;
3187
+ } else {
3188
+ const single = /([A-Za-z_$][\w$]*)$/.exec(before)?.[1];
3189
+ if (single) this.parameters.set(single, 0);
3190
+ open = -1;
3191
+ }
3192
+ }
3193
+ if (open >= 0 && close !== void 0 && close < body.start) {
3194
+ argumentExpressions(code, code, open + 1, close, pairs).forEach((part, index) => {
3195
+ const name = /^([A-Za-z_$][\w$]*)(?:\s*\??\s*:[^=]+)?$/.exec(part)?.[1];
3196
+ if (name) this.parameters.set(name, index);
3197
+ });
3198
+ }
3199
+ const text = code.slice(body.start + 1, body.end);
3200
+ const endOf = (start) => {
3201
+ const boundary = Math.min(body.end, start + 4001);
3202
+ let previous = "";
3203
+ for (let i = start; i < boundary; i++) {
3204
+ if (code[i] === ";" || code[i] === "}") return i;
3205
+ if (code[i] === "\n") {
3206
+ let next = i + 1;
3207
+ while (next < boundary && /\s/.test(code[next])) next++;
3208
+ if (next === boundary && boundary < body.end) return boundary;
3209
+ if (!/[.?+\-*/%&|^,:([`=]/.test(code[next] ?? "") && !/[=+\-*/%&|^?:,.]/.test(previous)) return i;
3210
+ i = next - 1;
3211
+ continue;
3212
+ }
3213
+ if (!/\s/.test(code[i])) previous = code[i];
3214
+ const close2 = pairs.get(i);
3215
+ if (close2 !== void 0) {
3216
+ i = close2;
3217
+ previous = code[close2];
3218
+ }
3219
+ }
3220
+ return boundary;
3221
+ };
3222
+ for (const match2 of text.matchAll(/\b(?:if|for|while|switch|catch)\s*\(/g)) {
3223
+ const close2 = pairs.get(body.start + 1 + match2.index + match2[0].length - 1);
3224
+ if (close2 === void 0) continue;
3225
+ let start = close2 + 1;
3226
+ while (/\s/.test(code[start] ?? "")) start++;
3227
+ this.conditions.push({ start, end: pairs.get(start) ?? endOf(start) });
3228
+ if (this.conditions.length > 512) {
3229
+ this.exhausted = true;
3230
+ limited.add(file);
3231
+ break;
3232
+ }
3233
+ }
3234
+ let low = 0, high = bodies.length;
3235
+ while (low < high) {
3236
+ const middle = low + high >>> 1;
3237
+ if (bodies[middle].declaration <= body.start) low = middle + 1;
3238
+ else high = middle;
3239
+ }
3240
+ const nested = [];
3241
+ for (let i = low; i < bodies.length && bodies[i].declaration < body.end; i++) nested.push(bodies[i]);
3242
+ let nestedIndex = 0;
3243
+ let count = 0;
3244
+ const declarations = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*|[\[{])|(?<![\w$.])([A-Za-z_$][\w$]*)\s*=(?!=|>)/g;
3245
+ let match;
3246
+ while ((match = declarations.exec(text)) !== null) {
3247
+ if (this.exhausted) break;
3248
+ const at = body.start + 1 + match.index;
3249
+ while (nestedIndex < nested.length && nested[nestedIndex].end < at) nestedIndex++;
3250
+ if (nested[nestedIndex] && at >= nested[nestedIndex].declaration && at < nested[nestedIndex].end) continue;
3251
+ if (++count > 512) {
3252
+ this.exhausted = true;
3253
+ limited.add(file);
3254
+ break;
3255
+ }
3256
+ let binding = match[1] ?? match[2];
3257
+ let from = at + match[0].length;
3258
+ if (match[1]) {
3259
+ if (binding === "{" || binding === "[") {
3260
+ const start = from - 1;
3261
+ const close2 = pairs.get(start);
3262
+ if (close2 === void 0) continue;
3263
+ if (close2 - start > 4e3) {
3264
+ this.exhausted = true;
3265
+ limited.add(file);
3266
+ break;
3267
+ }
3268
+ binding = code.slice(start, close2 + 1);
3269
+ from = close2 + 1;
3270
+ }
3271
+ while (/\s/.test(code[from] ?? "")) from++;
3272
+ if (code[from] === ":") {
3273
+ const type = /^:[^=;\n]{1,120}/.exec(code.slice(from, from + 121));
3274
+ if (type) from += type[0].length;
3275
+ }
3276
+ if (code[from] !== "=" || /[=>]/.test(code[from + 1] ?? "")) continue;
3277
+ from++;
3278
+ }
3279
+ while (/\s/.test(code[from] ?? "")) from++;
3280
+ declarations.lastIndex = from - body.start - 1;
3281
+ const end = endOf(from);
3282
+ for (const { name, path } of bindingNames(binding)) {
3283
+ if (!this.assignments.has(name) && this.assignments.size >= 512) {
3284
+ this.exhausted = true;
3285
+ limited.add(file);
3286
+ break;
3287
+ }
3288
+ const list = this.assignments.get(name) ?? [];
3289
+ list.push({ at, expression: end - from <= 4e3 ? source.slice(from, end).trim() : "", projection: path, overLimit: end - from > 4e3 });
3290
+ this.assignments.set(name, list);
3291
+ }
3292
+ }
3293
+ }
3294
+ file;
3295
+ assignments = /* @__PURE__ */ new Map();
3296
+ parameters = /* @__PURE__ */ new Map();
3297
+ conditions = [];
3298
+ exhausted = false;
3299
+ value(expression, at, depth = 0) {
3300
+ if (depth >= 8) limited.add(this.file);
3301
+ if (this.exhausted || depth >= 8) return { kind: "opaque" };
3302
+ const expr = expression.trim();
3303
+ if (expr.length > 4e3) {
3304
+ limited.add(this.file);
3305
+ return { kind: "opaque" };
3306
+ }
3307
+ if (/^process\.env\.[A-Z_][A-Z0-9_]*$/.test(expr)) return { kind: "secret" };
3308
+ if (/^(?:(?:true|false|null|undefined)\b|\d|['"`{\[])/.test(expr)) return { kind: "literal" };
3309
+ const call = /^(?:await\s+)?((?:[A-Za-z_$][\w$]*\.)*(?:getUser|getUserSession|getSession|getServerSession|safeGetSession|currentUser|verifyIdToken|auth))\s*\(/.exec(expr);
3310
+ const callee = call?.[1];
3311
+ const identityCall = callee && !/\.(?:body|query|headers|cookies)\b/.test(callee);
3312
+ if (identityCall) {
3313
+ if (!/^await\s+/.test(expr)) return { kind: "promise" };
3314
+ const masked = maskJsNoise(expr);
3315
+ let depth2 = 1, end = call[0].length;
3316
+ for (; end < masked.length && depth2 > 0; end++) {
3317
+ if (masked[end] === "(") depth2++;
3318
+ else if (masked[end] === ")") depth2--;
3319
+ }
3320
+ if (depth2 !== 0 || masked.slice(end).trim() !== "") return { kind: "opaque" };
3321
+ if (/\|\||&&|\?(?!\.)|\.then\s*\(/.test(expr) || /\.catch\s*\(/.test(expr)) return { kind: "opaque" };
3322
+ return { kind: /\.auth\.getUser\s*\(/.test(expr) ? "envelope" : "identity" };
3323
+ }
3324
+ if (/\.(?:body|query|headers|cookies|searchParams)\b|\.(?:json|text|formData)\s*\(/.test(expr)) return { kind: "input" };
3325
+ const access = /^([A-Za-z_$][\w$]*)((?:\??\.[A-Za-z_$][\w$]*)*)$/.exec(expr);
3326
+ if (!access) return { kind: "opaque" };
3327
+ const name = access[1];
3328
+ const list = this.assignments.get(name) ?? [];
3329
+ let low = 0, high = list.length;
3330
+ while (low < high) {
3331
+ const middle = low + high >>> 1;
3332
+ if (list[middle].at < at) low = middle + 1;
3333
+ else high = middle;
3334
+ }
3335
+ const assignment = list[low - 1];
3336
+ let value;
3337
+ if (assignment) {
3338
+ if (assignment.overLimit) {
3339
+ limited.add(this.file);
3340
+ return { kind: "opaque" };
3341
+ }
3342
+ if (this.conditions.some((range) => range.start <= assignment.at && assignment.at <= range.end && !(range.start <= at && at <= range.end))) return { kind: "opaque" };
3343
+ value = this.value(assignment.expression, assignment.at, depth + 1);
3344
+ if (assignment.projection === null) value = { kind: "opaque" };
3345
+ else if (assignment.projection.length && value.kind === "envelope") value = {
3346
+ kind: assignment.projection.join(".") === "data.user" ? "identity" : "opaque"
3347
+ };
3348
+ } else if (this.parameters.has(name)) value = { kind: "parameter", parameters: [this.parameters.get(name)] };
3349
+ else value = { kind: "unknown" };
3350
+ if (value.kind === "envelope") return { kind: /^\??\.data\??\.user$/.test(access[2]) ? "identity" : "envelope" };
3351
+ return value;
3352
+ }
3353
+ };
3354
+ function identityRequirement(value, allowParameters) {
3355
+ if (value.kind === "identity") return [];
3356
+ if (allowParameters && value.kind === "parameter") return value.parameters ?? [];
3357
+ return null;
3358
+ }
3359
+
2910
3360
  // src/rules/apiauth.ts
2911
3361
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2912
3362
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
@@ -3153,7 +3603,7 @@ function rejectsMissingIdentity(term, returnsDeniedStatus) {
3153
3603
  const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
3154
3604
  return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
3155
3605
  }
3156
- function hasConditionalAuthGuard(code) {
3606
+ function hasConditionalAuthGuard(code, requireThrow = false, accept) {
3157
3607
  const starts = /\bif\s*\(/g;
3158
3608
  let match;
3159
3609
  while ((match = starts.exec(code)) !== null) {
@@ -3167,7 +3617,7 @@ function hasConditionalAuthGuard(code) {
3167
3617
  let stopsRequest = false;
3168
3618
  for (let i = 0; i < body.length; i++) {
3169
3619
  if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
3170
- stopsRequest = true;
3620
+ stopsRequest = !requireThrow || !/^return\b/.test(body.slice(i, i + 16));
3171
3621
  break;
3172
3622
  }
3173
3623
  if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
@@ -3179,7 +3629,10 @@ function hasConditionalAuthGuard(code) {
3179
3629
  starts.lastIndex = statementStart + statement.length;
3180
3630
  if (!stopsRequest) continue;
3181
3631
  const returnsDeniedStatus = DENIED_STATUS.test(statement);
3182
- const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus));
3632
+ const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => {
3633
+ const term = part.trim();
3634
+ return accept ? (NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term)) && accept(term) : rejectsMissingIdentity(term, returnsDeniedStatus);
3635
+ });
3183
3636
  if (negative) return true;
3184
3637
  }
3185
3638
  return false;
@@ -3281,6 +3734,18 @@ function unguardedOpsOf(file) {
3281
3734
  }
3282
3735
  return hit;
3283
3736
  }
3737
+ function operationsInRange(ops, start, end) {
3738
+ const lowerBound = (value) => {
3739
+ let low = 0, high = ops.length;
3740
+ while (low < high) {
3741
+ const middle = low + high >>> 1;
3742
+ if (ops[middle].index < value) low = middle + 1;
3743
+ else high = middle;
3744
+ }
3745
+ return low;
3746
+ };
3747
+ return ops.slice(lowerBound(start + 1), lowerBound(end));
3748
+ }
3284
3749
  var lineStartsCache = /* @__PURE__ */ new WeakMap();
3285
3750
  function lineStartsCached(file) {
3286
3751
  let hit = lineStartsCache.get(file);
@@ -3291,38 +3756,96 @@ function lineStartsCached(file) {
3291
3756
  return hit;
3292
3757
  }
3293
3758
  function cachedByFile(check) {
3294
- const cache2 = /* @__PURE__ */ new WeakMap();
3759
+ const cache3 = /* @__PURE__ */ new WeakMap();
3295
3760
  return (file) => {
3296
- let hit = cache2.get(file);
3761
+ let hit = cache3.get(file);
3297
3762
  if (hit === void 0) {
3298
3763
  hit = check(file);
3299
- cache2.set(file, hit);
3764
+ cache3.set(file, hit);
3300
3765
  }
3301
3766
  return hit;
3302
3767
  };
3303
3768
  }
3304
- function unguardedOperations(file, ops, extra) {
3769
+ var localAuthNamesCache = /* @__PURE__ */ new WeakMap();
3770
+ var controlFlowLimited = /* @__PURE__ */ new WeakSet();
3771
+ function localAuthNames(file) {
3772
+ const cached = localAuthNamesCache.get(file);
3773
+ if (cached) return cached;
3774
+ const names = new Set(bindingsOf(file).imports.filter((binding) => binding.spec && normalizeSpec(binding.spec, file.path)).map((binding) => binding.local));
3775
+ const { declared, shadowed } = guardBindings(file);
3776
+ for (const name of declared) names.add(name);
3777
+ for (const name of shadowed) names.add(name);
3778
+ localAuthNamesCache.set(file, names);
3779
+ return names;
3780
+ }
3781
+ function unguardedOperations(file, ops, extra, requireThrow = false, observe) {
3305
3782
  if (ops.length === 0) return ops;
3306
3783
  const code = noiseMaskedOf(file);
3307
3784
  const pairs = delimiterPairs(code);
3308
3785
  const bodies = functionBodies(code, pairs);
3309
3786
  const declarations = new Map(bodies.map((body) => [body.declaration, body]));
3310
3787
  const functionStarts = new Set(bodies.map((body) => body.start));
3788
+ const bodiesByStart = new Map(bodies.map((body) => [body.start, body]));
3789
+ const localNames = localAuthNames(file);
3311
3790
  const guardEnds = /* @__PURE__ */ new Map();
3312
3791
  const guardNames = /* @__PURE__ */ new Map();
3792
+ const guardRequirements = /* @__PURE__ */ new Map();
3313
3793
  const extraCalls = extra?.guards.size ? new RegExp(`^(${namePattern(extra.guards)})\\s*\\(`) : null;
3314
3794
  const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
3315
3795
  const active = [];
3316
3796
  let blockIndex = 0;
3317
3797
  const wrapperPattern = extra?.wrappers.size ? new RegExp(`(?<![\\w$.])(${namePattern(extra.wrappers)})\\s*\\(`, "g") : null;
3318
3798
  const wrappers = [
3319
- ...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g),
3799
+ ...[...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g)].filter((match) => !localNames.has(match[1])),
3320
3800
  ...wrapperPattern ? code.matchAll(wrapperPattern) : []
3321
3801
  ].map((match) => {
3322
3802
  const open = match.index + match[0].length - 1;
3323
3803
  return { start: open, end: pairs.get(open) ?? open, name: match[1] };
3324
3804
  });
3325
- const guardEnd = (owner) => {
3805
+ const skipSpace = (at) => {
3806
+ while (/\s/.test(code[at] ?? "")) at++;
3807
+ return at;
3808
+ };
3809
+ const blockAt = (at) => {
3810
+ const start = skipSpace(at);
3811
+ const end = pairs.get(start);
3812
+ return code[start] === "{" && end !== void 0 ? { start, end } : null;
3813
+ };
3814
+ const denies = (block) => {
3815
+ for (let i = block.start + 1; i < block.end; i++) {
3816
+ if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
3817
+ const tail = code.slice(i, i + 400);
3818
+ if (/^(?:if|for|while|switch|try)\b/.test(tail)) return false;
3819
+ if (/^(?:throw|redirect|notFound)\b|^error\s*\(\s*40[13]\b/.test(tail)) return true;
3820
+ if (/^return\b/.test(tail)) return !requireThrow && DENIED_STATUS.test(code.slice(i, statementEnd2(code, i, block.end, pairs)));
3821
+ const end = pairs.get(i);
3822
+ if (end !== void 0) i = end;
3823
+ }
3824
+ return false;
3825
+ };
3826
+ const checkedEnd = (block, fn, depth) => {
3827
+ const cached = guardEnds.get(block.start);
3828
+ if (cached !== void 0) return cached;
3829
+ const result = guardEnd(block, fn, depth);
3830
+ guardEnds.set(block.start, result);
3831
+ return result;
3832
+ };
3833
+ const allProtected = (owner, children, fn, depth) => {
3834
+ const required = /* @__PURE__ */ new Set();
3835
+ for (const child of children) {
3836
+ if (!Number.isFinite(checkedEnd(child, fn, depth + 1)) && !denies(child)) return false;
3837
+ for (const argument of guardRequirements.get(child.start) ?? []) required.add(argument);
3838
+ }
3839
+ guardRequirements.set(owner.start, [...required]);
3840
+ const name = children.map((child) => guardNames.get(child.start)).find(Boolean);
3841
+ if (name) guardNames.set(owner.start, name);
3842
+ return true;
3843
+ };
3844
+ const guardEnd = (owner, fn, depth = 0) => {
3845
+ if (depth > 8) {
3846
+ controlFlowLimited.add(file);
3847
+ return Infinity;
3848
+ }
3326
3849
  for (let i = owner.start + 1; i < owner.end; i++) {
3327
3850
  const nested = declarations.get(i);
3328
3851
  if (nested) {
@@ -3334,6 +3857,35 @@ function unguardedOperations(file, ops, extra) {
3334
3857
  continue;
3335
3858
  }
3336
3859
  if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
3860
+ if (/^try\s*\{/.test(code.slice(i, i + 32))) {
3861
+ const attempted = blockAt(i + 3);
3862
+ if (attempted) {
3863
+ let cursor = skipSpace(attempted.end + 1);
3864
+ const children = [attempted];
3865
+ if (/^catch\b/.test(code.slice(cursor, cursor + 8))) {
3866
+ cursor = skipSpace(cursor + 5);
3867
+ if (code[cursor] === "(") cursor = skipSpace((pairs.get(cursor) ?? cursor) + 1);
3868
+ const caught = blockAt(cursor);
3869
+ if (!caught) {
3870
+ i = attempted.end;
3871
+ continue;
3872
+ }
3873
+ children.push(caught);
3874
+ cursor = skipSpace(caught.end + 1);
3875
+ }
3876
+ let overrides = false;
3877
+ if (/^finally\b/.test(code.slice(cursor, cursor + 10))) {
3878
+ const final = blockAt(cursor + 7);
3879
+ if (final) {
3880
+ overrides = /\breturn\b/.test(code.slice(final.start + 1, final.end));
3881
+ cursor = final.end + 1;
3882
+ }
3883
+ }
3884
+ if (!overrides && allProtected(owner, children, fn, depth)) return cursor;
3885
+ i = cursor - 1;
3886
+ continue;
3887
+ }
3888
+ }
3337
3889
  const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
3338
3890
  if (conditional) {
3339
3891
  const open = i + conditional[0].length - 1;
@@ -3342,12 +3894,41 @@ function unguardedOperations(file, ops, extra) {
3342
3894
  let start = close2 + 1;
3343
3895
  while (/\s/.test(code[start] ?? "")) start++;
3344
3896
  const end = statementEnd2(code, start, owner.end, pairs);
3345
- if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
3897
+ const required = /* @__PURE__ */ new Set();
3898
+ const values = authValuesOf(file, fn, bodies, pairs);
3899
+ const accepted = hasConditionalAuthGuard(code.slice(i, end), requireThrow, (term) => {
3900
+ const mismatch = /^(.*?)\s*!={1,2}\s*(.*?)$/.exec(term);
3901
+ if (mismatch && (values.value(mismatch[1], i).kind === "secret" || values.value(mismatch[2], i).kind === "secret")) return true;
3902
+ const subject = term.replace(/^!\s*/, "").split(/\s*(?:={2,3}|!={1,2})\s*/)[0];
3903
+ const value = values.value(subject, i);
3904
+ const dependencies = identityRequirement(value, !!observe);
3905
+ if (dependencies) {
3906
+ dependencies.forEach((index) => required.add(index));
3907
+ return true;
3908
+ }
3909
+ return !observe && value.kind === "unknown" && rejectsMissingIdentity(term, DENIED_STATUS.test(code.slice(i, end)));
3910
+ });
3911
+ if (end <= owner.end && accepted) {
3912
+ guardRequirements.set(owner.start, [...required]);
3913
+ return end;
3914
+ }
3915
+ const other = skipSpace(end);
3916
+ if (/^else\b/.test(code.slice(other, other + 8))) {
3917
+ const first = blockAt(start);
3918
+ const second = blockAt(other + 4);
3919
+ if (first && second) {
3920
+ if (allProtected(owner, [first, second], fn, depth)) return second.end + 1;
3921
+ i = second.end;
3922
+ continue;
3923
+ }
3924
+ }
3346
3925
  i = Math.min(end, owner.end) - 1;
3347
3926
  continue;
3348
3927
  }
3349
3928
  const indirectCall = i > 0 && /[\w$.]/.test(code[i - 1]) ? null : extraCalls?.exec(code.slice(i, i + 100));
3350
3929
  const call = indirectCall ?? AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
3930
+ const callName = call?.[0].replace(/\s*\($/, "");
3931
+ if (!indirectCall && call?.index === 0 && callName && localNames.has(callName)) continue;
3351
3932
  if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
3352
3933
  const prefixStart = Math.max(
3353
3934
  owner.start + 1,
@@ -3356,11 +3937,27 @@ function unguardedOperations(file, ops, extra) {
3356
3937
  );
3357
3938
  const prefix = code.slice(prefixStart, i).trim();
3358
3939
  if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
3359
- const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix);
3940
+ const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix) || requireThrow && /^return(?:\s+await)?$/.test(prefix);
3360
3941
  const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
3361
3942
  if (!awaited && !synchronous) continue;
3362
3943
  const close2 = pairs.get(i + call[0].length - 1);
3363
3944
  if (close2 !== void 0 && close2 < owner.end) {
3945
+ if (/^\s*(?:\.|\?\.)/.test(code.slice(close2 + 1, close2 + 100))) continue;
3946
+ const required = /* @__PURE__ */ new Set();
3947
+ const requirements = indirectCall ? extra?.requirements?.get(indirectCall[1]) ?? [] : [];
3948
+ const argumentsOfCall = requirements.length ? argumentExpressions(code, commentsMaskedOf(file), i + call[0].length, close2, pairs) : [];
3949
+ let valid = true;
3950
+ for (const argument of requirements) {
3951
+ const origin = authValuesOf(file, fn, bodies, pairs).value(argumentsOfCall[argument] ?? "undefined", i);
3952
+ const dependencies = identityRequirement(origin, !!observe);
3953
+ if (!dependencies) {
3954
+ valid = false;
3955
+ break;
3956
+ }
3957
+ dependencies.forEach((index) => required.add(index));
3958
+ }
3959
+ if (!valid) continue;
3960
+ guardRequirements.set(owner.start, [...required]);
3364
3961
  if (indirectCall) guardNames.set(owner.start, indirectCall[1]);
3365
3962
  return close2 + 1;
3366
3963
  }
@@ -3379,6 +3976,8 @@ function unguardedOperations(file, ops, extra) {
3379
3976
  while (active.length && active[active.length - 1].end < op.index) active.pop();
3380
3977
  const wrapper = wrappers.find((w) => w.start < op.index && w.end > op.index);
3381
3978
  if (wrapper) {
3979
+ if (extra?.requirements?.get(wrapper.name)?.length) return true;
3980
+ observe?.(op.index, []);
3382
3981
  if (extra?.wrappers.has(wrapper.name)) extra.onGuard(op.index, wrapper.name);
3383
3982
  return false;
3384
3983
  }
@@ -3389,10 +3988,11 @@ function unguardedOperations(file, ops, extra) {
3389
3988
  const block = active[index];
3390
3989
  let end = guardEnds.get(block.start);
3391
3990
  if (end === void 0) {
3392
- end = guardEnd(block);
3991
+ end = guardEnd(block, bodiesByStart.get(active[ownerIndex].start));
3393
3992
  guardEnds.set(block.start, end);
3394
3993
  }
3395
3994
  if (end <= op.index) {
3995
+ observe?.(op.index, guardRequirements.get(block.start) ?? []);
3396
3996
  const name = guardNames.get(block.start);
3397
3997
  if (name) extra?.onGuard(op.index, name);
3398
3998
  return false;
@@ -3605,7 +4205,7 @@ var definitionsCache = /* @__PURE__ */ new WeakMap();
3605
4205
  function guardDefinitions(file) {
3606
4206
  const cached = definitionsCache.get(file);
3607
4207
  if (cached) return cached;
3608
- const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map() };
4208
+ const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map(), candidates: /* @__PURE__ */ new Map(), exportedCandidates: /* @__PURE__ */ new Map() };
3609
4209
  const code = noiseMaskedOf(file);
3610
4210
  const pairs = delimiterPairs(code);
3611
4211
  const openers = new Map([...pairs].map(([open, close]) => [close, open]));
@@ -3623,8 +4223,18 @@ function guardDefinitions(file) {
3623
4223
  const target = byDeclaration.get(nestedAt) ?? body;
3624
4224
  let at = target.end;
3625
4225
  for (let i = target.start + 1; i < target.end; i++) {
4226
+ const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
4227
+ if (conditional && (i === 0 || !/[\w$]/.test(code[i - 1]))) {
4228
+ const close2 = pairs.get(i + conditional[0].length - 1);
4229
+ if (close2 !== void 0) {
4230
+ let start = close2 + 1;
4231
+ while (/\s/.test(code[start] ?? "")) start++;
4232
+ i = statementEnd2(code, start, target.end, pairs) - 1;
4233
+ continue;
4234
+ }
4235
+ }
3626
4236
  if (code.startsWith("return", i) && !/[\w$]/.test(code[i - 1] ?? "") && !/[\w$]/.test(code[i + 6] ?? "")) {
3627
- at = i;
4237
+ at = Math.min(target.end, statementEnd2(code, i, target.end, pairs));
3628
4238
  break;
3629
4239
  }
3630
4240
  const close = pairs.get(i);
@@ -3636,14 +4246,26 @@ function guardDefinitions(file) {
3636
4246
  wrapper: target !== body
3637
4247
  };
3638
4248
  const exported = !declaration.exported ? null : /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration)) ? "default" : declaration.name;
3639
- candidates.push({ name: declaration.name, exported, definition, at });
4249
+ candidates.push({ name: declaration.name, exported, definition, at, start: target.start });
4250
+ }
4251
+ const unguarded = /* @__PURE__ */ new Set();
4252
+ const requirements = /* @__PURE__ */ new Map();
4253
+ for (const wrapper of [false, true]) {
4254
+ const ops = candidates.filter((candidate) => candidate.definition.wrapper === wrapper).map((candidate) => ({ index: candidate.at, writes: false })).sort((a, b) => a.index - b.index);
4255
+ for (const hit of unguardedOperations(
4256
+ file,
4257
+ ops,
4258
+ void 0,
4259
+ !wrapper,
4260
+ (index, required) => requirements.set(index, required)
4261
+ )) unguarded.add(hit.index);
3640
4262
  }
3641
- const unguarded = new Set(unguardedOperations(
3642
- file,
3643
- candidates.map((candidate) => ({ index: candidate.at, writes: false })).sort((a, b) => a.index - b.index)
3644
- ).map((hit) => hit.index));
3645
4263
  for (const candidate of candidates) {
4264
+ result.candidates.set(candidate.name, candidate);
4265
+ if (candidate.exported) result.exportedCandidates.set(candidate.exported, candidate);
3646
4266
  if (unguarded.has(candidate.at)) continue;
4267
+ candidate.definition.requiredArgs = requirements.get(candidate.at) ?? [];
4268
+ if (candidate.definition.wrapper && candidate.definition.requiredArgs.length) continue;
3647
4269
  result.locals.set(candidate.name, candidate.definition);
3648
4270
  if (candidate.exported) result.exports.set(candidate.exported, candidate.definition);
3649
4271
  }
@@ -3657,23 +4279,33 @@ function bindingModule(spec, file, allFiles, scope) {
3657
4279
  const found = new Set(keys.flatMap((key) => moduleIndexOf(allFiles).get(moduleKey(key)) ?? []));
3658
4280
  return found.size === 1 ? [...found][0] : null;
3659
4281
  }
3660
- function exportedGuard(file, name, allFiles, scope, depth = 0, seen = /* @__PURE__ */ new Set()) {
4282
+ function exportedGuard(file, name, allFiles, scope, state, depth = 0, seen = /* @__PURE__ */ new Set()) {
3661
4283
  const key = `${file.path}\0${name}`;
3662
- if (depth >= 8 || seen.has(key)) return null;
3663
- seen.add(key);
4284
+ if (seen.has(key)) return null;
4285
+ if (depth >= 8 || state.remaining-- <= 0) {
4286
+ state.truncated = true;
4287
+ return null;
4288
+ }
4289
+ seen = new Set(seen).add(key);
3664
4290
  const definitions = guardDefinitions(file);
4291
+ const candidate = definitions.exportedCandidates.get(name);
4292
+ if (candidate && guardBindings(file).shadowed.has(candidate.name)) return null;
3665
4293
  const direct = definitions.exports.get(name);
3666
4294
  if (direct) return direct;
4295
+ if (candidate) return delegatedGuard(file, candidate, allFiles, scope, state, depth, seen);
3667
4296
  const bindings = bindingsOf(file);
3668
4297
  const exported = bindings.exports.find((binding) => binding.local === name);
3669
4298
  const follow = (spec, imported) => {
3670
4299
  const target = bindingModule(spec, file, allFiles, scope);
3671
- return target ? exportedGuard(target, imported, allFiles, scope, depth + 1, seen) : null;
4300
+ return target ? exportedGuard(target, imported, allFiles, scope, state, depth + 1, seen) : null;
3672
4301
  };
3673
4302
  if (exported) {
3674
4303
  if (exported.spec) return follow(exported.spec, exported.imported);
4304
+ if (guardBindings(file).shadowed.has(exported.imported)) return null;
3675
4305
  const local = definitions.locals.get(exported.imported);
3676
4306
  if (local) return local;
4307
+ const candidate2 = definitions.candidates.get(exported.imported);
4308
+ if (candidate2) return delegatedGuard(file, candidate2, allFiles, scope, state, depth, seen);
3677
4309
  const imported = bindings.imports.find((binding) => binding.local === exported.imported);
3678
4310
  if (imported?.spec) return follow(imported.spec, imported.imported);
3679
4311
  }
@@ -3683,22 +4315,100 @@ function exportedGuard(file, name, allFiles, scope, depth = 0, seen = /* @__PURE
3683
4315
  }
3684
4316
  return null;
3685
4317
  }
3686
- function indirectGuardOperations(route, allFiles) {
4318
+ var guardBindingCache = /* @__PURE__ */ new WeakMap();
4319
+ function guardBindings(file) {
4320
+ const cached = guardBindingCache.get(file);
4321
+ if (cached) return cached;
4322
+ const code = noiseMaskedOf(file);
4323
+ const declarations = code.replace(/\b(?:import|export)\s+(?:[A-Za-z_$][\w$]*\s*,\s*)?\{[^{}]{0,4000}\}/g, (text) => " ".repeat(text.length));
4324
+ const shadowed = /* @__PURE__ */ new Set();
4325
+ const declared = /* @__PURE__ */ new Set();
4326
+ const called = /* @__PURE__ */ new Set();
4327
+ for (const match of declarations.matchAll(/[({,]\s*([A-Za-z_$][\w$]*)\s*[,}):=]|(?<![\w$.])([A-Za-z_$][\w$]*)\s*=(?!=)|(?<![\w$])([A-Za-z_$][\w$]*)\s*=>/g)) {
4328
+ if (match[2] && /\b(?:const|let|var)\s*$/.test(declarations.slice(Math.max(0, match.index - 40), match.index))) continue;
4329
+ shadowed.add(match[1] ?? match[2] ?? match[3]);
4330
+ }
4331
+ for (const match of declarations.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)) {
4332
+ if (declared.has(match[1])) shadowed.add(match[1]);
4333
+ declared.add(match[1]);
4334
+ }
4335
+ for (const match of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(/g)) {
4336
+ if (!/\bfunction\s*\*?\s*$/.test(code.slice(Math.max(0, match.index - 40), match.index))) called.add(match[1]);
4337
+ }
4338
+ const result = { shadowed, declared, called };
4339
+ guardBindingCache.set(file, result);
4340
+ return result;
4341
+ }
4342
+ function namedGuard(file, name, allFiles, scope, state, depth, seen) {
4343
+ const bindings = guardBindings(file);
4344
+ if (bindings.shadowed.has(name)) return null;
4345
+ const definitions = guardDefinitions(file);
4346
+ const candidate = definitions.candidates.get(name);
4347
+ if (candidate) {
4348
+ const key = `${file.path}\0${name}`;
4349
+ if (seen.has(key)) return null;
4350
+ if (depth >= 8 || state.remaining-- <= 0) {
4351
+ state.truncated = true;
4352
+ return null;
4353
+ }
4354
+ return definitions.locals.get(name) ?? delegatedGuard(file, candidate, allFiles, scope, state, depth, new Set(seen).add(key));
4355
+ }
4356
+ if (bindings.declared.has(name)) return null;
4357
+ const imported = bindingsOf(file).imports.find((binding) => binding.local === name);
4358
+ if (!imported?.spec) return null;
4359
+ const target = bindingModule(imported.spec, file, allFiles, scope);
4360
+ return target ? exportedGuard(target, imported.imported, allFiles, scope, state, depth, seen) : null;
4361
+ }
4362
+ function delegatedGuard(file, candidate, allFiles, scope, state, depth, seen) {
4363
+ const code = noiseMaskedOf(file);
4364
+ const calls = /* @__PURE__ */ new Set();
4365
+ for (const match of code.slice(candidate.start + 1, candidate.at).matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(/g)) calls.add(match[1]);
4366
+ const resolved = /* @__PURE__ */ new Map();
4367
+ for (const name of calls) {
4368
+ if (state.remaining <= 0) {
4369
+ state.truncated = true;
4370
+ break;
4371
+ }
4372
+ const guard = namedGuard(file, name, allFiles, scope, state, depth + 1, seen);
4373
+ if (guard && !guard.wrapper) resolved.set(name, guard);
4374
+ }
4375
+ if (resolved.size === 0) return null;
4376
+ let selected;
4377
+ let requiredArgs = [];
4378
+ unguardedOperations(file, [{ index: candidate.at, writes: false }], {
4379
+ guards: new Set(resolved.keys()),
4380
+ wrappers: /* @__PURE__ */ new Set(),
4381
+ requirements: new Map([...resolved].map(([name, guard]) => [name, guard.requiredArgs ?? []])),
4382
+ onGuard: (_index, name) => {
4383
+ selected = resolved.get(name);
4384
+ }
4385
+ }, !candidate.definition.wrapper, (_index, required) => {
4386
+ requiredArgs = required;
4387
+ });
4388
+ return selected ? { ...candidate.definition, requiredArgs, chain: [selected, ...selected.chain ?? []] } : null;
4389
+ }
4390
+ function indirectGuardOperations(route, ctx) {
4391
+ const state = { remaining: 128, truncated: false };
4392
+ const { called } = guardBindings(route.file);
3687
4393
  const names = /* @__PURE__ */ new Map();
3688
- const code = noiseMaskedOf(route.file);
3689
- for (const binding of bindingsOf(route.file).imports) {
3690
- if (!binding.spec) continue;
3691
- const name = namePattern([binding.local]);
3692
- if (new RegExp(`\\b(?:const|let|var|function|class)\\s+${name}(?![\\w$])|[({,]\\s*${name}\\s*[,}):=]|(?<![\\w$])${name}\\s*=>`).test(code)) continue;
3693
- const target = bindingModule(binding.spec, route.file, allFiles, route.scope);
3694
- const definition = target ? exportedGuard(target, binding.imported, allFiles, route.scope) : null;
3695
- if (definition) names.set(binding.local, definition);
4394
+ for (const name of called) {
4395
+ if (state.remaining <= 0) {
4396
+ state.truncated = true;
4397
+ break;
4398
+ }
4399
+ const definition = namedGuard(route.file, name, ctx.files, route.scope, state, 0, /* @__PURE__ */ new Set());
4400
+ if (definition) names.set(name, definition);
3696
4401
  }
4402
+ if (state.truncated) ctx.reportIncomplete(
4403
+ "api/db-access-without-auth",
4404
+ `${route.file.path} reached the authentication resolution limit (8 hops or 128 symbols); unresolved guards were not accepted`
4405
+ );
3697
4406
  const protectedOps = /* @__PURE__ */ new Map();
3698
4407
  if (names.size === 0) return protectedOps;
3699
4408
  unguardedOperations(route.file, unguardedOpsOf(route.file), {
3700
4409
  guards: new Set([...names].filter(([, definition]) => !definition.wrapper).map(([name]) => name)),
3701
4410
  wrappers: new Set([...names].filter(([, definition]) => definition.wrapper).map(([name]) => name)),
4411
+ requirements: new Map([...names].map(([name, guard]) => [name, guard.requiredArgs ?? []])),
3702
4412
  onGuard: (index, name) => {
3703
4413
  const definition = names.get(name);
3704
4414
  if (definition) protectedOps.set(index, definition);
@@ -4073,11 +4783,12 @@ var apiAuthRule = {
4073
4783
  const indirectByFile = /* @__PURE__ */ new Map();
4074
4784
  for (const route of routes) {
4075
4785
  const reachable = route.reachable;
4076
- let ops = unguardedOpsOf(route.file).filter((op) => reachable === void 0 || op.index > reachable.start && op.index < reachable.end);
4786
+ const unguarded = unguardedOpsOf(route.file);
4787
+ let ops = reachable === void 0 ? unguarded : operationsInRange(unguarded, reachable.start, reachable.end);
4077
4788
  if (ops.length === 0) continue;
4078
4789
  let indirectOps = indirectByFile.get(route.file);
4079
4790
  if (!indirectOps) {
4080
- indirectOps = indirectGuardOperations(route, ctx.files);
4791
+ indirectOps = indirectGuardOperations(route, ctx);
4081
4792
  indirectByFile.set(route.file, indirectOps);
4082
4793
  }
4083
4794
  const unprotected = ops.filter((op) => !indirectOps.has(op.index));
@@ -4094,19 +4805,21 @@ var apiAuthRule = {
4094
4805
  const indirect = indirectOps.get(hit.index);
4095
4806
  const line = lineNumberAt(lineStartsCached(route.file), hit.index);
4096
4807
  const excerpt = excerptFor(route.file, line);
4097
- const indirectEvidence = indirect ? [{
4808
+ const indirectEvidence = indirect ? [indirect, ...indirect.chain ?? []].map((guard) => ({
4098
4809
  kind: "auth-helper",
4099
- file: indirect.file.path,
4100
- line: indirect.line,
4101
- description: "Local authentication helper recognized through imports; verify its runtime behaviour and caller coverage."
4102
- }] : [];
4810
+ file: guard.file.path,
4811
+ line: guard.line,
4812
+ description: "Local authentication helper in the recognized delegation chain; verify runtime behaviour and caller coverage."
4813
+ })) : [];
4103
4814
  if (indirect) globalNote.push("A local authentication helper or wrapper was recognized before this operation. This finding is retained for review because indirect control flow is not a proof of authorization.");
4104
4815
  if (admin) {
4105
4816
  const trace = adminEvidence(route, adminModules, ctx.files, line);
4817
+ const evidence = [...trace.evidence ?? [], ...indirectEvidence];
4106
4818
  findings.push({
4107
4819
  ruleId: "api/admin-db-access-without-auth",
4108
4820
  ...trace,
4109
- evidence: [...trace.evidence ?? [], ...indirectEvidence],
4821
+ evidence: evidence.slice(0, 24),
4822
+ evidenceTruncated: trace.evidenceTruncated || evidence.length > 24,
4110
4823
  severity: "P0",
4111
4824
  // 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
4112
4825
  confidence: globalGuard === null && !indirect ? "certain" : "likely",
@@ -4164,6 +4877,14 @@ var apiAuthRule = {
4164
4877
  });
4165
4878
  }
4166
4879
  }
4880
+ for (const file of ctx.files) if (identityFlowLimited(file)) ctx.reportIncomplete(
4881
+ "api/db-access-without-auth",
4882
+ `${file.path} reached the identity flow limit (8 value hops, 4000 expression characters, or 512 assignments or branches per function)`
4883
+ );
4884
+ for (const file of ctx.files) if (controlFlowLimited.has(file)) ctx.reportIncomplete(
4885
+ "api/db-access-without-auth",
4886
+ `${file.path} reached the control-flow proof limit (8 nested branch or exception regions)`
4887
+ );
4167
4888
  return findings;
4168
4889
  }
4169
4890
  };
@@ -4782,7 +5503,7 @@ var RULE_CATALOG = [
4782
5503
  "P0",
4783
5504
  "certain",
4784
5505
  "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro, including workspace applications.",
4785
- "Syntactic guards only; runtime authentication and business authorization are not verified. Local auth imports/re-exports (up to eight hops), SvelteKit hooks and Nuxt middleware may lower confidence, without removing findings."
5506
+ "Syntactic guards only; runtime authentication and business authorization are not verified. Local calls, identity arguments, branches and exception paths are analysed within documented limits. Local auth evidence, SvelteKit hooks and Nuxt middleware may lower confidence without removing findings; exceeded limits disclose incomplete coverage."
4786
5507
  ),
4787
5508
  rule(
4788
5509
  "api/db-write-without-auth",