osv-scanner-mcp 0.1.11 → 0.2.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
@@ -12,6 +12,7 @@ Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップす
12
12
  ## 特徴
13
13
 
14
14
  - **ワンショットスキャン**: `scan_java_project` ツールにプロジェクトパスを渡すだけで、検出→スキャン→整形済みレポートまで一気に返します
15
+ - **JAR/WAR実体スキャン**: `scan_java_artifact` ツールで、lockfileが無い・shaded/fat JARしか手元にないプロジェクトでもアーカイブ内メタデータから既知の脆弱性を検出します(ベストエフォート同定であることを明示するcoverage情報付き)
15
16
  - **深刻度順のレポート**: パッケージごとに脆弱性をCVSSスコア順に整理し、5段階の深刻度ラベル(critical / high / medium / low / unknown)とサマリ集計付きで返します
16
17
  - **修正版の提示**: 各脆弱性の `fixed_versions` をMavenバージョン優先順位規則で正しくソートして含めます(`2.17.1-RELEASE` のようなsemver非対応の表記にも対応)
17
18
  - **セキュリティ第一の設計**: シェル非経由の実行・引数ホワイトリスト・パス正規化と境界チェック・タイムアウト/出力サイズ上限を実装段階から組み込んでいます
@@ -178,6 +179,33 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
178
179
  - `fixed_versions` はMaven優先順位で昇順。複数のリリース系統(例: 2.12系バックポートと2.15系)が混在することがあります。空配列は「修正版が存在しない」ことを意味します
179
180
  - `severity_score` が取得できない脆弱性は `null` / `"unknown"` として扱います
180
181
 
182
+ ### `scan_java_artifact`
183
+
184
+ JAR/WARファイルの実体をスキャンします。既存のマニフェスト方式とは別ツールです。
185
+
186
+ ```json
187
+ { "artifact_path": "/absolute/path/to/application.war" }
188
+ ```
189
+
190
+ `artifact_path` はJAR/WARファイル、または探索するディレクトリの絶対パスです。
191
+ ディレクトリ指定では `target` や `build` も探索します。`.git`、`node_modules`、`.idea`、`.vscode` と探索中のシンボリックリンクは除外します。
192
+ 探索上限は深さ8・100ファイル・10,000エントリです。上限に達して探索を完了できない場合は、結果を黙って省略せず `artifact_search_limit_exceeded` を返します。対象を絞って再実行してください。
193
+ `OSV_MCP_ALLOWED_ROOT` による制限も適用されます。
194
+
195
+ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJARもスキャナー側で解析します。Javaコードやビルドは実行しません。
196
+ 識別にはアーカイブ内メタデータを用いるため、除去済みメタデータやshaded/minimized JAR内の依存を見落とす場合があります。
197
+
198
+ **出力の読み方:**
199
+
200
+ - 先頭の `coverage` に `jars_found`、`jars_identified`、`unidentified_jars` を返します。件数はWARも含む、ファイルシステム上で列挙した外側のアーカイブ単位です。ネストJARの総数ではありません。
201
+ - `artifacts[].status` は `identified_with_vulnerabilities` / `identified_without_known_vulnerabilities` / `unidentified` の3値です。「同定済み」は少なくとも1件のMaven座標を取得できた意味であり、全依存の同定ではありません。
202
+ - `coverage.completeness` は常に `incomplete`。`identified_vulnerability_count: 0` は安全性の保証ではありません。
203
+ - `packages` は同定できた脆弱なパッケージの詳細です。複数アーカイブに含まれる同一パッケージ・脆弱性は全体集計では重複排除します。
204
+ - JAR/WARが無い場合は `no_scannable_artifacts`、全件同定不能の場合は警告を含む成功レポートです。
205
+
206
+ `suggest_fix` は引き続きマニフェスト方式専用です。experimentalプラグインを使うため、OSV-Scannerのピン留めバージョン更新時には、フラグとJAR/WARの出力形式も再検証してください。
207
+ 信頼できないアーカイブの展開はOSV-Scannerのネイティブ処理に依存します。タイムアウト・出力上限はありますが、OSレベルのメモリ制限やサンドボックスを提供するものではありません。
208
+
181
209
  ### `suggest_fix`
182
210
 
183
211
  スキャンを実行し、脆弱なパッケージごとに**推奨アップグレードバージョン**を提案します。単純な最大バージョンではなく、現在のバージョンに最も近いリリース系統の修正版を3段階フォールバックで選定します:
@@ -205,7 +233,8 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
205
233
  "ecosystem": "Maven",
206
234
  "recommended_upgrade": "2.25.4",
207
235
  "upgrade_tier": "major_internal",
208
- "upgrade_note": "2.14系統向けの修正版は存在しない。同一メジャー(2.x)内では2.25.4が7件のCVEを解消する最小版",
236
+ "verification": "verified",
237
+ "upgrade_note": "取得済みの影響範囲に基づき修正対象CVEの範囲外と確認した候補です",
209
238
  "per_cve_detail": [
210
239
  { "id": "GHSA-jfh8-c2jp-5v3q", "cve": "CVE-2021-44228", "severity": "critical", "fixed_in": "2.15.0", "tier": "major_internal" }
211
240
  ]
