break-anything 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.js CHANGED
@@ -1,4 +1,7 @@
1
- // 人类可读报告(终端)。不依赖任何第三方 UI 库。
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";
2
5
 
3
6
  const C = {
4
7
  reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
@@ -6,55 +9,114 @@ const C = {
6
9
  };
7
10
 
8
11
  const SEV_COLOR = { CRITICAL: C.red, HIGH: C.red, MEDIUM: C.yellow, LOW: C.cyan, UNKNOWN: C.gray };
9
- const REACH_LABEL = {
10
- imported: "reachable(已被引用)",
11
- direct: "direct(直接依赖)",
12
- transitive: "transitive(传递依赖)",
13
- unknown: "—",
14
- };
15
12
 
16
- export function renderHuman(result, { color = true } = {}) {
17
- const c = color ? C : new Proxy({}, { get: () => "" });
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);
18
76
  const lines = [];
19
77
 
20
78
  if (!result.ok) {
21
79
  lines.push(`${c.yellow}${result.message}${c.reset}`);
22
- lines.push(`${c.gray}目标:${result.target}${c.reset}`);
80
+ lines.push(`${c.gray}${result.target}${c.reset}`);
23
81
  return lines.join("\n");
24
82
  }
25
83
 
26
84
  const srcDesc = result.sources.map((s) => `${s.ecosystem}:${s.packages}`).join(" ");
27
- lines.push(`${c.bold}Break Anything · 依赖扫描${c.reset}`);
28
- lines.push(`${c.gray}目标 ${result.target} · 已扫 ${result.scanned} 个包 · 来源 ${srcDesc}${c.reset}`);
85
+ lines.push(`${c.bold}${T.depsTitle}${c.reset}`);
86
+ lines.push(`${c.gray}${T.depsTarget(result.target, result.scanned, srcDesc)}${c.reset}`);
29
87
  lines.push("");
30
88
 
31
89
  if (!result.findings.length) {
32
- lines.push(`${c.green}✓ 未在已知漏洞库(OSV)中发现匹配的漏洞。${c.reset}`);
33
- lines.push(`${c.gray} 注意:这表示"未发现",不等于"安全"。${c.reset}`);
90
+ lines.push(`${c.green}${T.depsClean}${c.reset}`);
91
+ lines.push(`${c.gray}${T.cleanNote}${c.reset}`);
34
92
  return lines.join("\n");
35
93
  }
36
94
 
37
- for (const f of result.findings) {
95
+ const shown = top && result.findings.length > top ? result.findings.slice(0, top) : result.findings;
96
+ for (const f of shown) {
38
97
  const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
39
98
  const idLabel = f.cve || f.id;
40
- const epss = f.epss != null ? `EPSS ${(f.epss * 100).toFixed(1)}%` : "EPSS —";
41
- const fix = f.fixedVersions.length ? `${c.green}修复→ ${f.fixedVersions.join(", ")}${c.reset}` : `${c.red}无修复版本${c.reset}`;
99
+ const fix = f.fixedVersions.length
100
+ ? `${c.green}${T.fix(f.fixedVersions.join(", "))}${c.reset}`
101
+ : `${c.red}${T.noFix}${c.reset}`;
42
102
  lines.push(`${sev} ${c.bold}${f.package}@${f.version}${c.reset} ${c.gray}(${f.ecosystem})${c.reset}`);
43
- lines.push(` ${c.cyan}${idLabel}${c.reset} · ${REACH_LABEL[f.reachability]} · ${epss} · ${fix}`);
103
+ lines.push(` ${c.cyan}${idLabel}${c.reset} · ${T.reach[f.reachability]} · ${T.epss(f.epss)} · ${fix}`);
44
104
  if (f.summary) lines.push(` ${c.gray}${f.summary.replace(/\s+/g, " ").slice(0, 120)}${c.reset}`);
45
105
  lines.push("");
46
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
+ }
47
111
 
48
112
  const s = result.summary;
49
113
  const sevParts = Object.entries(s.bySeverity)
50
114
  .sort((a, b) => sevOrder(b[0]) - sevOrder(a[0]))
51
115
  .map(([k, v]) => `${k} ${v}`)
52
116
  .join(" ");
53
- lines.push(`${c.bold}小结${c.reset} 共 ${s.total} 条 · 可达(已引用) ${s.reachable} 条 · ${sevParts}`);
54
- if (result.errors?.length) {
55
- lines.push(`${c.gray}(${result.errors.length} 个包查询失败,已跳过)${c.reset}`);
56
- }
57
- lines.push(`${c.gray}可达性为启发式(判断是否被引用),非漏洞函数被调用的证明。${c.reset}`);
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}`);
58
120
  return lines.join("\n");
59
121
  }
60
122
 
@@ -62,24 +124,24 @@ function sevOrder(k) {
62
124
  return { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 }[k] ?? 0;
63
125
  }
64
126
 
65
- // ---- 密钥扫描报告 ----
127
+ // ---- secrets ----
66
128
  export function renderSecrets(result, { color = true } = {}) {
67
- const c = color ? C : new Proxy({}, { get: () => "" });
129
+ const c = palette(color);
68
130
  const lines = [];
69
- lines.push(`${c.bold}Break Anything · 密钥扫描${c.reset}`);
70
- lines.push(`${c.gray}目标 ${result.target} · 历史扫描 ${result.historyScanned ? "开" : "关"}${c.reset}`);
131
+ lines.push(`${c.bold}${T.secretsTitle}${c.reset}`);
132
+ lines.push(`${c.gray}${T.secretsTarget(result.target, result.historyScanned)}${c.reset}`);
71
133
  lines.push("");
72
134
 
73
135
  if (!result.findings.length) {
74
- lines.push(`${c.green}✓ 未发现疑似泄露的密钥。${c.reset}`);
75
- lines.push(`${c.gray} 注意:这表示"未发现",不等于"没有"。${c.reset}`);
136
+ lines.push(`${c.green}${T.secretsClean}${c.reset}`);
137
+ lines.push(`${c.gray}${T.cleanNote}${c.reset}`);
76
138
  return lines.join("\n");
77
139
  }
78
140
 
79
141
  for (const f of result.findings) {
80
142
  const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
81
143
  const loc = f.source === "history"
82
- ? `${c.yellow}git 历史${c.reset} ${c.gray}@${f.commit}${c.reset}`
144
+ ? `${c.yellow}${T.history}${c.reset} ${c.gray}@${f.commit}${c.reset}`
83
145
  : `${f.location}${f.line ? `:${f.line}` : ""}`;
84
146
  lines.push(`${sev} ${c.bold}${f.rule}${c.reset}`);
85
147
  lines.push(` ${loc} · ${c.gray}${f.match}${c.reset}`);
@@ -87,16 +149,16 @@ export function renderSecrets(result, { color = true } = {}) {
87
149
  lines.push("");
88
150
  const s = result.summary;
89
151
  const parts = Object.entries(s.bySeverity).sort((a, b) => sevOrder(b[0]) - sevOrder(a[0])).map(([k, v]) => `${k} ${v}`).join(" ");
90
- lines.push(`${c.bold}小结${c.reset} 共 ${s.total} 条 · 历史中 ${s.inHistory} 条 · ${parts}`);
91
- if (s.inHistory) lines.push(`${c.yellow}历史里的密钥即使已删除也可能仍然有效 —— 请轮换(rotate),不要只删提交。${c.reset}`);
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}`);
92
154
  return lines.join("\n");
