osv-scanner-mcp 0.1.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/LICENSE +202 -0
- package/README.md +228 -0
- package/dist/errors.js +16 -0
- package/dist/index.js +62 -0
- package/dist/osv/binaryDownloader.js +127 -0
- package/dist/osv/binaryManager.js +79 -0
- package/dist/osv/osvApi.js +74 -0
- package/dist/osv/runner.js +103 -0
- package/dist/osv/scanReport.js +186 -0
- package/dist/osv/suggestFix.js +120 -0
- package/dist/osv/vulnerabilityExplanation.js +118 -0
- package/dist/test/osv/binaryDownloader.test.js +117 -0
- package/dist/test/osv/explainVulnerability.test.js +173 -0
- package/dist/test/osv/runner.test.js +170 -0
- package/dist/test/osv/scanReport.test.js +342 -0
- package/dist/test/osv/suggestFix.test.js +104 -0
- package/dist/test/tools/explainVulnerability.test.js +39 -0
- package/dist/test/tools/scanJavaProject.test.js +87 -0
- package/dist/test/tools/suggestFix.test.js +79 -0
- package/dist/test/utils/mavenVersion.test.js +185 -0
- package/dist/test/utils/projectDetector.test.js +154 -0
- package/dist/tools/explainVulnerability.js +18 -0
- package/dist/tools/scanJavaProject.js +23 -0
- package/dist/tools/suggestFix.js +29 -0
- package/dist/tools/toolResult.js +29 -0
- package/dist/utils/mavenVersion.js +211 -0
- package/dist/utils/projectDetector.js +122 -0
- package/dist/utils/unknownJson.js +18 -0
- package/package.json +53 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseOsvScanOutput } from "../../osv/scanReport.js";
|
|
3
|
+
/**
|
|
4
|
+
* docs/DESIGN_TODO.mdの実機確認(pom.xml: log4j-core 2.14.1 / commons-collections 3.2.1 /
|
|
5
|
+
* jackson-databind 2.17.0)で観測した構造を縮約したフィクスチャ。
|
|
6
|
+
* - log4j: 複数リリース系統のfixedが混在
|
|
7
|
+
* - commons-collections: max_severityが空文字列
|
|
8
|
+
* - jackson-core: 同一pom.xmlが別のresults[]エントリに分かれて出力される
|
|
9
|
+
*/
|
|
10
|
+
const fixture = {
|
|
11
|
+
results: [
|
|
12
|
+
{
|
|
13
|
+
source: { path: "/work/demo/pom.xml", type: "lockfile" },
|
|
14
|
+
packages: [
|
|
15
|
+
{
|
|
16
|
+
package: {
|
|
17
|
+
name: "org.apache.logging.log4j:log4j-core",
|
|
18
|
+
version: "2.14.1",
|
|
19
|
+
ecosystem: "Maven",
|
|
20
|
+
},
|
|
21
|
+
groups: [
|
|
22
|
+
{
|
|
23
|
+
ids: ["GHSA-jfh8-c2jp-5v3q"],
|
|
24
|
+
aliases: ["CVE-2021-44228", "GHSA-jfh8-c2jp-5v3q"],
|
|
25
|
+
max_severity: "10.0",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
ids: ["GHSA-7rjr-3q55-vv33"],
|
|
29
|
+
aliases: ["CVE-2021-45046", "GHSA-7rjr-3q55-vv33"],
|
|
30
|
+
max_severity: "9.0",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
vulnerabilities: [
|
|
34
|
+
{
|
|
35
|
+
id: "GHSA-jfh8-c2jp-5v3q",
|
|
36
|
+
summary: "Remote code injection in Log4j",
|
|
37
|
+
affected: [
|
|
38
|
+
{
|
|
39
|
+
package: { name: "org.apache.logging.log4j:log4j-core", ecosystem: "Maven" },
|
|
40
|
+
ranges: [
|
|
41
|
+
{
|
|
42
|
+
type: "ECOSYSTEM",
|
|
43
|
+
events: [{ introduced: "2.13.0" }, { fixed: "2.15.0" }],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: "ECOSYSTEM",
|
|
47
|
+
events: [{ introduced: "2.4" }, { fixed: "2.12.2" }],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
// 別パッケージのaffectedは無視されること
|
|
53
|
+
package: { name: "org.example:other-artifact", ecosystem: "Maven" },
|
|
54
|
+
ranges: [{ type: "ECOSYSTEM", events: [{ fixed: "99.9.9" }] }],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "GHSA-7rjr-3q55-vv33",
|
|
60
|
+
summary: "Incomplete fix of CVE-2021-44228",
|
|
61
|
+
affected: [
|
|
62
|
+
{
|
|
63
|
+
package: { name: "org.apache.logging.log4j:log4j-core", ecosystem: "Maven" },
|
|
64
|
+
ranges: [
|
|
65
|
+
{
|
|
66
|
+
type: "ECOSYSTEM",
|
|
67
|
+
events: [{ introduced: "2.13.0" }, { fixed: "2.16.0" }],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "ECOSYSTEM",
|
|
71
|
+
events: [{ introduced: "2.0" }, { fixed: "2.12.2" }],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
package: {
|
|
81
|
+
name: "commons-collections:commons-collections",
|
|
82
|
+
version: "3.2.1",
|
|
83
|
+
ecosystem: "Maven",
|
|
84
|
+
},
|
|
85
|
+
groups: [
|
|
86
|
+
{
|
|
87
|
+
ids: ["GHSA-6hgm-866r-3cjv"],
|
|
88
|
+
aliases: ["CVE-2015-6420", "GHSA-6hgm-866r-3cjv"],
|
|
89
|
+
max_severity: "",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
vulnerabilities: [
|
|
93
|
+
{
|
|
94
|
+
id: "GHSA-6hgm-866r-3cjv",
|
|
95
|
+
summary: "Deserialization of untrusted data",
|
|
96
|
+
affected: [
|
|
97
|
+
{
|
|
98
|
+
package: { name: "commons-collections:commons-collections", ecosystem: "Maven" },
|
|
99
|
+
ranges: [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed: "3.2.2" }] }],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
// 同一pom.xmlが別のresults[]エントリに分かれるケース
|
|
109
|
+
source: { path: "/work/demo/pom.xml", type: "lockfile" },
|
|
110
|
+
packages: [
|
|
111
|
+
{
|
|
112
|
+
package: {
|
|
113
|
+
name: "com.fasterxml.jackson.core:jackson-core",
|
|
114
|
+
version: "2.17.0",
|
|
115
|
+
ecosystem: "Maven",
|
|
116
|
+
},
|
|
117
|
+
groups: [
|
|
118
|
+
{
|
|
119
|
+
ids: ["GHSA-h46c-h94j-95f3"],
|
|
120
|
+
aliases: ["CVE-2025-52999", "GHSA-h46c-h94j-95f3"],
|
|
121
|
+
max_severity: "7.5",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
vulnerabilities: [
|
|
125
|
+
{
|
|
126
|
+
id: "GHSA-h46c-h94j-95f3",
|
|
127
|
+
summary: "jackson-core StackOverflowError",
|
|
128
|
+
affected: [
|
|
129
|
+
{
|
|
130
|
+
package: { name: "com.fasterxml.jackson.core:jackson-core", ecosystem: "Maven" },
|
|
131
|
+
ranges: [{ type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed: "2.15.0-rc1" }] }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
experimental_config: {},
|
|
141
|
+
};
|
|
142
|
+
describe("parseOsvScanOutput", () => {
|
|
143
|
+
const report = parseOsvScanOutput(fixture);
|
|
144
|
+
it("複数のresults[]エントリをフラットに集約し、source_filesは重複しない", () => {
|
|
145
|
+
expect(report.source_files).toEqual(["/work/demo/pom.xml"]);
|
|
146
|
+
expect(report.packages.map((p) => p.name)).toContain("com.fasterxml.jackson.core:jackson-core");
|
|
147
|
+
expect(report.vulnerable_package_count).toBe(3);
|
|
148
|
+
expect(report.vulnerability_count).toBe(4);
|
|
149
|
+
});
|
|
150
|
+
it("groupsの1エントリを1脆弱性として扱い、代表ID/CVE/aliasesを整理する", () => {
|
|
151
|
+
const log4j = report.packages.find((p) => p.name.endsWith("log4j-core"));
|
|
152
|
+
const log4shell = log4j.vulnerabilities[0];
|
|
153
|
+
expect(log4shell.id).toBe("GHSA-jfh8-c2jp-5v3q");
|
|
154
|
+
expect(log4shell.cve).toBe("CVE-2021-44228");
|
|
155
|
+
// 代表IDはaliasesから除外される
|
|
156
|
+
expect(log4shell.aliases).toEqual(["CVE-2021-44228"]);
|
|
157
|
+
expect(log4shell.summary).toBe("Remote code injection in Log4j");
|
|
158
|
+
});
|
|
159
|
+
it("max_severityをスコアと5段階ラベルに変換する", () => {
|
|
160
|
+
const log4j = report.packages.find((p) => p.name.endsWith("log4j-core"));
|
|
161
|
+
expect(log4j.vulnerabilities.map((v) => [v.severity_score, v.severity])).toEqual([
|
|
162
|
+
[10.0, "critical"],
|
|
163
|
+
[9.0, "critical"],
|
|
164
|
+
]);
|
|
165
|
+
const jackson = report.packages.find((p) => p.name.endsWith("jackson-core"));
|
|
166
|
+
expect(jackson.vulnerabilities[0].severity).toBe("high");
|
|
167
|
+
});
|
|
168
|
+
it("空文字のmax_severityは例外を投げずunknown扱いにする", () => {
|
|
169
|
+
const commons = report.packages.find((p) => p.name.startsWith("commons-collections"));
|
|
170
|
+
const vuln = commons.vulnerabilities[0];
|
|
171
|
+
expect(vuln.severity_score).toBeNull();
|
|
172
|
+
expect(vuln.severity).toBe("unknown");
|
|
173
|
+
});
|
|
174
|
+
it("fixed_versionsを対象パッケージのaffectedのみからMaven昇順で収集する", () => {
|
|
175
|
+
const log4j = report.packages.find((p) => p.name.endsWith("log4j-core"));
|
|
176
|
+
const log4shell = log4j.vulnerabilities[0];
|
|
177
|
+
// 複数リリース系統(2.12系バックポートと2.15系)が混在し、99.9.9(別パッケージ)は含まれない
|
|
178
|
+
expect(log4shell.fixed_versions).toEqual(["2.12.2", "2.15.0"]);
|
|
179
|
+
// Maven優先順位規則でのソート: 2.15.0-rc1のような修飾子付きも正しく扱う
|
|
180
|
+
const jackson = report.packages.find((p) => p.name.endsWith("jackson-core"));
|
|
181
|
+
expect(jackson.vulnerabilities[0].fixed_versions).toEqual(["2.15.0-rc1"]);
|
|
182
|
+
});
|
|
183
|
+
it("パッケージは最も深刻な脆弱性を持つ順、unknownは末尾に並ぶ", () => {
|
|
184
|
+
expect(report.packages.map((p) => p.name)).toEqual([
|
|
185
|
+
"org.apache.logging.log4j:log4j-core", // 10.0
|
|
186
|
+
"com.fasterxml.jackson.core:jackson-core", // 7.5
|
|
187
|
+
"commons-collections:commons-collections", // unknown
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
it("severity_breakdownが集計される", () => {
|
|
191
|
+
expect(report.severity_breakdown).toEqual({
|
|
192
|
+
critical: 2,
|
|
193
|
+
high: 1,
|
|
194
|
+
medium: 0,
|
|
195
|
+
low: 0,
|
|
196
|
+
unknown: 1,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
it("同一パッケージ・同一groupが重複して現れても二重計上しない", () => {
|
|
200
|
+
const results = fixture.results;
|
|
201
|
+
const duplicated = parseOsvScanOutput({ results: [...results, ...results] });
|
|
202
|
+
expect(duplicated.vulnerability_count).toBe(4);
|
|
203
|
+
expect(duplicated.vulnerable_package_count).toBe(3);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe("parseOsvScanOutput (不正・欠損データへの耐性)", () => {
|
|
207
|
+
it("空オブジェクトやnullでも例外を投げず空レポートを返す", () => {
|
|
208
|
+
for (const input of [{}, null, undefined, [], "not json", 42, { results: "oops" }]) {
|
|
209
|
+
const report = parseOsvScanOutput(input);
|
|
210
|
+
expect(report.vulnerability_count).toBe(0);
|
|
211
|
+
expect(report.packages).toEqual([]);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
it("脆弱性ゼロのパッケージはレポートに含めない", () => {
|
|
215
|
+
const report = parseOsvScanOutput({
|
|
216
|
+
results: [
|
|
217
|
+
{
|
|
218
|
+
source: { path: "/x/pom.xml" },
|
|
219
|
+
packages: [
|
|
220
|
+
{ package: { name: "a:a", version: "1.0", ecosystem: "Maven" }, groups: [] },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
expect(report.vulnerable_package_count).toBe(0);
|
|
226
|
+
expect(report.source_files).toEqual(["/x/pom.xml"]);
|
|
227
|
+
});
|
|
228
|
+
it("name/versionが欠けたパッケージや空のgroupはスキップする", () => {
|
|
229
|
+
const report = parseOsvScanOutput({
|
|
230
|
+
results: [
|
|
231
|
+
{
|
|
232
|
+
packages: [
|
|
233
|
+
{ package: { name: "a:a" }, groups: [{ ids: ["GHSA-x"] }] },
|
|
234
|
+
{ package: { version: "1.0" }, groups: [{ ids: ["GHSA-y"] }] },
|
|
235
|
+
{
|
|
236
|
+
package: { name: "b:b", version: "2.0", ecosystem: "Maven" },
|
|
237
|
+
groups: [{ ids: [], aliases: [] }, { max_severity: "5.0" }],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
expect(report.vulnerability_count).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
it("巨大なsummaryは切り詰める", () => {
|
|
246
|
+
const report = parseOsvScanOutput({
|
|
247
|
+
results: [
|
|
248
|
+
{
|
|
249
|
+
packages: [
|
|
250
|
+
{
|
|
251
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
252
|
+
groups: [{ ids: ["GHSA-x"], max_severity: "5.0" }],
|
|
253
|
+
vulnerabilities: [{ id: "GHSA-x", summary: "x".repeat(2000) }],
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
const summary = report.packages[0].vulnerabilities[0].summary;
|
|
260
|
+
expect(summary.length).toBeLessThanOrEqual(501);
|
|
261
|
+
expect(summary.endsWith("…")).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
it("非数値・範囲外のmax_severityはunknown扱いにする", () => {
|
|
264
|
+
const report = parseOsvScanOutput({
|
|
265
|
+
results: [
|
|
266
|
+
{
|
|
267
|
+
packages: [
|
|
268
|
+
{
|
|
269
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
270
|
+
groups: [
|
|
271
|
+
{ ids: ["GHSA-1"], max_severity: "abc" },
|
|
272
|
+
{ ids: ["GHSA-2"], max_severity: "99" },
|
|
273
|
+
{ ids: ["GHSA-3"], max_severity: "3.1" },
|
|
274
|
+
{ ids: ["GHSA-4"], max_severity: "-1" },
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
const severities = report.packages[0].vulnerabilities.map((v) => v.severity);
|
|
282
|
+
// low(3.1)が先、unknown3件は末尾
|
|
283
|
+
expect(severities).toEqual(["low", "unknown", "unknown", "unknown"]);
|
|
284
|
+
});
|
|
285
|
+
it("オブジェクトでないgroupエントリはスキップする", () => {
|
|
286
|
+
const report = parseOsvScanOutput({
|
|
287
|
+
results: [
|
|
288
|
+
{
|
|
289
|
+
packages: [
|
|
290
|
+
{
|
|
291
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
292
|
+
groups: ["junk", null, 42, { ids: ["GHSA-valid"], max_severity: "5.0" }],
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
expect(report.vulnerability_count).toBe(1);
|
|
299
|
+
expect(report.packages[0].vulnerabilities[0].id).toBe("GHSA-valid");
|
|
300
|
+
});
|
|
301
|
+
it("スコア同点のパッケージは名前順、脆弱性はID順で安定ソートされる", () => {
|
|
302
|
+
const report = parseOsvScanOutput({
|
|
303
|
+
results: [
|
|
304
|
+
{
|
|
305
|
+
packages: [
|
|
306
|
+
{
|
|
307
|
+
package: { name: "z:z", version: "1.0", ecosystem: "Maven" },
|
|
308
|
+
groups: [{ ids: ["GHSA-z"], max_severity: "5.0" }],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
312
|
+
groups: [
|
|
313
|
+
{ ids: ["GHSA-b"], max_severity: "5.0" },
|
|
314
|
+
{ ids: ["GHSA-a"], max_severity: "5.0" },
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
expect(report.packages.map((p) => p.name)).toEqual(["a:a", "z:z"]);
|
|
322
|
+
expect(report.packages[0].vulnerabilities.map((v) => v.id)).toEqual(["GHSA-a", "GHSA-b"]);
|
|
323
|
+
});
|
|
324
|
+
it("GHSAが無くCVEのみのグループは代表ID自体をcveとして扱う", () => {
|
|
325
|
+
const report = parseOsvScanOutput({
|
|
326
|
+
results: [
|
|
327
|
+
{
|
|
328
|
+
packages: [
|
|
329
|
+
{
|
|
330
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
331
|
+
groups: [{ ids: ["CVE-2024-0001"], aliases: [], max_severity: "5.0" }],
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
const vuln = report.packages[0].vulnerabilities[0];
|
|
338
|
+
expect(vuln.id).toBe("CVE-2024-0001");
|
|
339
|
+
expect(vuln.cve).toBe("CVE-2024-0001");
|
|
340
|
+
expect(vuln.aliases).toEqual([]);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { suggestUpgradeForPackage, suggestUpgrades } from "../../osv/suggestFix.js";
|
|
3
|
+
function vuln(id, cve, fixedVersions, severity = "high") {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
cve,
|
|
7
|
+
aliases: cve !== null ? [cve] : [],
|
|
8
|
+
severity_score: null,
|
|
9
|
+
severity,
|
|
10
|
+
summary: null,
|
|
11
|
+
fixed_versions: fixedVersions,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function pkg(name, version, vulnerabilities) {
|
|
15
|
+
return { name, version, ecosystem: "Maven", vulnerabilities };
|
|
16
|
+
}
|
|
17
|
+
describe("suggestUpgradeForPackage", () => {
|
|
18
|
+
it("設計メモの確定例: log4j-core 2.14.1は2.25.4/major_internalになる", () => {
|
|
19
|
+
// 実スキャン(2026-07-04)で取得したfixed_versionsをそのまま使用
|
|
20
|
+
const log4j = pkg("org.apache.logging.log4j:log4j-core", "2.14.1", [
|
|
21
|
+
vuln("GHSA-jfh8-c2jp-5v3q", "CVE-2021-44228", ["2.3.1", "2.12.2", "2.15.0"], "critical"),
|
|
22
|
+
vuln("GHSA-7rjr-3q55-vv33", "CVE-2021-45046", ["2.12.2", "2.16.0"], "critical"),
|
|
23
|
+
vuln("GHSA-p6xc-xr62-6r2g", "CVE-2021-45105", ["2.3.1", "2.12.3", "2.17.0"]),
|
|
24
|
+
vuln("GHSA-8489-44mv-ggj8", "CVE-2021-44832", ["2.3.2", "2.12.4", "2.17.1"], "medium"),
|
|
25
|
+
vuln("GHSA-3pxv-7cmr-fjr4", "CVE-2026-34480", ["2.25.4"], "medium"),
|
|
26
|
+
vuln("GHSA-6hg6-v5c8-fphq", "CVE-2026-34477", ["2.25.4"], "medium"),
|
|
27
|
+
vuln("GHSA-vc5p-v9hr-52mj", "CVE-2025-68161", ["2.25.3"], "medium"),
|
|
28
|
+
]);
|
|
29
|
+
const suggestion = suggestUpgradeForPackage(log4j);
|
|
30
|
+
// 2.14系統向けの修正版は存在しない → 同一メジャー内の最大 2.25.4
|
|
31
|
+
expect(suggestion.recommended_upgrade).toBe("2.25.4");
|
|
32
|
+
expect(suggestion.upgrade_tier).toBe("major_internal");
|
|
33
|
+
expect(suggestion.upgrade_note).toContain("2.14系統向けの修正版は存在しない");
|
|
34
|
+
expect(suggestion.upgrade_note).toContain("2.25.4");
|
|
35
|
+
// CVEごとのTier: 2.14.1より古いバックポート(2.3.x/2.12.x)は候補にならない
|
|
36
|
+
const log4shell = suggestion.per_cve_detail.find((d) => d.cve === "CVE-2021-44228");
|
|
37
|
+
expect(log4shell.fixed_in).toBe("2.15.0");
|
|
38
|
+
expect(log4shell.tier).toBe("major_internal");
|
|
39
|
+
expect(suggestion.per_cve_detail.every((d) => d.tier === "major_internal")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it("Tier 1: 同一major.minor系統内の最小修正版を優先する", () => {
|
|
42
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "2.14.1", [vuln("GHSA-1", "CVE-1", ["2.15.0", "2.14.3", "2.14.2"])]));
|
|
43
|
+
expect(suggestion.per_cve_detail[0].fixed_in).toBe("2.14.2");
|
|
44
|
+
expect(suggestion.per_cve_detail[0].tier).toBe("same_minor");
|
|
45
|
+
expect(suggestion.recommended_upgrade).toBe("2.14.2");
|
|
46
|
+
expect(suggestion.upgrade_tier).toBe("same_minor");
|
|
47
|
+
expect(suggestion.upgrade_note).toContain("現在の2.14系統内");
|
|
48
|
+
});
|
|
49
|
+
it("Tier 3: 同一メジャー内に修正版がなければメジャーアップグレードを明示する", () => {
|
|
50
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "1.2.3", [vuln("GHSA-1", "CVE-1", ["2.0.0", "3.0.0"])]));
|
|
51
|
+
expect(suggestion.recommended_upgrade).toBe("2.0.0");
|
|
52
|
+
expect(suggestion.upgrade_tier).toBe("cross_major");
|
|
53
|
+
expect(suggestion.upgrade_note).toContain("メジャーアップグレード");
|
|
54
|
+
expect(suggestion.upgrade_note).toContain("破壊的変更");
|
|
55
|
+
});
|
|
56
|
+
it("複数CVEの推奨は「全CVEを解消できる最小バージョン」(Tier結果の最大)", () => {
|
|
57
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "2.14.1", [
|
|
58
|
+
vuln("GHSA-1", "CVE-1", ["2.14.2"]), // same_minorで解消可能
|
|
59
|
+
vuln("GHSA-2", "CVE-2", ["2.16.0"]), // major_internalが必要
|
|
60
|
+
]));
|
|
61
|
+
expect(suggestion.recommended_upgrade).toBe("2.16.0");
|
|
62
|
+
// 実際のアップグレード距離で再分類される(same_minorではない)
|
|
63
|
+
expect(suggestion.upgrade_tier).toBe("major_internal");
|
|
64
|
+
});
|
|
65
|
+
it("unfixed: 修正版が空、または現在以下しか無いCVEは推奨計算から除外して明示する", () => {
|
|
66
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "2.14.1", [
|
|
67
|
+
vuln("GHSA-1", "CVE-1", []), // 修正版なし
|
|
68
|
+
vuln("GHSA-2", "CVE-2", ["2.12.2"]), // 別ブランチ向けバックポートのみ
|
|
69
|
+
vuln("GHSA-3", "CVE-3", ["2.15.0"]),
|
|
70
|
+
]));
|
|
71
|
+
expect(suggestion.recommended_upgrade).toBe("2.15.0");
|
|
72
|
+
const tiers = Object.fromEntries(suggestion.per_cve_detail.map((d) => [d.id, d.tier]));
|
|
73
|
+
expect(tiers).toEqual({ "GHSA-1": "unfixed", "GHSA-2": "unfixed", "GHSA-3": "major_internal" });
|
|
74
|
+
expect(suggestion.upgrade_note).toContain("残り2件は修正版が存在せず");
|
|
75
|
+
});
|
|
76
|
+
it("全CVEがunfixedならrecommended_upgradeはnull", () => {
|
|
77
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "3.2.1", [vuln("GHSA-1", "CVE-1", []), vuln("GHSA-2", null, ["3.0.0"])]));
|
|
78
|
+
expect(suggestion.recommended_upgrade).toBeNull();
|
|
79
|
+
expect(suggestion.upgrade_tier).toBeNull();
|
|
80
|
+
expect(suggestion.upgrade_note).toContain("全2件");
|
|
81
|
+
expect(suggestion.upgrade_note).toContain("unfixed");
|
|
82
|
+
});
|
|
83
|
+
it("系統を判定できないバージョンはcross_major扱いで全体最小を提示する", () => {
|
|
84
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "unknown-version", [vuln("GHSA-1", "CVE-1", ["1.2.3", "2.0.0"])]));
|
|
85
|
+
expect(suggestion.recommended_upgrade).toBe("1.2.3");
|
|
86
|
+
expect(suggestion.upgrade_tier).toBe("cross_major");
|
|
87
|
+
expect(suggestion.upgrade_note).toContain("系統を判定できない");
|
|
88
|
+
});
|
|
89
|
+
it("Maven優先順位で比較する(2.9.0 < 2.10.0、修飾子付きも正しく扱う)", () => {
|
|
90
|
+
const suggestion = suggestUpgradeForPackage(pkg("a:a", "2.9.0", [vuln("GHSA-1", "CVE-1", ["2.10.0", "2.9.1-RELEASE"])]));
|
|
91
|
+
// 2.9.1-RELEASE(=2.9.1)が同一系統内の修正版として選ばれる
|
|
92
|
+
expect(suggestion.per_cve_detail[0].fixed_in).toBe("2.9.1-RELEASE");
|
|
93
|
+
expect(suggestion.per_cve_detail[0].tier).toBe("same_minor");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("suggestUpgrades", () => {
|
|
97
|
+
it("パッケージの並び順(深刻度順)を維持したまま提案一覧を返す", () => {
|
|
98
|
+
const suggestions = suggestUpgrades([
|
|
99
|
+
pkg("a:critical-pkg", "1.0", [vuln("GHSA-1", "CVE-1", ["1.1"], "critical")]),
|
|
100
|
+
pkg("b:low-pkg", "1.0", [vuln("GHSA-2", "CVE-2", ["1.2"], "low")]),
|
|
101
|
+
]);
|
|
102
|
+
expect(suggestions.map((s) => s.package)).toEqual(["a:critical-pkg", "b:low-pkg"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { handleExplainVulnerability } from "../../tools/explainVulnerability.js";
|
|
3
|
+
function parsePayload(result) {
|
|
4
|
+
return JSON.parse(result.content[0].text);
|
|
5
|
+
}
|
|
6
|
+
describe("handleExplainVulnerability", () => {
|
|
7
|
+
it("取得したレコードを構造化して返す", async () => {
|
|
8
|
+
const result = await handleExplainVulnerability({ vulnerability_id: "GHSA-jfh8-c2jp-5v3q" }, {
|
|
9
|
+
fetchFn: async () => new Response(JSON.stringify({
|
|
10
|
+
id: "GHSA-jfh8-c2jp-5v3q",
|
|
11
|
+
aliases: ["CVE-2021-44228"],
|
|
12
|
+
summary: "Remote code injection in Log4j",
|
|
13
|
+
}), { status: 200 }),
|
|
14
|
+
});
|
|
15
|
+
expect(result.isError).toBeUndefined();
|
|
16
|
+
const payload = parsePayload(result);
|
|
17
|
+
expect(payload.id).toBe("GHSA-jfh8-c2jp-5v3q");
|
|
18
|
+
expect(payload.aliases).toEqual(["CVE-2021-44228"]);
|
|
19
|
+
});
|
|
20
|
+
it("不正なIDはinvalid_vulnerability_idエラーになる(fetchは呼ばれない)", async () => {
|
|
21
|
+
let fetchCalled = false;
|
|
22
|
+
const result = await handleExplainVulnerability({ vulnerability_id: "../v1/query" }, {
|
|
23
|
+
fetchFn: async () => {
|
|
24
|
+
fetchCalled = true;
|
|
25
|
+
return new Response("{}", { status: 200 });
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
expect(result.isError).toBe(true);
|
|
29
|
+
expect(fetchCalled).toBe(false);
|
|
30
|
+
const payload = parsePayload(result);
|
|
31
|
+
expect(payload.error.kind).toBe("invalid_vulnerability_id");
|
|
32
|
+
});
|
|
33
|
+
it("404はvulnerability_not_foundエラーとして返る", async () => {
|
|
34
|
+
const result = await handleExplainVulnerability({ vulnerability_id: "GHSA-none-none-none" }, { fetchFn: async () => new Response("{}", { status: 404 }) });
|
|
35
|
+
expect(result.isError).toBe(true);
|
|
36
|
+
const payload = parsePayload(result);
|
|
37
|
+
expect(payload.error.kind).toBe("vulnerability_not_found");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { handleScanJavaProject } from "../../tools/scanJavaProject.js";
|
|
6
|
+
let binDir;
|
|
7
|
+
let projectDir;
|
|
8
|
+
async function makeFakeBinary(name, script) {
|
|
9
|
+
const filePath = path.join(binDir, name);
|
|
10
|
+
await writeFile(filePath, `#!/bin/sh\n${script}\n`);
|
|
11
|
+
await chmod(filePath, 0o755);
|
|
12
|
+
return filePath;
|
|
13
|
+
}
|
|
14
|
+
const VULN_JSON = JSON.stringify({
|
|
15
|
+
results: [
|
|
16
|
+
{
|
|
17
|
+
source: { path: "/x/pom.xml" },
|
|
18
|
+
packages: [
|
|
19
|
+
{
|
|
20
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
21
|
+
groups: [{ ids: ["GHSA-test"], aliases: ["CVE-2020-1"], max_severity: "9.9" }],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
function parsePayload(result) {
|
|
28
|
+
expect(result.content).toHaveLength(1);
|
|
29
|
+
expect(result.content[0].type).toBe("text");
|
|
30
|
+
return JSON.parse(result.content[0].text);
|
|
31
|
+
}
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
binDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-tool-bin-"));
|
|
34
|
+
projectDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-tool-proj-"));
|
|
35
|
+
await writeFile(path.join(projectDir, "pom.xml"), "<project/>");
|
|
36
|
+
});
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
await rm(binDir, { recursive: true, force: true });
|
|
39
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
describe("handleScanJavaProject", () => {
|
|
42
|
+
it("成功時はproject_dir/manifests付きのレポートJSONを返す", async () => {
|
|
43
|
+
const bin = await makeFakeBinary("ok", `echo '${VULN_JSON}'; exit 1`);
|
|
44
|
+
const result = await handleScanJavaProject({ project_path: projectDir }, { binaryPath: bin });
|
|
45
|
+
expect(result.isError).toBeUndefined();
|
|
46
|
+
const payload = parsePayload(result);
|
|
47
|
+
expect(payload.manifests).toEqual(["pom.xml"]);
|
|
48
|
+
expect(payload.vulnerability_count).toBe(1);
|
|
49
|
+
expect(typeof payload.project_dir).toBe("string");
|
|
50
|
+
});
|
|
51
|
+
it("ScanToolErrorはisError+kind付きのエラーJSONに変換される", async () => {
|
|
52
|
+
const result = await handleScanJavaProject({ project_path: "/no/such/path" }, { binaryPath: "/unused" });
|
|
53
|
+
expect(result.isError).toBe(true);
|
|
54
|
+
const payload = parsePayload(result);
|
|
55
|
+
expect(payload.error.kind).toBe("project_not_found");
|
|
56
|
+
expect(payload.error.message).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
it("スキャン失敗時はstderr抜粋がdetailに入る", async () => {
|
|
59
|
+
const bin = await makeFakeBinary("broken", `echo 'kaboom' >&2; exit 127`);
|
|
60
|
+
const result = await handleScanJavaProject({ project_path: projectDir }, { binaryPath: bin });
|
|
61
|
+
expect(result.isError).toBe(true);
|
|
62
|
+
const payload = parsePayload(result);
|
|
63
|
+
expect(payload.error.kind).toBe("scan_failed");
|
|
64
|
+
expect(payload.error.detail).toContain("kaboom");
|
|
65
|
+
});
|
|
66
|
+
it("ScanToolError以外の想定外例外はinternal_errorに丸め、内部情報を漏らさない", async () => {
|
|
67
|
+
// 空文字のbinaryPathはspawnがTypeErrorを投げる(ScanToolErrorではない例外の代表例)
|
|
68
|
+
const result = await handleScanJavaProject({ project_path: projectDir }, { binaryPath: "" });
|
|
69
|
+
expect(result.isError).toBe(true);
|
|
70
|
+
const payload = parsePayload(result);
|
|
71
|
+
expect(payload.error.kind).toBe("internal_error");
|
|
72
|
+
expect(payload.error.message).not.toContain("TypeError");
|
|
73
|
+
expect(JSON.stringify(payload)).not.toContain("stack");
|
|
74
|
+
});
|
|
75
|
+
it("allowedRoot外のパスはpath_outside_allowed_rootで拒否される", async () => {
|
|
76
|
+
const otherRoot = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-tool-root-"));
|
|
77
|
+
try {
|
|
78
|
+
const result = await handleScanJavaProject({ project_path: projectDir }, { binaryPath: "/unused", allowedRoot: otherRoot });
|
|
79
|
+
expect(result.isError).toBe(true);
|
|
80
|
+
const payload = parsePayload(result);
|
|
81
|
+
expect(payload.error.kind).toBe("path_outside_allowed_root");
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await rm(otherRoot, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import { handleSuggestFix } from "../../tools/suggestFix.js";
|
|
6
|
+
let binDir;
|
|
7
|
+
let projectDir;
|
|
8
|
+
async function makeFakeBinary(name, script) {
|
|
9
|
+
const filePath = path.join(binDir, name);
|
|
10
|
+
await writeFile(filePath, `#!/bin/sh\n${script}\n`);
|
|
11
|
+
await chmod(filePath, 0o755);
|
|
12
|
+
return filePath;
|
|
13
|
+
}
|
|
14
|
+
// 2.14.1に対し、same_minor修正なし・同一メジャー内の修正(2.15.0)あり、
|
|
15
|
+
// さらにunfixedなCVEを1件含むスキャン結果
|
|
16
|
+
const SCAN_JSON = JSON.stringify({
|
|
17
|
+
results: [
|
|
18
|
+
{
|
|
19
|
+
source: { path: "/x/pom.xml" },
|
|
20
|
+
packages: [
|
|
21
|
+
{
|
|
22
|
+
package: { name: "a:a", version: "2.14.1", ecosystem: "Maven" },
|
|
23
|
+
groups: [
|
|
24
|
+
{ ids: ["GHSA-fix"], aliases: ["CVE-2021-1"], max_severity: "9.0" },
|
|
25
|
+
{ ids: ["GHSA-unfixed"], aliases: ["CVE-2021-2"], max_severity: "5.0" },
|
|
26
|
+
],
|
|
27
|
+
vulnerabilities: [
|
|
28
|
+
{
|
|
29
|
+
id: "GHSA-fix",
|
|
30
|
+
affected: [
|
|
31
|
+
{
|
|
32
|
+
package: { name: "a:a", ecosystem: "Maven" },
|
|
33
|
+
ranges: [
|
|
34
|
+
{ type: "ECOSYSTEM", events: [{ introduced: "0" }, { fixed: "2.12.2" }] },
|
|
35
|
+
{ type: "ECOSYSTEM", events: [{ introduced: "2.13.0" }, { fixed: "2.15.0" }] },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{ id: "GHSA-unfixed", affected: [] },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
function parsePayload(result) {
|
|
48
|
+
return JSON.parse(result.content[0].text);
|
|
49
|
+
}
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
binDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-fix-bin-"));
|
|
52
|
+
projectDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-fix-proj-"));
|
|
53
|
+
await writeFile(path.join(projectDir, "pom.xml"), "<project/>");
|
|
54
|
+
});
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await rm(binDir, { recursive: true, force: true });
|
|
57
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
describe("handleSuggestFix", () => {
|
|
60
|
+
it("スキャン結果から3段階Tierの提案を組み立てて返す", async () => {
|
|
61
|
+
const bin = await makeFakeBinary("ok", `echo '${SCAN_JSON}'; exit 1`);
|
|
62
|
+
const result = await handleSuggestFix({ project_path: projectDir }, { binaryPath: bin });
|
|
63
|
+
expect(result.isError).toBeUndefined();
|
|
64
|
+
const payload = parsePayload(result);
|
|
65
|
+
expect(payload.vulnerable_package_count).toBe(1);
|
|
66
|
+
expect(payload.unfixed_vulnerability_count).toBe(1);
|
|
67
|
+
const suggestion = payload.suggestions[0];
|
|
68
|
+
expect(suggestion.recommended_upgrade).toBe("2.15.0");
|
|
69
|
+
expect(suggestion.upgrade_tier).toBe("major_internal");
|
|
70
|
+
const tiers = Object.fromEntries(suggestion.per_cve_detail.map((d) => [d.id, d.tier]));
|
|
71
|
+
expect(tiers).toEqual({ "GHSA-fix": "major_internal", "GHSA-unfixed": "unfixed" });
|
|
72
|
+
});
|
|
73
|
+
it("エラーはscan_java_projectと同じ形式(kind付きJSON)で返す", async () => {
|
|
74
|
+
const result = await handleSuggestFix({ project_path: "/no/such/path" }, { binaryPath: "/unused" });
|
|
75
|
+
expect(result.isError).toBe(true);
|
|
76
|
+
const payload = parsePayload(result);
|
|
77
|
+
expect(payload.error.kind).toBe("project_not_found");
|
|
78
|
+
});
|
|
79
|
+
});
|