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/README-zh-CN.md +134 -103
- package/README.md +138 -107
- package/dist/cli.js +2186 -254
- package/dist/index.d.ts +159 -0
- package/dist/index.js +5693 -0
- package/package.json +12 -1
- package/schemas/scan-report-v1.schema.json +26 -0
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
|
|
5
|
-
import { existsSync as existsSync3, realpathSync as
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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(
|
|
612
|
-
const read = readSync(fd, buf, 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) ?
|
|
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 =
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if (
|
|
719
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
-
|
|
2188
|
-
|
|
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
|
|
2956
|
+
return owners;
|
|
2191
2957
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2318
|
-
const
|
|
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 &&
|
|
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 = /(?:^|\/)
|
|
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 =
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
2548
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2711
|
-
const queue =
|
|
2712
|
-
|
|
2713
|
-
const
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
|
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 = /\
|
|
2725
|
-
var PRISMA_RAW = /\
|
|
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
|
|
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
|
|
2773
|
-
let best =
|
|
4477
|
+
function middlewaresFor(ctx, routePath, framework) {
|
|
4478
|
+
let best = [];
|
|
2774
4479
|
let bestDepth = -1;
|
|
2775
4480
|
for (const file of ctx.files) {
|
|
2776
|
-
if (!
|
|
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
|
|
2816
|
-
const
|
|
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 <
|
|
2822
|
-
const ch =
|
|
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 &&
|
|
2836
|
-
|
|
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
|
-
|
|
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
|
|
2843
|
-
if (
|
|
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 =
|
|
4590
|
+
const valueStart = topLevelPropertyStart(objectText, "matcher");
|
|
2861
4591
|
if (valueStart === null) return { kind: "absent" };
|
|
2862
4592
|
const rest = objectText.slice(valueStart);
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
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,
|
|
2958
|
-
|
|
2959
|
-
|
|
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.
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
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
|
-
|
|
3395
|
-
const origin = nearestOrigin(
|
|
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
|
|
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 — nothing was checked</div>`
|
|
4139
5981
|
) : result.partial ? (
|
|
4140
5982
|
// 扫描不完整时不得显示正常通过。
|
|
4141
5983
|
hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings — but not everything was checked</div>`
|
|
4142
|
-
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
5984
|
+
) : (result.changeView?.hiddenFindings ?? 0) > 0 ? `<div class="verdict warn">No visible findings in changed files — other findings still exist</div>` : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
4143
5985
|
// 基线抑制结果时明确说明,避免误报为项目无问题。
|
|
4144
5986
|
baselineSuppressed > 0 ? `<div class="verdict warn">No new findings — ${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")} — do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix — 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’
|
|
4167
6009
|
sessions, and <code>.env</code> files committed to git.</p>
|
|
4168
6010
|
<p><strong>A clean result means those checks passed — 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)} · ${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.
|
|
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.
|
|
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("..") ||
|
|
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 =
|
|
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(
|
|
5211
|
-
if (
|
|
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))}
|