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/index.js
CHANGED
|
@@ -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++;
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
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++;
|
|
1317
1320
|
continue;
|
|
1318
1321
|
}
|
|
1319
|
-
|
|
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,12 +5301,1434 @@ var corsRule = {
|
|
|
5258
5301
|
}
|
|
5259
5302
|
};
|
|
5260
5303
|
|
|
5304
|
+
// src/rules/request-input.ts
|
|
5305
|
+
var MAX_VALUE_HOPS = 8;
|
|
5306
|
+
var MAX_ASSIGNMENTS = 512;
|
|
5307
|
+
var MAX_EXPRESSION = 4e3;
|
|
5308
|
+
var REQUEST_OBJECT_NAMES = /^(?:req|request|event|evt|ctx|context)$/;
|
|
5309
|
+
var DESTRUCTURED_OBJECTS = /* @__PURE__ */ new Set(["request", "req", "event"]);
|
|
5310
|
+
var DESTRUCTURED_URLS = /* @__PURE__ */ new Set(["url"]);
|
|
5311
|
+
var DESTRUCTURED_COOKIES = /* @__PURE__ */ new Set(["cookies"]);
|
|
5312
|
+
var DESTRUCTURED_VALUES = /* @__PURE__ */ new Set(["params", "query", "body", "searchParams"]);
|
|
5313
|
+
var TRANSPARENT_CALLS = /* @__PURE__ */ new Set([
|
|
5314
|
+
"json",
|
|
5315
|
+
"formData",
|
|
5316
|
+
"text",
|
|
5317
|
+
"arrayBuffer",
|
|
5318
|
+
"blob",
|
|
5319
|
+
"get",
|
|
5320
|
+
"getAll",
|
|
5321
|
+
"String",
|
|
5322
|
+
"trim",
|
|
5323
|
+
"trimStart",
|
|
5324
|
+
"trimEnd",
|
|
5325
|
+
"toString",
|
|
5326
|
+
"toLowerCase",
|
|
5327
|
+
"toUpperCase",
|
|
5328
|
+
"URL",
|
|
5329
|
+
"URLSearchParams",
|
|
5330
|
+
"decodeURIComponent",
|
|
5331
|
+
"decodeURI",
|
|
5332
|
+
"fromEntries",
|
|
5333
|
+
"entries",
|
|
5334
|
+
"readBody",
|
|
5335
|
+
"readFormData",
|
|
5336
|
+
"readRawBody",
|
|
5337
|
+
"getQuery",
|
|
5338
|
+
"getRouterParam",
|
|
5339
|
+
"getRouterParams",
|
|
5340
|
+
"getHeader",
|
|
5341
|
+
"getHeaders",
|
|
5342
|
+
"getRequestHeader",
|
|
5343
|
+
"getRequestHeaders",
|
|
5344
|
+
"getCookie",
|
|
5345
|
+
"parseCookies",
|
|
5346
|
+
"getRequestURL"
|
|
5347
|
+
]);
|
|
5348
|
+
var H3_READERS = "readBody|readFormData|readMultipartFormData|readRawBody|readValidatedBody|getQuery|getValidatedQuery|getRouterParams?|getValidatedRouterParams|getHeaders?|getRequestHeaders?|getCookie|parseCookies|getRequestURL";
|
|
5349
|
+
var LITERAL = String.raw`(?:'[^']*'|"[^"]*"|\x60[^\x60$]*\x60|-?\d+(?:\.\d+)?|null|undefined|true|false)`;
|
|
5350
|
+
var CLEAN_EXPRESSION = [
|
|
5351
|
+
/^(?:await\s+)?(?:Number|parseInt|parseFloat|Boolean|BigInt|isNaN|Math\s*\.\s*\w+|Number\s*\.\s*\w+)\s*\(/,
|
|
5352
|
+
/\.\s*length\s*$/,
|
|
5353
|
+
/^(?:!|typeof\b)/,
|
|
5354
|
+
/\.\s*(?:includes|test|has|startsWith|endsWith|some|every)\s*\([^?]*\)\s*$/,
|
|
5355
|
+
new RegExp(String.raw`^[^?]*\?\s*${LITERAL}\s*:\s*${LITERAL}\s*$`),
|
|
5356
|
+
// 带标签的模板由标签函数处理插值(Prisma.sql、sql、html),String.raw 除外。
|
|
5357
|
+
/^(?:await\s+)?(?!String\s*\.\s*raw\b)[\w$]+(?:\s*\.\s*[\w$]+)*\s*\x60/
|
|
5358
|
+
];
|
|
5359
|
+
var URL_INPUT_ACCESS = /\??\.\s*(?:searchParams|pathname|search|hash)\b/;
|
|
5360
|
+
var URL_INPUT_KEYS = /* @__PURE__ */ new Set(["searchParams", "pathname", "search", "hash"]);
|
|
5361
|
+
var URL_ORIGIN_ACCESS = /^\s*\??\.\s*(?:origin|host|hostname|protocol|port)\b/;
|
|
5362
|
+
var URL_ORIGIN_KEYS = /* @__PURE__ */ new Set(["origin", "host", "hostname", "protocol", "port"]);
|
|
5363
|
+
function isComparison(expr) {
|
|
5364
|
+
return !/\?/.test(expr) && !/=>/.test(expr) && /(?:===|!==|==|!=|<=|>=|\binstanceof\b)/.test(expr);
|
|
5365
|
+
}
|
|
5366
|
+
function parameterList(text) {
|
|
5367
|
+
const parts = [];
|
|
5368
|
+
let depth = 0, from = 0;
|
|
5369
|
+
for (let i = 0; i < text.length; i++) {
|
|
5370
|
+
const ch = text[i];
|
|
5371
|
+
if ("{[(<".includes(ch)) depth++;
|
|
5372
|
+
else if ("}])>".includes(ch)) depth--;
|
|
5373
|
+
else if (ch === "," && depth === 0) {
|
|
5374
|
+
parts.push(text.slice(from, i));
|
|
5375
|
+
from = i + 1;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
parts.push(text.slice(from));
|
|
5379
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
5380
|
+
}
|
|
5381
|
+
function destructuredKeys(pattern) {
|
|
5382
|
+
const inner = pattern.slice(1, pattern.lastIndexOf("}"));
|
|
5383
|
+
const result = [];
|
|
5384
|
+
for (const part of parameterList(inner)) {
|
|
5385
|
+
const m = /^(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=[\s\S]*)?$/.exec(part);
|
|
5386
|
+
if (m) result.push({ key: m[1], local: m[2] ?? m[1] });
|
|
5387
|
+
}
|
|
5388
|
+
return result;
|
|
5389
|
+
}
|
|
5390
|
+
function patternNames(pattern) {
|
|
5391
|
+
const text = pattern.trim();
|
|
5392
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) return [text];
|
|
5393
|
+
const stripped = text.replace(/[A-Za-z_$][\w$]*\s*:(?!:)/g, " ").replace(/=[^,}\]]*/g, " ");
|
|
5394
|
+
return stripped.match(/[A-Za-z_$][\w$]*/g) ?? [];
|
|
5395
|
+
}
|
|
5396
|
+
function paramRoles(paramsText, paramsStart, allInput) {
|
|
5397
|
+
const roles = { objects: /* @__PURE__ */ new Map(), urls: /* @__PURE__ */ new Map(), cookies: /* @__PURE__ */ new Map(), values: /* @__PURE__ */ new Map() };
|
|
5398
|
+
let offset = 0;
|
|
5399
|
+
for (const raw of parameterList(paramsText)) {
|
|
5400
|
+
const at = paramsStart + Math.max(0, paramsText.indexOf(raw, offset));
|
|
5401
|
+
offset = Math.max(offset, paramsText.indexOf(raw, offset) + raw.length);
|
|
5402
|
+
const param = raw.startsWith("{") || raw.startsWith("[") ? raw.slice(0, matchingClose(raw) + 1) : raw.replace(/^\.\.\./, "").split(/[:=]/)[0].trim();
|
|
5403
|
+
if (allInput) {
|
|
5404
|
+
for (const name of patternNames(param)) roles.values.set(name, at);
|
|
5405
|
+
continue;
|
|
5406
|
+
}
|
|
5407
|
+
if (param.startsWith("{")) {
|
|
5408
|
+
for (const { key, local } of destructuredKeys(param)) {
|
|
5409
|
+
if (DESTRUCTURED_OBJECTS.has(key)) roles.objects.set(local, at);
|
|
5410
|
+
else if (DESTRUCTURED_URLS.has(key)) roles.urls.set(local, at);
|
|
5411
|
+
else if (DESTRUCTURED_COOKIES.has(key)) roles.cookies.set(local, at);
|
|
5412
|
+
else if (DESTRUCTURED_VALUES.has(key)) roles.values.set(local, at);
|
|
5413
|
+
}
|
|
5414
|
+
} else if (REQUEST_OBJECT_NAMES.test(param)) roles.objects.set(param, at);
|
|
5415
|
+
}
|
|
5416
|
+
return roles;
|
|
5417
|
+
}
|
|
5418
|
+
function matchingClose(text) {
|
|
5419
|
+
let depth = 0;
|
|
5420
|
+
for (let i = 0; i < text.length; i++) {
|
|
5421
|
+
if ("{[".includes(text[i])) depth++;
|
|
5422
|
+
else if ("}]".includes(text[i]) && --depth === 0) return i;
|
|
5423
|
+
}
|
|
5424
|
+
return text.length - 1;
|
|
5425
|
+
}
|
|
5426
|
+
function parametersOf(code, body, pairs, openers) {
|
|
5427
|
+
const fn = /^function\s*\*?\s*(?:[\w$]+\s*)?\(/.exec(code.slice(body.declaration, body.declaration + 200));
|
|
5428
|
+
if (fn) {
|
|
5429
|
+
const open = body.declaration + fn[0].length - 1;
|
|
5430
|
+
const close2 = pairs.get(open);
|
|
5431
|
+
return close2 === void 0 ? null : { start: open + 1, end: close2 };
|
|
5432
|
+
}
|
|
5433
|
+
const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
|
|
5434
|
+
const close = window.lastIndexOf(")");
|
|
5435
|
+
if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
|
|
5436
|
+
const absolute = body.declaration - window.length + close;
|
|
5437
|
+
const open = openers.get(absolute);
|
|
5438
|
+
return open === void 0 ? null : { start: open + 1, end: absolute };
|
|
5439
|
+
}
|
|
5440
|
+
const single = /([A-Za-z_$][\w$]*)\s*$/.exec(window);
|
|
5441
|
+
return single ? { start: body.declaration - window.length + single.index, end: body.declaration - window.length + single.index + single[1].length } : null;
|
|
5442
|
+
}
|
|
5443
|
+
function expressionEnd(code, from, limit, pairs, truncated) {
|
|
5444
|
+
const stop = Math.min(limit, from + MAX_EXPRESSION);
|
|
5445
|
+
for (let i = from; i < stop; i++) {
|
|
5446
|
+
const ch = code[i];
|
|
5447
|
+
const close = pairs.get(i);
|
|
5448
|
+
if (close !== void 0) {
|
|
5449
|
+
if (close >= stop) {
|
|
5450
|
+
truncated();
|
|
5451
|
+
return stop;
|
|
5452
|
+
}
|
|
5453
|
+
i = close;
|
|
5454
|
+
continue;
|
|
5455
|
+
}
|
|
5456
|
+
if (ch === ";" || ch === "," || ch === ")" || ch === "]" || ch === "}") return i;
|
|
5457
|
+
if (ch === "\n") {
|
|
5458
|
+
const before = code.slice(from, i).trimEnd();
|
|
5459
|
+
const after = code.slice(i + 1, i + 80).trimStart();
|
|
5460
|
+
if (before === "" || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
|
|
5461
|
+
if (/^(?:[.?+\-*/%&|^,:<>=]|\?\?)/.test(after) && !/^(?:\+\+|--)/.test(after)) continue;
|
|
5462
|
+
return i;
|
|
5463
|
+
}
|
|
5464
|
+
}
|
|
5465
|
+
if (stop < limit && !/[;,)}\]\n]/.test(code[stop] ?? "")) truncated();
|
|
5466
|
+
return stop;
|
|
5467
|
+
}
|
|
5468
|
+
function literalTable(text) {
|
|
5469
|
+
const value = text.trim();
|
|
5470
|
+
const item = new RegExp(`^${LITERAL}$`);
|
|
5471
|
+
const property = new RegExp(`^(?:[A-Za-z_$][\\w$]*|'[^']*'|"[^"]*")\\s*:\\s*${LITERAL}$`);
|
|
5472
|
+
if (!(value.startsWith("{") && value.endsWith("}")) && !(value.startsWith("[") && value.endsWith("]"))) return false;
|
|
5473
|
+
return parameterList(value.slice(1, -1)).every((part) => (value[0] === "{" ? property : item).test(part));
|
|
5474
|
+
}
|
|
5475
|
+
function fixedTablesOf(code, source, pairs) {
|
|
5476
|
+
const result = /* @__PURE__ */ new Map();
|
|
5477
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5478
|
+
for (const m of code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/g)) {
|
|
5479
|
+
const name = m[1];
|
|
5480
|
+
if (seen.has(name)) {
|
|
5481
|
+
result.delete(name);
|
|
5482
|
+
continue;
|
|
5483
|
+
}
|
|
5484
|
+
seen.add(name);
|
|
5485
|
+
const open = m.index + m[0].length;
|
|
5486
|
+
const close = pairs.get(open);
|
|
5487
|
+
if (!m[0].startsWith("const") || close === void 0 || close - open > MAX_EXPRESSION) continue;
|
|
5488
|
+
if (literalTable(source.slice(open, close + 1))) result.set(name, m.index);
|
|
5489
|
+
}
|
|
5490
|
+
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)(?:\s*\.\s*[\w$]+|\s*\[[^\]]*\])?\s*(?:\+\+|--|[+*/%&|^-]?=(?![=>]))/g)) {
|
|
5491
|
+
if (!/\bconst\s+$/.test(code.slice(Math.max(0, m.index - 20), m.index))) result.delete(m[1]);
|
|
5492
|
+
}
|
|
5493
|
+
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g)) {
|
|
5494
|
+
result.delete(m[1]);
|
|
5495
|
+
}
|
|
5496
|
+
for (const m of code.matchAll(/\b(?:Object\s*\.\s*(?:assign|defineProperty|defineProperties|setPrototypeOf)|Reflect\s*\.\s*(?:set|defineProperty|setPrototypeOf))\s*\(\s*([A-Za-z_$][\w$]*)\b/g)) {
|
|
5497
|
+
result.delete(m[1]);
|
|
5498
|
+
}
|
|
5499
|
+
return result;
|
|
5500
|
+
}
|
|
5501
|
+
var InputFlow = class {
|
|
5502
|
+
constructor(code, source, span, pairs, roles, fixedTables) {
|
|
5503
|
+
this.code = code;
|
|
5504
|
+
this.source = source;
|
|
5505
|
+
this.span = span;
|
|
5506
|
+
this.pairs = pairs;
|
|
5507
|
+
this.fixedTables = fixedTables;
|
|
5508
|
+
for (const [name, at] of roles.values) this.parameters.set(name, { level: "direct", origin: at, names: /* @__PURE__ */ new Set([name]), ownUrl: false });
|
|
5509
|
+
this.sourcePattern = sourceRegex(roles);
|
|
5510
|
+
const assignments = this.assignments().sort((a, b) => a.at - b.at);
|
|
5511
|
+
const region = code.slice(span.start, span.end);
|
|
5512
|
+
const functions = this.localFunctions(region, assignments);
|
|
5513
|
+
const scopes = [span];
|
|
5514
|
+
for (let i = span.start + 1; i < span.end; i++) {
|
|
5515
|
+
const end = code[i] === "{" ? pairs.get(i) : void 0;
|
|
5516
|
+
if (end !== void 0) scopes.push({ start: i, end });
|
|
5517
|
+
}
|
|
5518
|
+
const controls = [];
|
|
5519
|
+
for (const m of region.matchAll(/\b(?:if|for|while|switch|catch)\s*\(|\belse\b/g)) {
|
|
5520
|
+
const open = span.start + m.index + m[0].length - 1;
|
|
5521
|
+
let start = m[0] === "else" ? open + 1 : (pairs.get(open) ?? open) + 1;
|
|
5522
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
5523
|
+
controls.push({ start, end: pairs.get(start) ?? expressionEnd(code, start, span.end, pairs, () => {
|
|
5524
|
+
this.limited = true;
|
|
5525
|
+
}) });
|
|
5526
|
+
if (controls.length >= MAX_ASSIGNMENTS) {
|
|
5527
|
+
this.limited = true;
|
|
5528
|
+
break;
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
for (const a of assignments) {
|
|
5532
|
+
const owner = functions.filter((f) => f.start < a.at && a.at < f.end).at(-1);
|
|
5533
|
+
if (owner) a.owner = owner;
|
|
5534
|
+
a.scope = scopes.filter((s) => s.start < a.at && a.at < s.end).at(-1) ?? span;
|
|
5535
|
+
if (a.owner && a.owner.start > a.scope.start) a.scope = a.owner;
|
|
5536
|
+
a.controls = controls.filter((s) => s.start <= a.at && a.at <= s.end);
|
|
5537
|
+
for (const name of a.names) {
|
|
5538
|
+
const list = this.assignmentList.get(name) ?? [];
|
|
5539
|
+
if (!list.some((item) => item.exprAt === a.exprAt)) list.push(a);
|
|
5540
|
+
this.assignmentList.set(name, list);
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
const names = /* @__PURE__ */ new Set([...this.parameters.keys(), ...this.assignmentList.keys()]);
|
|
5544
|
+
if (names.size) this.references = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`, "g");
|
|
5545
|
+
}
|
|
5546
|
+
code;
|
|
5547
|
+
source;
|
|
5548
|
+
span;
|
|
5549
|
+
pairs;
|
|
5550
|
+
fixedTables;
|
|
5551
|
+
parameters = /* @__PURE__ */ new Map();
|
|
5552
|
+
limited = false;
|
|
5553
|
+
assignmentList = /* @__PURE__ */ new Map();
|
|
5554
|
+
sourcePattern;
|
|
5555
|
+
references = null;
|
|
5556
|
+
values = /* @__PURE__ */ new Map();
|
|
5557
|
+
strings = /* @__PURE__ */ new Map();
|
|
5558
|
+
/** 只索引当前处理函数;未引用的具名闭包不影响外层值。 */
|
|
5559
|
+
localFunctions(region, assignments) {
|
|
5560
|
+
const offset = this.span.start;
|
|
5561
|
+
const bodies = functionBodies(region, delimiterPairs(region)).map((f) => ({
|
|
5562
|
+
declaration: f.declaration + offset,
|
|
5563
|
+
start: f.start + offset,
|
|
5564
|
+
end: f.end + offset
|
|
5565
|
+
}));
|
|
5566
|
+
for (const m of region.matchAll(/=>\s*(?!\s*\{)/g)) {
|
|
5567
|
+
const start = offset + m.index + m[0].length;
|
|
5568
|
+
const end = expressionEnd(this.code, start, this.span.end, this.pairs, () => {
|
|
5569
|
+
this.limited = true;
|
|
5570
|
+
});
|
|
5571
|
+
bodies.push({ declaration: offset + m.index, start: start - 1, end });
|
|
5572
|
+
}
|
|
5573
|
+
if (bodies.length > MAX_ASSIGNMENTS) this.limited = true;
|
|
5574
|
+
return bodies.slice(0, MAX_ASSIGNMENTS).sort((a, b) => a.start - b.start).map((f) => {
|
|
5575
|
+
const binding = assignments.filter((a) => {
|
|
5576
|
+
if (!a.declaration || a.exprAt > f.declaration || a.exprAt + a.expr.length < f.end) return false;
|
|
5577
|
+
const prefix = this.code.slice(a.exprAt, f.declaration).trim();
|
|
5578
|
+
return prefix === "" || prefix === "async" || /^(?:async\s*)?(?:\([^;{}]*\)|[\w$]+)\s*(?::[^=;{}]*)?$/.test(prefix);
|
|
5579
|
+
}).sort((a, b) => b.exprAt - a.exprAt)[0];
|
|
5580
|
+
const declared = /^function\s*\*?\s*([\w$]+)\s*\(/.exec(this.code.slice(f.declaration, f.start))?.[1];
|
|
5581
|
+
const before = this.code.slice(this.span.start, f.declaration).trimEnd().replace(/\basync$/, "").trimEnd();
|
|
5582
|
+
const name = binding?.names.length === 1 ? binding.names[0] : /[;{}]$/.test(before) ? declared : void 0;
|
|
5583
|
+
if (/^\s*\)*\s*(?:\(|\.\s*(?:call|apply)\s*\()/.test(this.code.slice(f.end + 1, this.span.end))) {
|
|
5584
|
+
return { ...f, usedAt: f.end };
|
|
5585
|
+
}
|
|
5586
|
+
if (!name) return { ...f, usedAt: f.end };
|
|
5587
|
+
const start = binding?.at ?? f.declaration;
|
|
5588
|
+
const reference = new RegExp(`(?<![\\w$.])${namePattern([name])}(?![\\w$])`, "g");
|
|
5589
|
+
const use = [...region.matchAll(reference)].find((m) => offset + m.index < start || offset + m.index > f.end);
|
|
5590
|
+
return { ...f, usedAt: use ? offset + use.index : Infinity };
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5593
|
+
/** 表达式是否携带请求输入;已转换为数值、布尔或查表结果的不算。 */
|
|
5594
|
+
taintOf(expr, exprAt, depth = 0) {
|
|
5595
|
+
if (expr.length > MAX_EXPRESSION) this.limited = true;
|
|
5596
|
+
const text = expr.trim();
|
|
5597
|
+
if (text === "" || CLEAN_EXPRESSION.some((pattern) => pattern.test(text)) || isComparison(text)) return null;
|
|
5598
|
+
const lookup = new RegExp(`^([A-Za-z_$][\\w$]*)\\s*\\[[^\\]]*\\]\\s*(?:(?:\\?\\?|\\|\\|)\\s*${LITERAL})?\\s*$`).exec(text);
|
|
5599
|
+
if (lookup && this.isFixedTable(lookup[1], exprAt)) return null;
|
|
5600
|
+
const observed = expr.replace(/\[\s*['"][^'"]*['"]\s*\]/g, (part, offset) => {
|
|
5601
|
+
const raw = this.source.slice(exprAt + offset, exprAt + offset + part.length);
|
|
5602
|
+
const key = /^\[\s*['"]([A-Za-z_$][\w$]*)['"]\s*\]$/.exec(raw)?.[1];
|
|
5603
|
+
return key ? `.${key}`.padEnd(part.length) : part;
|
|
5604
|
+
});
|
|
5605
|
+
let found = null;
|
|
5606
|
+
if (this.sourcePattern) {
|
|
5607
|
+
this.sourcePattern.lastIndex = 0;
|
|
5608
|
+
for (const m of observed.matchAll(this.sourcePattern)) {
|
|
5609
|
+
const after = observed.slice(m.index + m[0].length);
|
|
5610
|
+
const ownUrl = m[1] === "url" || m[1] === "nextUrl";
|
|
5611
|
+
if (ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
|
|
5612
|
+
found = mergeTaint(found, {
|
|
5613
|
+
level: "direct",
|
|
5614
|
+
origin: exprAt + m.index,
|
|
5615
|
+
names: /* @__PURE__ */ new Set(),
|
|
5616
|
+
ownUrl: ownUrl && !URL_INPUT_ACCESS.test(after.slice(0, 40))
|
|
5617
|
+
});
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
const refs = this.references && new RegExp(this.references.source, "g");
|
|
5621
|
+
if (refs) {
|
|
5622
|
+
refs.lastIndex = 0;
|
|
5623
|
+
for (const m of expr.matchAll(refs)) {
|
|
5624
|
+
const before = expr.slice(0, m.index).trimEnd();
|
|
5625
|
+
const after = expr.slice(m.index + m[0].length);
|
|
5626
|
+
if (/^\s*:(?!:)/.test(after) && /[{,]$/.test(before)) continue;
|
|
5627
|
+
const t = this.valueOf(m[0], exprAt, depth);
|
|
5628
|
+
if (!t) continue;
|
|
5629
|
+
if (t.ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
|
|
5630
|
+
found = mergeTaint(found, t.ownUrl && URL_INPUT_ACCESS.test(after.slice(0, 40)) ? { ...t, ownUrl: false } : t);
|
|
5631
|
+
}
|
|
5632
|
+
}
|
|
5633
|
+
if (!found) return null;
|
|
5634
|
+
if (found.ownUrl && URL_INPUT_ACCESS.test(text)) found.ownUrl = false;
|
|
5635
|
+
const calls = [...text.replace(/\bJSON\s*\.\s*parse\s*\(/g, "(").matchAll(/([\w$]+)\s*\(/g)].map((m) => m[1]);
|
|
5636
|
+
if (calls.some((name) => !TRANSPARENT_CALLS.has(name))) found.level = "derived";
|
|
5637
|
+
return found;
|
|
5638
|
+
}
|
|
5639
|
+
/** 字符串拼接:无标签模板的插值或含字符串操作数的 + 拼接中携带请求输入。 */
|
|
5640
|
+
builtOf(expr, exprAt, depth = 0) {
|
|
5641
|
+
if (expr.length > MAX_EXPRESSION) this.limited = true;
|
|
5642
|
+
const text = expr.trim();
|
|
5643
|
+
const lead = expr.length - expr.trimStart().length;
|
|
5644
|
+
if (text.startsWith("`")) return this.interpolationTaint(exprAt + lead, exprAt + lead + text.length, depth);
|
|
5645
|
+
const operands = this.topLevelOperands(expr, exprAt);
|
|
5646
|
+
if (operands.length < 2 || !operands.some((o) => /^['"\x60]/.test(o.text.trim()))) return null;
|
|
5647
|
+
let found = null;
|
|
5648
|
+
for (const operand of operands) {
|
|
5649
|
+
const t = operand.text.trim().startsWith("`") ? this.interpolationTaint(operand.at, operand.at + operand.text.length, depth) : this.taintOf(operand.text, operand.at, depth);
|
|
5650
|
+
if (t) found = mergeTaint(found, t);
|
|
5651
|
+
}
|
|
5652
|
+
return found;
|
|
5653
|
+
}
|
|
5654
|
+
/** 仅保留使用位置可见的赋值;无条件覆盖终止旧值,分支与追加赋值保留可能来源。 */
|
|
5655
|
+
assignmentsFor(name, at) {
|
|
5656
|
+
const list = this.assignmentList.get(name) ?? [];
|
|
5657
|
+
const contains = (s, where) => s.start < where && where < s.end;
|
|
5658
|
+
const bindingAt = (where) => list.filter((a) => a.declaration && a.at <= where && contains(a.scope, where)).sort((a, b) => b.scope.start - a.scope.start || b.at - a.at)[0];
|
|
5659
|
+
const binding = bindingAt(at);
|
|
5660
|
+
const result = [];
|
|
5661
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
5662
|
+
const a = list[i];
|
|
5663
|
+
if (a.at >= at || a.exprAt + a.expr.length >= at) continue;
|
|
5664
|
+
if (a.declaration ? a !== binding : bindingAt(a.at) !== binding) continue;
|
|
5665
|
+
const indirect = a.owner !== void 0 && !contains(a.owner, at);
|
|
5666
|
+
if (indirect && a.owner.usedAt >= at) continue;
|
|
5667
|
+
result.push(indirect ? { ...a, indirect: true } : a);
|
|
5668
|
+
if (!indirect && !a.append && contains(a.scope, at) && a.controls.every((s) => contains(s, at))) break;
|
|
5669
|
+
}
|
|
5670
|
+
return result;
|
|
5671
|
+
}
|
|
5672
|
+
valueOf(name, at, depth = 0) {
|
|
5673
|
+
const key = `${name}:${at}:${depth}`;
|
|
5674
|
+
if (this.values.has(key)) return this.values.get(key);
|
|
5675
|
+
const result = this.resolveValue(name, at, depth);
|
|
5676
|
+
this.values.set(key, result);
|
|
5677
|
+
return result;
|
|
5678
|
+
}
|
|
5679
|
+
resolveValue(name, at, depth) {
|
|
5680
|
+
if (depth >= MAX_VALUE_HOPS) {
|
|
5681
|
+
this.limited = true;
|
|
5682
|
+
return null;
|
|
5683
|
+
}
|
|
5684
|
+
const assignments = this.assignmentsFor(name, at);
|
|
5685
|
+
if (!assignments.length) return this.parameters.get(name) ?? null;
|
|
5686
|
+
let found = null;
|
|
5687
|
+
for (const a of assignments) {
|
|
5688
|
+
const taint = this.builtOf(a.expr, a.exprAt, depth + 1) ?? this.taintOf(a.expr, a.exprAt, depth + 1);
|
|
5689
|
+
if (!taint) continue;
|
|
5690
|
+
const own = this.targetsOf(a, taint).find(([target]) => target === name);
|
|
5691
|
+
if (own) found = mergeTaint(found, { ...taint, level: a.indirect ? "derived" : taint.level, ownUrl: own[1], names: /* @__PURE__ */ new Set([name, ...taint.names]) });
|
|
5692
|
+
}
|
|
5693
|
+
if (this.parameters.has(name) && assignments.every((a) => a.append || !a.scope || a.scope.start !== this.span.start || a.controls.length)) {
|
|
5694
|
+
found = mergeTaint(found, this.parameters.get(name));
|
|
5695
|
+
}
|
|
5696
|
+
return found;
|
|
5697
|
+
}
|
|
5698
|
+
/** 保留有效拼接及别名的 SQL 文本证据,不借用已被覆盖的旧查询。 */
|
|
5699
|
+
builtValue(name, at, depth = 0) {
|
|
5700
|
+
const key = `${name}:${at}:${depth}`;
|
|
5701
|
+
if (this.strings.has(key)) return this.strings.get(key);
|
|
5702
|
+
const result = this.resolveBuiltValue(name, at, depth);
|
|
5703
|
+
this.strings.set(key, result);
|
|
5704
|
+
return result;
|
|
5705
|
+
}
|
|
5706
|
+
resolveBuiltValue(name, at, depth) {
|
|
5707
|
+
if (depth >= MAX_VALUE_HOPS) {
|
|
5708
|
+
this.limited = true;
|
|
5709
|
+
return null;
|
|
5710
|
+
}
|
|
5711
|
+
let found = null;
|
|
5712
|
+
let text = "";
|
|
5713
|
+
const append = (part) => {
|
|
5714
|
+
if (text.length + part.length + 1 > MAX_EXPRESSION) this.limited = true;
|
|
5715
|
+
text = `${text}
|
|
5716
|
+
${part}`.slice(0, MAX_EXPRESSION);
|
|
5717
|
+
};
|
|
5718
|
+
for (const a of this.assignmentsFor(name, at)) {
|
|
5719
|
+
append(this.source.slice(a.exprAt, a.exprAt + a.expr.length));
|
|
5720
|
+
const alias = /^[A-Za-z_$][\w$]*$/.test(a.expr.trim()) ? this.builtValue(a.expr.trim(), a.exprAt, depth + 1) : null;
|
|
5721
|
+
const t = this.builtOf(a.expr, a.exprAt, depth + 1) ?? alias ?? (a.append ? this.taintOf(a.expr, a.exprAt, depth + 1) : null);
|
|
5722
|
+
if (alias) append(alias.text);
|
|
5723
|
+
if (t) found = mergeTaint(found, a.indirect ? { ...t, level: "derived" } : t);
|
|
5724
|
+
}
|
|
5725
|
+
return found ? { ...found, names: /* @__PURE__ */ new Set([name, ...found.names]), text } : null;
|
|
5726
|
+
}
|
|
5727
|
+
isFixedTable(name, at) {
|
|
5728
|
+
const assignments = this.assignmentsFor(name, at);
|
|
5729
|
+
if (assignments.length) return assignments.every((a) => !a.append && literalTable(this.source.slice(a.exprAt, a.exprAt + a.expr.length))) && this.fixedTables.has(name);
|
|
5730
|
+
return (this.fixedTables.get(name) ?? Infinity) < at && !this.parameters.has(name);
|
|
5731
|
+
}
|
|
5732
|
+
/** 顶层 + 拼接的各操作数;括号与模板插值内部的 + 不拆分。 */
|
|
5733
|
+
operandsOf(expr, exprAt) {
|
|
5734
|
+
return this.topLevelOperands(expr, exprAt);
|
|
5735
|
+
}
|
|
5736
|
+
/** 使用前是否检查过内容:条件中除存在性判断外的引用,或作为校验函数实参。 */
|
|
5737
|
+
validatedBefore(names, at) {
|
|
5738
|
+
if (names.size === 0) return false;
|
|
5739
|
+
const region = this.code.slice(this.span.start, at);
|
|
5740
|
+
const pattern = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
|
|
5741
|
+
for (const m of region.matchAll(/\b(?:if|switch)\s*\(/g)) {
|
|
5742
|
+
const open = this.span.start + m.index + m[0].length - 1;
|
|
5743
|
+
const close = this.pairs.get(open);
|
|
5744
|
+
if (close === void 0 || close > at) continue;
|
|
5745
|
+
const condition = this.code.slice(open + 1, close);
|
|
5746
|
+
const alternatives = namePattern(names);
|
|
5747
|
+
const substantive = condition.replace(new RegExp(`!\\s*(?:${alternatives})(?![\\w$.(\\[])`, "g"), " ").replace(new RegExp(`(?<![\\w$.])(?:${alternatives})\\s*[!=]==?\\s*(?:null|undefined|''|""|0)`, "g"), " ").replace(new RegExp(`\\btypeof\\s+(?:${alternatives})\\b`, "g"), " ");
|
|
5748
|
+
if (pattern.test(substantive)) return true;
|
|
5749
|
+
}
|
|
5750
|
+
const validator = /(?:\.\s*(?:test|match|includes|has|startsWith|endsWith|parse|safeParse)|(?<![\w$.])(?:valid\w*|sanitiz\w*|escape\w*|is[A-Z]\w*|assert\w*))\s*\(/gi;
|
|
5751
|
+
for (const m of region.matchAll(validator)) {
|
|
5752
|
+
const open = this.span.start + m.index + m[0].length - 1;
|
|
5753
|
+
const close = this.pairs.get(open);
|
|
5754
|
+
if (close !== void 0 && close <= at && pattern.test(this.code.slice(open + 1, close))) return true;
|
|
5755
|
+
}
|
|
5756
|
+
return false;
|
|
5757
|
+
}
|
|
5758
|
+
interpolationTaint(from, to, depth) {
|
|
5759
|
+
let found = null;
|
|
5760
|
+
for (let i = from; i < to - 1; i++) {
|
|
5761
|
+
if (this.code[i] !== "$" || this.code[i + 1] !== "{") continue;
|
|
5762
|
+
const close = this.pairs.get(i + 1);
|
|
5763
|
+
if (close === void 0) continue;
|
|
5764
|
+
const t = this.taintOf(this.code.slice(i + 2, close), i + 2, depth);
|
|
5765
|
+
i = close;
|
|
5766
|
+
if (t) found = mergeTaint(found, t);
|
|
5767
|
+
}
|
|
5768
|
+
return found;
|
|
5769
|
+
}
|
|
5770
|
+
topLevelOperands(expr, exprAt) {
|
|
5771
|
+
const result = [];
|
|
5772
|
+
let from = 0;
|
|
5773
|
+
for (let i = 0; i < expr.length; i++) {
|
|
5774
|
+
const close = this.pairs.get(exprAt + i);
|
|
5775
|
+
if (close !== void 0) {
|
|
5776
|
+
i = close - exprAt;
|
|
5777
|
+
continue;
|
|
5778
|
+
}
|
|
5779
|
+
if (expr[i] === "+" && expr[i + 1] !== "+" && expr[i + 1] !== "=" && expr[i - 1] !== "+") {
|
|
5780
|
+
result.push({ text: expr.slice(from, i), at: exprAt + from });
|
|
5781
|
+
from = i + 1;
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
result.push({ text: expr.slice(from), at: exprAt + from });
|
|
5785
|
+
return result;
|
|
5786
|
+
}
|
|
5787
|
+
/**
|
|
5788
|
+
* 赋值目标及其是否仍只是本站 URL。从本站 URL 解构时按键区分:
|
|
5789
|
+
* searchParams、pathname 等为可控输入,origin、host 等不可控,其余保持本站 URL。
|
|
5790
|
+
*/
|
|
5791
|
+
targetsOf(a, taint) {
|
|
5792
|
+
if (!taint.ownUrl || !a.pattern.trim().startsWith("{")) return a.names.map((name) => [name, taint.ownUrl]);
|
|
5793
|
+
const targets = [];
|
|
5794
|
+
for (const { key, local } of destructuredKeys(a.pattern.trim())) {
|
|
5795
|
+
if (URL_ORIGIN_KEYS.has(key)) continue;
|
|
5796
|
+
targets.push([local, !URL_INPUT_KEYS.has(key)]);
|
|
5797
|
+
}
|
|
5798
|
+
return targets;
|
|
5799
|
+
}
|
|
5800
|
+
assignments() {
|
|
5801
|
+
const { code, span, pairs } = this;
|
|
5802
|
+
const region = code.slice(span.start, span.end);
|
|
5803
|
+
const found = [];
|
|
5804
|
+
const push = (a) => {
|
|
5805
|
+
if (found.length >= MAX_ASSIGNMENTS) {
|
|
5806
|
+
this.limited = true;
|
|
5807
|
+
return;
|
|
5808
|
+
}
|
|
5809
|
+
found.push(a);
|
|
5810
|
+
};
|
|
5811
|
+
for (const m of region.matchAll(/\b(?:const|let|var)\s+/g)) {
|
|
5812
|
+
let at = span.start + m.index + m[0].length;
|
|
5813
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
5814
|
+
let patternEnd;
|
|
5815
|
+
if (code[at] === "{" || code[at] === "[") {
|
|
5816
|
+
const close = pairs.get(at);
|
|
5817
|
+
if (close === void 0) break;
|
|
5818
|
+
patternEnd = close + 1;
|
|
5819
|
+
} else {
|
|
5820
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(code.slice(at, at + 200));
|
|
5821
|
+
if (!id) break;
|
|
5822
|
+
patternEnd = at + id[0].length;
|
|
5823
|
+
}
|
|
5824
|
+
const pattern = code.slice(at, patternEnd);
|
|
5825
|
+
const rest = code.slice(patternEnd, patternEnd + 400);
|
|
5826
|
+
const forOf = /^\s+of\s+/.exec(rest);
|
|
5827
|
+
if (forOf) {
|
|
5828
|
+
const exprAt2 = patternEnd + forOf[0].length;
|
|
5829
|
+
const end2 = expressionEnd(code, exprAt2, span.end, pairs, () => {
|
|
5830
|
+
this.limited = true;
|
|
5831
|
+
});
|
|
5832
|
+
push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt2, end2), exprAt: exprAt2, iterate: true, append: false, declaration: true });
|
|
5833
|
+
break;
|
|
5834
|
+
}
|
|
5835
|
+
const eq = /^\s*(?::[^=;]{0,200}?)?=(?![=>])/.exec(rest);
|
|
5836
|
+
if (!eq) break;
|
|
5837
|
+
const exprAt = patternEnd + eq[0].length;
|
|
5838
|
+
const end = expressionEnd(code, exprAt, span.end, pairs, () => {
|
|
5839
|
+
this.limited = true;
|
|
5840
|
+
});
|
|
5841
|
+
push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt, end), exprAt, iterate: false, append: false, declaration: true });
|
|
5842
|
+
if (code[end] !== ",") break;
|
|
5843
|
+
at = end + 1;
|
|
5844
|
+
while (/\s/.test(code[at] ?? "")) at++;
|
|
5845
|
+
}
|
|
5846
|
+
}
|
|
5847
|
+
for (const m of region.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(\+?=)(?![=>])/g)) {
|
|
5848
|
+
const before = region.slice(Math.max(0, m.index - 8), m.index);
|
|
5849
|
+
if (/\b(?:const|let|var)\s+$/.test(before)) continue;
|
|
5850
|
+
const exprAt = span.start + m.index + m[0].length;
|
|
5851
|
+
const end = expressionEnd(code, exprAt, span.end, pairs, () => {
|
|
5852
|
+
this.limited = true;
|
|
5853
|
+
});
|
|
5854
|
+
push({ at: span.start + m.index, pattern: m[1], names: [m[1]], expr: code.slice(exprAt, end), exprAt, iterate: false, append: m[2] === "+=" });
|
|
5855
|
+
}
|
|
5856
|
+
return found;
|
|
5857
|
+
}
|
|
5858
|
+
};
|
|
5859
|
+
function sourceRegex(roles) {
|
|
5860
|
+
const parts = [];
|
|
5861
|
+
if (roles.objects.size > 0) {
|
|
5862
|
+
const objects = namePattern(roles.objects.keys());
|
|
5863
|
+
parts.push(
|
|
5864
|
+
String.raw`(?<![\w$.])(?:${objects})(?:\s*\??\.\s*(?:request|event))?\s*\??\.\s*(json|formData|text|arrayBuffer|blob|body|query|params|url|nextUrl|headers|cookies|searchParams)\b`,
|
|
5865
|
+
String.raw`(?<![\w$.])(?:${objects})\s*\??\.\s*context\s*\??\.\s*params\b`,
|
|
5866
|
+
String.raw`\b(?:${H3_READERS})\s*(?:<[^()]{0,200}>\s*)?\(\s*(?:${objects})\b`
|
|
5867
|
+
);
|
|
5868
|
+
}
|
|
5869
|
+
if (roles.urls.size > 0) {
|
|
5870
|
+
parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.urls.keys())})\s*\??\.\s*(?:searchParams|pathname|search|hash)\b`);
|
|
5871
|
+
}
|
|
5872
|
+
if (roles.cookies.size > 0) {
|
|
5873
|
+
parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.cookies.keys())})\s*\??\.\s*get(?:All)?\s*\(`);
|
|
5874
|
+
}
|
|
5875
|
+
return parts.length === 0 ? null : new RegExp(parts.join("|"), "g");
|
|
5876
|
+
}
|
|
5877
|
+
function mergeTaint(found, t) {
|
|
5878
|
+
if (!found) return { level: t.level, origin: t.origin, names: new Set(t.names), ownUrl: t.ownUrl };
|
|
5879
|
+
if (t.level === "derived") found.level = "derived";
|
|
5880
|
+
if (found.ownUrl && !t.ownUrl) {
|
|
5881
|
+
found.ownUrl = false;
|
|
5882
|
+
found.origin = t.origin;
|
|
5883
|
+
}
|
|
5884
|
+
for (const n of t.names) found.names.add(n);
|
|
5885
|
+
return found;
|
|
5886
|
+
}
|
|
5887
|
+
function hasInput(roles) {
|
|
5888
|
+
return roles.objects.size + roles.urls.size + roles.cookies.size + roles.values.size > 0;
|
|
5889
|
+
}
|
|
5890
|
+
function lowerBound(values, value) {
|
|
5891
|
+
let low = 0, high = values.length;
|
|
5892
|
+
while (low < high) {
|
|
5893
|
+
const middle = low + high >>> 1;
|
|
5894
|
+
if (values[middle] < value) low = middle + 1;
|
|
5895
|
+
else high = middle;
|
|
5896
|
+
}
|
|
5897
|
+
return low;
|
|
5898
|
+
}
|
|
5899
|
+
var HandlerFile = class {
|
|
5900
|
+
constructor(code, source, pairs, lineStarts, candidates) {
|
|
5901
|
+
this.code = code;
|
|
5902
|
+
this.source = source;
|
|
5903
|
+
this.pairs = pairs;
|
|
5904
|
+
this.lineStarts = lineStarts;
|
|
5905
|
+
this.candidates = candidates;
|
|
5906
|
+
this.fixedTables = fixedTablesOf(code, source, pairs);
|
|
5907
|
+
}
|
|
5908
|
+
code;
|
|
5909
|
+
source;
|
|
5910
|
+
pairs;
|
|
5911
|
+
lineStarts;
|
|
5912
|
+
candidates;
|
|
5913
|
+
flows = /* @__PURE__ */ new Map();
|
|
5914
|
+
fixedTables;
|
|
5915
|
+
get limited() {
|
|
5916
|
+
for (const flow of this.flows.values()) if (flow.limited) return true;
|
|
5917
|
+
return false;
|
|
5918
|
+
}
|
|
5919
|
+
/** 至少包含一个给定位置的处理函数;positions 必须已按升序排列。 */
|
|
5920
|
+
handlersAround(positions) {
|
|
5921
|
+
const result = [];
|
|
5922
|
+
if (positions.length === 0) return result;
|
|
5923
|
+
for (const candidate of this.candidates) {
|
|
5924
|
+
const { body } = candidate;
|
|
5925
|
+
const first = lowerBound(positions, body.start + 1);
|
|
5926
|
+
if (first >= positions.length || positions[first] >= body.end) continue;
|
|
5927
|
+
let flow = this.flows.get(candidate);
|
|
5928
|
+
if (!flow) {
|
|
5929
|
+
flow = new InputFlow(this.code, this.source, { start: body.start, end: body.end }, this.pairs, candidate.roles, this.fixedTables);
|
|
5930
|
+
this.flows.set(candidate, flow);
|
|
5931
|
+
}
|
|
5932
|
+
result.push({ route: candidate.route, body, flow });
|
|
5933
|
+
}
|
|
5934
|
+
return result;
|
|
5935
|
+
}
|
|
5936
|
+
};
|
|
5937
|
+
var handlerCache = /* @__PURE__ */ new WeakMap();
|
|
5938
|
+
function handlerFileOf(file) {
|
|
5939
|
+
const cached = handlerCache.get(file);
|
|
5940
|
+
if (cached !== void 0) return cached;
|
|
5941
|
+
let result = null;
|
|
5942
|
+
if (/\.[mc]?[jt]sx?$/.test(file.path)) {
|
|
5943
|
+
const route = routeOf(file);
|
|
5944
|
+
const routes = route === null ? serverActionRoutes(file) : [route];
|
|
5945
|
+
if (routes.length > 0) {
|
|
5946
|
+
const code = noiseMaskedOf(file);
|
|
5947
|
+
const source = commentsMaskedOf(file);
|
|
5948
|
+
const pairs = delimiterPairs(code);
|
|
5949
|
+
const openers = /* @__PURE__ */ new Map();
|
|
5950
|
+
for (const [open, close] of pairs) openers.set(close, open);
|
|
5951
|
+
const bodies = functionBodies(code, pairs).sort((a, b) => a.start - b.start);
|
|
5952
|
+
const starts = bodies.map((body) => body.start);
|
|
5953
|
+
const candidates = [];
|
|
5954
|
+
for (const r of routes) {
|
|
5955
|
+
const reachable = r.reachable;
|
|
5956
|
+
const from = reachable ? lowerBound(starts, reachable.start) : 0;
|
|
5957
|
+
for (let i = from; i < bodies.length; i++) {
|
|
5958
|
+
const body = bodies[i];
|
|
5959
|
+
if (reachable && body.start >= reachable.end) break;
|
|
5960
|
+
if (reachable && body.end > reachable.end) continue;
|
|
5961
|
+
const action = r.action !== void 0 && reachable !== void 0 && body.start === reachable.start;
|
|
5962
|
+
const params = parametersOf(code, body, pairs, openers);
|
|
5963
|
+
if (!params) continue;
|
|
5964
|
+
const roles = paramRoles(source.slice(params.start, params.end), params.start, action);
|
|
5965
|
+
if (hasInput(roles)) candidates.push({ route: r, body, roles });
|
|
5966
|
+
}
|
|
5967
|
+
}
|
|
5968
|
+
if (candidates.length > 0) result = new HandlerFile(code, source, pairs, lineStartsOf(file.content), candidates);
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
handlerCache.set(file, result);
|
|
5972
|
+
return result;
|
|
5973
|
+
}
|
|
5974
|
+
function reportInputLimit(ctx, file, analysed) {
|
|
5975
|
+
if (analysed.limited) ctx.reportIncomplete(
|
|
5976
|
+
"request-input/tracking",
|
|
5977
|
+
`${file.path} reached the request-input tracking limit (8 value hops, 512 assignments or control/function regions, 4000 expression characters, or bounded URL analysis)`
|
|
5978
|
+
);
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
// src/rules/injection.ts
|
|
5982
|
+
var SQL_UNSAFE = /(?:\.\s*\$(?:queryRawUnsafe|executeRawUnsafe)|(?<![\w$.])Prisma\s*\.\s*raw|(?<![\w$.])sql\s*\.\s*(?:raw|unsafe))\s*\(/g;
|
|
5983
|
+
var SQL_RAW_FRAGMENT = /\.\s*(?:raw|whereRaw|orWhereRaw|andWhereRaw|havingRaw|orHavingRaw|orderByRaw|groupByRaw|joinRaw|fromRaw)\s*\(/g;
|
|
5984
|
+
var SQL_DRIVER = /\.\s*(?:query|execute|exec|prepare|run|all|get|unsafe|many|one|none|any|oneOrNone|manyOrNone)\s*\(/g;
|
|
5985
|
+
var SQL_CONDITION = /\.\s*(?:where|andWhere|orWhere|having|andHaving|orHaving)\s*\(/g;
|
|
5986
|
+
var LOOKS_LIKE_SQL = /\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bupdate\s+[\w."`]+\s+set\b|\bwhere\b|\border\s+by\b/i;
|
|
5987
|
+
var LOOKS_LIKE_CONDITION = /[=<>]|\b(?:like|ilike|in|is|between)\b/i;
|
|
5988
|
+
var CHILD_PROCESS = /^(?:node:)?child_process$/;
|
|
5989
|
+
var SHELL_EXPORTS = /* @__PURE__ */ new Set(["exec", "execSync"]);
|
|
5990
|
+
var SPAWN_EXPORTS = /* @__PURE__ */ new Set(["spawn", "spawnSync", "execFile", "execFileSync"]);
|
|
5991
|
+
function commandCallees(file) {
|
|
5992
|
+
const callees = { shell: /* @__PURE__ */ new Set(), spawn: /* @__PURE__ */ new Set(), commands: /* @__PURE__ */ new Set(), namespaces: /* @__PURE__ */ new Set() };
|
|
5993
|
+
for (const binding of bindingsOf(file).imports) {
|
|
5994
|
+
if (binding.spec !== void 0 && CHILD_PROCESS.test(binding.spec)) {
|
|
5995
|
+
if (binding.imported === "default") callees.namespaces.add(binding.local);
|
|
5996
|
+
else if (SHELL_EXPORTS.has(binding.imported)) callees.shell.add(binding.local);
|
|
5997
|
+
else if (SPAWN_EXPORTS.has(binding.imported)) callees.spawn.add(binding.local);
|
|
5998
|
+
} else if (binding.spec === "execa" && /^execaCommand(?:Sync)?$/.test(binding.imported)) {
|
|
5999
|
+
callees.commands.add(binding.local);
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
const source = commentsMaskedOf(file);
|
|
6003
|
+
for (const m of source.matchAll(/\bimport\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?child_process['"]/g)) {
|
|
6004
|
+
callees.namespaces.add(m[1]);
|
|
6005
|
+
}
|
|
6006
|
+
for (const m of source.matchAll(/\b(?:const|let|var)\s+(\{[^{}]{0,500}\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"](?:node:)?child_process['"]\s*\)/g)) {
|
|
6007
|
+
const target = m[1];
|
|
6008
|
+
if (!target.startsWith("{")) {
|
|
6009
|
+
callees.namespaces.add(target);
|
|
6010
|
+
continue;
|
|
6011
|
+
}
|
|
6012
|
+
for (const part of target.slice(1, -1).split(",")) {
|
|
6013
|
+
const names = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*$/.exec(part);
|
|
6014
|
+
if (!names) continue;
|
|
6015
|
+
if (SHELL_EXPORTS.has(names[1])) callees.shell.add(names[2] ?? names[1]);
|
|
6016
|
+
else if (SPAWN_EXPORTS.has(names[1])) callees.spawn.add(names[2] ?? names[1]);
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
for (const m of source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?promisify\s*\(\s*(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\)/g)) {
|
|
6020
|
+
const [, local, namespace, name] = m;
|
|
6021
|
+
const viaNamespace = namespace !== void 0 && callees.namespaces.has(namespace);
|
|
6022
|
+
if (namespace === void 0 && callees.shell.has(name) || viaNamespace && SHELL_EXPORTS.has(name)) callees.shell.add(local);
|
|
6023
|
+
else if (namespace === void 0 && callees.spawn.has(name) || viaNamespace && SPAWN_EXPORTS.has(name)) callees.spawn.add(local);
|
|
6024
|
+
}
|
|
6025
|
+
return callees;
|
|
6026
|
+
}
|
|
6027
|
+
function sinksIn(code, pairs, callees) {
|
|
6028
|
+
const sinks = /* @__PURE__ */ new Map();
|
|
6029
|
+
const add = (regex, kind, mode) => {
|
|
6030
|
+
regex.lastIndex = 0;
|
|
6031
|
+
for (const m of code.matchAll(regex)) {
|
|
6032
|
+
const open = m.index + m[0].length - 1;
|
|
6033
|
+
const close = pairs.get(open);
|
|
6034
|
+
if (close === void 0 || sinks.has(open)) continue;
|
|
6035
|
+
sinks.set(open, { kind, at: m.index, open, close, mode });
|
|
6036
|
+
}
|
|
6037
|
+
};
|
|
6038
|
+
if (callees.shell.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.shell)})\\s*\\(`, "g"), "command", "shell");
|
|
6039
|
+
if (callees.spawn.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.spawn)})\\s*\\(`, "g"), "command", "spawn");
|
|
6040
|
+
if (callees.commands.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.commands)})\\s*\\(`, "g"), "command", "execa");
|
|
6041
|
+
if (callees.namespaces.size > 0) {
|
|
6042
|
+
const ns = namePattern(callees.namespaces);
|
|
6043
|
+
add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:exec|execSync)\\s*\\(`, "g"), "command", "shell");
|
|
6044
|
+
add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:spawn|spawnSync|execFile|execFileSync)\\s*\\(`, "g"), "command", "spawn");
|
|
6045
|
+
}
|
|
6046
|
+
add(SQL_UNSAFE, "sql", "unsafe");
|
|
6047
|
+
add(SQL_RAW_FRAGMENT, "sql", "fragment");
|
|
6048
|
+
add(SQL_DRIVER, "sql", "driver");
|
|
6049
|
+
add(SQL_CONDITION, "sql", "condition");
|
|
6050
|
+
return [...sinks.values()].sort((a, b) => a.at - b.at);
|
|
6051
|
+
}
|
|
6052
|
+
function fixedProgram(expr, at, flow, source, depth = 0) {
|
|
6053
|
+
if (depth >= 8) {
|
|
6054
|
+
flow.limited = true;
|
|
6055
|
+
return false;
|
|
6056
|
+
}
|
|
6057
|
+
const text = expr.trim();
|
|
6058
|
+
const start = at + expr.length - expr.trimStart().length;
|
|
6059
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) {
|
|
6060
|
+
const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
|
|
6061
|
+
return assignments.length > 0 && assignments.every((a) => fixedProgram(a.expr, a.exprAt, flow, source, depth + 1));
|
|
6062
|
+
}
|
|
6063
|
+
const raw = source.slice(start, start + text.length);
|
|
6064
|
+
const prefix = /^["'`]([^"'`$\\]*)/.exec(raw)?.[1];
|
|
6065
|
+
return prefix !== void 0 && /^\S+\s/.test(prefix);
|
|
6066
|
+
}
|
|
6067
|
+
function hitOf(sink, code, source, pairs, flow) {
|
|
6068
|
+
const args = argumentExpressions(code, code, sink.open + 1, sink.close, pairs);
|
|
6069
|
+
if (args.length === 0) return null;
|
|
6070
|
+
const firstAt = sink.open + 1 + (code.slice(sink.open + 1, sink.close).length - code.slice(sink.open + 1, sink.close).trimStart().length);
|
|
6071
|
+
const first = args[0];
|
|
6072
|
+
const firstSource = source.slice(firstAt, firstAt + first.length);
|
|
6073
|
+
if (sink.mode === "spawn") {
|
|
6074
|
+
if (!/\bshell\s*:\s*true\b/.test(code.slice(sink.open, sink.close))) return null;
|
|
6075
|
+
const taint = flow.taintOf(code.slice(sink.open + 1, sink.close), sink.open + 1) ?? flow.builtOf(first, firstAt);
|
|
6076
|
+
return taint ? { taint } : null;
|
|
6077
|
+
}
|
|
6078
|
+
const built = flow.builtOf(first, firstAt);
|
|
6079
|
+
const identifier = /^[A-Za-z_$][\w$]*$/.exec(first);
|
|
6080
|
+
const builtVariable = identifier ? flow.builtValue(identifier[0], firstAt) : void 0;
|
|
6081
|
+
const text = built ? firstSource : builtVariable?.text ?? null;
|
|
6082
|
+
const stringTaint = built ?? builtVariable ?? null;
|
|
6083
|
+
if (sink.mode === "execa") {
|
|
6084
|
+
const shell = /\bshell\s*:\s*(?:true\b|['"][^'"]+['"])/.test(source.slice(sink.open, sink.close));
|
|
6085
|
+
if (!shell && fixedProgram(first, firstAt, flow, source)) return null;
|
|
6086
|
+
const taint = stringTaint ?? flow.taintOf(first, firstAt);
|
|
6087
|
+
return taint ? { taint, executable: !shell } : null;
|
|
6088
|
+
}
|
|
6089
|
+
if (sink.mode === "shell" || sink.mode === "unsafe") {
|
|
6090
|
+
const taint = stringTaint ?? flow.taintOf(first, firstAt);
|
|
6091
|
+
return taint ? { taint } : null;
|
|
6092
|
+
}
|
|
6093
|
+
if (!stringTaint || text === null) return null;
|
|
6094
|
+
if (sink.mode === "driver" && !LOOKS_LIKE_SQL.test(text)) return null;
|
|
6095
|
+
if (sink.mode === "condition" && !LOOKS_LIKE_CONDITION.test(text)) return null;
|
|
6096
|
+
return { taint: stringTaint };
|
|
6097
|
+
}
|
|
6098
|
+
function capitalised(text) {
|
|
6099
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
6100
|
+
}
|
|
6101
|
+
function findingFor(kind, route, file, line, originLine, certain, executable = false) {
|
|
6102
|
+
const lowered = certain ? [] : [
|
|
6103
|
+
"The value passes through other code or a check before it gets here, and the scan cannot tell whether that makes it safe. Confirm the value is restricted to what the query or command expects."
|
|
6104
|
+
];
|
|
6105
|
+
const excerpt = (file.lines[line - 1] ?? "").trim();
|
|
6106
|
+
if (kind === "sql") {
|
|
6107
|
+
return {
|
|
6108
|
+
ruleId: "injection/sql",
|
|
6109
|
+
severity: "P1",
|
|
6110
|
+
confidence: certain ? "certain" : "likely",
|
|
6111
|
+
title: `${capitalised(route.url)} builds a SQL query from request input`,
|
|
6112
|
+
file: file.path,
|
|
6113
|
+
line,
|
|
6114
|
+
excerpt,
|
|
6115
|
+
why: [
|
|
6116
|
+
`The query text is assembled from a value the caller controls (read on line ${originLine}). Whoever calls ${route.url} can change the query itself, not just the value it compares against \u2014 read other rows, skip filters, or change data.`,
|
|
6117
|
+
...lowered
|
|
6118
|
+
],
|
|
6119
|
+
fix: [
|
|
6120
|
+
"Pass values as parameters instead of splicing them into the SQL text: the tagged form (prisma.$queryRaw`\u2026 ${id}`, sql`\u2026 ${id}`) or placeholders ($1, ?) with a separate values array.",
|
|
6121
|
+
"If a column name or sort direction has to vary, choose it from a fixed list in code instead of using the request value."
|
|
6122
|
+
]
|
|
6123
|
+
};
|
|
6124
|
+
}
|
|
6125
|
+
return {
|
|
6126
|
+
ruleId: "injection/command",
|
|
6127
|
+
severity: "P1",
|
|
6128
|
+
confidence: certain ? "certain" : "likely",
|
|
6129
|
+
title: executable ? `${capitalised(route.url)} runs an executable selected by request input` : `${capitalised(route.url)} runs a shell command built from request input`,
|
|
6130
|
+
file: file.path,
|
|
6131
|
+
line,
|
|
6132
|
+
excerpt,
|
|
6133
|
+
why: [
|
|
6134
|
+
executable ? `The caller can influence the executable name (read on line ${originLine}). Execa does not use a shell by default, but allowing a caller to choose the program can still run unintended code with server permissions.` : `The command line includes a value the caller controls (read on line ${originLine}) and is run through a shell. Characters such as ; | $( ) let a caller append their own commands, which run with your server's permissions.`,
|
|
6135
|
+
...lowered
|
|
6136
|
+
],
|
|
6137
|
+
fix: [
|
|
6138
|
+
executable ? "Use a fixed executable and pass request values in a separate argument array; do not accept a caller-supplied command line." : "Use execFile or spawn with an argument array and no shell option, so the value is passed as one argument and never parsed by a shell.",
|
|
6139
|
+
"Check the value against a fixed list of allowed values before using it."
|
|
6140
|
+
]
|
|
6141
|
+
};
|
|
6142
|
+
}
|
|
6143
|
+
var injectionRule = {
|
|
6144
|
+
id: "injection/request-input",
|
|
6145
|
+
severity: "P1",
|
|
6146
|
+
check(ctx) {
|
|
6147
|
+
const findings = [];
|
|
6148
|
+
for (const file of ctx.files) {
|
|
6149
|
+
const analysed = handlerFileOf(file);
|
|
6150
|
+
if (!analysed) continue;
|
|
6151
|
+
const { code, source, pairs, lineStarts } = analysed;
|
|
6152
|
+
const sinks = sinksIn(code, pairs, commandCallees(file));
|
|
6153
|
+
if (sinks.length === 0) continue;
|
|
6154
|
+
const positions = sinks.map((sink) => sink.at);
|
|
6155
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6156
|
+
for (const { route, body, flow } of analysed.handlersAround(positions)) {
|
|
6157
|
+
for (let i = lowerBound(positions, body.start + 1); i < sinks.length && sinks[i].at < body.end; i++) {
|
|
6158
|
+
const sink = sinks[i];
|
|
6159
|
+
if (sink.close >= body.end || reported.has(sink.at)) continue;
|
|
6160
|
+
const hit = hitOf(sink, code, source, pairs, flow);
|
|
6161
|
+
if (!hit) continue;
|
|
6162
|
+
reported.add(sink.at);
|
|
6163
|
+
const certain = hit.taint.level === "direct" && !flow.validatedBefore(hit.taint.names, sink.at);
|
|
6164
|
+
findings.push(findingFor(
|
|
6165
|
+
sink.kind,
|
|
6166
|
+
route,
|
|
6167
|
+
file,
|
|
6168
|
+
lineNumberAt(lineStarts, sink.at),
|
|
6169
|
+
lineNumberAt(lineStarts, hit.taint.origin),
|
|
6170
|
+
certain,
|
|
6171
|
+
hit.executable
|
|
6172
|
+
));
|
|
6173
|
+
}
|
|
6174
|
+
}
|
|
6175
|
+
reportInputLimit(ctx, file, analysed);
|
|
6176
|
+
}
|
|
6177
|
+
return findings;
|
|
6178
|
+
}
|
|
6179
|
+
};
|
|
6180
|
+
|
|
6181
|
+
// src/rules/outbound.ts
|
|
6182
|
+
var FETCH_CALL = /(?<![\w$.])(?:fetch|\$fetch|ofetch|axios|got|ky|needle)\s*(?:<[^()]{0,200}>\s*)?\(/g;
|
|
6183
|
+
var FETCH_METHOD = /(?<![\w$.])(?:axios|got|ky|needle|http|https|undici)\s*\.\s*(?:get|post|put|patch|delete|head|request|stream)\s*(?:<[^()]{0,200}>\s*)?\(/g;
|
|
6184
|
+
var REDIRECT_CALL = /(?<![\w$.])(?:redirect|permanentRedirect|sendRedirect)\s*\(|(?<![\w$.])(?:NextResponse|Response|res|reply|response|ctx|context|Astro)\s*\.\s*redirect\s*\(/g;
|
|
6185
|
+
function callsIn(code, pairs) {
|
|
6186
|
+
const calls = [];
|
|
6187
|
+
const add = (regex, kind) => {
|
|
6188
|
+
regex.lastIndex = 0;
|
|
6189
|
+
for (const m of code.matchAll(regex)) {
|
|
6190
|
+
const open = m.index + m[0].length - 1;
|
|
6191
|
+
const close = pairs.get(open);
|
|
6192
|
+
if (close !== void 0) calls.push({ kind, at: m.index, open, close, name: m[0] });
|
|
6193
|
+
}
|
|
6194
|
+
};
|
|
6195
|
+
add(FETCH_CALL, "ssrf");
|
|
6196
|
+
add(FETCH_METHOD, "ssrf");
|
|
6197
|
+
add(REDIRECT_CALL, "redirect");
|
|
6198
|
+
return calls;
|
|
6199
|
+
}
|
|
6200
|
+
var HOST_OPEN = /^(?:X?|[a-z][\w+.-]*:\/\/[^/?#\\]*)$/i;
|
|
6201
|
+
var REDIRECT_OPEN = /^(?:\/|[/\\]{2}[^/?#\\]*)$/;
|
|
6202
|
+
function controlsStart(expr, at, flow, source, kind, depth = 0) {
|
|
6203
|
+
const lead = expr.length - expr.trimStart().length;
|
|
6204
|
+
const text = expr.trim();
|
|
6205
|
+
const start = at + lead;
|
|
6206
|
+
if (text === "") return null;
|
|
6207
|
+
if (depth >= 8) {
|
|
6208
|
+
flow.limited = true;
|
|
6209
|
+
return null;
|
|
6210
|
+
}
|
|
6211
|
+
if (text.length > 4e3) {
|
|
6212
|
+
flow.limited = true;
|
|
6213
|
+
return null;
|
|
6214
|
+
}
|
|
6215
|
+
const open = (prefix) => HOST_OPEN.test(prefix) || kind === "redirect" && REDIRECT_OPEN.test(prefix);
|
|
6216
|
+
const url = /^new\s+URL\s*\(/.exec(text);
|
|
6217
|
+
if (url) {
|
|
6218
|
+
const argsStart = start + url[0].length;
|
|
6219
|
+
const close = matchingParen(text, url[0].length - 1);
|
|
6220
|
+
if (close === null) return null;
|
|
6221
|
+
if (close === text.length - 1) {
|
|
6222
|
+
const first = splitTopLevel(text.slice(url[0].length, close))[0];
|
|
6223
|
+
return first === void 0 ? null : controlsStart(first, argsStart, flow, source, "redirect", depth + 1);
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
6226
|
+
if (text.startsWith("`")) return templateStart(text, start, flow, source, open);
|
|
6227
|
+
const operands = flow.operandsOf(text, start);
|
|
6228
|
+
if (operands.length > 1) {
|
|
6229
|
+
let prefix = "";
|
|
6230
|
+
for (const operand of operands) {
|
|
6231
|
+
const inner = operand.text.trim();
|
|
6232
|
+
if (/^['"]/.test(inner)) {
|
|
6233
|
+
prefix += source.slice(operand.at + operand.text.indexOf(inner) + 1, operand.at + operand.text.indexOf(inner) + inner.length - 1);
|
|
6234
|
+
continue;
|
|
6235
|
+
}
|
|
6236
|
+
const t2 = inner.startsWith("`") ? templateStart(inner, operand.at + operand.text.indexOf(inner), flow, source, open) : controlsStart(inner, operand.at + operand.text.indexOf(inner), flow, source, kind, depth + 1);
|
|
6237
|
+
if (t2) return open(prefix) ? t2 : null;
|
|
6238
|
+
prefix += "X";
|
|
6239
|
+
if (!open(prefix)) return null;
|
|
6240
|
+
}
|
|
6241
|
+
return null;
|
|
6242
|
+
}
|
|
6243
|
+
if (/^[A-Za-z_$][\w$]*$/.test(text)) {
|
|
6244
|
+
const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
|
|
6245
|
+
if (assignments.length === 0) {
|
|
6246
|
+
const t2 = flow.valueOf(text, at);
|
|
6247
|
+
return t2 && !t2.ownUrl ? t2 : null;
|
|
6248
|
+
}
|
|
6249
|
+
for (const a of assignments) {
|
|
6250
|
+
const t2 = controlsStart(a.expr, a.exprAt, flow, source, kind, depth + 1);
|
|
6251
|
+
if (t2) return { ...t2, level: a.indirect ? "derived" : t2.level, names: /* @__PURE__ */ new Set([...t2.names, text]) };
|
|
6252
|
+
}
|
|
6253
|
+
return null;
|
|
6254
|
+
}
|
|
6255
|
+
const t = flow.taintOf(text, start);
|
|
6256
|
+
return t && !t.ownUrl ? t : null;
|
|
6257
|
+
}
|
|
6258
|
+
function templateStart(text, start, flow, source, open) {
|
|
6259
|
+
let prefix = "";
|
|
6260
|
+
for (let i = 1; i < text.length; i++) {
|
|
6261
|
+
if (text[i] === "`") return null;
|
|
6262
|
+
if (text[i] === "$" && text[i + 1] === "{") {
|
|
6263
|
+
const close = matchingBrace(text, i + 1);
|
|
6264
|
+
if (close === null) return null;
|
|
6265
|
+
const t = flow.taintOf(text.slice(i + 2, close), start + i + 2);
|
|
6266
|
+
if (t && !t.ownUrl) return open(prefix) ? t : null;
|
|
6267
|
+
prefix += "X";
|
|
6268
|
+
i = close;
|
|
6269
|
+
continue;
|
|
6270
|
+
}
|
|
6271
|
+
prefix += source[start + i];
|
|
6272
|
+
if (prefix.length > 200) {
|
|
6273
|
+
flow.limited = true;
|
|
6274
|
+
return null;
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
return null;
|
|
6278
|
+
}
|
|
6279
|
+
function matchingParen(text, open) {
|
|
6280
|
+
let depth = 0;
|
|
6281
|
+
for (let i = open; i < text.length; i++) {
|
|
6282
|
+
if (text[i] === "(") depth++;
|
|
6283
|
+
else if (text[i] === ")" && --depth === 0) return i;
|
|
6284
|
+
}
|
|
6285
|
+
return null;
|
|
6286
|
+
}
|
|
6287
|
+
function matchingBrace(text, open) {
|
|
6288
|
+
let depth = 0;
|
|
6289
|
+
for (let i = open; i < text.length; i++) {
|
|
6290
|
+
if (text[i] === "{") depth++;
|
|
6291
|
+
else if (text[i] === "}" && --depth === 0) return i;
|
|
6292
|
+
}
|
|
6293
|
+
return null;
|
|
6294
|
+
}
|
|
6295
|
+
function splitTopLevel(text) {
|
|
6296
|
+
const parts = [];
|
|
6297
|
+
let depth = 0, from = 0;
|
|
6298
|
+
for (let i = 0; i < text.length; i++) {
|
|
6299
|
+
if ("([{".includes(text[i])) depth++;
|
|
6300
|
+
else if (")]}".includes(text[i])) depth--;
|
|
6301
|
+
else if (text[i] === "," && depth === 0) {
|
|
6302
|
+
parts.push(text.slice(from, i));
|
|
6303
|
+
from = i + 1;
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
parts.push(text.slice(from));
|
|
6307
|
+
return parts;
|
|
6308
|
+
}
|
|
6309
|
+
function targetOf(call, analysed) {
|
|
6310
|
+
const { code, pairs } = analysed;
|
|
6311
|
+
const args = argumentExpressions(code, code, call.open + 1, call.close, pairs);
|
|
6312
|
+
if (args.length === 0) return null;
|
|
6313
|
+
const positions = [];
|
|
6314
|
+
let cursor = call.open + 1;
|
|
6315
|
+
for (const arg of args) {
|
|
6316
|
+
const found = code.indexOf(arg, cursor);
|
|
6317
|
+
positions.push(found);
|
|
6318
|
+
cursor = found + arg.length;
|
|
6319
|
+
}
|
|
6320
|
+
let index = 0;
|
|
6321
|
+
if (call.kind === "redirect") {
|
|
6322
|
+
if (/sendRedirect/.test(call.name)) index = 1;
|
|
6323
|
+
else if (/^\d{3}$/.test(args[0]) && args.length > 1) index = 1;
|
|
6324
|
+
}
|
|
6325
|
+
const expr = args[index];
|
|
6326
|
+
if (expr === void 0) return null;
|
|
6327
|
+
if (call.kind === "ssrf" && expr.startsWith("{")) {
|
|
6328
|
+
const property = /(?:^|[{,])\s*(?:url|baseURL|href)\s*:\s*/.exec(expr);
|
|
6329
|
+
if (!property) {
|
|
6330
|
+
const shorthand = /(?:^|[{,])\s*(url|baseURL|href)\s*(?=[,}])/.exec(expr);
|
|
6331
|
+
return shorthand ? { expr: shorthand[1], at: positions[index] + shorthand.index + shorthand[0].length - shorthand[1].length } : null;
|
|
6332
|
+
}
|
|
6333
|
+
const valueAt = positions[index] + property.index + property[0].length;
|
|
6334
|
+
const value = splitTopLevel(expr.slice(property.index + property[0].length))[0].replace(/\}\s*$/, "");
|
|
6335
|
+
return { expr: value, at: valueAt };
|
|
6336
|
+
}
|
|
6337
|
+
return { expr, at: positions[index] };
|
|
6338
|
+
}
|
|
6339
|
+
function capitalised2(text) {
|
|
6340
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
6341
|
+
}
|
|
6342
|
+
function ssrfFinding(route, file, line, originLine) {
|
|
6343
|
+
return {
|
|
6344
|
+
ruleId: "ssrf/request-url",
|
|
6345
|
+
severity: "P1",
|
|
6346
|
+
confidence: "likely",
|
|
6347
|
+
title: `${capitalised2(route.url)} sends a server request to an address the caller chooses`,
|
|
6348
|
+
file: file.path,
|
|
6349
|
+
line,
|
|
6350
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6351
|
+
why: [
|
|
6352
|
+
`The address of this outgoing request comes from request input (read on line ${originLine}). A caller can point your server at internal services, cloud metadata endpoints such as 169.254.169.254, or other hosts only your server can reach, and read the response or trigger actions there.`,
|
|
6353
|
+
"Fetching a caller-supplied address can be the purpose of the route, as in a link preview or image proxy, so this is reported for review. Even then it needs a host allowlist."
|
|
6354
|
+
],
|
|
6355
|
+
fix: [
|
|
6356
|
+
"Build the address from a fixed origin in code and let the request choose only a path segment or query value.",
|
|
6357
|
+
"If callers must supply full URLs, parse them with new URL(), allow only https and hosts on an explicit allowlist, and reject private, loopback, and link-local addresses after DNS resolution."
|
|
6358
|
+
]
|
|
6359
|
+
};
|
|
6360
|
+
}
|
|
6361
|
+
function redirectFinding(route, file, line, originLine, certain) {
|
|
6362
|
+
return {
|
|
6363
|
+
ruleId: "redirect/open",
|
|
6364
|
+
severity: "P2",
|
|
6365
|
+
confidence: certain ? "certain" : "likely",
|
|
6366
|
+
title: `${capitalised2(route.url)} redirects to an address taken from the request`,
|
|
6367
|
+
file: file.path,
|
|
6368
|
+
line,
|
|
6369
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6370
|
+
why: [
|
|
6371
|
+
`The redirect target comes from request input (read on line ${originLine}), and nothing fixes the site it points to. A link to your site can then send visitors anywhere, which makes phishing links look trustworthy and can hand tokens in the URL to another site.`,
|
|
6372
|
+
...certain ? [] : ["The value passes through other code or a check first; confirm that check rejects other sites, including //host and /\\host forms."]
|
|
6373
|
+
],
|
|
6374
|
+
fix: [
|
|
6375
|
+
"Accept only relative paths that start with a single / (reject //, /\\, and anything with a scheme), or compare the target against a fixed list.",
|
|
6376
|
+
"When building an absolute URL, use new URL(path, yourOrigin) and redirect only if the result's origin equals yourOrigin."
|
|
6377
|
+
]
|
|
6378
|
+
};
|
|
6379
|
+
}
|
|
6380
|
+
function check(ctx, kind) {
|
|
6381
|
+
const findings = [];
|
|
6382
|
+
for (const file of ctx.files) {
|
|
6383
|
+
const analysed = handlerFileOf(file);
|
|
6384
|
+
if (!analysed) continue;
|
|
6385
|
+
const calls = callsIn(analysed.code, analysed.pairs).filter((call) => call.kind === kind).sort((a, b) => a.at - b.at);
|
|
6386
|
+
if (calls.length === 0) continue;
|
|
6387
|
+
const positions = calls.map((call) => call.at);
|
|
6388
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6389
|
+
for (const { route, body, flow } of analysed.handlersAround(positions)) {
|
|
6390
|
+
for (let i = lowerBound(positions, body.start + 1); i < calls.length && calls[i].at < body.end; i++) {
|
|
6391
|
+
const call = calls[i];
|
|
6392
|
+
if (call.close >= body.end || reported.has(call.at)) continue;
|
|
6393
|
+
const target = targetOf(call, analysed);
|
|
6394
|
+
if (!target) continue;
|
|
6395
|
+
const taint = controlsStart(target.expr, target.at, flow, analysed.source, kind);
|
|
6396
|
+
if (!taint) continue;
|
|
6397
|
+
reported.add(call.at);
|
|
6398
|
+
const line = lineNumberAt(analysed.lineStarts, call.at);
|
|
6399
|
+
const originLine = lineNumberAt(analysed.lineStarts, taint.origin);
|
|
6400
|
+
findings.push(kind === "ssrf" ? ssrfFinding(route, file, line, originLine) : redirectFinding(route, file, line, originLine, taint.level === "direct" && !flow.validatedBefore(taint.names, call.at)));
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
reportInputLimit(ctx, file, analysed);
|
|
6404
|
+
}
|
|
6405
|
+
return findings;
|
|
6406
|
+
}
|
|
6407
|
+
var ssrfRule = { id: "ssrf/request-url", severity: "P1", check: (ctx) => check(ctx, "ssrf") };
|
|
6408
|
+
var redirectRule = { id: "redirect/open", severity: "P2", check: (ctx) => check(ctx, "redirect") };
|
|
6409
|
+
|
|
6410
|
+
// src/rules/verification.ts
|
|
6411
|
+
var LocalVerification = class {
|
|
6412
|
+
code;
|
|
6413
|
+
source;
|
|
6414
|
+
pairs;
|
|
6415
|
+
bodies;
|
|
6416
|
+
constructor(file) {
|
|
6417
|
+
this.code = noiseMaskedOf(file);
|
|
6418
|
+
this.source = commentsMaskedOf(file);
|
|
6419
|
+
this.pairs = delimiterPairs(this.code);
|
|
6420
|
+
this.bodies = functionBodies(this.code, this.pairs);
|
|
6421
|
+
}
|
|
6422
|
+
owner(at) {
|
|
6423
|
+
return this.bodies.filter((b) => b.start < at && at < b.end).sort((a, b) => b.start - a.start)[0];
|
|
6424
|
+
}
|
|
6425
|
+
skipSpace(at) {
|
|
6426
|
+
while (/\s/.test(this.code[at] ?? "")) at++;
|
|
6427
|
+
return at;
|
|
6428
|
+
}
|
|
6429
|
+
endOf(at, limit = this.code.length) {
|
|
6430
|
+
if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
|
|
6431
|
+
for (let i = at; i < limit; i++) {
|
|
6432
|
+
if (/[;\n}]/.test(this.code[i])) return i;
|
|
6433
|
+
const end = this.pairs.get(i);
|
|
6434
|
+
if (end !== void 0) i = end;
|
|
6435
|
+
}
|
|
6436
|
+
return limit;
|
|
6437
|
+
}
|
|
6438
|
+
/**
|
|
6439
|
+
* 块的顶层有无条件退出即算退出,前面可以有记录日志等语句;
|
|
6440
|
+
* 嵌套分支、函数中的 return 以及无花括号 if/else/循环控制的 return 不算。
|
|
6441
|
+
*/
|
|
6442
|
+
exits(from, to) {
|
|
6443
|
+
let at = this.skipSpace(from);
|
|
6444
|
+
if (this.code[at] !== "{") return /^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, to));
|
|
6445
|
+
const end = Math.min(to, this.pairs.get(at) ?? to);
|
|
6446
|
+
at = this.skipSpace(at + 1);
|
|
6447
|
+
while (at < end) {
|
|
6448
|
+
if (/^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, at + 32))) return true;
|
|
6449
|
+
const next = this.statementEnd(at, end);
|
|
6450
|
+
if (next <= at) return false;
|
|
6451
|
+
at = this.skipSpace(next);
|
|
6452
|
+
}
|
|
6453
|
+
return false;
|
|
6454
|
+
}
|
|
6455
|
+
/** 跳过完整语句;分支、循环和函数内的退出不提升为外层退出。 */
|
|
6456
|
+
statementEnd(at, limit, depth = 0) {
|
|
6457
|
+
if (depth >= 64) return limit;
|
|
6458
|
+
if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
|
|
6459
|
+
const control = /^(if|for(?:\s+await)?|while|with|switch)\s*\(/.exec(this.code.slice(at, at + 80));
|
|
6460
|
+
if (control) {
|
|
6461
|
+
const close = this.pairs.get(at + control[0].length - 1);
|
|
6462
|
+
if (close === void 0) return limit;
|
|
6463
|
+
let end = this.statementEnd(this.skipSpace(close + 1), limit, depth + 1);
|
|
6464
|
+
const next = this.skipSpace(end);
|
|
6465
|
+
if (control[1] === "if" && /^else\b/.test(this.code.slice(next, next + 5))) {
|
|
6466
|
+
end = this.statementEnd(this.skipSpace(next + 4), limit, depth + 1);
|
|
6467
|
+
}
|
|
6468
|
+
return end;
|
|
6469
|
+
}
|
|
6470
|
+
if (/^(?:try|do)\b/.test(this.code.slice(at, at + 8))) return limit;
|
|
6471
|
+
for (let i = at; i < limit; i++) {
|
|
6472
|
+
if (this.code[i] === ";") return i + 1;
|
|
6473
|
+
const close = this.pairs.get(i);
|
|
6474
|
+
if (close !== void 0) {
|
|
6475
|
+
i = close;
|
|
6476
|
+
continue;
|
|
6477
|
+
}
|
|
6478
|
+
if (this.code[i] !== "\n") continue;
|
|
6479
|
+
const before = this.code.slice(at, i).trimEnd();
|
|
6480
|
+
const after = this.code.slice(i + 1, limit).trimStart();
|
|
6481
|
+
if (!before || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
|
|
6482
|
+
if (/^[.?+\-*/%&|^,:<>=([{`]/.test(after)) continue;
|
|
6483
|
+
if (/\b(?:const|let|var|function|async|await|new|yield)$/.test(before)) continue;
|
|
6484
|
+
return i + 1;
|
|
6485
|
+
}
|
|
6486
|
+
return limit;
|
|
6487
|
+
}
|
|
6488
|
+
/** 验证与使用须在同一函数,且不能依赖可选分支或被吞掉的异常。 */
|
|
6489
|
+
enforcedBefore(from, to) {
|
|
6490
|
+
const owner = this.owner(from);
|
|
6491
|
+
if (from >= to || owner !== this.owner(to)) return false;
|
|
6492
|
+
const start = owner ? owner.start + 1 : 0;
|
|
6493
|
+
for (let i = start; i < from; i++) {
|
|
6494
|
+
const conditional = /^(?:if|for|while|switch)\s*\(/.exec(this.code.slice(i, i + 32));
|
|
6495
|
+
if (conditional && !/[\w$]/.test(this.code[i - 1] ?? "")) {
|
|
6496
|
+
const close = this.pairs.get(i + conditional[0].length - 1);
|
|
6497
|
+
if (close !== void 0) {
|
|
6498
|
+
const body = this.skipSpace(close + 1);
|
|
6499
|
+
const end2 = this.endOf(body);
|
|
6500
|
+
if (from < end2 && to >= end2) return false;
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
const end = this.pairs.get(i);
|
|
6504
|
+
if (end === void 0) continue;
|
|
6505
|
+
if (end < from) {
|
|
6506
|
+
i = end;
|
|
6507
|
+
continue;
|
|
6508
|
+
}
|
|
6509
|
+
if (end >= to) continue;
|
|
6510
|
+
if (this.code[i] !== "{" || !/\btry\s*$/.test(this.code.slice(Math.max(start, i - 16), i))) return false;
|
|
6511
|
+
let next = this.skipSpace(end + 1);
|
|
6512
|
+
if (/^catch\b/.test(this.code.slice(next))) {
|
|
6513
|
+
next = this.skipSpace(next + 5);
|
|
6514
|
+
if (this.code[next] === "(") next = this.skipSpace((this.pairs.get(next) ?? next) + 1);
|
|
6515
|
+
const caughtEnd = this.endOf(next);
|
|
6516
|
+
if (!this.exits(next, caughtEnd)) return false;
|
|
6517
|
+
next = this.skipSpace(caughtEnd);
|
|
6518
|
+
}
|
|
6519
|
+
if (/^finally\b/.test(this.code.slice(next))) return false;
|
|
6520
|
+
}
|
|
6521
|
+
const boundary = Math.max(
|
|
6522
|
+
start,
|
|
6523
|
+
this.code.lastIndexOf(";", from - 1) + 1,
|
|
6524
|
+
this.code.lastIndexOf("\n", from - 1) + 1,
|
|
6525
|
+
this.code.lastIndexOf("{", from - 1) + 1
|
|
6526
|
+
);
|
|
6527
|
+
return !/[?]|&&|\|\|/.test(this.code.slice(boundary, from));
|
|
6528
|
+
}
|
|
6529
|
+
/** 找出当前函数中最后一次赋值;未知或条件覆盖同样会使旧证明失效。 */
|
|
6530
|
+
assignment(name, at) {
|
|
6531
|
+
const escaped = name.replace(/\$/g, "\\$");
|
|
6532
|
+
const pattern = new RegExp(`(?<![\\w$.])(?:const\\s+|let\\s+|var\\s+)?${escaped}((?:\\s*\\.[\\w$]+|\\s*\\[[^\\]]*\\])*)\\s*(?::[^=;{}\\n]{0,100})?=(?![=>])`, "g");
|
|
6533
|
+
let result = null;
|
|
6534
|
+
for (const m of this.code.slice(0, at).matchAll(pattern)) {
|
|
6535
|
+
if (this.owner(m.index) !== this.owner(at)) continue;
|
|
6536
|
+
const from = this.skipSpace(m.index + m[0].length);
|
|
6537
|
+
const end = this.endOf(from);
|
|
6538
|
+
if (end <= at) result = { at: m.index, from, end, expression: this.code.slice(from, end).trim(), member: m[1].length > 0 };
|
|
6539
|
+
}
|
|
6540
|
+
return result;
|
|
6541
|
+
}
|
|
6542
|
+
};
|
|
6543
|
+
|
|
6544
|
+
// src/rules/session.ts
|
|
6545
|
+
var GET_SESSION = /\.auth\s*\.\s*getSession\s*\(/g;
|
|
6546
|
+
function verifiedSession(file, context, sessionAt, names) {
|
|
6547
|
+
const { code, pairs, bodies } = context;
|
|
6548
|
+
const body = context.owner(sessionAt);
|
|
6549
|
+
if (!body) return false;
|
|
6550
|
+
const client = /([\w$.]+)\s*$/.exec(code.slice(body.start, sessionAt))?.[1];
|
|
6551
|
+
if (!client) return false;
|
|
6552
|
+
const values = authValuesOf(file, body, bodies, pairs);
|
|
6553
|
+
const reads = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$]|\\s*:)`, "g");
|
|
6554
|
+
const verification = /\b(?:const|let)\s+(\{[^;=]{1,500}\}|[A-Za-z_$][\w$]*)\s*=\s*await\s+([\w$.]+)\.auth\.(?:getUser|getClaims)\s*\(/g;
|
|
6555
|
+
for (const call of code.matchAll(verification)) {
|
|
6556
|
+
if (call[2] !== client || context.owner(call.index) !== body) continue;
|
|
6557
|
+
const close = pairs.get(call.index + call[0].length - 1);
|
|
6558
|
+
if (close === void 0) continue;
|
|
6559
|
+
if (code.slice(call.index + call[0].length, close).trim() !== "") continue;
|
|
6560
|
+
const guardAt = context.skipSpace(code[close + 1] === ";" ? close + 2 : close + 1);
|
|
6561
|
+
const condition = /^if\s*\(/.exec(code.slice(guardAt));
|
|
6562
|
+
if (!condition) continue;
|
|
6563
|
+
const conditionClose = pairs.get(guardAt + condition[0].length - 1);
|
|
6564
|
+
if (conditionClose === void 0) continue;
|
|
6565
|
+
const branchAt = context.skipSpace(conditionClose + 1);
|
|
6566
|
+
const branchEnd = context.endOf(branchAt);
|
|
6567
|
+
if (!context.exits(branchAt, branchEnd) || !context.enforcedBefore(call.index, guardAt)) continue;
|
|
6568
|
+
const test = code.slice(guardAt + condition[0].length, conditionClose).trim();
|
|
6569
|
+
const errorNames = call[1].startsWith("{") ? destructuredKeys(call[1]).filter((k) => k.key === "error").map((k) => k.local) : [`${call[1]}.error`];
|
|
6570
|
+
if (/&&|\?(?!\.)/.test(test) || !test.split("||").some((term) => {
|
|
6571
|
+
const t = term.trim();
|
|
6572
|
+
return errorNames.includes(t) || t.startsWith("!") && identityRequirement(values.value(t.slice(1), guardAt), false) !== null;
|
|
6573
|
+
})) continue;
|
|
6574
|
+
const sessionClose = pairs.get(code.indexOf("(", sessionAt)) ?? code.indexOf(")", sessionAt);
|
|
6575
|
+
let before = code.slice(sessionClose + 1, branchEnd);
|
|
6576
|
+
before = before.replace(/\bif\s*\(\s*!\s*([\w$.?]+)\s*\)\s*(?:return|throw|redirect)\b[^;\n]*/g, (text, subject) => {
|
|
6577
|
+
const rest = text.slice(text.indexOf(")") + 1).replace(/\b[\w$]+\s*:/g, "");
|
|
6578
|
+
return names.some((n) => subject === n || subject.startsWith(`${n}.`)) && !new RegExp(reads.source).test(rest) ? " ".repeat(text.length) : text;
|
|
6579
|
+
});
|
|
6580
|
+
if (new RegExp(reads.source).test(before)) continue;
|
|
6581
|
+
if (!context.enforcedBefore(guardAt, body.end - 1)) continue;
|
|
6582
|
+
return true;
|
|
6583
|
+
}
|
|
6584
|
+
return false;
|
|
6585
|
+
}
|
|
6586
|
+
var SERVER_PATH = [
|
|
6587
|
+
/(?:^|\/)(?:middleware|proxy)\.[mc]?[jt]s$/,
|
|
6588
|
+
/(?:^|\/)app\/(?:.+\/)?(?:page|layout|template|default)\.[mc]?[jt]sx?$/,
|
|
6589
|
+
/\.server\.[mc]?[jt]s$/,
|
|
6590
|
+
/(?:^|\/)server\/.+\.[mc]?[jt]s$/
|
|
6591
|
+
];
|
|
6592
|
+
function isServerFile(file) {
|
|
6593
|
+
if (!/\.[mc]?[jt]sx?$/.test(file.path)) return false;
|
|
6594
|
+
if (/^\s*(?:(?:'use strict'|"use strict");?\s*)?['"]use client['"]/.test(commentsMaskedOf(file))) return false;
|
|
6595
|
+
return SERVER_PATH.some((pattern) => pattern.test(file.path)) || routeOf(file) !== null || serverActionRoutes(file).length > 0;
|
|
6596
|
+
}
|
|
6597
|
+
var sessionRule = {
|
|
6598
|
+
id: "auth/unverified-session",
|
|
6599
|
+
severity: "P1",
|
|
6600
|
+
appliesTo(file) {
|
|
6601
|
+
return file.content.includes("getSession") && isServerFile(file);
|
|
6602
|
+
},
|
|
6603
|
+
check(file) {
|
|
6604
|
+
const code = noiseMaskedOf(file);
|
|
6605
|
+
const context = new LocalVerification(file);
|
|
6606
|
+
const { pairs, bodies } = context;
|
|
6607
|
+
const lineStarts = lineStartsOf(file.content);
|
|
6608
|
+
const findings = [];
|
|
6609
|
+
for (const m of code.matchAll(GET_SESSION)) {
|
|
6610
|
+
const body = bodies.filter((b) => b.start < m.index && m.index < b.end).sort((a, b) => b.start - a.start)[0];
|
|
6611
|
+
const start = body?.start ?? 0;
|
|
6612
|
+
const end = body?.end ?? code.length;
|
|
6613
|
+
const statement = code.slice(Math.max(start, m.index - 300), m.index);
|
|
6614
|
+
const declaration = /\b(?:const|let|var)\s+((?:\{[^;=]*\})|[A-Za-z_$][\w$]*)\s*(?::[^=;]{0,200})?=\s*(?:await\s+)?\(?\s*(?:await\s+)?[\w$.?\s]*$/.exec(statement);
|
|
6615
|
+
if (!declaration) continue;
|
|
6616
|
+
const names = patternNames(declaration[1]);
|
|
6617
|
+
if (names.length === 0) continue;
|
|
6618
|
+
const refs = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
|
|
6619
|
+
const after = code.slice(m.index, end);
|
|
6620
|
+
let decides = false;
|
|
6621
|
+
for (const condition of after.matchAll(/\bif\s*\(/g)) {
|
|
6622
|
+
const open = m.index + condition.index + condition[0].length - 1;
|
|
6623
|
+
const close = pairs.get(open);
|
|
6624
|
+
if (close !== void 0 && refs.test(code.slice(open + 1, close))) {
|
|
6625
|
+
decides = true;
|
|
6626
|
+
break;
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
const readsUser = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?:\\??\\.\\s*(?:session|data))*\\??\\.\\s*user\\b`).test(after);
|
|
6630
|
+
if (!decides && !readsUser) continue;
|
|
6631
|
+
if (verifiedSession(file, context, m.index, names)) continue;
|
|
6632
|
+
const line = lineNumberAt(lineStarts, m.index);
|
|
6633
|
+
findings.push({
|
|
6634
|
+
ruleId: "auth/unverified-session",
|
|
6635
|
+
severity: "P1",
|
|
6636
|
+
confidence: decides ? "certain" : "likely",
|
|
6637
|
+
title: "Server code trusts supabase.auth.getSession() to identify the user",
|
|
6638
|
+
file: file.path,
|
|
6639
|
+
line,
|
|
6640
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6641
|
+
why: [
|
|
6642
|
+
"On the server, getSession() reads the session straight from the request cookie without verifying it. Anyone can put a forged or expired session in their cookies, so a check based on it can be passed by a caller who is not signed in.",
|
|
6643
|
+
decides ? "The result is used in a condition here, so it decides who gets through." : "The user from this session is read here, so an unverified user id may be trusted."
|
|
6644
|
+
],
|
|
6645
|
+
fix: [
|
|
6646
|
+
"Use supabase.auth.getClaims() (verifies the token signature) or supabase.auth.getUser() (asks the Auth server) to decide who the caller is.",
|
|
6647
|
+
"Keep getSession() only where you need the access token itself, not to decide access."
|
|
6648
|
+
]
|
|
6649
|
+
});
|
|
6650
|
+
}
|
|
6651
|
+
return findings;
|
|
6652
|
+
}
|
|
6653
|
+
};
|
|
6654
|
+
|
|
6655
|
+
// src/rules/webhook.ts
|
|
6656
|
+
var STRIPE_EVENT = /(?:\bcase\s*|\.type\s*[!=]==?\s*)['"`]((?:checkout\.session|payment_intent|customer\.subscription|customer|invoice|charge|setup_intent|payment_method|subscription_schedule|account|payout|refund|checkout)\.[a-z_.]+)['"`]|['"`]((?:checkout\.session|payment_intent|customer\.subscription|invoice|charge)\.[a-z_.]+)['"`]\s*[!=]==?\s*[\w$.]+\.type\b/;
|
|
6657
|
+
function verifiedEvent(context, name, at) {
|
|
6658
|
+
const assignment = context.assignment(name, at);
|
|
6659
|
+
if (!assignment || assignment.member || !context.enforcedBefore(assignment.at, at)) return false;
|
|
6660
|
+
const call = /^(?:(await)\s+)?[\w$.]+\.(constructEvent(?:Async)?|retrieve|parseEventNotification)\s*\(/.exec(assignment.expression);
|
|
6661
|
+
if (!call || call[2] !== "constructEvent" && !call[1]) return false;
|
|
6662
|
+
if (call[2] === "retrieve" && !/\.events\s*\.\s*retrieve/.test(call[0])) return false;
|
|
6663
|
+
const close = context.pairs.get(assignment.from + call[0].length - 1);
|
|
6664
|
+
return close !== void 0 && context.code.slice(close + 1, assignment.end).trim() === "";
|
|
6665
|
+
}
|
|
6666
|
+
function eventName(context, event) {
|
|
6667
|
+
if (event[0].startsWith(".")) return /([A-Za-z_$][\w$]*)\s*$/.exec(context.code.slice(0, event.index))?.[1] ?? null;
|
|
6668
|
+
if (!event[0].startsWith("case")) return /([A-Za-z_$][\w$]*)\.type\b/.exec(event[0])?.[1] ?? null;
|
|
6669
|
+
let name = null;
|
|
6670
|
+
for (const m of context.code.slice(0, event.index).matchAll(/\bswitch\s*\(\s*([A-Za-z_$][\w$]*)\.type\s*\)\s*\{/g)) {
|
|
6671
|
+
const end = context.pairs.get(m.index + m[0].length - 1);
|
|
6672
|
+
if (end !== void 0 && end > event.index && context.owner(m.index) === context.owner(event.index)) name = m[1];
|
|
6673
|
+
}
|
|
6674
|
+
return name;
|
|
6675
|
+
}
|
|
6676
|
+
var READS_BODY = /\.\s*(?:json|text|arrayBuffer|formData)\s*\(|\breq(?:uest)?\s*\.\s*body\b|\b(?:readBody|readRawBody)\s*\(|\bbuffer\s*\(\s*req\b/;
|
|
6677
|
+
var webhookRule = {
|
|
6678
|
+
id: "webhook/unverified-signature",
|
|
6679
|
+
severity: "P1",
|
|
6680
|
+
appliesTo(file) {
|
|
6681
|
+
return /stripe/i.test(file.content) && routeOf(file) !== null;
|
|
6682
|
+
},
|
|
6683
|
+
check(file) {
|
|
6684
|
+
const route = routeOf(file);
|
|
6685
|
+
if (!route) return [];
|
|
6686
|
+
const context = new LocalVerification(file);
|
|
6687
|
+
const { source, code } = context;
|
|
6688
|
+
const findings = [];
|
|
6689
|
+
const reported = /* @__PURE__ */ new Set();
|
|
6690
|
+
for (const event of source.matchAll(new RegExp(STRIPE_EVENT.source, "g"))) {
|
|
6691
|
+
if (code[event.index] !== source[event.index]) continue;
|
|
6692
|
+
const owner = context.owner(event.index);
|
|
6693
|
+
const name = eventName(context, event);
|
|
6694
|
+
if (name && verifiedEvent(context, name, event.index)) continue;
|
|
6695
|
+
const key = owner?.start ?? 0;
|
|
6696
|
+
if (reported.has(key)) continue;
|
|
6697
|
+
reported.add(key);
|
|
6698
|
+
const line = lineNumberAt(lineStartsOf(file.content), event.index);
|
|
6699
|
+
const region = code.slice(owner?.start ?? 0, owner?.end ?? code.length);
|
|
6700
|
+
const readsBody = READS_BODY.test(region);
|
|
6701
|
+
const customCheck = /\bverifyHeader\s*\(/.test(region) || /\bcreateHmac\s*\(/.test(region) && /\btimingSafeEqual\s*\(/.test(region);
|
|
6702
|
+
findings.push({
|
|
6703
|
+
ruleId: "webhook/unverified-signature",
|
|
6704
|
+
severity: "P1",
|
|
6705
|
+
confidence: readsBody && !customCheck ? "certain" : "likely",
|
|
6706
|
+
title: customCheck ? `Review custom Stripe signature verification in ${route.url}` : `${route.url} acts on Stripe events without verifying the signature`,
|
|
6707
|
+
file: file.path,
|
|
6708
|
+
line,
|
|
6709
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
6710
|
+
why: [
|
|
6711
|
+
`This route handles ${event[1] ?? event[2]} and similar events, but the scan could not link this event to a signature-verification result. Without effective verification, callers can submit made-up events and trigger the handler.`,
|
|
6712
|
+
...customCheck ? ["A custom signature check is present; confirm it checks the raw body and rejects invalid signatures before processing this event."] : [],
|
|
6713
|
+
...readsBody ? [] : ["The event does not appear to come from the request body here; confirm where it is read and whether it is verified there."]
|
|
6714
|
+
],
|
|
6715
|
+
fix: [
|
|
6716
|
+
"Read the raw body (await request.text() in a route handler) and pass it to stripe.webhooks.constructEvent(body, request.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET) before handling the event. Return 400 when it throws.",
|
|
6717
|
+
"Do not parse the body as JSON before verifying; any change to the raw body makes verification fail."
|
|
6718
|
+
]
|
|
6719
|
+
});
|
|
6720
|
+
}
|
|
6721
|
+
return findings;
|
|
6722
|
+
}
|
|
6723
|
+
};
|
|
6724
|
+
|
|
5261
6725
|
// src/rules/index.ts
|
|
5262
|
-
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
|
|
5263
|
-
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule];
|
|
6726
|
+
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule, sessionRule, webhookRule];
|
|
6727
|
+
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule, injectionRule, ssrfRule, redirectRule];
|
|
5264
6728
|
var RULE_IDS = [
|
|
5265
6729
|
"api/admin-db-access-without-auth",
|
|
5266
6730
|
"api/db-write-without-auth",
|
|
6731
|
+
"auth/unverified-session",
|
|
5267
6732
|
"cors/reflected-origin-with-credentials",
|
|
5268
6733
|
"cors/wildcard-with-credentials",
|
|
5269
6734
|
"exposure/private-name-in-public-env",
|
|
@@ -5273,9 +6738,14 @@ var RULE_IDS = [
|
|
|
5273
6738
|
"firebase/test-mode-rules",
|
|
5274
6739
|
"gitleak/env-in-history",
|
|
5275
6740
|
"gitleak/env-tracked",
|
|
6741
|
+
"injection/command",
|
|
6742
|
+
"injection/sql",
|
|
6743
|
+
"redirect/open",
|
|
6744
|
+
"ssrf/request-url",
|
|
5276
6745
|
"supabase/permissive-policy",
|
|
5277
6746
|
"supabase/public-bucket-listing",
|
|
5278
6747
|
"supabase/rls-not-enabled",
|
|
6748
|
+
"webhook/unverified-signature",
|
|
5279
6749
|
// 凭据规则 ID 从共享格式表生成。
|
|
5280
6750
|
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
5281
6751
|
];
|
|
@@ -5513,6 +6983,14 @@ var RULE_CATALOG = [
|
|
|
5513
6983
|
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
|
|
5514
6984
|
"Session-scoped clients and indirect guards may need manual review."
|
|
5515
6985
|
),
|
|
6986
|
+
rule(
|
|
6987
|
+
"auth/unverified-session",
|
|
6988
|
+
"Server code trusting supabase.auth.getSession()",
|
|
6989
|
+
"P1",
|
|
6990
|
+
"varies",
|
|
6991
|
+
"Server routes, Server Functions, middleware and proxy modules, Next.js server components, *.server modules, and server directories that call supabase.auth.getSession(): a result used in a condition is certain; reading its user is reported for review. Exemption requires a checked, awaited identity from the same client before trusted session use.",
|
|
6992
|
+
"Only the receiving variable in the same function is followed; results passed to helpers are not tracked."
|
|
6993
|
+
),
|
|
5516
6994
|
rule(
|
|
5517
6995
|
"cors/reflected-origin-with-credentials",
|
|
5518
6996
|
"Reflected origin with credentials",
|
|
@@ -5585,6 +7063,30 @@ var RULE_CATALOG = [
|
|
|
5585
7063
|
"Tracked environment files; templates and public-only values are treated separately.",
|
|
5586
7064
|
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
5587
7065
|
),
|
|
7066
|
+
rule(
|
|
7067
|
+
"injection/command",
|
|
7068
|
+
"Shell command built from request input",
|
|
7069
|
+
"P1",
|
|
7070
|
+
"varies",
|
|
7071
|
+
"exec, execSync, and spawn/execFile with shell: true in supported server handlers. Execa command strings are checked for a caller-chosen executable, or shell interpolation when shell is enabled. Direct request input is certain; values passing through other calls or content checks require review.",
|
|
7072
|
+
"Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked."
|
|
7073
|
+
),
|
|
7074
|
+
rule(
|
|
7075
|
+
"injection/sql",
|
|
7076
|
+
"SQL query built from request input",
|
|
7077
|
+
"P1",
|
|
7078
|
+
"varies",
|
|
7079
|
+
"Untagged template or string concatenation in Prisma $queryRawUnsafe/$executeRawUnsafe, Prisma.raw, sql.raw/sql.unsafe, knex *Raw methods, driver query/execute calls, and query-builder where clauses, in server routes and Next.js Server Functions. Tagged templates and placeholders are not reported.",
|
|
7080
|
+
"Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked. Driver calls are reported only when the text looks like SQL."
|
|
7081
|
+
),
|
|
7082
|
+
rule(
|
|
7083
|
+
"redirect/open",
|
|
7084
|
+
"Redirect to an address taken from the request",
|
|
7085
|
+
"P2",
|
|
7086
|
+
"varies",
|
|
7087
|
+
"redirect, permanentRedirect, NextResponse.redirect, Response.redirect, res.redirect, and sendRedirect in server routes and Next.js Server Functions, when request input decides the start of the target (a whole value, a leading /, or the host). Request input used directly is certain; values that pass through other calls or checks require review.",
|
|
7088
|
+
"Only flows inside one function are followed. The request's own URL and fixed paths followed by input are not reported; helper-built targets are not tracked."
|
|
7089
|
+
),
|
|
5588
7090
|
rule(
|
|
5589
7091
|
"supabase/permissive-policy",
|
|
5590
7092
|
"Row Level Security policy with an always-true condition",
|
|
@@ -5601,6 +7103,14 @@ var RULE_CATALOG = [
|
|
|
5601
7103
|
"Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
|
|
5602
7104
|
"Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
|
|
5603
7105
|
),
|
|
7106
|
+
rule(
|
|
7107
|
+
"ssrf/request-url",
|
|
7108
|
+
"Server request to an address the caller chooses",
|
|
7109
|
+
"P1",
|
|
7110
|
+
"likely",
|
|
7111
|
+
"fetch, $fetch/ofetch, axios, got, ky, needle, and Node http(s) calls in server routes and Next.js Server Functions, when request input decides the scheme or host of the address.",
|
|
7112
|
+
"Only flows inside one function are followed. Fetching caller-supplied URLs can be intended, and allowlists or network controls are not verified."
|
|
7113
|
+
),
|
|
5604
7114
|
rule(
|
|
5605
7115
|
"supabase/rls-not-enabled",
|
|
5606
7116
|
"Table without RLS in migrations",
|
|
@@ -5609,6 +7119,14 @@ var RULE_CATALOG = [
|
|
|
5609
7119
|
"Supabase SQL migrations, replayed by application scope.",
|
|
5610
7120
|
"Migration state is not deployed database state; grants and policy correctness are outside this check."
|
|
5611
7121
|
),
|
|
7122
|
+
rule(
|
|
7123
|
+
"webhook/unverified-signature",
|
|
7124
|
+
"Stripe webhook events handled without signature verification",
|
|
7125
|
+
"P1",
|
|
7126
|
+
"varies",
|
|
7127
|
+
"Server routes that branch on Stripe event types without an enforced constructEvent or awaited event-retrieval result for the handled value. Request-body handling is certain; custom signature checks remain likely.",
|
|
7128
|
+
"Only Stripe is recognized. Unused calls do not verify events; custom HMAC and shared-helper verification require review."
|
|
7129
|
+
),
|
|
5612
7130
|
...SECRET_PATTERNS.map((pattern) => ({
|
|
5613
7131
|
...rule(
|
|
5614
7132
|
`secrets/hardcoded/${pattern.id}`,
|