canship 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +204 -114
  2. package/README.zh-CN.md +206 -116
  3. package/dist/cli.js +1180 -173
  4. package/package.json +1 -1
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;
@@ -44,6 +44,20 @@ var SECRET_PATTERNS = [
44
44
  impact: "Grants access to your repositories \u2014 including private ones, and the ability to push code.",
45
45
  rotateAt: "https://github.com/settings/tokens"
46
46
  },
47
+ {
48
+ id: "npm-token",
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.
57
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
58
+ impact: "Publishes packages under your account. Anyone who installs one afterwards runs whatever that version contains.",
59
+ rotateAt: "https://docs.npmjs.com/creating-and-viewing-access-tokens"
60
+ },
47
61
  {
48
62
  id: "google-api-key",
49
63
  name: "Google API key",
@@ -191,8 +205,8 @@ function redactSecret(secret) {
191
205
  }
192
206
  function redactLine(line, secret) {
193
207
  const trimmed = line.trim();
194
- if (!secret) return truncate(trimmed);
195
- return truncate(trimmed.split(secret).join(redactSecret(secret)));
208
+ if (!secret) return trimmed;
209
+ return trimmed.split(secret).join(redactSecret(secret));
196
210
  }
197
211
  var JWT_SHAPED = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
198
212
  function redactAll(text) {
@@ -407,31 +421,39 @@ function gitEnvironment() {
407
421
  env.PAGER = "";
408
422
  return env;
409
423
  }
410
- function execGitSync(executable, root, args, options = {}) {
424
+ function hardeningArgs(root) {
411
425
  const worktree = gitRootAbove(root) ?? root;
412
426
  const noHooks = process.platform === "win32" ? "NUL" : "/dev/null";
413
- return execFileSync(
414
- executable,
415
- [
416
- "-c",
417
- `core.worktree=${worktree}`,
418
- "-c",
419
- "core.bare=false",
420
- "-c",
421
- "core.fsmonitor=false",
422
- "-c",
423
- `core.hooksPath=${noHooks}`,
424
- ...args
425
- ],
426
- {
427
- cwd: root,
428
- encoding: "utf8",
429
- maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
430
- stdio: ["ignore", "pipe", options.stderr ?? "ignore"],
431
- windowsHide: true,
432
- env: gitEnvironment()
433
- }
434
- );
427
+ return [
428
+ "-c",
429
+ `core.worktree=${worktree}`,
430
+ "-c",
431
+ "core.bare=false",
432
+ "-c",
433
+ "core.fsmonitor=false",
434
+ "-c",
435
+ `core.hooksPath=${noHooks}`
436
+ ];
437
+ }
438
+ function execGitSync(executable, root, args, options = {}) {
439
+ return execFileSync(executable, [...hardeningArgs(root), ...args], {
440
+ cwd: root,
441
+ encoding: "utf8",
442
+ maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
443
+ stdio: ["ignore", "pipe", options.stderr ?? "ignore"],
444
+ windowsHide: true,
445
+ env: gitEnvironment()
446
+ });
447
+ }
448
+ function execGitBatch(executable, root, args, input, options = {}) {
449
+ return execFileSync(executable, [...hardeningArgs(root), ...args], {
450
+ cwd: root,
451
+ input,
452
+ maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
453
+ stdio: ["pipe", "pipe", options.stderr ?? "ignore"],
454
+ windowsHide: true,
455
+ env: gitEnvironment()
456
+ });
435
457
  }
436
458
 
437
459
  // src/walker.ts
@@ -521,10 +543,29 @@ var CREDENTIAL_FILENAMES = /* @__PURE__ */ new Set([
521
543
  "id_ed25519"
522
544
  ]);
523
545
  var PROBE_BYTES = 4096;
524
- var IGNORE_FILE_MARKER = /^\s*(?:\/\/|#|--|\*\/?|\/\*|<!--)?\s*canship-ignore-file\s*(?:\*\/|-->)?\s*$/;
546
+ var IGNORE_FILE_MARKER = /^\s*(?:(?:\/\/|#|--|\*\/?|\/\*|<!--)\s*)?canship-ignore-file(?:\s*(?:\*\/|-->))?\s*$/;
525
547
  function hasIgnoreMarker(lines) {
526
548
  return lines.some((line) => IGNORE_FILE_MARKER.test(line));
527
549
  }
550
+ var IGNORE_LINE_MARKER = /^\s*(?:(?:\/\/|#|--|\*\/?|\/\*|<!--)\s*)?canship-ignore-next-line(?:\s+([\w./-]+))?(?:\s*(?:\*\/|-->))?\s*$/;
551
+ function ignoredLinesOf(lines) {
552
+ const found = /* @__PURE__ */ new Map();
553
+ lines.forEach((line, index) => {
554
+ const match = IGNORE_LINE_MARKER.exec(line);
555
+ if (match === null) return;
556
+ const governed = index + 2;
557
+ const ruleId = match[1];
558
+ if (ruleId === void 0) {
559
+ found.set(governed, null);
560
+ return;
561
+ }
562
+ if (found.has(governed) && found.get(governed) === null) return;
563
+ const rules = found.get(governed) ?? /* @__PURE__ */ new Set();
564
+ rules.add(ruleId);
565
+ found.set(governed, rules);
566
+ });
567
+ return found;
568
+ }
528
569
  var SKIP_FILENAMES = /* @__PURE__ */ new Set(["bun.lockb"]);
529
570
  function isEnvFile(name) {
530
571
  const lower = name.toLowerCase();
@@ -878,68 +919,100 @@ function parseEnvLine(raw) {
878
919
  }
879
920
 
880
921
  // src/mask.ts
922
+ var SLASH = 47;
923
+ var STAR = 42;
924
+ var DOUBLE_QUOTE = 34;
925
+ var SINGLE_QUOTE = 39;
926
+ var BACKTICK = 96;
927
+ var BACKSLASH = 92;
928
+ var DOLLAR = 36;
929
+ var OPEN_BRACE = 123;
930
+ var CLOSE_BRACE = 125;
931
+ var NEWLINE = 10;
932
+ var SPACE = 32;
933
+ function codesOf(src) {
934
+ const out = new Uint16Array(src.length);
935
+ for (let i = 0; i < src.length; i++) out[i] = src.charCodeAt(i);
936
+ return out;
937
+ }
938
+ var CHUNK = 8192;
939
+ function stringOf(out) {
940
+ let text = "";
941
+ for (let i = 0; i < out.length; i += CHUNK) {
942
+ const end = i + CHUNK < out.length ? i + CHUNK : out.length;
943
+ text += String.fromCharCode.apply(null, out.subarray(i, end));
944
+ }
945
+ return text;
946
+ }
881
947
  function blank(out, from, to) {
882
- for (let i = from; i < to && i < out.length; i++) {
883
- if (out[i] !== "\n") out[i] = " ";
948
+ const end = to < out.length ? to : out.length;
949
+ for (let i = from; i < end; i++) {
950
+ if (out[i] !== NEWLINE) out[i] = SPACE;
884
951
  }
885
952
  }
886
953
  function endOfString(src, start, quote) {
954
+ const length = src.length;
887
955
  let i = start + 1;
888
- while (i < src.length) {
889
- if (src[i] === "\\") {
956
+ while (i < length) {
957
+ const ch = src.charCodeAt(i);
958
+ if (ch === BACKSLASH) {
890
959
  i += 2;
891
960
  continue;
892
961
  }
893
- if (src[i] === quote) return i + 1;
962
+ if (ch === quote) return i + 1;
894
963
  i++;
895
964
  }
896
- return src.length;
965
+ return length;
897
966
  }
898
967
  function maskTemplate(src, out, start) {
968
+ const length = src.length;
899
969
  let i = start + 1;
900
970
  let literalFrom = i;
901
- while (i < src.length) {
902
- if (src[i] === "\\") {
971
+ while (i < length) {
972
+ const ch = src.charCodeAt(i);
973
+ if (ch === BACKSLASH) {
903
974
  i += 2;
904
975
  continue;
905
976
  }
906
- if (src[i] === "`") {
977
+ if (ch === BACKTICK) {
907
978
  blank(out, literalFrom, i);
908
979
  return i + 1;
909
980
  }
910
- if (src[i] === "$" && src[i + 1] === "{") {
981
+ if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
911
982
  blank(out, literalFrom, i);
912
983
  let depth = 0;
913
984
  let j = i + 1;
914
- while (j < src.length) {
915
- const ch = src[j];
916
- const pair = src.slice(j, j + 2);
917
- if (pair === "//") {
918
- const end = src.indexOf("\n", j);
919
- const stop = end === -1 ? src.length : end;
920
- blank(out, j, stop);
921
- j = stop;
922
- continue;
923
- }
924
- if (pair === "/*") {
925
- const close = src.indexOf("*/", j + 2);
926
- const stop = close === -1 ? src.length : close + 2;
927
- blank(out, j, stop);
928
- j = stop;
929
- continue;
985
+ while (j < length) {
986
+ const inner = src.charCodeAt(j);
987
+ if (inner === SLASH) {
988
+ const next = src.charCodeAt(j + 1);
989
+ if (next === SLASH) {
990
+ const end = src.indexOf("\n", j);
991
+ const stop = end === -1 ? length : end;
992
+ blank(out, j, stop);
993
+ j = stop;
994
+ continue;
995
+ }
996
+ if (next === STAR) {
997
+ const close = src.indexOf("*/", j + 2);
998
+ const stop = close === -1 ? length : close + 2;
999
+ blank(out, j, stop);
1000
+ j = stop;
1001
+ continue;
1002
+ }
930
1003
  }
931
- if (ch === '"' || ch === "'") {
932
- const stop = endOfString(src, j, ch);
1004
+ if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
1005
+ const stop = endOfString(src, j, inner);
933
1006
  blank(out, j + 1, stop - 1);
934
1007
  j = stop;
935
1008
  continue;
936
1009
  }
937
- if (ch === "`") {
1010
+ if (inner === BACKTICK) {
938
1011
  j = maskTemplate(src, out, j);
939
1012
  continue;
940
1013
  }
941
- if (ch === "{") depth++;
942
- else if (ch === "}") {
1014
+ if (inner === OPEN_BRACE) depth++;
1015
+ else if (inner === CLOSE_BRACE) {
943
1016
  depth--;
944
1017
  if (depth === 0) {
945
1018
  j++;
@@ -954,70 +1027,76 @@ function maskTemplate(src, out, start) {
954
1027
  }
955
1028
  i++;
956
1029
  }
957
- blank(out, literalFrom, src.length);
958
- return src.length;
1030
+ blank(out, literalFrom, length);
1031
+ return length;
959
1032
  }
960
1033
  function maskJsComments(src) {
961
- const out = src.split("");
1034
+ const length = src.length;
1035
+ const out = codesOf(src);
962
1036
  let i = 0;
963
- while (i < src.length) {
964
- const ch = src[i];
965
- const two = src.slice(i, i + 2);
966
- if (two === "//") {
967
- const end = src.indexOf("\n", i);
968
- const stop = end === -1 ? src.length : end;
969
- blank(out, i, stop);
970
- i = stop;
971
- continue;
972
- }
973
- if (two === "/*") {
974
- const close = src.indexOf("*/", i + 2);
975
- const stop = close === -1 ? src.length : close + 2;
976
- blank(out, i, stop);
977
- i = stop;
978
- continue;
1037
+ while (i < length) {
1038
+ const ch = src.charCodeAt(i);
1039
+ if (ch === SLASH) {
1040
+ const next = src.charCodeAt(i + 1);
1041
+ if (next === SLASH) {
1042
+ const end = src.indexOf("\n", i);
1043
+ const stop = end === -1 ? length : end;
1044
+ blank(out, i, stop);
1045
+ i = stop;
1046
+ continue;
1047
+ }
1048
+ if (next === STAR) {
1049
+ const close = src.indexOf("*/", i + 2);
1050
+ const stop = close === -1 ? length : close + 2;
1051
+ blank(out, i, stop);
1052
+ i = stop;
1053
+ continue;
1054
+ }
979
1055
  }
980
- if (ch === '"' || ch === "'" || ch === "`") {
1056
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE || ch === BACKTICK) {
981
1057
  i = endOfString(src, i, ch);
982
1058
  continue;
983
1059
  }
984
1060
  i++;
985
1061
  }
986
- return out.join("");
1062
+ return stringOf(out);
987
1063
  }
988
1064
  function maskJsNoise(src) {
989
- const out = src.split("");
1065
+ const length = src.length;
1066
+ const out = codesOf(src);
990
1067
  let i = 0;
991
- while (i < src.length) {
992
- const ch = src[i];
993
- const two = src.slice(i, i + 2);
994
- if (two === "//") {
995
- const end = src.indexOf("\n", i);
996
- const stop = end === -1 ? src.length : end;
997
- blank(out, i, stop);
998
- i = stop;
999
- continue;
1000
- }
1001
- if (two === "/*") {
1002
- const close = src.indexOf("*/", i + 2);
1003
- const stop = close === -1 ? src.length : close + 2;
1004
- blank(out, i, stop);
1005
- i = stop;
1006
- continue;
1068
+ while (i < length) {
1069
+ const ch = src.charCodeAt(i);
1070
+ if (ch === SLASH) {
1071
+ const next = src.charCodeAt(i + 1);
1072
+ if (next === SLASH) {
1073
+ const end = src.indexOf("\n", i);
1074
+ const stop = end === -1 ? length : end;
1075
+ blank(out, i, stop);
1076
+ i = stop;
1077
+ continue;
1078
+ }
1079
+ if (next === STAR) {
1080
+ const close = src.indexOf("*/", i + 2);
1081
+ const stop = close === -1 ? length : close + 2;
1082
+ blank(out, i, stop);
1083
+ i = stop;
1084
+ continue;
1085
+ }
1007
1086
  }
1008
- if (ch === '"' || ch === "'") {
1087
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1009
1088
  const stop = endOfString(src, i, ch);
1010
1089
  blank(out, i + 1, stop - 1);
1011
1090
  i = stop;
1012
1091
  continue;
1013
1092
  }
1014
- if (ch === "`") {
1093
+ if (ch === BACKTICK) {
1015
1094
  i = maskTemplate(src, out, i);
1016
1095
  continue;
1017
1096
  }
1018
1097
  i++;
1019
1098
  }
1020
- return out.join("");
1099
+ return stringOf(out);
1021
1100
  }
1022
1101
  var commentCache = /* @__PURE__ */ new WeakMap();
1023
1102
  var noiseCache = /* @__PURE__ */ new WeakMap();
@@ -1108,12 +1187,14 @@ function isClientCode(file) {
1108
1187
  }
1109
1188
  return false;
1110
1189
  }
1111
- function isSupabaseProject(ctx) {
1190
+ var MENTIONS_SUPABASE = /supabase/i;
1191
+ function isSupabaseProject(ctx, files = ctx.files, scope = "") {
1112
1192
  const isSupabaseUrlName = (name) => name === "SUPABASE_URL" || name.endsWith("_SUPABASE_URL");
1113
- for (const file of ctx.files) {
1114
- if (file.path === "supabase" || file.path.startsWith("supabase/")) return true;
1115
- if (file.path.includes("/supabase/migrations/")) return true;
1116
- const name = file.path.slice(file.path.lastIndexOf("/") + 1);
1193
+ for (const file of files) {
1194
+ const path = scope === "" ? file.path : file.path.slice(scope.length + 1);
1195
+ if (path === "supabase" || path.startsWith("supabase/")) return true;
1196
+ if (path.includes("/supabase/migrations/")) return true;
1197
+ const name = path.slice(path.lastIndexOf("/") + 1);
1117
1198
  if (isEnvFile(name)) {
1118
1199
  for (const line of file.lines) {
1119
1200
  const entry = parseEnvLine(line);
@@ -1134,6 +1215,7 @@ function isSupabaseProject(ctx) {
1134
1215
  }
1135
1216
  continue;
1136
1217
  }
1218
+ if (!MENTIONS_SUPABASE.test(file.content)) continue;
1137
1219
  const commentsRemoved = commentsMaskedOf(file);
1138
1220
  const code = noiseMaskedOf(file);
1139
1221
  const supabaseImport = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s*)['"]@supabase\/(?:supabase-js|ssr)(?:\/[^'"]*)?['"]/g;
@@ -1209,18 +1291,17 @@ var secretsRule = {
1209
1291
  if (pat.publicByDesign) continue;
1210
1292
  pat.pattern.lastIndex = 0;
1211
1293
  let match;
1212
- if (findings.length >= MAX_FINDINGS_PER_FILE) break;
1213
1294
  while ((match = pat.pattern.exec(file.content)) !== null) {
1295
+ const secret = match[0];
1296
+ if (isPlaceholder(secretPartOf(match, pat))) continue;
1297
+ if (pat.ignoreIf?.(match)) continue;
1214
1298
  if (findings.length >= MAX_FINDINGS_PER_FILE) {
1215
1299
  ctx.reportIncomplete(
1216
1300
  "secrets/hardcoded",
1217
1301
  `${file.path} holds more than ${MAX_FINDINGS_PER_FILE} credential-shaped strings; the rest were not reported`
1218
1302
  );
1219
- break;
1303
+ return findings;
1220
1304
  }
1221
- const secret = match[0];
1222
- if (isPlaceholder(secretPartOf(match, pat))) continue;
1223
- if (pat.ignoreIf?.(match)) continue;
1224
1305
  const line = lineNumberAt(lineStarts, match.index);
1225
1306
  const rawLine = file.lines[line - 1] ?? "";
1226
1307
  const clientSide = isClientCode(file);
@@ -1454,6 +1535,7 @@ function checkSourceFile(file) {
1454
1535
 
1455
1536
  // src/rules/gitleak.ts
1456
1537
  import { basename as basename4 } from "path";
1538
+ import { createHash } from "crypto";
1457
1539
  function isEnvTemplate(path) {
1458
1540
  return isTemplateName(path);
1459
1541
  }
@@ -1495,6 +1577,41 @@ function git(root, gitExecutable, args) {
1495
1577
  return null;
1496
1578
  }
1497
1579
  }
1580
+ function batchBlobs(root, gitExecutable, specs) {
1581
+ if (gitExecutable === null || specs.length === 0) return specs.map(() => null);
1582
+ if (specs.some((spec) => spec.includes("\n") || spec.includes("\r"))) {
1583
+ return specs.map((spec) => git(root, gitExecutable, ["show", "--no-ext-diff", "--no-textconv", spec]));
1584
+ }
1585
+ let out;
1586
+ try {
1587
+ out = execGitBatch(
1588
+ gitExecutable,
1589
+ root,
1590
+ ["cat-file", "--batch", "--buffer"],
1591
+ `${specs.join("\n")}
1592
+ `
1593
+ );
1594
+ } catch {
1595
+ return specs.map(() => null);
1596
+ }
1597
+ const blobs = [];
1598
+ let at = 0;
1599
+ for (let i = 0; i < specs.length; i++) {
1600
+ const newline = out.indexOf(10, at);
1601
+ if (newline === -1) break;
1602
+ const header = out.toString("utf8", at, newline);
1603
+ at = newline + 1;
1604
+ const size = Number(header.slice(header.lastIndexOf(" ") + 1));
1605
+ if (!Number.isInteger(size) || size < 0) {
1606
+ blobs.push(null);
1607
+ continue;
1608
+ }
1609
+ blobs.push(out.toString("utf8", at, at + size));
1610
+ at += size + 1;
1611
+ }
1612
+ while (blobs.length < specs.length) blobs.push(null);
1613
+ return blobs;
1614
+ }
1498
1615
  function gitOrThrow(root, gitExecutable, args) {
1499
1616
  const out = git(root, gitExecutable, args);
1500
1617
  if (out === null) throw new Error(`git ${args.slice(0, 2).join(" ")} failed in ${root}`);
@@ -1542,24 +1659,37 @@ function historicalEvidence(root, gitExecutable, entry) {
1542
1659
  ]) ?? "").split(/\r?\n/).filter(Boolean);
1543
1660
  if (all.length === 0) return null;
1544
1661
  const revs = all.slice(0, MAX_HISTORY_REVISIONS);
1662
+ const bodies = batchBlobs(
1663
+ root,
1664
+ gitExecutable,
1665
+ revs.map((rev) => `${rev}:${entry.repoPath}`)
1666
+ );
1545
1667
  let best = "none";
1546
1668
  let unreadable = 0;
1547
- for (const rev of revs) {
1548
- const body = git(root, gitExecutable, [
1549
- "show",
1550
- "--no-ext-diff",
1551
- "--no-textconv",
1552
- `${rev}:${entry.repoPath}`
1553
- ]);
1669
+ const hintHashes = /* @__PURE__ */ new Set();
1670
+ for (const body of bodies) {
1554
1671
  if (body === null) {
1555
1672
  unreadable++;
1556
1673
  continue;
1557
1674
  }
1558
1675
  const evidence = evidenceIn(body.split(/\r?\n/));
1559
- if (evidence === "proof") return { evidence: "proof", unread: 0, unreadable };
1560
- if (evidence === "hint") best = "hint";
1676
+ if (evidence === "proof") return {
1677
+ evidence: "proof",
1678
+ unread: 0,
1679
+ unreadable,
1680
+ sourceFingerprint: createHash("sha256").update(body.trim(), "utf8").digest("hex")
1681
+ };
1682
+ if (evidence === "hint") {
1683
+ best = "hint";
1684
+ hintHashes.add(createHash("sha256").update(body.trim(), "utf8").digest("hex"));
1685
+ }
1561
1686
  }
1562
- return { evidence: best, unread: all.length - revs.length, unreadable };
1687
+ return {
1688
+ evidence: best,
1689
+ unread: all.length - revs.length,
1690
+ unreadable,
1691
+ sourceFingerprint: createHash("sha256").update([...hintHashes].sort().join("\n")).digest("hex")
1692
+ };
1563
1693
  }
1564
1694
  function hasRemote(root, gitExecutable) {
1565
1695
  const out = git(root, gitExecutable, ["remote"]);
@@ -1636,7 +1766,7 @@ var gitleakRule = {
1636
1766
  if (history && history.unreadable > 0) {
1637
1767
  ctx.reportIncomplete(
1638
1768
  "gitleak/env-in-history",
1639
- `${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`
1769
+ `${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`
1640
1770
  );
1641
1771
  }
1642
1772
  const evidence = history?.evidence ?? "hint";
@@ -1644,6 +1774,7 @@ var gitleakRule = {
1644
1774
  const scaffolding = isScaffolding(path);
1645
1775
  findings.push({
1646
1776
  ruleId: "gitleak/env-in-history",
1777
+ ...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
1647
1778
  severity: "P0",
1648
1779
  // Claiming certainty about a file nobody could read would be the same
1649
1780
  // overreach the tracked branch just stopped making.
@@ -1686,28 +1817,41 @@ var INTERNAL_SCHEMAS = /* @__PURE__ */ new Set([
1686
1817
  "pg_catalog"
1687
1818
  ]);
1688
1819
  var IS_DO_BLOCK = /\bdo\s+(?:language\s+\w+\s+)?$/i;
1820
+ var DASH = 45;
1821
+ var SLASH2 = 47;
1822
+ var STAR2 = 42;
1823
+ var SINGLE_QUOTE2 = 39;
1824
+ var DOUBLE_QUOTE2 = 34;
1825
+ var DOLLAR2 = 36;
1826
+ var NEWLINE2 = 10;
1827
+ var UNDERSCORE = 95;
1828
+ function isSpaceCode(code) {
1829
+ if (code === 32 || code >= 9 && code <= 13) return true;
1830
+ return code > 127 && /\s/.test(String.fromCharCode(code));
1831
+ }
1689
1832
  function maskSqlNoise(sql) {
1690
- const out = sql.split("");
1833
+ const length = sql.length;
1834
+ const out = codesOf(sql);
1691
1835
  const erase = (from, to) => blank(out, from, to);
1692
1836
  let i = 0;
1693
- while (i < sql.length) {
1694
- const ch = sql[i];
1695
- const two = sql.slice(i, i + 2);
1696
- if (two === "--") {
1837
+ while (i < length) {
1838
+ const ch = sql.charCodeAt(i);
1839
+ if (ch === DASH && sql.charCodeAt(i + 1) === DASH) {
1697
1840
  const end = sql.indexOf("\n", i);
1698
- erase(i, end === -1 ? sql.length : end);
1699
- i = end === -1 ? sql.length : end;
1841
+ erase(i, end === -1 ? length : end);
1842
+ i = end === -1 ? length : end;
1700
1843
  continue;
1701
1844
  }
1702
- if (two === "/*") {
1845
+ if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
1703
1846
  let depth = 0;
1704
1847
  let j = i;
1705
- while (j < sql.length) {
1706
- const pair = sql.slice(j, j + 2);
1707
- if (pair === "/*") {
1848
+ while (j < length) {
1849
+ const first = sql.charCodeAt(j);
1850
+ const second = sql.charCodeAt(j + 1);
1851
+ if (first === SLASH2 && second === STAR2) {
1708
1852
  depth++;
1709
1853
  j += 2;
1710
- } else if (pair === "*/") {
1854
+ } else if (first === STAR2 && second === SLASH2) {
1711
1855
  depth--;
1712
1856
  j += 2;
1713
1857
  if (depth === 0) break;
@@ -1719,16 +1863,17 @@ function maskSqlNoise(sql) {
1719
1863
  i = j;
1720
1864
  continue;
1721
1865
  }
1722
- if (ch === "'") {
1866
+ if (ch === SINGLE_QUOTE2) {
1723
1867
  const escaped = i > 0 && /[Ee]/.test(sql[i - 1] ?? "") && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? "");
1724
1868
  let j = i + 1;
1725
- while (j < sql.length) {
1726
- if (escaped && sql[j] === "\\") {
1869
+ while (j < length) {
1870
+ const inner = sql.charCodeAt(j);
1871
+ if (escaped && inner === 92) {
1727
1872
  j += 2;
1728
1873
  continue;
1729
1874
  }
1730
- if (sql[j] === "'") {
1731
- if (sql[j + 1] === "'") {
1875
+ if (inner === SINGLE_QUOTE2) {
1876
+ if (sql.charCodeAt(j + 1) === SINGLE_QUOTE2) {
1732
1877
  j += 2;
1733
1878
  continue;
1734
1879
  }
@@ -1741,20 +1886,21 @@ function maskSqlNoise(sql) {
1741
1886
  i = j;
1742
1887
  continue;
1743
1888
  }
1744
- if (ch === '"') {
1889
+ if (ch === DOUBLE_QUOTE2) {
1745
1890
  let j = i + 1;
1746
- while (j < sql.length && sql[j] !== '"') {
1747
- if (/\s/.test(out[j] ?? "") && out[j] !== "\n") out[j] = "_";
1891
+ while (j < length && sql.charCodeAt(j) !== DOUBLE_QUOTE2) {
1892
+ const code = out[j];
1893
+ if (code !== void 0 && code !== NEWLINE2 && isSpaceCode(code)) out[j] = UNDERSCORE;
1748
1894
  j++;
1749
1895
  }
1750
1896
  i = j + 1;
1751
1897
  continue;
1752
1898
  }
1753
- if (ch === "$") {
1899
+ if (ch === DOLLAR2) {
1754
1900
  const tag = /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i))?.[0];
1755
1901
  if (tag) {
1756
1902
  const close = sql.indexOf(tag, i + tag.length);
1757
- const end = close === -1 ? sql.length : close + tag.length;
1903
+ const end = close === -1 ? length : close + tag.length;
1758
1904
  if (!IS_DO_BLOCK.test(sql.slice(0, i))) erase(i, end);
1759
1905
  i = end;
1760
1906
  continue;
@@ -1762,7 +1908,7 @@ function maskSqlNoise(sql) {
1762
1908
  }
1763
1909
  i++;
1764
1910
  }
1765
- return out.join("");
1911
+ return stringOf(out);
1766
1912
  }
1767
1913
  function unquote(ident) {
1768
1914
  const quoted = /^"(.*)"$/.exec(ident);
@@ -1851,10 +1997,7 @@ function projectScopeOf(path, scopes) {
1851
1997
  return best;
1852
1998
  }
1853
1999
  function isActiveSupabaseScope(ctx, files, scope) {
1854
- const rebased = files.map(
1855
- (file) => scope === "" ? file : { ...file, path: file.path.slice(scope.length + 1) }
1856
- );
1857
- return isSupabaseProject({ ...ctx, files: rebased });
2000
+ return isSupabaseProject(ctx, files, scope);
1858
2001
  }
1859
2002
  function replayScopeOf(file, projectScope) {
1860
2003
  return `${file.isExampleContext ? "example" : "project"}:${projectScope}`;
@@ -2234,6 +2377,122 @@ function hasAuthSignal(file) {
2234
2377
  const code = noiseMaskedOf(file);
2235
2378
  return AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
2236
2379
  }
2380
+ function delimiterPairs(code) {
2381
+ const pairs = /* @__PURE__ */ new Map();
2382
+ const stack = [];
2383
+ for (let i = 0; i < code.length; i++) {
2384
+ const ch = code[i];
2385
+ if ("({[".includes(ch)) stack.push(i);
2386
+ else if (")}]".includes(ch)) {
2387
+ const open = stack.pop();
2388
+ if (open !== void 0 && "({[".indexOf(code[open]) === ")}]".indexOf(ch)) {
2389
+ pairs.set(open, i);
2390
+ }
2391
+ }
2392
+ }
2393
+ return pairs;
2394
+ }
2395
+ function functionBodies(code, pairs) {
2396
+ const bodies = [];
2397
+ for (const match of code.matchAll(/\bfunction\s*\*?\s*(?:\w+\s*)?\(|=>\s*\{/g)) {
2398
+ let body;
2399
+ if (match[0].startsWith("=>")) body = match.index + match[0].length - 1;
2400
+ else {
2401
+ const close = pairs.get(match.index + match[0].length - 1);
2402
+ if (close === void 0) continue;
2403
+ body = close + 1;
2404
+ while (/\s/.test(code[body] ?? "")) body++;
2405
+ if (code[body] === ":") {
2406
+ const type = /^:[\w\s.<>,[\]|?]+(?=\{)/.exec(code.slice(body));
2407
+ if (type) body += type[0].length;
2408
+ }
2409
+ }
2410
+ const end = pairs.get(body);
2411
+ if (code[body] === "{" && end !== void 0) bodies.push({ declaration: match.index, start: body, end });
2412
+ }
2413
+ return bodies;
2414
+ }
2415
+ function statementEnd(code, start, limit, pairs) {
2416
+ if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
2417
+ for (let i = start; i < limit; i++) {
2418
+ if (code[i] === ";" || code[i] === "\n") return i + 1;
2419
+ const close = pairs.get(i);
2420
+ if (close !== void 0) i = close;
2421
+ }
2422
+ return limit;
2423
+ }
2424
+ function unguardedOperations(file, ops) {
2425
+ if (ops.length === 0) return ops;
2426
+ const code = noiseMaskedOf(file);
2427
+ const pairs = delimiterPairs(code);
2428
+ const bodies = functionBodies(code, pairs);
2429
+ const declarations = new Map(bodies.map((body) => [body.declaration, body]));
2430
+ const functionStarts = new Set(bodies.map((body) => body.start));
2431
+ const guardEnds = /* @__PURE__ */ new Map();
2432
+ const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
2433
+ const active = [];
2434
+ let blockIndex = 0;
2435
+ const wrappers = [...code.matchAll(/\b(?:withAuth|NextAuth)\s*\(/g)].map((match) => {
2436
+ const open = match.index + match[0].length - 1;
2437
+ return { start: open, end: pairs.get(open) ?? open };
2438
+ });
2439
+ const guardEnd = (owner) => {
2440
+ for (let i = owner.start + 1; i < owner.end; i++) {
2441
+ const nested = declarations.get(i);
2442
+ if (nested) {
2443
+ i = nested.end;
2444
+ continue;
2445
+ }
2446
+ if (code.startsWith("=>", i)) {
2447
+ i = statementEnd(code, i + 2, owner.end, pairs) - 1;
2448
+ continue;
2449
+ }
2450
+ if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
2451
+ const conditional = /^if\s*\(/.exec(code.slice(i, i + 32));
2452
+ if (conditional) {
2453
+ const open = i + conditional[0].length - 1;
2454
+ const close2 = pairs.get(open);
2455
+ if (close2 === void 0) return Infinity;
2456
+ let start = close2 + 1;
2457
+ while (/\s/.test(code[start] ?? "")) start++;
2458
+ const end = statementEnd(code, start, owner.end, pairs);
2459
+ if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
2460
+ i = Math.min(end, owner.end) - 1;
2461
+ continue;
2462
+ }
2463
+ const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
2464
+ if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
2465
+ const close2 = pairs.get(i + call[0].length - 1);
2466
+ if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
2467
+ }
2468
+ const close = pairs.get(i);
2469
+ if (close !== void 0) i = close;
2470
+ }
2471
+ return Infinity;
2472
+ };
2473
+ return ops.filter((op) => {
2474
+ while (blockIndex < blocks.length && blocks[blockIndex].start < op.index) {
2475
+ const block = blocks[blockIndex++];
2476
+ while (active.length && active[active.length - 1].end < block.start) active.pop();
2477
+ active.push(block);
2478
+ }
2479
+ while (active.length && active[active.length - 1].end < op.index) active.pop();
2480
+ if (wrappers.some((w) => w.start < op.index && w.end > op.index)) return false;
2481
+ let ownerIndex = active.length - 1;
2482
+ while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
2483
+ if (ownerIndex < 0) return true;
2484
+ for (let index = ownerIndex; index < active.length; index++) {
2485
+ const block = active[index];
2486
+ let end = guardEnds.get(block.start);
2487
+ if (end === void 0) {
2488
+ end = guardEnd(block);
2489
+ guardEnds.set(block.start, end);
2490
+ }
2491
+ if (end <= op.index) return false;
2492
+ }
2493
+ return true;
2494
+ });
2495
+ }
2237
2496
  var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>)?\s*\(/;
2238
2497
  var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
2239
2498
  var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
@@ -2627,8 +2886,7 @@ var apiAuthRule = {
2627
2886
  const sessionModules = ctx.files.filter(buildsSessionClient);
2628
2887
  const findings = [];
2629
2888
  for (const route of routes) {
2630
- if (hasAuthSignal(route)) continue;
2631
- const ops = findDataOps(route);
2889
+ const ops = unguardedOperations(route, findDataOps(route));
2632
2890
  if (ops.length === 0) continue;
2633
2891
  const url = routeUrl(route.path);
2634
2892
  if (isAuthEndpoint(url)) continue;
@@ -2976,8 +3234,32 @@ var corsRule = {
2976
3234
  // src/rules/index.ts
2977
3235
  var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
2978
3236
  var PROJECT_RULES = [gitleakRule, supabaseRlsRule, apiAuthRule];
3237
+ var RULE_IDS = [
3238
+ "api/admin-db-access-without-auth",
3239
+ "api/db-write-without-auth",
3240
+ "cors/reflected-origin-with-credentials",
3241
+ "cors/wildcard-with-credentials",
3242
+ "exposure/private-name-in-public-env",
3243
+ "exposure/secret-in-public-env",
3244
+ "exposure/supabase-service-role-in-client",
3245
+ "firebase/open-rules",
3246
+ "firebase/test-mode-rules",
3247
+ "gitleak/env-in-history",
3248
+ "gitleak/env-tracked",
3249
+ "supabase/rls-not-enabled",
3250
+ // One per credential format, built the same way secrets.ts builds them, so
3251
+ // adding a pattern cannot leave a finding id this list has never heard of.
3252
+ ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
3253
+ ];
3254
+ function ruleMatches(selector, ruleId) {
3255
+ return ruleId === selector || ruleId.startsWith(`${selector}/`);
3256
+ }
3257
+ function isKnownSelector(selector) {
3258
+ return RULE_IDS.some((id) => ruleMatches(selector, id));
3259
+ }
2979
3260
 
2980
3261
  // src/engine.ts
3262
+ import { createHash as createHash2 } from "crypto";
2981
3263
  var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
2982
3264
  var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
2983
3265
  function dedupe(findings) {
@@ -3002,9 +3284,20 @@ function clean(text) {
3002
3284
  function cleanForOutput(text) {
3003
3285
  return clean(text);
3004
3286
  }
3005
- function sanitize(findings) {
3287
+ function sanitize(findings, files) {
3288
+ const byPath = new Map(files.map((file) => [file.path, file]));
3289
+ const sourceIdentity = (f) => {
3290
+ if (f.sourceFingerprint !== void 0) return { sourceFingerprint: f.sourceFingerprint };
3291
+ const file = f.file === null ? void 0 : byPath.get(f.file);
3292
+ const source = f.line === null ? file?.content : file?.lines[f.line - 1];
3293
+ return source === void 0 ? {} : {
3294
+ sourceFingerprint: createHash2("sha256").update(source.trim(), "utf8").digest("hex")
3295
+ };
3296
+ };
3006
3297
  return findings.map((f) => ({
3007
3298
  ...f,
3299
+ // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
3300
+ ...sourceIdentity(f),
3008
3301
  title: clean(f.title),
3009
3302
  // Per paragraph, so the breaks between them survive a cleaner that removes
3010
3303
  // every newline inside them. See Finding.why.
@@ -3046,6 +3339,45 @@ function downgradeExampleContext(findings, files) {
3046
3339
  (f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
3047
3340
  );
3048
3341
  }
3342
+ function suppressIgnoredLines(findings, files) {
3343
+ const byPath = new Map(files.map((f) => [f.path, f]));
3344
+ const markers = /* @__PURE__ */ new Map();
3345
+ const kept = [];
3346
+ const ignored = [];
3347
+ for (const f of findings) {
3348
+ if (f.file === null || f.line === null) {
3349
+ kept.push(f);
3350
+ continue;
3351
+ }
3352
+ let lines = markers.get(f.file);
3353
+ if (lines === void 0) {
3354
+ const file = byPath.get(f.file);
3355
+ lines = file === void 0 ? /* @__PURE__ */ new Map() : ignoredLinesOf(file.lines);
3356
+ markers.set(f.file, lines);
3357
+ }
3358
+ if (!lines.has(f.line)) {
3359
+ kept.push(f);
3360
+ continue;
3361
+ }
3362
+ const rules = lines.get(f.line);
3363
+ if (rules !== null && rules !== void 0 && !rules.has(f.ruleId)) {
3364
+ kept.push(f);
3365
+ continue;
3366
+ }
3367
+ ignored.push({ file: f.file, line: f.line, ruleId: f.ruleId });
3368
+ }
3369
+ return { kept, ignored };
3370
+ }
3371
+ function applyRuleSelection(findings, options) {
3372
+ const only = options.only ?? [];
3373
+ const skip = options.skip ?? [];
3374
+ if (only.length === 0 && skip.length === 0) return { kept: findings, selection: null };
3375
+ const kept = findings.filter((f) => {
3376
+ if (only.length > 0) return only.some((s) => ruleMatches(s, f.ruleId));
3377
+ return !skip.some((s) => ruleMatches(s, f.ruleId));
3378
+ });
3379
+ return { kept, selection: { only, skip, removed: findings.length - kept.length } };
3380
+ }
3049
3381
  function sortFindings(findings) {
3050
3382
  return [...findings].sort((a, b) => {
3051
3383
  const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
@@ -3055,7 +3387,7 @@ function sortFindings(findings) {
3055
3387
  return (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0);
3056
3388
  });
3057
3389
  }
3058
- async function scan(root) {
3390
+ async function scan(root, options = {}) {
3059
3391
  const started = Date.now();
3060
3392
  const gitExecutable = resolveGitExecutable(root);
3061
3393
  const git2 = detectGitRepo(root, gitExecutable);
@@ -3098,8 +3430,13 @@ async function scan(root) {
3098
3430
  errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
3099
3431
  }
3100
3432
  }
3433
+ const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
3434
+ dedupe(downgradeExampleContext(findings, files)),
3435
+ files
3436
+ );
3437
+ const selected = applyRuleSelection(kept, options);
3101
3438
  return {
3102
- findings: sanitize(sortFindings(dedupe(downgradeExampleContext(findings, files)))),
3439
+ findings: sanitize(sortFindings(selected.kept), files),
3103
3440
  filesScanned: files.length,
3104
3441
  durationMs: Date.now() - started,
3105
3442
  errors: errors.map((e) => ({
@@ -3109,6 +3446,17 @@ async function scan(root) {
3109
3446
  })),
3110
3447
  skipped: sanitizeSkippedForOutput(skipped),
3111
3448
  ignored: ignored.map(clean),
3449
+ // The path goes through the boundary like every other path that reaches a
3450
+ // reader: a filename is chosen by whoever can add a file to the repository,
3451
+ // and one holding a credential would otherwise print it here in full.
3452
+ ignoredFindings: ignoredFindings.map((f) => ({ ...f, file: clean(f.file) })),
3453
+ // Selectors come from a config file or the command line, both of which are
3454
+ // text canship prints back, so both go through the boundary.
3455
+ ruleSelection: selected.selection === null ? null : {
3456
+ only: selected.selection.only.map(clean),
3457
+ skip: selected.selection.skip.map(clean),
3458
+ removed: selected.selection.removed
3459
+ },
3112
3460
  vendored,
3113
3461
  // A deliberate opt-out is not an incomplete scan: the user made that call
3114
3462
  // knowingly. It is listed in the report, not treated as a failure.
@@ -3181,6 +3529,27 @@ function skipPhrase(reason) {
3181
3529
 
3182
3530
  // src/report/terminal.ts
3183
3531
  var INDENT = " ";
3532
+ function renderBaseline(opts) {
3533
+ const suppressed = opts.baselineSuppressed ?? 0;
3534
+ const stale = opts.baselineStale ?? 0;
3535
+ if (suppressed === 0 && stale === 0) return [];
3536
+ const out = [];
3537
+ if (suppressed > 0) {
3538
+ const where = opts.baselinePath ? ` (${opts.baselinePath})` : "";
3539
+ out.push(
3540
+ `${INDENT}${yellow(`${suppressed} ${plural(suppressed, "finding")} hidden by the baseline${where}`)}`
3541
+ );
3542
+ out.push(`${INDENT}${dim("These problems still exist. Re-run without --baseline to see them.")}`);
3543
+ }
3544
+ if (stale > 0) {
3545
+ out.push(
3546
+ // Not plural() — that helper only appends an s, and "entrys" is not a
3547
+ // word. The irregular ones have to be written out.
3548
+ `${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
3549
+ );
3550
+ }
3551
+ return out;
3552
+ }
3184
3553
  function renderReport(result, opts) {
3185
3554
  const out = [""];
3186
3555
  const { findings } = result;
@@ -3215,6 +3584,7 @@ function renderReport(result, opts) {
3215
3584
  out.push("");
3216
3585
  }
3217
3586
  out.push(...renderIgnored(result));
3587
+ out.push(...renderBaseline(opts));
3218
3588
  if (!opts.showingLikely && opts.hiddenLikely > 0) {
3219
3589
  out.push(
3220
3590
  `${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`
@@ -3292,10 +3662,17 @@ function renderClean(result, opts) {
3292
3662
  out.push(
3293
3663
  `${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
3294
3664
  );
3665
+ } else if ((opts.baselineSuppressed ?? 0) > 0) {
3666
+ const suppressed = opts.baselineSuppressed ?? 0;
3667
+ out.push(
3668
+ `${INDENT}${yellow(bold(`! No new findings \u2014 ${suppressed} ${plural(suppressed, "finding")} accepted by the baseline`))}`
3669
+ );
3295
3670
  } else {
3296
3671
  out.push(`${INDENT}${green(bold("\u2713 No exposed credentials found"))}`);
3297
3672
  }
3298
3673
  out.push("");
3674
+ out.push(...renderBaseline(opts));
3675
+ if ((opts.baselineSuppressed ?? 0) > 0 || (opts.baselineStale ?? 0) > 0) out.push("");
3299
3676
  out.push(`${INDENT}${dim("canship checked for:")}`);
3300
3677
  out.push(`${INDENT}${dim(" \xB7 API keys hardcoded in source code")}`);
3301
3678
  out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
@@ -3310,6 +3687,10 @@ function renderClean(result, opts) {
3310
3687
  out.push(`${INDENT}${dim("did find are the right ones.")}`);
3311
3688
  if (opts.hiddenLikely > 0) {
3312
3689
  out.push(`${INDENT}${dim("This is not a finding-free result. Review the hidden items with --all.")}`);
3690
+ } else if (result.ignoredFindings.length > 0) {
3691
+ out.push(
3692
+ `${INDENT}${dim("This is not a finding-free result \u2014 some were silenced in the source. See below.")}`
3693
+ );
3313
3694
  } else {
3314
3695
  out.push(`${INDENT}${dim("A clean result means these checks passed \u2014 not that your app is secure.")}`);
3315
3696
  }
@@ -3338,6 +3719,20 @@ function renderIgnored(result) {
3338
3719
  `${INDENT}${dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`)}`
3339
3720
  );
3340
3721
  }
3722
+ if (result.ruleSelection !== null) {
3723
+ const { only, skip, removed } = result.ruleSelection;
3724
+ const which = only.length > 0 ? `only ${only.join(", ")}` : `everything except ${skip.join(", ")}`;
3725
+ const cost = removed > 0 ? `, hiding ${removed} ${plural(removed, "finding")}` : "";
3726
+ out.push(`${INDENT}${dim(`Rule selection in force: ${which}${cost}`)}`);
3727
+ }
3728
+ if (result.ignoredFindings.length > 0) {
3729
+ const n = result.ignoredFindings.length;
3730
+ const shown = result.ignoredFindings.slice(0, 3).map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
3731
+ const more = n > 3 ? `, and ${n - 3} more` : "";
3732
+ out.push(
3733
+ `${INDENT}${dim(`${n} ${plural(n, "finding")} silenced by canship-ignore-next-line: ${shown}${more}`)}`
3734
+ );
3735
+ }
3341
3736
  if (result.vendored > 0) {
3342
3737
  out.push(
3343
3738
  `${INDENT}${dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`)}`
@@ -3431,8 +3826,18 @@ function renderFixPrompt(findings, ctx) {
3431
3826
  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.";
3432
3827
  const hiddenLikely = ctx?.hiddenLikely ?? 0;
3433
3828
  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.`;
3829
+ const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
3830
+ const silenced = ctx?.silenced ?? [];
3831
+ const suppressedNotes = [
3832
+ !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.`,
3833
+ 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.`,
3834
+ 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.`,
3835
+ !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.`
3836
+ ].filter((note) => note !== null);
3434
3837
  if (findings.length === 0) {
3435
- const notes = [incompleteNote, hiddenNote].filter((note) => note !== null);
3838
+ const notes = [incompleteNote, hiddenNote, ...suppressedNotes].filter(
3839
+ (note) => note !== null
3840
+ );
3436
3841
  return notes.length === 0 ? null : `${notes.join("\n\n")}
3437
3842
  `;
3438
3843
  }
@@ -3449,6 +3854,10 @@ function renderFixPrompt(findings, ctx) {
3449
3854
  out.push(hiddenNote);
3450
3855
  out.push("");
3451
3856
  }
3857
+ for (const note of suppressedNotes) {
3858
+ out.push(note);
3859
+ out.push("");
3860
+ }
3452
3861
  if (codeFixable.length > 0) {
3453
3862
  out.push("--- Paste everything below into your coding assistant ---");
3454
3863
  out.push("");
@@ -3525,6 +3934,8 @@ function renderFinding2(f, index) {
3525
3934
  function renderHtml(result, opts) {
3526
3935
  const { findings } = result;
3527
3936
  const hiddenLikely = opts.hiddenLikely ?? 0;
3937
+ const baselineSuppressed = opts.baselineSuppressed ?? 0;
3938
+ const baselineStale = opts.baselineStale ?? 0;
3528
3939
  const { blocking: certain, minor, unsure } = verdictOf(findings);
3529
3940
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
3530
3941
  // Examined nothing, so there is nothing to report either way.
@@ -3533,7 +3944,13 @@ function renderHtml(result, opts) {
3533
3944
  // Never the green banner on a partial scan: it reads as a guarantee,
3534
3945
  // and a scan that skipped files cannot make one.
3535
3946
  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>`
3536
- ) : 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>`;
3947
+ ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
3948
+ // The green banner is a statement about the project. A baseline
3949
+ // makes it a statement about the diff instead, and this document
3950
+ // outlives the run that produced it — whoever opens it later has
3951
+ // only the banner to go on.
3952
+ baselineSuppressed > 0 ? `<div class="verdict warn">No new findings &mdash; ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline</div>` : `<div class="verdict clean">No exposed credentials found</div>`
3953
+ ) : 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>`;
3537
3954
  const body = findings.length === 0 ? result.filesScanned === 0 ? (
3538
3955
  // The checklist below would be a false statement here: none of those
3539
3956
  // checks had any input to run against.
@@ -3548,6 +3965,10 @@ function renderHtml(result, opts) {
3548
3965
  <p><strong>This is not a finding-free result.</strong> The default report hides
3549
3966
  ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
3550
3967
  <p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
3968
+ </div>` : baselineSuppressed > 0 ? `<div class="clean-note">
3969
+ <p><strong>This is not a finding-free result.</strong> A baseline is hiding
3970
+ ${baselineSuppressed} ${plural(baselineSuppressed, "finding")}${opts.baselinePath ? ` (<code>${esc(opts.baselinePath)}</code>)` : ""}.</p>
3971
+ <p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
3551
3972
  </div>` : `<div class="clean-note">
3552
3973
  <p>canship checked for hardcoded API keys, server secrets exposed to the browser,
3553
3974
  Supabase tables without Row Level Security, open Firebase rules, API routes that reach
@@ -3558,7 +3979,12 @@ function renderHtml(result, opts) {
3558
3979
  checks it did find are the right ones.</p>
3559
3980
  </div>` : findings.map((f, i) => renderFinding2(f, i + 1)).join("\n");
3560
3981
  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>` : "";
3982
+ const selection = result.ruleSelection;
3983
+ 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>`;
3984
+ 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>` : "";
3561
3985
  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>` : "";
3986
+ 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>` : "";
3987
+ 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>` : "";
3562
3988
  const incomplete = result.partial ? `<div class="incomplete">
3563
3989
  <h2>Not everything was checked</h2>
3564
3990
  <ul>
@@ -3660,6 +4086,10 @@ function renderHtml(result, opts) {
3660
4086
  ${body}
3661
4087
  ${incomplete}
3662
4088
  ${hiddenNotice}
4089
+ ${baselineNotice}
4090
+ ${staleNotice}
4091
+ ${silenced}
4092
+ ${ruleSelection}
3663
4093
  ${optedOut}
3664
4094
  <footer>
3665
4095
  Generated by canship. Everything ran locally; nothing was uploaded.
@@ -3670,13 +4100,387 @@ function renderHtml(result, opts) {
3670
4100
  `;
3671
4101
  }
3672
4102
 
4103
+ // src/baseline.ts
4104
+ import { createHash as createHash3 } from "crypto";
4105
+ import { readFileSync as readFileSync3, statSync as statSync3, writeFileSync } from "fs";
4106
+ var BASELINE_VERSION = 2;
4107
+ var DEFAULT_BASELINE_PATH = "canship-baseline.json";
4108
+ function fingerprintOf(f) {
4109
+ const identity = [f.ruleId, f.file ?? "", f.title, f.sourceFingerprint ?? f.excerpt ?? ""].join("\0");
4110
+ return createHash3("sha256").update(identity, "utf8").digest("hex");
4111
+ }
4112
+ function buildBaseline(findings, now = /* @__PURE__ */ new Date()) {
4113
+ const byFingerprint = /* @__PURE__ */ new Map();
4114
+ for (const f of findings) {
4115
+ const fingerprint = fingerprintOf(f);
4116
+ const existing = byFingerprint.get(fingerprint);
4117
+ if (existing) {
4118
+ existing.count++;
4119
+ continue;
4120
+ }
4121
+ byFingerprint.set(fingerprint, {
4122
+ fingerprint,
4123
+ ruleId: f.ruleId,
4124
+ file: f.file,
4125
+ title: f.title,
4126
+ count: 1
4127
+ });
4128
+ }
4129
+ const entries = [...byFingerprint.values()].sort(
4130
+ (a, b) => (a.file ?? "").localeCompare(b.file ?? "") || a.ruleId.localeCompare(b.ruleId) || a.fingerprint.localeCompare(b.fingerprint)
4131
+ );
4132
+ return { version: BASELINE_VERSION, generatedAt: now.toISOString(), entries };
4133
+ }
4134
+ function serializeBaseline(baseline) {
4135
+ return `${JSON.stringify(baseline, null, 2)}
4136
+ `;
4137
+ }
4138
+ function writeBaseline(path, baseline) {
4139
+ writeFileSync(path, serializeBaseline(baseline), "utf8");
4140
+ }
4141
+ var BaselineError = class extends Error {
4142
+ };
4143
+ function isEntry(value) {
4144
+ if (typeof value !== "object" || value === null) return false;
4145
+ const e = value;
4146
+ 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;
4147
+ }
4148
+ var MAX_BASELINE_BYTES = 10 * 1024 * 1024;
4149
+ function readBaseline(path) {
4150
+ let text;
4151
+ try {
4152
+ const size = statSync3(path).size;
4153
+ if (size > MAX_BASELINE_BYTES) {
4154
+ throw new BaselineError(
4155
+ `baseline ${path} is ${size} bytes, over the ${MAX_BASELINE_BYTES}-byte limit`
4156
+ );
4157
+ }
4158
+ text = readFileSync3(path, "utf8");
4159
+ } catch (err) {
4160
+ if (err instanceof BaselineError) throw err;
4161
+ throw new BaselineError(
4162
+ `could not read baseline ${path}: ${err instanceof Error ? err.message : String(err)}`
4163
+ );
4164
+ }
4165
+ let parsed;
4166
+ try {
4167
+ parsed = JSON.parse(text);
4168
+ } catch {
4169
+ throw new BaselineError(`baseline ${path} is not valid JSON`);
4170
+ }
4171
+ if (typeof parsed !== "object" || parsed === null) {
4172
+ throw new BaselineError(`baseline ${path} is not a baseline file`);
4173
+ }
4174
+ const obj = parsed;
4175
+ const version = obj["version"];
4176
+ if (version !== BASELINE_VERSION) {
4177
+ throw new BaselineError(
4178
+ `baseline ${path} has version ${String(version)}; this canship reads version ${BASELINE_VERSION}. Review the findings and regenerate the baseline with --baseline-write.`
4179
+ );
4180
+ }
4181
+ const rawEntries = obj["entries"];
4182
+ if (!Array.isArray(rawEntries)) {
4183
+ throw new BaselineError(`baseline ${path} has no entries array`);
4184
+ }
4185
+ const entries = [];
4186
+ for (const [i, raw] of rawEntries.entries()) {
4187
+ if (!isEntry(raw)) throw new BaselineError(`baseline ${path}: entry ${i} is malformed`);
4188
+ entries.push({
4189
+ fingerprint: raw.fingerprint,
4190
+ ruleId: raw.ruleId,
4191
+ file: raw.file,
4192
+ title: raw.title,
4193
+ count: raw.count
4194
+ });
4195
+ }
4196
+ const generatedAt = typeof obj["generatedAt"] === "string" ? obj["generatedAt"] : "";
4197
+ return { version: BASELINE_VERSION, generatedAt, entries };
4198
+ }
4199
+ function applyBaseline(findings, baseline) {
4200
+ const remaining = /* @__PURE__ */ new Map();
4201
+ for (const entry of baseline.entries) {
4202
+ remaining.set(entry.fingerprint, (remaining.get(entry.fingerprint) ?? 0) + entry.count);
4203
+ }
4204
+ const kept = [];
4205
+ let suppressed = 0;
4206
+ for (const f of findings) {
4207
+ const budget = remaining.get(fingerprintOf(f)) ?? 0;
4208
+ if (budget > 0) {
4209
+ remaining.set(fingerprintOf(f), budget - 1);
4210
+ suppressed++;
4211
+ continue;
4212
+ }
4213
+ kept.push(f);
4214
+ }
4215
+ let stale = 0;
4216
+ for (const left of remaining.values()) stale += left;
4217
+ return { kept, suppressed, stale };
4218
+ }
4219
+
4220
+ // src/report/sarif.ts
4221
+ var INFORMATION_URI = "https://github.com/Tasomei/canship";
4222
+ function suppressionNotes(result, opts) {
4223
+ const notes = [];
4224
+ const baseline = opts.baselineSuppressed ?? 0;
4225
+ const hidden = opts.hiddenLikely ?? 0;
4226
+ if (baseline > 0) {
4227
+ notes.push({
4228
+ level: "warning",
4229
+ message: {
4230
+ text: `${baseline} finding${baseline === 1 ? "" : "s"} hidden by a baseline. Those problems still exist.`
4231
+ }
4232
+ });
4233
+ }
4234
+ if (result.ignoredFindings.length > 0) {
4235
+ const where = result.ignoredFindings.map((f) => `${f.file}:${f.line} (${f.ruleId})`).join(", ");
4236
+ notes.push({
4237
+ level: "warning",
4238
+ message: {
4239
+ text: `${result.ignoredFindings.length} finding${result.ignoredFindings.length === 1 ? "" : "s"} silenced by canship-ignore-next-line: ${where}`
4240
+ }
4241
+ });
4242
+ }
4243
+ if (result.ignored.length > 0) {
4244
+ notes.push({
4245
+ level: "warning",
4246
+ message: {
4247
+ text: `${result.ignored.length} file${result.ignored.length === 1 ? "" : "s"} excluded by canship-ignore-file: ${result.ignored.join(", ")}`
4248
+ }
4249
+ });
4250
+ }
4251
+ if (opts.ruleSelection) {
4252
+ notes.push({ level: "warning", message: { text: `Rule selection in force: ${opts.ruleSelection}` } });
4253
+ }
4254
+ if (hidden > 0) {
4255
+ notes.push({
4256
+ level: "warning",
4257
+ message: {
4258
+ text: `${hidden} lower-confidence finding${hidden === 1 ? "" : "s"} not included; re-run with --all.`
4259
+ }
4260
+ });
4261
+ }
4262
+ return notes;
4263
+ }
4264
+ function levelOf(f) {
4265
+ return f.confidence === "certain" && BLOCKING.has(f.severity) ? "error" : "warning";
4266
+ }
4267
+ function rulesOf(findings) {
4268
+ const seen = /* @__PURE__ */ new Map();
4269
+ for (const f of findings) if (!seen.has(f.ruleId)) seen.set(f.ruleId, f);
4270
+ return [...seen.entries()].map(([id, f]) => ({
4271
+ id,
4272
+ name: id,
4273
+ shortDescription: { text: f.title },
4274
+ fullDescription: { text: f.why.join(" ") },
4275
+ help: {
4276
+ text: [...f.why, ...f.fix.length > 0 ? ["How to fix:", ...f.fix] : []].join("\n")
4277
+ },
4278
+ properties: {
4279
+ // Not part of the SARIF vocabulary, so it travels as a property rather
4280
+ // than being mangled into one of the three levels above.
4281
+ "canship-severity": f.severity,
4282
+ "canship-confidence": f.confidence
4283
+ },
4284
+ defaultConfiguration: { level: levelOf(f) }
4285
+ }));
4286
+ }
4287
+ function resultsOf(findings) {
4288
+ return findings.map((f) => ({
4289
+ ruleId: f.ruleId,
4290
+ level: levelOf(f),
4291
+ message: { text: f.title },
4292
+ // A finding with no file — git history, an RLS gap spanning migrations —
4293
+ // gets no location rather than a made-up one. SARIF permits that, and
4294
+ // pointing it at an arbitrary file would put an annotation on a line that
4295
+ // has nothing to do with it.
4296
+ locations: f.file === null ? [] : [
4297
+ {
4298
+ physicalLocation: {
4299
+ // Already relative and already slash-separated, on Windows too.
4300
+ artifactLocation: { uri: f.file },
4301
+ ...f.line === null ? {} : { region: { startLine: f.line } }
4302
+ }
4303
+ }
4304
+ ],
4305
+ partialFingerprints: { canshipFindingV2: fingerprintOf(f) }
4306
+ }));
4307
+ }
4308
+ function renderSarif(result, opts) {
4309
+ const { findings } = result;
4310
+ const notifications = [
4311
+ ...result.errors.map((e) => ({
4312
+ level: e.kind === "crashed" ? "error" : "warning",
4313
+ message: { text: `${e.ruleId}: ${e.message}` }
4314
+ })),
4315
+ ...suppressionNotes(result, opts)
4316
+ ];
4317
+ const log = {
4318
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
4319
+ version: "2.1.0",
4320
+ runs: [
4321
+ {
4322
+ tool: {
4323
+ driver: {
4324
+ name: "canship",
4325
+ version: opts.version,
4326
+ informationUri: INFORMATION_URI,
4327
+ rules: rulesOf(findings)
4328
+ }
4329
+ },
4330
+ results: resultsOf(findings),
4331
+ invocations: [
4332
+ {
4333
+ // False when a rule crashed or a file could not be read. It is not
4334
+ // about whether findings exist — a scan that finds problems and
4335
+ // completes was a successful invocation.
4336
+ executionSuccessful: !result.partial,
4337
+ ...notifications.length > 0 ? { toolExecutionNotifications: notifications } : {}
4338
+ }
4339
+ ]
4340
+ }
4341
+ ]
4342
+ };
4343
+ return `${JSON.stringify(log, null, 2)}
4344
+ `;
4345
+ }
4346
+
4347
+ // src/config.ts
4348
+ import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4349
+ import { join as join3 } from "path";
4350
+ var CONFIG_FILENAME = "canship.config.json";
4351
+ var REFUSED_KEYS = /* @__PURE__ */ new Map([
4352
+ [
4353
+ "bestEffort",
4354
+ "accepting an incomplete scan is a decision for whoever runs canship, not for the project being scanned \u2014 pass --best-effort instead"
4355
+ ]
4356
+ ]);
4357
+ var ConfigError = class extends Error {
4358
+ };
4359
+ var KNOWN_KEYS = /* @__PURE__ */ new Set(["baseline", "only", "skip", "all"]);
4360
+ function selectors(value, field, path) {
4361
+ if (!Array.isArray(value)) {
4362
+ throw new ConfigError(`${path}: "${field}" must be an array of rule ids`);
4363
+ }
4364
+ const out = [];
4365
+ for (const entry of value) {
4366
+ if (typeof entry !== "string" || entry === "") {
4367
+ throw new ConfigError(`${path}: "${field}" must contain only rule ids`);
4368
+ }
4369
+ if (!isKnownSelector(entry)) {
4370
+ throw new ConfigError(`${path}: "${field}" names no known rule: ${entry}`);
4371
+ }
4372
+ out.push(entry);
4373
+ }
4374
+ return out;
4375
+ }
4376
+ function boolean(value, field, path) {
4377
+ if (typeof value !== "boolean") throw new ConfigError(`${path}: "${field}" must be true or false`);
4378
+ return value;
4379
+ }
4380
+ function parseConfig(text, path) {
4381
+ let parsed;
4382
+ try {
4383
+ parsed = JSON.parse(text);
4384
+ } catch {
4385
+ throw new ConfigError(`${path} is not valid JSON`);
4386
+ }
4387
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4388
+ throw new ConfigError(`${path} must contain a JSON object`);
4389
+ }
4390
+ const raw = parsed;
4391
+ for (const key of Object.keys(raw)) {
4392
+ const refused = REFUSED_KEYS.get(key);
4393
+ if (refused !== void 0) {
4394
+ throw new ConfigError(`${path}: "${key}" is not allowed here \u2014 ${refused}`);
4395
+ }
4396
+ if (!KNOWN_KEYS.has(key)) {
4397
+ throw new ConfigError(`${path}: unknown setting "${key}"`);
4398
+ }
4399
+ }
4400
+ const config = {};
4401
+ if (raw["baseline"] !== void 0) {
4402
+ if (typeof raw["baseline"] !== "string" || raw["baseline"] === "") {
4403
+ throw new ConfigError(`${path}: "baseline" must be a file path`);
4404
+ }
4405
+ config.baseline = raw["baseline"];
4406
+ }
4407
+ if (raw["only"] !== void 0) config.only = selectors(raw["only"], "only", path);
4408
+ if (raw["skip"] !== void 0) config.skip = selectors(raw["skip"], "skip", path);
4409
+ if (raw["all"] !== void 0) config.all = boolean(raw["all"], "all", path);
4410
+ if (config.only !== void 0 && config.skip !== void 0) {
4411
+ throw new ConfigError(`${path}: "only" and "skip" cannot both be set`);
4412
+ }
4413
+ return config;
4414
+ }
4415
+ var MAX_CONFIG_BYTES = 1024 * 1024;
4416
+ function loadConfig(root) {
4417
+ const path = join3(root, CONFIG_FILENAME);
4418
+ if (!existsSync2(path)) return { config: {}, path: null };
4419
+ let text;
4420
+ try {
4421
+ const size = statSync4(path).size;
4422
+ if (size > MAX_CONFIG_BYTES) {
4423
+ throw new ConfigError(
4424
+ `${path} is ${size} bytes, over the ${MAX_CONFIG_BYTES}-byte limit`
4425
+ );
4426
+ }
4427
+ text = readFileSync4(path, "utf8");
4428
+ } catch (err) {
4429
+ if (err instanceof ConfigError) throw err;
4430
+ throw new ConfigError(
4431
+ `could not read ${path}: ${err instanceof Error ? err.message : String(err)}`
4432
+ );
4433
+ }
4434
+ return { config: parseConfig(text, path), path };
4435
+ }
4436
+
3673
4437
  // src/cli.ts
3674
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
4438
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
3675
4439
  function argumentError(message) {
3676
4440
  process.stderr.write(`canship: ${cleanForOutput(message)}
3677
4441
  `);
3678
4442
  process.exit(3);
3679
4443
  }
4444
+ function optionalValue(arg, name, fallback) {
4445
+ if (arg === name) return fallback;
4446
+ if (!arg.startsWith(`${name}=`)) return null;
4447
+ const value = arg.slice(name.length + 1);
4448
+ if (!value) argumentError(`${name}= needs a file path`);
4449
+ return value;
4450
+ }
4451
+ function insideProject(root, relative3) {
4452
+ const target = resolve2(root, relative3);
4453
+ const inside = relative_(realPathOf(root), realPathOf(target));
4454
+ if (inside === "" || inside.startsWith("..") || isAbsolute2(inside)) {
4455
+ argumentError(
4456
+ `${CONFIG_FILENAME}: "baseline" must stay inside the project, and ${relative3} does not`
4457
+ );
4458
+ }
4459
+ return target;
4460
+ }
4461
+ function realPathOf(path) {
4462
+ let at = path;
4463
+ const rest = [];
4464
+ for (let depth = 0; depth < MAX_REAL_PATH_DEPTH; depth++) {
4465
+ try {
4466
+ const real = realpathSync2(at);
4467
+ return rest.length === 0 ? real : resolve2(real, ...rest);
4468
+ } catch {
4469
+ const parent = resolve2(at, "..");
4470
+ if (parent === at) return path;
4471
+ rest.unshift(relative_(parent, at));
4472
+ at = parent;
4473
+ }
4474
+ }
4475
+ return path;
4476
+ }
4477
+ var MAX_REAL_PATH_DEPTH = 64;
4478
+ var UNREACHABLE_DEFAULT = "";
4479
+ function selectionPhrase(selection) {
4480
+ if (selection === null) return null;
4481
+ const which = selection.only.length > 0 ? `only ${selection.only.join(", ")}` : `everything except ${selection.skip.join(", ")}`;
4482
+ return `${which}, hiding ${selection.removed}`;
4483
+ }
3680
4484
  function parseArgs(argv) {
3681
4485
  const args = {
3682
4486
  root: process.cwd(),
@@ -3685,21 +4489,61 @@ function parseArgs(argv) {
3685
4489
  fixPrompt: false,
3686
4490
  report: null,
3687
4491
  bestEffort: false,
4492
+ baseline: null,
4493
+ baselineDefault: false,
4494
+ baselineWrite: null,
4495
+ baselineWriteDefault: false,
4496
+ only: [],
4497
+ skip: [],
4498
+ sarif: null,
4499
+ noConfig: false,
3688
4500
  help: false,
3689
4501
  version: false
3690
4502
  };
3691
4503
  const positional = [];
3692
4504
  for (const arg of argv) {
3693
- if (arg === "--report") {
3694
- args.report = "canship-report.html";
4505
+ const list = (name) => {
4506
+ if (!arg.startsWith(`${name}=`)) return null;
4507
+ const value = arg.slice(name.length + 1);
4508
+ if (!value) argumentError(`${name}= needs at least one rule id`);
4509
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
4510
+ };
4511
+ const only = list("--only");
4512
+ if (only !== null) {
4513
+ args.only.push(...only);
3695
4514
  continue;
3696
4515
  }
3697
- if (arg.startsWith("--report=")) {
3698
- const value = arg.slice("--report=".length);
3699
- if (!value) {
3700
- argumentError("--report= needs a file path");
3701
- }
3702
- args.report = value;
4516
+ const skip = list("--skip");
4517
+ if (skip !== null) {
4518
+ args.skip.push(...skip);
4519
+ continue;
4520
+ }
4521
+ const report = optionalValue(arg, "--report", "canship-report.html");
4522
+ if (report !== null) {
4523
+ args.report = report;
4524
+ continue;
4525
+ }
4526
+ if (arg === "--baseline") {
4527
+ args.baselineDefault = true;
4528
+ continue;
4529
+ }
4530
+ const baseline = optionalValue(arg, "--baseline", UNREACHABLE_DEFAULT);
4531
+ if (baseline !== null) {
4532
+ args.baseline = baseline;
4533
+ continue;
4534
+ }
4535
+ if (arg === "--baseline-write") {
4536
+ args.baselineWriteDefault = true;
4537
+ continue;
4538
+ }
4539
+ const baselineWrite = optionalValue(arg, "--baseline-write", UNREACHABLE_DEFAULT);
4540
+ if (baselineWrite !== null) {
4541
+ args.baselineWrite = baselineWrite;
4542
+ continue;
4543
+ }
4544
+ const sarif = optionalValue(arg, "--sarif", "canship.sarif");
4545
+ if (sarif !== null) {
4546
+ args.sarif = sarif;
3703
4547
  continue;
3704
4548
  }
3705
4549
  switch (arg) {
@@ -3716,6 +4560,9 @@ function parseArgs(argv) {
3716
4560
  case "--best-effort":
3717
4561
  args.bestEffort = true;
3718
4562
  break;
4563
+ case "--no-config":
4564
+ args.noConfig = true;
4565
+ break;
3719
4566
  case "--help":
3720
4567
  case "-h":
3721
4568
  args.help = true;
@@ -3750,6 +4597,14 @@ var HELP = `
3750
4597
  --json Output raw JSON (for CI or tooling)
3751
4598
  --best-effort Allow exit 0 for an incomplete scan with no findings;
3752
4599
  findings still exit 1 or 2
4600
+ --baseline[=F] Hide findings already recorded in F, so only new
4601
+ ones are reported (default ${DEFAULT_BASELINE_PATH})
4602
+ --baseline-write[=F] Record the current findings as a new baseline and exit
4603
+ --only=IDS Report only these rules (comma-separated, repeatable)
4604
+ --skip=IDS Report everything except these rules
4605
+ --sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
4606
+ (default canship.sarif)
4607
+ --no-config Ignore canship.config.json in the scanned directory
3753
4608
  -h, --help Show this help
3754
4609
  -v, --version Show version
3755
4610
 
@@ -3761,6 +4616,10 @@ var HELP = `
3761
4616
 
3762
4617
  ${dim("--json and --fix-prompt are alternative stdout modes; --report may be combined with either.")}
3763
4618
 
4619
+ ${dim("A baseline hides real findings. Every output says how many it hid.")}
4620
+
4621
+ ${dim(`Settings may also be committed to ${CONFIG_FILENAME}. A flag always wins over the file.`)}
4622
+
3764
4623
  ${dim("Scanned files stay local: no project-code execution, network requests, or uploads.")}
3765
4624
  `;
3766
4625
  async function main() {
@@ -3778,20 +4637,116 @@ async function main() {
3778
4637
  if (args.json && args.fixPrompt) {
3779
4638
  argumentError("--json and --fix-prompt are mutually exclusive");
3780
4639
  }
3781
- if (!existsSync2(args.root) || !statSync3(args.root).isDirectory()) {
4640
+ if ((args.baseline !== null || args.baselineDefault) && (args.baselineWrite !== null || args.baselineWriteDefault)) {
4641
+ argumentError("--baseline and --baseline-write are mutually exclusive");
4642
+ }
4643
+ if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
3782
4644
  process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
3783
4645
  `);
3784
4646
  return process.exit(3);
3785
4647
  }
3786
- const result = await scan(args.root);
4648
+ let config;
4649
+ try {
4650
+ config = args.noConfig ? {} : loadConfig(args.root).config;
4651
+ } catch (err) {
4652
+ if (err instanceof ConfigError) {
4653
+ process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4654
+ `);
4655
+ return process.exit(3);
4656
+ }
4657
+ throw err;
4658
+ }
4659
+ for (const [field, values] of [
4660
+ ["--only", args.only],
4661
+ ["--skip", args.skip]
4662
+ ]) {
4663
+ for (const selector of values) {
4664
+ if (!isKnownSelector(selector)) {
4665
+ argumentError(`${field} names no known rule: ${selector}`);
4666
+ }
4667
+ }
4668
+ }
4669
+ const cliSelection = args.only.length > 0 || args.skip.length > 0;
4670
+ const only = cliSelection ? args.only : config.only ?? [];
4671
+ const skip = cliSelection ? args.skip : config.skip ?? [];
4672
+ if (only.length > 0 && skip.length > 0) {
4673
+ argumentError("rule selection cannot use both only and skip");
4674
+ }
4675
+ const showAll = args.showAll || config.all === true;
4676
+ const bestEffort = args.bestEffort;
4677
+ 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;
4678
+ const scanned = await scan(args.root, { only, skip });
4679
+ if (args.baselineWrite !== null || args.baselineWriteDefault) {
4680
+ const target = args.baselineWrite !== null ? resolve2(args.baselineWrite) : resolve2(args.root, DEFAULT_BASELINE_PATH);
4681
+ const baseline = buildBaseline(scanned.findings);
4682
+ try {
4683
+ writeBaseline(target, baseline);
4684
+ } catch (err) {
4685
+ process.stderr.write(
4686
+ `${red("canship:")} could not write baseline to ${cleanForOutput(target)}
4687
+ ${cleanForOutput(String(err))}
4688
+ `
4689
+ );
4690
+ return process.exit(3);
4691
+ }
4692
+ const accepted = scanned.findings.length;
4693
+ process.stdout.write(
4694
+ `
4695
+ ${bold("Baseline written to")} ${cyan(cleanForOutput(target))}
4696
+ ${dim(`${accepted} ${accepted === 1 ? "finding is" : "findings are"} now accepted and will not be reported.`)}
4697
+ ${yellow("These problems still exist.")}
4698
+ ${dim("The file names the path, rule and title of each one \u2014 including findings")}
4699
+ ${dim("in files git does not track, such as .env.local. It holds no credential")}
4700
+ ${dim("values. Commit it so the decision is reviewable; on a public repository,")}
4701
+ ${dim("weigh what that publishes first.")}
4702
+
4703
+ `
4704
+ );
4705
+ if (scanned.partial) {
4706
+ process.stderr.write(
4707
+ `${yellow("canship:")} the scan was incomplete, so this baseline may be missing findings.
4708
+ `
4709
+ );
4710
+ }
4711
+ if (scanned.ruleSelection !== null) {
4712
+ process.stderr.write(
4713
+ `${yellow("canship:")} rule selection was in force, so this baseline covers only the rules that ran.
4714
+ `
4715
+ );
4716
+ }
4717
+ return process.exit(0);
4718
+ }
4719
+ let baselineSuppressed = 0;
4720
+ let baselineStale = 0;
4721
+ let result = scanned;
4722
+ if (baselinePath !== null) {
4723
+ const source = baselinePath;
4724
+ try {
4725
+ const applied = applyBaseline(scanned.findings, readBaseline(source));
4726
+ result = { ...scanned, findings: applied.kept };
4727
+ baselineSuppressed = applied.suppressed;
4728
+ baselineStale = applied.stale;
4729
+ } catch (err) {
4730
+ if (err instanceof BaselineError) {
4731
+ process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4732
+ `);
4733
+ return process.exit(3);
4734
+ }
4735
+ throw err;
4736
+ }
4737
+ }
3787
4738
  const displayRoot = cleanForOutput(args.root);
3788
- const shown = args.showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
3789
- const hiddenLikely = args.showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
4739
+ const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
4740
+ const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
3790
4741
  if (args.fixPrompt) {
3791
4742
  const prompt = renderFixPrompt(shown, {
3792
4743
  partial: result.partial,
3793
4744
  filesScanned: result.filesScanned,
3794
- hiddenLikely
4745
+ hiddenLikely,
4746
+ baselineSuppressed,
4747
+ silenced: result.ignoredFindings.map((f) => `${f.file}:${f.line} (${f.ruleId})`),
4748
+ ignoredFiles: result.ignored,
4749
+ ruleSelection: selectionPhrase(result.ruleSelection)
3795
4750
  });
3796
4751
  process.stdout.write(
3797
4752
  prompt === null ? "Nothing to fix \u2014 no findings.\n" : `${prompt}
@@ -3811,10 +4766,18 @@ async function main() {
3811
4766
  errors: result.errors,
3812
4767
  skipped: result.skipped,
3813
4768
  ignored: result.ignored,
4769
+ ignoredFindings: result.ignoredFindings,
4770
+ ruleSelection: result.ruleSelection,
3814
4771
  vendored: result.vendored,
3815
4772
  // The default view hides the detail, never the fact. A machine reading this
3816
4773
  // must not see "no findings" while lower-confidence ones exist.
3817
4774
  hiddenLikely,
4775
+ // Same reason, for the other thing that removes findings from this
4776
+ // array. A CI job reading `findings: []` is entitled to know whether
4777
+ // that means "nothing is wrong" or "a file in your repository says
4778
+ // not to mention it".
4779
+ baselineSuppressed,
4780
+ baselineStale,
3818
4781
  findings: shown
3819
4782
  },
3820
4783
  null,
@@ -3826,19 +4789,63 @@ async function main() {
3826
4789
  process.stdout.write(
3827
4790
  `${renderReport(
3828
4791
  { ...result, findings: shown },
3829
- { root: displayRoot, showingLikely: args.showAll, hiddenLikely }
4792
+ {
4793
+ root: displayRoot,
4794
+ showingLikely: showAll,
4795
+ hiddenLikely,
4796
+ baselineSuppressed,
4797
+ baselineStale,
4798
+ baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
4799
+ }
3830
4800
  )}
3831
4801
  `
3832
4802
  );
3833
4803
  }
4804
+ if (args.sarif) {
4805
+ const target = resolve2(args.sarif);
4806
+ try {
4807
+ writeFileSync2(
4808
+ target,
4809
+ renderSarif(
4810
+ { ...result, findings: shown },
4811
+ {
4812
+ version: VERSION,
4813
+ baselineSuppressed,
4814
+ hiddenLikely,
4815
+ ruleSelection: selectionPhrase(result.ruleSelection)
4816
+ }
4817
+ ),
4818
+ "utf8"
4819
+ );
4820
+ if (!args.json && !args.fixPrompt) {
4821
+ process.stdout.write(` ${dim("SARIF written to")} ${cyan(cleanForOutput(target))}
4822
+
4823
+ `);
4824
+ }
4825
+ } catch (err) {
4826
+ process.stderr.write(
4827
+ `${red("canship:")} could not write SARIF to ${cleanForOutput(target)}
4828
+ ${cleanForOutput(String(err))}
4829
+ `
4830
+ );
4831
+ return process.exit(3);
4832
+ }
4833
+ }
3834
4834
  if (args.report) {
3835
4835
  const target = resolve2(args.report);
3836
4836
  try {
3837
- writeFileSync(
4837
+ writeFileSync2(
3838
4838
  target,
3839
4839
  renderHtml(
3840
4840
  { ...result, findings: shown },
3841
- { root: displayRoot, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), hiddenLikely }
4841
+ {
4842
+ root: displayRoot,
4843
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4844
+ hiddenLikely,
4845
+ baselineSuppressed,
4846
+ baselineStale,
4847
+ baselinePath: baselinePath === null ? null : cleanForOutput(baselinePath)
4848
+ }
3842
4849
  ),
3843
4850
  "utf8"
3844
4851
  );
@@ -3858,7 +4865,7 @@ ${cleanForOutput(String(err))}
3858
4865
  }
3859
4866
  if (verdictOf(result.findings).blocking > 0) return process.exit(1);
3860
4867
  if (result.findings.length > 0) return process.exit(2);
3861
- if (result.partial && !args.bestEffort) return process.exit(3);
4868
+ if (result.partial && !bestEffort) return process.exit(3);
3862
4869
  return process.exit(0);
3863
4870
  }
3864
4871
  main().catch((err) => {