93
155
  }
94
156
 
95
- // ---- 镜像扫描报告 ----
157
+ // ---- image ----
96
158
  export function renderImage(result, { color = true } = {}) {
97
- const c = color ? C : new Proxy({}, { get: () => "" });
159
+ const c = palette(color);
98
160
  const lines = [];
99
- lines.push(`${c.bold}Break Anything · 镜像扫描${c.reset} ${c.gray}(${result.engine})${c.reset}`);
161
+ lines.push(`${c.bold}${T.imageTitle}${c.reset} ${c.gray}(${result.engine})${c.reset}`);
100
162
 
101
163
  if (!result.ok) {
102
164
  lines.push("");
@@ -104,21 +166,60 @@ export function renderImage(result, { color = true } = {}) {
104
166
  return lines.join("\n");
105
167
  }
106
168
 
107
- lines.push(`${c.gray}目标 ${result.target}${c.reset}`);
169
+ lines.push(`${c.gray}${result.target}${c.reset}`);
108
170
  lines.push("");
109
171
  if (!result.findings.length) {
110
- lines.push(`${c.green}✓ 未在镜像中发现已知漏洞。${c.reset}`);
172
+ lines.push(`${c.green}${T.imageClean}${c.reset}`);
111
173
  return lines.join("\n");
112
174
  }
113
175
  for (const f of result.findings.slice(0, 40)) {
114
176
  const sev = `${SEV_COLOR[f.severity] || ""}${f.severity.padEnd(8)}${c.reset}`;
115
- const fix = f.fixedVersions.length ? `${c.green}修复→ ${f.fixedVersions.join(", ")}${c.reset}` : `${c.red}无修复${c.reset}`;
177
+ const fix = f.fixedVersions.length ? `${c.green}${T.fix(f.fixedVersions.join(", "))}${c.reset}` : `${c.red}${T.noFix}${c.reset}`;
116
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}`);
117
179
  }
118
- if (result.findings.length > 40) lines.push(`${c.gray}… 还有 ${result.findings.length - 40} 条,用 --json 查看全部${c.reset}`);
180
+ if (result.findings.length > 40) lines.push(`${c.gray}${T.more(result.findings.length - 40)}${c.reset}`);
119
181
  lines.push("");
120
182
  const s = result.summary;
121
183
  const parts = Object.entries(s.bySeverity).sort((a, b) => sevOrder(b[0]) - sevOrder(a[0])).map(([k, v]) => `${k} ${v}`).join(" ");
122
- lines.push(`${c.bold}小结${c.reset} 共 ${s.total} 条 · 可修复 ${s.fixable} 条 · ${parts}`);
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}`);
123
224
  return lines.join("\n");
124
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
+ }