osv-scanner-mcp 0.1.0 → 0.1.2
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 +68 -3
- package/dist/index.js +18 -3
- package/dist/osv/binaryDownloader.js +12 -5
- package/dist/osv/runner.js +31 -0
- package/dist/tools/toolResult.js +6 -2
- package/dist/utils/externalText.js +37 -0
- package/dist/utils/startupConfig.js +40 -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
|
|
@@ -57,9 +115,13 @@ npm run build
|
|
|
57
115
|
| 変数 | 説明 |
|
|
58
116
|
|---|---|
|
|
59
117
|
| `OSV_SCANNER_PATH` | 使用するosv-scannerバイナリの明示指定。省略時はPATH→自動ダウンロードの順で解決。**指定が無効な場合はフォールバックせずエラーになります**(意図しないバイナリの実行防止) |
|
|
60
|
-
| `OSV_MCP_ALLOWED_ROOT` | 指定時、このディレクトリ配下以外のスキャンを拒否します(パストラバーサル対策の境界) |
|
|
118
|
+
| `OSV_MCP_ALLOWED_ROOT` | 指定時、このディレクトリ配下以外のスキャンを拒否します(パストラバーサル対策の境界)。**設定を推奨** |
|
|
119
|
+
| `OSV_MCP_REQUIRE_ALLOWED_ROOT` | `1` または `true` 指定時、`OSV_MCP_ALLOWED_ROOT` が未設定ならサーバーの起動自体を拒否します(運用環境向けのfail-closedモード) |
|
|
120
|
+
| `OSV_MCP_MAX_CONCURRENT_SCANS` | 同時実行できるスキャン数の上限(デフォルト `2`、最大 `16`)。超過したリクエストは待たずに即時エラーになります |
|
|
61
121
|
| `OSV_MCP_AUTO_DOWNLOAD` | `0` または `false` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
|
|
62
122
|
|
|
123
|
+
> **推奨**: `OSV_MCP_ALLOWED_ROOT` は未設定でも動作しますが、その場合は任意の絶対パスをスキャンできてしまいます。悪意ある指示(プロンプトインジェクション)経由で意図しないディレクトリをスキャンさせられる経路を塞ぐため、プロジェクト置き場のルート(例: `~/projects`)を設定しておくことを推奨します。各クライアントの設定で `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` のように渡せます(Codex CLIのTOMLでは `[mcp_servers.osv-scanner.env]` セクション)。
|
|
124
|
+
|
|
63
125
|
## 提供ツール
|
|
64
126
|
|
|
65
127
|
### `scan_java_project`
|
|
@@ -187,6 +249,7 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
187
249
|
| `no_packages_found` | スキャン対象パッケージなし(依存関係が未定義のpom.xml等) |
|
|
188
250
|
| `scan_failed` | OSV-Scannerが異常終了(stderr抜粋を`detail`に含む) |
|
|
189
251
|
| `scan_timeout` | タイムアウト(デフォルト120秒) |
|
|
252
|
+
| `too_many_concurrent_scans` | 同時実行スキャン数が上限(デフォルト2)に達している。完了を待って再試行 |
|
|
190
253
|
| `output_too_large` | 出力がサイズ上限(デフォルト32MB)を超過 |
|
|
191
254
|
| `invalid_output` | 出力がJSONとして解釈できない |
|
|
192
255
|
| `invalid_vulnerability_id` | 脆弱性IDの形式が不正 |
|
|
@@ -201,8 +264,10 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
201
264
|
- **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します
|
|
202
265
|
- **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
|
|
203
266
|
- **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません
|
|
204
|
-
- **DoS対策**: タイムアウト・stdout上限・stderr
|
|
267
|
+
- **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます
|
|
268
|
+
- **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
|
|
205
269
|
- **情報漏えい対策**: 想定外の例外はスタックトレース等を含めず `internal_error` に丸めます。外部由来のテキスト(脆弱性summary等)は長さ上限付きの「データ」として構造化して返します
|
|
270
|
+
- **プロンプトインジェクション対策**: OSVデータベース由来のテキスト(summary / details / ID等)とOSV-Scannerのstderrは、LLMクライアントへ返す前にサニタイズします。制御文字(ANSIエスケープ含む)・ゼロ幅文字・双方向制御文字(RLO等)・Unicodeタグ文字(不可視のテキスト密輸)・行区切り(U+2028/2029)を除去し、NFC正規化を適用。外部データの読み取りアクセサを単一のサニタイズ境界にすることで適用漏れを防いでいます
|
|
206
271
|
|
|
207
272
|
## 開発
|
|
208
273
|
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 環境変数:
|
|
6
6
|
* - OSV_SCANNER_PATH: 使用するosv-scannerバイナリの明示指定(省略時はPATHから探索)
|
|
7
|
-
* - OSV_MCP_ALLOWED_ROOT: 指定時、このディレクトリ配下以外のスキャンを拒否する
|
|
7
|
+
* - OSV_MCP_ALLOWED_ROOT: 指定時、このディレクトリ配下以外のスキャンを拒否する(設定を推奨)
|
|
8
|
+
* - OSV_MCP_REQUIRE_ALLOWED_ROOT: 1/true指定時、OSV_MCP_ALLOWED_ROOT未設定なら起動を拒否する
|
|
9
|
+
* - OSV_MCP_MAX_CONCURRENT_SCANS: 同時実行スキャン数の上限(デフォルト2)
|
|
8
10
|
*/
|
|
9
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -12,10 +14,23 @@ import { z } from "zod";
|
|
|
12
14
|
import { handleExplainVulnerability } from "./tools/explainVulnerability.js";
|
|
13
15
|
import { handleScanJavaProject } from "./tools/scanJavaProject.js";
|
|
14
16
|
import { handleSuggestFix } from "./tools/suggestFix.js";
|
|
15
|
-
|
|
17
|
+
import { ALLOWED_ROOT_ENV, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
|
|
18
|
+
export { ALLOWED_ROOT_ENV };
|
|
19
|
+
// fail-closed: 運用モードで許可ルートが未設定なら、ツールを一切公開せず終了する
|
|
20
|
+
const startupError = allowedRootStartupError();
|
|
21
|
+
if (startupError !== null) {
|
|
22
|
+
console.error(`osv-scanner-mcp: ${startupError}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
// 互換性のため未設定でも起動は続けるが、残余リスクをstderrで可視化する
|
|
26
|
+
const startupWarning = allowedRootStartupWarning();
|
|
27
|
+
if (startupWarning !== null) {
|
|
28
|
+
console.error(`osv-scanner-mcp: [警告] ${startupWarning}`);
|
|
29
|
+
}
|
|
30
|
+
// NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
|
|
16
31
|
const server = new McpServer({
|
|
17
32
|
name: "osv-scanner-mcp",
|
|
18
|
-
version: "0.1.
|
|
33
|
+
version: "0.1.2",
|
|
19
34
|
});
|
|
20
35
|
server.registerTool("scan_java_project", {
|
|
21
36
|
title: "Javaプロジェクトの脆弱性スキャン",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* (一時ファイルに書き込み→検証→chmod→アトミックにリネーム)
|
|
11
11
|
* - キャッシュ済みバイナリも使用のたびに再検証する(キャッシュ改ざん対策)
|
|
12
12
|
*/
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
14
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
@@ -99,6 +99,12 @@ export async function ensureOsvScannerDownloaded(options = {}) {
|
|
|
99
99
|
const cacheDir = options.cacheDir ?? defaultCacheDir();
|
|
100
100
|
const versionDir = path.join(cacheDir, `v${PINNED_OSV_SCANNER_VERSION}`);
|
|
101
101
|
const targetPath = path.join(versionDir, assetName);
|
|
102
|
+
// キャッシュディレクトリは所有者のみアクセス可(0700)にする。
|
|
103
|
+
// mkdirのmodeは新規作成時しか効かないため、旧版が広い権限で作った
|
|
104
|
+
// 既存ディレクトリも明示的なchmodで締める(キャッシュヒット時も毎回)
|
|
105
|
+
await mkdir(versionDir, { recursive: true, mode: 0o700 });
|
|
106
|
+
await chmod(cacheDir, 0o700);
|
|
107
|
+
await chmod(versionDir, 0o700);
|
|
102
108
|
// キャッシュ済みでも毎回検証する(改ざん・破損したキャッシュは再ダウンロード)
|
|
103
109
|
if (await verifyFile(targetPath, expected)) {
|
|
104
110
|
return targetPath;
|
|
@@ -110,11 +116,12 @@ export async function ensureOsvScannerDownloaded(options = {}) {
|
|
|
110
116
|
if (actual !== expected) {
|
|
111
117
|
throw new ScanToolError("binary_checksum_mismatch", `ダウンロードしたOSV-Scannerのチェックサムが一致しません(改ざんまたは破損の可能性)。expected=${expected} actual=${actual}`);
|
|
112
118
|
}
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
// 検証合格後に初めて実行権限を付与し、アトミックに配置する。
|
|
120
|
+
// 一時ファイル名はランダム+排他作成(wx)で同時ダウンロードやシンボリック
|
|
121
|
+
// リンクの差し込みと競合しないようにする
|
|
122
|
+
const tempPath = `${targetPath}.download-${randomBytes(8).toString("hex")}`;
|
|
116
123
|
try {
|
|
117
|
-
await writeFile(tempPath, buffer, { mode: 0o600 });
|
|
124
|
+
await writeFile(tempPath, buffer, { mode: 0o600, flag: "wx" });
|
|
118
125
|
await chmod(tempPath, 0o755);
|
|
119
126
|
await rename(tempPath, targetPath);
|
|
120
127
|
}
|
package/dist/osv/runner.js
CHANGED
|
@@ -15,6 +15,24 @@ import { ScanToolError } from "../errors.js";
|
|
|
15
15
|
import { resolveOsvScannerBinary } from "./binaryManager.js";
|
|
16
16
|
import { parseOsvScanOutput } from "./scanReport.js";
|
|
17
17
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
18
|
+
/**
|
|
19
|
+
* 同時実行スキャン数の上限(CPU・メモリ・ネットワーク枯渇対策)。
|
|
20
|
+
* MCPクライアントは並列リクエストを送れるため、osv-scannerプロセスが
|
|
21
|
+
* 無制限に増えないようプロセス全体でカウントし、超過は待たせず即時エラーにする。
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_MAX_CONCURRENT_SCANS = 2;
|
|
24
|
+
const MAX_CONCURRENT_SCANS_ENV = "OSV_MCP_MAX_CONCURRENT_SCANS";
|
|
25
|
+
const MAX_CONCURRENT_SCANS_CEILING = 16;
|
|
26
|
+
let activeScans = 0;
|
|
27
|
+
function maxConcurrentScansFromEnv() {
|
|
28
|
+
const raw = process.env[MAX_CONCURRENT_SCANS_ENV];
|
|
29
|
+
if (raw === undefined || raw.trim() === "")
|
|
30
|
+
return DEFAULT_MAX_CONCURRENT_SCANS;
|
|
31
|
+
const parsed = Number(raw);
|
|
32
|
+
if (!Number.isInteger(parsed) || parsed < 1)
|
|
33
|
+
return DEFAULT_MAX_CONCURRENT_SCANS;
|
|
34
|
+
return Math.min(parsed, MAX_CONCURRENT_SCANS_CEILING);
|
|
35
|
+
}
|
|
18
36
|
const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
19
37
|
/** エラー詳細に含めるstderrの上限(外部由来テキストをそのまま膨らませない) */
|
|
20
38
|
const MAX_STDERR_DETAIL_BYTES = 8 * 1024;
|
|
@@ -83,6 +101,19 @@ function execOsvScanner(binaryPath, projectDir, timeoutMs, maxOutputBytes) {
|
|
|
83
101
|
* 絶対パスを渡すこと**(このレイヤーではパス検証を行わない)
|
|
84
102
|
*/
|
|
85
103
|
export async function runOsvScan(projectDir, options = {}) {
|
|
104
|
+
const limit = options.maxConcurrentScans ?? maxConcurrentScansFromEnv();
|
|
105
|
+
if (activeScans >= limit) {
|
|
106
|
+
throw new ScanToolError("too_many_concurrent_scans", `同時実行できるスキャンは${limit}件までです(現在${activeScans}件実行中)。実行中のスキャン完了後に再試行してください`);
|
|
107
|
+
}
|
|
108
|
+
activeScans++;
|
|
109
|
+
try {
|
|
110
|
+
return await runOsvScanUnguarded(projectDir, options);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
activeScans--;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function runOsvScanUnguarded(projectDir, options) {
|
|
86
117
|
const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
|
|
87
118
|
const result = await execOsvScanner(binaryPath, projectDir, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES);
|
|
88
119
|
if (result.exitCode === EXIT_NO_PACKAGES) {
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 起動時の設定検証。
|
|
3
|
+
*
|
|
4
|
+
* OSV_MCP_ALLOWED_ROOT(スキャン許可ルート)は未設定でも動作するが、
|
|
5
|
+
* その場合は任意の絶対パスをスキャンできてしまう。運用環境では
|
|
6
|
+
* OSV_MCP_REQUIRE_ALLOWED_ROOT=1 を設定することで、許可ルート未設定時に
|
|
7
|
+
* サーバーの起動自体を拒否できる(fail-closed)。
|
|
8
|
+
*/
|
|
9
|
+
export const ALLOWED_ROOT_ENV = "OSV_MCP_ALLOWED_ROOT";
|
|
10
|
+
export const REQUIRE_ALLOWED_ROOT_ENV = "OSV_MCP_REQUIRE_ALLOWED_ROOT";
|
|
11
|
+
function isTruthy(value) {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return false;
|
|
14
|
+
const normalized = value.trim().toLowerCase();
|
|
15
|
+
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 起動を拒否すべき設定不備があればエラーメッセージを返す。問題なければnull。
|
|
19
|
+
*/
|
|
20
|
+
export function allowedRootStartupError(env = process.env) {
|
|
21
|
+
if (!isTruthy(env[REQUIRE_ALLOWED_ROOT_ENV]))
|
|
22
|
+
return null;
|
|
23
|
+
const allowedRoot = env[ALLOWED_ROOT_ENV];
|
|
24
|
+
if (allowedRoot !== undefined && allowedRoot.trim() !== "")
|
|
25
|
+
return null;
|
|
26
|
+
return (`${REQUIRE_ALLOWED_ROOT_ENV}が有効ですが、${ALLOWED_ROOT_ENV}が未設定のため起動を中止します。` +
|
|
27
|
+
`スキャンを許可するルートディレクトリを${ALLOWED_ROOT_ENV}に設定してください`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 起動は許可するが注意喚起すべき設定があれば警告メッセージを返す。なければnull。
|
|
31
|
+
* (許可ルート未設定=任意の絶対パスをスキャン可能な状態の可視化)
|
|
32
|
+
*/
|
|
33
|
+
export function allowedRootStartupWarning(env = process.env) {
|
|
34
|
+
const allowedRoot = env[ALLOWED_ROOT_ENV];
|
|
35
|
+
if (allowedRoot !== undefined && allowedRoot.trim() !== "")
|
|
36
|
+
return null;
|
|
37
|
+
return (`${ALLOWED_ROOT_ENV}が未設定のため、任意の絶対パスをスキャンできる状態です。` +
|
|
38
|
+
`プロジェクト置き場のルートを${ALLOWED_ROOT_ENV}に設定することを推奨します` +
|
|
39
|
+
`(未設定時に起動を拒否するには${REQUIRE_ALLOWED_ROOT_ENV}=1)`);
|
|
40
|
+
}
|
|
@@ -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.2",
|
|
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
|
-
});
|