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,117 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
import { ScanToolError } from "../../errors.js";
|
|
7
|
+
import { assetNameForPlatform, ensureOsvScannerDownloaded, PINNED_OSV_SCANNER_VERSION, } from "../../osv/binaryDownloader.js";
|
|
8
|
+
const FAKE_BINARY = Buffer.from("#!/bin/sh\necho fake-osv-scanner\n");
|
|
9
|
+
const FAKE_SHA256 = createHash("sha256").update(FAKE_BINARY).digest("hex");
|
|
10
|
+
let cacheDir;
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
cacheDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-dl-"));
|
|
13
|
+
});
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
/** darwin/arm64固定でテストする(実行環境に依存させない) */
|
|
18
|
+
function options(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
cacheDir,
|
|
21
|
+
platform: "darwin",
|
|
22
|
+
arch: "arm64",
|
|
23
|
+
checksums: { "osv-scanner_darwin_arm64": FAKE_SHA256 },
|
|
24
|
+
fetchFn: async () => new Response(new Uint8Array(FAKE_BINARY), { status: 200 }),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function expectScanError(promise, kind) {
|
|
29
|
+
const error = await promise.then(() => null, (e) => e);
|
|
30
|
+
expect(error).toBeInstanceOf(ScanToolError);
|
|
31
|
+
expect(error.kind).toBe(kind);
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
describe("assetNameForPlatform", () => {
|
|
35
|
+
it("対応プラットフォームの公式資産名を組み立てる", () => {
|
|
36
|
+
expect(assetNameForPlatform("darwin", "arm64")).toBe("osv-scanner_darwin_arm64");
|
|
37
|
+
expect(assetNameForPlatform("linux", "x64")).toBe("osv-scanner_linux_amd64");
|
|
38
|
+
expect(assetNameForPlatform("win32", "x64")).toBe("osv-scanner_windows_amd64.exe");
|
|
39
|
+
});
|
|
40
|
+
it("未対応プラットフォームはnull", () => {
|
|
41
|
+
expect(assetNameForPlatform("sunos", "x64")).toBeNull();
|
|
42
|
+
expect(assetNameForPlatform("linux", "ia32")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("ensureOsvScannerDownloaded", () => {
|
|
46
|
+
it("ダウンロード→検証→実行権限付きで配置し、正しいURLを参照する", async () => {
|
|
47
|
+
let requestedUrl = "";
|
|
48
|
+
const binPath = await ensureOsvScannerDownloaded(options({
|
|
49
|
+
fetchFn: async (url) => {
|
|
50
|
+
requestedUrl = String(url);
|
|
51
|
+
return new Response(new Uint8Array(FAKE_BINARY), { status: 200 });
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
expect(requestedUrl).toBe(`https://github.com/google/osv-scanner/releases/download/v${PINNED_OSV_SCANNER_VERSION}/osv-scanner_darwin_arm64`);
|
|
55
|
+
expect(await readFile(binPath)).toEqual(FAKE_BINARY);
|
|
56
|
+
const mode = (await stat(binPath)).mode;
|
|
57
|
+
expect(mode & 0o111).not.toBe(0); // 実行権限あり
|
|
58
|
+
});
|
|
59
|
+
it("2回目以降はキャッシュを使いダウンロードしない", async () => {
|
|
60
|
+
let fetchCount = 0;
|
|
61
|
+
const opts = options({
|
|
62
|
+
fetchFn: async () => {
|
|
63
|
+
fetchCount++;
|
|
64
|
+
return new Response(new Uint8Array(FAKE_BINARY), { status: 200 });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
await ensureOsvScannerDownloaded(opts); // キャッシュ済み(前テストで配置)
|
|
68
|
+
expect(fetchCount).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
it("キャッシュが改ざんされていたら再ダウンロードして修復する", async () => {
|
|
71
|
+
const binPath = path.join(cacheDir, `v${PINNED_OSV_SCANNER_VERSION}`, "osv-scanner_darwin_arm64");
|
|
72
|
+
await writeFile(binPath, "tampered!");
|
|
73
|
+
let fetchCount = 0;
|
|
74
|
+
const restored = await ensureOsvScannerDownloaded(options({
|
|
75
|
+
fetchFn: async () => {
|
|
76
|
+
fetchCount++;
|
|
77
|
+
return new Response(new Uint8Array(FAKE_BINARY), { status: 200 });
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
expect(fetchCount).toBe(1);
|
|
81
|
+
expect(await readFile(restored)).toEqual(FAKE_BINARY);
|
|
82
|
+
});
|
|
83
|
+
it("チェックサム不一致はbinary_checksum_mismatchでバイナリを配置しない", async () => {
|
|
84
|
+
const isolated = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-dl-bad-"));
|
|
85
|
+
try {
|
|
86
|
+
const error = await expectScanError(ensureOsvScannerDownloaded(options({
|
|
87
|
+
cacheDir: isolated,
|
|
88
|
+
checksums: { "osv-scanner_darwin_arm64": "0".repeat(64) },
|
|
89
|
+
})), "binary_checksum_mismatch");
|
|
90
|
+
expect(error.message).toContain("改ざんまたは破損");
|
|
91
|
+
// 検証に失敗したバイナリはキャッシュに残らない
|
|
92
|
+
const target = path.join(isolated, `v${PINNED_OSV_SCANNER_VERSION}`, "osv-scanner_darwin_arm64");
|
|
93
|
+
await expect(stat(target)).rejects.toThrow();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await rm(isolated, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it("HTTPエラーはbinary_download_failed", async () => {
|
|
100
|
+
await expectScanError(ensureOsvScannerDownloaded(options({
|
|
101
|
+
cacheDir: await mkdtemp(path.join(os.tmpdir(), "osv-mcp-dl-404-")),
|
|
102
|
+
fetchFn: async () => new Response("not found", { status: 404 }),
|
|
103
|
+
})), "binary_download_failed");
|
|
104
|
+
});
|
|
105
|
+
it("ネットワークエラー・タイムアウトはbinary_download_failed", async () => {
|
|
106
|
+
await expectScanError(ensureOsvScannerDownloaded(options({
|
|
107
|
+
cacheDir: await mkdtemp(path.join(os.tmpdir(), "osv-mcp-dl-net-")),
|
|
108
|
+
fetchFn: async () => {
|
|
109
|
+
throw new TypeError("fetch failed");
|
|
110
|
+
},
|
|
111
|
+
})), "binary_download_failed");
|
|
112
|
+
});
|
|
113
|
+
it("未対応プラットフォームはbinary_download_failed(手動導入を案内)", async () => {
|
|
114
|
+
const error = await expectScanError(ensureOsvScannerDownloaded(options({ platform: "sunos" })), "binary_download_failed");
|
|
115
|
+
expect(error.message).toContain("OSV_SCANNER_PATH");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ScanToolError } from "../../errors.js";
|
|
3
|
+
import { fetchOsvRecord, validateVulnerabilityId } from "../../osv/osvApi.js";
|
|
4
|
+
import { buildVulnerabilityExplanation } from "../../osv/vulnerabilityExplanation.js";
|
|
5
|
+
async function expectScanError(promise, kind) {
|
|
6
|
+
const error = await promise.then(() => null, (e) => e);
|
|
7
|
+
expect(error).toBeInstanceOf(ScanToolError);
|
|
8
|
+
expect(error.kind).toBe(kind);
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
function jsonResponse(payload, status = 200) {
|
|
12
|
+
return new Response(JSON.stringify(payload), {
|
|
13
|
+
status,
|
|
14
|
+
headers: { "content-type": "application/json" },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
describe("validateVulnerabilityId", () => {
|
|
18
|
+
it("GHSA/CVE形式のIDを受け付ける(前後の空白は除去)", () => {
|
|
19
|
+
expect(validateVulnerabilityId("GHSA-jfh8-c2jp-5v3q")).toBe("GHSA-jfh8-c2jp-5v3q");
|
|
20
|
+
expect(validateVulnerabilityId("CVE-2021-44228")).toBe("CVE-2021-44228");
|
|
21
|
+
expect(validateVulnerabilityId(" CVE-2021-44228 ")).toBe("CVE-2021-44228");
|
|
22
|
+
});
|
|
23
|
+
it("URLインジェクションにつながる文字を含むIDを拒否する", () => {
|
|
24
|
+
for (const bad of [
|
|
25
|
+
"../../../v1/query",
|
|
26
|
+
"CVE-2021-44228/../secret",
|
|
27
|
+
"id with space",
|
|
28
|
+
"id?query=1",
|
|
29
|
+
"id#frag",
|
|
30
|
+
"",
|
|
31
|
+
"ab",
|
|
32
|
+
"x".repeat(65),
|
|
33
|
+
]) {
|
|
34
|
+
expect(() => validateVulnerabilityId(bad), bad).toThrowError(expect.objectContaining({ kind: "invalid_vulnerability_id" }));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("fetchOsvRecord", () => {
|
|
39
|
+
it("200レスポンスのJSONを返す", async () => {
|
|
40
|
+
const record = await fetchOsvRecord("GHSA-test-test-test", {
|
|
41
|
+
fetchFn: async (url) => {
|
|
42
|
+
expect(String(url)).toBe("https://api.osv.dev/v1/vulns/GHSA-test-test-test");
|
|
43
|
+
return jsonResponse({ id: "GHSA-test-test-test" });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
expect(record).toEqual({ id: "GHSA-test-test-test" });
|
|
47
|
+
});
|
|
48
|
+
it("404はvulnerability_not_found", async () => {
|
|
49
|
+
await expectScanError(fetchOsvRecord("GHSA-none-none-none", {
|
|
50
|
+
fetchFn: async () => jsonResponse({ error: "not found" }, 404),
|
|
51
|
+
}), "vulnerability_not_found");
|
|
52
|
+
});
|
|
53
|
+
it("5xxはapi_request_failed", async () => {
|
|
54
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", { fetchFn: async () => jsonResponse({}, 500) }), "api_request_failed");
|
|
55
|
+
});
|
|
56
|
+
it("ネットワークエラーはapi_request_failed", async () => {
|
|
57
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
58
|
+
fetchFn: async () => {
|
|
59
|
+
throw new TypeError("fetch failed");
|
|
60
|
+
},
|
|
61
|
+
}), "api_request_failed");
|
|
62
|
+
});
|
|
63
|
+
it("タイムアウトはapi_request_failedとして報告される", async () => {
|
|
64
|
+
const error = await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
65
|
+
timeoutMs: 50,
|
|
66
|
+
// signalを尊重するfetch実装を模倣(abortされるまで解決しない)
|
|
67
|
+
fetchFn: (_url, init) => new Promise((_resolve, reject) => {
|
|
68
|
+
init?.signal?.addEventListener("abort", () => reject(init.signal.reason));
|
|
69
|
+
}),
|
|
70
|
+
}), "api_request_failed");
|
|
71
|
+
expect(error.message).toContain("秒以内に完了しませんでした");
|
|
72
|
+
});
|
|
73
|
+
it("サイズ上限を超えるレスポンスはoutput_too_large", async () => {
|
|
74
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
75
|
+
maxResponseBytes: 100,
|
|
76
|
+
fetchFn: async () => new Response("x".repeat(200), { status: 200 }),
|
|
77
|
+
}), "output_too_large");
|
|
78
|
+
});
|
|
79
|
+
it("サイズ上限はバイト数で判定する(マルチバイト文字で文字数と乖離するケース)", async () => {
|
|
80
|
+
// 「脆」= UTF-8で3バイト。40文字(120バイト)は文字数では上限100未満だがバイト数では超過
|
|
81
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
82
|
+
maxResponseBytes: 100,
|
|
83
|
+
fetchFn: async () => new Response("脆".repeat(40), { status: 200 }),
|
|
84
|
+
}), "output_too_large");
|
|
85
|
+
});
|
|
86
|
+
it("Content-Lengthヘッダが上限超過を示す場合は本文を読まずにoutput_too_large", async () => {
|
|
87
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
88
|
+
maxResponseBytes: 100,
|
|
89
|
+
fetchFn: async () => new Response("{}", { status: 200, headers: { "content-length": "9999999" } }),
|
|
90
|
+
}), "output_too_large");
|
|
91
|
+
});
|
|
92
|
+
it("JSONでないレスポンスはapi_request_failed", async () => {
|
|
93
|
+
await expectScanError(fetchOsvRecord("CVE-2021-44228", {
|
|
94
|
+
fetchFn: async () => new Response("<html>oops</html>", { status: 200 }),
|
|
95
|
+
}), "api_request_failed");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("buildVulnerabilityExplanation", () => {
|
|
99
|
+
it("OSVレコードの主要フィールドを構造化する", () => {
|
|
100
|
+
const explanation = buildVulnerabilityExplanation({
|
|
101
|
+
id: "GHSA-jfh8-c2jp-5v3q",
|
|
102
|
+
aliases: ["CVE-2021-44228"],
|
|
103
|
+
summary: "Remote code injection in Log4j",
|
|
104
|
+
details: "Log4j versions prior to 2.15.0 are subject to RCE...",
|
|
105
|
+
severity: [{ type: "CVSS_V3", score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" }],
|
|
106
|
+
published: "2021-12-10T00:00:00Z",
|
|
107
|
+
modified: "2025-01-01T00:00:00Z",
|
|
108
|
+
affected: [
|
|
109
|
+
{
|
|
110
|
+
package: { name: "org.apache.logging.log4j:log4j-core", ecosystem: "Maven" },
|
|
111
|
+
ranges: [
|
|
112
|
+
{
|
|
113
|
+
type: "ECOSYSTEM",
|
|
114
|
+
events: [{ introduced: "2.13.0" }, { fixed: "2.15.0" }],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: "ECOSYSTEM",
|
|
118
|
+
events: [{ introduced: "2.4" }, { last_affected: "2.12.1" }],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
references: [
|
|
124
|
+
{ type: "ADVISORY", url: "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" },
|
|
125
|
+
{ type: "WEB", url: "ftp://evil.example/exploit" },
|
|
126
|
+
{ url: "https://github.com/apache/logging-log4j2/pull/608" },
|
|
127
|
+
],
|
|
128
|
+
}, "GHSA-jfh8-c2jp-5v3q");
|
|
129
|
+
expect(explanation.id).toBe("GHSA-jfh8-c2jp-5v3q");
|
|
130
|
+
expect(explanation.aliases).toEqual(["CVE-2021-44228"]);
|
|
131
|
+
expect(explanation.severity[0].score).toContain("CVSS:3.1");
|
|
132
|
+
expect(explanation.affected[0].ranges).toEqual([
|
|
133
|
+
{ introduced: "2.13.0", fixed: "2.15.0", last_affected: null },
|
|
134
|
+
{ introduced: "2.4", fixed: null, last_affected: "2.12.1" },
|
|
135
|
+
]);
|
|
136
|
+
// http/https以外の参照URLは除外、typeが無ければWEB扱い
|
|
137
|
+
expect(explanation.references).toEqual([
|
|
138
|
+
{ type: "ADVISORY", url: "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" },
|
|
139
|
+
{ type: "WEB", url: "https://github.com/apache/logging-log4j2/pull/608" },
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
it("巨大なdetailsは切り詰める", () => {
|
|
143
|
+
const explanation = buildVulnerabilityExplanation({ id: "X-1", details: "d".repeat(10_000) }, "X-1");
|
|
144
|
+
expect(explanation.details.length).toBeLessThanOrEqual(4001);
|
|
145
|
+
expect(explanation.details.endsWith("…")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
it("件数上限を超える配列は上限で打ち切る(DoS・プロンプト肥大対策)", () => {
|
|
148
|
+
const many = (n, make) => Array.from({ length: n }, (_, i) => make(i));
|
|
149
|
+
const explanation = buildVulnerabilityExplanation({
|
|
150
|
+
id: "X-1",
|
|
151
|
+
aliases: many(30, (i) => `ALIAS-${i}`),
|
|
152
|
+
severity: many(10, (i) => ({ type: `T${i}`, score: `S${i}` })),
|
|
153
|
+
affected: many(15, (i) => ({
|
|
154
|
+
package: { name: `pkg-${i}`, ecosystem: "Maven" },
|
|
155
|
+
ranges: [{ events: many(30, (j) => ({ introduced: `${j}.0` })) }],
|
|
156
|
+
})),
|
|
157
|
+
references: many(30, (i) => ({ type: "WEB", url: `https://example.com/${i}` })),
|
|
158
|
+
}, "X-1");
|
|
159
|
+
expect(explanation.aliases).toHaveLength(20);
|
|
160
|
+
expect(explanation.severity).toHaveLength(5);
|
|
161
|
+
expect(explanation.affected).toHaveLength(10);
|
|
162
|
+
expect(explanation.affected[0].ranges).toHaveLength(10);
|
|
163
|
+
expect(explanation.references).toHaveLength(20);
|
|
164
|
+
});
|
|
165
|
+
it("欠損・型不正のレコードでも例外を投げない(idはリクエスト値でフォールバック)", () => {
|
|
166
|
+
for (const raw of [null, {}, "junk", { affected: "oops", references: 42, severity: {} }]) {
|
|
167
|
+
const explanation = buildVulnerabilityExplanation(raw, "CVE-2024-1");
|
|
168
|
+
expect(explanation.id).toBe("CVE-2024-1");
|
|
169
|
+
expect(explanation.affected).toEqual([]);
|
|
170
|
+
expect(explanation.references).toEqual([]);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
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 { ScanToolError } from "../../errors.js";
|
|
6
|
+
import { findOsvScannerBinary, installGuidance, OSV_SCANNER_PATH_ENV, resolveOsvScannerBinary, } from "../../osv/binaryManager.js";
|
|
7
|
+
import { runOsvScan } from "../../osv/runner.js";
|
|
8
|
+
let binDir;
|
|
9
|
+
let projectDir;
|
|
10
|
+
/** 偽のosv-scannerスクリプトを作る。テストでは実バイナリの終了コード仕様を模倣する。 */
|
|
11
|
+
async function makeFakeBinary(name, script) {
|
|
12
|
+
const filePath = path.join(binDir, name);
|
|
13
|
+
await writeFile(filePath, `#!/bin/sh\n${script}\n`);
|
|
14
|
+
await chmod(filePath, 0o755);
|
|
15
|
+
return filePath;
|
|
16
|
+
}
|
|
17
|
+
const VULN_JSON = JSON.stringify({
|
|
18
|
+
results: [
|
|
19
|
+
{
|
|
20
|
+
source: { path: "/x/pom.xml" },
|
|
21
|
+
packages: [
|
|
22
|
+
{
|
|
23
|
+
package: { name: "a:a", version: "1.0", ecosystem: "Maven" },
|
|
24
|
+
groups: [{ ids: ["GHSA-test"], aliases: ["CVE-2020-1"], max_severity: "7.5" }],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
binDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-bin-"));
|
|
32
|
+
projectDir = await mkdtemp(path.join(os.tmpdir(), "osv-mcp-proj-"));
|
|
33
|
+
});
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
await rm(binDir, { recursive: true, force: true });
|
|
36
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
async function expectScanError(promise, kind) {
|
|
39
|
+
const error = await promise.then(() => null, (e) => e);
|
|
40
|
+
expect(error).toBeInstanceOf(ScanToolError);
|
|
41
|
+
expect(error.kind).toBe(kind);
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
44
|
+
describe("runOsvScan", () => {
|
|
45
|
+
it("exit 1(脆弱性あり)のJSONをレポートに変換する", async () => {
|
|
46
|
+
const bin = await makeFakeBinary("fake-vulns", `echo '${VULN_JSON}'; exit 1`);
|
|
47
|
+
const report = await runOsvScan(projectDir, { binaryPath: bin });
|
|
48
|
+
expect(report.vulnerability_count).toBe(1);
|
|
49
|
+
expect(report.packages[0].vulnerabilities[0].cve).toBe("CVE-2020-1");
|
|
50
|
+
});
|
|
51
|
+
it("exit 0(脆弱性なし)は空レポートを返す", async () => {
|
|
52
|
+
const bin = await makeFakeBinary("fake-clean", `echo '{"results":[]}'; exit 0`);
|
|
53
|
+
const report = await runOsvScan(projectDir, { binaryPath: bin });
|
|
54
|
+
expect(report.vulnerability_count).toBe(0);
|
|
55
|
+
expect(report.packages).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
it("exit 128はno_packages_found", async () => {
|
|
58
|
+
const bin = await makeFakeBinary("fake-nopkg", `echo 'No package sources found' >&2; exit 128`);
|
|
59
|
+
await expectScanError(runOsvScan(projectDir, { binaryPath: bin }), "no_packages_found");
|
|
60
|
+
});
|
|
61
|
+
it("その他の終了コードはscan_failed(stderr抜粋をdetailに含む)", async () => {
|
|
62
|
+
const bin = await makeFakeBinary("fake-fail", `echo 'something broke' >&2; exit 127`);
|
|
63
|
+
const error = await expectScanError(runOsvScan(projectDir, { binaryPath: bin }), "scan_failed");
|
|
64
|
+
expect(error.detail).toContain("something broke");
|
|
65
|
+
});
|
|
66
|
+
it("JSONでない出力はinvalid_output", async () => {
|
|
67
|
+
const bin = await makeFakeBinary("fake-notjson", `echo 'oops not json'; exit 0`);
|
|
68
|
+
await expectScanError(runOsvScan(projectDir, { binaryPath: bin }), "invalid_output");
|
|
69
|
+
});
|
|
70
|
+
it("タイムアウトでプロセスを打ち切りscan_timeout", async () => {
|
|
71
|
+
const bin = await makeFakeBinary("fake-slow", `sleep 30; echo '{"results":[]}'`);
|
|
72
|
+
await expectScanError(runOsvScan(projectDir, { binaryPath: bin, timeoutMs: 300 }), "scan_timeout");
|
|
73
|
+
});
|
|
74
|
+
it("出力サイズ上限を超えたらoutput_too_large", async () => {
|
|
75
|
+
const bin = await makeFakeBinary("fake-huge", `head -c 100000 /dev/zero | tr '\\0' 'a'; exit 0`);
|
|
76
|
+
await expectScanError(runOsvScan(projectDir, { binaryPath: bin, maxOutputBytes: 10_000 }), "output_too_large");
|
|
77
|
+
});
|
|
78
|
+
it("バイナリが起動できなければscan_failed", async () => {
|
|
79
|
+
await expectScanError(runOsvScan(projectDir, { binaryPath: path.join(binDir, "does-not-exist") }), "scan_failed");
|
|
80
|
+
});
|
|
81
|
+
it("binaryPath省略時はOSV_SCANNER_PATH環境変数から解決する", async () => {
|
|
82
|
+
const bin = await makeFakeBinary("env-resolved", `echo '{"results":[]}'; exit 0`);
|
|
83
|
+
const previous = process.env[OSV_SCANNER_PATH_ENV];
|
|
84
|
+
process.env[OSV_SCANNER_PATH_ENV] = bin;
|
|
85
|
+
try {
|
|
86
|
+
const report = await runOsvScan(projectDir);
|
|
87
|
+
expect(report.vulnerability_count).toBe(0);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (previous === undefined)
|
|
91
|
+
delete process.env[OSV_SCANNER_PATH_ENV];
|
|
92
|
+
else
|
|
93
|
+
process.env[OSV_SCANNER_PATH_ENV] = previous;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
it("シグナルで強制終了された場合もscan_failed(signal情報付き)", async () => {
|
|
97
|
+
const bin = await makeFakeBinary("fake-killed", `kill -KILL $$`);
|
|
98
|
+
const error = await expectScanError(runOsvScan(projectDir, { binaryPath: bin }), "scan_failed");
|
|
99
|
+
expect(error.message).toContain("SIGKILL");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("binaryManager", () => {
|
|
103
|
+
it("PATHからosv-scannerを見つける", async () => {
|
|
104
|
+
await makeFakeBinary("osv-scanner", `exit 0`);
|
|
105
|
+
const env = { PATH: binDir };
|
|
106
|
+
expect(await findOsvScannerBinary(env)).toBe(path.join(binDir, "osv-scanner"));
|
|
107
|
+
});
|
|
108
|
+
it("OSV_SCANNER_PATHの明示指定を優先し、無効ならPATHにフォールバックしない", async () => {
|
|
109
|
+
const explicit = await makeFakeBinary("custom-scanner", `exit 0`);
|
|
110
|
+
const env = {
|
|
111
|
+
PATH: binDir,
|
|
112
|
+
[OSV_SCANNER_PATH_ENV]: explicit,
|
|
113
|
+
};
|
|
114
|
+
expect(await findOsvScannerBinary(env)).toBe(explicit);
|
|
115
|
+
const badEnv = {
|
|
116
|
+
PATH: binDir, // PATH上には有効なosv-scannerがあるが、明示指定が優先される
|
|
117
|
+
[OSV_SCANNER_PATH_ENV]: "/no/such/binary",
|
|
118
|
+
};
|
|
119
|
+
expect(await findOsvScannerBinary(badEnv)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
it("見つからず自動ダウンロードも無効なら案内メッセージ付きのbinary_not_found", async () => {
|
|
122
|
+
const env = {
|
|
123
|
+
PATH: "/nonexistent-dir-for-test",
|
|
124
|
+
OSV_MCP_AUTO_DOWNLOAD: "0",
|
|
125
|
+
};
|
|
126
|
+
const error = await resolveOsvScannerBinary(env).then(() => null, (e) => e);
|
|
127
|
+
expect(error).toBeInstanceOf(ScanToolError);
|
|
128
|
+
expect(error.kind).toBe("binary_not_found");
|
|
129
|
+
expect(error.message).toContain("brew install osv-scanner");
|
|
130
|
+
expect(installGuidance(env)).toContain(OSV_SCANNER_PATH_ENV);
|
|
131
|
+
});
|
|
132
|
+
it("見つからなければ自動ダウンロードにフォールバックする(デフォルト有効)", async () => {
|
|
133
|
+
const env = { PATH: "/nonexistent-dir-for-test" };
|
|
134
|
+
let downloadCalled = false;
|
|
135
|
+
const resolved = await resolveOsvScannerBinary(env, {
|
|
136
|
+
downloadFn: async () => {
|
|
137
|
+
downloadCalled = true;
|
|
138
|
+
return "/cache/osv-scanner";
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
expect(downloadCalled).toBe(true);
|
|
142
|
+
expect(resolved).toBe("/cache/osv-scanner");
|
|
143
|
+
});
|
|
144
|
+
it("OSV_SCANNER_PATHが無効な場合は自動ダウンロードせずbinary_not_found", async () => {
|
|
145
|
+
const env = {
|
|
146
|
+
PATH: "/nonexistent-dir-for-test",
|
|
147
|
+
[OSV_SCANNER_PATH_ENV]: "/no/such/binary",
|
|
148
|
+
};
|
|
149
|
+
let downloadCalled = false;
|
|
150
|
+
const error = await resolveOsvScannerBinary(env, {
|
|
151
|
+
downloadFn: async () => {
|
|
152
|
+
downloadCalled = true;
|
|
153
|
+
return "/cache/osv-scanner";
|
|
154
|
+
},
|
|
155
|
+
}).then(() => null, (e) => e);
|
|
156
|
+
expect(downloadCalled).toBe(false);
|
|
157
|
+
expect(error.kind).toBe("binary_not_found");
|
|
158
|
+
});
|
|
159
|
+
it("resolveOsvScannerBinaryは見つかったバイナリのパスを返す", async () => {
|
|
160
|
+
const bin = await makeFakeBinary("osv-scanner", `exit 0`);
|
|
161
|
+
const env = { PATH: binDir };
|
|
162
|
+
expect(await resolveOsvScannerBinary(env)).toBe(bin);
|
|
163
|
+
});
|
|
164
|
+
it("環境変数が無効なパスを指す場合は案内メッセージでその旨を伝える", () => {
|
|
165
|
+
const env = { [OSV_SCANNER_PATH_ENV]: "/no/such/binary" };
|
|
166
|
+
const guidance = installGuidance(env);
|
|
167
|
+
expect(guidance).toContain("/no/such/binary");
|
|
168
|
+
expect(guidance).toContain("実行可能ファイルを指していません");
|
|
169
|
+
});
|
|
170
|
+
});
|