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.
- package/README.md +204 -114
- package/README.zh-CN.md +206 -116
- package/dist/cli.js +1166 -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;
|
|
@@ -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
|
|
209
|
-
return
|
|
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
|
|
424
|
+
function hardeningArgs(root) {
|
|
425
425
|
const worktree = gitRootAbove(root) ?? root;
|
|
426
426
|
const noHooks = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
427
|
-
return
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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*(?:\/\/|#|--|\*\/?|\/\*|<!--)
|
|
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
|
-
|
|
897
|
-
|
|
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 <
|
|
903
|
-
|
|
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 (
|
|
962
|
+
if (ch === quote) return i + 1;
|
|
908
963
|
i++;
|
|
909
964
|
}
|
|
910
|
-
return
|
|
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 <
|
|
916
|
-
|
|
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 (
|
|
977
|
+
if (ch === BACKTICK) {
|
|
921
978
|
blank(out, literalFrom, i);
|
|
922
979
|
return i + 1;
|
|
923
980
|
}
|
|
924
|
-
if (
|
|
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 <
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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 (
|
|
946
|
-
const stop = endOfString(src, j,
|
|
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 (
|
|
1010
|
+
if (inner === BACKTICK) {
|
|
952
1011
|
j = maskTemplate(src, out, j);
|
|
953
1012
|
continue;
|
|
954
1013
|
}
|
|
955
|
-
if (
|
|
956
|
-
else if (
|
|
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,
|
|
972
|
-
return
|
|
1030
|
+
blank(out, literalFrom, length);
|
|
1031
|
+
return length;
|
|
973
1032
|
}
|
|
974
1033
|
function maskJsComments(src) {
|
|
975
|
-
const
|
|
1034
|
+
const length = src.length;
|
|
1035
|
+
const out = codesOf(src);
|
|
976
1036
|
let i = 0;
|
|
977
|
-
while (i <
|
|
978
|
-
const ch = src
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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 ===
|
|
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
|
|
1062
|
+
return stringOf(out);
|
|
1001
1063
|
}
|
|
1002
1064
|
function maskJsNoise(src) {
|
|
1003
|
-
const
|
|
1065
|
+
const length = src.length;
|
|
1066
|
+
const out = codesOf(src);
|
|
1004
1067
|
let i = 0;
|
|
1005
|
-
while (i <
|
|
1006
|
-
const ch = src
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
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 {
|
|
1574
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 <
|
|
1708
|
-
const ch = sql
|
|
1709
|
-
|
|
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 ?
|
|
1713
|
-
i = end === -1 ?
|
|
1841
|
+
erase(i, end === -1 ? length : end);
|
|
1842
|
+
i = end === -1 ? length : end;
|
|
1714
1843
|
continue;
|
|
1715
1844
|
}
|
|
1716
|
-
if (
|
|
1845
|
+
if (ch === SLASH2 && sql.charCodeAt(i + 1) === STAR2) {
|
|
1717
1846
|
let depth = 0;
|
|
1718
1847
|
let j = i;
|
|
1719
|
-
while (j <
|
|
1720
|
-
const
|
|
1721
|
-
|
|
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 (
|
|
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 <
|
|
1740
|
-
|
|
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 (
|
|
1745
|
-
if (sql
|
|
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 <
|
|
1761
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings — but not everything was checked</div>`
|
|
3550
|
-
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` :
|
|
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>`;
|
|
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 — 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.
|
|
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
|
-
|
|
3708
|
-
|
|
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
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
3803
|
-
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;
|
|
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
|
-
{
|
|
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
|
-
|
|
4837
|
+
writeFileSync2(
|
|
3852
4838
|
target,
|
|
3853
4839
|
renderHtml(
|
|
3854
4840
|
{ ...result, findings: shown },
|
|
3855
|
-
{
|
|
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 && !
|
|
4868
|
+
if (result.partial && !bestEffort) return process.exit(3);
|
|
3876
4869
|
return process.exit(0);
|
|
3877
4870
|
}
|
|
3878
4871
|
main().catch((err) => {
|