canship 0.2.0 → 0.2.1

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({
@@ -1350,6 +1338,20 @@ var secretsRule = {
1350
1338
  // src/rules/exposure.ts
1351
1339
  import { basename as basename3 } from "path";
1352
1340
  var JWT_SHAPED2 = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
1341
+ var FindingBuffer = class {
1342
+ items = [];
1343
+ overflow = false;
1344
+ push(finding) {
1345
+ if (this.items.length < MAX_FINDINGS_PER_FILE) {
1346
+ this.items.push(finding);
1347
+ return;
1348
+ }
1349
+ this.overflow = true;
1350
+ if (finding.confidence !== "certain") return;
1351
+ const index = this.items.findIndex((item) => item.confidence === "likely");
1352
+ if (index !== -1) this.items[index] = finding;
1353
+ }
1354
+ };
1353
1355
  function parseEnv(file) {
1354
1356
  const entries = [];
1355
1357
  file.lines.forEach((raw, i) => {
@@ -1366,35 +1368,20 @@ var exposureRule = {
1366
1368
  if (isEnvFile(name)) return true;
1367
1369
  return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
1368
1370
  },
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
- */
1371
+ /** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
1385
1372
  check(file, ctx) {
1386
1373
  const name = basename3(file.path);
1387
1374
  const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
1388
- if (findings.length <= MAX_FINDINGS_PER_FILE) return findings;
1375
+ if (!findings.overflow) return findings.items;
1389
1376
  ctx.reportIncomplete(
1390
1377
  "exposure/public-env",
1391
1378
  `${file.path} holds more than ${MAX_FINDINGS_PER_FILE} values exposed to the browser; the rest were not reported`
1392
1379
  );
1393
- return findings.slice(0, MAX_FINDINGS_PER_FILE);
1380
+ return findings.items;
1394
1381
  }
1395
1382
  };
1396
1383
  function checkEnvFile(file) {
1397
- const findings = [];
1384
+ const findings = new FindingBuffer();
1398
1385
  for (const entry of parseEnv(file)) {
1399
1386
  const prefix = publicPrefixOf(entry.key);
1400
1387
  if (!prefix) continue;
@@ -1473,7 +1460,7 @@ function checkEnvFile(file) {
1473
1460
  return findings;
1474
1461
  }
1475
1462
  function checkSourceFile(file) {
1476
- const findings = [];
1463
+ const findings = new FindingBuffer();
1477
1464
  const clientSide = isClientCode(file);
1478
1465
  const commentless = commentsMaskedOf(file).split(/\r?\n/);
1479
1466
  file.lines.forEach((line, i) => {
@@ -1654,6 +1641,7 @@ function historicalEvidence(root, gitExecutable, entry) {
1654
1641
  "--no-textconv",
1655
1642
  "--all",
1656
1643
  "--format=%H",
1644
+ `--max-count=${MAX_HISTORY_REVISIONS + 1}`,
1657
1645
  "--",
1658
1646
  entry.localPath
1659
1647
  ]) ?? "").split(/\r?\n/).filter(Boolean);
@@ -1731,9 +1719,7 @@ var gitleakRule = {
1731
1719
  findings.push({
1732
1720
  ruleId: "gitleak/env-tracked",
1733
1721
  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.
1722
+ // 只有非示例中的明确凭据使用确定置信度。
1737
1723
  confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
1738
1724
  title: evidence === "proof" && !scaffolding ? `${path} is committed to git, with a credential in it` : `${path} is committed to git`,
1739
1725
  file: path,
@@ -1760,7 +1746,7 @@ var gitleakRule = {
1760
1746
  if (history && history.unread > 0) {
1761
1747
  ctx.reportIncomplete(
1762
1748
  "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`
1749
+ `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
1750
  );
1765
1751
  }
1766
1752
  if (history && history.unreadable > 0) {
@@ -1776,8 +1762,7 @@ var gitleakRule = {
1776
1762
  ruleId: "gitleak/env-in-history",
1777
1763
  ...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
1778
1764
  severity: "P0",
1779
- // Claiming certainty about a file nobody could read would be the same
1780
- // overreach the tracked branch just stopped making.
1765
+ // 证据或上下文不足时降低置信度。
1781
1766
  confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
1782
1767
  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
1768
  file: path,
@@ -2251,8 +2236,7 @@ var firebaseRulesRule = {
2251
2236
  findings.push({
2252
2237
  ruleId: "firebase/open-rules",
2253
2238
  severity: "P1",
2254
- // Open writes are unambiguous. An open read might be intentional
2255
- // (a public catalogue, announcements), so it stays lower-confidence.
2239
+ // 开放写入为确定结果,公开读取需人工确认。
2256
2240
  confidence: canWrite ? "certain" : "likely",
2257
2241
  title: canWrite ? `Your ${product} rules let anyone ${ops} this data` : `Your ${product} rules make this data publicly readable`,
2258
2242
  file: file.path,
@@ -2290,8 +2274,7 @@ var firebaseRulesRule = {
2290
2274
  findings.push({
2291
2275
  ruleId: "firebase/test-mode-rules",
2292
2276
  severity: "P1",
2293
- // A hardcoded expiry date is never a deliberate authorisation design,
2294
- // so this stays certain whether or not writes are involved.
2277
+ // 固定日期测试模式均作为确定配置问题。
2295
2278
  confidence: "certain",
2296
2279
  title: expired ? `Your ${product} rules are in test mode and expired on ${dateStr}` : `Your ${product} rules allow ${ops} to anyone until ${dateStr}`,
2297
2280
  file: file.path,
@@ -2320,7 +2303,10 @@ import { posix } from "path";
2320
2303
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2321
2304
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
2322
2305
  function isApiRoute(path) {
2323
- return APP_ROUTER.test(path) || PAGES_ROUTER.test(path);
2306
+ return APP_ROUTER.test(routePathOf(path)) || PAGES_ROUTER.test(path);
2307
+ }
2308
+ function routePathOf(path) {
2309
+ return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
2324
2310
  }
2325
2311
  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
2312
  var AUTH_CALLBACK = /^\/api\/auth\/callback(?:\/[^/]+)?$/;
@@ -2328,7 +2314,7 @@ function isAuthEndpoint(url) {
2328
2314
  return AUTH_ENDPOINT_NAMES.test(url) || AUTH_CALLBACK.test(url);
2329
2315
  }
2330
2316
  function routeUrl(path) {
2331
- const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
2317
+ const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
2332
2318
  if (!m) return `/${path}`;
2333
2319
  const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
2334
2320
  return `/${url}`;
@@ -2359,17 +2345,37 @@ function controlledStatement(source, afterCondition) {
2359
2345
  return source.slice(start, end);
2360
2346
  }
2361
2347
  function hasConditionalAuthGuard(code) {
2362
- const starts = code.matchAll(/\bif\s*\(/g);
2363
- for (const match of starts) {
2348
+ const starts = /\bif\s*\(/g;
2349
+ let match;
2350
+ while ((match = starts.exec(code)) !== null) {
2364
2351
  const open = code.indexOf("(", match.index);
2365
2352
  const close = closingDelimiter(code, open, "(", ")");
2366
2353
  if (close === null) continue;
2367
2354
  const condition = code.slice(open + 1, close);
2368
2355
  const statement = controlledStatement(code, close + 1);
2369
- const stopsRequest = /\b(?:return|throw|redirect|notFound)\b/.test(statement);
2356
+ const body = statement.startsWith("{") ? statement.slice(1, -1) : statement;
2357
+ const pairs = delimiterPairs(body);
2358
+ let stopsRequest = false;
2359
+ for (let i = 0; i < body.length; i++) {
2360
+ if (/^(?:return|throw|redirect|notFound)\b/.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2361
+ stopsRequest = true;
2362
+ break;
2363
+ }
2364
+ if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
2365
+ const end = pairs.get(i);
2366
+ if (end !== void 0) i = end;
2367
+ }
2368
+ let statementStart = close + 1;
2369
+ while (/\s/.test(code[statementStart] ?? "")) statementStart++;
2370
+ starts.lastIndex = statementStart + statement.length;
2370
2371
  if (!stopsRequest) continue;
2371
2372
  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;
2373
+ const negative = !/&&|\?/.test(condition) && condition.split("||").some((part) => {
2374
+ const term = part.trim();
2375
+ 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);
2376
+ return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2377
+ });
2378
+ if (negative) return true;
2373
2379
  }
2374
2380
  return false;
2375
2381
  }
@@ -2462,6 +2468,16 @@ function unguardedOperations(file, ops) {
2462
2468
  }
2463
2469
  const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
2464
2470
  if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
2471
+ const prefixStart = Math.max(
2472
+ owner.start + 1,
2473
+ code.lastIndexOf(";", i - 1) + 1,
2474
+ code.lastIndexOf("\n", i - 1) + 1
2475
+ );
2476
+ const prefix = code.slice(prefixStart, i).trim();
2477
+ if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
2478
+ const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix);
2479
+ const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
2480
+ if (!awaited && !synchronous) continue;
2465
2481
  const close2 = pairs.get(i + call[0].length - 1);
2466
2482
  if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
2467
2483
  }
@@ -2559,7 +2575,7 @@ function usesSessionClient(route, sessionModules, allFiles) {
2559
2575
  return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
2560
2576
  }
2561
2577
  function moduleScopeOf(routePath) {
2562
- return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePath)?.[1] ?? "";
2578
+ return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
2563
2579
  }
2564
2580
  function importedModules(file, allFiles, aliasScope) {
2565
2581
  const found = [];
@@ -2899,9 +2915,7 @@ var apiAuthRule = {
2899
2915
  findings.push({
2900
2916
  ruleId: "api/admin-db-access-without-auth",
2901
2917
  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.
2918
+ // 管理员客户端缺少鉴权时使用确定置信度。
2905
2919
  confidence: "certain",
2906
2920
  title: `Anyone can call ${url} and it queries your database as admin`,
2907
2921
  file: route.path,
@@ -2929,9 +2943,7 @@ var apiAuthRule = {
2929
2943
  findings.push({
2930
2944
  ruleId: "api/db-write-without-auth",
2931
2945
  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.
2946
+ // 公开写入可能是业务设计,保留疑似置信度。
2935
2947
  confidence: "likely",
2936
2948
  title: `${url} writes to your database with no sign-in check`,
2937
2949
  file: route.path,
@@ -3184,8 +3196,7 @@ var corsRule = {
3184
3196
  findings.push({
3185
3197
  ruleId: "cors/reflected-origin-with-credentials",
3186
3198
  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.
3199
+ // 明确的来源回显和凭据配置使用确定置信度。
3189
3200
  confidence: "certain",
3190
3201
  title: "Any website can make signed-in requests to your API and read the answer",
3191
3202
  file: file.path,
@@ -3207,8 +3218,7 @@ var corsRule = {
3207
3218
  findings.push({
3208
3219
  ruleId: "cors/wildcard-with-credentials",
3209
3220
  severity: "P2",
3210
- // Not a judgement call: the specification forbids this pair, so every
3211
- // browser rejects it.
3221
+ // 通配符与凭据组合违反浏览器跨域约束。
3212
3222
  confidence: "certain",
3213
3223
  title: "This CORS setup is rejected by every browser, so the requests it enables never work",
3214
3224
  file: file.path,
@@ -3247,8 +3257,7 @@ var RULE_IDS = [
3247
3257
  "gitleak/env-in-history",
3248
3258
  "gitleak/env-tracked",
3249
3259
  "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.
3260
+ // 凭据规则 ID 从共享格式表生成。
3252
3261
  ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
3253
3262
  ];
3254
3263
  function ruleMatches(selector, ruleId) {
@@ -3257,6 +3266,13 @@ function ruleMatches(selector, ruleId) {
3257
3266
  function isKnownSelector(selector) {
3258
3267
  return RULE_IDS.some((id) => ruleMatches(selector, id));
3259
3268
  }
3269
+ function shouldRunRule(id, only, skip) {
3270
+ if (only.length === 0 && skip.length === 0) return true;
3271
+ const namespace = id.split("/")[0];
3272
+ return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
3273
+ (candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
3274
+ );
3275
+ }
3260
3276
 
3261
3277
  // src/engine.ts
3262
3278
  import { createHash as createHash2 } from "crypto";
@@ -3299,28 +3315,11 @@ function sanitize(findings, files) {
3299
3315
  // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
3300
3316
  ...sourceIdentity(f),
3301
3317
  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.
3318
+ // 按段落清理,保留段落之间的结构。
3304
3319
  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.
3320
+ // 文件路径也必须经过输出清理。
3308
3321
  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.
3322
+ // 先脱敏再截断,避免截断导致凭据特征失效。
3324
3323
  excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
3325
3324
  fix: f.fix.map(clean),
3326
3325
  ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
@@ -3394,19 +3393,15 @@ async function scan(root, options = {}) {
3394
3393
  const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
3395
3394
  const findings = [];
3396
3395
  const errors = [];
3396
+ const fileRules = FILE_RULES.filter((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
3397
+ const projectRules = PROJECT_RULES.filter((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
3397
3398
  const incompleteSeen = /* @__PURE__ */ new Set();
3398
3399
  const ctx = {
3399
3400
  root,
3400
3401
  files,
3401
3402
  git: git2,
3402
3403
  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.
3404
+ // 统一记录不完整状态,避免重复提示。
3410
3405
  reportIncomplete: (ruleId, message) => {
3411
3406
  if (incompleteSeen.has(`${ruleId} ${message}`)) return;
3412
3407
  incompleteSeen.add(`${ruleId} ${message}`);
@@ -3414,16 +3409,16 @@ async function scan(root, options = {}) {
3414
3409
  }
3415
3410
  };
3416
3411
  for (const file of files) {
3417
- for (const rule of FILE_RULES) {
3418
- if (!rule.appliesTo(file)) continue;
3412
+ for (const rule of fileRules) {
3419
3413
  try {
3414
+ if (!rule.appliesTo(file)) continue;
3420
3415
  findings.push(...rule.check(file, ctx));
3421
3416
  } catch (err) {
3422
3417
  errors.push({ ruleId: rule.id, file: file.path, message: messageOf(err), kind: "crashed" });
3423
3418
  }
3424
3419
  }
3425
3420
  }
3426
- for (const rule of PROJECT_RULES) {
3421
+ for (const rule of projectRules) {
3427
3422
  try {
3428
3423
  findings.push(...await rule.check(ctx));
3429
3424
  } catch (err) {
@@ -3435,8 +3430,19 @@ async function scan(root, options = {}) {
3435
3430
  files
3436
3431
  );
3437
3432
  const selected = applyRuleSelection(kept, options);
3433
+ const counts = /* @__PURE__ */ new Map();
3434
+ const bounded = sortFindings(selected.kept).filter((finding) => {
3435
+ const count = (counts.get(finding.file) ?? 0) + 1;
3436
+ counts.set(finding.file, count);
3437
+ if (count <= MAX_FINDINGS_PER_FILE) return true;
3438
+ if (count === MAX_FINDINGS_PER_FILE + 1) ctx.reportIncomplete(
3439
+ "engine/findings-limit",
3440
+ `${finding.file ?? "project"} has more than ${MAX_FINDINGS_PER_FILE} findings; remaining findings were not reported`
3441
+ );
3442
+ return false;
3443
+ });
3438
3444
  return {
3439
- findings: sanitize(sortFindings(selected.kept), files),
3445
+ findings: sanitize(bounded, files),
3440
3446
  filesScanned: files.length,
3441
3447
  durationMs: Date.now() - started,
3442
3448
  errors: errors.map((e) => ({
@@ -3446,29 +3452,16 @@ async function scan(root, options = {}) {
3446
3452
  })),
3447
3453
  skipped: sanitizeSkippedForOutput(skipped),
3448
3454
  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.
3455
+ // 清理被忽略结果的路径。
3452
3456
  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.
3457
+ // 选择器来自外部输入,输出前也需清理。
3455
3458
  ruleSelection: selected.selection === null ? null : {
3456
3459
  only: selected.selection.only.map(clean),
3457
3460
  skip: selected.selection.skip.map(clean),
3458
3461
  removed: selected.selection.removed
3459
3462
  },
3460
3463
  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.
3464
+ // 主动忽略不影响完整性;错误、跳过或零文件扫描均标记为未完成。
3472
3465
  partial: errors.length > 0 || skipped.length > 0 || files.length === 0
3473
3466
  };
3474
3467
  }
@@ -3543,8 +3536,7 @@ function renderBaseline(opts) {
3543
3536
  }
3544
3537
  if (stale > 0) {
3545
3538
  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.
3539
+ // 不规则复数单独处理。
3548
3540
  `${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
3549
3541
  );
3550
3542
  }
@@ -3938,22 +3930,17 @@ function renderHtml(result, opts) {
3938
3930
  const baselineStale = opts.baselineStale ?? 0;
3939
3931
  const { blocking: certain, minor, unsure } = verdictOf(findings);
3940
3932
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
3941
- // Examined nothing, so there is nothing to report either way.
3933
+ // 未扫描文件时不显示通过结论。
3942
3934
  `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
3943
3935
  ) : 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.
3936
+ // 扫描不完整时不得显示正常通过。
3946
3937
  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
3938
  ) : 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.
3939
+ // 基线抑制结果时明确说明,避免误报为项目无问题。
3952
3940
  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
3941
  ) : 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
3942
  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.
3943
+ // 零文件扫描不能显示已完成的检查清单。
3957
3944
  `<div class="clean-note">
3958
3945
  <p>canship found no files it could read at this path, so none of its checks ran.
3959
3946
  <strong>This is not a clean result &mdash; it is an empty one.</strong></p>
@@ -3988,10 +3975,7 @@ function renderHtml(result, opts) {
3988
3975
  const incomplete = result.partial ? `<div class="incomplete">
3989
3976
  <h2>Not everything was checked</h2>
3990
3977
  <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.
3978
+ ${// 工作区零文件时仍可能产生 Git 历史结果。
3995
3979
  result.filesScanned === 0 ? `<li>no files could be read at this path, so every file-based check was skipped</li>` : ""}
3996
3980
  ${result.errors.map(
3997
3981
  (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>`
@@ -4276,8 +4260,7 @@ function rulesOf(findings) {
4276
4260
  text: [...f.why, ...f.fix.length > 0 ? ["How to fix:", ...f.fix] : []].join("\n")
4277
4261
  },
4278
4262
  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.
4263
+ // 工具专属严重度和置信度保存在扩展属性中。
4281
4264
  "canship-severity": f.severity,
4282
4265
  "canship-confidence": f.confidence
4283
4266
  },
@@ -4289,15 +4272,12 @@ function resultsOf(findings) {
4289
4272
  ruleId: f.ruleId,
4290
4273
  level: levelOf(f),
4291
4274
  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.
4275
+ // 无可定位文件时不构造虚假位置。
4296
4276
  locations: f.file === null ? [] : [
4297
4277
  {
4298
4278
  physicalLocation: {
4299
- // Already relative and already slash-separated, on Windows too.
4300
- artifactLocation: { uri: f.file },
4279
+ // 按路径段进行 URI 编码,保留目录分隔符。
4280
+ artifactLocation: { uri: f.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
4301
4281
  ...f.line === null ? {} : { region: { startLine: f.line } }
4302
4282
  }
4303
4283
  }
@@ -4308,6 +4288,10 @@ function resultsOf(findings) {
4308
4288
  function renderSarif(result, opts) {
4309
4289
  const { findings } = result;
4310
4290
  const notifications = [
4291
+ ...result.skipped.map((item) => ({
4292
+ level: "warning",
4293
+ message: { text: `${item.path}: ${item.reason}${item.detail ? ` \u2014 ${item.detail}` : ""}` }
4294
+ })),
4311
4295
  ...result.errors.map((e) => ({
4312
4296
  level: e.kind === "crashed" ? "error" : "warning",
4313
4297
  message: { text: `${e.ruleId}: ${e.message}` }
@@ -4330,9 +4314,7 @@ function renderSarif(result, opts) {
4330
4314
  results: resultsOf(findings),
4331
4315
  invocations: [
4332
4316
  {
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.
4317
+ // 该字段表示执行是否完整,与是否发现问题无关。
4336
4318
  executionSuccessful: !result.partial,
4337
4319
  ...notifications.length > 0 ? { toolExecutionNotifications: notifications } : {}
4338
4320
  }
@@ -4435,7 +4417,7 @@ function loadConfig(root) {
4435
4417
  }
4436
4418
 
4437
4419
  // src/cli.ts
4438
- var VERSION = true ? "0.2.0" : "0.0.0-dev";
4420
+ var VERSION = true ? "0.2.1" : "0.0.0-dev";
4439
4421
  function argumentError(message) {
4440
4422
  process.stderr.write(`canship: ${cleanForOutput(message)}
4441
4423
  `);
@@ -4600,8 +4582,8 @@ var HELP = `
4600
4582
  --baseline[=F] Hide findings already recorded in F, so only new
4601
4583
  ones are reported (default ${DEFAULT_BASELINE_PATH})
4602
4584
  --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
4585
+ --only=IDS Run matching rules (comma-separated, repeatable)
4586
+ --skip=IDS Exclude matching rules
4605
4587
  --sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
4606
4588
  (default canship.sarif)
4607
4589
  --no-config Ignore canship.config.json in the scanned directory
@@ -4760,8 +4742,7 @@ ${cleanForOutput(String(err))}
4760
4742
  root: displayRoot,
4761
4743
  filesScanned: result.filesScanned,
4762
4744
  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.
4745
+ // 空结果不能掩盖扫描未完成。
4765
4746
  partial: result.partial,
4766
4747
  errors: result.errors,
4767
4748
  skipped: result.skipped,
@@ -4769,13 +4750,9 @@ ${cleanForOutput(String(err))}
4769
4750
  ignoredFindings: result.ignoredFindings,
4770
4751
  ruleSelection: result.ruleSelection,
4771
4752
  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.
4753
+ // 隐藏详情时仍披露疑似结果数量。
4774
4754
  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".
4755
+ // 明确披露基线抑制和过期条目数量。
4779
4756
  baselineSuppressed,
4780
4757
  baselineStale,
4781
4758
  findings: shown