@@ -214,8 +243,10 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
214
243
  }
215
244
  ```
216
245
 
217
- - `recommended_upgrade` は「修正可能な全CVEを解消できる最小バージョン」(CVEごとのTier結果の最大値)
218
- - 修正版が存在しない(または現在より新しい修正版がない)CVEは `tier: "unfixed"` として明示し、推奨計算から除外します。全CVEがunfixedの場合 `recommended_upgrade` は `null`
246
+ - `recommended_upgrade` は既知の修正版を候補に、修正対象の全CVEの影響範囲外と確認できたものを3段階Tier順・バージョン昇順で選びます。CVEごとの修正版の最大値を単純に採用せず、別系統で再び影響を受ける候補も除外します。全公開版の中での最小性や未検出の脆弱性がないことは保証しません。
247
+ - MavenのOSV `ECOSYSTEM` 範囲(`introduced` / `fixed` / `last_affected` / 上限なし)を照合します。`versions` に明示された影響も確認します。範囲欠落・不正・未対応形式・`limit` による不完全な情報では安全と推定せず、候補を検証できなければ `recommended_upgrade: null`、`verification: "no_verified_candidate"` を返します。
248
+ - 推奨時は `verification: "verified"`、CVEごとの `recommended_status` は `affected` / `not_affected` / `unknown` です。推奨保留時は `not_evaluated` になります。`per_cve_detail.fixed_in` は各CVE単独の候補であり、最終推奨先の判定は `recommended_status` を参照してください。
249
+ - 現在より新しい修正版候補がないCVEは `tier: "unfixed"` として推奨の修正対象から除外します(情報欠落を含む場合があります)。除外したCVEも推奨先で判定し、その状態を表示します。全CVEがunfixedの場合も `recommended_upgrade` は `null` です。
219
250
 
220
251
  ### `explain_vulnerability`
221
252
 
@@ -294,6 +325,7 @@ npm run build # dist/ へビルド
294
325
  - [x] npmパッケージ化(`npx osv-scanner-mcp`)
295
326
  - [x] OSV-Scannerバイナリの自動ダウンロード(チェックサム検証付き)
296
327
  - [x] Gradle対応(lockfile方式)
328
+ - [x] `scan_java_artifact` ツール: JAR/WAR実体スキャン(lockfileが無い・shaded/fat JARのみのプロジェクト向け)
297
329
 
298
330
  ## ライセンス
299
331
 
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
15
15
  import { z } from "zod";
16
16
  import { handleExplainVulnerability } from "./tools/explainVulnerability.js";
17
17
  import { handleScanJavaProject } from "./tools/scanJavaProject.js";
18
+ import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
18
19
  import { handleSuggestFix } from "./tools/suggestFix.js";
19
20
  import { ALLOWED_ROOT_ENV, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
20
21
  export { ALLOWED_ROOT_ENV };
@@ -32,7 +33,7 @@ if (startupWarning !== null) {
32
33
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
33
34
  const server = new McpServer({
34
35
  name: "osv-scanner-mcp",
35
- version: "0.1.11",
36
+ version: "0.2.0",
36
37
  });
37
38
  server.registerTool("scan_java_project", {
38
39
  title: "Javaプロジェクトの脆弱性スキャン",
@@ -52,7 +53,8 @@ server.registerTool("suggest_fix", {
52
53
  "現在のバージョンに最も近いリリース系統の修正版を優先する3段階フォールバック" +
53
54
  "(same_minor: 同一major.minor系統内 → major_internal: 同一メジャー内 → cross_major: メジャーアップグレード)で選定し、" +
54
55
  "推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。" +
55
- "修正版が存在しないCVEはunfixedとして明示する。Maven(pom.xml)とGradle(gradle.lockfile)に対応。",
56
+ "候補を全修正対象CVEの影響範囲と照合し、情報不足の場合は推奨を保留する。" +
57
+ "現在より新しい修正版候補のないCVEはunfixedとして別表示し、推奨先での判定も返す。Maven(pom.xml)とGradle(gradle.lockfile)に対応。",
56
58
  inputSchema: {
57
59
  project_path: z
58
60
  .string()
@@ -73,6 +75,15 @@ server.registerTool("explain_vulnerability", {
73
75
  .describe("脆弱性のID(例: GHSA-jfh8-c2jp-5v3q、CVE-2021-44228)"),
74
76
  },
75
77
  }, async ({ vulnerability_id }) => handleExplainVulnerability({ vulnerability_id }));
78
+ server.registerTool("scan_java_artifact", {
79
+ title: "JAR/WAR実体の脆弱性スキャン",
80
+ description: "JAR/WARファイルまたはディレクトリ内の実体をスキャンする。ビルドやJavaコードの実行は行わない。" +
81
+ "メタデータによるベストエフォート同定のため、coverageと同定不能ファイルを必ず確認すること。" +
82
+ "completenessは常にincomplete。検出0件でも安全性や全依存の同定を保証しない。",
83
+ inputSchema: {
84
+ artifact_path: z.string().min(1).describe("JAR/WARファイル、または探索するディレクトリの絶対パス"),
85
+ },
86
+ }, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
76
87
  const transport = new StdioServerTransport();
77
88
  await server.connect(transport);
78
89
  // stdoutはMCPプロトコル専用のため、起動ログはstderrへ
@@ -0,0 +1,97 @@
1
+ import { compareMavenVersions } from "../utils/mavenVersion.js";
2
+ import { asArray, asRecord, asString } from "../utils/unknownJson.js";
3
+ /** Preserve uncertainty: unsupported or malformed ranges cannot prove a candidate safe. */
4
+ export function extractAffectedVersions(details, ids, name, ecosystem) {
5
+ const evidence = { complete: ecosystem === "Maven", intervals: [], versions: [] };
6
+ if (ids.length === 0 || ids.some(id => !details.some(d => d.id === id)))
7
+ evidence.complete = false;
8
+ for (const detail of details) {
9
+ let matched = false;
10
+ for (const raw of asArray(detail.affected)) {
11
+ const affected = asRecord(raw);
12
+ const pkg = asRecord(affected?.package);
13
+ if (asString(pkg?.name) !== name || asString(pkg?.ecosystem) !== ecosystem) {
14
+ if (!asString(pkg?.name) || !asString(pkg?.ecosystem))
15
+ evidence.complete = false;
16
+ continue;
17
+ }
18
+ matched = true;
19
+ if (affected?.versions !== undefined && !Array.isArray(affected.versions))
20
+ evidence.complete = false;
21
+ for (const version of asArray(affected?.versions)) {
22
+ const value = asString(version);
23
+ if (!value)
24
+ evidence.complete = false;
25
+ else
26
+ evidence.versions.push(value);
27
+ }
28
+ const ranges = asArray(affected?.ranges);
29
+ // A versions-only list does not establish that unlisted releases are unaffected.
30
+ if (ranges.length === 0)
31
+ evidence.complete = false;
32
+ for (const rawRange of ranges) {
33
+ const range = asRecord(rawRange);
34
+ if (range?.type !== "ECOSYSTEM") {
35
+ evidence.complete = false;
36
+ continue;
37
+ }
38
+ const events = asArray(range.events);
39
+ let start = null;
40
+ let previousEnd = null;
41
+ let previousInclusive = false;
42
+ if (events.length === 0)
43
+ evidence.complete = false;
44
+ for (const rawEvent of events) {
45
+ const event = asRecord(rawEvent);
46
+ const keys = event ? Object.keys(event) : [];
47
+ const kind = keys[0];
48
+ const value = kind ? asString(event?.[kind]) : null;
49
+ if (keys.length !== 1 || !value || !["introduced", "fixed", "last_affected", "limit"].includes(kind)) {
50
+ evidence.complete = false;
51
+ continue;
52
+ }
53
+ if (kind === "introduced") {
54
+ if (start !== null || (previousEnd !== null && (value === "0" ||
55
+ compareMavenVersions(value, previousEnd) < 0 ||
56
+ (previousInclusive && compareMavenVersions(value, previousEnd) === 0))))
57
+ evidence.complete = false;
58
+ start = value;
59
+ }
60
+ else {
61
+ if (start === null) {
62
+ evidence.complete = false;
63
+ continue;
64
+ }
65
+ const inclusive = kind === "last_affected";
66
+ if (start !== "0" && (compareMavenVersions(start, value) > 0 ||
67
+ (!inclusive && compareMavenVersions(start, value) === 0)))
68
+ evidence.complete = false;
69
+ evidence.intervals.push({ introduced: start, end: value, inclusive });
70
+ // limit bounds known affected versions, but is not evidence of a fix beyond it.
71
+ if (kind === "limit")
72
+ evidence.complete = false;
73
+ previousEnd = value;
74
+ previousInclusive = inclusive;
75
+ start = null;
76
+ }
77
+ }
78
+ if (start !== null)
79
+ evidence.intervals.push({ introduced: start, end: null, inclusive: false });
80
+ }
81
+ }
82
+ if (!matched)
83
+ evidence.complete = false;
84
+ }
85
+ if (details.length === 0)
86
+ evidence.complete = false;
87
+ return evidence;
88
+ }
89
+ export function candidateStatus(evidence, version) {
90
+ if (!evidence)
91
+ return "unknown";
92
+ if (evidence.versions.some(v => compareMavenVersions(v, version) === 0) || evidence.intervals.some(r => (r.introduced === "0" || compareMavenVersions(version, r.introduced) >= 0) &&
93
+ (r.end === null || compareMavenVersions(version, r.end) < 0 ||
94
+ (r.inclusive && compareMavenVersions(version, r.end) === 0))))
95
+ return "affected";
96
+ return evidence.complete ? "not_affected" : "unknown";
97
+ }
@@ -0,0 +1,60 @@
1
+ import { ScanToolError } from "../errors.js";
2
+ import { sanitizeExternalText } from "../utils/externalText.js";
3
+ import { asArray, asRecord } from "../utils/unknownJson.js";
4
+ import { parseOsvScanOutput } from "./scanReport.js";
5
+ const WARNING = "Best-effort metadata identification only. Missing or removed metadata can hide dependencies, even in identified archives. Zero identified vulnerabilities does not establish safety.";
6
+ const HINT = "Metadata may be missing (shaded/minimized archive). Scan the build project's lockfile instead.";
7
+ function isIdentifiedPackage(raw) {
8
+ const info = asRecord(asRecord(raw)?.package);
9
+ if (info?.ecosystem !== "Maven" || typeof info.name !== "string" || typeof info.version !== "string")
10
+ return false;
11
+ const parts = info.name.split(":");
12
+ return parts.length === 2 && parts.every((part) => part.trim() !== "" && part.toLowerCase() !== "unknown") &&
13
+ info.version.trim() !== "" && info.version.toLowerCase() !== "unknown";
14
+ }
15
+ export function buildArtifactReport(raw, artifactPaths) {
16
+ const results = asRecord(raw)?.results;
17
+ if (!Array.isArray(results)) {
18
+ throw new ScanToolError("invalid_output", "OSV-Scanner artifact output is missing its results array");
19
+ }
20
+ const byPath = new Map();
21
+ for (const result of results) {
22
+ const record = asRecord(result);
23
+ const source = asRecord(record?.source);
24
+ // Identity comparison must precede display sanitization, which can collapse distinct names.
25
+ const sourcePath = source?.path;
26
+ if (source?.type !== "artifact" || typeof sourcePath !== "string" || !artifactPaths.includes(sourcePath))
27
+ continue;
28
+ const packages = asArray(record?.packages).filter(isIdentifiedPackage);
29
+ if (packages.length > 0)
30
+ byPath.set(sourcePath, [...(byPath.get(sourcePath) ?? []), ...packages]);
31
+ }
32
+ const normalized = [...byPath].map(([file, packages]) => ({ source: { path: file }, packages }));
33
+ const report = parseOsvScanOutput({ results: normalized });
34
+ const artifacts = artifactPaths.map((file) => {
35
+ const packages = byPath.get(file);
36
+ const identified = packages !== undefined;
37
+ const perFile = parseOsvScanOutput({ results: [{ packages: packages ?? [] }] });
38
+ return {
39
+ path: sanitizeExternalText(file),
40
+ status: !identified ? "unidentified" : perFile.vulnerability_count > 0
41
+ ? "identified_with_vulnerabilities" : "identified_without_known_vulnerabilities",
42
+ identified_vulnerability_count: perFile.vulnerability_count,
43
+ };
44
+ });
45
+ return {
46
+ coverage: {
47
+ jars_found: artifactPaths.length,
48
+ jars_identified: byPath.size,
49
+ unidentified_jars: artifacts.filter((item) => item.status === "unidentified")
50
+ .map(({ path }) => ({ path, hint: HINT })),
51
+ completeness: "incomplete",
52
+ warning: WARNING,
53
+ },
54
+ artifacts,
55
+ identified_vulnerability_count: report.vulnerability_count,
56
+ identified_vulnerable_package_count: report.vulnerable_package_count,
57
+ severity_breakdown: report.severity_breakdown,
58
+ packages: report.packages,
59
+ };
60
+ }
@@ -15,6 +15,7 @@ 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";
17
17
  import { ScanToolError } from "../errors.js";
18
+ import { readResponseBytes } from "../utils/readResponseBytes.js";
18
19
  /** ピン留めするOSV-Scannerのバージョン。更新時は下のチェックサムも必ず更新すること */
19
20
  export const PINNED_OSV_SCANNER_VERSION = "2.4.0";
20
21
  /**
@@ -70,15 +71,14 @@ async function downloadAsset(url, fetchFn, timeoutMs) {
70
71
  if (!response.ok) {
71
72
  throw new ScanToolError("binary_download_failed", `OSV-Scannerのダウンロードに失敗しました(HTTP ${response.status}): ${url}`);
72
73
  }
73
- const contentLength = Number(response.headers.get("content-length") ?? "0");
74
- if (contentLength > MAX_BINARY_BYTES) {
75
- throw new ScanToolError("binary_download_failed", `ダウンロードサイズが上限(${MAX_BINARY_BYTES}バイト)を超えています`);
74
+ try {
75
+ return await readResponseBytes(response, MAX_BINARY_BYTES, new ScanToolError("binary_download_failed", `ダウンロードサイズが上限(${MAX_BINARY_BYTES}バイト)を超えています`));
76
76
  }
77
- const buffer = Buffer.from(await response.arrayBuffer());
78
- if (buffer.byteLength > MAX_BINARY_BYTES) {
79
- throw new ScanToolError("binary_download_failed", `ダウンロードサイズが上限(${MAX_BINARY_BYTES}バイト)を超えています`);
77
+ catch (error) {
78
+ if (error instanceof ScanToolError)
79
+ throw error;
80
+ throw new ScanToolError("binary_download_failed", "OSV-Scannerのダウンロード本文を受信できませんでした");
80
81
  }
81
- return buffer;
82
82
  }
83
83
  /**
84
84
  * ピン留めバージョンのOSV-Scannerをキャッシュから返す。
@@ -9,6 +9,7 @@
9
9
  * - レスポンスは外部由来データとして防御的にパースする(呼び出し側)
10
10
  */
11
11
  import { ScanToolError } from "../errors.js";
12
+ import { readResponseBytes } from "../utils/readResponseBytes.js";
12
13
  const DEFAULT_TIMEOUT_MS = 15_000;
13
14
  const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
14
15
  const DEFAULT_BASE_URL = "https://api.osv.dev";
@@ -56,14 +57,15 @@ export async function fetchOsvRecord(id, options = {}) {
56
57
  if (!response.ok) {
57
58
  throw new ScanToolError("api_request_failed", `OSV APIがエラーを返しました(HTTP ${response.status})`);
58
59
  }
59
- const contentLength = Number(response.headers.get("content-length") ?? "0");
60
- if (contentLength > maxBytes) {
61
- throw new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`);
60
+ let body;
61
+ try {
62
+ const bytes = await readResponseBytes(response, maxBytes, new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`));
63
+ body = new TextDecoder().decode(bytes);
62
64
  }
63
- const body = await response.text();
64
- // 文字数(UTF-16単位)ではなくUTF-8バイト数で判定する(マルチバイト本文のズレ防止)
65
- if (Buffer.byteLength(body, "utf8") > maxBytes) {
66
- throw new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`);
65
+ catch (error) {
66
+ if (error instanceof ScanToolError)
67
+ throw error;
68
+ throw new ScanToolError("api_request_failed", "OSV APIのレスポンス本文を受信できませんでした");
67
69
  }
68
70
  try {
69
71
  return JSON.parse(body);
@@ -4,7 +4,7 @@
4
4
  * セキュリティ設計(docs/DESIGN_TODO.md):
5
5
  * - シェルを経由しない`spawn`+引数配列で実行(コマンドインジェクション対策)
6
6
  * - OSV-Scannerへ渡す引数は固定リストのみ。呼び出し側から任意フラグは注入できない
7
- * (対象パスは位置引数1つだけで、projectDetectorでrealpath解決済みの絶対パスを渡す)
7
+ * (プロジェクトは検証済みディレクトリ1つ、実体スキャンは列挙済みのJAR/WAR絶対パスだけを渡す)
8
8
  * - タイムアウトと出力サイズ上限を設ける(ハング・巨大出力によるDoS対策)
9
9
  *
10
10
  * 終了コード(2.4.0で実機確認):
@@ -38,12 +38,18 @@ const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
38
38
  const MAX_STDERR_DETAIL_BYTES = 8 * 1024;
39
39
  /** OSV-Scannerに渡す固定引数。ここに無いオプションは一切使わない(ホワイトリスト) */
40
40
  const FIXED_SCAN_ARGS = ["scan", "source", "-r", "--format", "json"];
41
+ const FIXED_ARTIFACT_ARGS = [
42
+ "scan", "source", "--format", "json", "--all-packages", "--no-ignore",
43
+ "--experimental-no-default-plugins", "--experimental-plugins", "java/archive",
44
+ ];
41
45
  const EXIT_NO_VULNS = 0;
42
46
  const EXIT_VULNS_FOUND = 1;
43
47
  const EXIT_NO_PACKAGES = 128;
44
- function execOsvScanner(binaryPath, projectDir, timeoutMs, maxOutputBytes) {
48
+ function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, artifactMode) {
45
49
  return new Promise((resolve, reject) => {
46
- const child = spawn(binaryPath, [...FIXED_SCAN_ARGS, projectDir], {
50
+ const child = spawn(binaryPath, [
51
+ ...(artifactMode ? FIXED_ARTIFACT_ARGS : FIXED_SCAN_ARGS), ...targetPaths,
52
+ ], {
47
53
  shell: false,
48
54
  stdio: ["ignore", "pipe", "pipe"],
49
55
  });
@@ -101,25 +107,39 @@ function execOsvScanner(binaryPath, projectDir, timeoutMs, maxOutputBytes) {
101
107
  * 絶対パスを渡すこと**(このレイヤーではパス検証を行わない)
102
108
  */
103
109
  export async function runOsvScan(projectDir, options = {}) {
110
+ return parseOsvScanOutput(await runScan([projectDir], options, false));
111
+ }
112
+ /** Accept only the exact absolute files enumerated by detectJavaArtifacts. */
113
+ export async function runOsvArtifactScan(artifactPaths, options = {}) {
114
+ if (artifactPaths.length === 0) {
115
+ throw new ScanToolError("no_scannable_artifacts", "No JAR/WAR archives selected");
116
+ }
117
+ return runScan(artifactPaths, options, true);
118
+ }
119
+ async function runScan(targetPaths, options, artifactMode) {
104
120
  const limit = options.maxConcurrentScans ?? maxConcurrentScansFromEnv();
105
121
  if (activeScans >= limit) {
106
122
  throw new ScanToolError("too_many_concurrent_scans", `同時実行できるスキャンは${limit}件までです(現在${activeScans}件実行中)。実行中のスキャン完了後に再試行してください`);
107
123
  }
108
124
  activeScans++;
109
125
  try {
110
- return await runOsvScanUnguarded(projectDir, options);
126
+ return await runOsvScanUnguarded(targetPaths, options, artifactMode);
111
127
  }
112
128
  finally {
113
129
  activeScans--;
114
130
  }
115
131
  }
116
- async function runOsvScanUnguarded(projectDir, options) {
132
+ async function runOsvScanUnguarded(targetPaths, options, artifactMode) {
117
133
  const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
118
- const result = await execOsvScanner(binaryPath, projectDir, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES);
119
- if (result.exitCode === EXIT_NO_PACKAGES) {
120
- throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${projectDir}(pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
134
+ const result = await execOsvScanner(binaryPath, targetPaths, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, artifactMode);
135
+ if (result.exitCode === EXIT_NO_PACKAGES && !artifactMode) {
136
+ throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${targetPaths[0]}(pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
137
+ }
138
+ if (artifactMode && result.exitCode === EXIT_NO_PACKAGES && result.stdout.trim() === "") {
139
+ return { results: [] };
121
140
  }
122
- if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND) {
141
+ if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND &&
142
+ !(artifactMode && result.exitCode === EXIT_NO_PACKAGES)) {
123
143
  const status = result.exitCode !== null ? `exit code ${result.exitCode}` : `signal ${result.signal}`;
124
144
  throw new ScanToolError("scan_failed", `OSV-Scannerが異常終了しました(${status})`, result.stderr);
125
145
  }
@@ -130,5 +150,5 @@ async function runOsvScanUnguarded(projectDir, options) {
130
150
  catch {
131
151
  throw new ScanToolError("invalid_output", "OSV-Scannerの出力をJSONとして解釈できませんでした", result.stdout.slice(0, 1000));
132
152
  }
133
- return parseOsvScanOutput(parsed);
153
+ return parsed;
134
154
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { compareMavenVersions } from "../utils/mavenVersion.js";
16
16
  import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
17
+ import { extractAffectedVersions } from "./affectedVersions.js";
17
18
  /** 外部由来のsummaryをLLMに渡す際の長さ上限(プロンプト肥大・DoS対策) */
18
19
  const MAX_SUMMARY_LENGTH = 500;
19
20
  const CVSS_CRITICAL = 9.0;
@@ -65,7 +66,7 @@ function extractSummary(vulnDetails) {
65
66
  * OSVエントリの`affected[].ranges[].events[].fixed`から修正版を収集する。
66
67
  * 1つのOSVエントリが複数パッケージをカバーしうるため、対象パッケージ名で絞り込む。
67
68
  */
68
- function extractFixedVersions(vulnDetails, packageName) {
69
+ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
69
70
  const versions = new Set();
70
71
  for (const detail of vulnDetails) {
71
72
  for (const affectedRaw of asArray(detail.affected)) {
@@ -73,9 +74,11 @@ function extractFixedVersions(vulnDetails, packageName) {
73
74
  if (!affected)
74
75
  continue;
75
76
  const affectedName = asString(asRecord(affected.package)?.name);
76
- if (affectedName !== null && affectedName !== packageName)
77
+ if (affectedName !== packageName || asString(asRecord(affected.package)?.ecosystem) !== ecosystem)
77
78
  continue;
78
79
  for (const rangeRaw of asArray(affected.ranges)) {
80
+ if (asRecord(rangeRaw)?.type !== "ECOSYSTEM" || ecosystem !== "Maven")
81
+ continue;
79
82
  for (const eventRaw of asArray(asRecord(rangeRaw)?.events)) {
80
83
  const fixed = asString(asRecord(eventRaw)?.fixed);
81
84
  if (fixed !== null && fixed !== "")
@@ -139,7 +142,8 @@ export function parseOsvScanOutput(raw) {
139
142
  severity_score: score,
140
143
  severity: severityFromScore(score),
141
144
  summary: extractSummary(vulnDetails),
142
- fixed_versions: extractFixedVersions(vulnDetails, name),
145
+ fixed_versions: extractFixedVersions(vulnDetails, name, ecosystem),
146
+ affected_versions: extractAffectedVersions(vulnDetails, ids, name, ecosystem),
143
147
  });
144
148
  }
145
149
  }
@@ -10,11 +10,12 @@
10
10
  * Tier 2 (major_internal): 同一メジャー内の最小の修正版(マイナーバージョンアップ)
11
11
  * Tier 3 (cross_major): 全体最小の修正版(メジャーアップグレード、破壊的変更の可能性)
12
12
  *
13
- * パッケージ全体の推奨(recommended_upgrade)は全CVEのTier結果の最大値
14
- * (= すべての修正可能なCVEを解消できる最小バージョン)。
13
+ * パッケージ全体の候補を全修正対象CVEの影響範囲と照合して推奨する。
14
+ * 判定不能な候補は推奨しない。
15
15
  * 現在より新しい修正版が存在しないCVEはunfixedとして明示し、推奨計算から除外する。
16
16
  */
17
17
  import { compareMavenVersions, mavenVersionSeries } from "../utils/mavenVersion.js";
18
+ import { candidateStatus } from "./affectedVersions.js";
18
19
  function classifyTier(currentSeries, candidate) {
19
20
  const candidateSeries = mavenVersionSeries(candidate);
20
21
  if (currentSeries === null || candidateSeries === null)
@@ -48,26 +49,26 @@ function pickFixForCve(currentVersion, currentSeries, fixedVersions) {
48
49
  }
49
50
  function buildNote(currentSeries, recommended, tier, fixableCount, unfixedCount) {
50
51
  if (recommended === null) {
51
- return `全${unfixedCount}件のCVEに現在より新しい修正版が存在しない(unfixed)`;
52
+ return `全${unfixedCount}件のCVEに現在より新しい修正版候補がない(unfixed)。修正版情報の欠落を含む可能性があります`;
52
53
  }
53
54
  const label = currentSeries !== null ? `${currentSeries.major}.${currentSeries.minor}` : null;
54
55
  let note;
55
56
  switch (tier) {
56
57
  case "same_minor":
57
- note = `現在の${label}系統内の${recommended}で、修正版が存在する${fixableCount}件のCVEをすべて解消できる`;
58
+ note = `現在の${label}系統内の候補${recommended}を推奨`;
58
59
  break;
59
60
  case "major_internal":
60
- note = `${label}系統向けの修正版は存在しない。同一メジャー(${currentSeries.major}.x)内では${recommended}が${fixableCount}件のCVEを解消する最小版`;
61
+ note = `同一メジャー(${currentSeries.major}.x)内の候補${recommended}を推奨`;
61
62
  break;
62
63
  default:
63
64
  note =
64
65
  label !== null
65
- ? `同一メジャー(${currentSeries.major}.x)内に修正版が存在しない。${recommended}へのメジャーアップグレードが必要(破壊的変更の可能性あり)`
66
- : `現在バージョンの系統を判定できないため、全体最小の修正版${recommended}を提示`;
67
- }
68
- if (unfixedCount > 0) {
69
- note += `。残り${unfixedCount}件は修正版が存在せず、このアップグレードでは解消されない`;
66
+ ? `候補${recommended}へのメジャーアップグレードを推奨(破壊的変更の可能性あり)`
67
+ : `現在バージョンの系統を判定できないため、検証済み候補${recommended}を提示`;
70
68
  }
69
+ note += `。取得済みの影響範囲に基づき修正対象${fixableCount}件のCVEの範囲外と確認しました。全公開版の最小性や未検出の脆弱性がないことは保証しません`;
70
+ if (unfixedCount > 0)
71
+ note += `。残り${unfixedCount}件は現在より新しい修正版候補がなく、修正対象から除外しています。recommended_statusを確認してください`;
71
72
  return note;
72
73
  }
73
74
  /** 1パッケージ分のアップグレード提案を組み立てる。 */
@@ -96,11 +97,26 @@ export function suggestUpgradeForPackage(pkg) {
96
97
  fixed_in: pick.version,
97
98
  tier: pick.tier,
98
99
  });
99
- if (recommended === null || compareMavenVersions(pick.version, recommended) > 0) {
100
- recommended = pick.version;
101
- }
102
100
  }
103
101
  const fixableCount = details.length - unfixedCount;
102
+ const targets = pkg.vulnerabilities.filter((_, i) => details[i].fixed_in !== null);
103
+ const candidates = [...new Set(targets.flatMap(v => v.fixed_versions))]
104
+ .filter(v => compareMavenVersions(v, pkg.version) > 0)
105
+ .sort(compareMavenVersions);
106
+ const tierOrder = ["same_minor", "major_internal", "cross_major"];
107
+ recommended = null;
108
+ for (const tier of tierOrder) {
109
+ const candidate = candidates.find(v => classifyTier(currentSeries, v) === tier &&
110
+ targets.every(target => candidateStatus(target.affected_versions, v) === "not_affected"));
111
+ if (candidate !== undefined) {
112
+ recommended = candidate;
113
+ break;
114
+ }
115
+ }
116
+ for (let i = 0; i < details.length; i++) {
117
+ details[i].recommended_status = recommended === null ? "not_evaluated" :
118
+ candidateStatus(pkg.vulnerabilities[i].affected_versions, recommended);
119
+ }
104
120
  // 推奨バージョン自体のTierは「現在バージョンとの系統関係」で再分類する
105
121
  // (per-CVEのTierの寄せ集めではなく、実際に行うアップグレードの距離を表す)
106
122
  const upgradeTier = recommended !== null ? classifyTier(currentSeries, recommended) : null;
@@ -110,8 +126,11 @@ export function suggestUpgradeForPackage(pkg) {
110
126
  ecosystem: pkg.ecosystem,
111
127
  recommended_upgrade: recommended,
112
128
  upgrade_tier: upgradeTier,
113
- upgrade_note: buildNote(currentSeries, recommended, upgradeTier, fixableCount, unfixedCount),
129
+ upgrade_note: recommended === null && fixableCount > 0
130
+ ? "既知の修正版候補から、全修正対象CVEの影響範囲外と確認できる版が見つかりません。情報不足・未対応の範囲形式を含む場合も推奨を保留します。"
131
+ : buildNote(currentSeries, recommended, upgradeTier, fixableCount, unfixedCount),
114
132
  per_cve_detail: details,
133
+ verification: recommended === null ? "no_verified_candidate" : "verified",
115
134
  };
116
135
  }
117
136
  /** スキャンレポート全体からパッケージごとの提案一覧を作る(深刻度順を維持)。 */
@@ -0,0 +1,14 @@
1
+ import { buildArtifactReport } from "../osv/artifactReport.js";
2
+ import { runOsvArtifactScan } from "../osv/runner.js";
3
+ import { detectJavaArtifacts } from "../utils/artifactDetector.js";
4
+ import { errorResult, jsonResult } from "./toolResult.js";
5
+ export async function handleScanJavaArtifact(args, options = {}) {
6
+ try {
7
+ const { artifactPaths } = await detectJavaArtifacts(args.artifact_path, options);
8
+ const raw = await runOsvArtifactScan(artifactPaths, options);
9
+ return jsonResult(buildArtifactReport(raw, artifactPaths));
10
+ }
11
+ catch (error) {
12
+ return errorResult(error);
13
+ }
14
+ }
@@ -0,0 +1,61 @@
1
+ import { opendir, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ScanToolError } from "../errors.js";
4
+ // Build output directories must be included for artifact scans.
5
+ const SKIPPED_DIRS = new Set([".git", "node_modules", ".idea", ".vscode"]);
6
+ const isArchive = (file) => /\.(jar|war)$/i.test(file);
7
+ export async function detectJavaArtifacts(inputPath, options = {}) {
8
+ if (typeof inputPath !== "string" || inputPath.trim() === "" || !path.isAbsolute(inputPath)) {
9
+ throw new ScanToolError("project_not_found", "Specify an absolute JAR/WAR or directory path");
10
+ }
11
+ let targetPath;
12
+ try {
13
+ targetPath = await realpath(inputPath);
14
+ }
15
+ catch {
16
+ throw new ScanToolError("project_not_found", `Path does not exist: ${inputPath}`);
17
+ }
18
+ if (options.allowedRoot !== undefined) {
19
+ const root = await realpath(options.allowedRoot);
20
+ const relative = path.relative(root, targetPath);
21
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
22
+ throw new ScanToolError("path_outside_allowed_root", "Artifact path is outside the allowed root");
23
+ }
24
+ }
25
+ const info = await stat(targetPath);
26
+ if (info.isFile() && isArchive(targetPath))
27
+ return { targetPath, artifactPaths: [targetPath] };
28
+ if (!info.isDirectory()) {
29
+ throw new ScanToolError("no_scannable_artifacts", "The selected file is not a JAR/WAR archive");
30
+ }
31
+ const maxDepth = options.maxDepth ?? 8;
32
+ const maxArtifacts = options.maxArtifacts ?? 100;
33
+ const maxEntries = options.maxEntries ?? 10_000;
34
+ const artifactPaths = [];
35
+ let visited = 0;
36
+ const limitError = () => new ScanToolError("artifact_search_limit_exceeded", "Artifact discovery limit exceeded; select a smaller directory or an individual JAR/WAR");
37
+ async function walk(dir, depth) {
38
+ const entries = await opendir(dir);
39
+ for await (const entry of entries) {
40
+ if (++visited > maxEntries)
41
+ throw limitError();
42
+ const child = path.join(dir, entry.name);
43
+ if (entry.isFile() && isArchive(entry.name)) {
44
+ if (artifactPaths.length >= maxArtifacts)
45
+ throw limitError();
46
+ artifactPaths.push(child);
47
+ }
48
+ else if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) {
49
+ if (depth >= maxDepth)
50
+ throw limitError();
51
+ await walk(child, depth + 1);
52
+ }
53
+ // Dirent predicates do not follow symlinks. Never hand these to the scanner.
54
+ }
55
+ }
56
+ await walk(targetPath, 1);
57
+ if (artifactPaths.length === 0) {
58
+ throw new ScanToolError("no_scannable_artifacts", "No JAR/WAR archives found in the selected directory");
59
+ }
60
+ return { targetPath, artifactPaths: artifactPaths.sort() };
61
+ }
@@ -0,0 +1,33 @@
1
+ /** Read a fetch body without retaining more than the configured byte limit. */
2
+ export async function readResponseBytes(response, maxBytes, sizeError) {
3
+ if (Number(response.headers.get("content-length")) > maxBytes) {
4
+ // Cleanup must not delay or replace the size-limit error.
5
+ void response.body?.cancel().catch(() => { });
6
+ throw sizeError;
7
+ }
8
+ if (response.body === null)
9
+ return Buffer.alloc(0);
10
+ const reader = response.body.getReader();
11
+ const chunks = [];
12
+ let total = 0;
13
+ try {
14
+ while (true) {
15
+ const { done, value } = await reader.read();
16
+ if (done)
17
+ break;
18
+ if (value.byteLength > maxBytes - total)
19
+ throw sizeError;
20
+ total += value.byteLength;
21
+ if (value.byteLength > 0)
22
+ chunks.push(value);
23
+ }
24
+ return Buffer.concat(chunks, total);
25
+ }
26
+ catch (error) {
27
+ void reader.cancel().catch(() => { });
28
+ throw error;
29
+ }
30
+ finally {
31
+ reader.releaseLock();
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.1.11",
3
+ "version": "0.2.0",
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",
@@ -42,7 +42,7 @@
42
42
  "prepublishOnly": "npm run typecheck && npm test"
43
43
  },
44
44
  "devDependencies": {
45
- "@types/node": "^26.1.0",
45
+ "@types/node": "^26.5.0",
46
46
  "@vitest/coverage-v8": "^4.1.9",
47
47
  "typescript": "^7.0.2",
48
48
  "vitest": "^4.1.9"