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.
Files changed (5) hide show
  1. package/README-zh-CN.md +89 -126
  2. package/README.md +89 -126
  3. package/dist/cli.js +2505 -612
  4. package/dist/index.js +1661 -143
  5. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { isAbsolute as isAbsolute3, relative as relative_, resolve as resolve2 } from "path";
5
- import { existsSync as existsSync3, realpathSync as realpathSync3, statSync as statSync5, writeFileSync as writeFileSync2 } from "fs";
5
+ import { existsSync as existsSync3, realpathSync as realpathSync4, statSync as statSync6, writeFileSync as writeFileSync2 } from "fs";
6
6
 
7
7
  // src/rules/patterns.ts
8
8
  var IRRELEVANT_HOSTS = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|.*\.?example\.(?:com|org|net)|.*\.(?:test|invalid|localhost))$/i;
@@ -375,12 +375,12 @@ function isNetworkPath(path) {
375
375
  return process.platform === "win32" && (path.startsWith("\\\\") || path.startsWith("//"));
376
376
  }
377
377
  function resolveGitExecutable(root) {
378
- const executable = process.platform === "win32" ? "git.exe" : "git";
378
+ const executable2 = process.platform === "win32" ? "git.exe" : "git";
379
379
  const unsafe = untrustedRoots(root);
380
380
  for (const rawEntry of (process.env.PATH ?? "").split(delimiter)) {
381
381
  const entry = cleanPathEntry(rawEntry);
382
382
  if (!entry || !isAbsolute(entry) || isNetworkPath(entry) || isProjectBin(entry)) continue;
383
- const candidate = join(entry, executable);
383
+ const candidate = join(entry, executable2);
384
384
  try {
385
385
  if (!statSync(candidate).isFile()) continue;
386
386
  if (process.platform !== "win32") accessSync(candidate, constants.X_OK);
@@ -463,8 +463,8 @@ function hardeningArgs(root) {
463
463
  `core.hooksPath=${noHooks}`
464
464
  ];
465
465
  }
466
- function execGitSync(executable, root, args, options = {}) {
467
- return execFileSync(executable, [...hardeningArgs(root), ...args], {
466
+ function execGitSync(executable2, root, args, options = {}) {
467
+ return execFileSync(executable2, [...hardeningArgs(root), ...args], {
468
468
  timeout: GIT_TIMEOUT_MS,
469
469
  cwd: root,
470
470
  encoding: "utf8",
@@ -474,8 +474,8 @@ function execGitSync(executable, root, args, options = {}) {
474
474
  env: gitEnvironment()
475
475
  });
476
476
  }
477
- function execGitBatch(executable, root, args, input, options = {}) {
478
- return execFileSync(executable, [...hardeningArgs(root), ...args], {
477
+ function execGitBatch(executable2, root, args, input, options = {}) {
478
+ return execFileSync(executable2, [...hardeningArgs(root), ...args], {
479
479
  timeout: GIT_TIMEOUT_MS,
480
480
  cwd: root,
481
481
  input,
@@ -817,8 +817,7 @@ function walkTree(root, skipped, wantAll, maxEntries) {
817
817
  }
818
818
  if (!metadata.isFile()) continue;
819
819
  if (wantAll) all.push(rel);
820
- const isCandidate = isEnvFile(entry.name) || CREDENTIAL_FILENAMES.has(entry.name) || CREDENTIAL_EXTENSIONS.has(extname(entry.name).toLowerCase()) || isWorthProbing(rel);
821
- if (isCandidate) found.push(rel);
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: [], failedUntil: -1 });
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 < context.failedUntil || src[start + 1] === "=") return null;
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
- context.failedUntil = i;
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
- context.failedUntil = src.length;
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-\uffff]/.test(ch)) {
1143
+ if (/[A-Za-z_$\u0080-￿]/.test(ch)) {
1132
1144
  let end = at + 1;
1133
- while (end < src.length && /[\w$\u0080-\uffff]/.test(src[end])) end++;
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
- function maskTemplate(src, out, start, maskStrings = true) {
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 stack = [{ kind: "literal", from: start + 1 }];
1161
- let i = start + 1;
1162
- while (i < length && stack.length > 0) {
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
- if (maskStrings) blank(out, frame.from, i);
1211
+ text(frame.from, i);
1172
1212
  stack.pop();
1173
1213
  const parent = stack[stack.length - 1];
1174
- if (parent?.kind === "expression") valueEnded(parent.lexical);
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
- if (maskStrings) blank(out, frame.from, i);
1180
- stack.push({ kind: "expression", depth: 1, lexical: regexContext() });
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 (ch === SLASH) {
1188
- const next = src.charCodeAt(i + 1);
1189
- if (next === SLASH || next === STAR) {
1190
- const close = next === SLASH ? src.indexOf("\n", i) : src.indexOf("*/", i + 2);
1191
- const stop = close === -1 ? length : close + (next === STAR ? 2 : 0);
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
- const regex = regexAt(src, i, frame.lexical);
1197
- if (regex) {
1198
- if (maskStrings) blank(out, i + 1, regex.close);
1199
- i = regex.end;
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 (ch === OPEN_BRACE) frame.depth++;
1216
- else if (ch === CLOSE_BRACE) {
1217
- frame.depth--;
1218
- if (frame.depth === 0) {
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
- i = advanceCode(src, i, frame.lexical);
1225
- }
1226
- const last = stack[stack.length - 1];
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 (next === STAR) {
1247
- const close = src.indexOf("*/", i + 2);
1248
- const stop = close === -1 ? length : close + 2;
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
- if (ch === SLASH) {
1255
- const regex = regexAt(src, i, lexical);
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
- if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1262
- i = endOfString(src, i, ch);
1263
- valueEnded(lexical);
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 (next === STAR) {
1292
- const close = src.indexOf("*/", i + 2);
1293
- const stop = close === -1 ? length : close + 2;
1294
- blank(out, i, stop);
1295
- i = stop;
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 regex = regexAt(src, i, lexical);
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
- blank(out, i + 1, regex.close);
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
- blank(out, i + 1, stop - 1);
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
- i = maskTemplate(src, out, i);
1316
- valueEnded(lexical);
1312
+ stack.push({ kind: "literal", from: i + 1 });
1313
+ i++;
1317
1314
  continue;
1318
1315
  }
1319
- i = advanceCode(src, i, lexical);
1316
+ const markup = ch === LESS_THAN && !jsx && /^<[A-Za-z_$][\w$.:-]*\s+[\w:-]+\s*=\s*['"{]/.test(src.slice(i, i + 256));
1317
+ if ((jsx || markup) && ch === LESS_THAN && frame.lexical.allowed && jsxStartsAt(src, i)) {
1318
+ stack.push({ kind: "tag", closing: false });
1319
+ i++;
1320
+ continue;
1321
+ }
1322
+ if (ch === OPEN_BRACE) frame.depth++;
1323
+ else if (ch === CLOSE_BRACE) {
1324
+ frame.depth--;
1325
+ if (frame.nested && frame.depth === 0) {
1326
+ stack.pop();
1327
+ const parent = stack[stack.length - 1];
1328
+ if (parent.kind === "literal" || parent.kind === "children") parent.from = i + 1;
1329
+ i++;
1330
+ continue;
1331
+ }
1332
+ }
1333
+ i = advanceCode(src, i, frame.lexical);
1320
1334
  }
1335
+ const last = stack[stack.length - 1];
1336
+ if (last?.kind === "literal" || last?.kind === "children") text(last.from, length);
1321
1337
  return stringOf(out);
1322
1338
  }
1339
+ function maskJsComments(src, options = {}) {
1340
+ return maskSource(src, false, options.jsx === true);
1341
+ }
1342
+ function maskJsNoise(src, options = {}) {
1343
+ return maskSource(src, true, options.jsx === true);
1344
+ }
1345
+ var JSX_FILE = /\.(?:jsx|tsx|js|mjs|cjs)$/i;
1346
+ var maskOptionsOf = (file) => ({ jsx: typeof file.path === "string" && JSX_FILE.test(file.path) });
1323
1347
  var commentCache = /* @__PURE__ */ new WeakMap();
1324
1348
  var noiseCache = /* @__PURE__ */ new WeakMap();
1325
1349
  function commentsMaskedOf(file) {
1326
1350
  const hit = commentCache.get(file);
1327
1351
  if (hit !== void 0) return hit;
1328
- const masked = maskJsComments(file.content);
1352
+ const masked = maskJsComments(file.content, maskOptionsOf(file));
1329
1353
  commentCache.set(file, masked);
1330
1354
  return masked;
1331
1355
  }
1332
1356
  function noiseMaskedOf(file) {
1333
1357
  const hit = noiseCache.get(file);
1334
1358
  if (hit !== void 0) return hit;
1335
- const masked = maskJsNoise(file.content);
1359
+ const masked = maskJsNoise(file.content, maskOptionsOf(file));
1336
1360
  noiseCache.set(file, masked);
1337
1361
  return masked;
1338
1362
  }
@@ -1638,7 +1662,7 @@ function checkEnvFile(file) {
1638
1662
  `For anything the browser needs, use the anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY) together with Row Level Security policies.`
1639
1663
  ],
1640
1664
  humanOnly: [
1641
- `Rotate the service_role key in your Supabase dashboard (Project Settings -> API). If your site has ever been deployed with this key, assume it is already compromised \u2014 renaming the variable does not revoke it.`
1665
+ `Rotate the Supabase service_role key in your Supabase dashboard (Project Settings -> API). If your site has ever been deployed with this key, assume it is already compromised \u2014 renaming the variable does not revoke it.`
1642
1666
  ]
1643
1667
  });
1644
1668
  continue;
@@ -1720,7 +1744,8 @@ function pushServiceRoleJwts(findings, file, line, i, clientSide) {
1720
1744
  `Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
1721
1745
  ],
1722
1746
  humanOnly: [
1723
- `Rotate the key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
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
- `Rotate every credential in that file. This is the step people skip, and it is the only one that actually stops the leak.`,
1978
- `Removing it from history entirely requires rewriting the repo (git filter-repo or BFG). Do that only after rotating the keys \u2014 rotation is what matters, and history rewriting is disruptive enough that it should be a deliberate decision.`
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 this file. Do this first, and do not skip it \u2014 it is the only step that actually revokes access.`,
2021
- `Then, if you need the history cleaned, rewrite it with git filter-repo or BFG Repo-Cleaner. Do this deliberately: it rewrites every commit hash and disrupts anyone else working on the repo.`
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
- `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.`,
2652
- `If this table has been live without RLS, assume its contents have already been read.`
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: needsReview ? `Policy "${p.name}" has an unrestricted condition on ${table}; other conditions require review` : opensRows ? `Policy "${p.name}" lets ${audience} ${verb} every row of ${table}` : `Policy "${p.name}" lets ${audience} write any values into ${table}`,
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
- ...limitedCheck ? ["The WITH CHECK condition is not known to be always true, so unrestricted updates are not established."] : [],
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
- return { kind: /\.auth\.getUser\s*\(/.test(expr) ? "envelope" : "identity" };
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
- if (assignment.projection === null) value = { kind: "opaque" };
3345
- else if (assignment.projection.length && value.kind === "envelope") value = {
3346
- kind: assignment.projection.join(".") === "data.user" ? "identity" : "opaque"
3347
- };
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
- if (value.kind === "envelope") return { kind: /^\??\.data\??\.user$/.test(access[2]) ? "identity" : "envelope" };
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 lowerBound = (value) => {
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(lowerBound(start + 1), lowerBound(end));
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(check) {
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 = check(file);
3802
+ hit = check2(file);
3764
3803
  cache3.set(file, hit);
3765
3804
  }
3766
3805
  return hit;
@@ -4800,6 +4839,10 @@ var apiAuthRule = {
4800
4839
  const globalNote = globalGuard === null ? [] : [
4801
4840
  `${globalGuard} contains an authentication check that may cover ${url}. Its path conditions are written in code, so the scan cannot tell which routes it applies to; this finding is reported at lower confidence. Confirm that check runs for ${url}.`
4802
4841
  ];
4842
+ const handlerCode = noiseMaskedOf(route.file).slice(reachable?.start ?? 0, reachable?.end);
4843
+ if (/\.auth\s*\.\s*getSession\s*\(/.test(handlerCode)) globalNote.push(
4844
+ "This handler checks supabase.auth.getSession(). On the server that reads the session from the cookie without verifying it, so a forged cookie passes; it is not counted as authentication. Use supabase.auth.getClaims() or getUser() instead."
4845
+ );
4803
4846
  const admin = usesAdminClient(route, adminModules, ctx.files);
4804
4847
  const hit = ops.find((o) => o.writes) ?? ops[0];
4805
4848
  const indirect = indirectOps.get(hit.index);
@@ -5258,138 +5301,1565 @@ var corsRule = {
5258
5301
  }
5259
5302
  };
5260
5303
 
5261
- // src/rules/index.ts
5262
- var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
5263
- var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule];
5264
- var RULE_IDS = [
5265
- "api/admin-db-access-without-auth",
5266
- "api/db-write-without-auth",
5267
- "cors/reflected-origin-with-credentials",
5268
- "cors/wildcard-with-credentials",
5269
- "exposure/private-name-in-public-env",
5270
- "exposure/secret-in-public-env",
5271
- "exposure/supabase-service-role-in-client",
5272
- "firebase/open-rules",
5273
- "firebase/test-mode-rules",
5274
- "gitleak/env-in-history",
5275
- "gitleak/env-tracked",
5276
- "supabase/permissive-policy",
5277
- "supabase/public-bucket-listing",
5278
- "supabase/rls-not-enabled",
5279
- // 凭据规则 ID 从共享格式表生成。
5280
- ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
5304
+ // src/rules/request-input.ts
5305
+ var MAX_VALUE_HOPS = 8;
5306
+ var MAX_ASSIGNMENTS = 512;
5307
+ var MAX_EXPRESSION = 4e3;
5308
+ var REQUEST_OBJECT_NAMES = /^(?:req|request|event|evt|ctx|context)$/;
5309
+ var DESTRUCTURED_OBJECTS = /* @__PURE__ */ new Set(["request", "req", "event"]);
5310
+ var DESTRUCTURED_URLS = /* @__PURE__ */ new Set(["url"]);
5311
+ var DESTRUCTURED_COOKIES = /* @__PURE__ */ new Set(["cookies"]);
5312
+ var DESTRUCTURED_VALUES = /* @__PURE__ */ new Set(["params", "query", "body", "searchParams"]);
5313
+ var TRANSPARENT_CALLS = /* @__PURE__ */ new Set([
5314
+ "json",
5315
+ "formData",
5316
+ "text",
5317
+ "arrayBuffer",
5318
+ "blob",
5319
+ "get",
5320
+ "getAll",
5321
+ "String",
5322
+ "trim",
5323
+ "trimStart",
5324
+ "trimEnd",
5325
+ "toString",
5326
+ "toLowerCase",
5327
+ "toUpperCase",
5328
+ "URL",
5329
+ "URLSearchParams",
5330
+ "decodeURIComponent",
5331
+ "decodeURI",
5332
+ "fromEntries",
5333
+ "entries",
5334
+ "readBody",
5335
+ "readFormData",
5336
+ "readRawBody",
5337
+ "getQuery",
5338
+ "getRouterParam",
5339
+ "getRouterParams",
5340
+ "getHeader",
5341
+ "getHeaders",
5342
+ "getRequestHeader",
5343
+ "getRequestHeaders",
5344
+ "getCookie",
5345
+ "parseCookies",
5346
+ "getRequestURL"
5347
+ ]);
5348
+ var H3_READERS = "readBody|readFormData|readMultipartFormData|readRawBody|readValidatedBody|getQuery|getValidatedQuery|getRouterParams?|getValidatedRouterParams|getHeaders?|getRequestHeaders?|getCookie|parseCookies|getRequestURL";
5349
+ var LITERAL = String.raw`(?:'[^']*'|"[^"]*"|\x60[^\x60$]*\x60|-?\d+(?:\.\d+)?|null|undefined|true|false)`;
5350
+ var CLEAN_EXPRESSION = [
5351
+ /^(?:await\s+)?(?:Number|parseInt|parseFloat|Boolean|BigInt|isNaN|Math\s*\.\s*\w+|Number\s*\.\s*\w+)\s*\(/,
5352
+ /\.\s*length\s*$/,
5353
+ /^(?:!|typeof\b)/,
5354
+ /\.\s*(?:includes|test|has|startsWith|endsWith|some|every)\s*\([^?]*\)\s*$/,
5355
+ new RegExp(String.raw`^[^?]*\?\s*${LITERAL}\s*:\s*${LITERAL}\s*$`),
5356
+ // 带标签的模板由标签函数处理插值(Prisma.sql、sql、html),String.raw 除外。
5357
+ /^(?:await\s+)?(?!String\s*\.\s*raw\b)[\w$]+(?:\s*\.\s*[\w$]+)*\s*\x60/
5281
5358
  ];
5282
- function ruleMatches(selector, ruleId) {
5283
- return ruleId === selector || ruleId.startsWith(`${selector}/`);
5284
- }
5285
- function isKnownSelector(selector) {
5286
- return RULE_IDS.some((id) => ruleMatches(selector, id));
5287
- }
5288
- function shouldRunRule(id, only, skip) {
5289
- if (only.length === 0 && skip.length === 0) return true;
5290
- const namespace = id.split("/")[0];
5291
- return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
5292
- (candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
5293
- );
5294
- }
5295
-
5296
- // src/engine.ts
5297
- import { createHash as createHash2 } from "crypto";
5298
- var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
5299
- var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
5300
- function dedupe(findings) {
5301
- const seen = /* @__PURE__ */ new Set();
5302
- const out = [];
5303
- for (const f of findings) {
5304
- const key = `${f.ruleId}|${f.file ?? ""}|${f.line ?? ""}|${f.excerpt ?? ""}|${f.title}`;
5305
- if (seen.has(key)) continue;
5306
- seen.add(key);
5307
- out.push(f);
5359
+ var URL_INPUT_ACCESS = /\??\.\s*(?:searchParams|pathname|search|hash)\b/;
5360
+ var URL_INPUT_KEYS = /* @__PURE__ */ new Set(["searchParams", "pathname", "search", "hash"]);
5361
+ var URL_ORIGIN_ACCESS = /^\s*\??\.\s*(?:origin|host|hostname|protocol|port)\b/;
5362
+ var URL_ORIGIN_KEYS = /* @__PURE__ */ new Set(["origin", "host", "hostname", "protocol", "port"]);
5363
+ function isComparison(expr) {
5364
+ return !/\?/.test(expr) && !/=>/.test(expr) && /(?:===|!==|==|!=|<=|>=|\binstanceof\b)/.test(expr);
5365
+ }
5366
+ function parameterList(text) {
5367
+ const parts = [];
5368
+ let depth = 0, from = 0;
5369
+ for (let i = 0; i < text.length; i++) {
5370
+ const ch = text[i];
5371
+ if ("{[(<".includes(ch)) depth++;
5372
+ else if ("}])>".includes(ch)) depth--;
5373
+ else if (ch === "," && depth === 0) {
5374
+ parts.push(text.slice(from, i));
5375
+ from = i + 1;
5376
+ }
5308
5377
  }
5309
- return out;
5310
- }
5311
- var CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
5312
- var DECEPTIVE_CHARS = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
5313
- function nameOf(ch) {
5314
- return `<U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}>`;
5315
- }
5316
- function clean(text) {
5317
- return redactAll(text).replace(/\t/g, " ").replace(CONTROL_CHARS, "").replace(DECEPTIVE_CHARS, nameOf);
5378
+ parts.push(text.slice(from));
5379
+ return parts.map((part) => part.trim()).filter(Boolean);
5318
5380
  }
5319
- function cleanForOutput(text) {
5320
- return clean(text);
5381
+ function destructuredKeys(pattern) {
5382
+ const inner = pattern.slice(1, pattern.lastIndexOf("}"));
5383
+ const result = [];
5384
+ for (const part of parameterList(inner)) {
5385
+ const m = /^(?:\.\.\.)?\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=[\s\S]*)?$/.exec(part);
5386
+ if (m) result.push({ key: m[1], local: m[2] ?? m[1] });
5387
+ }
5388
+ return result;
5321
5389
  }
5322
- function sanitize(findings, files) {
5323
- const byPath = new Map(files.map((file) => [file.path, file]));
5324
- const sourceIdentity = (f) => {
5325
- if (f.sourceFingerprint !== void 0) return { sourceFingerprint: f.sourceFingerprint };
5326
- const file = f.file === null ? void 0 : byPath.get(f.file);
5327
- const source = f.line === null ? file?.content : file?.lines[f.line - 1];
5328
- return source === void 0 ? {} : {
5329
- sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
5330
- };
5331
- };
5332
- return findings.map((f) => ({
5333
- ...f,
5334
- // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
5335
- ...sourceIdentity(f),
5336
- title: clean(f.title),
5337
- // 按段落清理,保留段落之间的结构。
5338
- why: f.why.map(clean),
5339
- // 文件路径也必须经过输出清理。
5340
- file: f.file === null ? null : clean(f.file),
5341
- // 先脱敏再截断,避免截断导致凭据特征失效。
5342
- excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
5343
- ...f.evidence ? {
5344
- evidence: f.evidence.slice(0, 24).map((step) => ({
5345
- kind: step.kind,
5346
- file: clean(step.file),
5347
- line: step.line,
5348
- description: clean(step.description)
5349
- })),
5350
- evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
5351
- } : {},
5352
- fix: f.fix.map(clean),
5353
- ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
5354
- }));
5390
+ function patternNames(pattern) {
5391
+ const text = pattern.trim();
5392
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) return [text];
5393
+ const stripped = text.replace(/[A-Za-z_$][\w$]*\s*:(?!:)/g, " ").replace(/=[^,}\]]*/g, " ");
5394
+ return stripped.match(/[A-Za-z_$][\w$]*/g) ?? [];
5395
+ }
5396
+ function paramRoles(paramsText, paramsStart, allInput) {
5397
+ const roles = { objects: /* @__PURE__ */ new Map(), urls: /* @__PURE__ */ new Map(), cookies: /* @__PURE__ */ new Map(), values: /* @__PURE__ */ new Map() };
5398
+ let offset = 0;
5399
+ for (const raw of parameterList(paramsText)) {
5400
+ const at = paramsStart + Math.max(0, paramsText.indexOf(raw, offset));
5401
+ offset = Math.max(offset, paramsText.indexOf(raw, offset) + raw.length);
5402
+ const param = raw.startsWith("{") || raw.startsWith("[") ? raw.slice(0, matchingClose(raw) + 1) : raw.replace(/^\.\.\./, "").split(/[:=]/)[0].trim();
5403
+ if (allInput) {
5404
+ for (const name of patternNames(param)) roles.values.set(name, at);
5405
+ continue;
5406
+ }
5407
+ if (param.startsWith("{")) {
5408
+ for (const { key, local } of destructuredKeys(param)) {
5409
+ if (DESTRUCTURED_OBJECTS.has(key)) roles.objects.set(local, at);
5410
+ else if (DESTRUCTURED_URLS.has(key)) roles.urls.set(local, at);
5411
+ else if (DESTRUCTURED_COOKIES.has(key)) roles.cookies.set(local, at);
5412
+ else if (DESTRUCTURED_VALUES.has(key)) roles.values.set(local, at);
5413
+ }
5414
+ } else if (REQUEST_OBJECT_NAMES.test(param)) roles.objects.set(param, at);
5415
+ }
5416
+ return roles;
5355
5417
  }
5356
- function sanitizeSkippedForOutput(items) {
5357
- return items.map((item) => ({
5358
- ...item,
5359
- path: clean(item.path),
5360
- ...item.detail === void 0 ? {} : { detail: clean(item.detail) }
5361
- }));
5418
+ function matchingClose(text) {
5419
+ let depth = 0;
5420
+ for (let i = 0; i < text.length; i++) {
5421
+ if ("{[".includes(text[i])) depth++;
5422
+ else if ("}]".includes(text[i]) && --depth === 0) return i;
5423
+ }
5424
+ return text.length - 1;
5362
5425
  }
5363
- function downgradeExampleContext(findings, files) {
5364
- const examples = new Set(files.filter((f) => f.isExampleContext).map((f) => f.path));
5365
- return findings.map(
5366
- (f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
5367
- );
5426
+ function parametersOf(code, body, pairs, openers) {
5427
+ const fn = /^function\s*\*?\s*(?:[\w$]+\s*)?\(/.exec(code.slice(body.declaration, body.declaration + 200));
5428
+ if (fn) {
5429
+ const open = body.declaration + fn[0].length - 1;
5430
+ const close2 = pairs.get(open);
5431
+ return close2 === void 0 ? null : { start: open + 1, end: close2 };
5432
+ }
5433
+ const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
5434
+ const close = window.lastIndexOf(")");
5435
+ if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
5436
+ const absolute = body.declaration - window.length + close;
5437
+ const open = openers.get(absolute);
5438
+ return open === void 0 ? null : { start: open + 1, end: absolute };
5439
+ }
5440
+ const single = /([A-Za-z_$][\w$]*)\s*$/.exec(window);
5441
+ return single ? { start: body.declaration - window.length + single.index, end: body.declaration - window.length + single.index + single[1].length } : null;
5368
5442
  }
5369
- function suppressIgnoredLines(findings, files) {
5370
- const byPath = new Map(files.map((f) => [f.path, f]));
5371
- const markers = /* @__PURE__ */ new Map();
5372
- const kept = [];
5373
- const ignored = [];
5374
- for (const f of findings) {
5375
- if (f.file === null || f.line === null) {
5376
- kept.push(f);
5443
+ function expressionEnd(code, from, limit, pairs, truncated) {
5444
+ const stop = Math.min(limit, from + MAX_EXPRESSION);
5445
+ for (let i = from; i < stop; i++) {
5446
+ const ch = code[i];
5447
+ const close = pairs.get(i);
5448
+ if (close !== void 0) {
5449
+ if (close >= stop) {
5450
+ truncated();
5451
+ return stop;
5452
+ }
5453
+ i = close;
5377
5454
  continue;
5378
5455
  }
5379
- let lines = markers.get(f.file);
5380
- if (lines === void 0) {
5381
- const file = byPath.get(f.file);
5382
- lines = file === void 0 ? /* @__PURE__ */ new Map() : ignoredLinesOf(file.lines);
5383
- markers.set(f.file, lines);
5456
+ if (ch === ";" || ch === "," || ch === ")" || ch === "]" || ch === "}") return i;
5457
+ if (ch === "\n") {
5458
+ const before = code.slice(from, i).trimEnd();
5459
+ const after = code.slice(i + 1, i + 80).trimStart();
5460
+ if (before === "" || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
5461
+ if (/^(?:[.?+\-*/%&|^,:<>=]|\?\?)/.test(after) && !/^(?:\+\+|--)/.test(after)) continue;
5462
+ return i;
5384
5463
  }
5385
- if (!lines.has(f.line)) {
5386
- kept.push(f);
5464
+ }
5465
+ if (stop < limit && !/[;,)}\]\n]/.test(code[stop] ?? "")) truncated();
5466
+ return stop;
5467
+ }
5468
+ function literalTable(text) {
5469
+ const value = text.trim();
5470
+ const item = new RegExp(`^${LITERAL}$`);
5471
+ const property = new RegExp(`^(?:[A-Za-z_$][\\w$]*|'[^']*'|"[^"]*")\\s*:\\s*${LITERAL}$`);
5472
+ if (!(value.startsWith("{") && value.endsWith("}")) && !(value.startsWith("[") && value.endsWith("]"))) return false;
5473
+ return parameterList(value.slice(1, -1)).every((part) => (value[0] === "{" ? property : item).test(part));
5474
+ }
5475
+ function fixedTablesOf(code, source, pairs) {
5476
+ const result = /* @__PURE__ */ new Map();
5477
+ const seen = /* @__PURE__ */ new Set();
5478
+ for (const m of code.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/g)) {
5479
+ const name = m[1];
5480
+ if (seen.has(name)) {
5481
+ result.delete(name);
5387
5482
  continue;
5388
5483
  }
5389
- const rules = lines.get(f.line);
5390
- if (rules !== null && rules !== void 0 && !rules.has(f.ruleId)) {
5391
- kept.push(f);
5392
- continue;
5484
+ seen.add(name);
5485
+ const open = m.index + m[0].length;
5486
+ const close = pairs.get(open);
5487
+ if (!m[0].startsWith("const") || close === void 0 || close - open > MAX_EXPRESSION) continue;
5488
+ if (literalTable(source.slice(open, close + 1))) result.set(name, m.index);
5489
+ }
5490
+ for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)(?:\s*\.\s*[\w$]+|\s*\[[^\]]*\])?\s*(?:\+\+|--|[+*/%&|^-]?=(?![=>]))/g)) {
5491
+ if (!/\bconst\s+$/.test(code.slice(Math.max(0, m.index - 20), m.index))) result.delete(m[1]);
5492
+ }
5493
+ for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g)) {
5494
+ result.delete(m[1]);
5495
+ }
5496
+ for (const m of code.matchAll(/\b(?:Object\s*\.\s*(?:assign|defineProperty|defineProperties|setPrototypeOf)|Reflect\s*\.\s*(?:set|defineProperty|setPrototypeOf))\s*\(\s*([A-Za-z_$][\w$]*)\b/g)) {
5497
+ result.delete(m[1]);
5498
+ }
5499
+ return result;
5500
+ }
5501
+ var InputFlow = class {
5502
+ constructor(code, source, span, pairs, roles, fixedTables) {
5503
+ this.code = code;
5504
+ this.source = source;
5505
+ this.span = span;
5506
+ this.pairs = pairs;
5507
+ this.fixedTables = fixedTables;
5508
+ for (const [name, at] of roles.values) this.parameters.set(name, { level: "direct", origin: at, names: /* @__PURE__ */ new Set([name]), ownUrl: false });
5509
+ this.sourcePattern = sourceRegex(roles);
5510
+ const assignments = this.assignments().sort((a, b) => a.at - b.at);
5511
+ const region = code.slice(span.start, span.end);
5512
+ const functions = this.localFunctions(region, assignments);
5513
+ const scopes = [span];
5514
+ for (let i = span.start + 1; i < span.end; i++) {
5515
+ const end = code[i] === "{" ? pairs.get(i) : void 0;
5516
+ if (end !== void 0) scopes.push({ start: i, end });
5517
+ }
5518
+ const controls = [];
5519
+ for (const m of region.matchAll(/\b(?:if|for|while|switch|catch)\s*\(|\belse\b/g)) {
5520
+ const open = span.start + m.index + m[0].length - 1;
5521
+ let start = m[0] === "else" ? open + 1 : (pairs.get(open) ?? open) + 1;
5522
+ while (/\s/.test(code[start] ?? "")) start++;
5523
+ controls.push({ start, end: pairs.get(start) ?? expressionEnd(code, start, span.end, pairs, () => {
5524
+ this.limited = true;
5525
+ }) });
5526
+ if (controls.length >= MAX_ASSIGNMENTS) {
5527
+ this.limited = true;
5528
+ break;
5529
+ }
5530
+ }
5531
+ for (const a of assignments) {
5532
+ const owner = functions.filter((f) => f.start < a.at && a.at < f.end).at(-1);
5533
+ if (owner) a.owner = owner;
5534
+ a.scope = scopes.filter((s) => s.start < a.at && a.at < s.end).at(-1) ?? span;
5535
+ if (a.owner && a.owner.start > a.scope.start) a.scope = a.owner;
5536
+ a.controls = controls.filter((s) => s.start <= a.at && a.at <= s.end);
5537
+ for (const name of a.names) {
5538
+ const list = this.assignmentList.get(name) ?? [];
5539
+ if (!list.some((item) => item.exprAt === a.exprAt)) list.push(a);
5540
+ this.assignmentList.set(name, list);
5541
+ }
5542
+ }
5543
+ const names = /* @__PURE__ */ new Set([...this.parameters.keys(), ...this.assignmentList.keys()]);
5544
+ if (names.size) this.references = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`, "g");
5545
+ }
5546
+ code;
5547
+ source;
5548
+ span;
5549
+ pairs;
5550
+ fixedTables;
5551
+ parameters = /* @__PURE__ */ new Map();
5552
+ limited = false;
5553
+ assignmentList = /* @__PURE__ */ new Map();
5554
+ sourcePattern;
5555
+ references = null;
5556
+ values = /* @__PURE__ */ new Map();
5557
+ strings = /* @__PURE__ */ new Map();
5558
+ /** 只索引当前处理函数;未引用的具名闭包不影响外层值。 */
5559
+ localFunctions(region, assignments) {
5560
+ const offset = this.span.start;
5561
+ const bodies = functionBodies(region, delimiterPairs(region)).map((f) => ({
5562
+ declaration: f.declaration + offset,
5563
+ start: f.start + offset,
5564
+ end: f.end + offset
5565
+ }));
5566
+ for (const m of region.matchAll(/=>\s*(?!\s*\{)/g)) {
5567
+ const start = offset + m.index + m[0].length;
5568
+ const end = expressionEnd(this.code, start, this.span.end, this.pairs, () => {
5569
+ this.limited = true;
5570
+ });
5571
+ bodies.push({ declaration: offset + m.index, start: start - 1, end });
5572
+ }
5573
+ if (bodies.length > MAX_ASSIGNMENTS) this.limited = true;
5574
+ return bodies.slice(0, MAX_ASSIGNMENTS).sort((a, b) => a.start - b.start).map((f) => {
5575
+ const binding = assignments.filter((a) => {
5576
+ if (!a.declaration || a.exprAt > f.declaration || a.exprAt + a.expr.length < f.end) return false;
5577
+ const prefix = this.code.slice(a.exprAt, f.declaration).trim();
5578
+ return prefix === "" || prefix === "async" || /^(?:async\s*)?(?:\([^;{}]*\)|[\w$]+)\s*(?::[^=;{}]*)?$/.test(prefix);
5579
+ }).sort((a, b) => b.exprAt - a.exprAt)[0];
5580
+ const declared = /^function\s*\*?\s*([\w$]+)\s*\(/.exec(this.code.slice(f.declaration, f.start))?.[1];
5581
+ const before = this.code.slice(this.span.start, f.declaration).trimEnd().replace(/\basync$/, "").trimEnd();
5582
+ const name = binding?.names.length === 1 ? binding.names[0] : /[;{}]$/.test(before) ? declared : void 0;
5583
+ if (/^\s*\)*\s*(?:\(|\.\s*(?:call|apply)\s*\()/.test(this.code.slice(f.end + 1, this.span.end))) {
5584
+ return { ...f, usedAt: f.end };
5585
+ }
5586
+ if (!name) return { ...f, usedAt: f.end };
5587
+ const start = binding?.at ?? f.declaration;
5588
+ const reference = new RegExp(`(?<![\\w$.])${namePattern([name])}(?![\\w$])`, "g");
5589
+ const use = [...region.matchAll(reference)].find((m) => offset + m.index < start || offset + m.index > f.end);
5590
+ return { ...f, usedAt: use ? offset + use.index : Infinity };
5591
+ });
5592
+ }
5593
+ /** 表达式是否携带请求输入;已转换为数值、布尔或查表结果的不算。 */
5594
+ taintOf(expr, exprAt, depth = 0) {
5595
+ if (expr.length > MAX_EXPRESSION) this.limited = true;
5596
+ const text = expr.trim();
5597
+ if (text === "" || CLEAN_EXPRESSION.some((pattern) => pattern.test(text)) || isComparison(text)) return null;
5598
+ const lookup = new RegExp(`^([A-Za-z_$][\\w$]*)\\s*\\[[^\\]]*\\]\\s*(?:(?:\\?\\?|\\|\\|)\\s*${LITERAL})?\\s*$`).exec(text);
5599
+ if (lookup && this.isFixedTable(lookup[1], exprAt)) return null;
5600
+ const observed = expr.replace(/\[\s*['"][^'"]*['"]\s*\]/g, (part, offset) => {
5601
+ const raw = this.source.slice(exprAt + offset, exprAt + offset + part.length);
5602
+ const key = /^\[\s*['"]([A-Za-z_$][\w$]*)['"]\s*\]$/.exec(raw)?.[1];
5603
+ return key ? `.${key}`.padEnd(part.length) : part;
5604
+ });
5605
+ let found = null;
5606
+ if (this.sourcePattern) {
5607
+ this.sourcePattern.lastIndex = 0;
5608
+ for (const m of observed.matchAll(this.sourcePattern)) {
5609
+ const after = observed.slice(m.index + m[0].length);
5610
+ const ownUrl = m[1] === "url" || m[1] === "nextUrl";
5611
+ if (ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
5612
+ found = mergeTaint(found, {
5613
+ level: "direct",
5614
+ origin: exprAt + m.index,
5615
+ names: /* @__PURE__ */ new Set(),
5616
+ ownUrl: ownUrl && !URL_INPUT_ACCESS.test(after.slice(0, 40))
5617
+ });
5618
+ }
5619
+ }
5620
+ const refs = this.references && new RegExp(this.references.source, "g");
5621
+ if (refs) {
5622
+ refs.lastIndex = 0;
5623
+ for (const m of expr.matchAll(refs)) {
5624
+ const before = expr.slice(0, m.index).trimEnd();
5625
+ const after = expr.slice(m.index + m[0].length);
5626
+ if (/^\s*:(?!:)/.test(after) && /[{,]$/.test(before)) continue;
5627
+ const t = this.valueOf(m[0], exprAt, depth);
5628
+ if (!t) continue;
5629
+ if (t.ownUrl && URL_ORIGIN_ACCESS.test(after)) continue;
5630
+ found = mergeTaint(found, t.ownUrl && URL_INPUT_ACCESS.test(after.slice(0, 40)) ? { ...t, ownUrl: false } : t);
5631
+ }
5632
+ }
5633
+ if (!found) return null;
5634
+ if (found.ownUrl && URL_INPUT_ACCESS.test(text)) found.ownUrl = false;
5635
+ const calls = [...text.replace(/\bJSON\s*\.\s*parse\s*\(/g, "(").matchAll(/([\w$]+)\s*\(/g)].map((m) => m[1]);
5636
+ if (calls.some((name) => !TRANSPARENT_CALLS.has(name))) found.level = "derived";
5637
+ return found;
5638
+ }
5639
+ /** 字符串拼接:无标签模板的插值或含字符串操作数的 + 拼接中携带请求输入。 */
5640
+ builtOf(expr, exprAt, depth = 0) {
5641
+ if (expr.length > MAX_EXPRESSION) this.limited = true;
5642
+ const text = expr.trim();
5643
+ const lead = expr.length - expr.trimStart().length;
5644
+ if (text.startsWith("`")) return this.interpolationTaint(exprAt + lead, exprAt + lead + text.length, depth);
5645
+ const operands = this.topLevelOperands(expr, exprAt);
5646
+ if (operands.length < 2 || !operands.some((o) => /^['"\x60]/.test(o.text.trim()))) return null;
5647
+ let found = null;
5648
+ for (const operand of operands) {
5649
+ const t = operand.text.trim().startsWith("`") ? this.interpolationTaint(operand.at, operand.at + operand.text.length, depth) : this.taintOf(operand.text, operand.at, depth);
5650
+ if (t) found = mergeTaint(found, t);
5651
+ }
5652
+ return found;
5653
+ }
5654
+ /** 仅保留使用位置可见的赋值;无条件覆盖终止旧值,分支与追加赋值保留可能来源。 */
5655
+ assignmentsFor(name, at) {
5656
+ const list = this.assignmentList.get(name) ?? [];
5657
+ const contains = (s, where) => s.start < where && where < s.end;
5658
+ const bindingAt = (where) => list.filter((a) => a.declaration && a.at <= where && contains(a.scope, where)).sort((a, b) => b.scope.start - a.scope.start || b.at - a.at)[0];
5659
+ const binding = bindingAt(at);
5660
+ const result = [];
5661
+ for (let i = list.length - 1; i >= 0; i--) {
5662
+ const a = list[i];
5663
+ if (a.at >= at || a.exprAt + a.expr.length >= at) continue;
5664
+ if (a.declaration ? a !== binding : bindingAt(a.at) !== binding) continue;
5665
+ const indirect = a.owner !== void 0 && !contains(a.owner, at);
5666
+ if (indirect && a.owner.usedAt >= at) continue;
5667
+ result.push(indirect ? { ...a, indirect: true } : a);
5668
+ if (!indirect && !a.append && contains(a.scope, at) && a.controls.every((s) => contains(s, at))) break;
5669
+ }
5670
+ return result;
5671
+ }
5672
+ valueOf(name, at, depth = 0) {
5673
+ const key = `${name}:${at}:${depth}`;
5674
+ if (this.values.has(key)) return this.values.get(key);
5675
+ const result = this.resolveValue(name, at, depth);
5676
+ this.values.set(key, result);
5677
+ return result;
5678
+ }
5679
+ resolveValue(name, at, depth) {
5680
+ if (depth >= MAX_VALUE_HOPS) {
5681
+ this.limited = true;
5682
+ return null;
5683
+ }
5684
+ const assignments = this.assignmentsFor(name, at);
5685
+ if (!assignments.length) return this.parameters.get(name) ?? null;
5686
+ let found = null;
5687
+ for (const a of assignments) {
5688
+ const taint = this.builtOf(a.expr, a.exprAt, depth + 1) ?? this.taintOf(a.expr, a.exprAt, depth + 1);
5689
+ if (!taint) continue;
5690
+ const own = this.targetsOf(a, taint).find(([target]) => target === name);
5691
+ if (own) found = mergeTaint(found, { ...taint, level: a.indirect ? "derived" : taint.level, ownUrl: own[1], names: /* @__PURE__ */ new Set([name, ...taint.names]) });
5692
+ }
5693
+ if (this.parameters.has(name) && assignments.every((a) => a.append || !a.scope || a.scope.start !== this.span.start || a.controls.length)) {
5694
+ found = mergeTaint(found, this.parameters.get(name));
5695
+ }
5696
+ return found;
5697
+ }
5698
+ /** 保留有效拼接及别名的 SQL 文本证据,不借用已被覆盖的旧查询。 */
5699
+ builtValue(name, at, depth = 0) {
5700
+ const key = `${name}:${at}:${depth}`;
5701
+ if (this.strings.has(key)) return this.strings.get(key);
5702
+ const result = this.resolveBuiltValue(name, at, depth);
5703
+ this.strings.set(key, result);
5704
+ return result;
5705
+ }
5706
+ resolveBuiltValue(name, at, depth) {
5707
+ if (depth >= MAX_VALUE_HOPS) {
5708
+ this.limited = true;
5709
+ return null;
5710
+ }
5711
+ let found = null;
5712
+ let text = "";
5713
+ const append = (part) => {
5714
+ if (text.length + part.length + 1 > MAX_EXPRESSION) this.limited = true;
5715
+ text = `${text}
5716
+ ${part}`.slice(0, MAX_EXPRESSION);
5717
+ };
5718
+ for (const a of this.assignmentsFor(name, at)) {
5719
+ append(this.source.slice(a.exprAt, a.exprAt + a.expr.length));
5720
+ const alias = /^[A-Za-z_$][\w$]*$/.test(a.expr.trim()) ? this.builtValue(a.expr.trim(), a.exprAt, depth + 1) : null;
5721
+ const t = this.builtOf(a.expr, a.exprAt, depth + 1) ?? alias ?? (a.append ? this.taintOf(a.expr, a.exprAt, depth + 1) : null);
5722
+ if (alias) append(alias.text);
5723
+ if (t) found = mergeTaint(found, a.indirect ? { ...t, level: "derived" } : t);
5724
+ }
5725
+ return found ? { ...found, names: /* @__PURE__ */ new Set([name, ...found.names]), text } : null;
5726
+ }
5727
+ isFixedTable(name, at) {
5728
+ const assignments = this.assignmentsFor(name, at);
5729
+ if (assignments.length) return assignments.every((a) => !a.append && literalTable(this.source.slice(a.exprAt, a.exprAt + a.expr.length))) && this.fixedTables.has(name);
5730
+ return (this.fixedTables.get(name) ?? Infinity) < at && !this.parameters.has(name);
5731
+ }
5732
+ /** 顶层 + 拼接的各操作数;括号与模板插值内部的 + 不拆分。 */
5733
+ operandsOf(expr, exprAt) {
5734
+ return this.topLevelOperands(expr, exprAt);
5735
+ }
5736
+ /** 使用前是否检查过内容:条件中除存在性判断外的引用,或作为校验函数实参。 */
5737
+ validatedBefore(names, at) {
5738
+ if (names.size === 0) return false;
5739
+ const region = this.code.slice(this.span.start, at);
5740
+ const pattern = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
5741
+ for (const m of region.matchAll(/\b(?:if|switch)\s*\(/g)) {
5742
+ const open = this.span.start + m.index + m[0].length - 1;
5743
+ const close = this.pairs.get(open);
5744
+ if (close === void 0 || close > at) continue;
5745
+ const condition = this.code.slice(open + 1, close);
5746
+ const alternatives = namePattern(names);
5747
+ const substantive = condition.replace(new RegExp(`!\\s*(?:${alternatives})(?![\\w$.(\\[])`, "g"), " ").replace(new RegExp(`(?<![\\w$.])(?:${alternatives})\\s*[!=]==?\\s*(?:null|undefined|''|""|0)`, "g"), " ").replace(new RegExp(`\\btypeof\\s+(?:${alternatives})\\b`, "g"), " ");
5748
+ if (pattern.test(substantive)) return true;
5749
+ }
5750
+ const validator = /(?:\.\s*(?:test|match|includes|has|startsWith|endsWith|parse|safeParse)|(?<![\w$.])(?:valid\w*|sanitiz\w*|escape\w*|is[A-Z]\w*|assert\w*))\s*\(/gi;
5751
+ for (const m of region.matchAll(validator)) {
5752
+ const open = this.span.start + m.index + m[0].length - 1;
5753
+ const close = this.pairs.get(open);
5754
+ if (close !== void 0 && close <= at && pattern.test(this.code.slice(open + 1, close))) return true;
5755
+ }
5756
+ return false;
5757
+ }
5758
+ interpolationTaint(from, to, depth) {
5759
+ let found = null;
5760
+ for (let i = from; i < to - 1; i++) {
5761
+ if (this.code[i] !== "$" || this.code[i + 1] !== "{") continue;
5762
+ const close = this.pairs.get(i + 1);
5763
+ if (close === void 0) continue;
5764
+ const t = this.taintOf(this.code.slice(i + 2, close), i + 2, depth);
5765
+ i = close;
5766
+ if (t) found = mergeTaint(found, t);
5767
+ }
5768
+ return found;
5769
+ }
5770
+ topLevelOperands(expr, exprAt) {
5771
+ const result = [];
5772
+ let from = 0;
5773
+ for (let i = 0; i < expr.length; i++) {
5774
+ const close = this.pairs.get(exprAt + i);
5775
+ if (close !== void 0) {
5776
+ i = close - exprAt;
5777
+ continue;
5778
+ }
5779
+ if (expr[i] === "+" && expr[i + 1] !== "+" && expr[i + 1] !== "=" && expr[i - 1] !== "+") {
5780
+ result.push({ text: expr.slice(from, i), at: exprAt + from });
5781
+ from = i + 1;
5782
+ }
5783
+ }
5784
+ result.push({ text: expr.slice(from), at: exprAt + from });
5785
+ return result;
5786
+ }
5787
+ /**
5788
+ * 赋值目标及其是否仍只是本站 URL。从本站 URL 解构时按键区分:
5789
+ * searchParams、pathname 等为可控输入,origin、host 等不可控,其余保持本站 URL。
5790
+ */
5791
+ targetsOf(a, taint) {
5792
+ if (!taint.ownUrl || !a.pattern.trim().startsWith("{")) return a.names.map((name) => [name, taint.ownUrl]);
5793
+ const targets = [];
5794
+ for (const { key, local } of destructuredKeys(a.pattern.trim())) {
5795
+ if (URL_ORIGIN_KEYS.has(key)) continue;
5796
+ targets.push([local, !URL_INPUT_KEYS.has(key)]);
5797
+ }
5798
+ return targets;
5799
+ }
5800
+ assignments() {
5801
+ const { code, span, pairs } = this;
5802
+ const region = code.slice(span.start, span.end);
5803
+ const found = [];
5804
+ const push = (a) => {
5805
+ if (found.length >= MAX_ASSIGNMENTS) {
5806
+ this.limited = true;
5807
+ return;
5808
+ }
5809
+ found.push(a);
5810
+ };
5811
+ for (const m of region.matchAll(/\b(?:const|let|var)\s+/g)) {
5812
+ let at = span.start + m.index + m[0].length;
5813
+ for (let guard = 0; guard < 16; guard++) {
5814
+ let patternEnd;
5815
+ if (code[at] === "{" || code[at] === "[") {
5816
+ const close = pairs.get(at);
5817
+ if (close === void 0) break;
5818
+ patternEnd = close + 1;
5819
+ } else {
5820
+ const id = /^[A-Za-z_$][\w$]*/.exec(code.slice(at, at + 200));
5821
+ if (!id) break;
5822
+ patternEnd = at + id[0].length;
5823
+ }
5824
+ const pattern = code.slice(at, patternEnd);
5825
+ const rest = code.slice(patternEnd, patternEnd + 400);
5826
+ const forOf = /^\s+of\s+/.exec(rest);
5827
+ if (forOf) {
5828
+ const exprAt2 = patternEnd + forOf[0].length;
5829
+ const end2 = expressionEnd(code, exprAt2, span.end, pairs, () => {
5830
+ this.limited = true;
5831
+ });
5832
+ push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt2, end2), exprAt: exprAt2, iterate: true, append: false, declaration: true });
5833
+ break;
5834
+ }
5835
+ const eq = /^\s*(?::[^=;]{0,200}?)?=(?![=>])/.exec(rest);
5836
+ if (!eq) break;
5837
+ const exprAt = patternEnd + eq[0].length;
5838
+ const end = expressionEnd(code, exprAt, span.end, pairs, () => {
5839
+ this.limited = true;
5840
+ });
5841
+ push({ at, pattern, names: patternNames(pattern), expr: code.slice(exprAt, end), exprAt, iterate: false, append: false, declaration: true });
5842
+ if (code[end] !== ",") break;
5843
+ at = end + 1;
5844
+ while (/\s/.test(code[at] ?? "")) at++;
5845
+ }
5846
+ }
5847
+ for (const m of region.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(\+?=)(?![=>])/g)) {
5848
+ const before = region.slice(Math.max(0, m.index - 8), m.index);
5849
+ if (/\b(?:const|let|var)\s+$/.test(before)) continue;
5850
+ const exprAt = span.start + m.index + m[0].length;
5851
+ const end = expressionEnd(code, exprAt, span.end, pairs, () => {
5852
+ this.limited = true;
5853
+ });
5854
+ push({ at: span.start + m.index, pattern: m[1], names: [m[1]], expr: code.slice(exprAt, end), exprAt, iterate: false, append: m[2] === "+=" });
5855
+ }
5856
+ return found;
5857
+ }
5858
+ };
5859
+ function sourceRegex(roles) {
5860
+ const parts = [];
5861
+ if (roles.objects.size > 0) {
5862
+ const objects = namePattern(roles.objects.keys());
5863
+ parts.push(
5864
+ String.raw`(?<![\w$.])(?:${objects})(?:\s*\??\.\s*(?:request|event))?\s*\??\.\s*(json|formData|text|arrayBuffer|blob|body|query|params|url|nextUrl|headers|cookies|searchParams)\b`,
5865
+ String.raw`(?<![\w$.])(?:${objects})\s*\??\.\s*context\s*\??\.\s*params\b`,
5866
+ String.raw`\b(?:${H3_READERS})\s*(?:<[^()]{0,200}>\s*)?\(\s*(?:${objects})\b`
5867
+ );
5868
+ }
5869
+ if (roles.urls.size > 0) {
5870
+ parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.urls.keys())})\s*\??\.\s*(?:searchParams|pathname|search|hash)\b`);
5871
+ }
5872
+ if (roles.cookies.size > 0) {
5873
+ parts.push(String.raw`(?<![\w$.])(?:${namePattern(roles.cookies.keys())})\s*\??\.\s*get(?:All)?\s*\(`);
5874
+ }
5875
+ return parts.length === 0 ? null : new RegExp(parts.join("|"), "g");
5876
+ }
5877
+ function mergeTaint(found, t) {
5878
+ if (!found) return { level: t.level, origin: t.origin, names: new Set(t.names), ownUrl: t.ownUrl };
5879
+ if (t.level === "derived") found.level = "derived";
5880
+ if (found.ownUrl && !t.ownUrl) {
5881
+ found.ownUrl = false;
5882
+ found.origin = t.origin;
5883
+ }
5884
+ for (const n of t.names) found.names.add(n);
5885
+ return found;
5886
+ }
5887
+ function hasInput(roles) {
5888
+ return roles.objects.size + roles.urls.size + roles.cookies.size + roles.values.size > 0;
5889
+ }
5890
+ function lowerBound(values, value) {
5891
+ let low = 0, high = values.length;
5892
+ while (low < high) {
5893
+ const middle = low + high >>> 1;
5894
+ if (values[middle] < value) low = middle + 1;
5895
+ else high = middle;
5896
+ }
5897
+ return low;
5898
+ }
5899
+ var HandlerFile = class {
5900
+ constructor(code, source, pairs, lineStarts, candidates) {
5901
+ this.code = code;
5902
+ this.source = source;
5903
+ this.pairs = pairs;
5904
+ this.lineStarts = lineStarts;
5905
+ this.candidates = candidates;
5906
+ this.fixedTables = fixedTablesOf(code, source, pairs);
5907
+ }
5908
+ code;
5909
+ source;
5910
+ pairs;
5911
+ lineStarts;
5912
+ candidates;
5913
+ flows = /* @__PURE__ */ new Map();
5914
+ fixedTables;
5915
+ get limited() {
5916
+ for (const flow of this.flows.values()) if (flow.limited) return true;
5917
+ return false;
5918
+ }
5919
+ /** 至少包含一个给定位置的处理函数;positions 必须已按升序排列。 */
5920
+ handlersAround(positions) {
5921
+ const result = [];
5922
+ if (positions.length === 0) return result;
5923
+ for (const candidate of this.candidates) {
5924
+ const { body } = candidate;
5925
+ const first = lowerBound(positions, body.start + 1);
5926
+ if (first >= positions.length || positions[first] >= body.end) continue;
5927
+ let flow = this.flows.get(candidate);
5928
+ if (!flow) {
5929
+ flow = new InputFlow(this.code, this.source, { start: body.start, end: body.end }, this.pairs, candidate.roles, this.fixedTables);
5930
+ this.flows.set(candidate, flow);
5931
+ }
5932
+ result.push({ route: candidate.route, body, flow });
5933
+ }
5934
+ return result;
5935
+ }
5936
+ };
5937
+ var handlerCache = /* @__PURE__ */ new WeakMap();
5938
+ function handlerFileOf(file) {
5939
+ const cached = handlerCache.get(file);
5940
+ if (cached !== void 0) return cached;
5941
+ let result = null;
5942
+ if (/\.[mc]?[jt]sx?$/.test(file.path)) {
5943
+ const route = routeOf(file);
5944
+ const routes = route === null ? serverActionRoutes(file) : [route];
5945
+ if (routes.length > 0) {
5946
+ const code = noiseMaskedOf(file);
5947
+ const source = commentsMaskedOf(file);
5948
+ const pairs = delimiterPairs(code);
5949
+ const openers = /* @__PURE__ */ new Map();
5950
+ for (const [open, close] of pairs) openers.set(close, open);
5951
+ const bodies = functionBodies(code, pairs).sort((a, b) => a.start - b.start);
5952
+ const starts = bodies.map((body) => body.start);
5953
+ const candidates = [];
5954
+ for (const r of routes) {
5955
+ const reachable = r.reachable;
5956
+ const from = reachable ? lowerBound(starts, reachable.start) : 0;
5957
+ for (let i = from; i < bodies.length; i++) {
5958
+ const body = bodies[i];
5959
+ if (reachable && body.start >= reachable.end) break;
5960
+ if (reachable && body.end > reachable.end) continue;
5961
+ const action = r.action !== void 0 && reachable !== void 0 && body.start === reachable.start;
5962
+ const params = parametersOf(code, body, pairs, openers);
5963
+ if (!params) continue;
5964
+ const roles = paramRoles(source.slice(params.start, params.end), params.start, action);
5965
+ if (hasInput(roles)) candidates.push({ route: r, body, roles });
5966
+ }
5967
+ }
5968
+ if (candidates.length > 0) result = new HandlerFile(code, source, pairs, lineStartsOf(file.content), candidates);
5969
+ }
5970
+ }
5971
+ handlerCache.set(file, result);
5972
+ return result;
5973
+ }
5974
+ function reportInputLimit(ctx, file, analysed) {
5975
+ if (analysed.limited) ctx.reportIncomplete(
5976
+ "request-input/tracking",
5977
+ `${file.path} reached the request-input tracking limit (8 value hops, 512 assignments or control/function regions, 4000 expression characters, or bounded URL analysis)`
5978
+ );
5979
+ }
5980
+
5981
+ // src/rules/injection.ts
5982
+ var SQL_UNSAFE = /(?:\.\s*\$(?:queryRawUnsafe|executeRawUnsafe)|(?<![\w$.])Prisma\s*\.\s*raw|(?<![\w$.])sql\s*\.\s*(?:raw|unsafe))\s*\(/g;
5983
+ var SQL_RAW_FRAGMENT = /\.\s*(?:raw|whereRaw|orWhereRaw|andWhereRaw|havingRaw|orHavingRaw|orderByRaw|groupByRaw|joinRaw|fromRaw)\s*\(/g;
5984
+ var SQL_DRIVER = /\.\s*(?:query|execute|exec|prepare|run|all|get|unsafe|many|one|none|any|oneOrNone|manyOrNone)\s*\(/g;
5985
+ var SQL_CONDITION = /\.\s*(?:where|andWhere|orWhere|having|andHaving|orHaving)\s*\(/g;
5986
+ var LOOKS_LIKE_SQL = /\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bupdate\s+[\w."`]+\s+set\b|\bwhere\b|\border\s+by\b/i;
5987
+ var LOOKS_LIKE_CONDITION = /[=<>]|\b(?:like|ilike|in|is|between)\b/i;
5988
+ var CHILD_PROCESS = /^(?:node:)?child_process$/;
5989
+ var SHELL_EXPORTS = /* @__PURE__ */ new Set(["exec", "execSync"]);
5990
+ var SPAWN_EXPORTS = /* @__PURE__ */ new Set(["spawn", "spawnSync", "execFile", "execFileSync"]);
5991
+ function commandCallees(file) {
5992
+ const callees = { shell: /* @__PURE__ */ new Set(), spawn: /* @__PURE__ */ new Set(), commands: /* @__PURE__ */ new Set(), namespaces: /* @__PURE__ */ new Set() };
5993
+ for (const binding of bindingsOf(file).imports) {
5994
+ if (binding.spec !== void 0 && CHILD_PROCESS.test(binding.spec)) {
5995
+ if (binding.imported === "default") callees.namespaces.add(binding.local);
5996
+ else if (SHELL_EXPORTS.has(binding.imported)) callees.shell.add(binding.local);
5997
+ else if (SPAWN_EXPORTS.has(binding.imported)) callees.spawn.add(binding.local);
5998
+ } else if (binding.spec === "execa" && /^execaCommand(?:Sync)?$/.test(binding.imported)) {
5999
+ callees.commands.add(binding.local);
6000
+ }
6001
+ }
6002
+ const source = commentsMaskedOf(file);
6003
+ for (const m of source.matchAll(/\bimport\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?child_process['"]/g)) {
6004
+ callees.namespaces.add(m[1]);
6005
+ }
6006
+ for (const m of source.matchAll(/\b(?:const|let|var)\s+(\{[^{}]{0,500}\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"](?:node:)?child_process['"]\s*\)/g)) {
6007
+ const target = m[1];
6008
+ if (!target.startsWith("{")) {
6009
+ callees.namespaces.add(target);
6010
+ continue;
6011
+ }
6012
+ for (const part of target.slice(1, -1).split(",")) {
6013
+ const names = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*$/.exec(part);
6014
+ if (!names) continue;
6015
+ if (SHELL_EXPORTS.has(names[1])) callees.shell.add(names[2] ?? names[1]);
6016
+ else if (SPAWN_EXPORTS.has(names[1])) callees.spawn.add(names[2] ?? names[1]);
6017
+ }
6018
+ }
6019
+ for (const m of source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)?promisify\s*\(\s*(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\)/g)) {
6020
+ const [, local, namespace, name] = m;
6021
+ const viaNamespace = namespace !== void 0 && callees.namespaces.has(namespace);
6022
+ if (namespace === void 0 && callees.shell.has(name) || viaNamespace && SHELL_EXPORTS.has(name)) callees.shell.add(local);
6023
+ else if (namespace === void 0 && callees.spawn.has(name) || viaNamespace && SPAWN_EXPORTS.has(name)) callees.spawn.add(local);
6024
+ }
6025
+ return callees;
6026
+ }
6027
+ function sinksIn(code, pairs, callees) {
6028
+ const sinks = /* @__PURE__ */ new Map();
6029
+ const add = (regex, kind, mode) => {
6030
+ regex.lastIndex = 0;
6031
+ for (const m of code.matchAll(regex)) {
6032
+ const open = m.index + m[0].length - 1;
6033
+ const close = pairs.get(open);
6034
+ if (close === void 0 || sinks.has(open)) continue;
6035
+ sinks.set(open, { kind, at: m.index, open, close, mode });
6036
+ }
6037
+ };
6038
+ if (callees.shell.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.shell)})\\s*\\(`, "g"), "command", "shell");
6039
+ if (callees.spawn.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.spawn)})\\s*\\(`, "g"), "command", "spawn");
6040
+ if (callees.commands.size > 0) add(new RegExp(`(?<![\\w$.])(?:${namePattern(callees.commands)})\\s*\\(`, "g"), "command", "execa");
6041
+ if (callees.namespaces.size > 0) {
6042
+ const ns = namePattern(callees.namespaces);
6043
+ add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:exec|execSync)\\s*\\(`, "g"), "command", "shell");
6044
+ add(new RegExp(`(?<![\\w$.])(?:${ns})\\s*\\.\\s*(?:spawn|spawnSync|execFile|execFileSync)\\s*\\(`, "g"), "command", "spawn");
6045
+ }
6046
+ add(SQL_UNSAFE, "sql", "unsafe");
6047
+ add(SQL_RAW_FRAGMENT, "sql", "fragment");
6048
+ add(SQL_DRIVER, "sql", "driver");
6049
+ add(SQL_CONDITION, "sql", "condition");
6050
+ return [...sinks.values()].sort((a, b) => a.at - b.at);
6051
+ }
6052
+ function fixedProgram(expr, at, flow, source, depth = 0) {
6053
+ if (depth >= 8) {
6054
+ flow.limited = true;
6055
+ return false;
6056
+ }
6057
+ const text = expr.trim();
6058
+ const start = at + expr.length - expr.trimStart().length;
6059
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) {
6060
+ const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
6061
+ return assignments.length > 0 && assignments.every((a) => fixedProgram(a.expr, a.exprAt, flow, source, depth + 1));
6062
+ }
6063
+ const raw = source.slice(start, start + text.length);
6064
+ const prefix = /^["'`]([^"'`$\\]*)/.exec(raw)?.[1];
6065
+ return prefix !== void 0 && /^\S+\s/.test(prefix);
6066
+ }
6067
+ function hitOf(sink, code, source, pairs, flow) {
6068
+ const args = argumentExpressions(code, code, sink.open + 1, sink.close, pairs);
6069
+ if (args.length === 0) return null;
6070
+ const firstAt = sink.open + 1 + (code.slice(sink.open + 1, sink.close).length - code.slice(sink.open + 1, sink.close).trimStart().length);
6071
+ const first = args[0];
6072
+ const firstSource = source.slice(firstAt, firstAt + first.length);
6073
+ if (sink.mode === "spawn") {
6074
+ if (!/\bshell\s*:\s*true\b/.test(code.slice(sink.open, sink.close))) return null;
6075
+ const taint = flow.taintOf(code.slice(sink.open + 1, sink.close), sink.open + 1) ?? flow.builtOf(first, firstAt);
6076
+ return taint ? { taint } : null;
6077
+ }
6078
+ const built = flow.builtOf(first, firstAt);
6079
+ const identifier = /^[A-Za-z_$][\w$]*$/.exec(first);
6080
+ const builtVariable = identifier ? flow.builtValue(identifier[0], firstAt) : void 0;
6081
+ const text = built ? firstSource : builtVariable?.text ?? null;
6082
+ const stringTaint = built ?? builtVariable ?? null;
6083
+ if (sink.mode === "execa") {
6084
+ const shell = /\bshell\s*:\s*(?:true\b|['"][^'"]+['"])/.test(source.slice(sink.open, sink.close));
6085
+ if (!shell && fixedProgram(first, firstAt, flow, source)) return null;
6086
+ const taint = stringTaint ?? flow.taintOf(first, firstAt);
6087
+ return taint ? { taint, executable: !shell } : null;
6088
+ }
6089
+ if (sink.mode === "shell" || sink.mode === "unsafe") {
6090
+ const taint = stringTaint ?? flow.taintOf(first, firstAt);
6091
+ return taint ? { taint } : null;
6092
+ }
6093
+ if (!stringTaint || text === null) return null;
6094
+ if (sink.mode === "driver" && !LOOKS_LIKE_SQL.test(text)) return null;
6095
+ if (sink.mode === "condition" && !LOOKS_LIKE_CONDITION.test(text)) return null;
6096
+ return { taint: stringTaint };
6097
+ }
6098
+ function capitalised(text) {
6099
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
6100
+ }
6101
+ function findingFor(kind, route, file, line, originLine, certain, executable2 = false) {
6102
+ const lowered = certain ? [] : [
6103
+ "The value passes through other code or a check before it gets here, and the scan cannot tell whether that makes it safe. Confirm the value is restricted to what the query or command expects."
6104
+ ];
6105
+ const excerpt = (file.lines[line - 1] ?? "").trim();
6106
+ if (kind === "sql") {
6107
+ return {
6108
+ ruleId: "injection/sql",
6109
+ severity: "P1",
6110
+ confidence: certain ? "certain" : "likely",
6111
+ title: `${capitalised(route.url)} builds a SQL query from request input`,
6112
+ file: file.path,
6113
+ line,
6114
+ excerpt,
6115
+ why: [
6116
+ `The query text is assembled from a value the caller controls (read on line ${originLine}). Whoever calls ${route.url} can change the query itself, not just the value it compares against \u2014 read other rows, skip filters, or change data.`,
6117
+ ...lowered
6118
+ ],
6119
+ fix: [
6120
+ "Pass values as parameters instead of splicing them into the SQL text: the tagged form (prisma.$queryRaw`\u2026 ${id}`, sql`\u2026 ${id}`) or placeholders ($1, ?) with a separate values array.",
6121
+ "If a column name or sort direction has to vary, choose it from a fixed list in code instead of using the request value."
6122
+ ]
6123
+ };
6124
+ }
6125
+ return {
6126
+ ruleId: "injection/command",
6127
+ severity: "P1",
6128
+ confidence: certain ? "certain" : "likely",
6129
+ title: executable2 ? `${capitalised(route.url)} runs an executable selected by request input` : `${capitalised(route.url)} runs a shell command built from request input`,
6130
+ file: file.path,
6131
+ line,
6132
+ excerpt,
6133
+ why: [
6134
+ executable2 ? `The caller can influence the executable name (read on line ${originLine}). Execa does not use a shell by default, but allowing a caller to choose the program can still run unintended code with server permissions.` : `The command line includes a value the caller controls (read on line ${originLine}) and is run through a shell. Characters such as ; | $( ) let a caller append their own commands, which run with your server's permissions.`,
6135
+ ...lowered
6136
+ ],
6137
+ fix: [
6138
+ executable2 ? "Use a fixed executable and pass request values in a separate argument array; do not accept a caller-supplied command line." : "Use execFile or spawn with an argument array and no shell option, so the value is passed as one argument and never parsed by a shell.",
6139
+ "Check the value against a fixed list of allowed values before using it."
6140
+ ]
6141
+ };
6142
+ }
6143
+ var injectionRule = {
6144
+ id: "injection/request-input",
6145
+ severity: "P1",
6146
+ check(ctx) {
6147
+ const findings = [];
6148
+ for (const file of ctx.files) {
6149
+ const analysed = handlerFileOf(file);
6150
+ if (!analysed) continue;
6151
+ const { code, source, pairs, lineStarts } = analysed;
6152
+ const sinks = sinksIn(code, pairs, commandCallees(file));
6153
+ if (sinks.length === 0) continue;
6154
+ const positions = sinks.map((sink) => sink.at);
6155
+ const reported = /* @__PURE__ */ new Set();
6156
+ for (const { route, body, flow } of analysed.handlersAround(positions)) {
6157
+ for (let i = lowerBound(positions, body.start + 1); i < sinks.length && sinks[i].at < body.end; i++) {
6158
+ const sink = sinks[i];
6159
+ if (sink.close >= body.end || reported.has(sink.at)) continue;
6160
+ const hit = hitOf(sink, code, source, pairs, flow);
6161
+ if (!hit) continue;
6162
+ reported.add(sink.at);
6163
+ const certain = hit.taint.level === "direct" && !flow.validatedBefore(hit.taint.names, sink.at);
6164
+ findings.push(findingFor(
6165
+ sink.kind,
6166
+ route,
6167
+ file,
6168
+ lineNumberAt(lineStarts, sink.at),
6169
+ lineNumberAt(lineStarts, hit.taint.origin),
6170
+ certain,
6171
+ hit.executable
6172
+ ));
6173
+ }
6174
+ }
6175
+ reportInputLimit(ctx, file, analysed);
6176
+ }
6177
+ return findings;
6178
+ }
6179
+ };
6180
+
6181
+ // src/rules/outbound.ts
6182
+ var FETCH_CALL = /(?<![\w$.])(?:fetch|\$fetch|ofetch|axios|got|ky|needle)\s*(?:<[^()]{0,200}>\s*)?\(/g;
6183
+ var FETCH_METHOD = /(?<![\w$.])(?:axios|got|ky|needle|http|https|undici)\s*\.\s*(?:get|post|put|patch|delete|head|request|stream)\s*(?:<[^()]{0,200}>\s*)?\(/g;
6184
+ var REDIRECT_CALL = /(?<![\w$.])(?:redirect|permanentRedirect|sendRedirect)\s*\(|(?<![\w$.])(?:NextResponse|Response|res|reply|response|ctx|context|Astro)\s*\.\s*redirect\s*\(/g;
6185
+ function callsIn(code, pairs) {
6186
+ const calls = [];
6187
+ const add = (regex, kind) => {
6188
+ regex.lastIndex = 0;
6189
+ for (const m of code.matchAll(regex)) {
6190
+ const open = m.index + m[0].length - 1;
6191
+ const close = pairs.get(open);
6192
+ if (close !== void 0) calls.push({ kind, at: m.index, open, close, name: m[0] });
6193
+ }
6194
+ };
6195
+ add(FETCH_CALL, "ssrf");
6196
+ add(FETCH_METHOD, "ssrf");
6197
+ add(REDIRECT_CALL, "redirect");
6198
+ return calls;
6199
+ }
6200
+ var HOST_OPEN = /^(?:X?|[a-z][\w+.-]*:\/\/[^/?#\\]*)$/i;
6201
+ var REDIRECT_OPEN = /^(?:\/|[/\\]{2}[^/?#\\]*)$/;
6202
+ function controlsStart(expr, at, flow, source, kind, depth = 0) {
6203
+ const lead = expr.length - expr.trimStart().length;
6204
+ const text = expr.trim();
6205
+ const start = at + lead;
6206
+ if (text === "") return null;
6207
+ if (depth >= 8) {
6208
+ flow.limited = true;
6209
+ return null;
6210
+ }
6211
+ if (text.length > 4e3) {
6212
+ flow.limited = true;
6213
+ return null;
6214
+ }
6215
+ const open = (prefix) => HOST_OPEN.test(prefix) || kind === "redirect" && REDIRECT_OPEN.test(prefix);
6216
+ const url = /^new\s+URL\s*\(/.exec(text);
6217
+ if (url) {
6218
+ const argsStart = start + url[0].length;
6219
+ const close = matchingParen(text, url[0].length - 1);
6220
+ if (close === null) return null;
6221
+ if (close === text.length - 1) {
6222
+ const first = splitTopLevel(text.slice(url[0].length, close))[0];
6223
+ return first === void 0 ? null : controlsStart(first, argsStart, flow, source, "redirect", depth + 1);
6224
+ }
6225
+ }
6226
+ if (text.startsWith("`")) return templateStart(text, start, flow, source, open);
6227
+ const operands = flow.operandsOf(text, start);
6228
+ if (operands.length > 1) {
6229
+ let prefix = "";
6230
+ for (const operand of operands) {
6231
+ const inner = operand.text.trim();
6232
+ if (/^['"]/.test(inner)) {
6233
+ prefix += source.slice(operand.at + operand.text.indexOf(inner) + 1, operand.at + operand.text.indexOf(inner) + inner.length - 1);
6234
+ continue;
6235
+ }
6236
+ const t2 = inner.startsWith("`") ? templateStart(inner, operand.at + operand.text.indexOf(inner), flow, source, open) : controlsStart(inner, operand.at + operand.text.indexOf(inner), flow, source, kind, depth + 1);
6237
+ if (t2) return open(prefix) ? t2 : null;
6238
+ prefix += "X";
6239
+ if (!open(prefix)) return null;
6240
+ }
6241
+ return null;
6242
+ }
6243
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) {
6244
+ const assignments = flow.assignmentsFor(text, at).filter((a) => !a.append);
6245
+ if (assignments.length === 0) {
6246
+ const t2 = flow.valueOf(text, at);
6247
+ return t2 && !t2.ownUrl ? t2 : null;
6248
+ }
6249
+ for (const a of assignments) {
6250
+ const t2 = controlsStart(a.expr, a.exprAt, flow, source, kind, depth + 1);
6251
+ if (t2) return { ...t2, level: a.indirect ? "derived" : t2.level, names: /* @__PURE__ */ new Set([...t2.names, text]) };
6252
+ }
6253
+ return null;
6254
+ }
6255
+ const t = flow.taintOf(text, start);
6256
+ return t && !t.ownUrl ? t : null;
6257
+ }
6258
+ function templateStart(text, start, flow, source, open) {
6259
+ let prefix = "";
6260
+ for (let i = 1; i < text.length; i++) {
6261
+ if (text[i] === "`") return null;
6262
+ if (text[i] === "$" && text[i + 1] === "{") {
6263
+ const close = matchingBrace(text, i + 1);
6264
+ if (close === null) return null;
6265
+ const t = flow.taintOf(text.slice(i + 2, close), start + i + 2);
6266
+ if (t && !t.ownUrl) return open(prefix) ? t : null;
6267
+ prefix += "X";
6268
+ i = close;
6269
+ continue;
6270
+ }
6271
+ prefix += source[start + i];
6272
+ if (prefix.length > 200) {
6273
+ flow.limited = true;
6274
+ return null;
6275
+ }
6276
+ }
6277
+ return null;
6278
+ }
6279
+ function matchingParen(text, open) {
6280
+ let depth = 0;
6281
+ for (let i = open; i < text.length; i++) {
6282
+ if (text[i] === "(") depth++;
6283
+ else if (text[i] === ")" && --depth === 0) return i;
6284
+ }
6285
+ return null;
6286
+ }
6287
+ function matchingBrace(text, open) {
6288
+ let depth = 0;
6289
+ for (let i = open; i < text.length; i++) {
6290
+ if (text[i] === "{") depth++;
6291
+ else if (text[i] === "}" && --depth === 0) return i;
6292
+ }
6293
+ return null;
6294
+ }
6295
+ function splitTopLevel(text) {
6296
+ const parts = [];
6297
+ let depth = 0, from = 0;
6298
+ for (let i = 0; i < text.length; i++) {
6299
+ if ("([{".includes(text[i])) depth++;
6300
+ else if (")]}".includes(text[i])) depth--;
6301
+ else if (text[i] === "," && depth === 0) {
6302
+ parts.push(text.slice(from, i));
6303
+ from = i + 1;
6304
+ }
6305
+ }
6306
+ parts.push(text.slice(from));
6307
+ return parts;
6308
+ }
6309
+ function targetOf(call, analysed) {
6310
+ const { code, pairs } = analysed;
6311
+ const args = argumentExpressions(code, code, call.open + 1, call.close, pairs);
6312
+ if (args.length === 0) return null;
6313
+ const positions = [];
6314
+ let cursor = call.open + 1;
6315
+ for (const arg of args) {
6316
+ const found = code.indexOf(arg, cursor);
6317
+ positions.push(found);
6318
+ cursor = found + arg.length;
6319
+ }
6320
+ let index = 0;
6321
+ if (call.kind === "redirect") {
6322
+ if (/sendRedirect/.test(call.name)) index = 1;
6323
+ else if (/^\d{3}$/.test(args[0]) && args.length > 1) index = 1;
6324
+ }
6325
+ const expr = args[index];
6326
+ if (expr === void 0) return null;
6327
+ if (call.kind === "ssrf" && expr.startsWith("{")) {
6328
+ const property = /(?:^|[{,])\s*(?:url|baseURL|href)\s*:\s*/.exec(expr);
6329
+ if (!property) {
6330
+ const shorthand = /(?:^|[{,])\s*(url|baseURL|href)\s*(?=[,}])/.exec(expr);
6331
+ return shorthand ? { expr: shorthand[1], at: positions[index] + shorthand.index + shorthand[0].length - shorthand[1].length } : null;
6332
+ }
6333
+ const valueAt = positions[index] + property.index + property[0].length;
6334
+ const value = splitTopLevel(expr.slice(property.index + property[0].length))[0].replace(/\}\s*$/, "");
6335
+ return { expr: value, at: valueAt };
6336
+ }
6337
+ return { expr, at: positions[index] };
6338
+ }
6339
+ function capitalised2(text) {
6340
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
6341
+ }
6342
+ function ssrfFinding(route, file, line, originLine) {
6343
+ return {
6344
+ ruleId: "ssrf/request-url",
6345
+ severity: "P1",
6346
+ confidence: "likely",
6347
+ title: `${capitalised2(route.url)} sends a server request to an address the caller chooses`,
6348
+ file: file.path,
6349
+ line,
6350
+ excerpt: (file.lines[line - 1] ?? "").trim(),
6351
+ why: [
6352
+ `The address of this outgoing request comes from request input (read on line ${originLine}). A caller can point your server at internal services, cloud metadata endpoints such as 169.254.169.254, or other hosts only your server can reach, and read the response or trigger actions there.`,
6353
+ "Fetching a caller-supplied address can be the purpose of the route, as in a link preview or image proxy, so this is reported for review. Even then it needs a host allowlist."
6354
+ ],
6355
+ fix: [
6356
+ "Build the address from a fixed origin in code and let the request choose only a path segment or query value.",
6357
+ "If callers must supply full URLs, parse them with new URL(), allow only https and hosts on an explicit allowlist, and reject private, loopback, and link-local addresses after DNS resolution."
6358
+ ]
6359
+ };
6360
+ }
6361
+ function redirectFinding(route, file, line, originLine, certain) {
6362
+ return {
6363
+ ruleId: "redirect/open",
6364
+ severity: "P2",
6365
+ confidence: certain ? "certain" : "likely",
6366
+ title: `${capitalised2(route.url)} redirects to an address taken from the request`,
6367
+ file: file.path,
6368
+ line,
6369
+ excerpt: (file.lines[line - 1] ?? "").trim(),
6370
+ why: [
6371
+ `The redirect target comes from request input (read on line ${originLine}), and nothing fixes the site it points to. A link to your site can then send visitors anywhere, which makes phishing links look trustworthy and can hand tokens in the URL to another site.`,
6372
+ ...certain ? [] : ["The value passes through other code or a check first; confirm that check rejects other sites, including //host and /\\host forms."]
6373
+ ],
6374
+ fix: [
6375
+ "Accept only relative paths that start with a single / (reject //, /\\, and anything with a scheme), or compare the target against a fixed list.",
6376
+ "When building an absolute URL, use new URL(path, yourOrigin) and redirect only if the result's origin equals yourOrigin."
6377
+ ]
6378
+ };
6379
+ }
6380
+ function check(ctx, kind) {
6381
+ const findings = [];
6382
+ for (const file of ctx.files) {
6383
+ const analysed = handlerFileOf(file);
6384
+ if (!analysed) continue;
6385
+ const calls = callsIn(analysed.code, analysed.pairs).filter((call) => call.kind === kind).sort((a, b) => a.at - b.at);
6386
+ if (calls.length === 0) continue;
6387
+ const positions = calls.map((call) => call.at);
6388
+ const reported = /* @__PURE__ */ new Set();
6389
+ for (const { route, body, flow } of analysed.handlersAround(positions)) {
6390
+ for (let i = lowerBound(positions, body.start + 1); i < calls.length && calls[i].at < body.end; i++) {
6391
+ const call = calls[i];
6392
+ if (call.close >= body.end || reported.has(call.at)) continue;
6393
+ const target = targetOf(call, analysed);
6394
+ if (!target) continue;
6395
+ const taint = controlsStart(target.expr, target.at, flow, analysed.source, kind);
6396
+ if (!taint) continue;
6397
+ reported.add(call.at);
6398
+ const line = lineNumberAt(analysed.lineStarts, call.at);
6399
+ const originLine = lineNumberAt(analysed.lineStarts, taint.origin);
6400
+ findings.push(kind === "ssrf" ? ssrfFinding(route, file, line, originLine) : redirectFinding(route, file, line, originLine, taint.level === "direct" && !flow.validatedBefore(taint.names, call.at)));
6401
+ }
6402
+ }
6403
+ reportInputLimit(ctx, file, analysed);
6404
+ }
6405
+ return findings;
6406
+ }
6407
+ var ssrfRule = { id: "ssrf/request-url", severity: "P1", check: (ctx) => check(ctx, "ssrf") };
6408
+ var redirectRule = { id: "redirect/open", severity: "P2", check: (ctx) => check(ctx, "redirect") };
6409
+
6410
+ // src/rules/verification.ts
6411
+ var LocalVerification = class {
6412
+ code;
6413
+ source;
6414
+ pairs;
6415
+ bodies;
6416
+ constructor(file) {
6417
+ this.code = noiseMaskedOf(file);
6418
+ this.source = commentsMaskedOf(file);
6419
+ this.pairs = delimiterPairs(this.code);
6420
+ this.bodies = functionBodies(this.code, this.pairs);
6421
+ }
6422
+ owner(at) {
6423
+ return this.bodies.filter((b) => b.start < at && at < b.end).sort((a, b) => b.start - a.start)[0];
6424
+ }
6425
+ skipSpace(at) {
6426
+ while (/\s/.test(this.code[at] ?? "")) at++;
6427
+ return at;
6428
+ }
6429
+ endOf(at, limit = this.code.length) {
6430
+ if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
6431
+ for (let i = at; i < limit; i++) {
6432
+ if (/[;\n}]/.test(this.code[i])) return i;
6433
+ const end = this.pairs.get(i);
6434
+ if (end !== void 0) i = end;
6435
+ }
6436
+ return limit;
6437
+ }
6438
+ /**
6439
+ * 块的顶层有无条件退出即算退出,前面可以有记录日志等语句;
6440
+ * 嵌套分支、函数中的 return 以及无花括号 if/else/循环控制的 return 不算。
6441
+ */
6442
+ exits(from, to) {
6443
+ let at = this.skipSpace(from);
6444
+ if (this.code[at] !== "{") return /^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, to));
6445
+ const end = Math.min(to, this.pairs.get(at) ?? to);
6446
+ at = this.skipSpace(at + 1);
6447
+ while (at < end) {
6448
+ if (/^(?:return|throw)\b|^(?:redirect|notFound)\s*\(/.test(this.code.slice(at, at + 32))) return true;
6449
+ const next = this.statementEnd(at, end);
6450
+ if (next <= at) return false;
6451
+ at = this.skipSpace(next);
6452
+ }
6453
+ return false;
6454
+ }
6455
+ /** 跳过完整语句;分支、循环和函数内的退出不提升为外层退出。 */
6456
+ statementEnd(at, limit, depth = 0) {
6457
+ if (depth >= 64) return limit;
6458
+ if (this.code[at] === "{") return (this.pairs.get(at) ?? limit - 1) + 1;
6459
+ const control = /^(if|for(?:\s+await)?|while|with|switch)\s*\(/.exec(this.code.slice(at, at + 80));
6460
+ if (control) {
6461
+ const close = this.pairs.get(at + control[0].length - 1);
6462
+ if (close === void 0) return limit;
6463
+ let end = this.statementEnd(this.skipSpace(close + 1), limit, depth + 1);
6464
+ const next = this.skipSpace(end);
6465
+ if (control[1] === "if" && /^else\b/.test(this.code.slice(next, next + 5))) {
6466
+ end = this.statementEnd(this.skipSpace(next + 4), limit, depth + 1);
6467
+ }
6468
+ return end;
6469
+ }
6470
+ if (/^(?:try|do)\b/.test(this.code.slice(at, at + 8))) return limit;
6471
+ for (let i = at; i < limit; i++) {
6472
+ if (this.code[i] === ";") return i + 1;
6473
+ const close = this.pairs.get(i);
6474
+ if (close !== void 0) {
6475
+ i = close;
6476
+ continue;
6477
+ }
6478
+ if (this.code[i] !== "\n") continue;
6479
+ const before = this.code.slice(at, i).trimEnd();
6480
+ const after = this.code.slice(i + 1, limit).trimStart();
6481
+ if (!before || /[=+\-*/%&|^!?:,.([{<>]$/.test(before)) continue;
6482
+ if (/^[.?+\-*/%&|^,:<>=([{`]/.test(after)) continue;
6483
+ if (/\b(?:const|let|var|function|async|await|new|yield)$/.test(before)) continue;
6484
+ return i + 1;
6485
+ }
6486
+ return limit;
6487
+ }
6488
+ /** 验证与使用须在同一函数,且不能依赖可选分支或被吞掉的异常。 */
6489
+ enforcedBefore(from, to) {
6490
+ const owner = this.owner(from);
6491
+ if (from >= to || owner !== this.owner(to)) return false;
6492
+ const start = owner ? owner.start + 1 : 0;
6493
+ for (let i = start; i < from; i++) {
6494
+ const conditional = /^(?:if|for|while|switch)\s*\(/.exec(this.code.slice(i, i + 32));
6495
+ if (conditional && !/[\w$]/.test(this.code[i - 1] ?? "")) {
6496
+ const close = this.pairs.get(i + conditional[0].length - 1);
6497
+ if (close !== void 0) {
6498
+ const body = this.skipSpace(close + 1);
6499
+ const end2 = this.endOf(body);
6500
+ if (from < end2 && to >= end2) return false;
6501
+ }
6502
+ }
6503
+ const end = this.pairs.get(i);
6504
+ if (end === void 0) continue;
6505
+ if (end < from) {
6506
+ i = end;
6507
+ continue;
6508
+ }
6509
+ if (end >= to) continue;
6510
+ if (this.code[i] !== "{" || !/\btry\s*$/.test(this.code.slice(Math.max(start, i - 16), i))) return false;
6511
+ let next = this.skipSpace(end + 1);
6512
+ if (/^catch\b/.test(this.code.slice(next))) {
6513
+ next = this.skipSpace(next + 5);
6514
+ if (this.code[next] === "(") next = this.skipSpace((this.pairs.get(next) ?? next) + 1);
6515
+ const caughtEnd = this.endOf(next);
6516
+ if (!this.exits(next, caughtEnd)) return false;
6517
+ next = this.skipSpace(caughtEnd);
6518
+ }
6519
+ if (/^finally\b/.test(this.code.slice(next))) return false;
6520
+ }
6521
+ const boundary = Math.max(
6522
+ start,
6523
+ this.code.lastIndexOf(";", from - 1) + 1,
6524
+ this.code.lastIndexOf("\n", from - 1) + 1,
6525
+ this.code.lastIndexOf("{", from - 1) + 1
6526
+ );
6527
+ return !/[?]|&&|\|\|/.test(this.code.slice(boundary, from));
6528
+ }
6529
+ /** 找出当前函数中最后一次赋值;未知或条件覆盖同样会使旧证明失效。 */
6530
+ assignment(name, at) {
6531
+ const escaped = name.replace(/\$/g, "\\$");
6532
+ const pattern = new RegExp(`(?<![\\w$.])(?:const\\s+|let\\s+|var\\s+)?${escaped}((?:\\s*\\.[\\w$]+|\\s*\\[[^\\]]*\\])*)\\s*(?::[^=;{}\\n]{0,100})?=(?![=>])`, "g");
6533
+ let result = null;
6534
+ for (const m of this.code.slice(0, at).matchAll(pattern)) {
6535
+ if (this.owner(m.index) !== this.owner(at)) continue;
6536
+ const from = this.skipSpace(m.index + m[0].length);
6537
+ const end = this.endOf(from);
6538
+ if (end <= at) result = { at: m.index, from, end, expression: this.code.slice(from, end).trim(), member: m[1].length > 0 };
6539
+ }
6540
+ return result;
6541
+ }
6542
+ };
6543
+
6544
+ // src/rules/session.ts
6545
+ var GET_SESSION = /\.auth\s*\.\s*getSession\s*\(/g;
6546
+ function verifiedSession(file, context, sessionAt, names) {
6547
+ const { code, pairs, bodies } = context;
6548
+ const body = context.owner(sessionAt);
6549
+ if (!body) return false;
6550
+ const client = /([\w$.]+)\s*$/.exec(code.slice(body.start, sessionAt))?.[1];
6551
+ if (!client) return false;
6552
+ const values = authValuesOf(file, body, bodies, pairs);
6553
+ const reads = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$]|\\s*:)`, "g");
6554
+ const verification = /\b(?:const|let)\s+(\{[^;=]{1,500}\}|[A-Za-z_$][\w$]*)\s*=\s*await\s+([\w$.]+)\.auth\.(?:getUser|getClaims)\s*\(/g;
6555
+ for (const call of code.matchAll(verification)) {
6556
+ if (call[2] !== client || context.owner(call.index) !== body) continue;
6557
+ const close = pairs.get(call.index + call[0].length - 1);
6558
+ if (close === void 0) continue;
6559
+ if (code.slice(call.index + call[0].length, close).trim() !== "") continue;
6560
+ const guardAt = context.skipSpace(code[close + 1] === ";" ? close + 2 : close + 1);
6561
+ const condition = /^if\s*\(/.exec(code.slice(guardAt));
6562
+ if (!condition) continue;
6563
+ const conditionClose = pairs.get(guardAt + condition[0].length - 1);
6564
+ if (conditionClose === void 0) continue;
6565
+ const branchAt = context.skipSpace(conditionClose + 1);
6566
+ const branchEnd = context.endOf(branchAt);
6567
+ if (!context.exits(branchAt, branchEnd) || !context.enforcedBefore(call.index, guardAt)) continue;
6568
+ const test = code.slice(guardAt + condition[0].length, conditionClose).trim();
6569
+ const errorNames = call[1].startsWith("{") ? destructuredKeys(call[1]).filter((k) => k.key === "error").map((k) => k.local) : [`${call[1]}.error`];
6570
+ if (/&&|\?(?!\.)/.test(test) || !test.split("||").some((term) => {
6571
+ const t = term.trim();
6572
+ return errorNames.includes(t) || t.startsWith("!") && identityRequirement(values.value(t.slice(1), guardAt), false) !== null;
6573
+ })) continue;
6574
+ const sessionClose = pairs.get(code.indexOf("(", sessionAt)) ?? code.indexOf(")", sessionAt);
6575
+ let before = code.slice(sessionClose + 1, branchEnd);
6576
+ before = before.replace(/\bif\s*\(\s*!\s*([\w$.?]+)\s*\)\s*(?:return|throw|redirect)\b[^;\n]*/g, (text, subject) => {
6577
+ const rest = text.slice(text.indexOf(")") + 1).replace(/\b[\w$]+\s*:/g, "");
6578
+ return names.some((n) => subject === n || subject.startsWith(`${n}.`)) && !new RegExp(reads.source).test(rest) ? " ".repeat(text.length) : text;
6579
+ });
6580
+ if (new RegExp(reads.source).test(before)) continue;
6581
+ if (!context.enforcedBefore(guardAt, body.end - 1)) continue;
6582
+ return true;
6583
+ }
6584
+ return false;
6585
+ }
6586
+ var SERVER_PATH = [
6587
+ /(?:^|\/)(?:middleware|proxy)\.[mc]?[jt]s$/,
6588
+ /(?:^|\/)app\/(?:.+\/)?(?:page|layout|template|default)\.[mc]?[jt]sx?$/,
6589
+ /\.server\.[mc]?[jt]s$/,
6590
+ /(?:^|\/)server\/.+\.[mc]?[jt]s$/
6591
+ ];
6592
+ function isServerFile(file) {
6593
+ if (!/\.[mc]?[jt]sx?$/.test(file.path)) return false;
6594
+ if (/^\s*(?:(?:'use strict'|"use strict");?\s*)?['"]use client['"]/.test(commentsMaskedOf(file))) return false;
6595
+ return SERVER_PATH.some((pattern) => pattern.test(file.path)) || routeOf(file) !== null || serverActionRoutes(file).length > 0;
6596
+ }
6597
+ var sessionRule = {
6598
+ id: "auth/unverified-session",
6599
+ severity: "P1",
6600
+ appliesTo(file) {
6601
+ return file.content.includes("getSession") && isServerFile(file);
6602
+ },
6603
+ check(file) {
6604
+ const code = noiseMaskedOf(file);
6605
+ const context = new LocalVerification(file);
6606
+ const { pairs, bodies } = context;
6607
+ const lineStarts = lineStartsOf(file.content);
6608
+ const findings = [];
6609
+ for (const m of code.matchAll(GET_SESSION)) {
6610
+ const body = bodies.filter((b) => b.start < m.index && m.index < b.end).sort((a, b) => b.start - a.start)[0];
6611
+ const start = body?.start ?? 0;
6612
+ const end = body?.end ?? code.length;
6613
+ const statement = code.slice(Math.max(start, m.index - 300), m.index);
6614
+ const declaration = /\b(?:const|let|var)\s+((?:\{[^;=]*\})|[A-Za-z_$][\w$]*)\s*(?::[^=;]{0,200})?=\s*(?:await\s+)?\(?\s*(?:await\s+)?[\w$.?\s]*$/.exec(statement);
6615
+ if (!declaration) continue;
6616
+ const names = patternNames(declaration[1]);
6617
+ if (names.length === 0) continue;
6618
+ const refs = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?![\\w$])`);
6619
+ const after = code.slice(m.index, end);
6620
+ let decides = false;
6621
+ for (const condition of after.matchAll(/\bif\s*\(/g)) {
6622
+ const open = m.index + condition.index + condition[0].length - 1;
6623
+ const close = pairs.get(open);
6624
+ if (close !== void 0 && refs.test(code.slice(open + 1, close))) {
6625
+ decides = true;
6626
+ break;
6627
+ }
6628
+ }
6629
+ const readsUser = new RegExp(`(?<![\\w$.])(?:${namePattern(names)})(?:\\??\\.\\s*(?:session|data))*\\??\\.\\s*user\\b`).test(after);
6630
+ if (!decides && !readsUser) continue;
6631
+ if (verifiedSession(file, context, m.index, names)) continue;
6632
+ const line = lineNumberAt(lineStarts, m.index);
6633
+ findings.push({
6634
+ ruleId: "auth/unverified-session",
6635
+ severity: "P1",
6636
+ confidence: decides ? "certain" : "likely",
6637
+ title: "Server code trusts supabase.auth.getSession() to identify the user",
6638
+ file: file.path,
6639
+ line,
6640
+ excerpt: (file.lines[line - 1] ?? "").trim(),
6641
+ why: [
6642
+ "On the server, getSession() reads the session straight from the request cookie without verifying it. Anyone can put a forged or expired session in their cookies, so a check based on it can be passed by a caller who is not signed in.",
6643
+ decides ? "The result is used in a condition here, so it decides who gets through." : "The user from this session is read here, so an unverified user id may be trusted."
6644
+ ],
6645
+ fix: [
6646
+ "Use supabase.auth.getClaims() (verifies the token signature) or supabase.auth.getUser() (asks the Auth server) to decide who the caller is.",
6647
+ "Keep getSession() only where you need the access token itself, not to decide access."
6648
+ ]
6649
+ });
6650
+ }
6651
+ return findings;
6652
+ }
6653
+ };
6654
+
6655
+ // src/rules/webhook.ts
6656
+ var STRIPE_EVENT = /(?:\bcase\s*|\.type\s*[!=]==?\s*)['"`]((?:checkout\.session|payment_intent|customer\.subscription|customer|invoice|charge|setup_intent|payment_method|subscription_schedule|account|payout|refund|checkout)\.[a-z_.]+)['"`]|['"`]((?:checkout\.session|payment_intent|customer\.subscription|invoice|charge)\.[a-z_.]+)['"`]\s*[!=]==?\s*[\w$.]+\.type\b/;
6657
+ function verifiedEvent(context, name, at) {
6658
+ const assignment = context.assignment(name, at);
6659
+ if (!assignment || assignment.member || !context.enforcedBefore(assignment.at, at)) return false;
6660
+ const call = /^(?:(await)\s+)?[\w$.]+\.(constructEvent(?:Async)?|retrieve|parseEventNotification)\s*\(/.exec(assignment.expression);
6661
+ if (!call || call[2] !== "constructEvent" && !call[1]) return false;
6662
+ if (call[2] === "retrieve" && !/\.events\s*\.\s*retrieve/.test(call[0])) return false;
6663
+ const close = context.pairs.get(assignment.from + call[0].length - 1);
6664
+ return close !== void 0 && context.code.slice(close + 1, assignment.end).trim() === "";
6665
+ }
6666
+ function eventName(context, event) {
6667
+ if (event[0].startsWith(".")) return /([A-Za-z_$][\w$]*)\s*$/.exec(context.code.slice(0, event.index))?.[1] ?? null;
6668
+ if (!event[0].startsWith("case")) return /([A-Za-z_$][\w$]*)\.type\b/.exec(event[0])?.[1] ?? null;
6669
+ let name = null;
6670
+ for (const m of context.code.slice(0, event.index).matchAll(/\bswitch\s*\(\s*([A-Za-z_$][\w$]*)\.type\s*\)\s*\{/g)) {
6671
+ const end = context.pairs.get(m.index + m[0].length - 1);
6672
+ if (end !== void 0 && end > event.index && context.owner(m.index) === context.owner(event.index)) name = m[1];
6673
+ }
6674
+ return name;
6675
+ }
6676
+ var READS_BODY = /\.\s*(?:json|text|arrayBuffer|formData)\s*\(|\breq(?:uest)?\s*\.\s*body\b|\b(?:readBody|readRawBody)\s*\(|\bbuffer\s*\(\s*req\b/;
6677
+ var webhookRule = {
6678
+ id: "webhook/unverified-signature",
6679
+ severity: "P1",
6680
+ appliesTo(file) {
6681
+ return /stripe/i.test(file.content) && routeOf(file) !== null;
6682
+ },
6683
+ check(file) {
6684
+ const route = routeOf(file);
6685
+ if (!route) return [];
6686
+ const context = new LocalVerification(file);
6687
+ const { source, code } = context;
6688
+ const findings = [];
6689
+ const reported = /* @__PURE__ */ new Set();
6690
+ for (const event of source.matchAll(new RegExp(STRIPE_EVENT.source, "g"))) {
6691
+ if (code[event.index] !== source[event.index]) continue;
6692
+ const owner = context.owner(event.index);
6693
+ const name = eventName(context, event);
6694
+ if (name && verifiedEvent(context, name, event.index)) continue;
6695
+ const key = owner?.start ?? 0;
6696
+ if (reported.has(key)) continue;
6697
+ reported.add(key);
6698
+ const line = lineNumberAt(lineStartsOf(file.content), event.index);
6699
+ const region = code.slice(owner?.start ?? 0, owner?.end ?? code.length);
6700
+ const readsBody = READS_BODY.test(region);
6701
+ const customCheck = /\bverifyHeader\s*\(/.test(region) || /\bcreateHmac\s*\(/.test(region) && /\btimingSafeEqual\s*\(/.test(region);
6702
+ findings.push({
6703
+ ruleId: "webhook/unverified-signature",
6704
+ severity: "P1",
6705
+ confidence: readsBody && !customCheck ? "certain" : "likely",
6706
+ title: customCheck ? `Review custom Stripe signature verification in ${route.url}` : `${route.url} acts on Stripe events without verifying the signature`,
6707
+ file: file.path,
6708
+ line,
6709
+ excerpt: (file.lines[line - 1] ?? "").trim(),
6710
+ why: [
6711
+ `This route handles ${event[1] ?? event[2]} and similar events, but the scan could not link this event to a signature-verification result. Without effective verification, callers can submit made-up events and trigger the handler.`,
6712
+ ...customCheck ? ["A custom signature check is present; confirm it checks the raw body and rejects invalid signatures before processing this event."] : [],
6713
+ ...readsBody ? [] : ["The event does not appear to come from the request body here; confirm where it is read and whether it is verified there."]
6714
+ ],
6715
+ fix: [
6716
+ "Read the raw body (await request.text() in a route handler) and pass it to stripe.webhooks.constructEvent(body, request.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET) before handling the event. Return 400 when it throws.",
6717
+ "Do not parse the body as JSON before verifying; any change to the raw body makes verification fail."
6718
+ ]
6719
+ });
6720
+ }
6721
+ return findings;
6722
+ }
6723
+ };
6724
+
6725
+ // src/rules/index.ts
6726
+ var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule, sessionRule, webhookRule];
6727
+ var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule, injectionRule, ssrfRule, redirectRule];
6728
+ var RULE_IDS = [
6729
+ "api/admin-db-access-without-auth",
6730
+ "api/db-write-without-auth",
6731
+ "auth/unverified-session",
6732
+ "cors/reflected-origin-with-credentials",
6733
+ "cors/wildcard-with-credentials",
6734
+ "exposure/private-name-in-public-env",
6735
+ "exposure/secret-in-public-env",
6736
+ "exposure/supabase-service-role-in-client",
6737
+ "firebase/open-rules",
6738
+ "firebase/test-mode-rules",
6739
+ "gitleak/env-in-history",
6740
+ "gitleak/env-tracked",
6741
+ "injection/command",
6742
+ "injection/sql",
6743
+ "redirect/open",
6744
+ "ssrf/request-url",
6745
+ "supabase/permissive-policy",
6746
+ "supabase/public-bucket-listing",
6747
+ "supabase/rls-not-enabled",
6748
+ "webhook/unverified-signature",
6749
+ // 凭据规则 ID 从共享格式表生成。
6750
+ ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
6751
+ ];
6752
+ function ruleMatches(selector, ruleId) {
6753
+ return ruleId === selector || ruleId.startsWith(`${selector}/`);
6754
+ }
6755
+ function isKnownSelector(selector) {
6756
+ return RULE_IDS.some((id) => ruleMatches(selector, id));
6757
+ }
6758
+ function shouldRunRule(id, only, skip) {
6759
+ if (only.length === 0 && skip.length === 0) return true;
6760
+ const namespace = id.split("/")[0];
6761
+ return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
6762
+ (candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
6763
+ );
6764
+ }
6765
+
6766
+ // src/engine.ts
6767
+ import { createHash as createHash2 } from "crypto";
6768
+ var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
6769
+ var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
6770
+ function dedupe(findings) {
6771
+ const seen = /* @__PURE__ */ new Set();
6772
+ const out = [];
6773
+ for (const f of findings) {
6774
+ const key = `${f.ruleId}|${f.file ?? ""}|${f.line ?? ""}|${f.excerpt ?? ""}|${f.title}`;
6775
+ if (seen.has(key)) continue;
6776
+ seen.add(key);
6777
+ out.push(f);
6778
+ }
6779
+ return out;
6780
+ }
6781
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
6782
+ var DECEPTIVE_CHARS = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
6783
+ function nameOf(ch) {
6784
+ return `<U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}>`;
6785
+ }
6786
+ function clean(text) {
6787
+ return redactAll(text).replace(/\t/g, " ").replace(CONTROL_CHARS, "").replace(DECEPTIVE_CHARS, nameOf);
6788
+ }
6789
+ function cleanForOutput(text) {
6790
+ return clean(text);
6791
+ }
6792
+ function sanitize(findings, files) {
6793
+ const byPath = new Map(files.map((file) => [file.path, file]));
6794
+ const sourceIdentity = (f) => {
6795
+ if (f.sourceFingerprint !== void 0) return { sourceFingerprint: f.sourceFingerprint };
6796
+ const file = f.file === null ? void 0 : byPath.get(f.file);
6797
+ const source = f.line === null ? file?.content : file?.lines[f.line - 1];
6798
+ return source === void 0 ? {} : {
6799
+ sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
6800
+ };
6801
+ };
6802
+ return findings.map((f) => ({
6803
+ ...f,
6804
+ // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
6805
+ ...sourceIdentity(f),
6806
+ title: clean(f.title),
6807
+ // 按段落清理,保留段落之间的结构。
6808
+ why: f.why.map(clean),
6809
+ // 文件路径也必须经过输出清理。
6810
+ file: f.file === null ? null : clean(f.file),
6811
+ // 先脱敏再截断,避免截断导致凭据特征失效。
6812
+ excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
6813
+ ...f.evidence ? {
6814
+ evidence: f.evidence.slice(0, 24).map((step) => ({
6815
+ kind: step.kind,
6816
+ file: clean(step.file),
6817
+ line: step.line,
6818
+ description: clean(step.description)
6819
+ })),
6820
+ evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
6821
+ } : {},
6822
+ fix: f.fix.map(clean),
6823
+ ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
6824
+ }));
6825
+ }
6826
+ function sanitizeSkippedForOutput(items) {
6827
+ return items.map((item) => ({
6828
+ ...item,
6829
+ path: clean(item.path),
6830
+ ...item.detail === void 0 ? {} : { detail: clean(item.detail) }
6831
+ }));
6832
+ }
6833
+ function downgradeExampleContext(findings, files) {
6834
+ const examples = new Set(files.filter((f) => f.isExampleContext).map((f) => f.path));
6835
+ return findings.map(
6836
+ (f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
6837
+ );
6838
+ }
6839
+ function suppressIgnoredLines(findings, files) {
6840
+ const byPath = new Map(files.map((f) => [f.path, f]));
6841
+ const markers = /* @__PURE__ */ new Map();
6842
+ const kept = [];
6843
+ const ignored = [];
6844
+ for (const f of findings) {
6845
+ if (f.file === null || f.line === null) {
6846
+ kept.push(f);
6847
+ continue;
6848
+ }
6849
+ let lines = markers.get(f.file);
6850
+ if (lines === void 0) {
6851
+ const file = byPath.get(f.file);
6852
+ lines = file === void 0 ? /* @__PURE__ */ new Map() : ignoredLinesOf(file.lines);
6853
+ markers.set(f.file, lines);
6854
+ }
6855
+ if (!lines.has(f.line)) {
6856
+ kept.push(f);
6857
+ continue;
6858
+ }
6859
+ const rules = lines.get(f.line);
6860
+ if (rules !== null && rules !== void 0 && !rules.has(f.ruleId)) {
6861
+ kept.push(f);
6862
+ continue;
5393
6863
  }
5394
6864
  ignored.push({ file: f.file, line: f.line, ruleId: f.ruleId });
5395
6865
  }
@@ -5549,196 +7019,319 @@ var SKIP_LABEL = {
5549
7019
  function skipPhrase(reason) {
5550
7020
  return SKIP_LABEL[reason].because;
5551
7021
  }
7022
+ var CATEGORIES = ["Credentials", "API access", "Database rules", "CORS", "Code", "Other"];
7023
+ var SEVERITIES = ["P0", "P1", "P2"];
7024
+ function categoryOf(ruleId) {
7025
+ const namespace = ruleId.split("/")[0];
7026
+ if (namespace === "secrets" || namespace === "exposure" || namespace === "gitleak") return "Credentials";
7027
+ if (namespace === "api" || namespace === "auth" || namespace === "webhook") return "API access";
7028
+ if (namespace === "supabase" || namespace === "firebase") return "Database rules";
7029
+ if (namespace === "cors") return "CORS";
7030
+ if (namespace === "injection" || namespace === "ssrf" || namespace === "redirect") return "Code";
7031
+ return "Other";
7032
+ }
7033
+ function categoryCounts(findings) {
7034
+ return CATEGORIES.map((category) => {
7035
+ const counts = { P0: 0, P1: 0, P2: 0 };
7036
+ for (const f of findings) if (categoryOf(f.ruleId) === category) counts[f.severity]++;
7037
+ return { category, counts, total: counts.P0 + counts.P1 + counts.P2 };
7038
+ }).filter((row) => row.total > 0);
7039
+ }
7040
+ function groupByFile(findings) {
7041
+ const groups = /* @__PURE__ */ new Map();
7042
+ for (const f of findings) {
7043
+ const list = groups.get(f.file) ?? [];
7044
+ list.push(f);
7045
+ groups.set(f.file, list);
7046
+ }
7047
+ const rank = (list) => Math.min(...list.map((f) => SEVERITIES.indexOf(f.severity)));
7048
+ return [...groups].map(([file, list]) => ({
7049
+ file,
7050
+ findings: [...list].sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity))
7051
+ })).sort((a, b) => rank(a.findings) - rank(b.findings) || b.findings.length - a.findings.length || (a.file ?? "").localeCompare(b.file ?? ""));
7052
+ }
7053
+ function firstSentence(text) {
7054
+ const cut = /\.\s+(?=[A-Z"'(])|\s+—\s+/.exec(text);
7055
+ const sentence = cut ? text.slice(0, cut.index + (cut[0].startsWith(".") ? 1 : 0)) : text;
7056
+ return sentence.trim().replace(/\.$/, "");
7057
+ }
7058
+ function manualSteps(findings) {
7059
+ const steps = /* @__PURE__ */ new Map();
7060
+ for (const f of findings) {
7061
+ for (const item of f.humanOnly ?? []) {
7062
+ const text = firstSentence(item);
7063
+ if (!text) continue;
7064
+ const step = steps.get(text) ?? { text, locations: [], severity: f.severity };
7065
+ const location = locationOf(f);
7066
+ if (!step.locations.includes(location)) step.locations.push(location);
7067
+ if (SEVERITIES.indexOf(f.severity) < SEVERITIES.indexOf(step.severity)) step.severity = f.severity;
7068
+ steps.set(text, step);
7069
+ }
7070
+ }
7071
+ return [...steps.values()].sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
7072
+ }
5552
7073
 
5553
7074
  // src/report/terminal.ts
5554
- var INDENT = " ";
5555
- function renderBaseline(opts) {
5556
- const suppressed = opts.baselineSuppressed ?? 0;
5557
- const stale = opts.baselineStale ?? 0;
5558
- if (suppressed === 0 && stale === 0) return [];
5559
- const out = [];
5560
- if (suppressed > 0) {
5561
- const where = opts.baselinePath ? ` (${opts.baselinePath})` : "";
5562
- out.push(
5563
- `${INDENT}${yellow(`${suppressed} ${plural(suppressed, "finding")} hidden by the baseline${where}`)}`
5564
- );
5565
- out.push(`${INDENT}${dim("These problems still exist. Re-run without --baseline to see them.")}`);
5566
- }
5567
- if (stale > 0) {
5568
- out.push(
5569
- // 不规则复数单独处理。
5570
- `${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
5571
- );
7075
+ var DETAIL = " ".repeat(12);
7076
+ function widthOf(opts) {
7077
+ const columns = opts.width ?? (process.stdout.isTTY ? process.stdout.columns : void 0) ?? 96;
7078
+ return Math.max(60, Math.min(120, columns));
7079
+ }
7080
+ function severityColor(severity) {
7081
+ return severity === "P0" ? red : severity === "P1" ? yellow : (s) => s;
7082
+ }
7083
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7084
+ var visibleLength = (s) => s.replace(ANSI, "").length;
7085
+ function joinFitting(items, separator, width) {
7086
+ const lines = [];
7087
+ let line = "";
7088
+ for (const item of items) {
7089
+ const next = line === "" ? item : `${line}${separator}${item}`;
7090
+ if (line !== "" && visibleLength(next) > width) {
7091
+ lines.push(line);
7092
+ line = item;
7093
+ } else {
7094
+ line = next;
7095
+ }
5572
7096
  }
5573
- return out;
7097
+ if (line !== "") lines.push(line);
7098
+ return lines;
5574
7099
  }
7100
+ var pad = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
7101
+ var rpad = (s, n) => s.length >= n ? s : " ".repeat(n - s.length) + s;
5575
7102
  function renderReport(result, opts) {
5576
- const out = [""];
5577
- const { findings } = result;
5578
- out.push(
5579
- `${INDENT}${bold("canship")} ${dim(`scanned ${result.filesScanned} ${plural(result.filesScanned, "file")} in ${result.durationMs}ms`)}`
5580
- );
5581
- out.push(`${INDENT}${dim(opts.root)}`);
5582
- if (result.changeView) out.push(`${INDENT}${yellow(changeViewNotice(result.changeView))}`);
7103
+ const width = widthOf(opts);
7104
+ const out = [];
7105
+ const rule2 = () => {
7106
+ out.push(dim("\u2500".repeat(width)));
7107
+ };
7108
+ out.push(`${bold("canship")}${opts.version ? ` ${opts.version}` : ""} ${dim("\xB7")} ${opts.root}`);
7109
+ out.push("\u2500".repeat(width));
5583
7110
  out.push("");
7111
+ const { findings } = result;
5584
7112
  if (findings.length === 0) {
5585
- out.push(...renderClean(result, opts));
7113
+ out.push(...renderClean(result, opts, width));
7114
+ out.push(...renderNext(opts, width));
5586
7115
  return out.join("\n");
5587
7116
  }
5588
- const { blocking: visibleBlocking, minor: confirmedMinor, unsure } = verdictOf(findings);
5589
- const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
5590
- if (blocking > 0) {
5591
- out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
5592
- } else if (confirmedMinor > 0) {
5593
- out.push(
5594
- `${INDENT}${yellow(bold(`! ${confirmedMinor} ${plural(confirmedMinor, "thing")} to fix \u2014 nothing exposed`))}`
5595
- );
5596
- } else {
5597
- out.push(`${INDENT}${yellow(bold(`! ${unsure} possible ${plural(unsure, "issue")} to review`))}`);
5598
- }
7117
+ out.push(...renderVerdict(result, opts, width));
5599
7118
  out.push("");
5600
- findings.forEach((f, i) => {
5601
- out.push(...renderFinding(f, i + 1));
5602
- out.push("");
5603
- });
5604
- out.push(`${INDENT}${gray("\u2500".repeat(60))}`);
7119
+ rule2();
5605
7120
  out.push("");
5606
- if (result.partial) {
5607
- out.push(...renderIncomplete(result));
7121
+ out.push(...renderSummary(findings));
7122
+ const steps = renderManualSteps(findings, width);
7123
+ if (steps.length > 0) out.push("", ...steps);
7124
+ out.push("");
7125
+ rule2();
7126
+ out.push("");
7127
+ out.push(`${bold("Findings")}${dim(" grouped by file \xB7 most severe first")}`);
7128
+ out.push("");
7129
+ for (const group of groupByFile(findings)) {
7130
+ out.push(`${group.file ?? "repository"}${dim(` \u2014 ${group.findings.length}`)}`);
7131
+ for (const f of group.findings) out.push(...renderFinding(f, width, opts.verbose === true));
5608
7132
  out.push("");
5609
7133
  }
5610
- out.push(...renderIgnored(result));
5611
- out.push(...renderBaseline(opts));
7134
+ rule2();
7135
+ const notes = [...renderIncomplete(result, width), ...renderIgnored(result), ...renderBaseline(opts)];
5612
7136
  if (!opts.showingLikely && opts.hiddenLikely > 0) {
5613
- out.push(
5614
- `${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`
5615
- );
7137
+ notes.push(dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`));
5616
7138
  }
5617
- out.push(`${INDENT}${dim("Rotate any key that was exposed. Removing it from the code is not enough.")}`);
5618
- out.push("");
7139
+ if (notes.length > 0) out.push(...notes, "");
7140
+ out.push(...renderNext(opts, width));
5619
7141
  return out.join("\n");
5620
7142
  }
5621
- function renderFinding(f, index) {
7143
+ function renderVerdict(result, opts, width) {
7144
+ const { findings } = result;
7145
+ const { blocking: visibleBlocking, minor, unsure } = verdictOf(findings);
7146
+ const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
7147
+ const rotations = manualSteps(findings).filter((step) => /^Rotate\b/.test(step.text)).length;
5622
7148
  const out = [];
5623
- const marker = f.confidence === "certain" ? red("\u2717") : yellow("!");
5624
- const location = locationOf(f);
5625
- out.push(`${INDENT}${marker} ${bold(`[${index}] ${f.title}`)}`);
5626
- out.push(`${INDENT}${INDENT}${cyan(location)}${f.confidence === "likely" ? dim(" (lower confidence)") : ""}`);
5627
- if (f.excerpt) {
5628
- out.push("");
5629
- out.push(`${INDENT}${INDENT}${gray(f.excerpt)}`);
5630
- }
5631
- if (f.evidence?.length) {
5632
- out.push("", `${INDENT}${INDENT}${bold("Evidence (static relationships):")}`);
5633
- for (const step of f.evidence) out.push(`${INDENT}${INDENT} ${locationOf(step)} \u2014 ${step.description}`);
5634
- if (f.evidenceTruncated) out.push(`${INDENT}${INDENT} Additional dependency steps omitted.`);
7149
+ let explanation;
7150
+ if (blocking > 0) {
7151
+ out.push(`${red(bold(`${blocking} blocking ${plural(blocking, "finding")}.`))} ${bold("Do not deploy yet.")}`);
7152
+ explanation = "Every certain P0 or P1 result below needs a code change." + (rotations > 0 ? ` ${rotations} exposed ${plural(rotations, "credential")} must also be rotated in ${rotations === 1 ? "its provider" : "their providers"}; a code change does not revoke ${rotations === 1 ? "it" : "them"}.` : "");
7153
+ } else if (minor > 0) {
7154
+ out.push(`${yellow(bold(`${minor} ${plural(minor, "finding")} to fix.`))} ${bold("Nothing blocking.")}`);
7155
+ explanation = "No certain P0 or P1 result was found; the findings below are lower severity.";
7156
+ } else {
7157
+ out.push(`${yellow(bold(`${unsure} ${plural(unsure, "finding")} to review.`))}`);
7158
+ explanation = "These are likely findings: the static evidence is not conclusive, so check each one.";
5635
7159
  }
7160
+ for (const line of wrapText(explanation, width)) out.push(dim(line));
5636
7161
  out.push("");
5637
- for (const line of wrapText(f.why.join("\n\n"), 76)) {
5638
- out.push(line === "" ? "" : `${INDENT}${INDENT}${line}`);
7162
+ const facts = [
7163
+ `${dim("findings")} ${findings.length} ${dim("shown")}${opts.hiddenLikely > 0 && !opts.showingLikely ? dim(`, ${opts.hiddenLikely} likely hidden (--all)`) : ""}`,
7164
+ `${dim("files scanned")} ${result.filesScanned}`,
7165
+ `${dim("coverage")} ${result.partial ? yellow("incomplete") : "complete"}`,
7166
+ `${result.durationMs} ${dim("ms")}`
7167
+ ];
7168
+ out.push(...joinFitting(facts, dim(" \xB7 "), width));
7169
+ if (result.changeView) for (const line of wrapText(changeViewNotice(result.changeView), width)) out.push(yellow(line));
7170
+ return out;
7171
+ }
7172
+ function renderSummary(findings) {
7173
+ const rows = categoryCounts(findings);
7174
+ const out = [bold("By category"), dim(pad("", 22) + SEVERITIES.map((s) => rpad(s, 5)).join("") + rpad("total", 8))];
7175
+ for (const row of rows) {
7176
+ out.push(pad(row.category, 22) + SEVERITIES.map((s) => row.counts[s] ? severityColor(s)(rpad(String(row.counts[s]), 5)) : dim(rpad("\u2013", 5))).join("") + rpad(String(row.total), 8));
7177
+ }
7178
+ const totals = SEVERITIES.map((s) => findings.filter((f) => f.severity === s).length);
7179
+ out.push(dim(pad("all", 22) + totals.map((n) => rpad(String(n), 5)).join("") + rpad(String(findings.length), 8)));
7180
+ return out;
7181
+ }
7182
+ function renderManualSteps(findings, width) {
7183
+ const steps = manualSteps(findings);
7184
+ if (steps.length === 0) return [];
7185
+ const out = [bold("Manual steps")];
7186
+ steps.forEach((step, i) => {
7187
+ wrapText(step.text, width - 5).forEach((line, j) => out.push(`${dim(j === 0 ? rpad(String(i + 1), 3) : " ")} ${line}`));
7188
+ const more = step.locations.length > 1 ? ` +${step.locations.length - 1}` : "";
7189
+ out.push(` ${dim(`${step.locations[0]}${more}`)}`);
7190
+ });
7191
+ return out;
7192
+ }
7193
+ var EVIDENCE_LABEL = {
7194
+ operation: "operation",
7195
+ import: "imports",
7196
+ "admin-client": "admin client",
7197
+ "auth-helper": "auth helper"
7198
+ };
7199
+ function renderFinding(f, width, verbose) {
7200
+ const out = [];
7201
+ const title = wrapText(f.title, width - DETAIL.length);
7202
+ const likely = f.confidence === "likely" ? dim(" likely") : "";
7203
+ title.forEach((line, i) => out.push(i === 0 ? ` ${severityColor(f.severity)(pad(f.severity, 4))}${dim(rpad(f.line !== null ? String(f.line) : f.file ? "file" : "", 4))} ${verbose ? bold(line) : line}${i === title.length - 1 ? likely : ""}` : `${DETAIL}${verbose ? bold(line) : line}${i === title.length - 1 ? likely : ""}`));
7204
+ if (!verbose) return out;
7205
+ const text = (s, indent = DETAIL) => wrapText(s, width - indent.length).map((line) => line === "" ? "" : `${indent}${line}`);
7206
+ out.push(`${DETAIL}${dim(`${categoryOf(f.ruleId)} \xB7 ${f.ruleId}`)}`);
7207
+ if (f.excerpt) out.push("", `${DETAIL}${gray(f.excerpt)}`);
7208
+ out.push("", ...text(f.why.join("\n\n")));
7209
+ if (f.evidence?.length) {
7210
+ out.push("", `${DETAIL}${dim("trace (static relationships)")}`);
7211
+ f.evidence.forEach((step, i) => {
7212
+ const marker = i === 0 ? red("\u25CF") : i === f.evidence.length - 1 ? dim("\u25CB") : dim("\u2502");
7213
+ out.push(`${DETAIL}${marker} ${dim(pad(EVIDENCE_LABEL[step.kind], 14))}${locationOf(step)}`);
7214
+ });
7215
+ if (f.evidenceTruncated) out.push(`${DETAIL}${dim(" additional dependency steps omitted")}`);
5639
7216
  }
5640
7217
  if (f.fix.length > 0) {
5641
- out.push("");
5642
- out.push(`${INDENT}${INDENT}${bold("How to fix:")}`);
7218
+ out.push("", `${DETAIL}${dim("fix")}`);
5643
7219
  f.fix.forEach((step, i) => {
5644
- const wrapped = wrapText(step, 72);
5645
- wrapped.forEach((line, j) => {
5646
- const prefix = j === 0 ? `${i + 1}. ` : " ";
5647
- out.push(`${INDENT}${INDENT}${INDENT}${dim(prefix)}${line}`);
5648
- });
7220
+ wrapText(step, width - DETAIL.length - 3).forEach((line, j) => out.push(`${DETAIL}${j === 0 ? `${i + 1}. ` : " "}${line}`));
5649
7221
  });
5650
7222
  }
5651
- if (f.humanOnly && f.humanOnly.length > 0) {
5652
- out.push("");
5653
- out.push(`${INDENT}${INDENT}${yellow(bold("Only you can do this:"))}`);
5654
- f.humanOnly.forEach((step) => {
5655
- wrapText(step, 72).forEach((line, j) => {
5656
- const prefix = j === 0 ? "\xB7 " : " ";
5657
- out.push(`${INDENT}${INDENT}${INDENT}${dim(prefix)}${line}`);
5658
- });
5659
- });
7223
+ if (f.humanOnly?.length) {
7224
+ out.push("", `${DETAIL}${yellow("by hand")}`);
7225
+ for (const step of f.humanOnly) {
7226
+ wrapText(step, width - DETAIL.length - 2).forEach((line, j) => out.push(`${DETAIL}${j === 0 ? dim("\xB7 ") : " "}${line}`));
7227
+ }
7228
+ }
7229
+ out.push("");
7230
+ return out;
7231
+ }
7232
+ function renderNext(opts, width) {
7233
+ const out = [];
7234
+ const commands = [
7235
+ ...opts.verbose ? [] : [`${dim("details")} canship --verbose`],
7236
+ `${dim("report")} canship --report --open`,
7237
+ `${dim("fix prompt")} canship --fix-prompt`
7238
+ ];
7239
+ out.push(...joinFitting(commands, " ", width));
7240
+ if (opts.exitCode !== void 0) {
7241
+ const reason = {
7242
+ 0: "no findings",
7243
+ 1: "blocking findings present",
7244
+ 2: "findings present, none blocking",
7245
+ 3: "scan incomplete"
7246
+ }[opts.exitCode];
7247
+ out.push(dim(`exit ${opts.exitCode} \xB7 ${reason}`));
7248
+ }
7249
+ out.push("");
7250
+ return out;
7251
+ }
7252
+ function renderBaseline(opts) {
7253
+ const suppressed = opts.baselineSuppressed ?? 0;
7254
+ const stale = opts.baselineStale ?? 0;
7255
+ const out = [];
7256
+ if (suppressed > 0) {
7257
+ const where = opts.baselinePath ? ` (${opts.baselinePath})` : "";
7258
+ out.push(yellow(`${suppressed} ${plural(suppressed, "finding")} hidden by the baseline${where}`));
7259
+ out.push(dim("These problems still exist. Re-run without --baseline to see them."));
7260
+ }
7261
+ if (stale > 0) {
7262
+ out.push(dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`));
5660
7263
  }
5661
7264
  return out;
5662
7265
  }
5663
- function renderClean(result, opts) {
7266
+ function renderClean(result, opts, width) {
5664
7267
  const out = [];
7268
+ const facts = joinFitting([
7269
+ `${dim("files scanned")} ${result.filesScanned}`,
7270
+ `${dim("coverage")} ${result.partial ? yellow("incomplete") : "complete"}`,
7271
+ `${result.durationMs} ${dim("ms")}`
7272
+ ], dim(" \xB7 "), width);
5665
7273
  if (result.filesScanned === 0) {
5666
- out.push(`${INDENT}${yellow(bold("! No files were scanned \u2014 nothing was checked"))}`);
5667
- out.push("");
5668
- for (const line of wrapText(
5669
- "canship found no files it could read here, so none of its checks ran. This is not a clean result \u2014 it is an empty one.",
5670
- 76
5671
- )) {
5672
- out.push(`${INDENT}${line}`);
5673
- }
7274
+ out.push(yellow(bold("No files were scanned \u2014 nothing was checked.")));
7275
+ for (const line of wrapText("canship found no files it could read here, so none of its checks ran. This is not a clean result \u2014 it is an empty one.", width)) out.push(dim(line));
5674
7276
  out.push("");
5675
- out.push(`${INDENT}${dim("Most likely one of:")}`);
5676
- out.push(`${INDENT}${dim(" \xB7 this is not the directory you meant to scan")}`);
5677
- out.push(`${INDENT}${dim(" \xB7 everything here is gitignored, or is build output canship skips")}`);
5678
- out.push(`${INDENT}${dim(" \xB7 the project lives in a subdirectory \u2014 try: npx canship ./app")}`);
7277
+ out.push(dim("Most likely one of:"));
7278
+ out.push(dim(" \xB7 this is not the directory you meant to scan"));
7279
+ out.push(dim(" \xB7 everything here is gitignored, or is build output canship skips"));
7280
+ out.push(dim(" \xB7 the project lives in a subdirectory \u2014 try: npx canship ./app"));
5679
7281
  if (result.ignored.length > 0) {
5680
- out.push(`${INDENT}${dim(" \xB7 every file here was excluded by canship-ignore-file")}`);
5681
- out.push("");
5682
- out.push(...renderIgnored(result));
7282
+ out.push(dim(" \xB7 every file here was excluded by canship-ignore-file"));
7283
+ out.push("", ...renderIgnored(result));
5683
7284
  }
5684
7285
  out.push("");
7286
+ if (result.partial) out.push(...renderIncomplete(result, width), "");
5685
7287
  return out;
5686
7288
  }
5687
7289
  if (result.partial) {
5688
- const headline = opts.hiddenLikely > 0 ? `! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked` : "! No findings \u2014 but not everything was checked";
5689
- out.push(`${INDENT}${yellow(bold(headline))}`);
7290
+ out.push(yellow(bold(opts.hiddenLikely > 0 ? `No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked.` : "No findings \u2014 but not everything was checked.")));
5690
7291
  } else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
5691
- out.push(`${INDENT}${yellow(bold("! No visible findings in changed files \u2014 other findings still exist"))}`);
7292
+ out.push(yellow(bold("No visible findings in changed files \u2014 other findings still exist.")));
5692
7293
  } else if (opts.hiddenLikely > 0) {
5693
- out.push(
5694
- `${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
5695
- );
7294
+ out.push(yellow(bold(`No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden.`)));
5696
7295
  } else if ((opts.baselineSuppressed ?? 0) > 0) {
5697
7296
  const suppressed = opts.baselineSuppressed ?? 0;
5698
- out.push(
5699
- `${INDENT}${yellow(bold(`! No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline`))}`
5700
- );
7297
+ out.push(yellow(bold(`No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline.`)));
5701
7298
  } else {
5702
- out.push(`${INDENT}${green(bold("\u2713 No exposed credentials found"))}`);
7299
+ out.push(green(bold("No exposed credentials found.")));
5703
7300
  }
7301
+ out.push(...facts);
7302
+ if (result.changeView) for (const line of wrapText(changeViewNotice(result.changeView), width)) out.push(yellow(line));
5704
7303
  out.push("");
5705
- out.push(...renderBaseline(opts));
5706
- if ((opts.baselineSuppressed ?? 0) > 0 || (opts.baselineStale ?? 0) > 0) out.push("");
5707
- out.push(`${INDENT}${dim("canship checked for:")}`);
5708
- out.push(`${INDENT}${dim(" \xB7 API keys hardcoded in source code")}`);
5709
- out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
5710
- out.push(`${INDENT}${dim(" \xB7 Supabase service_role keys reachable from the client")}`);
5711
- out.push(`${INDENT}${dim(" \xB7 .env files committed to git, including in history")}`);
5712
- out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security, or policies open to everyone")}`);
5713
- out.push(`${INDENT}${dim(" \xB7 Firebase rules left open to anyone")}`);
5714
- out.push(`${INDENT}${dim(" \xB7 API routes and server actions that query your database with no sign-in check")}`);
5715
- out.push(`${INDENT}${dim(" \xB7 CORS that lets other sites act as your signed-in visitors")}`);
7304
+ const baseline = renderBaseline(opts);
7305
+ if (baseline.length > 0) out.push(...baseline, "");
7306
+ out.push(bold("Checked for"));
7307
+ for (const item of [
7308
+ "API keys hardcoded in source code",
7309
+ "Server-side secrets exposed to the browser via public env prefixes",
7310
+ "Supabase service_role keys reachable from the client",
7311
+ ".env files committed to git, including in history",
7312
+ "Supabase tables with no Row Level Security, or policies open to everyone",
7313
+ "Firebase rules left open to anyone",
7314
+ "API routes and server actions that query your database with no sign-in check",
7315
+ "CORS that lets other sites act as your signed-in visitors",
7316
+ "Request input in SQL, commands, outbound URLs, and redirects within supported handlers",
7317
+ "Unverified Supabase sessions and Stripe webhook events"
7318
+ ]) out.push(`${dim(" \xB7 ")}${item}`);
5716
7319
  out.push("");
5717
- out.push(`${INDENT}${dim("It does not check rate limiting, injection, or whether the checks it")}`);
5718
- out.push(`${INDENT}${dim("did find are the right ones.")}`);
7320
+ out.push(dim("Input analysis is bounded and handler-local; business authorisation, rate limiting and dependency vulnerabilities are not verified."));
5719
7321
  if (opts.hiddenLikely > 0) {
5720
- out.push(`${INDENT}${dim("This is not a finding-free result. Review the hidden items with --all.")}`);
7322
+ out.push(dim("This is not a finding-free result. Review the hidden items with --all."));
5721
7323
  } else if (result.ignoredFindings.length > 0) {
5722
- out.push(
5723
- `${INDENT}${dim("This is not a finding-free result \u2014 some were silenced in the source. See below.")}`
5724
- );
7324
+ out.push(dim("This is not a finding-free result \u2014 some were silenced in the source. See below."));
5725
7325
  } else {
5726
- out.push(`${INDENT}${dim("A clean result means these checks passed \u2014 not that your app is secure.")}`);
5727
- }
5728
- if (result.partial) {
5729
- out.push("");
5730
- out.push(...renderIncomplete(result));
5731
- }
5732
- const optedOut = renderIgnored(result);
5733
- if (optedOut.length > 0) {
5734
- out.push("");
5735
- out.push(...optedOut);
7326
+ out.push(dim("A clean result means these checks passed \u2014 not that your app is secure."));
5736
7327
  }
7328
+ const notes = [...renderIncomplete(result, width), ...renderIgnored(result)];
5737
7329
  if (opts.hiddenLikely > 0) {
5738
- out.push("");
5739
- out.push(`${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`);
7330
+ notes.push(dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`));
5740
7331
  }
7332
+ if (notes.length > 0) out.push("", ...notes);
5741
7333
  out.push("");
7334
+ out.push(dim("\u2500".repeat(width)));
5742
7335
  return out;
5743
7336
  }
5744
7337
  function renderIgnored(result) {
@@ -5746,47 +7339,38 @@ function renderIgnored(result) {
5746
7339
  if (result.ignored.length > 0) {
5747
7340
  const shown = result.ignored.slice(0, 3).join(", ");
5748
7341
  const more = result.ignored.length > 3 ? `, and ${result.ignored.length - 3} more` : "";
5749
- out.push(
5750
- `${INDENT}${dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`)}`
5751
- );
7342
+ out.push(dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`));
5752
7343
  }
5753
7344
  if (result.ruleSelection !== null) {
5754
7345
  const { only, skip, removed } = result.ruleSelection;
5755
7346
  const which = only.length > 0 ? `only ${only.join(", ")}` : `everything except ${skip.join(", ")}`;
5756
7347
  const cost = removed > 0 ? `, hiding ${removed} ${plural(removed, "finding")}` : "";
5757
- out.push(`${INDENT}${dim(`Rule selection in force: ${which}${cost}`)}`);
7348
+ out.push(dim(`Rule selection in force: ${which}${cost}`));
5758
7349
  }
5759
7350
  if (result.ignoredFindings.length > 0) {
5760
7351
  const n = result.ignoredFindings.length;
5761
7352
  const shown = result.ignoredFindings.slice(0, 3).map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
5762
7353
  const more = n > 3 ? `, and ${n - 3} more` : "";
5763
- out.push(
5764
- `${INDENT}${dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`)}`
5765
- );
7354
+ out.push(dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`));
5766
7355
  }
5767
7356
  if (result.vendored > 0) {
5768
- out.push(
5769
- `${INDENT}${dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`)}`
5770
- );
7357
+ out.push(dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`));
5771
7358
  }
5772
7359
  return out;
5773
7360
  }
5774
- function renderIncomplete(result) {
5775
- const out = [];
5776
- out.push(`${INDENT}${yellow(bold("Not everything was checked:"))}`);
5777
- if (result.filesScanned === 0) {
5778
- out.push(
5779
- `${INDENT}${INDENT}${dim("\xB7")} no files could be read at this path, so every file-based check was skipped`
5780
- );
5781
- }
7361
+ function renderIncomplete(result, width) {
7362
+ if (!result.partial) return [];
7363
+ const out = [yellow(bold("Not everything was checked"))];
7364
+ const item = (text) => {
7365
+ wrapText(text, width - 4).forEach((line, i) => out.push(`${i === 0 ? dim(" \xB7 ") : " "}${line}`));
7366
+ };
7367
+ if (result.filesScanned === 0) item("no files could be read at this path, so every file-based check was skipped");
5782
7368
  for (const err of result.errors.slice(0, 5)) {
5783
7369
  const where = err.file ? ` on ${err.file}` : "";
5784
7370
  const verb = err.kind === "incomplete" ? "did not finish" : "failed";
5785
- out.push(`${INDENT}${INDENT}${dim("\xB7")} the ${err.ruleId} check ${verb}${where} \u2014 ${err.message}`);
5786
- }
5787
- if (result.errors.length > 5) {
5788
- out.push(`${INDENT}${INDENT}${dim(`\xB7 and ${result.errors.length - 5} more`)}`);
7371
+ item(`the ${err.ruleId} check ${verb}${where} \u2014 ${err.message}`);
5789
7372
  }
7373
+ if (result.errors.length > 5) out.push(dim(` \xB7 and ${result.errors.length - 5} more`));
5790
7374
  const byReason = /* @__PURE__ */ new Map();
5791
7375
  for (const skip of result.skipped) {
5792
7376
  const list = byReason.get(skip.reason) ?? [];
@@ -5797,12 +7381,9 @@ function renderIncomplete(result) {
5797
7381
  const { noun, because } = SKIP_LABEL[reason];
5798
7382
  const shown = paths.slice(0, 3).join(", ");
5799
7383
  const more = paths.length > 3 ? `, and ${paths.length - 3} more` : "";
5800
- out.push(
5801
- `${INDENT}${INDENT}${dim("\xB7")} ${paths.length} ${plural(paths.length, noun)} ${because}: ${shown}${more}`
5802
- );
7384
+ item(`${paths.length} ${plural(paths.length, noun)} ${because}: ${shown}${more}`);
5803
7385
  }
5804
- out.push("");
5805
- out.push(`${INDENT}${dim("Anything could be in what was skipped. Re-run once it is readable.")}`);
7386
+ out.push(dim("Anything could be in what was skipped. Re-run once it is readable."));
5806
7387
  return out;
5807
7388
  }
5808
7389
  function wrapText(text, width) {
@@ -5937,11 +7518,12 @@ function renderFixPrompt(findings, ctx) {
5937
7518
  }
5938
7519
 
5939
7520
  // src/report/html.ts
7521
+ import { createHash as createHash3 } from "crypto";
5940
7522
  function esc(s) {
5941
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7523
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5942
7524
  }
5943
7525
  function paragraphs(parts) {
5944
- return parts.map((p) => `<p>${esc(p.trim())}</p>`).join("");
7526
+ return parts.map((p) => `<p>${linkify(esc(p.trim()))}</p>`).join("");
5945
7527
  }
5946
7528
  function linkify(html) {
5947
7529
  return html.replace(
@@ -5949,196 +7531,353 @@ function linkify(html) {
5949
7531
  (url) => `<a href="${url}" target="_blank" rel="noreferrer noopener">${url}</a>`
5950
7532
  );
5951
7533
  }
5952
- function renderFinding2(f, index) {
5953
- const location = locationOf(f);
5954
- const cls = f.confidence === "certain" ? "certain" : "likely";
5955
- const fixList = f.fix.length > 0 ? `<h4>How to fix</h4><ol>${f.fix.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ol>` : "";
5956
- const humanList = f.humanOnly && f.humanOnly.length > 0 ? `<div class="human"><h4>Only you can do this</h4><ul>${f.humanOnly.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ul></div>` : "";
5957
- return `
5958
- <article class="finding ${cls}">
5959
- <header>
5960
- <span class="num">${index}</span>
5961
- <h3>${esc(f.title)}</h3>
5962
- </header>
5963
- <div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
5964
- ${f.excerpt ? `<pre><code>${esc(f.excerpt)}</code></pre>` : ""}
5965
- ${f.evidence?.length ? `<h4>Evidence (static relationships)</h4><ol>${f.evidence.map((step) => `<li><code>${esc(locationOf(step))}</code> \u2014 ${esc(step.description)}</li>`).join("")}</ol>${f.evidenceTruncated ? "<p>Additional dependency steps omitted.</p>" : ""}` : ""}
5966
- <div class="why">${linkify(paragraphs(f.why))}</div>
5967
- ${fixList}
5968
- ${humanList}
5969
- </article>`;
7534
+ function jsonForScript(value) {
7535
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(new RegExp(String.fromCharCode(8232), "g"), "\\u2028").replace(new RegExp(String.fromCharCode(8233), "g"), "\\u2029");
5970
7536
  }
7537
+ function displayTime(iso) {
7538
+ const date = new Date(iso);
7539
+ if (!iso || Number.isNaN(date.getTime())) return iso;
7540
+ return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
7541
+ }
7542
+ var EVIDENCE_LABEL2 = {
7543
+ operation: "operation",
7544
+ import: "imports",
7545
+ "admin-client": "admin client",
7546
+ "auth-helper": "auth helper"
7547
+ };
7548
+ function renderFinding2(f, index) {
7549
+ const category = categoryOf(f.ruleId);
7550
+ const search = `${f.title} ${f.file ?? ""} ${f.ruleId}`.toLowerCase();
7551
+ const trace = f.evidence?.length ? `<h3>Trace (static relationships)</h3><ol class="trace">${f.evidence.map((step) => `<li><span class="k">${EVIDENCE_LABEL2[step.kind]}</span><span class="v">${esc(locationOf(step))}</span><span class="note">${esc(step.description)}</span></li>`).join("")}</ol>${f.evidenceTruncated ? '<p class="faint">Additional dependency steps omitted.</p>' : ""}` : "";
7552
+ const fix = f.fix.length > 0 ? `<h3>Fix</h3><ol class="steps">${f.fix.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ol>` : "";
7553
+ const hand = f.humanOnly?.length ? `<div class="hand"><b>By hand</b><ul>${f.humanOnly.map((s) => `<li>${linkify(esc(s))}</li>`).join("")}</ul></div>` : "";
7554
+ return `<details class="f" data-i="${index}" data-sev="${f.severity}" data-cat="${esc(category)}" data-conf="${f.confidence}" data-file="${esc(f.file ?? "")}" data-text="${esc(search)}">
7555
+ <summary class="row"><span class="sev ${f.severity}">${f.severity}</span><span class="main"><span class="title">${esc(f.title)}</span>${f.confidence === "likely" ? '<span class="likely">likely</span>' : ""}<span class="loc"><span class="loc-line">${f.line !== null ? `line ${f.line}` : f.file ? "whole file" : "repository"}</span><span class="loc-full">${esc(locationOf(f))}</span></span></span><span class="cat">${esc(category)}</span></summary>
7556
+ <div class="body">
7557
+ <p class="rule">${esc(f.ruleId)}</p>
7558
+ ${f.excerpt ? `<pre class="excerpt"><code>${esc(f.excerpt)}</code></pre>` : ""}
7559
+ <div class="why">${paragraphs(f.why)}</div>
7560
+ ${trace}${fix}${hand}
7561
+ <p class="actions"><button type="button" class="link" data-copy="${index}">copy fix prompt</button></p>
7562
+ </div>
7563
+ </details>`;
7564
+ }
7565
+ var SCRIPT = `(function(){
7566
+ var d=document,b=d.body;b.classList.add('js');
7567
+ var data={};try{data=JSON.parse(d.getElementById('canship-data').textContent||'{}')}catch(e){}
7568
+ var rows=[].slice.call(d.querySelectorAll('details.f')),list=d.getElementById('list'),st={sev:null,cat:null,q:'',g:'file'};
7569
+ var SEV=['P0','P1','P2'],CAT=data.categories||[];
7570
+ function matches(r){return(!st.sev||r.dataset.sev===st.sev)&&(!st.cat||r.dataset.cat===st.cat)&&(!st.q||r.dataset.text.indexOf(st.q)>=0)}
7571
+ function keyOf(r){return st.g==='sev'?r.dataset.sev:st.g==='cat'?r.dataset.cat:r.dataset.file}
7572
+ function label(k){return st.g==='sev'?k+' \\u00b7 '+({P0:'critical',P1:'high',P2:'medium'})[k]:(k||'repository')}
7573
+ function render(){
7574
+ if(!list)return;
7575
+ var groups=[],index=new Map();
7576
+ rows.forEach(function(r){var k=keyOf(r);if(!index.has(k)){index.set(k,groups.length);groups.push({k:k,rows:[]})}groups[index.get(k)].rows.push(r)});
7577
+ if(st.g==='sev')groups.sort(function(a,c){return SEV.indexOf(a.k)-SEV.indexOf(c.k)});
7578
+ if(st.g==='cat')groups.sort(function(a,c){return CAT.indexOf(a.k)-CAT.indexOf(c.k)});
7579
+ list.textContent='';var shown=0;
7580
+ groups.forEach(function(g){
7581
+ var visible=g.rows.filter(matches);if(!visible.length)return;shown+=visible.length;
7582
+ var h=d.createElement('div');h.className='group';var name=d.createElement('span');name.className='gk';name.textContent=label(g.k);
7583
+ h.appendChild(name);h.appendChild(d.createTextNode(' \\u2014 '+visible.length));list.appendChild(h);
7584
+ var box=d.createElement('div');box.className='ledger';visible.forEach(function(r){box.appendChild(r)});list.appendChild(box);
7585
+ });
7586
+ list.classList.toggle('by-file',st.g==='file');
7587
+ var count=d.getElementById('count');if(count)count.textContent=shown===rows.length?rows.length+' findings':shown+' of '+rows.length+' findings';
7588
+ var empty=d.getElementById('empty');if(empty)empty.hidden=shown>0;
7589
+ [].forEach.call(d.querySelectorAll('[data-filter-sev]'),function(x){x.classList.toggle('on',(x.dataset.filterSev||null)===st.sev&&!st.cat)});
7590
+ [].forEach.call(d.querySelectorAll('[data-group]'),function(x){x.classList.toggle('on',x.dataset.group===st.g)});
7591
+ [].forEach.call(d.querySelectorAll('[data-mx-cat]'),function(x){x.classList.toggle('on',x.dataset.mxCat===st.cat&&x.dataset.mxSev===st.sev)});
7592
+ }
7593
+ [].forEach.call(d.querySelectorAll('[data-filter-sev]'),function(x){x.onclick=function(){st.sev=x.dataset.filterSev||null;st.cat=null;render()}});
7594
+ [].forEach.call(d.querySelectorAll('[data-group]'),function(x){x.onclick=function(){st.g=x.dataset.group;render()}});
7595
+ [].forEach.call(d.querySelectorAll('[data-mx-cat]'),function(x){x.onclick=function(){var same=st.cat===x.dataset.mxCat&&st.sev===x.dataset.mxSev;st.cat=same?null:x.dataset.mxCat;st.sev=same?null:x.dataset.mxSev;render()}});
7596
+ var q=d.getElementById('q');if(q)q.oninput=function(){st.q=q.value.toLowerCase();render()};
7597
+ var clear=d.getElementById('clear');if(clear)clear.onclick=function(){st.sev=null;st.cat=null;st.q='';if(q)q.value='';render()};
7598
+ function copy(text,btn){
7599
+ function done(){var t=btn.textContent;btn.textContent='copied';setTimeout(function(){btn.textContent=t},1200)}
7600
+ if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(done,fallback)}else fallback();
7601
+ function fallback(){var a=d.createElement('textarea');a.value=text;d.body.appendChild(a);a.select();try{d.execCommand('copy');done()}catch(e){}d.body.removeChild(a)}
7602
+ }
7603
+ [].forEach.call(d.querySelectorAll('[data-copy]'),function(x){x.onclick=function(e){e.preventDefault();var t=(data.prompts||{})[x.dataset.copy];if(t)copy(t,x)}});
7604
+ var store=null;try{store=window.localStorage}catch(e){}
7605
+ var prefix='canship:'+(data.key||'')+':';
7606
+ [].forEach.call(d.querySelectorAll('.manual input[type=checkbox]'),function(c){
7607
+ try{c.checked=!!store&&store.getItem(prefix+c.dataset.step)==='1'}catch(e){}
7608
+ c.closest('li').classList.toggle('done',c.checked);
7609
+ c.onchange=function(){c.closest('li').classList.toggle('done',c.checked);try{if(store){if(c.checked)store.setItem(prefix+c.dataset.step,'1');else store.removeItem(prefix+c.dataset.step)}}catch(e){}};
7610
+ });
7611
+ var print=d.getElementById('print');if(print)print.onclick=function(){window.print()};
7612
+ window.addEventListener('beforeprint',function(){rows.forEach(function(r){r.open=true})});
7613
+ render();
7614
+ })();`;
7615
+ var SCRIPT_HASH = createHash3("sha256").update(SCRIPT, "utf8").digest("base64");
7616
+ var STYLE = `
7617
+ :root{color-scheme:light dark;
7618
+ --paper:#fcfcfa;--ink:#1b1b18;--ink-2:#5c5b56;--ink-3:#8f8d86;--rule:#dddbd3;--rule-2:#bdbab0;--hover:#f3f2ed;
7619
+ --p0:#b3261e;--p1:#946200;--p2:#5c5b56;--ok:#2f6b2f;
7620
+ --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
7621
+ --code:ui-monospace,"Cascadia Mono","SF Mono",Menlo,Consolas,monospace}
7622
+ @media (prefers-color-scheme:dark){:root{
7623
+ --paper:#171715;--ink:#e9e7e0;--ink-2:#a8a69e;--ink-3:#77756e;--rule:#2e2d29;--rule-2:#4a4843;--hover:#1f1f1c;
7624
+ --p0:#f2877f;--p1:#e0b050;--p2:#a8a69e;--ok:#8fcf8f}}
7625
+ *{box-sizing:border-box}
7626
+ body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 var(--serif);font-variant-numeric:lining-nums tabular-nums}
7627
+ button,input{font:inherit;color:inherit;background:none;border:0;padding:0}
7628
+ a{color:inherit}
7629
+ .page{max-width:1040px;margin:0 auto;padding:28px 32px 80px}
7630
+ .P0{color:var(--p0)}.P1{color:var(--p1)}.P2{color:var(--p2)}
7631
+ .faint{color:var(--ink-3)}
7632
+ .mast{display:flex;justify-content:space-between;align-items:baseline;gap:16px;border-bottom:1px solid var(--ink);padding-bottom:8px;font-size:13.5px}
7633
+ .mast .root{overflow-wrap:anywhere}
7634
+ .mast .right{white-space:nowrap}
7635
+ .link{cursor:pointer;text-decoration:underline;text-decoration-color:var(--rule-2);text-underline-offset:3px;margin-left:16px;color:var(--ink-2)}
7636
+ .js-only{display:none}.js .js-only{display:inline}
7637
+ .verdict-block{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:40px;padding:36px 0 30px;border-bottom:1px solid var(--rule)}
7638
+ h1.verdict{font-weight:400;font-size:44px;line-height:1.08;margin:0 0 12px;letter-spacing:-.01em}
7639
+ h1.verdict .n{color:var(--p0)}
7640
+ h1.verdict.warn .n{color:var(--p1)}
7641
+ h1.verdict.clean{color:var(--ok)}
7642
+ .verdict-block p{margin:0 0 8px;max-width:58ch;color:var(--ink-2)}
7643
+ .facts{font-size:13.5px;display:grid;grid-template-columns:auto auto;gap:4px 18px;align-self:end;color:var(--ink-2);margin:0}
7644
+ .facts dt{color:var(--ink-3)}.facts dd{margin:0;text-align:right;color:var(--ink)}
7645
+ .notice{color:var(--ink-3);font-size:13.5px;max-width:78ch;margin:14px 0 0}
7646
+ h2{font-weight:400;font-size:22px;margin:0 0 12px}
7647
+ .section{padding:28px 0;border-bottom:1px solid var(--rule)}
7648
+ .section.last{border-bottom:0}
7649
+ .two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:48px}
7650
+ table.matrix{border-collapse:collapse;width:100%}
7651
+ .matrix th,.matrix td{padding:7px 0;border-bottom:1px solid var(--rule);font-weight:400;text-align:right}
7652
+ .matrix th{font-size:13.5px;color:var(--ink-3)}
7653
+ .matrix th:first-child,.matrix td:first-child{text-align:left}
7654
+ .matrix td button{min-width:28px;text-align:right;cursor:pointer;border-bottom:1px solid transparent}
7655
+ .matrix td button:hover{border-bottom-color:currentColor}
7656
+ .matrix td button.on{border-bottom:2px solid currentColor}
7657
+ .matrix .zero{color:var(--rule-2)}
7658
+ .matrix tfoot td{border-bottom:0;color:var(--ink-2)}
7659
+ ol.manual{margin:0;padding:0;list-style:none}
7660
+ ol.manual li{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:start;gap:8px;padding:6px 0;border-bottom:1px solid var(--rule);font-size:15px}
7661
+ ol.manual .num{color:var(--ink-3)}
7662
+ .js ol.manual .num{display:none}
7663
+ ol.manual input{display:none;margin:5px 0 0;accent-color:var(--ink)}
7664
+ .js ol.manual input{display:block}
7665
+ ol.manual li.done .t{color:var(--ink-3);text-decoration:line-through}
7666
+ ol.manual .src{font-size:13.5px;color:var(--ink-3);overflow-wrap:anywhere}
7667
+ .controls{display:none;flex-wrap:wrap;align-items:baseline;gap:6px 22px;margin:4px 0 10px;font-size:14px;color:var(--ink-2)}
7668
+ .js .controls{display:flex}
7669
+ .controls .lbl{color:var(--ink-3);margin-right:6px}
7670
+ .controls button{cursor:pointer;margin-right:10px;color:var(--ink-2)}
7671
+ .controls button.on{color:var(--ink);text-decoration:underline;text-underline-offset:4px;text-decoration-thickness:1.5px}
7672
+ .controls input{border-bottom:1px solid var(--rule-2);width:200px;padding:2px 0;outline:0}
7673
+ .controls input:focus{border-bottom-color:var(--ink)}
7674
+ .controls .clear{margin-left:auto}
7675
+ #count{color:var(--ink-3);font-size:13.5px;margin:0 0 4px}
7676
+ .head{display:grid;grid-template-columns:44px minmax(0,1fr) 130px;font-size:13.5px;color:var(--ink-3);padding:6px 0;border-bottom:1px solid var(--ink)}
7677
+ .group{padding:22px 0 6px;font-size:13.5px;color:var(--ink-3)}
7678
+ .group .gk{color:var(--ink);font-size:15.5px;overflow-wrap:anywhere}
7679
+ details.f{border-bottom:1px solid var(--rule)}
7680
+ summary.row{display:grid;grid-template-columns:44px minmax(0,1fr) 130px;align-items:start;padding:10px 0;cursor:pointer;list-style:none}
7681
+ summary.row::-webkit-details-marker{display:none}
7682
+ summary.row:hover{background:var(--hover)}
7683
+ summary.row:focus-visible{outline:2px solid var(--ink-2);outline-offset:2px}
7684
+ .sev{font-weight:600;font-size:14px;padding-left:4px}
7685
+ .main{min-width:0}
7686
+ .title{overflow-wrap:anywhere}
7687
+ .likely{font-size:13.5px;color:var(--ink-3);margin-left:8px}
7688
+ .loc{display:block;font-size:13.5px;color:var(--ink-2);overflow-wrap:anywhere}
7689
+ .loc-line{display:none}
7690
+ .by-file .loc-line{display:inline}.by-file .loc-full{display:none}
7691
+ .cat{font-size:13.5px;color:var(--ink-3)}
7692
+ .body{margin:0 0 22px 44px;max-width:740px}
7693
+ .body p{margin:0 0 12px;color:var(--ink-2)}
7694
+ .body .rule{font-size:13.5px;color:var(--ink-3)}
7695
+ .body h3{font-weight:400;font-style:italic;font-size:14px;color:var(--ink-3);margin:18px 0 6px}
7696
+ pre.excerpt{margin:0 0 14px;padding:8px 10px;background:var(--hover);overflow-x:auto}
7697
+ code{font-family:var(--code);font-size:12.5px}
7698
+ ol.trace{margin:0;padding:0;list-style:none;border-left:1px solid var(--rule-2)}
7699
+ ol.trace li{padding:3px 0 3px 14px;position:relative;font-size:14px}
7700
+ ol.trace li::before{content:"";position:absolute;left:-4px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--paper);border:1px solid var(--rule-2)}
7701
+ ol.trace li:first-child::before{background:var(--p0);border-color:var(--p0)}
7702
+ ol.trace .k{color:var(--ink-3);display:inline-block;width:120px}
7703
+ ol.trace .v{overflow-wrap:anywhere}
7704
+ ol.trace .note{display:block;color:var(--ink-3);font-size:13.5px;margin-left:120px}
7705
+ ol.steps{margin:0;padding-left:20px}
7706
+ ol.steps li{margin-bottom:6px}
7707
+ .hand{margin-top:14px;padding-left:12px;border-left:2px solid var(--p1);font-size:15px}
7708
+ .hand ul{margin:4px 0 0;padding-left:18px}
7709
+ .actions{margin:14px 0 0;font-size:14px}
7710
+ .actions .link{margin-left:0}
7711
+ .incomplete{border-left:2px solid var(--p1);padding-left:14px}
7712
+ .incomplete ul{margin:0 0 8px;padding-left:18px}
7713
+ .notes p{margin:0 0 6px;color:var(--ink-2);font-size:14px}
7714
+ .checked ul{margin:0 0 12px;padding-left:18px;color:var(--ink-2)}
7715
+ .colophon{margin-top:36px;font-size:13.5px;color:var(--ink-3);max-width:78ch}
7716
+ @media (max-width:820px){.verdict-block,.two{grid-template-columns:1fr;gap:24px}h1.verdict{font-size:34px}
7717
+ summary.row,.head{grid-template-columns:40px minmax(0,1fr)}.cat,.head .c{display:none}.body{margin-left:40px}}
7718
+ @media print{.controls,.link,#count{display:none!important}body{background:#fff;color:#000}.page{max-width:none;padding:0}}
7719
+ `;
5971
7720
  function renderHtml(result, opts) {
5972
7721
  const { findings } = result;
5973
7722
  const hiddenLikely = opts.hiddenLikely ?? 0;
5974
7723
  const baselineSuppressed = opts.baselineSuppressed ?? 0;
5975
7724
  const baselineStale = opts.baselineStale ?? 0;
5976
- const { blocking, minor, unsure } = verdictOf(findings);
5977
- const certain = result.changeView?.totalBlocking ?? blocking;
5978
- const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
5979
- // 未扫描文件时不显示通过结论。
5980
- `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
5981
- ) : result.partial ? (
5982
- // 扫描不完整时不得显示正常通过。
5983
- hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings &mdash; but not everything was checked</div>`
5984
- ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? `<div class="verdict warn">No visible findings in changed files &mdash; other findings still exist</div>` : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
5985
- // 基线抑制结果时明确说明,避免误报为项目无问题。
5986
- baselineSuppressed > 0 ? `<div class="verdict warn">No new findings &mdash; ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline</div>` : `<div class="verdict clean">No exposed credentials found</div>`
5987
- ) : certain > 0 ? `<div class="verdict bad">${certain} critical ${plural(certain, "issue")} &mdash; do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix &mdash; nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
5988
- const body = findings.length === 0 ? result.filesScanned === 0 ? (
5989
- // 零文件扫描不能显示已完成的检查清单。
5990
- `<div class="clean-note">
5991
- <p>canship found no files it could read at this path, so none of its checks ran.
5992
- <strong>This is not a clean result &mdash; it is an empty one.</strong></p>
5993
- <p>Most likely this is not the directory you meant to scan, or everything in it is
5994
- gitignored or build output that canship skips. If the project lives in a
5995
- subdirectory, point canship at it: <code>npx canship ./app</code>.</p>
5996
- </div>`
5997
- ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? '<div class="clean-note">This view hides existing findings. Re-run without --changed-since for the full report.</div>' : hiddenLikely > 0 ? `<div class="clean-note">
5998
- <p><strong>This is not a finding-free result.</strong> The default report hides
5999
- ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
6000
- <p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
6001
- </div>` : baselineSuppressed > 0 ? `<div class="clean-note">
6002
- <p><strong>This is not a finding-free result.</strong> A baseline is hiding
6003
- ${baselineSuppressed} ${plural(baselineSuppressed, "finding")}${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}.</p>
6004
- <p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
6005
- </div>` : `<div class="clean-note">
6006
- <p>canship checked for hardcoded API keys, server secrets exposed to the browser,
6007
- Supabase tables without Row Level Security or with policies open to everyone, open Firebase rules, API routes and server actions that reach
6008
- the database with no sign-in check, CORS that lets other sites use your visitors&rsquo;
6009
- sessions, and <code>.env</code> files committed to git.</p>
6010
- <p><strong>A clean result means those checks passed &mdash; not that your app is secure.</strong>
6011
- Rate limiting and injection are not covered, and neither is whether the authorisation
6012
- checks it did find are the right ones.</p>
6013
- </div>` : findings.map((f, i) => renderFinding2(f, i + 1)).join("\n");
6014
- const optedOut = result.ignored.length > 0 ? `<p class="opted-out">${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by <code>canship-ignore-file</code>: ${result.ignored.map((f) => `<code>${esc(f)}</code>`).join(", ")}</p>` : "";
7725
+ const { blocking: visibleBlocking, minor, unsure } = verdictOf(findings);
7726
+ const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
7727
+ const steps = manualSteps(findings);
7728
+ const rotations = steps.filter((step) => /^Rotate\b/.test(step.text)).length;
7729
+ let verdict;
7730
+ let explanation = "";
7731
+ if (findings.length > 0) {
7732
+ if (blocking > 0) {
7733
+ verdict = `<h1 class="verdict bad"><span class="n">${blocking} blocking ${plural(blocking, "finding")}.</span><br>Do not deploy yet.</h1>`;
7734
+ explanation = "Every certain P0 or P1 result below needs a code change." + (rotations > 0 ? ` ${rotations} exposed ${plural(rotations, "credential")} must also be rotated in ${rotations === 1 ? "its provider" : "their providers"}; a code change does not revoke ${rotations === 1 ? "it" : "them"}.` : "");
7735
+ } else if (minor > 0) {
7736
+ verdict = `<h1 class="verdict warn"><span class="n">${minor} ${plural(minor, "finding")} to fix.</span><br>Nothing blocking.</h1>`;
7737
+ explanation = "No certain P0 or P1 result was found; the findings below are lower severity.";
7738
+ } else {
7739
+ verdict = `<h1 class="verdict warn"><span class="n">${unsure} ${plural(unsure, "finding")} to review.</span></h1>`;
7740
+ explanation = "These are likely findings: the static evidence is not conclusive, so check each one.";
7741
+ }
7742
+ } else if (result.filesScanned === 0) {
7743
+ verdict = '<h1 class="verdict warn">No files were scanned.<br>Nothing was checked.</h1>';
7744
+ explanation = "canship found no files it could read at this path, so none of its checks ran. This is not a clean result \u2014 it is an empty one.";
7745
+ } else if (result.partial) {
7746
+ verdict = hiddenLikely > 0 ? `<h1 class="verdict warn">No certain findings \u2014 ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked.</h1>` : '<h1 class="verdict warn">No findings \u2014 but not everything was checked.</h1>';
7747
+ } else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
7748
+ verdict = '<h1 class="verdict warn">No visible findings in changed files \u2014 other findings still exist.</h1>';
7749
+ explanation = "This view hides existing findings. Re-run without --changed-since for the full report.";
7750
+ } else if (hiddenLikely > 0) {
7751
+ verdict = `<h1 class="verdict warn">No certain findings \u2014 ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden.</h1>`;
7752
+ explanation = `This is not a finding-free result. Re-run with --all --report to include ${hiddenLikely === 1 ? "it" : "them"} in the report.`;
7753
+ } else if (baselineSuppressed > 0) {
7754
+ verdict = `<h1 class="verdict warn">No new findings \u2014 ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline.</h1>`;
7755
+ explanation = `This is not a finding-free result. Those problems still exist. Re-run without --baseline to see ${baselineSuppressed === 1 ? "it" : "them"}.`;
7756
+ } else {
7757
+ verdict = '<h1 class="verdict clean">No exposed credentials found.</h1>';
7758
+ }
7759
+ const facts = [
7760
+ ["findings", String(findings.length)],
7761
+ ...hiddenLikely > 0 ? [["likely hidden", String(hiddenLikely)]] : [],
7762
+ ["files scanned", String(result.filesScanned)],
7763
+ ["coverage", result.partial ? "incomplete" : "complete"],
7764
+ ["duration", `${result.durationMs} ms`]
7765
+ ].map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("");
7766
+ const rows = categoryCounts(findings);
7767
+ const totals = SEVERITIES.map((s) => findings.filter((f) => f.severity === s).length);
7768
+ const matrix = `<table class="matrix"><thead><tr><th></th>${SEVERITIES.map((s) => `<th>${s}</th>`).join("")}<th>total</th></tr></thead><tbody>${rows.map((row) => `<tr><td>${row.category}</td>${SEVERITIES.map((s) => `<td>${row.counts[s] ? `<button type="button" class="${s}" data-mx-cat="${row.category}" data-mx-sev="${s}">${row.counts[s]}</button>` : '<span class="zero">\u2013</span>'}</td>`).join("")}<td>${row.total}</td></tr>`).join("")}</tbody><tfoot><tr><td>all</td>${totals.map((n) => `<td>${n}</td>`).join("")}<td>${findings.length}</td></tr></tfoot></table>`;
7769
+ const manual = steps.length > 0 ? `<ol class="manual">${steps.map((step, i) => `<li><span class="num">${i + 1}</span><input type="checkbox" data-step="${i}" aria-label="done"><div><div class="t">${linkify(esc(step.text))}</div><div class="src">${esc(step.locations.join(", "))}</div></div></li>`).join("")}</ol>` : '<p class="faint">No manual steps for these findings.</p>';
7770
+ const groups = groupByFile(findings);
7771
+ let index = 0;
7772
+ const prompts = {};
7773
+ const allPrompt = renderFixPrompt(findings);
7774
+ if (allPrompt) prompts["all"] = allPrompt;
7775
+ const list = groups.map((group) => {
7776
+ const items = group.findings.map((f) => {
7777
+ index++;
7778
+ const prompt = renderFixPrompt([f]);
7779
+ if (prompt) prompts[String(index)] = prompt;
7780
+ return renderFinding2(f, index);
7781
+ }).join("\n");
7782
+ return `<div class="group"><span class="gk">${esc(group.file ?? "repository")}</span> \u2014 ${group.findings.length}</div><div class="ledger">${items}</div>`;
7783
+ }).join("\n");
7784
+ const findingsSection = findings.length > 0 ? `
7785
+ <section class="section two">
7786
+ <div><h2>By category</h2>${matrix}</div>
7787
+ <div><h2>Manual steps</h2>${manual}</div>
7788
+ </section>
7789
+ <section class="section last">
7790
+ <h2>Findings</h2>
7791
+ <div class="controls">
7792
+ <span><span class="lbl">severity</span><button type="button" data-filter-sev="">all</button>${SEVERITIES.map((s) => `<button type="button" data-filter-sev="${s}">${s}</button>`).join("")}</span>
7793
+ <span><span class="lbl">group by</span><button type="button" data-group="file">file</button><button type="button" data-group="sev">severity</button><button type="button" data-group="cat">category</button></span>
7794
+ <input id="q" type="search" placeholder="filter by text or path" aria-label="Filter findings">
7795
+ <button type="button" class="clear" id="clear">clear filters</button>
7796
+ </div>
7797
+ <p id="count"></p>
7798
+ <div class="head"><span>sev</span><span>finding</span><span class="c">category</span></div>
7799
+ <div id="list" class="by-file">${list}</div>
7800
+ <p id="empty" class="faint" hidden>No findings match these filters.</p>
7801
+ </section>` : `
7802
+ <section class="section last checked">
7803
+ <h2>Checked for</h2>
7804
+ <ul>
7805
+ <li>API keys hardcoded in source code</li>
7806
+ <li>Server-side secrets exposed to the browser via public env prefixes</li>
7807
+ <li>Supabase service_role keys reachable from the client</li>
7808
+ <li><code>.env</code> files committed to git, including in history</li>
7809
+ <li>Supabase tables with no Row Level Security, or policies open to everyone</li>
7810
+ <li>Firebase rules left open to anyone</li>
7811
+ <li>API routes and server actions that query your database with no sign-in check</li>
7812
+ <li>CORS that lets other sites act as your signed-in visitors</li>
7813
+ <li>Request input in SQL, commands, outbound URLs, and redirects within supported handlers</li>
7814
+ <li>Unverified Supabase sessions and Stripe webhook events</li>
7815
+ </ul>
7816
+ <p><strong>No findings means no matches in the selected scope \u2014 not that your app is secure.</strong> Checks are bounded and syntax-based. Request-input analysis stays within supported handlers; business authorisation, rate limiting and dependency vulnerabilities are not verified. Rule selection and exclusions are disclosed below.</p>
7817
+ </section>`;
7818
+ const incomplete = result.partial ? `
7819
+ <section class="section incomplete">
7820
+ <h2>Not everything was checked</h2>
7821
+ <ul>
7822
+ ${result.filesScanned === 0 ? "<li>no files could be read at this path, so every file-based check was skipped</li>" : ""}
7823
+ ${result.errors.map((e) => `<li>the <code>${esc(e.ruleId)}</code> check ${e.kind === "incomplete" ? "did not finish" : "failed"}${e.file ? ` on <code>${esc(e.file)}</code>` : ""} \u2014 ${esc(e.message)}</li>`).join("\n")}
7824
+ ${result.skipped.map((s) => `<li><code>${esc(s.path)}</code> \u2014 ${esc(skipPhrase(s.reason))}${s.detail ? ` (${esc(s.detail)})` : ""}</li>`).join("\n")}
7825
+ </ul>
7826
+ <p>Anything could be in what was skipped. Re-run once it is readable.</p>
7827
+ </section>` : "";
6015
7828
  const selection = result.ruleSelection;
6016
- const ruleSelection = selection === null ? "" : `<p class="opted-out">Rule selection in force: ${selection.only.length > 0 ? `only <code>${selection.only.map(esc).join("</code>, <code>")}</code>` : `everything except <code>${selection.skip.map(esc).join("</code>, <code>")}</code>`}${selection.removed > 0 ? `, hiding ${selection.removed} ${plural(selection.removed, "finding")}` : ""}.</p>`;
6017
- const silenced = result.ignoredFindings.length > 0 ? `<p class="opted-out">${result.ignoredFindings.length} ${plural(result.ignoredFindings.length, "finding")} silenced by <code>canship-ignore-next-line</code>: ${result.ignoredFindings.map((f) => `<code>${esc(f.file)}:${f.line}</code> (${esc(f.ruleId)})`).join(", ")}</p>` : "";
6018
- const hiddenNotice = hiddenLikely > 0 && findings.length > 0 ? `<p class="opted-out">${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden. Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"}.</p>` : "";
6019
- const baselineNotice = baselineSuppressed > 0 && findings.length > 0 ? `<p class="opted-out">${baselineSuppressed} ${plural(baselineSuppressed, "finding")} hidden by the baseline${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}. Those problems still exist.</p>` : "";
6020
- const staleNotice = baselineStale > 0 ? `<p class="opted-out">${baselineStale} baseline ${baselineStale === 1 ? "entry" : "entries"} no longer ${baselineStale === 1 ? "matches" : "match"} anything &mdash; re-run <code>--baseline-write</code> to prune.</p>` : "";
6021
- const incomplete = result.partial ? `<div class="incomplete">
6022
- <h2>Not everything was checked</h2>
6023
- <ul>
6024
- ${// 工作区零文件时仍可能产生 Git 历史结果。
6025
- result.filesScanned === 0 ? `<li>no files could be read at this path, so every file-based check was skipped</li>` : ""}
6026
- ${result.errors.map(
6027
- (e) => `<li>the <code>${esc(e.ruleId)}</code> check ${e.kind === "incomplete" ? "did not finish" : "failed"}${e.file ? ` on <code>${esc(e.file)}</code>` : ""} &mdash; ${esc(e.message)}</li>`
6028
- ).join("\n ")}
6029
- ${result.skipped.map((s) => `<li><code>${esc(s.path)}</code> &mdash; ${esc(skipPhrase(s.reason))}${s.detail ? ` (${esc(s.detail)})` : ""}</li>`).join("\n ")}
6030
- </ul>
6031
- <p>Anything could be in what was skipped. Re-run once it is readable.</p>
6032
- </div>` : "";
7829
+ const notes = [
7830
+ hiddenLikely > 0 && findings.length > 0 ? `${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden. Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"}.` : "",
7831
+ baselineSuppressed > 0 ? `${baselineSuppressed} ${plural(baselineSuppressed, "finding")} hidden by the baseline${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}. Those problems still exist.` : "",
7832
+ baselineStale > 0 ? `${baselineStale} baseline ${baselineStale === 1 ? "entry" : "entries"} no longer ${baselineStale === 1 ? "matches" : "match"} anything \u2014 re-run <code>--baseline-write</code> to prune.` : "",
7833
+ result.ignoredFindings.length > 0 ? `${result.ignoredFindings.length} ${plural(result.ignoredFindings.length, "finding")} silenced by <code>canship-ignore-next-line</code>: ${result.ignoredFindings.map((f) => `<code>${esc(f.file)}:${f.line}</code> (${esc(f.ruleId)})`).join(", ")}` : "",
7834
+ selection === null ? "" : `Rule selection in force: ${selection.only.length > 0 ? `only <code>${selection.only.map(esc).join("</code>, <code>")}</code>` : `everything except <code>${selection.skip.map(esc).join("</code>, <code>")}</code>`}${selection.removed > 0 ? `, hiding ${selection.removed} ${plural(selection.removed, "finding")}` : ""}.`,
7835
+ result.ignored.length > 0 ? `${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by <code>canship-ignore-file</code>: ${result.ignored.map((f) => `<code>${esc(f)}</code>`).join(", ")}` : "",
7836
+ result.vendored > 0 ? `${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories.` : ""
7837
+ ].filter(Boolean);
7838
+ const data = {
7839
+ key: createHash3("sha256").update(`${opts.root}\0${opts.generatedAt}`).digest("hex").slice(0, 16),
7840
+ categories: CATEGORIES,
7841
+ prompts
7842
+ };
6033
7843
  return `<!doctype html>
6034
7844
  <html lang="en">
6035
7845
  <head>
6036
7846
  <meta charset="utf-8">
6037
7847
  <meta name="viewport" content="width=device-width, initial-scale=1">
7848
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-${SCRIPT_HASH}'; base-uri 'none'; form-action 'none'">
7849
+ <meta name="referrer" content="no-referrer">
6038
7850
  <title>canship report</title>
6039
- <style>
6040
- :root {
6041
- --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #e3e3e3;
6042
- --card: #fafafa; --bad: #c0392b; --warn: #b8860b; --good: #1e7e34;
6043
- --code-bg: #f4f4f4; --human-bg: #fff8e6; --human-line: #e6c35c;
6044
- }
6045
- @media (prefers-color-scheme: dark) {
6046
- :root {
6047
- --bg: #16181c; --fg: #e6e6e6; --muted: #9aa0a6; --line: #2c3036;
6048
- --card: #1c1f24; --bad: #ff6b5e; --warn: #e8b339; --good: #4ade80;
6049
- --code-bg: #22262c; --human-bg: #2a2418; --human-line: #6b5a2a;
6050
- }
6051
- }
6052
- * { box-sizing: border-box; }
6053
- body {
6054
- margin: 0; padding: 2rem 1rem 4rem; background: var(--bg); color: var(--fg);
6055
- font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
6056
- }
6057
- main { max-width: 46rem; margin: 0 auto; }
6058
- h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
6059
- .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1.5rem; word-break: break-all; }
6060
- .verdict { font-weight: 600; padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1.5rem; }
6061
- .verdict.bad { background: var(--bad); color: #fff; }
6062
- .verdict.warn { background: var(--warn); color: #000; }
6063
- .verdict.clean { background: var(--good); color: #fff; }
6064
- .notice {
6065
- border: 1px solid var(--line); border-left: 3px solid var(--muted);
6066
- padding: .75rem 1rem; margin-bottom: 2rem; font-size: .85rem; color: var(--muted);
6067
- }
6068
- .finding {
6069
- border: 1px solid var(--line); border-radius: 6px; background: var(--card);
6070
- padding: 1.25rem; margin-bottom: 1.25rem;
6071
- }
6072
- .finding.certain { border-left: 3px solid var(--bad); }
6073
- .finding.likely { border-left: 3px solid var(--warn); }
6074
- .finding header { display: flex; gap: .6rem; align-items: baseline; }
6075
- .num { color: var(--muted); font-variant-numeric: tabular-nums; font-size: .9rem; }
6076
- .finding h3 { font-size: 1.05rem; margin: 0 0 .35rem; }
6077
- .loc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; color: var(--muted); margin-bottom: .75rem; word-break: break-all; }
6078
- .tag { background: var(--warn); color: #000; padding: 0 .35rem; border-radius: 3px; font-size: .72rem; }
6079
- pre {
6080
- background: var(--code-bg); padding: .7rem .9rem; border-radius: 4px;
6081
- overflow-x: auto; font-size: .82rem; margin: 0 0 .9rem;
6082
- }
6083
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
6084
- .why p { margin: 0 0 .7rem; }
6085
- h4 { font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 1.1rem 0 .4rem; }
6086
- ol, ul { margin: 0; padding-left: 1.3rem; }
6087
- li { margin-bottom: .45rem; }
6088
- .human {
6089
- background: var(--human-bg); border-left: 3px solid var(--human-line);
6090
- padding: .1rem 1rem .8rem; margin-top: 1rem; border-radius: 0 4px 4px 0;
6091
- }
6092
- .human h4 { color: var(--fg); }
6093
- .opted-out { color: var(--muted); font-size: .85rem; margin-top: 1.5rem; }
6094
- .clean-note { border: 1px solid var(--line); border-radius: 6px; padding: 1.25rem; }
6095
- .incomplete { border: 1px solid var(--warn); border-radius: 6px; padding: 1rem 1.25rem; margin-top: 1.5rem; }
6096
- .incomplete h2 { font-size: 1rem; margin: 0 0 .5rem; }
6097
- .incomplete ul { margin: 0 0 .75rem; padding-left: 1.25rem; }
6098
- .incomplete li { margin-bottom: .25rem; }
6099
- .incomplete p:last-child { margin-bottom: 0; }
6100
- .clean-note p:last-child { margin-bottom: 0; }
6101
- footer { margin-top: 2.5rem; padding-top: 1.25rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .82rem; }
6102
- a { color: inherit; }
6103
- </style>
7851
+ <style>${STYLE}</style>
6104
7852
  </head>
6105
7853
  <body>
6106
- <main>
6107
- <h1>canship report</h1>
6108
- <div class="meta">${esc(opts.root)}<br>${esc(opts.generatedAt)} &middot; ${result.filesScanned} ${plural(result.filesScanned, "file")} scanned in ${result.durationMs}ms</div>
6109
- ${verdict}
6110
- ${result.changeView ? `<p class="opted-out">${esc(changeViewNotice(result.changeView))}</p>` : ""}
6111
- <div class="notice">
6112
- Credential values canship recognises are masked in this report. One in a format it has no
6113
- pattern for can still appear inside a quoted line, and this report lists your
6114
- file paths and project structure either way &mdash; so treat it as internal,
6115
- shareable with your team rather than something to post publicly.
6116
- </div>
6117
- ${body}
6118
- ${incomplete}
6119
- ${hiddenNotice}
6120
- ${baselineNotice}
6121
- ${staleNotice}
6122
- ${silenced}
6123
- ${ruleSelection}
6124
- ${optedOut}
6125
- <footer>
6126
- Generated by canship. Everything ran locally; nothing was uploaded.
6127
- </footer>
6128
- </main>
7854
+ <div class="page">
7855
+ <header class="mast"><span class="root"><b>canship</b>${opts.version ? ` ${esc(opts.version)}` : ""} \xB7 ${esc(opts.root)}</span><span class="right">${esc(displayTime(opts.generatedAt))}${allPrompt ? '<button type="button" class="link js-only" data-copy="all">copy fix prompt</button>' : ""}<button type="button" class="link js-only" id="print">print</button></span></header>
7856
+ <section class="verdict-block">
7857
+ <div>${verdict}${explanation ? `<p>${esc(explanation)}</p>` : ""}${result.changeView ? `<p>${esc(changeViewNotice(result.changeView))}</p>` : ""}
7858
+ <p class="notice">Credential values canship recognises are masked in this report. One in a format it has no pattern for can still appear inside a quoted line, and this report lists your file paths and project structure either way \u2014 so treat it as internal, shareable with your team rather than something to post publicly.</p></div>
7859
+ <dl class="facts">${facts}</dl>
7860
+ </section>
7861
+ ${findingsSection}
7862
+ ${incomplete}
7863
+ ${notes.length > 0 ? `<section class="section last notes">${notes.map((n) => `<p>${n}</p>`).join("")}</section>` : ""}
7864
+ <p class="colophon">Generated by canship. Everything ran locally; nothing was uploaded. Findings describe static evidence in this repository; they do not verify deployed configuration, credential validity or business authorisation.</p>
7865
+ </div>
7866
+ <script type="application/json" id="canship-data">${jsonForScript(data)}</script>
7867
+ <script>${SCRIPT}</script>
6129
7868
  </body>
6130
7869
  </html>
6131
7870
  `;
6132
7871
  }
6133
7872
 
6134
7873
  // src/baseline.ts
6135
- import { createHash as createHash3 } from "crypto";
7874
+ import { createHash as createHash4 } from "crypto";
6136
7875
  import { readFileSync as readFileSync3, statSync as statSync3, writeFileSync } from "fs";
6137
7876
  var BASELINE_VERSION = 2;
6138
7877
  var DEFAULT_BASELINE_PATH = "canship-baseline.json";
6139
7878
  function fingerprintOf(f) {
6140
7879
  const identity = [f.ruleId, f.file ?? "", f.title, f.sourceFingerprint ?? f.excerpt ?? ""].join("\0");
6141
- return createHash3("sha256").update(identity, "utf8").digest("hex");
7880
+ return createHash4("sha256").update(identity, "utf8").digest("hex");
6142
7881
  }
6143
7882
  function buildBaseline(findings, now = /* @__PURE__ */ new Date()) {
6144
7883
  const byFingerprint = /* @__PURE__ */ new Map();
@@ -6519,6 +8258,14 @@ var RULE_CATALOG = [
6519
8258
  "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
6520
8259
  "Session-scoped clients and indirect guards may need manual review."
6521
8260
  ),
8261
+ rule(
8262
+ "auth/unverified-session",
8263
+ "Server code trusting supabase.auth.getSession()",
8264
+ "P1",
8265
+ "varies",
8266
+ "Server routes, Server Functions, middleware and proxy modules, Next.js server components, *.server modules, and server directories that call supabase.auth.getSession(): a result used in a condition is certain; reading its user is reported for review. Exemption requires a checked, awaited identity from the same client before trusted session use.",
8267
+ "Only the receiving variable in the same function is followed; results passed to helpers are not tracked."
8268
+ ),
6522
8269
  rule(
6523
8270
  "cors/reflected-origin-with-credentials",
6524
8271
  "Reflected origin with credentials",
@@ -6591,6 +8338,30 @@ var RULE_CATALOG = [
6591
8338
  "Tracked environment files; templates and public-only values are treated separately.",
6592
8339
  "Unrecognized private values are heuristic findings, not verified credentials."
6593
8340
  ),
8341
+ rule(
8342
+ "injection/command",
8343
+ "Shell command built from request input",
8344
+ "P1",
8345
+ "varies",
8346
+ "exec, execSync, and spawn/execFile with shell: true in supported server handlers. Execa command strings are checked for a caller-chosen executable, or shell interpolation when shell is enabled. Direct request input is certain; values passing through other calls or content checks require review.",
8347
+ "Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked."
8348
+ ),
8349
+ rule(
8350
+ "injection/sql",
8351
+ "SQL query built from request input",
8352
+ "P1",
8353
+ "varies",
8354
+ "Untagged template or string concatenation in Prisma $queryRawUnsafe/$executeRawUnsafe, Prisma.raw, sql.raw/sql.unsafe, knex *Raw methods, driver query/execute calls, and query-builder where clauses, in server routes and Next.js Server Functions. Tagged templates and placeholders are not reported.",
8355
+ "Only flows inside one function are followed; values passed to helpers, other files, or validated elsewhere are not tracked. Driver calls are reported only when the text looks like SQL."
8356
+ ),
8357
+ rule(
8358
+ "redirect/open",
8359
+ "Redirect to an address taken from the request",
8360
+ "P2",
8361
+ "varies",
8362
+ "redirect, permanentRedirect, NextResponse.redirect, Response.redirect, res.redirect, and sendRedirect in server routes and Next.js Server Functions, when request input decides the start of the target (a whole value, a leading /, or the host). Request input used directly is certain; values that pass through other calls or checks require review.",
8363
+ "Only flows inside one function are followed. The request's own URL and fixed paths followed by input are not reported; helper-built targets are not tracked."
8364
+ ),
6594
8365
  rule(
6595
8366
  "supabase/permissive-policy",
6596
8367
  "Row Level Security policy with an always-true condition",
@@ -6607,6 +8378,14 @@ var RULE_CATALOG = [
6607
8378
  "Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
6608
8379
  "Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
6609
8380
  ),
8381
+ rule(
8382
+ "ssrf/request-url",
8383
+ "Server request to an address the caller chooses",
8384
+ "P1",
8385
+ "likely",
8386
+ "fetch, $fetch/ofetch, axios, got, ky, needle, and Node http(s) calls in server routes and Next.js Server Functions, when request input decides the scheme or host of the address.",
8387
+ "Only flows inside one function are followed. Fetching caller-supplied URLs can be intended, and allowlists or network controls are not verified."
8388
+ ),
6610
8389
  rule(
6611
8390
  "supabase/rls-not-enabled",
6612
8391
  "Table without RLS in migrations",
@@ -6615,6 +8394,14 @@ var RULE_CATALOG = [
6615
8394
  "Supabase SQL migrations, replayed by application scope.",
6616
8395
  "Migration state is not deployed database state; grants and policy correctness are outside this check."
6617
8396
  ),
8397
+ rule(
8398
+ "webhook/unverified-signature",
8399
+ "Stripe webhook events handled without signature verification",
8400
+ "P1",
8401
+ "varies",
8402
+ "Server routes that branch on Stripe event types without an enforced constructEvent or awaited event-retrieval result for the handled value. Request-body handling is certain; custom signature checks remain likely.",
8403
+ "Only Stripe is recognized. Unused calls do not verify events; custom HMAC and shared-helper verification require review."
8404
+ ),
6618
8405
  ...SECRET_PATTERNS.map((pattern) => ({
6619
8406
  ...rule(
6620
8407
  `secrets/hardcoded/${pattern.id}`,
@@ -6683,8 +8470,94 @@ function changedFileView(result, changed) {
6683
8470
  } };
6684
8471
  }
6685
8472
 
8473
+ // src/open.ts
8474
+ import { spawn } from "child_process";
8475
+ import { accessSync as accessSync2, constants as constants2, realpathSync as realpathSync3, statSync as statSync5 } from "fs";
8476
+ import { dirname as dirname2, posix as posix2, win32 } from "path";
8477
+ var UNIX_SYSTEM_DIRS = ["/usr/bin", "/bin", "/usr/local/bin", "/usr/sbin", "/sbin", "/usr/local/sbin"];
8478
+ function pathValue(env) {
8479
+ return Object.entries(env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
8480
+ }
8481
+ function unixBoundary(env, cwd, options) {
8482
+ const canonical2 = (path) => {
8483
+ try {
8484
+ const real = (options.realpath ?? realpathSync3)(path);
8485
+ return posix2.isAbsolute(real) ? posix2.normalize(real) : null;
8486
+ } catch {
8487
+ return null;
8488
+ }
8489
+ };
8490
+ const named = [cwd, options.root ?? cwd, env.INIT_CWD, env.npm_config_local_prefix].filter((path) => !!path && posix2.isAbsolute(path)).map((path) => posix2.normalize(path));
8491
+ const resolved = named.map(canonical2);
8492
+ if (!canonical2(cwd) || !canonical2(options.root ?? cwd)) return () => false;
8493
+ const roots = [...named, ...resolved.filter((p) => p !== null)];
8494
+ const outside = (path) => !/(?:^|\/)node_modules(?:\/|$)/.test(path) && roots.every((root) => {
8495
+ const rel = posix2.relative(root, path);
8496
+ return rel === ".." || rel.startsWith("../") || posix2.isAbsolute(rel);
8497
+ });
8498
+ return (path) => {
8499
+ if (!posix2.isAbsolute(path) || path.includes("\0")) return false;
8500
+ const real = canonical2(path);
8501
+ return real !== null && outside(posix2.normalize(path)) && outside(real);
8502
+ };
8503
+ }
8504
+ function trustedUnixPath(env, cwd, options = {}) {
8505
+ const trusted = unixBoundary(env, cwd, options);
8506
+ return [...new Set([...UNIX_SYSTEM_DIRS, ...pathValue(env).split(":")].filter(trusted).map((dir) => posix2.normalize(dir).replace(/(.)\/$/, "$1")))];
8507
+ }
8508
+ function executable(path) {
8509
+ try {
8510
+ accessSync2(path, constants2.X_OK);
8511
+ return statSync5(path).isFile();
8512
+ } catch {
8513
+ return false;
8514
+ }
8515
+ }
8516
+ function openerFor(platform, path, env = process.env, options = {}) {
8517
+ if (platform === "win32") {
8518
+ const root = Object.entries(env).find(([key]) => key.toLowerCase() === "systemroot")?.[1];
8519
+ if (!root || !/^[a-z]:[\\/]/i.test(root) || root.includes("\0")) throw new Error("Windows system directory is unavailable");
8520
+ return { command: win32.join(root, "explorer.exe"), args: [path] };
8521
+ }
8522
+ if (platform === "darwin") return { command: "/usr/bin/open", args: [path] };
8523
+ const exists = options.exists ?? executable;
8524
+ const cwd = options.cwd ?? process.cwd();
8525
+ const trusted = unixBoundary(env, cwd, options);
8526
+ for (const dir of trustedUnixPath(env, cwd, options)) {
8527
+ const candidate = posix2.join(dir, "xdg-open");
8528
+ if (trusted(candidate) && exists(candidate)) return { command: candidate, args: [path] };
8529
+ }
8530
+ throw new Error("xdg-open was not found in system directories or PATH outside the project");
8531
+ }
8532
+ function openerEnvironment(platform, command, env, cwd = process.cwd(), options = {}) {
8533
+ const clean2 = { ...env };
8534
+ for (const key of Object.keys(clean2)) if (key.toLowerCase() === "path") delete clean2[key];
8535
+ clean2.PATH = platform === "win32" ? `${win32.dirname(command)};${win32.join(win32.dirname(command), "System32")}` : trustedUnixPath(env, cwd, options).join(":");
8536
+ return clean2;
8537
+ }
8538
+ function canOpen(env, interactive) {
8539
+ return interactive && !env["CI"];
8540
+ }
8541
+ function openReport(path, onError, options = {}) {
8542
+ try {
8543
+ const { command, args } = openerFor(process.platform, path, process.env, options);
8544
+ const child = spawn(command, args, {
8545
+ detached: true,
8546
+ stdio: "ignore",
8547
+ windowsHide: true,
8548
+ shell: false,
8549
+ cwd: dirname2(command),
8550
+ env: openerEnvironment(process.platform, command, process.env, options.cwd, options)
8551
+ });
8552
+ child.on("error", (err) => onError(`could not open the report with ${command}: ${err.message}`));
8553
+ child.unref();
8554
+ } catch (err) {
8555
+ onError(`could not open the report: ${err instanceof Error ? err.message : String(err)}`);
8556
+ }
8557
+ }
8558
+
6686
8559
  // src/cli.ts
6687
- var VERSION = true ? "0.5.0" : "0.0.0-dev";
8560
+ var VERSION = true ? "0.6.0" : "0.0.0-dev";
6688
8561
  function finish(code) {
6689
8562
  process.exitCode = code;
6690
8563
  }
@@ -6715,7 +8588,7 @@ function realPathOf(path) {
6715
8588
  const rest = [];
6716
8589
  for (let depth = 0; depth < MAX_REAL_PATH_DEPTH; depth++) {
6717
8590
  try {
6718
- const real = realpathSync3(at);
8591
+ const real = realpathSync4(at);
6719
8592
  return rest.length === 0 ? real : resolve2(real, ...rest);
6720
8593
  } catch {
6721
8594
  const parent = resolve2(at, "..");
@@ -6754,7 +8627,9 @@ function parseArgs(argv) {
6754
8627
  version: false,
6755
8628
  listRules: false,
6756
8629
  noExcerpts: false,
6757
- changedSince: null
8630
+ changedSince: null,
8631
+ verbose: false,
8632
+ open: false
6758
8633
  };
6759
8634
  const positional = [];
6760
8635
  for (const arg of argv) {
@@ -6833,6 +8708,12 @@ function parseArgs(argv) {
6833
8708
  case "--no-excerpts":
6834
8709
  args.noExcerpts = true;
6835
8710
  break;
8711
+ case "--verbose":
8712
+ args.verbose = true;
8713
+ break;
8714
+ case "--open":
8715
+ args.open = true;
8716
+ break;
6836
8717
  case "--help":
6837
8718
  case "-h":
6838
8719
  args.help = true;
@@ -6862,8 +8743,11 @@ var HELP = `
6862
8743
 
6863
8744
  ${bold("Options")}
6864
8745
  -a, --all Show likely findings
8746
+ --verbose Show each finding's excerpt, explanation, trace and fix steps
6865
8747
  --fix-prompt Output instructions to paste into a coding assistant
6866
8748
  --report[=F] Write a self-contained HTML report (default canship-report.html)
8749
+ --open With --report, open the report in the default browser
8750
+ (skipped in CI and non-interactive shells)
6867
8751
  --json Output raw JSON (for CI or tooling)
6868
8752
  --best-effort Allow exit 0 for an incomplete scan with no findings;
6869
8753
  findings still exit 1 or 2
@@ -6925,11 +8809,12 @@ async function main() {
6925
8809
  if ((args.baseline !== null || args.baselineDefault) && (args.baselineWrite !== null || args.baselineWriteDefault)) {
6926
8810
  argumentError("--baseline and --baseline-write are mutually exclusive");
6927
8811
  }
6928
- if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
8812
+ if (!existsSync3(args.root) || !statSync6(args.root).isDirectory()) {
6929
8813
  process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
6930
8814
  `);
6931
8815
  return finish(3);
6932
8816
  }
8817
+ if (args.open && args.report === null) argumentError("--open requires --report");
6933
8818
  if (args.changedSince !== null && (args.baselineWrite !== null || args.baselineWriteDefault)) {
6934
8819
  argumentError("--changed-since cannot be combined with --baseline-write");
6935
8820
  }
@@ -7030,6 +8915,7 @@ ${cleanForOutput(String(err))}
7030
8915
  if (changed) result = changedFileView(result, changed);
7031
8916
  const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
7032
8917
  const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
8918
+ const exitCode = verdictOf(fullResult.findings).blocking > 0 ? 1 : fullResult.findings.length > 0 ? 2 : result.partial && !bestEffort ? 3 : 0;
7033
8919
  if (args.fixPrompt) {
7034
8920
  const prompt = renderFixPrompt(shown, {
7035
8921
  ...result.changeView ? { changeView: result.changeView } : {},
@@ -7071,7 +8957,10 @@ ${cleanForOutput(String(err))}
7071
8957
  hiddenLikely,
7072
8958
  baselineSuppressed,
7073
8959
  baselineStale,
7074
- baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
8960
+ baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath),
8961
+ verbose: args.verbose,
8962
+ version: VERSION,
8963
+ exitCode
7075
8964
  }
7076
8965
  )}
7077
8966
  `
@@ -7094,8 +8983,7 @@ ${cleanForOutput(String(err))}
7094
8983
  "utf8"
7095
8984
  );
7096
8985
  if (!args.json && !args.fixPrompt) {
7097
- process.stdout.write(` ${dim("SARIF written to")} ${cyan(cleanForOutput(target))}
7098
-
8986
+ process.stdout.write(`SARIF written to ${cleanForOutput(target)}
7099
8987
  `);
7100
8988
  }
7101
8989
  } catch (err) {
@@ -7117,6 +9005,7 @@ ${cleanForOutput(String(err))}
7117
9005
  {
7118
9006
  root: displayRoot,
7119
9007
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9008
+ version: VERSION,
7120
9009
  hiddenLikely,
7121
9010
  baselineSuppressed,
7122
9011
  baselineStale,
@@ -7126,10 +9015,17 @@ ${cleanForOutput(String(err))}
7126
9015
  "utf8"
7127
9016
  );
7128
9017
  if (!args.json && !args.fixPrompt) {
7129
- process.stdout.write(` ${dim("Report written to")} ${cyan(cleanForOutput(target))}
7130
-
9018
+ process.stdout.write(`Report written to ${cleanForOutput(target)}
7131
9019
  `);
7132
9020
  }
9021
+ if (args.open) {
9022
+ if (canOpen(process.env, process.stdout.isTTY === true)) {
9023
+ openReport(target, (message) => process.stderr.write(`canship: ${cleanForOutput(message)}
9024
+ `), { root: args.root });
9025
+ } else {
9026
+ process.stderr.write("canship: not opening the report in CI or a non-interactive session\n");
9027
+ }
9028
+ }
7133
9029
  } catch (err) {
7134
9030
  process.stderr.write(
7135
9031
  `${red("canship:")} could not write report to ${cleanForOutput(target)}
@@ -7139,10 +9035,7 @@ ${cleanForOutput(String(err))}
7139
9035
  return finish(3);
7140
9036
  }
7141
9037
  }
7142
- if (verdictOf(fullResult.findings).blocking > 0) return finish(1);
7143
- if (fullResult.findings.length > 0) return finish(2);
7144
- if (result.partial && !bestEffort) return finish(3);
7145
- return finish(0);
9038
+ return finish(exitCode);
7146
9039
  }
7147
9040
  main().catch((err) => {
7148
9041
  if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}