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/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 existsSync2, statSync as statSync3, writeFileSync } from "fs";
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
- // Shape only, though npm encodes a CRC32 of the token into its last six
51
- // characters and this could verify it. The github-token entry above uses
52
- // the same scheme and does not verify it either, for the reason that
53
- // settles it: if the algorithm ever changes, a checksum test turns a real
54
- // leaked token into a silent pass. A false positive costs a glance. This
55
- // particular false negative costs the account, and every account that
56
- // installs anything published from it afterwards.
50
+ // 仅检查令牌形状,不校验其校验码。
57
51
  pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
58
52
  impact: "Publishes packages under your account. Anyone who installs one afterwards runs whatever that version contains.",
59
53
  rotateAt: "https://docs.npmjs.com/creating-and-viewing-access-tokens"
@@ -64,13 +58,7 @@ var SECRET_PATTERNS = [
64
58
  pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
65
59
  impact: "Depending on its scope, this can be used to run up billed usage on Google Cloud services.",
66
60
  rotateAt: "https://console.cloud.google.com/apis/credentials",
67
- // Unlike every other pattern in this table, an AIza-format key is not a
68
- // bearer credential: it identifies a Firebase project or a Maps Platform
69
- // caller, and Google's own docs say it belongs in client code. Flagging
70
- // it as "exposed to the browser — rotate this" was a false positive on
71
- // every ordinary Firebase or Maps front-end, and the actual protection
72
- // (application/API restrictions in the Cloud console) is not something a
73
- // static scan of the repository can confirm one way or the other.
61
+ // Google 项目标识符不直接作为私密凭据报告。
74
62
  publicByDesign: true
75
63
  },
