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/image.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// 容器镜像扫描:封装 Trivy(行业标准)。装了 trivy 就能用,没装给出清晰安装提示。
|
|
4
|
+
// 把 Trivy 的 JSON 归一化成 Break Anything 统一的发现结构。
|
|
5
|
+
|
|
6
|
+
export async function scanImage(ref, { severityFilter } = {}) {
|
|
7
|
+
const trivy = await which("trivy");
|
|
8
|
+
if (!trivy) {
|
|
9
|
+
return {
|
|
10
|
+
target: ref,
|
|
11
|
+
ok: false,
|
|
12
|
+
engine: "trivy",
|
|
13
|
+
message:
|
|
14
|
+
"未找到 trivy。镜像扫描依赖 Trivy(Aqua Security)。安装:\n" +
|
|
15
|
+
" macOS: brew install trivy\n" +
|
|
16
|
+
" Linux: 见 https://aquasecurity.github.io/trivy/latest/getting-started/installation/",
|
|
17
|
+
findings: [],
|
|
18
|
+
summary: { total: 0, bySeverity: {} },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const args = ["image", "--quiet", "--format", "json", "--scanners", "vuln"];
|
|
23
|
+
if (severityFilter) args.push("--severity", severityFilter);
|
|
24
|
+
args.push(ref);
|
|
25
|
+
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await run(trivy, args);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return {
|
|
31
|
+
target: ref,
|
|
32
|
+
ok: false,
|
|
33
|
+
engine: "trivy",
|
|
34
|
+
message: `trivy 执行失败:${err instanceof Error ? err.message : String(err)}`,
|
|
35
|
+
findings: [],
|
|
36
|
+
summary: { total: 0, bySeverity: {} },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return normalizeTrivy(raw, ref);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 导出以便单测:把 trivy JSON 文本归一化
|
|
44
|
+
export function normalizeTrivy(rawJson, ref) {
|
|
45
|
+
let data;
|
|
46
|
+
try { data = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson; }
|
|
47
|
+
catch { return { target: ref, ok: false, engine: "trivy", message: "无法解析 trivy 输出", findings: [], summary: { total: 0, bySeverity: {} } }; }
|
|
48
|
+
|
|
49
|
+
const findings = [];
|
|
50
|
+
for (const result of data.Results || []) {
|
|
51
|
+
const target = result.Target;
|
|
52
|
+
const cls = result.Class; // os-pkgs / lang-pkgs
|
|
53
|
+
for (const v of result.Vulnerabilities || []) {
|
|
54
|
+
findings.push({
|
|
55
|
+
package: v.PkgName,
|
|
56
|
+
version: v.InstalledVersion,
|
|
57
|
+
id: v.VulnerabilityID,
|
|
58
|
+
cve: /^CVE-/i.test(v.VulnerabilityID || "") ? v.VulnerabilityID : null,
|
|
59
|
+
severity: (v.Severity || "UNKNOWN").toUpperCase(),
|
|
60
|
+
fixedVersions: v.FixedVersion ? [v.FixedVersion] : [],
|
|
61
|
+
class: cls,
|
|
62
|
+
location: target,
|
|
63
|
+
summary: v.Title || (v.Description ? v.Description.slice(0, 160) : ""),
|
|
64
|
+
references: (v.References || []).slice(0, 5),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
findings.sort((a, b) => sevRank(b.severity) - sevRank(a.severity));
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
target: ref,
|
|
72
|
+
ok: true,
|
|
73
|
+
engine: "trivy",
|
|
74
|
+
findings,
|
|
75
|
+
summary: {
|
|
76
|
+
total: findings.length,
|
|
77
|
+
fixable: findings.filter((f) => f.fixedVersions.length).length,
|
|
78
|
+
bySeverity: countBy(findings, "severity"),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function which(cmd) {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const p = spawn(process.platform === "win32" ? "where" : "which", [cmd], { stdio: ["ignore", "pipe", "ignore"] });
|
|
86
|
+
let out = "";
|
|
87
|
+
p.stdout.on("data", (d) => (out += d));
|
|
88
|
+
p.on("close", (code) => resolve(code === 0 ? out.trim().split("\n")[0] : null));
|
|
89
|
+
p.on("error", () => resolve(null));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function run(cmd, args) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
96
|
+
let out = "";
|
|
97
|
+
let err = "";
|
|
98
|
+
p.stdout.on("data", (d) => (out += d));
|
|
99
|
+
p.stderr.on("data", (d) => (err += d));
|
|
100
|
+
p.on("close", (code) => {
|
|
101
|
+
if (code === 0 || out.trim().startsWith("{")) resolve(out);
|
|
102
|
+
else reject(new Error(err.trim().slice(0, 300) || `exit ${code}`));
|
|
103
|
+
});
|
|
104
|
+
p.on("error", reject);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SEV = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
109
|
+
function sevRank(s) { return SEV[s] ?? 0; }
|
|
110
|
+
function countBy(arr, key) {
|
|
111
|
+
const m = {};
|
|
112
|
+
for (const x of arr) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
113
|
+
return m;
|
|
114
|
+
}
|
package/src/lockfiles.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// 探测目标路径下支持的 lockfile,返回 { ecosystem, file, packages, manifestDirect }
|
|
5
|
+
// packages: [{ name, version, ecosystem, direct }]
|
|
6
|
+
// manifestDirect: Set<string> 直接依赖名(用于可达性判断)
|
|
7
|
+
export async function loadLockfiles(targetPath) {
|
|
8
|
+
const dir = await resolveDir(targetPath);
|
|
9
|
+
const out = [];
|
|
10
|
+
|
|
11
|
+
const npmLock = path.join(dir, "package-lock.json");
|
|
12
|
+
if (await exists(npmLock)) out.push(await parseNpmLock(npmLock, dir));
|
|
13
|
+
|
|
14
|
+
const composerLock = path.join(dir, "composer.lock");
|
|
15
|
+
if (await exists(composerLock)) out.push(await parseComposerLock(composerLock, dir));
|
|
16
|
+
|
|
17
|
+
const reqTxt = path.join(dir, "requirements.txt");
|
|
18
|
+
if (await exists(reqTxt)) out.push(await parseRequirements(reqTxt));
|
|
19
|
+
|
|
20
|
+
return { dir, sources: out.filter(Boolean) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveDir(p) {
|
|
24
|
+
const s = await stat(p);
|
|
25
|
+
return s.isDirectory() ? p : path.dirname(p);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function exists(p) {
|
|
29
|
+
try { await stat(p); return true; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- npm: package-lock.json(v2/v3 的 packages,回退 v1 的 dependencies)----
|
|
33
|
+
async function parseNpmLock(file, dir) {
|
|
34
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
35
|
+
const direct = await readNpmDirect(dir);
|
|
36
|
+
const map = new Map();
|
|
37
|
+
|
|
38
|
+
if (raw.packages && typeof raw.packages === "object") {
|
|
39
|
+
for (const [key, info] of Object.entries(raw.packages)) {
|
|
40
|
+
if (!key || !info?.version) continue; // "" 是根项目,跳过
|
|
41
|
+
const name = key.startsWith("node_modules/")
|
|
42
|
+
? key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length)
|
|
43
|
+
: key;
|
|
44
|
+
if (!name) continue;
|
|
45
|
+
map.set(`${name}@${info.version}`, { name, version: info.version });
|
|
46
|
+
}
|
|
47
|
+
} else if (raw.dependencies && typeof raw.dependencies === "object") {
|
|
48
|
+
walkV1(raw.dependencies, map);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const packages = [...map.values()].map((p) => ({
|
|
52
|
+
...p,
|
|
53
|
+
ecosystem: "npm",
|
|
54
|
+
direct: direct.has(p.name),
|
|
55
|
+
}));
|
|
56
|
+
return { ecosystem: "npm", file, packages, manifestDirect: direct };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function walkV1(deps, map) {
|
|
60
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
61
|
+
if (info?.version) map.set(`${name}@${info.version}`, { name, version: info.version });
|
|
62
|
+
if (info?.dependencies) walkV1(info.dependencies, map);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readNpmDirect(dir) {
|
|
67
|
+
const pkgPath = path.join(dir, "package.json");
|
|
68
|
+
const set = new Set();
|
|
69
|
+
if (!(await exists(pkgPath))) return set;
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
72
|
+
for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
73
|
+
for (const name of Object.keys(pkg[field] || {})) set.add(name);
|
|
74
|
+
}
|
|
75
|
+
} catch { /* 忽略损坏的 package.json */ }
|
|
76
|
+
return set;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- PHP: composer.lock ----
|
|
80
|
+
async function parseComposerLock(file, dir) {
|
|
81
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
82
|
+
const direct = await readComposerDirect(dir);
|
|
83
|
+
const packages = [];
|
|
84
|
+
for (const group of ["packages", "packages-dev"]) {
|
|
85
|
+
for (const p of raw[group] || []) {
|
|
86
|
+
if (!p.name || !p.version) continue;
|
|
87
|
+
const version = String(p.version).replace(/^v/, "");
|
|
88
|
+
packages.push({ name: p.name, version, ecosystem: "Packagist", direct: direct.has(p.name) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ecosystem: "Packagist", file, packages, manifestDirect: direct };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readComposerDirect(dir) {
|
|
95
|
+
const p = path.join(dir, "composer.json");
|
|
96
|
+
const set = new Set();
|
|
97
|
+
if (!(await exists(p))) return set;
|
|
98
|
+
try {
|
|
99
|
+
const json = JSON.parse(await readFile(p, "utf8"));
|
|
100
|
+
for (const field of ["require", "require-dev"]) {
|
|
101
|
+
for (const name of Object.keys(json[field] || {})) set.add(name);
|
|
102
|
+
}
|
|
103
|
+
} catch { /* 忽略 */ }
|
|
104
|
+
return set;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- Python: requirements.txt(只取用 == 钉死的版本)----
|
|
108
|
+
async function parseRequirements(file) {
|
|
109
|
+
const text = await readFile(file, "utf8");
|
|
110
|
+
const packages = [];
|
|
111
|
+
for (const line of text.split(/\r?\n/)) {
|
|
112
|
+
const s = line.trim();
|
|
113
|
+
if (!s || s.startsWith("#") || s.startsWith("-")) continue;
|
|
114
|
+
const m = s.match(/^([A-Za-z0-9._-]+)\s*==\s*([A-Za-z0-9._-]+)/);
|
|
115
|
+
if (m) packages.push({ name: m[1], version: m[2], ecosystem: "PyPI", direct: true });
|
|
116
|
+
}
|
|
117
|
+
return { ecosystem: "PyPI", file, packages, manifestDirect: new Set(packages.map((p) => p.name)) };
|
|
118
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { scanDependencies } from "./scan.js";
|
|
5
|
+
import { scanSecrets } from "./secrets.js";
|
|
6
|
+
import { scanImage } from "./image.js";
|
|
7
|
+
import { scanSast } from "./sast.js";
|
|
8
|
+
|
|
9
|
+
const TOOLS = [
|
|
10
|
+
{
|
|
11
|
+
name: "deps_scan",
|
|
12
|
+
description:
|
|
13
|
+
"Scan a project's dependency lockfiles (npm package-lock.json, PHP composer.lock, " +
|
|
14
|
+
"Python requirements.txt) for known vulnerabilities via OSV. Returns findings ranked by " +
|
|
15
|
+
"severity, reachability (is the package actually imported in the project source), and EPSS " +
|
|
16
|
+
"exploit probability, with fix versions. Point this only at code you own or are authorized " +
|
|
17
|
+
"to test.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
path: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Absolute path to the project directory (or a lockfile) to scan.",
|
|
24
|
+
},
|
|
25
|
+
reachability: {
|
|
26
|
+
type: "boolean",
|
|
27
|
+
description: "Compute import-based reachability heuristic from project source. Default true.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ["path"],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "secrets_scan",
|
|
35
|
+
description:
|
|
36
|
+
"Scan a project for leaked secrets (API keys, tokens, private keys) in the working tree " +
|
|
37
|
+
"and, optionally, the full git history. Matches are redacted in the output. Runs locally. " +
|
|
38
|
+
"Point this only at repositories you own or are authorized to test.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
path: { type: "string", description: "Absolute path to the repository to scan." },
|
|
43
|
+
history: { type: "boolean", description: "Also scan git history (git log -p). Default false." },
|
|
44
|
+
},
|
|
45
|
+
required: ["path"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "image_scan",
|
|
50
|
+
description:
|
|
51
|
+
"Scan a container image for known vulnerabilities using Trivy. Returns OS and application " +
|
|
52
|
+
"package CVEs ranked by severity, with fix versions. Requires Trivy to be installed. " +
|
|
53
|
+
"Scan only images you own or are authorized to test.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
ref: { type: "string", description: "Image reference, e.g. nginx:1.21 or my-service:latest." },
|
|
58
|
+
},
|
|
59
|
+
required: ["ref"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "sast_scan",
|
|
64
|
+
description:
|
|
65
|
+
"Static application security testing (SAST) on a source tree using Semgrep. Finds injection, " +
|
|
66
|
+
"broken access control, insecure deserialization, SSRF, hardcoded secrets and more, each with " +
|
|
67
|
+
"file/line, CWE/OWASP tags, and the offending snippet. Requires Semgrep to be installed. " +
|
|
68
|
+
"Scan only code you own or are authorized to test.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
path: { type: "string", description: "Absolute path to the source tree to scan." },
|
|
73
|
+
config: { type: "string", description: "Semgrep ruleset (default p/security-audit)." },
|
|
74
|
+
},
|
|
75
|
+
required: ["path"],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export async function startMcpServer() {
|
|
81
|
+
const server = new Server(
|
|
82
|
+
{ name: "break-anything", version: "0.2.0" },
|
|
83
|
+
{ capabilities: { tools: {} } },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
87
|
+
|
|
88
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
89
|
+
const { name, arguments: args = {} } = req.params;
|
|
90
|
+
try {
|
|
91
|
+
let result;
|
|
92
|
+
if (name === "deps_scan") {
|
|
93
|
+
if (!strArg(args.path)) return bad("缺少必填参数 path。");
|
|
94
|
+
result = await scanDependencies(args.path, { reachability: args.reachability !== false });
|
|
95
|
+
} else if (name === "secrets_scan") {
|
|
96
|
+
if (!strArg(args.path)) return bad("缺少必填参数 path。");
|
|
97
|
+
result = await scanSecrets(args.path, { history: !!args.history });
|
|
98
|
+
} else if (name === "image_scan") {
|
|
99
|
+
if (!strArg(args.ref)) return bad("缺少必填参数 ref。");
|
|
100
|
+
result = await scanImage(args.ref);
|
|
101
|
+
} else if (name === "sast_scan") {
|
|
102
|
+
if (!strArg(args.path)) return bad("缺少必填参数 path。");
|
|
103
|
+
result = await scanSast(args.path, args.config ? { config: args.config } : {});
|
|
104
|
+
} else {
|
|
105
|
+
return bad(`Unknown tool: ${name}`);
|
|
106
|
+
}
|
|
107
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
110
|
+
return { isError: true, content: [{ type: "text", text: `扫描失败:${msg}` }] };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function strArg(v) { return typeof v === "string" && v.length > 0; }
|
|
115
|
+
function bad(text) { return { isError: true, content: [{ type: "text", text }] }; }
|
|
116
|
+
|
|
117
|
+
const transport = new StdioServerTransport();
|
|
118
|
+
await server.connect(transport);
|
|
119
|
+
// stdio 模式下不要往 stdout 写任何非协议内容;日志走 stderr
|
|
120
|
+
process.stderr.write("break-anything MCP server 已启动(stdio)。\n");
|
|
121
|
+
}
|
package/src/osv.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { fetchJson, mapPool } from "./http.js";
|
|
2
|
+
|
|
3
|
+
const OSV_QUERY = "https://api.osv.dev/v1/query";
|
|
4
|
+
|
|
5
|
+
// 对单个「包@版本」查询 OSV 已知漏洞
|
|
6
|
+
async function queryOne(pkg) {
|
|
7
|
+
const data = await fetchJson(OSV_QUERY, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
body: { version: pkg.version, package: { name: pkg.name, ecosystem: pkg.ecosystem } },
|
|
10
|
+
});
|
|
11
|
+
const vulns = Array.isArray(data.vulns) ? data.vulns : [];
|
|
12
|
+
return vulns.map((v) => normalizeVuln(v, pkg));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 把 OSV 的原始漏洞记录归一化成我们统一的发现结构
|
|
16
|
+
function normalizeVuln(v, pkg) {
|
|
17
|
+
const aliases = Array.isArray(v.aliases) ? v.aliases : [];
|
|
18
|
+
const cve = aliases.find((a) => /^CVE-/i.test(a)) || null;
|
|
19
|
+
return {
|
|
20
|
+
id: v.id,
|
|
21
|
+
cve,
|
|
22
|
+
aliases,
|
|
23
|
+
summary: v.summary || (v.details ? v.details.slice(0, 160) : ""),
|
|
24
|
+
severity: extractSeverity(v),
|
|
25
|
+
fixedVersions: extractFixedVersions(v, pkg),
|
|
26
|
+
references: (v.references || []).map((r) => r.url).slice(0, 5),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 严重度:优先用 database_specific.severity(GHSA 的 HIGH/MODERATE…),否则从 CVSS 推
|
|
31
|
+
function extractSeverity(v) {
|
|
32
|
+
const label = v.database_specific?.severity;
|
|
33
|
+
if (typeof label === "string" && label) return label.toUpperCase();
|
|
34
|
+
const cvss = (v.severity || []).find((s) => /CVSS/i.test(s.type));
|
|
35
|
+
if (cvss?.score) {
|
|
36
|
+
const base = parseBaseFromVector(cvss.score);
|
|
37
|
+
if (base != null) return scoreToLabel(base);
|
|
38
|
+
}
|
|
39
|
+
return "UNKNOWN";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseBaseFromVector(vectorOrScore) {
|
|
43
|
+
const num = Number(vectorOrScore);
|
|
44
|
+
if (!Number.isNaN(num)) return num;
|
|
45
|
+
return null; // 不在 MVP 里完整解析 CVSS 向量
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scoreToLabel(base) {
|
|
49
|
+
if (base >= 9) return "CRITICAL";
|
|
50
|
+
if (base >= 7) return "HIGH";
|
|
51
|
+
if (base >= 4) return "MEDIUM";
|
|
52
|
+
if (base > 0) return "LOW";
|
|
53
|
+
return "UNKNOWN";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 从 affected[].ranges[].events[] 里抽出匹配本包的「fixed」版本
|
|
57
|
+
function extractFixedVersions(v, pkg) {
|
|
58
|
+
const out = new Set();
|
|
59
|
+
for (const aff of v.affected || []) {
|
|
60
|
+
if (aff.package?.name !== pkg.name) continue;
|
|
61
|
+
if (aff.package?.ecosystem && pkg.ecosystem && aff.package.ecosystem !== pkg.ecosystem) continue;
|
|
62
|
+
for (const range of aff.ranges || []) {
|
|
63
|
+
for (const ev of range.events || []) {
|
|
64
|
+
if (ev.fixed) out.add(ev.fixed);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...out];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SEVERITY_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
72
|
+
export function severityRank(label) {
|
|
73
|
+
return SEVERITY_ORDER[label] ?? 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 批量查询:对一组包并发查 OSV,返回「有漏洞的包 + 其漏洞列表」
|
|
77
|
+
export async function scanPackages(packages, { concurrency = 8 } = {}) {
|
|
78
|
+
const results = await mapPool(packages, concurrency, async (pkg) => {
|
|
79
|
+
const vulns = await queryOne(pkg);
|
|
80
|
+
return { pkg, vulns };
|
|
81
|
+
});
|
|
82
|
+
const findings = [];
|
|
83
|
+
const errors = [];
|
|
84
|
+
results.forEach((r, i) => {
|
|
85
|
+
if (r?.__error) {
|
|
86
|
+
errors.push({ pkg: packages[i], error: r.__error });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (r.vulns.length) findings.push(r);
|
|
90
|
+
});
|
|
91
|
+
return { findings, errors };
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// 可达性初判(启发式,非证明):
|
|
5
|
+
// imported —— 源码里确实 import/require 了这个包 → 最高优先级
|
|
6
|
+
// direct —— 清单里声明为直接依赖,但没在源码里发现引用
|
|
7
|
+
// transitive—— 既非直接依赖、也没在源码里发现引用(传递依赖)
|
|
8
|
+
// 诚实地标注这是启发式:它判断"是否被引用",不等于"漏洞函数被调用"。
|
|
9
|
+
|
|
10
|
+
const SOURCE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".vue", ".php", ".py"]);
|
|
11
|
+
const SKIP_DIR = new Set(["node_modules", ".git", "dist", "build", "vendor", ".venv", "venv", "__pycache__"]);
|
|
12
|
+
|
|
13
|
+
export async function buildReachability(dir, packageNames) {
|
|
14
|
+
const imported = new Set();
|
|
15
|
+
const files = [];
|
|
16
|
+
await collect(dir, files, 0);
|
|
17
|
+
|
|
18
|
+
// npm 包可能是 scope 形式 @scope/name;只取用于匹配的词根
|
|
19
|
+
const patterns = packageNames.map((name) => ({ name, re: importRegex(name) }));
|
|
20
|
+
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
let text;
|
|
23
|
+
try { text = await readFile(f, "utf8"); } catch { continue; }
|
|
24
|
+
for (const { name, re } of patterns) {
|
|
25
|
+
if (imported.has(name)) continue;
|
|
26
|
+
if (re.test(text)) imported.add(name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return imported;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function collect(dir, out, depth) {
|
|
33
|
+
if (depth > 8 || out.length > 5000) return;
|
|
34
|
+
let entries;
|
|
35
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
38
|
+
const full = path.join(dir, e.name);
|
|
39
|
+
if (e.isDirectory()) {
|
|
40
|
+
if (SKIP_DIR.has(e.name)) continue;
|
|
41
|
+
await collect(full, out, depth + 1);
|
|
42
|
+
} else if (SOURCE_EXT.has(path.extname(e.name))) {
|
|
43
|
+
out.push(full);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function importRegex(name) {
|
|
49
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
// 命中 import 'x' / from 'x' / require('x') / use X\...(PHP) / import x(py)
|
|
51
|
+
return new RegExp(
|
|
52
|
+
`(require\\(\\s*['"\`]${esc}|from\\s+['"\`]${esc}|import\\s+['"\`]${esc}|import\\s+[^'"\`\\n]*['"\`]${esc})`,
|
|
53
|
+
"m",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function classify(pkg, imported) {
|
|
58
|
+
if (imported.has(pkg.name)) return "imported";
|
|
59
|
+
if (pkg.direct) return "direct";
|
|
60
|
+
return "transitive";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const REACH_ORDER = { imported: 3, direct: 2, transitive: 1 };
|
|
64
|
+
export function reachRank(label) {
|
|
65
|
+
return REACH_ORDER[label] ?? 0;
|
|
66
|
+
}
|