canship 0.1.1 → 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 +1166 -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;
@@ -205,8 +205,8 @@ function redactSecret(secret) {
205
205
  }
206
206
  function redactLine(line, secret) {
207
207
  const trimmed = line.trim();
208
- if (!secret) return truncate(trimmed);
209
- return truncate(trimmed.split(secret).join(redactSecret(secret)));
208
+ if (!secret) return trimmed;
209
+ return trimmed.split(secret).join(redactSecret(secret));
210
210
  }
211
211
  var JWT_SHAPED = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
212
212
  function redactAll(text) {
@@ -421,31 +421,39 @@ function gitEnvironment() {
421
421
  env.PAGER = "";
422
422
  return env;
423
423
  }
424
- function execGitSync(executable, root, args, options = {}) {
424
+ function hardeningArgs(root) {
425
425
  const worktree = gitRootAbove(root) ?? root;
426
426
  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
- );
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
+ });
449
457
  }
450
458
 
451
459
  // src/walker.ts
@@ -535,10 +543,29 @@ var CREDENTIAL_FILENAMES = /* @__PURE__ */ new Set([
535
543
  "id_ed25519"
536
544
  ]);
537
545
  var PROBE_BYTES = 4096;
538
- var IGNORE_FILE_MARKER = /^\s*(?:\/\/|#|--|\*\/?|\/\*|<!--)?\s*canship-ignore-file\s*(?:\*\/|-->)?\s*$/;
546
+ var IGNORE_FILE_MARKER = /^\s*(?:(?:\/\/|#|--|\*\/?|\/\*|<!--)\s*)?canship-ignore-file(?:\s*(?:\*\/|-->))?\s*$/;
539
547
  function hasIgnoreMarker(lines) {
540
548
  return lines.some((line) => IGNORE_FILE_MARKER.test(line));
541
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
+ }
542
569
  var SKIP_FILENAMES = /* @__PURE__ */ new Set(["bun.lockb"]);
543
570
  function isEnvFile(name) {
544
571
  const lower = name.toLowerCase();
@@ -892,68 +919,100 @@ function parseEnvLine(raw) {
892
919
  }
893
920
 
894
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
+ }
895
947
  function blank(out, from, to) {
896
- for (let i = from; i < to && i < out.length; i++) {
897
- 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;
898
951
  }
899
952
  }
900
953
  function endOfString(src, start, quote) {
954
+ const length = src.length;
901
955
  let i = start + 1;
902
- while (i < src.length) {
903
- if (src[i] === "\\") {
956
+ while (i < length) {
957
+ const ch = src.charCodeAt(i);
958
+ if (ch === BACKSLASH) {
904
959
  i += 2;
905
960
  continue;
906
961
  }
907
- if (src[i] === quote) return i + 1;
962
+ if (ch === quote) return i + 1;
908
963
  i++;
909
964
  }
910
- return src.length;
965
+ return length;
911
966
  }
912
967
  function maskTemplate(src, out, start) {
968
+ const length = src.length;
913
969
  let i = start + 1;
914
970
  let literalFrom = i;
915
- while (i < src.length) {
916
- if (src[i] === "\\") {
971
+ while (i < length) {
972
+ const ch = src.charCodeAt(i);
973
+ if (ch === BACKSLASH) {
917
974
  i += 2;
918
975
  continue;
919
976
  }
920
- if (src[i] === "`") {
977
+ if (ch === BACKTICK) {
921
978
  blank(out, literalFrom, i);
922
979
  return i + 1;
923
980
  }
924
- if (src[i] === "$" && src[i + 1] === "{") {
981
+ if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
925
982
  blank(out, literalFrom, i);
926
983
  let depth = 0;
927
984
  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;
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
+ }
944
1003
  }
945
- if (ch === '"' || ch === "'") {
946
- const stop = endOfString(src, j, ch);
1004
+ if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
1005
+ const stop = endOfString(src, j, inner);
947
1006
  blank(out, j + 1, stop - 1);
948
1007
  j = stop;
949
1008
  continue;
950
1009
  }
951
- if (ch === "`") {
1010
+ if (inner === BACKTICK) {
952
1011
  j = maskTemplate(src, out, j);
953
1012
  continue;
954
1013
  }
955
- if (ch === "{") depth++;
956
- else if (ch === "}") {
1014
+ if (inner === OPEN_BRACE) depth++;
1015
+ else if (inner === CLOSE_BRACE) {
957
1016
  depth--;
958
1017
  if (depth === 0) {
959
1018
  j++;
@@ -968,70 +1027,76 @@ function maskTemplate(src, out, start) {
968
1027
  }
969
1028
  i++;
970
1029
  }
971
- blank(out, literalFrom, src.length);
972
- return src.length;
1030
+ blank(out, literalFrom, length);
1031
+ return length;
973
1032
  }
974
1033
  function maskJsComments(src) {
975
- const out = src.split("");
1034
+ const length = src.length;
1035
+ const out = codesOf(src);
976
1036
  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;
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
+ }
993
1055
  }
994
- if (ch === '"' || ch === "'" || ch === "`") {
1056
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE || ch === BACKTICK) {
995
1057
  i = endOfString(src, i, ch);
996
1058
  continue;
997
1059
  }
998
1060
  i++;
999
1061
  }
1000
- return out.join("");
1062
+ return stringOf(out);
1001
1063
  }
1002
1064
  function maskJsNoise(src) {
1003
- const out = src.split("");
1065
+ const length = src.length;
1066
+ const out = codesOf(src);
1004
1067
  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;
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
+ }
1021
1086
  }
1022
- if (ch === '"' || ch === "'") {
1087
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1023
1088
  const stop = endOfString(src, i, ch);
1024
1089
  blank(out, i + 1, stop - 1);
1025
1090
  i = stop;
1026
1091
  continue;
1027
1092
  }
1028
- if (ch === "`") {
1093
+ if (ch === BACKTICK) {
1029
1094
  i = maskTemplate(src, out, i);
1030
1095
  continue;
1031
1096
  }
1032
1097
  i++;
1033
1098
  }
1034
- return out.join("");
1099
+ return stringOf(out);
1035
1100
  }
1036
1101
  var commentCache = /* @__PURE__ */ new WeakMap();
1037
1102
  var noiseCache = /* @__PURE__ */ new WeakMap();
@@ -1122,12 +1187,14 @@ function isClientCode(file) {
1122
1187
  }
1123
1188
  return false;
1124
1189
  }
1125
- function isSupabaseProject(ctx) {
1190
+ var MENTIONS_SUPABASE = /supabase/i;
1191
+ function isSupabaseProject(ctx, files = ctx.files, scope = "") {
1126
1192
  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);
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);
1131
1198
  if (isEnvFile(name)) {
1132
1199
  for (const line of file.lines) {
1133
1200
  const entry = parseEnvLine(line);
@@ -1148,6 +1215,7 @@ function isSupabaseProject(ctx) {
1148
1215
  }
1149
1216
  continue;
1150
1217
  }
1218
+ if (!MENTIONS_SUPABASE.test(file.content)) continue;
1151
1219
  const commentsRemoved = commentsMaskedOf(file);
1152
1220
  const code = noiseMaskedOf(file);
1153
1221
  const supabaseImport = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s*)['"]@supabase\/(?:supabase-js|ssr)(?:\/[^'"]*)?['"]/g;
@@ -1223,18 +1291,17 @@ var secretsRule = {
1223
1291
  if (pat.publicByDesign) continue;
1224
1292
  pat.pattern.lastIndex = 0;
1225
1293
  let match;
1226
- if (findings.length >= MAX_FINDINGS_PER_FILE) break;
1227
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;
1228
1298
  if (findings.length >= MAX_FINDINGS_PER_FILE) {
1229
1299
  ctx.reportIncomplete(
1230
1300
  "secrets/hardcoded",
1231
1301
  `${file.path} holds more than ${MAX_FINDINGS_PER_FILE} credential-shaped strings; the rest were not reported`
1232
1302
  );
1233
- break;
1303
+ return findings;
1234
1304
  }
1235
- const secret = match[0];
1236
- if (isPlaceholder(secretPartOf(match, pat))) continue;
1237
- if (pat.ignoreIf?.(match)) continue;
1238
1305
  const line = lineNumberAt(lineStarts, match.index);
1239
1306
  const rawLine = file.lines[line - 1] ?? "";
1240
1307
  const clientSide = isClientCode(file);
@@ -1468,6 +1535,7 @@ function checkSourceFile(file) {
1468
1535
 
1469
1536
  // src/rules/gitleak.ts
1470
1537
  import { basename as basename4 } from "path";
1538
+ import { createHash } from "crypto";
1471
1539
  function isEnvTemplate(path) {
1472
1540
  return isTemplateName(path);
1473
1541
  }
@@ -1509,6 +1577,41 @@ function git(root, gitExecutable, args) {
1509
1577
  return null;
1510
1578
  }
1511
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
+ }
1512
1615
  function gitOrThrow(root, gitExecutable, args) {
1513
1616
  const out = git(root, gitExecutable, args);
1514
1617
  if (out === null) throw new Error(`git ${args.slice(0, 2).join(" ")} failed in ${root}`);
@@ -1556,24 +1659,37 @@ function historicalEvidence(root, gitExecutable, entry) {
1556
1659
  ]) ?? "").split(/\r?\n/).filter(Boolean);
1557
1660
  if (all.length === 0) return null;
1558
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
+ );
1559
1667
  let best = "none";
1560
1668
  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
- ]);
1669
+ const hintHashes = /* @__PURE__ */ new Set();
1670
+ for (const body of bodies) {
1568
1671
  if (body === null) {
1569
1672
  unreadable++;
1570
1673
  continue;
1571
1674
  }
1572
1675
  const evidence = evidenceIn(body.split(/\r?\n/));
1573
- if (evidence === "proof") return { evidence: "proof", unread: 0, unreadable };
1574
- 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
+ }
1575
1686
  }
1576
- 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
+ };
1577
1693
  }
