break-anything 0.0.0-stage → 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/DISCLAIMER.md +44 -0
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/bin/brk.js +187 -0
- package/docs/assets/break-flow.svg +54 -0
- package/docs/assets/hero.gif +0 -0
- package/docs/hero-gif.md +51 -0
- package/docs/installation.md +73 -0
- package/docs/launch.md +70 -0
- package/docs/mcp-tools.md +205 -0
- package/docs/readme/README_zh.md +172 -0
- package/docs/roadmap.md +51 -0
- package/package.json +48 -4
- package/src/epss.js +28 -0
- package/src/http.js +44 -0
- package/src/image.js +114 -0
- package/src/lockfiles.js +118 -0
- package/src/mcp.js +121 -0
- package/src/osv.js +92 -0
- package/src/reachability.js +66 -0
- package/src/report.js +225 -0
- package/src/sast.js +128 -0
- package/src/scan.js +93 -0
- package/src/secrets.js +160 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Human-readable terminal reports. No third-party UI deps.
|
|
2
|
+
// English by default; set BRK_LANG=zh for Chinese labels.
|
|
3
|
+
|
|
4
|
+
const LANG = (process.env.BRK_LANG || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
5
|
+
|
|
6
|
+
const C = {
|
|
7
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
8
|
+
red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", cyan: "\x1b[36m", gray: "\x1b[90m",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SEV_COLOR = { CRITICAL: C.red, HIGH: C.red, MEDIUM: C.yellow, LOW: C.cyan, UNKNOWN: C.gray };
|
|
12
|
+
|
|
13
|
+
const T = {
|
|
14
|
+
en: {
|
|
15
|
+
depsTitle: "Break Anything · dependency scan",
|
|
16
|
+
depsTarget: (t, n, s) => `target ${t} · ${n} packages scanned · ${s}`,
|
|
17
|
+
depsClean: "✓ No matching vulnerabilities in the OSV database.",
|
|
18
|
+
cleanNote: " Note: this means \"none found\", not \"safe\".",
|
|
19
|
+
reach: { imported: "reachable", direct: "direct dep", transitive: "transitive", unknown: "—" },
|
|
20
|
+
epss: (v) => (v != null ? `EPSS ${(v * 100).toFixed(1)}%` : "EPSS —"),
|
|
21
|
+
fix: (v) => `fix → ${v}`,
|
|
22
|
+
noFix: "no fix available",
|
|
23
|
+
summary: "summary",
|
|
24
|
+
findings: "findings",
|
|
25
|
+
reachable: "reachable",
|
|
26
|
+
skipped: (n) => `(${n} packages failed to query, skipped)`,
|
|
27
|
+
heuristic: "Reachability is a heuristic (is it imported), not proof the vulnerable code runs.",
|
|
28
|
+
secretsTitle: "Break Anything · secret scan",
|
|
29
|
+
secretsTarget: (t, on) => `target ${t} · history scan ${on ? "on" : "off"}`,
|
|
30
|
+
secretsClean: "✓ No leaked secrets found.",
|
|
31
|
+
history: "git history",
|
|
32
|
+
inHistory: "in history",
|
|
33
|
+
rotate: "Secrets in history may still be live even after deletion — rotate them, don't just delete the commit.",
|
|
34
|
+
imageTitle: "Break Anything · image scan",
|
|
35
|
+
imageClean: "✓ No known vulnerabilities in the image.",
|
|
36
|
+
more: (n) => `… ${n} more, use --json for the full list`,
|
|
37
|
+
fixable: "fixable",
|
|
38
|
+
sastTitle: "Break Anything · static code scan",
|
|
39
|
+
sastClean: "✓ No issues found by the static-analysis rules.",
|
|
40
|
+
},
|
|
41
|
+
zh: {
|
|
42
|
+
depsTitle: "Break Anything · 依赖扫描",
|
|
43
|
+
depsTarget: (t, n, s) => `目标 ${t} · 已扫 ${n} 个包 · ${s}`,
|
|
44
|
+
depsClean: "✓ 未在已知漏洞库(OSV)中发现匹配的漏洞。",
|
|
45
|
+
cleanNote: " 注意:这表示\"未发现\",不等于\"安全\"。",
|
|
46
|
+
reach: { imported: "可达(已被引用)", direct: "直接依赖", transitive: "传递依赖", unknown: "—" },
|
|
47
|
+
epss: (v) => (v != null ? `EPSS ${(v * 100).toFixed(1)}%` : "EPSS —"),
|
|
48
|
+
fix: (v) => `修复 → ${v}`,
|
|
49
|
+
noFix: "无修复版本",
|
|
50
|
+
summary: "小结",
|
|
51
|
+
findings: "条",
|
|
52
|
+
reachable: "可达",
|
|
53
|
+
skipped: (n) => `(${n} 个包查询失败,已跳过)`,
|
|
54
|
+
heuristic: "可达性为启发式(判断是否被引用),非漏洞函数被调用的证明。",
|
|
55
|
+
secretsTitle: "Break Anything · 密钥扫描",
|
|
56
|
+
secretsTarget: (t, on) => `目标 ${t} · 历史扫描 ${on ? "开" : "关"}`,
|
|
57
|
+
secretsClean: "✓ 未发现疑似泄露的密钥。",
|
|
58
|
+
history: "git 历史",
|
|
59
|
+
inHistory: "历史中",
|
|
60
|
+
rotate: "历史里的密钥即使已删除也可能仍然有效 —— 请轮换(rotate),不要只删提交。",
|
|
61
|
+
imageTitle: "Break Anything · 镜像扫描",
|
|
62
|
+
imageClean: "✓ 未在镜像中发现已知漏洞。",
|
|
63
|
+
more: (n) => `… 还有 ${n} 条,用 --json 查看全部`,
|
|
64
|
+
fixable: "可修复",
|
|
65
|
+
sastTitle: "Break Anything · 静态代码扫描",
|
|
66
|
+
sastClean: "✓ 静态分析规则未发现问题。",
|
|
67
|
+
},
|
|
68
|
+
}[LANG];
|
|
69
|
+
|
|
70
|
+
function palette(color) {
|
|
71
|
+
return color ? C : new Proxy({}, { get: () => "" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function renderHuman(result, { color = true, top } = {}) {
|
|
75
|
+
const c = palette(color);
|
|
76
|
+
const lines = [];
|
|
77
|
+
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
lines.push(`${c.yellow}${result.message}${c.reset}`);
|
|
80
|
+
lines.push(`${c.gray}${result.target}${c.reset}`);
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const srcDesc = result.sources.map((s) => `${s.ecosystem}:${s.packages}`).join(" ");
|
|
85
|
+
lines.push(`${c.bold}${T.depsTitle}${c.reset}`);
|
|
86
|
+
lines.push(`${c.gray}${T.depsTarget(result.target, result.scanned, srcDesc)}${c.reset}`);
|
|
87
|
+
lines.push("");
|
|
88
|
+
|
|
89
|
+
if (!result.findings.length) {
|
|
90
|
+
lines.push(`${c.green}${T.depsClean}${c.reset}`);
|
|
91
|
+
lines.push(`${c.gray}${T.cleanNote}${c.reset}`);
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const shown = top && result.findings.length > top ? result.findings.slice(0, top) : result.findings;
|
|
96
|
+
for (const f of shown) {
|
|
97
|
+
const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
|
|
98
|
+
const idLabel = f.cve || f.id;
|
|
99
|
+
const fix = f.fixedVersions.length
|
|
100
|
+
? `${c.green}${T.fix(f.fixedVersions.join(", "))}${c.reset}`
|
|
101
|
+
: `${c.red}${T.noFix}${c.reset}`;
|
|
102
|
+
lines.push(`${sev} ${c.bold}${f.package}@${f.version}${c.reset} ${c.gray}(${f.ecosystem})${c.reset}`);
|
|
103
|
+
lines.push(` ${c.cyan}${idLabel}${c.reset} · ${T.reach[f.reachability]} · ${T.epss(f.epss)} · ${fix}`);
|
|
104
|
+
if (f.summary) lines.push(` ${c.gray}${f.summary.replace(/\s+/g, " ").slice(0, 120)}${c.reset}`);
|
|
105
|
+
lines.push("");
|
|
106
|
+
}
|
|
107
|
+
if (shown.length < result.findings.length) {
|
|
108
|
+
lines.push(`${c.gray}${T.more(result.findings.length - shown.length)}${c.reset}`);
|
|
109
|
+
lines.push("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const s = result.summary;
|
|
113
|
+
const sevParts = Object.entries(s.bySeverity)
|
|
114
|
+
.sort((a, b) => sevOrder(b[0]) - sevOrder(a[0]))
|
|
115
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
116
|
+
.join(" ");
|
|
117
|
+
lines.push(`${c.bold}${T.summary}${c.reset} ${s.total} ${T.findings} · ${s.reachable} ${T.reachable} · ${sevParts}`);
|
|
118
|
+
if (result.errors?.length) lines.push(`${c.gray}${T.skipped(result.errors.length)}${c.reset}`);
|
|
119
|
+
lines.push(`${c.gray}${T.heuristic}${c.reset}`);
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sevOrder(k) {
|
|
124
|
+
return { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 }[k] ?? 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- secrets ----
|
|
128
|
+
export function renderSecrets(result, { color = true } = {}) {
|
|
129
|
+
const c = palette(color);
|
|
130
|
+
const lines = [];
|
|
131
|
+
lines.push(`${c.bold}${T.secretsTitle}${c.reset}`);
|
|
132
|
+
lines.push(`${c.gray}${T.secretsTarget(result.target, result.historyScanned)}${c.reset}`);
|
|
133
|
+
lines.push("");
|
|
134
|
+
|
|
135
|
+
if (!result.findings.length) {
|
|
136
|
+
lines.push(`${c.green}${T.secretsClean}${c.reset}`);
|
|
137
|
+
lines.push(`${c.gray}${T.cleanNote}${c.reset}`);
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const f of result.findings) {
|
|
142
|
+
const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
|
|
143
|
+
const loc = f.source === "history"
|
|
144
|
+
? `${c.yellow}${T.history}${c.reset} ${c.gray}@${f.commit}${c.reset}`
|
|
145
|
+
: `${f.location}${f.line ? `:${f.line}` : ""}`;
|
|
146
|
+
lines.push(`${sev} ${c.bold}${f.rule}${c.reset}`);
|
|
147
|
+
lines.push(` ${loc} · ${c.gray}${f.match}${c.reset}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
const s = result.summary;
|
|
151
|
+
const parts = Object.entries(s.bySeverity).sort((a, b) => sevOrder(b[0]) - sevOrder(a[0])).map(([k, v]) => `${k} ${v}`).join(" ");
|
|
152
|
+
lines.push(`${c.bold}${T.summary}${c.reset} ${s.total} ${T.findings} · ${s.inHistory} ${T.inHistory} · ${parts}`);
|
|
153
|
+
if (s.inHistory) lines.push(`${c.yellow}${T.rotate}${c.reset}`);
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- image ----
|
|
158
|
+
export function renderImage(result, { color = true } = {}) {
|
|
159
|
+
const c = palette(color);
|
|
160
|
+
const lines = [];
|
|
161
|
+
lines.push(`${c.bold}${T.imageTitle}${c.reset} ${c.gray}(${result.engine})${c.reset}`);
|
|
162
|
+
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push(`${c.yellow}${result.message}${c.reset}`);
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push(`${c.gray}${result.target}${c.reset}`);
|
|
170
|
+
lines.push("");
|
|
171
|
+
if (!result.findings.length) {
|
|
172
|
+
lines.push(`${c.green}${T.imageClean}${c.reset}`);
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
175
|
+
for (const f of result.findings.slice(0, 40)) {
|
|
176
|
+
const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
|
|
177
|
+
const fix = f.fixedVersions.length ? `${c.green}${T.fix(f.fixedVersions.join(", "))}${c.reset}` : `${c.red}${T.noFix}${c.reset}`;
|
|
178
|
+
lines.push(`${sev} ${c.bold}${f.package}@${f.version}${c.reset} ${c.cyan}${f.cve || f.id}${c.reset} ${fix} ${c.gray}[${f.class}]${c.reset}`);
|
|
179
|
+
}
|
|
180
|
+
if (result.findings.length > 40) lines.push(`${c.gray}${T.more(result.findings.length - 40)}${c.reset}`);
|
|
181
|
+
lines.push("");
|
|
182
|
+
const s = result.summary;
|
|
183
|
+
const parts = Object.entries(s.bySeverity).sort((a, b) => sevOrder(b[0]) - sevOrder(a[0])).map(([k, v]) => `${k} ${v}`).join(" ");
|
|
184
|
+
lines.push(`${c.bold}${T.summary}${c.reset} ${s.total} ${T.findings} · ${s.fixable} ${T.fixable} · ${parts}`);
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- SAST ----
|
|
189
|
+
export function renderSast(result, { color = true, top } = {}) {
|
|
190
|
+
const c = palette(color);
|
|
191
|
+
const lines = [];
|
|
192
|
+
lines.push(`${c.bold}${T.sastTitle}${c.reset} ${c.gray}(${result.engine})${c.reset}`);
|
|
193
|
+
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push(`${c.yellow}${result.message}${c.reset}`);
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lines.push(`${c.gray}${result.target}${c.reset}`);
|
|
201
|
+
lines.push("");
|
|
202
|
+
if (!result.findings.length) {
|
|
203
|
+
lines.push(`${c.green}${T.sastClean}${c.reset}`);
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const shown = top && result.findings.length > top ? result.findings.slice(0, top) : result.findings;
|
|
208
|
+
for (const f of shown) {
|
|
209
|
+
const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
|
|
210
|
+
const tags = [f.cwe[0], f.owasp[0]].filter(Boolean).join(" · ");
|
|
211
|
+
lines.push(`${sev} ${c.bold}${f.rule}${c.reset} ${c.gray}${tags}${c.reset}`);
|
|
212
|
+
lines.push(` ${c.cyan}${f.location}${c.reset} ${c.gray}${f.message.slice(0, 100)}${c.reset}`);
|
|
213
|
+
if (f.snippet) lines.push(` ${c.gray}${f.snippet.slice(0, 90)}${c.reset}`);
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
if (shown.length < result.findings.length) {
|
|
217
|
+
lines.push(`${c.gray}${T.more(result.findings.length - shown.length)}${c.reset}`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const s = result.summary;
|
|
222
|
+
const parts = Object.entries(s.bySeverity).sort((a, b) => sevOrder(b[0]) - sevOrder(a[0])).map(([k, v]) => `${k} ${v}`).join(" ");
|
|
223
|
+
lines.push(`${c.bold}${T.summary}${c.reset} ${s.total} ${T.findings} · ${parts}`);
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
package/src/sast.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// 静态代码分析(SAST):封装 Semgrep。装了 semgrep 就能用,没装给安装提示。
|
|
4
|
+
// 把 Semgrep 的 JSON 归一化成 Break Anything 统一的发现结构。
|
|
5
|
+
// 默认规则集 p/security-audit(首次运行会从 Semgrep registry 拉取并缓存,需联网)。
|
|
6
|
+
|
|
7
|
+
export async function scanSast(path, { config = "p/security-audit" } = {}) {
|
|
8
|
+
const semgrep = await which("semgrep");
|
|
9
|
+
if (!semgrep) {
|
|
10
|
+
return {
|
|
11
|
+
target: path,
|
|
12
|
+
ok: false,
|
|
13
|
+
engine: "semgrep",
|
|
14
|
+
message:
|
|
15
|
+
"未找到 semgrep。SAST 依赖 Semgrep。安装:\n" +
|
|
16
|
+
" pipx install semgrep (或 brew install semgrep)\n" +
|
|
17
|
+
" 文档:https://semgrep.dev/docs/getting-started/",
|
|
18
|
+
findings: [],
|
|
19
|
+
summary: { total: 0, bySeverity: {} },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = await run(semgrep, ["--json", "--quiet", "--config", config, "--timeout", "0", path]);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
target: path,
|
|
29
|
+
ok: false,
|
|
30
|
+
engine: "semgrep",
|
|
31
|
+
message: `semgrep 执行失败:${err instanceof Error ? err.message : String(err)}`,
|
|
32
|
+
findings: [],
|
|
33
|
+
summary: { total: 0, bySeverity: {} },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return normalizeSemgrep(raw, path);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 导出以便单测:把 semgrep JSON 文本归一化
|
|
40
|
+
export function normalizeSemgrep(rawJson, target) {
|
|
41
|
+
let data;
|
|
42
|
+
try { data = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; }
|
|
43
|
+
catch { return { target, ok: false, engine: "semgrep", message: "无法解析 semgrep 输出", findings: [], summary: { total: 0, bySeverity: {} } }; }
|
|
44
|
+
|
|
45
|
+
const findings = [];
|
|
46
|
+
for (const r of data.results || []) {
|
|
47
|
+
const sev = mapSeverity(r.extra?.severity);
|
|
48
|
+
const meta = r.extra?.metadata || {};
|
|
49
|
+
findings.push({
|
|
50
|
+
rule: shortRule(r.check_id),
|
|
51
|
+
id: r.check_id,
|
|
52
|
+
severity: sev,
|
|
53
|
+
location: `${r.path}:${r.start?.line ?? "?"}`,
|
|
54
|
+
message: (r.extra?.message || "").replace(/\s+/g, " ").trim(),
|
|
55
|
+
cwe: toArray(meta.cwe),
|
|
56
|
+
owasp: toArray(meta.owasp),
|
|
57
|
+
confidence: meta.confidence || null,
|
|
58
|
+
snippet: (r.extra?.lines || "").trim().slice(0, 200),
|
|
59
|
+
references: toArray(meta.references).slice(0, 3),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
findings.sort((a, b) => sevRank(b.severity) - sevRank(a.severity));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
target,
|
|
66
|
+
ok: true,
|
|
67
|
+
engine: "semgrep",
|
|
68
|
+
scanned: data.paths?.scanned?.length ?? undefined,
|
|
69
|
+
errors: (data.errors || []).length,
|
|
70
|
+
findings,
|
|
71
|
+
summary: { total: findings.length, bySeverity: countBy(findings, "severity") },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Semgrep: ERROR / WARNING / INFO → 我们的严重度
|
|
76
|
+
function mapSeverity(s) {
|
|
77
|
+
switch ((s || "").toUpperCase()) {
|
|
78
|
+
case "ERROR": return "HIGH";
|
|
79
|
+
case "WARNING": return "MEDIUM";
|
|
80
|
+
case "INFO": return "LOW";
|
|
81
|
+
default: return "UNKNOWN";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shortRule(id) {
|
|
86
|
+
if (!id) return "rule";
|
|
87
|
+
const parts = id.split(".");
|
|
88
|
+
return parts.slice(-2).join(".");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toArray(v) {
|
|
92
|
+
if (v == null) return [];
|
|
93
|
+
return Array.isArray(v) ? v : [v];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function which(cmd) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const p = spawn(process.platform === "win32" ? "where" : "which", [cmd], { stdio: ["ignore", "pipe", "ignore"] });
|
|
99
|
+
let out = "";
|
|
100
|
+
p.stdout.on("data", (d) => (out += d));
|
|
101
|
+
p.on("close", (code) => resolve(code === 0 ? out.trim().split("\n")[0] : null));
|
|
102
|
+
p.on("error", () => resolve(null));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function run(cmd, args) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
109
|
+
let out = "";
|
|
110
|
+
let err = "";
|
|
111
|
+
p.stdout.on("data", (d) => (out += d));
|
|
112
|
+
p.stderr.on("data", (d) => (err += d));
|
|
113
|
+
p.on("close", (code) => {
|
|
114
|
+
// semgrep: 0 = 无发现, 1 = 有发现, 其它 = 错误
|
|
115
|
+
if (code === 0 || code === 1 || out.trim().startsWith("{")) resolve(out);
|
|
116
|
+
else reject(new Error(err.trim().slice(0, 300) || `exit ${code}`));
|
|
117
|
+
});
|
|
118
|
+
p.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SEV = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
123
|
+
function sevRank(s) { return SEV[s] ?? 0; }
|
|
124
|
+
function countBy(arr, key) {
|
|
125
|
+
const m = {};
|
|
126
|
+
for (const x of arr) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
127
|
+
return m;
|
|
128
|
+
}
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { loadLockfiles } from "./lockfiles.js";
|
|
2
|
+
import { scanPackages, severityRank } from "./osv.js";
|
|
3
|
+
import { enrichEpss } from "./epss.js";
|
|
4
|
+
import { buildReachability, classify, reachRank } from "./reachability.js";
|
|
5
|
+
|
|
6
|
+
// 一次完整的依赖扫描:lockfile → OSV → EPSS → 可达性 → 排序后的发现列表
|
|
7
|
+
export async function scanDependencies(targetPath, { reachability = true, concurrency = 8 } = {}) {
|
|
8
|
+
const { dir, sources } = await loadLockfiles(targetPath);
|
|
9
|
+
if (!sources.length) {
|
|
10
|
+
return {
|
|
11
|
+
target: dir,
|
|
12
|
+
ok: false,
|
|
13
|
+
message: "未找到受支持的 lockfile(package-lock.json / composer.lock / requirements.txt)。",
|
|
14
|
+
sources: [],
|
|
15
|
+
findings: [],
|
|
16
|
+
summary: emptySummary(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const allPackages = sources.flatMap((s) => s.packages);
|
|
21
|
+
const { findings: raw, errors } = await scanPackages(allPackages, { concurrency });
|
|
22
|
+
|
|
23
|
+
// EPSS:汇总所有 CVE 一次性查
|
|
24
|
+
const cves = raw.flatMap((f) => f.vulns.map((v) => v.cve)).filter(Boolean);
|
|
25
|
+
const epss = await enrichEpss(cves);
|
|
26
|
+
|
|
27
|
+
// 可达性:只在有源码时做
|
|
28
|
+
let imported = new Set();
|
|
29
|
+
if (reachability) {
|
|
30
|
+
const names = [...new Set(allPackages.map((p) => p.name))];
|
|
31
|
+
imported = await buildReachability(dir, names);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 展开成「每个漏洞一行」的发现
|
|
35
|
+
const flat = [];
|
|
36
|
+
for (const { pkg, vulns } of raw) {
|
|
37
|
+
const reach = reachability ? classify(pkg, imported) : "unknown";
|
|
38
|
+
for (const v of vulns) {
|
|
39
|
+
const ep = v.cve ? epss.get(v.cve) : null;
|
|
40
|
+
flat.push({
|
|
41
|
+
package: pkg.name,
|
|
42
|
+
version: pkg.version,
|
|
43
|
+
ecosystem: pkg.ecosystem,
|
|
44
|
+
direct: pkg.direct,
|
|
45
|
+
reachability: reach,
|
|
46
|
+
id: v.id,
|
|
47
|
+
cve: v.cve,
|
|
48
|
+
aliases: v.aliases,
|
|
49
|
+
severity: v.severity,
|
|
50
|
+
epss: ep ? ep.epss : null,
|
|
51
|
+
epssPercentile: ep ? ep.percentile : null,
|
|
52
|
+
fixedVersions: v.fixedVersions,
|
|
53
|
+
summary: v.summary,
|
|
54
|
+
references: v.references,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
flat.sort(cmpFinding);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
target: dir,
|
|
63
|
+
ok: true,
|
|
64
|
+
sources: sources.map((s) => ({ ecosystem: s.ecosystem, file: s.file, packages: s.packages.length })),
|
|
65
|
+
scanned: allPackages.length,
|
|
66
|
+
errors,
|
|
67
|
+
findings: flat,
|
|
68
|
+
summary: summarize(flat),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 排序:先严重度,再可达性,再 EPSS
|
|
73
|
+
function cmpFinding(a, b) {
|
|
74
|
+
const s = severityRank(b.severity) - severityRank(a.severity);
|
|
75
|
+
if (s) return s;
|
|
76
|
+
const r = reachRank(b.reachability) - reachRank(a.reachability);
|
|
77
|
+
if (r) return r;
|
|
78
|
+
return (b.epss ?? 0) - (a.epss ?? 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function summarize(flat) {
|
|
82
|
+
const sum = emptySummary();
|
|
83
|
+
for (const f of flat) {
|
|
84
|
+
sum.total++;
|
|
85
|
+
sum.bySeverity[f.severity] = (sum.bySeverity[f.severity] || 0) + 1;
|
|
86
|
+
if (f.reachability === "imported") sum.reachable++;
|
|
87
|
+
}
|
|
88
|
+
return sum;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function emptySummary() {
|
|
92
|
+
return { total: 0, reachable: 0, bySeverity: {} };
|
|
93
|
+
}
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// 密钥扫描:工作区文件 + 可选 git 历史。全部本地完成,不联网。
|
|
6
|
+
// 命中的密钥在输出里脱敏,只保留头尾几位。
|
|
7
|
+
|
|
8
|
+
const RULES = [
|
|
9
|
+
{ id: "aws-access-key-id", severity: "HIGH", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
10
|
+
{ id: "aws-secret-access-key", severity: "CRITICAL", re: /aws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/i },
|
|
11
|
+
{ id: "github-token", severity: "HIGH", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
|
|
12
|
+
{ id: "gitlab-pat", severity: "HIGH", re: /\bglpat-[A-Za-z0-9_-]{20,}\b/ },
|
|
13
|
+
{ id: "slack-token", severity: "HIGH", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
14
|
+
{ id: "google-api-key", severity: "HIGH", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
15
|
+
{ id: "stripe-secret-key", severity: "CRITICAL", re: /\bsk_live_[0-9a-zA-Z]{24,}\b/ },
|
|
16
|
+
{ id: "openai-key", severity: "HIGH", re: /\bsk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}\b/ },
|
|
17
|
+
{ id: "private-key", severity: "CRITICAL", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
18
|
+
{ id: "jwt", severity: "MEDIUM", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
19
|
+
{ id: "generic-assigned-secret", severity: "MEDIUM", re: /(?:password|passwd|secret|api[_-]?key|token)\s*[=:]\s*['"]([^'"\s]{12,})['"]/i },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const SKIP_DIR = new Set(["node_modules", ".git", "dist", "build", "vendor", ".venv", "venv", "__pycache__"]);
|
|
23
|
+
const SKIP_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".gz", ".lock", ".min.js", ".map"]);
|
|
24
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
25
|
+
|
|
26
|
+
export async function scanSecrets(targetPath, { history = false } = {}) {
|
|
27
|
+
const dir = await resolveDir(targetPath);
|
|
28
|
+
const findings = [];
|
|
29
|
+
|
|
30
|
+
// 1) 工作区文件
|
|
31
|
+
const files = [];
|
|
32
|
+
await collect(dir, files, 0);
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
const s = await stat(f);
|
|
37
|
+
if (s.size > MAX_FILE_BYTES) continue;
|
|
38
|
+
text = await readFile(f, "utf8");
|
|
39
|
+
} catch { continue; }
|
|
40
|
+
scanText(text, { location: path.relative(dir, f), source: "worktree" }, findings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2) git 历史(可选)
|
|
44
|
+
let historyScanned = false;
|
|
45
|
+
if (history) {
|
|
46
|
+
const hit = await scanGitHistory(dir, findings);
|
|
47
|
+
historyScanned = hit.ok;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
dedupe(findings);
|
|
51
|
+
findings.sort((a, b) => sevRank(b.severity) - sevRank(a.severity));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
target: dir,
|
|
55
|
+
ok: true,
|
|
56
|
+
historyScanned,
|
|
57
|
+
findings,
|
|
58
|
+
summary: {
|
|
59
|
+
total: findings.length,
|
|
60
|
+
bySeverity: countBy(findings, "severity"),
|
|
61
|
+
inHistory: findings.filter((f) => f.source === "history").length,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scanText(text, meta, findings) {
|
|
67
|
+
const lines = text.split(/\r?\n/);
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
const line = lines[i];
|
|
70
|
+
if (line.length > 500) continue; // 跳过超长压缩行
|
|
71
|
+
for (const rule of RULES) {
|
|
72
|
+
const m = rule.re.exec(line);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
const raw = m[1] || m[0];
|
|
75
|
+
findings.push({
|
|
76
|
+
rule: rule.id,
|
|
77
|
+
severity: rule.severity,
|
|
78
|
+
location: meta.location,
|
|
79
|
+
line: meta.source === "worktree" ? i + 1 : null,
|
|
80
|
+
source: meta.source,
|
|
81
|
+
commit: meta.commit || null,
|
|
82
|
+
match: redact(raw),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function redact(s) {
|
|
89
|
+
if (s.length <= 8) return "****";
|
|
90
|
+
return `${s.slice(0, 4)}…${s.slice(-4)} (${s.length} chars)`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 用 git log -p 读历史 diff,有字节上限,防止超大仓库卡死
|
|
94
|
+
function scanGitHistory(dir, findings) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = spawn("git", ["-C", dir, "log", "-p", "--all", "-U0", "--no-color"], {
|
|
97
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
98
|
+
});
|
|
99
|
+
let buf = "";
|
|
100
|
+
let bytes = 0;
|
|
101
|
+
let commit = null;
|
|
102
|
+
const CAP = 8_000_000;
|
|
103
|
+
child.stdout.on("data", (chunk) => {
|
|
104
|
+
bytes += chunk.length;
|
|
105
|
+
buf += chunk.toString("utf8");
|
|
106
|
+
const lines = buf.split("\n");
|
|
107
|
+
buf = lines.pop() || "";
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const cm = line.match(/^commit ([0-9a-f]{7,40})/);
|
|
110
|
+
if (cm) { commit = cm[1].slice(0, 10); continue; }
|
|
111
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
112
|
+
scanText(line.slice(1), { location: "(git history)", source: "history", commit }, findings);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (bytes > CAP) child.kill();
|
|
116
|
+
});
|
|
117
|
+
child.on("close", () => resolve({ ok: true }));
|
|
118
|
+
child.on("error", () => resolve({ ok: false }));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dedupe(findings) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (let i = findings.length - 1; i >= 0; i--) {
|
|
125
|
+
const f = findings[i];
|
|
126
|
+
const key = `${f.rule}|${f.match}|${f.source}|${f.location}`;
|
|
127
|
+
if (seen.has(key)) findings.splice(i, 1);
|
|
128
|
+
else seen.add(key);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function resolveDir(p) {
|
|
133
|
+
const s = await stat(p);
|
|
134
|
+
return s.isDirectory() ? p : path.dirname(p);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function collect(dir, out, depth) {
|
|
138
|
+
if (depth > 8 || out.length > 8000) return;
|
|
139
|
+
let entries;
|
|
140
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
141
|
+
for (const e of entries) {
|
|
142
|
+
const full = path.join(dir, e.name);
|
|
143
|
+
if (e.isDirectory()) {
|
|
144
|
+
if (SKIP_DIR.has(e.name) || e.name.startsWith(".")) continue;
|
|
145
|
+
await collect(full, out, depth + 1);
|
|
146
|
+
} else {
|
|
147
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
148
|
+
if (SKIP_EXT.has(ext)) continue;
|
|
149
|
+
out.push(full);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const SEV = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
155
|
+
function sevRank(s) { return SEV[s] ?? 0; }
|
|
156
|
+
function countBy(arr, key) {
|
|
157
|
+
const m = {};
|
|
158
|
+
for (const x of arr) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
159
|
+
return m;
|
|
160
|
+
}
|