canship 0.4.0 → 0.6.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 +99 -153
- package/README.md +97 -151
- package/dist/cli.js +3156 -542
- package/dist/index.js +2434 -195
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolve as resolve2 } from "path";
|
|
|
8
8
|
var IRRELEVANT_HOSTS = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|.*\.?example\.(?:com|org|net)|.*\.(?:test|invalid|localhost))$/i;
|
|
9
9
|
var JWT_SOURCE = String.raw`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`;
|
|
10
10
|
var SB_SECRET_SOURCE = String.raw`sb_secret_[A-Za-z0-9_-]{8,}`;
|
|
11
|
+
var PRIVATE_KEY_BEGIN_SOURCE = String.raw`-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----`;
|
|
11
12
|
var SECRET_PATTERNS = [
|
|
12
13
|
{
|
|
13
14
|
id: "openai",
|
|
@@ -133,7 +134,7 @@ var SECRET_PATTERNS = [
|
|
|
133
134
|
id: "private-key",
|
|
134
135
|
name: "Private key file contents",
|
|
135
136
|
rotateLabel: "private key",
|
|
136
|
-
pattern:
|
|
137
|
+
pattern: new RegExp(PRIVATE_KEY_BEGIN_SOURCE, "g"),
|
|
137
138
|
impact: "A private key in source code can be used to impersonate your server, decrypt traffic, or log into your machines."
|
|
138
139
|
},
|
|
139
140
|
{
|
|
@@ -214,13 +215,29 @@ function redactSecret(secret) {
|
|
|
214
215
|
return `${head}\u2026(${secret.length} chars)\u2026${tail}`;
|
|
215
216
|
}
|
|
216
217
|
function redactLine(line, secret) {
|
|
217
|
-
const trimmed = line.trim();
|
|
218
|
+
const trimmed = redactPrivateKeys(line.trim());
|
|
218
219
|
if (!secret) return trimmed;
|
|
219
220
|
return trimmed.split(secret).join(redactSecret(secret));
|
|
220
221
|
}
|
|
221
222
|
var JWT_SHAPED = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
|
|
223
|
+
function redactPrivateKeys(text) {
|
|
224
|
+
const starts = new RegExp(PRIVATE_KEY_BEGIN_SOURCE, "g");
|
|
225
|
+
const parts = [];
|
|
226
|
+
let from = 0;
|
|
227
|
+
let match;
|
|
228
|
+
while ((match = starts.exec(text)) !== null) {
|
|
229
|
+
const closing = match[0].replace("BEGIN ", "END ");
|
|
230
|
+
const end = text.indexOf(closing, starts.lastIndex);
|
|
231
|
+
parts.push(text.slice(from, match.index), "[REDACTED PRIVATE KEY]");
|
|
232
|
+
from = end === -1 ? text.length : end + closing.length;
|
|
233
|
+
starts.lastIndex = from;
|
|
234
|
+
if (end === -1) break;
|
|
235
|
+
}
|
|
236
|
+
parts.push(text.slice(from));
|
|
237
|
+
return parts.join("");
|
|
238
|
+
}
|
|
222
239
|
function redactAll(text) {
|
|
223
|
-
let out = text;
|
|
240
|
+
let out = redactPrivateKeys(text);
|
|
224
241
|
for (const pat of SECRET_PATTERNS) {
|
|
225
242
|
const re = new RegExp(pat.pattern.source, pat.pattern.flags);
|
|
226
243
|
out = out.replace(re, (match) => redactSecret(match));
|
|
@@ -235,8 +252,8 @@ function truncate(s, max = 120) {
|
|
|
235
252
|
import { basename as basename2 } from "path";
|
|
236
253
|
|
|
237
254
|
// src/walker.ts
|
|
238
|
-
import {
|
|
239
|
-
import { join as join2, relative as relative2, sep as sep2, extname, basename } from "path";
|
|
255
|
+
import { opendirSync, readFileSync as readFileSync2, statSync as statSync2, lstatSync as lstatSync2, realpathSync as realpathSync2, openSync, readSync, closeSync } from "fs";
|
|
256
|
+
import { join as join2, relative as relative2, isAbsolute as isAbsolute2, sep as sep2, extname, basename } from "path";
|
|
240
257
|
|
|
241
258
|
// src/git.ts
|
|
242
259
|
import { execFileSync } from "child_process";
|
|
@@ -473,6 +490,7 @@ function execGitBatch(executable, root, args, input, options = {}) {
|
|
|
473
490
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
474
491
|
var MAX_SCAN_BYTES = 128 * 1024 * 1024;
|
|
475
492
|
var MAX_SCAN_FILES = 1e4;
|
|
493
|
+
var MAX_WALK_ENTRIES = 5e4;
|
|
476
494
|
var MAX_WALK_DEPTH = 16;
|
|
477
495
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
478
496
|
"node_modules",
|
|
@@ -649,16 +667,16 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
649
667
|
".sqlite3",
|
|
650
668
|
".mo"
|
|
651
669
|
]);
|
|
652
|
-
function probeFileType(absPath) {
|
|
670
|
+
function probeFileType(absPath, limit) {
|
|
653
671
|
let fd = null;
|
|
654
672
|
try {
|
|
655
673
|
fd = openSync(absPath, "r");
|
|
656
|
-
const buf = Buffer.alloc(
|
|
657
|
-
const read = readSync(fd, buf, 0,
|
|
674
|
+
const buf = Buffer.alloc(limit);
|
|
675
|
+
const read = readSync(fd, buf, 0, limit, 0);
|
|
658
676
|
const head = buf.subarray(0, read);
|
|
659
677
|
const hasTextBom = head.length >= 2 && head[0] === 255 && head[1] === 254 || head.length >= 2 && head[0] === 254 && head[1] === 255 || head.length >= 3 && head[0] === 239 && head[1] === 187 && head[2] === 191;
|
|
660
|
-
if (hasTextBom) return { kind: "text" };
|
|
661
|
-
return head.includes(0) ?
|
|
678
|
+
if (hasTextBom) return { kind: "text", bytes: read };
|
|
679
|
+
return { kind: head.includes(0) ? "binary" : "text", bytes: read };
|
|
662
680
|
} catch (err) {
|
|
663
681
|
return {
|
|
664
682
|
kind: "unreadable",
|
|
@@ -731,10 +749,13 @@ function listViaGit(root, gitExecutable) {
|
|
|
731
749
|
return null;
|
|
732
750
|
}
|
|
733
751
|
}
|
|
734
|
-
function walkTree(root, skipped, wantAll) {
|
|
752
|
+
function walkTree(root, skipped, wantAll, maxEntries) {
|
|
735
753
|
const all = [];
|
|
736
754
|
const found = [];
|
|
755
|
+
let visited = 0;
|
|
756
|
+
let exhausted = false;
|
|
737
757
|
const walk = (dir, depth) => {
|
|
758
|
+
if (exhausted) return;
|
|
738
759
|
if (depth > MAX_WALK_DEPTH) {
|
|
739
760
|
skipped.push({
|
|
740
761
|
path: relative2(root, dir).split(sep2).join("/") || ".",
|
|
@@ -745,7 +766,7 @@ function walkTree(root, skipped, wantAll) {
|
|
|
745
766
|
}
|
|
746
767
|
let entries;
|
|
747
768
|
try {
|
|
748
|
-
entries =
|
|
769
|
+
entries = opendirSync(dir);
|
|
749
770
|
} catch (err) {
|
|
750
771
|
if (!isMissing(err)) {
|
|
751
772
|
skipped.push({
|
|
@@ -756,31 +777,56 @@ function walkTree(root, skipped, wantAll) {
|
|
|
756
777
|
}
|
|
757
778
|
return;
|
|
758
779
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
if (
|
|
764
|
-
|
|
780
|
+
try {
|
|
781
|
+
while (!exhausted) {
|
|
782
|
+
const entry = entries.readSync();
|
|
783
|
+
if (entry === null) break;
|
|
784
|
+
if (visited >= maxEntries) {
|
|
785
|
+
exhausted = true;
|
|
786
|
+
skipped.push({
|
|
787
|
+
path: relative2(root, dir).split(sep2).join("/") || ".",
|
|
788
|
+
reason: "directory-unreadable",
|
|
789
|
+
detail: `directory entry budget exceeded (${maxEntries} entries)`
|
|
790
|
+
});
|
|
791
|
+
break;
|
|
765
792
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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;
|
|
781
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
|
+
if (isCredentialName(entry.name) || isWorthProbing(rel)) found.push(rel);
|
|
782
821
|
}
|
|
783
|
-
|
|
822
|
+
} catch (err) {
|
|
823
|
+
if (!isMissing(err)) skipped.push({
|
|
824
|
+
path: relative2(root, dir).split(sep2).join("/") || ".",
|
|
825
|
+
reason: "directory-unreadable",
|
|
826
|
+
detail: String(err instanceof Error ? err.message : err)
|
|
827
|
+
});
|
|
828
|
+
} finally {
|
|
829
|
+
entries.closeSync();
|
|
784
830
|
}
|
|
785
831
|
};
|
|
786
832
|
walk(root, 0);
|
|
@@ -824,11 +870,44 @@ function isExampleContext(relPath) {
|
|
|
824
870
|
if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
|
|
825
871
|
return false;
|
|
826
872
|
}
|
|
873
|
+
function isCredentialName(name) {
|
|
874
|
+
return isEnvFile(name) || CREDENTIAL_FILENAMES.has(name) || CREDENTIAL_EXTENSIONS.has(extname(name).toLowerCase());
|
|
875
|
+
}
|
|
876
|
+
function readablePath(root, path, skipped) {
|
|
877
|
+
const rel = relative2(root, path);
|
|
878
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
|
|
879
|
+
skipped.push({ path: ".", reason: "unreadable", detail: "candidate path is outside the scan root" });
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
let current = root;
|
|
883
|
+
for (const segment of rel.split(sep2)) {
|
|
884
|
+
current = join2(current, segment);
|
|
885
|
+
const stat = lstatSync2(current);
|
|
886
|
+
if (stat.isSymbolicLink()) {
|
|
887
|
+
const linked = relative2(root, current).split(sep2).join("/");
|
|
888
|
+
if (!skipped.some((item) => item.path === linked && item.reason === "symlink")) {
|
|
889
|
+
skipped.push({ path: linked, reason: "symlink", detail: "symbolic links are not followed" });
|
|
890
|
+
}
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const real = relative2(root, realpathSync2(path));
|
|
895
|
+
if (real === ".." || real.startsWith(`..${sep2}`) || isAbsolute2(real)) {
|
|
896
|
+
skipped.push({ path: rel.split(sep2).join("/"), reason: "unreadable", detail: "resolved path is outside the scan root" });
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
if (!lstatSync2(path).isFile()) {
|
|
900
|
+
skipped.push({ path: rel.split(sep2).join("/"), reason: "unreadable", detail: "not a regular file" });
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
827
905
|
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
|
|
906
|
+
root = realpathSync2(root);
|
|
828
907
|
const skipped = [];
|
|
829
908
|
const ignored = [];
|
|
830
909
|
const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
|
|
831
|
-
const walked = walkTree(root, skipped, fromGit === null);
|
|
910
|
+
const walked = walkTree(root, skipped, fromGit === null, limits.maxEntries ?? MAX_WALK_ENTRIES);
|
|
832
911
|
const listed = fromGit?.files ?? walked.all;
|
|
833
912
|
const candidates = /* @__PURE__ */ new Set();
|
|
834
913
|
let vendored = 0;
|
|
@@ -857,19 +936,23 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
857
936
|
let filesRead = 0;
|
|
858
937
|
const maxBytes = limits.maxBytes ?? MAX_SCAN_BYTES;
|
|
859
938
|
const maxFiles = limits.maxFiles ?? MAX_SCAN_FILES;
|
|
939
|
+
const credentials = [];
|
|
940
|
+
const sources = [];
|
|
941
|
+
const probes = [];
|
|
860
942
|
for (const relPath of candidates) {
|
|
861
943
|
if (!forced.has(relPath) && !shouldScan(relPath)) continue;
|
|
944
|
+
if (isCredentialName(basename(relPath))) credentials.push(relPath);
|
|
945
|
+
else if (isWorthProbing(relPath)) probes.push(relPath);
|
|
946
|
+
else sources.push(relPath);
|
|
947
|
+
}
|
|
948
|
+
for (const relPath of [...credentials, ...sources, ...probes]) {
|
|
862
949
|
const absPath = join2(root, relPath);
|
|
863
950
|
let content;
|
|
864
951
|
try {
|
|
865
|
-
if (
|
|
866
|
-
if (!skipped.some((entry) => entry.path === relPath && entry.reason === "symlink")) {
|
|
867
|
-
skipped.push({ path: relPath, reason: "symlink", detail: "symbolic links are not followed" });
|
|
868
|
-
}
|
|
869
|
-
continue;
|
|
870
|
-
}
|
|
952
|
+
if (!readablePath(root, absPath, skipped)) continue;
|
|
871
953
|
const size = statSync2(absPath).size;
|
|
872
|
-
|
|
954
|
+
const needsProbe = isWorthProbing(relPath);
|
|
955
|
+
if (!needsProbe && size > MAX_FILE_BYTES) {
|
|
873
956
|
skipped.push({
|
|
874
957
|
path: relPath,
|
|
875
958
|
reason: "too-large",
|
|
@@ -877,7 +960,8 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
877
960
|
});
|
|
878
961
|
continue;
|
|
879
962
|
}
|
|
880
|
-
|
|
963
|
+
const initialBytes = needsProbe ? Math.min(size, PROBE_BYTES) : size;
|
|
964
|
+
if (filesRead >= maxFiles || bytesRead + initialBytes > maxBytes) {
|
|
881
965
|
skipped.push({
|
|
882
966
|
path: relPath,
|
|
883
967
|
reason: "too-large",
|
|
@@ -885,9 +969,30 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
885
969
|
});
|
|
886
970
|
break;
|
|
887
971
|
}
|
|
972
|
+
filesRead++;
|
|
973
|
+
if (needsProbe) {
|
|
974
|
+
const probe = probeFileType(absPath, initialBytes);
|
|
975
|
+
if (probe.kind === "unreadable") {
|
|
976
|
+
skipped.push({ path: relPath, reason: "unreadable", detail: probe.detail });
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
bytesRead += probe.bytes;
|
|
980
|
+
if (probe.kind === "binary") continue;
|
|
981
|
+
if (size > MAX_FILE_BYTES) {
|
|
982
|
+
skipped.push({
|
|
983
|
+
path: relPath,
|
|
984
|
+
reason: "too-large",
|
|
985
|
+
detail: `${Math.round(size / 1024)} KB, cap is ${MAX_FILE_BYTES / 1024} KB`
|
|
986
|
+
});
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (bytesRead + size > maxBytes) {
|
|
990
|
+
skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded after file probe" });
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
888
994
|
const bytes = readFileSync2(absPath);
|
|
889
995
|
bytesRead += bytes.length;
|
|
890
|
-
filesRead++;
|
|
891
996
|
if (bytesRead > maxBytes) {
|
|
892
997
|
skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
|
|
893
998
|
break;
|
|
@@ -961,6 +1066,8 @@ var BACKSLASH = 92;
|
|
|
961
1066
|
var DOLLAR = 36;
|
|
962
1067
|
var OPEN_BRACE = 123;
|
|
963
1068
|
var CLOSE_BRACE = 125;
|
|
1069
|
+
var LESS_THAN = 60;
|
|
1070
|
+
var GREATER_THAN = 62;
|
|
964
1071
|
var NEWLINE = 10;
|
|
965
1072
|
var SPACE = 32;
|
|
966
1073
|
function codesOf(src) {
|
|
@@ -997,11 +1104,102 @@ function endOfString(src, start, quote) {
|
|
|
997
1104
|
}
|
|
998
1105
|
return length;
|
|
999
1106
|
}
|
|
1000
|
-
|
|
1107
|
+
var REGEX_PREFIX_WORDS = /* @__PURE__ */ new Set(["return", "throw", "case", "delete", "void", "typeof", "instanceof", "in", "of", "new", "yield", "await", "else", "do"]);
|
|
1108
|
+
var CONTROL_WORDS = /* @__PURE__ */ new Set(["if", "while", "for", "with", "switch", "catch"]);
|
|
1109
|
+
var regexContext = () => ({ allowed: true, control: false, member: false, parentheses: [] });
|
|
1110
|
+
function valueEnded(context) {
|
|
1111
|
+
context.allowed = false;
|
|
1112
|
+
context.control = false;
|
|
1113
|
+
context.member = false;
|
|
1114
|
+
}
|
|
1115
|
+
function regexAt(src, start, context, state) {
|
|
1116
|
+
if (!context.allowed || start < state.failedUntil) return null;
|
|
1117
|
+
let inClass = false;
|
|
1118
|
+
for (let i = start + 1; i < src.length; i++) {
|
|
1119
|
+
const ch = src[i];
|
|
1120
|
+
if (ch === "\n" || ch === "\r") {
|
|
1121
|
+
state.failedUntil = i;
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
if (ch === "\\") {
|
|
1125
|
+
i++;
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (ch === "[") inClass = true;
|
|
1129
|
+
else if (ch === "]") inClass = false;
|
|
1130
|
+
else if (ch === "/" && !inClass) {
|
|
1131
|
+
let end = i + 1;
|
|
1132
|
+
while (end < src.length && /[A-Za-z]/.test(src[end])) end++;
|
|
1133
|
+
valueEnded(context);
|
|
1134
|
+
return { close: i, end };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
state.failedUntil = src.length;
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
function advanceCode(src, at, context) {
|
|
1141
|
+
const ch = src[at];
|
|
1142
|
+
if (/\s/.test(ch)) return at + 1;
|
|
1143
|
+
if (/[A-Za-z_$\u0080-]/.test(ch)) {
|
|
1144
|
+
let end = at + 1;
|
|
1145
|
+
while (end < src.length && /[\w$\u0080-]/.test(src[end])) end++;
|
|
1146
|
+
const word = src.slice(at, end);
|
|
1147
|
+
context.control = !context.member && CONTROL_WORDS.has(word);
|
|
1148
|
+
context.allowed = !context.member && REGEX_PREFIX_WORDS.has(word);
|
|
1149
|
+
context.member = false;
|
|
1150
|
+
return end;
|
|
1151
|
+
}
|
|
1152
|
+
if (ch === "(") {
|
|
1153
|
+
context.parentheses.push(context.control);
|
|
1154
|
+
context.allowed = true;
|
|
1155
|
+
} else if (ch === ")") context.allowed = context.parentheses.pop() ?? false;
|
|
1156
|
+
else if (ch === "." || ch === "?" && src[at + 1] === ".") {
|
|
1157
|
+
context.allowed = false;
|
|
1158
|
+
context.control = false;
|
|
1159
|
+
context.member = true;
|
|
1160
|
+
return at + (ch === "?" ? 2 : 1);
|
|
1161
|
+
} else if ((ch === "+" || ch === "-") && src[at + 1] === ch) {
|
|
1162
|
+
context.control = false;
|
|
1163
|
+
context.member = false;
|
|
1164
|
+
return at + 2;
|
|
1165
|
+
} else context.allowed = !/[\d\]}]/.test(ch);
|
|
1166
|
+
context.control = false;
|
|
1167
|
+
context.member = false;
|
|
1168
|
+
return at + 1;
|
|
1169
|
+
}
|
|
1170
|
+
var JSX_START = /<(?:>|([A-Za-z_$][\w$.:-]*)(?=[\s/>])(?!\s*(?:,|=|extends\b)))/y;
|
|
1171
|
+
function jsxStartsAt(src, at) {
|
|
1172
|
+
JSX_START.lastIndex = at;
|
|
1173
|
+
const match = JSX_START.exec(src);
|
|
1174
|
+
if (!match) return false;
|
|
1175
|
+
const name = match[1];
|
|
1176
|
+
if (!name || !/^[A-Z]/.test(name)) return true;
|
|
1177
|
+
const after = at + 1 + name.length;
|
|
1178
|
+
return !/^>\s*\(/.test(src.slice(after, after + 64));
|
|
1179
|
+
}
|
|
1180
|
+
function maskSource(src, maskStrings, jsx) {
|
|
1001
1181
|
const length = src.length;
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1182
|
+
const out = codesOf(src);
|
|
1183
|
+
const state = { failedUntil: -1 };
|
|
1184
|
+
const stack = [{ kind: "code", depth: 0, lexical: regexContext(), nested: false }];
|
|
1185
|
+
const text = (from, to) => {
|
|
1186
|
+
if (maskStrings) blank(out, from, to);
|
|
1187
|
+
};
|
|
1188
|
+
const expression = () => ({ kind: "code", depth: 1, lexical: regexContext(), nested: true });
|
|
1189
|
+
const skipComment = (at) => {
|
|
1190
|
+
const star = src.charCodeAt(at + 1) === STAR;
|
|
1191
|
+
const close = star ? src.indexOf("*/", at + 2) : src.indexOf("\n", at);
|
|
1192
|
+
const stop = close === -1 ? length : close + (star ? 2 : 0);
|
|
1193
|
+
blank(out, at, stop);
|
|
1194
|
+
return stop;
|
|
1195
|
+
};
|
|
1196
|
+
const elementClosed = (at) => {
|
|
1197
|
+
const parent = stack[stack.length - 1];
|
|
1198
|
+
if (parent?.kind === "children") parent.from = at;
|
|
1199
|
+
else if (parent?.kind === "code") valueEnded(parent.lexical);
|
|
1200
|
+
};
|
|
1201
|
+
let i = 0;
|
|
1202
|
+
while (i < length) {
|
|
1005
1203
|
const frame = stack[stack.length - 1];
|
|
1006
1204
|
const ch = src.charCodeAt(i);
|
|
1007
1205
|
if (frame.kind === "literal") {
|
|
@@ -1010,141 +1208,155 @@ function maskTemplate(src, out, start, maskStrings = true) {
|
|
|
1010
1208
|
continue;
|
|
1011
1209
|
}
|
|
1012
1210
|
if (ch === BACKTICK) {
|
|
1013
|
-
|
|
1211
|
+
text(frame.from, i);
|
|
1014
1212
|
stack.pop();
|
|
1213
|
+
const parent = stack[stack.length - 1];
|
|
1214
|
+
if (parent?.kind === "code") valueEnded(parent.lexical);
|
|
1015
1215
|
i++;
|
|
1016
1216
|
continue;
|
|
1017
1217
|
}
|
|
1018
1218
|
if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
|
|
1019
|
-
|
|
1020
|
-
stack.push(
|
|
1219
|
+
text(frame.from, i);
|
|
1220
|
+
stack.push(expression());
|
|
1021
1221
|
i += 2;
|
|
1022
1222
|
continue;
|
|
1023
1223
|
}
|
|
1024
1224
|
i++;
|
|
1025
1225
|
continue;
|
|
1026
1226
|
}
|
|
1027
|
-
if (
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1227
|
+
if (frame.kind === "children") {
|
|
1228
|
+
if (ch === OPEN_BRACE) {
|
|
1229
|
+
text(frame.from, i);
|
|
1230
|
+
stack.push(expression());
|
|
1231
|
+
i++;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (ch === LESS_THAN && src.charCodeAt(i + 1) === SLASH) {
|
|
1235
|
+
text(frame.from, i);
|
|
1236
|
+
stack.push({ kind: "tag", closing: true });
|
|
1237
|
+
i += 2;
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (ch === LESS_THAN && jsxStartsAt(src, i)) {
|
|
1241
|
+
text(frame.from, i);
|
|
1242
|
+
stack.push({ kind: "tag", closing: false });
|
|
1243
|
+
i++;
|
|
1034
1244
|
continue;
|
|
1035
1245
|
}
|
|
1036
|
-
}
|
|
1037
|
-
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1038
|
-
const stop = endOfString(src, i, ch);
|
|
1039
|
-
if (maskStrings) blank(out, i + 1, stop - 1);
|
|
1040
|
-
i = stop;
|
|
1041
|
-
continue;
|
|
1042
|
-
}
|
|
1043
|
-
if (ch === BACKTICK) {
|
|
1044
|
-
stack.push({ kind: "literal", from: i + 1 });
|
|
1045
1246
|
i++;
|
|
1046
1247
|
continue;
|
|
1047
1248
|
}
|
|
1048
|
-
if (
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
stack.pop();
|
|
1053
|
-
const parent = stack[stack.length - 1];
|
|
1054
|
-
if (parent.kind === "literal") parent.from = i + 1;
|
|
1249
|
+
if (frame.kind === "tag") {
|
|
1250
|
+
if (ch === SLASH && (src.charCodeAt(i + 1) === SLASH || src.charCodeAt(i + 1) === STAR)) {
|
|
1251
|
+
i = skipComment(i);
|
|
1252
|
+
continue;
|
|
1055
1253
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
|
|
1061
|
-
return i;
|
|
1062
|
-
}
|
|
1063
|
-
function maskJsComments(src) {
|
|
1064
|
-
const length = src.length;
|
|
1065
|
-
const out = codesOf(src);
|
|
1066
|
-
let i = 0;
|
|
1067
|
-
while (i < length) {
|
|
1068
|
-
const ch = src.charCodeAt(i);
|
|
1069
|
-
if (ch === SLASH) {
|
|
1070
|
-
const next = src.charCodeAt(i + 1);
|
|
1071
|
-
if (next === SLASH) {
|
|
1072
|
-
const end = src.indexOf("\n", i);
|
|
1073
|
-
const stop = end === -1 ? length : end;
|
|
1074
|
-
blank(out, i, stop);
|
|
1254
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1255
|
+
const close = src.indexOf(src[i], i + 1);
|
|
1256
|
+
const stop = close === -1 ? length : close + 1;
|
|
1257
|
+
text(i + 1, close === -1 ? length : close);
|
|
1075
1258
|
i = stop;
|
|
1076
1259
|
continue;
|
|
1077
1260
|
}
|
|
1078
|
-
if (
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
blank(out, i, stop);
|
|
1082
|
-
i = stop;
|
|
1261
|
+
if (ch === OPEN_BRACE) {
|
|
1262
|
+
stack.push(expression());
|
|
1263
|
+
i++;
|
|
1083
1264
|
continue;
|
|
1084
1265
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1266
|
+
if (ch === LESS_THAN && !frame.closing && jsxStartsAt(src, i)) {
|
|
1267
|
+
stack.push({ kind: "tag", closing: false });
|
|
1268
|
+
i++;
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (ch === SLASH && src.charCodeAt(i + 1) === GREATER_THAN && !frame.closing) {
|
|
1272
|
+
stack.pop();
|
|
1273
|
+
elementClosed(i + 2);
|
|
1274
|
+
i += 2;
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (ch === GREATER_THAN) {
|
|
1278
|
+
stack.pop();
|
|
1279
|
+
if (frame.closing) {
|
|
1280
|
+
if (stack[stack.length - 1]?.kind === "children") stack.pop();
|
|
1281
|
+
elementClosed(i + 1);
|
|
1282
|
+
} else {
|
|
1283
|
+
stack.push({ kind: "children", from: i + 1 });
|
|
1284
|
+
}
|
|
1285
|
+
i++;
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
i++;
|
|
1092
1289
|
continue;
|
|
1093
1290
|
}
|
|
1094
|
-
i++;
|
|
1095
|
-
}
|
|
1096
|
-
return stringOf(out);
|
|
1097
|
-
}
|
|
1098
|
-
function maskJsNoise(src) {
|
|
1099
|
-
const length = src.length;
|
|
1100
|
-
const out = codesOf(src);
|
|
1101
|
-
let i = 0;
|
|
1102
|
-
while (i < length) {
|
|
1103
|
-
const ch = src.charCodeAt(i);
|
|
1104
1291
|
if (ch === SLASH) {
|
|
1105
1292
|
const next = src.charCodeAt(i + 1);
|
|
1106
|
-
if (next === SLASH) {
|
|
1107
|
-
|
|
1108
|
-
const stop = end === -1 ? length : end;
|
|
1109
|
-
blank(out, i, stop);
|
|
1110
|
-
i = stop;
|
|
1293
|
+
if (next === SLASH || next === STAR) {
|
|
1294
|
+
i = skipComment(i);
|
|
1111
1295
|
continue;
|
|
1112
1296
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
i = stop;
|
|
1297
|
+
const regex = regexAt(src, i, frame.lexical, state);
|
|
1298
|
+
if (regex) {
|
|
1299
|
+
text(i + 1, regex.close);
|
|
1300
|
+
i = regex.end;
|
|
1118
1301
|
continue;
|
|
1119
1302
|
}
|
|
1120
1303
|
}
|
|
1121
1304
|
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1122
1305
|
const stop = endOfString(src, i, ch);
|
|
1123
|
-
|
|
1306
|
+
text(i + 1, stop - 1);
|
|
1307
|
+
valueEnded(frame.lexical);
|
|
1124
1308
|
i = stop;
|
|
1125
1309
|
continue;
|
|
1126
1310
|
}
|
|
1127
1311
|
if (ch === BACKTICK) {
|
|
1128
|
-
|
|
1312
|
+
stack.push({ kind: "literal", from: i + 1 });
|
|
1313
|
+
i++;
|
|
1129
1314
|
continue;
|
|
1130
1315
|
}
|
|
1131
|
-
i
|
|
1316
|
+
const markup = ch === LESS_THAN && !jsx && /^<[A-Za-z_$][\w$.:-]*\s+[\w:-]+\s*=\s*['"{]/.test(src.slice(i, i + 256));
|
|
1317
|
+
if ((jsx || markup) && ch === LESS_THAN && frame.lexical.allowed && jsxStartsAt(src, i)) {
|
|
1318
|
+
stack.push({ kind: "tag", closing: false });
|
|
1319
|
+
i++;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (ch === OPEN_BRACE) frame.depth++;
|
|
1323
|
+
else if (ch === CLOSE_BRACE) {
|
|
1324
|
+
frame.depth--;
|
|
1325
|
+
if (frame.nested && frame.depth === 0) {
|
|
1326
|
+
stack.pop();
|
|
1327
|
+
const parent = stack[stack.length - 1];
|
|
1328
|
+
if (parent.kind === "literal" || parent.kind === "children") parent.from = i + 1;
|
|
1329
|
+
i++;
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
i = advanceCode(src, i, frame.lexical);
|
|
1132
1334
|
}
|
|
1335
|
+
const last = stack[stack.length - 1];
|
|
1336
|
+
if (last?.kind === "literal" || last?.kind === "children") text(last.from, length);
|
|
1133
1337
|
return stringOf(out);
|
|
1134
1338
|
}
|
|
1339
|
+
function maskJsComments(src, options = {}) {
|
|
1340
|
+
return maskSource(src, false, options.jsx === true);
|
|
1341
|
+
}
|
|
1342
|
+
function maskJsNoise(src, options = {}) {
|
|
1343
|
+
return maskSource(src, true, options.jsx === true);
|
|
1344
|
+
}
|
|
1345
|
+
var JSX_FILE = /\.(?:jsx|tsx|js|mjs|cjs)$/i;
|
|
1346
|
+
var maskOptionsOf = (file) => ({ jsx: typeof file.path === "string" && JSX_FILE.test(file.path) });
|
|
1135
1347
|
var commentCache = /* @__PURE__ */ new WeakMap();
|
|
1136
1348
|
var noiseCache = /* @__PURE__ */ new WeakMap();
|
|
1137
1349
|
function commentsMaskedOf(file) {
|
|
1138
1350
|
const hit = commentCache.get(file);
|
|
1139
1351
|
if (hit !== void 0) return hit;
|
|
1140
|
-
const masked = maskJsComments(file.content);
|
|
1352
|
+
const masked = maskJsComments(file.content, maskOptionsOf(file));
|
|
1141
1353
|
commentCache.set(file, masked);
|
|
1142
1354
|
return masked;
|
|
1143
1355
|
}
|
|
1144
1356
|
function noiseMaskedOf(file) {
|
|
1145
1357
|
const hit = noiseCache.get(file);
|
|
1146
1358
|
if (hit !== void 0) return hit;
|
|
1147
|
-
const masked = maskJsNoise(file.content);
|
|
1359
|
+
const masked = maskJsNoise(file.content, maskOptionsOf(file));
|
|
1148
1360
|
noiseCache.set(file, masked);
|
|
1149
1361
|
return masked;
|
|
1150
1362
|
}
|
|
@@ -1450,7 +1662,7 @@ function checkEnvFile(file) {
|
|
|
1450
1662
|
`For anything the browser needs, use the anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY) together with Row Level Security policies.`
|
|
1451
1663
|
],
|
|
1452
1664
|
humanOnly: [
|
|
1453
|
-
`Rotate the service_role key in your Supabase dashboard (Project Settings -> API). If your site has ever been deployed with this key, assume it is already compromised \u2014 renaming the variable does not revoke it.`
|
|
1665
|
+
`Rotate the Supabase service_role key in your Supabase dashboard (Project Settings -> API). If your site has ever been deployed with this key, assume it is already compromised \u2014 renaming the variable does not revoke it.`
|
|
1454
1666
|
]
|
|
1455
1667
|
});
|
|
1456
1668
|
continue;
|
|
@@ -1532,7 +1744,8 @@ function pushServiceRoleJwts(findings, file, line, i, clientSide) {
|
|
|
1532
1744
|
`Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
|
|
1533
1745
|
],
|
|
1534
1746
|
humanOnly: [
|
|
1535
|
-
|
|
1747
|
+
// 与公开变量中的同类结果使用相同首句,汇总时合并为一个轮换步骤。
|
|
1748
|
+
`Rotate the Supabase service_role key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
|
|
1536
1749
|
]
|
|
1537
1750
|
});
|
|
1538
1751
|
}
|
|
@@ -1786,8 +1999,9 @@ var gitleakRule = {
|
|
|
1786
1999
|
],
|
|
1787
2000
|
fix: [`Add ${path} to .gitignore.`, untrackStep(path)],
|
|
1788
2001
|
humanOnly: [
|
|
1789
|
-
|
|
1790
|
-
`
|
|
2002
|
+
// 人工步骤会被单独汇总,首句须写明文件并能独立执行。
|
|
2003
|
+
`Rotate every credential in ${path}. This is the step people skip, and it is the only one that actually stops the leak.`,
|
|
2004
|
+
`If you want ${path} gone from git history, rewrite the history with git filter-repo or BFG Repo-Cleaner after rotating the keys. Rotation is what matters; history rewriting is disruptive enough that it should be a deliberate decision.`
|
|
1791
2005
|
]
|
|
1792
2006
|
});
|
|
1793
2007
|
reportedTracked.add(path);
|
|
@@ -1829,8 +2043,8 @@ var gitleakRule = {
|
|
|
1829
2043
|
],
|
|
1830
2044
|
fix: stillTracked ? [`Add ${path} to .gitignore.`, untrackStep(path)] : [`Confirm ${path} is in .gitignore so it does not come back.`],
|
|
1831
2045
|
humanOnly: [
|
|
1832
|
-
`Rotate every credential that was ever in
|
|
1833
|
-
`
|
|
2046
|
+
`Rotate every credential that was ever in ${path}. Do this first, and do not skip it \u2014 it is the only step that actually revokes access.`,
|
|
2047
|
+
`If you want ${path} gone from git history, rewrite the history with git filter-repo or BFG Repo-Cleaner after rotating the keys. Do this deliberately: it rewrites every commit hash and disrupts anyone else working on the repo.`
|
|
1834
2048
|
]
|
|
1835
2049
|
});
|
|
1836
2050
|
}
|
|
@@ -2460,8 +2674,8 @@ var supabaseRlsRule = {
|
|
|
2460
2674
|
`Keep this in a migration rather than only in the dashboard, so the rule travels with your code.`
|
|
2461
2675
|
],
|
|
2462
2676
|
humanOnly: [
|
|
2463
|
-
|
|
2464
|
-
`If
|
|
2677
|
+
// 后果说明并入同一步骤,汇总时不会变成脱离表名的独立条目。
|
|
2678
|
+
`Check the real state first: open Table Editor in the Supabase dashboard and look for the "RLS disabled" badge on "${entry.table}". The repository cannot tell you whether RLS was turned on there. If the table has been live without RLS, assume its contents have already been read.`
|
|
2465
2679
|
]
|
|
2466
2680
|
});
|
|
2467
2681
|
}
|
|
@@ -2551,18 +2765,19 @@ function policyFindings(ctx, policies, live, keyOf, buckets) {
|
|
|
2551
2765
|
ruleId: "supabase/permissive-policy",
|
|
2552
2766
|
severity: "P1",
|
|
2553
2767
|
confidence: modifiesAny && !needsReview ? "certain" : "likely",
|
|
2554
|
-
title:
|
|
2768
|
+
title: hasRestriction ? `Policy "${p.name}" has an unrestricted condition on ${table}; other conditions require review` : limitedCheck ? `Policy "${p.name}" lets ${audience} target any row of ${table} for updates; only the new values are checked` : opensRows ? `Policy "${p.name}" lets ${audience} ${verb} every row of ${table}` : `Policy "${p.name}" lets ${audience} write any values into ${table}`,
|
|
2555
2769
|
file: p.file,
|
|
2556
2770
|
line: p.line,
|
|
2557
2771
|
excerpt: excerpt(p.file, p.line),
|
|
2558
2772
|
why: [
|
|
2559
2773
|
`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.`,
|
|
2560
2774
|
...hasRestriction ? ["A restrictive policy on this table may limit access; verify its command and role coverage."] : [],
|
|
2561
|
-
|
|
2775
|
+
// PostgreSQL:USING 决定可更新的已有行,WITH CHECK 只约束写回的新行;带条件的更新还需通过 SELECT 策略。
|
|
2776
|
+
...limitedCheck ? ["USING is always true, so this policy does not limit which existing rows can be updated. WITH CHECK only validates the values written back: a caller may overwrite other users' rows, for example by setting the owner column to their own ID. Updates that filter or return rows must also pass SELECT policies, which may prevent this."] : [],
|
|
2562
2777
|
...p.command === "select" || p.command === "insert" ? [`Public read-only tables and open forms can be intentional. If this one is, keep it and consider restricting the columns the API exposes.`] : []
|
|
2563
2778
|
],
|
|
2564
2779
|
fix: [
|
|
2565
|
-
`
|
|
2780
|
+
`Tie each row to its owner; adapt user_id to your schema. For ${p.command.toUpperCase()}, use ` + (p.command === "insert" ? "WITH CHECK ((select auth.uid()) = user_id)." : `USING ((select auth.uid()) = user_id)${p.command === "update" || p.command === "all" ? " and WITH CHECK ((select auth.uid()) = user_id)" : ""}.`),
|
|
2566
2781
|
`If a broad policy is needed as a base, add an AS RESTRICTIVE policy alongside it to limit which rows it reaches.`,
|
|
2567
2782
|
`Keep the change in a migration, so the rule travels with your code.`
|
|
2568
2783
|
],
|
|
@@ -2664,7 +2879,7 @@ function checkRealtimeRules(file, ctx) {
|
|
|
2664
2879
|
]
|
|
2665
2880
|
],
|
|
2666
2881
|
fix: [
|
|
2667
|
-
`Require sign-in and ownership
|
|
2882
|
+
`Require sign-in and ownership for ${[openRead ? ".read" : "", openWrite ? ".write" : ""].filter(Boolean).join(" and ")}. For a per-user "$uid" node, use "auth !== null && auth.uid === $uid" for each of those rule values; adapt the path to your data model.`,
|
|
2668
2883
|
`Test the new rules with the Firebase emulator before deploying.`,
|
|
2669
2884
|
...openWrite ? [`If this database has been open for a while, assume the data has already been copied or changed.`] : []
|
|
2670
2885
|
]
|
|
@@ -2718,6 +2933,18 @@ var WRITE_OPS = /* @__PURE__ */ new Set(["write", "create", "update", "delete"])
|
|
|
2718
2933
|
function parseOps(raw) {
|
|
2719
2934
|
return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2720
2935
|
}
|
|
2936
|
+
function ownershipAdvice(product, rawOps) {
|
|
2937
|
+
const prefix = `Replace the open or date-based condition for ${rawOps.trim()} with authentication and ownership checks. `;
|
|
2938
|
+
if (product !== "Firestore") {
|
|
2939
|
+
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.";
|
|
2940
|
+
}
|
|
2941
|
+
const ops = new Set(parseOps(rawOps));
|
|
2942
|
+
const checks = ["Require request.auth != null. Adapt userId to your ownership field."];
|
|
2943
|
+
if (ops.has("create") || ops.has("write")) checks.push("For create, check request.resource.data.userId == request.auth.uid; no existing resource is available.");
|
|
2944
|
+
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.");
|
|
2945
|
+
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.");
|
|
2946
|
+
return prefix + checks.join(" ") + " Do not grant additional operations.";
|
|
2947
|
+
}
|
|
2721
2948
|
function neutralizePathWildcards(content) {
|
|
2722
2949
|
return content.replace(/\{[a-zA-Z_]\w*(?:\s*=\s*\*\*)?\}/g, (m) => "_".repeat(m.length));
|
|
2723
2950
|
}
|
|
@@ -2816,7 +3043,7 @@ var firebaseRulesRule = {
|
|
|
2816
3043
|
`Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
|
|
2817
3044
|
],
|
|
2818
3045
|
fix: canWrite ? [
|
|
2819
|
-
|
|
3046
|
+
ownershipAdvice(product, rawOps),
|
|
2820
3047
|
`For data that is genuinely public, restrict it to reads only: allow read: if true; allow write: if false;`,
|
|
2821
3048
|
`Test your rules with the Firebase emulator before deploying, so you do not lock yourself out.`,
|
|
2822
3049
|
`If this database has been open for a while, assume the data has already been copied.`
|
|
@@ -2854,7 +3081,7 @@ var firebaseRulesRule = {
|
|
|
2854
3081
|
`Neither state is what you want in production.`
|
|
2855
3082
|
],
|
|
2856
3083
|
fix: [
|
|
2857
|
-
|
|
3084
|
+
ownershipAdvice(product, rawOps),
|
|
2858
3085
|
`Test the new rules with the Firebase emulator before deploying.`,
|
|
2859
3086
|
expired ? `Note that your app is currently denied access here, so fixing this also fixes whatever stopped working.` : `Do this before ${dateStr}, otherwise your app breaks on that date.`
|
|
2860
3087
|
]
|
|
@@ -2907,6 +3134,266 @@ function namePattern(names) {
|
|
|
2907
3134
|
return [...names].map((name) => name.replace(/\$/g, "\\$")).join("|");
|
|
2908
3135
|
}
|
|
2909
3136
|
|
|
3137
|
+
// src/rules/auth-values.ts
|
|
3138
|
+
function project(value, path) {
|
|
3139
|
+
let current = value;
|
|
3140
|
+
for (const property of path) {
|
|
3141
|
+
if (current.kind === "envelope") current = property === "data" ? { kind: "data", field: current.field } : { kind: "opaque" };
|
|
3142
|
+
else if (current.kind === "data") current = property === current.field ? { kind: "identity" } : { kind: "opaque" };
|
|
3143
|
+
else break;
|
|
3144
|
+
}
|
|
3145
|
+
return current;
|
|
3146
|
+
}
|
|
3147
|
+
var limited = /* @__PURE__ */ new WeakSet();
|
|
3148
|
+
var cache2 = /* @__PURE__ */ new WeakMap();
|
|
3149
|
+
var reverseCache = /* @__PURE__ */ new WeakMap();
|
|
3150
|
+
var identityFlowLimited = (file) => limited.has(file);
|
|
3151
|
+
function bindingNames(pattern, path = []) {
|
|
3152
|
+
const text = pattern.trim();
|
|
3153
|
+
if (path.length > 8) return (text.match(/[A-Za-z_$][\w$]*/g) ?? []).map((name) => ({ name, path: null }));
|
|
3154
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) return [{ name: text, path }];
|
|
3155
|
+
if (text.startsWith("{") && text.endsWith("}")) {
|
|
3156
|
+
const parts = [];
|
|
3157
|
+
let from = 1, depth = 0;
|
|
3158
|
+
for (let i = 1; i < text.length - 1; i++) {
|
|
3159
|
+
if ("{[(".includes(text[i])) depth++;
|
|
3160
|
+
else if ("}])".includes(text[i])) depth--;
|
|
3161
|
+
else if (text[i] === "," && depth === 0) {
|
|
3162
|
+
parts.push(text.slice(from, i));
|
|
3163
|
+
from = i + 1;
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
parts.push(text.slice(from, -1));
|
|
3167
|
+
const result = [];
|
|
3168
|
+
for (const part of parts) {
|
|
3169
|
+
if (!part.trim()) continue;
|
|
3170
|
+
const property = /^\s*([A-Za-z_$][\w$]*)(?:\s*:\s*([^=]+))?\s*$/.exec(part);
|
|
3171
|
+
if (property) result.push(...bindingNames(property[2] ?? property[1], [...path, property[1]]));
|
|
3172
|
+
else for (const name of part.match(/[A-Za-z_$][\w$]*/g) ?? []) result.push({ name, path: null });
|
|
3173
|
+
}
|
|
3174
|
+
return result;
|
|
3175
|
+
}
|
|
3176
|
+
return (text.match(/[A-Za-z_$][\w$]*/g) ?? []).map((name) => ({ name, path: null }));
|
|
3177
|
+
}
|
|
3178
|
+
function argumentExpressions(code, source, start, end, pairs) {
|
|
3179
|
+
const result = [];
|
|
3180
|
+
let from = start;
|
|
3181
|
+
for (let i = start; i < end; i++) {
|
|
3182
|
+
if (code[i] === ",") {
|
|
3183
|
+
result.push(source.slice(from, i).trim());
|
|
3184
|
+
from = i + 1;
|
|
3185
|
+
}
|
|
3186
|
+
const close = pairs.get(i);
|
|
3187
|
+
if (close !== void 0) i = close;
|
|
3188
|
+
}
|
|
3189
|
+
if (from < end) result.push(source.slice(from, end).trim());
|
|
3190
|
+
return result;
|
|
3191
|
+
}
|
|
3192
|
+
function authValuesOf(file, body, bodies, pairs) {
|
|
3193
|
+
let contexts = cache2.get(file);
|
|
3194
|
+
if (!contexts) {
|
|
3195
|
+
contexts = /* @__PURE__ */ new Map();
|
|
3196
|
+
cache2.set(file, contexts);
|
|
3197
|
+
}
|
|
3198
|
+
let found = contexts.get(body.start);
|
|
3199
|
+
if (!found) {
|
|
3200
|
+
found = new AuthValues(file, body, bodies, pairs);
|
|
3201
|
+
contexts.set(body.start, found);
|
|
3202
|
+
}
|
|
3203
|
+
return found;
|
|
3204
|
+
}
|
|
3205
|
+
var AuthValues = class {
|
|
3206
|
+
constructor(file, body, bodies, pairs) {
|
|
3207
|
+
this.file = file;
|
|
3208
|
+
const code = noiseMaskedOf(file);
|
|
3209
|
+
const source = commentsMaskedOf(file);
|
|
3210
|
+
let open = code.indexOf("(", body.declaration);
|
|
3211
|
+
let close = pairs.get(open);
|
|
3212
|
+
if (code.startsWith("=>", body.declaration)) {
|
|
3213
|
+
const window = code.slice(Math.max(0, body.declaration - 300), body.declaration);
|
|
3214
|
+
const before = window.trimEnd();
|
|
3215
|
+
if (before.endsWith(")")) {
|
|
3216
|
+
close = body.declaration - (window.length - before.length) - 1;
|
|
3217
|
+
let reverse = reverseCache.get(file);
|
|
3218
|
+
if (!reverse) {
|
|
3219
|
+
reverse = new Map([...pairs].map(([left, right]) => [right, left]));
|
|
3220
|
+
reverseCache.set(file, reverse);
|
|
3221
|
+
}
|
|
3222
|
+
open = reverse.get(close) ?? -1;
|
|
3223
|
+
} else {
|
|
3224
|
+
const single = /([A-Za-z_$][\w$]*)$/.exec(before)?.[1];
|
|
3225
|
+
if (single) this.parameters.set(single, 0);
|
|
3226
|
+
open = -1;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
if (open >= 0 && close !== void 0 && close < body.start) {
|
|
3230
|
+
argumentExpressions(code, code, open + 1, close, pairs).forEach((part, index) => {
|
|
3231
|
+
const name = /^([A-Za-z_$][\w$]*)(?:\s*\??\s*:[^=]+)?$/.exec(part)?.[1];
|
|
3232
|
+
if (name) this.parameters.set(name, index);
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
const text = code.slice(body.start + 1, body.end);
|
|
3236
|
+
const endOf = (start) => {
|
|
3237
|
+
const boundary = Math.min(body.end, start + 4001);
|
|
3238
|
+
let previous = "";
|
|
3239
|
+
for (let i = start; i < boundary; i++) {
|
|
3240
|
+
if (code[i] === ";" || code[i] === "}") return i;
|
|
3241
|
+
if (code[i] === "\n") {
|
|
3242
|
+
let next = i + 1;
|
|
3243
|
+
while (next < boundary && /\s/.test(code[next])) next++;
|
|
3244
|
+
if (next === boundary && boundary < body.end) return boundary;
|
|
3245
|
+
if (!/[.?+\-*/%&|^,:([`=]/.test(code[next] ?? "") && !/[=+\-*/%&|^?:,.]/.test(previous)) return i;
|
|
3246
|
+
i = next - 1;
|
|
3247
|
+
continue;
|
|
3248
|
+
}
|
|
3249
|
+
if (!/\s/.test(code[i])) previous = code[i];
|
|
3250
|
+
const close2 = pairs.get(i);
|
|
3251
|
+
if (close2 !== void 0) {
|
|
3252
|
+
i = close2;
|
|
3253
|
+
previous = code[close2];
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return boundary;
|
|
3257
|
+
};
|
|
3258
|
+
for (const match2 of text.matchAll(/\b(?:if|for|while|switch|catch)\s*\(/g)) {
|
|
3259
|
+
const close2 = pairs.get(body.start + 1 + match2.index + match2[0].length - 1);
|
|
3260
|
+
if (close2 === void 0) continue;
|
|
3261
|
+
let start = close2 + 1;
|
|
3262
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
3263
|
+
this.conditions.push({ start, end: pairs.get(start) ?? endOf(start) });
|
|
3264
|
+
if (this.conditions.length > 512) {
|
|
3265
|
+
this.exhausted = true;
|
|
3266
|
+
limited.add(file);
|
|
3267
|
+
break;
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
let low = 0, high = bodies.length;
|
|
3271
|
+
while (low < high) {
|
|
3272
|
+
const middle = low + high >>> 1;
|
|
3273
|
+
if (bodies[middle].declaration <= body.start) low = middle + 1;
|
|
3274
|
+
else high = middle;
|
|
3275
|
+
}
|
|
3276
|
+
const nested = [];
|
|
3277
|
+
for (let i = low; i < bodies.length && bodies[i].declaration < body.end; i++) nested.push(bodies[i]);
|
|
3278
|
+
let nestedIndex = 0;
|
|
3279
|
+
let count = 0;
|
|
3280
|
+
const declarations = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*|[\[{])|(?<![\w$.])([A-Za-z_$][\w$]*)\s*=(?!=|>)/g;
|
|
3281
|
+
let match;
|
|
3282
|
+
while ((match = declarations.exec(text)) !== null) {
|
|
3283
|
+
if (this.exhausted) break;
|
|
3284
|
+
const at = body.start + 1 + match.index;
|
|
3285
|
+
while (nestedIndex < nested.length && nested[nestedIndex].end < at) nestedIndex++;
|
|
3286
|
+
if (nested[nestedIndex] && at >= nested[nestedIndex].declaration && at < nested[nestedIndex].end) continue;
|
|
3287
|
+
if (++count > 512) {
|
|
3288
|
+
this.exhausted = true;
|
|
3289
|
+
limited.add(file);
|
|
3290
|
+
break;
|
|
3291
|
+
}
|
|
3292
|
+
let binding = match[1] ?? match[2];
|
|
3293
|
+
let from = at + match[0].length;
|
|
3294
|
+
if (match[1]) {
|
|
3295
|
+
if (binding === "{" || binding === "[") {
|
|
3296
|
+
const start = from - 1;
|
|
3297
|
+
const close2 = pairs.get(start);
|
|
3298
|
+
if (close2 === void 0) continue;
|
|
3299
|
+
if (close2 - start > 4e3) {
|
|
3300
|
+
this.exhausted = true;
|
|
3301
|
+
limited.add(file);
|
|
3302
|
+
break;
|
|
3303
|
+
}
|
|
3304
|
+
binding = code.slice(start, close2 + 1);
|
|
3305
|
+
from = close2 + 1;
|
|
3306
|
+
}
|
|
3307
|
+
while (/\s/.test(code[from] ?? "")) from++;
|
|
3308
|
+
if (code[from] === ":") {
|
|
3309
|
+
const type = /^:[^=;\n]{1,120}/.exec(code.slice(from, from + 121));
|
|
3310
|
+
if (type) from += type[0].length;
|
|
3311
|
+
}
|
|
3312
|
+
if (code[from] !== "=" || /[=>]/.test(code[from + 1] ?? "")) continue;
|
|
3313
|
+
from++;
|
|
3314
|
+
}
|
|
3315
|
+
while (/\s/.test(code[from] ?? "")) from++;
|
|
3316
|
+
declarations.lastIndex = from - body.start - 1;
|
|
3317
|
+
const end = endOf(from);
|
|
3318
|
+
for (const { name, path } of bindingNames(binding)) {
|
|
3319
|
+
if (!this.assignments.has(name) && this.assignments.size >= 512) {
|
|
3320
|
+
this.exhausted = true;
|
|
3321
|
+
limited.add(file);
|
|
3322
|
+
break;
|
|
3323
|
+
}
|
|
3324
|
+
const list = this.assignments.get(name) ?? [];
|
|
3325
|
+
list.push({ at, expression: end - from <= 4e3 ? source.slice(from, end).trim() : "", projection: path, overLimit: end - from > 4e3 });
|
|
3326
|
+
this.assignments.set(name, list);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
file;
|
|
3331
|
+
assignments = /* @__PURE__ */ new Map();
|
|
3332
|
+
parameters = /* @__PURE__ */ new Map();
|
|
3333
|
+
conditions = [];
|
|
3334
|
+
exhausted = false;
|
|
3335
|
+
value(expression, at, depth = 0) {
|
|
3336
|
+
if (depth >= 8) limited.add(this.file);
|
|
3337
|
+
if (this.exhausted || depth >= 8) return { kind: "opaque" };
|
|
3338
|
+
const expr = expression.trim();
|
|
3339
|
+
if (expr.length > 4e3) {
|
|
3340
|
+
limited.add(this.file);
|
|
3341
|
+
return { kind: "opaque" };
|
|
3342
|
+
}
|
|
3343
|
+
if (/^process\.env\.[A-Z_][A-Z0-9_]*$/.test(expr)) return { kind: "secret" };
|
|
3344
|
+
const template = /^`[^`$]*\$\{([^{}`]{1,200})\}[^`$]*`$/.exec(expr);
|
|
3345
|
+
if (template) return this.value(template[1], at, depth + 1).kind === "secret" ? { kind: "secret" } : { kind: "literal" };
|
|
3346
|
+
if (/^(?:(?:true|false|null|undefined)\b|\d|['"`{\[])/.test(expr)) return { kind: "literal" };
|
|
3347
|
+
const call = /^(?:await\s+)?((?:[A-Za-z_$][\w$]*\.)*(?:getUser|getClaims|getUserSession|getSession|getServerSession|safeGetSession|currentUser|verifyIdToken|auth))\s*\(/.exec(expr);
|
|
3348
|
+
const callee = call?.[1];
|
|
3349
|
+
const identityCall = callee && !/\.(?:body|query|headers|cookies)\b/.test(callee);
|
|
3350
|
+
if (identityCall) {
|
|
3351
|
+
if (!/^await\s+/.test(expr)) return { kind: "promise" };
|
|
3352
|
+
const masked = maskJsNoise(expr);
|
|
3353
|
+
let depth2 = 1, end = call[0].length;
|
|
3354
|
+
for (; end < masked.length && depth2 > 0; end++) {
|
|
3355
|
+
if (masked[end] === "(") depth2++;
|
|
3356
|
+
else if (masked[end] === ")") depth2--;
|
|
3357
|
+
}
|
|
3358
|
+
if (depth2 !== 0 || masked.slice(end).trim() !== "") return { kind: "opaque" };
|
|
3359
|
+
if (/\|\||&&|\?(?!\.)|\.then\s*\(/.test(expr) || /\.catch\s*\(/.test(expr)) return { kind: "opaque" };
|
|
3360
|
+
if (/\.auth\.getSession\s*\($/.test(call[0])) return { kind: "opaque" };
|
|
3361
|
+
const envelope = /\.auth\.(getUser|getClaims)\s*\($/.exec(call[0])?.[1];
|
|
3362
|
+
if (envelope) return { kind: "envelope", field: envelope === "getUser" ? "user" : "claims" };
|
|
3363
|
+
return { kind: "identity" };
|
|
3364
|
+
}
|
|
3365
|
+
if (/\.(?:body|query|headers|cookies|searchParams)\b|\.(?:json|text|formData)\s*\(/.test(expr)) return { kind: "input" };
|
|
3366
|
+
const access = /^([A-Za-z_$][\w$]*)((?:\??\.[A-Za-z_$][\w$]*)*)$/.exec(expr);
|
|
3367
|
+
if (!access) return { kind: "opaque" };
|
|
3368
|
+
const name = access[1];
|
|
3369
|
+
const list = this.assignments.get(name) ?? [];
|
|
3370
|
+
let low = 0, high = list.length;
|
|
3371
|
+
while (low < high) {
|
|
3372
|
+
const middle = low + high >>> 1;
|
|
3373
|
+
if (list[middle].at < at) low = middle + 1;
|
|
3374
|
+
else high = middle;
|
|
3375
|
+
}
|
|
3376
|
+
const assignment = list[low - 1];
|
|
3377
|
+
let value;
|
|
3378
|
+
if (assignment) {
|
|
3379
|
+
if (assignment.overLimit) {
|
|
3380
|
+
limited.add(this.file);
|
|
3381
|
+
return { kind: "opaque" };
|
|
3382
|
+
}
|
|
3383
|
+
if (this.conditions.some((range) => range.start <= assignment.at && assignment.at <= range.end && !(range.start <= at && at <= range.end))) return { kind: "opaque" };
|
|
3384
|
+
value = this.value(assignment.expression, assignment.at, depth + 1);
|
|
3385
|
+
value = assignment.projection === null ? { kind: "opaque" } : project(value, assignment.projection);
|
|
3386
|
+
} else if (this.parameters.has(name)) value = { kind: "parameter", parameters: [this.parameters.get(name)] };
|
|
3387
|
+
else value = { kind: "unknown" };
|
|
3388
|
+
return project(value, access[2].split(/\??\./).filter(Boolean));
|
|
3389
|
+
}
|
|
3390
|
+
};
|
|
3391
|
+
function identityRequirement(value, allowParameters) {
|
|
3392
|
+
if (value.kind === "identity") return [];
|
|
3393
|
+
if (allowParameters && value.kind === "parameter") return value.parameters ?? [];
|
|
3394
|
+
return null;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
2910
3397
|
// src/rules/apiauth.ts
|
|
2911
3398
|
var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
|
|
2912
3399
|
var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
|
|
@@ -3124,6 +3611,8 @@ var ACCESS_CHAIN = String.raw`[\w$]+(?:\??\.[\w$]+)*`;
|
|
|
3124
3611
|
var NEGATED_IDENTITY = new RegExp(`^!\\s*${ACCESS_CHAIN}(?:\\s*\\([^=]*\\))?$`);
|
|
3125
3612
|
var IDENTITY_IS_EMPTY = new RegExp(`^${ACCESS_CHAIN}\\s*={2,3}\\s*(?:null|undefined|false)$`);
|
|
3126
3613
|
var IDENTITY_MISMATCH = new RegExp(`^${ACCESS_CHAIN}\\s*!={1,2}\\s*(?!(?:null|undefined|false)\\b)${ACCESS_CHAIN}$`);
|
|
3614
|
+
var COMPARED_VALUE = String.raw`(?!(?:null|undefined|true|false)\b)(?:${ACCESS_CHAIN}(?:\s*\([^()]{0,200}\))?|\`[^\`]{0,300}\`)`;
|
|
3615
|
+
var SECRET_MISMATCH = new RegExp(`^${COMPARED_VALUE}\\s*!={1,2}\\s*${COMPARED_VALUE}$`);
|
|
3127
3616
|
var STOPS_REQUEST = /^(?:(?:return|throw|redirect|notFound)\b|error\s*\(\s*40[13]\b)/;
|
|
3128
3617
|
var DENIED_STATUS = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus(?:Code)?\s*[:(=]\s*(?:401|403)\b|\berror\s*\(\s*40[13]\b/i;
|
|
3129
3618
|
function closingDelimiter(source, start, open, close) {
|
|
@@ -3153,7 +3642,7 @@ function rejectsMissingIdentity(term, returnsDeniedStatus) {
|
|
|
3153
3642
|
const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
|
|
3154
3643
|
return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
|
|
3155
3644
|
}
|
|
3156
|
-
function hasConditionalAuthGuard(code) {
|
|
3645
|
+
function hasConditionalAuthGuard(code, requireThrow = false, accept) {
|
|
3157
3646
|
const starts = /\bif\s*\(/g;
|
|
3158
3647
|
let match;
|
|
3159
3648
|
while ((match = starts.exec(code)) !== null) {
|
|
@@ -3167,7 +3656,7 @@ function hasConditionalAuthGuard(code) {
|
|
|
3167
3656
|
let stopsRequest = false;
|
|
3168
3657
|
for (let i = 0; i < body.length; i++) {
|
|
3169
3658
|
if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
|
|
3170
|
-
stopsRequest =
|
|
3659
|
+
stopsRequest = !requireThrow || !/^return\b/.test(body.slice(i, i + 16));
|
|
3171
3660
|
break;
|
|
3172
3661
|
}
|
|
3173
3662
|
if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
|
|
@@ -3179,7 +3668,10 @@ function hasConditionalAuthGuard(code) {
|
|
|
3179
3668
|
starts.lastIndex = statementStart + statement.length;
|
|
3180
3669
|
if (!stopsRequest) continue;
|
|
3181
3670
|
const returnsDeniedStatus = DENIED_STATUS.test(statement);
|
|
3182
|
-
const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) =>
|
|
3671
|
+
const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => {
|
|
3672
|
+
const term = part.trim();
|
|
3673
|
+
return accept ? (NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term) || SECRET_MISMATCH.test(term)) && accept(term) : rejectsMissingIdentity(term, returnsDeniedStatus);
|
|
3674
|
+
});
|
|
3183
3675
|
if (negative) return true;
|
|
3184
3676
|
}
|
|
3185
3677
|
return false;
|
|
@@ -3281,6 +3773,18 @@ function unguardedOpsOf(file) {
|
|
|
3281
3773
|
}
|
|
3282
3774
|
return hit;
|
|
3283
3775
|
}
|
|
3776
|
+
function operationsInRange(ops, start, end) {
|
|
3777
|
+
const lowerBound2 = (value) => {
|
|
3778
|
+
let low = 0, high = ops.length;
|
|
3779
|
+
while (low < high) {
|
|
3780
|
+
const middle = low + high >>> 1;
|
|
3781
|
+
if (ops[middle].index < value) low = middle + 1;
|
|
3782
|
+
else high = middle;
|
|
3783
|
+
}
|
|
3784
|
+
return low;
|
|
3785
|
+
};
|
|
3786
|
+
return ops.slice(lowerBound2(start + 1), lowerBound2(end));
|
|
3787
|
+
}
|
|
3284
3788
|
var lineStartsCache = /* @__PURE__ */ new WeakMap();
|
|
3285
3789
|
function lineStartsCached(file) {
|
|
3286
3790
|
let hit = lineStartsCache.get(file);
|
|
@@ -3290,39 +3794,97 @@ function lineStartsCached(file) {
|
|
|
3290
3794
|
}
|
|
3291
3795
|
return hit;
|
|
3292
3796
|
}
|
|
3293
|
-
function cachedByFile(
|
|
3294
|
-
const
|
|
3797
|
+
function cachedByFile(check2) {
|
|
3798
|
+
const cache3 = /* @__PURE__ */ new WeakMap();
|
|
3295
3799
|
return (file) => {
|
|
3296
|
-
let hit =
|
|
3800
|
+
let hit = cache3.get(file);
|
|
3297
3801
|
if (hit === void 0) {
|
|
3298
|
-
hit =
|
|
3299
|
-
|
|
3802
|
+
hit = check2(file);
|
|
3803
|
+
cache3.set(file, hit);
|
|
3300
3804
|
}
|
|
3301
3805
|
return hit;
|
|
3302
3806
|
};
|
|
3303
3807
|
}
|
|
3304
|
-
|
|
3808
|
+
var localAuthNamesCache = /* @__PURE__ */ new WeakMap();
|
|
3809
|
+
var controlFlowLimited = /* @__PURE__ */ new WeakSet();
|
|
3810
|
+
function localAuthNames(file) {
|
|
3811
|
+
const cached = localAuthNamesCache.get(file);
|
|
3812
|
+
if (cached) return cached;
|
|
3813
|
+
const names = new Set(bindingsOf(file).imports.filter((binding) => binding.spec && normalizeSpec(binding.spec, file.path)).map((binding) => binding.local));
|
|
3814
|
+
const { declared, shadowed } = guardBindings(file);
|
|
3815
|
+
for (const name of declared) names.add(name);
|
|
3816
|
+
for (const name of shadowed) names.add(name);
|
|
3817
|
+
localAuthNamesCache.set(file, names);
|
|
3818
|
+
return names;
|
|
3819
|
+
}
|
|
3820
|
+
function unguardedOperations(file, ops, extra, requireThrow = false, observe) {
|
|
3305
3821
|
if (ops.length === 0) return ops;
|
|
3306
3822
|
const code = noiseMaskedOf(file);
|
|
3307
3823
|
const pairs = delimiterPairs(code);
|
|
3308
3824
|
const bodies = functionBodies(code, pairs);
|
|
3309
3825
|
const declarations = new Map(bodies.map((body) => [body.declaration, body]));
|
|
3310
3826
|
const functionStarts = new Set(bodies.map((body) => body.start));
|
|
3827
|
+
const bodiesByStart = new Map(bodies.map((body) => [body.start, body]));
|
|
3828
|
+
const localNames = localAuthNames(file);
|
|
3311
3829
|
const guardEnds = /* @__PURE__ */ new Map();
|
|
3312
3830
|
const guardNames = /* @__PURE__ */ new Map();
|
|
3831
|
+
const guardRequirements = /* @__PURE__ */ new Map();
|
|
3313
3832
|
const extraCalls = extra?.guards.size ? new RegExp(`^(${namePattern(extra.guards)})\\s*\\(`) : null;
|
|
3314
3833
|
const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
|
|
3315
3834
|
const active = [];
|
|
3316
3835
|
let blockIndex = 0;
|
|
3317
3836
|
const wrapperPattern = extra?.wrappers.size ? new RegExp(`(?<![\\w$.])(${namePattern(extra.wrappers)})\\s*\\(`, "g") : null;
|
|
3318
3837
|
const wrappers = [
|
|
3319
|
-
...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g),
|
|
3838
|
+
...[...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g)].filter((match) => !localNames.has(match[1])),
|
|
3320
3839
|
...wrapperPattern ? code.matchAll(wrapperPattern) : []
|
|
3321
3840
|
].map((match) => {
|
|
3322
3841
|
const open = match.index + match[0].length - 1;
|
|
3323
3842
|
return { start: open, end: pairs.get(open) ?? open, name: match[1] };
|
|
3324
3843
|
});
|
|
3325
|
-
const
|
|
3844
|
+
const skipSpace = (at) => {
|
|
3845
|
+
while (/\s/.test(code[at] ?? "")) at++;
|
|
3846
|
+
return at;
|
|
3847
|
+
};
|
|
3848
|
+
const blockAt = (at) => {
|
|
3849
|
+
const start = skipSpace(at);
|
|
3850
|
+
const end = pairs.get(start);
|
|
3851
|
+
return code[start] === "{" && end !== void 0 ? { start, end } : null;
|
|
3852
|
+
};
|
|
3853
|
+
const denies = (block) => {
|
|
3854
|
+
for (let i = block.start + 1; i < block.end; i++) {
|
|
3855
|
+
if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
|
|
3856
|
+
const tail = code.slice(i, i + 400);
|
|
3857
|
+
if (/^(?:if|for|while|switch|try)\b/.test(tail)) return false;
|
|
3858
|
+
if (/^(?:throw|redirect|notFound)\b|^error\s*\(\s*40[13]\b/.test(tail)) return true;
|
|
3859
|
+
if (/^return\b/.test(tail)) return !requireThrow && DENIED_STATUS.test(code.slice(i, statementEnd2(code, i, block.end, pairs)));
|
|
3860
|
+
const end = pairs.get(i);
|
|
3861
|
+
if (end !== void 0) i = end;
|
|
3862
|
+
}
|
|
3863
|
+
return false;
|
|
3864
|
+
};
|
|
3865
|
+
const checkedEnd = (block, fn, depth) => {
|
|
3866
|
+
const cached = guardEnds.get(block.start);
|
|
3867
|
+
if (cached !== void 0) return cached;
|
|
3868
|
+
const result = guardEnd(block, fn, depth);
|
|
3869
|
+
guardEnds.set(block.start, result);
|
|
3870
|
+
return result;
|
|
3871
|
+
};
|
|
3872
|
+
const allProtected = (owner, children, fn, depth) => {
|
|
3873
|
+
const required = /* @__PURE__ */ new Set();
|
|
3874
|
+
for (const child of children) {
|
|
3875
|
+
if (!Number.isFinite(checkedEnd(child, fn, depth + 1)) && !denies(child)) return false;
|
|
3876
|
+
for (const argument of guardRequirements.get(child.start) ?? []) required.add(argument);
|
|
3877
|
+
}
|
|
3878
|
+
guardRequirements.set(owner.start, [...required]);
|
|
3879
|
+
const name = children.map((child) => guardNames.get(child.start)).find(Boolean);
|
|
3880
|
+
if (name) guardNames.set(owner.start, name);
|
|
3881
|
+
return true;
|
|
3882
|
+
};
|
|
3883
|
+
const guardEnd = (owner, fn, depth = 0) => {
|
|
3884
|
+
if (depth > 8) {
|
|
3885
|
+
controlFlowLimited.add(file);
|
|
3886
|
+
return Infinity;
|
|
3887
|
+
}
|
|
3326
3888
|
for (let i = owner.start + 1; i < owner.end; i++) {
|
|
3327
3889
|
const nested = declarations.get(i);
|
|
3328
3890
|
if (nested) {
|
|
@@ -3334,6 +3896,35 @@ function unguardedOperations(file, ops, extra) {
|
|
|
3334
3896
|
continue;
|
|
3335
3897
|
}
|
|
3336
3898
|
if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
|
|
3899
|
+
if (/^try\s*\{/.test(code.slice(i, i + 32))) {
|
|
3900
|
+
const attempted = blockAt(i + 3);
|
|
3901
|
+
if (attempted) {
|
|
3902
|
+
let cursor = skipSpace(attempted.end + 1);
|
|
3903
|
+
const children = [attempted];
|
|
3904
|
+
if (/^catch\b/.test(code.slice(cursor, cursor + 8))) {
|
|
3905
|
+
cursor = skipSpace(cursor + 5);
|
|
3906
|
+
if (code[cursor] === "(") cursor = skipSpace((pairs.get(cursor) ?? cursor) + 1);
|
|
3907
|
+
const caught = blockAt(cursor);
|
|
3908
|
+
if (!caught) {
|
|
3909
|
+
i = attempted.end;
|
|
3910
|
+
continue;
|
|
3911
|
+
}
|
|
3912
|
+
children.push(caught);
|
|
3913
|
+
cursor = skipSpace(caught.end + 1);
|
|
3914
|
+
}
|
|
3915
|
+
let overrides = false;
|
|
3916
|
+
if (/^finally\b/.test(code.slice(cursor, cursor + 10))) {
|
|
3917
|
+
const final = blockAt(cursor + 7);
|
|
3918
|
+
if (final) {
|
|
3919
|
+
overrides = /\breturn\b/.test(code.slice(final.start + 1, final.end));
|
|
3920
|
+
cursor = final.end + 1;
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
if (!overrides && allProtected(owner, children, fn, depth)) return cursor;
|
|
3924
|
+
i = cursor - 1;
|
|
3925
|
+
continue;
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3337
3928
|
const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
|
|
3338
3929
|
if (conditional) {
|
|
3339
3930
|
const open = i + conditional[0].length - 1;
|
|
@@ -3342,12 +3933,41 @@ function unguardedOperations(file, ops, extra) {
|
|
|
3342
3933
|
let start = close2 + 1;
|
|
3343
3934
|
while (/\s/.test(code[start] ?? "")) start++;
|
|
3344
3935
|
const end = statementEnd2(code, start, owner.end, pairs);
|
|
3345
|
-
|
|
3936
|
+
const required = /* @__PURE__ */ new Set();
|
|
3937
|
+
const values = authValuesOf(file, fn, bodies, pairs);
|
|
3938
|
+
const accepted = hasConditionalAuthGuard(code.slice(i, end), requireThrow, (term) => {
|
|
3939
|
+
const mismatch = /^(.*?)\s*!={1,2}\s*(.*?)$/.exec(term);
|
|
3940
|
+
if (mismatch && (values.value(mismatch[1], i).kind === "secret" || values.value(mismatch[2], i).kind === "secret")) return true;
|
|
3941
|
+
const subject = term.replace(/^!\s*/, "").split(/\s*(?:={2,3}|!={1,2})\s*/)[0];
|
|
3942
|
+
const value = values.value(subject, i);
|
|
3943
|
+
const dependencies = identityRequirement(value, !!observe);
|
|
3944
|
+
if (dependencies) {
|
|
3945
|
+
dependencies.forEach((index) => required.add(index));
|
|
3946
|
+
return true;
|
|
3947
|
+
}
|
|
3948
|
+
return !observe && value.kind === "unknown" && rejectsMissingIdentity(term, DENIED_STATUS.test(code.slice(i, end)));
|
|
3949
|
+
});
|
|
3950
|
+
if (end <= owner.end && accepted) {
|
|
3951
|
+
guardRequirements.set(owner.start, [...required]);
|
|
3952
|
+
return end;
|
|
3953
|
+
}
|
|
3954
|
+
const other = skipSpace(end);
|
|
3955
|
+
if (/^else\b/.test(code.slice(other, other + 8))) {
|
|
3956
|
+
const first = blockAt(start);
|
|
3957
|
+
const second = blockAt(other + 4);
|
|
3958
|
+
if (first && second) {
|
|
3959
|
+
if (allProtected(owner, [first, second], fn, depth)) return second.end + 1;
|
|
3960
|
+
i = second.end;
|
|
3961
|
+
continue;
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3346
3964
|
i = Math.min(end, owner.end) - 1;
|
|
3347
3965
|
continue;
|
|
3348
3966
|
}
|
|
3349
3967
|
const indirectCall = i > 0 && /[\w$.]/.test(code[i - 1]) ? null : extraCalls?.exec(code.slice(i, i + 100));
|
|
3350
3968
|
const call = indirectCall ?? AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
|
|
3969
|
+
const callName = call?.[0].replace(/\s*\($/, "");
|
|
3970
|
+
if (!indirectCall && call?.index === 0 && callName && localNames.has(callName)) continue;
|
|
3351
3971
|
if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
|
|
3352
3972
|
const prefixStart = Math.max(
|
|
3353
3973
|
owner.start + 1,
|
|
@@ -3356,11 +3976,27 @@ function unguardedOperations(file, ops, extra) {
|
|
|
3356
3976
|
);
|
|
3357
3977
|
const prefix = code.slice(prefixStart, i).trim();
|
|
3358
3978
|
if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
|
|
3359
|
-
const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix);
|
|
3979
|
+
const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix) || requireThrow && /^return(?:\s+await)?$/.test(prefix);
|
|
3360
3980
|
const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
|
|
3361
3981
|
if (!awaited && !synchronous) continue;
|
|
3362
3982
|
const close2 = pairs.get(i + call[0].length - 1);
|
|
3363
3983
|
if (close2 !== void 0 && close2 < owner.end) {
|
|
3984
|
+
if (/^\s*(?:\.|\?\.)/.test(code.slice(close2 + 1, close2 + 100))) continue;
|
|
3985
|
+
const required = /* @__PURE__ */ new Set();
|
|
3986
|
+
const requirements = indirectCall ? extra?.requirements?.get(indirectCall[1]) ?? [] : [];
|
|
3987
|
+
const argumentsOfCall = requirements.length ? argumentExpressions(code, commentsMaskedOf(file), i + call[0].length, close2, pairs) : [];
|
|
3988
|
+
let valid = true;
|
|
3989
|
+
for (const argument of requirements) {
|
|
3990
|
+
const origin = authValuesOf(file, fn, bodies, pairs).value(argumentsOfCall[argument] ?? "undefined", i);
|
|
3991
|
+
const dependencies = identityRequirement(origin, !!observe);
|
|
3992
|
+
if (!dependencies) {
|
|
3993
|
+
valid = false;
|
|
3994
|
+
break;
|
|
3995
|
+
}
|
|
3996
|
+
dependencies.forEach((index) => required.add(index));
|
|
3997
|
+
}
|
|
3998
|
+
if (!valid) continue;
|
|
3999
|
+
guardRequirements.set(owner.start, [...required]);
|
|
3364
4000
|
if (indirectCall) guardNames.set(owner.start, indirectCall[1]);
|
|
3365
4001
|
return close2 + 1;
|
|
3366
4002
|
}
|
|
@@ -3379,6 +4015,8 @@ function unguardedOperations(file, ops, extra) {
|
|
|
3379
4015
|
while (active.length && active[active.length - 1].end < op.index) active.pop();
|
|
3380
4016
|
const wrapper = wrappers.find((w) => w.start < op.index && w.end > op.index);
|
|
3381
4017
|
if (wrapper) {
|
|
4018
|
+
if (extra?.requirements?.get(wrapper.name)?.length) return true;
|
|
4019
|
+
observe?.(op.index, []);
|
|
3382
4020
|
if (extra?.wrappers.has(wrapper.name)) extra.onGuard(op.index, wrapper.name);
|
|
3383
4021
|
return false;
|
|
3384
4022
|
}
|
|
@@ -3389,10 +4027,11 @@ function unguardedOperations(file, ops, extra) {
|
|
|
3389
4027
|
const block = active[index];
|
|
3390
4028
|
let end = guardEnds.get(block.start);
|
|
3391
4029
|
if (end === void 0) {
|
|
3392
|
-
end = guardEnd(block);
|
|
4030
|
+
end = guardEnd(block, bodiesByStart.get(active[ownerIndex].start));
|
|
3393
4031
|
guardEnds.set(block.start, end);
|
|
3394
4032
|
}
|
|
3395
4033
|
if (end <= op.index) {
|
|
4034
|
+
observe?.(op.index, guardRequirements.get(block.start) ?? []);
|
|
3396
4035
|
const name = guardNames.get(block.start);
|
|
3397
4036
|
if (name) extra?.onGuard(op.index, name);
|
|
3398
4037
|
return false;
|
|
@@ -3605,7 +4244,7 @@ var definitionsCache = /* @__PURE__ */ new WeakMap();
|
|
|
3605
4244
|
function guardDefinitions(file) {
|
|
3606
4245
|
const cached = definitionsCache.get(file);
|
|
3607
4246
|
if (cached) return cached;
|
|
3608
|
-
const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map() };
|
|
4247
|
+
const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map(), candidates: /* @__PURE__ */ new Map(), exportedCandidates: /* @__PURE__ */ new Map() };
|
|
3609
4248
|
const code = noiseMaskedOf(file);
|
|
3610
4249
|
const pairs = delimiterPairs(code);
|
|
3611
4250
|
const openers = new Map([...pairs].map(([open, close]) => [close, open]));
|
|
@@ -3623,8 +4262,18 @@ function guardDefinitions(file) {
|
|
|
3623
4262
|
const target = byDeclaration.get(nestedAt) ?? body;
|
|
3624
4263
|
let at = target.end;
|
|
3625
4264
|
for (let i = target.start + 1; i < target.end; i++) {
|
|
4265
|
+
const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
|
|
4266
|
+
if (conditional && (i === 0 || !/[\w$]/.test(code[i - 1]))) {
|
|
4267
|
+
const close2 = pairs.get(i + conditional[0].length - 1);
|
|
4268
|
+
if (close2 !== void 0) {
|
|
4269
|
+
let start = close2 + 1;
|
|
4270
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
4271
|
+
i = statementEnd2(code, start, target.end, pairs) - 1;
|
|
4272
|
+
continue;
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
3626
4275
|
if (code.startsWith("return", i) && !/[\w$]/.test(code[i - 1] ?? "") && !/[\w$]/.test(code[i + 6] ?? "")) {
|
|
3627
|
-
at = i;
|
|
4276
|
+
at = Math.min(target.end, statementEnd2(code, i, target.end, pairs));
|
|
3628
4277
|
break;
|
|
3629
4278
|
}
|
|
3630
4279
|
const close = pairs.get(i);
|
|
@@ -3636,14 +4285,26 @@ function guardDefinitions(file) {
|
|
|
3636
4285
|
wrapper: target !== body
|
|
3637
4286
|
};
|
|
3638
4287
|
const exported = !declaration.exported ? null : /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration)) ? "default" : declaration.name;
|
|
3639
|
-
candidates.push({ name: declaration.name, exported, definition, at });
|
|
4288
|
+
candidates.push({ name: declaration.name, exported, definition, at, start: target.start });
|
|
4289
|
+
}
|
|
4290
|
+
const unguarded = /* @__PURE__ */ new Set();
|
|
4291
|
+
const requirements = /* @__PURE__ */ new Map();
|
|
4292
|
+
for (const wrapper of [false, true]) {
|
|
4293
|
+
const ops = candidates.filter((candidate) => candidate.definition.wrapper === wrapper).map((candidate) => ({ index: candidate.at, writes: false })).sort((a, b) => a.index - b.index);
|
|
4294
|
+
for (const hit of unguardedOperations(
|
|
4295
|
+
file,
|
|
4296
|
+
ops,
|
|
4297
|
+
void 0,
|
|
4298
|
+
!wrapper,
|
|
4299
|
+
(index, required) => requirements.set(index, required)
|
|
4300
|
+
)) unguarded.add(hit.index);
|
|
3640
4301
|
}
|
|
3641
|
-
const unguarded = new Set(unguardedOperations(
|
|
3642
|
-
file,
|
|
3643
|
-
candidates.map((candidate) => ({ index: candidate.at, writes: false })).sort((a, b) => a.index - b.index)
|
|
3644
|
-
).map((hit) => hit.index));
|
|
3645
4302
|
for (const candidate of candidates) {
|
|
4303
|
+
result.candidates.set(candidate.name, candidate);
|
|
4304
|
+
if (candidate.exported) result.exportedCandidates.set(candidate.exported, candidate);
|
|
3646
4305
|
if (unguarded.has(candidate.at)) continue;
|
|
4306
|
+
candidate.definition.requiredArgs = requirements.get(candidate.at) ?? [];
|
|
4307
|
+
if (candidate.definition.wrapper && candidate.definition.requiredArgs.length) continue;
|
|
3647
4308
|
result.locals.set(candidate.name, candidate.definition);
|
|
3648
4309
|
if (candidate.exported) result.exports.set(candidate.exported, candidate.definition);
|
|
3649
4310
|
}
|
|
@@ -3657,23 +4318,33 @@ function bindingModule(spec, file, allFiles, scope) {
|
|
|
3657
4318
|
const found = new Set(keys.flatMap((key) => moduleIndexOf(allFiles).get(moduleKey(key)) ?? []));
|
|
3658
4319
|
return found.size === 1 ? [...found][0] : null;
|
|
3659
4320
|
}
|
|
3660
|
-
function exportedGuard(file, name, allFiles, scope, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
4321
|
+
function exportedGuard(file, name, allFiles, scope, state, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
3661
4322
|
const key = `${file.path}\0${name}`;
|
|
3662
|
-
if (
|
|
3663
|
-
|
|
4323
|
+
if (seen.has(key)) return null;
|
|
4324
|
+
if (depth >= 8 || state.remaining-- <= 0) {
|
|
4325
|
+
state.truncated = true;
|
|
4326
|
+
return null;
|
|
4327
|
+
}
|
|
4328
|
+
seen = new Set(seen).add(key);
|
|
3664
4329
|
const definitions = guardDefinitions(file);
|
|
4330
|
+
const candidate = definitions.exportedCandidates.get(name);
|
|
4331
|
+
if (candidate && guardBindings(file).shadowed.has(candidate.name)) return null;
|
|
3665
4332
|
const direct = definitions.exports.get(name);
|
|
3666
4333
|
if (direct) return direct;
|
|
4334
|
+
if (candidate) return delegatedGuard(file, candidate, allFiles, scope, state, depth, seen);
|
|
3667
4335
|
const bindings = bindingsOf(file);
|
|
3668
4336
|
const exported = bindings.exports.find((binding) => binding.local === name);
|
|
3669
4337
|
const follow = (spec, imported) => {
|
|
3670
4338
|
const target = bindingModule(spec, file, allFiles, scope);
|
|
3671
|
-
return target ? exportedGuard(target, imported, allFiles, scope, depth + 1, seen) : null;
|
|
4339
|
+
return target ? exportedGuard(target, imported, allFiles, scope, state, depth + 1, seen) : null;
|
|
3672
4340
|
};
|
|
3673
4341
|
if (exported) {
|
|
3674
4342
|
if (exported.spec) return follow(exported.spec, exported.imported);
|
|
4343
|
+
if (guardBindings(file).shadowed.has(exported.imported)) return null;
|
|
3675
4344
|
const local = definitions.locals.get(exported.imported);
|
|
3676
4345
|
if (local) return local;
|
|
4346
|
+
const candidate2 = definitions.candidates.get(exported.imported);
|
|
4347
|
+
if (candidate2) return delegatedGuard(file, candidate2, allFiles, scope, state, depth, seen);
|
|
3677
4348
|
const imported = bindings.imports.find((binding) => binding.local === exported.imported);
|
|
3678
4349
|
if (imported?.spec) return follow(imported.spec, imported.imported);
|
|
3679
4350
|
}
|
|
@@ -3683,22 +4354,100 @@ function exportedGuard(file, name, allFiles, scope, depth = 0, seen = /* @__PURE
|
|
|
3683
4354
|
}
|
|
3684
4355
|
return null;
|
|
3685
4356
|
}
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
const
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
4357
|
+
var guardBindingCache = /* @__PURE__ */ new WeakMap();
|
|
4358
|
+
function guardBindings(file) {
|
|
4359
|
+
const cached = guardBindingCache.get(file);
|
|
4360
|
+
if (cached) return cached;
|
|
4361
|
+
const code = noiseMaskedOf(file);
|
|
4362
|
+
const declarations = code.replace(/\b(?:import|export)\s+(?:[A-Za-z_$][\w$]*\s*,\s*)?\{[^{}]{0,4000}\}/g, (text) => " ".repeat(text.length));
|
|
4363
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
4364
|
+
const declared = /* @__PURE__ */ new Set();
|
|
4365
|
+
const called = /* @__PURE__ */ new Set();
|
|
4366
|
+
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)) {
|
|
4367
|
+
if (match[2] && /\b(?:const|let|var)\s*$/.test(declarations.slice(Math.max(0, match.index - 40), match.index))) continue;
|
|
4368
|
+
shadowed.add(match[1] ?? match[2] ?? match[3]);
|
|
4369
|
+
}
|
|
4370
|
+
for (const match of declarations.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)) {
|
|
4371
|
+
if (declared.has(match[1])) shadowed.add(match[1]);
|
|
4372
|
+
declared.add(match[1]);
|
|
4373
|
+
}
|
|
4374
|
+
for (const match of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
4375
|
+
if (!/\bfunction\s*\*?\s*$/.test(code.slice(Math.max(0, match.index - 40), match.index))) called.add(match[1]);
|
|
4376
|
+
}
|
|
4377
|
+
const result = { shadowed, declared, called };
|
|
4378
|
+
guardBindingCache.set(file, result);
|
|
4379
|
+
return result;
|
|
4380
|
+
}
|
|
4381
|
+
function namedGuard(file, name, allFiles, scope, state, depth, seen) {
|
|
4382
|
+
const bindings = guardBindings(file);
|
|
4383
|
+
if (bindings.shadowed.has(name)) return null;
|
|
4384
|
+
const definitions = guardDefinitions(file);
|
|
4385
|
+
const candidate = definitions.candidates.get(name);
|
|
4386
|
+
if (candidate) {
|
|
4387
|
+
const key = `${file.path}\0${name}`;
|
|
4388
|
+
if (seen.has(key)) return null;
|
|
4389
|
+
if (depth >= 8 || state.remaining-- <= 0) {
|
|
4390
|
+
state.truncated = true;
|
|
4391
|
+
return null;
|
|
4392
|
+
}
|
|
4393
|
+
return definitions.locals.get(name) ?? delegatedGuard(file, candidate, allFiles, scope, state, depth, new Set(seen).add(key));
|
|
4394
|
+
}
|
|
4395
|
+
if (bindings.declared.has(name)) return null;
|
|
4396
|
+
const imported = bindingsOf(file).imports.find((binding) => binding.local === name);
|
|
4397
|
+
if (!imported?.spec) return null;
|
|
4398
|
+
const target = bindingModule(imported.spec, file, allFiles, scope);
|
|
4399
|
+
return target ? exportedGuard(target, imported.imported, allFiles, scope, state, depth, seen) : null;
|
|
4400
|
+
}
|
|
4401
|
+
function delegatedGuard(file, candidate, allFiles, scope, state, depth, seen) {
|
|
4402
|
+
const code = noiseMaskedOf(file);
|
|
4403
|
+
const calls = /* @__PURE__ */ new Set();
|
|
4404
|
+
for (const match of code.slice(candidate.start + 1, candidate.at).matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(/g)) calls.add(match[1]);
|
|
4405
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
4406
|
+
for (const name of calls) {
|
|
4407
|
+
if (state.remaining <= 0) {
|
|
4408
|
+
state.truncated = true;
|
|
4409
|
+
break;
|
|
4410
|
+
}
|
|
4411
|
+
const guard = namedGuard(file, name, allFiles, scope, state, depth + 1, seen);
|
|
4412
|
+
if (guard && !guard.wrapper) resolved.set(name, guard);
|
|
4413
|
+
}
|
|
4414
|
+
if (resolved.size === 0) return null;
|
|
4415
|
+
let selected;
|
|
4416
|
+
let requiredArgs = [];
|
|
4417
|
+
unguardedOperations(file, [{ index: candidate.at, writes: false }], {
|
|
4418
|
+
guards: new Set(resolved.keys()),
|
|
4419
|
+
wrappers: /* @__PURE__ */ new Set(),
|
|
4420
|
+
requirements: new Map([...resolved].map(([name, guard]) => [name, guard.requiredArgs ?? []])),
|
|
4421
|
+
onGuard: (_index, name) => {
|
|
4422
|
+
selected = resolved.get(name);
|
|
4423
|
+
}
|
|
4424
|
+
}, !candidate.definition.wrapper, (_index, required) => {
|
|
4425
|
+
requiredArgs = required;
|
|
4426
|
+
});
|
|
4427
|
+
return selected ? { ...candidate.definition, requiredArgs, chain: [selected, ...selected.chain ?? []] } : null;
|
|
4428
|
+
}
|
|
4429
|
+
function indirectGuardOperations(route, ctx) {
|
|
4430
|
+
const state = { remaining: 128, truncated: false };
|
|
4431
|
+
const { called } = guardBindings(route.file);
|
|
4432
|
+
const names = /* @__PURE__ */ new Map();
|
|
4433
|
+
for (const name of called) {
|
|
4434
|
+
if (state.remaining <= 0) {
|
|
4435
|
+
state.truncated = true;
|
|
4436
|
+
break;
|
|
4437
|
+
}
|
|
4438
|
+
const definition = namedGuard(route.file, name, ctx.files, route.scope, state, 0, /* @__PURE__ */ new Set());
|
|
4439
|
+
if (definition) names.set(name, definition);
|
|
4440
|
+
}
|
|
4441
|
+
if (state.truncated) ctx.reportIncomplete(
|
|
4442
|
+
"api/db-access-without-auth",
|
|
4443
|
+
`${route.file.path} reached the authentication resolution limit (8 hops or 128 symbols); unresolved guards were not accepted`
|
|
4444
|
+
);
|
|
4445
|
+
const protectedOps = /* @__PURE__ */ new Map();
|
|
4446
|
+
if (names.size === 0) return protectedOps;
|
|
4447
|
+
unguardedOperations(route.file, unguardedOpsOf(route.file), {
|
|
4448
|
+
guards: new Set([...names].filter(([, definition]) => !definition.wrapper).map(([name]) => name)),
|
|
3701
4449
|
wrappers: new Set([...names].filter(([, definition]) => definition.wrapper).map(([name]) => name)),
|
|
4450
|
+
requirements: new Map([...names].map(([name, guard]) => [name, guard.requiredArgs ?? []])),
|
|
3702
4451
|
onGuard: (index, name) => {
|
|
3703
4452
|
const definition = names.get(name);
|
|
3704
4453
|
if (definition) protectedOps.set(index, definition);
|
|
@@ -4073,11 +4822,12 @@ var apiAuthRule = {
|
|
|
4073
4822
|
const indirectByFile = /* @__PURE__ */ new Map();
|
|
4074
4823
|
for (const route of routes) {
|
|
4075
4824
|
const reachable = route.reachable;
|
|
4076
|
-
|
|
4825
|
+
const unguarded = unguardedOpsOf(route.file);
|
|
4826
|
+
let ops = reachable === void 0 ? unguarded : operationsInRange(unguarded, reachable.start, reachable.end);
|
|
4077
4827
|
if (ops.length === 0) continue;
|
|
4078
4828
|
let indirectOps = indirectByFile.get(route.file);
|
|
4079
4829
|
if (!indirectOps) {
|
|
4080
|
-
indirectOps = indirectGuardOperations(route, ctx
|
|
4830
|
+
indirectOps = indirectGuardOperations(route, ctx);
|
|
4081
4831
|
indirectByFile.set(route.file, indirectOps);
|
|
4082
4832
|
}
|
|
4083
4833
|
const unprotected = ops.filter((op) => !indirectOps.has(op.index));
|
|
@@ -4089,24 +4839,30 @@ var apiAuthRule = {
|
|
|
4089
4839
|
const globalNote = globalGuard === null ? [] : [
|
|
4090
4840
|
`${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}.`
|
|
4091
4841
|
];
|
|
4842
|
+
const handlerCode = noiseMaskedOf(route.file).slice(reachable?.start ?? 0, reachable?.end);
|
|
4843
|
+
if (/\.auth\s*\.\s*getSession\s*\(/.test(handlerCode)) globalNote.push(
|
|
4844
|
+
"This handler checks supabase.auth.getSession(). On the server that reads the session from the cookie without verifying it, so a forged cookie passes; it is not counted as authentication. Use supabase.auth.getClaims() or getUser() instead."
|
|
4845
|
+
);
|
|
4092
4846
|
const admin = usesAdminClient(route, adminModules, ctx.files);
|
|
4093
4847
|
const hit = ops.find((o) => o.writes) ?? ops[0];
|
|
4094
4848
|
const indirect = indirectOps.get(hit.index);
|
|
4095
4849
|
const line = lineNumberAt(lineStartsCached(route.file), hit.index);
|
|
4096
4850
|
const excerpt = excerptFor(route.file, line);
|
|
4097
|
-
const indirectEvidence = indirect ? [{
|
|
4851
|
+
const indirectEvidence = indirect ? [indirect, ...indirect.chain ?? []].map((guard) => ({
|
|
4098
4852
|
kind: "auth-helper",
|
|
4099
|
-
file:
|
|
4100
|
-
line:
|
|
4101
|
-
description: "Local authentication helper recognized
|
|
4102
|
-
}
|
|
4853
|
+
file: guard.file.path,
|
|
4854
|
+
line: guard.line,
|
|
4855
|
+
description: "Local authentication helper in the recognized delegation chain; verify runtime behaviour and caller coverage."
|
|
4856
|
+
})) : [];
|
|
4103
4857
|
if (indirect) globalNote.push("A local authentication helper or wrapper was recognized before this operation. This finding is retained for review because indirect control flow is not a proof of authorization.");
|
|
4104
4858
|
if (admin) {
|
|
4105
4859
|
const trace = adminEvidence(route, adminModules, ctx.files, line);
|
|
4860
|
+
const evidence = [...trace.evidence ?? [], ...indirectEvidence];
|
|
4106
4861
|
findings.push({
|
|
4107
4862
|
ruleId: "api/admin-db-access-without-auth",
|
|
4108
4863
|
...trace,
|
|
4109
|
-
evidence:
|
|
4864
|
+
evidence: evidence.slice(0, 24),
|
|
4865
|
+
evidenceTruncated: trace.evidenceTruncated || evidence.length > 24,
|
|
4110
4866
|
severity: "P0",
|
|
4111
4867
|
// 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
|
|
4112
4868
|
confidence: globalGuard === null && !indirect ? "certain" : "likely",
|
|
@@ -4164,6 +4920,14 @@ var apiAuthRule = {
|
|
|
4164
4920
|
});
|
|
4165
4921
|
}
|
|
4166
4922
|
}
|
|
4923
|
+
for (const file of ctx.files) if (identityFlowLimited(file)) ctx.reportIncomplete(
|
|
4924
|
+
"api/db-access-without-auth",
|
|
4925
|
+
`${file.path} reached the identity flow limit (8 value hops, 4000 expression characters, or 512 assignments or branches per function)`
|
|
4926
|
+
);
|
|
4927
|
+
for (const file of ctx.files) if (controlFlowLimited.has(file)) ctx.reportIncomplete(
|
|
4928
|
+
"api/db-access-without-auth",
|
|
4929
|
+
`${file.path} reached the control-flow proof limit (8 nested branch or exception regions)`
|
|
4930
|
+
);
|
|
4167
4931
|
return findings;
|
|
4168
4932
|
}
|
|
4169
4933
|
};
|
|
@@ -4537,12 +5301,1434 @@ var corsRule = {
|
|
|
4537
5301
|
}
|
|
4538
5302
|
};
|
|
4539
5303
|
|
|
5304
|
+
// src/rules/request-input.ts
|
|
5305
|
+
var MAX_VALUE_HOPS = 8;
|
|
5306
|
+
var MAX_ASSIGNMENTS = 512;
|
|
5307
|
+
var MAX_EXPRESSION = 4e3;
|
|
5308
|
+
var REQUEST_OBJECT_NAMES = /^(?:req|request|event|evt|ctx|context)$/;
|
|
5309
|
+
var DESTRUCTURED_OBJECTS = /* @__PURE__ */ new Set(["request", "req", "event"]);
|
|
5310
|
+
var DESTRUCTURED_URLS = /* @__PURE__ */ new Set(["url"]);
|
|
5311
|
+
var DESTRUCTURED_COOKIES = /* @__PURE__ */ new Set(["cookies"]);
|
|
5312
|
+
var DESTRUCTURED_VALUES = /* @__PURE__ */ new Set(["params", "query", "body", "searchParams"]);
|
|
5313
|
+
var TRANSPARENT_CALLS = /* @__PURE__ */ new Set([
|
|
5314
|
+
"json",
|
|
5315
|
+
"formData",
|
|
5316
|
+
"text",
|
|
5317
|
+
"arrayBuffer",
|
|
5318
|
+
"blob",
|
|
5319
|
+
"get",
|
|
5320
|
+
"getAll",
|
|
5321
|
+
"String",
|
|
5322
|
+
"trim",
|
|
5323
|
+
"trimStart",
|
|
5324
|
+
"trimEnd",
|
|
5325
|
+
"toString",
|
|
5326
|
+
"toLowerCase",
|
|
5327
|
+
"toUpperCase",
|
|
5328
|
+
"URL",
|
|
5329
|
+
"URLSearchParams",
|
|
5330
|
+
"decodeURIComponent",
|
|
5331
|
+
"decodeURI",
|
|
5332
|
+
"fromEntries",
|
|
5333
|
+
"entries",
|
|
5334
|
+
"readBody",
|
|
5335
|
+
"readFormData",
|
|
5336
|
+
"readRawBody",
|
|
5337
|
+
"getQuery",
|
|
5338
|
+
"getRouterParam",
|
|
5339
|
+
"getRouterParams",
|
|
5340
|
+
"getHeader",
|
|
5341
|
+
"getHeaders",
|
|
5342
|
+
"getRequestHeader",
|
|
5343
|
+
"getRequestHeaders",
|
|
5344
|
+
"getCookie",
|
|
5345
|
+
"parseCookies",
|
|
5346
|
+
"getRequestURL"
|
|
5347
|
+
]);
|
|
5348
|
+
var H3_READERS = "readBody|readFormData|readMultipartFormData|readRawBody|readValidatedBody|getQuery|getValidatedQuery|getRouterParams?|getValidatedRouterParams|getHeaders?|getRequestHeaders?|getCookie|parseCookies|getRequestURL";
|
|
5349
|
+
var LITERAL = String.raw`(?:'[^']*'|"[^"]*"|\x60[^\x60$]*\x60|-?\d+(?:\.\d+)?|null|undefined|true|false)`;
|
|
5350
|
+
var CLEAN_EXPRESSION = [
|
|
5351
|
+
/^(?:await\s+)?(?:Number|parseInt|parseFloat|Boolean|BigInt|isNaN|Math\s*\.\s*\w+|Number\s*\.\s*\w+)\s*\(/,
|
|
5352
|
+
/\.\s*length\s*$/,
|
|
5353
|
+
/^(?:!|typeof\b)/,
|
|
5354
|
+
/\.\s*(?:includes|test|has|startsWith|endsWith|some|every)\s*\([^?]*\)\s*$/,
|
|
5355
|
+
new RegExp(String.raw`^[^?]*\?\s*${LITERAL}\s*:\s*${LITERAL}\s*$`),
|
|
5356
|
+
// 带标签的模板由标签函数处理插值(Prisma.sql、sql、html),String.raw 除外。
|
|
5357
|
+
/^(?:await\s+)?(?!String\s*\.\s*raw\b)[\w$]+(?:\s*\.\s*[\w$]+)*\s*\x60/
|
|
5358
|
+
];
|
|
5359
|
+
var URL_INPUT_ACCESS = /\??\.\s*(?:searchParams|pathname|search|hash)\b/;
|
|
5360
|
+
var URL_INPUT_KEYS = /* @__PURE__ */ new Set(["searchParams", "pathname", "search", "hash"]);
|
|
5361
|
+
var URL_ORIGIN_ACCESS = /^\s*\??\.\s*(?:origin|host|hostname|protocol|port)\b/;
|
|
5362
|
+
var URL_ORIGIN_KEYS = /* @__PURE__ */ new Set(["origin", "host", "hostname", "protocol", "port"]);
|
|
5363
|
+
function isComparison(expr) {
|
|
5364
|
+
return !/\?/.test(expr) && !/=>/.test(expr) && /(?:===|!==|==|!=|<=|>=|\binstanceof\b)/.test(expr);
|
|
5365
|
+
}
|
|
5366
|
+
function parameterList(text) {
|
|
5367
|
+
const parts = [];
|
|
5368
|
+
let depth = 0, from = 0;
|
|
5369
|
+
for (let i = 0; i < text.length; i++) {
|
|
5370
|
+
const ch = text[i];
|
|
5371
|
+
if ("{[(<".includes(ch)) depth++;
|
|
5372
|
+
else if ("}])>".includes(ch)) depth--;
|
|
5373
|
+
else if (ch === "," && depth === 0) {
|
|
5374
|
+
parts.push(text.slice(from, i));
|
|
5375
|
+
from = i + 1;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
parts.push(text.slice(from));
|
|
5379
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
5380
|
+
}
|
|
5381
|
+
function destructuredKeys(pattern) {
|
|
5382
|
+
const inner = pattern.slice(1, pattern.lastIndexOf("}"));
|
|
5383
|
+
const result = [];
|
|
5384
|
+
for (const part of parameterList(inner)) {
|
|
5385
|
+
const m = /^(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=[\s\S]*)?$/.exec(part);
|
|
5386
|
+
if (m) result.push({ key: m[1], local: m[2] ?? m[1] });
|
|
5387
|
+
}
|
|
5388
|
+
return result;
|
|
5389
|
+
}
|
|
5390
|
+
function patternNames(pattern) {
|
|
5391
|
+
const text = pattern.trim();
|
|
5392
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) return [text];
|
|
5393
|
+
const stripped = text.replace(/[A-Za-z_$][\w$]*\s*:(?!:)/g, " ").replace(/=[^,}\]]*/g, " ");
|
|
5394
|
+
return stripped.match(/[A-Za-z_$][\w$]*/g) ?? [];
|
|
5395
|
+
}
|
|
5396
|
+
function paramRoles(paramsText, paramsStart, allInput) {
|
|
5397
|
+
const roles = { objects: /* @__PURE__ */ new Map(), urls: /* @__PURE__ */ new Map(), cookies: /* @__PURE__ */ new Map(), values: /* @__PURE__ */ new Map() };
|
|
5398
|
+
let offset = 0;
|
|
5399
|
+
for (const raw of parameterList(paramsText)) {
|
|
5400
|
+
const at = paramsStart + Math.max(0, paramsText.indexOf(raw, offset));
|
|
5401
|
+
offset = Math.max(offset, paramsText.indexOf(raw, offset) + raw.length);
|
|
5402
|
+
const param = raw.startsWith("{") || raw.startsWith("[") ? raw.slice(0, matchingClose(raw) + 1) : raw.replace(/^\.\.\./, "").split(/[:=]/)[0].trim();
|
|
5403
|
+
if (allInput) {
|
|
5404
|
+
for (const name of patternNames(param)) roles.values.set(name, at);
|
|
5405
|
+
continue;
|
|
5406
|
+
}
|
|
5407
|
+
if (param.startsWith("{")) {
|
|
5408
|
+
for (const { key, local } of destructuredKeys(param)) {
|
|
5409
|
+
if (DESTRUCTURED_OBJECTS.has(key)) roles.objects.set(local, at);
|
|
5410
|
+
else if (DESTRUCTURED_URLS.has(key)) roles.urls.set(local, at);
|
|
5411
|
+
else if (DESTRUCTURED_COOKIES.has(key)) roles.cookies.set(local, at);
|
|
5412
|
+
else if (DESTRUCTURED_VALUES.has(key)) roles.values.set(local, at);
|
|
5413
|
+
}
|
|
5414
|
+
} else if (REQUEST_OBJECT_NAMES.test(param)) roles.objects.set(param, at);
|
|
5415
|
+
}
|
|
5416
|
+
return roles;
|
|
5417
|
+
}
|
|
5418
|
+
function matchingClose(text) {
|
|
5419
|
+
let depth = 0;
|
|
5420
|
+
for (let i = 0; i < text.length; i++) {
|
|
5421
|
+
if ("{[".includes(text[i])) depth++;
|
|
5422
|
+
else if ("}]".includes(text[i]) && --depth === 0) return i;
|
|
5423
|
+
}
|
|
5424
|
+
return text.length - 1;
|
|
5425
|
+
}
|
|
5426
|
+
function parametersOf(code, body, pairs, openers) {
|
|
5427
|
+
const fn = /^function\s*\*?\s*(?:[\w$]+\s*)?\(/.exec(code.slice(body.declaration, body.declaration + 200));
|
|
5428
|
+
if (fn) {
|
|
5429
|
+
const open = body.declaration + fn[0].length - 1;
|
|
5430
|
+
const close2 = pairs.get(open);
|
|
5431
|
+
return close2 === void 0 ? null : { start: open + 1, end: close2 };
|
|
5432
|
+
}
|
|
5433
|
+
const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
|
|
5434
|
+
const close = window.lastIndexOf(")");
|
|
5435
|
+
if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
|
|
5436
|
+
const absolute = body.declaration - window.length + close;
|
|
5437
|
+
const open = openers.get(absolute);
|
|
5438
|
+
return open === void 0 ? null : { start: open + 1, end: absolute };
|
|
5439
|
+
}
|
|
5440
|
+
const single = /([A-Za-z_$][\w$]*)\s*$/.exec(window);
|
|
5441
|
+
return single ? { start: body.declaration - window.length + single.index, end: body.declaration - window.length + single.index + single[1].length } : null;
|
|
5442
|
+
}
|
|
5443
|
+
function expressionEnd(code, from, limit, pairs, truncated) {
|
|
5444
|
+
const stop = Math.min(limit, from + MAX_EXPRESSION);
|
|
5445
|
+
for (let i = from; i < stop; i++) {
|
|
5446
|
+
const ch = code[i];
|
|
5447
|
+
const close = pairs.get(i);
|
|
5448
|
+
if (close !== void 0) {
|
|
5449
|
+
if (close >= stop) {
|
|
5450
|
+
truncated();
|
|
5451
|
+
return stop;
|
|
5452
|
+
}
|
|
5453
|
+
i = close;
|
|
5454
|
+
continue;
|
|
5455
|
+
}
|
|
5456
|
+
if (ch === ";" || ch === "," || ch === ")" || ch === "]" || ch === "}") return i;
|
|
5457
|
+
if (ch === "\n") {
|
|
5458
|
+
const before = code.slice(from, i).trimEnd();
|
|
5459
|
+
const after = code.slice(i + 1, i + 80).trimStart();
|
|
5460
|
+
if (before === "" || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
|
|
5461
|
+
if (/^(?:[.?+\-*/%&|^,:<>=]|\?\?)/.test(after) && !/^(?:\+\+|--)/.test(after)) continue;
|
|
5462
|
+
return i;
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
if (stop < limit && !/[;,)}\]\n]/.test(code[stop] ?? "")) truncated();
|
|
5466
|
+
return stop;
|
|
5467
|
+
}
|
|
5468
|
+
function literalTable(text) {
|
|
5469
|
+
const value = text.trim();
|
|
5470
|
+
const item = new RegExp(`^${LITERAL}$`);
|
|
5471
|
+
const property = new RegExp(`^(?:[A-Za-z_$][\\w$]*|'[^']*'|"[^"]*")\\s*:\\s*${LITERAL}$`);
|
|
5472
|
+
if (!(value.startsWith("{") && value.endsWith("}")) && !(value.startsWith("[") && value.endsWith("]"))) return false;
|
|
5473
|
+
return parameterList(value.slice(1, -1)).every((part) => (value[0] === "{" ? property : item).test(part));
|
|
5474
|
+
}
|
|
5475
|
+
function fixedTablesOf(code, source, pairs) {
|
|
5476
|
+
const result = /* @__PURE__ */ new Map();
|
|
5477
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5478
|
+
for (const m of code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/g)) {
|
|
5479
|
+
const name = m[1];
|
|
5480
|
+
if (seen.has(name)) {
|
|
5481
|
+
result.delete(name);
|
|
5482
|
+
continue;
|
|
5483
|
+
}
|
|
5484
|
+
seen.add(name);
|
|
5485
|
+
const open = m.index + m[0].length;
|
|
5486
|
+
const close = pairs.get(open);
|
|
5487
|
+
if (!m[0].startsWith("const") || close === void 0 || close - open > MAX_EXPRESSION) continue;
|
|
5488
|
+
if (literalTable(source.slice(open, close + 1))) result.set(name, m.index);
|
|
5489
|
+
}
|
|
5490
|
+
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)(?:\s*\.\s*[\w$]+|\s*\[[^\]]*\])?\s*(?:\+\+|--|[+*/%&|^-]?=(?![=>]))/g)) {
|
|
5491
|
+
if (!/\bconst\s+$/.test(code.slice(Math.max(0, m.index - 20), m.index))) result.delete(m[1]);
|
|
5492
|
+
}
|
|
5493
|
+
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g)) {
|
|
5494
|
+
result.delete(m[1]);
|
|
5495
|
+
}
|
|
5496
|
+
for (const m of code.matchAll(/\b(?:Object\s*\.\s*(?:assign|defineProperty|defineProperties|setPrototypeOf)|Reflect\s*\.\s*(?:set|defineProperty|setPrototypeOf))\s*\(\s*([A-Za-z_$][\w$]*)\b/g)) {
|
|
5497
|
+
result.delete(m[1]);
|
|
5498
|
+
}
|
|
5499
|
+
return result;
|
|
5500
|
+
}
|
|
5501
|
+
var InputFlow = class {
|
|
5502
|
+
constructor(code, source, span, pairs, roles, fixedTables) {
|
|
5503
|
+
this.code = code;
|
|
5504
|
+
this.source = source;
|
|
5505
|
+
this.span = span;
|
|
5506
|
+
this.pairs = pairs;
|
|
5507
|
+
this.fixedTables = fixedTables;
|
|
5508
|
+
for (const [name, at] of roles.values) this.parameters.set(name, { level: "direct", origin: at, names: /* @__PURE__ */ new Set([name]), ownUrl: false });
|
|
5509
|
+
this.sourcePattern = sourceRegex(roles);
|
|
5510
|
+
const assignments = this.assignments().sort((a, b) => a.at - b.at);
|
|
5511
|
+
const region = code.slice(span.start, span.end);
|
|
5512
|
+
const functions = this.localFunctions(region, assignments);
|
|
5513
|
+
const scopes = [span];
|
|
5514
|
+
for (let i = span.start + 1; i < span.end; i++) {
|
|
5515
|
+
const end = code[i] === "{" ? pairs.get(i) : void 0;
|
|
5516
|
+
if (end !== void 0) scopes.push({ start: i, end });
|
|
5517
|
+
}
|
|
5518
|
+
const controls = [];
|
|
5519
|
+
for (const m of region.matchAll(/\b(?:if|for|while|switch|catch)\s*\(|\belse\b/g)) {
|
|
5520
|
+
const open = span.start + m.index + m[0].length - 1;
|
|
5521
|
+
let start = m[0] === "else" ? open + 1 : (pairs.get(open) ?? open) + 1;
|
|
5522
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
5523
|
+
controls.push({ start, end: pairs.get(start) ?? expressionEnd(code, start, span.end, pairs, () => {
|
|
5524
|
+
this.limited = true;
|
|
5525
|
+
}) });
|
|
5526
|
+
if (controls.length >= MAX_ASSIGNMENTS) {
|
|
5527
|
+
this.limited = true;
|
|
5528
|
+
break;
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
for (const a of assignments) {
|
|
5532
|
+
const owner = functions.filter((f) => f.start < a.at && a.at < f.end).at(-1);
|
|
5533
|
+
if (owner) a.owner = owner;
|
|
5534
|
+
a.scope = scopes.filter((s) => s.start < a.at && a.at < s.end).at(-1) ?? span;
|
|
5535
|
+
if (a.owner && a.owner.start > a.scope.start) a.scope = a.owner;
|
|
5536
|
+
a.controls = controls.filter((s) => s.start <= a.at && a.at <= s.end);
|
|
5537
|
+
for (const name of a.names) {
|
|
5538
|
+
const list = this.assignmentList.get(name) ?? [];
|
|
5539
|
+
if (!list.some((item) => item.exprAt === a.exprAt)) list.push(a);
|
|
5540
|
+
this.assignmentList.set(name, list);
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
const names = /* @__PURE__ */ new Set([...this.parameters.keys(), ...this.assignmentList.keys()]);
|
|
5544
|
+
if (names.size) this.references = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`, "g");
|
|
5545
|
+
}
|
|
5546
|
+
code;
|
|
5547
|
+
source;
|
|
5548
|
+
span;
|
|
5549
|
+
pairs;
|
|
5550
|
+
fixedTables;
|
|
5551
|
+
parameters = /* @__PURE__ */ new Map();
|
|
5552
|
+
limited = false;
|
|
5553
|
+
assignmentList = /* @__PURE__ */ new Map();
|
|
5554
|
+
sourcePattern;
|
|
5555
|
+
references = null;
|
|
5556
|
+
values = /* @__PURE__ */ new Map();
|
|
5557
|
+
strings = /* @__PURE__ */ new Map();
|
|
5558
|
+
/** 只索引当前处理函数;未引用的具名闭包不影响外层值。 */
|
|
5559
|
+
localFunctions(region, assignments) {
|
|
5560
|
+
const offset = this.span.start;
|
|
5561
|
+
const bodies = functionBodies(region, delimiterPairs(region)).map((f) => ({
|
|
5562
|
+
declaration: f.declaration + offset,
|
|
5563
|
+
start: f.start + offset,
|
|
5564
|
+
end: f.end + offset
|
|
5565
|
+
}));
|
|
5566
|
+
for (const m of region.matchAll(/=>\s*(?!\s*\{)/g)) {
|
|
5567
|
+
const start = offset + m.index + m[0].length;
|
|
5568
|
+
const end = expressionEnd(this.code, start, this.span.end, this.pairs, () => {
|
|
5569
|
+
this.limited = true;
|
|
5570
|
+
});
|
|
5571
|
+
bodies.push({ declaration: offset + m.index, start: start - 1, end });
|
|
5572
|
+
}
|
|
5573
|
+
if (bodies.length > MAX_ASSIGNMENTS) this.limited = true;
|
|
5574
|
+
return bodies.slice(0, MAX_ASSIGNMENTS).sort((a, b) => a.start - b.start).map((f) => {
|
|
5575
|
+
const binding = assignments.filter((a) => {
|
|
5576
|
+
if (!a.declaration || a.exprAt > f.declaration || a.exprAt + a.expr.length < f.end) return false;
|
|
5577
|
+
const prefix = this.code.slice(a.exprAt, f.declaration).trim();
|
|
5578
|
+
return prefix === "" || prefix === "async" || /^(?:async\s*)?(?:\([^;{}]*\)|[\w$]+)\s*(?::[^=;{}]*)?$/.test(prefix);
|
|
5579
|
+
}).sort((a, b) => b.exprAt - a.exprAt)[0];
|
|
5580
|
+
const declared = /^function\s*\*?\s*([\w$]+)\s*\(/.exec(this.code.slice(f.declaration, f.start))?.[1];
|
|
5581
|
+
const before = this.code.slice(this.span.start, f.declaration).trimEnd().replace(/\basync$/, "").trimEnd();
|
|
5582
|
+
const name = binding?.names.length === 1 ? binding.names[0] : /[;{}]$/.test(before) ? declared : void 0;
|
|
5583
|
+
if (/^\s*\)*\s*(?:\(|\.\s*(?:call|apply)\s*\()/.test(this.code.slice(f.end + 1, this.span.end))) {
|
|
5584
|
+
return { ...f, usedAt: f.end };
|
|
5585
|
+
}
|
|
5586
|
+
if (!name) return { ...f, usedAt: f.end };
|
|
5587
|
+
const start = binding?.at ?? f.declaration;
|
|
5588
|
+
const reference = new RegExp(`(?<![\\w$.])${namePattern([name])}(?![\\w$])`, "g");
|
|
5589
|
+
const use = [...region.matchAll(reference)].find((m) => offset + m.index < start || offset + m.index > f.end);
|
|
5590
|
+
return { ...f, usedAt: use ? offset + use.index : Infinity };
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5593
|
+
/** 表达式是否携带请求输入;已转换为数值、布尔或查表结果的不算。 */
|
|
5594
|
+
taintOf(expr, exprAt, depth = 0) {
|
|
5595
|
+
if (expr.length > MAX_EXPRESSION) this.limited = true;
|
|
5596
|
+
const text = expr.trim();
|
|
5597
|
+
if (text === "" || CLEAN_EXPRESSION.some((pattern) => pattern.test(text)) || isComparison(text)) return null;
|
|
5598
|
+
const lookup = new RegExp(`^([A-Za-z_$][\\w$]*)\\s*\\[[^\\]]*\\]\\s*(?:(?:\\?\\?|\\|\\|)\\s*${LITERAL})?\\s*$`).exec(text);
|
|
5599
|
+
if (lookup && this.isFixedTable(lookup[1], exprAt)) return null;
|
|
5600
|
+
const observed = expr.replace(/\[\s*['"][^'"]*['"]\s*\]/g, (part, offset) => {
|
|
5601
|
+
const raw = this.source.slice(exprAt + offset, exprAt + offset + part.length);
|
|
5602
|
+
const key = /^\[\s*['"]([A-Za-z_$][\w$]*)['"]\s*\]$/.exec(raw)?.[1];
|
|
5603
|
+
return key ? `.${key}`.padEnd(part.length) : part;
|
|
5604
|
+
});
|
|
5605
|
+
let found = null;
|
|
5606
|
+
if (this.sourcePattern) {
|
|
5607
|
+
this.sourcePattern.lastIndex = 0;
|
|
5608
|
+
for (const m of observed.matchAll(this.sourcePattern)) {
|
|
5609
|
+
const after = observed.slice(m.index + m[0].length);
|
|
5610
|
+
const ownUrl = m[1] === "url" || m[1] === "nextUrl";
|
|
5611
|
+
if (ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
|
|
5612
|
+
found = mergeTaint(found, {
|
|
5613
|
+
level: "direct",
|
|
5614
|
+
origin: exprAt + m.index,
|
|
5615
|
+
names: /* @__PURE__ */ new Set(),
|
|
5616
|
+
ownUrl: ownUrl && !URL_INPUT_ACCESS.test(after.slice(0, 40))
|
|
5617
|
+
});
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
const refs = this.references && new RegExp(this.references.source, "g");
|
|
5621
|
+
if (refs) {
|
|
5622
|
+
refs.lastIndex = 0;
|
|
5623
|
+
for (const m of expr.matchAll(refs)) {
|
|
5624
|
+
const before = expr.slice(0, m.index).trimEnd();
|
|
5625
|
+
const after = expr.slice(m.index + m[0].length);
|
|
5626
|
+
if (/^\s*:(?!:)/.test(after) && /[{,]$/.test(before)) continue;
|
|
5627
|
+
const t = this.valueOf(m[0], exprAt, depth);
|
|
5628
|
+
if (!t) continue;
|
|
5629
|
+
if (t.ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
|
|
5630
|
+
found = mergeTaint(found, t.ownUrl && URL_INPUT_ACCESS.test(after.slice(0, 40)) ? { ...t, ownUrl: false } : t);
|
|
5631
|
+
}
|
|
5632
|
+
}
|
|
5633
|
+
if (!found) return null;
|
|
5634
|
+
if (found.ownUrl && URL_INPUT_ACCESS.test(text)) found.ownUrl = false;
|
|
5635
|
+
const calls = [...text.replace(/\bJSON\s*\.\s*parse\s*\(/g, "(").matchAll(/([\w$]+)\s*\(/g)].map((m) => m[1]);
|
|
5636
|
+
if (calls.some((name) => !TRANSPARENT_CALLS.has(name))) found.level = "derived";
|
|
5637
|
+
return found;
|
|
5638
|
+
}
|
|
5639
|
+
/** 字符串拼接:无标签模板的插值或含字符串操作数的 + 拼接中携带请求输入。 */
|
|
5640
|
+
builtOf(expr, exprAt, depth = 0) {
|
|
5641
|
+
if (expr.length > MAX_EXPRESSION) this.limited = true;
|
|
5642
|
+
const text = expr.trim();
|
|
5643
|
+
const lead = expr.length - expr.trimStart().length;
|
|
5644
|
+
if (text.startsWith("`")) return this.interpolationTaint(exprAt + lead, exprAt + lead + text.length, depth);
|
|
5645
|
+
const operands = this.topLevelOperands(expr, exprAt);
|
|
5646
|
+
if (operands.length < 2 || !operands.some((o) => /^['"\x60]/.test(o.text.trim()))) return null;
|
|
5647
|
+
let found = null;
|
|
5648
|
+
for (const operand of operands) {
|
|
5649
|
+
const t = operand.text.trim().startsWith("`") ? this.interpolationTaint(operand.at, operand.at + operand.text.length, depth) : this.taintOf(operand.text, operand.at, depth);
|
|
5650
|
+
if (t) found = mergeTaint(found, t);
|
|
5651
|
+
}
|
|
5652
|
+
return found;
|
|
5653
|
+
}
|
|
5654
|
+
/** 仅保留使用位置可见的赋值;无条件覆盖终止旧值,分支与追加赋值保留可能来源。 */
|
|
5655
|
+
assignmentsFor(name, at) {
|
|
5656
|
+
const list = this.assignmentList.get(name) ?? [];
|
|
5657
|
+
const contains = (s, where) => s.start < where && where < s.end;
|
|
5658
|
+
const bindingAt = (where) => list.filter((a) => a.declaration && a.at <= where && contains(a.scope, where)).sort((a, b) => b.scope.start - a.scope.start || b.at - a.at)[0];
|
|
5659
|
+
const binding = bindingAt(at);
|
|
5660
|
+
const result = [];
|
|
5661
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
5662
|
+
const a = list[i];
|
|
5663
|
+
if (a.at >= at || a.exprAt + a.expr.length >= at) continue;
|
|
5664
|
+
if (a.declaration ? a !== binding : bindingAt(a.at) !== binding) continue;
|
|
5665
|
+
const indirect = a.owner !== void 0 && !contains(a.owner, at);
|
|
5666
|
+
if (indirect && a.owner.usedAt >= at) continue;
|
|
5667
|
+
result.push(indirect ? { ...a, indirect: true } : a);
|
|
5668
|
+
if (!indirect && !a.append && contains(a.scope, at) && a.controls.every((s) => contains(s, at))) break;
|
|
5669
|
+
}
|
|
5670
|
+
return result;
|
|
5671
|
+
}
|
|
5672
|
+
valueOf(name, at, depth = 0) {
|
|
5673
|
+
const key = `${name}:${at}:${depth}`;
|
|
5674
|
+
if (this.values.has(key)) return this.values.get(key);
|
|
5675
|
+
const result = this.resolveValue(name, at, depth);
|
|
5676
|
+
this.values.set(key, result);
|
|
5677
|
+
return result;
|
|
5678
|
+
}
|
|
5679
|
+
resolveValue(name, at, depth) {
|
|
5680
|
+
if (depth >= MAX_VALUE_HOPS) {
|
|
5681
|
+
this.limited = true;
|
|
5682
|
+
return null;
|
|
5683
|
+
}
|
|
5684
|
+
const assignments = this.assignmentsFor(name, at);
|
|
5685
|
+
if (!assignments.length) return this.parameters.get(name) ?? null;
|
|
5686
|
+
let found = null;
|
|
5687
|
+
for (const a of assignments) {
|
|
5688
|
+
const taint = this.builtOf(a.expr, a.exprAt, depth + 1) ?? this.taintOf(a.expr, a.exprAt, depth + 1);
|
|
5689
|
+
if (!taint) continue;
|
|
5690
|
+
const own = this.targetsOf(a, taint).find(([target]) => target === name);
|
|
5691
|
+
if (own) found = mergeTaint(found, { ...taint, level: a.indirect ? "derived" : taint.level, ownUrl: own[1], names: /* @__PURE__ */ new Set([name, ...taint.names]) });
|
|
5692
|
+
}
|
|
5693
|
+
if (this.parameters.has(name) && assignments.every((a) => a.append || !a.scope || a.scope.start !== this.span.start || a.controls.length)) {
|
|
5694
|
+
found = mergeTaint(found, this.parameters.get(name));
|
|
5695
|
+
}
|
|
5696
|
+
return found;
|
|
5697
|
+
}
|
|
5698
|
+
/** 保留有效拼接及别名的 SQL 文本证据,不借用已被覆盖的旧查询。 */
|
|
5699
|
+
builtValue(name, at, depth = 0) {
|
|
5700
|
+
const key = `${name}:${at}:${depth}`;
|
|
5701
|
+
if (this.strings.has(key)) return this.strings.get(key);
|
|
5702
|
+
const result = this.resolveBuiltValue(name, at, depth);
|
|
5703
|
+
this.strings.set(key, result);
|
|
5704
|
+
return result;
|
|
5705
|
+
}
|
|
5706
|
+
resolveBuiltValue(name, at, depth) {
|
|
5707
|
+
if (depth >= MAX_VALUE_HOPS) {
|
|
5708
|
+
this.limited = true;
|
|
5709
|
+
return null;
|
|
5710
|
+
}
|
|
5711
|
+
let found = null;
|
|
5712
|
+
let text = "";
|
|
5713
|
+
const append = (part) => {
|
|
5714
|
+
if (text.length + part.length + 1 > MAX_EXPRESSION) this.limited = true;
|
|
5715
|
+
text = `${text}
|
|
5716
|
+
${part}`.slice(0, MAX_EXPRESSION);
|
|
5717
|
+
};
|
|
5718
|
+
for (const a of this.assignmentsFor(name, at)) {
|
|
5719
|
+
append(this.source.slice(a.exprAt, a.exprAt + a.expr.length));
|
|
5720
|
+
const alias = /^[A-Za-z_$][\w$]*$/.test(a.expr.trim()) ? this.builtValue(a.expr.trim(), a.exprAt, depth + 1) : null;
|
|
5721
|
+
const t = this.builtOf(a.expr, a.exprAt, depth + 1) ?? alias ?? (a.append ? this.taintOf(a.expr, a.exprAt, depth + 1) : null);
|
|
5722
|
+
if (alias) append(alias.text);
|
|
5723
|
+
if (t) found = mergeTaint(found, a.indirect ? { ...t, level: "derived" } : t);
|
|
5724
|
+
}
|
|
5725
|
+
return found ? { ...found, names: /* @__PURE__ */ new Set([name, ...found.names]), text } : null;
|
|
5726
|
+
}
|
|
5727
|
+
isFixedTable(name, at) {
|
|
5728
|
+
const assignments = this.assignmentsFor(name, at);
|
|
5729
|
+
if (assignments.length) return assignments.every((a) => !a.append && literalTable(this.source.slice(a.exprAt, a.exprAt + a.expr.length))) && this.fixedTables.has(name);
|
|
5730
|
+
return (this.fixedTables.get(name) ?? Infinity) < at && !this.parameters.has(name);
|
|
5731
|
+
}
|
|
5732
|
+
/** 顶层 + 拼接的各操作数;括号与模板插值内部的 + 不拆分。 */
|
|
5733
|
+
operandsOf(expr, exprAt) {
|
|
5734
|
+
return this.topLevelOperands(expr, exprAt);
|
|
5735
|
+
}
|
|
5736
|
+
/** 使用前是否检查过内容:条件中除存在性判断外的引用,或作为校验函数实参。 */
|
|
5737
|
+
validatedBefore(names, at) {
|
|
5738
|
+
if (names.size === 0) return false;
|
|
5739
|
+
const region = this.code.slice(this.span.start, at);
|
|
5740
|
+
const pattern = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
|
|
5741
|
+
for (const m of region.matchAll(/\b(?:if|switch)\s*\(/g)) {
|
|
5742
|
+
const open = this.span.start + m.index + m[0].length - 1;
|
|
5743
|
+
const close = this.pairs.get(open);
|
|
5744
|
+
if (close === void 0 || close > at) continue;
|
|
5745
|
+
const condition = this.code.slice(open + 1, close);
|
|
5746
|
+
const alternatives = namePattern(names);
|
|
5747
|
+
const substantive = condition.replace(new RegExp(`!\\s*(?:${alternatives})(?![\\w$.(\\[])`, "g"), " ").replace(new RegExp(`(?<![\\w$.])(?:${alternatives})\\s*[!=]==?\\s*(?:null|undefined|''|""|0)`, "g"), " ").replace(new RegExp(`\\btypeof\\s+(?:${alternatives})\\b`, "g"), " ");
|
|
5748
|
+
if (pattern.test(substantive)) return true;
|
|
5749
|
+
}
|
|
5750
|
+
const validator = /(?:\.\s*(?:test|match|includes|has|startsWith|endsWith|parse|safeParse)|(?<![\w$.])(?:valid\w*|sanitiz\w*|escape\w*|is[A-Z]\w*|assert\w*))\s*\(/gi;
|
|
5751
|
+
for (const m of region.matchAll(validator)) {
|
|
5752
|
+
const open = this.span.start + m.index + m[0].length - 1;
|
|
5753
|
+
const close = this.pairs.get(open);
|
|
5754
|
+
if (close !== void 0 && close <= at && pattern.test(this.code.slice(open + 1, close))) return true;
|
|
5755
|
+
}
|
|
5756
|
+
return false;
|
|
5757
|
+
}
|
|
5758
|
+
interpolationTaint(from, to, depth) {
|
|
5759
|
+
let found = null;
|
|
5760
|
+
for (let i = from; i < to - 1; i++) {
|
|
5761
|
+
if (this.code[i] !== "$" || this.code[i + 1] !== "{") continue;
|
|
5762
|
+
const close = this.pairs.get(i + 1);
|
|
5763
|
+
if (close === void 0) continue;
|
|
5764
|
+
const t = this.taintOf(this.code.slice(i + 2, close), i + 2, depth);
|
|
5765
|
+
i = close;
|
|
5766
|
+
if (t) found = mergeTaint(found, t);
|
|
5767
|
+
}
|
|
5768
|
+
return found;
|
|
5769
|
+
}
|
|
5770
|
+
topLevelOperands(expr, exprAt) {
|
|
5771
|
+
const result = [];
|
|
5772
|
+
let from = 0;
|
|
5773
|
+
for (let i = 0; i < expr.length; i++) {
|
|
5774
|
+
const close = this.pairs.get(exprAt + i);
|
|
5775
|
+
if (close !== void 0) {
|
|
5776
|
+
i = close - exprAt;
|
|
5777
|
+
continue;
|
|
5778
|
+
}
|
|
5779
|
+
if (expr[i] === "+" && expr[i + 1] !== "+" && expr[i + 1] !== "=" && expr[i - 1] !== "+") {
|
|
5780
|
+
result.push({ text: expr.slice(from, i), at: exprAt + from });
|
|
5781
|
+
from = i + 1;
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
result.push({ text: expr.slice(from), at: exprAt + from });
|
|
5785
|
+
return result;
|
|
5786
|
+
}
|
|
5787
|
+
/**
|
|
5788
|
+
* 赋值目标及其是否仍只是本站 URL。从本站 URL 解构时按键区分:
|
|
5789
|
+
* searchParams、pathname 等为可控输入,origin、host 等不可控,其余保持本站 URL。
|
|
5790
|
+
*/
|
|
5791
|
+
targetsOf(a, taint) {
|
|
5792
|
+
if (!taint.ownUrl || !a.pattern.trim().startsWith("{")) return a.names.map((name) => [name, taint.ownUrl]);
|
|
5793
|
+
const targets = [];
|
|
5794
|
+
for (const { key, local } of destructuredKeys(a.pattern.trim())) {
|
|
5795
|
+
if (URL_ORIGIN_KEYS.has(key)) continue;
|
|
5796
|
+
targets.push([local, !URL_INPUT_KEYS.has(key)]);
|
|
5797
|
+
}
|
|
5798
|
+
return targets;
|
|
5799
|
+
}
|
|
5800
|
+
assignments() {
|
|
5801
|
+
const { code, span, pairs } = this;
|
|
5802
|
+
const region = code.slice(span.start, span.end);
|
|
5803
|
+
const found = [];
|
|
5804
|
+
const push = (a) => {
|
|
5805
|
+
if (found.length >= MAX_ASSIGNMENTS) {
|
|
5806
|
+
this.limited = true;
|
|
5807
|
+
return;
|
|
5808
|
+
}
|
|
5809
|
+
found.push(a);
|
|
5810
|
+
};
|
|
5811
|
+
for (const m of region.matchAll(/\b(?:const|let|var)\s+/g)) {
|
|
5812
|
+
let at = span.start + m.index + m[0].length;
|
|
5813
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
5814
|
+
let patternEnd;
|
|
5815
|
+
if (code[at] === "{" || code[at] === "[") {
|
|
5816
|
+
const close = pairs.get(at);
|
|
5817
|
+
if (close === void 0) break;
|
|
5818
|
+
patternEnd = close + 1;
|
|
5819
|
+
} else {
|
|
5820
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(code.slice(at, at + 200));
|
|
5821
|
+
if (!id) break;
|
|
5822
|
+
patternEnd = at + id[0].length;
|
|
5823
|
+
}
|
|
5824
|
+
const pattern = code.slice(at, patternEnd);
|
|
5825
|
+
const rest = code.slice(patternEnd, patternEnd + 400);
|
|
5826
|
+
const forOf = /^\s+of\s+/.exec(rest);
|
|
5827
|
+
if (forOf) {
|
|
5828
|
+
const exprAt2 = patternEnd + forOf[0].length;
|
|
5829
|
+
const end2 = expressionEnd(code, exprAt2, span.end, pairs, () => {
|
|
5830
|
+
this.limited = true;
|
|
5831
|
+
});
|
|
5832
|
+
push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt2, end2), exprAt: exprAt2, iterate: true, append: false, declaration: true });
|
|
5833
|
+
break;
|
|
5834
|
+
}
|
|
5835
|
+
const eq = /^\s*(?::[^=;]{0,200}?)?=(?![=>])/.exec(rest);
|
|
5836
|
+
if (!eq) break;
|
|
5837
|
+
const exprAt = patternEnd + eq[0].length;
|
|
5838
|
+
const end = expressionEnd(code, exprAt, span.end, pairs, () => {
|
|
5839
|
+
this.limited = true;
|
|
5840
|
+
});
|
|
5841
|
+
push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt, end), exprAt, iterate: false, append: false, declaration: true });
|
|
5842
|
+
if (code[end] !== ",") break;
|
|
5843
|
+
at = end + 1;
|
|
5844
|
+
while (/\s/.test(code[at] ?? "")) at++;
|
|
5845
|
+
}
|
|
5846
|
+
}
|
|
5847
|
+
for (const m of region.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(\+?=)(?![=>])/g)) {
|
|
5848
|
+
const before = region.slice(Math.max(0, m.index - 8), m.index);
|
|
5849
|
+
if (/\b(?:const|let|var)\s+$/.test(before)) continue;
|
|
5850
|
+
const exprAt = span.start + m.index + m[0].length;
|
|
5851
|
+
const end = expressionEnd(code, exprAt, span.end, pairs, () => {
|
|
5852
|
+
this.limited = true;
|
|
5853
|
+
});
|
|
5854
|
+
push({ at: span.start + m.index, pattern: m[1], names: [m[1]], expr: code.slice(exprAt, end), exprAt, iterate: false, append: m[2] === "+=" });
|
|
5855
|
+
}
|
|
5856
|
+
return found;
|
|
5857
|
+
}
|
|
5858
|
+
};
|
|
5859
|
+
function sourceRegex(roles) {
|
|
5860
|
+
const parts = [];
|
|
5861
|
+
if (roles.objects.size > 0) {
|
|
5862
|
+
const objects = namePattern(roles.objects.keys());
|
|
5863
|
+
parts.push(
|
|
5864
|
+
String.raw`(?<![\w$.])(?:${objects})(?:\s*\??\.\s*(?:request|event))?\s*\??\.\s*(json|formData|text|arrayBuffer|blob|body|query|params|url|nextUrl|headers|cookies|searchParams)\b`,
|
|
5865
|
+
String.raw`(?<![\w$.])(?:${objects})\s*\??\.\s*context\s*\??\.\s*params\b`,
|
|
5866
|
+
String.raw`\b(?:${H3_READERS})\s*(?:<[^()]{0,200}>\s*)?\(\s*(?:${objects})\b`
|
|
5867
|
+
);
|
|
5868
|
+
}
|
|
5869
|
+
if (roles.urls.size > 0) {
|
|
5870
|
+
parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.urls.keys())})\s*\??\.\s*(?:searchParams|pathname|search|hash)\b`);
|
|
5871
|
+
}
|
|
5872
|
+
if (roles.cookies.size > 0) {
|
|
5873
|
+
parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.cookies.keys())})\s*\??\.\s*get(?:All)?\s*\(`);
|
|
5874
|
+
}
|
|
5875
|
+
return parts.length === 0 ? null : new RegExp(parts.join("|"), "g");
|
|
5876
|
+
}
|
|
5877
|
+
function mergeTaint(found, t) {
|
|
5878
|
+
if (!found) return { level: t.level, origin: t.origin, names: new Set(t.names), ownUrl: t.ownUrl };
|
|
5879
|
+
if (t.level === "derived") found.level = "derived";
|
|
5880
|
+
if (found.ownUrl && !t.ownUrl) {
|
|
5881
|
+
found.ownUrl = false;
|
|
5882
|
+
found.origin = t.origin;
|
|
5883
|
+
}
|
|
5884
|
+
for (const n of t.names) found.names.add(n);
|
|
5885
|
+
return found;
|
|
5886
|
+
}
|
|
5887
|
+
function hasInput(roles) {
|
|
5888
|
+
return roles.objects.size + roles.urls.size + roles.cookies.size + roles.values.size > 0;
|
|
5889
|
+
}
|
|
5890
|
+
function lowerBound(values, value) {
|
|
5891
|
+
let low = 0, high = values.length;
|
|
5892
|
+
while (low < high) {
|
|
5893
|
+
const middle = low + high >>> 1;
|
|
5894
|
+
if (values[middle] < value) low = middle + 1;
|
|
5895
|
+
else high = middle;
|
|
5896
|
+
}
|
|
5897
|
+
return low;
|
|
5898
|
+
}
|
|
5899
|
+
var HandlerFile = class {
|
|
5900
|
+
constructor(code, source, pairs, lineStarts, candidates) {
|
|
5901
|
+
this.code = code;
|
|
5902
|
+
this.source = source;
|
|
5903
|
+
this.pairs = pairs;
|
|
5904
|
+
this.lineStarts = lineStarts;
|
|
5905
|
+
this.candidates = candidates;
|
|
5906
|
+
this.fixedTables = fixedTablesOf(code, source, pairs);
|
|
5907
|
+
}
|
|
5908
|
+
code;
|
|
5909
|
+
source;
|
|
5910
|
+
pairs;
|
|
5911
|
+
lineStarts;
|
|
5912
|
+
candidates;
|
|
5913
|
+
flows = /* @__PURE__ */ new Map();
|
|
5914
|
+
fixedTables;
|
|
5915
|
+
get limited() {
|
|
5916
|
+
for (const flow of this.flows.values()) if (flow.limited) return true;
|
|
5917
|
+
return false;
|
|
5918
|
+
}
|
|
5919
|
+
/** 至少包含一个给定位置的处理函数;positions 必须已按升序排列。 */
|
|
5920
|
+
handlersAround(positions) {
|
|
5921
|
+
const result = [];
|
|
5922
|
+
if (positions.length === 0) return result;
|
|
5923
|
+
for (const candidate of this.candidates) {
|
|
5924
|
+
const { body } = candidate;
|
|
5925
|
+
const first = lowerBound(positions, body.start + 1);
|
|
5926
|
+
if (first >= positions.length || positions[first] >= body.end) continue;
|
|
5927
|
+
let flow = this.flows.get(candidate);
|
|
5928
|
+
if (!flow) {
|
|
5929
|
+
flow = new InputFlow(this.code, this.source, { start: body.start, end: body.end }, this.pairs, candidate.roles, this.fixedTables);
|
|
5930
|
+
this.flows.set(candidate, flow);
|
|
5931
|
+
}
|
|
5932
|
+
result.push({ route: candidate.route, body, flow });
|
|
5933
|
+
}
|
|
5934
|
+
return result;
|
|
5935
|
+
}
|
|
5936
|
+
};
|
|
5937
|
+
var handlerCache = /* @__PURE__ */ new WeakMap();
|
|
5938
|
+
function handlerFileOf(file) {
|
|
5939
|
+
const cached = handlerCache.get(file);
|
|
5940
|
+
if (cached !== void 0) return cached;
|
|
5941
|
+
let result = null;
|
|
5942
|
+
if (/\.[mc]?[jt]sx?$/.test(file.path)) {
|
|
5943
|
+
const route = routeOf(file);
|
|
5944
|
+
const routes = route === null ? serverActionRoutes(file) : [route];
|
|
5945
|
+
if (routes.length > 0) {
|
|
5946
|
+
const code = noiseMaskedOf(file);
|
|
5947
|
+
const source = commentsMaskedOf(file);
|
|
5948
|
+
const pairs = delimiterPairs(code);
|
|
5949
|
+
const openers = /* @__PURE__ */ new Map();
|
|
5950
|
+
for (const [open, close] of pairs) openers.set(close, open);
|
|
5951
|
+
const bodies = functionBodies(code, pairs).sort((a, b) => a.start - b.start);
|
|
5952
|
+
const starts = bodies.map((body) => body.start);
|
|
5953
|
+
const candidates = [];
|
|
5954
|
+
for (const r of routes) {
|
|
5955
|
+
const reachable = r.reachable;
|
|
5956
|
+
const from = reachable ? lowerBound(starts, reachable.start) : 0;
|
|
5957
|
+
for (let i = from; i < bodies.length; i++) {
|
|
5958
|
+
const body = bodies[i];
|
|
5959
|
+
if (reachable && body.start >= reachable.end) break;
|
|
5960
|
+
if (reachable && body.end > reachable.end) continue;
|
|
5961
|
+
const action = r.action !== void 0 && reachable !== void 0 && body.start === reachable.start;
|
|
5962
|
+
const params = parametersOf(code, body, pairs, openers);
|
|
5963
|
+
if (!params) continue;
|
|
5964
|
+
const roles = paramRoles(source.slice(params.start, params.end), params.start, action);
|
|
5965
|
+
if (hasInput(roles)) candidates.push({ route: r, body, roles });
|
|
5966
|
+
}
|
|
5967
|
+
}
|
|
5968
|
+
if (candidates.length > 0) result = new HandlerFile(code, source, pairs, lineStartsOf(file.content), candidates);
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
handlerCache.set(file, result);
|
|
5972
|
+
return result;
|
|
5973
|
+
}
|
|
5974
|
+
function reportInputLimit(ctx, file, analysed) {
|
|
5975
|
+
if (analysed.limited) ctx.reportIncomplete(
|
|
5976
|
+
"request-input/tracking",
|
|
5977
|
+
`${file.path} reached the request-input tracking limit (8 value hops, 512 assignments or control/function regions, 4000 expression characters, or bounded URL analysis)`
|
|
5978
|
+
);
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
// src/rules/injection.ts
|
|
5982
|
+
var SQL_UNSAFE = /(?:\.\s*\$(?:queryRawUnsafe|executeRawUnsafe)|(?<![\w$.])Prisma\s*\.\s*raw|(?<![\w$.])sql\s*\.\s*(?:raw|unsafe))\s*\(/g;
|
|
5983
|
+
var SQL_RAW_FRAGMENT = /\.\s*(?:raw|whereRaw|orWhereRaw|andWhereRaw|havingRaw|orHavingRaw|orderByRaw|groupByRaw|joinRaw|fromRaw)\s*\(/g;
|
|
5984
|
+
var SQL_DRIVER = /\.\s*(?:query|execute|exec|prepare|run|all|get|unsafe|many|one|none|any|oneOrNone|manyOrNone)\s*\(/g;
|
|
5985
|
+
var SQL_CONDITION = /\.\s*(?:where|andWhere|orWhere|having|andHaving|orHaving)\s*\(/g;
|
|
5986
|
+
var LOOKS_LIKE_SQL = /\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bupdate\s+[\w."`]+\s+set\b|\bwhere\b|\border\s+by\b/i;
|
|
5987
|
+
var LOOKS_LIKE_CONDITION = /[=<>]|\b(?:like|ilike|in|is|between)\b/i;
|
|
5988
|
+
var CHILD_PROCESS = /^(?:node:)?child_process$/;
|
|
5989
|
+
var SHELL_EXPORTS = /* @__PURE__ */ new Set(["exec", "execSync"]);
|
|
5990
|
+
var SPAWN_EXPORTS = /* @__PURE__ */ new Set(["spawn", "spawnSync", "execFile", "execFileSync"]);
|
|
5991
|
+
function commandCallees(file) {
|
|
5992
|
+
const callees = { shell: /* @__PURE__ */ new Set(), spawn: /* @__PURE__ */ new Set(), commands: /* @__PURE__ */ new Set(), namespaces: /* @__PURE__ */ new Set() };
|
|
5993
|
+
for (const binding of bindingsOf(file).imports) {
|
|
5994
|
+
if (binding.spec !== void 0 && CHILD_PROCESS.test(binding.spec)) {
|
|
5995
|
+
if (binding.imported === "default") callees.namespaces.add(binding.local);
|
|
5996
|
+
else if (SHELL_EXPORTS.has(binding.imported)) callees.shell.add(binding.local);
|
|
5997
|
+
else if (SPAWN_EXPORTS.has(binding.imported)) callees.spawn.add(binding.local);
|
|
5998
|
+
} else if (binding.spec === "execa" && /^execaCommand(?:Sync)?$/.test(binding.imported)) {
|
|
5999
|
+
callees.commands.add(binding.local);
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
const source = commentsMaskedOf(file);
|
|
6003
|
+
for (const m of source.matchAll(/\bimport\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?child_process['"]/g)) {
|
|
6004
|
+
callees.namespaces.add(m[1]);
|
|
6005
|
+
}
|
|
6006
|
+
for (const m of source.matchAll(/\b(?:const|let|var)\s+(\{[^{}]{0,500}\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"](?:node:)?child_process['"]\s*\)/g)) {
|
|
6007
|
+
const target = m[1];
|
|
6008
|
+
if (!target.startsWith("{")) {
|
|
6009
|
+
callees.namespaces.add(target);
|
|
6010
|
+
continue;
|
|
6011
|
+
}
|
|
6012
|
+
for (const part of target.slice(1, -1).split(",")) {
|
|
6013
|
+
const names = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*$/.exec(part);
|
|
6014
|
+
if (!names) continue;
|
|
6015
|
+
if (SHELL_EXPORTS.has(names[1])) callees.shell.add(names[2] ?? names[1]);
|
|
6016
|
+
else if (SPAWN_EXPORTS.has(names[1])) callees.spawn.add(names[2] ?? names[1]);
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
for (const m of source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?promisify\s*\(\s*(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\)/g)) {
|
|
6020
|
+
const [, local, namespace, name] = m;
|
|
6021
|
+
const viaNamespace = namespace !== void 0 && callees.namespaces.has(namespace);
|
|
6022
|
+
if (namespace === void 0 && callees.shell.has(name) || viaNamespace && SHELL_EXPORTS.has(name)) callees.shell.add(local);
|
|
6023
|
+
else if (namespace === void 0 && callees.spawn.has(name) || viaNamespace && SPAWN_EXPORTS.has(name)) callees.spawn.add(local);
|
|
6024
|
+
}
|
|
6025
|
+
return callees;
|
|
6026
|
+
}
|
|
6027
|
+
function sinksIn(code, pairs, callees) {
|
|
6028
|
+
const sinks = /* @__PURE__ */ new Map();
|
|
6029
|
+
const add = (regex, kind, mode) => {
|
|
6030
|
+
regex.lastIndex = 0;
|
|
6031
|
+
for (const m of code.matchAll(regex)) {
|
|
6032
|
+
const open = m.index + m[0].length - 1;
|
|
6033
|
+
const close = pairs.get(open);
|
|
6034
|
+
if (close === void 0 || sinks.has(open)) continue;
|
|
6035
|
+
sinks.set(open, { kind, at: m.index, open, close, mode });
|
|
6036
|
+
}
|
|
6037
|
+
};
|
|
6038
|
+
if (callees.shell.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.shell)})\\s*\\(`, "g"), "command", "shell");
|
|
6039
|
+
if (callees.spawn.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.spawn)})\\s*\\(`, "g"), "command", "spawn");
|
|
6040
|
+
if (callees.commands.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.commands)})\\s*\\(`, "g"), "command", "execa");
|
|
6041
|
+
if (callees.namespaces.size > 0) {
|
|
6042
|
+
const ns = namePattern(callees.namespaces);
|
|
6043
|
+
add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:exec|execSync)\\s*\\(`, "g"), "command", "shell");
|
|
6044
|
+
add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:spawn|spawnSync|execFile|execFileSync)\\s*\\(`, "g"), "command", "spawn");
|
|
6045
|
+
}
|
|
6046
|
+
add(SQL_UNSAFE, "sql", "unsafe");
|
|
6047
|
+
add(SQL_RAW_FRAGMENT, "sql", "fragment");
|
|
6048
|
+
add(SQL_DRIVER, "sql", "driver");
|
|
6049
|
+
add(SQL_CONDITION, "sql", "condition");
|
|
6050
|
+
return [...sinks.values()].sort((a, b) => a.at - b.at);
|
|
6051
|
+
}
|
|
6052
|
+
function fixedProgram(expr, at, flow, source, depth = 0) {
|
|
6053
|
+
if (depth >= 8) {
|
|
6054
|
+
flow.limited = true;
|
|
6055
|
+
return false;
|
|
6056
|
+
}
|
|
6057
|
+
const text = expr.trim();
|
|
6058
|
+
const start = at + expr.length - expr.trimStart().length;
|
|
6059
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) {
|
|
6060
|
+
const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
|
|
6061
|
+
return assignments.length > 0 && assignments.every((a) => fixedProgram(a.expr, a.exprAt, flow, source, depth + 1));
|
|
6062
|
+
}
|
|
6063
|
+
const raw = source.slice(start, start + text.length);
|
|
6064
|
+
const prefix = /^["'`]([^"'`$\\]*)/.exec(raw)?.[1];
|
|
6065
|
+
return prefix !== void 0 && /^\S+\s/.test(prefix);
|
|
6066
|
+
}
|
|
6067
|
+
function hitOf(sink, code, source, pairs, flow) {
|
|
6068
|
+
const args = argumentExpressions(code, code, sink.open + 1, sink.close, pairs);
|
|
6069
|
+
if (args.length === 0) return null;
|
|
6070
|
+
const firstAt = sink.open + 1 + (code.slice(sink.open + 1, sink.close).length - code.slice(sink.open + 1, sink.close).trimStart().length);
|
|
6071
|
+
const first = args[0];
|
|
6072
|
+
const firstSource = source.slice(firstAt, firstAt + first.length);
|
|
6073
|
+
if (sink.mode === "spawn") {
|
|
6074
|
+
if (!/\bshell\s*:\s*true\b/.test(code.slice(sink.open, sink.close))) return null;
|
|
6075
|
+
const taint = flow.taintOf(code.slice(sink.open + 1, sink.close), sink.open + 1) ?? flow.builtOf(first, firstAt);
|
|
6076
|
+
return taint ? { taint } : null;
|
|
6077
|
+
}
|
|
6078
|
+
const built = flow.builtOf(first, firstAt);
|
|
6079
|
+
const identifier = /^[A-Za-z_$][\w$]*$/.exec(first);
|
|
6080
|
+
const builtVariable = identifier ? flow.builtValue(identifier[0], firstAt) : void 0;
|
|
6081
|
+
const text = built ? firstSource : builtVariable?.text ?? null;
|
|
6082
|
+
const stringTaint = built ?? builtVariable ?? null;
|
|
6083
|
+
if (sink.mode === "execa") {
|
|
6084
|
+
const shell = /\bshell\s*:\s*(?:true\b|['"][^'"]+['"])/.test(source.slice(sink.open, sink.close));
|
|
6085
|
+
if (!shell && fixedProgram(first, firstAt, flow, source)) return null;
|
|
6086
|
+
const taint = stringTaint ?? flow.taintOf(first, firstAt);
|
|
6087
|
+
return taint ? { taint, executable: !shell } : null;
|
|
6088
|
+
}
|
|
6089
|
+
if (sink.mode === "shell" || sink.mode === "unsafe") {
|
|
6090
|
+
const taint = stringTaint ?? flow.taintOf(first, firstAt);
|
|
6091
|
+
return taint ? { taint } : null;
|
|
6092
|
+
}
|
|
6093
|
+
if (!stringTaint || text === null) return null;
|
|
6094
|
+
if (sink.mode === "driver" && !LOOKS_LIKE_SQL.test(text)) return null;
|
|
6095
|
+
if (sink.mode === "condition" && !LOOKS_LIKE_CONDITION.test(text)) return null;
|
|
6096
|
+
return { taint: stringTaint };
|
|
6097
|
+
}
|
|
6098
|
+
function capitalised(text) {
|
|
6099
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
6100
|
+
}
|
|
6101
|
+
function findingFor(kind, route, file, line, originLine, certain, executable = false) {
|
|
6102
|
+
const lowered = certain ? [] : [
|
|
6103
|
+
"The value passes through other code or a check before it gets here, and the scan cannot tell whether that makes it safe. Confirm the value is restricted to what the query or command expects."
|
|
6104
|
+
];
|
|
6105
|
+
const excerpt = (file.lines[line - 1] ?? "").trim();
|
|
6106
|
+
if (kind === "sql") {
|
|
6107
|
+
return {
|
|
6108
|
+
ruleId: "injection/sql",
|
|
6109
|
+
severity: "P1",
|
|
6110
|
+
confidence: certain ? "certain" : "likely",
|
|
6111
|
+
title: `${capitalised(route.url)} builds a SQL query from request input`,
|
|
6112
|
+
file: file.path,
|
|
6113
|
+
line,
|
|
6114
|
+
excerpt,
|
|
6115
|
+
why: [
|
|
6116
|
+
`The query text is assembled from a value the caller controls (read on line ${originLine}). Whoever calls ${route.url} can change the query itself, not just the value it compares against \u2014 read other rows, skip filters, or change data.`,
|
|
6117
|
+
...lowered
|
|
6118
|
+
],
|
|
6119
|
+
fix: [
|
|
6120
|
+
"Pass values as parameters instead of splicing them into the SQL text: the tagged form (prisma.$queryRaw`\u2026 ${id}`, sql`\u2026 ${id}`) or placeholders ($1, ?) with a separate values array.",
|
|
6121
|
+
"If a column name or sort direction has to vary, choose it from a fixed list in code instead of using the request value."
|
|
6122
|
+
]
|
|
6123
|
+
};
|
|
6124
|
+
}
|
|
6125
|
+
return {
|
|
6126
|
+
ruleId: "injection/command",
|
|
6127
|
+
severity: "P1",
|
|
6128
|
+
confidence: certain ? "certain" : "likely",
|
|
6129
|
+
title: executable ? `${capitalised(route.url)} runs an executable selected by request input` : `${capitalised(route.url)} runs a shell command built from request input`,
|
|
6130
|
+
file: file.path,
|
|
6131
|
+
line,
|
|
6132
|
+
excerpt,
|
|
6133
|
+
why: [
|
|
6134
|
+
executable ? `The caller can influence the executable name (read on line ${originLine}). Execa does not use a shell by default, but allowing a caller to choose the program can still run unintended code with server permissions.` : `The command line includes a value the caller controls (read on line ${originLine}) and is run through a shell. Characters such as ; | $( ) let a caller append their own commands, which run with your server's permissions.`,
|
|
6135
|
+
...lowered
|
|
6136
|
+
],
|
|
6137
|
+
fix: [
|
|
6138
|
+
executable ? "Use a fixed executable and pass request values in a separate argument array; do not accept a caller-supplied command line." : "Use execFile or spawn with an argument array and no shell option, so the value is passed as one argument and never parsed by a shell.",
|
|
6139
|
+
"Check the value against a fixed list of allowed values before using it."
|
|
6140
|
+
]
|
|
6141
|
+
};
|
|
6142
|
+
}
|
|
6143
|
+
var injectionRule = {
|
|
6144
|
+
id: "injection/request-input",
|
|
6145
|
+
severity: "P1",
|
|
6146
|
+
check(ctx) {
|
|
6147
|
+
const findings = [];
|
|
6148
|
+
for (const file of ctx.files) {
|
|
6149
|
+
const analysed = handlerFileOf(file);
|
|
6150
|
+
if (!analysed) continue;
|
|
6151
|
+
const { code, source, pairs, lineStarts } = analysed;
|
|
6152
|
+
const sinks = sinksIn(code, pairs, commandCallees(file));
|
|
6153
|
+
if (sinks.length === 0) continue;
|
|
6154
|
+
const positions = sinks.map((sink) => sink.at);
|
|
6155
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6156
|
+
for (const { route, body, flow } of analysed.handlersAround(positions)) {
|
|
6157
|
+
for (let i = lowerBound(positions, body.start + 1); i < sinks.length && sinks[i].at < body.end; i++) {
|
|
6158
|
+
const sink = sinks[i];
|
|
6159
|
+
if (sink.close >= body.end || reported.has(sink.at)) continue;
|
|
6160
|
+
const hit = hitOf(sink, code, source, pairs, flow);
|
|
6161
|
+
if (!hit) continue;
|
|
6162
|
+
reported.add(sink.at);
|
|
6163
|
+
const certain = hit.taint.level === "direct" && !flow.validatedBefore(hit.taint.names, sink.at);
|
|
6164
|
+
findings.push(findingFor(
|
|
6165
|
+
sink.kind,
|
|
6166
|
+
route,
|
|
6167
|
+
file,
|
|
6168
|
+
lineNumberAt(lineStarts, sink.at),
|
|
6169
|
+
lineNumberAt(lineStarts, hit.taint.origin),
|
|
6170
|
+
certain,
|
|
6171
|
+
hit.executable
|
|
6172
|
+
));
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
6175
|
+
reportInputLimit(ctx, file, analysed);
|
|
6176
|
+
}
|
|
6177
|
+
return findings;
|
|
6178
|
+
}
|
|
6179
|
+
};
|
|
6180
|
+
|
|
6181
|
+
// src/rules/outbound.ts
|
|
6182
|
+
var FETCH_CALL = /(?<![\w$.])(?:fetch|\$fetch|ofetch|axios|got|ky|needle)\s*(?:<[^()]{0,200}>\s*)?\(/g;
|
|
6183
|
+
var FETCH_METHOD = /(?<![\w$.])(?:axios|got|ky|needle|http|https|undici)\s*\.\s*(?:get|post|put|patch|delete|head|request|stream)\s*(?:<[^()]{0,200}>\s*)?\(/g;
|
|
6184
|
+
var REDIRECT_CALL = /(?<![\w$.])(?:redirect|permanentRedirect|sendRedirect)\s*\(|(?<![\w$.])(?:NextResponse|Response|res|reply|response|ctx|context|Astro)\s*\.\s*redirect\s*\(/g;
|
|
6185
|
+
function callsIn(code, pairs) {
|
|
6186
|
+
const calls = [];
|
|
6187
|
+
const add = (regex, kind) => {
|
|
6188
|
+
regex.lastIndex = 0;
|
|
6189
|
+
for (const m of code.matchAll(regex)) {
|
|
6190
|
+
const open = m.index + m[0].length - 1;
|
|
6191
|
+
const close = pairs.get(open);
|
|
6192
|
+
if (close !== void 0) calls.push({ kind, at: m.index, open, close, name: m[0] });
|
|
6193
|
+
}
|
|
6194
|
+
};
|
|
6195
|
+
add(FETCH_CALL, "ssrf");
|
|
6196
|
+
add(FETCH_METHOD, "ssrf");
|
|
6197
|
+
add(REDIRECT_CALL, "redirect");
|
|
6198
|
+
return calls;
|
|
6199
|
+
}
|
|
6200
|
+
var HOST_OPEN = /^(?:X?|[a-z][\w+.-]*:\/\/[^/?#\\]*)$/i;
|
|
6201
|
+
var REDIRECT_OPEN = /^(?:\/|[/\\]{2}[^/?#\\]*)$/;
|
|
6202
|
+
function controlsStart(expr, at, flow, source, kind, depth = 0) {
|
|
6203
|
+
const lead = expr.length - expr.trimStart().length;
|
|
6204
|
+
const text = expr.trim();
|
|
6205
|
+
const start = at + lead;
|
|
6206
|
+
if (text === "") return null;
|
|
6207
|
+
if (depth >= 8) {
|
|
6208
|
+
flow.limited = true;
|
|
6209
|
+
return null;
|
|
6210
|
+
}
|
|
6211
|
+
if (text.length > 4e3) {
|
|
6212
|
+
flow.limited = true;
|
|
6213
|
+
return null;
|
|
6214
|
+
}
|
|
6215
|
+
const open = (prefix) => HOST_OPEN.test(prefix) || kind === "redirect" && REDIRECT_OPEN.test(prefix);
|
|
6216
|
+
const url = /^new\s+URL\s*\(/.exec(text);
|
|
6217
|
+
if (url) {
|
|
6218
|
+
const argsStart = start + url[0].length;
|
|
6219
|
+
const close = matchingParen(text, url[0].length - 1);
|
|
6220
|
+
if (close === null) return null;
|
|
6221
|
+
if (close === text.length - 1) {
|
|
6222
|
+
const first = splitTopLevel(text.slice(url[0].length, close))[0];
|
|
6223
|
+
return first === void 0 ? null : controlsStart(first, argsStart, flow, source, "redirect", depth + 1);
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
6226
|
+
if (text.startsWith("`")) return templateStart(text, start, flow, source, open);
|
|
6227
|
+
const operands = flow.operandsOf(text, start);
|
|
6228
|
+
if (operands.length > 1) {
|
|
6229
|
+
let prefix = "";
|
|
6230
|
+
for (const operand of operands) {
|
|
6231
|
+
const inner = operand.text.trim();
|
|
6232
|
+
if (/^['"]/.test(inner)) {
|
|
6233
|
+
prefix += source.slice(operand.at + operand.text.indexOf(inner) + 1, operand.at + operand.text.indexOf(inner) + inner.length - 1);
|
|
6234
|
+
continue;
|
|
6235
|
+
}
|
|
6236
|
+
const t2 = inner.startsWith("`") ? templateStart(inner, operand.at + operand.text.indexOf(inner), flow, source, open) : controlsStart(inner, operand.at + operand.text.indexOf(inner), flow, source, kind, depth + 1);
|
|
6237
|
+
if (t2) return open(prefix) ? t2 : null;
|
|
6238
|
+
prefix += "X";
|
|
6239
|
+
if (!open(prefix)) return null;
|
|
6240
|
+
}
|
|
6241
|
+
return null;
|
|
6242
|
+
}
|
|
6243
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) {
|
|
6244
|
+
const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
|
|
6245
|
+
if (assignments.length === 0) {
|
|
6246
|
+
const t2 = flow.valueOf(text, at);
|
|
6247
|
+
return t2 && !t2.ownUrl ? t2 : null;
|
|
6248
|
+
}
|
|
6249
|
+
for (const a of assignments) {
|
|
6250
|
+
const t2 = controlsStart(a.expr, a.exprAt, flow, source, kind, depth + 1);
|
|
6251
|
+
if (t2) return { ...t2, level: a.indirect ? "derived" : t2.level, names: /* @__PURE__ */ new Set([...t2.names, text]) };
|
|
6252
|
+
}
|
|
6253
|
+
return null;
|
|
6254
|
+
}
|
|
6255
|
+
const t = flow.taintOf(text, start);
|
|
6256
|
+
return t && !t.ownUrl ? t : null;
|
|
6257
|
+
}
|
|
6258
|
+
function templateStart(text, start, flow, source, open) {
|
|
6259
|
+
let prefix = "";
|
|
6260
|
+
for (let i = 1; i < text.length; i++) {
|
|
6261
|
+
if (text[i] === "`") return null;
|
|
6262
|
+
if (text[i] === "$" && text[i + 1] === "{") {
|
|
6263
|
+
const close = matchingBrace(text, i + 1);
|
|
6264
|
+
if (close === null) return null;
|
|
6265
|
+
const t = flow.taintOf(text.slice(i + 2, close), start + i + 2);
|
|
6266
|
+
if (t && !t.ownUrl) return open(prefix) ? t : null;
|
|
6267
|
+
prefix += "X";
|
|
6268
|
+
i = close;
|
|
6269
|
+
continue;
|
|
6270
|
+
}
|
|
6271
|
+
prefix += source[start + i];
|
|
6272
|
+
if (prefix.length > 200) {
|
|
6273
|
+
flow.limited = true;
|
|
6274
|
+
return null;
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
return null;
|
|
6278
|
+
}
|
|
6279
|
+
function matchingParen(text, open) {
|
|
6280
|
+
let depth = 0;
|
|
6281
|
+
for (let i = open; i < text.length; i++) {
|
|
6282
|
+
if (text[i] === "(") depth++;
|
|
6283
|
+
else if (text[i] === ")" && --depth === 0) return i;
|
|
6284
|
+
}
|
|
6285
|
+
return null;
|
|
6286
|
+
}
|
|
6287
|
+
function matchingBrace(text, open) {
|
|
6288
|
+
let depth = 0;
|
|
6289
|
+
for (let i = open; i < text.length; i++) {
|
|
6290
|
+
if (text[i] === "{") depth++;
|
|
6291
|
+
else if (text[i] === "}" && --depth === 0) return i;
|
|
6292
|
+
}
|
|
6293
|
+
return null;
|
|
6294
|
+
}
|
|
6295
|
+
function splitTopLevel(text) {
|
|
6296
|
+
const parts = [];
|
|
6297
|
+
let depth = 0, from = 0;
|
|
6298
|
+
for (let i = 0; i < text.length; i++) {
|
|
6299
|
+
if ("([{".includes(text[i])) depth++;
|
|
6300
|
+
else if (")]}".includes(text[i])) depth--;
|
|
6301
|
+
else if (text[i] === "," && depth === 0) {
|
|
6302
|
+
parts.push(text.slice(from, i));
|
|
6303
|
+
from = i + 1;
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
parts.push(text.slice(from));
|
|
6307
|
+
return parts;
|
|
6308
|
+
}
|
|
6309
|
+
function targetOf(call, analysed) {
|
|
6310
|
+
const { code, pairs } = analysed;
|
|
6311
|
+
const args = argumentExpressions(code, code, call.open + 1, call.close, pairs);
|
|
6312
|
+
if (args.length === 0) return null;
|
|
6313
|
+
const positions = [];
|
|
6314
|
+
let cursor = call.open + 1;
|
|
6315
|
+
for (const arg of args) {
|
|
6316
|
+
const found = code.indexOf(arg, cursor);
|
|
6317
|
+
positions.push(found);
|
|
6318
|
+
cursor = found + arg.length;
|
|
6319
|
+
}
|
|
6320
|
+
let index = 0;
|
|
6321
|
+
if (call.kind === "redirect") {
|
|
6322
|
+
if (/sendRedirect/.test(call.name)) index = 1;
|
|
6323
|
+
else if (/^\d{3}$/.test(args[0]) && args.length > 1) index = 1;
|
|
6324
|
+
}
|
|
6325
|
+
const expr = args[index];
|
|
6326
|
+
if (expr === void 0) return null;
|
|
6327
|
+
if (call.kind === "ssrf" && expr.startsWith("{")) {
|
|
6328
|
+
const property = /(?:^|[{,])\s*(?:url|baseURL|href)\s*:\s*/.exec(expr);
|
|
6329
|
+
if (!property) {
|
|
6330
|
+
const shorthand = /(?:^|[{,])\s*(url|baseURL|href)\s*(?=[,}])/.exec(expr);
|
|
6331
|
+
return shorthand ? { expr: shorthand[1], at: positions[index] + shorthand.index + shorthand[0].length - shorthand[1].length } : null;
|
|
6332
|
+
}
|
|
6333
|
+
const valueAt = positions[index] + property.index + property[0].length;
|
|
6334
|
+
const value = splitTopLevel(expr.slice(property.index + property[0].length))[0].replace(/\}\s*$/, "");
|
|
6335
|
+
return { expr: value, at: valueAt };
|
|
6336
|
+
}
|
|
6337
|
+
return { expr, at: positions[index] };
|
|
6338
|
+
}
|
|
6339
|
+
function capitalised2(text) {
|
|
6340
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
6341
|
+
}
|
|
6342
|
+
function ssrfFinding(route, file, line, originLine) {
|
|
6343
|
+
return {
|
|
6344
|
+
ruleId: "ssrf/request-url",
|
|
6345
|
+
severity: "P1",
|
|
6346
|
+
confidence: "likely",
|
|
6347
|
+
title: `${capitalised2(route.url)} sends a server request to an address the caller chooses`,
|
|
6348
|
+
file: file.path,
|
|
6349
|
+
line,
|
|
6350
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6351
|
+
why: [
|
|
6352
|
+
`The address of this outgoing request comes from request input (read on line ${originLine}). A caller can point your server at internal services, cloud metadata endpoints such as 169.254.169.254, or other hosts only your server can reach, and read the response or trigger actions there.`,
|
|
6353
|
+
"Fetching a caller-supplied address can be the purpose of the route, as in a link preview or image proxy, so this is reported for review. Even then it needs a host allowlist."
|
|
6354
|
+
],
|
|
6355
|
+
fix: [
|
|
6356
|
+
"Build the address from a fixed origin in code and let the request choose only a path segment or query value.",
|
|
6357
|
+
"If callers must supply full URLs, parse them with new URL(), allow only https and hosts on an explicit allowlist, and reject private, loopback, and link-local addresses after DNS resolution."
|
|
6358
|
+
]
|
|
6359
|
+
};
|
|
6360
|
+
}
|
|
6361
|
+
function redirectFinding(route, file, line, originLine, certain) {
|
|
6362
|
+
return {
|
|
6363
|
+
ruleId: "redirect/open",
|
|
6364
|
+
severity: "P2",
|
|
6365
|
+
confidence: certain ? "certain" : "likely",
|
|
6366
|
+
title: `${capitalised2(route.url)} redirects to an address taken from the request`,
|
|
6367
|
+
file: file.path,
|
|
6368
|
+
line,
|
|
6369
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6370
|
+
why: [
|
|
6371
|
+
`The redirect target comes from request input (read on line ${originLine}), and nothing fixes the site it points to. A link to your site can then send visitors anywhere, which makes phishing links look trustworthy and can hand tokens in the URL to another site.`,
|
|
6372
|
+
...certain ? [] : ["The value passes through other code or a check first; confirm that check rejects other sites, including //host and /\\host forms."]
|
|
6373
|
+
],
|
|
6374
|
+
fix: [
|
|
6375
|
+
"Accept only relative paths that start with a single / (reject //, /\\, and anything with a scheme), or compare the target against a fixed list.",
|
|
6376
|
+
"When building an absolute URL, use new URL(path, yourOrigin) and redirect only if the result's origin equals yourOrigin."
|
|
6377
|
+
]
|
|
6378
|
+
};
|
|
6379
|
+
}
|
|
6380
|
+
function check(ctx, kind) {
|
|
6381
|
+
const findings = [];
|
|
6382
|
+
for (const file of ctx.files) {
|
|
6383
|
+
const analysed = handlerFileOf(file);
|
|
6384
|
+
if (!analysed) continue;
|
|
6385
|
+
const calls = callsIn(analysed.code, analysed.pairs).filter((call) => call.kind === kind).sort((a, b) => a.at - b.at);
|
|
6386
|
+
if (calls.length === 0) continue;
|
|
6387
|
+
const positions = calls.map((call) => call.at);
|
|
6388
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6389
|
+
for (const { route, body, flow } of analysed.handlersAround(positions)) {
|
|
6390
|
+
for (let i = lowerBound(positions, body.start + 1); i < calls.length && calls[i].at < body.end; i++) {
|
|
6391
|
+
const call = calls[i];
|
|
6392
|
+
if (call.close >= body.end || reported.has(call.at)) continue;
|
|
6393
|
+
const target = targetOf(call, analysed);
|
|
6394
|
+
if (!target) continue;
|
|
6395
|
+
const taint = controlsStart(target.expr, target.at, flow, analysed.source, kind);
|
|
6396
|
+
if (!taint) continue;
|
|
6397
|
+
reported.add(call.at);
|
|
6398
|
+
const line = lineNumberAt(analysed.lineStarts, call.at);
|
|
6399
|
+
const originLine = lineNumberAt(analysed.lineStarts, taint.origin);
|
|
6400
|
+
findings.push(kind === "ssrf" ? ssrfFinding(route, file, line, originLine) : redirectFinding(route, file, line, originLine, taint.level === "direct" && !flow.validatedBefore(taint.names, call.at)));
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
reportInputLimit(ctx, file, analysed);
|
|
6404
|
+
}
|
|
6405
|
+
return findings;
|
|
6406
|
+
}
|
|
6407
|
+
var ssrfRule = { id: "ssrf/request-url", severity: "P1", check: (ctx) => check(ctx, "ssrf") };
|
|
6408
|
+
var redirectRule = { id: "redirect/open", severity: "P2", check: (ctx) => check(ctx, "redirect") };
|
|
6409
|
+
|
|
6410
|
+
// src/rules/verification.ts
|
|
6411
|
+
var LocalVerification = class {
|
|
6412
|
+
code;
|
|
6413
|
+
source;
|
|
6414
|
+
pairs;
|
|
6415
|
+
bodies;
|
|
6416
|
+
constructor(file) {
|
|
6417
|
+
this.code = noiseMaskedOf(file);
|
|
6418
|
+
this.source = commentsMaskedOf(file);
|
|
6419
|
+
this.pairs = delimiterPairs(this.code);
|
|
6420
|
+
this.bodies = functionBodies(this.code, this.pairs);
|
|
6421
|
+
}
|
|
6422
|
+
owner(at) {
|
|
6423
|
+
return this.bodies.filter((b) => b.start < at && at < b.end).sort((a, b) => b.start - a.start)[0];
|
|
6424
|
+
}
|
|
6425
|
+
skipSpace(at) {
|
|
6426
|
+
while (/\s/.test(this.code[at] ?? "")) at++;
|
|
6427
|
+
return at;
|
|
6428
|
+
}
|
|
6429
|
+
endOf(at, limit = this.code.length) {
|
|
6430
|
+
if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
|
|
6431
|
+
for (let i = at; i < limit; i++) {
|
|
6432
|
+
if (/[;\n}]/.test(this.code[i])) return i;
|
|
6433
|
+
const end = this.pairs.get(i);
|
|
6434
|
+
if (end !== void 0) i = end;
|
|
6435
|
+
}
|
|
6436
|
+
return limit;
|
|
6437
|
+
}
|
|
6438
|
+
/**
|
|
6439
|
+
* 块的顶层有无条件退出即算退出,前面可以有记录日志等语句;
|
|
6440
|
+
* 嵌套分支、函数中的 return 以及无花括号 if/else/循环控制的 return 不算。
|
|
6441
|
+
*/
|
|
6442
|
+
exits(from, to) {
|
|
6443
|
+
let at = this.skipSpace(from);
|
|
6444
|
+
if (this.code[at] !== "{") return /^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, to));
|
|
6445
|
+
const end = Math.min(to, this.pairs.get(at) ?? to);
|
|
6446
|
+
at = this.skipSpace(at + 1);
|
|
6447
|
+
while (at < end) {
|
|
6448
|
+
if (/^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, at + 32))) return true;
|
|
6449
|
+
const next = this.statementEnd(at, end);
|
|
6450
|
+
if (next <= at) return false;
|
|
6451
|
+
at = this.skipSpace(next);
|
|
6452
|
+
}
|
|
6453
|
+
return false;
|
|
6454
|
+
}
|
|
6455
|
+
/** 跳过完整语句;分支、循环和函数内的退出不提升为外层退出。 */
|
|
6456
|
+
statementEnd(at, limit, depth = 0) {
|
|
6457
|
+
if (depth >= 64) return limit;
|
|
6458
|
+
if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
|
|
6459
|
+
const control = /^(if|for(?:\s+await)?|while|with|switch)\s*\(/.exec(this.code.slice(at, at + 80));
|
|
6460
|
+
if (control) {
|
|
6461
|
+
const close = this.pairs.get(at + control[0].length - 1);
|
|
6462
|
+
if (close === void 0) return limit;
|
|
6463
|
+
let end = this.statementEnd(this.skipSpace(close + 1), limit, depth + 1);
|
|
6464
|
+
const next = this.skipSpace(end);
|
|
6465
|
+
if (control[1] === "if" && /^else\b/.test(this.code.slice(next, next + 5))) {
|
|
6466
|
+
end = this.statementEnd(this.skipSpace(next + 4), limit, depth + 1);
|
|
6467
|
+
}
|
|
6468
|
+
return end;
|
|
6469
|
+
}
|
|
6470
|
+
if (/^(?:try|do)\b/.test(this.code.slice(at, at + 8))) return limit;
|
|
6471
|
+
for (let i = at; i < limit; i++) {
|
|
6472
|
+
if (this.code[i] === ";") return i + 1;
|
|
6473
|
+
const close = this.pairs.get(i);
|
|
6474
|
+
if (close !== void 0) {
|
|
6475
|
+
i = close;
|
|
6476
|
+
continue;
|
|
6477
|
+
}
|
|
6478
|
+
if (this.code[i] !== "\n") continue;
|
|
6479
|
+
const before = this.code.slice(at, i).trimEnd();
|
|
6480
|
+
const after = this.code.slice(i + 1, limit).trimStart();
|
|
6481
|
+
if (!before || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
|
|
6482
|
+
if (/^[.?+\-*/%&|^,:<>=([{`]/.test(after)) continue;
|
|
6483
|
+
if (/\b(?:const|let|var|function|async|await|new|yield)$/.test(before)) continue;
|
|
6484
|
+
return i + 1;
|
|
6485
|
+
}
|
|
6486
|
+
return limit;
|
|
6487
|
+
}
|
|
6488
|
+
/** 验证与使用须在同一函数,且不能依赖可选分支或被吞掉的异常。 */
|
|
6489
|
+
enforcedBefore(from, to) {
|
|
6490
|
+
const owner = this.owner(from);
|
|
6491
|
+
if (from >= to || owner !== this.owner(to)) return false;
|
|
6492
|
+
const start = owner ? owner.start + 1 : 0;
|
|
6493
|
+
for (let i = start; i < from; i++) {
|
|
6494
|
+
const conditional = /^(?:if|for|while|switch)\s*\(/.exec(this.code.slice(i, i + 32));
|
|
6495
|
+
if (conditional && !/[\w$]/.test(this.code[i - 1] ?? "")) {
|
|
6496
|
+
const close = this.pairs.get(i + conditional[0].length - 1);
|
|
6497
|
+
if (close !== void 0) {
|
|
6498
|
+
const body = this.skipSpace(close + 1);
|
|
6499
|
+
const end2 = this.endOf(body);
|
|
6500
|
+
if (from < end2 && to >= end2) return false;
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
const end = this.pairs.get(i);
|
|
6504
|
+
if (end === void 0) continue;
|
|
6505
|
+
if (end < from) {
|
|
6506
|
+
i = end;
|
|
6507
|
+
continue;
|
|
6508
|
+
}
|
|
6509
|
+
if (end >= to) continue;
|
|
6510
|
+
if (this.code[i] !== "{" || !/\btry\s*$/.test(this.code.slice(Math.max(start, i - 16), i))) return false;
|
|
6511
|
+
let next = this.skipSpace(end + 1);
|
|
6512
|
+
if (/^catch\b/.test(this.code.slice(next))) {
|
|
6513
|
+
next = this.skipSpace(next + 5);
|
|
6514
|
+
if (this.code[next] === "(") next = this.skipSpace((this.pairs.get(next) ?? next) + 1);
|
|
6515
|
+
const caughtEnd = this.endOf(next);
|
|
6516
|
+
if (!this.exits(next, caughtEnd)) return false;
|
|
6517
|
+
next = this.skipSpace(caughtEnd);
|
|
6518
|
+
}
|
|
6519
|
+
if (/^finally\b/.test(this.code.slice(next))) return false;
|
|
6520
|
+
}
|
|
6521
|
+
const boundary = Math.max(
|
|
6522
|
+
start,
|
|
6523
|
+
this.code.lastIndexOf(";", from - 1) + 1,
|
|
6524
|
+
this.code.lastIndexOf("\n", from - 1) + 1,
|
|
6525
|
+
this.code.lastIndexOf("{", from - 1) + 1
|
|
6526
|
+
);
|
|
6527
|
+
return !/[?]|&&|\|\|/.test(this.code.slice(boundary, from));
|
|
6528
|
+
}
|
|
6529
|
+
/** 找出当前函数中最后一次赋值;未知或条件覆盖同样会使旧证明失效。 */
|
|
6530
|
+
assignment(name, at) {
|
|
6531
|
+
const escaped = name.replace(/\$/g, "\\$");
|
|
6532
|
+
const pattern = new RegExp(`(?<![\\w$.])(?:const\\s+|let\\s+|var\\s+)?${escaped}((?:\\s*\\.[\\w$]+|\\s*\\[[^\\]]*\\])*)\\s*(?::[^=;{}\\n]{0,100})?=(?![=>])`, "g");
|
|
6533
|
+
let result = null;
|
|
6534
|
+
for (const m of this.code.slice(0, at).matchAll(pattern)) {
|
|
6535
|
+
if (this.owner(m.index) !== this.owner(at)) continue;
|
|
6536
|
+
const from = this.skipSpace(m.index + m[0].length);
|
|
6537
|
+
const end = this.endOf(from);
|
|
6538
|
+
if (end <= at) result = { at: m.index, from, end, expression: this.code.slice(from, end).trim(), member: m[1].length > 0 };
|
|
6539
|
+
}
|
|
6540
|
+
return result;
|
|
6541
|
+
}
|
|
6542
|
+
};
|
|
6543
|
+
|
|
6544
|
+
// src/rules/session.ts
|
|
6545
|
+
var GET_SESSION = /\.auth\s*\.\s*getSession\s*\(/g;
|
|
6546
|
+
function verifiedSession(file, context, sessionAt, names) {
|
|
6547
|
+
const { code, pairs, bodies } = context;
|
|
6548
|
+
const body = context.owner(sessionAt);
|
|
6549
|
+
if (!body) return false;
|
|
6550
|
+
const client = /([\w$.]+)\s*$/.exec(code.slice(body.start, sessionAt))?.[1];
|
|
6551
|
+
if (!client) return false;
|
|
6552
|
+
const values = authValuesOf(file, body, bodies, pairs);
|
|
6553
|
+
const reads = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$]|\\s*:)`, "g");
|
|
6554
|
+
const verification = /\b(?:const|let)\s+(\{[^;=]{1,500}\}|[A-Za-z_$][\w$]*)\s*=\s*await\s+([\w$.]+)\.auth\.(?:getUser|getClaims)\s*\(/g;
|
|
6555
|
+
for (const call of code.matchAll(verification)) {
|
|
6556
|
+
if (call[2] !== client || context.owner(call.index) !== body) continue;
|
|
6557
|
+
const close = pairs.get(call.index + call[0].length - 1);
|
|
6558
|
+
if (close === void 0) continue;
|
|
6559
|
+
if (code.slice(call.index + call[0].length, close).trim() !== "") continue;
|
|
6560
|
+
const guardAt = context.skipSpace(code[close + 1] === ";" ? close + 2 : close + 1);
|
|
6561
|
+
const condition = /^if\s*\(/.exec(code.slice(guardAt));
|
|
6562
|
+
if (!condition) continue;
|
|
6563
|
+
const conditionClose = pairs.get(guardAt + condition[0].length - 1);
|
|
6564
|
+
if (conditionClose === void 0) continue;
|
|
6565
|
+
const branchAt = context.skipSpace(conditionClose + 1);
|
|
6566
|
+
const branchEnd = context.endOf(branchAt);
|
|
6567
|
+
if (!context.exits(branchAt, branchEnd) || !context.enforcedBefore(call.index, guardAt)) continue;
|
|
6568
|
+
const test = code.slice(guardAt + condition[0].length, conditionClose).trim();
|
|
6569
|
+
const errorNames = call[1].startsWith("{") ? destructuredKeys(call[1]).filter((k) => k.key === "error").map((k) => k.local) : [`${call[1]}.error`];
|
|
6570
|
+
if (/&&|\?(?!\.)/.test(test) || !test.split("||").some((term) => {
|
|
6571
|
+
const t = term.trim();
|
|
6572
|
+
return errorNames.includes(t) || t.startsWith("!") && identityRequirement(values.value(t.slice(1), guardAt), false) !== null;
|
|
6573
|
+
})) continue;
|
|
6574
|
+
const sessionClose = pairs.get(code.indexOf("(", sessionAt)) ?? code.indexOf(")", sessionAt);
|
|
6575
|
+
let before = code.slice(sessionClose + 1, branchEnd);
|
|
6576
|
+
before = before.replace(/\bif\s*\(\s*!\s*([\w$.?]+)\s*\)\s*(?:return|throw|redirect)\b[^;\n]*/g, (text, subject) => {
|
|
6577
|
+
const rest = text.slice(text.indexOf(")") + 1).replace(/\b[\w$]+\s*:/g, "");
|
|
6578
|
+
return names.some((n) => subject === n || subject.startsWith(`${n}.`)) && !new RegExp(reads.source).test(rest) ? " ".repeat(text.length) : text;
|
|
6579
|
+
});
|
|
6580
|
+
if (new RegExp(reads.source).test(before)) continue;
|
|
6581
|
+
if (!context.enforcedBefore(guardAt, body.end - 1)) continue;
|
|
6582
|
+
return true;
|
|
6583
|
+
}
|
|
6584
|
+
return false;
|
|
6585
|
+
}
|
|
6586
|
+
var SERVER_PATH = [
|
|
6587
|
+
/(?:^|\/)(?:middleware|proxy)\.[mc]?[jt]s$/,
|
|
6588
|
+
/(?:^|\/)app\/(?:.+\/)?(?:page|layout|template|default)\.[mc]?[jt]sx?$/,
|
|
6589
|
+
/\.server\.[mc]?[jt]s$/,
|
|
6590
|
+
/(?:^|\/)server\/.+\.[mc]?[jt]s$/
|
|
6591
|
+
];
|
|
6592
|
+
function isServerFile(file) {
|
|
6593
|
+
if (!/\.[mc]?[jt]sx?$/.test(file.path)) return false;
|
|
6594
|
+
if (/^\s*(?:(?:'use strict'|"use strict");?\s*)?['"]use client['"]/.test(commentsMaskedOf(file))) return false;
|
|
6595
|
+
return SERVER_PATH.some((pattern) => pattern.test(file.path)) || routeOf(file) !== null || serverActionRoutes(file).length > 0;
|
|
6596
|
+
}
|
|
6597
|
+
var sessionRule = {
|
|
6598
|
+
id: "auth/unverified-session",
|
|
6599
|
+
severity: "P1",
|
|
6600
|
+
appliesTo(file) {
|
|
6601
|
+
return file.content.includes("getSession") && isServerFile(file);
|
|
6602
|
+
},
|
|
6603
|
+
check(file) {
|
|
6604
|
+
const code = noiseMaskedOf(file);
|
|
6605
|
+
const context = new LocalVerification(file);
|
|
6606
|
+
const { pairs, bodies } = context;
|
|
6607
|
+
const lineStarts = lineStartsOf(file.content);
|
|
6608
|
+
const findings = [];
|
|
6609
|
+
for (const m of code.matchAll(GET_SESSION)) {
|
|
6610
|
+
const body = bodies.filter((b) => b.start < m.index && m.index < b.end).sort((a, b) => b.start - a.start)[0];
|
|
6611
|
+
const start = body?.start ?? 0;
|
|
6612
|
+
const end = body?.end ?? code.length;
|
|
6613
|
+
const statement = code.slice(Math.max(start, m.index - 300), m.index);
|
|
6614
|
+
const declaration = /\b(?:const|let|var)\s+((?:\{[^;=]*\})|[A-Za-z_$][\w$]*)\s*(?::[^=;]{0,200})?=\s*(?:await\s+)?\(?\s*(?:await\s+)?[\w$.?\s]*$/.exec(statement);
|
|
6615
|
+
if (!declaration) continue;
|
|
6616
|
+
const names = patternNames(declaration[1]);
|
|
6617
|
+
if (names.length === 0) continue;
|
|
6618
|
+
const refs = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
|
|
6619
|
+
const after = code.slice(m.index, end);
|
|
6620
|
+
let decides = false;
|
|
6621
|
+
for (const condition of after.matchAll(/\bif\s*\(/g)) {
|
|
6622
|
+
const open = m.index + condition.index + condition[0].length - 1;
|
|
6623
|
+
const close = pairs.get(open);
|
|
6624
|
+
if (close !== void 0 && refs.test(code.slice(open + 1, close))) {
|
|
6625
|
+
decides = true;
|
|
6626
|
+
break;
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
const readsUser = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?:\\??\\.\\s*(?:session|data))*\\??\\.\\s*user\\b`).test(after);
|
|
6630
|
+
if (!decides && !readsUser) continue;
|
|
6631
|
+
if (verifiedSession(file, context, m.index, names)) continue;
|
|
6632
|
+
const line = lineNumberAt(lineStarts, m.index);
|
|
6633
|
+
findings.push({
|
|
6634
|
+
ruleId: "auth/unverified-session",
|
|
6635
|
+
severity: "P1",
|
|
6636
|
+
confidence: decides ? "certain" : "likely",
|
|
6637
|
+
title: "Server code trusts supabase.auth.getSession() to identify the user",
|
|
6638
|
+
file: file.path,
|
|
6639
|
+
line,
|
|
6640
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6641
|
+
why: [
|
|
6642
|
+
"On the server, getSession() reads the session straight from the request cookie without verifying it. Anyone can put a forged or expired session in their cookies, so a check based on it can be passed by a caller who is not signed in.",
|
|
6643
|
+
decides ? "The result is used in a condition here, so it decides who gets through." : "The user from this session is read here, so an unverified user id may be trusted."
|
|
6644
|
+
],
|
|
6645
|
+
fix: [
|
|
6646
|
+
"Use supabase.auth.getClaims() (verifies the token signature) or supabase.auth.getUser() (asks the Auth server) to decide who the caller is.",
|
|
6647
|
+
"Keep getSession() only where you need the access token itself, not to decide access."
|
|
6648
|
+
]
|
|
6649
|
+
});
|
|
6650
|
+
}
|
|
6651
|
+
return findings;
|
|
6652
|
+
}
|
|
6653
|
+
};
|
|
6654
|
+
|
|
6655
|
+
// src/rules/webhook.ts
|
|
6656
|
+
var STRIPE_EVENT = /(?:\bcase\s*|\.type\s*[!=]==?\s*)['"`]((?:checkout\.session|payment_intent|customer\.subscription|customer|invoice|charge|setup_intent|payment_method|subscription_schedule|account|payout|refund|checkout)\.[a-z_.]+)['"`]|['"`]((?:checkout\.session|payment_intent|customer\.subscription|invoice|charge)\.[a-z_.]+)['"`]\s*[!=]==?\s*[\w$.]+\.type\b/;
|
|
6657
|
+
function verifiedEvent(context, name, at) {
|
|
6658
|
+
const assignment = context.assignment(name, at);
|
|
6659
|
+
if (!assignment || assignment.member || !context.enforcedBefore(assignment.at, at)) return false;
|
|
6660
|
+
const call = /^(?:(await)\s+)?[\w$.]+\.(constructEvent(?:Async)?|retrieve|parseEventNotification)\s*\(/.exec(assignment.expression);
|
|
6661
|
+
if (!call || call[2] !== "constructEvent" && !call[1]) return false;
|
|
6662
|
+
if (call[2] === "retrieve" && !/\.events\s*\.\s*retrieve/.test(call[0])) return false;
|
|
6663
|
+
const close = context.pairs.get(assignment.from + call[0].length - 1);
|
|
6664
|
+
return close !== void 0 && context.code.slice(close + 1, assignment.end).trim() === "";
|
|
6665
|
+
}
|
|
6666
|
+
function eventName(context, event) {
|
|
6667
|
+
if (event[0].startsWith(".")) return /([A-Za-z_$][\w$]*)\s*$/.exec(context.code.slice(0, event.index))?.[1] ?? null;
|
|
6668
|
+
if (!event[0].startsWith("case")) return /([A-Za-z_$][\w$]*)\.type\b/.exec(event[0])?.[1] ?? null;
|
|
6669
|
+
let name = null;
|
|
6670
|
+
for (const m of context.code.slice(0, event.index).matchAll(/\bswitch\s*\(\s*([A-Za-z_$][\w$]*)\.type\s*\)\s*\{/g)) {
|
|
6671
|
+
const end = context.pairs.get(m.index + m[0].length - 1);
|
|
6672
|
+
if (end !== void 0 && end > event.index && context.owner(m.index) === context.owner(event.index)) name = m[1];
|
|
6673
|
+
}
|
|
6674
|
+
return name;
|
|
6675
|
+
}
|
|
6676
|
+
var READS_BODY = /\.\s*(?:json|text|arrayBuffer|formData)\s*\(|\breq(?:uest)?\s*\.\s*body\b|\b(?:readBody|readRawBody)\s*\(|\bbuffer\s*\(\s*req\b/;
|
|
6677
|
+
var webhookRule = {
|
|
6678
|
+
id: "webhook/unverified-signature",
|
|
6679
|
+
severity: "P1",
|
|
6680
|
+
appliesTo(file) {
|
|
6681
|
+
return /stripe/i.test(file.content) && routeOf(file) !== null;
|
|
6682
|
+
},
|
|
6683
|
+
check(file) {
|
|
6684
|
+
const route = routeOf(file);
|
|
6685
|
+
if (!route) return [];
|
|
6686
|
+
const context = new LocalVerification(file);
|
|
6687
|
+
const { source, code } = context;
|
|
6688
|
+
const findings = [];
|
|
6689
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6690
|
+
for (const event of source.matchAll(new RegExp(STRIPE_EVENT.source, "g"))) {
|
|
6691
|
+
if (code[event.index] !== source[event.index]) continue;
|
|
6692
|
+
const owner = context.owner(event.index);
|
|
6693
|
+
const name = eventName(context, event);
|
|
6694
|
+
if (name && verifiedEvent(context, name, event.index)) continue;
|
|
6695
|
+
const key = owner?.start ?? 0;
|
|
6696
|
+
if (reported.has(key)) continue;
|
|
6697
|
+
reported.add(key);
|
|
6698
|
+
const line = lineNumberAt(lineStartsOf(file.content), event.index);
|
|
6699
|
+
const region = code.slice(owner?.start ?? 0, owner?.end ?? code.length);
|
|
6700
|
+
const readsBody = READS_BODY.test(region);
|
|
6701
|
+
const customCheck = /\bverifyHeader\s*\(/.test(region) || /\bcreateHmac\s*\(/.test(region) && /\btimingSafeEqual\s*\(/.test(region);
|
|
6702
|
+
findings.push({
|
|
6703
|
+
ruleId: "webhook/unverified-signature",
|
|
6704
|
+
severity: "P1",
|
|
6705
|
+
confidence: readsBody && !customCheck ? "certain" : "likely",
|
|
6706
|
+
title: customCheck ? `Review custom Stripe signature verification in ${route.url}` : `${route.url} acts on Stripe events without verifying the signature`,
|
|
6707
|
+
file: file.path,
|
|
6708
|
+
line,
|
|
6709
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6710
|
+
why: [
|
|
6711
|
+
`This route handles ${event[1] ?? event[2]} and similar events, but the scan could not link this event to a signature-verification result. Without effective verification, callers can submit made-up events and trigger the handler.`,
|
|
6712
|
+
...customCheck ? ["A custom signature check is present; confirm it checks the raw body and rejects invalid signatures before processing this event."] : [],
|
|
6713
|
+
...readsBody ? [] : ["The event does not appear to come from the request body here; confirm where it is read and whether it is verified there."]
|
|
6714
|
+
],
|
|
6715
|
+
fix: [
|
|
6716
|
+
"Read the raw body (await request.text() in a route handler) and pass it to stripe.webhooks.constructEvent(body, request.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET) before handling the event. Return 400 when it throws.",
|
|
6717
|
+
"Do not parse the body as JSON before verifying; any change to the raw body makes verification fail."
|
|
6718
|
+
]
|
|
6719
|
+
});
|
|
6720
|
+
}
|
|
6721
|
+
return findings;
|
|
6722
|
+
}
|
|
6723
|
+
};
|
|
6724
|
+
|
|
4540
6725
|
// src/rules/index.ts
|
|
4541
|
-
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
|
|
4542
|
-
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule];
|
|
6726
|
+
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule, sessionRule, webhookRule];
|
|
6727
|
+
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule, injectionRule, ssrfRule, redirectRule];
|
|
4543
6728
|
var RULE_IDS = [
|
|
4544
6729
|
"api/admin-db-access-without-auth",
|
|
4545
6730
|
"api/db-write-without-auth",
|
|
6731
|
+
"auth/unverified-session",
|
|
4546
6732
|
"cors/reflected-origin-with-credentials",
|
|
4547
6733
|
"cors/wildcard-with-credentials",
|
|
4548
6734
|
"exposure/private-name-in-public-env",
|
|
@@ -4552,9 +6738,14 @@ var RULE_IDS = [
|
|
|
4552
6738
|
"firebase/test-mode-rules",
|
|
4553
6739
|
"gitleak/env-in-history",
|
|
4554
6740
|
"gitleak/env-tracked",
|
|
6741
|
+
"injection/command",
|
|
6742
|
+
"injection/sql",
|
|
6743
|
+
"redirect/open",
|
|
6744
|
+
"ssrf/request-url",
|
|
4555
6745
|
"supabase/permissive-policy",
|
|
4556
6746
|
"supabase/public-bucket-listing",
|
|
4557
6747
|
"supabase/rls-not-enabled",
|
|
6748
|
+
"webhook/unverified-signature",
|
|
4558
6749
|
// 凭据规则 ID 从共享格式表生成。
|
|
4559
6750
|
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
4560
6751
|
];
|
|
@@ -4782,7 +6973,7 @@ var RULE_CATALOG = [
|
|
|
4782
6973
|
"P0",
|
|
4783
6974
|
"certain",
|
|
4784
6975
|
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro, including workspace applications.",
|
|
4785
|
-
"Syntactic guards only; runtime authentication and business authorization are not verified. Local
|
|
6976
|
+
"Syntactic guards only; runtime authentication and business authorization are not verified. Local calls, identity arguments, branches and exception paths are analysed within documented limits. Local auth evidence, SvelteKit hooks and Nuxt middleware may lower confidence without removing findings; exceeded limits disclose incomplete coverage."
|
|
4786
6977
|
),
|
|
4787
6978
|
rule(
|
|
4788
6979
|
"api/db-write-without-auth",
|
|
@@ -4792,6 +6983,14 @@ var RULE_CATALOG = [
|
|
|
4792
6983
|
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
|
|
4793
6984
|
"Session-scoped clients and indirect guards may need manual review."
|
|
4794
6985
|
),
|
|
6986
|
+
rule(
|
|
6987
|
+
"auth/unverified-session",
|
|
6988
|
+
"Server code trusting supabase.auth.getSession()",
|
|
6989
|
+
"P1",
|
|
6990
|
+
"varies",
|
|
6991
|
+
"Server routes, Server Functions, middleware and proxy modules, Next.js server components, *.server modules, and server directories that call supabase.auth.getSession(): a result used in a condition is certain; reading its user is reported for review. Exemption requires a checked, awaited identity from the same client before trusted session use.",
|
|
6992
|
+
"Only the receiving variable in the same function is followed; results passed to helpers are not tracked."
|
|
6993
|
+
),
|
|
4795
6994
|
rule(
|
|
4796
6995
|
"cors/reflected-origin-with-credentials",
|
|
4797
6996
|
"Reflected origin with credentials",
|
|
@@ -4864,6 +7063,30 @@ var RULE_CATALOG = [
|
|
|
4864
7063
|
"Tracked environment files; templates and public-only values are treated separately.",
|
|
4865
7064
|
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
4866
7065
|
),
|
|
7066
|
+
rule(
|
|
7067
|
+
"injection/command",
|
|
7068
|
+
"Shell command built from request input",
|
|
7069
|
+
"P1",
|
|
7070
|
+
"varies",
|
|
7071
|
+
"exec, execSync, and spawn/execFile with shell: true in supported server handlers. Execa command strings are checked for a caller-chosen executable, or shell interpolation when shell is enabled. Direct request input is certain; values passing through other calls or content checks require review.",
|
|
7072
|
+
"Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked."
|
|
7073
|
+
),
|
|
7074
|
+
rule(
|
|
7075
|
+
"injection/sql",
|
|
7076
|
+
"SQL query built from request input",
|
|
7077
|
+
"P1",
|
|
7078
|
+
"varies",
|
|
7079
|
+
"Untagged template or string concatenation in Prisma $queryRawUnsafe/$executeRawUnsafe, Prisma.raw, sql.raw/sql.unsafe, knex *Raw methods, driver query/execute calls, and query-builder where clauses, in server routes and Next.js Server Functions. Tagged templates and placeholders are not reported.",
|
|
7080
|
+
"Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked. Driver calls are reported only when the text looks like SQL."
|
|
7081
|
+
),
|
|
7082
|
+
rule(
|
|
7083
|
+
"redirect/open",
|
|
7084
|
+
"Redirect to an address taken from the request",
|
|
7085
|
+
"P2",
|
|
7086
|
+
"varies",
|
|
7087
|
+
"redirect, permanentRedirect, NextResponse.redirect, Response.redirect, res.redirect, and sendRedirect in server routes and Next.js Server Functions, when request input decides the start of the target (a whole value, a leading /, or the host). Request input used directly is certain; values that pass through other calls or checks require review.",
|
|
7088
|
+
"Only flows inside one function are followed. The request's own URL and fixed paths followed by input are not reported; helper-built targets are not tracked."
|
|
7089
|
+
),
|
|
4867
7090
|
rule(
|
|
4868
7091
|
"supabase/permissive-policy",
|
|
4869
7092
|
"Row Level Security policy with an always-true condition",
|
|
@@ -4880,6 +7103,14 @@ var RULE_CATALOG = [
|
|
|
4880
7103
|
"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.",
|
|
4881
7104
|
"Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
|
|
4882
7105
|
),
|
|
7106
|
+
rule(
|
|
7107
|
+
"ssrf/request-url",
|
|
7108
|
+
"Server request to an address the caller chooses",
|
|
7109
|
+
"P1",
|
|
7110
|
+
"likely",
|
|
7111
|
+
"fetch, $fetch/ofetch, axios, got, ky, needle, and Node http(s) calls in server routes and Next.js Server Functions, when request input decides the scheme or host of the address.",
|
|
7112
|
+
"Only flows inside one function are followed. Fetching caller-supplied URLs can be intended, and allowlists or network controls are not verified."
|
|
7113
|
+
),
|
|
4883
7114
|
rule(
|
|
4884
7115
|
"supabase/rls-not-enabled",
|
|
4885
7116
|
"Table without RLS in migrations",
|
|
@@ -4888,6 +7119,14 @@ var RULE_CATALOG = [
|
|
|
4888
7119
|
"Supabase SQL migrations, replayed by application scope.",
|
|
4889
7120
|
"Migration state is not deployed database state; grants and policy correctness are outside this check."
|
|
4890
7121
|
),
|
|
7122
|
+
rule(
|
|
7123
|
+
"webhook/unverified-signature",
|
|
7124
|
+
"Stripe webhook events handled without signature verification",
|
|
7125
|
+
"P1",
|
|
7126
|
+
"varies",
|
|
7127
|
+
"Server routes that branch on Stripe event types without an enforced constructEvent or awaited event-retrieval result for the handled value. Request-body handling is certain; custom signature checks remain likely.",
|
|
7128
|
+
"Only Stripe is recognized. Unused calls do not verify events; custom HMAC and shared-helper verification require review."
|
|
7129
|
+
),
|
|
4891
7130
|
...SECRET_PATTERNS.map((pattern) => ({
|
|
4892
7131
|
...rule(
|
|
4893
7132
|
`secrets/hardcoded/${pattern.id}`,
|