1578
1694
  function hasRemote(root, gitExecutable) {
1579
1695
  const out = git(root, gitExecutable, ["remote"]);
@@ -1650,7 +1766,7 @@ var gitleakRule = {
1650
1766
  if (history && history.unreadable > 0) {
1651
1767
  ctx.reportIncomplete(
1652
1768
  "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`
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`
1654
1770
  );
1655
1771
  }
1656
1772
  const evidence = history?.evidence ?? "hint";
@@ -1658,6 +1774,7 @@ var gitleakRule = {
1658
1774
  const scaffolding = isScaffolding(path);
1659
1775
  findings.push({
1660
1776
  ruleId: "gitleak/env-in-history",
1777
+ ...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
1661
1778
  severity: "P0",
1662
1779
  // Claiming certainty about a file nobody could read would be the same
1663
1780
  // overreach the tracked branch just stopped making.
@@ -1700,28 +1817,41 @@ var INTERNAL_SCHEMAS = /* @__PURE__ */ new Set([
1700
1817
  "pg_catalog"
1701
1818
  ]);
1702
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
+ }
1703
1832
  function maskSqlNoise(sql) {
1704
- const out = sql.split("");
1833
+ const length = sql.length;
1834
+ const out = codesOf(sql);
1705
1835
  const erase = (from, to) => blank(out, from, to);
1706
1836
  let i = 0;
1707
- while (i < sql.length) {
1708
- const ch = sql[i];
1709
- const two = sql.slice(i, i + 2);
1710
- if (two === "--") {
1837
+ while (i < length) {
1838
+ const ch = sql.charCodeAt(i);
1839
+ if (ch === DASH && sql.charCodeAt(i + 1) === DASH) {
1711
1840
  const end = sql.indexOf("\n", i);
1712
- erase(i, end === -1 ? sql.length : end);
1713
- i = end === -1 ? sql.length : end;
1841
+ erase(i, end === -1 ? length : end);
1842
+ i = end === -1 ? length : end;
1714
1843
  continue;
1715
1844
  }
1716
- if (two === "/*") {
1845
+ if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
1717
1846
  let depth = 0;
1718
1847
  let j = i;
1719
- while (j < sql.length) {
1720
- const pair = sql.slice(j, j + 2);
1721
- 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) {
1722
1852
  depth++;
1723
1853
  j += 2;
1724
- } else if (pair === "*/") {
1854
+ } else if (first === STAR2 && second === SLASH2) {
1725
1855
  depth--;
1726
1856
  j += 2;
1727
1857
  if (depth === 0) break;
@@ -1733,16 +1863,17 @@ function maskSqlNoise(sql) {
1733
1863
  i = j;
1734
1864
  continue;
1735
1865
  }
1736
- if (ch === "'") {
1866
+ if (ch === SINGLE_QUOTE2) {
1737
1867
  const escaped = i > 0 && /[Ee]/.test(sql[i - 1] ?? "") && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? "");
1738
1868
  let j = i + 1;
1739
- while (j < sql.length) {
1740
- if (escaped && sql[j] === "\\") {
1869
+ while (j < length) {
1870
+ const inner = sql.charCodeAt(j);
1871
+ if (escaped && inner === 92) {
1741
1872
  j += 2;
1742
1873
  continue;
1743
1874
  }
1744
- if (sql[j] === "'") {
1745
- if (sql[j + 1] === "'") {
1875
+ if (inner === SINGLE_QUOTE2) {
1876
+ if (sql.charCodeAt(j + 1) === SINGLE_QUOTE2) {
1746
1877
  j += 2;
1747
1878
  continue;
1748
1879
  }
@@ -1755,20 +1886,21 @@ function maskSqlNoise(sql) {
1755
1886
  i = j;
1756
1887
  continue;
1757
1888
  }
1758
- if (ch === '"') {
1889
+ if (ch === DOUBLE_QUOTE2) {
1759
1890
  let j = i + 1;
1760
- while (j < sql.length && sql[j] !== '"') {
1761
- 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;
1762
1894
  j++;
1763
1895
  }
1764
1896
  i = j + 1;
1765
1897
  continue;
1766
1898
  }
1767
- if (ch === "$") {
1899
+ if (ch === DOLLAR2) {
1768
1900
  const tag = /^\$(?:[A-Za-z_]\w*)?\$/.exec(sql.slice(i))?.[0];
1769
1901
  if (tag) {
1770
1902
  const close = sql.indexOf(tag, i + tag.length);
1771
- const end = close === -1 ? sql.length : close + tag.length;
1903
+ const end = close === -1 ? length : close + tag.length;
1772
1904
  if (!IS_DO_BLOCK.test(sql.slice(0, i))) erase(i, end);
1773
1905
  i = end;
1774
1906
  continue;
@@ -1776,7 +1908,7 @@ function maskSqlNoise(sql) {
1776
1908
  }
1777
1909
  i++;
1778
1910
  }
1779
- return out.join("");
1911
+ return stringOf(out);
1780
1912
  }
1781
1913
  function unquote(ident) {
1782
1914
  const quoted = /^"(.*)"$/.exec(ident);
@@ -1865,10 +1997,7 @@ function projectScopeOf(path, scopes) {
1865
1997
  return best;
1866
1998
  }
1867
1999
  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 });
2000
+ return isSupabaseProject(ctx, files, scope);
1872
2001
  }
1873
2002
  function replayScopeOf(file, projectScope) {
1874
2003
  return `${file.isExampleContext ? "example" : "project"}:${projectScope}`;
@@ -2248,6 +2377,122 @@ function hasAuthSignal(file) {
2248
2377
  const code = noiseMaskedOf(file);
2249
2378
  return AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
2250
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
+ }
2251
2496
  var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>)?\s*\(/;
2252
2497
  var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
2253
2498
  var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
@@ -2641,8 +2886,7 @@ var apiAuthRule = {
2641
2886
  const sessionModules = ctx.files.filter(buildsSessionClient);
2642
2887
  const findings = [];
2643
2888
  for (const route of routes) {
2644
- if (hasAuthSignal(route)) continue;
2645
- const ops = findDataOps(route);
2889
+ const ops = unguardedOperations(route, findDataOps(route));
2646
2890
  if (ops.length === 0) continue;
2647
2891
  const url = routeUrl(route.path);
2648
2892
  if (isAuthEndpoint(url)) continue;
@@ -2990,8 +3234,32 @@ var corsRule = {
2990
3234
  // src/rules/index.ts
2991
3235
  var FILE_RULES = [secretsRule, exposureRule, firebaseRulesRule, corsRule];
2992
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
+ }
2993
3260
 
2994
3261
  // src/engine.ts
3262
+ import { createHash as createHash2 } from "crypto";
2995
3263
  var SEVERITY_ORDER = { P0: 0, P1: 1, P2: 2 };
2996
3264
  var CONFIDENCE_ORDER = { certain: 0, likely: 1 };
2997
3265
  function dedupe(findings) {
@@ -3016,9 +3284,20 @@ function clean(text) {
3016
3284
  function cleanForOutput(text) {
3017
3285
  return clean(text);
3018
3286
  }
3019
- 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
+ };
3020
3297
  return findings.map((f) => ({
3021
3298
  ...f,
3299
+ // 仅输出摘要;原始行不进入报告,移动行号不改变身份。
3300
+ ...sourceIdentity(f),
3022
3301
  title: clean(f.title),
3023
3302
  // Per paragraph, so the breaks between them survive a cleaner that removes
3024
3303
  // every newline inside them. See Finding.why.
@@ -3060,6 +3339,45 @@ function downgradeExampleContext(findings, files) {
3060
3339
  (f) => f.file !== null && examples.has(f.file) ? { ...f, confidence: "likely" } : f
3061
3340
  );
3062
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
+ }
3063
3381
  function sortFindings(findings) {
3064
3382
  return [...findings].sort((a, b) => {
3065
3383
  const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
@@ -3069,7 +3387,7 @@ function sortFindings(findings) {
3069
3387
  return (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0);
3070
3388
  });
3071
3389
  }
3072
- async function scan(root) {
3390
+ async function scan(root, options = {}) {
3073
3391
  const started = Date.now();
3074
3392
  const gitExecutable = resolveGitExecutable(root);
3075
3393
  const git2 = detectGitRepo(root, gitExecutable);
@@ -3112,8 +3430,13 @@ async function scan(root) {
3112
3430
  errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
3113
3431
  }
3114
3432
  }
3433
+ const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
3434
+ dedupe(downgradeExampleContext(findings, files)),
3435
+ files
3436
+ );
3437
+ const selected = applyRuleSelection(kept, options);
3115
3438
  return {
3116
- findings: sanitize(sortFindings(dedupe(downgradeExampleContext(findings, files)))),
3439
+ findings: sanitize(sortFindings(selected.kept), files),
3117
3440
  filesScanned: files.length,
3118
3441
  durationMs: Date.now() - started,
3119
3442
  errors: errors.map((e) => ({
@@ -3123,6 +3446,17 @@ async function scan(root) {
3123
3446
  })),
3124
3447
  skipped: sanitizeSkippedForOutput(skipped),
3125
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
+ },
3126
3460
  vendored,
3127
3461
  // A deliberate opt-out is not an incomplete scan: the user made that call
3128
3462
  // knowingly. It is listed in the report, not treated as a failure.
@@ -3195,6 +3529,27 @@ function skipPhrase(reason) {
3195
3529
 
3196
3530
  // src/report/terminal.ts
3197
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
+ }
3198
3553
  function renderReport(result, opts) {
3199
3554
  const out = [""];
3200
3555
  const { findings } = result;
@@ -3229,6 +3584,7 @@ function renderReport(result, opts) {
3229
3584
  out.push("");
3230
3585
  }
3231
3586
  out.push(...renderIgnored(result));
3587
+ out.push(...renderBaseline(opts));
3232
3588
  if (!opts.showingLikely && opts.hiddenLikely > 0) {
3233
3589
  out.push(
3234
3590
  `${INDENT}${dim(`${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden. Run with --all to see ${opts.hiddenLikely === 1 ? "it" : "them"}.`)}`
@@ -3306,10 +3662,17 @@ function renderClean(result, opts) {
3306
3662
  out.push(
3307
3663
  `${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
3308
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
+ );
3309
3670
  } else {
3310
3671
  out.push(`${INDENT}${green(bold("\u2713 No exposed credentials found"))}`);
3311
3672
  }
3312
3673
  out.push("");
3674
+ out.push(...renderBaseline(opts));
3675
+ if ((opts.baselineSuppressed ?? 0) > 0 || (opts.baselineStale ?? 0) > 0) out.push("");
3313
3676
  out.push(`${INDENT}${dim("canship checked for:")}`);
3314
3677
  out.push(`${INDENT}${dim(" \xB7 API keys hardcoded in source code")}`);
3315
3678
  out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
@@ -3324,6 +3687,10 @@ function renderClean(result, opts) {
3324
3687
  out.push(`${INDENT}${dim("did find are the right ones.")}`);
3325
3688
  if (opts.hiddenLikely > 0) {
3326
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
+ );
3327
3694
  } else {
3328
3695
  out.push(`${INDENT}${dim("A clean result means these checks passed \u2014 not that your app is secure.")}`);
3329
3696
  }
@@ -3352,6 +3719,20 @@ function renderIgnored(result) {
3352
3719
  `${INDENT}${dim(`${result.ignored.length} ${plural(result.ignored.length, "file")} excluded by canship-ignore-file: ${shown}${more}`)}`
3353
3720
  );
3354
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
+ }
3355
3736
  if (result.vendored > 0) {
3356
3737
  out.push(
3357
3738
  `${INDENT}${dim(`${result.vendored} ${plural(result.vendored, "file")} skipped inside dependency directories (node_modules, vendor, Pods, .yarn, .pnpm-store)`)}`
@@ -3445,8 +3826,18 @@ function renderFixPrompt(findings, ctx) {
3445
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.";
3446
3827
  const hiddenLikely = ctx?.hiddenLikely ?? 0;
3447
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);
3448
3837
  if (findings.length === 0) {
3449
- const notes = [incompleteNote, hiddenNote].filter((note) => note !== null);
3838
+ const notes = [incompleteNote, hiddenNote, ...suppressedNotes].filter(
3839
+ (note) => note !== null
3840
+ );
3450
3841
  return notes.length === 0 ? null : `${notes.join("\n\n")}
3451
3842
  `;
3452
3843
  }
@@ -3463,6 +3854,10 @@ function renderFixPrompt(findings, ctx) {
3463
3854
  out.push(hiddenNote);
3464
3855
  out.push("");
3465
3856
  }
3857
+ for (const note of suppressedNotes) {
3858
+ out.push(note);
3859
+ out.push("");
3860
+ }
3466
3861
  if (codeFixable.length > 0) {
3467
3862
  out.push("--- Paste everything below into your coding assistant ---");
3468
3863
  out.push("");
@@ -3539,6 +3934,8 @@ function renderFinding2(f, index) {
3539
3934
  function renderHtml(result, opts) {
3540
3935
  const { findings } = result;
3541
3936
  const hiddenLikely = opts.hiddenLikely ?? 0;
3937
+ const baselineSuppressed = opts.baselineSuppressed ?? 0;
3938
+ const baselineStale = opts.baselineStale ?? 0;
3542
3939
  const { blocking: certain, minor, unsure } = verdictOf(findings);
3543
3940
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
3544
3941
  // Examined nothing, so there is nothing to report either way.
@@ -3547,7 +3944,13 @@ function renderHtml(result, opts) {
3547
3944
  // Never the green banner on a partial scan: it reads as a guarantee,
3548
3945
  // and a scan that skipped files cannot make one.
3549
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>`
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>`;
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>`;
3551
3954
  const body = findings.length === 0 ? result.filesScanned === 0 ? (
3552
3955
  // The checklist below would be a false statement here: none of those
3553
3956
  // checks had any input to run against.
@@ -3562,6 +3965,10 @@ function renderHtml(result, opts) {
3562
3965
  <p><strong>This is not a finding-free result.</strong> The default report hides
3563
3966
  ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
3564
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>
3565
3972
  </div>` : `<div class="clean-note">
3566
3973
  <p>canship checked for hardcoded API keys, server secrets exposed to the browser,
3567
3974
  Supabase tables without Row Level Security, open Firebase rules, API routes that reach
@@ -3572,7 +3979,12 @@ function renderHtml(result, opts) {
3572
3979
  checks it did find are the right ones.</p>
3573
3980
  </div>` : findings.map((f, i) => renderFinding2(f, i + 1)).join("\n");
3574
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>` : "";
3575
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>` : "";
3576
3988
  const incomplete = result.partial ? `<div class="incomplete">
3577
3989
  <h2>Not everything was checked</h2>
3578
3990
  <ul>
@@ -3674,6 +4086,10 @@ function renderHtml(result, opts) {
3674
4086
  ${body}
3675
4087
  ${incomplete}
3676
4088
  ${hiddenNotice}
4089
+ ${baselineNotice}
4090
+ ${staleNotice}
4091
+ ${silenced}
4092
+ ${ruleSelection}
3677
4093
  ${optedOut}
3678
4094
  <footer>
3679
4095
  Generated by canship. Everything ran locally; nothing was uploaded.
@@ -3684,13 +4100,387 @@ function renderHtml(result, opts) {
3684
4100
  `;
3685
4101
  }
3686
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
+
3687
4437
  // src/cli.ts
3688
- var VERSION = true ? "0.1.1" : "0.0.0-dev";
4438
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
3689
4439
  function argumentError(message) {
3690
4440
  process.stderr.write(`canship: ${cleanForOutput(message)}
3691
4441
  `);
3692
4442
  process.exit(3);
3693
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
+ }
3694
4484
  function parseArgs(argv) {
3695
4485
  const args = {
3696
4486
  root: process.cwd(),
@@ -3699,21 +4489,61 @@ function parseArgs(argv) {
3699
4489
  fixPrompt: false,
3700
4490
  report: null,
3701
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,
3702
4500
  help: false,
3703
4501
  version: false
3704
4502
  };
3705
4503
  const positional = [];
3706
4504
  for (const arg of argv) {
3707
- if (arg === "--report") {
3708
- 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);
3709
4514
  continue;
3710
4515
  }
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;
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;
3717
4547
  continue;
3718
4548
  }
3719
4549
  switch (arg) {
@@ -3730,6 +4560,9 @@ function parseArgs(argv) {
3730
4560
  case "--best-effort":
3731
4561
  args.bestEffort = true;
3732
4562
  break;
4563
+ case "--no-config":
4564
+ args.noConfig = true;
4565
+ break;
3733
4566
  case "--help":
3734
4567
  case "-h":
3735
4568
  args.help = true;
@@ -3764,6 +4597,14 @@ var HELP = `
3764
4597
  --json Output raw JSON (for CI or tooling)
3765
4598
  --best-effort Allow exit 0 for an incomplete scan with no findings;
3766
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
3767
4608
  -h, --help Show this help
3768
4609
  -v, --version Show version
3769
4610
 
@@ -3775,6 +4616,10 @@ var HELP = `
3775
4616
 
3776
4617
  ${dim("--json and --fix-prompt are alternative stdout modes; --report may be combined with either.")}
3777
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
+
3778
4623
  ${dim("Scanned files stay local: no project-code execution, network requests, or uploads.")}
3779
4624
  `;
3780
4625
  async function main() {
@@ -3792,20 +4637,116 @@ async function main() {
3792
4637
  if (args.json && args.fixPrompt) {
3793
4638
  argumentError("--json and --fix-prompt are mutually exclusive");
3794
4639
  }
3795
- 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()) {
3796
4644
  process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
3797
4645
  `);
3798
4646
  return process.exit(3);
3799
4647
  }
3800
- 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
+ }
3801
4738
  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;
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;
3804
4741
  if (args.fixPrompt) {
3805
4742
  const prompt = renderFixPrompt(shown, {
3806
4743
  partial: result.partial,
3807
4744
  filesScanned: result.filesScanned,
3808
- 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)
3809
4750
  });
3810
4751
  process.stdout.write(
3811
4752
  prompt === null ? "Nothing to fix \u2014 no findings.\n" : `${prompt}
@@ -3825,10 +4766,18 @@ async function main() {
3825
4766
  errors: result.errors,
3826
4767
  skipped: result.skipped,
3827
4768
  ignored: result.ignored,
4769
+ ignoredFindings: result.ignoredFindings,
4770
+ ruleSelection: result.ruleSelection,
3828
4771
  vendored: result.vendored,
3829
4772
  // The default view hides the detail, never the fact. A machine reading this
3830
4773
  // must not see "no findings" while lower-confidence ones exist.
3831
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,
3832
4781
  findings: shown
3833
4782
  },
3834
4783
  null,
@@ -3840,19 +4789,63 @@ async function main() {
3840
4789
  process.stdout.write(
3841
4790
  `${renderReport(
3842
4791
  { ...result, findings: shown },
3843
- { 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
+ }
3844
4800
  )}
3845
4801
  `
3846
4802
  );
3847
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
+ }
3848
4834
  if (args.report) {
3849
4835
  const target = resolve2(args.report);
3850
4836
  try {
3851
- writeFileSync(
4837
+ writeFileSync2(
3852
4838
  target,
3853
4839
  renderHtml(
3854
4840
  { ...result, findings: shown },
3855
- { 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
+ }
3856
4849
  ),
3857
4850
  "utf8"
3858
4851
  );
@@ -3872,7 +4865,7 @@ ${cleanForOutput(String(err))}
3872
4865
  }
3873
4866
  if (verdictOf(result.findings).blocking > 0) return process.exit(1);
3874
4867
  if (result.findings.length > 0) return process.exit(2);
3875
- if (result.partial && !args.bestEffort) return process.exit(3);
4868
+ if (result.partial && !bestEffort) return process.exit(3);
3876
4869
  return process.exit(0);
3877
4870
  }
3878
4871
  main().catch((err) => {