76
64
  {
@@ -91,17 +79,7 @@ var SECRET_PATTERNS = [
91
79
  id: "supabase-secret-key",
92
80
  name: "Supabase secret key",
93
81
  rotateLabel: "Supabase secret key",
94
- // Supabase's newer key format. The prefix settles it: sb_secret_ is the
95
- // server-side half, sb_publishable_ is the one meant for browsers, and the
96
- // two are never confusable.
97
- //
98
- // framework.ts has recognised this format since the day it was written —
99
- // but only for deciding whether a *client-exposed* value is the admin key.
100
- // It was never in this table, and this table is what the hardcoded-secret
101
- // rule and the output-boundary redaction both walk. So a project with one
102
- // of these in its source got a clean report, and a project with one beside
103
- // another credential had it printed in full: the redaction pass could not
104
- // mask a format it did not know.
82
+ // 共享新版 Supabase 私密密钥模式,与公开密钥区分。
105
83
  pattern: new RegExp(String.raw`\b${SB_SECRET_SOURCE}\b`, "g"),
106
84
  impact: "This is the server-side Supabase key. It bypasses every Row Level Security policy \u2014 it is effectively your database root password.",
107
85
  rotateAt: "your Supabase dashboard, Project Settings -> API Keys"
@@ -117,22 +95,9 @@ var SECRET_PATTERNS = [
117
95
  id: "db-connection-string",
118
96
  name: "Database connection string with password",
119
97
  pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/'"`]+:([^\s@/'"`]+)@(\[[^\]\s]+\]|[^\s'"`/:]+)(?::\d+)?(?:[/?#][^\s'"`]*)?/g,
120
- // Check only the password group for placeholders, so an "example" or "test"
121
- // in the host does not cause a miss.
98
+ // 仅用密码组判断占位符,避免主机名影响判断。
122
99
  secretGroup: 1,
123
- // But if the host itself is an example domain or a local address, the
124
- // connection string is worthless and not worth reporting.
125
- //
126
- // The host alternative takes a bracketed form first so IPv6 survives:
127
- // `[^\s'"/:]+` stops at the first colon, so `@[::1]:5432` captured a lone
128
- // `[`, which matches no entry below — an IPv6 loopback string was reported
129
- // P0 while the identical `localhost` one was correctly ignored.
130
- //
131
- // The backtick is excluded for the same reason. It is the third string
132
- // delimiter in JavaScript and the only one this pattern had never heard of,
133
- // so a connection string written in a template literal handed the host
134
- // group a trailing backtick and defeated the check below. canship found
135
- // that one on its own source, in the comment above.
100
+ // 支持 IPv6,并排除示例和本地地址。
136
101
  ignoreIf: (m) => IRRELEVANT_HOSTS.test(m[2] ?? ""),
137
102
  rotateLabel: "database password",
138
103
  impact: "This contains your database username AND password. Anyone with it can read, modify, or delete your entire database."
@@ -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 truncate(trimmed);
209
- return truncate(trimmed.split(secret).join(redactSecret(secret)));
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 execGitSync(executable, root, args, options = {}) {
390
+ function hardeningArgs(root) {
425
391
  const worktree = gitRootAbove(root) ?? root;
426
392
  const noHooks = process.platform === "win32" ? "NUL" : "/dev/null";
427
- return execFileSync(
428
- executable,
429
- [
430
- "-c",
431
- `core.worktree=${worktree}`,
432
- "-c",
433
- "core.bare=false",
434
- "-c",
435
- "core.fsmonitor=false",
436
- "-c",
437
- `core.hooksPath=${noHooks}`,
438
- ...args
439
- ],
440
- {
441
- cwd: root,
442
- encoding: "utf8",
443
- maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
444
- stdio: ["ignore", "pipe", options.stderr ?? "ignore"],
445
- windowsHide: true,
446
- env: gitEnvironment()
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
- // Build output and tool caches from ecosystems beyond JavaScript. Walking
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 security rules (firestore.rules / storage.rules)
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*(?:\/\/|#|--|\*\/?|\/\*|<!--)?\s*canship-ignore-file\s*(?:\*\/|-->)?\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
- content = decodeText(readFileSync2(absPath));
835
+ if (filesRead >= maxFiles || bytesRead + size > maxBytes) {
836
+ skipped.push({
837
+ path: relPath,
838
+ reason: "too-large",
839
+ detail: `scan read budget exceeded (${maxFiles} files, ${maxBytes} bytes); remaining candidates were not read`
840
+ });
841
+ break;
842
+ }
843
+ const bytes = readFileSync2(absPath);
844
+ bytesRead += bytes.length;
845
+ filesRead++;
846
+ if (bytesRead > maxBytes) {
847
+ skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
848
+ break;
849
+ }
850
+ content = decodeText(bytes);
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
- for (let i = from; i < to && i < out.length; i++) {
897
- if (out[i] !== "\n") out[i] = " ";
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 < src.length) {
903
- if (src[i] === "\\") {
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 (src[i] === quote) return i + 1;
950
+ if (ch === quote) return i + 1;
908
951
  i++;
909
952
  }
910
- return src.length;
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 < src.length) {
916
- if (src[i] === "\\") {
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 (src[i] === "`") {
965
+ if (ch === BACKTICK) {
921
966
  blank(out, literalFrom, i);
922
967
  return i + 1;
923
968
  }
924
- if (src[i] === "$" && src[i + 1] === "{") {
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 < src.length) {
929
- const ch = src[j];
930
- const pair = src.slice(j, j + 2);
931
- if (pair === "//") {
932
- const end = src.indexOf("\n", j);
933
- const stop = end === -1 ? src.length : end;
934
- blank(out, j, stop);
935
- j = stop;
936
- continue;
937
- }
938
- if (pair === "/*") {
939
- const close = src.indexOf("*/", j + 2);
940
- const stop = close === -1 ? src.length : close + 2;
941
- blank(out, j, stop);
942
- j = stop;
943
- continue;
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 (ch === '"' || ch === "'") {
946
- const stop = endOfString(src, j, ch);
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 (ch === "`") {
998
+ if (inner === BACKTICK) {
952
999
  j = maskTemplate(src, out, j);
953
1000
  continue;
954
1001
  }
955
- if (ch === "{") depth++;
956
- else if (ch === "}") {
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, src.length);
972
- return src.length;
1018
+ blank(out, literalFrom, length);
1019
+ return length;
973
1020
  }
974
1021
  function maskJsComments(src) {
975
- const out = src.split("");
1022
+ const length = src.length;
1023
+ const out = codesOf(src);
976
1024
  let i = 0;
977
- while (i < src.length) {
978
- const ch = src[i];
979
- const two = src.slice(i, i + 2);
980
- if (two === "//") {
981
- const end = src.indexOf("\n", i);
982
- const stop = end === -1 ? src.length : end;
983
- blank(out, i, stop);
984
- i = stop;
985
- continue;
986
- }
987
- if (two === "/*") {
988
- const close = src.indexOf("*/", i + 2);
989
- const stop = close === -1 ? src.length : close + 2;
990
- blank(out, i, stop);
991
- i = stop;
992
- continue;
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 === '"' || ch === "'" || 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.join("");
1050
+ return stringOf(out);
1001
1051
  }
1002
1052
  function maskJsNoise(src) {
1003
- const out = src.split("");
1053
+ const length = src.length;
1054
+ const out = codesOf(src);
1004
1055
  let i = 0;
1005
- while (i < src.length) {
1006
- const ch = src[i];
1007
- const two = src.slice(i, i + 2);
1008
- if (two === "//") {
1009
- const end = src.indexOf("\n", i);
1010
- const stop = end === -1 ? src.length : end;
1011
- blank(out, i, stop);
1012
- i = stop;
1013
- continue;
1014
- }
1015
- if (two === "/*") {
1016
- const close = src.indexOf("*/", i + 2);
1017
- const stop = close === -1 ? src.length : close + 2;
1018
- blank(out, i, stop);
1019
- i = stop;
1020
- continue;
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 === '"' || 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.join("");
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
- function isSupabaseProject(ctx) {
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 ctx.files) {
1128
- if (file.path === "supabase" || file.path.startsWith("supabase/")) return true;
1129
- if (file.path.includes("/supabase/migrations/")) return true;
1130
- const name = file.path.slice(file.path.lastIndexOf("/") + 1);
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
- break;
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.length <= MAX_FINDINGS_PER_FILE) return 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.slice(0, MAX_FINDINGS_PER_FILE);
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
- for (const rev of revs) {
1562
- const body = git(root, gitExecutable, [
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 { evidence: "proof", unread: 0, unreadable };
1574
- if (evidence === "hint") best = "hint";
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 { evidence: best, unread: all.length - revs.length, unreadable };
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
- // Only claim certainty when the file actually holds something that is
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 with git show; the repository may be incomplete or the file may exceed the Git output limit`
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
- // Claiming certainty about a file nobody could read would be the same
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 out = sql.split("");
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 < sql.length) {
1708
- const ch = sql[i];
1709
- const two = sql.slice(i, i + 2);
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 ? sql.length : end);
1713
- i = end === -1 ? sql.length : end;
1826
+ erase(i, end === -1 ? length : end);
1827
+ i = end === -1 ? length : end;
1714
1828
  continue;
1715
1829
  }
1716
- if (two === "/*") {
1830
+ if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
1717
1831
  let depth = 0;
1718
1832
  let j = i;
1719
- while (j < sql.length) {
1720
- const pair = sql.slice(j, j + 2);
1721
- if (pair === "/*") {
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 (pair === "*/") {
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 < sql.length) {
1740
- if (escaped && sql[j] === "\\") {
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 (sql[j] === "'") {
1745
- if (sql[j + 1] === "'") {
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 < sql.length && sql[j] !== '"') {
1761
- if (/\s/.test(out[j] ?? "") && out[j] !== "\n") out[j] = "_";
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 ? sql.length : close + tag.length;
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.join("");
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
- const rebased = files.map(
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
- // Open writes are unambiguous. An open read might be intentional
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
- // A hardcoded expiry date is never a deliberate authorisation design,
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 = code.matchAll(/\bif\s*\(/g);
2234
- for (const match of starts) {
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 stopsRequest = /\b(?:return|throw|redirect|notFound)\b/.test(statement);
2356
+ const body = statement.startsWith("{") ? statement.slice(1, -1) : statement;
2357
+ const pairs = delimiterPairs(body);
2358
+ let stopsRequest = false;
2359
+ for (let i = 0; i < body.length; i++) {
2360
+ if (/^(?:return|throw|redirect|notFound)\b/.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2361
+ stopsRequest = true;
2362
+ break;
2363
+ }
2364
+ if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
2365
+ const end = pairs.get(i);
2366
+ if (end !== void 0) i = end;
2367
+ }
2368
+ let statementStart = close + 1;
2369
+ while (/\s/.test(code[statementStart] ?? "")) statementStart++;
2370
+ starts.lastIndex = statementStart + statement.length;
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
- if (AUTH_CONDITION.test(condition) || returnsDeniedStatus) return true;
2373
+ const negative = !/&&|\?/.test(condition) && condition.split("||").some((part) => {
2374
+ const term = part.trim();
2375
+ const rejects = /^!\s*[\w$.]+(?:\s*\([^=]*\))?$/.test(term) || /^[\w$.]+\s*={2,3}\s*(?:null|undefined|false)$/.test(term) || /^[\w$.]+\s*!={1,2}\s*(?!(?:null|undefined|false)\b)[\w$.]+$/.test(term);
2376
+ return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2377
+ });
2378
+ if (negative) return true;
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
- if (hasAuthSignal(route)) continue;
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
- // Hard evidence: the route runs queries through a key that bypasses
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
- // Lower confidence on purpose: the write may be legitimately open
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
- // Both halves are read straight out of the file: the origin is handed
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
- // Not a judgement call: the specification forbids this pair, so every
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
- // Per paragraph, so the breaks between them survive a cleaner that removes
3024
- // every newline inside them. See Finding.why.
3318
+ // 按段落清理,保留段落之间的结构。
3025
3319
  why: f.why.map(clean),
3026
- // The path was left out of this list once, and a filename holding a
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
- // Redacted first, cut second, and both of them here.
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
- // Deduplicated here rather than by each rule remembering to report once.
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 FILE_RULES) {
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 PROJECT_RULES) {
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(sortFindings(dedupe(downgradeExampleContext(findings, files)))),
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
- // A deliberate opt-out is not an incomplete scan: the user made that call
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((note) => note !== null);
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
- // Examined nothing, so there is nothing to report either way.
3933
+ // 未扫描文件时不显示通过结论。
3545
3934
  `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
3546
3935
  ) : result.partial ? (
3547
- // Never the green banner on a partial scan: it reads as a guarantee,
3548
- // and a scan that skipped files cannot make one.
3936
+ // 扫描不完整时不得显示正常通过。
3549
3937
  hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings &mdash; but not everything was checked</div>`
3550
- ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : `<div class="verdict clean">No exposed credentials found</div>` : certain > 0 ? `<div class="verdict bad">${certain} critical ${plural(certain, "issue")} &mdash; do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix &mdash; nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
3938
+ ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
3939
+ // 基线抑制结果时明确说明,避免误报为项目无问题。
3940
+ baselineSuppressed > 0 ? `<div class="verdict warn">No new findings &mdash; ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline</div>` : `<div class="verdict clean">No exposed credentials found</div>`
3941
+ ) : certain > 0 ? `<div class="verdict bad">${certain} critical ${plural(certain, "issue")} &mdash; do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix &mdash; nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
3551
3942
  const body = findings.length === 0 ? result.filesScanned === 0 ? (
3552
- // The checklist below would be a false statement here: none of those
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 &mdash; 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 &mdash; 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
- ${// Reachable with findings present: a repository whose working
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>` : ""} &mdash; ${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.1.1" : "0.0.0-dev";
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
- if (arg === "--report") {
3708
- args.report = "canship-report.html";
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
- if (arg.startsWith("--report=")) {
3712
- const value = arg.slice("--report=".length);
3713
- if (!value) {
3714
- argumentError("--report= needs a file path");
3715
- }
3716
- args.report = value;
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 (!existsSync2(args.root) || !statSync3(args.root).isDirectory()) {
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
- const result = await scan(args.root);
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 = args.showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
3803
- const hiddenLikely = args.showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
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
- // Machine consumers need the same distinction humans get: an empty
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
- // The default view hides the detail, never the fact. A machine reading this
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
- { root: displayRoot, showingLikely: args.showAll, hiddenLikely }
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
- writeFileSync(
4814
+ writeFileSync2(
3852
4815
  target,
3853
4816
  renderHtml(
3854
4817
  { ...result, findings: shown },
3855
- { root: displayRoot, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), hiddenLikely }
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 && !args.bestEffort) return process.exit(3);
4845
+ if (result.partial && !bestEffort) return process.exit(3);
3876
4846
  return process.exit(0);
3877
4847
  }
3878
4848
  main().catch((err) => {