canship 0.3.2 → 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/cli.js CHANGED
@@ -1,18 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { isAbsolute as isAbsolute2, relative as relative_, resolve as resolve2 } from "path";
5
- import { existsSync as existsSync3, realpathSync as realpathSync2, statSync as statSync5, writeFileSync as writeFileSync2 } from "fs";
4
+ import { isAbsolute as isAbsolute3, relative as relative_, resolve as resolve2 } from "path";
5
+ import { existsSync as existsSync3, realpathSync as realpathSync3, statSync as statSync5, writeFileSync as writeFileSync2 } from "fs";
6
6
 
7
7
  // src/rules/patterns.ts
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",
14
15
  name: "OpenAI API key",
15
- pattern: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
16
+ // OpenRouter 密钥同样以 sk- 开头,由下方的专用格式识别。
17
+ pattern: /\bsk-(?!ant-|or-v1-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
16
18
  impact: "Anyone with this key can spend your OpenAI credit. Leaked keys are typically abused within minutes of going public.",
17
19
  rotateAt: "https://platform.openai.com/api-keys"
18
20
  },
@@ -23,6 +25,50 @@ var SECRET_PATTERNS = [
23
25
  impact: "Anyone with this key can spend your Anthropic credit.",
24
26
  rotateAt: "https://console.anthropic.com/settings/keys"
25
27
  },
