canship 0.5.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 +89 -126
- package/README.md +89 -126
- package/dist/cli.js +2505 -612
- package/dist/index.js +1661 -143
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { isAbsolute as isAbsolute3, relative as relative_, resolve as resolve2 } from "path";
|
|
5
|
-
import { existsSync as existsSync3, realpathSync as
|
|
5
|
+
import { existsSync as existsSync3, realpathSync as realpathSync4, statSync as statSync6, writeFileSync as writeFileSync2 } from "fs";
|
|
6
6
|
|
|
7
7
|
// src/rules/patterns.ts
|
|
8
8
|
var IRRELEVANT_HOSTS = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|.*\.?example\.(?:com|org|net)|.*\.(?:test|invalid|localhost))$/i;
|
|
@@ -375,12 +375,12 @@ function isNetworkPath(path) {
|
|
|
375
375
|
return process.platform === "win32" && (path.startsWith("\\\\") || path.startsWith("//"));
|
|
376
376
|
}
|
|
377
377
|
function resolveGitExecutable(root) {
|
|
378
|
-
const
|
|
378
|
+
const executable2 = process.platform === "win32" ? "git.exe" : "git";
|
|
379
379
|
const unsafe = untrustedRoots(root);
|
|
380
380
|
for (const rawEntry of (process.env.PATH ?? "").split(delimiter)) {
|
|
381
381
|
const entry = cleanPathEntry(rawEntry);
|
|
382
382
|
if (!entry || !isAbsolute(entry) || isNetworkPath(entry) || isProjectBin(entry)) continue;
|
|
383
|
-
const candidate = join(entry,
|
|
383
|
+
const candidate = join(entry, executable2);
|
|
384
384
|
try {
|
|
385
385
|
if (!statSync(candidate).isFile()) continue;
|
|
386
386
|
if (process.platform !== "win32") accessSync(candidate, constants.X_OK);
|
|
@@ -463,8 +463,8 @@ function hardeningArgs(root) {
|
|
|
463
463
|
`core.hooksPath=${noHooks}`
|
|
464
464
|
];
|
|
465
465
|
}
|
|
466
|
-
function execGitSync(
|
|
467
|
-
return execFileSync(
|
|
466
|
+
function execGitSync(executable2, root, args, options = {}) {
|
|
467
|
+
return execFileSync(executable2, [...hardeningArgs(root), ...args], {
|
|
468
468
|
timeout: GIT_TIMEOUT_MS,
|
|
469
469
|
cwd: root,
|
|
470
470
|
encoding: "utf8",
|
|
@@ -474,8 +474,8 @@ function execGitSync(executable, root, args, options = {}) {
|
|
|
474
474
|
env: gitEnvironment()
|
|
475
475
|
});
|
|
476
476
|
}
|
|
477
|
-
function execGitBatch(
|
|
478
|
-
return execFileSync(
|
|
477
|
+
function execGitBatch(executable2, root, args, input, options = {}) {
|
|
478
|
+
return execFileSync(executable2, [...hardeningArgs(root), ...args], {
|
|
479
479
|
timeout: GIT_TIMEOUT_MS,
|
|
480
480
|
cwd: root,
|
|
481
481
|
input,
|
|
@@ -817,8 +817,7 @@ function walkTree(root, skipped, wantAll, maxEntries) {
|
|
|
817
817
|
}
|
|
818
818
|
if (!metadata.isFile()) continue;
|
|
819
819
|
if (wantAll) all.push(rel);
|
|
820
|
-
|
|
821
|
-
if (isCandidate) found.push(rel);
|
|
820
|
+
if (isCredentialName(entry.name) || isWorthProbing(rel)) found.push(rel);
|
|
822
821
|
}
|
|
823
822
|
} catch (err) {
|
|
824
823
|
if (!isMissing(err)) skipped.push({
|
|
@@ -871,6 +870,9 @@ function isExampleContext(relPath) {
|
|
|
871
870
|
if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
|
|
872
871
|
return false;
|
|
873
872
|
}
|
|
873
|
+
function isCredentialName(name) {
|
|
874
|
+
return isEnvFile(name) || CREDENTIAL_FILENAMES.has(name) || CREDENTIAL_EXTENSIONS.has(extname(name).toLowerCase());
|
|
875
|
+
}
|
|
874
876
|
function readablePath(root, path, skipped) {
|
|
875
877
|
const rel = relative2(root, path);
|
|
876
878
|
if (!rel || rel === ".." || rel.startsWith(`..${sep2}`) || isAbsolute2(rel)) {
|
|
@@ -934,8 +936,16 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
934
936
|
let filesRead = 0;
|
|
935
937
|
const maxBytes = limits.maxBytes ?? MAX_SCAN_BYTES;
|
|
936
938
|
const maxFiles = limits.maxFiles ?? MAX_SCAN_FILES;
|
|
939
|
+
const credentials = [];
|
|
940
|
+
const sources = [];
|
|
941
|
+
const probes = [];
|
|
937
942
|
for (const relPath of candidates) {
|
|
938
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]) {
|
|
939
949
|
const absPath = join2(root, relPath);
|
|
940
950
|
let content;
|
|
941
951
|
try {
|
|
@@ -1056,6 +1066,8 @@ var BACKSLASH = 92;
|
|
|
1056
1066
|
var DOLLAR = 36;
|
|
1057
1067
|
var OPEN_BRACE = 123;
|
|
1058
1068
|
var CLOSE_BRACE = 125;
|
|
1069
|
+
var LESS_THAN = 60;
|
|
1070
|
+
var GREATER_THAN = 62;
|
|
1059
1071
|
var NEWLINE = 10;
|
|
1060
1072
|
var SPACE = 32;
|
|
1061
1073
|
function codesOf(src) {
|
|
@@ -1094,19 +1106,19 @@ function endOfString(src, start, quote) {
|
|
|
1094
1106
|
}
|
|
1095
1107
|
var REGEX_PREFIX_WORDS = /* @__PURE__ */ new Set(["return", "throw", "case", "delete", "void", "typeof", "instanceof", "in", "of", "new", "yield", "await", "else", "do"]);
|
|
1096
1108
|
var CONTROL_WORDS = /* @__PURE__ */ new Set(["if", "while", "for", "with", "switch", "catch"]);
|
|
1097
|
-
var regexContext = () => ({ allowed: true, control: false, member: false, parentheses: []
|
|
1109
|
+
var regexContext = () => ({ allowed: true, control: false, member: false, parentheses: [] });
|
|
1098
1110
|
function valueEnded(context) {
|
|
1099
1111
|
context.allowed = false;
|
|
1100
1112
|
context.control = false;
|
|
1101
1113
|
context.member = false;
|
|
1102
1114
|
}
|
|
1103
|
-
function regexAt(src, start, context) {
|
|
1104
|
-
if (!context.allowed || start <
|
|
1115
|
+
function regexAt(src, start, context, state) {
|
|
1116
|
+
if (!context.allowed || start < state.failedUntil) return null;
|
|
1105
1117
|
let inClass = false;
|
|
1106
1118
|
for (let i = start + 1; i < src.length; i++) {
|
|
1107
1119
|
const ch = src[i];
|
|
1108
1120
|
if (ch === "\n" || ch === "\r") {
|
|
1109
|
-
|
|
1121
|
+
state.failedUntil = i;
|
|
1110
1122
|
return null;
|
|
1111
1123
|
}
|
|
1112
1124
|
if (ch === "\\") {
|
|
@@ -1122,15 +1134,15 @@ function regexAt(src, start, context) {
|
|
|
1122
1134
|
return { close: i, end };
|
|
1123
1135
|
}
|
|
1124
1136
|
}
|
|
1125
|
-
|
|
1137
|
+
state.failedUntil = src.length;
|
|
1126
1138
|
return null;
|
|
1127
1139
|
}
|
|
1128
1140
|
function advanceCode(src, at, context) {
|
|
1129
1141
|
const ch = src[at];
|
|
1130
1142
|
if (/\s/.test(ch)) return at + 1;
|
|
1131
|
-
if (/[A-Za-z_$\u0080
|
|
1143
|
+
if (/[A-Za-z_$\u0080-]/.test(ch)) {
|
|
1132
1144
|
let end = at + 1;
|
|
1133
|
-
while (end < src.length && /[\w$\u0080
|
|
1145
|
+
while (end < src.length && /[\w$\u0080-]/.test(src[end])) end++;
|
|
1134
1146
|
const word = src.slice(at, end);
|
|
1135
1147
|
context.control = !context.member && CONTROL_WORDS.has(word);
|
|
1136
1148
|
context.allowed = !context.member && REGEX_PREFIX_WORDS.has(word);
|
|
@@ -1155,11 +1167,39 @@ function advanceCode(src, at, context) {
|
|
|
1155
1167
|
context.member = false;
|
|
1156
1168
|
return at + 1;
|
|
1157
1169
|
}
|
|
1158
|
-
|
|
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) {
|
|
1159
1181
|
const length = src.length;
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
|
|
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) {
|
|
1163
1203
|
const frame = stack[stack.length - 1];
|
|
1164
1204
|
const ch = src.charCodeAt(i);
|
|
1165
1205
|
if (frame.kind === "literal") {
|
|
@@ -1168,171 +1208,155 @@ function maskTemplate(src, out, start, maskStrings = true) {
|
|
|
1168
1208
|
continue;
|
|
1169
1209
|
}
|
|
1170
1210
|
if (ch === BACKTICK) {
|
|
1171
|
-
|
|
1211
|
+
text(frame.from, i);
|
|
1172
1212
|
stack.pop();
|
|
1173
1213
|
const parent = stack[stack.length - 1];
|
|
1174
|
-
if (parent?.kind === "
|
|
1214
|
+
if (parent?.kind === "code") valueEnded(parent.lexical);
|
|
1175
1215
|
i++;
|
|
1176
1216
|
continue;
|
|
1177
1217
|
}
|
|
1178
1218
|
if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
|
|
1179
|
-
|
|
1180
|
-
stack.push(
|
|
1219
|
+
text(frame.from, i);
|
|
1220
|
+
stack.push(expression());
|
|
1181
1221
|
i += 2;
|
|
1182
1222
|
continue;
|
|
1183
1223
|
}
|
|
1184
1224
|
i++;
|
|
1185
1225
|
continue;
|
|
1186
1226
|
}
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
blank(out, i, stop);
|
|
1193
|
-
i = stop;
|
|
1227
|
+
if (frame.kind === "children") {
|
|
1228
|
+
if (ch === OPEN_BRACE) {
|
|
1229
|
+
text(frame.from, i);
|
|
1230
|
+
stack.push(expression());
|
|
1231
|
+
i++;
|
|
1194
1232
|
continue;
|
|
1195
1233
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
i
|
|
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++;
|
|
1200
1244
|
continue;
|
|
1201
1245
|
}
|
|
1202
|
-
}
|
|
1203
|
-
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1204
|
-
const stop = endOfString(src, i, ch);
|
|
1205
|
-
if (maskStrings) blank(out, i + 1, stop - 1);
|
|
1206
|
-
valueEnded(frame.lexical);
|
|
1207
|
-
i = stop;
|
|
1208
|
-
continue;
|
|
1209
|
-
}
|
|
1210
|
-
if (ch === BACKTICK) {
|
|
1211
|
-
stack.push({ kind: "literal", from: i + 1 });
|
|
1212
1246
|
i++;
|
|
1213
1247
|
continue;
|
|
1214
1248
|
}
|
|
1215
|
-
if (
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
stack.pop();
|
|
1220
|
-
const parent = stack[stack.length - 1];
|
|
1221
|
-
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;
|
|
1222
1253
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
|
|
1228
|
-
return i;
|
|
1229
|
-
}
|
|
1230
|
-
function maskJsComments(src) {
|
|
1231
|
-
const length = src.length;
|
|
1232
|
-
const out = codesOf(src);
|
|
1233
|
-
let i = 0;
|
|
1234
|
-
const lexical = regexContext();
|
|
1235
|
-
while (i < length) {
|
|
1236
|
-
const ch = src.charCodeAt(i);
|
|
1237
|
-
if (ch === SLASH) {
|
|
1238
|
-
const next = src.charCodeAt(i + 1);
|
|
1239
|
-
if (next === SLASH) {
|
|
1240
|
-
const end = src.indexOf("\n", i);
|
|
1241
|
-
const stop = end === -1 ? length : end;
|
|
1242
|
-
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);
|
|
1243
1258
|
i = stop;
|
|
1244
1259
|
continue;
|
|
1245
1260
|
}
|
|
1246
|
-
if (
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
blank(out, i, stop);
|
|
1250
|
-
i = stop;
|
|
1261
|
+
if (ch === OPEN_BRACE) {
|
|
1262
|
+
stack.push(expression());
|
|
1263
|
+
i++;
|
|
1251
1264
|
continue;
|
|
1252
1265
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
if (regex) {
|
|
1257
|
-
i = regex.end;
|
|
1266
|
+
if (ch === LESS_THAN && !frame.closing && jsxStartsAt(src, i)) {
|
|
1267
|
+
stack.push({ kind: "tag", closing: false });
|
|
1268
|
+
i++;
|
|
1258
1269
|
continue;
|
|
1259
1270
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1266
|
-
if (ch === BACKTICK) {
|
|
1267
|
-
i = maskTemplate(src, out, i, false);
|
|
1268
|
-
valueEnded(lexical);
|
|
1269
|
-
continue;
|
|
1270
|
-
}
|
|
1271
|
-
i = advanceCode(src, i, lexical);
|
|
1272
|
-
}
|
|
1273
|
-
return stringOf(out);
|
|
1274
|
-
}
|
|
1275
|
-
function maskJsNoise(src) {
|
|
1276
|
-
const length = src.length;
|
|
1277
|
-
const out = codesOf(src);
|
|
1278
|
-
let i = 0;
|
|
1279
|
-
const lexical = regexContext();
|
|
1280
|
-
while (i < length) {
|
|
1281
|
-
const ch = src.charCodeAt(i);
|
|
1282
|
-
if (ch === SLASH) {
|
|
1283
|
-
const next = src.charCodeAt(i + 1);
|
|
1284
|
-
if (next === SLASH) {
|
|
1285
|
-
const end = src.indexOf("\n", i);
|
|
1286
|
-
const stop = end === -1 ? length : end;
|
|
1287
|
-
blank(out, i, stop);
|
|
1288
|
-
i = stop;
|
|
1271
|
+
if (ch === SLASH && src.charCodeAt(i + 1) === GREATER_THAN && !frame.closing) {
|
|
1272
|
+
stack.pop();
|
|
1273
|
+
elementClosed(i + 2);
|
|
1274
|
+
i += 2;
|
|
1289
1275
|
continue;
|
|
1290
1276
|
}
|
|
1291
|
-
if (
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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++;
|
|
1296
1286
|
continue;
|
|
1297
1287
|
}
|
|
1288
|
+
i++;
|
|
1289
|
+
continue;
|
|
1298
1290
|
}
|
|
1299
1291
|
if (ch === SLASH) {
|
|
1300
|
-
const
|
|
1292
|
+
const next = src.charCodeAt(i + 1);
|
|
1293
|
+
if (next === SLASH || next === STAR) {
|
|
1294
|
+
i = skipComment(i);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const regex = regexAt(src, i, frame.lexical, state);
|
|
1301
1298
|
if (regex) {
|
|
1302
|
-
|
|
1299
|
+
text(i + 1, regex.close);
|
|
1303
1300
|
i = regex.end;
|
|
1304
1301
|
continue;
|
|
1305
1302
|
}
|
|
1306
1303
|
}
|
|
1307
1304
|
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1308
1305
|
const stop = endOfString(src, i, ch);
|
|
1309
|
-
|
|
1310
|
-
valueEnded(lexical);
|
|
1306
|
+
text(i + 1, stop - 1);
|
|
1307
|
+
valueEnded(frame.lexical);
|
|
1311
1308
|
i = stop;
|
|
1312
1309
|
continue;
|
|
1313
1310
|
}
|
|
1314
1311
|
if (ch === BACKTICK) {
|
|
1315
|
-
|
|
1316
|
-
|
|
1312
|
+
stack.push({ kind: "literal", from: i + 1 });
|
|
1313
|
+
i++;
|
|
1317
1314
|
continue;
|
|
1318
1315
|
}
|
|
1319
|
-
|
|
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);
|
|
1320
1334
|
}
|
|
1335
|
+
const last = stack[stack.length - 1];
|
|
1336
|
+
if (last?.kind === "literal" || last?.kind === "children") text(last.from, length);
|
|
1321
1337
|
return stringOf(out);
|
|
1322
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) });
|
|
1323
1347
|
var commentCache = /* @__PURE__ */ new WeakMap();
|
|
1324
1348
|
var noiseCache = /* @__PURE__ */ new WeakMap();
|
|
1325
1349
|
function commentsMaskedOf(file) {
|
|
1326
1350
|
const hit = commentCache.get(file);
|
|
1327
1351
|
if (hit !== void 0) return hit;
|
|
1328
|
-
const masked = maskJsComments(file.content);
|
|
1352
|
+
const masked = maskJsComments(file.content, maskOptionsOf(file));
|
|
1329
1353
|
commentCache.set(file, masked);
|
|
1330
1354
|
return masked;
|
|
1331
1355
|
}
|
|
1332
1356
|
function noiseMaskedOf(file) {
|
|
1333
1357
|
const hit = noiseCache.get(file);
|
|
1334
1358
|
if (hit !== void 0) return hit;
|
|
1335
|
-
const masked = maskJsNoise(file.content);
|
|
1359
|
+
const masked = maskJsNoise(file.content, maskOptionsOf(file));
|
|
1336
1360
|
noiseCache.set(file, masked);
|
|
1337
1361
|
return masked;
|
|
1338
1362
|
}
|
|
@@ -1638,7 +1662,7 @@ function checkEnvFile(file) {
|
|
|
1638
1662
|
`For anything the browser needs, use the anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY) together with Row Level Security policies.`
|
|
1639
1663
|
],
|
|
1640
1664
|
humanOnly: [
|
|
1641
|
-
`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.`
|
|
1642
1666
|
]
|
|
1643
1667
|
});
|
|
1644
1668
|
continue;
|
|
@@ -1720,7 +1744,8 @@ function pushServiceRoleJwts(findings, file, line, i, clientSide) {
|
|
|
1720
1744
|
`Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
|
|
1721
1745
|
],
|
|
1722
1746
|
humanOnly: [
|
|
1723
|
-
|
|
1747
|
+
// 与公开变量中的同类结果使用相同首句,汇总时合并为一个轮换步骤。
|
|
1748
|
+
`Rotate the Supabase service_role key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
|
|
1724
1749
|
]
|
|
1725
1750
|
});
|
|
1726
1751
|
}
|
|
@@ -1974,8 +1999,9 @@ var gitleakRule = {
|
|
|
1974
1999
|
],
|
|
1975
2000
|
fix: [`Add ${path} to .gitignore.`, untrackStep(path)],
|
|
1976
2001
|
humanOnly: [
|
|
1977
|
-
|
|
1978
|
-
`
|
|
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.`
|
|
1979
2005
|
]
|
|
1980
2006
|
});
|
|
1981
2007
|
reportedTracked.add(path);
|
|
@@ -2017,8 +2043,8 @@ var gitleakRule = {
|
|
|
2017
2043
|
],
|
|
2018
2044
|
fix: stillTracked ? [`Add ${path} to .gitignore.`, untrackStep(path)] : [`Confirm ${path} is in .gitignore so it does not come back.`],
|
|
2019
2045
|
humanOnly: [
|
|
2020
|
-
`Rotate every credential that was ever in
|
|
2021
|
-
`
|
|
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.`
|
|
2022
2048
|
]
|
|
2023
2049
|
});
|
|
2024
2050
|
}
|
|
@@ -2648,8 +2674,8 @@ var supabaseRlsRule = {
|
|
|
2648
2674
|
`Keep this in a migration rather than only in the dashboard, so the rule travels with your code.`
|
|
2649
2675
|
],
|
|
2650
2676
|
humanOnly: [
|
|
2651
|
-
|
|
2652
|
-
`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.`
|
|
2653
2679
|
]
|
|
2654
2680
|
});
|
|
2655
2681
|
}
|
|
@@ -2739,14 +2765,15 @@ function policyFindings(ctx, policies, live, keyOf, buckets) {
|
|
|
2739
2765
|
ruleId: "supabase/permissive-policy",
|
|
2740
2766
|
severity: "P1",
|
|
2741
2767
|
confidence: modifiesAny && !needsReview ? "certain" : "likely",
|
|
2742
|
-
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}`,
|
|
2743
2769
|
file: p.file,
|
|
2744
2770
|
line: p.line,
|
|
2745
2771
|
excerpt: excerpt(p.file, p.line),
|
|
2746
2772
|
why: [
|
|
2747
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.`,
|
|
2748
2774
|
...hasRestriction ? ["A restrictive policy on this table may limit access; verify its command and role coverage."] : [],
|
|
2749
|
-
|
|
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."] : [],
|
|
2750
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.`] : []
|
|
2751
2778
|
],
|
|
2752
2779
|
fix: [
|
|
@@ -3108,6 +3135,15 @@ function namePattern(names) {
|
|
|
3108
3135
|
}
|
|
3109
3136
|
|
|
3110
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
|
+
}
|
|
3111
3147
|
var limited = /* @__PURE__ */ new WeakSet();
|
|
3112
3148
|
var cache2 = /* @__PURE__ */ new WeakMap();
|
|
3113
3149
|
var reverseCache = /* @__PURE__ */ new WeakMap();
|
|
@@ -3305,8 +3341,10 @@ var AuthValues = class {
|
|
|
3305
3341
|
return { kind: "opaque" };
|
|
3306
3342
|
}
|
|
3307
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" };
|
|
3308
3346
|
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);
|
|
3347
|
+
const call = /^(?:await\s+)?((?:[A-Za-z_$][\w$]*\.)*(?:getUser|getClaims|getUserSession|getSession|getServerSession|safeGetSession|currentUser|verifyIdToken|auth))\s*\(/.exec(expr);
|
|
3310
3348
|
const callee = call?.[1];
|
|
3311
3349
|
const identityCall = callee && !/\.(?:body|query|headers|cookies)\b/.test(callee);
|
|
3312
3350
|
if (identityCall) {
|
|
@@ -3319,7 +3357,10 @@ var AuthValues = class {
|
|
|
3319
3357
|
}
|
|
3320
3358
|
if (depth2 !== 0 || masked.slice(end).trim() !== "") return { kind: "opaque" };
|
|
3321
3359
|
if (/\|\||&&|\?(?!\.)|\.then\s*\(/.test(expr) || /\.catch\s*\(/.test(expr)) return { kind: "opaque" };
|
|
3322
|
-
|
|
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" };
|
|
3323
3364
|
}
|
|
3324
3365
|
if (/\.(?:body|query|headers|cookies|searchParams)\b|\.(?:json|text|formData)\s*\(/.test(expr)) return { kind: "input" };
|
|
3325
3366
|
const access = /^([A-Za-z_$][\w$]*)((?:\??\.[A-Za-z_$][\w$]*)*)$/.exec(expr);
|
|
@@ -3341,14 +3382,10 @@ var AuthValues = class {
|
|
|
3341
3382
|
}
|
|
3342
3383
|
if (this.conditions.some((range) => range.start <= assignment.at && assignment.at <= range.end && !(range.start <= at && at <= range.end))) return { kind: "opaque" };
|
|
3343
3384
|
value = this.value(assignment.expression, assignment.at, depth + 1);
|
|
3344
|
-
|
|
3345
|
-
else if (assignment.projection.length && value.kind === "envelope") value = {
|
|
3346
|
-
kind: assignment.projection.join(".") === "data.user" ? "identity" : "opaque"
|
|
3347
|
-
};
|
|
3385
|
+
value = assignment.projection === null ? { kind: "opaque" } : project(value, assignment.projection);
|
|
3348
3386
|
} else if (this.parameters.has(name)) value = { kind: "parameter", parameters: [this.parameters.get(name)] };
|
|
3349
3387
|
else value = { kind: "unknown" };
|
|
3350
|
-
|
|
3351
|
-
return value;
|
|
3388
|
+
return project(value, access[2].split(/\??\./).filter(Boolean));
|
|
3352
3389
|
}
|
|
3353
3390
|
};
|
|
3354
3391
|
function identityRequirement(value, allowParameters) {
|
|
@@ -3574,6 +3611,8 @@ var ACCESS_CHAIN = String.raw`[\w$]+(?:\??\.[\w$]+)*`;
|
|
|
3574
3611
|
var NEGATED_IDENTITY = new RegExp(`^!\\s*${ACCESS_CHAIN}(?:\\s*\\([^=]*\\))?$`);
|
|
3575
3612
|
var IDENTITY_IS_EMPTY = new RegExp(`^${ACCESS_CHAIN}\\s*={2,3}\\s*(?:null|undefined|false)$`);
|
|
3576
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}$`);
|
|
3577
3616
|
var STOPS_REQUEST = /^(?:(?:return|throw|redirect|notFound)\b|error\s*\(\s*40[13]\b)/;
|
|
3578
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;
|
|
3579
3618
|
function closingDelimiter(source, start, open, close) {
|
|
@@ -3631,7 +3670,7 @@ function hasConditionalAuthGuard(code, requireThrow = false, accept) {
|
|
|
3631
3670
|
const returnsDeniedStatus = DENIED_STATUS.test(statement);
|
|
3632
3671
|
const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => {
|
|
3633
3672
|
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);
|
|
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);
|
|
3635
3674
|
});
|
|
3636
3675
|
if (negative) return true;
|
|
3637
3676
|
}
|
|
@@ -3735,7 +3774,7 @@ function unguardedOpsOf(file) {
|
|
|
3735
3774
|
return hit;
|
|
3736
3775
|
}
|
|
3737
3776
|
function operationsInRange(ops, start, end) {
|
|
3738
|
-
const
|
|
3777
|
+
const lowerBound2 = (value) => {
|
|
3739
3778
|
let low = 0, high = ops.length;
|
|
3740
3779
|
while (low < high) {
|
|
3741
3780
|
const middle = low + high >>> 1;
|
|
@@ -3744,7 +3783,7 @@ function operationsInRange(ops, start, end) {
|
|
|
3744
3783
|
}
|
|
3745
3784
|
return low;
|
|
3746
3785
|
};
|
|
3747
|
-
return ops.slice(
|
|
3786
|
+
return ops.slice(lowerBound2(start + 1), lowerBound2(end));
|
|
3748
3787
|
}
|
|
3749
3788
|
var lineStartsCache = /* @__PURE__ */ new WeakMap();
|
|
3750
3789
|
function lineStartsCached(file) {
|
|
@@ -3755,12 +3794,12 @@ function lineStartsCached(file) {
|
|
|
3755
3794
|
}
|
|
3756
3795
|
return hit;
|
|
3757
3796
|
}
|
|
3758
|
-
function cachedByFile(
|
|
3797
|
+
function cachedByFile(check2) {
|
|
3759
3798
|
const cache3 = /* @__PURE__ */ new WeakMap();
|
|
3760
3799
|
return (file) => {
|
|
3761
3800
|
let hit = cache3.get(file);
|
|
3762
3801
|
if (hit === void 0) {
|
|
3763
|
-
hit =
|
|
3802
|
+
hit = check2(file);
|
|
3764
3803
|
cache3.set(file, hit);
|
|
3765
3804
|
}
|
|
3766
3805
|
return hit;
|
|
@@ -4800,6 +4839,10 @@ var apiAuthRule = {
|
|
|
4800
4839
|
const globalNote = globalGuard === null ? [] : [
|
|
4801
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}.`
|
|
4802
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
|
+
);
|
|
4803
4846
|
const admin = usesAdminClient(route, adminModules, ctx.files);
|
|
4804
4847
|
const hit = ops.find((o) => o.writes) ?? ops[0];
|
|
4805
4848
|
const indirect = indirectOps.get(hit.index);
|
|
@@ -5258,138 +5301,1565 @@ var corsRule = {
|
|
|
5258
5301
|
}
|
|
5259
5302
|
};
|
|
5260
5303
|
|
|
5261
|
-
// src/rules/
|
|
5262
|
-
var
|
|
5263
|
-
var
|
|
5264
|
-
var
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
"
|
|
5272
|
-
"
|
|
5273
|
-
"
|
|
5274
|
-
"
|
|
5275
|
-
"
|
|
5276
|
-
"
|
|
5277
|
-
"
|
|
5278
|
-
"
|
|
5279
|
-
|
|
5280
|
-
|
|
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/
|
|
5281
5358
|
];
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
const
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
function dedupe(findings) {
|
|
5301
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5302
|
-
const out = [];
|
|
5303
|
-
for (const f of findings) {
|
|
5304
|
-
const key = `${f.ruleId}|${f.file ?? ""}|${f.line ?? ""}|${f.excerpt ?? ""}|${f.title}`;
|
|
5305
|
-
if (seen.has(key)) continue;
|
|
5306
|
-
seen.add(key);
|
|
5307
|
-
out.push(f);
|
|
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
|
+
}
|
|
5308
5377
|
}
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
var CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
5312
|
-
var DECEPTIVE_CHARS = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
5313
|
-
function nameOf(ch) {
|
|
5314
|
-
return `<U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}>`;
|
|
5315
|
-
}
|
|
5316
|
-
function clean(text) {
|
|
5317
|
-
return redactAll(text).replace(/\t/g, " ").replace(CONTROL_CHARS, "").replace(DECEPTIVE_CHARS, nameOf);
|
|
5378
|
+
parts.push(text.slice(from));
|
|
5379
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
5318
5380
|
}
|
|
5319
|
-
function
|
|
5320
|
-
|
|
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;
|
|
5321
5389
|
}
|
|
5322
|
-
function
|
|
5323
|
-
const
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
})),
|
|
5350
|
-
evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
|
|
5351
|
-
} : {},
|
|
5352
|
-
fix: f.fix.map(clean),
|
|
5353
|
-
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
5354
|
-
}));
|
|
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;
|
|
5355
5417
|
}
|
|
5356
|
-
function
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
}
|
|
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;
|
|
5362
5425
|
}
|
|
5363
|
-
function
|
|
5364
|
-
const
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
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;
|
|
5368
5442
|
}
|
|
5369
|
-
function
|
|
5370
|
-
const
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
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;
|
|
5377
5454
|
continue;
|
|
5378
5455
|
}
|
|
5379
|
-
|
|
5380
|
-
if (
|
|
5381
|
-
const
|
|
5382
|
-
|
|
5383
|
-
|
|
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;
|
|
5384
5463
|
}
|
|
5385
|
-
|
|
5386
|
-
|
|
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);
|
|
5387
5482
|
continue;
|
|
5388
5483
|
}
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
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, executable2 = 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: executable2 ? `${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
|
+
executable2 ? `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
|
+
executable2 ? "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
|
+
|
|
6725
|
+
// src/rules/index.ts
|
|
6726
|
+
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule, sessionRule, webhookRule];
|
|
6727
|
+
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule, injectionRule, ssrfRule, redirectRule];
|
|
6728
|
+
var RULE_IDS = [
|
|
6729
|
+
"api/admin-db-access-without-auth",
|
|
6730
|
+
"api/db-write-without-auth",
|
|
6731
|
+
"auth/unverified-session",
|
|
6732
|
+
"cors/reflected-origin-with-credentials",
|
|
6733
|
+
"cors/wildcard-with-credentials",
|
|
6734
|
+
"exposure/private-name-in-public-env",
|
|
6735
|
+
"exposure/secret-in-public-env",
|
|
6736
|
+
"exposure/supabase-service-role-in-client",
|
|
6737
|
+
"firebase/open-rules",
|
|
6738
|
+
"firebase/test-mode-rules",
|
|
6739
|
+
"gitleak/env-in-history",
|
|
6740
|
+
"gitleak/env-tracked",
|
|
6741
|
+
"injection/command",
|
|
6742
|
+
"injection/sql",
|
|
6743
|
+
"redirect/open",
|
|
6744
|
+
"ssrf/request-url",
|
|
6745
|
+
"supabase/permissive-policy",
|
|
6746
|
+
"supabase/public-bucket-listing",
|
|
6747
|
+
"supabase/rls-not-enabled",
|
|
6748
|
+
"webhook/unverified-signature",
|
|
6749
|
+
// 凭据规则 ID 从共享格式表生成。
|
|
6750
|
+
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
6751
|
+
];
|
|
6752
|
+
function ruleMatches(selector, ruleId) {
|
|
6753
|
+
return ruleId === selector || ruleId.startsWith(`${selector}/`);
|
|
6754
|
+
}
|
|
6755
|
+
function isKnownSelector(selector) {
|
|
6756
|
+
return RULE_IDS.some((id) => ruleMatches(selector, id));
|
|
6757
|
+
}
|
|
6758
|
+
function shouldRunRule(id, only, skip) {
|
|
6759
|
+
if (only.length === 0 && skip.length === 0) return true;
|
|
6760
|
+
const namespace = id.split("/")[0];
|
|
6761
|
+
return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
|
|
6762
|
+
(candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
|
|
6763
|
+
);
|
|
6764
|
+
}
|
|
6765
|
+
|
|
6766
|
+
// src/engine.ts
|
|
6767
|
+
import { createHash as createHash2 } from "crypto";
|
|
6768
|
+
var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
|
|
6769
|
+
var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
|
|
6770
|
+
function dedupe(findings) {
|
|
6771
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6772
|
+
const out = [];
|
|
6773
|
+
for (const f of findings) {
|
|
6774
|
+
const key = `${f.ruleId}|${f.file ?? ""}|${f.line ?? ""}|${f.excerpt ?? ""}|${f.title}`;
|
|
6775
|
+
if (seen.has(key)) continue;
|
|
6776
|
+
seen.add(key);
|
|
6777
|
+
out.push(f);
|
|
6778
|
+
}
|
|
6779
|
+
return out;
|
|
6780
|
+
}
|
|
6781
|
+
var CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
6782
|
+
var DECEPTIVE_CHARS = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
6783
|
+
function nameOf(ch) {
|
|
6784
|
+
return `<U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}>`;
|
|
6785
|
+
}
|
|
6786
|
+
function clean(text) {
|
|
6787
|
+
return redactAll(text).replace(/\t/g, " ").replace(CONTROL_CHARS, "").replace(DECEPTIVE_CHARS, nameOf);
|
|
6788
|
+
}
|
|
6789
|
+
function cleanForOutput(text) {
|
|
6790
|
+
return clean(text);
|
|
6791
|
+
}
|
|
6792
|
+
function sanitize(findings, files) {
|
|
6793
|
+
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
6794
|
+
const sourceIdentity = (f) => {
|
|
6795
|
+
if (f.sourceFingerprint !== void 0) return { sourceFingerprint: f.sourceFingerprint };
|
|
6796
|
+
const file = f.file === null ? void 0 : byPath.get(f.file);
|
|
6797
|
+
const source = f.line === null ? file?.content : file?.lines[f.line - 1];
|
|
6798
|
+
return source === void 0 ? {} : {
|
|
6799
|
+
sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
|
|
6800
|
+
};
|
|
6801
|
+
};
|
|
6802
|
+
return findings.map((f) => ({
|
|
6803
|
+
...f,
|
|
6804
|
+
// 仅输出摘要;原始行不进入报告,移动行号不改变身份。
|
|
6805
|
+
...sourceIdentity(f),
|
|
6806
|
+
title: clean(f.title),
|
|
6807
|
+
// 按段落清理,保留段落之间的结构。
|
|
6808
|
+
why: f.why.map(clean),
|
|
6809
|
+
// 文件路径也必须经过输出清理。
|
|
6810
|
+
file: f.file === null ? null : clean(f.file),
|
|
6811
|
+
// 先脱敏再截断,避免截断导致凭据特征失效。
|
|
6812
|
+
excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
|
|
6813
|
+
...f.evidence ? {
|
|
6814
|
+
evidence: f.evidence.slice(0, 24).map((step) => ({
|
|
6815
|
+
kind: step.kind,
|
|
6816
|
+
file: clean(step.file),
|
|
6817
|
+
line: step.line,
|
|
6818
|
+
description: clean(step.description)
|
|
6819
|
+
})),
|
|
6820
|
+
evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
|
|
6821
|
+
} : {},
|
|
6822
|
+
fix: f.fix.map(clean),
|
|
6823
|
+
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
6824
|
+
}));
|
|
6825
|
+
}
|
|
6826
|
+
function sanitizeSkippedForOutput(items) {
|
|
6827
|
+
return items.map((item) => ({
|
|
6828
|
+
...item,
|
|
6829
|
+
path: clean(item.path),
|
|
6830
|
+
...item.detail === void 0 ? {} : { detail: clean(item.detail) }
|
|
6831
|
+
}));
|
|
6832
|
+
}
|
|
6833
|
+
function downgradeExampleContext(findings, files) {
|
|
6834
|
+
const examples = new Set(files.filter((f) => f.isExampleContext).map((f) => f.path));
|
|
6835
|
+
return findings.map(
|
|
6836
|
+
(f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
|
|
6837
|
+
);
|
|
6838
|
+
}
|
|
6839
|
+
function suppressIgnoredLines(findings, files) {
|
|
6840
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
6841
|
+
const markers = /* @__PURE__ */ new Map();
|
|
6842
|
+
const kept = [];
|
|
6843
|
+
const ignored = [];
|
|
6844
|
+
for (const f of findings) {
|
|
6845
|
+
if (f.file === null || f.line === null) {
|
|
6846
|
+
kept.push(f);
|
|
6847
|
+
continue;
|
|
6848
|
+
}
|
|
6849
|
+
let lines = markers.get(f.file);
|
|
6850
|
+
if (lines === void 0) {
|
|
6851
|
+
const file = byPath.get(f.file);
|
|
6852
|
+
lines = file === void 0 ? /* @__PURE__ */ new Map() : ignoredLinesOf(file.lines);
|
|
6853
|
+
markers.set(f.file, lines);
|
|
6854
|
+
}
|
|
6855
|
+
if (!lines.has(f.line)) {
|
|
6856
|
+
kept.push(f);
|
|
6857
|
+
continue;
|
|
6858
|
+
}
|
|
6859
|
+
const rules = lines.get(f.line);
|
|
6860
|
+
if (rules !== null && rules !== void 0 && !rules.has(f.ruleId)) {
|
|
6861
|
+
kept.push(f);
|
|
6862
|
+
continue;
|
|
5393
6863
|
}
|
|
5394
6864
|
ignored.push({ file: f.file, line: f.line, ruleId: f.ruleId });
|
|
5395
6865
|
}
|
|
@@ -5549,196 +7019,319 @@ var SKIP_LABEL = {
|
|
|
5549
7019
|
function skipPhrase(reason) {
|
|
5550
7020
|
return SKIP_LABEL[reason].because;
|
|
5551
7021
|
}
|
|
7022
|
+
var CATEGORIES = ["Credentials", "API access", "Database rules", "CORS", "Code", "Other"];
|
|
7023
|
+
var SEVERITIES = ["P0", "P1", "P2"];
|
|
7024
|
+
function categoryOf(ruleId) {
|
|
7025
|
+
const namespace = ruleId.split("/")[0];
|
|
7026
|
+
if (namespace === "secrets" || namespace === "exposure" || namespace === "gitleak") return "Credentials";
|
|
7027
|
+
if (namespace === "api" || namespace === "auth" || namespace === "webhook") return "API access";
|
|
7028
|
+
if (namespace === "supabase" || namespace === "firebase") return "Database rules";
|
|
7029
|
+
if (namespace === "cors") return "CORS";
|
|
7030
|
+
if (namespace === "injection" || namespace === "ssrf" || namespace === "redirect") return "Code";
|
|
7031
|
+
return "Other";
|
|
7032
|
+
}
|
|
7033
|
+
function categoryCounts(findings) {
|
|
7034
|
+
return CATEGORIES.map((category) => {
|
|
7035
|
+
const counts = { P0: 0, P1: 0, P2: 0 };
|
|
7036
|
+
for (const f of findings) if (categoryOf(f.ruleId) === category) counts[f.severity]++;
|
|
7037
|
+
return { category, counts, total: counts.P0 + counts.P1 + counts.P2 };
|
|
7038
|
+
}).filter((row) => row.total > 0);
|
|
7039
|
+
}
|
|
7040
|
+
function groupByFile(findings) {
|
|
7041
|
+
const groups = /* @__PURE__ */ new Map();
|
|
7042
|
+
for (const f of findings) {
|
|
7043
|
+
const list = groups.get(f.file) ?? [];
|
|
7044
|
+
list.push(f);
|
|
7045
|
+
groups.set(f.file, list);
|
|
7046
|
+
}
|
|
7047
|
+
const rank = (list) => Math.min(...list.map((f) => SEVERITIES.indexOf(f.severity)));
|
|
7048
|
+
return [...groups].map(([file, list]) => ({
|
|
7049
|
+
file,
|
|
7050
|
+
findings: [...list].sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity))
|
|
7051
|
+
})).sort((a, b) => rank(a.findings) - rank(b.findings) || b.findings.length - a.findings.length || (a.file ?? "").localeCompare(b.file ?? ""));
|
|
7052
|
+
}
|
|
7053
|
+
function firstSentence(text) {
|
|
7054
|
+
const cut = /\.\s+(?=[A-Z"'(])|\s+—\s+/.exec(text);
|
|
7055
|
+
const sentence = cut ? text.slice(0, cut.index + (cut[0].startsWith(".") ? 1 : 0)) : text;
|
|
7056
|
+
return sentence.trim().replace(/\.$/, "");
|
|
7057
|
+
}
|
|
7058
|
+
function manualSteps(findings) {
|
|
7059
|
+
const steps = /* @__PURE__ */ new Map();
|
|
7060
|
+
for (const f of findings) {
|
|
7061
|
+
for (const item of f.humanOnly ?? []) {
|
|
7062
|
+
const text = firstSentence(item);
|
|
7063
|
+
if (!text) continue;
|
|
7064
|
+
const step = steps.get(text) ?? { text, locations: [], severity: f.severity };
|
|
7065
|
+
const location = locationOf(f);
|
|
7066
|
+
if (!step.locations.includes(location)) step.locations.push(location);
|
|
7067
|
+
if (SEVERITIES.indexOf(f.severity) < SEVERITIES.indexOf(step.severity)) step.severity = f.severity;
|
|
7068
|
+
steps.set(text, step);
|
|
7069
|
+
}
|
|
7070
|
+
}
|
|
7071
|
+
return [...steps.values()].sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
7072
|
+
}
|
|
5552
7073
|
|
|
5553
7074
|
// src/report/terminal.ts
|
|
5554
|
-
var
|
|
5555
|
-
function
|
|
5556
|
-
const
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
7075
|
+
var DETAIL = " ".repeat(12);
|
|
7076
|
+
function widthOf(opts) {
|
|
7077
|
+
const columns = opts.width ?? (process.stdout.isTTY ? process.stdout.columns : void 0) ?? 96;
|
|
7078
|
+
return Math.max(60, Math.min(120, columns));
|
|
7079
|
+
}
|
|
7080
|
+
function severityColor(severity) {
|
|
7081
|
+
return severity === "P0" ? red : severity === "P1" ? yellow : (s) => s;
|
|
7082
|
+
}
|
|
7083
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
7084
|
+
var visibleLength = (s) => s.replace(ANSI, "").length;
|
|
7085
|
+
function joinFitting(items, separator, width) {
|
|
7086
|
+
const lines = [];
|
|
7087
|
+
let line = "";
|
|
7088
|
+
for (const item of items) {
|
|
7089
|
+
const next = line === "" ? item : `${line}${separator}${item}`;
|
|
7090
|
+
if (line !== "" && visibleLength(next) > width) {
|
|
7091
|
+
lines.push(line);
|
|
7092
|
+
line = item;
|
|
7093
|
+
} else {
|
|
7094
|
+
line = next;
|
|
7095
|
+
}
|
|
5572
7096
|
}
|
|
5573
|
-
|
|
7097
|
+
if (line !== "") lines.push(line);
|
|
7098
|
+
return lines;
|
|
5574
7099
|
}
|
|
7100
|
+
var pad = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
7101
|
+
var rpad = (s, n) => s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
5575
7102
|
function renderReport(result, opts) {
|
|
5576
|
-
const
|
|
5577
|
-
const
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
out.push(`${
|
|
5582
|
-
|
|
7103
|
+
const width = widthOf(opts);
|
|
7104
|
+
const out = [];
|
|
7105
|
+
const rule2 = () => {
|
|
7106
|
+
out.push(dim("\u2500".repeat(width)));
|
|
7107
|
+
};
|
|
7108
|
+
out.push(`${bold("canship")}${opts.version ? ` ${opts.version}` : ""} ${dim("\xB7")} ${opts.root}`);
|
|
7109
|
+
out.push("\u2500".repeat(width));
|
|
5583
7110
|
out.push("");
|
|
7111
|
+
const { findings } = result;
|
|
5584
7112
|
if (findings.length === 0) {
|
|
5585
|
-
out.push(...renderClean(result, opts));
|
|
7113
|
+
out.push(...renderClean(result, opts, width));
|
|
7114
|
+
out.push(...renderNext(opts, width));
|
|
5586
7115
|
return out.join("\n");
|
|
5587
7116
|
}
|
|
5588
|
-
|
|
5589
|
-
const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
|
|
5590
|
-
if (blocking > 0) {
|
|
5591
|
-
out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
|
|
5592
|
-
} else if (confirmedMinor > 0) {
|
|
5593
|
-
out.push(
|
|
5594
|
-
`${INDENT}${yellow(bold(`! ${confirmedMinor} ${plural(confirmedMinor, "thing")} to fix \u2014 nothing exposed`))}`
|
|
5595
|
-
);
|
|
5596
|
-
} else {
|
|
5597
|
-
out.push(`${INDENT}${yellow(bold(`! ${unsure} possible ${plural(unsure, "issue")} to review`))}`);
|
|
5598
|
-
}
|
|
7117
|
+
out.push(...renderVerdict(result, opts, width));
|
|
5599
7118
|
out.push("");
|
|
5600
|
-
|
|
5601
|
-
out.push(...renderFinding(f, i + 1));
|
|
5602
|
-
out.push("");
|
|
5603
|
-
});
|
|
5604
|
-
out.push(`${INDENT}${gray("\u2500".repeat(60))}`);
|
|
7119
|
+
rule2();
|
|
5605
7120
|
out.push("");
|
|
5606
|
-
|
|
5607
|
-
|
|
7121
|
+
out.push(...renderSummary(findings));
|
|
7122
|
+
const steps = renderManualSteps(findings, width);
|
|
7123
|
+
if (steps.length > 0) out.push("", ...steps);
|
|
7124
|
+
out.push("");
|
|
7125
|
+
rule2();
|
|
7126
|
+
out.push("");
|
|
7127
|
+
out.push(`${bold("Findings")}${dim(" grouped by file \xB7 most severe first")}`);
|
|
7128
|
+
out.push("");
|
|
7129
|
+
for (const group of groupByFile(findings)) {
|
|
7130
|
+
out.push(`${group.file ?? "repository"}${dim(` \u2014 ${group.findings.length}`)}`);
|
|
7131
|
+
for (const f of group.findings) out.push(...renderFinding(f, width, opts.verbose === true));
|
|
5608
7132
|
out.push("");
|
|
5609
7133
|
}
|
|
5610
|
-
|
|
5611
|
-
|
|
7134
|
+
rule2();
|
|
7135
|
+
const notes = [...renderIncomplete(result, width), ...renderIgnored(result), ...renderBaseline(opts)];
|
|
5612
7136
|
if (!opts.showingLikely && opts.hiddenLikely > 0) {
|
|
5613
|
-
|
|
5614
|
-
`${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`
|
|
5615
|
-
);
|
|
7137
|
+
notes.push(dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`));
|
|
5616
7138
|
}
|
|
5617
|
-
|
|
5618
|
-
out.push(
|
|
7139
|
+
if (notes.length > 0) out.push(...notes, "");
|
|
7140
|
+
out.push(...renderNext(opts, width));
|
|
5619
7141
|
return out.join("\n");
|
|
5620
7142
|
}
|
|
5621
|
-
function
|
|
7143
|
+
function renderVerdict(result, opts, width) {
|
|
7144
|
+
const { findings } = result;
|
|
7145
|
+
const { blocking: visibleBlocking, minor, unsure } = verdictOf(findings);
|
|
7146
|
+
const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
|
|
7147
|
+
const rotations = manualSteps(findings).filter((step) => /^Rotate\b/.test(step.text)).length;
|
|
5622
7148
|
const out = [];
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
if (
|
|
5628
|
-
out.push("");
|
|
5629
|
-
|
|
5630
|
-
}
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
for (const step of f.evidence) out.push(`${INDENT}${INDENT} ${locationOf(step)} \u2014 ${step.description}`);
|
|
5634
|
-
if (f.evidenceTruncated) out.push(`${INDENT}${INDENT} Additional dependency steps omitted.`);
|
|
7149
|
+
let explanation;
|
|
7150
|
+
if (blocking > 0) {
|
|
7151
|
+
out.push(`${red(bold(`${blocking} blocking ${plural(blocking, "finding")}.`))} ${bold("Do not deploy yet.")}`);
|
|
7152
|
+
explanation = "Every certain P0 or P1 result below needs a code change." + (rotations > 0 ? ` ${rotations} exposed ${plural(rotations, "credential")} must also be rotated in ${rotations === 1 ? "its provider" : "their providers"}; a code change does not revoke ${rotations === 1 ? "it" : "them"}.` : "");
|
|
7153
|
+
} else if (minor > 0) {
|
|
7154
|
+
out.push(`${yellow(bold(`${minor} ${plural(minor, "finding")} to fix.`))} ${bold("Nothing blocking.")}`);
|
|
7155
|
+
explanation = "No certain P0 or P1 result was found; the findings below are lower severity.";
|
|
7156
|
+
} else {
|
|
7157
|
+
out.push(`${yellow(bold(`${unsure} ${plural(unsure, "finding")} to review.`))}`);
|
|
7158
|
+
explanation = "These are likely findings: the static evidence is not conclusive, so check each one.";
|
|
5635
7159
|
}
|
|
7160
|
+
for (const line of wrapText(explanation, width)) out.push(dim(line));
|
|
5636
7161
|
out.push("");
|
|
5637
|
-
|
|
5638
|
-
|
|
7162
|
+
const facts = [
|
|
7163
|
+
`${dim("findings")} ${findings.length} ${dim("shown")}${opts.hiddenLikely > 0 && !opts.showingLikely ? dim(`, ${opts.hiddenLikely} likely hidden (--all)`) : ""}`,
|
|
7164
|
+
`${dim("files scanned")} ${result.filesScanned}`,
|
|
7165
|
+
`${dim("coverage")} ${result.partial ? yellow("incomplete") : "complete"}`,
|
|
7166
|
+
`${result.durationMs} ${dim("ms")}`
|
|
7167
|
+
];
|
|
7168
|
+
out.push(...joinFitting(facts, dim(" \xB7 "), width));
|
|
7169
|
+
if (result.changeView) for (const line of wrapText(changeViewNotice(result.changeView), width)) out.push(yellow(line));
|
|
7170
|
+
return out;
|
|
7171
|
+
}
|
|
7172
|
+
function renderSummary(findings) {
|
|
7173
|
+
const rows = categoryCounts(findings);
|
|
7174
|
+
const out = [bold("By category"), dim(pad("", 22) + SEVERITIES.map((s) => rpad(s, 5)).join("") + rpad("total", 8))];
|
|
7175
|
+
for (const row of rows) {
|
|
7176
|
+
out.push(pad(row.category, 22) + SEVERITIES.map((s) => row.counts[s] ? severityColor(s)(rpad(String(row.counts[s]), 5)) : dim(rpad("\u2013", 5))).join("") + rpad(String(row.total), 8));
|
|
7177
|
+
}
|
|
7178
|
+
const totals = SEVERITIES.map((s) => findings.filter((f) => f.severity === s).length);
|
|
7179
|
+
out.push(dim(pad("all", 22) + totals.map((n) => rpad(String(n), 5)).join("") + rpad(String(findings.length), 8)));
|
|
7180
|
+
return out;
|
|
7181
|
+
}
|
|
7182
|
+
function renderManualSteps(findings, width) {
|
|
7183
|
+
const steps = manualSteps(findings);
|
|
7184
|
+
if (steps.length === 0) return [];
|
|
7185
|
+
const out = [bold("Manual steps")];
|
|
7186
|
+
steps.forEach((step, i) => {
|
|
7187
|
+
wrapText(step.text, width - 5).forEach((line, j) => out.push(`${dim(j === 0 ? rpad(String(i + 1), 3) : " ")} ${line}`));
|
|
7188
|
+
const more = step.locations.length > 1 ? ` +${step.locations.length - 1}` : "";
|
|
7189
|
+
out.push(` ${dim(`${step.locations[0]}${more}`)}`);
|
|
7190
|
+
});
|
|
7191
|
+
return out;
|
|
7192
|
+
}
|
|
7193
|
+
var EVIDENCE_LABEL = {
|
|
7194
|
+
operation: "operation",
|
|
7195
|
+
import: "imports",
|
|
7196
|
+
"admin-client": "admin client",
|
|
7197
|
+
"auth-helper": "auth helper"
|
|
7198
|
+
};
|
|
7199
|
+
function renderFinding(f, width, verbose) {
|
|
7200
|
+
const out = [];
|
|
7201
|
+
const title = wrapText(f.title, width - DETAIL.length);
|
|
7202
|
+
const likely = f.confidence === "likely" ? dim(" likely") : "";
|
|
7203
|
+
title.forEach((line, i) => out.push(i === 0 ? ` ${severityColor(f.severity)(pad(f.severity, 4))}${dim(rpad(f.line !== null ? String(f.line) : f.file ? "file" : "", 4))} ${verbose ? bold(line) : line}${i === title.length - 1 ? likely : ""}` : `${DETAIL}${verbose ? bold(line) : line}${i === title.length - 1 ? likely : ""}`));
|
|
7204
|
+
if (!verbose) return out;
|
|
7205
|
+
const text = (s, indent = DETAIL) => wrapText(s, width - indent.length).map((line) => line === "" ? "" : `${indent}${line}`);
|
|
7206
|
+
out.push(`${DETAIL}${dim(`${categoryOf(f.ruleId)} \xB7 ${f.ruleId}`)}`);
|
|
7207
|
+
if (f.excerpt) out.push("", `${DETAIL}${gray(f.excerpt)}`);
|
|
7208
|
+
out.push("", ...text(f.why.join("\n\n")));
|
|
7209
|
+
if (f.evidence?.length) {
|
|
7210
|
+
out.push("", `${DETAIL}${dim("trace (static relationships)")}`);
|
|
7211
|
+
f.evidence.forEach((step, i) => {
|
|
7212
|
+
const marker = i === 0 ? red("\u25CF") : i === f.evidence.length - 1 ? dim("\u25CB") : dim("\u2502");
|
|
7213
|
+
out.push(`${DETAIL}${marker} ${dim(pad(EVIDENCE_LABEL[step.kind], 14))}${locationOf(step)}`);
|
|
7214
|
+
});
|
|
7215
|
+
if (f.evidenceTruncated) out.push(`${DETAIL}${dim(" additional dependency steps omitted")}`);
|
|
5639
7216
|
}
|
|
5640
7217
|
if (f.fix.length > 0) {
|
|
5641
|
-
out.push("");
|
|
5642
|
-
out.push(`${INDENT}${INDENT}${bold("How to fix:")}`);
|
|
7218
|
+
out.push("", `${DETAIL}${dim("fix")}`);
|
|
5643
7219
|
f.fix.forEach((step, i) => {
|
|
5644
|
-
|
|
5645
|
-
wrapped.forEach((line, j) => {
|
|
5646
|
-
const prefix = j === 0 ? `${i + 1}. ` : " ";
|
|
5647
|
-
out.push(`${INDENT}${INDENT}${INDENT}${dim(prefix)}${line}`);
|
|
5648
|
-
});
|
|
7220
|
+
wrapText(step, width - DETAIL.length - 3).forEach((line, j) => out.push(`${DETAIL}${j === 0 ? `${i + 1}. ` : " "}${line}`));
|
|
5649
7221
|
});
|
|
5650
7222
|
}
|
|
5651
|
-
if (f.humanOnly
|
|
5652
|
-
out.push("");
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
7223
|
+
if (f.humanOnly?.length) {
|
|
7224
|
+
out.push("", `${DETAIL}${yellow("by hand")}`);
|
|
7225
|
+
for (const step of f.humanOnly) {
|
|
7226
|
+
wrapText(step, width - DETAIL.length - 2).forEach((line, j) => out.push(`${DETAIL}${j === 0 ? dim("\xB7 ") : " "}${line}`));
|
|
7227
|
+
}
|
|
7228
|
+
}
|
|
7229
|
+
out.push("");
|
|
7230
|
+
return out;
|
|
7231
|
+
}
|
|
7232
|
+
function renderNext(opts, width) {
|
|
7233
|
+
const out = [];
|
|
7234
|
+
const commands = [
|
|
7235
|
+
...opts.verbose ? [] : [`${dim("details")} canship --verbose`],
|
|
7236
|
+
`${dim("report")} canship --report --open`,
|
|
7237
|
+
`${dim("fix prompt")} canship --fix-prompt`
|
|
7238
|
+
];
|
|
7239
|
+
out.push(...joinFitting(commands, " ", width));
|
|
7240
|
+
if (opts.exitCode !== void 0) {
|
|
7241
|
+
const reason = {
|
|
7242
|
+
0: "no findings",
|
|
7243
|
+
1: "blocking findings present",
|
|
7244
|
+
2: "findings present, none blocking",
|
|
7245
|
+
3: "scan incomplete"
|
|
7246
|
+
}[opts.exitCode];
|
|
7247
|
+
out.push(dim(`exit ${opts.exitCode} \xB7 ${reason}`));
|
|
7248
|
+
}
|
|
7249
|
+
out.push("");
|
|
7250
|
+
return out;
|
|
7251
|
+
}
|
|
7252
|
+
function renderBaseline(opts) {
|
|
7253
|
+
const suppressed = opts.baselineSuppressed ?? 0;
|
|
7254
|
+
const stale = opts.baselineStale ?? 0;
|
|
7255
|
+
const out = [];
|
|
7256
|
+
if (suppressed > 0) {
|
|
7257
|
+
const where = opts.baselinePath ? ` (${opts.baselinePath})` : "";
|
|
7258
|
+
out.push(yellow(`${suppressed} ${plural(suppressed, "finding")} hidden by the baseline${where}`));
|
|
7259
|
+
out.push(dim("These problems still exist. Re-run without --baseline to see them."));
|
|
7260
|
+
}
|
|
7261
|
+
if (stale > 0) {
|
|
7262
|
+
out.push(dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`));
|
|
5660
7263
|
}
|
|
5661
7264
|
return out;
|
|
5662
7265
|
}
|
|
5663
|
-
function renderClean(result, opts) {
|
|
7266
|
+
function renderClean(result, opts, width) {
|
|
5664
7267
|
const out = [];
|
|
7268
|
+
const facts = joinFitting([
|
|
7269
|
+
`${dim("files scanned")} ${result.filesScanned}`,
|
|
7270
|
+
`${dim("coverage")} ${result.partial ? yellow("incomplete") : "complete"}`,
|
|
7271
|
+
`${result.durationMs} ${dim("ms")}`
|
|
7272
|
+
], dim(" \xB7 "), width);
|
|
5665
7273
|
if (result.filesScanned === 0) {
|
|
5666
|
-
out.push(
|
|
5667
|
-
out.push(
|
|
5668
|
-
for (const line of wrapText(
|
|
5669
|
-
"canship found no files it could read here, so none of its checks ran. This is not a clean result \u2014 it is an empty one.",
|
|
5670
|
-
76
|
|
5671
|
-
)) {
|
|
5672
|
-
out.push(`${INDENT}${line}`);
|
|
5673
|
-
}
|
|
7274
|
+
out.push(yellow(bold("No files were scanned \u2014 nothing was checked.")));
|
|
7275
|
+
for (const line of wrapText("canship found no files it could read here, so none of its checks ran. This is not a clean result \u2014 it is an empty one.", width)) out.push(dim(line));
|
|
5674
7276
|
out.push("");
|
|
5675
|
-
out.push(
|
|
5676
|
-
out.push(
|
|
5677
|
-
out.push(
|
|
5678
|
-
out.push(
|
|
7277
|
+
out.push(dim("Most likely one of:"));
|
|
7278
|
+
out.push(dim(" \xB7 this is not the directory you meant to scan"));
|
|
7279
|
+
out.push(dim(" \xB7 everything here is gitignored, or is build output canship skips"));
|
|
7280
|
+
out.push(dim(" \xB7 the project lives in a subdirectory \u2014 try: npx canship ./app"));
|
|
5679
7281
|
if (result.ignored.length > 0) {
|
|
5680
|
-
out.push(
|
|
5681
|
-
out.push("");
|
|
5682
|
-
out.push(...renderIgnored(result));
|
|
7282
|
+
out.push(dim(" \xB7 every file here was excluded by canship-ignore-file"));
|
|
7283
|
+
out.push("", ...renderIgnored(result));
|
|
5683
7284
|
}
|
|
5684
7285
|
out.push("");
|
|
7286
|
+
if (result.partial) out.push(...renderIncomplete(result, width), "");
|
|
5685
7287
|
return out;
|
|
5686
7288
|
}
|
|
5687
7289
|
if (result.partial) {
|
|
5688
|
-
|
|
5689
|
-
out.push(`${INDENT}${yellow(bold(headline))}`);
|
|
7290
|
+
out.push(yellow(bold(opts.hiddenLikely > 0 ? `No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked.` : "No findings \u2014 but not everything was checked.")));
|
|
5690
7291
|
} else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
|
|
5691
|
-
out.push(
|
|
7292
|
+
out.push(yellow(bold("No visible findings in changed files \u2014 other findings still exist.")));
|
|
5692
7293
|
} else if (opts.hiddenLikely > 0) {
|
|
5693
|
-
out.push(
|
|
5694
|
-
`${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
|
|
5695
|
-
);
|
|
7294
|
+
out.push(yellow(bold(`No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden.`)));
|
|
5696
7295
|
} else if ((opts.baselineSuppressed ?? 0) > 0) {
|
|
5697
7296
|
const suppressed = opts.baselineSuppressed ?? 0;
|
|
5698
|
-
out.push(
|
|
5699
|
-
`${INDENT}${yellow(bold(`! No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline`))}`
|
|
5700
|
-
);
|
|
7297
|
+
out.push(yellow(bold(`No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline.`)));
|
|
5701
7298
|
} else {
|
|
5702
|
-
out.push(
|
|
7299
|
+
out.push(green(bold("No exposed credentials found.")));
|
|
5703
7300
|
}
|
|
7301
|
+
out.push(...facts);
|
|
7302
|
+
if (result.changeView) for (const line of wrapText(changeViewNotice(result.changeView), width)) out.push(yellow(line));
|
|
5704
7303
|
out.push("");
|
|
5705
|
-
|
|
5706
|
-
if (
|
|
5707
|
-
out.push(
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
7304
|
+
const baseline = renderBaseline(opts);
|
|
7305
|
+
if (baseline.length > 0) out.push(...baseline, "");
|
|
7306
|
+
out.push(bold("Checked for"));
|
|
7307
|
+
for (const item of [
|
|
7308
|
+
"API keys hardcoded in source code",
|
|
7309
|
+
"Server-side secrets exposed to the browser via public env prefixes",
|
|
7310
|
+
"Supabase service_role keys reachable from the client",
|
|
7311
|
+
".env files committed to git, including in history",
|
|
7312
|
+
"Supabase tables with no Row Level Security, or policies open to everyone",
|
|
7313
|
+
"Firebase rules left open to anyone",
|
|
7314
|
+
"API routes and server actions that query your database with no sign-in check",
|
|
7315
|
+
"CORS that lets other sites act as your signed-in visitors",
|
|
7316
|
+
"Request input in SQL, commands, outbound URLs, and redirects within supported handlers",
|
|
7317
|
+
"Unverified Supabase sessions and Stripe webhook events"
|
|
7318
|
+
]) out.push(`${dim(" \xB7 ")}${item}`);
|
|
5716
7319
|
out.push("");
|
|
5717
|
-
out.push(
|
|
5718
|
-
out.push(`${INDENT}${dim("did find are the right ones.")}`);
|
|
7320
|
+
out.push(dim("Input analysis is bounded and handler-local; business authorisation, rate limiting and dependency vulnerabilities are not verified."));
|
|
5719
7321
|
if (opts.hiddenLikely > 0) {
|
|
5720
|
-
out.push(
|
|
7322
|
+
out.push(dim("This is not a finding-free result. Review the hidden items with --all."));
|
|
5721
7323
|
} else if (result.ignoredFindings.length > 0) {
|
|
5722
|
-
out.push(
|
|
5723
|
-
`${INDENT}${dim("This is not a finding-free result \u2014 some were silenced in the source. See below.")}`
|
|
5724
|
-
);
|
|
7324
|
+
out.push(dim("This is not a finding-free result \u2014 some were silenced in the source. See below."));
|
|
5725
7325
|
} else {
|
|
5726
|
-
out.push(
|
|
5727
|
-
}
|
|
5728
|
-
if (result.partial) {
|
|
5729
|
-
out.push("");
|
|
5730
|
-
out.push(...renderIncomplete(result));
|
|
5731
|
-
}
|
|
5732
|
-
const optedOut = renderIgnored(result);
|
|
5733
|
-
if (optedOut.length > 0) {
|
|
5734
|
-
out.push("");
|
|
5735
|
-
out.push(...optedOut);
|
|
7326
|
+
out.push(dim("A clean result means these checks passed \u2014 not that your app is secure."));
|
|
5736
7327
|
}
|
|
7328
|
+
const notes = [...renderIncomplete(result, width), ...renderIgnored(result)];
|
|
5737
7329
|
if (opts.hiddenLikely > 0) {
|
|
5738
|
-
|
|
5739
|
-
out.push(`${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`);
|
|
7330
|
+
notes.push(dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`));
|
|
5740
7331
|
}
|
|
7332
|
+
if (notes.length > 0) out.push("", ...notes);
|
|
5741
7333
|
out.push("");
|
|
7334
|
+
out.push(dim("\u2500".repeat(width)));
|
|
5742
7335
|
return out;
|
|
5743
7336
|
}
|
|
5744
7337
|
function renderIgnored(result) {
|
|
@@ -5746,47 +7339,38 @@ function renderIgnored(result) {
|
|
|
5746
7339
|
if (result.ignored.length > 0) {
|
|
5747
7340
|
const shown = result.ignored.slice(0, 3).join(", ");
|
|
5748
7341
|
const more = result.ignored.length > 3 ? `, and ${result.ignored.length - 3} more` : "";
|
|
5749
|
-
out.push(
|
|
5750
|
-
`${INDENT}${dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`)}`
|
|
5751
|
-
);
|
|
7342
|
+
out.push(dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`));
|
|
5752
7343
|
}
|
|
5753
7344
|
if (result.ruleSelection !== null) {
|
|
5754
7345
|
const { only, skip, removed } = result.ruleSelection;
|
|
5755
7346
|
const which = only.length > 0 ? `only ${only.join(", ")}` : `everything except ${skip.join(", ")}`;
|
|
5756
7347
|
const cost = removed > 0 ? `, hiding ${removed} ${plural(removed, "finding")}` : "";
|
|
5757
|
-
out.push(
|
|
7348
|
+
out.push(dim(`Rule selection in force: ${which}${cost}`));
|
|
5758
7349
|
}
|
|
5759
7350
|
if (result.ignoredFindings.length > 0) {
|
|
5760
7351
|
const n = result.ignoredFindings.length;
|
|
5761
7352
|
const shown = result.ignoredFindings.slice(0, 3).map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
|
|
5762
7353
|
const more = n > 3 ? `, and ${n - 3} more` : "";
|
|
5763
|
-
out.push(
|
|
5764
|
-
`${INDENT}${dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`)}`
|
|
5765
|
-
);
|
|
7354
|
+
out.push(dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`));
|
|
5766
7355
|
}
|
|
5767
7356
|
if (result.vendored > 0) {
|
|
5768
|
-
out.push(
|
|
5769
|
-
`${INDENT}${dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`)}`
|
|
5770
|
-
);
|
|
7357
|
+
out.push(dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`));
|
|
5771
7358
|
}
|
|
5772
7359
|
return out;
|
|
5773
7360
|
}
|
|
5774
|
-
function renderIncomplete(result) {
|
|
5775
|
-
|
|
5776
|
-
out
|
|
5777
|
-
|
|
5778
|
-
out.push(
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
}
|
|
7361
|
+
function renderIncomplete(result, width) {
|
|
7362
|
+
if (!result.partial) return [];
|
|
7363
|
+
const out = [yellow(bold("Not everything was checked"))];
|
|
7364
|
+
const item = (text) => {
|
|
7365
|
+
wrapText(text, width - 4).forEach((line, i) => out.push(`${i === 0 ? dim(" \xB7 ") : " "}${line}`));
|
|
7366
|
+
};
|
|
7367
|
+
if (result.filesScanned === 0) item("no files could be read at this path, so every file-based check was skipped");
|
|
5782
7368
|
for (const err of result.errors.slice(0, 5)) {
|
|
5783
7369
|
const where = err.file ? ` on ${err.file}` : "";
|
|
5784
7370
|
const verb = err.kind === "incomplete" ? "did not finish" : "failed";
|
|
5785
|
-
|
|
5786
|
-
}
|
|
5787
|
-
if (result.errors.length > 5) {
|
|
5788
|
-
out.push(`${INDENT}${INDENT}${dim(`\xB7 and ${result.errors.length - 5} more`)}`);
|
|
7371
|
+
item(`the ${err.ruleId} check ${verb}${where} \u2014 ${err.message}`);
|
|
5789
7372
|
}
|
|
7373
|
+
if (result.errors.length > 5) out.push(dim(` \xB7 and ${result.errors.length - 5} more`));
|
|
5790
7374
|
const byReason = /* @__PURE__ */ new Map();
|
|
5791
7375
|
for (const skip of result.skipped) {
|
|
5792
7376
|
const list = byReason.get(skip.reason) ?? [];
|
|
@@ -5797,12 +7381,9 @@ function renderIncomplete(result) {
|
|
|
5797
7381
|
const { noun, because } = SKIP_LABEL[reason];
|
|
5798
7382
|
const shown = paths.slice(0, 3).join(", ");
|
|
5799
7383
|
const more = paths.length > 3 ? `, and ${paths.length - 3} more` : "";
|
|
5800
|
-
|
|
5801
|
-
`${INDENT}${INDENT}${dim("\xB7")} ${paths.length} ${plural(paths.length, noun)} ${because}: ${shown}${more}`
|
|
5802
|
-
);
|
|
7384
|
+
item(`${paths.length} ${plural(paths.length, noun)} ${because}: ${shown}${more}`);
|
|
5803
7385
|
}
|
|
5804
|
-
out.push("");
|
|
5805
|
-
out.push(`${INDENT}${dim("Anything could be in what was skipped. Re-run once it is readable.")}`);
|
|
7386
|
+
out.push(dim("Anything could be in what was skipped. Re-run once it is readable."));
|
|
5806
7387
|
return out;
|
|
5807
7388
|
}
|
|
5808
7389
|
function wrapText(text, width) {
|
|
@@ -5937,11 +7518,12 @@ function renderFixPrompt(findings, ctx) {
|
|
|
5937
7518
|
}
|
|
5938
7519
|
|
|
5939
7520
|
// src/report/html.ts
|
|
7521
|
+
import { createHash as createHash3 } from "crypto";
|
|
5940
7522
|
function esc(s) {
|
|
5941
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7523
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5942
7524
|
}
|
|
5943
7525
|
function paragraphs(parts) {
|
|
5944
|
-
return parts.map((p) => `<p>${esc(p.trim())}</p>`).join("");
|
|
7526
|
+
return parts.map((p) => `<p>${linkify(esc(p.trim()))}</p>`).join("");
|
|
5945
7527
|
}
|
|
5946
7528
|
function linkify(html) {
|
|
5947
7529
|
return html.replace(
|
|
@@ -5949,196 +7531,353 @@ function linkify(html) {
|
|
|
5949
7531
|
(url) => `<a href="${url}" target="_blank" rel="noreferrer noopener">${url}</a>`
|
|
5950
7532
|
);
|
|
5951
7533
|
}
|
|
5952
|
-
function
|
|
5953
|
-
|
|
5954
|
-
const cls = f.confidence === "certain" ? "certain" : "likely";
|
|
5955
|
-
const fixList = f.fix.length > 0 ? `<h4>How to fix</h4><ol>${f.fix.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ol>` : "";
|
|
5956
|
-
const humanList = f.humanOnly && f.humanOnly.length > 0 ? `<div class="human"><h4>Only you can do this</h4><ul>${f.humanOnly.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ul></div>` : "";
|
|
5957
|
-
return `
|
|
5958
|
-
<article class="finding ${cls}">
|
|
5959
|
-
<header>
|
|
5960
|
-
<span class="num">${index}</span>
|
|
5961
|
-
<h3>${esc(f.title)}</h3>
|
|
5962
|
-
</header>
|
|
5963
|
-
<div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
|
|
5964
|
-
${f.excerpt ? `<pre><code>${esc(f.excerpt)}</code></pre>` : ""}
|
|
5965
|
-
${f.evidence?.length ? `<h4>Evidence (static relationships)</h4><ol>${f.evidence.map((step) => `<li><code>${esc(locationOf(step))}</code> \u2014 ${esc(step.description)}</li>`).join("")}</ol>${f.evidenceTruncated ? "<p>Additional dependency steps omitted.</p>" : ""}` : ""}
|
|
5966
|
-
<div class="why">${linkify(paragraphs(f.why))}</div>
|
|
5967
|
-
${fixList}
|
|
5968
|
-
${humanList}
|
|
5969
|
-
</article>`;
|
|
7534
|
+
function jsonForScript(value) {
|
|
7535
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(new RegExp(String.fromCharCode(8232), "g"), "\\u2028").replace(new RegExp(String.fromCharCode(8233), "g"), "\\u2029");
|
|
5970
7536
|
}
|
|
7537
|
+
function displayTime(iso) {
|
|
7538
|
+
const date = new Date(iso);
|
|
7539
|
+
if (!iso || Number.isNaN(date.getTime())) return iso;
|
|
7540
|
+
return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
7541
|
+
}
|
|
7542
|
+
var EVIDENCE_LABEL2 = {
|
|
7543
|
+
operation: "operation",
|
|
7544
|
+
import: "imports",
|
|
7545
|
+
"admin-client": "admin client",
|
|
7546
|
+
"auth-helper": "auth helper"
|
|
7547
|
+
};
|
|
7548
|
+
function renderFinding2(f, index) {
|
|
7549
|
+
const category = categoryOf(f.ruleId);
|
|
7550
|
+
const search = `${f.title} ${f.file ?? ""} ${f.ruleId}`.toLowerCase();
|
|
7551
|
+
const trace = f.evidence?.length ? `<h3>Trace (static relationships)</h3><ol class="trace">${f.evidence.map((step) => `<li><span class="k">${EVIDENCE_LABEL2[step.kind]}</span><span class="v">${esc(locationOf(step))}</span><span class="note">${esc(step.description)}</span></li>`).join("")}</ol>${f.evidenceTruncated ? '<p class="faint">Additional dependency steps omitted.</p>' : ""}` : "";
|
|
7552
|
+
const fix = f.fix.length > 0 ? `<h3>Fix</h3><ol class="steps">${f.fix.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ol>` : "";
|
|
7553
|
+
const hand = f.humanOnly?.length ? `<div class="hand"><b>By hand</b><ul>${f.humanOnly.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ul></div>` : "";
|
|
7554
|
+
return `<details class="f" data-i="${index}" data-sev="${f.severity}" data-cat="${esc(category)}" data-conf="${f.confidence}" data-file="${esc(f.file ?? "")}" data-text="${esc(search)}">
|
|
7555
|
+
<summary class="row"><span class="sev ${f.severity}">${f.severity}</span><span class="main"><span class="title">${esc(f.title)}</span>${f.confidence === "likely" ? '<span class="likely">likely</span>' : ""}<span class="loc"><span class="loc-line">${f.line !== null ? `line ${f.line}` : f.file ? "whole file" : "repository"}</span><span class="loc-full">${esc(locationOf(f))}</span></span></span><span class="cat">${esc(category)}</span></summary>
|
|
7556
|
+
<div class="body">
|
|
7557
|
+
<p class="rule">${esc(f.ruleId)}</p>
|
|
7558
|
+
${f.excerpt ? `<pre class="excerpt"><code>${esc(f.excerpt)}</code></pre>` : ""}
|
|
7559
|
+
<div class="why">${paragraphs(f.why)}</div>
|
|
7560
|
+
${trace}${fix}${hand}
|
|
7561
|
+
<p class="actions"><button type="button" class="link" data-copy="${index}">copy fix prompt</button></p>
|
|
7562
|
+
</div>
|
|
7563
|
+
</details>`;
|
|
7564
|
+
}
|
|
7565
|
+
var SCRIPT = `(function(){
|
|
7566
|
+
var d=document,b=d.body;b.classList.add('js');
|
|
7567
|
+
var data={};try{data=JSON.parse(d.getElementById('canship-data').textContent||'{}')}catch(e){}
|
|
7568
|
+
var rows=[].slice.call(d.querySelectorAll('details.f')),list=d.getElementById('list'),st={sev:null,cat:null,q:'',g:'file'};
|
|
7569
|
+
var SEV=['P0','P1','P2'],CAT=data.categories||[];
|
|
7570
|
+
function matches(r){return(!st.sev||r.dataset.sev===st.sev)&&(!st.cat||r.dataset.cat===st.cat)&&(!st.q||r.dataset.text.indexOf(st.q)>=0)}
|
|
7571
|
+
function keyOf(r){return st.g==='sev'?r.dataset.sev:st.g==='cat'?r.dataset.cat:r.dataset.file}
|
|
7572
|
+
function label(k){return st.g==='sev'?k+' \\u00b7 '+({P0:'critical',P1:'high',P2:'medium'})[k]:(k||'repository')}
|
|
7573
|
+
function render(){
|
|
7574
|
+
if(!list)return;
|
|
7575
|
+
var groups=[],index=new Map();
|
|
7576
|
+
rows.forEach(function(r){var k=keyOf(r);if(!index.has(k)){index.set(k,groups.length);groups.push({k:k,rows:[]})}groups[index.get(k)].rows.push(r)});
|
|
7577
|
+
if(st.g==='sev')groups.sort(function(a,c){return SEV.indexOf(a.k)-SEV.indexOf(c.k)});
|
|
7578
|
+
if(st.g==='cat')groups.sort(function(a,c){return CAT.indexOf(a.k)-CAT.indexOf(c.k)});
|
|
7579
|
+
list.textContent='';var shown=0;
|
|
7580
|
+
groups.forEach(function(g){
|
|
7581
|
+
var visible=g.rows.filter(matches);if(!visible.length)return;shown+=visible.length;
|
|
7582
|
+
var h=d.createElement('div');h.className='group';var name=d.createElement('span');name.className='gk';name.textContent=label(g.k);
|
|
7583
|
+
h.appendChild(name);h.appendChild(d.createTextNode(' \\u2014 '+visible.length));list.appendChild(h);
|
|
7584
|
+
var box=d.createElement('div');box.className='ledger';visible.forEach(function(r){box.appendChild(r)});list.appendChild(box);
|
|
7585
|
+
});
|
|
7586
|
+
list.classList.toggle('by-file',st.g==='file');
|
|
7587
|
+
var count=d.getElementById('count');if(count)count.textContent=shown===rows.length?rows.length+' findings':shown+' of '+rows.length+' findings';
|
|
7588
|
+
var empty=d.getElementById('empty');if(empty)empty.hidden=shown>0;
|
|
7589
|
+
[].forEach.call(d.querySelectorAll('[data-filter-sev]'),function(x){x.classList.toggle('on',(x.dataset.filterSev||null)===st.sev&&!st.cat)});
|
|
7590
|
+
[].forEach.call(d.querySelectorAll('[data-group]'),function(x){x.classList.toggle('on',x.dataset.group===st.g)});
|
|
7591
|
+
[].forEach.call(d.querySelectorAll('[data-mx-cat]'),function(x){x.classList.toggle('on',x.dataset.mxCat===st.cat&&x.dataset.mxSev===st.sev)});
|
|
7592
|
+
}
|
|
7593
|
+
[].forEach.call(d.querySelectorAll('[data-filter-sev]'),function(x){x.onclick=function(){st.sev=x.dataset.filterSev||null;st.cat=null;render()}});
|
|
7594
|
+
[].forEach.call(d.querySelectorAll('[data-group]'),function(x){x.onclick=function(){st.g=x.dataset.group;render()}});
|
|
7595
|
+
[].forEach.call(d.querySelectorAll('[data-mx-cat]'),function(x){x.onclick=function(){var same=st.cat===x.dataset.mxCat&&st.sev===x.dataset.mxSev;st.cat=same?null:x.dataset.mxCat;st.sev=same?null:x.dataset.mxSev;render()}});
|
|
7596
|
+
var q=d.getElementById('q');if(q)q.oninput=function(){st.q=q.value.toLowerCase();render()};
|
|
7597
|
+
var clear=d.getElementById('clear');if(clear)clear.onclick=function(){st.sev=null;st.cat=null;st.q='';if(q)q.value='';render()};
|
|
7598
|
+
function copy(text,btn){
|
|
7599
|
+
function done(){var t=btn.textContent;btn.textContent='copied';setTimeout(function(){btn.textContent=t},1200)}
|
|
7600
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(done,fallback)}else fallback();
|
|
7601
|
+
function fallback(){var a=d.createElement('textarea');a.value=text;d.body.appendChild(a);a.select();try{d.execCommand('copy');done()}catch(e){}d.body.removeChild(a)}
|
|
7602
|
+
}
|
|
7603
|
+
[].forEach.call(d.querySelectorAll('[data-copy]'),function(x){x.onclick=function(e){e.preventDefault();var t=(data.prompts||{})[x.dataset.copy];if(t)copy(t,x)}});
|
|
7604
|
+
var store=null;try{store=window.localStorage}catch(e){}
|
|
7605
|
+
var prefix='canship:'+(data.key||'')+':';
|
|
7606
|
+
[].forEach.call(d.querySelectorAll('.manual input[type=checkbox]'),function(c){
|
|
7607
|
+
try{c.checked=!!store&&store.getItem(prefix+c.dataset.step)==='1'}catch(e){}
|
|
7608
|
+
c.closest('li').classList.toggle('done',c.checked);
|
|
7609
|
+
c.onchange=function(){c.closest('li').classList.toggle('done',c.checked);try{if(store){if(c.checked)store.setItem(prefix+c.dataset.step,'1');else store.removeItem(prefix+c.dataset.step)}}catch(e){}};
|
|
7610
|
+
});
|
|
7611
|
+
var print=d.getElementById('print');if(print)print.onclick=function(){window.print()};
|
|
7612
|
+
window.addEventListener('beforeprint',function(){rows.forEach(function(r){r.open=true})});
|
|
7613
|
+
render();
|
|
7614
|
+
})();`;
|
|
7615
|
+
var SCRIPT_HASH = createHash3("sha256").update(SCRIPT, "utf8").digest("base64");
|
|
7616
|
+
var STYLE = `
|
|
7617
|
+
:root{color-scheme:light dark;
|
|
7618
|
+
--paper:#fcfcfa;--ink:#1b1b18;--ink-2:#5c5b56;--ink-3:#8f8d86;--rule:#dddbd3;--rule-2:#bdbab0;--hover:#f3f2ed;
|
|
7619
|
+
--p0:#b3261e;--p1:#946200;--p2:#5c5b56;--ok:#2f6b2f;
|
|
7620
|
+
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
|
|
7621
|
+
--code:ui-monospace,"Cascadia Mono","SF Mono",Menlo,Consolas,monospace}
|
|
7622
|
+
@media (prefers-color-scheme:dark){:root{
|
|
7623
|
+
--paper:#171715;--ink:#e9e7e0;--ink-2:#a8a69e;--ink-3:#77756e;--rule:#2e2d29;--rule-2:#4a4843;--hover:#1f1f1c;
|
|
7624
|
+
--p0:#f2877f;--p1:#e0b050;--p2:#a8a69e;--ok:#8fcf8f}}
|
|
7625
|
+
*{box-sizing:border-box}
|
|
7626
|
+
body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 var(--serif);font-variant-numeric:lining-nums tabular-nums}
|
|
7627
|
+
button,input{font:inherit;color:inherit;background:none;border:0;padding:0}
|
|
7628
|
+
a{color:inherit}
|
|
7629
|
+
.page{max-width:1040px;margin:0 auto;padding:28px 32px 80px}
|
|
7630
|
+
.P0{color:var(--p0)}.P1{color:var(--p1)}.P2{color:var(--p2)}
|
|
7631
|
+
.faint{color:var(--ink-3)}
|
|
7632
|
+
.mast{display:flex;justify-content:space-between;align-items:baseline;gap:16px;border-bottom:1px solid var(--ink);padding-bottom:8px;font-size:13.5px}
|
|
7633
|
+
.mast .root{overflow-wrap:anywhere}
|
|
7634
|
+
.mast .right{white-space:nowrap}
|
|
7635
|
+
.link{cursor:pointer;text-decoration:underline;text-decoration-color:var(--rule-2);text-underline-offset:3px;margin-left:16px;color:var(--ink-2)}
|
|
7636
|
+
.js-only{display:none}.js .js-only{display:inline}
|
|
7637
|
+
.verdict-block{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:40px;padding:36px 0 30px;border-bottom:1px solid var(--rule)}
|
|
7638
|
+
h1.verdict{font-weight:400;font-size:44px;line-height:1.08;margin:0 0 12px;letter-spacing:-.01em}
|
|
7639
|
+
h1.verdict .n{color:var(--p0)}
|
|
7640
|
+
h1.verdict.warn .n{color:var(--p1)}
|
|
7641
|
+
h1.verdict.clean{color:var(--ok)}
|
|
7642
|
+
.verdict-block p{margin:0 0 8px;max-width:58ch;color:var(--ink-2)}
|
|
7643
|
+
.facts{font-size:13.5px;display:grid;grid-template-columns:auto auto;gap:4px 18px;align-self:end;color:var(--ink-2);margin:0}
|
|
7644
|
+
.facts dt{color:var(--ink-3)}.facts dd{margin:0;text-align:right;color:var(--ink)}
|
|
7645
|
+
.notice{color:var(--ink-3);font-size:13.5px;max-width:78ch;margin:14px 0 0}
|
|
7646
|
+
h2{font-weight:400;font-size:22px;margin:0 0 12px}
|
|
7647
|
+
.section{padding:28px 0;border-bottom:1px solid var(--rule)}
|
|
7648
|
+
.section.last{border-bottom:0}
|
|
7649
|
+
.two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:48px}
|
|
7650
|
+
table.matrix{border-collapse:collapse;width:100%}
|
|
7651
|
+
.matrix th,.matrix td{padding:7px 0;border-bottom:1px solid var(--rule);font-weight:400;text-align:right}
|
|
7652
|
+
.matrix th{font-size:13.5px;color:var(--ink-3)}
|
|
7653
|
+
.matrix th:first-child,.matrix td:first-child{text-align:left}
|
|
7654
|
+
.matrix td button{min-width:28px;text-align:right;cursor:pointer;border-bottom:1px solid transparent}
|
|
7655
|
+
.matrix td button:hover{border-bottom-color:currentColor}
|
|
7656
|
+
.matrix td button.on{border-bottom:2px solid currentColor}
|
|
7657
|
+
.matrix .zero{color:var(--rule-2)}
|
|
7658
|
+
.matrix tfoot td{border-bottom:0;color:var(--ink-2)}
|
|
7659
|
+
ol.manual{margin:0;padding:0;list-style:none}
|
|
7660
|
+
ol.manual li{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:start;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule);font-size:15px}
|
|
7661
|
+
ol.manual .num{color:var(--ink-3)}
|
|
7662
|
+
.js ol.manual .num{display:none}
|
|
7663
|
+
ol.manual input{display:none;margin:5px 0 0;accent-color:var(--ink)}
|
|
7664
|
+
.js ol.manual input{display:block}
|
|
7665
|
+
ol.manual li.done .t{color:var(--ink-3);text-decoration:line-through}
|
|
7666
|
+
ol.manual .src{font-size:13.5px;color:var(--ink-3);overflow-wrap:anywhere}
|
|
7667
|
+
.controls{display:none;flex-wrap:wrap;align-items:baseline;gap:6px 22px;margin:4px 0 10px;font-size:14px;color:var(--ink-2)}
|
|
7668
|
+
.js .controls{display:flex}
|
|
7669
|
+
.controls .lbl{color:var(--ink-3);margin-right:6px}
|
|
7670
|
+
.controls button{cursor:pointer;margin-right:10px;color:var(--ink-2)}
|
|
7671
|
+
.controls button.on{color:var(--ink);text-decoration:underline;text-underline-offset:4px;text-decoration-thickness:1.5px}
|
|
7672
|
+
.controls input{border-bottom:1px solid var(--rule-2);width:200px;padding:2px 0;outline:0}
|
|
7673
|
+
.controls input:focus{border-bottom-color:var(--ink)}
|
|
7674
|
+
.controls .clear{margin-left:auto}
|
|
7675
|
+
#count{color:var(--ink-3);font-size:13.5px;margin:0 0 4px}
|
|
7676
|
+
.head{display:grid;grid-template-columns:44px minmax(0,1fr) 130px;font-size:13.5px;color:var(--ink-3);padding:6px 0;border-bottom:1px solid var(--ink)}
|
|
7677
|
+
.group{padding:22px 0 6px;font-size:13.5px;color:var(--ink-3)}
|
|
7678
|
+
.group .gk{color:var(--ink);font-size:15.5px;overflow-wrap:anywhere}
|
|
7679
|
+
details.f{border-bottom:1px solid var(--rule)}
|
|
7680
|
+
summary.row{display:grid;grid-template-columns:44px minmax(0,1fr) 130px;align-items:start;padding:10px 0;cursor:pointer;list-style:none}
|
|
7681
|
+
summary.row::-webkit-details-marker{display:none}
|
|
7682
|
+
summary.row:hover{background:var(--hover)}
|
|
7683
|
+
summary.row:focus-visible{outline:2px solid var(--ink-2);outline-offset:2px}
|
|
7684
|
+
.sev{font-weight:600;font-size:14px;padding-left:4px}
|
|
7685
|
+
.main{min-width:0}
|
|
7686
|
+
.title{overflow-wrap:anywhere}
|
|
7687
|
+
.likely{font-size:13.5px;color:var(--ink-3);margin-left:8px}
|
|
7688
|
+
.loc{display:block;font-size:13.5px;color:var(--ink-2);overflow-wrap:anywhere}
|
|
7689
|
+
.loc-line{display:none}
|
|
7690
|
+
.by-file .loc-line{display:inline}.by-file .loc-full{display:none}
|
|
7691
|
+
.cat{font-size:13.5px;color:var(--ink-3)}
|
|
7692
|
+
.body{margin:0 0 22px 44px;max-width:740px}
|
|
7693
|
+
.body p{margin:0 0 12px;color:var(--ink-2)}
|
|
7694
|
+
.body .rule{font-size:13.5px;color:var(--ink-3)}
|
|
7695
|
+
.body h3{font-weight:400;font-style:italic;font-size:14px;color:var(--ink-3);margin:18px 0 6px}
|
|
7696
|
+
pre.excerpt{margin:0 0 14px;padding:8px 10px;background:var(--hover);overflow-x:auto}
|
|
7697
|
+
code{font-family:var(--code);font-size:12.5px}
|
|
7698
|
+
ol.trace{margin:0;padding:0;list-style:none;border-left:1px solid var(--rule-2)}
|
|
7699
|
+
ol.trace li{padding:3px 0 3px 14px;position:relative;font-size:14px}
|
|
7700
|
+
ol.trace li::before{content:"";position:absolute;left:-4px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--paper);border:1px solid var(--rule-2)}
|
|
7701
|
+
ol.trace li:first-child::before{background:var(--p0);border-color:var(--p0)}
|
|
7702
|
+
ol.trace .k{color:var(--ink-3);display:inline-block;width:120px}
|
|
7703
|
+
ol.trace .v{overflow-wrap:anywhere}
|
|
7704
|
+
ol.trace .note{display:block;color:var(--ink-3);font-size:13.5px;margin-left:120px}
|
|
7705
|
+
ol.steps{margin:0;padding-left:20px}
|
|
7706
|
+
ol.steps li{margin-bottom:6px}
|
|
7707
|
+
.hand{margin-top:14px;padding-left:12px;border-left:2px solid var(--p1);font-size:15px}
|
|
7708
|
+
.hand ul{margin:4px 0 0;padding-left:18px}
|
|
7709
|
+
.actions{margin:14px 0 0;font-size:14px}
|
|
7710
|
+
.actions .link{margin-left:0}
|
|
7711
|
+
.incomplete{border-left:2px solid var(--p1);padding-left:14px}
|
|
7712
|
+
.incomplete ul{margin:0 0 8px;padding-left:18px}
|
|
7713
|
+
.notes p{margin:0 0 6px;color:var(--ink-2);font-size:14px}
|
|
7714
|
+
.checked ul{margin:0 0 12px;padding-left:18px;color:var(--ink-2)}
|
|
7715
|
+
.colophon{margin-top:36px;font-size:13.5px;color:var(--ink-3);max-width:78ch}
|
|
7716
|
+
@media (max-width:820px){.verdict-block,.two{grid-template-columns:1fr;gap:24px}h1.verdict{font-size:34px}
|
|
7717
|
+
summary.row,.head{grid-template-columns:40px minmax(0,1fr)}.cat,.head .c{display:none}.body{margin-left:40px}}
|
|
7718
|
+
@media print{.controls,.link,#count{display:none!important}body{background:#fff;color:#000}.page{max-width:none;padding:0}}
|
|
7719
|
+
`;
|
|
5971
7720
|
function renderHtml(result, opts) {
|
|
5972
7721
|
const { findings } = result;
|
|
5973
7722
|
const hiddenLikely = opts.hiddenLikely ?? 0;
|
|
5974
7723
|
const baselineSuppressed = opts.baselineSuppressed ?? 0;
|
|
5975
7724
|
const baselineStale = opts.baselineStale ?? 0;
|
|
5976
|
-
const { blocking, minor, unsure } = verdictOf(findings);
|
|
5977
|
-
const
|
|
5978
|
-
const
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
7725
|
+
const { blocking: visibleBlocking, minor, unsure } = verdictOf(findings);
|
|
7726
|
+
const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
|
|
7727
|
+
const steps = manualSteps(findings);
|
|
7728
|
+
const rotations = steps.filter((step) => /^Rotate\b/.test(step.text)).length;
|
|
7729
|
+
let verdict;
|
|
7730
|
+
let explanation = "";
|
|
7731
|
+
if (findings.length > 0) {
|
|
7732
|
+
if (blocking > 0) {
|
|
7733
|
+
verdict = `<h1 class="verdict bad"><span class="n">${blocking} blocking ${plural(blocking, "finding")}.</span><br>Do not deploy yet.</h1>`;
|
|
7734
|
+
explanation = "Every certain P0 or P1 result below needs a code change." + (rotations > 0 ? ` ${rotations} exposed ${plural(rotations, "credential")} must also be rotated in ${rotations === 1 ? "its provider" : "their providers"}; a code change does not revoke ${rotations === 1 ? "it" : "them"}.` : "");
|
|
7735
|
+
} else if (minor > 0) {
|
|
7736
|
+
verdict = `<h1 class="verdict warn"><span class="n">${minor} ${plural(minor, "finding")} to fix.</span><br>Nothing blocking.</h1>`;
|
|
7737
|
+
explanation = "No certain P0 or P1 result was found; the findings below are lower severity.";
|
|
7738
|
+
} else {
|
|
7739
|
+
verdict = `<h1 class="verdict warn"><span class="n">${unsure} ${plural(unsure, "finding")} to review.</span></h1>`;
|
|
7740
|
+
explanation = "These are likely findings: the static evidence is not conclusive, so check each one.";
|
|
7741
|
+
}
|
|
7742
|
+
} else if (result.filesScanned === 0) {
|
|
7743
|
+
verdict = '<h1 class="verdict warn">No files were scanned.<br>Nothing was checked.</h1>';
|
|
7744
|
+
explanation = "canship found no files it could read at this path, so none of its checks ran. This is not a clean result \u2014 it is an empty one.";
|
|
7745
|
+
} else if (result.partial) {
|
|
7746
|
+
verdict = hiddenLikely > 0 ? `<h1 class="verdict warn">No certain findings \u2014 ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked.</h1>` : '<h1 class="verdict warn">No findings \u2014 but not everything was checked.</h1>';
|
|
7747
|
+
} else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
|
|
7748
|
+
verdict = '<h1 class="verdict warn">No visible findings in changed files \u2014 other findings still exist.</h1>';
|
|
7749
|
+
explanation = "This view hides existing findings. Re-run without --changed-since for the full report.";
|
|
7750
|
+
} else if (hiddenLikely > 0) {
|
|
7751
|
+
verdict = `<h1 class="verdict warn">No certain findings \u2014 ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden.</h1>`;
|
|
7752
|
+
explanation = `This is not a finding-free result. Re-run with --all --report to include ${hiddenLikely === 1 ? "it" : "them"} in the report.`;
|
|
7753
|
+
} else if (baselineSuppressed > 0) {
|
|
7754
|
+
verdict = `<h1 class="verdict warn">No new findings \u2014 ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline.</h1>`;
|
|
7755
|
+
explanation = `This is not a finding-free result. Those problems still exist. Re-run without --baseline to see ${baselineSuppressed === 1 ? "it" : "them"}.`;
|
|
7756
|
+
} else {
|
|
7757
|
+
verdict = '<h1 class="verdict clean">No exposed credentials found.</h1>';
|
|
7758
|
+
}
|
|
7759
|
+
const facts = [
|
|
7760
|
+
["findings", String(findings.length)],
|
|
7761
|
+
...hiddenLikely > 0 ? [["likely hidden", String(hiddenLikely)]] : [],
|
|
7762
|
+
["files scanned", String(result.filesScanned)],
|
|
7763
|
+
["coverage", result.partial ? "incomplete" : "complete"],
|
|
7764
|
+
["duration", `${result.durationMs} ms`]
|
|
7765
|
+
].map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("");
|
|
7766
|
+
const rows = categoryCounts(findings);
|
|
7767
|
+
const totals = SEVERITIES.map((s) => findings.filter((f) => f.severity === s).length);
|
|
7768
|
+
const matrix = `<table class="matrix"><thead><tr><th></th>${SEVERITIES.map((s) => `<th>${s}</th>`).join("")}<th>total</th></tr></thead><tbody>${rows.map((row) => `<tr><td>${row.category}</td>${SEVERITIES.map((s) => `<td>${row.counts[s] ? `<button type="button" class="${s}" data-mx-cat="${row.category}" data-mx-sev="${s}">${row.counts[s]}</button>` : '<span class="zero">\u2013</span>'}</td>`).join("")}<td>${row.total}</td></tr>`).join("")}</tbody><tfoot><tr><td>all</td>${totals.map((n) => `<td>${n}</td>`).join("")}<td>${findings.length}</td></tr></tfoot></table>`;
|
|
7769
|
+
const manual = steps.length > 0 ? `<ol class="manual">${steps.map((step, i) => `<li><span class="num">${i + 1}</span><input type="checkbox" data-step="${i}" aria-label="done"><div><div class="t">${linkify(esc(step.text))}</div><div class="src">${esc(step.locations.join(", "))}</div></div></li>`).join("")}</ol>` : '<p class="faint">No manual steps for these findings.</p>';
|
|
7770
|
+
const groups = groupByFile(findings);
|
|
7771
|
+
let index = 0;
|
|
7772
|
+
const prompts = {};
|
|
7773
|
+
const allPrompt = renderFixPrompt(findings);
|
|
7774
|
+
if (allPrompt) prompts["all"] = allPrompt;
|
|
7775
|
+
const list = groups.map((group) => {
|
|
7776
|
+
const items = group.findings.map((f) => {
|
|
7777
|
+
index++;
|
|
7778
|
+
const prompt = renderFixPrompt([f]);
|
|
7779
|
+
if (prompt) prompts[String(index)] = prompt;
|
|
7780
|
+
return renderFinding2(f, index);
|
|
7781
|
+
}).join("\n");
|
|
7782
|
+
return `<div class="group"><span class="gk">${esc(group.file ?? "repository")}</span> \u2014 ${group.findings.length}</div><div class="ledger">${items}</div>`;
|
|
7783
|
+
}).join("\n");
|
|
7784
|
+
const findingsSection = findings.length > 0 ? `
|
|
7785
|
+
<section class="section two">
|
|
7786
|
+
<div><h2>By category</h2>${matrix}</div>
|
|
7787
|
+
<div><h2>Manual steps</h2>${manual}</div>
|
|
7788
|
+
</section>
|
|
7789
|
+
<section class="section last">
|
|
7790
|
+
<h2>Findings</h2>
|
|
7791
|
+
<div class="controls">
|
|
7792
|
+
<span><span class="lbl">severity</span><button type="button" data-filter-sev="">all</button>${SEVERITIES.map((s) => `<button type="button" data-filter-sev="${s}">${s}</button>`).join("")}</span>
|
|
7793
|
+
<span><span class="lbl">group by</span><button type="button" data-group="file">file</button><button type="button" data-group="sev">severity</button><button type="button" data-group="cat">category</button></span>
|
|
7794
|
+
<input id="q" type="search" placeholder="filter by text or path" aria-label="Filter findings">
|
|
7795
|
+
<button type="button" class="clear" id="clear">clear filters</button>
|
|
7796
|
+
</div>
|
|
7797
|
+
<p id="count"></p>
|
|
7798
|
+
<div class="head"><span>sev</span><span>finding</span><span class="c">category</span></div>
|
|
7799
|
+
<div id="list" class="by-file">${list}</div>
|
|
7800
|
+
<p id="empty" class="faint" hidden>No findings match these filters.</p>
|
|
7801
|
+
</section>` : `
|
|
7802
|
+
<section class="section last checked">
|
|
7803
|
+
<h2>Checked for</h2>
|
|
7804
|
+
<ul>
|
|
7805
|
+
<li>API keys hardcoded in source code</li>
|
|
7806
|
+
<li>Server-side secrets exposed to the browser via public env prefixes</li>
|
|
7807
|
+
<li>Supabase service_role keys reachable from the client</li>
|
|
7808
|
+
<li><code>.env</code> files committed to git, including in history</li>
|
|
7809
|
+
<li>Supabase tables with no Row Level Security, or policies open to everyone</li>
|
|
7810
|
+
<li>Firebase rules left open to anyone</li>
|
|
7811
|
+
<li>API routes and server actions that query your database with no sign-in check</li>
|
|
7812
|
+
<li>CORS that lets other sites act as your signed-in visitors</li>
|
|
7813
|
+
<li>Request input in SQL, commands, outbound URLs, and redirects within supported handlers</li>
|
|
7814
|
+
<li>Unverified Supabase sessions and Stripe webhook events</li>
|
|
7815
|
+
</ul>
|
|
7816
|
+
<p><strong>No findings means no matches in the selected scope \u2014 not that your app is secure.</strong> Checks are bounded and syntax-based. Request-input analysis stays within supported handlers; business authorisation, rate limiting and dependency vulnerabilities are not verified. Rule selection and exclusions are disclosed below.</p>
|
|
7817
|
+
</section>`;
|
|
7818
|
+
const incomplete = result.partial ? `
|
|
7819
|
+
<section class="section incomplete">
|
|
7820
|
+
<h2>Not everything was checked</h2>
|
|
7821
|
+
<ul>
|
|
7822
|
+
${result.filesScanned === 0 ? "<li>no files could be read at this path, so every file-based check was skipped</li>" : ""}
|
|
7823
|
+
${result.errors.map((e) => `<li>the <code>${esc(e.ruleId)}</code> check ${e.kind === "incomplete" ? "did not finish" : "failed"}${e.file ? ` on <code>${esc(e.file)}</code>` : ""} \u2014 ${esc(e.message)}</li>`).join("\n")}
|
|
7824
|
+
${result.skipped.map((s) => `<li><code>${esc(s.path)}</code> \u2014 ${esc(skipPhrase(s.reason))}${s.detail ? ` (${esc(s.detail)})` : ""}</li>`).join("\n")}
|
|
7825
|
+
</ul>
|
|
7826
|
+
<p>Anything could be in what was skipped. Re-run once it is readable.</p>
|
|
7827
|
+
</section>` : "";
|
|
6015
7828
|
const selection = result.ruleSelection;
|
|
6016
|
-
const
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
</ul>
|
|
6031
|
-
<p>Anything could be in what was skipped. Re-run once it is readable.</p>
|
|
6032
|
-
</div>` : "";
|
|
7829
|
+
const notes = [
|
|
7830
|
+
hiddenLikely > 0 && findings.length > 0 ? `${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden. Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"}.` : "",
|
|
7831
|
+
baselineSuppressed > 0 ? `${baselineSuppressed} ${plural(baselineSuppressed, "finding")} hidden by the baseline${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}. Those problems still exist.` : "",
|
|
7832
|
+
baselineStale > 0 ? `${baselineStale} baseline ${baselineStale === 1 ? "entry" : "entries"} no longer ${baselineStale === 1 ? "matches" : "match"} anything \u2014 re-run <code>--baseline-write</code> to prune.` : "",
|
|
7833
|
+
result.ignoredFindings.length > 0 ? `${result.ignoredFindings.length} ${plural(result.ignoredFindings.length, "finding")} silenced by <code>canship-ignore-next-line</code>: ${result.ignoredFindings.map((f) => `<code>${esc(f.file)}:${f.line}</code> (${esc(f.ruleId)})`).join(", ")}` : "",
|
|
7834
|
+
selection === null ? "" : `Rule selection in force: ${selection.only.length > 0 ? `only <code>${selection.only.map(esc).join("</code>, <code>")}</code>` : `everything except <code>${selection.skip.map(esc).join("</code>, <code>")}</code>`}${selection.removed > 0 ? `, hiding ${selection.removed} ${plural(selection.removed, "finding")}` : ""}.`,
|
|
7835
|
+
result.ignored.length > 0 ? `${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by <code>canship-ignore-file</code>: ${result.ignored.map((f) => `<code>${esc(f)}</code>`).join(", ")}` : "",
|
|
7836
|
+
result.vendored > 0 ? `${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories.` : ""
|
|
7837
|
+
].filter(Boolean);
|
|
7838
|
+
const data = {
|
|
7839
|
+
key: createHash3("sha256").update(`${opts.root}\0${opts.generatedAt}`).digest("hex").slice(0, 16),
|
|
7840
|
+
categories: CATEGORIES,
|
|
7841
|
+
prompts
|
|
7842
|
+
};
|
|
6033
7843
|
return `<!doctype html>
|
|
6034
7844
|
<html lang="en">
|
|
6035
7845
|
<head>
|
|
6036
7846
|
<meta charset="utf-8">
|
|
6037
7847
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7848
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-${SCRIPT_HASH}'; base-uri 'none'; form-action 'none'">
|
|
7849
|
+
<meta name="referrer" content="no-referrer">
|
|
6038
7850
|
<title>canship report</title>
|
|
6039
|
-
<style>
|
|
6040
|
-
:root {
|
|
6041
|
-
--bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #e3e3e3;
|
|
6042
|
-
--card: #fafafa; --bad: #c0392b; --warn: #b8860b; --good: #1e7e34;
|
|
6043
|
-
--code-bg: #f4f4f4; --human-bg: #fff8e6; --human-line: #e6c35c;
|
|
6044
|
-
}
|
|
6045
|
-
@media (prefers-color-scheme: dark) {
|
|
6046
|
-
:root {
|
|
6047
|
-
--bg: #16181c; --fg: #e6e6e6; --muted: #9aa0a6; --line: #2c3036;
|
|
6048
|
-
--card: #1c1f24; --bad: #ff6b5e; --warn: #e8b339; --good: #4ade80;
|
|
6049
|
-
--code-bg: #22262c; --human-bg: #2a2418; --human-line: #6b5a2a;
|
|
6050
|
-
}
|
|
6051
|
-
}
|
|
6052
|
-
* { box-sizing: border-box; }
|
|
6053
|
-
body {
|
|
6054
|
-
margin: 0; padding: 2rem 1rem 4rem; background: var(--bg); color: var(--fg);
|
|
6055
|
-
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
6056
|
-
}
|
|
6057
|
-
main { max-width: 46rem; margin: 0 auto; }
|
|
6058
|
-
h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
|
|
6059
|
-
.meta { color: var(--muted); font-size: .85rem; margin-bottom: 1.5rem; word-break: break-all; }
|
|
6060
|
-
.verdict { font-weight: 600; padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1.5rem; }
|
|
6061
|
-
.verdict.bad { background: var(--bad); color: #fff; }
|
|
6062
|
-
.verdict.warn { background: var(--warn); color: #000; }
|
|
6063
|
-
.verdict.clean { background: var(--good); color: #fff; }
|
|
6064
|
-
.notice {
|
|
6065
|
-
border: 1px solid var(--line); border-left: 3px solid var(--muted);
|
|
6066
|
-
padding: .75rem 1rem; margin-bottom: 2rem; font-size: .85rem; color: var(--muted);
|
|
6067
|
-
}
|
|
6068
|
-
.finding {
|
|
6069
|
-
border: 1px solid var(--line); border-radius: 6px; background: var(--card);
|
|
6070
|
-
padding: 1.25rem; margin-bottom: 1.25rem;
|
|
6071
|
-
}
|
|
6072
|
-
.finding.certain { border-left: 3px solid var(--bad); }
|
|
6073
|
-
.finding.likely { border-left: 3px solid var(--warn); }
|
|
6074
|
-
.finding header { display: flex; gap: .6rem; align-items: baseline; }
|
|
6075
|
-
.num { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .9rem; }
|
|
6076
|
-
.finding h3 { font-size: 1.05rem; margin: 0 0 .35rem; }
|
|
6077
|
-
.loc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; color: var(--muted); margin-bottom: .75rem; word-break: break-all; }
|
|
6078
|
-
.tag { background: var(--warn); color: #000; padding: 0 .35rem; border-radius: 3px; font-size: .72rem; }
|
|
6079
|
-
pre {
|
|
6080
|
-
background: var(--code-bg); padding: .7rem .9rem; border-radius: 4px;
|
|
6081
|
-
overflow-x: auto; font-size: .82rem; margin: 0 0 .9rem;
|
|
6082
|
-
}
|
|
6083
|
-
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
6084
|
-
.why p { margin: 0 0 .7rem; }
|
|
6085
|
-
h4 { font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 1.1rem 0 .4rem; }
|
|
6086
|
-
ol, ul { margin: 0; padding-left: 1.3rem; }
|
|
6087
|
-
li { margin-bottom: .45rem; }
|
|
6088
|
-
.human {
|
|
6089
|
-
background: var(--human-bg); border-left: 3px solid var(--human-line);
|
|
6090
|
-
padding: .1rem 1rem .8rem; margin-top: 1rem; border-radius: 0 4px 4px 0;
|
|
6091
|
-
}
|
|
6092
|
-
.human h4 { color: var(--fg); }
|
|
6093
|
-
.opted-out { color: var(--muted); font-size: .85rem; margin-top: 1.5rem; }
|
|
6094
|
-
.clean-note { border: 1px solid var(--line); border-radius: 6px; padding: 1.25rem; }
|
|
6095
|
-
.incomplete { border: 1px solid var(--warn); border-radius: 6px; padding: 1rem 1.25rem; margin-top: 1.5rem; }
|
|
6096
|
-
.incomplete h2 { font-size: 1rem; margin: 0 0 .5rem; }
|
|
6097
|
-
.incomplete ul { margin: 0 0 .75rem; padding-left: 1.25rem; }
|
|
6098
|
-
.incomplete li { margin-bottom: .25rem; }
|
|
6099
|
-
.incomplete p:last-child { margin-bottom: 0; }
|
|
6100
|
-
.clean-note p:last-child { margin-bottom: 0; }
|
|
6101
|
-
footer { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .82rem; }
|
|
6102
|
-
a { color: inherit; }
|
|
6103
|
-
</style>
|
|
7851
|
+
<style>${STYLE}</style>
|
|
6104
7852
|
</head>
|
|
6105
7853
|
<body>
|
|
6106
|
-
<
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
${baselineNotice}
|
|
6121
|
-
${staleNotice}
|
|
6122
|
-
${silenced}
|
|
6123
|
-
${ruleSelection}
|
|
6124
|
-
${optedOut}
|
|
6125
|
-
<footer>
|
|
6126
|
-
Generated by canship. Everything ran locally; nothing was uploaded.
|
|
6127
|
-
</footer>
|
|
6128
|
-
</main>
|
|
7854
|
+
<div class="page">
|
|
7855
|
+
<header class="mast"><span class="root"><b>canship</b>${opts.version ? ` ${esc(opts.version)}` : ""} \xB7 ${esc(opts.root)}</span><span class="right">${esc(displayTime(opts.generatedAt))}${allPrompt ? '<button type="button" class="link js-only" data-copy="all">copy fix prompt</button>' : ""}<button type="button" class="link js-only" id="print">print</button></span></header>
|
|
7856
|
+
<section class="verdict-block">
|
|
7857
|
+
<div>${verdict}${explanation ? `<p>${esc(explanation)}</p>` : ""}${result.changeView ? `<p>${esc(changeViewNotice(result.changeView))}</p>` : ""}
|
|
7858
|
+
<p class="notice">Credential values canship recognises are masked in this report. One in a format it has no pattern for can still appear inside a quoted line, and this report lists your file paths and project structure either way \u2014 so treat it as internal, shareable with your team rather than something to post publicly.</p></div>
|
|
7859
|
+
<dl class="facts">${facts}</dl>
|
|
7860
|
+
</section>
|
|
7861
|
+
${findingsSection}
|
|
7862
|
+
${incomplete}
|
|
7863
|
+
${notes.length > 0 ? `<section class="section last notes">${notes.map((n) => `<p>${n}</p>`).join("")}</section>` : ""}
|
|
7864
|
+
<p class="colophon">Generated by canship. Everything ran locally; nothing was uploaded. Findings describe static evidence in this repository; they do not verify deployed configuration, credential validity or business authorisation.</p>
|
|
7865
|
+
</div>
|
|
7866
|
+
<script type="application/json" id="canship-data">${jsonForScript(data)}</script>
|
|
7867
|
+
<script>${SCRIPT}</script>
|
|
6129
7868
|
</body>
|
|
6130
7869
|
</html>
|
|
6131
7870
|
`;
|
|
6132
7871
|
}
|
|
6133
7872
|
|
|
6134
7873
|
// src/baseline.ts
|
|
6135
|
-
import { createHash as
|
|
7874
|
+
import { createHash as createHash4 } from "crypto";
|
|
6136
7875
|
import { readFileSync as readFileSync3, statSync as statSync3, writeFileSync } from "fs";
|
|
6137
7876
|
var BASELINE_VERSION = 2;
|
|
6138
7877
|
var DEFAULT_BASELINE_PATH = "canship-baseline.json";
|
|
6139
7878
|
function fingerprintOf(f) {
|
|
6140
7879
|
const identity = [f.ruleId, f.file ?? "", f.title, f.sourceFingerprint ?? f.excerpt ?? ""].join("\0");
|
|
6141
|
-
return
|
|
7880
|
+
return createHash4("sha256").update(identity, "utf8").digest("hex");
|
|
6142
7881
|
}
|
|
6143
7882
|
function buildBaseline(findings, now = /* @__PURE__ */ new Date()) {
|
|
6144
7883
|
const byFingerprint = /* @__PURE__ */ new Map();
|
|
@@ -6519,6 +8258,14 @@ var RULE_CATALOG = [
|
|
|
6519
8258
|
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
|
|
6520
8259
|
"Session-scoped clients and indirect guards may need manual review."
|
|
6521
8260
|
),
|
|
8261
|
+
rule(
|
|
8262
|
+
"auth/unverified-session",
|
|
8263
|
+
"Server code trusting supabase.auth.getSession()",
|
|
8264
|
+
"P1",
|
|
8265
|
+
"varies",
|
|
8266
|
+
"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.",
|
|
8267
|
+
"Only the receiving variable in the same function is followed; results passed to helpers are not tracked."
|
|
8268
|
+
),
|
|
6522
8269
|
rule(
|
|
6523
8270
|
"cors/reflected-origin-with-credentials",
|
|
6524
8271
|
"Reflected origin with credentials",
|
|
@@ -6591,6 +8338,30 @@ var RULE_CATALOG = [
|
|
|
6591
8338
|
"Tracked environment files; templates and public-only values are treated separately.",
|
|
6592
8339
|
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
6593
8340
|
),
|
|
8341
|
+
rule(
|
|
8342
|
+
"injection/command",
|
|
8343
|
+
"Shell command built from request input",
|
|
8344
|
+
"P1",
|
|
8345
|
+
"varies",
|
|
8346
|
+
"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.",
|
|
8347
|
+
"Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked."
|
|
8348
|
+
),
|
|
8349
|
+
rule(
|
|
8350
|
+
"injection/sql",
|
|
8351
|
+
"SQL query built from request input",
|
|
8352
|
+
"P1",
|
|
8353
|
+
"varies",
|
|
8354
|
+
"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.",
|
|
8355
|
+
"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."
|
|
8356
|
+
),
|
|
8357
|
+
rule(
|
|
8358
|
+
"redirect/open",
|
|
8359
|
+
"Redirect to an address taken from the request",
|
|
8360
|
+
"P2",
|
|
8361
|
+
"varies",
|
|
8362
|
+
"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.",
|
|
8363
|
+
"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."
|
|
8364
|
+
),
|
|
6594
8365
|
rule(
|
|
6595
8366
|
"supabase/permissive-policy",
|
|
6596
8367
|
"Row Level Security policy with an always-true condition",
|
|
@@ -6607,6 +8378,14 @@ var RULE_CATALOG = [
|
|
|
6607
8378
|
"Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
|
|
6608
8379
|
"Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
|
|
6609
8380
|
),
|
|
8381
|
+
rule(
|
|
8382
|
+
"ssrf/request-url",
|
|
8383
|
+
"Server request to an address the caller chooses",
|
|
8384
|
+
"P1",
|
|
8385
|
+
"likely",
|
|
8386
|
+
"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.",
|
|
8387
|
+
"Only flows inside one function are followed. Fetching caller-supplied URLs can be intended, and allowlists or network controls are not verified."
|
|
8388
|
+
),
|
|
6610
8389
|
rule(
|
|
6611
8390
|
"supabase/rls-not-enabled",
|
|
6612
8391
|
"Table without RLS in migrations",
|
|
@@ -6615,6 +8394,14 @@ var RULE_CATALOG = [
|
|
|
6615
8394
|
"Supabase SQL migrations, replayed by application scope.",
|
|
6616
8395
|
"Migration state is not deployed database state; grants and policy correctness are outside this check."
|
|
6617
8396
|
),
|
|
8397
|
+
rule(
|
|
8398
|
+
"webhook/unverified-signature",
|
|
8399
|
+
"Stripe webhook events handled without signature verification",
|
|
8400
|
+
"P1",
|
|
8401
|
+
"varies",
|
|
8402
|
+
"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.",
|
|
8403
|
+
"Only Stripe is recognized. Unused calls do not verify events; custom HMAC and shared-helper verification require review."
|
|
8404
|
+
),
|
|
6618
8405
|
...SECRET_PATTERNS.map((pattern) => ({
|
|
6619
8406
|
...rule(
|
|
6620
8407
|
`secrets/hardcoded/${pattern.id}`,
|
|
@@ -6683,8 +8470,94 @@ function changedFileView(result, changed) {
|
|
|
6683
8470
|
} };
|
|
6684
8471
|
}
|
|
6685
8472
|
|
|
8473
|
+
// src/open.ts
|
|
8474
|
+
import { spawn } from "child_process";
|
|
8475
|
+
import { accessSync as accessSync2, constants as constants2, realpathSync as realpathSync3, statSync as statSync5 } from "fs";
|
|
8476
|
+
import { dirname as dirname2, posix as posix2, win32 } from "path";
|
|
8477
|
+
var UNIX_SYSTEM_DIRS = ["/usr/bin", "/bin", "/usr/local/bin", "/usr/sbin", "/sbin", "/usr/local/sbin"];
|
|
8478
|
+
function pathValue(env) {
|
|
8479
|
+
return Object.entries(env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
|
|
8480
|
+
}
|
|
8481
|
+
function unixBoundary(env, cwd, options) {
|
|
8482
|
+
const canonical2 = (path) => {
|
|
8483
|
+
try {
|
|
8484
|
+
const real = (options.realpath ?? realpathSync3)(path);
|
|
8485
|
+
return posix2.isAbsolute(real) ? posix2.normalize(real) : null;
|
|
8486
|
+
} catch {
|
|
8487
|
+
return null;
|
|
8488
|
+
}
|
|
8489
|
+
};
|
|
8490
|
+
const named = [cwd, options.root ?? cwd, env.INIT_CWD, env.npm_config_local_prefix].filter((path) => !!path && posix2.isAbsolute(path)).map((path) => posix2.normalize(path));
|
|
8491
|
+
const resolved = named.map(canonical2);
|
|
8492
|
+
if (!canonical2(cwd) || !canonical2(options.root ?? cwd)) return () => false;
|
|
8493
|
+
const roots = [...named, ...resolved.filter((p) => p !== null)];
|
|
8494
|
+
const outside = (path) => !/(?:^|\/)node_modules(?:\/|$)/.test(path) && roots.every((root) => {
|
|
8495
|
+
const rel = posix2.relative(root, path);
|
|
8496
|
+
return rel === ".." || rel.startsWith("../") || posix2.isAbsolute(rel);
|
|
8497
|
+
});
|
|
8498
|
+
return (path) => {
|
|
8499
|
+
if (!posix2.isAbsolute(path) || path.includes("\0")) return false;
|
|
8500
|
+
const real = canonical2(path);
|
|
8501
|
+
return real !== null && outside(posix2.normalize(path)) && outside(real);
|
|
8502
|
+
};
|
|
8503
|
+
}
|
|
8504
|
+
function trustedUnixPath(env, cwd, options = {}) {
|
|
8505
|
+
const trusted = unixBoundary(env, cwd, options);
|
|
8506
|
+
return [...new Set([...UNIX_SYSTEM_DIRS, ...pathValue(env).split(":")].filter(trusted).map((dir) => posix2.normalize(dir).replace(/(.)\/$/, "$1")))];
|
|
8507
|
+
}
|
|
8508
|
+
function executable(path) {
|
|
8509
|
+
try {
|
|
8510
|
+
accessSync2(path, constants2.X_OK);
|
|
8511
|
+
return statSync5(path).isFile();
|
|
8512
|
+
} catch {
|
|
8513
|
+
return false;
|
|
8514
|
+
}
|
|
8515
|
+
}
|
|
8516
|
+
function openerFor(platform, path, env = process.env, options = {}) {
|
|
8517
|
+
if (platform === "win32") {
|
|
8518
|
+
const root = Object.entries(env).find(([key]) => key.toLowerCase() === "systemroot")?.[1];
|
|
8519
|
+
if (!root || !/^[a-z]:[\\/]/i.test(root) || root.includes("\0")) throw new Error("Windows system directory is unavailable");
|
|
8520
|
+
return { command: win32.join(root, "explorer.exe"), args: [path] };
|
|
8521
|
+
}
|
|
8522
|
+
if (platform === "darwin") return { command: "/usr/bin/open", args: [path] };
|
|
8523
|
+
const exists = options.exists ?? executable;
|
|
8524
|
+
const cwd = options.cwd ?? process.cwd();
|
|
8525
|
+
const trusted = unixBoundary(env, cwd, options);
|
|
8526
|
+
for (const dir of trustedUnixPath(env, cwd, options)) {
|
|
8527
|
+
const candidate = posix2.join(dir, "xdg-open");
|
|
8528
|
+
if (trusted(candidate) && exists(candidate)) return { command: candidate, args: [path] };
|
|
8529
|
+
}
|
|
8530
|
+
throw new Error("xdg-open was not found in system directories or PATH outside the project");
|
|
8531
|
+
}
|
|
8532
|
+
function openerEnvironment(platform, command, env, cwd = process.cwd(), options = {}) {
|
|
8533
|
+
const clean2 = { ...env };
|
|
8534
|
+
for (const key of Object.keys(clean2)) if (key.toLowerCase() === "path") delete clean2[key];
|
|
8535
|
+
clean2.PATH = platform === "win32" ? `${win32.dirname(command)};${win32.join(win32.dirname(command), "System32")}` : trustedUnixPath(env, cwd, options).join(":");
|
|
8536
|
+
return clean2;
|
|
8537
|
+
}
|
|
8538
|
+
function canOpen(env, interactive) {
|
|
8539
|
+
return interactive && !env["CI"];
|
|
8540
|
+
}
|
|
8541
|
+
function openReport(path, onError, options = {}) {
|
|
8542
|
+
try {
|
|
8543
|
+
const { command, args } = openerFor(process.platform, path, process.env, options);
|
|
8544
|
+
const child = spawn(command, args, {
|
|
8545
|
+
detached: true,
|
|
8546
|
+
stdio: "ignore",
|
|
8547
|
+
windowsHide: true,
|
|
8548
|
+
shell: false,
|
|
8549
|
+
cwd: dirname2(command),
|
|
8550
|
+
env: openerEnvironment(process.platform, command, process.env, options.cwd, options)
|
|
8551
|
+
});
|
|
8552
|
+
child.on("error", (err) => onError(`could not open the report with ${command}: ${err.message}`));
|
|
8553
|
+
child.unref();
|
|
8554
|
+
} catch (err) {
|
|
8555
|
+
onError(`could not open the report: ${err instanceof Error ? err.message : String(err)}`);
|
|
8556
|
+
}
|
|
8557
|
+
}
|
|
8558
|
+
|
|
6686
8559
|
// src/cli.ts
|
|
6687
|
-
var VERSION = true ? "0.
|
|
8560
|
+
var VERSION = true ? "0.6.0" : "0.0.0-dev";
|
|
6688
8561
|
function finish(code) {
|
|
6689
8562
|
process.exitCode = code;
|
|
6690
8563
|
}
|
|
@@ -6715,7 +8588,7 @@ function realPathOf(path) {
|
|
|
6715
8588
|
const rest = [];
|
|
6716
8589
|
for (let depth = 0; depth < MAX_REAL_PATH_DEPTH; depth++) {
|
|
6717
8590
|
try {
|
|
6718
|
-
const real =
|
|
8591
|
+
const real = realpathSync4(at);
|
|
6719
8592
|
return rest.length === 0 ? real : resolve2(real, ...rest);
|
|
6720
8593
|
} catch {
|
|
6721
8594
|
const parent = resolve2(at, "..");
|
|
@@ -6754,7 +8627,9 @@ function parseArgs(argv) {
|
|
|
6754
8627
|
version: false,
|
|
6755
8628
|
listRules: false,
|
|
6756
8629
|
noExcerpts: false,
|
|
6757
|
-
changedSince: null
|
|
8630
|
+
changedSince: null,
|
|
8631
|
+
verbose: false,
|
|
8632
|
+
open: false
|
|
6758
8633
|
};
|
|
6759
8634
|
const positional = [];
|
|
6760
8635
|
for (const arg of argv) {
|
|
@@ -6833,6 +8708,12 @@ function parseArgs(argv) {
|
|
|
6833
8708
|
case "--no-excerpts":
|
|
6834
8709
|
args.noExcerpts = true;
|
|
6835
8710
|
break;
|
|
8711
|
+
case "--verbose":
|
|
8712
|
+
args.verbose = true;
|
|
8713
|
+
break;
|
|
8714
|
+
case "--open":
|
|
8715
|
+
args.open = true;
|
|
8716
|
+
break;
|
|
6836
8717
|
case "--help":
|
|
6837
8718
|
case "-h":
|
|
6838
8719
|
args.help = true;
|
|
@@ -6862,8 +8743,11 @@ var HELP = `
|
|
|
6862
8743
|
|
|
6863
8744
|
${bold("Options")}
|
|
6864
8745
|
-a, --all Show likely findings
|
|
8746
|
+
--verbose Show each finding's excerpt, explanation, trace and fix steps
|
|
6865
8747
|
--fix-prompt Output instructions to paste into a coding assistant
|
|
6866
8748
|
--report[=F] Write a self-contained HTML report (default canship-report.html)
|
|
8749
|
+
--open With --report, open the report in the default browser
|
|
8750
|
+
(skipped in CI and non-interactive shells)
|
|
6867
8751
|
--json Output raw JSON (for CI or tooling)
|
|
6868
8752
|
--best-effort Allow exit 0 for an incomplete scan with no findings;
|
|
6869
8753
|
findings still exit 1 or 2
|
|
@@ -6925,11 +8809,12 @@ async function main() {
|
|
|
6925
8809
|
if ((args.baseline !== null || args.baselineDefault) && (args.baselineWrite !== null || args.baselineWriteDefault)) {
|
|
6926
8810
|
argumentError("--baseline and --baseline-write are mutually exclusive");
|
|
6927
8811
|
}
|
|
6928
|
-
if (!existsSync3(args.root) || !
|
|
8812
|
+
if (!existsSync3(args.root) || !statSync6(args.root).isDirectory()) {
|
|
6929
8813
|
process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
|
|
6930
8814
|
`);
|
|
6931
8815
|
return finish(3);
|
|
6932
8816
|
}
|
|
8817
|
+
if (args.open && args.report === null) argumentError("--open requires --report");
|
|
6933
8818
|
if (args.changedSince !== null && (args.baselineWrite !== null || args.baselineWriteDefault)) {
|
|
6934
8819
|
argumentError("--changed-since cannot be combined with --baseline-write");
|
|
6935
8820
|
}
|
|
@@ -7030,6 +8915,7 @@ ${cleanForOutput(String(err))}
|
|
|
7030
8915
|
if (changed) result = changedFileView(result, changed);
|
|
7031
8916
|
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
7032
8917
|
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
8918
|
+
const exitCode = verdictOf(fullResult.findings).blocking > 0 ? 1 : fullResult.findings.length > 0 ? 2 : result.partial && !bestEffort ? 3 : 0;
|
|
7033
8919
|
if (args.fixPrompt) {
|
|
7034
8920
|
const prompt = renderFixPrompt(shown, {
|
|
7035
8921
|
...result.changeView ? { changeView: result.changeView } : {},
|
|
@@ -7071,7 +8957,10 @@ ${cleanForOutput(String(err))}
|
|
|
7071
8957
|
hiddenLikely,
|
|
7072
8958
|
baselineSuppressed,
|
|
7073
8959
|
baselineStale,
|
|
7074
|
-
baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
|
|
8960
|
+
baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath),
|
|
8961
|
+
verbose: args.verbose,
|
|
8962
|
+
version: VERSION,
|
|
8963
|
+
exitCode
|
|
7075
8964
|
}
|
|
7076
8965
|
)}
|
|
7077
8966
|
`
|
|
@@ -7094,8 +8983,7 @@ ${cleanForOutput(String(err))}
|
|
|
7094
8983
|
"utf8"
|
|
7095
8984
|
);
|
|
7096
8985
|
if (!args.json && !args.fixPrompt) {
|
|
7097
|
-
process.stdout.write(`
|
|
7098
|
-
|
|
8986
|
+
process.stdout.write(`SARIF written to ${cleanForOutput(target)}
|
|
7099
8987
|
`);
|
|
7100
8988
|
}
|
|
7101
8989
|
} catch (err) {
|
|
@@ -7117,6 +9005,7 @@ ${cleanForOutput(String(err))}
|
|
|
7117
9005
|
{
|
|
7118
9006
|
root: displayRoot,
|
|
7119
9007
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9008
|
+
version: VERSION,
|
|
7120
9009
|
hiddenLikely,
|
|
7121
9010
|
baselineSuppressed,
|
|
7122
9011
|
baselineStale,
|
|
@@ -7126,10 +9015,17 @@ ${cleanForOutput(String(err))}
|
|
|
7126
9015
|
"utf8"
|
|
7127
9016
|
);
|
|
7128
9017
|
if (!args.json && !args.fixPrompt) {
|
|
7129
|
-
process.stdout.write(`
|
|
7130
|
-
|
|
9018
|
+
process.stdout.write(`Report written to ${cleanForOutput(target)}
|
|
7131
9019
|
`);
|
|
7132
9020
|
}
|
|
9021
|
+
if (args.open) {
|
|
9022
|
+
if (canOpen(process.env, process.stdout.isTTY === true)) {
|
|
9023
|
+
openReport(target, (message) => process.stderr.write(`canship: ${cleanForOutput(message)}
|
|
9024
|
+
`), { root: args.root });
|
|
9025
|
+
} else {
|
|
9026
|
+
process.stderr.write("canship: not opening the report in CI or a non-interactive session\n");
|
|
9027
|
+
}
|
|
9028
|
+
}
|
|
7133
9029
|
} catch (err) {
|
|
7134
9030
|
process.stderr.write(
|
|
7135
9031
|
`${red("canship:")} could not write report to ${cleanForOutput(target)}
|
|
@@ -7139,10 +9035,7 @@ ${cleanForOutput(String(err))}
|
|
|
7139
9035
|
return finish(3);
|
|
7140
9036
|
}
|
|
7141
9037
|
}
|
|
7142
|
-
|
|
7143
|
-
if (fullResult.findings.length > 0) return finish(2);
|
|
7144
|
-
if (result.partial && !bestEffort) return finish(3);
|
|
7145
|
-
return finish(0);
|
|
9038
|
+
return finish(exitCode);
|
|
7146
9039
|
}
|
|
7147
9040
|
main().catch((err) => {
|
|
7148
9041
|
if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}
|