osv-scanner-mcp 0.1.1 → 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 +8 -2
- package/dist/index.js +18 -3
- package/dist/osv/binaryDownloader.js +12 -5
- package/dist/osv/runner.js +31 -0
- package/dist/utils/startupConfig.js +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,9 +115,13 @@ npm run build
|
|
|
115
115
|
| 変数 | 説明 |
|
|
116
116
|
|---|---|
|
|
117
117
|
| `OSV_SCANNER_PATH` | 使用するosv-scannerバイナリの明示指定。省略時はPATH→自動ダウンロードの順で解決。**指定が無効な場合はフォールバックせずエラーになります**(意図しないバイナリの実行防止) |
|
|
118
|
-
| `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`)。超過したリクエストは待たずに即時エラーになります |
|
|
119
121
|
| `OSV_MCP_AUTO_DOWNLOAD` | `0` または `false` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
|
|
120
122
|
|
|
123
|
+
> **推奨**: `OSV_MCP_ALLOWED_ROOT` は未設定でも動作しますが、その場合は任意の絶対パスをスキャンできてしまいます。悪意ある指示(プロンプトインジェクション)経由で意図しないディレクトリをスキャンさせられる経路を塞ぐため、プロジェクト置き場のルート(例: `~/projects`)を設定しておくことを推奨します。各クライアントの設定で `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` のように渡せます(Codex CLIのTOMLでは `[mcp_servers.osv-scanner.env]` セクション)。
|
|
124
|
+
|
|
121
125
|
## 提供ツール
|
|
122
126
|
|
|
123
127
|
### `scan_java_project`
|
|
@@ -245,6 +249,7 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
245
249
|
| `no_packages_found` | スキャン対象パッケージなし(依存関係が未定義のpom.xml等) |
|
|
246
250
|
| `scan_failed` | OSV-Scannerが異常終了(stderr抜粋を`detail`に含む) |
|
|
247
251
|
| `scan_timeout` | タイムアウト(デフォルト120秒) |
|
|
252
|
+
| `too_many_concurrent_scans` | 同時実行スキャン数が上限(デフォルト2)に達している。完了を待って再試行 |
|
|
248
253
|
| `output_too_large` | 出力がサイズ上限(デフォルト32MB)を超過 |
|
|
249
254
|
| `invalid_output` | 出力がJSONとして解釈できない |
|
|
250
255
|
| `invalid_vulnerability_id` | 脆弱性IDの形式が不正 |
|
|
@@ -259,7 +264,8 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
259
264
|
- **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します
|
|
260
265
|
- **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
|
|
261
266
|
- **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません
|
|
262
|
-
- **DoS対策**: タイムアウト・stdout上限・stderr
|
|
267
|
+
- **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます
|
|
268
|
+
- **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
|
|
263
269
|
- **情報漏えい対策**: 想定外の例外はスタックトレース等を含めず `internal_error` に丸めます。外部由来のテキスト(脆弱性summary等)は長さ上限付きの「データ」として構造化して返します
|
|
264
270
|
- **プロンプトインジェクション対策**: OSVデータベース由来のテキスト(summary / details / ID等)とOSV-Scannerのstderrは、LLMクライアントへ返す前にサニタイズします。制御文字(ANSIエスケープ含む)・ゼロ幅文字・双方向制御文字(RLO等)・Unicodeタグ文字(不可視のテキスト密輸)・行区切り(U+2028/2029)を除去し、NFC正規化を適用。外部データの読み取りアクセサを単一のサニタイズ境界にすることで適用漏れを防いでいます
|
|
265
271
|
|
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) {
|
|
@@ -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
|
+
}
|