28
+ // 以下前缀取自各服务官方文档;除 Replicate 外文档未给出长度,只设最小长度以排除示例占位符。
29
+ {
30
+ id: "openrouter",
31
+ name: "OpenRouter API key",
32
+ pattern: /\bsk-or-v1-[A-Za-z0-9]{32,}\b/g,
33
+ impact: "Anyone with this key can spend your OpenRouter credit on any model it routes to, up to the key's limit.",
34
+ rotateAt: "https://openrouter.ai/settings/keys"
35
+ },
36
+ {
37
+ id: "groq",
38
+ name: "Groq API key",
39
+ pattern: /\bgsk_[A-Za-z0-9]{40,}\b/g,
40
+ impact: "Anyone with this key can run models on your Groq account and use up its rate limits and billing.",
41
+ rotateAt: "https://console.groq.com/keys"
42
+ },
43
+ {
44
+ id: "huggingface",
45
+ name: "Hugging Face access token",
46
+ pattern: /\bhf_[A-Za-z0-9]{30,}\b/g,
47
+ impact: "Depending on its scope, this token can read your private models and datasets, push to your repositories, and spend on paid inference.",
48
+ rotateAt: "https://huggingface.co/settings/tokens"
49
+ },
50
+ {
51
+ id: "replicate",
52
+ name: "Replicate API token",
53
+ // 官方文档:共 40 个字符,始终以 r8_ 开头。
54
+ pattern: /\br8_[A-Za-z0-9]{37}\b/g,
55
+ impact: "Anyone with this token can run paid predictions on your Replicate account and read your models and predictions.",
56
+ rotateAt: "https://replicate.com/account/api-tokens"
57
+ },
58
+ {
59
+ id: "xai",
60
+ name: "xAI API key",
61
+ pattern: /\bxai-[A-Za-z0-9]{40,}\b/g,
62
+ impact: "Anyone with this key can call Grok models on your xAI account and spend its credit.",
63
+ rotateAt: "https://console.x.ai"
64
+ },
65
+ {
66
+ id: "perplexity",
67
+ name: "Perplexity API key",
68
+ pattern: /\bpplx-[A-Za-z0-9]{40,}\b/g,
69
+ impact: "Anyone with this key can run Perplexity API requests billed to your account.",
70
+ rotateAt: "https://console.perplexity.ai"
71
+ },
26
72
  {
27
73
  id: "aws-access-key-id",
28
74
  name: "AWS access key ID",
@@ -88,7 +134,7 @@ var SECRET_PATTERNS = [
88
134
  id: "private-key",
89
135
  name: "Private key file contents",
90
136
  rotateLabel: "private key",
91
- pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
137
+ pattern: new RegExp(PRIVATE_KEY_BEGIN_SOURCE, "g"),
92
138
  impact: "A private key in source code can be used to impersonate your server, decrypt traffic, or log into your machines."
93
139
  },
94
140
  {
@@ -134,7 +180,7 @@ function isPlaceholder(secret) {
134
180
  if (LONG_PLACEHOLDER_SEGMENT.test(secret)) return true;
135
181
  if (namesItselfTwice(secret)) return true;
136
182
  if (PLACEHOLDER_SHAPE.test(secret)) return true;
137
- const body = lower.replace(/^(sk-ant-|sk-proj-|sk-|rk_live_|sk_live_|akia|aiza|sg\.|gh[pousr]_)/, "");
183
+ const body = lower.replace(/^(sk-ant-|sk-proj-|sk-or-v1-|sk-|rk_live_|sk_live_|akia|aiza|sg\.|gh[pousr]_|gsk_|hf_|r8_|xai-|pplx-)/, "");
138
184
  if (body.length >= 8) {
139
185
  const distinct = new Set(body.replace(/[^a-z0-9]/g, "")).size;
140
186
  if (distinct <= 3) return true;
@@ -169,13 +215,29 @@ function redactSecret(secret) {
169
215
  return `${head}\u2026(${secret.length} chars)\u2026${tail}`;
170
216
  }
171
217
  function redactLine(line, secret) {
172
- const trimmed = line.trim();
218
+ const trimmed = redactPrivateKeys(line.trim());
173
219
  if (!secret) return trimmed;
174
220
  return trimmed.split(secret).join(redactSecret(secret));
175
221
  }
176
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
+ }
177
239
  function redactAll(text) {
178
- let out = text;
240
+ let out = redactPrivateKeys(text);
179
241
  for (const pat of SECRET_PATTERNS) {
180
242
  const re = new RegExp(pat.pattern.source, pat.pattern.flags);
181
243
  out = out.replace(re, (match) => redactSecret(match));
@@ -190,8 +252,8 @@ function truncate(s, max = 120) {
190
252
  import { basename as basename2 } from "path";
191
253
 
192
254
  // src/walker.ts
193
- import { readdirSync, readFileSync as readFileSync2, statSync as statSync2, lstatSync as lstatSync2, openSync, readSync, closeSync } from "fs";
194
- 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";
195
257
 
196
258
  // src/git.ts
197
259
  import { execFileSync } from "child_process";
@@ -428,6 +490,7 @@ function execGitBatch(executable, root, args, input, options = {}) {
428
490
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
429
491
  var MAX_SCAN_BYTES = 128 * 1024 * 1024;
430
492
  var MAX_SCAN_FILES = 1e4;
493
+ var MAX_WALK_ENTRIES = 5e4;
431
494
  var MAX_WALK_DEPTH = 16;
432
495
  var SKIP_DIRS = /* @__PURE__ */ new Set([
433
496
  "node_modules",
@@ -604,16 +667,16 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
604
667
  ".sqlite3",
605
668
  ".mo"
606
669
  ]);
607
- function probeFileType(absPath) {
670
+ function probeFileType(absPath, limit) {
608
671
  let fd = null;
609
672
  try {
610
673
  fd = openSync(absPath, "r");
611
- const buf = Buffer.alloc(PROBE_BYTES);
612
- const read = readSync(fd, buf, 0, PROBE_BYTES, 0);
674
+ const buf = Buffer.alloc(limit);
675
+ const read = readSync(fd, buf, 0, limit, 0);
613
676
  const head = buf.subarray(0, read);
614
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;
615
- if (hasTextBom) return { kind: "text" };
616
- 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 };
617
680
  } catch (err) {
618
681
  return {
619
682
  kind: "unreadable",
@@ -686,10 +749,13 @@ function listViaGit(root, gitExecutable) {
686
749
  return null;
687
750
  }
688
751
  }
689
- function walkTree(root, skipped, wantAll) {
752
+ function walkTree(root, skipped, wantAll, maxEntries) {
690
753
  const all = [];
691
754
  const found = [];
755
+ let visited = 0;
756
+ let exhausted = false;
692
757
  const walk = (dir, depth) => {
758
+ if (exhausted) return;
693
759
  if (depth > MAX_WALK_DEPTH) {
694
760
  skipped.push({
695
761
  path: relative2(root, dir).split(sep2).join("/") || ".",
@@ -700,7 +766,7 @@ function walkTree(root, skipped, wantAll) {
700
766
  }
701
767
  let entries;
702
768
  try {
703
- entries = readdirSync(dir, { withFileTypes: true });
769
+ entries = opendirSync(dir);
704
770
  } catch (err) {
705
771
  if (!isMissing(err)) {
706
772
  skipped.push({
@@ -711,31 +777,57 @@ function walkTree(root, skipped, wantAll) {
711
777
  }
712
778
  return;
713
779
  }
714
- for (const entry of entries) {
715
- const full = join2(dir, entry.name);
716
- const rel = relative2(root, full).split(sep2).join("/");
717
- if (entry.isSymbolicLink()) {
718
- if (!SKIP_DIRS.has(entry.name)) {
719
- 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;
720
792
  }
721
- continue;
722
- }
723
- if (entry.isDirectory()) {
724
- if (SKIP_DIRS.has(entry.name)) continue;
725
- walk(full, depth + 1);
726
- continue;
727
- }
728
- if (!entry.isFile()) continue;
729
- if (wantAll) all.push(rel);
730
- let isCandidate = isEnvFile(entry.name) || CREDENTIAL_FILENAMES.has(entry.name) || CREDENTIAL_EXTENSIONS.has(extname(entry.name).toLowerCase());
731
- if (!isCandidate && isWorthProbing(rel)) {
732
- const probe = probeFileType(full);
733
- if (probe.kind === "text") isCandidate = true;
734
- else if (probe.kind === "unreadable") {
735
- 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;
736
812
  }
813
+ if (metadata.isDirectory()) {
814
+ if (SKIP_DIRS.has(entry.name)) continue;
815
+ walk(full, depth + 1);
816
+ continue;
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);
737
822
  }
738
- 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();
739
831
  }
740
832
  };
741
833
  walk(root, 0);
@@ -779,11 +871,41 @@ function isExampleContext(relPath) {
779
871
  if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
780
872
  return false;
781
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
+ }
782
903
  function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
904
+ root = realpathSync2(root);
783
905
  const skipped = [];
784
906
  const ignored = [];
785
907
  const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
786
- const walked = walkTree(root, skipped, fromGit === null);
908
+ const walked = walkTree(root, skipped, fromGit === null, limits.maxEntries ?? MAX_WALK_ENTRIES);
787
909
  const listed = fromGit?.files ?? walked.all;
788
910
  const candidates = /* @__PURE__ */ new Set();
789
911
  let vendored = 0;
@@ -817,14 +939,10 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
817
939
  const absPath = join2(root, relPath);
818
940
  let content;
819
941
  try {
820
- if (lstatSync2(absPath).isSymbolicLink()) {
821
- if (!skipped.some((entry) => entry.path === relPath && entry.reason === "symlink")) {
822
- skipped.push({ path: relPath, reason: "symlink", detail: "symbolic links are not followed" });
823
- }
824
- continue;
825
- }
942
+ if (!readablePath(root, absPath, skipped)) continue;
826
943
  const size = statSync2(absPath).size;
827
- if (size > MAX_FILE_BYTES) {
944
+ const needsProbe = isWorthProbing(relPath);
945
+ if (!needsProbe && size > MAX_FILE_BYTES) {
828
946
  skipped.push({
829
947
  path: relPath,
830
948
  reason: "too-large",
@@ -832,7 +950,8 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
832
950
  });
833
951
  continue;
834
952
  }
835
- if (filesRead >= maxFiles || bytesRead + size > maxBytes) {
953
+ const initialBytes = needsProbe ? Math.min(size, PROBE_BYTES) : size;
954
+ if (filesRead >= maxFiles || bytesRead + initialBytes > maxBytes) {
836
955
  skipped.push({
837
956
  path: relPath,
838
957
  reason: "too-large",
@@ -840,9 +959,30 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
840
959
  });
841
960
  break;
842
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
+ }
843
984
  const bytes = readFileSync2(absPath);
844
985
  bytesRead += bytes.length;
845
- filesRead++;
846
986
  if (bytesRead > maxBytes) {
847
987
  skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
848
988
  break;
@@ -952,6 +1092,69 @@ function endOfString(src, start, quote) {
952
1092
  }
953
1093
  return length;
954
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
+ }
955
1158
  function maskTemplate(src, out, start, maskStrings = true) {
956
1159
  const length = src.length;
957
1160
  const stack = [{ kind: "literal", from: start + 1 }];
@@ -967,12 +1170,14 @@ function maskTemplate(src, out, start, maskStrings = true) {
967
1170
  if (ch === BACKTICK) {
968
1171
  if (maskStrings) blank(out, frame.from, i);
969
1172
  stack.pop();
1173
+ const parent = stack[stack.length - 1];
1174
+ if (parent?.kind === "expression") valueEnded(parent.lexical);
970
1175
  i++;
971
1176
  continue;
972
1177
  }
973
1178
  if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
974
1179
  if (maskStrings) blank(out, frame.from, i);
975
- stack.push({ kind: "expression", depth: 1 });
1180
+ stack.push({ kind: "expression", depth: 1, lexical: regexContext() });
976
1181
  i += 2;
977
1182
  continue;
978
1183
  }
@@ -988,10 +1193,17 @@ function maskTemplate(src, out, start, maskStrings = true) {
988
1193
  i = stop;
989
1194
  continue;
990
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
+ }
991
1202
  }
992
1203
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
993
1204
  const stop = endOfString(src, i, ch);
994
1205
  if (maskStrings) blank(out, i + 1, stop - 1);
1206
+ valueEnded(frame.lexical);
995
1207
  i = stop;
996
1208
  continue;
997
1209
  }
@@ -1009,7 +1221,7 @@ function maskTemplate(src, out, start, maskStrings = true) {
1009
1221
  if (parent.kind === "literal") parent.from = i + 1;
1010
1222
  }
1011
1223
  }
1012
- i++;
1224
+ i = advanceCode(src, i, frame.lexical);
1013
1225
  }
1014
1226
  const last = stack[stack.length - 1];
1015
1227
  if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
@@ -1019,6 +1231,7 @@ function maskJsComments(src) {
1019
1231
  const length = src.length;
1020
1232
  const out = codesOf(src);
1021
1233
  let i = 0;
1234
+ const lexical = regexContext();
1022
1235
  while (i < length) {
1023
1236
  const ch = src.charCodeAt(i);
1024
1237
  if (ch === SLASH) {
@@ -1038,15 +1251,24 @@ function maskJsComments(src) {
1038
1251
  continue;
1039
1252
  }
1040
1253
  }
1254
+ if (ch === SLASH) {
1255
+ const regex = regexAt(src, i, lexical);
1256
+ if (regex) {
1257
+ i = regex.end;
1258
+ continue;
1259
+ }
1260
+ }
1041
1261
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1042
1262
  i = endOfString(src, i, ch);
1263
+ valueEnded(lexical);
1043
1264
  continue;
1044
1265
  }
1045
1266
  if (ch === BACKTICK) {
1046
1267
  i = maskTemplate(src, out, i, false);
1268
+ valueEnded(lexical);
1047
1269
  continue;
1048
1270
  }
1049
- i++;
1271
+ i = advanceCode(src, i, lexical);
1050
1272
  }
1051
1273
  return stringOf(out);
1052
1274
  }
@@ -1054,6 +1276,7 @@ function maskJsNoise(src) {
1054
1276
  const length = src.length;
1055
1277
  const out = codesOf(src);
1056
1278
  let i = 0;
1279
+ const lexical = regexContext();
1057
1280
  while (i < length) {
1058
1281
  const ch = src.charCodeAt(i);
1059
1282
  if (ch === SLASH) {
@@ -1073,17 +1296,27 @@ function maskJsNoise(src) {
1073
1296
  continue;
1074
1297
  }
1075
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
+ }
1076
1307
  if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1077
1308
  const stop = endOfString(src, i, ch);
1078
1309
  blank(out, i + 1, stop - 1);
1310
+ valueEnded(lexical);
1079
1311
  i = stop;
1080
1312
  continue;
1081
1313
  }
1082
1314
  if (ch === BACKTICK) {
1083
1315
  i = maskTemplate(src, out, i);
1316
+ valueEnded(lexical);
1084
1317
  continue;
1085
1318
  }
1086
- i++;
1319
+ i = advanceCode(src, i, lexical);
1087
1320
  }
1088
1321
  return stringOf(out);
1089
1322
  }
@@ -1364,15 +1597,13 @@ function parseEnv(file) {
1364
1597
  var exposureRule = {
1365
1598
  id: "exposure/public-env",
1366
1599
  severity: "P0",
1367
- appliesTo(file) {
1368
- const name = basename3(file.path);
1369
- if (isEnvFile(name)) return true;
1370
- return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
1600
+ appliesTo() {
1601
+ return true;
1371
1602
  },
1372
1603
  /** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
1373
1604
  check(file, ctx) {
1374
1605
  const name = basename3(file.path);
1375
- const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
1606
+ const findings = isEnvFile(name) ? checkEnvFile(file) : FRONTEND_SOURCE.test(name) ? checkSourceFile(file) : checkOtherFile(file);
1376
1607
  if (!findings.overflow) return findings.items;
1377
1608
  ctx.reportIncomplete(
1378
1609
  "exposure/public-env",
@@ -1460,38 +1691,46 @@ function checkEnvFile(file) {
1460
1691
  }
1461
1692
  return findings;
1462
1693
  }
1694
+ var FRONTEND_SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/;
1695
+ function checkOtherFile(file) {
1696
+ const findings = new FindingBuffer();
1697
+ file.lines.forEach((line, i) => pushServiceRoleJwts(findings, file, line, i, false));
1698
+ return findings;
1699
+ }
1700
+ function pushServiceRoleJwts(findings, file, line, i, clientSide) {
1701
+ JWT_SHAPED2.lastIndex = 0;
1702
+ const jwtMatches = line.match(JWT_SHAPED2);
1703
+ if (!jwtMatches) return;
1704
+ for (const jwt of jwtMatches) {
1705
+ if (!isSupabaseServiceRole(jwt)) continue;
1706
+ findings.push({
1707
+ ruleId: "exposure/supabase-service-role-in-client",
1708
+ severity: "P0",
1709
+ confidence: "certain",
1710
+ title: clientSide ? "Your Supabase admin key is hardcoded in a client component" : "Your Supabase admin key is hardcoded in source code",
1711
+ file: file.path,
1712
+ line: i + 1,
1713
+ excerpt: redactLine(line, jwt),
1714
+ why: [
1715
+ `This is the service_role key \u2014 it bypasses every Row Level Security policy and is effectively your database root password.`,
1716
+ clientSide ? `This file starts with 'use client', so it is shipped to the browser in full. Any visitor can read this key.` : `Hardcoding it in source means it is in your git history, and it will be bundled anywhere this file is imported from client code.`
1717
+ ],
1718
+ fix: [
1719
+ `Remove the key from the source file entirely.`,
1720
+ `Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
1721
+ ],
1722
+ humanOnly: [
1723
+ `Rotate the key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
1724
+ ]
1725
+ });
1726
+ }
1727
+ }
1463
1728
  function checkSourceFile(file) {
1464
1729
  const findings = new FindingBuffer();
1465
1730
  const clientSide = isClientCode(file);
1466
1731
  const commentless = commentsMaskedOf(file).split(/\r?\n/);
1467
1732
  file.lines.forEach((line, i) => {
1468
- JWT_SHAPED2.lastIndex = 0;
1469
- const jwtMatches = line.match(JWT_SHAPED2);
1470
- if (jwtMatches) {
1471
- for (const jwt of jwtMatches) {
1472
- if (!isSupabaseServiceRole(jwt)) continue;
1473
- findings.push({
1474
- ruleId: "exposure/supabase-service-role-in-client",
1475
- severity: "P0",
1476
- confidence: "certain",
1477
- title: clientSide ? "Your Supabase admin key is hardcoded in a client component" : "Your Supabase admin key is hardcoded in source code",
1478
- file: file.path,
1479
- line: i + 1,
1480
- excerpt: redactLine(line, jwt),
1481
- why: [
1482
- `This is the service_role key \u2014 it bypasses every Row Level Security policy and is effectively your database root password.`,
1483
- clientSide ? `This file starts with 'use client', so it is shipped to the browser in full. Any visitor can read this key.` : `Hardcoding it in source means it is in your git history, and it will be bundled anywhere this file is imported from client code.`
1484
- ],
1485
- fix: [
1486
- `Remove the key from the source file entirely.`,
1487
- `Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
1488
- ],
1489
- humanOnly: [
1490
- `Rotate the key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
1491
- ]
1492
- });
1493
- }
1494
- }
1733
+ pushServiceRoleJwts(findings, file, line, i, clientSide);
1495
1734
  const envRef = /(?:process\.env|import\.meta\.env)(?:\.([A-Z_][A-Z0-9_]*)|\[\s*['"]([A-Z_][A-Z0-9_]*)['"]\s*\])/g;
1496
1735
  let m;
1497
1736
  while ((m = envRef.exec(commentless[i] ?? "")) !== null) {
@@ -1640,6 +1879,8 @@ function historicalEvidence(root, gitExecutable, entry) {
1640
1879
  "log",
1641
1880
  "--no-ext-diff",
1642
1881
  "--no-textconv",
1882
+ "--no-renames",
1883
+ "--diff-filter=d",
1643
1884
  "--all",
1644
1885
  "--format=%H",
1645
1886
  `--max-count=${MAX_HISTORY_REVISIONS + 1}`,
@@ -1785,6 +2026,194 @@ var gitleakRule = {
1785
2026
  }
1786
2027
  };
1787
2028
 
2029
+ // src/rules/sqlpolicy.ts
2030
+ var MAX_STATEMENT = 4e3;
2031
+ function statementEnd(masked, from, onTruncated) {
2032
+ const limit = Math.min(masked.length, from + MAX_STATEMENT);
2033
+ const semicolon = masked.slice(from, limit).indexOf(";");
2034
+ if (semicolon === -1 && limit < masked.length && masked[limit] !== ";") onTruncated?.();
2035
+ return semicolon === -1 ? limit : from + semicolon;
2036
+ }
2037
+ function parenthesized(masked, open, end) {
2038
+ let depth = 0;
2039
+ for (let i = open; i < end; i++) {
2040
+ if (masked[i] === "(") depth++;
2041
+ else if (masked[i] === ")" && --depth === 0) return { from: open + 1, to: i };
2042
+ }
2043
+ return null;
2044
+ }
2045
+ function normalizeExpression(text) {
2046
+ const parts = [];
2047
+ for (let i = 0; i < text.length; ) {
2048
+ const ch = text[i];
2049
+ if (ch === "'") {
2050
+ const start2 = i++;
2051
+ let closed = false;
2052
+ while (i < text.length) {
2053
+ if (text[i++] !== "'") continue;
2054
+ if (text[i] === "'") {
2055
+ i++;
2056
+ continue;
2057
+ }
2058
+ closed = true;
2059
+ break;
2060
+ }
2061
+ if (!closed) return "";
2062
+ parts.push(text.slice(start2, i));
2063
+ } else if (text.startsWith("--", i)) {
2064
+ const end2 = text.indexOf("\n", i + 2);
2065
+ i = end2 === -1 ? text.length : end2;
2066
+ } else if (text.startsWith("/*", i)) {
2067
+ let depth = 1;
2068
+ i += 2;
2069
+ while (i < text.length && depth > 0) {
2070
+ if (text.startsWith("/*", i)) {
2071
+ depth++;
2072
+ i += 2;
2073
+ } else if (text.startsWith("*/", i)) {
2074
+ depth--;
2075
+ i += 2;
2076
+ } else i++;
2077
+ }
2078
+ if (depth !== 0) return "";
2079
+ } else {
2080
+ if (!/\s/.test(ch)) parts.push(ch.toLowerCase());
2081
+ i++;
2082
+ }
2083
+ }
2084
+ const out = parts.join("");
2085
+ let start = 0;
2086
+ let end = out.length;
2087
+ while (out[start] === "(") start++;
2088
+ while (end > start && out[end - 1] === ")") end--;
2089
+ return start === out.length - end ? out.slice(start, end) : out;
2090
+ }
2091
+ function isAlwaysTrue(expression) {
2092
+ const e = normalizeExpression(expression);
2093
+ if (e === "true") return true;
2094
+ const numbers = /^(\d+)=(\d+)$/.exec(e);
2095
+ if (numbers) return numbers[1] === numbers[2];
2096
+ const strings = /^'((?:[^']|'')*)'='((?:[^']|'')*)'$/.exec(e);
2097
+ return strings !== null && strings[1] === strings[2];
2098
+ }
2099
+ function bucketOnlyCondition(expression) {
2100
+ return /^(?:(?:storage\.)?objects\.)?bucket_id='((?:[^']|'')*)'$/.exec(normalizeExpression(expression))?.[1]?.replace(/''/g, "'") ?? null;
2101
+ }
2102
+ function unquoteIdent(ident) {
2103
+ const quoted = /^"(.*)"$/.exec(ident);
2104
+ return quoted ? quoted[1].replace(/""/g, '"') : ident.toLowerCase();
2105
+ }
2106
+ var IDENT = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
2107
+ var TARGET = String.raw`(${IDENT})\s+on\s+(?:(${IDENT})\s*\.\s*)?(${IDENT})`;
2108
+ var CREATE_POLICY = new RegExp(String.raw`\bcreate\s+policy\s+${TARGET}`, "gid");
2109
+ var DROP_POLICY = new RegExp(String.raw`\bdrop\s+policy\s+(?:if\s+exists\s+)?${TARGET}`, "gid");
2110
+ var ALTER_POLICY = new RegExp(String.raw`\balter\s+policy\s+${TARGET}`, "gid");
2111
+ function identAt(source, match, group) {
2112
+ const range = match.indices?.[group];
2113
+ return range ? unquoteIdent(source.slice(range[0], range[1])) : void 0;
2114
+ }
2115
+ function policyClauses(masked, source, from, end) {
2116
+ const text = masked.slice(from, end);
2117
+ const clauses = {};
2118
+ const firstExpression = text.search(/\busing\s*\(|\bwith\s+check\s*\(/i);
2119
+ const head = firstExpression === -1 ? text : text.slice(0, firstExpression);
2120
+ const rename = /\brename\s+to\s+("[^"]+"|[a-z_][\w$]*)/id.exec(head);
2121
+ if (rename) {
2122
+ const [start, stop] = rename.indices[1];
2123
+ clauses.renamedTo = unquoteIdent(source.slice(from + start, from + stop));
2124
+ return clauses;
2125
+ }
2126
+ const as = /\bas\s+(permissive|restrictive)\b/i.exec(head);
2127
+ if (as) clauses.restrictive = as[1].toLowerCase() === "restrictive";
2128
+ const command = /\bfor\s+(all|select|insert|update|delete)\b/i.exec(head);
2129
+ if (command) clauses.command = command[1].toLowerCase();
2130
+ const roles = /\bto\s+([\s\S]+)$/i.exec(head);
2131
+ if (roles) clauses.roles = roles[1].split(",").map((role) => unquoteIdent(role.trim())).filter(Boolean);
2132
+ for (const [key, pattern] of [["using", /\busing\s*\(/i], ["check", /\bwith\s+check\s*\(/i]]) {
2133
+ const at = text.search(pattern);
2134
+ if (at === -1) continue;
2135
+ const open = from + at + text.slice(at).indexOf("(");
2136
+ const range = parenthesized(masked, open, end);
2137
+ if (range) clauses[key] = source.slice(range.from, range.to);
2138
+ }
2139
+ return clauses;
2140
+ }
2141
+ function literal(source) {
2142
+ const m = /^\s*'((?:[^']|'')*)'/.exec(source);
2143
+ return m ? m[1].replace(/''/g, "'") : null;
2144
+ }
2145
+ function topLevelValues(masked, from, to) {
2146
+ const values = [];
2147
+ let depth = 0;
2148
+ let start = from;
2149
+ for (let i = from; i < to; i++) {
2150
+ const ch = masked[i];
2151
+ if (ch === "(") depth++;
2152
+ else if (ch === ")") depth--;
2153
+ else if (ch === "," && depth === 0) {
2154
+ values.push({ from: start, to: i });
2155
+ start = i + 1;
2156
+ }
2157
+ }
2158
+ values.push({ from: start, to });
2159
+ return values;
2160
+ }
2161
+ var INSERT_BUCKETS = /\binsert\s+into\s+storage\s*\.\s*buckets\s*\(([^()]{0,500})\)\s*values\s*/gi;
2162
+ var UPDATE_BUCKETS = /\bupdate\s+storage\s*\.\s*buckets\s+set\b/gi;
2163
+ function bucketDeclarations(masked, source, onTruncated) {
2164
+ const found = [];
2165
+ let m;
2166
+ INSERT_BUCKETS.lastIndex = 0;
2167
+ while ((m = INSERT_BUCKETS.exec(masked)) !== null) {
2168
+ const columns = m[1].split(",").map((c) => unquoteIdent(c.trim()));
2169
+ const publicIndex = columns.indexOf("public");
2170
+ const idIndex = columns.indexOf("id") !== -1 ? columns.indexOf("id") : columns.indexOf("name");
2171
+ const end = statementEnd(masked, m.index, onTruncated);
2172
+ let at = m.index + m[0].length;
2173
+ while (at < end) {
2174
+ while (at < end && /[\s,]/.test(masked[at])) at++;
2175
+ if (masked[at] !== "(") break;
2176
+ const tuple = parenthesized(masked, at, end);
2177
+ if (!tuple) break;
2178
+ const values = topLevelValues(masked, tuple.from, tuple.to);
2179
+ const id = idIndex === -1 ? null : literal(source.slice(values[idIndex]?.from ?? 0, values[idIndex]?.to ?? 0));
2180
+ const isPublic = publicIndex !== -1 && source.slice(values[publicIndex]?.from ?? 0, values[publicIndex]?.to ?? 0).trim().toLowerCase() === "true";
2181
+ if (id !== null) found.push({ id, public: isPublic, at: m.index });
2182
+ at = tuple.to + 1;
2183
+ }
2184
+ INSERT_BUCKETS.lastIndex = Math.max(INSERT_BUCKETS.lastIndex, end);
2185
+ }
2186
+ UPDATE_BUCKETS.lastIndex = 0;
2187
+ while ((m = UPDATE_BUCKETS.exec(masked)) !== null) {
2188
+ const end = statementEnd(masked, m.index, onTruncated);
2189
+ const statement = masked.slice(m.index, end);
2190
+ const setPublic = /\bpublic\s*=\s*(true|false)\b/i.exec(statement);
2191
+ const where = /\bwhere\s+(?:id|name)\s*=/i.exec(statement);
2192
+ const id = where ? literal(source.slice(m.index + where.index + where[0].length, end)) : null;
2193
+ if (setPublic && id !== null) found.push({ id, public: setPublic[1].toLowerCase() === "true", at: m.index });
2194
+ UPDATE_BUCKETS.lastIndex = Math.max(UPDATE_BUCKETS.lastIndex, end);
2195
+ }
2196
+ return found.sort((a, b) => a.at - b.at);
2197
+ }
2198
+ function configPublicBuckets(toml) {
2199
+ const found = [];
2200
+ let current = null;
2201
+ toml.split(/\r?\n/).forEach((raw, index) => {
2202
+ const line = raw.replace(/#.*$/, "").trim();
2203
+ const section = /^\[\s*storage\.buckets\.("?)([^"\]]+)\1\s*\]$/.exec(line);
2204
+ if (section) {
2205
+ current = { id: section[2], line: index + 1 };
2206
+ return;
2207
+ }
2208
+ if (line.startsWith("[")) {
2209
+ current = null;
2210
+ return;
2211
+ }
2212
+ if (current && /^public\s*=\s*true$/i.test(line)) found.push(current);
2213
+ });
2214
+ return found;
2215
+ }
2216
+
1788
2217
  // src/rules/supabase.ts
1789
2218
  var INTERNAL_SCHEMAS = /* @__PURE__ */ new Set([
1790
2219
  "auth",
@@ -2011,6 +2440,10 @@ var supabaseRlsRule = {
2011
2440
  ).sort((a, b) => a.path.localeCompare(b.path));
2012
2441
  for (const file of sqlFiles) {
2013
2442
  const sql = maskSqlNoise(file.content);
2443
+ const truncatedStatement = () => ctx.reportIncomplete(
2444
+ "supabase/policy-parse-limit",
2445
+ `${file.path} contains a policy or bucket statement longer than the 4000-character parse limit`
2446
+ );
2014
2447
  const scope = replayScopeOf(file, projectScopeOf(file.path, projectScopes));
2015
2448
  const sqlLines = lineStartsOf(sql);
2016
2449
  let m;
@@ -2062,11 +2495,62 @@ var supabaseRlsRule = {
2062
2495
  line: lineNumberAt(sqlLines, m.index)
2063
2496
  });
2064
2497
  }
2498
+ for (const [pattern, kind] of [
2499
+ [CREATE_POLICY, "policy"],
2500
+ [DROP_POLICY, "policy-drop"],
2501
+ [ALTER_POLICY, "policy-alter"]
2502
+ ]) {
2503
+ pattern.lastIndex = 0;
2504
+ while ((m = pattern.exec(sql)) !== null) {
2505
+ const end = statementEnd(sql, m.index, truncatedStatement);
2506
+ events.push({
2507
+ kind,
2508
+ policy: identAt(file.content, m, 1),
2509
+ schema: m[2] ? unquote(m[2]) : "public",
2510
+ table: unquote(m[3]),
2511
+ ...kind === "policy-drop" ? {} : { clauses: policyClauses(sql, file.content, m.index + m[0].length, end) },
2512
+ file: file.path,
2513
+ scope,
2514
+ at: m.index,
2515
+ line: lineNumberAt(sqlLines, m.index)
2516
+ });
2517
+ }
2518
+ }
2519
+ for (const bucket of bucketDeclarations(sql, file.content, truncatedStatement)) {
2520
+ events.push({
2521
+ kind: "bucket",
2522
+ bucket: { id: bucket.id, public: bucket.public },
2523
+ schema: "storage",
2524
+ table: "buckets",
2525
+ file: file.path,
2526
+ scope,
2527
+ at: bucket.at,
2528
+ line: lineNumberAt(sqlLines, bucket.at)
2529
+ });
2530
+ }
2065
2531
  }
2066
2532
  const fileOrder = new Map(sqlFiles.map((f, i) => [f.path, i]));
2067
2533
  events.sort((a, b) => fileOrder.get(a.file) - fileOrder.get(b.file) || a.at - b.at);
2068
2534
  const live = /* @__PURE__ */ new Map();
2069
2535
  const keyOf = (scope, schema, table) => JSON.stringify([scope, schema, table]);
2536
+ const policies = /* @__PURE__ */ new Map();
2537
+ const policyKey = (scope, schema, table, name) => JSON.stringify([scope, schema, table, name]);
2538
+ const byTable = /* @__PURE__ */ new Map();
2539
+ const setPolicy = (k, p) => {
2540
+ policies.set(k, p);
2541
+ const tableKey = keyOf(p.scope, p.schema, p.table);
2542
+ const set = byTable.get(tableKey) ?? /* @__PURE__ */ new Set();
2543
+ set.add(k);
2544
+ byTable.set(tableKey, set);
2545
+ };
2546
+ const deletePolicy = (k) => {
2547
+ const p = policies.get(k);
2548
+ if (!p) return;
2549
+ policies.delete(k);
2550
+ byTable.get(keyOf(p.scope, p.schema, p.table))?.delete(k);
2551
+ };
2552
+ const policiesOn = (key) => [...byTable.get(key) ?? []].map((k) => [k, policies.get(k)]);
2553
+ const buckets = /* @__PURE__ */ new Map();
2070
2554
  for (const ev of events) {
2071
2555
  const scope = ev.scope;
2072
2556
  const key = keyOf(scope, ev.schema, ev.table);
@@ -2075,15 +2559,66 @@ var supabaseRlsRule = {
2075
2559
  live.set(key, { schema: ev.schema, table: ev.table, file: ev.file, line: ev.line, rls: false });
2076
2560
  } else if (ev.kind === "drop") {
2077
2561
  live.delete(key);
2562
+ for (const [k] of policiesOn(key)) deletePolicy(k);
2078
2563
  } else if (ev.kind === "rename") {
2079
2564
  const cur = live.get(key);
2080
2565
  if (cur) {
2081
2566
  live.delete(key);
2082
2567
  live.set(keyOf(scope, ev.schema, ev.renamedTo), { ...cur, table: ev.renamedTo });
2083
2568
  }
2084
- } else {
2569
+ for (const [k, p] of policiesOn(key)) {
2570
+ deletePolicy(k);
2571
+ setPolicy(policyKey(scope, ev.schema, ev.renamedTo, p.name), { ...p, table: ev.renamedTo });
2572
+ }
2573
+ } else if (ev.kind === "enable-rls" || ev.kind === "disable-rls") {
2085
2574
  const cur = live.get(key);
2086
2575
  if (cur) cur.rls = ev.kind === "enable-rls";
2576
+ } else if (ev.kind === "policy") {
2577
+ const c = ev.clauses ?? {};
2578
+ setPolicy(policyKey(scope, ev.schema, ev.table, ev.policy), {
2579
+ name: ev.policy,
2580
+ scope,
2581
+ schema: ev.schema,
2582
+ table: ev.table,
2583
+ file: ev.file,
2584
+ line: ev.line,
2585
+ restrictive: c.restrictive ?? false,
2586
+ command: c.command ?? "all",
2587
+ roles: c.roles ?? ["public"],
2588
+ ...c.using === void 0 ? {} : { using: c.using },
2589
+ ...c.check === void 0 ? {} : { check: c.check }
2590
+ });
2591
+ } else if (ev.kind === "policy-drop") {
2592
+ deletePolicy(policyKey(scope, ev.schema, ev.table, ev.policy));
2593
+ } else if (ev.kind === "policy-alter") {
2594
+ const k = policyKey(scope, ev.schema, ev.table, ev.policy);
2595
+ const cur = policies.get(k);
2596
+ const c = ev.clauses ?? {};
2597
+ if (cur) {
2598
+ const next = {
2599
+ ...cur,
2600
+ ...c.roles ? { roles: c.roles } : {},
2601
+ // 角色或条件改变时,证据位置跟随实际修改;单纯改名保留原条件位置。
2602
+ ...c.roles !== void 0 || c.using !== void 0 || c.check !== void 0 ? { file: ev.file, line: ev.line } : {},
2603
+ ...c.using === void 0 ? {} : { using: c.using },
2604
+ ...c.check === void 0 ? {} : { check: c.check }
2605
+ };
2606
+ deletePolicy(k);
2607
+ const name = c.renamedTo ?? cur.name;
2608
+ setPolicy(policyKey(scope, ev.schema, ev.table, name), { ...next, name });
2609
+ }
2610
+ } else if (ev.kind === "bucket") {
2611
+ buckets.set(JSON.stringify([scope, ev.bucket.id]), { public: ev.bucket.public, file: ev.file, line: ev.line });
2612
+ }
2613
+ }
2614
+ for (const file of ctx.files) {
2615
+ if (!/(?:^|\/)supabase\/config\.toml$/.test(file.path)) continue;
2616
+ const projectScope = projectScopeOf(file.path, projectScopes);
2617
+ if (!activeScopes.has(projectScope)) continue;
2618
+ const scope = replayScopeOf(file, projectScope);
2619
+ for (const bucket of configPublicBuckets(file.content)) {
2620
+ const k = JSON.stringify([scope, bucket.id]);
2621
+ if (!buckets.has(k)) buckets.set(k, { public: true, file: file.path, line: bucket.line });
2087
2622
  }
2088
2623
  }
2089
2624
  const findings = [];
@@ -2124,80 +2659,306 @@ var supabaseRlsRule = {
2124
2659
  `${unreported} further ${unreported === 1 ? "table has" : "tables have"} no Row Level Security in your migrations beyond the ${MAX_FINDINGS_PER_FILE} listed; they were not reported individually`
2125
2660
  );
2126
2661
  }
2662
+ findings.push(...policyFindings(ctx, [...policies.values()], live, keyOf, buckets));
2127
2663
  return findings;
2128
2664
  }
2129
2665
  };
2666
+ var PRIVILEGED_ROLES = /* @__PURE__ */ new Set(["service_role", "postgres", "supabase_admin"]);
2667
+ function audienceOf(roles) {
2668
+ if (roles.includes("public") || roles.includes("anon")) return "anyone, signed in or not,";
2669
+ if (roles.includes("authenticated")) return "any signed-in user";
2670
+ return `the ${roles.join(", ")} role${roles.length === 1 ? "" : "s"}`;
2671
+ }
2672
+ function policyFindings(ctx, policies, live, keyOf, buckets) {
2673
+ const lines = new Map(ctx.files.map((file) => [file.path, file.lines]));
2674
+ const excerpt = (file, line) => lines.get(file)?.[line - 1]?.trim() ?? null;
2675
+ const findings = [];
2676
+ let unreported = 0;
2677
+ const push = (finding) => {
2678
+ if (findings.length >= MAX_FINDINGS_PER_FILE) unreported++;
2679
+ else findings.push(finding);
2680
+ };
2681
+ const publicBuckets = /* @__PURE__ */ new Map();
2682
+ for (const [key, bucket] of buckets) {
2683
+ if (!bucket.public) continue;
2684
+ const [scope, id] = JSON.parse(key);
2685
+ const ids = publicBuckets.get(scope) ?? [];
2686
+ ids.push(id);
2687
+ publicBuckets.set(scope, ids);
2688
+ }
2689
+ const restrictiveCommands = /* @__PURE__ */ new Map();
2690
+ for (const policy of policies) {
2691
+ if (!policy.restrictive) continue;
2692
+ const key = keyOf(policy.scope, policy.schema, policy.table);
2693
+ const commands = restrictiveCommands.get(key) ?? /* @__PURE__ */ new Set();
2694
+ commands.add(policy.command);
2695
+ restrictiveCommands.set(key, commands);
2696
+ }
2697
+ for (const p of policies) {
2698
+ if (p.restrictive || p.roles.every((role) => PRIVILEGED_ROLES.has(role))) continue;
2699
+ if (live.get(keyOf(p.scope, p.schema, p.table))?.rls === false) continue;
2700
+ const table = `${renderIdent(p.schema)}.${renderIdent(p.table)}`;
2701
+ const audience = audienceOf(p.roles);
2702
+ const restrictions = restrictiveCommands.get(keyOf(p.scope, p.schema, p.table));
2703
+ const hasRestriction = restrictions !== void 0 && (p.command === "all" || restrictions.has("all") || restrictions.has(p.command));
2704
+ if (p.schema === "storage" && p.table === "objects" && (p.command === "select" || p.command === "all") && p.using !== void 0) {
2705
+ const onlyBucket = bucketOnlyCondition(p.using);
2706
+ const everyBucket = isAlwaysTrue(p.using);
2707
+ const listable = (publicBuckets.get(p.scope) ?? []).filter((id) => everyBucket || onlyBucket === id);
2708
+ for (const id of listable) {
2709
+ push({
2710
+ ruleId: "supabase/public-bucket-listing",
2711
+ severity: "P2",
2712
+ confidence: hasRestriction ? "likely" : "certain",
2713
+ title: hasRestriction ? `Policy "${p.name}" permits listing the public "${id}" bucket, subject to restrictive policies` : `Policy "${p.name}" lets ${audience} list every file in the public "${id}" bucket`,
2714
+ file: p.file,
2715
+ line: p.line,
2716
+ excerpt: excerpt(p.file, p.line),
2717
+ why: [
2718
+ `Files in a public bucket can already be downloaded by anyone who has their URL, without any policy. This SELECT policy on storage.objects adds something else: it lets callers list the bucket's contents.`,
2719
+ `Listing reveals every file name, including files uploaded by other users that were only meant to be reachable by someone who already had the link.`,
2720
+ ...hasRestriction ? ["Restrictive policies also apply with AND; their conditions and role coverage require review."] : []
2721
+ ],
2722
+ fix: [
2723
+ `If the app only serves files by URL, drop this policy: public object URLs keep working without it.`,
2724
+ `If the app really needs listing, make the bucket private and scope the policy to the owner, e.g. USING (bucket_id = '${id.replace(/'/g, "''")}' AND owner_id = (select auth.uid()::text)).`
2725
+ ]
2726
+ });
2727
+ }
2728
+ }
2729
+ const usingTrue = p.using !== void 0 && isAlwaysTrue(p.using);
2730
+ const checkTrue = p.check !== void 0 && isAlwaysTrue(p.check);
2731
+ const opensRows = usingTrue && p.command !== "insert";
2732
+ const opensWrites = checkTrue && (p.command === "insert" || p.command === "update" || p.command === "all");
2733
+ if (!opensRows && !opensWrites) continue;
2734
+ const modifiesAny = usingTrue && (p.command === "update" || p.command === "delete" || p.command === "all");
2735
+ const limitedCheck = (p.command === "update" || p.command === "all") && p.check !== void 0 && !checkTrue;
2736
+ const needsReview = hasRestriction || limitedCheck;
2737
+ const verb = { all: "read, change and delete", select: "read", insert: "insert any data into", update: "change", delete: "delete" }[p.command];
2738
+ push({
2739
+ ruleId: "supabase/permissive-policy",
2740
+ severity: "P1",
2741
+ confidence: modifiesAny && !needsReview ? "certain" : "likely",
2742
+ title: needsReview ? `Policy "${p.name}" has an unrestricted condition on ${table}; other conditions require review` : opensRows ? `Policy "${p.name}" lets ${audience} ${verb} every row of ${table}` : `Policy "${p.name}" lets ${audience} write any values into ${table}`,
2743
+ file: p.file,
2744
+ line: p.line,
2745
+ excerpt: excerpt(p.file, p.line),
2746
+ why: [
2747
+ `This policy contains an always-true condition. Permissive policies combine with OR, while applicable restrictive policies combine with AND. Database grants, SELECT policies and WITH CHECK conditions may further limit access.`,
2748
+ ...hasRestriction ? ["A restrictive policy on this table may limit access; verify its command and role coverage."] : [],
2749
+ ...limitedCheck ? ["The WITH CHECK condition is not known to be always true, so unrestricted updates are not established."] : [],
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.`] : []
2751
+ ],
2752
+ fix: [
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)" : ""}.`),
2754
+ `If a broad policy is needed as a base, add an AS RESTRICTIVE policy alongside it to limit which rows it reaches.`,
2755
+ `Keep the change in a migration, so the rule travels with your code.`
2756
+ ],
2757
+ humanOnly: [
2758
+ `Check the policy in the Supabase dashboard (Authentication -> Policies); a policy changed there is not visible in the repository.`
2759
+ ]
2760
+ });
2761
+ }
2762
+ if (unreported > 0) {
2763
+ ctx.reportIncomplete(
2764
+ "supabase/permissive-policy",
2765
+ `${unreported} further permissive ${unreported === 1 ? "policy was" : "policies were"} not reported individually`
2766
+ );
2767
+ }
2768
+ return findings;
2769
+ }
2130
2770
 
2131
2771
  // src/rules/firebase.ts
2132
2772
  import { basename as basename5 } from "path";
2133
2773
  function isRulesFile(file) {
2134
2774
  return basename5(file.path).toLowerCase().endsWith(".rules");
2135
2775
  }
2776
+ function isRealtimeRulesFile(file) {
2777
+ if (!/\.(?:json|rules)$/i.test(file.path)) return false;
2778
+ if (!/"\.(?:read|write)"/.test(file.content)) return false;
2779
+ return /^\s*\{\s*"rules"\s*:\s*\{/.test(commentsMaskedOf(file));
2780
+ }
2781
+ function booleanConstant(value) {
2782
+ if (value === void 0) return null;
2783
+ let decoded;
2784
+ try {
2785
+ decoded = JSON.parse(value);
2786
+ } catch {
2787
+ return null;
2788
+ }
2789
+ if (typeof decoded === "boolean") return decoded;
2790
+ if (typeof decoded !== "string") return null;
2791
+ const match = /^\s*(?:\(\s*)*(true|false)(?:\s*\))*\s*$/.exec(decoded);
2792
+ if (!match || [...decoded.matchAll(/\(/g)].length !== [...decoded.matchAll(/\)/g)].length) return null;
2793
+ return match[1] === "true";
2794
+ }
2795
+ function grantsEveryone(value) {
2796
+ return booleanConstant(value) === true;
2797
+ }
2798
+ function deniesEveryone(value) {
2799
+ return booleanConstant(value) === false;
2800
+ }
2801
+ function decodeKey(token) {
2802
+ try {
2803
+ return JSON.parse(token);
2804
+ } catch {
2805
+ return token.replace(/^"|"$/g, "");
2806
+ }
2807
+ }
2808
+ function checkRealtimeRules(file, ctx) {
2809
+ const text = commentsMaskedOf(file);
2810
+ try {
2811
+ JSON.parse(text);
2812
+ } catch {
2813
+ ctx.reportIncomplete("firebase/open-rules", `${file.path} contains invalid Realtime Database rule JSON; rules were not evaluated`);
2814
+ return [];
2815
+ }
2816
+ const lineStarts = lineStartsOf(text);
2817
+ const findings = [];
2818
+ const stack = [];
2819
+ let pendingKey = null;
2820
+ const report = (node) => {
2821
+ const read = node.rules.get(".read");
2822
+ const write = node.rules.get(".write");
2823
+ const openRead = grantsEveryone(read?.value);
2824
+ const openWrite = grantsEveryone(write?.value);
2825
+ if (!openRead && !openWrite) return;
2826
+ if (!openWrite && deniesEveryone(write?.value)) return;
2827
+ if (findings.length >= MAX_FINDINGS_PER_FILE) {
2828
+ ctx.reportIncomplete(
2829
+ "firebase/open-rules",
2830
+ `${file.path} holds more than ${MAX_FINDINGS_PER_FILE} open rules; the rest were not reported`
2831
+ );
2832
+ return;
2833
+ }
2834
+ const segments = stack.slice(2).map((entry) => entry.key).concat(stack.length >= 2 ? [node.key] : []);
2835
+ const path = `/${segments.join("/")}`.replace(/\/{2,}/g, "/");
2836
+ const at = (openWrite ? write : read).index;
2837
+ const line = lineNumberAt(lineStarts, at);
2838
+ const ops = openRead && openWrite ? "read and write" : openWrite ? "write" : "read";
2839
+ findings.push({
2840
+ ruleId: "firebase/open-rules",
2841
+ severity: "P1",
2842
+ confidence: openWrite ? "certain" : "likely",
2843
+ title: openWrite ? `Your Realtime Database rules let anyone ${ops} ${path === "/" ? "your entire database" : path}` : `Your Realtime Database rules make ${path === "/" ? "your entire database" : path} publicly readable`,
2844
+ file: file.path,
2845
+ line,
2846
+ excerpt: (file.lines[line - 1] ?? "").trim(),
2847
+ why: [
2848
+ `This rule grants ${ops} access to everyone, with no sign-in required. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
2849
+ `Realtime Database rules cascade: access granted at ${path} also applies to everything beneath it, and a stricter rule deeper down cannot take it back.`,
2850
+ ...openWrite ? [] : [
2851
+ `If this data is meant to be public, add ".write": false next to it to make that intent explicit.`
2852
+ ]
2853
+ ],
2854
+ fix: [
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.`,
2856
+ `Test the new rules with the Firebase emulator before deploying.`,
2857
+ ...openWrite ? [`If this database has been open for a while, assume the data has already been copied or changed.`] : []
2858
+ ]
2859
+ });
2860
+ };
2861
+ let i = 0;
2862
+ while (i < text.length) {
2863
+ const ch = text[i];
2864
+ if (ch === '"') {
2865
+ let j = i + 1;
2866
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
2867
+ const token = text.slice(i, j + 1);
2868
+ let k = j + 1;
2869
+ while (k < text.length && /\s/.test(text[k])) k++;
2870
+ if (text[k] === ":" && pendingKey === null) pendingKey = { key: decodeKey(token), index: i };
2871
+ else if (pendingKey !== null) {
2872
+ stack[stack.length - 1]?.rules.set(pendingKey.key, { value: token, index: pendingKey.index });
2873
+ pendingKey = null;
2874
+ }
2875
+ i = j + 1;
2876
+ continue;
2877
+ }
2878
+ if (ch === "{") {
2879
+ stack.push({ key: pendingKey?.key ?? "", rules: /* @__PURE__ */ new Map() });
2880
+ pendingKey = null;
2881
+ } else if (ch === "}") {
2882
+ const node = stack.pop();
2883
+ if (node) report(node);
2884
+ } else if (pendingKey !== null && /[A-Za-z0-9-]/.test(ch)) {
2885
+ let j = i;
2886
+ while (j < text.length && /[A-Za-z0-9.+-]/.test(text[j])) j++;
2887
+ stack[stack.length - 1]?.rules.set(pendingKey.key, { value: text.slice(i, j), index: pendingKey.index });
2888
+ pendingKey = null;
2889
+ i = j;
2890
+ continue;
2891
+ }
2892
+ i++;
2893
+ }
2894
+ return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
2895
+ }
2136
2896
  function productOf(path) {
2137
2897
  const name = basename5(path).toLowerCase();
2138
2898
  if (name.includes("storage")) return "Storage";
2139
2899
  if (name.includes("firestore")) return "Firestore";
2140
2900
  return "Firebase";
2141
2901
  }
2142
- var ALLOW_IF_TRUE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+true\s*;/gi;
2902
+ var OPS = String.raw`([a-z]{1,16}(?:\s*,\s*[a-z]{1,16}){0,8})`;
2903
+ var ALLOW_IF_TRUE = new RegExp(String.raw`\ballow\s+${OPS}\s*:\s*if\s+true\s*;`, "gi");
2904
+ var ALLOW_IF_FALSE = new RegExp(String.raw`\ballow\s+${OPS}\s*:\s*if\s+false\s*;`, "gi");
2143
2905
  var WRITE_OPS = /* @__PURE__ */ new Set(["write", "create", "update", "delete"]);
2144
2906
  function parseOps(raw) {
2145
2907
  return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2146
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
+ }
2147
2921
  function neutralizePathWildcards(content) {
2148
2922
  return content.replace(/\{[a-zA-Z_]\w*(?:\s*=\s*\*\*)?\}/g, (m) => "_".repeat(m.length));
2149
2923
  }
2150
- function enclosingMatchBlock(neutralized, index) {
2151
- const matchStart = neutralized.slice(0, index).lastIndexOf("match ");
2152
- if (matchStart < 0) return neutralized;
2153
- const braceStart = neutralized.indexOf("{", matchStart);
2154
- if (braceStart < 0 || braceStart > index) return neutralized;
2155
- let depth = 0;
2156
- for (let i = braceStart; i < neutralized.length; i++) {
2157
- if (neutralized[i] === "{") depth++;
2158
- else if (neutralized[i] === "}") {
2159
- depth--;
2160
- if (depth === 0) return neutralized.slice(braceStart, i + 1);
2161
- }
2162
- }
2163
- return neutralized.slice(braceStart);
2164
- }
2165
- function ownStatements(block) {
2166
- let out = "";
2167
- let i = 0;
2168
- while (i < block.length) {
2169
- const rest = block.slice(i);
2170
- const nested = /^\s*match\s+[^{]*\{/.exec(rest);
2171
- if (nested && i > 0) {
2172
- let depth = 0;
2173
- let j = i + nested[0].length - 1;
2174
- for (; j < block.length; j++) {
2175
- if (block[j] === "{") depth++;
2176
- else if (block[j] === "}") {
2177
- depth--;
2178
- if (depth === 0) {
2179
- j++;
2180
- break;
2181
- }
2182
- }
2183
- }
2184
- i = j;
2185
- continue;
2924
+ function matchBlocksOf(neutralized) {
2925
+ const blocks = [];
2926
+ const stack = [];
2927
+ let lastDelimiter = -1;
2928
+ for (let i = 0; i < neutralized.length; i++) {
2929
+ const ch = neutralized[i];
2930
+ if (ch === "{") {
2931
+ stack.push({ open: i, isMatch: /^\s*match\b/.test(neutralized.slice(lastDelimiter + 1, i)) });
2932
+ lastDelimiter = i;
2933
+ } else if (ch === "}") {
2934
+ const top = stack.pop();
2935
+ if (top?.isMatch) blocks.push({ open: top.open, close: i });
2936
+ lastDelimiter = i;
2937
+ } else if (ch === ";") {
2938
+ lastDelimiter = i;
2939
+ }
2940
+ }
2941
+ for (const top of stack) if (top.isMatch) blocks.push({ open: top.open, close: neutralized.length });
2942
+ return blocks.sort((a, b) => a.open - b.open);
2943
+ }
2944
+ function innermostBlocks(blocks, positions) {
2945
+ const owners = [];
2946
+ const active = [];
2947
+ let next = 0;
2948
+ for (const at of positions) {
2949
+ while (next < blocks.length && blocks[next].open < at) {
2950
+ while (active.length && blocks[active[active.length - 1]].close < blocks[next].open) active.pop();
2951
+ active.push(next++);
2186
2952
  }
2187
- out += block[i];
2188
- i++;
2953
+ while (active.length && blocks[active[active.length - 1]].close < at) active.pop();
2954
+ owners.push(active.length ? active[active.length - 1] : -1);
2189
2955
  }
2190
- return out;
2956
+ return owners;
2191
2957
  }
2192
- function blockDeniesWrites(block) {
2193
- const denial = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+false\s*;/gi;
2194
- let m;
2195
- while ((m = denial.exec(block)) !== null) {
2196
- if (parseOps(m[1] ?? "").some((op) => WRITE_OPS.has(op))) return true;
2197
- }
2198
- return false;
2199
- }
2200
- var TEST_MODE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;/gi;
2958
+ var TEST_MODE = new RegExp(
2959
+ String.raw`\ballow\s+${OPS}\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;`,
2960
+ "gi"
2961
+ );
2201
2962
  function describeOps(raw) {
2202
2963
  const ops = parseOps(raw);
2203
2964
  if (ops.includes("write") && ops.includes("read")) return "read and write";
@@ -2208,9 +2969,10 @@ var firebaseRulesRule = {
2208
2969
  id: "firebase/open-rules",
2209
2970
  severity: "P1",
2210
2971
  appliesTo(file) {
2211
- return isRulesFile(file);
2972
+ return isRulesFile(file) || isRealtimeRulesFile(file);
2212
2973
  },
2213
2974
  check(file, ctx) {
2975
+ if (isRealtimeRulesFile(file)) return checkRealtimeRules(file, ctx);
2214
2976
  const findings = [];
2215
2977
  const product = productOf(file.path);
2216
2978
  const capReached = () => {
@@ -2224,13 +2986,18 @@ var firebaseRulesRule = {
2224
2986
  const content = noiseMaskedOf(file);
2225
2987
  const neutralized = neutralizePathWildcards(content);
2226
2988
  const contentLines = lineStartsOf(content);
2227
- ALLOW_IF_TRUE.lastIndex = 0;
2989
+ const blocks = matchBlocksOf(neutralized);
2990
+ const denials = [...neutralized.matchAll(ALLOW_IF_FALSE)].filter((d) => parseOps(d[1] ?? "").some((op) => WRITE_OPS.has(op)));
2991
+ const denyingBlocks = new Set(innermostBlocks(blocks, denials.map((d) => d.index)));
2992
+ const opens = [...content.matchAll(ALLOW_IF_TRUE)];
2993
+ const openOwners = innermostBlocks(blocks, opens.map((o) => o.index));
2228
2994
  let m;
2229
- while ((m = ALLOW_IF_TRUE.exec(content)) !== null) {
2995
+ for (const [index, open] of opens.entries()) {
2996
+ m = open;
2230
2997
  if (capReached()) break;
2231
2998
  const rawOps = m[1] ?? "";
2232
2999
  const canWrite = parseOps(rawOps).some((op) => WRITE_OPS.has(op));
2233
- if (!canWrite && blockDeniesWrites(ownStatements(enclosingMatchBlock(neutralized, m.index)))) continue;
3000
+ if (!canWrite && denyingBlocks.has(openOwners[index])) continue;
2234
3001
  const ops = describeOps(rawOps);
2235
3002
  findings.push({
2236
3003
  ruleId: "firebase/open-rules",
@@ -2249,7 +3016,7 @@ var firebaseRulesRule = {
2249
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.`
2250
3017
  ],
2251
3018
  fix: canWrite ? [
2252
- `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),
2253
3020
  `For data that is genuinely public, restrict it to reads only: allow read: if true; allow write: if false;`,
2254
3021
  `Test your rules with the Firebase emulator before deploying, so you do not lock yourself out.`,
2255
3022
  `If this database has been open for a while, assume the data has already been copied.`
@@ -2287,7 +3054,7 @@ var firebaseRulesRule = {
2287
3054
  `Neither state is what you want in production.`
2288
3055
  ],
2289
3056
  fix: [
2290
- `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),
2291
3058
  `Test the new rules with the Firebase emulator before deploying.`,
2292
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.`
2293
3060
  ]
@@ -2299,9 +3066,309 @@ var firebaseRulesRule = {
2299
3066
 
2300
3067
  // src/rules/apiauth.ts
2301
3068
  import { posix } from "path";
3069
+
3070
+ // src/rules/bindings.ts
3071
+ var cache = /* @__PURE__ */ new WeakMap();
3072
+ function bindingsOf(file) {
3073
+ const cached = cache.get(file);
3074
+ if (cached) return cached;
3075
+ const source = commentsMaskedOf(file);
3076
+ const code = noiseMaskedOf(file);
3077
+ const result = { imports: [], exports: [], stars: [] };
3078
+ const lists = /\b(import|export)\s+(type\s+)?\{([^{}]{0,4000})\}(?:\s*from\s*['"]([^'"\r\n]{1,1024})['"])?/g;
3079
+ for (const match of source.matchAll(lists)) {
3080
+ if (match[2] || code.slice(match.index, match.index + match[1].length) !== match[1]) continue;
3081
+ if (match[1] === "import" && !match[4]) continue;
3082
+ for (const part of match[3].split(",")) {
3083
+ const names = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(part);
3084
+ if (!names) continue;
3085
+ const imported = names[1];
3086
+ const local = names[2] ?? imported;
3087
+ const target = match[1] === "import" ? result.imports : result.exports;
3088
+ target.push({
3089
+ imported,
3090
+ local,
3091
+ ...match[4] ? { spec: match[4] } : {}
3092
+ });
3093
+ }
3094
+ }
3095
+ for (const match of source.matchAll(/\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
3096
+ if (code.slice(match.index, match.index + 6) === "import" && match[1] !== "type") {
3097
+ result.imports.push({ local: match[1], imported: "default", spec: match[2] });
3098
+ }
3099
+ }
3100
+ for (const match of source.matchAll(/\bexport\s*\*\s*from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
3101
+ if (code.slice(match.index, match.index + 6) === "export") result.stars.push(match[1]);
3102
+ }
3103
+ cache.set(file, result);
3104
+ return result;
3105
+ }
3106
+ function namePattern(names) {
3107
+ return [...names].map((name) => name.replace(/\$/g, "\\$")).join("|");
3108
+ }
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
+
3360
+ // src/rules/apiauth.ts
2302
3361
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2303
3362
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
3363
+ var APP_ROUTE_FILE = /(?:^|\/)app\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
3364
+ var NEXT_METHODS = "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS";
3365
+ var NEXT_METHOD_EXPORT = new RegExp(
3366
+ String.raw`\bexport\s+(?:async\s+)?function\s+(?:${NEXT_METHODS})\b|\bexport\s+const\s+(?:${NEXT_METHODS})\b|` + // export { handler as GET } 与 Auth.js 的 export const { GET, POST } = handlers;长度有界以免回溯。
3367
+ String.raw`\bexport\s+(?:const\s*)?\{[^{}]{0,500}\b(?:${NEXT_METHODS})\b`
3368
+ );
2304
3369
  var SVELTEKIT_ENDPOINT = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+server\.[mc]?[jt]s$/;
3370
+ var SVELTEKIT_PAGE_SERVER = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+page\.server\.[mc]?[jt]s$/;
3371
+ var SVELTEKIT_ACTIONS = /\bexport\s+const\s+actions\b\s*(?::[^=;{}]{0,200})?=\s*\{/;
2305
3372
  var NUXT_ROUTE = /^(.*?\/)?server\/(api|routes)\/(.+)\.[mc]?[jt]s$/;
2306
3373
  var H3_HANDLER = /\b(?:defineEventHandler|eventHandler|defineCachedEventHandler|defineLazyEventHandler)\s*\(/;
2307
3374
  var NUXT_METHOD_SUFFIX = /\.(?:get|post|put|patch|delete|head|options|connect|trace)$/i;
@@ -2309,17 +3376,154 @@ var REMIX_ROUTE = /^(.*?\/)?app\/routes\/(.+)\.[mc]?[jt]sx?$/;
2309
3376
  var REMIX_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:loader|action)\b|\bexport\s+const\s+(?:loader|action)\b/;
2310
3377
  var ASTRO_ENDPOINT = /^(.*?\/)?src\/pages\/(.+)\.[mc]?[jt]s$/;
2311
3378
  var ASTRO_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b|\bexport\s+const\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
3379
+ function isAstroEndpoint(file) {
3380
+ const code = noiseMaskedOf(file);
3381
+ return ASTRO_EXPORT.test(code) && !/\bexport\s+default\b/.test(code);
3382
+ }
3383
+ function serverActionRoutes(file) {
3384
+ if (!/use server/.test(file.content) || !/\.[mc]?[jt]sx?$/.test(file.path)) return [];
3385
+ const code = noiseMaskedOf(file);
3386
+ const source = commentsMaskedOf(file);
3387
+ const fileLevel = startsWithDirective(source, 0, "use server");
3388
+ const pairs = delimiterPairs(code);
3389
+ const openers = /* @__PURE__ */ new Map();
3390
+ for (const [open, close] of pairs) openers.set(close, open);
3391
+ const exportedList = /* @__PURE__ */ new Set();
3392
+ for (const m of code.matchAll(/\bexport\s*\{([^{}]{0,1000})\}/g)) {
3393
+ for (const part of m[1].split(",")) {
3394
+ const name = /^\s*([A-Za-z_$][\w$]*)/.exec(part)?.[1];
3395
+ if (name) exportedList.add(name);
3396
+ }
3397
+ }
3398
+ const scope = /^(.*?\/)?(?:src|app|lib|actions|server|utils)\//.exec(file.path)?.[1] ?? "";
3399
+ const routes = [];
3400
+ for (const body of functionBodies(code, pairs)) {
3401
+ const inline = startsWithDirective(source, body.start + 1, "use server");
3402
+ const declared = declarationOf(code, body, openers);
3403
+ const exported = fileLevel && declared !== null && (declared.exported || exportedList.has(declared.name));
3404
+ if (!inline && !exported) continue;
3405
+ const name = declared?.name ?? "anonymous";
3406
+ routes.push({
3407
+ file,
3408
+ framework: "next",
3409
+ url: `the server action ${name}`,
3410
+ scope,
3411
+ reachable: { start: body.start, end: body.end },
3412
+ action: name
3413
+ });
3414
+ }
3415
+ return routes;
3416
+ }
3417
+ function startsWithDirective(source, from, directive) {
3418
+ let i = from;
3419
+ while (i < source.length) {
3420
+ while (i < source.length && /\s/.test(source[i])) i++;
3421
+ const quote = source[i++];
3422
+ if (quote !== "'" && quote !== '"') return false;
3423
+ const start = i;
3424
+ while (i < source.length && source[i] !== quote && !/[\r\n]/.test(source[i])) {
3425
+ if (source[i] === "\\") i++;
3426
+ i++;
3427
+ }
3428
+ if (source[i] !== quote) return false;
3429
+ const value = source.slice(start, i++);
3430
+ let newline = false;
3431
+ while (i < source.length && /\s/.test(source[i])) {
3432
+ if (/[\r\n]/.test(source[i])) newline = true;
3433
+ i++;
3434
+ }
3435
+ const next = source[i];
3436
+ const terminated = next === ";" || next === "}" || next === void 0 || newline && !/[([.`+\-*/%?:,<>=!&|^]/.test(next);
3437
+ if (!terminated) return false;
3438
+ if (value === directive) return true;
3439
+ if (next === ";") i++;
3440
+ }
3441
+ return false;
3442
+ }
3443
+ function declarationOf(code, body, openers) {
3444
+ if (/^function\s*\(/.test(code.slice(body.declaration, body.declaration + 40)) && /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration))) {
3445
+ return { name: "default", exported: true };
3446
+ }
3447
+ const named = /^function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(code.slice(body.declaration, body.declaration + 200));
3448
+ if (named) {
3449
+ const before = code.slice(Math.max(0, body.declaration - 40), body.declaration);
3450
+ return { name: named[1], exported: /\bexport\s+(?:default\s+)?(?:async\s+)?$/.test(before) };
3451
+ }
3452
+ let i = body.declaration - 1;
3453
+ const skipSpace = () => {
3454
+ while (i >= 0 && /\s/.test(code[i])) i--;
3455
+ };
3456
+ const readWord = () => {
3457
+ const end = i + 1;
3458
+ while (i >= 0 && /[\w$]/.test(code[i])) i--;
3459
+ return code.slice(i + 1, end);
3460
+ };
3461
+ const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
3462
+ const close = window.lastIndexOf(")");
3463
+ if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
3464
+ const open = openers.get(body.declaration - window.length + close);
3465
+ if (open === void 0) return null;
3466
+ i = open - 1;
3467
+ } else {
3468
+ skipSpace();
3469
+ if (readWord() === "") return null;
3470
+ }
3471
+ skipSpace();
3472
+ const maybeAsync = i;
3473
+ if (readWord() !== "async") i = maybeAsync;
3474
+ skipSpace();
3475
+ if (code[i] !== "=" || code[i - 1] === "=" || code[i - 1] === "!") return null;
3476
+ i--;
3477
+ skipSpace();
3478
+ let name = readWord();
3479
+ skipSpace();
3480
+ if (code[i] === ":") {
3481
+ i--;
3482
+ skipSpace();
3483
+ name = readWord();
3484
+ skipSpace();
3485
+ }
3486
+ if (name === "" || !/^(?:const|let|var)$/.test(readWord())) return null;
3487
+ skipSpace();
3488
+ return { name, exported: readWord() === "export" };
3489
+ }
3490
+ var AUTH_ACTION_NAMES = /^(?:sign[_-]?(?:in|up|out)|log[_-]?(?:in|out)|register|reset[_-]?password|forgot[_-]?password|verify(?:[_-]?(?:email|otp))?|confirm(?:[_-]?email)?|send[_-]?magic[_-]?link)(?:action)?$/i;
2312
3491
  function routePathOf(path) {
2313
3492
  return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
2314
3493
  }
2315
3494
  function routeOf(file) {
2316
3495
  const path = routePathOf(file.path);
2317
- if (APP_ROUTER.test(path) || PAGES_ROUTER.test(file.path)) {
2318
- const scope = /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(path)?.[1] ?? "";
3496
+ if (PAGES_ROUTER.test(file.path) && ASTRO_ENDPOINT.test(path) && isAstroEndpoint(file)) {
3497
+ const astro2 = ASTRO_ENDPOINT.exec(path);
3498
+ return { file, framework: "astro", url: `/${astro2[2].replace(/(?:^|\/)index$/, "")}`, scope: astro2[1] ?? "" };
3499
+ }
3500
+ if (PAGES_ROUTER.test(file.path)) {
3501
+ const scope = /^(.*?)(?:src\/)?pages\/api\//.exec(path)?.[1] ?? "";
2319
3502
  return { file, framework: "next", url: nextRouteUrl(path), scope };
2320
3503
  }
3504
+ if (APP_ROUTE_FILE.test(path)) {
3505
+ const app = nextAppRoute(path);
3506
+ const code = noiseMaskedOf(file);
3507
+ const handler = APP_ROUTER.test(path) || NEXT_METHOD_EXPORT.test(code) && !REMIX_EXPORT.test(code);
3508
+ if (app && handler) return app.url === null ? null : { file, framework: "next", url: app.url, scope: app.scope };
3509
+ }
2321
3510
  const svelte = SVELTEKIT_ENDPOINT.exec(path);
2322
3511
  if (svelte) return { file, framework: "sveltekit", url: `/${svelte[2] ?? ""}`, scope: svelte[1] ?? "" };
3512
+ const page = SVELTEKIT_PAGE_SERVER.exec(path);
3513
+ if (page) {
3514
+ const code = noiseMaskedOf(file);
3515
+ const actions = SVELTEKIT_ACTIONS.exec(code);
3516
+ if (!actions) return null;
3517
+ const open = actions.index + actions[0].length - 1;
3518
+ const close = closingDelimiter(code, open, "{", "}");
3519
+ return {
3520
+ file,
3521
+ framework: "sveltekit",
3522
+ url: `/${page[2] ?? ""}`,
3523
+ scope: page[1] ?? "",
3524
+ reachable: { start: open, end: close ?? code.length }
3525
+ };
3526
+ }
2323
3527
  const nuxt = NUXT_ROUTE.exec(path);
2324
3528
  if (nuxt && H3_HANDLER.test(noiseMaskedOf(file))) {
2325
3529
  const rest = nuxt[3].replace(NUXT_METHOD_SUFFIX, "").replace(/(?:^|\/)index$/, "");
@@ -2331,14 +3535,21 @@ function routeOf(file) {
2331
3535
  return { file, framework: "remix", url: remixRouteUrl(remix[2]), scope: remix[1] ?? "" };
2332
3536
  }
2333
3537
  const astro = ASTRO_ENDPOINT.exec(path);
2334
- if (astro && ASTRO_EXPORT.test(noiseMaskedOf(file))) {
3538
+ if (astro && isAstroEndpoint(file)) {
2335
3539
  const rest = astro[2].replace(/(?:^|\/)index$/, "");
2336
3540
  return { file, framework: "astro", url: `/${rest}`, scope: astro[1] ?? "" };
2337
3541
  }
2338
3542
  return null;
2339
3543
  }
3544
+ function nextAppRoute(path) {
3545
+ const m = /^(.*?\/)?src\/app\/(.+)$/.exec(path) ?? /^(.*?\/)?app\/(.+)$/.exec(path);
3546
+ if (!m) return null;
3547
+ const segments = m[2].split("/").slice(0, -1);
3548
+ const url = segments.some((segment) => segment.startsWith("_")) ? null : `/${segments.map((segment) => segment.replace(/^%5F/i, "_")).join("/")}`;
3549
+ return { scope: m[1] ?? "", url };
3550
+ }
2340
3551
  function nextRouteUrl(path) {
2341
- const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
3552
+ const m = /(?:^|\/)pages\/(api\/.*)$/.exec(path);
2342
3553
  if (!m) return `/${path}`;
2343
3554
  const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
2344
3555
  return `/${url}`;
@@ -2354,6 +3565,7 @@ var AUTH_CALLBACK = /^(?:\/api)?\/auth\/callback(?:\/[^/]+)?$/;
2354
3565
  var PROVIDER_CALLBACK = /^(?:\/api)?\/(?:auth|oauth|login|sign-?in)\/[^/]+\/callback$/;
2355
3566
  var OAUTH_HANDLER = /\bdefineOAuth\w*EventHandler\s*\(/;
2356
3567
  function isAuthEndpoint(route) {
3568
+ if (route.action !== void 0) return AUTH_ACTION_NAMES.test(route.action);
2357
3569
  return AUTH_ENDPOINT_NAMES.test(route.url) || AUTH_CALLBACK.test(route.url) || PROVIDER_CALLBACK.test(route.url) || OAUTH_HANDLER.test(noiseMaskedOf(route.file));
2358
3570
  }
2359
3571
  var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)\w*|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
@@ -2391,7 +3603,7 @@ function rejectsMissingIdentity(term, returnsDeniedStatus) {
2391
3603
  const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
2392
3604
  return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2393
3605
  }
2394
- function hasConditionalAuthGuard(code) {
3606
+ function hasConditionalAuthGuard(code, requireThrow = false, accept) {
2395
3607
  const starts = /\bif\s*\(/g;
2396
3608
  let match;
2397
3609
  while ((match = starts.exec(code)) !== null) {
@@ -2405,7 +3617,7 @@ function hasConditionalAuthGuard(code) {
2405
3617
  let stopsRequest = false;
2406
3618
  for (let i = 0; i < body.length; i++) {
2407
3619
  if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2408
- stopsRequest = true;
3620
+ stopsRequest = !requireThrow || !/^return\b/.test(body.slice(i, i + 16));
2409
3621
  break;
2410
3622
  }
2411
3623
  if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
@@ -2417,7 +3629,10 @@ function hasConditionalAuthGuard(code) {
2417
3629
  starts.lastIndex = statementStart + statement.length;
2418
3630
  if (!stopsRequest) continue;
2419
3631
  const returnsDeniedStatus = DENIED_STATUS.test(statement);
2420
- 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
+ });
2421
3636
  if (negative) return true;
2422
3637
  }
2423
3638
  return false;
@@ -2501,7 +3716,7 @@ function functionBodies(code, pairs) {
2501
3716
  }
2502
3717
  return bodies;
2503
3718
  }
2504
- function statementEnd(code, start, limit, pairs) {
3719
+ function statementEnd2(code, start, limit, pairs) {
2505
3720
  if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
2506
3721
  for (let i = start; i < limit; i++) {
2507
3722
  if (code[i] === ";" || code[i] === "\n") return i + 1;
@@ -2510,22 +3725,127 @@ function statementEnd(code, start, limit, pairs) {
2510
3725
  }
2511
3726
  return limit;
2512
3727
  }
2513
- function unguardedOperations(file, ops) {
3728
+ var unguardedCache = /* @__PURE__ */ new WeakMap();
3729
+ function unguardedOpsOf(file) {
3730
+ let hit = unguardedCache.get(file);
3731
+ if (hit === void 0) {
3732
+ hit = unguardedOperations(file, findDataOps(file));
3733
+ unguardedCache.set(file, hit);
3734
+ }
3735
+ return hit;
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
+ }
3749
+ var lineStartsCache = /* @__PURE__ */ new WeakMap();
3750
+ function lineStartsCached(file) {
3751
+ let hit = lineStartsCache.get(file);
3752
+ if (hit === void 0) {
3753
+ hit = lineStartsOf(file.content);
3754
+ lineStartsCache.set(file, hit);
3755
+ }
3756
+ return hit;
3757
+ }
3758
+ function cachedByFile(check) {
3759
+ const cache3 = /* @__PURE__ */ new WeakMap();
3760
+ return (file) => {
3761
+ let hit = cache3.get(file);
3762
+ if (hit === void 0) {
3763
+ hit = check(file);
3764
+ cache3.set(file, hit);
3765
+ }
3766
+ return hit;
3767
+ };
3768
+ }
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) {
2514
3782
  if (ops.length === 0) return ops;
2515
3783
  const code = noiseMaskedOf(file);
2516
3784
  const pairs = delimiterPairs(code);
2517
3785
  const bodies = functionBodies(code, pairs);
2518
3786
  const declarations = new Map(bodies.map((body) => [body.declaration, body]));
2519
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);
2520
3790
  const guardEnds = /* @__PURE__ */ new Map();
3791
+ const guardNames = /* @__PURE__ */ new Map();
3792
+ const guardRequirements = /* @__PURE__ */ new Map();
3793
+ const extraCalls = extra?.guards.size ? new RegExp(`^(${namePattern(extra.guards)})\\s*\\(`) : null;
2521
3794
  const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
2522
3795
  const active = [];
2523
3796
  let blockIndex = 0;
2524
- const wrappers = [...code.matchAll(/\b(?:withAuth|NextAuth)\s*\(/g)].map((match) => {
3797
+ const wrapperPattern = extra?.wrappers.size ? new RegExp(`(?<![\\w$.])(${namePattern(extra.wrappers)})\\s*\\(`, "g") : null;
3798
+ const wrappers = [
3799
+ ...[...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g)].filter((match) => !localNames.has(match[1])),
3800
+ ...wrapperPattern ? code.matchAll(wrapperPattern) : []
3801
+ ].map((match) => {
2525
3802
  const open = match.index + match[0].length - 1;
2526
- return { start: open, end: pairs.get(open) ?? open };
3803
+ return { start: open, end: pairs.get(open) ?? open, name: match[1] };
2527
3804
  });
2528
- 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
+ }
2529
3849
  for (let i = owner.start + 1; i < owner.end; i++) {
2530
3850
  const nested = declarations.get(i);
2531
3851
  if (nested) {
@@ -2533,10 +3853,39 @@ function unguardedOperations(file, ops) {
2533
3853
  continue;
2534
3854
  }
2535
3855
  if (code.startsWith("=>", i)) {
2536
- i = statementEnd(code, i + 2, owner.end, pairs) - 1;
3856
+ i = statementEnd2(code, i + 2, owner.end, pairs) - 1;
2537
3857
  continue;
2538
3858
  }
2539
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
+ }
2540
3889
  const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
2541
3890
  if (conditional) {
2542
3891
  const open = i + conditional[0].length - 1;
@@ -2544,12 +3893,42 @@ function unguardedOperations(file, ops) {
2544
3893
  if (close2 === void 0) return Infinity;
2545
3894
  let start = close2 + 1;
2546
3895
  while (/\s/.test(code[start] ?? "")) start++;
2547
- const end = statementEnd(code, start, owner.end, pairs);
2548
- if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
3896
+ const end = statementEnd2(code, start, owner.end, pairs);
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
+ }
2549
3925
  i = Math.min(end, owner.end) - 1;
2550
3926
  continue;
2551
3927
  }
2552
- const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
3928
+ const indirectCall = i > 0 && /[\w$.]/.test(code[i - 1]) ? null : extraCalls?.exec(code.slice(i, i + 100));
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;
2553
3932
  if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
2554
3933
  const prefixStart = Math.max(
2555
3934
  owner.start + 1,
@@ -2558,11 +3937,30 @@ function unguardedOperations(file, ops) {
2558
3937
  );
2559
3938
  const prefix = code.slice(prefixStart, i).trim();
2560
3939
  if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
2561
- 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);
2562
3941
  const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
2563
3942
  if (!awaited && !synchronous) continue;
2564
3943
  const close2 = pairs.get(i + call[0].length - 1);
2565
- if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
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]);
3961
+ if (indirectCall) guardNames.set(owner.start, indirectCall[1]);
3962
+ return close2 + 1;
3963
+ }
2566
3964
  }
2567
3965
  const close = pairs.get(i);
2568
3966
  if (close !== void 0) i = close;
@@ -2576,7 +3974,13 @@ function unguardedOperations(file, ops) {
2576
3974
  active.push(block);
2577
3975
  }
2578
3976
  while (active.length && active[active.length - 1].end < op.index) active.pop();
2579
- if (wrappers.some((w) => w.start < op.index && w.end > op.index)) return false;
3977
+ const wrapper = wrappers.find((w) => w.start < op.index && w.end > op.index);
3978
+ if (wrapper) {
3979
+ if (extra?.requirements?.get(wrapper.name)?.length) return true;
3980
+ observe?.(op.index, []);
3981
+ if (extra?.wrappers.has(wrapper.name)) extra.onGuard(op.index, wrapper.name);
3982
+ return false;
3983
+ }
2580
3984
  let ownerIndex = active.length - 1;
2581
3985
  while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
2582
3986
  if (ownerIndex < 0) return true;
@@ -2584,15 +3988,35 @@ function unguardedOperations(file, ops) {
2584
3988
  const block = active[index];
2585
3989
  let end = guardEnds.get(block.start);
2586
3990
  if (end === void 0) {
2587
- end = guardEnd(block);
3991
+ end = guardEnd(block, bodiesByStart.get(active[ownerIndex].start));
2588
3992
  guardEnds.set(block.start, end);
2589
3993
  }
2590
- if (end <= op.index) return false;
3994
+ if (end <= op.index) {
3995
+ observe?.(op.index, guardRequirements.get(block.start) ?? []);
3996
+ const name = guardNames.get(block.start);
3997
+ if (name) extra?.onGuard(op.index, name);
3998
+ return false;
3999
+ }
2591
4000
  }
2592
4001
  return true;
2593
4002
  });
2594
4003
  }
2595
4004
  var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>\s*)?\(/;
4005
+ var constructorCache = /* @__PURE__ */ new WeakMap();
4006
+ function clientConstructorAt(file) {
4007
+ const cached = constructorCache.get(file);
4008
+ if (cached !== void 0) return cached ?? void 0;
4009
+ const code = noiseMaskedOf(file);
4010
+ const direct = CLIENT_CONSTRUCTOR.exec(code)?.index;
4011
+ if (direct !== void 0) {
4012
+ constructorCache.set(file, direct);
4013
+ return direct;
4014
+ }
4015
+ const aliases = bindingsOf(file).imports.filter((binding) => /^@supabase\/(?:supabase-js|ssr)$/.test(binding.spec ?? "") && ["createClient", "createServerClient"].includes(binding.imported)).map((binding) => binding.local);
4016
+ const found = aliases.length === 0 ? void 0 : new RegExp(`(?<![\\w$.])(?:${namePattern(aliases)})\\s*(?:<[^()]{0,200}>\\s*)?\\(`).exec(code)?.index;
4017
+ constructorCache.set(file, found ?? null);
4018
+ return found;
4019
+ }
2596
4020
  var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
2597
4021
  var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
2598
4022
  var ENV_BRACKET_ACCESS = /(?:process\.env|import\.meta\.env)\s*\[\s*['"]([^'"]+)['"]\s*\]/g;
@@ -2607,10 +4031,11 @@ function referencesServiceRole(code, source) {
2607
4031
  }
2608
4032
  var NUXT_SUPABASE_ADMIN = /\bserverSupabaseServiceRole\s*(?:<[^()]{0,200}>\s*)?\(/;
2609
4033
  var NUXT_SUPABASE_SESSION = /\bserverSupabaseClient\s*(?:<[^()]{0,200}>\s*)?\(/;
2610
- function buildsAdminClient(file) {
4034
+ var buildsAdminClient = cachedByFile(buildsAdminClientUncached);
4035
+ function buildsAdminClientUncached(file) {
2611
4036
  const code = noiseMaskedOf(file);
2612
4037
  if (NUXT_SUPABASE_ADMIN.test(code)) return true;
2613
- if (!CLIENT_CONSTRUCTOR.test(code)) return false;
4038
+ if (clientConstructorAt(file) === void 0) return false;
2614
4039
  const source = commentsMaskedOf(file);
2615
4040
  if (referencesServiceRole(code, source)) return true;
2616
4041
  SERVICE_ROLE_LITERAL.lastIndex = 0;
@@ -2620,11 +4045,12 @@ function buildsAdminClient(file) {
2620
4045
  }
2621
4046
  return false;
2622
4047
  }
2623
- function buildsSessionClient(file) {
4048
+ var buildsSessionClient = cachedByFile(buildsSessionClientUncached);
4049
+ function buildsSessionClientUncached(file) {
2624
4050
  const code = noiseMaskedOf(file);
2625
4051
  if (NUXT_SUPABASE_ADMIN.test(code)) return false;
2626
4052
  if (NUXT_SUPABASE_SESSION.test(code)) return true;
2627
- if (!CLIENT_CONSTRUCTOR.test(code)) return false;
4053
+ if (clientConstructorAt(file) === void 0) return false;
2628
4054
  if (referencesServiceRole(code, commentsMaskedOf(file))) return false;
2629
4055
  return /\bcookies\b/.test(code);
2630
4056
  }
@@ -2666,10 +4092,13 @@ function usesSessionClient(route, sessionModules, allFiles) {
2666
4092
  return buildsSessionClient(route.file) || importsAnyOf(route, sessionModules, allFiles) || autoImportsAnyOf(route, sessionModules);
2667
4093
  }
2668
4094
  function autoImportsAnyOf(route, modules) {
2669
- if (route.framework !== "nuxt") return false;
4095
+ return autoImportedModule(route, modules) !== void 0;
4096
+ }
4097
+ function autoImportedModule(route, modules) {
4098
+ if (route.framework !== "nuxt") return void 0;
2670
4099
  const utils = `${route.scope}server/utils/`;
2671
4100
  const code = noiseMaskedOf(route.file);
2672
- return modules.some((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
4101
+ return modules.find((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
2673
4102
  }
2674
4103
  function exportedNames(file) {
2675
4104
  const names = noiseMaskedOf(file).matchAll(
@@ -2677,7 +4106,7 @@ function exportedNames(file) {
2677
4106
  );
2678
4107
  return [...names].map((m) => m[1]);
2679
4108
  }
2680
- function importedModules(file, allFiles, aliasScope) {
4109
+ function importedEdges(file, allFiles, aliasScope) {
2681
4110
  const found = [];
2682
4111
  const source = commentsMaskedOf(file);
2683
4112
  const code = noiseMaskedOf(file);
@@ -2688,8 +4117,11 @@ function importedModules(file, allFiles, aliasScope) {
2688
4117
  const target = normalizeSpec(m[1], file.path);
2689
4118
  if (!target) continue;
2690
4119
  const index = moduleIndexOf(allFiles);
4120
+ const append = (targets) => {
4121
+ for (const to of targets) found.push({ from: file, to, line: lineNumberAt(lineStartsCached(file), m.index) });
4122
+ };
2691
4123
  if (!target.alias) {
2692
- found.push(...index.get(target.key) ?? []);
4124
+ append(index.get(target.key) ?? []);
2693
4125
  } else {
2694
4126
  const prefix = aliasScope;
2695
4127
  for (const key of [
@@ -2697,32 +4129,298 @@ function importedModules(file, allFiles, aliasScope) {
2697
4129
  moduleKey(`${prefix}src/${target.key}`),
2698
4130
  moduleKey(`${prefix}app/${target.key}`)
2699
4131
  ]) {
2700
- found.push(...index.get(key) ?? []);
4132
+ append(index.get(key) ?? []);
2701
4133
  }
2702
4134
  }
2703
4135
  }
2704
4136
  return found;
2705
4137
  }
4138
+ var importsCache = /* @__PURE__ */ new WeakMap();
2706
4139
  function importsAnyOf(route, modules, allFiles) {
2707
- if (modules.length === 0) return false;
4140
+ return importChain(route, modules, allFiles) !== null;
4141
+ }
4142
+ function importChain(route, modules, allFiles) {
4143
+ if (modules.length === 0) return null;
4144
+ const byFile = importsCache.get(modules) ?? /* @__PURE__ */ new WeakMap();
4145
+ importsCache.set(modules, byFile);
4146
+ const byScope = byFile.get(route.file) ?? /* @__PURE__ */ new Map();
4147
+ byFile.set(route.file, byScope);
4148
+ const cached = byScope.get(route.scope);
4149
+ if (cached !== void 0) return cached;
4150
+ const result = importChainUncached(route, modules, allFiles);
4151
+ byScope.set(route.scope, result);
4152
+ return result;
4153
+ }
4154
+ function importChainUncached(route, modules, allFiles) {
2708
4155
  const targetPaths = new Set(modules.map((file) => file.path));
2709
- const visited = /* @__PURE__ */ new Set();
2710
- const aliasScope = route.scope;
2711
- const queue = importedModules(route.file, allFiles, aliasScope);
2712
- while (queue.length > 0) {
2713
- const file = queue.pop();
2714
- if (targetPaths.has(file.path)) return true;
2715
- if (visited.has(file.path)) continue;
2716
- visited.add(file.path);
2717
- queue.push(...importedModules(file, allFiles, aliasScope));
4156
+ const visited = /* @__PURE__ */ new Set([route.file.path]);
4157
+ const parents = /* @__PURE__ */ new Map();
4158
+ const queue = [route.file];
4159
+ for (let i = 0; i < queue.length; i++) {
4160
+ for (const edge of importedEdges(queue[i], allFiles, route.scope)) {
4161
+ if (visited.has(edge.to.path)) continue;
4162
+ visited.add(edge.to.path);
4163
+ parents.set(edge.to.path, edge);
4164
+ if (targetPaths.has(edge.to.path)) {
4165
+ const chain = [];
4166
+ let current = edge;
4167
+ while (current) {
4168
+ chain.push(current);
4169
+ current = parents.get(current.from.path);
4170
+ }
4171
+ return chain.reverse();
4172
+ }
4173
+ queue.push(edge.to);
4174
+ }
2718
4175
  }
2719
- return false;
4176
+ return null;
4177
+ }
4178
+ function adminEvidence(route, modules, allFiles, line) {
4179
+ const chain = buildsAdminClient(route.file) ? [] : importChain(route, modules, allFiles);
4180
+ const evidence = [{
4181
+ kind: "operation",
4182
+ file: route.file.path,
4183
+ line,
4184
+ description: "Data operation selected for authentication review."
4185
+ }];
4186
+ for (const edge of (chain ?? []).slice(0, 22)) evidence.push({
4187
+ kind: "import",
4188
+ file: edge.from.path,
4189
+ line: edge.line,
4190
+ description: `Statically imports ${edge.to.path}.`
4191
+ });
4192
+ const target = chain?.at(-1)?.to ?? (buildsAdminClient(route.file) ? route.file : autoImportedModule(route, modules));
4193
+ if (target) {
4194
+ const at = clientConstructorAt(target) ?? NUXT_SUPABASE_ADMIN.exec(noiseMaskedOf(target))?.index;
4195
+ evidence.push({
4196
+ kind: "admin-client",
4197
+ file: target.path,
4198
+ line: at === void 0 ? null : lineNumberAt(lineStartsCached(target), at),
4199
+ description: "Admin-client construction detected in this module; imports are not proof of runtime data flow."
4200
+ });
4201
+ }
4202
+ return { evidence, evidenceTruncated: (chain?.length ?? 0) > 22 };
4203
+ }
4204
+ var definitionsCache = /* @__PURE__ */ new WeakMap();
4205
+ function guardDefinitions(file) {
4206
+ const cached = definitionsCache.get(file);
4207
+ if (cached) return cached;
4208
+ const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map(), candidates: /* @__PURE__ */ new Map(), exportedCandidates: /* @__PURE__ */ new Map() };
4209
+ const code = noiseMaskedOf(file);
4210
+ const pairs = delimiterPairs(code);
4211
+ const openers = new Map([...pairs].map(([open, close]) => [close, open]));
4212
+ const bodies = functionBodies(code, pairs);
4213
+ const byDeclaration = new Map(bodies.map((body) => [body.declaration, body]));
4214
+ const candidates = [];
4215
+ let until = -1;
4216
+ for (const body of bodies) {
4217
+ if (body.start < until) continue;
4218
+ until = body.end;
4219
+ const declaration = declarationOf(code, body, openers);
4220
+ if (!declaration) continue;
4221
+ const returned = /\breturn\s+(?:async\s+)?(?:function\b|(?:\([^)]{0,200}\)|[A-Za-z_$][\w$]*)\s*=>)/.exec(code.slice(body.start + 1, body.end));
4222
+ const nestedAt = returned ? body.start + 1 + returned.index + (returned[0].includes("=>") ? returned[0].lastIndexOf("=>") : returned[0].lastIndexOf("function")) : -1;
4223
+ const target = byDeclaration.get(nestedAt) ?? body;
4224
+ let at = target.end;
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
+ }
4236
+ if (code.startsWith("return", i) && !/[\w$]/.test(code[i - 1] ?? "") && !/[\w$]/.test(code[i + 6] ?? "")) {
4237
+ at = Math.min(target.end, statementEnd2(code, i, target.end, pairs));
4238
+ break;
4239
+ }
4240
+ const close = pairs.get(i);
4241
+ if (close !== void 0) i = close;
4242
+ }
4243
+ const definition = {
4244
+ file,
4245
+ line: lineNumberAt(lineStartsCached(file), body.declaration),
4246
+ wrapper: target !== body
4247
+ };
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;
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);
4262
+ }
4263
+ for (const candidate of candidates) {
4264
+ result.candidates.set(candidate.name, candidate);
4265
+ if (candidate.exported) result.exportedCandidates.set(candidate.exported, candidate);
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;
4269
+ result.locals.set(candidate.name, candidate.definition);
4270
+ if (candidate.exported) result.exports.set(candidate.exported, candidate.definition);
4271
+ }
4272
+ definitionsCache.set(file, result);
4273
+ return result;
4274
+ }
4275
+ function bindingModule(spec, file, allFiles, scope) {
4276
+ const target = normalizeSpec(spec, file.path);
4277
+ if (!target) return null;
4278
+ const keys = target.alias ? [`${scope}${target.key}`, `${scope}src/${target.key}`, `${scope}app/${target.key}`] : [target.key];
4279
+ const found = new Set(keys.flatMap((key) => moduleIndexOf(allFiles).get(moduleKey(key)) ?? []));
4280
+ return found.size === 1 ? [...found][0] : null;
4281
+ }
4282
+ function exportedGuard(file, name, allFiles, scope, state, depth = 0, seen = /* @__PURE__ */ new Set()) {
4283
+ const key = `${file.path}\0${name}`;
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);
4290
+ const definitions = guardDefinitions(file);
4291
+ const candidate = definitions.exportedCandidates.get(name);
4292
+ if (candidate && guardBindings(file).shadowed.has(candidate.name)) return null;
4293
+ const direct = definitions.exports.get(name);
4294
+ if (direct) return direct;
4295
+ if (candidate) return delegatedGuard(file, candidate, allFiles, scope, state, depth, seen);
4296
+ const bindings = bindingsOf(file);
4297
+ const exported = bindings.exports.find((binding) => binding.local === name);
4298
+ const follow = (spec, imported) => {
4299
+ const target = bindingModule(spec, file, allFiles, scope);
4300
+ return target ? exportedGuard(target, imported, allFiles, scope, state, depth + 1, seen) : null;
4301
+ };
4302
+ if (exported) {
4303
+ if (exported.spec) return follow(exported.spec, exported.imported);
4304
+ if (guardBindings(file).shadowed.has(exported.imported)) return null;
4305
+ const local = definitions.locals.get(exported.imported);
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);
4309
+ const imported = bindings.imports.find((binding) => binding.local === exported.imported);
4310
+ if (imported?.spec) return follow(imported.spec, imported.imported);
4311
+ }
4312
+ if (name !== "default") for (const spec of bindings.stars) {
4313
+ const found = follow(spec, name);
4314
+ if (found) return found;
4315
+ }
4316
+ return null;
4317
+ }
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);
4393
+ const names = /* @__PURE__ */ new Map();
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);
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
+ );
4406
+ const protectedOps = /* @__PURE__ */ new Map();
4407
+ if (names.size === 0) return protectedOps;
4408
+ unguardedOperations(route.file, unguardedOpsOf(route.file), {
4409
+ guards: new Set([...names].filter(([, definition]) => !definition.wrapper).map(([name]) => name)),
4410
+ wrappers: new Set([...names].filter(([, definition]) => definition.wrapper).map(([name]) => name)),
4411
+ requirements: new Map([...names].map(([name, guard]) => [name, guard.requiredArgs ?? []])),
4412
+ onGuard: (index, name) => {
4413
+ const definition = names.get(name);
4414
+ if (definition) protectedOps.set(index, definition);
4415
+ }
4416
+ });
4417
+ return protectedOps;
2720
4418
  }
2721
4419
  var SUPABASE_TABLE = /\.from\(\s*['"`][^'"`]+['"`]\s*\)\s*\.?\s*(\w+)?/g;
2722
4420
  var SUPABASE_ADMIN_API = /\bauth\s*\.\s*admin\s*\.\s*(\w+)\s*\(/g;
2723
4421
  var SUPABASE_WRITES = /* @__PURE__ */ new Set(["insert", "update", "upsert", "delete"]);
2724
- var PRISMA_OP = /\bprisma\s*\.\s*\$?(\w+)\s*\.\s*(findMany|findFirst|findUnique|findUniqueOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
2725
- var PRISMA_RAW = /\bprisma\s*\.\s*\$(queryRaw|executeRaw)/g;
4422
+ var PRISMA_OP = /\b(?:prisma|db)\s*\.\s*\$?(\w+)\s*\.\s*(findMany|findFirst|findUnique|findUniqueOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
4423
+ var PRISMA_RAW = /\b(?:prisma|db)\s*\.\s*\$(queryRaw|executeRaw)/g;
2726
4424
  var PRISMA_WRITES = /^(?:create|createMany|update|updateMany|upsert|delete|deleteMany)$/;
2727
4425
  var DRIZZLE_OP = /\bdb\s*\.\s*(select|insert|update|delete)\s*\(/g;
2728
4426
  var MONGO_OP = /\.(?:deleteMany|deleteOne|updateMany|updateOne|insertMany|insertOne|findOneAndDelete|findOneAndUpdate)\s*\(/g;
@@ -2765,20 +4463,29 @@ function findDataOps(file) {
2765
4463
  }
2766
4464
  return hits.sort((a, b) => a.index - b.index);
2767
4465
  }
2768
- var MIDDLEWARE_FILE = /(?:^|\/)(?:src\/)?middleware\.[mc]?[jt]s$/;
4466
+ var NEXT_MIDDLEWARE_FILE = /(?:^|\/)(?:src\/)?(?:middleware|proxy)\.[mc]?[jt]s$/;
4467
+ var ASTRO_MIDDLEWARE_FILE = /(?:^|\/)src\/middleware(?:\/index)?\.[mc]?[jt]s$/;
4468
+ function isMiddlewareFile(path, framework) {
4469
+ return framework === "astro" ? ASTRO_MIDDLEWARE_FILE.test(path) : NEXT_MIDDLEWARE_FILE.test(path);
4470
+ }
4471
+ function isAnyMiddlewareFile(path) {
4472
+ return NEXT_MIDDLEWARE_FILE.test(path) || ASTRO_MIDDLEWARE_FILE.test(path);
4473
+ }
2769
4474
  function middlewareScopeOf(path) {
2770
- return path.replace(/(?:src\/)?middleware\.[mc]?[jt]s$/, "");
4475
+ return path.replace(/(?:src\/)?(?:middleware(?:\/index)?|proxy)\.[mc]?[jt]s$/, "");
2771
4476
  }
2772
- function middlewareFor(ctx, routePath) {
2773
- let best = null;
4477
+ function middlewaresFor(ctx, routePath, framework) {
4478
+ let best = [];
2774
4479
  let bestDepth = -1;
2775
4480
  for (const file of ctx.files) {
2776
- if (!MIDDLEWARE_FILE.test(file.path)) continue;
4481
+ if (!isMiddlewareFile(file.path, framework)) continue;
2777
4482
  const scope = middlewareScopeOf(file.path);
2778
4483
  if (!routePath.startsWith(scope)) continue;
2779
4484
  if (scope.length > bestDepth) {
2780
- best = file;
4485
+ best = [file];
2781
4486
  bestDepth = scope.length;
4487
+ } else if (scope.length === bestDepth) {
4488
+ best.push(file);
2782
4489
  }
2783
4490
  }
2784
4491
  return best;
@@ -2812,14 +4519,14 @@ function sliceQuoted(text) {
2812
4519
  }
2813
4520
  return null;
2814
4521
  }
2815
- function topLevelMatcherValueStart(objectText) {
2816
- const candidates = [1];
4522
+ function topLevelMemberStarts(text, container) {
4523
+ const starts = [1];
2817
4524
  let quote = null;
2818
4525
  let braces = 0;
2819
4526
  let brackets = 0;
2820
4527
  let parentheses = 0;
2821
- for (let i = 0; i < objectText.length; i++) {
2822
- const ch = objectText[i];
4528
+ for (let i = 0; i < text.length; i++) {
4529
+ const ch = text[i];
2823
4530
  if (quote !== null) {
2824
4531
  if (ch === "\\") i++;
2825
4532
  else if (ch === quote) quote = null;
@@ -2832,18 +4539,41 @@ function topLevelMatcherValueStart(objectText) {
2832
4539
  else if (ch === "]") brackets--;
2833
4540
  else if (ch === "(") parentheses++;
2834
4541
  else if (ch === ")") parentheses--;
2835
- else if (ch === "," && braces === 1 && brackets === 0 && parentheses === 0) {
2836
- candidates.push(i + 1);
4542
+ else if (ch === "," && parentheses === 0 && (container === "{" ? braces === 1 && brackets === 0 : brackets === 1 && braces === 0)) {
4543
+ starts.push(i + 1);
2837
4544
  }
2838
4545
  }
2839
- for (const start of candidates) {
4546
+ return starts;
4547
+ }
4548
+ function topLevelPropertyStart(objectText, name) {
4549
+ const property = new RegExp(`^(?:${name}|['"]${name}['"])\\s*:\\s*`);
4550
+ for (const start of topLevelMemberStarts(objectText, "{")) {
2840
4551
  let cursor = start;
2841
4552
  while (/\s/.test(objectText[cursor] ?? "")) cursor++;
2842
- const property = /^(?:matcher|['"]matcher['"])\s*:\s*/.exec(objectText.slice(cursor));
2843
- if (property) return cursor + property[0].length;
4553
+ const match = property.exec(objectText.slice(cursor));
4554
+ if (match) return cursor + match[0].length;
2844
4555
  }
2845
4556
  return null;
2846
4557
  }
4558
+ function quotedValue(text) {
4559
+ const quoted = sliceQuoted(text);
4560
+ return quoted === null || quoted.length < 3 ? null : quoted.slice(1, -1);
4561
+ }
4562
+ function matcherEntry(text) {
4563
+ const trimmed = text.trimStart();
4564
+ if (trimmed.startsWith("{")) {
4565
+ const object = sliceDelimited(trimmed, "{", "}");
4566
+ if (object === null) return null;
4567
+ if (topLevelPropertyStart(object, "has") !== null || topLevelPropertyStart(object, "missing") !== null) {
4568
+ return { conditional: true };
4569
+ }
4570
+ const source = topLevelPropertyStart(object, "source");
4571
+ const pattern2 = source === null ? null : quotedValue(object.slice(source));
4572
+ return pattern2 === null ? null : { pattern: pattern2 };
4573
+ }
4574
+ const pattern = quotedValue(trimmed);
4575
+ return pattern === null ? null : { pattern };
4576
+ }
2847
4577
  function extractMatcherConfig(file) {
2848
4578
  const code = noiseMaskedOf(file);
2849
4579
  const declaration = /\bexport\s+const\s+config\b/.exec(code);
@@ -2857,13 +4587,28 @@ function extractMatcherConfig(file) {
2857
4587
  const masked = commentsMaskedOf(file);
2858
4588
  const objectText = sliceDelimited(masked.slice(objectStart), "{", "}");
2859
4589
  if (objectText === null) return { kind: "unreadable" };
2860
- const valueStart = topLevelMatcherValueStart(objectText);
4590
+ const valueStart = topLevelPropertyStart(objectText, "matcher");
2861
4591
  if (valueStart === null) return { kind: "absent" };
2862
4592
  const rest = objectText.slice(valueStart);
2863
- const raw = rest.startsWith("[") ? sliceBracketed(rest) : sliceQuoted(rest);
2864
- if (raw === null) return { kind: "unreadable" };
2865
- const patterns = [...raw.matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
2866
- return patterns.length === 0 ? { kind: "unreadable" } : { kind: "patterns", patterns };
4593
+ if (!rest.startsWith("[")) {
4594
+ const entry = matcherEntry(rest);
4595
+ return entry === null ? { kind: "unreadable" } : "pattern" in entry ? { kind: "patterns", patterns: [entry.pattern] } : { kind: "patterns", patterns: [] };
4596
+ }
4597
+ const array = sliceBracketed(rest);
4598
+ if (array === null) return { kind: "unreadable" };
4599
+ const patterns = [];
4600
+ let conditional = 0;
4601
+ const starts = topLevelMemberStarts(array, "[");
4602
+ for (const [index, start] of starts.entries()) {
4603
+ const member = array.slice(start, index + 1 < starts.length ? starts[index + 1] - 1 : array.length - 1);
4604
+ if (member.trim() === "") continue;
4605
+ const entry = matcherEntry(member);
4606
+ if (entry === null) continue;
4607
+ if ("pattern" in entry) patterns.push(entry.pattern);
4608
+ else conditional++;
4609
+ }
4610
+ if (patterns.length === 0 && conditional === 0) return { kind: "unreadable" };
4611
+ return { kind: "patterns", patterns };
2867
4612
  }
2868
4613
  var MAX_MATCHER_LENGTH = 300;
2869
4614
  function withoutGroupPrefix(body) {
@@ -2954,9 +4699,10 @@ function matcherToRegex(pattern) {
2954
4699
  return null;
2955
4700
  }
2956
4701
  }
2957
- function middlewareCovers(ctx, routePath, url) {
2958
- const mw = middlewareFor(ctx, routePath);
2959
- if (!mw) return false;
4702
+ function middlewareCovers(ctx, route) {
4703
+ return middlewaresFor(ctx, route.file.path, route.framework).some((mw) => covers(ctx, mw, route.url));
4704
+ }
4705
+ function covers(ctx, mw, url) {
2960
4706
  if (!hasAuthSignal(mw)) return false;
2961
4707
  const { config, readable } = compiledMatchersOf(mw);
2962
4708
  if (config.kind === "absent") return true;
@@ -2967,6 +4713,7 @@ function middlewareCovers(ctx, routePath, url) {
2967
4713
  );
2968
4714
  return true;
2969
4715
  }
4716
+ if (config.patterns.length === 0) return false;
2970
4717
  if (readable.length === 0) return true;
2971
4718
  return readable.some((re) => re.test(url));
2972
4719
  }
@@ -3015,9 +4762,12 @@ var apiAuthRule = {
3015
4762
  id: "api/db-access-without-auth",
3016
4763
  severity: "P0",
3017
4764
  check(ctx) {
3018
- const routes = ctx.files.map(routeOf).filter((route) => route !== null);
4765
+ const routes = ctx.files.flatMap((file) => {
4766
+ const route = routeOf(file);
4767
+ return route === null ? serverActionRoutes(file) : [route];
4768
+ });
3019
4769
  if (routes.length === 0) return [];
3020
- for (const middlewareFile of ctx.files.filter((f) => MIDDLEWARE_FILE.test(f.path))) {
4770
+ for (const middlewareFile of ctx.files.filter((f) => isAnyMiddlewareFile(f.path))) {
3021
4771
  const config = extractMatcherConfig(middlewareFile);
3022
4772
  const refused = config.kind === "patterns" ? config.patterns.filter((p) => matcherToRegex(p) === null) : [];
3023
4773
  if (refused.length > 0) {
@@ -3030,34 +4780,57 @@ var apiAuthRule = {
3030
4780
  const adminModules = ctx.files.filter(buildsAdminClient);
3031
4781
  const sessionModules = ctx.files.filter(buildsSessionClient);
3032
4782
  const findings = [];
4783
+ const indirectByFile = /* @__PURE__ */ new Map();
3033
4784
  for (const route of routes) {
3034
- const ops = unguardedOperations(route.file, findDataOps(route.file));
4785
+ const reachable = route.reachable;
4786
+ const unguarded = unguardedOpsOf(route.file);
4787
+ let ops = reachable === void 0 ? unguarded : operationsInRange(unguarded, reachable.start, reachable.end);
3035
4788
  if (ops.length === 0) continue;
4789
+ let indirectOps = indirectByFile.get(route.file);
4790
+ if (!indirectOps) {
4791
+ indirectOps = indirectGuardOperations(route, ctx);
4792
+ indirectByFile.set(route.file, indirectOps);
4793
+ }
4794
+ const unprotected = ops.filter((op) => !indirectOps.has(op.index));
4795
+ if (unprotected.length > 0) ops = unprotected;
3036
4796
  const url = route.url;
3037
4797
  if (isAuthEndpoint(route)) continue;
3038
- if ((route.framework === "next" || route.framework === "astro") && middlewareCovers(ctx, route.file.path, url)) continue;
4798
+ if ((route.framework === "next" || route.framework === "astro") && route.action === void 0 && middlewareCovers(ctx, route)) continue;
3039
4799
  const globalGuard = globalGuardFor(ctx, route);
3040
4800
  const globalNote = globalGuard === null ? [] : [
3041
4801
  `${globalGuard} contains an authentication check that may cover ${url}. Its path conditions are written in code, so the scan cannot tell which routes it applies to; this finding is reported at lower confidence. Confirm that check runs for ${url}.`
3042
4802
  ];
3043
4803
  const admin = usesAdminClient(route, adminModules, ctx.files);
3044
4804
  const hit = ops.find((o) => o.writes) ?? ops[0];
3045
- const line = lineNumberAt(lineStartsOf(route.file.content), hit.index);
4805
+ const indirect = indirectOps.get(hit.index);
4806
+ const line = lineNumberAt(lineStartsCached(route.file), hit.index);
3046
4807
  const excerpt = excerptFor(route.file, line);
4808
+ const indirectEvidence = indirect ? [indirect, ...indirect.chain ?? []].map((guard) => ({
4809
+ kind: "auth-helper",
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
+ })) : [];
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.");
3047
4815
  if (admin) {
4816
+ const trace = adminEvidence(route, adminModules, ctx.files, line);
4817
+ const evidence = [...trace.evidence ?? [], ...indirectEvidence];
3048
4818
  findings.push({
3049
4819
  ruleId: "api/admin-db-access-without-auth",
4820
+ ...trace,
4821
+ evidence: evidence.slice(0, 24),
4822
+ evidenceTruncated: trace.evidenceTruncated || evidence.length > 24,
3050
4823
  severity: "P0",
3051
4824
  // 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
3052
- confidence: globalGuard === null ? "certain" : "likely",
3053
- title: `Anyone can call ${url} and it queries your database as admin`,
4825
+ confidence: globalGuard === null && !indirect ? "certain" : "likely",
4826
+ title: indirect ? `Review indirect authentication for admin database access in ${url}` : `Anyone can call ${url} and it queries your database as admin`,
3054
4827
  file: route.file.path,
3055
4828
  line,
3056
4829
  excerpt,
3057
4830
  why: [
3058
4831
  ...globalNote,
3059
4832
  `This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
3060
- `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
4833
+ indirect ? "A local guard was recognized, but its runtime enforcement and business authorization still require verification." : `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
3061
4834
  `If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
3062
4835
  `If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
3063
4836
  ],
@@ -3079,13 +4852,20 @@ var apiAuthRule = {
3079
4852
  severity: "P1",
3080
4853
  // 公开写入可能是业务设计,保留疑似置信度。
3081
4854
  confidence: "likely",
3082
- title: `${url} writes to your database with no sign-in check`,
4855
+ // Server Action 的名称以小写开头,放在句首时首字母大写。
4856
+ title: indirect ? `Review indirect authentication for database writes in ${url}` : `${url.charAt(0).toUpperCase()}${url.slice(1)} writes to your database with no sign-in check`,
3083
4857
  file: route.file.path,
3084
4858
  line,
3085
4859
  excerpt,
4860
+ ...indirect ? { evidence: [{
4861
+ kind: "operation",
4862
+ file: route.file.path,
4863
+ line,
4864
+ description: "Data write using a local authentication helper."
4865
+ }, ...indirectEvidence] } : {},
3086
4866
  why: [
3087
4867
  ...globalNote,
3088
- `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
4868
+ indirect ? "This route changes data after a local authentication helper; verify that it rejects unauthorized callers." : `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
3089
4869
  `If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
3090
4870
  `If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
3091
4871
  ],
@@ -3097,6 +4877,14 @@ var apiAuthRule = {
3097
4877
  });
3098
4878
  }
3099
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
+ );
3100
4888
  return findings;
3101
4889
  }
3102
4890
  };
@@ -3360,21 +5148,37 @@ function collectCredentialLines(file) {
3360
5148
  }
3361
5149
  return lines;
3362
5150
  }
3363
- function nearestOrigin(origins, credLine) {
3364
- let best = Infinity;
3365
- let closest = [];
3366
- for (const o of origins) {
3367
- const d = Math.abs(o.line - credLine);
3368
- if (d < best) {
3369
- best = d;
3370
- closest = [o];
3371
- } else if (d === best) {
3372
- closest.push(o);
5151
+ function indexOrigins(origins) {
5152
+ const byLine = /* @__PURE__ */ new Map();
5153
+ for (const { mark, order } of origins) {
5154
+ const entry = byLine.get(mark.line);
5155
+ if (entry === void 0) byLine.set(mark.line, { line: mark.line, kinds: /* @__PURE__ */ new Set([mark.kind]), first: mark, order });
5156
+ else {
5157
+ entry.kinds.add(mark.kind);
5158
+ if (order < entry.order) {
5159
+ entry.first = mark;
5160
+ entry.order = order;
5161
+ }
3373
5162
  }
3374
5163
  }
3375
- if (best > PAIRING_DISTANCE || closest.length === 0) return null;
3376
- const kinds = new Set(closest.map((o) => o.kind));
3377
- return kinds.size === 1 ? closest[0] : null;
5164
+ return [...byLine.values()].sort((a, b) => a.line - b.line);
5165
+ }
5166
+ function nearestOrigin(lines, credLine) {
5167
+ let lo = 0;
5168
+ let hi = lines.length;
5169
+ while (lo < hi) {
5170
+ const mid = lo + hi >> 1;
5171
+ if (lines[mid].line < credLine) lo = mid + 1;
5172
+ else hi = mid;
5173
+ }
5174
+ const below = lo > 0 ? lines[lo - 1] : void 0;
5175
+ const above = lines[lo];
5176
+ const best = Math.min(below ? credLine - below.line : Infinity, above ? above.line - credLine : Infinity);
5177
+ if (best > PAIRING_DISTANCE) return null;
5178
+ const closest = [below, above].filter((entry) => entry !== void 0 && Math.abs(entry.line - credLine) === best);
5179
+ const kinds = new Set(closest.flatMap((entry) => [...entry.kinds]));
5180
+ if (kinds.size !== 1) return null;
5181
+ return closest.reduce((a, b) => b.order < a.order ? b : a).first;
3378
5182
  }
3379
5183
  var corsRule = {
3380
5184
  id: "cors/credentialed-cross-origin",
@@ -3390,9 +5194,17 @@ var corsRule = {
3390
5194
  const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
3391
5195
  const findings = [];
3392
5196
  const reported = /* @__PURE__ */ new Set();
5197
+ const groupOf = (mark) => mark.mode === "header" ? "header" : `option:${scopes.get(mark.at)}`;
5198
+ const grouped = /* @__PURE__ */ new Map();
5199
+ origins.forEach((mark, order) => {
5200
+ const group = grouped.get(groupOf(mark)) ?? [];
5201
+ group.push({ mark, order });
5202
+ grouped.set(groupOf(mark), group);
5203
+ });
5204
+ const indexes = new Map([...grouped].map(([group, marks]) => [group, indexOrigins(marks)]));
3393
5205
  for (const credential of credentialLines) {
3394
- const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
3395
- const origin = nearestOrigin(candidates, credential.line);
5206
+ if (reported.size === 2) break;
5207
+ const origin = nearestOrigin(indexes.get(groupOf(credential)) ?? [], credential.line);
3396
5208
  if (!origin) continue;
3397
5209
  if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
3398
5210
  if (reported.has(origin.kind)) continue;
@@ -3461,6 +5273,8 @@ var RULE_IDS = [
3461
5273
  "firebase/test-mode-rules",
3462
5274
  "gitleak/env-in-history",
3463
5275
  "gitleak/env-tracked",
5276
+ "supabase/permissive-policy",
5277
+ "supabase/public-bucket-listing",
3464
5278
  "supabase/rls-not-enabled",
3465
5279
  // 凭据规则 ID 从共享格式表生成。
3466
5280
  ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
@@ -3526,6 +5340,15 @@ function sanitize(findings, files) {
3526
5340
  file: f.file === null ? null : clean(f.file),
3527
5341
  // 先脱敏再截断,避免截断导致凭据特征失效。
3528
5342
  excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
5343
+ ...f.evidence ? {
5344
+ evidence: f.evidence.slice(0, 24).map((step) => ({
5345
+ kind: step.kind,
5346
+ file: clean(step.file),
5347
+ line: step.line,
5348
+ description: clean(step.description)
5349
+ })),
5350
+ evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
5351
+ } : {},
3529
5352
  fix: f.fix.map(clean),
3530
5353
  ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
3531
5354
  }));
@@ -3694,6 +5517,9 @@ var gray = wrap(90, 39);
3694
5517
  var BLOCKING = /* @__PURE__ */ new Set(["P0", "P1"]);
3695
5518
 
3696
5519
  // src/report/shared.ts
5520
+ function changeViewNotice(view) {
5521
+ return `Changed-file view: ${view.changedFiles} changed files; ${view.hiddenFindings} findings hidden. Full scan: ${view.totalFindings} findings, ${view.totalBlocking} blocking. Scan scope and exit status are unchanged.`;
5522
+ }
3697
5523
  function plural(n, word) {
3698
5524
  return n === 1 ? word : `${word}s`;
3699
5525
  }
@@ -3753,12 +5579,14 @@ function renderReport(result, opts) {
3753
5579
  `${INDENT}${bold("canship")} ${dim(`scanned ${result.filesScanned} ${plural(result.filesScanned, "file")} in ${result.durationMs}ms`)}`
3754
5580
  );
3755
5581
  out.push(`${INDENT}${dim(opts.root)}`);
5582
+ if (result.changeView) out.push(`${INDENT}${yellow(changeViewNotice(result.changeView))}`);
3756
5583
  out.push("");
3757
5584
  if (findings.length === 0) {
3758
5585
  out.push(...renderClean(result, opts));
3759
5586
  return out.join("\n");
3760
5587
  }
3761
- const { blocking, minor: confirmedMinor, unsure } = verdictOf(findings);
5588
+ const { blocking: visibleBlocking, minor: confirmedMinor, unsure } = verdictOf(findings);
5589
+ const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
3762
5590
  if (blocking > 0) {
3763
5591
  out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
3764
5592
  } else if (confirmedMinor > 0) {
@@ -3800,6 +5628,11 @@ function renderFinding(f, index) {
3800
5628
  out.push("");
3801
5629
  out.push(`${INDENT}${INDENT}${gray(f.excerpt)}`);
3802
5630
  }
5631
+ if (f.evidence?.length) {
5632
+ out.push("", `${INDENT}${INDENT}${bold("Evidence (static relationships):")}`);
5633
+ for (const step of f.evidence) out.push(`${INDENT}${INDENT} ${locationOf(step)} \u2014 ${step.description}`);
5634
+ if (f.evidenceTruncated) out.push(`${INDENT}${INDENT} Additional dependency steps omitted.`);
5635
+ }
3803
5636
  out.push("");
3804
5637
  for (const line of wrapText(f.why.join("\n\n"), 76)) {
3805
5638
  out.push(line === "" ? "" : `${INDENT}${INDENT}${line}`);
@@ -3854,6 +5687,8 @@ function renderClean(result, opts) {
3854
5687
  if (result.partial) {
3855
5688
  const headline = opts.hiddenLikely > 0 ? `! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked` : "! No findings \u2014 but not everything was checked";
3856
5689
  out.push(`${INDENT}${yellow(bold(headline))}`);
5690
+ } else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
5691
+ out.push(`${INDENT}${yellow(bold("! No visible findings in changed files \u2014 other findings still exist"))}`);
3857
5692
  } else if (opts.hiddenLikely > 0) {
3858
5693
  out.push(
3859
5694
  `${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
@@ -3874,9 +5709,9 @@ function renderClean(result, opts) {
3874
5709
  out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
3875
5710
  out.push(`${INDENT}${dim(" \xB7 Supabase service_role keys reachable from the client")}`);
3876
5711
  out.push(`${INDENT}${dim(" \xB7 .env files committed to git, including in history")}`);
3877
- out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security")}`);
5712
+ out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security, or policies open to everyone")}`);
3878
5713
  out.push(`${INDENT}${dim(" \xB7 Firebase rules left open to anyone")}`);
3879
- out.push(`${INDENT}${dim(" \xB7 API routes that query your database with no sign-in check")}`);
5714
+ out.push(`${INDENT}${dim(" \xB7 API routes and server actions that query your database with no sign-in check")}`);
3880
5715
  out.push(`${INDENT}${dim(" \xB7 CORS that lets other sites act as your signed-in visitors")}`);
3881
5716
  out.push("");
3882
5717
  out.push(`${INDENT}${dim("It does not check rate limiting, injection, or whether the checks it")}`);
@@ -4013,8 +5848,12 @@ function renderInstruction(f, index) {
4013
5848
  const location = defuseMarkers(locationOf(f));
4014
5849
  lines.push(`${index}. ${location} \u2014 ${defuseMarkers(f.title)}`);
4015
5850
  if (f.excerpt) lines.push(` Found: ${defuseMarkers(f.excerpt)}`);
5851
+ for (const step of f.evidence ?? []) {
5852
+ lines.push(` Static evidence: ${defuseMarkers(locationOf(step))} \u2014 ${defuseMarkers(step.description)}`);
5853
+ }
5854
+ if (f.evidenceTruncated) lines.push(" Additional dependency steps omitted.");
4016
5855
  for (const step of f.fix) {
4017
- lines.push(` - ${step}`);
5856
+ lines.push(` - ${defuseMarkers(step)}`);
4018
5857
  }
4019
5858
  return lines.join("\n");
4020
5859
  }
@@ -4025,6 +5864,7 @@ function renderFixPrompt(findings, ctx) {
4025
5864
  const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
4026
5865
  const silenced = ctx?.silenced ?? [];
4027
5866
  const suppressedNotes = [
5867
+ ctx?.changeView ? changeViewNotice(ctx.changeView) : null,
4028
5868
  !ctx?.ignoredFiles?.length ? null : `Note: ${ctx.ignoredFiles.length} file(s) excluded by canship-ignore-file: ${defuseMarkers(ctx.ignoredFiles.join(", "))}. Their contents were not checked. Do not treat this as a clean result.`,
4029
5869
  baselineSuppressed === 0 ? null : `Note: ${baselineSuppressed} ${baselineSuppressed === 1 ? "finding was" : "findings were"} hidden by a baseline. Those problems still exist and are not listed below. Re-run without --baseline to see them.`,
4030
5870
  silenced.length === 0 ? null : `Note: ${silenced.length} ${silenced.length === 1 ? "finding was" : "findings were"} silenced by a canship-ignore-next-line marker in the source, at ${defuseMarkers(silenced.join(", "))}. Those problems still exist and are not listed below.`,
@@ -4088,7 +5928,7 @@ function renderFixPrompt(findings, ctx) {
4088
5928
  for (const { step } of humanSteps) {
4089
5929
  if (seen.has(step)) continue;
4090
5930
  seen.add(step);
4091
- out.push(`- ${step}`);
5931
+ out.push(`- ${defuseMarkers(step)}`);
4092
5932
  }
4093
5933
  out.push("");
4094
5934
  out.push("Until these are done, the exposure is still live \u2014 the code fix alone does not close it.");
@@ -4122,6 +5962,7 @@ function renderFinding2(f, index) {
4122
5962
  </header>
4123
5963
  <div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
4124
5964
  ${f.excerpt ? `<pre><code>${esc(f.excerpt)}</code></pre>` : ""}
5965
+ ${f.evidence?.length ? `<h4>Evidence (static relationships)</h4><ol>${f.evidence.map((step) => `<li><code>${esc(locationOf(step))}</code> \u2014 ${esc(step.description)}</li>`).join("")}</ol>${f.evidenceTruncated ? "<p>Additional dependency steps omitted.</p>" : ""}` : ""}
4125
5966
  <div class="why">${linkify(paragraphs(f.why))}</div>
4126
5967
  ${fixList}
4127
5968
  ${humanList}
@@ -4132,14 +5973,15 @@ function renderHtml(result, opts) {
4132
5973
  const hiddenLikely = opts.hiddenLikely ?? 0;
4133
5974
  const baselineSuppressed = opts.baselineSuppressed ?? 0;
4134
5975
  const baselineStale = opts.baselineStale ?? 0;
4135
- const { blocking: certain, minor, unsure } = verdictOf(findings);
5976
+ const { blocking, minor, unsure } = verdictOf(findings);
5977
+ const certain = result.changeView?.totalBlocking ?? blocking;
4136
5978
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
4137
5979
  // 未扫描文件时不显示通过结论。
4138
5980
  `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
4139
5981
  ) : result.partial ? (
4140
5982
  // 扫描不完整时不得显示正常通过。
4141
5983
  hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings &mdash; but not everything was checked</div>`
4142
- ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
5984
+ ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? `<div class="verdict warn">No visible findings in changed files &mdash; other findings still exist</div>` : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
4143
5985
  // 基线抑制结果时明确说明,避免误报为项目无问题。
4144
5986
  baselineSuppressed > 0 ? `<div class="verdict warn">No new findings &mdash; ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline</div>` : `<div class="verdict clean">No exposed credentials found</div>`
4145
5987
  ) : certain > 0 ? `<div class="verdict bad">${certain} critical ${plural(certain, "issue")} &mdash; do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix &mdash; nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
@@ -4152,7 +5994,7 @@ function renderHtml(result, opts) {
4152
5994
  gitignored or build output that canship skips. If the project lives in a
4153
5995
  subdirectory, point canship at it: <code>npx canship ./app</code>.</p>
4154
5996
  </div>`
4155
- ) : hiddenLikely > 0 ? `<div class="clean-note">
5997
+ ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? '<div class="clean-note">This view hides existing findings. Re-run without --changed-since for the full report.</div>' : hiddenLikely > 0 ? `<div class="clean-note">
4156
5998
  <p><strong>This is not a finding-free result.</strong> The default report hides
4157
5999
  ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
4158
6000
  <p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
@@ -4162,7 +6004,7 @@ function renderHtml(result, opts) {
4162
6004
  <p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
4163
6005
  </div>` : `<div class="clean-note">
4164
6006
  <p>canship checked for hardcoded API keys, server secrets exposed to the browser,
4165
- Supabase tables without Row Level Security, open Firebase rules, API routes that reach
6007
+ Supabase tables without Row Level Security or with policies open to everyone, open Firebase rules, API routes and server actions that reach
4166
6008
  the database with no sign-in check, CORS that lets other sites use your visitors&rsquo;
4167
6009
  sessions, and <code>.env</code> files committed to git.</p>
4168
6010
  <p><strong>A clean result means those checks passed &mdash; not that your app is secure.</strong>
@@ -4265,6 +6107,7 @@ function renderHtml(result, opts) {
4265
6107
  <h1>canship report</h1>
4266
6108
  <div class="meta">${esc(opts.root)}<br>${esc(opts.generatedAt)} &middot; ${result.filesScanned} ${plural(result.filesScanned, "file")} scanned in ${result.durationMs}ms</div>
4267
6109
  ${verdict}
6110
+ ${result.changeView ? `<p class="opted-out">${esc(changeViewNotice(result.changeView))}</p>` : ""}
4268
6111
  <div class="notice">
4269
6112
  Credential values canship recognises are masked in this report. One in a format it has no
4270
6113
  pattern for can still appear inside a quoted line, and this report lists your
@@ -4410,6 +6253,7 @@ function applyBaseline(findings, baseline) {
4410
6253
  var INFORMATION_URI = "https://github.com/Tasomei/canship";
4411
6254
  function suppressionNotes(result, opts) {
4412
6255
  const notes = [];
6256
+ if (result.changeView) notes.push({ level: "warning", message: { text: changeViewNotice(result.changeView) } });
4413
6257
  const baseline = opts.baselineSuppressed ?? 0;
4414
6258
  const hidden = opts.hiddenLikely ?? 0;
4415
6259
  if (baseline > 0) {
@@ -4487,7 +6331,18 @@ function resultsOf(findings) {
4487
6331
  }
4488
6332
  }
4489
6333
  ],
4490
- partialFingerprints: { canshipFindingV2: fingerprintOf(f) }
6334
+ partialFingerprints: { canshipFindingV2: fingerprintOf(f) },
6335
+ ...f.evidence?.length ? {
6336
+ relatedLocations: f.evidence.map((step, index) => ({
6337
+ id: index + 1,
6338
+ message: { text: step.description },
6339
+ physicalLocation: {
6340
+ artifactLocation: { uri: step.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
6341
+ ...step.line === null ? {} : { region: { startLine: step.line } }
6342
+ }
6343
+ })),
6344
+ properties: { evidenceTruncated: f.evidenceTruncated ?? false }
6345
+ } : {}
4491
6346
  }));
4492
6347
  }
4493
6348
  function renderSarif(result, opts) {
@@ -4550,7 +6405,8 @@ function createJsonReport(result, options) {
4550
6405
  baselineSuppressed: options.baselineSuppressed,
4551
6406
  baselineStale: options.baselineStale,
4552
6407
  excerptsOmitted: options.excerptsOmitted ?? false,
4553
- findings: result.findings
6408
+ findings: result.findings,
6409
+ ...result.changeView ? { changeView: result.changeView } : {}
4554
6410
  };
4555
6411
  }
4556
6412
 
@@ -4652,15 +6508,15 @@ var RULE_CATALOG = [
4652
6508
  "Admin database access without a recognized auth guard",
4653
6509
  "P0",
4654
6510
  "certain",
4655
- "Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro, including workspace applications.",
4656
- "Syntactic guards only; runtime authentication and business authorization are not verified. Guards in SvelteKit hooks or Nuxt server middleware lower confidence."
6511
+ "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro, including workspace applications.",
6512
+ "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."
4657
6513
  ),
4658
6514
  rule(
4659
6515
  "api/db-write-without-auth",
4660
6516
  "Database write without a recognized auth guard",
4661
6517
  "P1",
4662
6518
  "likely",
4663
- "Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro.",
6519
+ "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
4664
6520
  "Session-scoped clients and indirect guards may need manual review."
4665
6521
  ),
4666
6522
  rule(
@@ -4700,7 +6556,7 @@ var RULE_CATALOG = [
4700
6556
  "Supabase admin credential exposed in source or public env",
4701
6557
  "P0",
4702
6558
  "certain",
4703
- "Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
6559
+ "Recognized service_role JWTs in scanned text files, including configuration and non-JavaScript source; Supabase admin credentials in public environment variables.",
4704
6560
  "Source presence is observable; actual browser delivery and key validity are not verified."
4705
6561
  ),
4706
6562
  rule(
@@ -4708,7 +6564,7 @@ var RULE_CATALOG = [
4708
6564
  "Unconditional Firebase access",
4709
6565
  "P1",
4710
6566
  "varies",
4711
- "Firebase .rules files: unconditional writes are certain; public reads require review.",
6567
+ "Firebase .rules files and Realtime Database JSON rules: unconditional writes are certain; public reads require review.",
4712
6568
  "Public reads may be intentional; runtime rules and business authorization are not verified."
4713
6569
  ),
4714
6570
  rule(
@@ -4735,6 +6591,22 @@ var RULE_CATALOG = [
4735
6591
  "Tracked environment files; templates and public-only values are treated separately.",
4736
6592
  "Unrecognized private values are heuristic findings, not verified credentials."
4737
6593
  ),
6594
+ rule(
6595
+ "supabase/permissive-policy",
6596
+ "Row Level Security policy with an always-true condition",
6597
+ "P1",
6598
+ "varies",
6599
+ "Supabase SQL migrations, replayed by application scope: permissive policies whose USING or WITH CHECK is always true (true, 1=1, 'a'='a'). Changing or deleting any row is certain; reading or inserting any row requires review.",
6600
+ "Missing clauses and dashboard changes are not checked; matching restrictive policies and nontrivial WITH CHECK conditions lower confidence."
6601
+ ),
6602
+ rule(
6603
+ "supabase/public-bucket-listing",
6604
+ "Public storage bucket whose contents can be listed",
6605
+ "P2",
6606
+ "varies",
6607
+ "Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
6608
+ "Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
6609
+ ),
4738
6610
  rule(
4739
6611
  "supabase/rls-not-enabled",
4740
6612
  "Table without RLS in migrations",
@@ -4766,8 +6638,53 @@ function renderRuleCatalog() {
4766
6638
  ).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
4767
6639
  }
4768
6640
 
6641
+ // src/changes.ts
6642
+ var ChangeViewError = class extends Error {
6643
+ };
6644
+ function changedFilesSince(root, ref) {
6645
+ if (!ref || ref.length > 1024 || /[\0\r\n]/.test(ref)) throw new ChangeViewError("Invalid changed-since reference.");
6646
+ const git2 = resolveGitExecutable(root);
6647
+ if (!git2 || !hasContainedGitMetadata(root)) throw new ChangeViewError("Changed-file views require a readable local Git repository.");
6648
+ try {
6649
+ const baseCommit = execGitSync(git2, root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).trim();
6650
+ const mergeBase = execGitSync(git2, root, ["merge-base", baseCommit, "HEAD"]).trim();
6651
+ const hash = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
6652
+ if (!hash.test(baseCommit) || !hash.test(mergeBase)) throw new Error("Invalid object ID");
6653
+ const tracked = execGitSync(git2, root, [
6654
+ "diff",
6655
+ "--no-ext-diff",
6656
+ "--no-textconv",
6657
+ "--no-renames",
6658
+ "--relative",
6659
+ "--name-only",
6660
+ "-z",
6661
+ mergeBase,
6662
+ "--",
6663
+ "."
6664
+ ]);
6665
+ const untracked = execGitSync(git2, root, ["ls-files", "--others", "--exclude-standard", "-z", "--", "."]);
6666
+ const paths = new Set((tracked + untracked).split("\0").filter(Boolean).map(cleanForOutput));
6667
+ return { baseCommit, mergeBase, paths };
6668
+ } catch {
6669
+ throw new ChangeViewError("Cannot compute changed files. Check that the base commit and merge history are available locally.");
6670
+ }
6671
+ }
6672
+ function changedFileView(result, changed) {
6673
+ const findings = result.findings.filter((finding) => finding.file === null || finding.evidenceTruncated || changed.paths.has(finding.file) || finding.evidence?.some((step) => changed.paths.has(step.file)));
6674
+ const verdict = verdictOf(result.findings);
6675
+ return { ...result, findings, changeView: {
6676
+ baseCommit: changed.baseCommit,
6677
+ mergeBase: changed.mergeBase,
6678
+ changedFiles: changed.paths.size,
6679
+ hiddenFindings: result.findings.length - findings.length,
6680
+ totalFindings: result.findings.length,
6681
+ totalBlocking: verdict.blocking,
6682
+ totalLikely: verdict.unsure
6683
+ } };
6684
+ }
6685
+
4769
6686
  // src/cli.ts
4770
- var VERSION = true ? "0.3.2" : "0.0.0-dev";
6687
+ var VERSION = true ? "0.5.0" : "0.0.0-dev";
4771
6688
  function finish(code) {
4772
6689
  process.exitCode = code;
4773
6690
  }
@@ -4786,7 +6703,7 @@ function optionalValue(arg, name, fallback) {
4786
6703
  function insideProject(root, relative3) {
4787
6704
  const target = resolve2(root, relative3);
4788
6705
  const inside = relative_(realPathOf(root), realPathOf(target));
4789
- if (inside === "" || inside.startsWith("..") || isAbsolute2(inside)) {
6706
+ if (inside === "" || inside.startsWith("..") || isAbsolute3(inside)) {
4790
6707
  argumentError(
4791
6708
  `${CONFIG_FILENAME}: "baseline" must stay inside the project, and ${relative3} does not`
4792
6709
  );
@@ -4798,7 +6715,7 @@ function realPathOf(path) {
4798
6715
  const rest = [];
4799
6716
  for (let depth = 0; depth < MAX_REAL_PATH_DEPTH; depth++) {
4800
6717
  try {
4801
- const real = realpathSync2(at);
6718
+ const real = realpathSync3(at);
4802
6719
  return rest.length === 0 ? real : resolve2(real, ...rest);
4803
6720
  } catch {
4804
6721
  const parent = resolve2(at, "..");
@@ -4836,10 +6753,16 @@ function parseArgs(argv) {
4836
6753
  help: false,
4837
6754
  version: false,
4838
6755
  listRules: false,
4839
- noExcerpts: false
6756
+ noExcerpts: false,
6757
+ changedSince: null
4840
6758
  };
4841
6759
  const positional = [];
4842
6760
  for (const arg of argv) {
6761
+ if (arg.startsWith("--changed-since=")) {
6762
+ if (args.changedSince !== null || arg === "--changed-since=") argumentError("--changed-since requires one non-empty reference");
6763
+ args.changedSince = arg.slice("--changed-since=".length);
6764
+ continue;
6765
+ }
4843
6766
  const list = (name) => {
4844
6767
  if (!arg.startsWith(`${name}=`)) return null;
4845
6768
  const value = arg.slice(name.length + 1);
@@ -4957,6 +6880,8 @@ var HELP = `
4957
6880
  markers; use with --no-config for untrusted projects
4958
6881
  --list-rules List rule IDs, scope, and limits; add --json for structured output
4959
6882
  --no-excerpts Omit source excerpts from every report; paths and descriptions remain
6883
+ --changed-since=REF Show changed-file findings since the local merge base;
6884
+ scan scope and exit status remain unchanged
4960
6885
  -h, --help Show this help
4961
6886
  -v, --version Show version
4962
6887
 
@@ -5005,6 +6930,10 @@ async function main() {
5005
6930
  `);
5006
6931
  return finish(3);
5007
6932
  }
6933
+ if (args.changedSince !== null && (args.baselineWrite !== null || args.baselineWriteDefault)) {
6934
+ argumentError("--changed-since cannot be combined with --baseline-write");
6935
+ }
6936
+ const changed = args.changedSince === null ? null : changedFilesSince(args.root, args.changedSince);
5008
6937
  let config;
5009
6938
  try {
5010
6939
  config = args.noConfig ? {} : loadConfig(args.root).config;
@@ -5097,10 +7026,13 @@ ${cleanForOutput(String(err))}
5097
7026
  }
5098
7027
  const displayRoot = cleanForOutput(args.root);
5099
7028
  if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
7029
+ const fullResult = result;
7030
+ if (changed) result = changedFileView(result, changed);
5100
7031
  const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
5101
7032
  const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
5102
7033
  if (args.fixPrompt) {
5103
7034
  const prompt = renderFixPrompt(shown, {
7035
+ ...result.changeView ? { changeView: result.changeView } : {},
5104
7036
  partial: result.partial,
5105
7037
  filesScanned: result.filesScanned,
5106
7038
  hiddenLikely,
@@ -5207,13 +7139,13 @@ ${cleanForOutput(String(err))}
5207
7139
  return finish(3);
5208
7140
  }
5209
7141
  }
5210
- if (verdictOf(result.findings).blocking > 0) return finish(1);
5211
- if (result.findings.length > 0) return finish(2);
7142
+ if (verdictOf(fullResult.findings).blocking > 0) return finish(1);
7143
+ if (fullResult.findings.length > 0) return finish(2);
5212
7144
  if (result.partial && !bestEffort) return finish(3);
5213
7145
  return finish(0);
5214
7146
  }
5215
7147
  main().catch((err) => {
5216
- if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
7148
+ if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}
5217
7149
  `);
5218
7150
  else process.stderr.write(`${red("canship: unexpected error")}
5219
7151
  ${cleanForOutput(String(err))}