canship 0.1.1 → 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 +81 -70
- package/dist/cli.js +1289 -319
- package/package.json +2 -2
- package/README.zh-CN.md +0 -116
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve2 } from "path";
|
|
5
|
-
import { existsSync as
|
|
4
|
+
import { isAbsolute as isAbsolute2, relative as relative_, resolve as resolve2 } from "path";
|
|
5
|
+
import { existsSync as existsSync3, realpathSync as realpathSync2, statSync as statSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
6
6
|
|
|
7
7
|
// src/rules/patterns.ts
|
|
8
8
|
var IRRELEVANT_HOSTS = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|.*\.?example\.(?:com|org|net)|.*\.(?:test|invalid|localhost))$/i;
|
|
@@ -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."
|
|
@@ -205,8 +170,8 @@ function redactSecret(secret) {
|
|
|
205
170
|
}
|
|
206
171
|
function redactLine(line, secret) {
|
|
207
172
|
const trimmed = line.trim();
|
|
208
|
-
if (!secret) return
|
|
209
|
-
return
|
|
173
|
+
if (!secret) return trimmed;
|
|
174
|
+
return trimmed.split(secret).join(redactSecret(secret));
|
|
210
175
|
}
|
|
211
176
|
var JWT_SHAPED = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
|
|
212
177
|
function redactAll(text) {
|
|
@@ -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);
|
|
@@ -421,35 +387,47 @@ function gitEnvironment() {
|
|
|
421
387
|
env.PAGER = "";
|
|
422
388
|
return env;
|
|
423
389
|
}
|
|
424
|
-
function
|
|
390
|
+
function hardeningArgs(root) {
|
|
425
391
|
const worktree = gitRootAbove(root) ?? root;
|
|
426
392
|
const noHooks = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
427
|
-
return
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
393
|
+
return [
|
|
394
|
+
"-c",
|
|
395
|
+
`core.worktree=${worktree}`,
|
|
396
|
+
"-c",
|
|
397
|
+
"core.bare=false",
|
|
398
|
+
"-c",
|
|
399
|
+
"core.fsmonitor=false",
|
|
400
|
+
"-c",
|
|
401
|
+
`core.hooksPath=${noHooks}`
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
function execGitSync(executable, root, args, options = {}) {
|
|
405
|
+
return execFileSync(executable, [...hardeningArgs(root), ...args], {
|
|
406
|
+
timeout: GIT_TIMEOUT_MS,
|
|
407
|
+
cwd: root,
|
|
408
|
+
encoding: "utf8",
|
|
409
|
+
maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
|
|
410
|
+
stdio: ["ignore", "pipe", options.stderr ?? "ignore"],
|
|
411
|
+
windowsHide: true,
|
|
412
|
+
env: gitEnvironment()
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
function execGitBatch(executable, root, args, input, options = {}) {
|
|
416
|
+
return execFileSync(executable, [...hardeningArgs(root), ...args], {
|
|
417
|
+
timeout: GIT_TIMEOUT_MS,
|
|
418
|
+
cwd: root,
|
|
419
|
+
input,
|
|
420
|
+
maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
|
|
421
|
+
stdio: ["pipe", "pipe", options.stderr ?? "ignore"],
|
|
422
|
+
windowsHide: true,
|
|
423
|
+
env: gitEnvironment()
|
|
424
|
+
});
|
|
449
425
|
}
|
|
450
426
|
|
|
451
427
|
// src/walker.ts
|
|
452
428
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
429
|
+
var MAX_SCAN_BYTES = 128 * 1024 * 1024;
|
|
430
|
+
var MAX_SCAN_FILES = 1e4;
|
|
453
431
|
var MAX_WALK_DEPTH = 16;
|
|
454
432
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
455
433
|
"node_modules",
|
|
@@ -468,8 +446,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
468
446
|
".venv",
|
|
469
447
|
"venv",
|
|
470
448
|
".cache",
|
|
471
|
-
//
|
|
472
|
-
// these to look for credential files is pure cost.
|
|
449
|
+
// 排除其他生态的构建产物和工具缓存。
|
|
473
450
|
".dart_tool",
|
|
474
451
|
".gradle",
|
|
475
452
|
"Pods",
|
|
@@ -507,7 +484,7 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
507
484
|
".yml",
|
|
508
485
|
".toml",
|
|
509
486
|
".sql",
|
|
510
|
-
// Firebase
|
|
487
|
+
// Firebase 规则文件。
|
|
511
488
|
".rules",
|
|
512
489
|
".env",
|
|
513
490
|
".sh",
|
|
@@ -535,10 +512,29 @@ var CREDENTIAL_FILENAMES = /* @__PURE__ */ new Set([
|
|
|
535
512
|
"id_ed25519"
|
|
536
513
|
]);
|
|
537
514
|
var PROBE_BYTES = 4096;
|
|
538
|
-
var IGNORE_FILE_MARKER = /^\s*(?:\/\/|#|--|\*\/?|\/\*|<!--)
|
|
515
|
+
var IGNORE_FILE_MARKER = /^\s*(?:(?:\/\/|#|--|\*\/?|\/\*|<!--)\s*)?canship-ignore-file(?:\s*(?:\*\/|-->))?\s*$/;
|
|
539
516
|
function hasIgnoreMarker(lines) {
|
|
540
517
|
return lines.some((line) => IGNORE_FILE_MARKER.test(line));
|
|
541
518
|
}
|
|
519
|
+
var IGNORE_LINE_MARKER = /^\s*(?:(?:\/\/|#|--|\*\/?|\/\*|<!--)\s*)?canship-ignore-next-line(?:\s+([\w./-]+))?(?:\s*(?:\*\/|-->))?\s*$/;
|
|
520
|
+
function ignoredLinesOf(lines) {
|
|
521
|
+
const found = /* @__PURE__ */ new Map();
|
|
522
|
+
lines.forEach((line, index) => {
|
|
523
|
+
const match = IGNORE_LINE_MARKER.exec(line);
|
|
524
|
+
if (match === null) return;
|
|
525
|
+
const governed = index + 2;
|
|
526
|
+
const ruleId = match[1];
|
|
527
|
+
if (ruleId === void 0) {
|
|
528
|
+
found.set(governed, null);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (found.has(governed) && found.get(governed) === null) return;
|
|
532
|
+
const rules = found.get(governed) ?? /* @__PURE__ */ new Set();
|
|
533
|
+
rules.add(ruleId);
|
|
534
|
+
found.set(governed, rules);
|
|
535
|
+
});
|
|
536
|
+
return found;
|
|
537
|
+
}
|
|
542
538
|
var SKIP_FILENAMES = /* @__PURE__ */ new Set(["bun.lockb"]);
|
|
543
539
|
function isEnvFile(name) {
|
|
544
540
|
const lower = name.toLowerCase();
|
|
@@ -783,7 +779,7 @@ function isExampleContext(relPath) {
|
|
|
783
779
|
if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
|
|
784
780
|
return false;
|
|
785
781
|
}
|
|
786
|
-
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root)) {
|
|
782
|
+
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}) {
|
|
787
783
|
const skipped = [];
|
|
788
784
|
const ignored = [];
|
|
789
785
|
const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
|
|
@@ -812,6 +808,10 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
812
808
|
forced.add(hidden);
|
|
813
809
|
}
|
|
814
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;
|
|
815
815
|
for (const relPath of candidates) {
|
|
816
816
|
if (!forced.has(relPath) && !shouldScan(relPath)) continue;
|
|
817
817
|
const absPath = join2(root, relPath);
|
|
@@ -832,7 +832,22 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
832
832
|
});
|
|
833
833
|
continue;
|
|
834
834
|
}
|
|
835
|
-
|
|
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);
|
|
836
851
|
} catch (err) {
|
|
837
852
|
if (!isMissing(err)) {
|
|
838
853
|
skipped.push({
|
|
@@ -892,68 +907,100 @@ function parseEnvLine(raw) {
|
|
|
892
907
|
}
|
|
893
908
|
|
|
894
909
|
// src/mask.ts
|
|
910
|
+
var SLASH = 47;
|
|
911
|
+
var STAR = 42;
|
|
912
|
+
var DOUBLE_QUOTE = 34;
|
|
913
|
+
var SINGLE_QUOTE = 39;
|
|
914
|
+
var BACKTICK = 96;
|
|
915
|
+
var BACKSLASH = 92;
|
|
916
|
+
var DOLLAR = 36;
|
|
917
|
+
var OPEN_BRACE = 123;
|
|
918
|
+
var CLOSE_BRACE = 125;
|
|
919
|
+
var NEWLINE = 10;
|
|
920
|
+
var SPACE = 32;
|
|
921
|
+
function codesOf(src) {
|
|
922
|
+
const out = new Uint16Array(src.length);
|
|
923
|
+
for (let i = 0; i < src.length; i++) out[i] = src.charCodeAt(i);
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
var CHUNK = 8192;
|
|
927
|
+
function stringOf(out) {
|
|
928
|
+
let text = "";
|
|
929
|
+
for (let i = 0; i < out.length; i += CHUNK) {
|
|
930
|
+
const end = i + CHUNK < out.length ? i + CHUNK : out.length;
|
|
931
|
+
text += String.fromCharCode.apply(null, out.subarray(i, end));
|
|
932
|
+
}
|
|
933
|
+
return text;
|
|
934
|
+
}
|
|
895
935
|
function blank(out, from, to) {
|
|
896
|
-
|
|
897
|
-
|
|
936
|
+
const end = to < out.length ? to : out.length;
|
|
937
|
+
for (let i = from; i < end; i++) {
|
|
938
|
+
if (out[i] !== NEWLINE) out[i] = SPACE;
|
|
898
939
|
}
|
|
899
940
|
}
|
|
900
941
|
function endOfString(src, start, quote) {
|
|
942
|
+
const length = src.length;
|
|
901
943
|
let i = start + 1;
|
|
902
|
-
while (i <
|
|
903
|
-
|
|
944
|
+
while (i < length) {
|
|
945
|
+
const ch = src.charCodeAt(i);
|
|
946
|
+
if (ch === BACKSLASH) {
|
|
904
947
|
i += 2;
|
|
905
948
|
continue;
|
|
906
949
|
}
|
|
907
|
-
if (
|
|
950
|
+
if (ch === quote) return i + 1;
|
|
908
951
|
i++;
|
|
909
952
|
}
|
|
910
|
-
return
|
|
953
|
+
return length;
|
|
911
954
|
}
|
|
912
955
|
function maskTemplate(src, out, start) {
|
|
956
|
+
const length = src.length;
|
|
913
957
|
let i = start + 1;
|
|
914
958
|
let literalFrom = i;
|
|
915
|
-
while (i <
|
|
916
|
-
|
|
959
|
+
while (i < length) {
|
|
960
|
+
const ch = src.charCodeAt(i);
|
|
961
|
+
if (ch === BACKSLASH) {
|
|
917
962
|
i += 2;
|
|
918
963
|
continue;
|
|
919
964
|
}
|
|
920
|
-
if (
|
|
965
|
+
if (ch === BACKTICK) {
|
|
921
966
|
blank(out, literalFrom, i);
|
|
922
967
|
return i + 1;
|
|
923
968
|
}
|
|
924
|
-
if (
|
|
969
|
+
if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
|
|
925
970
|
blank(out, literalFrom, i);
|
|
926
971
|
let depth = 0;
|
|
927
972
|
let j = i + 1;
|
|
928
|
-
while (j <
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
973
|
+
while (j < length) {
|
|
974
|
+
const inner = src.charCodeAt(j);
|
|
975
|
+
if (inner === SLASH) {
|
|
976
|
+
const next = src.charCodeAt(j + 1);
|
|
977
|
+
if (next === SLASH) {
|
|
978
|
+
const end = src.indexOf("\n", j);
|
|
979
|
+
const stop = end === -1 ? length : end;
|
|
980
|
+
blank(out, j, stop);
|
|
981
|
+
j = stop;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (next === STAR) {
|
|
985
|
+
const close = src.indexOf("*/", j + 2);
|
|
986
|
+
const stop = close === -1 ? length : close + 2;
|
|
987
|
+
blank(out, j, stop);
|
|
988
|
+
j = stop;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
944
991
|
}
|
|
945
|
-
if (
|
|
946
|
-
const stop = endOfString(src, j,
|
|
992
|
+
if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
|
|
993
|
+
const stop = endOfString(src, j, inner);
|
|
947
994
|
blank(out, j + 1, stop - 1);
|
|
948
995
|
j = stop;
|
|
949
996
|
continue;
|
|
950
997
|
}
|
|
951
|
-
if (
|
|
998
|
+
if (inner === BACKTICK) {
|
|
952
999
|
j = maskTemplate(src, out, j);
|
|
953
1000
|
continue;
|
|
954
1001
|
}
|
|
955
|
-
if (
|
|
956
|
-
else if (
|
|
1002
|
+
if (inner === OPEN_BRACE) depth++;
|
|
1003
|
+
else if (inner === CLOSE_BRACE) {
|
|
957
1004
|
depth--;
|
|
958
1005
|
if (depth === 0) {
|
|
959
1006
|
j++;
|
|
@@ -968,70 +1015,76 @@ function maskTemplate(src, out, start) {
|
|
|
968
1015
|
}
|
|
969
1016
|
i++;
|
|
970
1017
|
}
|
|
971
|
-
blank(out, literalFrom,
|
|
972
|
-
return
|
|
1018
|
+
blank(out, literalFrom, length);
|
|
1019
|
+
return length;
|
|
973
1020
|
}
|
|
974
1021
|
function maskJsComments(src) {
|
|
975
|
-
const
|
|
1022
|
+
const length = src.length;
|
|
1023
|
+
const out = codesOf(src);
|
|
976
1024
|
let i = 0;
|
|
977
|
-
while (i <
|
|
978
|
-
const ch = src
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1025
|
+
while (i < length) {
|
|
1026
|
+
const ch = src.charCodeAt(i);
|
|
1027
|
+
if (ch === SLASH) {
|
|
1028
|
+
const next = src.charCodeAt(i + 1);
|
|
1029
|
+
if (next === SLASH) {
|
|
1030
|
+
const end = src.indexOf("\n", i);
|
|
1031
|
+
const stop = end === -1 ? length : end;
|
|
1032
|
+
blank(out, i, stop);
|
|
1033
|
+
i = stop;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (next === STAR) {
|
|
1037
|
+
const close = src.indexOf("*/", i + 2);
|
|
1038
|
+
const stop = close === -1 ? length : close + 2;
|
|
1039
|
+
blank(out, i, stop);
|
|
1040
|
+
i = stop;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
993
1043
|
}
|
|
994
|
-
if (ch ===
|
|
1044
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE || ch === BACKTICK) {
|
|
995
1045
|
i = endOfString(src, i, ch);
|
|
996
1046
|
continue;
|
|
997
1047
|
}
|
|
998
1048
|
i++;
|
|
999
1049
|
}
|
|
1000
|
-
return out
|
|
1050
|
+
return stringOf(out);
|
|
1001
1051
|
}
|
|
1002
1052
|
function maskJsNoise(src) {
|
|
1003
|
-
const
|
|
1053
|
+
const length = src.length;
|
|
1054
|
+
const out = codesOf(src);
|
|
1004
1055
|
let i = 0;
|
|
1005
|
-
while (i <
|
|
1006
|
-
const ch = src
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1056
|
+
while (i < length) {
|
|
1057
|
+
const ch = src.charCodeAt(i);
|
|
1058
|
+
if (ch === SLASH) {
|
|
1059
|
+
const next = src.charCodeAt(i + 1);
|
|
1060
|
+
if (next === SLASH) {
|
|
1061
|
+
const end = src.indexOf("\n", i);
|
|
1062
|
+
const stop = end === -1 ? length : end;
|
|
1063
|
+
blank(out, i, stop);
|
|
1064
|
+
i = stop;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (next === STAR) {
|
|
1068
|
+
const close = src.indexOf("*/", i + 2);
|
|
1069
|
+
const stop = close === -1 ? length : close + 2;
|
|
1070
|
+
blank(out, i, stop);
|
|
1071
|
+
i = stop;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1021
1074
|
}
|
|
1022
|
-
if (ch ===
|
|
1075
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1023
1076
|
const stop = endOfString(src, i, ch);
|
|
1024
1077
|
blank(out, i + 1, stop - 1);
|
|
1025
1078
|
i = stop;
|
|
1026
1079
|
continue;
|
|
1027
1080
|
}
|
|
1028
|
-
if (ch ===
|
|
1081
|
+
if (ch === BACKTICK) {
|
|
1029
1082
|
i = maskTemplate(src, out, i);
|
|
1030
1083
|
continue;
|
|
1031
1084
|
}
|
|
1032
1085
|
i++;
|
|
1033
1086
|
}
|
|
1034
|
-
return out
|
|
1087
|
+
return stringOf(out);
|
|
1035
1088
|
}
|
|
1036
1089
|
var commentCache = /* @__PURE__ */ new WeakMap();
|
|
1037
1090
|
var noiseCache = /* @__PURE__ */ new WeakMap();
|
|
@@ -1122,12 +1175,14 @@ function isClientCode(file) {
|
|
|
1122
1175
|
}
|
|
1123
1176
|
return false;
|
|
1124
1177
|
}
|
|
1125
|
-
|
|
1178
|
+
var MENTIONS_SUPABASE = /supabase/i;
|
|
1179
|
+
function isSupabaseProject(ctx, files = ctx.files, scope = "") {
|
|
1126
1180
|
const isSupabaseUrlName = (name) => name === "SUPABASE_URL" || name.endsWith("_SUPABASE_URL");
|
|
1127
|
-
for (const file of
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1181
|
+
for (const file of files) {
|
|
1182
|
+
const path = scope === "" ? file.path : file.path.slice(scope.length + 1);
|
|
1183
|
+
if (path === "supabase" || path.startsWith("supabase/")) return true;
|
|
1184
|
+
if (path.includes("/supabase/migrations/")) return true;
|
|
1185
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
1131
1186
|
if (isEnvFile(name)) {
|
|
1132
1187
|
for (const line of file.lines) {
|
|
1133
1188
|
const entry = parseEnvLine(line);
|
|
@@ -1148,6 +1203,7 @@ function isSupabaseProject(ctx) {
|
|
|
1148
1203
|
}
|
|
1149
1204
|
continue;
|
|
1150
1205
|
}
|
|
1206
|
+
if (!MENTIONS_SUPABASE.test(file.content)) continue;
|
|
1151
1207
|
const commentsRemoved = commentsMaskedOf(file);
|
|
1152
1208
|
const code = noiseMaskedOf(file);
|
|
1153
1209
|
const supabaseImport = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s*)['"]@supabase\/(?:supabase-js|ssr)(?:\/[^'"]*)?['"]/g;
|
|
@@ -1223,18 +1279,17 @@ var secretsRule = {
|
|
|
1223
1279
|
if (pat.publicByDesign) continue;
|
|
1224
1280
|
pat.pattern.lastIndex = 0;
|
|
1225
1281
|
let match;
|
|
1226
|
-
if (findings.length >= MAX_FINDINGS_PER_FILE) break;
|
|
1227
1282
|
while ((match = pat.pattern.exec(file.content)) !== null) {
|
|
1283
|
+
const secret = match[0];
|
|
1284
|
+
if (isPlaceholder(secretPartOf(match, pat))) continue;
|
|
1285
|
+
if (pat.ignoreIf?.(match)) continue;
|
|
1228
1286
|
if (findings.length >= MAX_FINDINGS_PER_FILE) {
|
|
1229
1287
|
ctx.reportIncomplete(
|
|
1230
1288
|
"secrets/hardcoded",
|
|
1231
1289
|
`${file.path} holds more than ${MAX_FINDINGS_PER_FILE} credential-shaped strings; the rest were not reported`
|
|
1232
1290
|
);
|
|
1233
|
-
|
|
1291
|
+
return findings;
|
|
1234
1292
|
}
|
|
1235
|
-
const secret = match[0];
|
|
1236
|
-
if (isPlaceholder(secretPartOf(match, pat))) continue;
|
|
1237
|
-
if (pat.ignoreIf?.(match)) continue;
|
|
1238
1293
|
const line = lineNumberAt(lineStarts, match.index);
|
|
1239
1294
|
const rawLine = file.lines[line - 1] ?? "";
|
|
1240
1295
|
const clientSide = isClientCode(file);
|
|
@@ -1283,6 +1338,20 @@ var secretsRule = {
|
|
|
1283
1338
|
// src/rules/exposure.ts
|
|
1284
1339
|
import { basename as basename3 } from "path";
|
|
1285
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
|
+
};
|
|
1286
1355
|
function parseEnv(file) {
|
|
1287
1356
|
const entries = [];
|
|
1288
1357
|
file.lines.forEach((raw, i) => {
|
|
@@ -1299,35 +1368,20 @@ var exposureRule = {
|
|
|
1299
1368
|
if (isEnvFile(name)) return true;
|
|
1300
1369
|
return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
|
|
1301
1370
|
},
|
|
1302
|
-
/**
|
|
1303
|
-
* The ceiling, applied here rather than inside each branch.
|
|
1304
|
-
*
|
|
1305
|
-
* This rule was the last one without one. secrets.ts, firebase.ts and
|
|
1306
|
-
* supabase.ts all cap and all say so — the constant was pulled into limits.ts
|
|
1307
|
-
* precisely so the reasoning would not have to be rediscovered — and exposure
|
|
1308
|
-
* never adopted it. A `.env` holding 3,000 public-prefixed credential names
|
|
1309
|
-
* produced 3,000 findings, 2.36 MB of JSON and 48,046 lines of terminal
|
|
1310
|
-
* output, with `partial` false and `errors` empty: the identical shape of the
|
|
1311
|
-
* bug firebase.ts records in its own comment.
|
|
1312
|
-
*
|
|
1313
|
-
* At the entry point because there are two branches and a future third would
|
|
1314
|
-
* have to remember. Truncating after the fact rather than stopping the loop
|
|
1315
|
-
* keeps that single place honest: the input is already bounded by
|
|
1316
|
-
* MAX_FILE_BYTES, so what this protects is the report, not the scan.
|
|
1317
|
-
*/
|
|
1371
|
+
/** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
|
|
1318
1372
|
check(file, ctx) {
|
|
1319
1373
|
const name = basename3(file.path);
|
|
1320
1374
|
const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
|
|
1321
|
-
if (findings.
|
|
1375
|
+
if (!findings.overflow) return findings.items;
|
|
1322
1376
|
ctx.reportIncomplete(
|
|
1323
1377
|
"exposure/public-env",
|
|
1324
1378
|
`${file.path} holds more than ${MAX_FINDINGS_PER_FILE} values exposed to the browser; the rest were not reported`
|
|
1325
1379
|
);
|
|
1326
|
-
return findings.
|
|
1380
|
+
return findings.items;
|
|
1327
1381
|
}
|
|
1328
1382
|
};
|
|
1329
1383
|
function checkEnvFile(file) {
|
|
1330
|
-
const findings =
|
|
1384
|
+
const findings = new FindingBuffer();
|
|
1331
1385
|
for (const entry of parseEnv(file)) {
|
|
1332
1386
|
const prefix = publicPrefixOf(entry.key);
|
|
1333
1387
|
if (!prefix) continue;
|
|
@@ -1406,7 +1460,7 @@ function checkEnvFile(file) {
|
|
|
1406
1460
|
return findings;
|
|
1407
1461
|
}
|
|
1408
1462
|
function checkSourceFile(file) {
|
|
1409
|
-
const findings =
|
|
1463
|
+
const findings = new FindingBuffer();
|
|
1410
1464
|
const clientSide = isClientCode(file);
|
|
1411
1465
|
const commentless = commentsMaskedOf(file).split(/\r?\n/);
|
|
1412
1466
|
file.lines.forEach((line, i) => {
|
|
@@ -1468,6 +1522,7 @@ function checkSourceFile(file) {
|
|
|
1468
1522
|
|
|
1469
1523
|
// src/rules/gitleak.ts
|
|
1470
1524
|
import { basename as basename4 } from "path";
|
|
1525
|
+
import { createHash } from "crypto";
|
|
1471
1526
|
function isEnvTemplate(path) {
|
|
1472
1527
|
return isTemplateName(path);
|
|
1473
1528
|
}
|
|
@@ -1509,6 +1564,41 @@ function git(root, gitExecutable, args) {
|
|
|
1509
1564
|
return null;
|
|
1510
1565
|
}
|
|
1511
1566
|
}
|
|
1567
|
+
function batchBlobs(root, gitExecutable, specs) {
|
|
1568
|
+
if (gitExecutable === null || specs.length === 0) return specs.map(() => null);
|
|
1569
|
+
if (specs.some((spec) => spec.includes("\n") || spec.includes("\r"))) {
|
|
1570
|
+
return specs.map((spec) => git(root, gitExecutable, ["show", "--no-ext-diff", "--no-textconv", spec]));
|
|
1571
|
+
}
|
|
1572
|
+
let out;
|
|
1573
|
+
try {
|
|
1574
|
+
out = execGitBatch(
|
|
1575
|
+
gitExecutable,
|
|
1576
|
+
root,
|
|
1577
|
+
["cat-file", "--batch", "--buffer"],
|
|
1578
|
+
`${specs.join("\n")}
|
|
1579
|
+
`
|
|
1580
|
+
);
|
|
1581
|
+
} catch {
|
|
1582
|
+
return specs.map(() => null);
|
|
1583
|
+
}
|
|
1584
|
+
const blobs = [];
|
|
1585
|
+
let at = 0;
|
|
1586
|
+
for (let i = 0; i < specs.length; i++) {
|
|
1587
|
+
const newline = out.indexOf(10, at);
|
|
1588
|
+
if (newline === -1) break;
|
|
1589
|
+
const header = out.toString("utf8", at, newline);
|
|
1590
|
+
at = newline + 1;
|
|
1591
|
+
const size = Number(header.slice(header.lastIndexOf(" ") + 1));
|
|
1592
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
1593
|
+
blobs.push(null);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
blobs.push(out.toString("utf8", at, at + size));
|
|
1597
|
+
at += size + 1;
|
|
1598
|
+
}
|
|
1599
|
+
while (blobs.length < specs.length) blobs.push(null);
|
|
1600
|
+
return blobs;
|
|
1601
|
+
}
|
|
1512
1602
|
function gitOrThrow(root, gitExecutable, args) {
|
|
1513
1603
|
const out = git(root, gitExecutable, args);
|
|
1514
1604
|
if (out === null) throw new Error(`git ${args.slice(0, 2).join(" ")} failed in ${root}`);
|
|
@@ -1551,29 +1641,43 @@ function historicalEvidence(root, gitExecutable, entry) {
|
|
|
1551
1641
|
"--no-textconv",
|
|
1552
1642
|
"--all",
|
|
1553
1643
|
"--format=%H",
|
|
1644
|
+
`--max-count=${MAX_HISTORY_REVISIONS + 1}`,
|
|
1554
1645
|
"--",
|
|
1555
1646
|
entry.localPath
|
|
1556
1647
|
]) ?? "").split(/\r?\n/).filter(Boolean);
|
|
1557
1648
|
if (all.length === 0) return null;
|
|
1558
1649
|
const revs = all.slice(0, MAX_HISTORY_REVISIONS);
|
|
1650
|
+
const bodies = batchBlobs(
|
|
1651
|
+
root,
|
|
1652
|
+
gitExecutable,
|
|
1653
|
+
revs.map((rev) => `${rev}:${entry.repoPath}`)
|
|
1654
|
+
);
|
|
1559
1655
|
let best = "none";
|
|
1560
1656
|
let unreadable = 0;
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
"show",
|
|
1564
|
-
"--no-ext-diff",
|
|
1565
|
-
"--no-textconv",
|
|
1566
|
-
`${rev}:${entry.repoPath}`
|
|
1567
|
-
]);
|
|
1657
|
+
const hintHashes = /* @__PURE__ */ new Set();
|
|
1658
|
+
for (const body of bodies) {
|
|
1568
1659
|
if (body === null) {
|
|
1569
1660
|
unreadable++;
|
|
1570
1661
|
continue;
|
|
1571
1662
|
}
|
|
1572
1663
|
const evidence = evidenceIn(body.split(/\r?\n/));
|
|
1573
|
-
if (evidence === "proof") return {
|
|
1574
|
-
|
|
1664
|
+
if (evidence === "proof") return {
|
|
1665
|
+
evidence: "proof",
|
|
1666
|
+
unread: 0,
|
|
1667
|
+
unreadable,
|
|
1668
|
+
sourceFingerprint: createHash("sha256").update(body.trim(), "utf8").digest("hex")
|
|
1669
|
+
};
|
|
1670
|
+
if (evidence === "hint") {
|
|
1671
|
+
best = "hint";
|
|
1672
|
+
hintHashes.add(createHash("sha256").update(body.trim(), "utf8").digest("hex"));
|
|
1673
|
+
}
|
|
1575
1674
|
}
|
|
1576
|
-
return {
|
|
1675
|
+
return {
|
|
1676
|
+
evidence: best,
|
|
1677
|
+
unread: all.length - revs.length,
|
|
1678
|
+
unreadable,
|
|
1679
|
+
sourceFingerprint: createHash("sha256").update([...hintHashes].sort().join("\n")).digest("hex")
|
|
1680
|
+
};
|
|
1577
1681
|
}
|
|
1578
1682
|
function hasRemote(root, gitExecutable) {
|
|
1579
1683
|
const out = git(root, gitExecutable, ["remote"]);
|
|
@@ -1615,9 +1719,7 @@ var gitleakRule = {
|
|
|
1615
1719
|
findings.push({
|
|
1616
1720
|
ruleId: "gitleak/env-tracked",
|
|
1617
1721
|
severity: "P0",
|
|
1618
|
-
//
|
|
1619
|
-
// recognisably a credential. Everything else is a committed env file
|
|
1620
|
-
// that might hold one, which is worth saying quietly.
|
|
1722
|
+
// 只有非示例中的明确凭据使用确定置信度。
|
|
1621
1723
|
confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
|
|
1622
1724
|
title: evidence === "proof" && !scaffolding ? `${path} is committed to git, with a credential in it` : `${path} is committed to git`,
|
|
1623
1725
|
file: path,
|
|
@@ -1644,13 +1746,13 @@ var gitleakRule = {
|
|
|
1644
1746
|
if (history && history.unread > 0) {
|
|
1645
1747
|
ctx.reportIncomplete(
|
|
1646
1748
|
"gitleak/env-in-history",
|
|
1647
|
-
`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`
|
|
1648
1750
|
);
|
|
1649
1751
|
}
|
|
1650
1752
|
if (history && history.unreadable > 0) {
|
|
1651
1753
|
ctx.reportIncomplete(
|
|
1652
1754
|
"gitleak/env-in-history",
|
|
1653
|
-
`${history.unreadable} historical ${history.unreadable === 1 ? "version" : "versions"} of ${path} could not be read
|
|
1755
|
+
`${history.unreadable} historical ${history.unreadable === 1 ? "version" : "versions"} of ${path} could not be read from the repository; the repository may be incomplete or the file may exceed the Git output limit`
|
|
1654
1756
|
);
|
|
1655
1757
|
}
|
|
1656
1758
|
const evidence = history?.evidence ?? "hint";
|
|
@@ -1658,9 +1760,9 @@ var gitleakRule = {
|
|
|
1658
1760
|
const scaffolding = isScaffolding(path);
|
|
1659
1761
|
findings.push({
|
|
1660
1762
|
ruleId: "gitleak/env-in-history",
|
|
1763
|
+
...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
|
|
1661
1764
|
severity: "P0",
|
|
1662
|
-
//
|
|
1663
|
-
// overreach the tracked branch just stopped making.
|
|
1765
|
+
// 证据或上下文不足时降低置信度。
|
|
1664
1766
|
confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
|
|
1665
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`,
|
|
1666
1768
|
file: path,
|
|
@@ -1700,28 +1802,41 @@ var INTERNAL_SCHEMAS = /* @__PURE__ */ new Set([
|
|
|
1700
1802
|
"pg_catalog"
|
|
1701
1803
|
]);
|
|
1702
1804
|
var IS_DO_BLOCK = /\bdo\s+(?:language\s+\w+\s+)?$/i;
|
|
1805
|
+
var DASH = 45;
|
|
1806
|
+
var SLASH2 = 47;
|
|
1807
|
+
var STAR2 = 42;
|
|
1808
|
+
var SINGLE_QUOTE2 = 39;
|
|
1809
|
+
var DOUBLE_QUOTE2 = 34;
|
|
1810
|
+
var DOLLAR2 = 36;
|
|
1811
|
+
var NEWLINE2 = 10;
|
|
1812
|
+
var UNDERSCORE = 95;
|
|
1813
|
+
function isSpaceCode(code) {
|
|
1814
|
+
if (code === 32 || code >= 9 && code <= 13) return true;
|
|
1815
|
+
return code > 127 && /\s/.test(String.fromCharCode(code));
|
|
1816
|
+
}
|
|
1703
1817
|
function maskSqlNoise(sql) {
|
|
1704
|
-
const
|
|
1818
|
+
const length = sql.length;
|
|
1819
|
+
const out = codesOf(sql);
|
|
1705
1820
|
const erase = (from, to) => blank(out, from, to);
|
|
1706
1821
|
let i = 0;
|
|
1707
|
-
while (i <
|
|
1708
|
-
const ch = sql
|
|
1709
|
-
|
|
1710
|
-
if (two === "--") {
|
|
1822
|
+
while (i < length) {
|
|
1823
|
+
const ch = sql.charCodeAt(i);
|
|
1824
|
+
if (ch === DASH && sql.charCodeAt(i + 1) === DASH) {
|
|
1711
1825
|
const end = sql.indexOf("\n", i);
|
|
1712
|
-
erase(i, end === -1 ?
|
|
1713
|
-
i = end === -1 ?
|
|
1826
|
+
erase(i, end === -1 ? length : end);
|
|
1827
|
+
i = end === -1 ? length : end;
|
|
1714
1828
|
continue;
|
|
1715
1829
|
}
|
|
1716
|
-
if (
|
|
1830
|
+
if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
|
|
1717
1831
|
let depth = 0;
|
|
1718
1832
|
let j = i;
|
|
1719
|
-
while (j <
|
|
1720
|
-
const
|
|
1721
|
-
|
|
1833
|
+
while (j < length) {
|
|
1834
|
+
const first = sql.charCodeAt(j);
|
|
1835
|
+
const second = sql.charCodeAt(j + 1);
|
|
1836
|
+
if (first === SLASH2 && second === STAR2) {
|
|
1722
1837
|
depth++;
|
|
1723
1838
|
j += 2;
|
|
1724
|
-
} else if (
|
|
1839
|
+
} else if (first === STAR2 && second === SLASH2) {
|
|
1725
1840
|
depth--;
|
|
1726
1841
|
j += 2;
|
|
1727
1842
|
if (depth === 0) break;
|
|
@@ -1733,16 +1848,17 @@ function maskSqlNoise(sql) {
|
|
|
1733
1848
|
i = j;
|
|
1734
1849
|
continue;
|
|
1735
1850
|
}
|
|
1736
|
-
if (ch ===
|
|
1851
|
+
if (ch === SINGLE_QUOTE2) {
|
|
1737
1852
|
const escaped = i > 0 && /[Ee]/.test(sql[i - 1] ?? "") && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? "");
|
|
1738
1853
|
let j = i + 1;
|
|
1739
|
-
while (j <
|
|
1740
|
-
|
|
1854
|
+
while (j < length) {
|
|
1855
|
+
const inner = sql.charCodeAt(j);
|
|
1856
|
+
if (escaped && inner === 92) {
|
|
1741
1857
|
j += 2;
|
|
1742
1858
|
continue;
|
|
1743
1859
|
}
|
|
1744
|
-
if (
|
|
1745
|
-
if (sql
|
|
1860
|
+
if (inner === SINGLE_QUOTE2) {
|
|
1861
|
+
if (sql.charCodeAt(j + 1) === SINGLE_QUOTE2) {
|
|
1746
1862
|
j += 2;
|
|
1747
1863
|
continue;
|
|
1748
1864
|
}
|
|
@@ -1755,20 +1871,21 @@ function maskSqlNoise(sql) {
|
|
|
1755
1871
|
i = j;
|
|
1756
1872
|
continue;
|
|
1757
1873
|
}
|
|
1758
|
-
if (ch ===
|
|
1874
|
+
if (ch === DOUBLE_QUOTE2) {
|
|
1759
1875
|
let j = i + 1;
|
|
1760
|
-
while (j <
|
|
1761
|
-
|
|
1876
|
+
while (j < length && sql.charCodeAt(j) !== DOUBLE_QUOTE2) {
|
|
1877
|
+
const code = out[j];
|
|
1878
|
+
if (code !== void 0 && code !== NEWLINE2 && isSpaceCode(code)) out[j] = UNDERSCORE;
|
|
1762
1879
|
j++;
|
|
1763
1880
|
}
|
|
1764
1881
|
i = j + 1;
|
|
1765
1882
|
continue;
|
|
1766
1883
|
}
|
|
1767
|
-
if (ch ===
|
|
1884
|
+
if (ch === DOLLAR2) {
|
|
1768
1885
|
const tag = /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i))?.[0];
|
|
1769
1886
|
if (tag) {
|
|
1770
1887
|
const close = sql.indexOf(tag, i + tag.length);
|
|
1771
|
-
const end = close === -1 ?
|
|
1888
|
+
const end = close === -1 ? length : close + tag.length;
|
|
1772
1889
|
if (!IS_DO_BLOCK.test(sql.slice(0, i))) erase(i, end);
|
|
1773
1890
|
i = end;
|
|
1774
1891
|
continue;
|
|
@@ -1776,7 +1893,7 @@ function maskSqlNoise(sql) {
|
|
|
1776
1893
|
}
|
|
1777
1894
|
i++;
|
|
1778
1895
|
}
|
|
1779
|
-
return out
|
|
1896
|
+
return stringOf(out);
|
|
1780
1897
|
}
|
|
1781
1898
|
function unquote(ident) {
|
|
1782
1899
|
const quoted = /^"(.*)"$/.exec(ident);
|
|
@@ -1865,10 +1982,7 @@ function projectScopeOf(path, scopes) {
|
|
|
1865
1982
|
return best;
|
|
1866
1983
|
}
|
|
1867
1984
|
function isActiveSupabaseScope(ctx, files, scope) {
|
|
1868
|
-
|
|
1869
|
-
(file) => scope === "" ? file : { ...file, path: file.path.slice(scope.length + 1) }
|
|
1870
|
-
);
|
|
1871
|
-
return isSupabaseProject({ ...ctx, files: rebased });
|
|
1985
|
+
return isSupabaseProject(ctx, files, scope);
|
|
1872
1986
|
}
|
|
1873
1987
|
function replayScopeOf(file, projectScope) {
|
|
1874
1988
|
return `${file.isExampleContext ? "example" : "project"}:${projectScope}`;
|
|
@@ -2122,8 +2236,7 @@ var firebaseRulesRule = {
|
|
|
2122
2236
|
findings.push({
|
|
2123
2237
|
ruleId: "firebase/open-rules",
|
|
2124
2238
|
severity: "P1",
|
|
2125
|
-
//
|
|
2126
|
-
// (a public catalogue, announcements), so it stays lower-confidence.
|
|
2239
|
+
// 开放写入为确定结果,公开读取需人工确认。
|
|
2127
2240
|
confidence: canWrite ? "certain" : "likely",
|
|
2128
2241
|
title: canWrite ? `Your ${product} rules let anyone ${ops} this data` : `Your ${product} rules make this data publicly readable`,
|
|
2129
2242
|
file: file.path,
|
|
@@ -2161,8 +2274,7 @@ var firebaseRulesRule = {
|
|
|
2161
2274
|
findings.push({
|
|
2162
2275
|
ruleId: "firebase/test-mode-rules",
|
|
2163
2276
|
severity: "P1",
|
|
2164
|
-
//
|
|
2165
|
-
// so this stays certain whether or not writes are involved.
|
|
2277
|
+
// 固定日期测试模式均作为确定配置问题。
|
|
2166
2278
|
confidence: "certain",
|
|
2167
2279
|
title: expired ? `Your ${product} rules are in test mode and expired on ${dateStr}` : `Your ${product} rules allow ${ops} to anyone until ${dateStr}`,
|
|
2168
2280
|
file: file.path,
|
|
@@ -2191,7 +2303,10 @@ import { posix } from "path";
|
|
|
2191
2303
|
var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
|
|
2192
2304
|
var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
|
|
2193
2305
|
function isApiRoute(path) {
|
|
2194
|
-
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, "/");
|
|
2195
2310
|
}
|
|
2196
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)$/;
|
|
2197
2312
|
var AUTH_CALLBACK = /^\/api\/auth\/callback(?:\/[^/]+)?$/;
|
|
@@ -2199,7 +2314,7 @@ function isAuthEndpoint(url) {
|
|
|
2199
2314
|
return AUTH_ENDPOINT_NAMES.test(url) || AUTH_CALLBACK.test(url);
|
|
2200
2315
|
}
|
|
2201
2316
|
function routeUrl(path) {
|
|
2202
|
-
const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
|
|
2317
|
+
const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
|
|
2203
2318
|
if (!m) return `/${path}`;
|
|
2204
2319
|
const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
|
|
2205
2320
|
return `/${url}`;
|
|
@@ -2230,17 +2345,37 @@ function controlledStatement(source, afterCondition) {
|
|
|
2230
2345
|
return source.slice(start, end);
|
|
2231
2346
|
}
|
|
2232
2347
|
function hasConditionalAuthGuard(code) {
|
|
2233
|
-
const starts =
|
|
2234
|
-
|
|
2348
|
+
const starts = /\bif\s*\(/g;
|
|
2349
|
+
let match;
|
|
2350
|
+
while ((match = starts.exec(code)) !== null) {
|
|
2235
2351
|
const open = code.indexOf("(", match.index);
|
|
2236
2352
|
const close = closingDelimiter(code, open, "(", ")");
|
|
2237
2353
|
if (close === null) continue;
|
|
2238
2354
|
const condition = code.slice(open + 1, close);
|
|
2239
2355
|
const statement = controlledStatement(code, close + 1);
|
|
2240
|
-
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;
|
|
2241
2371
|
if (!stopsRequest) continue;
|
|
2242
2372
|
const returnsDeniedStatus = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus\s*[:(=]\s*(?:401|403)\b/i.test(statement);
|
|
2243
|
-
|
|
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;
|
|
2244
2379
|
}
|
|
2245
2380
|
return false;
|
|
2246
2381
|
}
|
|
@@ -2248,6 +2383,132 @@ function hasAuthSignal(file) {
|
|
|
2248
2383
|
const code = noiseMaskedOf(file);
|
|
2249
2384
|
return AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
|
|
2250
2385
|
}
|
|
2386
|
+
function delimiterPairs(code) {
|
|
2387
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
2388
|
+
const stack = [];
|
|
2389
|
+
for (let i = 0; i < code.length; i++) {
|
|
2390
|
+
const ch = code[i];
|
|
2391
|
+
if ("({[".includes(ch)) stack.push(i);
|
|
2392
|
+
else if (")}]".includes(ch)) {
|
|
2393
|
+
const open = stack.pop();
|
|
2394
|
+
if (open !== void 0 && "({[".indexOf(code[open]) === ")}]".indexOf(ch)) {
|
|
2395
|
+
pairs.set(open, i);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return pairs;
|
|
2400
|
+
}
|
|
2401
|
+
function functionBodies(code, pairs) {
|
|
2402
|
+
const bodies = [];
|
|
2403
|
+
for (const match of code.matchAll(/\bfunction\s*\*?\s*(?:\w+\s*)?\(|=>\s*\{/g)) {
|
|
2404
|
+
let body;
|
|
2405
|
+
if (match[0].startsWith("=>")) body = match.index + match[0].length - 1;
|
|
2406
|
+
else {
|
|
2407
|
+
const close = pairs.get(match.index + match[0].length - 1);
|
|
2408
|
+
if (close === void 0) continue;
|
|
2409
|
+
body = close + 1;
|
|
2410
|
+
while (/\s/.test(code[body] ?? "")) body++;
|
|
2411
|
+
if (code[body] === ":") {
|
|
2412
|
+
const type = /^:[\w\s.<>,[\]|?]+(?=\{)/.exec(code.slice(body));
|
|
2413
|
+
if (type) body += type[0].length;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const end = pairs.get(body);
|
|
2417
|
+
if (code[body] === "{" && end !== void 0) bodies.push({ declaration: match.index, start: body, end });
|
|
2418
|
+
}
|
|
2419
|
+
return bodies;
|
|
2420
|
+
}
|
|
2421
|
+
function statementEnd(code, start, limit, pairs) {
|
|
2422
|
+
if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
|
|
2423
|
+
for (let i = start; i < limit; i++) {
|
|
2424
|
+
if (code[i] === ";" || code[i] === "\n") return i + 1;
|
|
2425
|
+
const close = pairs.get(i);
|
|
2426
|
+
if (close !== void 0) i = close;
|
|
2427
|
+
}
|
|
2428
|
+
return limit;
|
|
2429
|
+
}
|
|
2430
|
+
function unguardedOperations(file, ops) {
|
|
2431
|
+
if (ops.length === 0) return ops;
|
|
2432
|
+
const code = noiseMaskedOf(file);
|
|
2433
|
+
const pairs = delimiterPairs(code);
|
|
2434
|
+
const bodies = functionBodies(code, pairs);
|
|
2435
|
+
const declarations = new Map(bodies.map((body) => [body.declaration, body]));
|
|
2436
|
+
const functionStarts = new Set(bodies.map((body) => body.start));
|
|
2437
|
+
const guardEnds = /* @__PURE__ */ new Map();
|
|
2438
|
+
const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
|
|
2439
|
+
const active = [];
|
|
2440
|
+
let blockIndex = 0;
|
|
2441
|
+
const wrappers = [...code.matchAll(/\b(?:withAuth|NextAuth)\s*\(/g)].map((match) => {
|
|
2442
|
+
const open = match.index + match[0].length - 1;
|
|
2443
|
+
return { start: open, end: pairs.get(open) ?? open };
|
|
2444
|
+
});
|
|
2445
|
+
const guardEnd = (owner) => {
|
|
2446
|
+
for (let i = owner.start + 1; i < owner.end; i++) {
|
|
2447
|
+
const nested = declarations.get(i);
|
|
2448
|
+
if (nested) {
|
|
2449
|
+
i = nested.end;
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
if (code.startsWith("=>", i)) {
|
|
2453
|
+
i = statementEnd(code, i + 2, owner.end, pairs) - 1;
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
|
|
2457
|
+
const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
|
|
2458
|
+
if (conditional) {
|
|
2459
|
+
const open = i + conditional[0].length - 1;
|
|
2460
|
+
const close2 = pairs.get(open);
|
|
2461
|
+
if (close2 === void 0) return Infinity;
|
|
2462
|
+
let start = close2 + 1;
|
|
2463
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
2464
|
+
const end = statementEnd(code, start, owner.end, pairs);
|
|
2465
|
+
if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
|
|
2466
|
+
i = Math.min(end, owner.end) - 1;
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
|
|
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;
|
|
2481
|
+
const close2 = pairs.get(i + call[0].length - 1);
|
|
2482
|
+
if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
|
|
2483
|
+
}
|
|
2484
|
+
const close = pairs.get(i);
|
|
2485
|
+
if (close !== void 0) i = close;
|
|
2486
|
+
}
|
|
2487
|
+
return Infinity;
|
|
2488
|
+
};
|
|
2489
|
+
return ops.filter((op) => {
|
|
2490
|
+
while (blockIndex < blocks.length && blocks[blockIndex].start < op.index) {
|
|
2491
|
+
const block = blocks[blockIndex++];
|
|
2492
|
+
while (active.length && active[active.length - 1].end < block.start) active.pop();
|
|
2493
|
+
active.push(block);
|
|
2494
|
+
}
|
|
2495
|
+
while (active.length && active[active.length - 1].end < op.index) active.pop();
|
|
2496
|
+
if (wrappers.some((w) => w.start < op.index && w.end > op.index)) return false;
|
|
2497
|
+
let ownerIndex = active.length - 1;
|
|
2498
|
+
while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
|
|
2499
|
+
if (ownerIndex < 0) return true;
|
|
2500
|
+
for (let index = ownerIndex; index < active.length; index++) {
|
|
2501
|
+
const block = active[index];
|
|
2502
|
+
let end = guardEnds.get(block.start);
|
|
2503
|
+
if (end === void 0) {
|
|
2504
|
+
end = guardEnd(block);
|
|
2505
|
+
guardEnds.set(block.start, end);
|
|
2506
|
+
}
|
|
2507
|
+
if (end <= op.index) return false;
|
|
2508
|
+
}
|
|
2509
|
+
return true;
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2251
2512
|
var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>)?\s*\(/;
|
|
2252
2513
|
var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
|
|
2253
2514
|
var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
|
|
@@ -2314,7 +2575,7 @@ function usesSessionClient(route, sessionModules, allFiles) {
|
|
|
2314
2575
|
return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
|
|
2315
2576
|
}
|
|
2316
2577
|
function moduleScopeOf(routePath) {
|
|
2317
|
-
return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePath)?.[1] ?? "";
|
|
2578
|
+
return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
|
|
2318
2579
|
}
|
|
2319
2580
|
function importedModules(file, allFiles, aliasScope) {
|
|
2320
2581
|
const found = [];
|
|
@@ -2641,8 +2902,7 @@ var apiAuthRule = {
|
|
|
2641
2902
|
const sessionModules = ctx.files.filter(buildsSessionClient);
|
|
2642
2903
|
const findings = [];
|
|
2643
2904
|
for (const route of routes) {
|
|
2644
|
-
|
|
2645
|
-
const ops = findDataOps(route);
|
|
2905
|
+
const ops = unguardedOperations(route, findDataOps(route));
|
|
2646
2906
|
if (ops.length === 0) continue;
|
|
2647
2907
|
const url = routeUrl(route.path);
|
|
2648
2908
|
if (isAuthEndpoint(url)) continue;
|
|
@@ -2655,9 +2915,7 @@ var apiAuthRule = {
|
|
|
2655
2915
|
findings.push({
|
|
2656
2916
|
ruleId: "api/admin-db-access-without-auth",
|
|
2657
2917
|
severity: "P0",
|
|
2658
|
-
//
|
|
2659
|
-
// every RLS policy, and nothing in the file or in middleware checks
|
|
2660
|
-
// who sent the request.
|
|
2918
|
+
// 管理员客户端缺少鉴权时使用确定置信度。
|
|
2661
2919
|
confidence: "certain",
|
|
2662
2920
|
title: `Anyone can call ${url} and it queries your database as admin`,
|
|
2663
2921
|
file: route.path,
|
|
@@ -2685,9 +2943,7 @@ var apiAuthRule = {
|
|
|
2685
2943
|
findings.push({
|
|
2686
2944
|
ruleId: "api/db-write-without-auth",
|
|
2687
2945
|
severity: "P1",
|
|
2688
|
-
//
|
|
2689
|
-
// (a waitlist, a contact form), and protection can also live in a
|
|
2690
|
-
// deployment-level proxy this scan cannot see.
|
|
2946
|
+
// 公开写入可能是业务设计,保留疑似置信度。
|
|
2691
2947
|
confidence: "likely",
|
|
2692
2948
|
title: `${url} writes to your database with no sign-in check`,
|
|
2693
2949
|
file: route.path,
|
|
@@ -2940,8 +3196,7 @@ var corsRule = {
|
|
|
2940
3196
|
findings.push({
|
|
2941
3197
|
ruleId: "cors/reflected-origin-with-credentials",
|
|
2942
3198
|
severity: "P1",
|
|
2943
|
-
//
|
|
2944
|
-
// back unchanged, and credentials are allowed. Nothing is inferred.
|
|
3199
|
+
// 明确的来源回显和凭据配置使用确定置信度。
|
|
2945
3200
|
confidence: "certain",
|
|
2946
3201
|
title: "Any website can make signed-in requests to your API and read the answer",
|
|
2947
3202
|
file: file.path,
|
|
@@ -2963,8 +3218,7 @@ var corsRule = {
|
|
|
2963
3218
|
findings.push({
|
|
2964
3219
|
ruleId: "cors/wildcard-with-credentials",
|
|
2965
3220
|
severity: "P2",
|
|
2966
|
-
//
|
|
2967
|
-
// browser rejects it.
|
|
3221
|
+
// 通配符与凭据组合违反浏览器跨域约束。
|
|
2968
3222
|
confidence: "certain",
|
|
2969
3223
|
title: "This CORS setup is rejected by every browser, so the requests it enables never work",
|
|
2970
3224
|
file: file.path,
|
|
@@ -2990,8 +3244,38 @@ var corsRule = {
|
|
|
2990
3244
|
// src/rules/index.ts
|
|
2991
3245
|
var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
|
|
2992
3246
|
var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule];
|
|
3247
|
+
var RULE_IDS = [
|
|
3248
|
+
"api/admin-db-access-without-auth",
|
|
3249
|
+
"api/db-write-without-auth",
|
|
3250
|
+
"cors/reflected-origin-with-credentials",
|
|
3251
|
+
"cors/wildcard-with-credentials",
|
|
3252
|
+
"exposure/private-name-in-public-env",
|
|
3253
|
+
"exposure/secret-in-public-env",
|
|
3254
|
+
"exposure/supabase-service-role-in-client",
|
|
3255
|
+
"firebase/open-rules",
|
|
3256
|
+
"firebase/test-mode-rules",
|
|
3257
|
+
"gitleak/env-in-history",
|
|
3258
|
+
"gitleak/env-tracked",
|
|
3259
|
+
"supabase/rls-not-enabled",
|
|
3260
|
+
// 凭据规则 ID 从共享格式表生成。
|
|
3261
|
+
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
3262
|
+
];
|
|
3263
|
+
function ruleMatches(selector, ruleId) {
|
|
3264
|
+
return ruleId === selector || ruleId.startsWith(`${selector}/`);
|
|
3265
|
+
}
|
|
3266
|
+
function isKnownSelector(selector) {
|
|
3267
|
+
return RULE_IDS.some((id) => ruleMatches(selector, id));
|
|
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
|
+
}
|
|
2993
3276
|
|
|
2994
3277
|
// src/engine.ts
|
|
3278
|
+
import { createHash as createHash2 } from "crypto";
|
|
2995
3279
|
var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
|
|
2996
3280
|
var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
|
|
2997
3281
|
function dedupe(findings) {
|
|
@@ -3016,32 +3300,26 @@ function clean(text) {
|
|
|
3016
3300
|
function cleanForOutput(text) {
|
|
3017
3301
|
return clean(text);
|
|
3018
3302
|
}
|
|
3019
|
-
function sanitize(findings) {
|
|
3303
|
+
function sanitize(findings, files) {
|
|
3304
|
+
const byPath = new Map(files.map((file) => [file.path, file]));
|
|
3305
|
+
const sourceIdentity = (f) => {
|
|
3306
|
+
if (f.sourceFingerprint !== void 0) return { sourceFingerprint: f.sourceFingerprint };
|
|
3307
|
+
const file = f.file === null ? void 0 : byPath.get(f.file);
|
|
3308
|
+
const source = f.line === null ? file?.content : file?.lines[f.line - 1];
|
|
3309
|
+
return source === void 0 ? {} : {
|
|
3310
|
+
sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
|
|
3311
|
+
};
|
|
3312
|
+
};
|
|
3020
3313
|
return findings.map((f) => ({
|
|
3021
3314
|
...f,
|
|
3315
|
+
// 仅输出摘要;原始行不进入报告,移动行号不改变身份。
|
|
3316
|
+
...sourceIdentity(f),
|
|
3022
3317
|
title: clean(f.title),
|
|
3023
|
-
//
|
|
3024
|
-
// every newline inside them. See Finding.why.
|
|
3318
|
+
// 按段落清理,保留段落之间的结构。
|
|
3025
3319
|
why: f.why.map(clean),
|
|
3026
|
-
//
|
|
3027
|
-
// credential put it straight back into the JSON, the terminal, the HTML
|
|
3028
|
-
// and the prompt meant for pasting into an assistant.
|
|
3320
|
+
// 文件路径也必须经过输出清理。
|
|
3029
3321
|
file: f.file === null ? null : clean(f.file),
|
|
3030
|
-
//
|
|
3031
|
-
//
|
|
3032
|
-
// A rule that trimmed its own excerpt to length before this ran could
|
|
3033
|
-
// defeat the redaction entirely: cutting at 120 characters through the
|
|
3034
|
-
// middle of a key leaves a fragment that matches no pattern, so `clean`
|
|
3035
|
-
// waved it past and nineteen characters of a live OpenAI key reached the
|
|
3036
|
-
// terminal, the JSON, the HTML report and the prompt meant for pasting
|
|
3037
|
-
// into an assistant. The rule was not doing anything unreasonable — it
|
|
3038
|
-
// truncated, which every other rule also does. The order was simply not
|
|
3039
|
-
// its decision to make.
|
|
3040
|
-
//
|
|
3041
|
-
// So rules hand over the whole line and the boundary does both jobs, in
|
|
3042
|
-
// the only order that is safe. Rules that redact per match still may:
|
|
3043
|
-
// masking a known secret before this point is additive, and truncating an
|
|
3044
|
-
// already-truncated string is a no-op.
|
|
3322
|
+
// 先脱敏再截断,避免截断导致凭据特征失效。
|
|
3045
3323
|
excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
|
|
3046
3324
|
fix: f.fix.map(clean),
|
|
3047
3325
|
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
@@ -3060,6 +3338,45 @@ function downgradeExampleContext(findings, files) {
|
|
|
3060
3338
|
(f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
|
|
3061
3339
|
);
|
|
3062
3340
|
}
|
|
3341
|
+
function suppressIgnoredLines(findings, files) {
|
|
3342
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
3343
|
+
const markers = /* @__PURE__ */ new Map();
|
|
3344
|
+
const kept = [];
|
|
3345
|
+
const ignored = [];
|
|
3346
|
+
for (const f of findings) {
|
|
3347
|
+
if (f.file === null || f.line === null) {
|
|
3348
|
+
kept.push(f);
|
|
3349
|
+
continue;
|
|
3350
|
+
}
|
|
3351
|
+
let lines = markers.get(f.file);
|
|
3352
|
+
if (lines === void 0) {
|
|
3353
|
+
const file = byPath.get(f.file);
|
|
3354
|
+
lines = file === void 0 ? /* @__PURE__ */ new Map() : ignoredLinesOf(file.lines);
|
|
3355
|
+
markers.set(f.file, lines);
|
|
3356
|
+
}
|
|
3357
|
+
if (!lines.has(f.line)) {
|
|
3358
|
+
kept.push(f);
|
|
3359
|
+
continue;
|
|
3360
|
+
}
|
|
3361
|
+
const rules = lines.get(f.line);
|
|
3362
|
+
if (rules !== null && rules !== void 0 && !rules.has(f.ruleId)) {
|
|
3363
|
+
kept.push(f);
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
ignored.push({ file: f.file, line: f.line, ruleId: f.ruleId });
|
|
3367
|
+
}
|
|
3368
|
+
return { kept, ignored };
|
|
3369
|
+
}
|
|
3370
|
+
function applyRuleSelection(findings, options) {
|
|
3371
|
+
const only = options.only ?? [];
|
|
3372
|
+
const skip = options.skip ?? [];
|
|
3373
|
+
if (only.length === 0 && skip.length === 0) return { kept: findings, selection: null };
|
|
3374
|
+
const kept = findings.filter((f) => {
|
|
3375
|
+
if (only.length > 0) return only.some((s) => ruleMatches(s, f.ruleId));
|
|
3376
|
+
return !skip.some((s) => ruleMatches(s, f.ruleId));
|
|
3377
|
+
});
|
|
3378
|
+
return { kept, selection: { only, skip, removed: findings.length - kept.length } };
|
|
3379
|
+
}
|
|
3063
3380
|
function sortFindings(findings) {
|
|
3064
3381
|
return [...findings].sort((a, b) => {
|
|
3065
3382
|
const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
@@ -3069,26 +3386,22 @@ function sortFindings(findings) {
|
|
|
3069
3386
|
return (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0);
|
|
3070
3387
|
});
|
|
3071
3388
|
}
|
|
3072
|
-
async function scan(root) {
|
|
3389
|
+
async function scan(root, options = {}) {
|
|
3073
3390
|
const started = Date.now();
|
|
3074
3391
|
const gitExecutable = resolveGitExecutable(root);
|
|
3075
3392
|
const git2 = detectGitRepo(root, gitExecutable);
|
|
3076
3393
|
const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
|
|
3077
3394
|
const findings = [];
|
|
3078
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 ?? []));
|
|
3079
3398
|
const incompleteSeen = /* @__PURE__ */ new Set();
|
|
3080
3399
|
const ctx = {
|
|
3081
3400
|
root,
|
|
3082
3401
|
files,
|
|
3083
3402
|
git: git2,
|
|
3084
3403
|
gitExecutable,
|
|
3085
|
-
//
|
|
3086
|
-
// A rule that reaches the same ceiling from two loops over the same file —
|
|
3087
|
-
// firebase does, once for open rules and once for test-mode rules — said
|
|
3088
|
-
// the identical sentence twice, in the terminal's incomplete section and in
|
|
3089
|
-
// the JSON. Saying "part of this did not happen" twice does not make it
|
|
3090
|
-
// twice as true, and a once-flag per rule is the kind of bookkeeping every
|
|
3091
|
-
// new rule would have to remember.
|
|
3404
|
+
// 统一记录不完整状态,避免重复提示。
|
|
3092
3405
|
reportIncomplete: (ruleId, message) => {
|
|
3093
3406
|
if (incompleteSeen.has(`${ruleId} ${message}`)) return;
|
|
3094
3407
|
incompleteSeen.add(`${ruleId} ${message}`);
|
|
@@ -3096,24 +3409,40 @@ async function scan(root) {
|
|
|
3096
3409
|
}
|
|
3097
3410
|
};
|
|
3098
3411
|
for (const file of files) {
|
|
3099
|
-
for (const rule of
|
|
3100
|
-
if (!rule.appliesTo(file)) continue;
|
|
3412
|
+
for (const rule of fileRules) {
|
|
3101
3413
|
try {
|
|
3414
|
+
if (!rule.appliesTo(file)) continue;
|
|
3102
3415
|
findings.push(...rule.check(file, ctx));
|
|
3103
3416
|
} catch (err) {
|
|
3104
3417
|
errors.push({ ruleId: rule.id, file: file.path, message: messageOf(err), kind: "crashed" });
|
|
3105
3418
|
}
|
|
3106
3419
|
}
|
|
3107
3420
|
}
|
|
3108
|
-
for (const rule of
|
|
3421
|
+
for (const rule of projectRules) {
|
|
3109
3422
|
try {
|
|
3110
3423
|
findings.push(...await rule.check(ctx));
|
|
3111
3424
|
} catch (err) {
|
|
3112
3425
|
errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
|
|
3113
3426
|
}
|
|
3114
3427
|
}
|
|
3428
|
+
const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
|
|
3429
|
+
dedupe(downgradeExampleContext(findings, files)),
|
|
3430
|
+
files
|
|
3431
|
+
);
|
|
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
|
+
});
|
|
3115
3444
|
return {
|
|
3116
|
-
findings: sanitize(
|
|
3445
|
+
findings: sanitize(bounded, files),
|
|
3117
3446
|
filesScanned: files.length,
|
|
3118
3447
|
durationMs: Date.now() - started,
|
|
3119
3448
|
errors: errors.map((e) => ({
|
|
@@ -3123,18 +3452,16 @@ async function scan(root) {
|
|
|
3123
3452
|
})),
|
|
3124
3453
|
skipped: sanitizeSkippedForOutput(skipped),
|
|
3125
3454
|
ignored: ignored.map(clean),
|
|
3455
|
+
// 清理被忽略结果的路径。
|
|
3456
|
+
ignoredFindings: ignoredFindings.map((f) => ({ ...f, file: clean(f.file) })),
|
|
3457
|
+
// 选择器来自外部输入,输出前也需清理。
|
|
3458
|
+
ruleSelection: selected.selection === null ? null : {
|
|
3459
|
+
only: selected.selection.only.map(clean),
|
|
3460
|
+
skip: selected.selection.skip.map(clean),
|
|
3461
|
+
removed: selected.selection.removed
|
|
3462
|
+
},
|
|
3126
3463
|
vendored,
|
|
3127
|
-
//
|
|
3128
|
-
// knowingly. It is listed in the report, not treated as a failure.
|
|
3129
|
-
//
|
|
3130
|
-
// Examining no files at all, however, is the purest form of an incomplete
|
|
3131
|
-
// scan, and it used to print a green tick and exit 0 — the exact outcome
|
|
3132
|
-
// the README says must never share an exit code with "clean". It is also
|
|
3133
|
-
// the most likely way to be wrong in practice: the headline command is
|
|
3134
|
-
// `npx canship` with no argument, so running it from the wrong directory
|
|
3135
|
-
// is the ordinary user error, and a directory holding nothing but a
|
|
3136
|
-
// build/ folder (every entry of which the walker skips by design) reaches
|
|
3137
|
-
// zero without looking empty to a human.
|
|
3464
|
+
// 主动忽略不影响完整性;错误、跳过或零文件扫描均标记为未完成。
|
|
3138
3465
|
partial: errors.length > 0 || skipped.length > 0 || files.length === 0
|
|
3139
3466
|
};
|
|
3140
3467
|
}
|
|
@@ -3195,6 +3522,26 @@ function skipPhrase(reason) {
|
|
|
3195
3522
|
|
|
3196
3523
|
// src/report/terminal.ts
|
|
3197
3524
|
var INDENT = " ";
|
|
3525
|
+
function renderBaseline(opts) {
|
|
3526
|
+
const suppressed = opts.baselineSuppressed ?? 0;
|
|
3527
|
+
const stale = opts.baselineStale ?? 0;
|
|
3528
|
+
if (suppressed === 0 && stale === 0) return [];
|
|
3529
|
+
const out = [];
|
|
3530
|
+
if (suppressed > 0) {
|
|
3531
|
+
const where = opts.baselinePath ? ` (${opts.baselinePath})` : "";
|
|
3532
|
+
out.push(
|
|
3533
|
+
`${INDENT}${yellow(`${suppressed} ${plural(suppressed, "finding")} hidden by the baseline${where}`)}`
|
|
3534
|
+
);
|
|
3535
|
+
out.push(`${INDENT}${dim("These problems still exist. Re-run without --baseline to see them.")}`);
|
|
3536
|
+
}
|
|
3537
|
+
if (stale > 0) {
|
|
3538
|
+
out.push(
|
|
3539
|
+
// 不规则复数单独处理。
|
|
3540
|
+
`${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
return out;
|
|
3544
|
+
}
|
|
3198
3545
|
function renderReport(result, opts) {
|
|
3199
3546
|
const out = [""];
|
|
3200
3547
|
const { findings } = result;
|
|
@@ -3229,6 +3576,7 @@ function renderReport(result, opts) {
|
|
|
3229
3576
|
out.push("");
|
|
3230
3577
|
}
|
|
3231
3578
|
out.push(...renderIgnored(result));
|
|
3579
|
+
out.push(...renderBaseline(opts));
|
|
3232
3580
|
if (!opts.showingLikely && opts.hiddenLikely > 0) {
|
|
3233
3581
|
out.push(
|
|
3234
3582
|
`${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`
|
|
@@ -3306,10 +3654,17 @@ function renderClean(result, opts) {
|
|
|
3306
3654
|
out.push(
|
|
3307
3655
|
`${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
|
|
3308
3656
|
);
|
|
3657
|
+
} else if ((opts.baselineSuppressed ?? 0) > 0) {
|
|
3658
|
+
const suppressed = opts.baselineSuppressed ?? 0;
|
|
3659
|
+
out.push(
|
|
3660
|
+
`${INDENT}${yellow(bold(`! No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline`))}`
|
|
3661
|
+
);
|
|
3309
3662
|
} else {
|
|
3310
3663
|
out.push(`${INDENT}${green(bold("\u2713 No exposed credentials found"))}`);
|
|
3311
3664
|
}
|
|
3312
3665
|
out.push("");
|
|
3666
|
+
out.push(...renderBaseline(opts));
|
|
3667
|
+
if ((opts.baselineSuppressed ?? 0) > 0 || (opts.baselineStale ?? 0) > 0) out.push("");
|
|
3313
3668
|
out.push(`${INDENT}${dim("canship checked for:")}`);
|
|
3314
3669
|
out.push(`${INDENT}${dim(" \xB7 API keys hardcoded in source code")}`);
|
|
3315
3670
|
out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
|
|
@@ -3324,6 +3679,10 @@ function renderClean(result, opts) {
|
|
|
3324
3679
|
out.push(`${INDENT}${dim("did find are the right ones.")}`);
|
|
3325
3680
|
if (opts.hiddenLikely > 0) {
|
|
3326
3681
|
out.push(`${INDENT}${dim("This is not a finding-free result. Review the hidden items with --all.")}`);
|
|
3682
|
+
} else if (result.ignoredFindings.length > 0) {
|
|
3683
|
+
out.push(
|
|
3684
|
+
`${INDENT}${dim("This is not a finding-free result \u2014 some were silenced in the source. See below.")}`
|
|
3685
|
+
);
|
|
3327
3686
|
} else {
|
|
3328
3687
|
out.push(`${INDENT}${dim("A clean result means these checks passed \u2014 not that your app is secure.")}`);
|
|
3329
3688
|
}
|
|
@@ -3352,6 +3711,20 @@ function renderIgnored(result) {
|
|
|
3352
3711
|
`${INDENT}${dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`)}`
|
|
3353
3712
|
);
|
|
3354
3713
|
}
|
|
3714
|
+
if (result.ruleSelection !== null) {
|
|
3715
|
+
const { only, skip, removed } = result.ruleSelection;
|
|
3716
|
+
const which = only.length > 0 ? `only ${only.join(", ")}` : `everything except ${skip.join(", ")}`;
|
|
3717
|
+
const cost = removed > 0 ? `, hiding ${removed} ${plural(removed, "finding")}` : "";
|
|
3718
|
+
out.push(`${INDENT}${dim(`Rule selection in force: ${which}${cost}`)}`);
|
|
3719
|
+
}
|
|
3720
|
+
if (result.ignoredFindings.length > 0) {
|
|
3721
|
+
const n = result.ignoredFindings.length;
|
|
3722
|
+
const shown = result.ignoredFindings.slice(0, 3).map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
|
|
3723
|
+
const more = n > 3 ? `, and ${n - 3} more` : "";
|
|
3724
|
+
out.push(
|
|
3725
|
+
`${INDENT}${dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`)}`
|
|
3726
|
+
);
|
|
3727
|
+
}
|
|
3355
3728
|
if (result.vendored > 0) {
|
|
3356
3729
|
out.push(
|
|
3357
3730
|
`${INDENT}${dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`)}`
|
|
@@ -3445,8 +3818,18 @@ function renderFixPrompt(findings, ctx) {
|
|
|
3445
3818
|
const incompleteNote = !ctx?.partial ? null : ctx.filesScanned === 0 ? "Note: canship scanned zero files at this path, so none of its file-based checks ran. Do not treat this as a clean result. The path was probably wrong, or everything there is gitignored or build output \u2014 re-run canship pointed at the project source." : "Note: the scan did not finish \u2014 some rules failed or some files could not be read. Fixing what follows does not mean the project is clear; re-run canship once it can complete.";
|
|
3446
3819
|
const hiddenLikely = ctx?.hiddenLikely ?? 0;
|
|
3447
3820
|
const hiddenNote = hiddenLikely === 0 ? null : `Note: ${hiddenLikely} lower-confidence ${hiddenLikely === 1 ? "finding was" : "findings were"} hidden by the default view. Do not treat this as a finding-free result. Re-run with --all --fix-prompt to review them.`;
|
|
3821
|
+
const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
|
|
3822
|
+
const silenced = ctx?.silenced ?? [];
|
|
3823
|
+
const suppressedNotes = [
|
|
3824
|
+
!ctx?.ignoredFiles?.length ? null : `Note: ${ctx.ignoredFiles.length} file(s) excluded by canship-ignore-file: ${defuseMarkers(ctx.ignoredFiles.join(", "))}. Their contents were not checked. Do not treat this as a clean result.`,
|
|
3825
|
+
baselineSuppressed === 0 ? null : `Note: ${baselineSuppressed} ${baselineSuppressed === 1 ? "finding was" : "findings were"} hidden by a baseline. Those problems still exist and are not listed below. Re-run without --baseline to see them.`,
|
|
3826
|
+
silenced.length === 0 ? null : `Note: ${silenced.length} ${silenced.length === 1 ? "finding was" : "findings were"} silenced by a canship-ignore-next-line marker in the source, at ${defuseMarkers(silenced.join(", "))}. Those problems still exist and are not listed below.`,
|
|
3827
|
+
!ctx?.ruleSelection ? null : `Note: rules were selected before this list was produced \u2014 ${defuseMarkers(ctx.ruleSelection)}. Findings from the rules that did not run are not listed below.`
|
|
3828
|
+
].filter((note) => note !== null);
|
|
3448
3829
|
if (findings.length === 0) {
|
|
3449
|
-
const notes = [incompleteNote, hiddenNote].filter(
|
|
3830
|
+
const notes = [incompleteNote, hiddenNote, ...suppressedNotes].filter(
|
|
3831
|
+
(note) => note !== null
|
|
3832
|
+
);
|
|
3450
3833
|
return notes.length === 0 ? null : `${notes.join("\n\n")}
|
|
3451
3834
|
`;
|
|
3452
3835
|
}
|
|
@@ -3463,6 +3846,10 @@ function renderFixPrompt(findings, ctx) {
|
|
|
3463
3846
|
out.push(hiddenNote);
|
|
3464
3847
|
out.push("");
|
|
3465
3848
|
}
|
|
3849
|
+
for (const note of suppressedNotes) {
|
|
3850
|
+
out.push(note);
|
|
3851
|
+
out.push("");
|
|
3852
|
+
}
|
|
3466
3853
|
if (codeFixable.length > 0) {
|
|
3467
3854
|
out.push("--- Paste everything below into your coding assistant ---");
|
|
3468
3855
|
out.push("");
|
|
@@ -3539,18 +3926,21 @@ function renderFinding2(f, index) {
|
|
|
3539
3926
|
function renderHtml(result, opts) {
|
|
3540
3927
|
const { findings } = result;
|
|
3541
3928
|
const hiddenLikely = opts.hiddenLikely ?? 0;
|
|
3929
|
+
const baselineSuppressed = opts.baselineSuppressed ?? 0;
|
|
3930
|
+
const baselineStale = opts.baselineStale ?? 0;
|
|
3542
3931
|
const { blocking: certain, minor, unsure } = verdictOf(findings);
|
|
3543
3932
|
const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
3544
|
-
//
|
|
3933
|
+
// 未扫描文件时不显示通过结论。
|
|
3545
3934
|
`<div class="verdict warn">No files were scanned — nothing was checked</div>`
|
|
3546
3935
|
) : result.partial ? (
|
|
3547
|
-
//
|
|
3548
|
-
// and a scan that skipped files cannot make one.
|
|
3936
|
+
// 扫描不完整时不得显示正常通过。
|
|
3549
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>`
|
|
3550
|
-
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` :
|
|
3938
|
+
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
3939
|
+
// 基线抑制结果时明确说明,避免误报为项目无问题。
|
|
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>`
|
|
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>`;
|
|
3551
3942
|
const body = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
3552
|
-
//
|
|
3553
|
-
// checks had any input to run against.
|
|
3943
|
+
// 零文件扫描不能显示已完成的检查清单。
|
|
3554
3944
|
`<div class="clean-note">
|
|
3555
3945
|
<p>canship found no files it could read at this path, so none of its checks ran.
|
|
3556
3946
|
<strong>This is not a clean result — it is an empty one.</strong></p>
|
|
@@ -3562,6 +3952,10 @@ function renderHtml(result, opts) {
|
|
|
3562
3952
|
<p><strong>This is not a finding-free result.</strong> The default report hides
|
|
3563
3953
|
${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
|
|
3564
3954
|
<p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
|
|
3955
|
+
</div>` : baselineSuppressed > 0 ? `<div class="clean-note">
|
|
3956
|
+
<p><strong>This is not a finding-free result.</strong> A baseline is hiding
|
|
3957
|
+
${baselineSuppressed} ${plural(baselineSuppressed, "finding")}${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}.</p>
|
|
3958
|
+
<p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
|
|
3565
3959
|
</div>` : `<div class="clean-note">
|
|
3566
3960
|
<p>canship checked for hardcoded API keys, server secrets exposed to the browser,
|
|
3567
3961
|
Supabase tables without Row Level Security, open Firebase rules, API routes that reach
|
|
@@ -3572,14 +3966,16 @@ function renderHtml(result, opts) {
|
|
|
3572
3966
|
checks it did find are the right ones.</p>
|
|
3573
3967
|
</div>` : findings.map((f, i) => renderFinding2(f, i + 1)).join("\n");
|
|
3574
3968
|
const optedOut = result.ignored.length > 0 ? `<p class="opted-out">${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by <code>canship-ignore-file</code>: ${result.ignored.map((f) => `<code>${esc(f)}</code>`).join(", ")}</p>` : "";
|
|
3969
|
+
const selection = result.ruleSelection;
|
|
3970
|
+
const ruleSelection = selection === null ? "" : `<p class="opted-out">Rule selection in force: ${selection.only.length > 0 ? `only <code>${selection.only.map(esc).join("</code>, <code>")}</code>` : `everything except <code>${selection.skip.map(esc).join("</code>, <code>")}</code>`}${selection.removed > 0 ? `, hiding ${selection.removed} ${plural(selection.removed, "finding")}` : ""}.</p>`;
|
|
3971
|
+
const silenced = result.ignoredFindings.length > 0 ? `<p class="opted-out">${result.ignoredFindings.length} ${plural(result.ignoredFindings.length, "finding")} silenced by <code>canship-ignore-next-line</code>: ${result.ignoredFindings.map((f) => `<code>${esc(f.file)}:${f.line}</code> (${esc(f.ruleId)})`).join(", ")}</p>` : "";
|
|
3575
3972
|
const hiddenNotice = hiddenLikely > 0 && findings.length > 0 ? `<p class="opted-out">${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden. Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"}.</p>` : "";
|
|
3973
|
+
const baselineNotice = baselineSuppressed > 0 && findings.length > 0 ? `<p class="opted-out">${baselineSuppressed} ${plural(baselineSuppressed, "finding")} hidden by the baseline${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}. Those problems still exist.</p>` : "";
|
|
3974
|
+
const staleNotice = baselineStale > 0 ? `<p class="opted-out">${baselineStale} baseline ${baselineStale === 1 ? "entry" : "entries"} no longer ${baselineStale === 1 ? "matches" : "match"} anything — re-run <code>--baseline-write</code> to prune.</p>` : "";
|
|
3576
3975
|
const incomplete = result.partial ? `<div class="incomplete">
|
|
3577
3976
|
<h2>Not everything was checked</h2>
|
|
3578
3977
|
<ul>
|
|
3579
|
-
${//
|
|
3580
|
-
// tree is entirely gitignored still has a git history, and the
|
|
3581
|
-
// history rule reads it. The findings are real; the file-based
|
|
3582
|
-
// checks simply never ran.
|
|
3978
|
+
${// 工作区零文件时仍可能产生 Git 历史结果。
|
|
3583
3979
|
result.filesScanned === 0 ? `<li>no files could be read at this path, so every file-based check was skipped</li>` : ""}
|
|
3584
3980
|
${result.errors.map(
|
|
3585
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>`
|
|
@@ -3674,6 +4070,10 @@ function renderHtml(result, opts) {
|
|
|
3674
4070
|
${body}
|
|
3675
4071
|
${incomplete}
|
|
3676
4072
|
${hiddenNotice}
|
|
4073
|
+
${baselineNotice}
|
|
4074
|
+
${staleNotice}
|
|
4075
|
+
${silenced}
|
|
4076
|
+
${ruleSelection}
|
|
3677
4077
|
${optedOut}
|
|
3678
4078
|
<footer>
|
|
3679
4079
|
Generated by canship. Everything ran locally; nothing was uploaded.
|
|
@@ -3684,13 +4084,385 @@ function renderHtml(result, opts) {
|
|
|
3684
4084
|
`;
|
|
3685
4085
|
}
|
|
3686
4086
|
|
|
4087
|
+
// src/baseline.ts
|
|
4088
|
+
import { createHash as createHash3 } from "crypto";
|
|
4089
|
+
import { readFileSync as readFileSync3, statSync as statSync3, writeFileSync } from "fs";
|
|
4090
|
+
var BASELINE_VERSION = 2;
|
|
4091
|
+
var DEFAULT_BASELINE_PATH = "canship-baseline.json";
|
|
4092
|
+
function fingerprintOf(f) {
|
|
4093
|
+
const identity = [f.ruleId, f.file ?? "", f.title, f.sourceFingerprint ?? f.excerpt ?? ""].join("\0");
|
|
4094
|
+
return createHash3("sha256").update(identity, "utf8").digest("hex");
|
|
4095
|
+
}
|
|
4096
|
+
function buildBaseline(findings, now = /* @__PURE__ */ new Date()) {
|
|
4097
|
+
const byFingerprint = /* @__PURE__ */ new Map();
|
|
4098
|
+
for (const f of findings) {
|
|
4099
|
+
const fingerprint = fingerprintOf(f);
|
|
4100
|
+
const existing = byFingerprint.get(fingerprint);
|
|
4101
|
+
if (existing) {
|
|
4102
|
+
existing.count++;
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
4105
|
+
byFingerprint.set(fingerprint, {
|
|
4106
|
+
fingerprint,
|
|
4107
|
+
ruleId: f.ruleId,
|
|
4108
|
+
file: f.file,
|
|
4109
|
+
title: f.title,
|
|
4110
|
+
count: 1
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
4113
|
+
const entries = [...byFingerprint.values()].sort(
|
|
4114
|
+
(a, b) => (a.file ?? "").localeCompare(b.file ?? "") || a.ruleId.localeCompare(b.ruleId) || a.fingerprint.localeCompare(b.fingerprint)
|
|
4115
|
+
);
|
|
4116
|
+
return { version: BASELINE_VERSION, generatedAt: now.toISOString(), entries };
|
|
4117
|
+
}
|
|
4118
|
+
function serializeBaseline(baseline) {
|
|
4119
|
+
return `${JSON.stringify(baseline, null, 2)}
|
|
4120
|
+
`;
|
|
4121
|
+
}
|
|
4122
|
+
function writeBaseline(path, baseline) {
|
|
4123
|
+
writeFileSync(path, serializeBaseline(baseline), "utf8");
|
|
4124
|
+
}
|
|
4125
|
+
var BaselineError = class extends Error {
|
|
4126
|
+
};
|
|
4127
|
+
function isEntry(value) {
|
|
4128
|
+
if (typeof value !== "object" || value === null) return false;
|
|
4129
|
+
const e = value;
|
|
4130
|
+
return typeof e["fingerprint"] === "string" && e["fingerprint"].length > 0 && typeof e["ruleId"] === "string" && (e["file"] === null || typeof e["file"] === "string") && typeof e["title"] === "string" && typeof e["count"] === "number" && Number.isInteger(e["count"]) && e["count"] > 0;
|
|
4131
|
+
}
|
|
4132
|
+
var MAX_BASELINE_BYTES = 10 * 1024 * 1024;
|
|
4133
|
+
function readBaseline(path) {
|
|
4134
|
+
let text;
|
|
4135
|
+
try {
|
|
4136
|
+
const size = statSync3(path).size;
|
|
4137
|
+
if (size > MAX_BASELINE_BYTES) {
|
|
4138
|
+
throw new BaselineError(
|
|
4139
|
+
`baseline ${path} is ${size} bytes, over the ${MAX_BASELINE_BYTES}-byte limit`
|
|
4140
|
+
);
|
|
4141
|
+
}
|
|
4142
|
+
text = readFileSync3(path, "utf8");
|
|
4143
|
+
} catch (err) {
|
|
4144
|
+
if (err instanceof BaselineError) throw err;
|
|
4145
|
+
throw new BaselineError(
|
|
4146
|
+
`could not read baseline ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
4147
|
+
);
|
|
4148
|
+
}
|
|
4149
|
+
let parsed;
|
|
4150
|
+
try {
|
|
4151
|
+
parsed = JSON.parse(text);
|
|
4152
|
+
} catch {
|
|
4153
|
+
throw new BaselineError(`baseline ${path} is not valid JSON`);
|
|
4154
|
+
}
|
|
4155
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
4156
|
+
throw new BaselineError(`baseline ${path} is not a baseline file`);
|
|
4157
|
+
}
|
|
4158
|
+
const obj = parsed;
|
|
4159
|
+
const version = obj["version"];
|
|
4160
|
+
if (version !== BASELINE_VERSION) {
|
|
4161
|
+
throw new BaselineError(
|
|
4162
|
+
`baseline ${path} has version ${String(version)}; this canship reads version ${BASELINE_VERSION}. Review the findings and regenerate the baseline with --baseline-write.`
|
|
4163
|
+
);
|
|
4164
|
+
}
|
|
4165
|
+
const rawEntries = obj["entries"];
|
|
4166
|
+
if (!Array.isArray(rawEntries)) {
|
|
4167
|
+
throw new BaselineError(`baseline ${path} has no entries array`);
|
|
4168
|
+
}
|
|
4169
|
+
const entries = [];
|
|
4170
|
+
for (const [i, raw] of rawEntries.entries()) {
|
|
4171
|
+
if (!isEntry(raw)) throw new BaselineError(`baseline ${path}: entry ${i} is malformed`);
|
|
4172
|
+
entries.push({
|
|
4173
|
+
fingerprint: raw.fingerprint,
|
|
4174
|
+
ruleId: raw.ruleId,
|
|
4175
|
+
file: raw.file,
|
|
4176
|
+
title: raw.title,
|
|
4177
|
+
count: raw.count
|
|
4178
|
+
});
|
|
4179
|
+
}
|
|
4180
|
+
const generatedAt = typeof obj["generatedAt"] === "string" ? obj["generatedAt"] : "";
|
|
4181
|
+
return { version: BASELINE_VERSION, generatedAt, entries };
|
|
4182
|
+
}
|
|
4183
|
+
function applyBaseline(findings, baseline) {
|
|
4184
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
4185
|
+
for (const entry of baseline.entries) {
|
|
4186
|
+
remaining.set(entry.fingerprint, (remaining.get(entry.fingerprint) ?? 0) + entry.count);
|
|
4187
|
+
}
|
|
4188
|
+
const kept = [];
|
|
4189
|
+
let suppressed = 0;
|
|
4190
|
+
for (const f of findings) {
|
|
4191
|
+
const budget = remaining.get(fingerprintOf(f)) ?? 0;
|
|
4192
|
+
if (budget > 0) {
|
|
4193
|
+
remaining.set(fingerprintOf(f), budget - 1);
|
|
4194
|
+
suppressed++;
|
|
4195
|
+
continue;
|
|
4196
|
+
}
|
|
4197
|
+
kept.push(f);
|
|
4198
|
+
}
|
|
4199
|
+
let stale = 0;
|
|
4200
|
+
for (const left of remaining.values()) stale += left;
|
|
4201
|
+
return { kept, suppressed, stale };
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// src/report/sarif.ts
|
|
4205
|
+
var INFORMATION_URI = "https://github.com/Tasomei/canship";
|
|
4206
|
+
function suppressionNotes(result, opts) {
|
|
4207
|
+
const notes = [];
|
|
4208
|
+
const baseline = opts.baselineSuppressed ?? 0;
|
|
4209
|
+
const hidden = opts.hiddenLikely ?? 0;
|
|
4210
|
+
if (baseline > 0) {
|
|
4211
|
+
notes.push({
|
|
4212
|
+
level: "warning",
|
|
4213
|
+
message: {
|
|
4214
|
+
text: `${baseline} finding${baseline === 1 ? "" : "s"} hidden by a baseline. Those problems still exist.`
|
|
4215
|
+
}
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
4218
|
+
if (result.ignoredFindings.length > 0) {
|
|
4219
|
+
const where = result.ignoredFindings.map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
|
|
4220
|
+
notes.push({
|
|
4221
|
+
level: "warning",
|
|
4222
|
+
message: {
|
|
4223
|
+
text: `${result.ignoredFindings.length} finding${result.ignoredFindings.length === 1 ? "" : "s"} silenced by canship-ignore-next-line: ${where}`
|
|
4224
|
+
}
|
|
4225
|
+
});
|
|
4226
|
+
}
|
|
4227
|
+
if (result.ignored.length > 0) {
|
|
4228
|
+
notes.push({
|
|
4229
|
+
level: "warning",
|
|
4230
|
+
message: {
|
|
4231
|
+
text: `${result.ignored.length} file${result.ignored.length === 1 ? "" : "s"} excluded by canship-ignore-file: ${result.ignored.join(", ")}`
|
|
4232
|
+
}
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
if (opts.ruleSelection) {
|
|
4236
|
+
notes.push({ level: "warning", message: { text: `Rule selection in force: ${opts.ruleSelection}` } });
|
|
4237
|
+
}
|
|
4238
|
+
if (hidden > 0) {
|
|
4239
|
+
notes.push({
|
|
4240
|
+
level: "warning",
|
|
4241
|
+
message: {
|
|
4242
|
+
text: `${hidden} lower-confidence finding${hidden === 1 ? "" : "s"} not included; re-run with --all.`
|
|
4243
|
+
}
|
|
4244
|
+
});
|
|
4245
|
+
}
|
|
4246
|
+
return notes;
|
|
4247
|
+
}
|
|
4248
|
+
function levelOf(f) {
|
|
4249
|
+
return f.confidence === "certain" && BLOCKING.has(f.severity) ? "error" : "warning";
|
|
4250
|
+
}
|
|
4251
|
+
function rulesOf(findings) {
|
|
4252
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4253
|
+
for (const f of findings) if (!seen.has(f.ruleId)) seen.set(f.ruleId, f);
|
|
4254
|
+
return [...seen.entries()].map(([id, f]) => ({
|
|
4255
|
+
id,
|
|
4256
|
+
name: id,
|
|
4257
|
+
shortDescription: { text: f.title },
|
|
4258
|
+
fullDescription: { text: f.why.join(" ") },
|
|
4259
|
+
help: {
|
|
4260
|
+
text: [...f.why, ...f.fix.length > 0 ? ["How to fix:", ...f.fix] : []].join("\n")
|
|
4261
|
+
},
|
|
4262
|
+
properties: {
|
|
4263
|
+
// 工具专属严重度和置信度保存在扩展属性中。
|
|
4264
|
+
"canship-severity": f.severity,
|
|
4265
|
+
"canship-confidence": f.confidence
|
|
4266
|
+
},
|
|
4267
|
+
defaultConfiguration: { level: levelOf(f) }
|
|
4268
|
+
}));
|
|
4269
|
+
}
|
|
4270
|
+
function resultsOf(findings) {
|
|
4271
|
+
return findings.map((f) => ({
|
|
4272
|
+
ruleId: f.ruleId,
|
|
4273
|
+
level: levelOf(f),
|
|
4274
|
+
message: { text: f.title },
|
|
4275
|
+
// 无可定位文件时不构造虚假位置。
|
|
4276
|
+
locations: f.file === null ? [] : [
|
|
4277
|
+
{
|
|
4278
|
+
physicalLocation: {
|
|
4279
|
+
// 按路径段进行 URI 编码,保留目录分隔符。
|
|
4280
|
+
artifactLocation: { uri: f.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
|
|
4281
|
+
...f.line === null ? {} : { region: { startLine: f.line } }
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
],
|
|
4285
|
+
partialFingerprints: { canshipFindingV2: fingerprintOf(f) }
|
|
4286
|
+
}));
|
|
4287
|
+
}
|
|
4288
|
+
function renderSarif(result, opts) {
|
|
4289
|
+
const { findings } = result;
|
|
4290
|
+
const notifications = [
|
|
4291
|
+
...result.skipped.map((item) => ({
|
|
4292
|
+
level: "warning",
|
|
4293
|
+
message: { text: `${item.path}: ${item.reason}${item.detail ? ` \u2014 ${item.detail}` : ""}` }
|
|
4294
|
+
})),
|
|
4295
|
+
...result.errors.map((e) => ({
|
|
4296
|
+
level: e.kind === "crashed" ? "error" : "warning",
|
|
4297
|
+
message: { text: `${e.ruleId}: ${e.message}` }
|
|
4298
|
+
})),
|
|
4299
|
+
...suppressionNotes(result, opts)
|
|
4300
|
+
];
|
|
4301
|
+
const log = {
|
|
4302
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
4303
|
+
version: "2.1.0",
|
|
4304
|
+
runs: [
|
|
4305
|
+
{
|
|
4306
|
+
tool: {
|
|
4307
|
+
driver: {
|
|
4308
|
+
name: "canship",
|
|
4309
|
+
version: opts.version,
|
|
4310
|
+
informationUri: INFORMATION_URI,
|
|
4311
|
+
rules: rulesOf(findings)
|
|
4312
|
+
}
|
|
4313
|
+
},
|
|
4314
|
+
results: resultsOf(findings),
|
|
4315
|
+
invocations: [
|
|
4316
|
+
{
|
|
4317
|
+
// 该字段表示执行是否完整,与是否发现问题无关。
|
|
4318
|
+
executionSuccessful: !result.partial,
|
|
4319
|
+
...notifications.length > 0 ? { toolExecutionNotifications: notifications } : {}
|
|
4320
|
+
}
|
|
4321
|
+
]
|
|
4322
|
+
}
|
|
4323
|
+
]
|
|
4324
|
+
};
|
|
4325
|
+
return `${JSON.stringify(log, null, 2)}
|
|
4326
|
+
`;
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
// src/config.ts
|
|
4330
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
|
|
4331
|
+
import { join as join3 } from "path";
|
|
4332
|
+
var CONFIG_FILENAME = "canship.config.json";
|
|
4333
|
+
var REFUSED_KEYS = /* @__PURE__ */ new Map([
|
|
4334
|
+
[
|
|
4335
|
+
"bestEffort",
|
|
4336
|
+
"accepting an incomplete scan is a decision for whoever runs canship, not for the project being scanned \u2014 pass --best-effort instead"
|
|
4337
|
+
]
|
|
4338
|
+
]);
|
|
4339
|
+
var ConfigError = class extends Error {
|
|
4340
|
+
};
|
|
4341
|
+
var KNOWN_KEYS = /* @__PURE__ */ new Set(["baseline", "only", "skip", "all"]);
|
|
4342
|
+
function selectors(value, field, path) {
|
|
4343
|
+
if (!Array.isArray(value)) {
|
|
4344
|
+
throw new ConfigError(`${path}: "${field}" must be an array of rule ids`);
|
|
4345
|
+
}
|
|
4346
|
+
const out = [];
|
|
4347
|
+
for (const entry of value) {
|
|
4348
|
+
if (typeof entry !== "string" || entry === "") {
|
|
4349
|
+
throw new ConfigError(`${path}: "${field}" must contain only rule ids`);
|
|
4350
|
+
}
|
|
4351
|
+
if (!isKnownSelector(entry)) {
|
|
4352
|
+
throw new ConfigError(`${path}: "${field}" names no known rule: ${entry}`);
|
|
4353
|
+
}
|
|
4354
|
+
out.push(entry);
|
|
4355
|
+
}
|
|
4356
|
+
return out;
|
|
4357
|
+
}
|
|
4358
|
+
function boolean(value, field, path) {
|
|
4359
|
+
if (typeof value !== "boolean") throw new ConfigError(`${path}: "${field}" must be true or false`);
|
|
4360
|
+
return value;
|
|
4361
|
+
}
|
|
4362
|
+
function parseConfig(text, path) {
|
|
4363
|
+
let parsed;
|
|
4364
|
+
try {
|
|
4365
|
+
parsed = JSON.parse(text);
|
|
4366
|
+
} catch {
|
|
4367
|
+
throw new ConfigError(`${path} is not valid JSON`);
|
|
4368
|
+
}
|
|
4369
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
4370
|
+
throw new ConfigError(`${path} must contain a JSON object`);
|
|
4371
|
+
}
|
|
4372
|
+
const raw = parsed;
|
|
4373
|
+
for (const key of Object.keys(raw)) {
|
|
4374
|
+
const refused = REFUSED_KEYS.get(key);
|
|
4375
|
+
if (refused !== void 0) {
|
|
4376
|
+
throw new ConfigError(`${path}: "${key}" is not allowed here \u2014 ${refused}`);
|
|
4377
|
+
}
|
|
4378
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
4379
|
+
throw new ConfigError(`${path}: unknown setting "${key}"`);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
const config = {};
|
|
4383
|
+
if (raw["baseline"] !== void 0) {
|
|
4384
|
+
if (typeof raw["baseline"] !== "string" || raw["baseline"] === "") {
|
|
4385
|
+
throw new ConfigError(`${path}: "baseline" must be a file path`);
|
|
4386
|
+
}
|
|
4387
|
+
config.baseline = raw["baseline"];
|
|
4388
|
+
}
|
|
4389
|
+
if (raw["only"] !== void 0) config.only = selectors(raw["only"], "only", path);
|
|
4390
|
+
if (raw["skip"] !== void 0) config.skip = selectors(raw["skip"], "skip", path);
|
|
4391
|
+
if (raw["all"] !== void 0) config.all = boolean(raw["all"], "all", path);
|
|
4392
|
+
if (config.only !== void 0 && config.skip !== void 0) {
|
|
4393
|
+
throw new ConfigError(`${path}: "only" and "skip" cannot both be set`);
|
|
4394
|
+
}
|
|
4395
|
+
return config;
|
|
4396
|
+
}
|
|
4397
|
+
var MAX_CONFIG_BYTES = 1024 * 1024;
|
|
4398
|
+
function loadConfig(root) {
|
|
4399
|
+
const path = join3(root, CONFIG_FILENAME);
|
|
4400
|
+
if (!existsSync2(path)) return { config: {}, path: null };
|
|
4401
|
+
let text;
|
|
4402
|
+
try {
|
|
4403
|
+
const size = statSync4(path).size;
|
|
4404
|
+
if (size > MAX_CONFIG_BYTES) {
|
|
4405
|
+
throw new ConfigError(
|
|
4406
|
+
`${path} is ${size} bytes, over the ${MAX_CONFIG_BYTES}-byte limit`
|
|
4407
|
+
);
|
|
4408
|
+
}
|
|
4409
|
+
text = readFileSync4(path, "utf8");
|
|
4410
|
+
} catch (err) {
|
|
4411
|
+
if (err instanceof ConfigError) throw err;
|
|
4412
|
+
throw new ConfigError(
|
|
4413
|
+
`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
return { config: parseConfig(text, path), path };
|
|
4417
|
+
}
|
|
4418
|
+
|
|
3687
4419
|
// src/cli.ts
|
|
3688
|
-
var VERSION = true ? "0.
|
|
4420
|
+
var VERSION = true ? "0.2.1" : "0.0.0-dev";
|
|
3689
4421
|
function argumentError(message) {
|
|
3690
4422
|
process.stderr.write(`canship: ${cleanForOutput(message)}
|
|
3691
4423
|
`);
|
|
3692
4424
|
process.exit(3);
|
|
3693
4425
|
}
|
|
4426
|
+
function optionalValue(arg, name, fallback) {
|
|
4427
|
+
if (arg === name) return fallback;
|
|
4428
|
+
if (!arg.startsWith(`${name}=`)) return null;
|
|
4429
|
+
const value = arg.slice(name.length + 1);
|
|
4430
|
+
if (!value) argumentError(`${name}= needs a file path`);
|
|
4431
|
+
return value;
|
|
4432
|
+
}
|
|
4433
|
+
function insideProject(root, relative3) {
|
|
4434
|
+
const target = resolve2(root, relative3);
|
|
4435
|
+
const inside = relative_(realPathOf(root), realPathOf(target));
|
|
4436
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute2(inside)) {
|
|
4437
|
+
argumentError(
|
|
4438
|
+
`${CONFIG_FILENAME}: "baseline" must stay inside the project, and ${relative3} does not`
|
|
4439
|
+
);
|
|
4440
|
+
}
|
|
4441
|
+
return target;
|
|
4442
|
+
}
|
|
4443
|
+
function realPathOf(path) {
|
|
4444
|
+
let at = path;
|
|
4445
|
+
const rest = [];
|
|
4446
|
+
for (let depth = 0; depth < MAX_REAL_PATH_DEPTH; depth++) {
|
|
4447
|
+
try {
|
|
4448
|
+
const real = realpathSync2(at);
|
|
4449
|
+
return rest.length === 0 ? real : resolve2(real, ...rest);
|
|
4450
|
+
} catch {
|
|
4451
|
+
const parent = resolve2(at, "..");
|
|
4452
|
+
if (parent === at) return path;
|
|
4453
|
+
rest.unshift(relative_(parent, at));
|
|
4454
|
+
at = parent;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
return path;
|
|
4458
|
+
}
|
|
4459
|
+
var MAX_REAL_PATH_DEPTH = 64;
|
|
4460
|
+
var UNREACHABLE_DEFAULT = "";
|
|
4461
|
+
function selectionPhrase(selection) {
|
|
4462
|
+
if (selection === null) return null;
|
|
4463
|
+
const which = selection.only.length > 0 ? `only ${selection.only.join(", ")}` : `everything except ${selection.skip.join(", ")}`;
|
|
4464
|
+
return `${which}, hiding ${selection.removed}`;
|
|
4465
|
+
}
|
|
3694
4466
|
function parseArgs(argv) {
|
|
3695
4467
|
const args = {
|
|
3696
4468
|
root: process.cwd(),
|
|
@@ -3699,21 +4471,61 @@ function parseArgs(argv) {
|
|
|
3699
4471
|
fixPrompt: false,
|
|
3700
4472
|
report: null,
|
|
3701
4473
|
bestEffort: false,
|
|
4474
|
+
baseline: null,
|
|
4475
|
+
baselineDefault: false,
|
|
4476
|
+
baselineWrite: null,
|
|
4477
|
+
baselineWriteDefault: false,
|
|
4478
|
+
only: [],
|
|
4479
|
+
skip: [],
|
|
4480
|
+
sarif: null,
|
|
4481
|
+
noConfig: false,
|
|
3702
4482
|
help: false,
|
|
3703
4483
|
version: false
|
|
3704
4484
|
};
|
|
3705
4485
|
const positional = [];
|
|
3706
4486
|
for (const arg of argv) {
|
|
3707
|
-
|
|
3708
|
-
|
|
4487
|
+
const list = (name) => {
|
|
4488
|
+
if (!arg.startsWith(`${name}=`)) return null;
|
|
4489
|
+
const value = arg.slice(name.length + 1);
|
|
4490
|
+
if (!value) argumentError(`${name}= needs at least one rule id`);
|
|
4491
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
4492
|
+
};
|
|
4493
|
+
const only = list("--only");
|
|
4494
|
+
if (only !== null) {
|
|
4495
|
+
args.only.push(...only);
|
|
3709
4496
|
continue;
|
|
3710
4497
|
}
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
4498
|
+
const skip = list("--skip");
|
|
4499
|
+
if (skip !== null) {
|
|
4500
|
+
args.skip.push(...skip);
|
|
4501
|
+
continue;
|
|
4502
|
+
}
|
|
4503
|
+
const report = optionalValue(arg, "--report", "canship-report.html");
|
|
4504
|
+
if (report !== null) {
|
|
4505
|
+
args.report = report;
|
|
4506
|
+
continue;
|
|
4507
|
+
}
|
|
4508
|
+
if (arg === "--baseline") {
|
|
4509
|
+
args.baselineDefault = true;
|
|
4510
|
+
continue;
|
|
4511
|
+
}
|
|
4512
|
+
const baseline = optionalValue(arg, "--baseline", UNREACHABLE_DEFAULT);
|
|
4513
|
+
if (baseline !== null) {
|
|
4514
|
+
args.baseline = baseline;
|
|
4515
|
+
continue;
|
|
4516
|
+
}
|
|
4517
|
+
if (arg === "--baseline-write") {
|
|
4518
|
+
args.baselineWriteDefault = true;
|
|
4519
|
+
continue;
|
|
4520
|
+
}
|
|
4521
|
+
const baselineWrite = optionalValue(arg, "--baseline-write", UNREACHABLE_DEFAULT);
|
|
4522
|
+
if (baselineWrite !== null) {
|
|
4523
|
+
args.baselineWrite = baselineWrite;
|
|
4524
|
+
continue;
|
|
4525
|
+
}
|
|
4526
|
+
const sarif = optionalValue(arg, "--sarif", "canship.sarif");
|
|
4527
|
+
if (sarif !== null) {
|
|
4528
|
+
args.sarif = sarif;
|
|
3717
4529
|
continue;
|
|
3718
4530
|
}
|
|
3719
4531
|
switch (arg) {
|
|
@@ -3730,6 +4542,9 @@ function parseArgs(argv) {
|
|
|
3730
4542
|
case "--best-effort":
|
|
3731
4543
|
args.bestEffort = true;
|
|
3732
4544
|
break;
|
|
4545
|
+
case "--no-config":
|
|
4546
|
+
args.noConfig = true;
|
|
4547
|
+
break;
|
|
3733
4548
|
case "--help":
|
|
3734
4549
|
case "-h":
|
|
3735
4550
|
args.help = true;
|
|
@@ -3764,6 +4579,14 @@ var HELP = `
|
|
|
3764
4579
|
--json Output raw JSON (for CI or tooling)
|
|
3765
4580
|
--best-effort Allow exit 0 for an incomplete scan with no findings;
|
|
3766
4581
|
findings still exit 1 or 2
|
|
4582
|
+
--baseline[=F] Hide findings already recorded in F, so only new
|
|
4583
|
+
ones are reported (default ${DEFAULT_BASELINE_PATH})
|
|
4584
|
+
--baseline-write[=F] Record the current findings as a new baseline and exit
|
|
4585
|
+
--only=IDS Run matching rules (comma-separated, repeatable)
|
|
4586
|
+
--skip=IDS Exclude matching rules
|
|
4587
|
+
--sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
|
|
4588
|
+
(default canship.sarif)
|
|
4589
|
+
--no-config Ignore canship.config.json in the scanned directory
|
|
3767
4590
|
-h, --help Show this help
|
|
3768
4591
|
-v, --version Show version
|
|
3769
4592
|
|
|
@@ -3775,6 +4598,10 @@ var HELP = `
|
|
|
3775
4598
|
|
|
3776
4599
|
${dim("--json and --fix-prompt are alternative stdout modes; --report may be combined with either.")}
|
|
3777
4600
|
|
|
4601
|
+
${dim("A baseline hides real findings. Every output says how many it hid.")}
|
|
4602
|
+
|
|
4603
|
+
${dim(`Settings may also be committed to ${CONFIG_FILENAME}. A flag always wins over the file.`)}
|
|
4604
|
+
|
|
3778
4605
|
${dim("Scanned files stay local: no project-code execution, network requests, or uploads.")}
|
|
3779
4606
|
`;
|
|
3780
4607
|
async function main() {
|
|
@@ -3792,20 +4619,116 @@ async function main() {
|
|
|
3792
4619
|
if (args.json && args.fixPrompt) {
|
|
3793
4620
|
argumentError("--json and --fix-prompt are mutually exclusive");
|
|
3794
4621
|
}
|
|
3795
|
-
if (
|
|
4622
|
+
if ((args.baseline !== null || args.baselineDefault) && (args.baselineWrite !== null || args.baselineWriteDefault)) {
|
|
4623
|
+
argumentError("--baseline and --baseline-write are mutually exclusive");
|
|
4624
|
+
}
|
|
4625
|
+
if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
|
|
3796
4626
|
process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
|
|
3797
4627
|
`);
|
|
3798
4628
|
return process.exit(3);
|
|
3799
4629
|
}
|
|
3800
|
-
|
|
4630
|
+
let config;
|
|
4631
|
+
try {
|
|
4632
|
+
config = args.noConfig ? {} : loadConfig(args.root).config;
|
|
4633
|
+
} catch (err) {
|
|
4634
|
+
if (err instanceof ConfigError) {
|
|
4635
|
+
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4636
|
+
`);
|
|
4637
|
+
return process.exit(3);
|
|
4638
|
+
}
|
|
4639
|
+
throw err;
|
|
4640
|
+
}
|
|
4641
|
+
for (const [field, values] of [
|
|
4642
|
+
["--only", args.only],
|
|
4643
|
+
["--skip", args.skip]
|
|
4644
|
+
]) {
|
|
4645
|
+
for (const selector of values) {
|
|
4646
|
+
if (!isKnownSelector(selector)) {
|
|
4647
|
+
argumentError(`${field} names no known rule: ${selector}`);
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
const cliSelection = args.only.length > 0 || args.skip.length > 0;
|
|
4652
|
+
const only = cliSelection ? args.only : config.only ?? [];
|
|
4653
|
+
const skip = cliSelection ? args.skip : config.skip ?? [];
|
|
4654
|
+
if (only.length > 0 && skip.length > 0) {
|
|
4655
|
+
argumentError("rule selection cannot use both only and skip");
|
|
4656
|
+
}
|
|
4657
|
+
const showAll = args.showAll || config.all === true;
|
|
4658
|
+
const bestEffort = args.bestEffort;
|
|
4659
|
+
const baselinePath = args.baseline !== null ? resolve2(args.baseline) : args.baselineDefault ? resolve2(args.root, DEFAULT_BASELINE_PATH) : config.baseline !== void 0 ? insideProject(args.root, config.baseline) : null;
|
|
4660
|
+
const scanned = await scan(args.root, { only, skip });
|
|
4661
|
+
if (args.baselineWrite !== null || args.baselineWriteDefault) {
|
|
4662
|
+
const target = args.baselineWrite !== null ? resolve2(args.baselineWrite) : resolve2(args.root, DEFAULT_BASELINE_PATH);
|
|
4663
|
+
const baseline = buildBaseline(scanned.findings);
|
|
4664
|
+
try {
|
|
4665
|
+
writeBaseline(target, baseline);
|
|
4666
|
+
} catch (err) {
|
|
4667
|
+
process.stderr.write(
|
|
4668
|
+
`${red("canship:")} could not write baseline to ${cleanForOutput(target)}
|
|
4669
|
+
${cleanForOutput(String(err))}
|
|
4670
|
+
`
|
|
4671
|
+
);
|
|
4672
|
+
return process.exit(3);
|
|
4673
|
+
}
|
|
4674
|
+
const accepted = scanned.findings.length;
|
|
4675
|
+
process.stdout.write(
|
|
4676
|
+
`
|
|
4677
|
+
${bold("Baseline written to")} ${cyan(cleanForOutput(target))}
|
|
4678
|
+
${dim(`${accepted} ${accepted === 1 ? "finding is" : "findings are"} now accepted and will not be reported.`)}
|
|
4679
|
+
${yellow("These problems still exist.")}
|
|
4680
|
+
${dim("The file names the path, rule and title of each one \u2014 including findings")}
|
|
4681
|
+
${dim("in files git does not track, such as .env.local. It holds no credential")}
|
|
4682
|
+
${dim("values. Commit it so the decision is reviewable; on a public repository,")}
|
|
4683
|
+
${dim("weigh what that publishes first.")}
|
|
4684
|
+
|
|
4685
|
+
`
|
|
4686
|
+
);
|
|
4687
|
+
if (scanned.partial) {
|
|
4688
|
+
process.stderr.write(
|
|
4689
|
+
`${yellow("canship:")} the scan was incomplete, so this baseline may be missing findings.
|
|
4690
|
+
`
|
|
4691
|
+
);
|
|
4692
|
+
}
|
|
4693
|
+
if (scanned.ruleSelection !== null) {
|
|
4694
|
+
process.stderr.write(
|
|
4695
|
+
`${yellow("canship:")} rule selection was in force, so this baseline covers only the rules that ran.
|
|
4696
|
+
`
|
|
4697
|
+
);
|
|
4698
|
+
}
|
|
4699
|
+
return process.exit(0);
|
|
4700
|
+
}
|
|
4701
|
+
let baselineSuppressed = 0;
|
|
4702
|
+
let baselineStale = 0;
|
|
4703
|
+
let result = scanned;
|
|
4704
|
+
if (baselinePath !== null) {
|
|
4705
|
+
const source = baselinePath;
|
|
4706
|
+
try {
|
|
4707
|
+
const applied = applyBaseline(scanned.findings, readBaseline(source));
|
|
4708
|
+
result = { ...scanned, findings: applied.kept };
|
|
4709
|
+
baselineSuppressed = applied.suppressed;
|
|
4710
|
+
baselineStale = applied.stale;
|
|
4711
|
+
} catch (err) {
|
|
4712
|
+
if (err instanceof BaselineError) {
|
|
4713
|
+
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4714
|
+
`);
|
|
4715
|
+
return process.exit(3);
|
|
4716
|
+
}
|
|
4717
|
+
throw err;
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
3801
4720
|
const displayRoot = cleanForOutput(args.root);
|
|
3802
|
-
const shown =
|
|
3803
|
-
const hiddenLikely =
|
|
4721
|
+
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
4722
|
+
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
3804
4723
|
if (args.fixPrompt) {
|
|
3805
4724
|
const prompt = renderFixPrompt(shown, {
|
|
3806
4725
|
partial: result.partial,
|
|
3807
4726
|
filesScanned: result.filesScanned,
|
|
3808
|
-
hiddenLikely
|
|
4727
|
+
hiddenLikely,
|
|
4728
|
+
baselineSuppressed,
|
|
4729
|
+
silenced: result.ignoredFindings.map((f) => `${f.file}:${f.line} (${f.ruleId})`),
|
|
4730
|
+
ignoredFiles: result.ignored,
|
|
4731
|
+
ruleSelection: selectionPhrase(result.ruleSelection)
|
|
3809
4732
|
});
|
|
3810
4733
|
process.stdout.write(
|
|
3811
4734
|
prompt === null ? "Nothing to fix \u2014 no findings.\n" : `${prompt}
|
|
@@ -3819,16 +4742,19 @@ async function main() {
|
|
|
3819
4742
|
root: displayRoot,
|
|
3820
4743
|
filesScanned: result.filesScanned,
|
|
3821
4744
|
durationMs: result.durationMs,
|
|
3822
|
-
//
|
|
3823
|
-
// findings array from a partial scan is not a pass.
|
|
4745
|
+
// 空结果不能掩盖扫描未完成。
|
|
3824
4746
|
partial: result.partial,
|
|
3825
4747
|
errors: result.errors,
|
|
3826
4748
|
skipped: result.skipped,
|
|
3827
4749
|
ignored: result.ignored,
|
|
4750
|
+
ignoredFindings: result.ignoredFindings,
|
|
4751
|
+
ruleSelection: result.ruleSelection,
|
|
3828
4752
|
vendored: result.vendored,
|
|
3829
|
-
//
|
|
3830
|
-
// must not see "no findings" while lower-confidence ones exist.
|
|
4753
|
+
// 隐藏详情时仍披露疑似结果数量。
|
|
3831
4754
|
hiddenLikely,
|
|
4755
|
+
// 明确披露基线抑制和过期条目数量。
|
|
4756
|
+
baselineSuppressed,
|
|
4757
|
+
baselineStale,
|
|
3832
4758
|
findings: shown
|
|
3833
4759
|
},
|
|
3834
4760
|
null,
|
|
@@ -3840,19 +4766,63 @@ async function main() {
|
|
|
3840
4766
|
process.stdout.write(
|
|
3841
4767
|
`${renderReport(
|
|
3842
4768
|
{ ...result, findings: shown },
|
|
3843
|
-
{
|
|
4769
|
+
{
|
|
4770
|
+
root: displayRoot,
|
|
4771
|
+
showingLikely: showAll,
|
|
4772
|
+
hiddenLikely,
|
|
4773
|
+
baselineSuppressed,
|
|
4774
|
+
baselineStale,
|
|
4775
|
+
baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
|
|
4776
|
+
}
|
|
3844
4777
|
)}
|
|
3845
4778
|
`
|
|
3846
4779
|
);
|
|
3847
4780
|
}
|
|
4781
|
+
if (args.sarif) {
|
|
4782
|
+
const target = resolve2(args.sarif);
|
|
4783
|
+
try {
|
|
4784
|
+
writeFileSync2(
|
|
4785
|
+
target,
|
|
4786
|
+
renderSarif(
|
|
4787
|
+
{ ...result, findings: shown },
|
|
4788
|
+
{
|
|
4789
|
+
version: VERSION,
|
|
4790
|
+
baselineSuppressed,
|
|
4791
|
+
hiddenLikely,
|
|
4792
|
+
ruleSelection: selectionPhrase(result.ruleSelection)
|
|
4793
|
+
}
|
|
4794
|
+
),
|
|
4795
|
+
"utf8"
|
|
4796
|
+
);
|
|
4797
|
+
if (!args.json && !args.fixPrompt) {
|
|
4798
|
+
process.stdout.write(` ${dim("SARIF written to")} ${cyan(cleanForOutput(target))}
|
|
4799
|
+
|
|
4800
|
+
`);
|
|
4801
|
+
}
|
|
4802
|
+
} catch (err) {
|
|
4803
|
+
process.stderr.write(
|
|
4804
|
+
`${red("canship:")} could not write SARIF to ${cleanForOutput(target)}
|
|
4805
|
+
${cleanForOutput(String(err))}
|
|
4806
|
+
`
|
|
4807
|
+
);
|
|
4808
|
+
return process.exit(3);
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
3848
4811
|
if (args.report) {
|
|
3849
4812
|
const target = resolve2(args.report);
|
|
3850
4813
|
try {
|
|
3851
|
-
|
|
4814
|
+
writeFileSync2(
|
|
3852
4815
|
target,
|
|
3853
4816
|
renderHtml(
|
|
3854
4817
|
{ ...result, findings: shown },
|
|
3855
|
-
{
|
|
4818
|
+
{
|
|
4819
|
+
root: displayRoot,
|
|
4820
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4821
|
+
hiddenLikely,
|
|
4822
|
+
baselineSuppressed,
|
|
4823
|
+
baselineStale,
|
|
4824
|
+
baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
|
|
4825
|
+
}
|
|
3856
4826
|
),
|
|
3857
4827
|
"utf8"
|
|
3858
4828
|
);
|
|
@@ -3872,7 +4842,7 @@ ${cleanForOutput(String(err))}
|
|
|
3872
4842
|
}
|
|
3873
4843
|
if (verdictOf(result.findings).blocking > 0) return process.exit(1);
|
|
3874
4844
|
if (result.findings.length > 0) return process.exit(2);
|
|
3875
|
-
if (result.partial && !
|
|
4845
|
+
if (result.partial && !bestEffort) return process.exit(3);
|
|
3876
4846
|
return process.exit(0);
|
|
3877
4847
|
}
|
|
3878
4848
|
main().catch((err) => {
|