osv-scanner-mcp 0.1.0 → 0.1.1
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/README.md +60 -1
- package/dist/tools/toolResult.js +6 -2
- package/dist/utils/externalText.js +37 -0
- package/dist/utils/unknownJson.js +10 -2
- package/package.json +2 -1
- package/dist/test/osv/binaryDownloader.test.js +0 -117
- package/dist/test/osv/explainVulnerability.test.js +0 -173
- package/dist/test/osv/runner.test.js +0 -170
- package/dist/test/osv/scanReport.test.js +0 -342
- package/dist/test/osv/suggestFix.test.js +0 -104
- package/dist/test/tools/explainVulnerability.test.js +0 -39
- package/dist/test/tools/scanJavaProject.test.js +0 -87
- package/dist/test/tools/suggestFix.test.js +0 -79
- package/dist/test/utils/mavenVersion.test.js +0 -185
- package/dist/test/utils/projectDetector.test.js +0 -154
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# OSV-Scanner-MCP
|
|
2
2
|
|
|
3
|
+
[](https://github.com/tedorigawa001/OSV-Scanner-MCP/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/osv-scanner-mcp)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](package.json)
|
|
7
|
+
|
|
3
8
|
Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップするMCPサーバーです。Claude等のMCPクライアントから「このJavaプロジェクトの脆弱性をチェックして」と自然言語で依頼するだけで、依存ライブラリの既知の脆弱性(CVE / GHSA)を深刻度順のレポートで取得できます。
|
|
4
9
|
|
|
5
|
-
> **ステータス**:
|
|
10
|
+
> **ステータス**: [npmで公開中](https://www.npmjs.com/package/osv-scanner-mcp)(`npx -y osv-scanner-mcp`)。Maven(pom.xml)と Gradle(gradle.lockfile / lockfile方式)に対応しています。MCPクライアントは Claude Code / Claude Desktop / Codex CLI / Antigravity / VS Code(GitHub Copilot)での利用手順を用意しています。
|
|
6
11
|
|
|
7
12
|
## 特徴
|
|
8
13
|
|
|
@@ -42,6 +47,59 @@ claude mcp add osv-scanner -- npx -y osv-scanner-mcp
|
|
|
42
47
|
}
|
|
43
48
|
```
|
|
44
49
|
|
|
50
|
+
### Codex CLI への登録
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
codex mcp add osv-scanner -- npx -y osv-scanner-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
または `~/.codex/config.toml` に追加:
|
|
57
|
+
|
|
58
|
+
```toml
|
|
59
|
+
[mcp_servers.osv-scanner]
|
|
60
|
+
command = "npx"
|
|
61
|
+
args = ["-y", "osv-scanner-mcp"]
|
|
62
|
+
startup_timeout_sec = 60 # 初回のnpxパッケージ取得に備えて延長
|
|
63
|
+
tool_timeout_sec = 300 # 既定60秒。バイナリ自動ダウンロード+スキャン(既定120秒)を見込んで延長
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> **注意**: CodexのMCPツール実行タイムアウトは既定60秒です。本サーバーはスキャンのタイムアウトが既定120秒のため、初回のOSV-Scanner自動ダウンロードや大きめのプロジェクトのスキャンでは既定値のままだとCodex側が先にタイムアウトします。上記のように `tool_timeout_sec` の延長を推奨します。
|
|
67
|
+
|
|
68
|
+
### Antigravity への登録
|
|
69
|
+
|
|
70
|
+
エージェントパネルの **MCP Servers → Manage MCP Servers → View raw config** で開く `mcp_config.json` に追加(Claude Desktopと同じ形式):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"osv-scanner": {
|
|
76
|
+
"command": "npx",
|
|
77
|
+
"args": ["-y", "osv-scanner-mcp"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### VS Code(GitHub Copilot)への登録
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
code --add-mcp '{"name":"osv-scanner","command":"npx","args":["-y","osv-scanner-mcp"]}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
またはワークスペースの `.vscode/mcp.json` に追加(コマンドパレットの **MCP: Add Server** からも設定可能):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"servers": {
|
|
94
|
+
"osv-scanner": {
|
|
95
|
+
"type": "stdio",
|
|
96
|
+
"command": "npx",
|
|
97
|
+
"args": ["-y", "osv-scanner-mcp"]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
45
103
|
### ソースから使う場合
|
|
46
104
|
|
|
47
105
|
```bash
|
|
@@ -203,6 +261,7 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
203
261
|
- **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません
|
|
204
262
|
- **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません
|
|
205
263
|
- **情報漏えい対策**: 想定外の例外はスタックトレース等を含めず `internal_error` に丸めます。外部由来のテキスト(脆弱性summary等)は長さ上限付きの「データ」として構造化して返します
|
|
264
|
+
- **プロンプトインジェクション対策**: OSVデータベース由来のテキスト(summary / details / ID等)とOSV-Scannerのstderrは、LLMクライアントへ返す前にサニタイズします。制御文字(ANSIエスケープ含む)・ゼロ幅文字・双方向制御文字(RLO等)・Unicodeタグ文字(不可視のテキスト密輸)・行区切り(U+2028/2029)を除去し、NFC正規化を適用。外部データの読み取りアクセサを単一のサニタイズ境界にすることで適用漏れを防いでいます
|
|
206
265
|
|
|
207
266
|
## 開発
|
|
208
267
|
|
package/dist/tools/toolResult.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - 予期しない例外は内部情報を漏らさず`internal_error`に丸める
|
|
8
8
|
*/
|
|
9
9
|
import { ScanToolError } from "../errors.js";
|
|
10
|
+
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
10
11
|
export function jsonResult(payload, isError = false) {
|
|
11
12
|
return {
|
|
12
13
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
@@ -16,11 +17,14 @@ export function jsonResult(payload, isError = false) {
|
|
|
16
17
|
/** 捕捉した例外をエラーレスポンスに変換する。 */
|
|
17
18
|
export function errorResult(error) {
|
|
18
19
|
if (error instanceof ScanToolError) {
|
|
20
|
+
// detailはstderr抜粋等の外部由来テキスト(unknownJsonのアクセサを通らない経路)、
|
|
21
|
+
// messageもユーザー入力のパス等を含みうるため、両方をサニタイズして返す
|
|
22
|
+
const detail = error.detail !== undefined ? sanitizeExternalText(error.detail) : undefined;
|
|
19
23
|
return jsonResult({
|
|
20
24
|
error: {
|
|
21
25
|
kind: error.kind,
|
|
22
|
-
message: error.message,
|
|
23
|
-
...(
|
|
26
|
+
message: sanitizeExternalText(error.message),
|
|
27
|
+
...(detail !== undefined && detail !== "" ? { detail } : {}),
|
|
24
28
|
},
|
|
25
29
|
}, true);
|
|
26
30
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部由来テキスト(OSVレコードのsummary/details、OSV-Scannerのstderr等)を
|
|
3
|
+
* LLMクライアントへ返す前のサニタイズ。
|
|
4
|
+
*
|
|
5
|
+
* プロンプトインジェクション対策の方針:
|
|
6
|
+
* 「指示文の検出・除去」は原理的に完全にはできないため、
|
|
7
|
+
* - 出力を構造化JSONのデータフィールドとして返す(既存設計)ことを前提に、
|
|
8
|
+
* - 人間のレビュアーやLLMに「見えない」細工を無効化することに集中する。
|
|
9
|
+
*
|
|
10
|
+
* 除去対象:
|
|
11
|
+
* - 制御文字(C0のうち\n・\t以外、DEL、C1): ANSIエスケープによる表示偽装を含む
|
|
12
|
+
* - ゼロ幅・不可視文字(ZWSP等)、BOM、ソフトハイフン: 表示に現れない文字の混入
|
|
13
|
+
* - 双方向制御文字(RLO等): 表示順の偽装
|
|
14
|
+
* - Unicodeタグ文字(U+E0000〜U+E007F): 不可視のASCII密輸(invisible prompt injection)
|
|
15
|
+
* - 行区切りU+2028/U+2029: JSON文字列やログでの行構造の偽装
|
|
16
|
+
*
|
|
17
|
+
* あわせてNFC正規化で合成文字による見た目の偽装を低減する。
|
|
18
|
+
* \n・\tは正当なテキスト構造(markdown等)のため保持する。
|
|
19
|
+
*/
|
|
20
|
+
// C0制御文字(\t=U+0009・\n=U+000Aを除く)、DEL、C1制御文字
|
|
21
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
22
|
+
// 不可視文字(ソフトハイフンU+00AD・モンゴル母音分離U+180E・ゼロ幅各種U+200B-200F・BOM U+FEFF)、
|
|
23
|
+
// 行区切り(U+2028/2029)、双方向制御(U+202A-202E・U+2066-2069)、不可視演算子等(U+2060-2064)
|
|
24
|
+
const INVISIBLE_CHARS = /[\u00AD\u180E\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
|
|
25
|
+
// Unicodeタグ文字ブロック(不可視のASCII複製。テキスト中に現れる正当な用途はない)
|
|
26
|
+
const TAG_CHARS = /[\u{E0000}-\u{E007F}]/gu;
|
|
27
|
+
/**
|
|
28
|
+
* 外部由来テキストから不可視・制御文字を除去する。
|
|
29
|
+
* 可視文字と\n・\tはそのまま保持する(内容の書き換え・要約は行わない)。
|
|
30
|
+
*/
|
|
31
|
+
export function sanitizeExternalText(text) {
|
|
32
|
+
return text
|
|
33
|
+
.normalize("NFC")
|
|
34
|
+
.replace(TAG_CHARS, "")
|
|
35
|
+
.replace(CONTROL_CHARS, "")
|
|
36
|
+
.replace(INVISIBLE_CHARS, "");
|
|
37
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 外部由来JSONを型安全に読むための防御的アクセサ。
|
|
3
3
|
* 形式が想定と異なっても例外を投げず、読み取れない値はnull/空として扱う。
|
|
4
|
+
*
|
|
5
|
+
* 文字列はここで必ず`sanitizeExternalText`を通す。OSV APIレスポンスと
|
|
6
|
+
* OSV-Scanner出力の文字列はすべてこのアクセサ経由で読むため、
|
|
7
|
+
* ここが不可視・制御文字を除去する単一のサニタイズ境界になる
|
|
8
|
+
* (プロンプトインジェクション対策。詳細はexternalText.ts参照)。
|
|
4
9
|
*/
|
|
10
|
+
import { sanitizeExternalText } from "./externalText.js";
|
|
5
11
|
export function asRecord(value) {
|
|
6
12
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
7
13
|
? value
|
|
@@ -11,8 +17,10 @@ export function asArray(value) {
|
|
|
11
17
|
return Array.isArray(value) ? value : [];
|
|
12
18
|
}
|
|
13
19
|
export function asString(value) {
|
|
14
|
-
return typeof value === "string" ? value : null;
|
|
20
|
+
return typeof value === "string" ? sanitizeExternalText(value) : null;
|
|
15
21
|
}
|
|
16
22
|
export function asStrings(value) {
|
|
17
|
-
return asArray(value)
|
|
23
|
+
return asArray(value)
|
|
24
|
+
.filter((v) => typeof v === "string")
|
|
25
|
+
.map(sanitizeExternalText);
|
|
18
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osv-scanner-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "MCP server that wraps Google's OSV-Scanner to scan Java projects for known vulnerabilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"osv-scanner-mcp": "dist/index.js"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
+
"prebuild": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
|
|
37
38
|
"build": "tsc -p tsconfig.build.json",
|
|
38
39
|
"typecheck": "tsc --noEmit",
|
|
39
40
|
"test": "vitest run",
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,173 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,170 +0,0 @@
|
|
|
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
|
-
});
|