canship 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -47,13 +47,7 @@ var SECRET_PATTERNS = [
47
47
  {
48
48
  id: "npm-token",
49
49
  name: "npm access token",
50
- // Shape only, though npm encodes a CRC32 of the token into its last six
51
- // characters and this could verify it. The github-token entry above uses
52
- // the same scheme and does not verify it either, for the reason that
53
- // settles it: if the algorithm ever changes, a checksum test turns a real
54
- // leaked token into a silent pass. A false positive costs a glance. This
55
- // particular false negative costs the account, and every account that
56
- // installs anything published from it afterwards.
50
+ // 仅检查令牌形状,不校验其校验码。
57
51
  pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
58
52
  impact: "Publishes packages under your account. Anyone who installs one afterwards runs whatever that version contains.",
59
53
  rotateAt: "https://docs.npmjs.com/creating-and-viewing-access-tokens"
@@ -64,13 +58,7 @@ var SECRET_PATTERNS = [
64
58
  pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
65
59
  impact: "Depending on its scope, this can be used to run up billed usage on Google Cloud services.",
66
60
  rotateAt: "https://console.cloud.google.com/apis/credentials",
67
- // Unlike every other pattern in this table, an AIza-format key is not a
68
- // bearer credential: it identifies a Firebase project or a Maps Platform
69
- // caller, and Google's own docs say it belongs in client code. Flagging
70
- // it as "exposed to the browser — rotate this" was a false positive on
71
- // every ordinary Firebase or Maps front-end, and the actual protection
72
- // (application/API restrictions in the Cloud console) is not something a
73
- // static scan of the repository can confirm one way or the other.
61
+ // Google 项目标识符不直接作为私密凭据报告。
74
62
  publicByDesign: true
75
63
  },
76
64
  {
@@ -91,17 +79,7 @@ var SECRET_PATTERNS = [
91
79
  id: "supabase-secret-key",
92
80
  name: "Supabase secret key",
93
81
  rotateLabel: "Supabase secret key",
94
- // Supabase's newer key format. The prefix settles it: sb_secret_ is the
95
- // server-side half, sb_publishable_ is the one meant for browsers, and the
96
- // two are never confusable.
97
- //
98
- // framework.ts has recognised this format since the day it was written —
99
- // but only for deciding whether a *client-exposed* value is the admin key.
100
- // It was never in this table, and this table is what the hardcoded-secret
101
- // rule and the output-boundary redaction both walk. So a project with one
102
- // of these in its source got a clean report, and a project with one beside
103
- // another credential had it printed in full: the redaction pass could not
104
- // mask a format it did not know.
82
+ // 共享新版 Supabase 私密密钥模式,与公开密钥区分。
105
83
  pattern: new RegExp(String.raw`\b${SB_SECRET_SOURCE}\b`, "g"),
106
84
  impact: "This is the server-side Supabase key. It bypasses every Row Level Security policy \u2014 it is effectively your database root password.",
107
85
  rotateAt: "your Supabase dashboard, Project Settings -> API Keys"
@@ -117,22 +95,9 @@ var SECRET_PATTERNS = [
117
95
  id: "db-connection-string",
118
96
  name: "Database connection string with password",
119
97
  pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/'"`]+:([^\s@/'"`]+)@(\[[^\]\s]+\]|[^\s'"`/:]+)(?::\d+)?(?:[/?#][^\s'"`]*)?/g,
120
- // Check only the password group for placeholders, so an "example" or "test"
121
- // in the host does not cause a miss.
98
+ // 仅用密码组判断占位符,避免主机名影响判断。
122
99
  secretGroup: 1,
123
- // But if the host itself is an example domain or a local address, the
124
- // connection string is worthless and not worth reporting.
125
- //
126
- // The host alternative takes a bracketed form first so IPv6 survives:
127
- // `[^\s'"/:]+` stops at the first colon, so `@[::1]:5432` captured a lone
128
- // `[`, which matches no entry below — an IPv6 loopback string was reported
129
- // P0 while the identical `localhost` one was correctly ignored.
130
- //
131
- // The backtick is excluded for the same reason. It is the third string
132
- // delimiter in JavaScript and the only one this pattern had never heard of,
133
- // so a connection string written in a template literal handed the host
134
- // group a trailing backtick and defeated the check below. canship found
135
- // that one on its own source, in the comment above.
100
+ // 支持 IPv6,并排除示例和本地地址。
136
101
  ignoreIf: (m) => IRRELEVANT_HOSTS.test(m[2] ?? ""),
137
102
  rotateLabel: "database password",
138
103
  impact: "This contains your database username AND password. Anyone with it can read, modify, or delete your entire database."
@@ -233,6 +198,7 @@ import { execFileSync } from "child_process";
233
198
  import { accessSync, constants, existsSync, lstatSync, readFileSync, realpathSync, statSync } from "fs";
234
199
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path";
235
200
  var MAX_GIT_OUTPUT = 32 * 1024 * 1024;
201
+ var GIT_TIMEOUT_MS = 3e4;
236
202
  function canonical(path) {
237
203
  try {
238
204
  return realpathSync.native(path);
@@ -437,6 +403,7 @@ function hardeningArgs(root) {
437
403
  }
438
404
  function execGitSync(executable, root, args, options = {}) {
439
405
  return execFileSync(executable, [...hardeningArgs(root), ...args], {
406
+ timeout: GIT_TIMEOUT_MS,
440
407
  cwd: root,
441
408
  encoding: "utf8",
442
409
  maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
@@ -447,6 +414,7 @@ function execGitSync(executable, root, args, options = {}) {
447
414
  }
448
415
  function execGitBatch(executable, root, args, input, options = {}) {
449
416
  return execFileSync(executable, [...hardeningArgs(root), ...args], {
417
+ timeout: GIT_TIMEOUT_MS,
450
418
  cwd: root,
451
419
  input,
452
420
  maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
@@ -458,6 +426,8 @@ function execGitBatch(executable, root, args, input, options = {}) {
458
426
 
459
427
  // src/walker.ts
460
428
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
429
+ var MAX_SCAN_BYTES = 128 * 1024 * 1024;
430
+ var MAX_SCAN_FILES = 1e4;
461
431
  var MAX_WALK_DEPTH = 16;
462
432
  var SKIP_DIRS = /* @__PURE__ */ new Set([
463
433
  "node_modules",
@@ -476,8 +446,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
476
446
  ".venv",
477
447
  "venv",
478
448
  ".cache",
479
- // Build output and tool caches from ecosystems beyond JavaScript. Walking
480
- // these to look for credential files is pure cost.
449
+ // 排除其他生态的构建产物和工具缓存。
481
450
  ".dart_tool",
482
451
  ".gradle",
483
452
  "Pods",
@@ -515,7 +484,7 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
515
484
  ".yml",
516
485
  ".toml",
517
486
  ".sql",
518
- // Firebase security rules (firestore.rules / storage.rules)
487
+ // Firebase 规则文件。
519
488
  ".rules",
520
489
  ".env",
521
490
  ".sh",
@@ -810,7 +779,7 @@ function isExampleContext(relPath) {
810
779
  if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
811
780
  return false;
812
781
  }
813
- function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root)) {
782
+ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}) {
814
783
  const skipped = [];
815
784
  const ignored = [];
816
785
  const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
@@ -839,6 +808,10 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
839
808
  forced.add(hidden);
840
809
  }
841
810
  const files = [];
811
+ let bytesRead = 0;
812
+ let filesRead = 0;
813
+ const maxBytes = limits.maxBytes ?? MAX_SCAN_BYTES;
814
+ const maxFiles = limits.maxFiles ?? MAX_SCAN_FILES;
842
815
  for (const relPath of candidates) {
843
816
  if (!forced.has(relPath) && !shouldScan(relPath)) continue;
844
817
  const absPath = join2(root, relPath);
@@ -859,7 +832,22 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
859
832
  });
860
833
  continue;
861
834
  }
862
- content = decodeText(readFileSync2(absPath));
835
+ if (filesRead >= maxFiles || bytesRead + size > maxBytes) {
836
+ skipped.push({
837
+ path: relPath,
838
+ reason: "too-large",
839
+ detail: `scan read budget exceeded (${maxFiles} files, ${maxBytes} bytes); remaining candidates were not read`
840
+ });
841
+ break;
842
+ }
843
+ const bytes = readFileSync2(absPath);
844
+ bytesRead += bytes.length;
845
+ filesRead++;
846
+ if (bytesRead > maxBytes) {
847
+ skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
848
+ break;
849
+ }
850
+ content = decodeText(bytes);
863
851
  } catch (err) {
864
852
  if (!isMissing(err)) {
865
853
  skipped.push({
@@ -964,71 +952,68 @@ function endOfString(src, start, quote) {
964
952
  }
965
953
  return length;
966
954
  }
967
- function maskTemplate(src, out, start) {
955
+ function maskTemplate(src, out, start, maskStrings = true) {
968
956
  const length = src.length;
957
+ const stack = [{ kind: "literal", from: start + 1 }];
969
958
  let i = start + 1;
970
- let literalFrom = i;
971
- while (i < length) {
959
+ while (i < length && stack.length > 0) {
960
+ const frame = stack[stack.length - 1];
972
961
  const ch = src.charCodeAt(i);
973
- if (ch === BACKSLASH) {
974
- i += 2;
962
+ if (frame.kind === "literal") {
963
+ if (ch === BACKSLASH) {
964
+ i = Math.min(i + 2, length);
965
+ continue;
966
+ }
967
+ if (ch === BACKTICK) {
968
+ if (maskStrings) blank(out, frame.from, i);
969
+ stack.pop();
970
+ i++;
971
+ continue;
972
+ }
973
+ if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
974
+ if (maskStrings) blank(out, frame.from, i);
975
+ stack.push({ kind: "expression", depth: 1 });
976
+ i += 2;
977
+ continue;
978
+ }
979
+ i++;
980
+ continue;
981
+ }
982
+ if (ch === SLASH) {
983
+ const next = src.charCodeAt(i + 1);
984
+ if (next === SLASH || next === STAR) {
985
+ const close = next === SLASH ? src.indexOf("\n", i) : src.indexOf("*/", i + 2);
986
+ const stop = close === -1 ? length : close + (next === STAR ? 2 : 0);
987
+ blank(out, i, stop);
988
+ i = stop;
989
+ continue;
990
+ }
991
+ }
992
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
993
+ const stop = endOfString(src, i, ch);
994
+ if (maskStrings) blank(out, i + 1, stop - 1);
995
+ i = stop;
975
996
  continue;
976
997
  }
977
998
  if (ch === BACKTICK) {
978
- blank(out, literalFrom, i);
979
- return i + 1;
999
+ stack.push({ kind: "literal", from: i + 1 });
1000
+ i++;
1001
+ continue;
980
1002
  }
981
- if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
982
- blank(out, literalFrom, i);
983
- let depth = 0;
984
- let j = i + 1;
985
- while (j < length) {
986
- const inner = src.charCodeAt(j);
987
- if (inner === SLASH) {
988
- const next = src.charCodeAt(j + 1);
989
- if (next === SLASH) {
990
- const end = src.indexOf("\n", j);
991
- const stop = end === -1 ? length : end;
992
- blank(out, j, stop);
993
- j = stop;
994
- continue;
995
- }
996
- if (next === STAR) {
997
- const close = src.indexOf("*/", j + 2);
998
- const stop = close === -1 ? length : close + 2;
999
- blank(out, j, stop);
1000
- j = stop;
1001
- continue;
1002
- }
1003
- }
1004
- if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
1005
- const stop = endOfString(src, j, inner);
1006
- blank(out, j + 1, stop - 1);
1007
- j = stop;
1008
- continue;
1009
- }
1010
- if (inner === BACKTICK) {
1011
- j = maskTemplate(src, out, j);
1012
- continue;
1013
- }
1014
- if (inner === OPEN_BRACE) depth++;
1015
- else if (inner === CLOSE_BRACE) {
1016
- depth--;
1017
- if (depth === 0) {
1018
- j++;
1019
- break;
1020
- }
1021
- }
1022
- j++;
1003
+ if (ch === OPEN_BRACE) frame.depth++;
1004
+ else if (ch === CLOSE_BRACE) {
1005
+ frame.depth--;
1006
+ if (frame.depth === 0) {
1007
+ stack.pop();
1008
+ const parent = stack[stack.length - 1];
1009
+ if (parent.kind === "literal") parent.from = i + 1;
1023
1010
  }
1024
- i = j;
1025
- literalFrom = i;
1026
- continue;
1027
1011
  }
1028
1012
  i++;
1029
1013
  }
1030
- blank(out, literalFrom, length);
1031
- return length;
1014
+ const last = stack[stack.length - 1];
1015
+ if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
1016
+ return i;
1032
1017
  }
1033
1018
  function maskJsComments(src) {
1034
1019
  const length = src.length;
@@ -1053,10 +1038,14 @@ function maskJsComments(src) {
1053
1038
  continue;
1054
1039
  }
1055
1040
  }
1056
- if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE || ch === BACKTICK) {
1041
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1057
1042
  i = endOfString(src, i, ch);
1058
1043
  continue;
1059
1044
  }
1045
+ if (ch === BACKTICK) {
1046
+ i = maskTemplate(src, out, i, false);
1047
+ continue;
1048
+ }
1060
1049
  i++;
1061
1050
  }
1062
1051
  return stringOf(out);
@@ -1350,6 +1339,20 @@ var secretsRule = {
1350
1339
  // src/rules/exposure.ts
1351
1340
  import { basename as basename3 } from "path";
1352
1341
  var JWT_SHAPED2 = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
1342
+ var FindingBuffer = class {
1343
+ items = [];
1344
+ overflow = false;
1345
+ push(finding) {
1346
+ if (this.items.length < MAX_FINDINGS_PER_FILE) {
1347
+ this.items.push(finding);
1348
+ return;
1349
+ }
1350
+ this.overflow = true;
1351
+ if (finding.confidence !== "certain") return;
1352
+ const index = this.items.findIndex((item) => item.confidence === "likely");
1353
+ if (index !== -1) this.items[index] = finding;
1354
+ }
1355
+ };
1353
1356
  function parseEnv(file) {
1354
1357
  const entries = [];
1355
1358
  file.lines.forEach((raw, i) => {
@@ -1366,35 +1369,20 @@ var exposureRule = {
1366
1369
  if (isEnvFile(name)) return true;
1367
1370
  return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
1368
1371
  },
1369
- /**
1370
- * The ceiling, applied here rather than inside each branch.
1371
- *
1372
- * This rule was the last one without one. secrets.ts, firebase.ts and
1373
- * supabase.ts all cap and all say so — the constant was pulled into limits.ts
1374
- * precisely so the reasoning would not have to be rediscovered — and exposure
1375
- * never adopted it. A `.env` holding 3,000 public-prefixed credential names
1376
- * produced 3,000 findings, 2.36 MB of JSON and 48,046 lines of terminal
1377
- * output, with `partial` false and `errors` empty: the identical shape of the
1378
- * bug firebase.ts records in its own comment.
1379
- *
1380
- * At the entry point because there are two branches and a future third would
1381
- * have to remember. Truncating after the fact rather than stopping the loop
1382
- * keeps that single place honest: the input is already bounded by
1383
- * MAX_FILE_BYTES, so what this protects is the report, not the scan.
1384
- */
1372
+ /** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
1385
1373
  check(file, ctx) {
1386
1374
  const name = basename3(file.path);
1387
1375
  const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
1388
- if (findings.length <= MAX_FINDINGS_PER_FILE) return findings;
1376
+ if (!findings.overflow) return findings.items;
1389
1377
  ctx.reportIncomplete(
1390
1378
  "exposure/public-env",
1391
1379
  `${file.path} holds more than ${MAX_FINDINGS_PER_FILE} values exposed to the browser; the rest were not reported`
1392
1380
  );
1393
- return findings.slice(0, MAX_FINDINGS_PER_FILE);
1381
+ return findings.items;
1394
1382
  }
1395
1383
  };
1396
1384
  function checkEnvFile(file) {
1397
- const findings = [];
1385
+ const findings = new FindingBuffer();
1398
1386
  for (const entry of parseEnv(file)) {
1399
1387
  const prefix = publicPrefixOf(entry.key);
1400
1388
  if (!prefix) continue;
@@ -1473,7 +1461,7 @@ function checkEnvFile(file) {
1473
1461
  return findings;
1474
1462
  }
1475
1463
  function checkSourceFile(file) {
1476
- const findings = [];
1464
+ const findings = new FindingBuffer();
1477
1465
  const clientSide = isClientCode(file);
1478
1466
  const commentless = commentsMaskedOf(file).split(/\r?\n/);
1479
1467
  file.lines.forEach((line, i) => {
@@ -1654,6 +1642,7 @@ function historicalEvidence(root, gitExecutable, entry) {
1654
1642
  "--no-textconv",
1655
1643
  "--all",
1656
1644
  "--format=%H",
1645
+ `--max-count=${MAX_HISTORY_REVISIONS + 1}`,
1657
1646
  "--",
1658
1647
  entry.localPath
1659
1648
  ]) ?? "").split(/\r?\n/).filter(Boolean);
@@ -1731,9 +1720,7 @@ var gitleakRule = {
1731
1720
  findings.push({
1732
1721
  ruleId: "gitleak/env-tracked",
1733
1722
  severity: "P0",
1734
- // Only claim certainty when the file actually holds something that is
1735
- // recognisably a credential. Everything else is a committed env file
1736
- // that might hold one, which is worth saying quietly.
1723
+ // 只有非示例中的明确凭据使用确定置信度。
1737
1724
  confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
1738
1725
  title: evidence === "proof" && !scaffolding ? `${path} is committed to git, with a credential in it` : `${path} is committed to git`,
1739
1726
  file: path,
@@ -1760,7 +1747,7 @@ var gitleakRule = {
1760
1747
  if (history && history.unread > 0) {
1761
1748
  ctx.reportIncomplete(
1762
1749
  "gitleak/env-in-history",
1763
- `only the ${MAX_HISTORY_REVISIONS} most recent versions of ${path} were read; ${history.unread} older ${history.unread === 1 ? "version was" : "versions were"} not checked`
1750
+ `only the ${MAX_HISTORY_REVISIONS} most recent versions of ${path} were read; at least ${history.unread} older ${history.unread === 1 ? "version was" : "versions were"} not checked`
1764
1751
  );
1765
1752
  }
1766
1753
  if (history && history.unreadable > 0) {
@@ -1776,8 +1763,7 @@ var gitleakRule = {
1776
1763
  ruleId: "gitleak/env-in-history",
1777
1764
  ...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
1778
1765
  severity: "P0",
1779
- // Claiming certainty about a file nobody could read would be the same
1780
- // overreach the tracked branch just stopped making.
1766
+ // 证据或上下文不足时降低置信度。
1781
1767
  confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
1782
1768
  title: stillTracked ? `${path} is committed to git, and an older version of it held a credential` : `${path} was removed, but it is still in your git history`,
1783
1769
  file: path,
@@ -2117,8 +2103,8 @@ var supabaseRlsRule = {
2117
2103
  line: entry.line,
2118
2104
  excerpt: null,
2119
2105
  why: [
2120
- `Supabase exposes your database to the browser directly, and the anon key that reaches it is public by design \u2014 it ships inside your frontend. Row Level Security is the only thing that decides who can read or write a row.`,
2121
- `No "ALTER TABLE ${renderIdent(entry.table)} ENABLE ROW LEVEL SECURITY" appears anywhere in your SQL, and new tables do not get it by default. If that is the real state, anyone who visits your site can list this entire table with a single request \u2014 and depending on your policies, write to it too.`,
2106
+ `Supabase's browser clients use public identifiers. For exposed tables, database grants and Row Level Security determine which operations and rows a caller can access.`,
2107
+ `After replaying the scanned migrations, the final recorded state of ${renderIdent(entry.table)} does not have RLS enabled. It may never have been enabled or may have been disabled by a later migration. Without RLS, any access granted to the caller is not restricted by row policies.`,
2122
2108
  `If you enabled RLS from the Supabase dashboard instead, this file simply cannot show it. Check the Authentication -> Policies page to confirm.`
2123
2109
  ],
2124
2110
  fix: [
@@ -2145,9 +2131,7 @@ var supabaseRlsRule = {
2145
2131
  // src/rules/firebase.ts
2146
2132
  import { basename as basename5 } from "path";
2147
2133
  function isRulesFile(file) {
2148
- const name = basename5(file.path).toLowerCase();
2149
- if (name.endsWith(".rules")) return true;
2150
- return name === "firestore.rules" || name === "storage.rules";
2134
+ return basename5(file.path).toLowerCase().endsWith(".rules");
2151
2135
  }
2152
2136
  function productOf(path) {
2153
2137
  const name = basename5(path).toLowerCase();
@@ -2251,8 +2235,7 @@ var firebaseRulesRule = {
2251
2235
  findings.push({
2252
2236
  ruleId: "firebase/open-rules",
2253
2237
  severity: "P1",
2254
- // Open writes are unambiguous. An open read might be intentional
2255
- // (a public catalogue, announcements), so it stays lower-confidence.
2238
+ // 开放写入为确定结果,公开读取需人工确认。
2256
2239
  confidence: canWrite ? "certain" : "likely",
2257
2240
  title: canWrite ? `Your ${product} rules let anyone ${ops} this data` : `Your ${product} rules make this data publicly readable`,
2258
2241
  file: file.path,
@@ -2260,7 +2243,7 @@ var firebaseRulesRule = {
2260
2243
  excerpt: m[0].trim(),
2261
2244
  why: canWrite ? [
2262
2245
  `"if true" grants access unconditionally \u2014 no sign-in, no ownership check, nothing. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
2263
- `Anyone who finds your project id can read every document here, overwrite it, or delete all of it. Project ids are not secret; they ship inside your frontend bundle.`
2246
+ `Anyone can perform the allowed ${ops} operations on this matched path without authentication. Other operations depend on their own rules. Project ids are public identifiers, not access controls.`
2264
2247
  ] : [
2265
2248
  `"if true" grants read access unconditionally, so anyone who finds your project id can list every document in this collection. Project ids are not secret; they ship inside your frontend bundle.`,
2266
2249
  `Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
@@ -2290,8 +2273,7 @@ var firebaseRulesRule = {
2290
2273
  findings.push({
2291
2274
  ruleId: "firebase/test-mode-rules",
2292
2275
  severity: "P1",
2293
- // A hardcoded expiry date is never a deliberate authorisation design,
2294
- // so this stays certain whether or not writes are involved.
2276
+ // 固定日期测试模式均作为确定配置问题。
2295
2277
  confidence: "certain",
2296
2278
  title: expired ? `Your ${product} rules are in test mode and expired on ${dateStr}` : `Your ${product} rules allow ${ops} to anyone until ${dateStr}`,
2297
2279
  file: file.path,
@@ -2320,7 +2302,10 @@ import { posix } from "path";
2320
2302
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2321
2303
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
2322
2304
  function isApiRoute(path) {
2323
- return APP_ROUTER.test(path) || PAGES_ROUTER.test(path);
2305
+ return APP_ROUTER.test(routePathOf(path)) || PAGES_ROUTER.test(path);
2306
+ }
2307
+ function routePathOf(path) {
2308
+ return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
2324
2309
  }
2325
2310
  var AUTH_ENDPOINT_NAMES = /^\/api\/auth\/(?:sign[-_]?in|sign[-_]?up|sign[-_]?out|log[-_]?in|log[-_]?out|register|session|verify|confirm|reset(?:[-_]password)?|forgot(?:[-_]password)?|magic[-_]?link|otp)$/;
2326
2311
  var AUTH_CALLBACK = /^\/api\/auth\/callback(?:\/[^/]+)?$/;
@@ -2328,7 +2313,7 @@ function isAuthEndpoint(url) {
2328
2313
  return AUTH_ENDPOINT_NAMES.test(url) || AUTH_CALLBACK.test(url);
2329
2314
  }
2330
2315
  function routeUrl(path) {
2331
- const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
2316
+ const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
2332
2317
  if (!m) return `/${path}`;
2333
2318
  const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
2334
2319
  return `/${url}`;
@@ -2359,17 +2344,37 @@ function controlledStatement(source, afterCondition) {
2359
2344
  return source.slice(start, end);
2360
2345
  }
2361
2346
  function hasConditionalAuthGuard(code) {
2362
- const starts = code.matchAll(/\bif\s*\(/g);
2363
- for (const match of starts) {
2347
+ const starts = /\bif\s*\(/g;
2348
+ let match;
2349
+ while ((match = starts.exec(code)) !== null) {
2364
2350
  const open = code.indexOf("(", match.index);
2365
2351
  const close = closingDelimiter(code, open, "(", ")");
2366
2352
  if (close === null) continue;
2367
2353
  const condition = code.slice(open + 1, close);
2368
2354
  const statement = controlledStatement(code, close + 1);
2369
- const stopsRequest = /\b(?:return|throw|redirect|notFound)\b/.test(statement);
2355
+ const body = statement.startsWith("{") ? statement.slice(1, -1) : statement;
2356
+ const pairs = delimiterPairs(body);
2357
+ let stopsRequest = false;
2358
+ for (let i = 0; i < body.length; i++) {
2359
+ if (/^(?:return|throw|redirect|notFound)\b/.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2360
+ stopsRequest = true;
2361
+ break;
2362
+ }
2363
+ if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
2364
+ const end = pairs.get(i);
2365
+ if (end !== void 0) i = end;
2366
+ }
2367
+ let statementStart = close + 1;
2368
+ while (/\s/.test(code[statementStart] ?? "")) statementStart++;
2369
+ starts.lastIndex = statementStart + statement.length;
2370
2370
  if (!stopsRequest) continue;
2371
2371
  const returnsDeniedStatus = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus\s*[:(=]\s*(?:401|403)\b/i.test(statement);
2372
- if (AUTH_CONDITION.test(condition) || returnsDeniedStatus) return true;
2372
+ const negative = !/&&|\?/.test(condition) && condition.split("||").some((part) => {
2373
+ const term = part.trim();
2374
+ const rejects = /^!\s*[\w$.]+(?:\s*\([^=]*\))?$/.test(term) || /^[\w$.]+\s*={2,3}\s*(?:null|undefined|false)$/.test(term) || /^[\w$.]+\s*!={1,2}\s*(?!(?:null|undefined|false)\b)[\w$.]+$/.test(term);
2375
+ return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2376
+ });
2377
+ if (negative) return true;
2373
2378
  }
2374
2379
  return false;
2375
2380
  }
@@ -2462,6 +2467,16 @@ function unguardedOperations(file, ops) {
2462
2467
  }
2463
2468
  const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
2464
2469
  if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
2470
+ const prefixStart = Math.max(
2471
+ owner.start + 1,
2472
+ code.lastIndexOf(";", i - 1) + 1,
2473
+ code.lastIndexOf("\n", i - 1) + 1
2474
+ );
2475
+ const prefix = code.slice(prefixStart, i).trim();
2476
+ if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
2477
+ const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix);
2478
+ const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
2479
+ if (!awaited && !synchronous) continue;
2465
2480
  const close2 = pairs.get(i + call[0].length - 1);
2466
2481
  if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
2467
2482
  }
@@ -2559,7 +2574,7 @@ function usesSessionClient(route, sessionModules, allFiles) {
2559
2574
  return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
2560
2575
  }
2561
2576
  function moduleScopeOf(routePath) {
2562
- return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePath)?.[1] ?? "";
2577
+ return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
2563
2578
  }
2564
2579
  function importedModules(file, allFiles, aliasScope) {
2565
2580
  const found = [];
@@ -2899,18 +2914,16 @@ var apiAuthRule = {
2899
2914
  findings.push({
2900
2915
  ruleId: "api/admin-db-access-without-auth",
2901
2916
  severity: "P0",
2902
- // Hard evidence: the route runs queries through a key that bypasses
2903
- // every RLS policy, and nothing in the file or in middleware checks
2904
- // who sent the request.
2917
+ // 管理员客户端缺少鉴权时使用确定置信度。
2905
2918
  confidence: "certain",
2906
2919
  title: `Anyone can call ${url} and it queries your database as admin`,
2907
2920
  file: route.path,
2908
2921
  line,
2909
2922
  excerpt,
2910
2923
  why: [
2911
- `This route uses the service_role key, which bypasses every Row Level Security policy you have. Whatever your database would normally refuse, this route performs.`,
2912
- `Nothing in this file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere \u2014 and no middleware covers it. The URL is not a secret either: it is spelled out by the file path, and it appears in your frontend bundle as soon as anything calls it.`,
2913
- `So a single curl to ${url} gets the same access your admin key has.`,
2924
+ `This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
2925
+ `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
2926
+ `If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
2914
2927
  `If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
2915
2928
  ],
2916
2929
  fix: [
@@ -2929,17 +2942,15 @@ var apiAuthRule = {
2929
2942
  findings.push({
2930
2943
  ruleId: "api/db-write-without-auth",
2931
2944
  severity: "P1",
2932
- // Lower confidence on purpose: the write may be legitimately open
2933
- // (a waitlist, a contact form), and protection can also live in a
2934
- // deployment-level proxy this scan cannot see.
2945
+ // 公开写入可能是业务设计,保留疑似置信度。
2935
2946
  confidence: "likely",
2936
2947
  title: `${url} writes to your database with no sign-in check`,
2937
2948
  file: route.path,
2938
2949
  line,
2939
2950
  excerpt,
2940
2951
  why: [
2941
- `This route changes data, and nothing in the file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere.`,
2942
- `Route URLs are not secret; this one is spelled out by its file path. Anyone who sends a request can trigger the same write.`,
2952
+ `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
2953
+ `If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
2943
2954
  `If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
2944
2955
  ],
2945
2956
  fix: [
@@ -2956,8 +2967,31 @@ var apiAuthRule = {
2956
2967
 
2957
2968
  // src/rules/cors.ts
2958
2969
  var CORS_MARKER = /Access-Control-Allow-Origin|\bcors\s*\(/i;
2959
- var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?([^\n]+)/gi;
2960
- var CORS_ORIGIN_OPTION = /\borigin\s*:\s*(true|['"`]\*['"`])/gi;
2970
+ var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?/gi;
2971
+ function headerExpression(content, start) {
2972
+ const closes = [];
2973
+ let quote = null;
2974
+ let end = start;
2975
+ for (; end < content.length; end++) {
2976
+ const ch = content[end];
2977
+ if (ch === "\n" || ch === "\r") break;
2978
+ if (quote !== null) {
2979
+ if (ch === "\\") end++;
2980
+ else if (ch === quote) quote = null;
2981
+ continue;
2982
+ }
2983
+ if (ch === "'" || ch === '"' || ch === "`") quote = ch;
2984
+ else if (ch === "(") closes.push(")");
2985
+ else if (ch === "[") closes.push("]");
2986
+ else if (ch === "{") closes.push("}");
2987
+ else if (ch === ")" || ch === "]" || ch === "}") {
2988
+ if (closes.at(-1) !== ch) break;
2989
+ closes.pop();
2990
+ } else if (closes.length === 0 && (ch === "," || ch === ";")) break;
2991
+ }
2992
+ return content.slice(start, end);
2993
+ }
2994
+ var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
2961
2995
  var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*)?\s*)?\(/gi;
2962
2996
  var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
2963
2997
  function closingParameterList(source, open) {
@@ -2998,6 +3032,8 @@ function readOriginCallback(content, match, kind) {
2998
3032
  }
2999
3033
  return {
3000
3034
  index: match.index,
3035
+ parametersStart: open + 1,
3036
+ parametersEnd: close,
3001
3037
  params: content.slice(open + 1, close),
3002
3038
  body: content.slice(bodyStart, bodyStart + 400)
3003
3039
  };
@@ -3106,31 +3142,63 @@ function classifyOrigin(raw) {
3106
3142
  return /\b(?:req|request|headers?|ctx|event)/i.test(compact) ? "reflected" : "unknown";
3107
3143
  }
3108
3144
  var PAIRING_DISTANCE = 25;
3145
+ function optionScopes(file, positions) {
3146
+ const scopes = /* @__PURE__ */ new Map();
3147
+ const wanted = [...new Set(positions)].sort((a, b) => a - b);
3148
+ if (wanted.length === 0) return scopes;
3149
+ const content = noiseMaskedOf(file);
3150
+ const stack = [-1];
3151
+ let next = 0;
3152
+ for (let at = 0; at < content.length && next < wanted.length; at++) {
3153
+ if (at === wanted[next]) {
3154
+ scopes.set(at, stack[stack.length - 1]);
3155
+ next++;
3156
+ }
3157
+ if (content[at] === "{") stack.push(at);
3158
+ else if (content[at] === "}" && stack.length > 1) stack.pop();
3159
+ }
3160
+ return scopes;
3161
+ }
3109
3162
  function collectOrigins(file) {
3110
3163
  const marks = [];
3111
3164
  let m;
3112
3165
  const content = commentsMaskedOf(file);
3113
3166
  const contentLines = lineStartsOf(content);
3167
+ const code = noiseMaskedOf(file);
3168
+ const callbacks = originCallbacks(content).filter((callback) => code[callback.index] === content[callback.index]);
3169
+ const callbackIndices = new Set(callbacks.map((callback) => callback.index));
3170
+ const parameterRanges = [...callbacks].sort((a, b) => a.parametersStart - b.parametersStart);
3171
+ let parameterRange = 0;
3114
3172
  ACAO.lastIndex = 0;
3115
3173
  while ((m = ACAO.exec(content)) !== null) {
3174
+ if (code[m.index] !== content[m.index]) continue;
3116
3175
  const line = lineNumberAt(contentLines, m.index);
3117
- marks.push({ line, kind: classifyOrigin(m[1] ?? ""), excerpt: (file.lines[line - 1] ?? "").trim() });
3176
+ const expression = headerExpression(content, ACAO.lastIndex);
3177
+ ACAO.lastIndex += expression.length;
3178
+ marks.push({ line, at: m.index, mode: "header", kind: classifyOrigin(expression), excerpt: (file.lines[line - 1] ?? "").trim() });
3118
3179
  }
3119
3180
  CORS_ORIGIN_OPTION.lastIndex = 0;
3120
3181
  while ((m = CORS_ORIGIN_OPTION.exec(content)) !== null) {
3182
+ if (code[m.index] !== content[m.index]) continue;
3183
+ while (parameterRange < parameterRanges.length && parameterRanges[parameterRange].parametersEnd < m.index) parameterRange++;
3184
+ if (parameterRanges[parameterRange] && parameterRanges[parameterRange].parametersStart <= m.index) continue;
3185
+ if (callbackIndices.has(m.index)) continue;
3121
3186
  const line = lineNumberAt(contentLines, m.index);
3187
+ const expression = headerExpression(content, CORS_ORIGIN_OPTION.lastIndex);
3188
+ CORS_ORIGIN_OPTION.lastIndex += expression.length;
3122
3189
  marks.push({
3123
3190
  line,
3124
- kind: (m[1] ?? "") === "true" ? "reflected" : "wildcard",
3191
+ at: m.index,
3192
+ mode: "option",
3193
+ kind: expression.trim() === "true" ? "reflected" : classifyOrigin(expression),
3125
3194
  excerpt: (file.lines[line - 1] ?? "").trim()
3126
3195
  });
3127
3196
  }
3128
- for (const callback of originCallbacks(content)) {
3197
+ for (const callback of callbacks) {
3129
3198
  const answer = callbackAnswer(callback.params, callback.body);
3130
- if (answer === null) continue;
3131
- if (ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index))) continue;
3199
+ const kind = answer !== null && !ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index)) ? answer.kind : "unknown";
3132
3200
  const line = lineNumberAt(contentLines, callback.index);
3133
- marks.push({ line, kind: answer.kind, excerpt: (file.lines[line - 1] ?? "").trim() });
3201
+ marks.push({ line, at: callback.index, mode: "option", kind, excerpt: (file.lines[line - 1] ?? "").trim() });
3134
3202
  }
3135
3203
  return marks;
3136
3204
  }
@@ -3138,11 +3206,16 @@ function collectCredentialLines(file) {
3138
3206
  const lines = [];
3139
3207
  let m;
3140
3208
  const content = commentsMaskedOf(file);
3209
+ const code = noiseMaskedOf(file);
3141
3210
  const contentLines = lineStartsOf(content);
3142
3211
  ACAC_HEADER.lastIndex = 0;
3143
- while ((m = ACAC_HEADER.exec(content)) !== null) lines.push(lineNumberAt(contentLines, m.index));
3212
+ while ((m = ACAC_HEADER.exec(content)) !== null) {
3213
+ if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "header" });
3214
+ }
3144
3215
  CORS_CREDENTIALS_OPTION.lastIndex = 0;
3145
- while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) lines.push(lineNumberAt(contentLines, m.index));
3216
+ while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) {
3217
+ if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "option" });
3218
+ }
3146
3219
  return lines;
3147
3220
  }
3148
3221
  function nearestOrigin(origins, credLine) {
@@ -3172,10 +3245,12 @@ var corsRule = {
3172
3245
  if (credentialLines.length === 0) return [];
3173
3246
  const origins = collectOrigins(file);
3174
3247
  if (origins.length === 0) return [];
3248
+ const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
3175
3249
  const findings = [];
3176
3250
  const reported = /* @__PURE__ */ new Set();
3177
- for (const credLine of credentialLines) {
3178
- const origin = nearestOrigin(origins, credLine);
3251
+ for (const credential of credentialLines) {
3252
+ const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
3253
+ const origin = nearestOrigin(candidates, credential.line);
3179
3254
  if (!origin) continue;
3180
3255
  if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
3181
3256
  if (reported.has(origin.kind)) continue;
@@ -3184,8 +3259,7 @@ var corsRule = {
3184
3259
  findings.push({
3185
3260
  ruleId: "cors/reflected-origin-with-credentials",
3186
3261
  severity: "P1",
3187
- // Both halves are read straight out of the file: the origin is handed
3188
- // back unchanged, and credentials are allowed. Nothing is inferred.
3262
+ // 明确的来源回显和凭据配置使用确定置信度。
3189
3263
  confidence: "certain",
3190
3264
  title: "Any website can make signed-in requests to your API and read the answer",
3191
3265
  file: file.path,
@@ -3207,8 +3281,7 @@ var corsRule = {
3207
3281
  findings.push({
3208
3282
  ruleId: "cors/wildcard-with-credentials",
3209
3283
  severity: "P2",
3210
- // Not a judgement call: the specification forbids this pair, so every
3211
- // browser rejects it.
3284
+ // 通配符与凭据组合违反浏览器跨域约束。
3212
3285
  confidence: "certain",
3213
3286
  title: "This CORS setup is rejected by every browser, so the requests it enables never work",
3214
3287
  file: file.path,
@@ -3247,8 +3320,7 @@ var RULE_IDS = [
3247
3320
  "gitleak/env-in-history",
3248
3321
  "gitleak/env-tracked",
3249
3322
  "supabase/rls-not-enabled",
3250
- // One per credential format, built the same way secrets.ts builds them, so
3251
- // adding a pattern cannot leave a finding id this list has never heard of.
3323
+ // 凭据规则 ID 从共享格式表生成。
3252
3324
  ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
3253
3325
  ];
3254
3326
  function ruleMatches(selector, ruleId) {
@@ -3257,6 +3329,13 @@ function ruleMatches(selector, ruleId) {
3257
3329
  function isKnownSelector(selector) {
3258
3330
  return RULE_IDS.some((id) => ruleMatches(selector, id));
3259
3331
  }
3332
+ function shouldRunRule(id, only, skip) {
3333
+ if (only.length === 0 && skip.length === 0) return true;
3334
+ const namespace = id.split("/")[0];
3335
+ return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
3336
+ (candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
3337
+ );
3338
+ }
3260
3339
 
3261
3340
  // src/engine.ts
3262
3341
  import { createHash as createHash2 } from "crypto";
@@ -3299,28 +3378,11 @@ function sanitize(findings, files) {
3299
3378
  // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
3300
3379
  ...sourceIdentity(f),
3301
3380
  title: clean(f.title),
3302
- // Per paragraph, so the breaks between them survive a cleaner that removes
3303
- // every newline inside them. See Finding.why.
3381
+ // 按段落清理,保留段落之间的结构。
3304
3382
  why: f.why.map(clean),
3305
- // The path was left out of this list once, and a filename holding a
3306
- // credential put it straight back into the JSON, the terminal, the HTML
3307
- // and the prompt meant for pasting into an assistant.
3383
+ // 文件路径也必须经过输出清理。
3308
3384
  file: f.file === null ? null : clean(f.file),
3309
- // Redacted first, cut second, and both of them here.
3310
- //
3311
- // A rule that trimmed its own excerpt to length before this ran could
3312
- // defeat the redaction entirely: cutting at 120 characters through the
3313
- // middle of a key leaves a fragment that matches no pattern, so `clean`
3314
- // waved it past and nineteen characters of a live OpenAI key reached the
3315
- // terminal, the JSON, the HTML report and the prompt meant for pasting
3316
- // into an assistant. The rule was not doing anything unreasonable — it
3317
- // truncated, which every other rule also does. The order was simply not
3318
- // its decision to make.
3319
- //
3320
- // So rules hand over the whole line and the boundary does both jobs, in
3321
- // the only order that is safe. Rules that redact per match still may:
3322
- // masking a known secret before this point is additive, and truncating an
3323
- // already-truncated string is a no-op.
3385
+ // 先脱敏再截断,避免截断导致凭据特征失效。
3324
3386
  excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
3325
3387
  fix: f.fix.map(clean),
3326
3388
  ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
@@ -3394,19 +3456,15 @@ async function scan(root, options = {}) {
3394
3456
  const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
3395
3457
  const findings = [];
3396
3458
  const errors = [];
3459
+ const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
3460
+ const projectRules = PROJECT_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
3397
3461
  const incompleteSeen = /* @__PURE__ */ new Set();
3398
3462
  const ctx = {
3399
3463
  root,
3400
3464
  files,
3401
3465
  git: git2,
3402
3466
  gitExecutable,
3403
- // Deduplicated here rather than by each rule remembering to report once.
3404
- // A rule that reaches the same ceiling from two loops over the same file —
3405
- // firebase does, once for open rules and once for test-mode rules — said
3406
- // the identical sentence twice, in the terminal's incomplete section and in
3407
- // the JSON. Saying "part of this did not happen" twice does not make it
3408
- // twice as true, and a once-flag per rule is the kind of bookkeeping every
3409
- // new rule would have to remember.
3467
+ // 统一记录不完整状态,避免重复提示。
3410
3468
  reportIncomplete: (ruleId, message) => {
3411
3469
  if (incompleteSeen.has(`${ruleId} ${message}`)) return;
3412
3470
  incompleteSeen.add(`${ruleId} ${message}`);
@@ -3414,20 +3472,20 @@ async function scan(root, options = {}) {
3414
3472
  }
3415
3473
  };
3416
3474
  for (const file of files) {
3417
- for (const rule of FILE_RULES) {
3418
- if (!rule.appliesTo(file)) continue;
3475
+ for (const rule2 of fileRules) {
3419
3476
  try {
3420
- findings.push(...rule.check(file, ctx));
3477
+ if (!rule2.appliesTo(file)) continue;
3478
+ findings.push(...rule2.check(file, ctx));
3421
3479
  } catch (err) {
3422
- errors.push({ ruleId: rule.id, file: file.path, message: messageOf(err), kind: "crashed" });
3480
+ errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
3423
3481
  }
3424
3482
  }
3425
3483
  }
3426
- for (const rule of PROJECT_RULES) {
3484
+ for (const rule2 of projectRules) {
3427
3485
  try {
3428
- findings.push(...await rule.check(ctx));
3486
+ findings.push(...await rule2.check(ctx));
3429
3487
  } catch (err) {
3430
- errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
3488
+ errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
3431
3489
  }
3432
3490
  }
3433
3491
  const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
@@ -3435,8 +3493,19 @@ async function scan(root, options = {}) {
3435
3493
  files
3436
3494
  );
3437
3495
  const selected = applyRuleSelection(kept, options);
3496
+ const counts = /* @__PURE__ */ new Map();
3497
+ const bounded = sortFindings(selected.kept).filter((finding) => {
3498
+ const count = (counts.get(finding.file) ?? 0) + 1;
3499
+ counts.set(finding.file, count);
3500
+ if (count <= MAX_FINDINGS_PER_FILE) return true;
3501
+ if (count === MAX_FINDINGS_PER_FILE + 1) ctx.reportIncomplete(
3502
+ "engine/findings-limit",
3503
+ `${finding.file ?? "project"} has more than ${MAX_FINDINGS_PER_FILE} findings; remaining findings were not reported`
3504
+ );
3505
+ return false;
3506
+ });
3438
3507
  return {
3439
- findings: sanitize(sortFindings(selected.kept), files),
3508
+ findings: sanitize(bounded, files),
3440
3509
  filesScanned: files.length,
3441
3510
  durationMs: Date.now() - started,
3442
3511
  errors: errors.map((e) => ({
@@ -3446,29 +3515,16 @@ async function scan(root, options = {}) {
3446
3515
  })),
3447
3516
  skipped: sanitizeSkippedForOutput(skipped),
3448
3517
  ignored: ignored.map(clean),
3449
- // The path goes through the boundary like every other path that reaches a
3450
- // reader: a filename is chosen by whoever can add a file to the repository,
3451
- // and one holding a credential would otherwise print it here in full.
3518
+ // 清理被忽略结果的路径。
3452
3519
  ignoredFindings: ignoredFindings.map((f) => ({ ...f, file: clean(f.file) })),
3453
- // Selectors come from a config file or the command line, both of which are
3454
- // text canship prints back, so both go through the boundary.
3520
+ // 选择器来自外部输入,输出前也需清理。
3455
3521
  ruleSelection: selected.selection === null ? null : {
3456
3522
  only: selected.selection.only.map(clean),
3457
3523
  skip: selected.selection.skip.map(clean),
3458
3524
  removed: selected.selection.removed
3459
3525
  },
3460
3526
  vendored,
3461
- // A deliberate opt-out is not an incomplete scan: the user made that call
3462
- // knowingly. It is listed in the report, not treated as a failure.
3463
- //
3464
- // Examining no files at all, however, is the purest form of an incomplete
3465
- // scan, and it used to print a green tick and exit 0 — the exact outcome
3466
- // the README says must never share an exit code with "clean". It is also
3467
- // the most likely way to be wrong in practice: the headline command is
3468
- // `npx canship` with no argument, so running it from the wrong directory
3469
- // is the ordinary user error, and a directory holding nothing but a
3470
- // build/ folder (every entry of which the walker skips by design) reaches
3471
- // zero without looking empty to a human.
3527
+ // 主动忽略不影响完整性;错误、跳过或零文件扫描均标记为未完成。
3472
3528
  partial: errors.length > 0 || skipped.length > 0 || files.length === 0
3473
3529
  };
3474
3530
  }
@@ -3543,8 +3599,7 @@ function renderBaseline(opts) {
3543
3599
  }
3544
3600
  if (stale > 0) {
3545
3601
  out.push(
3546
- // Not plural() — that helper only appends an s, and "entrys" is not a
3547
- // word. The irregular ones have to be written out.
3602
+ // 不规则复数单独处理。
3548
3603
  `${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
3549
3604
  );
3550
3605
  }
@@ -3938,22 +3993,17 @@ function renderHtml(result, opts) {
3938
3993
  const baselineStale = opts.baselineStale ?? 0;
3939
3994
  const { blocking: certain, minor, unsure } = verdictOf(findings);
3940
3995
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
3941
- // Examined nothing, so there is nothing to report either way.
3996
+ // 未扫描文件时不显示通过结论。
3942
3997
  `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
3943
3998
  ) : result.partial ? (
3944
- // Never the green banner on a partial scan: it reads as a guarantee,
3945
- // and a scan that skipped files cannot make one.
3999
+ // 扫描不完整时不得显示正常通过。
3946
4000
  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>`
3947
4001
  ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
3948
- // The green banner is a statement about the project. A baseline
3949
- // makes it a statement about the diff instead, and this document
3950
- // outlives the run that produced it — whoever opens it later has
3951
- // only the banner to go on.
4002
+ // 基线抑制结果时明确说明,避免误报为项目无问题。
3952
4003
  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>`
3953
4004
  ) : 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>`;
3954
4005
  const body = findings.length === 0 ? result.filesScanned === 0 ? (
3955
- // The checklist below would be a false statement here: none of those
3956
- // checks had any input to run against.
4006
+ // 零文件扫描不能显示已完成的检查清单。
3957
4007
  `<div class="clean-note">
3958
4008
  <p>canship found no files it could read at this path, so none of its checks ran.
3959
4009
  <strong>This is not a clean result &mdash; it is an empty one.</strong></p>
@@ -3988,10 +4038,7 @@ function renderHtml(result, opts) {
3988
4038
  const incomplete = result.partial ? `<div class="incomplete">
3989
4039
  <h2>Not everything was checked</h2>
3990
4040
  <ul>
3991
- ${// Reachable with findings present: a repository whose working
3992
- // tree is entirely gitignored still has a git history, and the
3993
- // history rule reads it. The findings are real; the file-based
3994
- // checks simply never ran.
4041
+ ${// 工作区零文件时仍可能产生 Git 历史结果。
3995
4042
  result.filesScanned === 0 ? `<li>no files could be read at this path, so every file-based check was skipped</li>` : ""}
3996
4043
  ${result.errors.map(
3997
4044
  (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>`
@@ -4204,9 +4251,10 @@ function applyBaseline(findings, baseline) {
4204
4251
  const kept = [];
4205
4252
  let suppressed = 0;
4206
4253
  for (const f of findings) {
4207
- const budget = remaining.get(fingerprintOf(f)) ?? 0;
4254
+ const fp = fingerprintOf(f);
4255
+ const budget = remaining.get(fp) ?? 0;
4208
4256
  if (budget > 0) {
4209
- remaining.set(fingerprintOf(f), budget - 1);
4257
+ remaining.set(fp, budget - 1);
4210
4258
  suppressed++;
4211
4259
  continue;
4212
4260
  }
@@ -4276,8 +4324,7 @@ function rulesOf(findings) {
4276
4324
  text: [...f.why, ...f.fix.length > 0 ? ["How to fix:", ...f.fix] : []].join("\n")
4277
4325
  },
4278
4326
  properties: {
4279
- // Not part of the SARIF vocabulary, so it travels as a property rather
4280
- // than being mangled into one of the three levels above.
4327
+ // 工具专属严重度和置信度保存在扩展属性中。
4281
4328
  "canship-severity": f.severity,
4282
4329
  "canship-confidence": f.confidence
4283
4330
  },
@@ -4289,15 +4336,12 @@ function resultsOf(findings) {
4289
4336
  ruleId: f.ruleId,
4290
4337
  level: levelOf(f),
4291
4338
  message: { text: f.title },
4292
- // A finding with no file — git history, an RLS gap spanning migrations —
4293
- // gets no location rather than a made-up one. SARIF permits that, and
4294
- // pointing it at an arbitrary file would put an annotation on a line that
4295
- // has nothing to do with it.
4339
+ // 无可定位文件时不构造虚假位置。
4296
4340
  locations: f.file === null ? [] : [
4297
4341
  {
4298
4342
  physicalLocation: {
4299
- // Already relative and already slash-separated, on Windows too.
4300
- artifactLocation: { uri: f.file },
4343
+ // 按路径段进行 URI 编码,保留目录分隔符。
4344
+ artifactLocation: { uri: f.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
4301
4345
  ...f.line === null ? {} : { region: { startLine: f.line } }
4302
4346
  }
4303
4347
  }
@@ -4308,6 +4352,10 @@ function resultsOf(findings) {
4308
4352
  function renderSarif(result, opts) {
4309
4353
  const { findings } = result;
4310
4354
  const notifications = [
4355
+ ...result.skipped.map((item) => ({
4356
+ level: "warning",
4357
+ message: { text: `${item.path}: ${item.reason}${item.detail ? ` \u2014 ${item.detail}` : ""}` }
4358
+ })),
4311
4359
  ...result.errors.map((e) => ({
4312
4360
  level: e.kind === "crashed" ? "error" : "warning",
4313
4361
  message: { text: `${e.ruleId}: ${e.message}` }
@@ -4330,9 +4378,7 @@ function renderSarif(result, opts) {
4330
4378
  results: resultsOf(findings),
4331
4379
  invocations: [
4332
4380
  {
4333
- // False when a rule crashed or a file could not be read. It is not
4334
- // about whether findings exist — a scan that finds problems and
4335
- // completes was a successful invocation.
4381
+ // 该字段表示执行是否完整,与是否发现问题无关。
4336
4382
  executionSuccessful: !result.partial,
4337
4383
  ...notifications.length > 0 ? { toolExecutionNotifications: notifications } : {}
4338
4384
  }
@@ -4344,6 +4390,29 @@ function renderSarif(result, opts) {
4344
4390
  `;
4345
4391
  }
4346
4392
 
4393
+ // src/report/json.ts
4394
+ function createJsonReport(result, options) {
4395
+ return {
4396
+ schemaVersion: 1,
4397
+ version: options.version,
4398
+ root: options.root,
4399
+ filesScanned: result.filesScanned,
4400
+ durationMs: result.durationMs,
4401
+ partial: result.partial,
4402
+ errors: result.errors,
4403
+ skipped: result.skipped,
4404
+ ignored: result.ignored,
4405
+ ignoredFindings: result.ignoredFindings,
4406
+ ruleSelection: result.ruleSelection,
4407
+ vendored: result.vendored,
4408
+ hiddenLikely: options.hiddenLikely,
4409
+ baselineSuppressed: options.baselineSuppressed,
4410
+ baselineStale: options.baselineStale,
4411
+ excerptsOmitted: options.excerptsOmitted ?? false,
4412
+ findings: result.findings
4413
+ };
4414
+ }
4415
+
4347
4416
  // src/config.ts
4348
4417
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4349
4418
  import { join as join3 } from "path";
@@ -4434,12 +4503,137 @@ function loadConfig(root) {
4434
4503
  return { config: parseConfig(text, path), path };
4435
4504
  }
4436
4505
 
4506
+ // src/rules/catalog.ts
4507
+ var rule = (id, name, severity, confidence, scope, limitation) => ({ id, name, severity, confidence, scope, limitation, reportsFindings: true });
4508
+ var RULE_CATALOG = [
4509
+ rule(
4510
+ "api/admin-db-access-without-auth",
4511
+ "Admin database access without a recognized auth guard",
4512
+ "P0",
4513
+ "certain",
4514
+ "Next.js App/Pages API handlers, including workspace applications.",
4515
+ "Syntactic guards only; runtime authentication and business authorization are not verified."
4516
+ ),
4517
+ rule(
4518
+ "api/db-write-without-auth",
4519
+ "Database write without a recognized auth guard",
4520
+ "P1",
4521
+ "likely",
4522
+ "Next.js App/Pages API handlers.",
4523
+ "Session-scoped clients and indirect guards may need manual review."
4524
+ ),
4525
+ rule(
4526
+ "cors/reflected-origin-with-credentials",
4527
+ "Reflected origin with credentials",
4528
+ "P1",
4529
+ "certain",
4530
+ "Response headers and cors middleware options.",
4531
+ "Static pairing does not prove the deployed policy or cookie behavior."
4532
+ ),
4533
+ rule(
4534
+ "cors/wildcard-with-credentials",
4535
+ "Wildcard origin with credentials",
4536
+ "P2",
4537
+ "certain",
4538
+ "Response headers and cors middleware options.",
4539
+ "Browsers reject this combination; it is not proof of data exposure."
4540
+ ),
4541
+ rule(
4542
+ "exposure/private-name-in-public-env",
4543
+ "Private-looking name with a public env prefix",
4544
+ "P0",
4545
+ "likely",
4546
+ "Environment files and source references using recognized public prefixes.",
4547
+ "A variable name is not proof that its value is a secret."
4548
+ ),
4549
+ rule(
4550
+ "exposure/secret-in-public-env",
4551
+ "Recognized secret in a public env variable",
4552
+ "P0",
4553
+ "certain",
4554
+ "Environment files using recognized public prefixes.",
4555
+ "Credential validity and actual deployment are not verified."
4556
+ ),
4557
+ rule(
4558
+ "exposure/supabase-service-role-in-client",
4559
+ "Supabase admin credential exposed in source or public env",
4560
+ "P0",
4561
+ "certain",
4562
+ "Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
4563
+ "Source presence is observable; actual browser delivery and key validity are not verified."
4564
+ ),
4565
+ rule(
4566
+ "firebase/open-rules",
4567
+ "Unconditional Firebase access",
4568
+ "P1",
4569
+ "varies",
4570
+ "Firebase .rules files: unconditional writes are certain; public reads require review.",
4571
+ "Public reads may be intentional; runtime rules and business authorization are not verified."
4572
+ ),
4573
+ rule(
4574
+ "firebase/test-mode-rules",
4575
+ "Date-based Firebase test rules",
4576
+ "P1",
4577
+ "certain",
4578
+ "Firebase .rules files using a fixed timestamp cutoff.",
4579
+ "Expired rules may deny access; deployment state is not verified."
4580
+ ),
4581
+ rule(
4582
+ "gitleak/env-in-history",
4583
+ "Private env values in local Git history",
4584
+ "P0",
4585
+ "varies",
4586
+ "Relevant historical versions of environment files in the local repository.",
4587
+ "History and resource limits apply; remote refs not available locally are not checked."
4588
+ ),
4589
+ rule(
4590
+ "gitleak/env-tracked",
4591
+ "Private env values tracked by Git",
4592
+ "P0",
4593
+ "varies",
4594
+ "Tracked environment files; templates and public-only values are treated separately.",
4595
+ "Unrecognized private values are heuristic findings, not verified credentials."
4596
+ ),
4597
+ rule(
4598
+ "supabase/rls-not-enabled",
4599
+ "Table without RLS in migrations",
4600
+ "P1",
4601
+ "certain",
4602
+ "Supabase SQL migrations, replayed by application scope.",
4603
+ "Migration state is not deployed database state; grants and policy correctness are outside this check."
4604
+ ),
4605
+ ...SECRET_PATTERNS.map((pattern) => ({
4606
+ ...rule(
4607
+ `secrets/hardcoded/${pattern.id}`,
4608
+ pattern.name,
4609
+ "P0",
4610
+ "certain",
4611
+ "Recognized credential formats in scanned text files.",
4612
+ "Validity, revocation, and use are not checked; example contexts lower confidence."
4613
+ ),
4614
+ reportsFindings: !pattern.publicByDesign,
4615
+ ...pattern.publicByDesign ? { limitation: "Treated as a public identifier; this format alone does not produce a finding." } : {}
4616
+ }))
4617
+ ];
4618
+ function renderRuleCatalog() {
4619
+ return "canship rules\n\n" + RULE_CATALOG.map(
4620
+ (item) => `${item.id}
4621
+ ${item.name}
4622
+ ${item.reportsFindings ? `${item.severity}; ${item.confidence}` : "Public identifier; not reported"}
4623
+ Scope: ${item.scope}
4624
+ Limit: ${item.limitation}`
4625
+ ).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
4626
+ }
4627
+
4437
4628
  // src/cli.ts
4438
- var VERSION = true ? "0.2.0" : "0.0.0-dev";
4629
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
4630
+ function finish(code) {
4631
+ process.exitCode = code;
4632
+ }
4633
+ var ArgumentError = class extends Error {
4634
+ };
4439
4635
  function argumentError(message) {
4440
- process.stderr.write(`canship: ${cleanForOutput(message)}
4441
- `);
4442
- process.exit(3);
4636
+ throw new ArgumentError(cleanForOutput(message));
4443
4637
  }
4444
4638
  function optionalValue(arg, name, fallback) {
4445
4639
  if (arg === name) return fallback;
@@ -4498,7 +4692,9 @@ function parseArgs(argv) {
4498
4692
  sarif: null,
4499
4693
  noConfig: false,
4500
4694
  help: false,
4501
- version: false
4695
+ version: false,
4696
+ listRules: false,
4697
+ noExcerpts: false
4502
4698
  };
4503
4699
  const positional = [];
4504
4700
  for (const arg of argv) {
@@ -4563,6 +4759,12 @@ function parseArgs(argv) {
4563
4759
  case "--no-config":
4564
4760
  args.noConfig = true;
4565
4761
  break;
4762
+ case "--list-rules":
4763
+ args.listRules = true;
4764
+ break;
4765
+ case "--no-excerpts":
4766
+ args.noExcerpts = true;
4767
+ break;
4566
4768
  case "--help":
4567
4769
  case "-h":
4568
4770
  args.help = true;
@@ -4600,11 +4802,13 @@ var HELP = `
4600
4802
  --baseline[=F] Hide findings already recorded in F, so only new
4601
4803
  ones are reported (default ${DEFAULT_BASELINE_PATH})
4602
4804
  --baseline-write[=F] Record the current findings as a new baseline and exit
4603
- --only=IDS Report only these rules (comma-separated, repeatable)
4604
- --skip=IDS Report everything except these rules
4805
+ --only=IDS Run matching rules (comma-separated, repeatable)
4806
+ --skip=IDS Exclude matching rules
4605
4807
  --sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
4606
4808
  (default canship.sarif)
4607
4809
  --no-config Ignore canship.config.json in the scanned directory
4810
+ --list-rules List rule IDs, scope, and limits; add --json for structured output
4811
+ --no-excerpts Omit source excerpts from every report; paths and descriptions remain
4608
4812
  -h, --help Show this help
4609
4813
  -v, --version Show version
4610
4814
 
@@ -4627,12 +4831,20 @@ async function main() {
4627
4831
  if (args.help) {
4628
4832
  process.stdout.write(`${HELP}
4629
4833
  `);
4630
- return process.exit(0);
4834
+ return finish(0);
4631
4835
  }
4632
4836
  if (args.version) {
4633
4837
  process.stdout.write(`${VERSION}
4634
4838
  `);
4635
- return process.exit(0);
4839
+ return finish(0);
4840
+ }
4841
+ if (args.listRules) {
4842
+ if (process.argv.slice(2).some((arg) => arg !== "--list-rules" && arg !== "--json")) {
4843
+ argumentError("--list-rules only supports --json; scan options and paths cannot be combined with it");
4844
+ }
4845
+ process.stdout.write(args.json ? `${JSON.stringify({ schemaVersion: 1, kind: "rule-catalog", version: VERSION, rules: RULE_CATALOG }, null, 2)}
4846
+ ` : renderRuleCatalog());
4847
+ return;
4636
4848
  }
4637
4849
  if (args.json && args.fixPrompt) {
4638
4850
  argumentError("--json and --fix-prompt are mutually exclusive");
@@ -4643,7 +4855,7 @@ async function main() {
4643
4855
  if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
4644
4856
  process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
4645
4857
  `);
4646
- return process.exit(3);
4858
+ return finish(3);
4647
4859
  }
4648
4860
  let config;
4649
4861
  try {
@@ -4652,7 +4864,7 @@ async function main() {
4652
4864
  if (err instanceof ConfigError) {
4653
4865
  process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4654
4866
  `);
4655
- return process.exit(3);
4867
+ return finish(3);
4656
4868
  }
4657
4869
  throw err;
4658
4870
  }
@@ -4687,7 +4899,7 @@ async function main() {
4687
4899
  ${cleanForOutput(String(err))}
4688
4900
  `
4689
4901
  );
4690
- return process.exit(3);
4902
+ return finish(3);
4691
4903
  }
4692
4904
  const accepted = scanned.findings.length;
4693
4905
  process.stdout.write(
@@ -4714,7 +4926,7 @@ ${cleanForOutput(String(err))}
4714
4926
  `
4715
4927
  );
4716
4928
  }
4717
- return process.exit(0);
4929
+ return finish(0);
4718
4930
  }
4719
4931
  let baselineSuppressed = 0;
4720
4932
  let baselineStale = 0;
@@ -4730,12 +4942,13 @@ ${cleanForOutput(String(err))}
4730
4942
  if (err instanceof BaselineError) {
4731
4943
  process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4732
4944
  `);
4733
- return process.exit(3);
4945
+ return finish(3);
4734
4946
  }
4735
4947
  throw err;
4736
4948
  }
4737
4949
  }
4738
4950
  const displayRoot = cleanForOutput(args.root);
4951
+ if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
4739
4952
  const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
4740
4953
  const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
4741
4954
  if (args.fixPrompt) {
@@ -4755,31 +4968,14 @@ ${cleanForOutput(String(err))}
4755
4968
  } else if (args.json) {
4756
4969
  process.stdout.write(
4757
4970
  `${JSON.stringify(
4758
- {
4971
+ createJsonReport({ ...result, findings: shown }, {
4759
4972
  version: VERSION,
4760
4973
  root: displayRoot,
4761
- filesScanned: result.filesScanned,
4762
- durationMs: result.durationMs,
4763
- // Machine consumers need the same distinction humans get: an empty
4764
- // findings array from a partial scan is not a pass.
4765
- partial: result.partial,
4766
- errors: result.errors,
4767
- skipped: result.skipped,
4768
- ignored: result.ignored,
4769
- ignoredFindings: result.ignoredFindings,
4770
- ruleSelection: result.ruleSelection,
4771
- vendored: result.vendored,
4772
- // The default view hides the detail, never the fact. A machine reading this
4773
- // must not see "no findings" while lower-confidence ones exist.
4774
4974
  hiddenLikely,
4775
- // Same reason, for the other thing that removes findings from this
4776
- // array. A CI job reading `findings: []` is entitled to know whether
4777
- // that means "nothing is wrong" or "a file in your repository says
4778
- // not to mention it".
4779
4975
  baselineSuppressed,
4780
4976
  baselineStale,
4781
- findings: shown
4782
- },
4977
+ excerptsOmitted: args.noExcerpts
4978
+ }),
4783
4979
  null,
4784
4980
  2
4785
4981
  )}
@@ -4828,7 +5024,7 @@ ${cleanForOutput(String(err))}
4828
5024
  ${cleanForOutput(String(err))}
4829
5025
  `
4830
5026
  );
4831
- return process.exit(3);
5027
+ return finish(3);
4832
5028
  }
4833
5029
  }
4834
5030
  if (args.report) {
@@ -4860,17 +5056,19 @@ ${cleanForOutput(String(err))}
4860
5056
  ${cleanForOutput(String(err))}
4861
5057
  `
4862
5058
  );
4863
- return process.exit(3);
5059
+ return finish(3);
4864
5060
  }
4865
5061
  }
4866
- if (verdictOf(result.findings).blocking > 0) return process.exit(1);
4867
- if (result.findings.length > 0) return process.exit(2);
4868
- if (result.partial && !bestEffort) return process.exit(3);
4869
- return process.exit(0);
5062
+ if (verdictOf(result.findings).blocking > 0) return finish(1);
5063
+ if (result.findings.length > 0) return finish(2);
5064
+ if (result.partial && !bestEffort) return finish(3);
5065
+ return finish(0);
4870
5066
  }
4871
5067
  main().catch((err) => {
4872
- process.stderr.write(`${red("canship: unexpected error")}
5068
+ if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
5069
+ `);
5070
+ else process.stderr.write(`${red("canship: unexpected error")}
4873
5071
  ${cleanForOutput(String(err))}
4874
5072
  `);
4875
- process.exit(3);
5073
+ finish(3);
4876
5074
  });