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.
- package/README.md +204 -114
- package/README.zh-CN.md +206 -116
- package/dist/cli.js +1180 -173
- 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
|
|
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
|
|
195
|
-
return
|
|
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
|
|
424
|
+
function hardeningArgs(root) {
|
|
411
425
|
const worktree = gitRootAbove(root) ?? root;
|
|
412
426
|
const noHooks = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
413
|
-
return
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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*(?:\/\/|#|--|\*\/?|\/\*|<!--)
|
|
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
|
-
|
|
883
|
-
|
|
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 <
|
|
889
|
-
|
|
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 (
|
|
962
|
+
if (ch === quote) return i + 1;
|
|
894
963
|
i++;
|
|
895
964
|
}
|
|
896
|
-
return
|
|
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 <
|
|
902
|
-
|
|
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 (
|
|
977
|
+
if (ch === BACKTICK) {
|
|
907
978
|
blank(out, literalFrom, i);
|
|
908
979
|
return i + 1;
|
|
909
980
|
}
|
|
910
|
-
if (
|
|
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 <
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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 (
|
|
932
|
-
const stop = endOfString(src, j,
|
|
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 (
|
|
1010
|
+
if (inner === BACKTICK) {
|
|
938
1011
|
j = maskTemplate(src, out, j);
|
|
939
1012
|
continue;
|
|
940
1013
|
}
|
|
941
|
-
if (
|
|
942
|
-
else if (
|
|
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,
|
|
958
|
-
return
|
|
1030
|
+
blank(out, literalFrom, length);
|
|
1031
|
+
return length;
|
|
959
1032
|
}
|
|
960
1033
|
function maskJsComments(src) {
|
|
961
|
-
const
|
|
1034
|
+
const length = src.length;
|
|
1035
|
+
const out = codesOf(src);
|
|
962
1036
|
let i = 0;
|
|
963
|
-
while (i <
|
|
964
|
-
const ch = src
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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 ===
|
|
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
|
|
1062
|
+
return stringOf(out);
|
|
987
1063
|
}
|
|
988
1064
|
function maskJsNoise(src) {
|
|
989
|
-
const
|
|
1065
|
+
const length = src.length;
|
|
1066
|
+
const out = codesOf(src);
|
|
990
1067
|
let i = 0;
|
|
991
|
-
while (i <
|
|
992
|
-
const ch = src
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
|
1114
|
-
|
|
1115
|
-
if (
|
|
1116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1548
|
-
|
|
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 {
|
|
1560
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 <
|
|
1694
|
-
const ch = sql
|
|
1695
|
-
|
|
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 ?
|
|
1699
|
-
i = end === -1 ?
|
|
1841
|
+
erase(i, end === -1 ? length : end);
|
|
1842
|
+
i = end === -1 ? length : end;
|
|
1700
1843
|
continue;
|
|
1701
1844
|
}
|
|
1702
|
-
if (
|
|
1845
|
+
if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
|
|
1703
1846
|
let depth = 0;
|
|
1704
1847
|
let j = i;
|
|
1705
|
-
while (j <
|
|
1706
|
-
const
|
|
1707
|
-
|
|
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 (
|
|
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 <
|
|
1726
|
-
|
|
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 (
|
|
1731
|
-
if (sql
|
|
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 <
|
|
1747
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings — but not everything was checked</div>`
|
|
3536
|
-
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` :
|
|
3947
|
+
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${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 — ${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")} — do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix — nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
|
|
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 — 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.
|
|
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
|
-
|
|
3694
|
-
|
|
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
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
3789
|
-
const hiddenLikely =
|
|
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
|
-
{
|
|
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
|
-
|
|
4837
|
+
writeFileSync2(
|
|
3838
4838
|
target,
|
|
3839
4839
|
renderHtml(
|
|
3840
4840
|
{ ...result, findings: shown },
|
|
3841
|
-
{
|
|
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 && !
|
|
4868
|
+
if (result.partial && !bestEffort) return process.exit(3);
|
|
3862
4869
|
return process.exit(0);
|
|
3863
4870
|
}
|
|
3864
4871
|
main().catch((err) => {
|