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/README-zh-CN.md +125 -0
- package/README.md +125 -204
- package/dist/cli.js +155 -178
- package/package.json +2 -2
- package/README.zh-CN.md +0 -206
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
121
|
-
// in the host does not cause a miss.
|
|
98
|
+
// 仅用密码组判断占位符,避免主机名影响判断。
|
|
122
99
|
secretGroup: 1,
|
|
123
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 =
|
|
2363
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
3303
|
-
// every newline inside them. See Finding.why.
|
|
3318
|
+
// 按段落清理,保留段落之间的结构。
|
|
3304
3319
|
why: f.why.map(clean),
|
|
3305
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
3933
|
+
// 未扫描文件时不显示通过结论。
|
|
3942
3934
|
`<div class="verdict warn">No files were scanned — nothing was checked</div>`
|
|
3943
3935
|
) : result.partial ? (
|
|
3944
|
-
//
|
|
3945
|
-
// and a scan that skipped files cannot make one.
|
|
3936
|
+
// 扫描不完整时不得显示正常通过。
|
|
3946
3937
|
hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings — but not everything was checked</div>`
|
|
3947
3938
|
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
3948
|
-
//
|
|
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 — ${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")} — do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix — 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
|
-
//
|
|
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 — 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
|
-
${//
|
|
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>` : ""} — ${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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
4604
|
-
--skip=IDS
|
|
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
|
-
//
|
|
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
|
-
//
|
|
4773
|
-
// must not see "no findings" while lower-confidence ones exist.
|
|
4753
|
+
// 隐藏详情时仍披露疑似结果数量。
|
|
4774
4754
|
hiddenLikely,
|
|
4775
|
-
//
|
|
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
|