canship 0.2.0 → 0.3.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-zh-CN.md +199 -0
- package/README.md +199 -204
- package/dist/cli.js +491 -293
- package/package.json +7 -2
- package/schemas/scan-report-v1.schema.json +95 -0
- package/README.zh-CN.md +0 -206
package/dist/cli.js
CHANGED
|
@@ -47,13 +47,7 @@ var SECRET_PATTERNS = [
|
|
|
47
47
|
{
|
|
48
48
|
id: "npm-token",
|
|
49
49
|
name: "npm access token",
|
|
50
|
-
//
|
|
51
|
-
// characters and this could verify it. The github-token entry above uses
|
|
52
|
-
// the same scheme and does not verify it either, for the reason that
|
|
53
|
-
// settles it: if the algorithm ever changes, a checksum test turns a real
|
|
54
|
-
// leaked token into a silent pass. A false positive costs a glance. This
|
|
55
|
-
// particular false negative costs the account, and every account that
|
|
56
|
-
// installs anything published from it afterwards.
|
|
50
|
+
// 仅检查令牌形状,不校验其校验码。
|
|
57
51
|
pattern: /\bnpm_[A-Za-z0-9]{36}\b/g,
|
|
58
52
|
impact: "Publishes packages under your account. Anyone who installs one afterwards runs whatever that version contains.",
|
|
59
53
|
rotateAt: "https://docs.npmjs.com/creating-and-viewing-access-tokens"
|
|
@@ -64,13 +58,7 @@ var SECRET_PATTERNS = [
|
|
|
64
58
|
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
65
59
|
impact: "Depending on its scope, this can be used to run up billed usage on Google Cloud services.",
|
|
66
60
|
rotateAt: "https://console.cloud.google.com/apis/credentials",
|
|
67
|
-
//
|
|
68
|
-
// bearer credential: it identifies a Firebase project or a Maps Platform
|
|
69
|
-
// caller, and Google's own docs say it belongs in client code. Flagging
|
|
70
|
-
// it as "exposed to the browser — rotate this" was a false positive on
|
|
71
|
-
// every ordinary Firebase or Maps front-end, and the actual protection
|
|
72
|
-
// (application/API restrictions in the Cloud console) is not something a
|
|
73
|
-
// static scan of the repository can confirm one way or the other.
|
|
61
|
+
// Google 项目标识符不直接作为私密凭据报告。
|
|
74
62
|
publicByDesign: true
|
|
75
63
|
},
|
|
76
64
|
{
|
|
@@ -91,17 +79,7 @@ var SECRET_PATTERNS = [
|
|
|
91
79
|
id: "supabase-secret-key",
|
|
92
80
|
name: "Supabase secret key",
|
|
93
81
|
rotateLabel: "Supabase secret key",
|
|
94
|
-
// Supabase
|
|
95
|
-
// server-side half, sb_publishable_ is the one meant for browsers, and the
|
|
96
|
-
// two are never confusable.
|
|
97
|
-
//
|
|
98
|
-
// framework.ts has recognised this format since the day it was written —
|
|
99
|
-
// but only for deciding whether a *client-exposed* value is the admin key.
|
|
100
|
-
// It was never in this table, and this table is what the hardcoded-secret
|
|
101
|
-
// rule and the output-boundary redaction both walk. So a project with one
|
|
102
|
-
// of these in its source got a clean report, and a project with one beside
|
|
103
|
-
// another credential had it printed in full: the redaction pass could not
|
|
104
|
-
// mask a format it did not know.
|
|
82
|
+
// 共享新版 Supabase 私密密钥模式,与公开密钥区分。
|
|
105
83
|
pattern: new RegExp(String.raw`\b${SB_SECRET_SOURCE}\b`, "g"),
|
|
106
84
|
impact: "This is the server-side Supabase key. It bypasses every Row Level Security policy \u2014 it is effectively your database root password.",
|
|
107
85
|
rotateAt: "your Supabase dashboard, Project Settings -> API Keys"
|
|
@@ -117,22 +95,9 @@ var SECRET_PATTERNS = [
|
|
|
117
95
|
id: "db-connection-string",
|
|
118
96
|
name: "Database connection string with password",
|
|
119
97
|
pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s:@/'"`]+:([^\s@/'"`]+)@(\[[^\]\s]+\]|[^\s'"`/:]+)(?::\d+)?(?:[/?#][^\s'"`]*)?/g,
|
|
120
|
-
//
|
|
121
|
-
// in the host does not cause a miss.
|
|
98
|
+
// 仅用密码组判断占位符,避免主机名影响判断。
|
|
122
99
|
secretGroup: 1,
|
|
123
|
-
//
|
|
124
|
-
// connection string is worthless and not worth reporting.
|
|
125
|
-
//
|
|
126
|
-
// The host alternative takes a bracketed form first so IPv6 survives:
|
|
127
|
-
// `[^\s'"/:]+` stops at the first colon, so `@[::1]:5432` captured a lone
|
|
128
|
-
// `[`, which matches no entry below — an IPv6 loopback string was reported
|
|
129
|
-
// P0 while the identical `localhost` one was correctly ignored.
|
|
130
|
-
//
|
|
131
|
-
// The backtick is excluded for the same reason. It is the third string
|
|
132
|
-
// delimiter in JavaScript and the only one this pattern had never heard of,
|
|
133
|
-
// so a connection string written in a template literal handed the host
|
|
134
|
-
// group a trailing backtick and defeated the check below. canship found
|
|
135
|
-
// that one on its own source, in the comment above.
|
|
100
|
+
// 支持 IPv6,并排除示例和本地地址。
|
|
136
101
|
ignoreIf: (m) => IRRELEVANT_HOSTS.test(m[2] ?? ""),
|
|
137
102
|
rotateLabel: "database password",
|
|
138
103
|
impact: "This contains your database username AND password. Anyone with it can read, modify, or delete your entire database."
|
|
@@ -233,6 +198,7 @@ import { execFileSync } from "child_process";
|
|
|
233
198
|
import { accessSync, constants, existsSync, lstatSync, readFileSync, realpathSync, statSync } from "fs";
|
|
234
199
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
235
200
|
var MAX_GIT_OUTPUT = 32 * 1024 * 1024;
|
|
201
|
+
var GIT_TIMEOUT_MS = 3e4;
|
|
236
202
|
function canonical(path) {
|
|
237
203
|
try {
|
|
238
204
|
return realpathSync.native(path);
|
|
@@ -437,6 +403,7 @@ function hardeningArgs(root) {
|
|
|
437
403
|
}
|
|
438
404
|
function execGitSync(executable, root, args, options = {}) {
|
|
439
405
|
return execFileSync(executable, [...hardeningArgs(root), ...args], {
|
|
406
|
+
timeout: GIT_TIMEOUT_MS,
|
|
440
407
|
cwd: root,
|
|
441
408
|
encoding: "utf8",
|
|
442
409
|
maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
|
|
@@ -447,6 +414,7 @@ function execGitSync(executable, root, args, options = {}) {
|
|
|
447
414
|
}
|
|
448
415
|
function execGitBatch(executable, root, args, input, options = {}) {
|
|
449
416
|
return execFileSync(executable, [...hardeningArgs(root), ...args], {
|
|
417
|
+
timeout: GIT_TIMEOUT_MS,
|
|
450
418
|
cwd: root,
|
|
451
419
|
input,
|
|
452
420
|
maxBuffer: options.maxBuffer ?? MAX_GIT_OUTPUT,
|
|
@@ -458,6 +426,8 @@ function execGitBatch(executable, root, args, input, options = {}) {
|
|
|
458
426
|
|
|
459
427
|
// src/walker.ts
|
|
460
428
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
429
|
+
var MAX_SCAN_BYTES = 128 * 1024 * 1024;
|
|
430
|
+
var MAX_SCAN_FILES = 1e4;
|
|
461
431
|
var MAX_WALK_DEPTH = 16;
|
|
462
432
|
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
463
433
|
"node_modules",
|
|
@@ -476,8 +446,7 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
|
476
446
|
".venv",
|
|
477
447
|
"venv",
|
|
478
448
|
".cache",
|
|
479
|
-
//
|
|
480
|
-
// these to look for credential files is pure cost.
|
|
449
|
+
// 排除其他生态的构建产物和工具缓存。
|
|
481
450
|
".dart_tool",
|
|
482
451
|
".gradle",
|
|
483
452
|
"Pods",
|
|
@@ -515,7 +484,7 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
515
484
|
".yml",
|
|
516
485
|
".toml",
|
|
517
486
|
".sql",
|
|
518
|
-
// Firebase
|
|
487
|
+
// Firebase 规则文件。
|
|
519
488
|
".rules",
|
|
520
489
|
".env",
|
|
521
490
|
".sh",
|
|
@@ -810,7 +779,7 @@ function isExampleContext(relPath) {
|
|
|
810
779
|
if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
|
|
811
780
|
return false;
|
|
812
781
|
}
|
|
813
|
-
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root)) {
|
|
782
|
+
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}) {
|
|
814
783
|
const skipped = [];
|
|
815
784
|
const ignored = [];
|
|
816
785
|
const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
|
|
@@ -839,6 +808,10 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
839
808
|
forced.add(hidden);
|
|
840
809
|
}
|
|
841
810
|
const files = [];
|
|
811
|
+
let bytesRead = 0;
|
|
812
|
+
let filesRead = 0;
|
|
813
|
+
const maxBytes = limits.maxBytes ?? MAX_SCAN_BYTES;
|
|
814
|
+
const maxFiles = limits.maxFiles ?? MAX_SCAN_FILES;
|
|
842
815
|
for (const relPath of candidates) {
|
|
843
816
|
if (!forced.has(relPath) && !shouldScan(relPath)) continue;
|
|
844
817
|
const absPath = join2(root, relPath);
|
|
@@ -859,7 +832,22 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
859
832
|
});
|
|
860
833
|
continue;
|
|
861
834
|
}
|
|
862
|
-
|
|
835
|
+
if (filesRead >= maxFiles || bytesRead + size > maxBytes) {
|
|
836
|
+
skipped.push({
|
|
837
|
+
path: relPath,
|
|
838
|
+
reason: "too-large",
|
|
839
|
+
detail: `scan read budget exceeded (${maxFiles} files, ${maxBytes} bytes); remaining candidates were not read`
|
|
840
|
+
});
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
const bytes = readFileSync2(absPath);
|
|
844
|
+
bytesRead += bytes.length;
|
|
845
|
+
filesRead++;
|
|
846
|
+
if (bytesRead > maxBytes) {
|
|
847
|
+
skipped.push({ path: relPath, reason: "too-large", detail: "scan read budget exceeded during file read" });
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
content = decodeText(bytes);
|
|
863
851
|
} catch (err) {
|
|
864
852
|
if (!isMissing(err)) {
|
|
865
853
|
skipped.push({
|
|
@@ -964,71 +952,68 @@ function endOfString(src, start, quote) {
|
|
|
964
952
|
}
|
|
965
953
|
return length;
|
|
966
954
|
}
|
|
967
|
-
function maskTemplate(src, out, start) {
|
|
955
|
+
function maskTemplate(src, out, start, maskStrings = true) {
|
|
968
956
|
const length = src.length;
|
|
957
|
+
const stack = [{ kind: "literal", from: start + 1 }];
|
|
969
958
|
let i = start + 1;
|
|
970
|
-
|
|
971
|
-
|
|
959
|
+
while (i < length && stack.length > 0) {
|
|
960
|
+
const frame = stack[stack.length - 1];
|
|
972
961
|
const ch = src.charCodeAt(i);
|
|
973
|
-
if (
|
|
974
|
-
|
|
962
|
+
if (frame.kind === "literal") {
|
|
963
|
+
if (ch === BACKSLASH) {
|
|
964
|
+
i = Math.min(i + 2, length);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (ch === BACKTICK) {
|
|
968
|
+
if (maskStrings) blank(out, frame.from, i);
|
|
969
|
+
stack.pop();
|
|
970
|
+
i++;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
|
|
974
|
+
if (maskStrings) blank(out, frame.from, i);
|
|
975
|
+
stack.push({ kind: "expression", depth: 1 });
|
|
976
|
+
i += 2;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
i++;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (ch === SLASH) {
|
|
983
|
+
const next = src.charCodeAt(i + 1);
|
|
984
|
+
if (next === SLASH || next === STAR) {
|
|
985
|
+
const close = next === SLASH ? src.indexOf("\n", i) : src.indexOf("*/", i + 2);
|
|
986
|
+
const stop = close === -1 ? length : close + (next === STAR ? 2 : 0);
|
|
987
|
+
blank(out, i, stop);
|
|
988
|
+
i = stop;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
993
|
+
const stop = endOfString(src, i, ch);
|
|
994
|
+
if (maskStrings) blank(out, i + 1, stop - 1);
|
|
995
|
+
i = stop;
|
|
975
996
|
continue;
|
|
976
997
|
}
|
|
977
998
|
if (ch === BACKTICK) {
|
|
978
|
-
|
|
979
|
-
|
|
999
|
+
stack.push({ kind: "literal", from: i + 1 });
|
|
1000
|
+
i++;
|
|
1001
|
+
continue;
|
|
980
1002
|
}
|
|
981
|
-
if (ch ===
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const
|
|
987
|
-
if (
|
|
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
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
|
|
1005
|
-
const stop = endOfString(src, j, inner);
|
|
1006
|
-
blank(out, j + 1, stop - 1);
|
|
1007
|
-
j = stop;
|
|
1008
|
-
continue;
|
|
1009
|
-
}
|
|
1010
|
-
if (inner === BACKTICK) {
|
|
1011
|
-
j = maskTemplate(src, out, j);
|
|
1012
|
-
continue;
|
|
1013
|
-
}
|
|
1014
|
-
if (inner === OPEN_BRACE) depth++;
|
|
1015
|
-
else if (inner === CLOSE_BRACE) {
|
|
1016
|
-
depth--;
|
|
1017
|
-
if (depth === 0) {
|
|
1018
|
-
j++;
|
|
1019
|
-
break;
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
j++;
|
|
1003
|
+
if (ch === OPEN_BRACE) frame.depth++;
|
|
1004
|
+
else if (ch === CLOSE_BRACE) {
|
|
1005
|
+
frame.depth--;
|
|
1006
|
+
if (frame.depth === 0) {
|
|
1007
|
+
stack.pop();
|
|
1008
|
+
const parent = stack[stack.length - 1];
|
|
1009
|
+
if (parent.kind === "literal") parent.from = i + 1;
|
|
1023
1010
|
}
|
|
1024
|
-
i = j;
|
|
1025
|
-
literalFrom = i;
|
|
1026
|
-
continue;
|
|
1027
1011
|
}
|
|
1028
1012
|
i++;
|
|
1029
1013
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1014
|
+
const last = stack[stack.length - 1];
|
|
1015
|
+
if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
|
|
1016
|
+
return i;
|
|
1032
1017
|
}
|
|
1033
1018
|
function maskJsComments(src) {
|
|
1034
1019
|
const length = src.length;
|
|
@@ -1053,10 +1038,14 @@ function maskJsComments(src) {
|
|
|
1053
1038
|
continue;
|
|
1054
1039
|
}
|
|
1055
1040
|
}
|
|
1056
|
-
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE
|
|
1041
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1057
1042
|
i = endOfString(src, i, ch);
|
|
1058
1043
|
continue;
|
|
1059
1044
|
}
|
|
1045
|
+
if (ch === BACKTICK) {
|
|
1046
|
+
i = maskTemplate(src, out, i, false);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1060
1049
|
i++;
|
|
1061
1050
|
}
|
|
1062
1051
|
return stringOf(out);
|
|
@@ -1350,6 +1339,20 @@ var secretsRule = {
|
|
|
1350
1339
|
// src/rules/exposure.ts
|
|
1351
1340
|
import { basename as basename3 } from "path";
|
|
1352
1341
|
var JWT_SHAPED2 = new RegExp(String.raw`\b${JWT_SOURCE}\b`, "g");
|
|
1342
|
+
var FindingBuffer = class {
|
|
1343
|
+
items = [];
|
|
1344
|
+
overflow = false;
|
|
1345
|
+
push(finding) {
|
|
1346
|
+
if (this.items.length < MAX_FINDINGS_PER_FILE) {
|
|
1347
|
+
this.items.push(finding);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
this.overflow = true;
|
|
1351
|
+
if (finding.confidence !== "certain") return;
|
|
1352
|
+
const index = this.items.findIndex((item) => item.confidence === "likely");
|
|
1353
|
+
if (index !== -1) this.items[index] = finding;
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1353
1356
|
function parseEnv(file) {
|
|
1354
1357
|
const entries = [];
|
|
1355
1358
|
file.lines.forEach((raw, i) => {
|
|
@@ -1366,35 +1369,20 @@ var exposureRule = {
|
|
|
1366
1369
|
if (isEnvFile(name)) return true;
|
|
1367
1370
|
return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
|
|
1368
1371
|
},
|
|
1369
|
-
/**
|
|
1370
|
-
* The ceiling, applied here rather than inside each branch.
|
|
1371
|
-
*
|
|
1372
|
-
* This rule was the last one without one. secrets.ts, firebase.ts and
|
|
1373
|
-
* supabase.ts all cap and all say so — the constant was pulled into limits.ts
|
|
1374
|
-
* precisely so the reasoning would not have to be rediscovered — and exposure
|
|
1375
|
-
* never adopted it. A `.env` holding 3,000 public-prefixed credential names
|
|
1376
|
-
* produced 3,000 findings, 2.36 MB of JSON and 48,046 lines of terminal
|
|
1377
|
-
* output, with `partial` false and `errors` empty: the identical shape of the
|
|
1378
|
-
* bug firebase.ts records in its own comment.
|
|
1379
|
-
*
|
|
1380
|
-
* At the entry point because there are two branches and a future third would
|
|
1381
|
-
* have to remember. Truncating after the fact rather than stopping the loop
|
|
1382
|
-
* keeps that single place honest: the input is already bounded by
|
|
1383
|
-
* MAX_FILE_BYTES, so what this protects is the report, not the scan.
|
|
1384
|
-
*/
|
|
1372
|
+
/** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
|
|
1385
1373
|
check(file, ctx) {
|
|
1386
1374
|
const name = basename3(file.path);
|
|
1387
1375
|
const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
|
|
1388
|
-
if (findings.
|
|
1376
|
+
if (!findings.overflow) return findings.items;
|
|
1389
1377
|
ctx.reportIncomplete(
|
|
1390
1378
|
"exposure/public-env",
|
|
1391
1379
|
`${file.path} holds more than ${MAX_FINDINGS_PER_FILE} values exposed to the browser; the rest were not reported`
|
|
1392
1380
|
);
|
|
1393
|
-
return findings.
|
|
1381
|
+
return findings.items;
|
|
1394
1382
|
}
|
|
1395
1383
|
};
|
|
1396
1384
|
function checkEnvFile(file) {
|
|
1397
|
-
const findings =
|
|
1385
|
+
const findings = new FindingBuffer();
|
|
1398
1386
|
for (const entry of parseEnv(file)) {
|
|
1399
1387
|
const prefix = publicPrefixOf(entry.key);
|
|
1400
1388
|
if (!prefix) continue;
|
|
@@ -1473,7 +1461,7 @@ function checkEnvFile(file) {
|
|
|
1473
1461
|
return findings;
|
|
1474
1462
|
}
|
|
1475
1463
|
function checkSourceFile(file) {
|
|
1476
|
-
const findings =
|
|
1464
|
+
const findings = new FindingBuffer();
|
|
1477
1465
|
const clientSide = isClientCode(file);
|
|
1478
1466
|
const commentless = commentsMaskedOf(file).split(/\r?\n/);
|
|
1479
1467
|
file.lines.forEach((line, i) => {
|
|
@@ -1654,6 +1642,7 @@ function historicalEvidence(root, gitExecutable, entry) {
|
|
|
1654
1642
|
"--no-textconv",
|
|
1655
1643
|
"--all",
|
|
1656
1644
|
"--format=%H",
|
|
1645
|
+
`--max-count=${MAX_HISTORY_REVISIONS + 1}`,
|
|
1657
1646
|
"--",
|
|
1658
1647
|
entry.localPath
|
|
1659
1648
|
]) ?? "").split(/\r?\n/).filter(Boolean);
|
|
@@ -1731,9 +1720,7 @@ var gitleakRule = {
|
|
|
1731
1720
|
findings.push({
|
|
1732
1721
|
ruleId: "gitleak/env-tracked",
|
|
1733
1722
|
severity: "P0",
|
|
1734
|
-
//
|
|
1735
|
-
// recognisably a credential. Everything else is a committed env file
|
|
1736
|
-
// that might hold one, which is worth saying quietly.
|
|
1723
|
+
// 只有非示例中的明确凭据使用确定置信度。
|
|
1737
1724
|
confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
|
|
1738
1725
|
title: evidence === "proof" && !scaffolding ? `${path} is committed to git, with a credential in it` : `${path} is committed to git`,
|
|
1739
1726
|
file: path,
|
|
@@ -1760,7 +1747,7 @@ var gitleakRule = {
|
|
|
1760
1747
|
if (history && history.unread > 0) {
|
|
1761
1748
|
ctx.reportIncomplete(
|
|
1762
1749
|
"gitleak/env-in-history",
|
|
1763
|
-
`only the ${MAX_HISTORY_REVISIONS} most recent versions of ${path} were read; ${history.unread} older ${history.unread === 1 ? "version was" : "versions were"} not checked`
|
|
1750
|
+
`only the ${MAX_HISTORY_REVISIONS} most recent versions of ${path} were read; at least ${history.unread} older ${history.unread === 1 ? "version was" : "versions were"} not checked`
|
|
1764
1751
|
);
|
|
1765
1752
|
}
|
|
1766
1753
|
if (history && history.unreadable > 0) {
|
|
@@ -1776,8 +1763,7 @@ var gitleakRule = {
|
|
|
1776
1763
|
ruleId: "gitleak/env-in-history",
|
|
1777
1764
|
...history?.sourceFingerprint === void 0 ? {} : { sourceFingerprint: history.sourceFingerprint },
|
|
1778
1765
|
severity: "P0",
|
|
1779
|
-
//
|
|
1780
|
-
// overreach the tracked branch just stopped making.
|
|
1766
|
+
// 证据或上下文不足时降低置信度。
|
|
1781
1767
|
confidence: evidence === "proof" && !scaffolding ? "certain" : "likely",
|
|
1782
1768
|
title: stillTracked ? `${path} is committed to git, and an older version of it held a credential` : `${path} was removed, but it is still in your git history`,
|
|
1783
1769
|
file: path,
|
|
@@ -2117,8 +2103,8 @@ var supabaseRlsRule = {
|
|
|
2117
2103
|
line: entry.line,
|
|
2118
2104
|
excerpt: null,
|
|
2119
2105
|
why: [
|
|
2120
|
-
`Supabase
|
|
2121
|
-
`
|
|
2106
|
+
`Supabase's browser clients use public identifiers. For exposed tables, database grants and Row Level Security determine which operations and rows a caller can access.`,
|
|
2107
|
+
`After replaying the scanned migrations, the final recorded state of ${renderIdent(entry.table)} does not have RLS enabled. It may never have been enabled or may have been disabled by a later migration. Without RLS, any access granted to the caller is not restricted by row policies.`,
|
|
2122
2108
|
`If you enabled RLS from the Supabase dashboard instead, this file simply cannot show it. Check the Authentication -> Policies page to confirm.`
|
|
2123
2109
|
],
|
|
2124
2110
|
fix: [
|
|
@@ -2145,9 +2131,7 @@ var supabaseRlsRule = {
|
|
|
2145
2131
|
// src/rules/firebase.ts
|
|
2146
2132
|
import { basename as basename5 } from "path";
|
|
2147
2133
|
function isRulesFile(file) {
|
|
2148
|
-
|
|
2149
|
-
if (name.endsWith(".rules")) return true;
|
|
2150
|
-
return name === "firestore.rules" || name === "storage.rules";
|
|
2134
|
+
return basename5(file.path).toLowerCase().endsWith(".rules");
|
|
2151
2135
|
}
|
|
2152
2136
|
function productOf(path) {
|
|
2153
2137
|
const name = basename5(path).toLowerCase();
|
|
@@ -2251,8 +2235,7 @@ var firebaseRulesRule = {
|
|
|
2251
2235
|
findings.push({
|
|
2252
2236
|
ruleId: "firebase/open-rules",
|
|
2253
2237
|
severity: "P1",
|
|
2254
|
-
//
|
|
2255
|
-
// (a public catalogue, announcements), so it stays lower-confidence.
|
|
2238
|
+
// 开放写入为确定结果,公开读取需人工确认。
|
|
2256
2239
|
confidence: canWrite ? "certain" : "likely",
|
|
2257
2240
|
title: canWrite ? `Your ${product} rules let anyone ${ops} this data` : `Your ${product} rules make this data publicly readable`,
|
|
2258
2241
|
file: file.path,
|
|
@@ -2260,7 +2243,7 @@ var firebaseRulesRule = {
|
|
|
2260
2243
|
excerpt: m[0].trim(),
|
|
2261
2244
|
why: canWrite ? [
|
|
2262
2245
|
`"if true" grants access unconditionally \u2014 no sign-in, no ownership check, nothing. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
|
|
2263
|
-
`Anyone
|
|
2246
|
+
`Anyone can perform the allowed ${ops} operations on this matched path without authentication. Other operations depend on their own rules. Project ids are public identifiers, not access controls.`
|
|
2264
2247
|
] : [
|
|
2265
2248
|
`"if true" grants read access unconditionally, so anyone who finds your project id can list every document in this collection. Project ids are not secret; they ship inside your frontend bundle.`,
|
|
2266
2249
|
`Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
|
|
@@ -2290,8 +2273,7 @@ var firebaseRulesRule = {
|
|
|
2290
2273
|
findings.push({
|
|
2291
2274
|
ruleId: "firebase/test-mode-rules",
|
|
2292
2275
|
severity: "P1",
|
|
2293
|
-
//
|
|
2294
|
-
// so this stays certain whether or not writes are involved.
|
|
2276
|
+
// 固定日期测试模式均作为确定配置问题。
|
|
2295
2277
|
confidence: "certain",
|
|
2296
2278
|
title: expired ? `Your ${product} rules are in test mode and expired on ${dateStr}` : `Your ${product} rules allow ${ops} to anyone until ${dateStr}`,
|
|
2297
2279
|
file: file.path,
|
|
@@ -2320,7 +2302,10 @@ import { posix } from "path";
|
|
|
2320
2302
|
var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
|
|
2321
2303
|
var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
|
|
2322
2304
|
function isApiRoute(path) {
|
|
2323
|
-
return APP_ROUTER.test(path) || PAGES_ROUTER.test(path);
|
|
2305
|
+
return APP_ROUTER.test(routePathOf(path)) || PAGES_ROUTER.test(path);
|
|
2306
|
+
}
|
|
2307
|
+
function routePathOf(path) {
|
|
2308
|
+
return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
|
|
2324
2309
|
}
|
|
2325
2310
|
var AUTH_ENDPOINT_NAMES = /^\/api\/auth\/(?:sign[-_]?in|sign[-_]?up|sign[-_]?out|log[-_]?in|log[-_]?out|register|session|verify|confirm|reset(?:[-_]password)?|forgot(?:[-_]password)?|magic[-_]?link|otp)$/;
|
|
2326
2311
|
var AUTH_CALLBACK = /^\/api\/auth\/callback(?:\/[^/]+)?$/;
|
|
@@ -2328,7 +2313,7 @@ function isAuthEndpoint(url) {
|
|
|
2328
2313
|
return AUTH_ENDPOINT_NAMES.test(url) || AUTH_CALLBACK.test(url);
|
|
2329
2314
|
}
|
|
2330
2315
|
function routeUrl(path) {
|
|
2331
|
-
const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
|
|
2316
|
+
const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
|
|
2332
2317
|
if (!m) return `/${path}`;
|
|
2333
2318
|
const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
|
|
2334
2319
|
return `/${url}`;
|
|
@@ -2359,17 +2344,37 @@ function controlledStatement(source, afterCondition) {
|
|
|
2359
2344
|
return source.slice(start, end);
|
|
2360
2345
|
}
|
|
2361
2346
|
function hasConditionalAuthGuard(code) {
|
|
2362
|
-
const starts =
|
|
2363
|
-
|
|
2347
|
+
const starts = /\bif\s*\(/g;
|
|
2348
|
+
let match;
|
|
2349
|
+
while ((match = starts.exec(code)) !== null) {
|
|
2364
2350
|
const open = code.indexOf("(", match.index);
|
|
2365
2351
|
const close = closingDelimiter(code, open, "(", ")");
|
|
2366
2352
|
if (close === null) continue;
|
|
2367
2353
|
const condition = code.slice(open + 1, close);
|
|
2368
2354
|
const statement = controlledStatement(code, close + 1);
|
|
2369
|
-
const
|
|
2355
|
+
const body = statement.startsWith("{") ? statement.slice(1, -1) : statement;
|
|
2356
|
+
const pairs = delimiterPairs(body);
|
|
2357
|
+
let stopsRequest = false;
|
|
2358
|
+
for (let i = 0; i < body.length; i++) {
|
|
2359
|
+
if (/^(?:return|throw|redirect|notFound)\b/.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
|
|
2360
|
+
stopsRequest = true;
|
|
2361
|
+
break;
|
|
2362
|
+
}
|
|
2363
|
+
if (/^if\s*\(/.test(body.slice(i, i + 16))) break;
|
|
2364
|
+
const end = pairs.get(i);
|
|
2365
|
+
if (end !== void 0) i = end;
|
|
2366
|
+
}
|
|
2367
|
+
let statementStart = close + 1;
|
|
2368
|
+
while (/\s/.test(code[statementStart] ?? "")) statementStart++;
|
|
2369
|
+
starts.lastIndex = statementStart + statement.length;
|
|
2370
2370
|
if (!stopsRequest) continue;
|
|
2371
2371
|
const returnsDeniedStatus = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus\s*[:(=]\s*(?:401|403)\b/i.test(statement);
|
|
2372
|
-
|
|
2372
|
+
const negative = !/&&|\?/.test(condition) && condition.split("||").some((part) => {
|
|
2373
|
+
const term = part.trim();
|
|
2374
|
+
const rejects = /^!\s*[\w$.]+(?:\s*\([^=]*\))?$/.test(term) || /^[\w$.]+\s*={2,3}\s*(?:null|undefined|false)$/.test(term) || /^[\w$.]+\s*!={1,2}\s*(?!(?:null|undefined|false)\b)[\w$.]+$/.test(term);
|
|
2375
|
+
return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
|
|
2376
|
+
});
|
|
2377
|
+
if (negative) return true;
|
|
2373
2378
|
}
|
|
2374
2379
|
return false;
|
|
2375
2380
|
}
|
|
@@ -2462,6 +2467,16 @@ function unguardedOperations(file, ops) {
|
|
|
2462
2467
|
}
|
|
2463
2468
|
const call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
|
|
2464
2469
|
if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
|
|
2470
|
+
const prefixStart = Math.max(
|
|
2471
|
+
owner.start + 1,
|
|
2472
|
+
code.lastIndexOf(";", i - 1) + 1,
|
|
2473
|
+
code.lastIndexOf("\n", i - 1) + 1
|
|
2474
|
+
);
|
|
2475
|
+
const prefix = code.slice(prefixStart, i).trim();
|
|
2476
|
+
if (/(?:&&|\|\||\?|:)\s*$/.test(code.slice(owner.start + 1, prefixStart))) continue;
|
|
2477
|
+
const awaited = /^(?:await|(?:const|let|var)\s+[\w${},:\s]+?=\s*await)(?:\s+[\w$.]+\.)?$/.test(prefix);
|
|
2478
|
+
const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
|
|
2479
|
+
if (!awaited && !synchronous) continue;
|
|
2465
2480
|
const close2 = pairs.get(i + call[0].length - 1);
|
|
2466
2481
|
if (close2 !== void 0 && close2 < owner.end) return close2 + 1;
|
|
2467
2482
|
}
|
|
@@ -2559,7 +2574,7 @@ function usesSessionClient(route, sessionModules, allFiles) {
|
|
|
2559
2574
|
return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
|
|
2560
2575
|
}
|
|
2561
2576
|
function moduleScopeOf(routePath) {
|
|
2562
|
-
return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePath)?.[1] ?? "";
|
|
2577
|
+
return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
|
|
2563
2578
|
}
|
|
2564
2579
|
function importedModules(file, allFiles, aliasScope) {
|
|
2565
2580
|
const found = [];
|
|
@@ -2899,18 +2914,16 @@ var apiAuthRule = {
|
|
|
2899
2914
|
findings.push({
|
|
2900
2915
|
ruleId: "api/admin-db-access-without-auth",
|
|
2901
2916
|
severity: "P0",
|
|
2902
|
-
//
|
|
2903
|
-
// every RLS policy, and nothing in the file or in middleware checks
|
|
2904
|
-
// who sent the request.
|
|
2917
|
+
// 管理员客户端缺少鉴权时使用确定置信度。
|
|
2905
2918
|
confidence: "certain",
|
|
2906
2919
|
title: `Anyone can call ${url} and it queries your database as admin`,
|
|
2907
2920
|
file: route.path,
|
|
2908
2921
|
line,
|
|
2909
2922
|
excerpt,
|
|
2910
2923
|
why: [
|
|
2911
|
-
`This route
|
|
2912
|
-
`
|
|
2913
|
-
`
|
|
2924
|
+
`This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
|
|
2925
|
+
`The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
|
|
2926
|
+
`If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
|
|
2914
2927
|
`If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
|
|
2915
2928
|
],
|
|
2916
2929
|
fix: [
|
|
@@ -2929,17 +2942,15 @@ var apiAuthRule = {
|
|
|
2929
2942
|
findings.push({
|
|
2930
2943
|
ruleId: "api/db-write-without-auth",
|
|
2931
2944
|
severity: "P1",
|
|
2932
|
-
//
|
|
2933
|
-
// (a waitlist, a contact form), and protection can also live in a
|
|
2934
|
-
// deployment-level proxy this scan cannot see.
|
|
2945
|
+
// 公开写入可能是业务设计,保留疑似置信度。
|
|
2935
2946
|
confidence: "likely",
|
|
2936
2947
|
title: `${url} writes to your database with no sign-in check`,
|
|
2937
2948
|
file: route.path,
|
|
2938
2949
|
line,
|
|
2939
2950
|
excerpt,
|
|
2940
2951
|
why: [
|
|
2941
|
-
`This route changes data, and
|
|
2942
|
-
`
|
|
2952
|
+
`This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
|
|
2953
|
+
`If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
|
|
2943
2954
|
`If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
|
|
2944
2955
|
],
|
|
2945
2956
|
fix: [
|
|
@@ -2956,8 +2967,31 @@ var apiAuthRule = {
|
|
|
2956
2967
|
|
|
2957
2968
|
// src/rules/cors.ts
|
|
2958
2969
|
var CORS_MARKER = /Access-Control-Allow-Origin|\bcors\s*\(/i;
|
|
2959
|
-
var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)
|
|
2960
|
-
|
|
2970
|
+
var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?/gi;
|
|
2971
|
+
function headerExpression(content, start) {
|
|
2972
|
+
const closes = [];
|
|
2973
|
+
let quote = null;
|
|
2974
|
+
let end = start;
|
|
2975
|
+
for (; end < content.length; end++) {
|
|
2976
|
+
const ch = content[end];
|
|
2977
|
+
if (ch === "\n" || ch === "\r") break;
|
|
2978
|
+
if (quote !== null) {
|
|
2979
|
+
if (ch === "\\") end++;
|
|
2980
|
+
else if (ch === quote) quote = null;
|
|
2981
|
+
continue;
|
|
2982
|
+
}
|
|
2983
|
+
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
|
|
2984
|
+
else if (ch === "(") closes.push(")");
|
|
2985
|
+
else if (ch === "[") closes.push("]");
|
|
2986
|
+
else if (ch === "{") closes.push("}");
|
|
2987
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2988
|
+
if (closes.at(-1) !== ch) break;
|
|
2989
|
+
closes.pop();
|
|
2990
|
+
} else if (closes.length === 0 && (ch === "," || ch === ";")) break;
|
|
2991
|
+
}
|
|
2992
|
+
return content.slice(start, end);
|
|
2993
|
+
}
|
|
2994
|
+
var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
|
|
2961
2995
|
var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*)?\s*)?\(/gi;
|
|
2962
2996
|
var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
|
|
2963
2997
|
function closingParameterList(source, open) {
|
|
@@ -2998,6 +3032,8 @@ function readOriginCallback(content, match, kind) {
|
|
|
2998
3032
|
}
|
|
2999
3033
|
return {
|
|
3000
3034
|
index: match.index,
|
|
3035
|
+
parametersStart: open + 1,
|
|
3036
|
+
parametersEnd: close,
|
|
3001
3037
|
params: content.slice(open + 1, close),
|
|
3002
3038
|
body: content.slice(bodyStart, bodyStart + 400)
|
|
3003
3039
|
};
|
|
@@ -3106,31 +3142,63 @@ function classifyOrigin(raw) {
|
|
|
3106
3142
|
return /\b(?:req|request|headers?|ctx|event)/i.test(compact) ? "reflected" : "unknown";
|
|
3107
3143
|
}
|
|
3108
3144
|
var PAIRING_DISTANCE = 25;
|
|
3145
|
+
function optionScopes(file, positions) {
|
|
3146
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3147
|
+
const wanted = [...new Set(positions)].sort((a, b) => a - b);
|
|
3148
|
+
if (wanted.length === 0) return scopes;
|
|
3149
|
+
const content = noiseMaskedOf(file);
|
|
3150
|
+
const stack = [-1];
|
|
3151
|
+
let next = 0;
|
|
3152
|
+
for (let at = 0; at < content.length && next < wanted.length; at++) {
|
|
3153
|
+
if (at === wanted[next]) {
|
|
3154
|
+
scopes.set(at, stack[stack.length - 1]);
|
|
3155
|
+
next++;
|
|
3156
|
+
}
|
|
3157
|
+
if (content[at] === "{") stack.push(at);
|
|
3158
|
+
else if (content[at] === "}" && stack.length > 1) stack.pop();
|
|
3159
|
+
}
|
|
3160
|
+
return scopes;
|
|
3161
|
+
}
|
|
3109
3162
|
function collectOrigins(file) {
|
|
3110
3163
|
const marks = [];
|
|
3111
3164
|
let m;
|
|
3112
3165
|
const content = commentsMaskedOf(file);
|
|
3113
3166
|
const contentLines = lineStartsOf(content);
|
|
3167
|
+
const code = noiseMaskedOf(file);
|
|
3168
|
+
const callbacks = originCallbacks(content).filter((callback) => code[callback.index] === content[callback.index]);
|
|
3169
|
+
const callbackIndices = new Set(callbacks.map((callback) => callback.index));
|
|
3170
|
+
const parameterRanges = [...callbacks].sort((a, b) => a.parametersStart - b.parametersStart);
|
|
3171
|
+
let parameterRange = 0;
|
|
3114
3172
|
ACAO.lastIndex = 0;
|
|
3115
3173
|
while ((m = ACAO.exec(content)) !== null) {
|
|
3174
|
+
if (code[m.index] !== content[m.index]) continue;
|
|
3116
3175
|
const line = lineNumberAt(contentLines, m.index);
|
|
3117
|
-
|
|
3176
|
+
const expression = headerExpression(content, ACAO.lastIndex);
|
|
3177
|
+
ACAO.lastIndex += expression.length;
|
|
3178
|
+
marks.push({ line, at: m.index, mode: "header", kind: classifyOrigin(expression), excerpt: (file.lines[line - 1] ?? "").trim() });
|
|
3118
3179
|
}
|
|
3119
3180
|
CORS_ORIGIN_OPTION.lastIndex = 0;
|
|
3120
3181
|
while ((m = CORS_ORIGIN_OPTION.exec(content)) !== null) {
|
|
3182
|
+
if (code[m.index] !== content[m.index]) continue;
|
|
3183
|
+
while (parameterRange < parameterRanges.length && parameterRanges[parameterRange].parametersEnd < m.index) parameterRange++;
|
|
3184
|
+
if (parameterRanges[parameterRange] && parameterRanges[parameterRange].parametersStart <= m.index) continue;
|
|
3185
|
+
if (callbackIndices.has(m.index)) continue;
|
|
3121
3186
|
const line = lineNumberAt(contentLines, m.index);
|
|
3187
|
+
const expression = headerExpression(content, CORS_ORIGIN_OPTION.lastIndex);
|
|
3188
|
+
CORS_ORIGIN_OPTION.lastIndex += expression.length;
|
|
3122
3189
|
marks.push({
|
|
3123
3190
|
line,
|
|
3124
|
-
|
|
3191
|
+
at: m.index,
|
|
3192
|
+
mode: "option",
|
|
3193
|
+
kind: expression.trim() === "true" ? "reflected" : classifyOrigin(expression),
|
|
3125
3194
|
excerpt: (file.lines[line - 1] ?? "").trim()
|
|
3126
3195
|
});
|
|
3127
3196
|
}
|
|
3128
|
-
for (const callback of
|
|
3197
|
+
for (const callback of callbacks) {
|
|
3129
3198
|
const answer = callbackAnswer(callback.params, callback.body);
|
|
3130
|
-
|
|
3131
|
-
if (ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index))) continue;
|
|
3199
|
+
const kind = answer !== null && !ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index)) ? answer.kind : "unknown";
|
|
3132
3200
|
const line = lineNumberAt(contentLines, callback.index);
|
|
3133
|
-
marks.push({ line,
|
|
3201
|
+
marks.push({ line, at: callback.index, mode: "option", kind, excerpt: (file.lines[line - 1] ?? "").trim() });
|
|
3134
3202
|
}
|
|
3135
3203
|
return marks;
|
|
3136
3204
|
}
|
|
@@ -3138,11 +3206,16 @@ function collectCredentialLines(file) {
|
|
|
3138
3206
|
const lines = [];
|
|
3139
3207
|
let m;
|
|
3140
3208
|
const content = commentsMaskedOf(file);
|
|
3209
|
+
const code = noiseMaskedOf(file);
|
|
3141
3210
|
const contentLines = lineStartsOf(content);
|
|
3142
3211
|
ACAC_HEADER.lastIndex = 0;
|
|
3143
|
-
while ((m = ACAC_HEADER.exec(content)) !== null)
|
|
3212
|
+
while ((m = ACAC_HEADER.exec(content)) !== null) {
|
|
3213
|
+
if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "header" });
|
|
3214
|
+
}
|
|
3144
3215
|
CORS_CREDENTIALS_OPTION.lastIndex = 0;
|
|
3145
|
-
while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null)
|
|
3216
|
+
while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) {
|
|
3217
|
+
if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "option" });
|
|
3218
|
+
}
|
|
3146
3219
|
return lines;
|
|
3147
3220
|
}
|
|
3148
3221
|
function nearestOrigin(origins, credLine) {
|
|
@@ -3172,10 +3245,12 @@ var corsRule = {
|
|
|
3172
3245
|
if (credentialLines.length === 0) return [];
|
|
3173
3246
|
const origins = collectOrigins(file);
|
|
3174
3247
|
if (origins.length === 0) return [];
|
|
3248
|
+
const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
|
|
3175
3249
|
const findings = [];
|
|
3176
3250
|
const reported = /* @__PURE__ */ new Set();
|
|
3177
|
-
for (const
|
|
3178
|
-
const
|
|
3251
|
+
for (const credential of credentialLines) {
|
|
3252
|
+
const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
|
|
3253
|
+
const origin = nearestOrigin(candidates, credential.line);
|
|
3179
3254
|
if (!origin) continue;
|
|
3180
3255
|
if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
|
|
3181
3256
|
if (reported.has(origin.kind)) continue;
|
|
@@ -3184,8 +3259,7 @@ var corsRule = {
|
|
|
3184
3259
|
findings.push({
|
|
3185
3260
|
ruleId: "cors/reflected-origin-with-credentials",
|
|
3186
3261
|
severity: "P1",
|
|
3187
|
-
//
|
|
3188
|
-
// back unchanged, and credentials are allowed. Nothing is inferred.
|
|
3262
|
+
// 明确的来源回显和凭据配置使用确定置信度。
|
|
3189
3263
|
confidence: "certain",
|
|
3190
3264
|
title: "Any website can make signed-in requests to your API and read the answer",
|
|
3191
3265
|
file: file.path,
|
|
@@ -3207,8 +3281,7 @@ var corsRule = {
|
|
|
3207
3281
|
findings.push({
|
|
3208
3282
|
ruleId: "cors/wildcard-with-credentials",
|
|
3209
3283
|
severity: "P2",
|
|
3210
|
-
//
|
|
3211
|
-
// browser rejects it.
|
|
3284
|
+
// 通配符与凭据组合违反浏览器跨域约束。
|
|
3212
3285
|
confidence: "certain",
|
|
3213
3286
|
title: "This CORS setup is rejected by every browser, so the requests it enables never work",
|
|
3214
3287
|
file: file.path,
|
|
@@ -3247,8 +3320,7 @@ var RULE_IDS = [
|
|
|
3247
3320
|
"gitleak/env-in-history",
|
|
3248
3321
|
"gitleak/env-tracked",
|
|
3249
3322
|
"supabase/rls-not-enabled",
|
|
3250
|
-
//
|
|
3251
|
-
// adding a pattern cannot leave a finding id this list has never heard of.
|
|
3323
|
+
// 凭据规则 ID 从共享格式表生成。
|
|
3252
3324
|
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
3253
3325
|
];
|
|
3254
3326
|
function ruleMatches(selector, ruleId) {
|
|
@@ -3257,6 +3329,13 @@ function ruleMatches(selector, ruleId) {
|
|
|
3257
3329
|
function isKnownSelector(selector) {
|
|
3258
3330
|
return RULE_IDS.some((id) => ruleMatches(selector, id));
|
|
3259
3331
|
}
|
|
3332
|
+
function shouldRunRule(id, only, skip) {
|
|
3333
|
+
if (only.length === 0 && skip.length === 0) return true;
|
|
3334
|
+
const namespace = id.split("/")[0];
|
|
3335
|
+
return RULE_IDS.filter((candidate) => candidate.startsWith(`${namespace}/`)).some(
|
|
3336
|
+
(candidate) => (only.length === 0 || only.some((selector) => ruleMatches(selector, candidate))) && !skip.some((selector) => ruleMatches(selector, candidate))
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3260
3339
|
|
|
3261
3340
|
// src/engine.ts
|
|
3262
3341
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -3299,28 +3378,11 @@ function sanitize(findings, files) {
|
|
|
3299
3378
|
// 仅输出摘要;原始行不进入报告,移动行号不改变身份。
|
|
3300
3379
|
...sourceIdentity(f),
|
|
3301
3380
|
title: clean(f.title),
|
|
3302
|
-
//
|
|
3303
|
-
// every newline inside them. See Finding.why.
|
|
3381
|
+
// 按段落清理,保留段落之间的结构。
|
|
3304
3382
|
why: f.why.map(clean),
|
|
3305
|
-
//
|
|
3306
|
-
// credential put it straight back into the JSON, the terminal, the HTML
|
|
3307
|
-
// and the prompt meant for pasting into an assistant.
|
|
3383
|
+
// 文件路径也必须经过输出清理。
|
|
3308
3384
|
file: f.file === null ? null : clean(f.file),
|
|
3309
|
-
//
|
|
3310
|
-
//
|
|
3311
|
-
// A rule that trimmed its own excerpt to length before this ran could
|
|
3312
|
-
// defeat the redaction entirely: cutting at 120 characters through the
|
|
3313
|
-
// middle of a key leaves a fragment that matches no pattern, so `clean`
|
|
3314
|
-
// waved it past and nineteen characters of a live OpenAI key reached the
|
|
3315
|
-
// terminal, the JSON, the HTML report and the prompt meant for pasting
|
|
3316
|
-
// into an assistant. The rule was not doing anything unreasonable — it
|
|
3317
|
-
// truncated, which every other rule also does. The order was simply not
|
|
3318
|
-
// its decision to make.
|
|
3319
|
-
//
|
|
3320
|
-
// So rules hand over the whole line and the boundary does both jobs, in
|
|
3321
|
-
// the only order that is safe. Rules that redact per match still may:
|
|
3322
|
-
// masking a known secret before this point is additive, and truncating an
|
|
3323
|
-
// already-truncated string is a no-op.
|
|
3385
|
+
// 先脱敏再截断,避免截断导致凭据特征失效。
|
|
3324
3386
|
excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
|
|
3325
3387
|
fix: f.fix.map(clean),
|
|
3326
3388
|
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
@@ -3394,19 +3456,15 @@ async function scan(root, options = {}) {
|
|
|
3394
3456
|
const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
|
|
3395
3457
|
const findings = [];
|
|
3396
3458
|
const errors = [];
|
|
3459
|
+
const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
|
|
3460
|
+
const projectRules = PROJECT_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
|
|
3397
3461
|
const incompleteSeen = /* @__PURE__ */ new Set();
|
|
3398
3462
|
const ctx = {
|
|
3399
3463
|
root,
|
|
3400
3464
|
files,
|
|
3401
3465
|
git: git2,
|
|
3402
3466
|
gitExecutable,
|
|
3403
|
-
//
|
|
3404
|
-
// A rule that reaches the same ceiling from two loops over the same file —
|
|
3405
|
-
// firebase does, once for open rules and once for test-mode rules — said
|
|
3406
|
-
// the identical sentence twice, in the terminal's incomplete section and in
|
|
3407
|
-
// the JSON. Saying "part of this did not happen" twice does not make it
|
|
3408
|
-
// twice as true, and a once-flag per rule is the kind of bookkeeping every
|
|
3409
|
-
// new rule would have to remember.
|
|
3467
|
+
// 统一记录不完整状态,避免重复提示。
|
|
3410
3468
|
reportIncomplete: (ruleId, message) => {
|
|
3411
3469
|
if (incompleteSeen.has(`${ruleId} ${message}`)) return;
|
|
3412
3470
|
incompleteSeen.add(`${ruleId} ${message}`);
|
|
@@ -3414,20 +3472,20 @@ async function scan(root, options = {}) {
|
|
|
3414
3472
|
}
|
|
3415
3473
|
};
|
|
3416
3474
|
for (const file of files) {
|
|
3417
|
-
for (const
|
|
3418
|
-
if (!rule.appliesTo(file)) continue;
|
|
3475
|
+
for (const rule2 of fileRules) {
|
|
3419
3476
|
try {
|
|
3420
|
-
|
|
3477
|
+
if (!rule2.appliesTo(file)) continue;
|
|
3478
|
+
findings.push(...rule2.check(file, ctx));
|
|
3421
3479
|
} catch (err) {
|
|
3422
|
-
errors.push({ ruleId:
|
|
3480
|
+
errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
|
|
3423
3481
|
}
|
|
3424
3482
|
}
|
|
3425
3483
|
}
|
|
3426
|
-
for (const
|
|
3484
|
+
for (const rule2 of projectRules) {
|
|
3427
3485
|
try {
|
|
3428
|
-
findings.push(...await
|
|
3486
|
+
findings.push(...await rule2.check(ctx));
|
|
3429
3487
|
} catch (err) {
|
|
3430
|
-
errors.push({ ruleId:
|
|
3488
|
+
errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
|
|
3431
3489
|
}
|
|
3432
3490
|
}
|
|
3433
3491
|
const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
|
|
@@ -3435,8 +3493,19 @@ async function scan(root, options = {}) {
|
|
|
3435
3493
|
files
|
|
3436
3494
|
);
|
|
3437
3495
|
const selected = applyRuleSelection(kept, options);
|
|
3496
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3497
|
+
const bounded = sortFindings(selected.kept).filter((finding) => {
|
|
3498
|
+
const count = (counts.get(finding.file) ?? 0) + 1;
|
|
3499
|
+
counts.set(finding.file, count);
|
|
3500
|
+
if (count <= MAX_FINDINGS_PER_FILE) return true;
|
|
3501
|
+
if (count === MAX_FINDINGS_PER_FILE + 1) ctx.reportIncomplete(
|
|
3502
|
+
"engine/findings-limit",
|
|
3503
|
+
`${finding.file ?? "project"} has more than ${MAX_FINDINGS_PER_FILE} findings; remaining findings were not reported`
|
|
3504
|
+
);
|
|
3505
|
+
return false;
|
|
3506
|
+
});
|
|
3438
3507
|
return {
|
|
3439
|
-
findings: sanitize(
|
|
3508
|
+
findings: sanitize(bounded, files),
|
|
3440
3509
|
filesScanned: files.length,
|
|
3441
3510
|
durationMs: Date.now() - started,
|
|
3442
3511
|
errors: errors.map((e) => ({
|
|
@@ -3446,29 +3515,16 @@ async function scan(root, options = {}) {
|
|
|
3446
3515
|
})),
|
|
3447
3516
|
skipped: sanitizeSkippedForOutput(skipped),
|
|
3448
3517
|
ignored: ignored.map(clean),
|
|
3449
|
-
//
|
|
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.
|
|
3518
|
+
// 清理被忽略结果的路径。
|
|
3452
3519
|
ignoredFindings: ignoredFindings.map((f) => ({ ...f, file: clean(f.file) })),
|
|
3453
|
-
//
|
|
3454
|
-
// text canship prints back, so both go through the boundary.
|
|
3520
|
+
// 选择器来自外部输入,输出前也需清理。
|
|
3455
3521
|
ruleSelection: selected.selection === null ? null : {
|
|
3456
3522
|
only: selected.selection.only.map(clean),
|
|
3457
3523
|
skip: selected.selection.skip.map(clean),
|
|
3458
3524
|
removed: selected.selection.removed
|
|
3459
3525
|
},
|
|
3460
3526
|
vendored,
|
|
3461
|
-
//
|
|
3462
|
-
// knowingly. It is listed in the report, not treated as a failure.
|
|
3463
|
-
//
|
|
3464
|
-
// Examining no files at all, however, is the purest form of an incomplete
|
|
3465
|
-
// scan, and it used to print a green tick and exit 0 — the exact outcome
|
|
3466
|
-
// the README says must never share an exit code with "clean". It is also
|
|
3467
|
-
// the most likely way to be wrong in practice: the headline command is
|
|
3468
|
-
// `npx canship` with no argument, so running it from the wrong directory
|
|
3469
|
-
// is the ordinary user error, and a directory holding nothing but a
|
|
3470
|
-
// build/ folder (every entry of which the walker skips by design) reaches
|
|
3471
|
-
// zero without looking empty to a human.
|
|
3527
|
+
// 主动忽略不影响完整性;错误、跳过或零文件扫描均标记为未完成。
|
|
3472
3528
|
partial: errors.length > 0 || skipped.length > 0 || files.length === 0
|
|
3473
3529
|
};
|
|
3474
3530
|
}
|
|
@@ -3543,8 +3599,7 @@ function renderBaseline(opts) {
|
|
|
3543
3599
|
}
|
|
3544
3600
|
if (stale > 0) {
|
|
3545
3601
|
out.push(
|
|
3546
|
-
//
|
|
3547
|
-
// word. The irregular ones have to be written out.
|
|
3602
|
+
// 不规则复数单独处理。
|
|
3548
3603
|
`${INDENT}${dim(`${stale} baseline ${stale === 1 ? "entry" : "entries"} no longer ${stale === 1 ? "matches" : "match"} anything \u2014 re-run --baseline-write to prune.`)}`
|
|
3549
3604
|
);
|
|
3550
3605
|
}
|
|
@@ -3938,22 +3993,17 @@ function renderHtml(result, opts) {
|
|
|
3938
3993
|
const baselineStale = opts.baselineStale ?? 0;
|
|
3939
3994
|
const { blocking: certain, minor, unsure } = verdictOf(findings);
|
|
3940
3995
|
const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
3941
|
-
//
|
|
3996
|
+
// 未扫描文件时不显示通过结论。
|
|
3942
3997
|
`<div class="verdict warn">No files were scanned — nothing was checked</div>`
|
|
3943
3998
|
) : result.partial ? (
|
|
3944
|
-
//
|
|
3945
|
-
// and a scan that skipped files cannot make one.
|
|
3999
|
+
// 扫描不完整时不得显示正常通过。
|
|
3946
4000
|
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>`
|
|
3947
4001
|
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
3948
|
-
//
|
|
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.
|
|
4002
|
+
// 基线抑制结果时明确说明,避免误报为项目无问题。
|
|
3952
4003
|
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
4004
|
) : 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>`;
|
|
3954
4005
|
const body = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
3955
|
-
//
|
|
3956
|
-
// checks had any input to run against.
|
|
4006
|
+
// 零文件扫描不能显示已完成的检查清单。
|
|
3957
4007
|
`<div class="clean-note">
|
|
3958
4008
|
<p>canship found no files it could read at this path, so none of its checks ran.
|
|
3959
4009
|
<strong>This is not a clean result — it is an empty one.</strong></p>
|
|
@@ -3988,10 +4038,7 @@ function renderHtml(result, opts) {
|
|
|
3988
4038
|
const incomplete = result.partial ? `<div class="incomplete">
|
|
3989
4039
|
<h2>Not everything was checked</h2>
|
|
3990
4040
|
<ul>
|
|
3991
|
-
${//
|
|
3992
|
-
// tree is entirely gitignored still has a git history, and the
|
|
3993
|
-
// history rule reads it. The findings are real; the file-based
|
|
3994
|
-
// checks simply never ran.
|
|
4041
|
+
${// 工作区零文件时仍可能产生 Git 历史结果。
|
|
3995
4042
|
result.filesScanned === 0 ? `<li>no files could be read at this path, so every file-based check was skipped</li>` : ""}
|
|
3996
4043
|
${result.errors.map(
|
|
3997
4044
|
(e) => `<li>the <code>${esc(e.ruleId)}</code> check ${e.kind === "incomplete" ? "did not finish" : "failed"}${e.file ? ` on <code>${esc(e.file)}</code>` : ""} — ${esc(e.message)}</li>`
|
|
@@ -4204,9 +4251,10 @@ function applyBaseline(findings, baseline) {
|
|
|
4204
4251
|
const kept = [];
|
|
4205
4252
|
let suppressed = 0;
|
|
4206
4253
|
for (const f of findings) {
|
|
4207
|
-
const
|
|
4254
|
+
const fp = fingerprintOf(f);
|
|
4255
|
+
const budget = remaining.get(fp) ?? 0;
|
|
4208
4256
|
if (budget > 0) {
|
|
4209
|
-
remaining.set(
|
|
4257
|
+
remaining.set(fp, budget - 1);
|
|
4210
4258
|
suppressed++;
|
|
4211
4259
|
continue;
|
|
4212
4260
|
}
|
|
@@ -4276,8 +4324,7 @@ function rulesOf(findings) {
|
|
|
4276
4324
|
text: [...f.why, ...f.fix.length > 0 ? ["How to fix:", ...f.fix] : []].join("\n")
|
|
4277
4325
|
},
|
|
4278
4326
|
properties: {
|
|
4279
|
-
//
|
|
4280
|
-
// than being mangled into one of the three levels above.
|
|
4327
|
+
// 工具专属严重度和置信度保存在扩展属性中。
|
|
4281
4328
|
"canship-severity": f.severity,
|
|
4282
4329
|
"canship-confidence": f.confidence
|
|
4283
4330
|
},
|
|
@@ -4289,15 +4336,12 @@ function resultsOf(findings) {
|
|
|
4289
4336
|
ruleId: f.ruleId,
|
|
4290
4337
|
level: levelOf(f),
|
|
4291
4338
|
message: { text: f.title },
|
|
4292
|
-
//
|
|
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.
|
|
4339
|
+
// 无可定位文件时不构造虚假位置。
|
|
4296
4340
|
locations: f.file === null ? [] : [
|
|
4297
4341
|
{
|
|
4298
4342
|
physicalLocation: {
|
|
4299
|
-
//
|
|
4300
|
-
artifactLocation: { uri: f.file },
|
|
4343
|
+
// 按路径段进行 URI 编码,保留目录分隔符。
|
|
4344
|
+
artifactLocation: { uri: f.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
|
|
4301
4345
|
...f.line === null ? {} : { region: { startLine: f.line } }
|
|
4302
4346
|
}
|
|
4303
4347
|
}
|
|
@@ -4308,6 +4352,10 @@ function resultsOf(findings) {
|
|
|
4308
4352
|
function renderSarif(result, opts) {
|
|
4309
4353
|
const { findings } = result;
|
|
4310
4354
|
const notifications = [
|
|
4355
|
+
...result.skipped.map((item) => ({
|
|
4356
|
+
level: "warning",
|
|
4357
|
+
message: { text: `${item.path}: ${item.reason}${item.detail ? ` \u2014 ${item.detail}` : ""}` }
|
|
4358
|
+
})),
|
|
4311
4359
|
...result.errors.map((e) => ({
|
|
4312
4360
|
level: e.kind === "crashed" ? "error" : "warning",
|
|
4313
4361
|
message: { text: `${e.ruleId}: ${e.message}` }
|
|
@@ -4330,9 +4378,7 @@ function renderSarif(result, opts) {
|
|
|
4330
4378
|
results: resultsOf(findings),
|
|
4331
4379
|
invocations: [
|
|
4332
4380
|
{
|
|
4333
|
-
//
|
|
4334
|
-
// about whether findings exist — a scan that finds problems and
|
|
4335
|
-
// completes was a successful invocation.
|
|
4381
|
+
// 该字段表示执行是否完整,与是否发现问题无关。
|
|
4336
4382
|
executionSuccessful: !result.partial,
|
|
4337
4383
|
...notifications.length > 0 ? { toolExecutionNotifications: notifications } : {}
|
|
4338
4384
|
}
|
|
@@ -4344,6 +4390,29 @@ function renderSarif(result, opts) {
|
|
|
4344
4390
|
`;
|
|
4345
4391
|
}
|
|
4346
4392
|
|
|
4393
|
+
// src/report/json.ts
|
|
4394
|
+
function createJsonReport(result, options) {
|
|
4395
|
+
return {
|
|
4396
|
+
schemaVersion: 1,
|
|
4397
|
+
version: options.version,
|
|
4398
|
+
root: options.root,
|
|
4399
|
+
filesScanned: result.filesScanned,
|
|
4400
|
+
durationMs: result.durationMs,
|
|
4401
|
+
partial: result.partial,
|
|
4402
|
+
errors: result.errors,
|
|
4403
|
+
skipped: result.skipped,
|
|
4404
|
+
ignored: result.ignored,
|
|
4405
|
+
ignoredFindings: result.ignoredFindings,
|
|
4406
|
+
ruleSelection: result.ruleSelection,
|
|
4407
|
+
vendored: result.vendored,
|
|
4408
|
+
hiddenLikely: options.hiddenLikely,
|
|
4409
|
+
baselineSuppressed: options.baselineSuppressed,
|
|
4410
|
+
baselineStale: options.baselineStale,
|
|
4411
|
+
excerptsOmitted: options.excerptsOmitted ?? false,
|
|
4412
|
+
findings: result.findings
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4347
4416
|
// src/config.ts
|
|
4348
4417
|
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
|
|
4349
4418
|
import { join as join3 } from "path";
|
|
@@ -4434,12 +4503,137 @@ function loadConfig(root) {
|
|
|
4434
4503
|
return { config: parseConfig(text, path), path };
|
|
4435
4504
|
}
|
|
4436
4505
|
|
|
4506
|
+
// src/rules/catalog.ts
|
|
4507
|
+
var rule = (id, name, severity, confidence, scope, limitation) => ({ id, name, severity, confidence, scope, limitation, reportsFindings: true });
|
|
4508
|
+
var RULE_CATALOG = [
|
|
4509
|
+
rule(
|
|
4510
|
+
"api/admin-db-access-without-auth",
|
|
4511
|
+
"Admin database access without a recognized auth guard",
|
|
4512
|
+
"P0",
|
|
4513
|
+
"certain",
|
|
4514
|
+
"Next.js App/Pages API handlers, including workspace applications.",
|
|
4515
|
+
"Syntactic guards only; runtime authentication and business authorization are not verified."
|
|
4516
|
+
),
|
|
4517
|
+
rule(
|
|
4518
|
+
"api/db-write-without-auth",
|
|
4519
|
+
"Database write without a recognized auth guard",
|
|
4520
|
+
"P1",
|
|
4521
|
+
"likely",
|
|
4522
|
+
"Next.js App/Pages API handlers.",
|
|
4523
|
+
"Session-scoped clients and indirect guards may need manual review."
|
|
4524
|
+
),
|
|
4525
|
+
rule(
|
|
4526
|
+
"cors/reflected-origin-with-credentials",
|
|
4527
|
+
"Reflected origin with credentials",
|
|
4528
|
+
"P1",
|
|
4529
|
+
"certain",
|
|
4530
|
+
"Response headers and cors middleware options.",
|
|
4531
|
+
"Static pairing does not prove the deployed policy or cookie behavior."
|
|
4532
|
+
),
|
|
4533
|
+
rule(
|
|
4534
|
+
"cors/wildcard-with-credentials",
|
|
4535
|
+
"Wildcard origin with credentials",
|
|
4536
|
+
"P2",
|
|
4537
|
+
"certain",
|
|
4538
|
+
"Response headers and cors middleware options.",
|
|
4539
|
+
"Browsers reject this combination; it is not proof of data exposure."
|
|
4540
|
+
),
|
|
4541
|
+
rule(
|
|
4542
|
+
"exposure/private-name-in-public-env",
|
|
4543
|
+
"Private-looking name with a public env prefix",
|
|
4544
|
+
"P0",
|
|
4545
|
+
"likely",
|
|
4546
|
+
"Environment files and source references using recognized public prefixes.",
|
|
4547
|
+
"A variable name is not proof that its value is a secret."
|
|
4548
|
+
),
|
|
4549
|
+
rule(
|
|
4550
|
+
"exposure/secret-in-public-env",
|
|
4551
|
+
"Recognized secret in a public env variable",
|
|
4552
|
+
"P0",
|
|
4553
|
+
"certain",
|
|
4554
|
+
"Environment files using recognized public prefixes.",
|
|
4555
|
+
"Credential validity and actual deployment are not verified."
|
|
4556
|
+
),
|
|
4557
|
+
rule(
|
|
4558
|
+
"exposure/supabase-service-role-in-client",
|
|
4559
|
+
"Supabase admin credential exposed in source or public env",
|
|
4560
|
+
"P0",
|
|
4561
|
+
"certain",
|
|
4562
|
+
"Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
|
|
4563
|
+
"Source presence is observable; actual browser delivery and key validity are not verified."
|
|
4564
|
+
),
|
|
4565
|
+
rule(
|
|
4566
|
+
"firebase/open-rules",
|
|
4567
|
+
"Unconditional Firebase access",
|
|
4568
|
+
"P1",
|
|
4569
|
+
"varies",
|
|
4570
|
+
"Firebase .rules files: unconditional writes are certain; public reads require review.",
|
|
4571
|
+
"Public reads may be intentional; runtime rules and business authorization are not verified."
|
|
4572
|
+
),
|
|
4573
|
+
rule(
|
|
4574
|
+
"firebase/test-mode-rules",
|
|
4575
|
+
"Date-based Firebase test rules",
|
|
4576
|
+
"P1",
|
|
4577
|
+
"certain",
|
|
4578
|
+
"Firebase .rules files using a fixed timestamp cutoff.",
|
|
4579
|
+
"Expired rules may deny access; deployment state is not verified."
|
|
4580
|
+
),
|
|
4581
|
+
rule(
|
|
4582
|
+
"gitleak/env-in-history",
|
|
4583
|
+
"Private env values in local Git history",
|
|
4584
|
+
"P0",
|
|
4585
|
+
"varies",
|
|
4586
|
+
"Relevant historical versions of environment files in the local repository.",
|
|
4587
|
+
"History and resource limits apply; remote refs not available locally are not checked."
|
|
4588
|
+
),
|
|
4589
|
+
rule(
|
|
4590
|
+
"gitleak/env-tracked",
|
|
4591
|
+
"Private env values tracked by Git",
|
|
4592
|
+
"P0",
|
|
4593
|
+
"varies",
|
|
4594
|
+
"Tracked environment files; templates and public-only values are treated separately.",
|
|
4595
|
+
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
4596
|
+
),
|
|
4597
|
+
rule(
|
|
4598
|
+
"supabase/rls-not-enabled",
|
|
4599
|
+
"Table without RLS in migrations",
|
|
4600
|
+
"P1",
|
|
4601
|
+
"certain",
|
|
4602
|
+
"Supabase SQL migrations, replayed by application scope.",
|
|
4603
|
+
"Migration state is not deployed database state; grants and policy correctness are outside this check."
|
|
4604
|
+
),
|
|
4605
|
+
...SECRET_PATTERNS.map((pattern) => ({
|
|
4606
|
+
...rule(
|
|
4607
|
+
`secrets/hardcoded/${pattern.id}`,
|
|
4608
|
+
pattern.name,
|
|
4609
|
+
"P0",
|
|
4610
|
+
"certain",
|
|
4611
|
+
"Recognized credential formats in scanned text files.",
|
|
4612
|
+
"Validity, revocation, and use are not checked; example contexts lower confidence."
|
|
4613
|
+
),
|
|
4614
|
+
reportsFindings: !pattern.publicByDesign,
|
|
4615
|
+
...pattern.publicByDesign ? { limitation: "Treated as a public identifier; this format alone does not produce a finding." } : {}
|
|
4616
|
+
}))
|
|
4617
|
+
];
|
|
4618
|
+
function renderRuleCatalog() {
|
|
4619
|
+
return "canship rules\n\n" + RULE_CATALOG.map(
|
|
4620
|
+
(item) => `${item.id}
|
|
4621
|
+
${item.name}
|
|
4622
|
+
${item.reportsFindings ? `${item.severity}; ${item.confidence}` : "Public identifier; not reported"}
|
|
4623
|
+
Scope: ${item.scope}
|
|
4624
|
+
Limit: ${item.limitation}`
|
|
4625
|
+
).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4437
4628
|
// src/cli.ts
|
|
4438
|
-
var VERSION = true ? "0.
|
|
4629
|
+
var VERSION = true ? "0.3.0" : "0.0.0-dev";
|
|
4630
|
+
function finish(code) {
|
|
4631
|
+
process.exitCode = code;
|
|
4632
|
+
}
|
|
4633
|
+
var ArgumentError = class extends Error {
|
|
4634
|
+
};
|
|
4439
4635
|
function argumentError(message) {
|
|
4440
|
-
|
|
4441
|
-
`);
|
|
4442
|
-
process.exit(3);
|
|
4636
|
+
throw new ArgumentError(cleanForOutput(message));
|
|
4443
4637
|
}
|
|
4444
4638
|
function optionalValue(arg, name, fallback) {
|
|
4445
4639
|
if (arg === name) return fallback;
|
|
@@ -4498,7 +4692,9 @@ function parseArgs(argv) {
|
|
|
4498
4692
|
sarif: null,
|
|
4499
4693
|
noConfig: false,
|
|
4500
4694
|
help: false,
|
|
4501
|
-
version: false
|
|
4695
|
+
version: false,
|
|
4696
|
+
listRules: false,
|
|
4697
|
+
noExcerpts: false
|
|
4502
4698
|
};
|
|
4503
4699
|
const positional = [];
|
|
4504
4700
|
for (const arg of argv) {
|
|
@@ -4563,6 +4759,12 @@ function parseArgs(argv) {
|
|
|
4563
4759
|
case "--no-config":
|
|
4564
4760
|
args.noConfig = true;
|
|
4565
4761
|
break;
|
|
4762
|
+
case "--list-rules":
|
|
4763
|
+
args.listRules = true;
|
|
4764
|
+
break;
|
|
4765
|
+
case "--no-excerpts":
|
|
4766
|
+
args.noExcerpts = true;
|
|
4767
|
+
break;
|
|
4566
4768
|
case "--help":
|
|
4567
4769
|
case "-h":
|
|
4568
4770
|
args.help = true;
|
|
@@ -4600,11 +4802,13 @@ var HELP = `
|
|
|
4600
4802
|
--baseline[=F] Hide findings already recorded in F, so only new
|
|
4601
4803
|
ones are reported (default ${DEFAULT_BASELINE_PATH})
|
|
4602
4804
|
--baseline-write[=F] Record the current findings as a new baseline and exit
|
|
4603
|
-
--only=IDS
|
|
4604
|
-
--skip=IDS
|
|
4805
|
+
--only=IDS Run matching rules (comma-separated, repeatable)
|
|
4806
|
+
--skip=IDS Exclude matching rules
|
|
4605
4807
|
--sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
|
|
4606
4808
|
(default canship.sarif)
|
|
4607
4809
|
--no-config Ignore canship.config.json in the scanned directory
|
|
4810
|
+
--list-rules List rule IDs, scope, and limits; add --json for structured output
|
|
4811
|
+
--no-excerpts Omit source excerpts from every report; paths and descriptions remain
|
|
4608
4812
|
-h, --help Show this help
|
|
4609
4813
|
-v, --version Show version
|
|
4610
4814
|
|
|
@@ -4627,12 +4831,20 @@ async function main() {
|
|
|
4627
4831
|
if (args.help) {
|
|
4628
4832
|
process.stdout.write(`${HELP}
|
|
4629
4833
|
`);
|
|
4630
|
-
return
|
|
4834
|
+
return finish(0);
|
|
4631
4835
|
}
|
|
4632
4836
|
if (args.version) {
|
|
4633
4837
|
process.stdout.write(`${VERSION}
|
|
4634
4838
|
`);
|
|
4635
|
-
return
|
|
4839
|
+
return finish(0);
|
|
4840
|
+
}
|
|
4841
|
+
if (args.listRules) {
|
|
4842
|
+
if (process.argv.slice(2).some((arg) => arg !== "--list-rules" && arg !== "--json")) {
|
|
4843
|
+
argumentError("--list-rules only supports --json; scan options and paths cannot be combined with it");
|
|
4844
|
+
}
|
|
4845
|
+
process.stdout.write(args.json ? `${JSON.stringify({ schemaVersion: 1, kind: "rule-catalog", version: VERSION, rules: RULE_CATALOG }, null, 2)}
|
|
4846
|
+
` : renderRuleCatalog());
|
|
4847
|
+
return;
|
|
4636
4848
|
}
|
|
4637
4849
|
if (args.json && args.fixPrompt) {
|
|
4638
4850
|
argumentError("--json and --fix-prompt are mutually exclusive");
|
|
@@ -4643,7 +4855,7 @@ async function main() {
|
|
|
4643
4855
|
if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
|
|
4644
4856
|
process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
|
|
4645
4857
|
`);
|
|
4646
|
-
return
|
|
4858
|
+
return finish(3);
|
|
4647
4859
|
}
|
|
4648
4860
|
let config;
|
|
4649
4861
|
try {
|
|
@@ -4652,7 +4864,7 @@ async function main() {
|
|
|
4652
4864
|
if (err instanceof ConfigError) {
|
|
4653
4865
|
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4654
4866
|
`);
|
|
4655
|
-
return
|
|
4867
|
+
return finish(3);
|
|
4656
4868
|
}
|
|
4657
4869
|
throw err;
|
|
4658
4870
|
}
|
|
@@ -4687,7 +4899,7 @@ async function main() {
|
|
|
4687
4899
|
${cleanForOutput(String(err))}
|
|
4688
4900
|
`
|
|
4689
4901
|
);
|
|
4690
|
-
return
|
|
4902
|
+
return finish(3);
|
|
4691
4903
|
}
|
|
4692
4904
|
const accepted = scanned.findings.length;
|
|
4693
4905
|
process.stdout.write(
|
|
@@ -4714,7 +4926,7 @@ ${cleanForOutput(String(err))}
|
|
|
4714
4926
|
`
|
|
4715
4927
|
);
|
|
4716
4928
|
}
|
|
4717
|
-
return
|
|
4929
|
+
return finish(0);
|
|
4718
4930
|
}
|
|
4719
4931
|
let baselineSuppressed = 0;
|
|
4720
4932
|
let baselineStale = 0;
|
|
@@ -4730,12 +4942,13 @@ ${cleanForOutput(String(err))}
|
|
|
4730
4942
|
if (err instanceof BaselineError) {
|
|
4731
4943
|
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4732
4944
|
`);
|
|
4733
|
-
return
|
|
4945
|
+
return finish(3);
|
|
4734
4946
|
}
|
|
4735
4947
|
throw err;
|
|
4736
4948
|
}
|
|
4737
4949
|
}
|
|
4738
4950
|
const displayRoot = cleanForOutput(args.root);
|
|
4951
|
+
if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
|
|
4739
4952
|
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
4740
4953
|
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
4741
4954
|
if (args.fixPrompt) {
|
|
@@ -4755,31 +4968,14 @@ ${cleanForOutput(String(err))}
|
|
|
4755
4968
|
} else if (args.json) {
|
|
4756
4969
|
process.stdout.write(
|
|
4757
4970
|
`${JSON.stringify(
|
|
4758
|
-
{
|
|
4971
|
+
createJsonReport({ ...result, findings: shown }, {
|
|
4759
4972
|
version: VERSION,
|
|
4760
4973
|
root: displayRoot,
|
|
4761
|
-
filesScanned: result.filesScanned,
|
|
4762
|
-
durationMs: result.durationMs,
|
|
4763
|
-
// Machine consumers need the same distinction humans get: an empty
|
|
4764
|
-
// findings array from a partial scan is not a pass.
|
|
4765
|
-
partial: result.partial,
|
|
4766
|
-
errors: result.errors,
|
|
4767
|
-
skipped: result.skipped,
|
|
4768
|
-
ignored: result.ignored,
|
|
4769
|
-
ignoredFindings: result.ignoredFindings,
|
|
4770
|
-
ruleSelection: result.ruleSelection,
|
|
4771
|
-
vendored: result.vendored,
|
|
4772
|
-
// The default view hides the detail, never the fact. A machine reading this
|
|
4773
|
-
// must not see "no findings" while lower-confidence ones exist.
|
|
4774
4974
|
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
4975
|
baselineSuppressed,
|
|
4780
4976
|
baselineStale,
|
|
4781
|
-
|
|
4782
|
-
},
|
|
4977
|
+
excerptsOmitted: args.noExcerpts
|
|
4978
|
+
}),
|
|
4783
4979
|
null,
|
|
4784
4980
|
2
|
|
4785
4981
|
)}
|
|
@@ -4828,7 +5024,7 @@ ${cleanForOutput(String(err))}
|
|
|
4828
5024
|
${cleanForOutput(String(err))}
|
|
4829
5025
|
`
|
|
4830
5026
|
);
|
|
4831
|
-
return
|
|
5027
|
+
return finish(3);
|
|
4832
5028
|
}
|
|
4833
5029
|
}
|
|
4834
5030
|
if (args.report) {
|
|
@@ -4860,17 +5056,19 @@ ${cleanForOutput(String(err))}
|
|
|
4860
5056
|
${cleanForOutput(String(err))}
|
|
4861
5057
|
`
|
|
4862
5058
|
);
|
|
4863
|
-
return
|
|
5059
|
+
return finish(3);
|
|
4864
5060
|
}
|
|
4865
5061
|
}
|
|
4866
|
-
if (verdictOf(result.findings).blocking > 0) return
|
|
4867
|
-
if (result.findings.length > 0) return
|
|
4868
|
-
if (result.partial && !bestEffort) return
|
|
4869
|
-
return
|
|
5062
|
+
if (verdictOf(result.findings).blocking > 0) return finish(1);
|
|
5063
|
+
if (result.findings.length > 0) return finish(2);
|
|
5064
|
+
if (result.partial && !bestEffort) return finish(3);
|
|
5065
|
+
return finish(0);
|
|
4870
5066
|
}
|
|
4871
5067
|
main().catch((err) => {
|
|
4872
|
-
process.stderr.write(
|
|
5068
|
+
if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
|
|
5069
|
+
`);
|
|
5070
|
+
else process.stderr.write(`${red("canship: unexpected error")}
|
|
4873
5071
|
${cleanForOutput(String(err))}
|
|
4874
5072
|
`);
|
|
4875
|
-
|
|
5073
|
+
finish(3);
|
|
4876
5074
|
});
|