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