osv-scanner-mcp 0.3.4 → 0.4.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/README.md CHANGED
@@ -5,13 +5,14 @@
5
5
  [![license](https://img.shields.io/npm/l/osv-scanner-mcp)](LICENSE)
6
6
  [![node](https://img.shields.io/node/v/osv-scanner-mcp)](package.json)
7
7
 
8
- Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップするMCPサーバーです。Claude等のMCPクライアントから「このJavaプロジェクトの脆弱性をチェックして」と自然言語で依頼するだけで、依存ライブラリの既知の脆弱性(CVE / GHSA)を深刻度順のレポートで取得できます。
8
+ Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップするMCPサーバーです。Claude等のMCPクライアントから「このプロジェクトの脆弱性をチェックして」と自然言語で依頼するだけで、依存ライブラリの既知の脆弱性(CVE / GHSA)を深刻度順のレポートで取得できます。
9
9
 
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)での利用手順を用意しています。
10
+ > **ステータス**: [npmで公開中](https://www.npmjs.com/package/osv-scanner-mcp)(`npx -y osv-scanner-mcp`)。Java(Maven / Gradle)、JavaScript(npm / yarn / pnpm / bun)、Python(Poetry / uv / Pipenv / PDM / requirements.txt)、Go のlockfileに対応しています(修正版の推奨はJavaのみ)。MCPクライアントは Claude Code / Claude Desktop / Codex CLI / Antigravity / VS Code(GitHub Copilot)での利用手順を用意しています。
11
11
 
12
12
  ## 特徴
13
13
 
14
- - **ワンショットスキャン**: `scan_java_project` ツールにプロジェクトパスを渡すだけで、検出→スキャン→整形済みレポートまで一気に返します
14
+ - **複数言語のワンショットスキャン**: `scan_project` ツールにプロジェクトパスを渡すだけで、Java・JavaScript・Python・Goのlockfileを検出してまとめてスキャンします。lockfileが無い・バージョンが未固定などでスキャンできなかった依存は、応答先頭の `coverage` で明示します
15
+ - **Javaプロジェクトのスキャン**: `scan_java_project` ツールでJava(Maven / Gradle)のマニフェストだけを対象に、検出→スキャン→整形済みレポートまで一気に返します
15
16
  - **JAR/WAR実体スキャン**: `scan_java_artifact` ツールで、lockfileが無い・shaded/fat JARしか手元にないプロジェクトでもアーカイブ内メタデータから既知の脆弱性を検出します(ベストエフォート同定であることを明示するcoverage情報付き)
16
17
  - **SBOM入力スキャン**: `scan_sbom` ツールでCycloneDX/SPDXのJSON SBOMに記録された依存を検査します。SBOMの網羅性や実成果物との一致は未検証であることを明示します
17
18
  - **深刻度順のレポート**: パッケージごとに脆弱性をCVSSスコア順に整理し、5段階の深刻度ラベル(critical / high / medium / low / unknown)とサマリ集計付きで返します
@@ -123,7 +124,7 @@ npm run build
123
124
  | `OSV_MCP_MAX_CONCURRENT_SCANS` | 同時実行できるスキャン数の上限(デフォルト `2`、最大 `16`)。超過したリクエストは待たずに即時エラーになります |
124
125
  | `OSV_MCP_AUTO_DOWNLOAD` | `0` または `false` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
125
126
  | `OSV_MCP_PREFER_DOWNLOAD` | `1` または `true` 指定時、PATH上のosv-scannerを使わず、チェックサム検証済みの自動ダウンロードバイナリを常に使用します(PATH汚染による偽バイナリ実行の防止。`OSV_SCANNER_PATH` の明示指定は引き続き最優先) |
126
- | `OSV_MCP_NO_REMOTE_RESOLUTION` | `1` または `true` 指定時、`pom.xml` の推移的依存を deps.dev で解決しません。**止まるのは deps.dev への送信だけで、脆弱性照会のためパッケージの名前とバージョンは引き続き `api.osv.dev` に送られます**。推移的依存の脆弱性は検出できなくなり、その旨が応答の `dependency_resolution.warning` に示されます。詳細は[通信先とプライバシー](#通信先とプライバシー) |
127
+ | `OSV_MCP_NO_REMOTE_RESOLUTION` | `1` または `true` 指定時、`pom.xml` と `requirements.txt` の推移的依存を deps.dev で解決しません。**止まるのは deps.dev への送信だけで、脆弱性照会のためパッケージの名前とバージョンは引き続き `api.osv.dev` に送られます**。推移的依存の脆弱性は検出できなくなり、その旨が応答の `dependency_resolution.warning` に示されます。詳細は[通信先とプライバシー](#通信先とプライバシー) |
127
128
 
128
129
  > **推奨**: `OSV_MCP_ALLOWED_ROOT` は未設定でも動作しますが、その場合は任意の絶対パスをスキャンできてしまいます。悪意ある指示(プロンプトインジェクション)経由で意図しないディレクトリをスキャンさせられる経路を塞ぐため、プロジェクト置き場のルート(例: `~/projects`)を設定しておくことを推奨します。各クライアントの設定で `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` のように渡せます(Codex CLIのTOMLでは `[mcp_servers.osv-scanner.env]` セクション)。
129
130
 
@@ -140,19 +141,79 @@ osv-scanner v2.4.0 で接続先を実機確認した結果です(2026-10-07)。
140
141
 
141
142
  | 操作 | 接続先 | 送られる情報 |
142
143
  |---|---|---|
143
- | `pom.xml` のスキャン(`scan_java_project` / `suggest_fix`) | `api.osv.dev`、**`api.deps.dev`** | パッケージの名前とバージョン。deps.dev には推移的依存の解決のため、`pom.xml` に宣言された依存(社内パッケージを含む)の名前とバージョンが送られます |
144
- | `gradle.lockfile` のスキャン | `api.osv.dev` | パッケージの名前とバージョン(lockfileに全依存が記載済みのため、解決のための外部接続はしません) |
144
+ | `pom.xml` のスキャン(`scan_project` / `scan_java_project` / `suggest_fix`) | `api.osv.dev`、**`api.deps.dev`** | パッケージの名前とバージョン。deps.dev には推移的依存の解決のため、`pom.xml` に宣言された依存(社内パッケージを含む)の名前とバージョンが送られます |
145
+ | `requirements.txt` のスキャン(`scan_project`) | `api.osv.dev`、**`api.deps.dev`** | `pom.xml` と同じく、推移的依存の解決のため記載された依存の名前とバージョンが deps.dev に送られます。`--index-url` 等に書かれた取得先へは接続しません |
146
+ | lockfileのスキャン(`gradle.lockfile`、`package-lock.json` 等のnpm系、`poetry.lock` 等のPython系、`go.mod`) | `api.osv.dev` | パッケージの名前とバージョン(lockfileに全依存が記載済みのため、解決のための外部接続はしません) |
145
147
  | `scan_java_artifact` / `scan_sbom` | `api.osv.dev` | 同定できたパッケージの名前とバージョン |
146
148
  | `explain_vulnerability` | `api.osv.dev` | 指定した脆弱性ID |
147
149
  | バイナリの自動ダウンロード(初回のみ) | GitHub(公式Releases) | なし(ピン留めしたバージョンのバイナリを取得) |
148
150
 
149
151
  api.osv.dev と deps.dev はどちらも Google が運営するサービスです。**どの設定でも、スキャンしたパッケージの名前とバージョンは脆弱性照会のため `api.osv.dev` に送られます**(オフラインでの照会には対応していません)。
150
152
 
151
- - **deps.dev への送信を止めたい場合**: `OSV_MCP_NO_REMOTE_RESOLUTION=1` を設定すると、`pom.xml` の推移的依存を解決しなくなり、接続先は `api.osv.dev` だけになります。止まるのは deps.dev への送信だけで、OSV への送信は続きます。また `pom.xml` に直接書いた依存しかスキャンされず、**推移的依存の脆弱性を見落とします**。この状態は `scan_java_project` / `suggest_fix` の応答の `dependency_resolution` に `transitive_resolution: "disabled"` と警告で示されるので、検出0件と区別できます。推移的依存も含めて deps.dev を使わずにスキャンするには、Gradleのlockfile方式(`gradle.lockfile`)を使ってください
153
+ - **deps.dev への送信を止めたい場合**: `OSV_MCP_NO_REMOTE_RESOLUTION=1` を設定すると、`pom.xml` と `requirements.txt` の推移的依存を解決しなくなり、接続先は `api.osv.dev` だけになります。止まるのは deps.dev への送信だけで、OSV への送信は続きます。また直接書いた依存しかスキャンされず、**推移的依存の脆弱性を見落とします**。この状態は `scan_project` / `scan_java_project` / `suggest_fix` の応答の `dependency_resolution` に `transitive_resolution: "disabled"` と警告で示されるので、検出0件と区別できます。推移的依存も含めて deps.dev を使わずにスキャンするには、lockfile方式(`gradle.lockfile`、`poetry.lock` 等)を使ってください
152
154
  - **任意の取得先には接続しません**: osv-scanner の `--data-source native` モードは、スキャン対象の `pom.xml` の `<repositories>` に書かれた任意のURLへ接続します(悪意あるpom.xmlで攻撃者のサーバーへ通信させられる)。本サーバーはこのモードを使わず、`deps.dev` を明示指定しています
153
155
 
154
156
  ## 提供ツール
155
157
 
158
+ ### `scan_project`
159
+
160
+ プロジェクト内のlockfile・マニフェストを検出し、Java / JavaScript / Python / Go の依存をまとめてスキャンします。パッケージマネージャーやビルドは実行しません。
161
+
162
+ **入力**
163
+
164
+ | パラメータ | 型 | 説明 |
165
+ |---|---|---|
166
+ | `project_path` | string | スキャン対象のプロジェクトディレクトリ、または対応するlockfile・マニフェストの絶対パス(直接指定したファイルはそれ1件だけをスキャン) |
167
+
168
+ **対応ファイル**
169
+
170
+ | エコシステム | ファイル |
171
+ |---|---|
172
+ | Java(Maven) | `pom.xml`、`gradle.lockfile`、`buildscript-gradle.lockfile` |
173
+ | JavaScript(npm) | `package-lock.json`、`npm-shrinkwrap.json`、`yarn.lock`、`pnpm-lock.yaml`、`bun.lock`(テキスト形式) |
174
+ | Python(PyPI) | `poetry.lock`、`uv.lock`、`Pipfile.lock`、`pdm.lock`、`requirements.txt`(`requirements-dev.txt` 等も) |
175
+ | Go | `go.mod` |
176
+
177
+ `.git`、`node_modules`、`target`、`build`、`.venv`、`venv`、`site-packages`、`__pycache__`、`.tox`、`vendor` とシンボリックリンクは探索しません。検出したファイルだけを形式を明示してOSV-Scannerに渡します(応答の `coverage.manifests` がそのままスキャン範囲です)。探索上限は `scan_java_project` と同じです。
178
+
179
+ **requirements.txtは元のファイルをOSV-Scannerに渡しません**。本サーバーが解析し、解釈できた依存の行だけを `名前==版` 等の単純な形に直して専用の一時コピーに書き、それをスキャンします(スキャン後に削除)。OSV-Scannerは取り込み指定を独自に解釈してたどる(`- r ../x.txt` のような空白入りも取り込みとみなし、スキャン範囲の外のファイルを読む)ため、コピーには取り込み指定やオプションを一切含めません。取り込み(`-r` / `--requirement`)は、プロジェクトディレクトリ内の取り込み先だけを本サーバーが展開してコピーに含めます。
180
+
181
+ **出力の読み方**
182
+
183
+ ```json
184
+ {
185
+ "project_dir": "/path/to/project",
186
+ "dependency_resolution": { "transitive_resolution": "enabled" },
187
+ "coverage": {
188
+ "complete": false,
189
+ "warning": "一部の依存はスキャンされていないか、版を推測してスキャンしています(…)",
190
+ "manifests": [{ "path": "web/package-lock.json", "ecosystem": "npm", "format": "package-lock.json" }],
191
+ "lockfile_missing": [{ "path": "svc/package.json", "ecosystem": "npm", "status": "missing", "hint": "lockfileがありません。…" }],
192
+ "unpinned_requirements": [{ "file": "py/requirements.txt", "line": 2, "name": "Jinja2", "specifier": ">=2.0", "kind": "lower_bound" }],
193
+ "unscannable_requirements": [
194
+ { "file": "py/requirements.txt", "line": 4, "text": "-e git+https://…", "reason": "編集可能インストール(-e)はスキャンされません" },
195
+ { "file": "py/requirements.txt", "line": 5, "text": "-r ../shared/base.txt", "reason": "取り込み先がプロジェクトディレクトリの外のため展開しません" }
196
+ ],
197
+ "skipped_files": []
198
+ },
199
+ "ecosystem_breakdown": { "npm": { "manifests": 1, "vulnerable_package_count": 2, "vulnerability_count": 4 } },
200
+ "vulnerable_package_count": 2,
201
+ "vulnerability_count": 4,
202
+ "severity_breakdown": { "critical": 0, "high": 2, "medium": 2, "low": 0, "unknown": 0 },
203
+ "packages": [{ "name": "minimist", "version": "1.2.5", "ecosystem": "npm", "dependency_groups": ["dev"], "vulnerabilities": [] }]
204
+ }
205
+ ```
206
+
207
+ - **`coverage` を必ず確認してください**。`complete: false` の場合、一部の依存はスキャンされていないため、検出0件でも安全とは言えません
208
+ - `lockfile_missing`: lockfileの無いマニフェスト(`package.json`、`pyproject.toml`、`Pipfile`、`setup.py`、`build.gradle` 等)。同じディレクトリに同じエコシステムのlockfileがあれば記録しません。上位のディレクトリのlockfileだけがある場合は、`package-lock.json`(v2以降)にそのディレクトリが収録されていることを確認できたときだけ記録しません(npm workspaces)。収録されていなければ `status: "missing"`、確認できない形式(yarn.lock、Python系等)なら `status: "unconfirmed"` として記録します。`hint` の手順でlockfileを生成してから再スキャンしてください(生成は信頼できる環境で)
209
+ - `unpinned_requirements`: requirements.txtのうち、版を固定していない行。`kind` は `unpinned`(版の指定なし)・`range`(`>`、`<`、`!=`、`==1.*`、範囲の組み合わせ等)・`lower_bound`(`>=`、`~=`)。`unpinned` と `range` の行はOSV-Scannerがスキャンせず、`lower_bound` の行は下限の版を使用中の版とみなしてスキャンします(該当パッケージには `version_is_lower_bound: true` が付き、実際の版とは異なる可能性があります)
210
+ - `unscannable_requirements`: スキャンされない行と理由。`-e`、`name @ URL`、パス指定、展開しなかった取り込み(プロジェクトディレクトリの外・存在しない・URL・上限超過)、制約ファイル(`-c`、適用しません)、解釈できないオプションや版の指定。解釈できない行は無視せず、ここに記録します
211
+ - `skipped_files`: スキャン対象から外したファイルと理由(requirements.txt自体が読めない・1MiBを超える場合、`pom.xml` の親POMが許可ルートの外を参照する場合。後者は[親POMの扱い](#親pomの扱い)を参照)
212
+ - 各一覧は200件までで、超えた分の件数を `omitted_items` に返します
213
+ - `ecosystem_breakdown` は、脆弱性0件のエコシステムも含めて「スキャンした」ことを示します
214
+ - `dependency_groups` はOSV-Scannerが付けた依存グループ(例: `dev`)の生の値です。lockfileの形式によって欠落・不正確なため(pnpmでは付かず、pdmでは `optional` になる等)、参考情報として扱ってください
215
+ - 修正版の推奨(`suggest_fix`)は現在Javaのみ対応です
216
+
156
217
  ### `scan_java_project`
157
218
 
158
219
  Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポートを返します。
@@ -167,6 +228,20 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
167
228
 
168
229
  > **スキャン範囲**: ディレクトリを指定すると、配下の `pom.xml` / `gradle.lockfile` / `buildscript-gradle.lockfile` を深さに関係なく検出し、**検出したファイルだけ**をスキャンします(応答の `manifests` がそのままスキャン範囲です)。同じディレクトリにある `package-lock.json` や `requirements.txt` などJava以外のファイルはスキャンしません。`.git`、`node_modules`、`target`、`build`、`.idea`、`.vscode` とシンボリックリンクは探索しません。探索するエントリが20万件、またはマニフェストが1,000件を超える場合は、結果を黙って省略せず `manifest_search_limit_exceeded` を返します。`pom.xml` などのマニフェストを直接指定した場合は、ディレクトリを探索せず**そのファイルだけ**をスキャンします(上限に達した場合の回避手段としても使えます)。
169
230
 
231
+ #### 親POMの扱い
232
+
233
+ OSV-Scannerは `pom.xml` の `<parent>` が参照する親POM(`<relativePath>` の指すファイル。省略時はMavenの既定どおり `../pom.xml`)を読み、親の親もたどって、そこに書かれた依存を結果に含めます。サブモジュールだけをスキャンしても親から引き継いだ依存を検出できるのはこのためです。
234
+
235
+ `OSV_MCP_ALLOWED_ROOT` を設定している場合、親POMの連鎖のどこかが**許可ルートの外**のファイルを参照する `pom.xml` は、スキャン対象から外します(許可ルート外のファイルの内容を結果や照会先に出さないため)。外したファイルは応答の `skipped_manifests`(`scan_project` では `coverage.skipped_files`)に理由付きで示し、`scope_warning` で「検出0件でも安全とは判断しない」旨を伝えます。全件が外れた場合や、該当する `pom.xml` を直接指定した場合は `path_outside_allowed_root` を返します。
236
+
237
+ - 許可ルート内の親POMは従来どおり読みます(サブモジュールのスキャンは許可ルート内なら引き続き使えます)
238
+ - `<relativePath/>`(空)はローカルの親POMを参照しないため対象外です
239
+ - 親POMの指定は、OSV-Scanner(Go)のXMLの解釈に合わせて読みます。要素は名前空間の接頭辞に関係なく要素名で照合し(`<m:parent>` も親として扱う)、ルート要素の直下の `parent` だけを対象にし、文字参照を展開します
240
+ - XMLの仕様どおり、解析前に改行(CRLF・CR)をLFに正規化します
241
+ - 同じ解釈を保証できない場合は除外します: ルート直下の `parent` や `relativePath` が複数ある、CDATA・DOCTYPE・未知の実体参照・プロパティ参照(`${...}`)がある、タグが閉じていない、UTF-8として読めない、`relativePath` に制御文字(改行・タブ等)・通常の空白以外の空白・書式文字が含まれる(通常の空白や日本語のディレクトリ名は使えます)
242
+ - 親のGAVが一致しなければOSV-Scannerは読みませんが、本サーバーはGAVを確認せず、許可ルートの外に参照先のファイルがあれば安全側に除外します
243
+ - `OSV_MCP_ALLOWED_ROOT` が未設定の場合は任意の絶対パスをスキャンできる状態のため、この検証は行いません
244
+
170
245
  **出力(成功時)**
171
246
 
172
247
  ```json
@@ -334,7 +409,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
334
409
  | `binary_download_failed` | バイナリのダウンロード失敗(未対応プラットフォーム含む) |
335
410
  | `binary_checksum_mismatch` | ダウンロードしたバイナリのチェックサム不一致(改ざん/破損の可能性) |
336
411
  | `gradle_lockfile_missing` | Gradleプロジェクトだがgradle.lockfileが無い(生成手順をmessageで案内) |
337
- | `path_outside_allowed_root` | `OSV_MCP_ALLOWED_ROOT` の外を指している |
412
+ | `path_outside_allowed_root` | `OSV_MCP_ALLOWED_ROOT` の外を指している(マニフェストの親POMが許可ルートの外を参照し、スキャンできるマニフェストが残らない場合を含む) |
338
413
  | `no_packages_found` | スキャン対象パッケージなし(依存関係が未定義のpom.xml等) |
339
414
  | `scan_failed` | OSV-Scannerが異常終了(stderr抜粋を`detail`に含む) |
340
415
  | `scan_timeout` | タイムアウト(デフォルト120秒) |
@@ -352,7 +427,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
352
427
 
353
428
  - **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します。`OSV_MCP_PREFER_DOWNLOAD=1` でPATH上の未検証バイナリを使わない運用も選べます
354
429
  - **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
355
- - **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません。OSV-Scannerにはディレクトリを渡さず、検出したマニフェストだけを形式を明示して個別に渡します(ディレクトリを渡すと、OSV-Scannerが同じディレクトリの `requirements.txt` も読み、その取り込み指定 `-r ../x.txt` でスキャン範囲の外のファイルを読むため)
430
+ - **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません。OSV-Scannerにはディレクトリを渡さず、検出したマニフェストだけを形式を明示して個別に渡します(ディレクトリを渡すと、OSV-Scannerが同じディレクトリの `requirements.txt` も読み、その取り込み指定 `-r ../x.txt` でスキャン範囲の外のファイルを読むため)。`scan_project` でrequirements.txtをスキャンする場合は元ファイルを渡さず、解釈できた依存の行だけを正規化して書いた専用コピーをスキャンします。コピーには取り込み指定を含めないため、OSV-Scannerの取り込みの解釈と本サーバーの解析がずれても、範囲外のファイルは読まれません。`pom.xml` の親POMの連鎖が `OSV_MCP_ALLOWED_ROOT` の外を参照する場合は、その `pom.xml` をスキャン対象から外します([親POMの扱い](#親pomの扱い))
356
431
  - **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます
357
432
  - **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
358
433
  - **通信先の固定と明示**: osv-scannerの依存解決先は `deps.dev` を明示指定し、スキャン対象のpom.xmlが指定する任意のリポジトリへ接続するモード(`--data-source native`)は使いません(テストで保証)。通信先の一覧と、deps.devへの送信を止める `OSV_MCP_NO_REMOTE_RESOLUTION=1` は[通信先とプライバシー](#通信先とプライバシー)を参照
@@ -378,6 +453,9 @@ npm run build # dist/ へビルド
378
453
  - [x] OSV-Scannerバイナリの自動ダウンロード(チェックサム検証付き)
379
454
  - [x] Gradle対応(lockfile方式)
380
455
  - [x] `scan_java_artifact` ツール: JAR/WAR実体スキャン(lockfileが無い・shaded/fat JARのみのプロジェクト向け)
456
+ - [x] `scan_project` ツール: Java / JavaScript / Python / Go のlockfileをまとめてスキャン
457
+ - [ ] `suggest_fix` のJavaScript / Go対応(semver)
458
+ - [ ] `suggest_fix` のPython対応(PEP 440)、直接/推移的依存の区別
381
459
 
382
460
  ## ライセンス
383
461
 
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
16
  import { z } from "zod";
17
17
  import { handleExplainVulnerability } from "./tools/explainVulnerability.js";
18
18
  import { handleScanJavaProject } from "./tools/scanJavaProject.js";
19
+ import { handleScanProject } from "./tools/scanProject.js";
19
20
  import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
20
21
  import { handleScanSbom } from "./tools/scanSbom.js";
21
22
  import { handleSuggestFix } from "./tools/suggestFix.js";
@@ -35,8 +36,23 @@ if (startupWarning !== null) {
35
36
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
36
37
  const server = new McpServer({
37
38
  name: "osv-scanner-mcp",
38
- version: "0.3.4",
39
+ version: "0.4.0",
39
40
  });
41
+ server.registerTool("scan_project", {
42
+ title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
43
+ description: "プロジェクト内のlockfile・マニフェストを検出し、依存ライブラリの既知の脆弱性(CVE/GHSA)をまとめてスキャンする。" +
44
+ "対応: Java(pom.xml / gradle.lockfile)、JavaScript(package-lock.json / npm-shrinkwrap.json / yarn.lock / pnpm-lock.yaml / bun.lock)、" +
45
+ "Python(poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt)、Go(go.mod)。" +
46
+ "パッケージマネージャーやビルドは実行しない。" +
47
+ "応答先頭のcoverageを必ず確認すること: lockfileが無いマニフェスト、バージョン未固定のrequirements行、スキャン対象から外したファイルを示す。" +
48
+ "coverage.complete=falseの場合は、検出0件でも安全とは判断しないこと。修正版の推奨(suggest_fix)は現在Javaのみ対応。",
49
+ inputSchema: {
50
+ project_path: z
51
+ .string()
52
+ .min(1)
53
+ .describe("スキャン対象のプロジェクトディレクトリ、または対応するlockfile・マニフェストの絶対パス"),
54
+ },
55
+ }, async ({ project_path }) => handleScanProject({ project_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
40
56
  server.registerTool("scan_java_project", {
41
57
  title: "Javaプロジェクトの脆弱性スキャン",
42
58
  description: "Java(Maven)プロジェクトをGoogle OSV-Scannerでスキャンし、依存ライブラリの既知の脆弱性(CVE/GHSA)を深刻度順のJSONレポートで返す。" +
@@ -16,7 +16,11 @@ import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { ScanToolError } from "../errors.js";
18
18
  import { readResponseBytes } from "../utils/readResponseBytes.js";
19
- /** ピン留めするOSV-Scannerのバージョン。更新時は下のチェックサムも必ず更新すること */
19
+ /**
20
+ * ピン留めするOSV-Scannerのバージョン。更新時は下のチェックサムも必ず更新すること。
21
+ * 範囲外の読み込みを防ぐ検証はこの版の挙動に合わせているため、更新時は
22
+ * docs/DESIGN_TODO.md「B5」の監査と、requirements.txt・親POMの解釈の実機確認をやり直すこと
23
+ */
20
24
  export const PINNED_OSV_SCANNER_VERSION = "2.4.0";
21
25
  /**
22
26
  * v2.4.0公式リリースのSHA256(osv-scanner_SHA256SUMSより転記、2026-07-04取得)。
@@ -17,6 +17,7 @@
17
17
  import { spawn } from "node:child_process";
18
18
  import path from "node:path";
19
19
  import { ScanToolError } from "../errors.js";
20
+ import { isManifestFormat } from "../utils/manifestFormats.js";
20
21
  import { resolveOsvScannerBinary } from "./binaryManager.js";
21
22
  import { parseOsvScanOutput } from "./scanReport.js";
22
23
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -59,16 +60,13 @@ export function isRemoteResolutionDisabled(options = {}) {
59
60
  */
60
61
  const FIXED_SCAN_ARGS = ["scan", "source", "--format", "json", "--data-source", "deps.dev"];
61
62
  const NO_RESOLVE_ARG = "--no-resolve";
62
- /** 個別に渡せるマニフェスト。ファイル名をそのまま解析形式として明示する(2.4.0で実機確認) */
63
- const PROJECT_MANIFEST_FORMATS = new Set(["pom.xml", "gradle.lockfile", "buildscript-gradle.lockfile"]);
64
- /** 検出済みマニフェストを`--lockfile <形式>:<絶対パス>`の組にする。対象外のパスは渡さない */
65
- export function buildProjectTargetArgs(manifestPaths) {
66
- return manifestPaths.flatMap((manifestPath) => {
67
- const format = path.basename(manifestPath);
68
- if (!PROJECT_MANIFEST_FORMATS.has(format) || !path.isAbsolute(manifestPath)) {
69
- throw new Error(`Unsupported manifest path for project scan: ${manifestPath}`);
63
+ /** 検出済みマニフェストを`--lockfile <形式>:<絶対パス>`の組にする。許可外の形式・相対パスは渡さない */
64
+ export function buildProjectTargetArgs(targets) {
65
+ return targets.flatMap((target) => {
66
+ if (!isManifestFormat(target.format) || !path.isAbsolute(target.path)) {
67
+ throw new Error(`Unsupported manifest target for project scan: ${target.format}:${target.path}`);
70
68
  }
71
- return ["--lockfile", `${format}:${manifestPath}`];
69
+ return ["--lockfile", `${target.format}:${target.path}`];
72
70
  });
73
71
  }
74
72
  const FIXED_ARTIFACT_ARGS = [
@@ -148,14 +146,14 @@ function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, scan
148
146
  /**
149
147
  * 検出済みマニフェストだけをOSV-Scannerでスキャンし、整形済みレポートを返す。
150
148
  *
151
- * @param manifestPaths **`detectJavaProject`が検出したマニフェストの絶対パス**
149
+ * @param targets **検出器(detectJavaProject / detectProject)が検出したマニフェスト**
152
150
  * (このレイヤーではスキャン範囲の検証を行わない。ディレクトリは渡さない)
153
151
  */
154
- export async function runOsvScan(manifestPaths, options = {}) {
155
- if (manifestPaths.length === 0) {
152
+ export async function runOsvScan(targets, options = {}) {
153
+ if (targets.length === 0) {
156
154
  throw new ScanToolError("no_manifest_found", "スキャン対象のマニフェストがありません");
157
155
  }
158
- return parseOsvScanOutput(await runScan(manifestPaths, options, "project"));
156
+ return parseOsvScanOutput(await runScan(buildProjectTargetArgs(targets), options, "project"));
159
157
  }
160
158
  /** Accept only the exact absolute files enumerated by detectJavaArtifacts. */
161
159
  export async function runOsvArtifactScan(artifactPaths, options = {}) {
@@ -168,24 +166,25 @@ export async function runOsvArtifactScan(artifactPaths, options = {}) {
168
166
  export async function runOsvSbomScan(snapshotPath, options = {}) {
169
167
  return runScan([snapshotPath], options, "sbom");
170
168
  }
171
- async function runScan(targetPaths, options, mode) {
169
+ /** targetArgs: projectモードは`--lockfile`の組、artifact/sbomモードは検証済みの絶対パス */
170
+ async function runScan(targetArgs, options, mode) {
172
171
  const limit = options.maxConcurrentScans ?? maxConcurrentScansFromEnv();
173
172
  if (activeScans >= limit) {
174
173
  throw new ScanToolError("too_many_concurrent_scans", `同時実行できるスキャンは${limit}件までです(現在${activeScans}件実行中)。実行中のスキャン完了後に再試行してください`);
175
174
  }
176
175
  activeScans++;
177
176
  try {
178
- return await runOsvScanUnguarded(targetPaths, options, mode);
177
+ return await runOsvScanUnguarded(targetArgs, options, mode);
179
178
  }
180
179
  finally {
181
180
  activeScans--;
182
181
  }
183
182
  }
184
- async function runOsvScanUnguarded(targetPaths, options, mode) {
183
+ async function runOsvScanUnguarded(targetArgs, options, mode) {
185
184
  const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
186
- const result = await execOsvScanner(binaryPath, mode === "project" ? buildProjectTargetArgs(targetPaths) : targetPaths, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, buildOsvScanArgs(mode, isRemoteResolutionDisabled(options)));
185
+ const result = await execOsvScanner(binaryPath, targetArgs, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, buildOsvScanArgs(mode, isRemoteResolutionDisabled(options)));
187
186
  if (result.exitCode === EXIT_NO_PACKAGES && mode === "project") {
188
- throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした(マニフェスト${targetPaths.length}件。pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
187
+ throw new ScanToolError("no_packages_found", "OSV-Scannerがスキャン対象のパッケージを検出できませんでした(マニフェストに依存関係が定義されているか確認してください)", result.stderr);
189
188
  }
190
189
  if (mode !== "project" && result.exitCode === EXIT_NO_PACKAGES && result.stdout.trim() === "") {
191
190
  return { results: [] };
@@ -119,9 +119,11 @@ export function parseOsvScanOutput(raw) {
119
119
  const key = `${ecosystem}:${name}@${version}`;
120
120
  let entry = packageMap.get(key);
121
121
  if (!entry) {
122
- entry = { name, version, ecosystem, vulns: new Map() };
122
+ entry = { name, version, ecosystem, groups: new Set(), vulns: new Map() };
123
123
  packageMap.set(key, entry);
124
124
  }
125
+ for (const group of asStrings(pkgObj.dependency_groups))
126
+ entry.groups.add(group);
125
127
  const details = asArray(pkgObj.vulnerabilities);
126
128
  for (const groupRaw of asArray(pkgObj.groups)) {
127
129
  const group = asRecord(groupRaw);
@@ -171,7 +173,13 @@ function buildReport(sourceFiles, packageMap) {
171
173
  severityBreakdown[vuln.severity]++;
172
174
  vulnerabilityCount++;
173
175
  }
174
- return { name: entry.name, version: entry.version, ecosystem: entry.ecosystem, vulnerabilities };
176
+ return {
177
+ name: entry.name,
178
+ version: entry.version,
179
+ ecosystem: entry.ecosystem,
180
+ ...(entry.groups.size > 0 ? { dependency_groups: [...entry.groups].sort() } : {}),
181
+ vulnerabilities,
182
+ };
175
183
  })
176
184
  .sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
177
185
  return {
@@ -3,6 +3,7 @@
3
3
  * レスポンス形式(成功/エラー)は`toolResult.ts`参照。
4
4
  */
5
5
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
6
+ import { sanitizeExternalText } from "../utils/externalText.js";
6
7
  import { detectJavaProject } from "../utils/projectDetector.js";
7
8
  import { errorResult, jsonResult } from "./toolResult.js";
8
9
  const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定により、マニフェスト(pom.xml)からの推移的依存の解決を省略しています。" +
@@ -10,24 +11,38 @@ const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定によ
10
11
  "検出0件でも推移的依存の安全性は確認できていません";
11
12
  /**
12
13
  * 推移的依存の解決状態。無効時に「検出0件」を安全と誤読されないよう応答の先頭付近に置く。
13
- * マニフェスト一覧の探索深さはosv-scannerの`-r`と一致しないため、一覧からpom.xmlの有無を
14
- * 判定せず、無効時は条件付きの警告を常に返す。
14
+ * 無効時は、どのマニフェストを含むかに依らず条件付きの警告を常に返す。
15
15
  */
16
- export function dependencyResolution(noRemoteResolution) {
16
+ export function dependencyResolution(noRemoteResolution, warning = TRANSITIVE_OMITTED_WARNING) {
17
17
  return noRemoteResolution
18
- ? { transitive_resolution: "disabled", warning: TRANSITIVE_OMITTED_WARNING }
18
+ ? { transitive_resolution: "disabled", warning }
19
19
  : { transitive_resolution: "enabled" };
20
20
  }
21
+ const SKIPPED_MANIFESTS_WARNING = "一部のマニフェストをスキャン対象から外しました(skipped_manifestsを参照)。" +
22
+ "それらの依存の脆弱性は結果に含まれないため、検出0件でも安全とは判断しないでください";
23
+ /**
24
+ * 親POMが許可ルートの外を参照するため外したマニフェスト。外したものが無ければ何も出力しない
25
+ * (既存の出力を変えない)。件数より前に置き、検出0件を安全と誤読させない
26
+ */
27
+ export function skippedManifestsFields(skipped) {
28
+ if (skipped.length === 0)
29
+ return {};
30
+ return {
31
+ skipped_manifests: skipped.map((s) => ({ path: sanitizeExternalText(s.path), reason: sanitizeExternalText(s.reason) })),
32
+ scope_warning: SKIPPED_MANIFESTS_WARNING,
33
+ };
34
+ }
21
35
  export async function handleScanJavaProject(args, options = {}) {
22
36
  try {
23
37
  const project = await detectJavaProject(args.project_path, {
24
38
  allowedRoot: options.allowedRoot,
25
39
  });
26
40
  const noRemoteResolution = isRemoteResolutionDisabled(options);
27
- const report = await runOsvScan(project.manifestPaths, { ...options, noRemoteResolution });
41
+ const report = await runOsvScan(project.targets, { ...options, noRemoteResolution });
28
42
  return jsonResult({
29
43
  project_dir: project.projectDir,
30
44
  manifests: project.manifests,
45
+ ...skippedManifestsFields(project.skipped),
31
46
  dependency_resolution: dependencyResolution(noRemoteResolution),
32
47
  ...report,
33
48
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `scan_project`ツールのハンドラ: Java / JavaScript / Python / Goのlockfileをまとめてスキャンする。
3
+ *
4
+ * 誤要約対策(JAR実体スキャンと同じ原則): スキャンできなかった依存を示すcoverageを件数より前に置き、
5
+ * complete=falseの場合は「検出0件でも安全とは言えない」旨の警告を付ける。
6
+ */
7
+ import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
11
+ import { parseOsvScanOutput } from "../osv/scanReport.js";
12
+ import { sanitizeExternalText } from "../utils/externalText.js";
13
+ import { detectProject } from "../utils/manifestDetector.js";
14
+ import { normalizePypiName } from "../utils/requirementsFile.js";
15
+ import { dependencyResolution } from "./scanJavaProject.js";
16
+ import { errorResult, jsonResult } from "./toolResult.js";
17
+ const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定により、マニフェスト(pom.xml / requirements.txt)からの推移的依存の解決を省略しています。" +
18
+ "lockfile(package-lock.json / poetry.lock / go.mod / gradle.lockfile等)に記録された依存は対象ですが、" +
19
+ "pom.xml・requirements.txtに直接記載された依存の先にある推移的依存の脆弱性は含まれません。" +
20
+ "検出0件でも推移的依存の安全性は確認できていません";
21
+ const INCOMPLETE_WARNING = "一部の依存はスキャンされていないか、版を推測してスキャンしています" +
22
+ "(lockfile_missing / unpinned_requirements / unscannable_requirements / skipped_filesを参照)。" +
23
+ "検出0件でも、それらの依存の安全性は確認できていません";
24
+ /** coverageの各一覧の上限(巨大なrequirements.txtで応答を膨らませない) */
25
+ const MAX_COVERAGE_ITEMS = 200;
26
+ function capped(items) {
27
+ return { items: items.slice(0, MAX_COVERAGE_ITEMS), omitted: Math.max(0, items.length - MAX_COVERAGE_ITEMS) };
28
+ }
29
+ function buildCoverage(project) {
30
+ const rel = (file) => sanitizeExternalText(path.relative(project.projectDir, file));
31
+ const lockfileMissing = capped(project.lockfileMissing);
32
+ const unpinned = capped(project.requirementIssues);
33
+ const unscannable = capped(project.requirementReferences);
34
+ const skipped = capped(project.skippedFiles);
35
+ const complete = project.lockfileMissing.length === 0 &&
36
+ project.requirementIssues.length === 0 &&
37
+ project.requirementReferences.length === 0 &&
38
+ project.skippedFiles.length === 0;
39
+ const omitted = lockfileMissing.omitted + unpinned.omitted + unscannable.omitted + skipped.omitted;
40
+ return {
41
+ complete,
42
+ ...(complete ? {} : { warning: INCOMPLETE_WARNING }),
43
+ manifests: project.manifests.map((m) => ({ path: sanitizeExternalText(m.path), ecosystem: m.ecosystem, format: m.format })),
44
+ lockfile_missing: lockfileMissing.items.map((m) => ({
45
+ path: sanitizeExternalText(m.path),
46
+ ecosystem: m.ecosystem,
47
+ status: m.status,
48
+ hint: sanitizeExternalText(m.hint),
49
+ })),
50
+ unpinned_requirements: unpinned.items.map((issue) => ({
51
+ file: rel(issue.file),
52
+ line: issue.line,
53
+ name: sanitizeExternalText(issue.name),
54
+ specifier: sanitizeExternalText(issue.specifier),
55
+ kind: issue.kind,
56
+ })),
57
+ unscannable_requirements: unscannable.items.map((ref) => ({
58
+ file: rel(ref.file),
59
+ line: ref.line,
60
+ text: sanitizeExternalText(ref.text),
61
+ reason: ref.reason,
62
+ })),
63
+ skipped_files: skipped.items.map((s) => ({ path: sanitizeExternalText(s.path), reason: sanitizeExternalText(s.reason) })),
64
+ ...(omitted > 0 ? { omitted_items: omitted } : {}),
65
+ };
66
+ }
67
+ function ecosystemBreakdown(project, packages) {
68
+ const breakdown = {};
69
+ for (const manifest of project.manifests) {
70
+ breakdown[manifest.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 };
71
+ breakdown[manifest.ecosystem].manifests++;
72
+ }
73
+ for (const pkg of packages) {
74
+ breakdown[pkg.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 };
75
+ breakdown[pkg.ecosystem].vulnerable_package_count++;
76
+ breakdown[pkg.ecosystem].vulnerability_count += pkg.vulnerabilities.length;
77
+ }
78
+ return breakdown;
79
+ }
80
+ /** `>=X` / `~=X` の行は、osv-scannerが下限Xを使用中の版とみなしてスキャンしている */
81
+ function markLowerBounds(project, packages) {
82
+ return packages.map((pkg) => pkg.ecosystem === "PyPI" &&
83
+ project.lowerBounds.some((lb) => lb.name === normalizePypiName(pkg.name) && lb.version === pkg.version)
84
+ ? { ...pkg, version_is_lower_bound: true }
85
+ : pkg);
86
+ }
87
+ /**
88
+ * requirements.txtは検証済みの正規化行だけを専用の一時ディレクトリに書いてスキャンする
89
+ * (元ファイルの取り込み指定をosv-scannerにたどらせない)。成功・失敗とも削除する。
90
+ */
91
+ async function scanWithRequirementsCopies(project, options) {
92
+ const copies = project.requirementsCopies.filter((copy) => copy.entries.length > 0);
93
+ if (project.targets.length === 0 && copies.length === 0)
94
+ return parseOsvScanOutput({ results: [] });
95
+ const dir = copies.length > 0 ? await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-req-")) : null;
96
+ try {
97
+ const targets = [...project.targets];
98
+ for (const [index, copy] of copies.entries()) {
99
+ const copyPath = path.join(dir, `${index}.txt`);
100
+ await writeFile(copyPath, `${copy.entries.join("\n")}\n`, { mode: 0o600, flag: "wx" });
101
+ targets.push({ path: copyPath, format: "requirements.txt" });
102
+ }
103
+ return await runOsvScan(targets, options);
104
+ }
105
+ finally {
106
+ if (dir !== null)
107
+ await rm(dir, { recursive: true, force: true });
108
+ }
109
+ }
110
+ export async function handleScanProject(args, options = {}) {
111
+ try {
112
+ const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
113
+ const noRemoteResolution = isRemoteResolutionDisabled(options);
114
+ const report = await scanWithRequirementsCopies(project, { ...options, noRemoteResolution });
115
+ return jsonResult({
116
+ project_dir: project.projectDir,
117
+ dependency_resolution: dependencyResolution(noRemoteResolution, TRANSITIVE_OMITTED_WARNING),
118
+ coverage: buildCoverage(project),
119
+ ecosystem_breakdown: ecosystemBreakdown(project, report.packages),
120
+ vulnerable_package_count: report.vulnerable_package_count,
121
+ vulnerability_count: report.vulnerability_count,
122
+ severity_breakdown: report.severity_breakdown,
123
+ packages: markLowerBounds(project, report.packages),
124
+ });
125
+ }
126
+ catch (error) {
127
+ return errorResult(error);
128
+ }
129
+ }
@@ -6,7 +6,7 @@
6
6
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
7
7
  import { suggestUpgrades } from "../osv/suggestFix.js";
8
8
  import { detectJavaProject } from "../utils/projectDetector.js";
9
- import { dependencyResolution, } from "./scanJavaProject.js";
9
+ import { dependencyResolution, skippedManifestsFields, } from "./scanJavaProject.js";
10
10
  import { errorResult, jsonResult } from "./toolResult.js";
11
11
  export async function handleSuggestFix(args, options = {}) {
12
12
  try {
@@ -14,12 +14,13 @@ export async function handleSuggestFix(args, options = {}) {
14
14
  allowedRoot: options.allowedRoot,
15
15
  });
16
16
  const noRemoteResolution = isRemoteResolutionDisabled(options);
17
- const report = await runOsvScan(project.manifestPaths, { ...options, noRemoteResolution });
17
+ const report = await runOsvScan(project.targets, { ...options, noRemoteResolution });
18
18
  const suggestions = suggestUpgrades(report.packages);
19
19
  const unfixedVulnerabilities = suggestions.reduce((sum, s) => sum + s.per_cve_detail.filter((d) => d.tier === "unfixed").length, 0);
20
20
  return jsonResult({
21
21
  project_dir: project.projectDir,
22
22
  manifests: project.manifests,
23
+ ...skippedManifestsFields(project.skipped),
23
24
  dependency_resolution: dependencyResolution(noRemoteResolution),
24
25
  vulnerable_package_count: suggestions.length,
25
26
  unfixed_vulnerability_count: unfixedVulnerabilities,