osv-scanner-mcp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップす
13
13
 
14
14
  - **ワンショットスキャン**: `scan_java_project` ツールにプロジェクトパスを渡すだけで、検出→スキャン→整形済みレポートまで一気に返します
15
15
  - **JAR/WAR実体スキャン**: `scan_java_artifact` ツールで、lockfileが無い・shaded/fat JARしか手元にないプロジェクトでもアーカイブ内メタデータから既知の脆弱性を検出します(ベストエフォート同定であることを明示するcoverage情報付き)
16
+ - **SBOM入力スキャン**: `scan_sbom` ツールでCycloneDX/SPDXのJSON SBOMに記録された依存を検査します。SBOMの網羅性や実成果物との一致は未検証であることを明示します
16
17
  - **深刻度順のレポート**: パッケージごとに脆弱性をCVSSスコア順に整理し、5段階の深刻度ラベル(critical / high / medium / low / unknown)とサマリ集計付きで返します
17
18
  - **修正版の提示**: 各脆弱性の `fixed_versions` をMavenバージョン優先順位規則で正しくソートして含めます(`2.17.1-RELEASE` のようなsemver非対応の表記にも対応)
18
19
  - **セキュリティ第一の設計**: シェル非経由の実行・引数ホワイトリスト・パス正規化と境界チェック・タイムアウト/出力サイズ上限を実装段階から組み込んでいます
@@ -206,6 +207,30 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
206
207
  `suggest_fix` は引き続きマニフェスト方式専用です。experimentalプラグインを使うため、OSV-Scannerのピン留めバージョン更新時には、フラグとJAR/WARの出力形式も再検証してください。
207
208
  信頼できないアーカイブの展開はOSV-Scannerのネイティブ処理に依存します。タイムアウト・出力上限はありますが、OSレベルのメモリ制限やサンドボックスを提供するものではありません。
208
209
 
210
+ ### `scan_sbom`
211
+
212
+ 既存のSBOMに記録された依存をOSV-Scannerで照会します。SBOMの生成、ビルド、JARの実行は行いません。
213
+
214
+ ```json
215
+ { "sbom_path": "/absolute/path/to/release-sbom.json" }
216
+ ```
217
+
218
+ - **対応形式**: UTF-8 JSONのCycloneDX 1.4 / 1.5 / 1.6、SPDX 2.2 / 2.3。XML、SPDX tag-value、SPDX 3は未対応です。
219
+ - **入力**: 16MiB以下のローカル通常ファイルの絶対パス。ファイル名は任意で、内容から形式を判別します。CycloneDXは`components`、SPDXは`packages`配列が必要です。形式・主要構造の確認であり、仕様全体のJSON Schema検証ではありません。
220
+ - **識別情報**: CycloneDXの`components[].purl`、SPDXの`packages[].externalRefs`にバージョン付きPackage URLを含めてください。例: `pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1`。詳細は[OSV-Scanner公式ドキュメント](https://github.com/google/osv-scanner/blob/main/docs/scan-source.md)を参照してください。
221
+ - **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。
222
+
223
+ 出力の先頭に`coverage`を返します。
224
+
225
+ - `identified_package_count`: スキャナーが識別した、名前・バージョン・エコシステムの重複を除いたパッケージ数。既知脆弱性がないものも含みます。
226
+ - `unidentified_packages`: スキャナー出力に存在したものの、バージョン等が不足しているパッケージ。スキャナー自体が読み飛ばした項目は列挙できないため、この配列が空でも全件検査を意味しません。
227
+ - `status`: 識別できたものがあれば`packages_identified`、なければ`no_packages_identified`。
228
+ - `completeness` / `artifact_match`: ともに`not_verified`。SBOMの依存網羅性や、実際のJARと同一ビルドのものかは自動検証しません。
229
+
230
+ `sbom`には元ファイルのパス・形式・仕様バージョン・スキャンに用いた入力バイト列のSHA256を返します。`identified_vulnerability_count`と`packages`には識別できた依存の検出結果を返します。**検出0件は「安全」の保証ではありません。** メタデータのないJARを補完するには、そのビルドに対応する正確なSBOMを別途用意してください。
231
+
232
+ 不正JSONは`invalid_sbom`、未対応形式は`unsupported_sbom_format`、入力上限超過は`sbom_too_large`、存在しない・読み取れないファイルは`sbom_not_found`です。空のSBOMや識別できるパッケージがないSBOMは、警告付きの成功レポートになります。既存ツールと同じタイムアウト・出力上限・同時実行枠を使用します。
233
+
209
234
  ### `suggest_fix`
210
235
 
211
236
  スキャンを実行し、脆弱なパッケージごとに**推奨アップグレードバージョン**を提案します。単純な最大バージョンではなく、現在のバージョンに最も近いリリース系統の修正版を3段階フォールバックで選定します:
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { z } from "zod";
16
16
  import { handleExplainVulnerability } from "./tools/explainVulnerability.js";
17
17
  import { handleScanJavaProject } from "./tools/scanJavaProject.js";
18
18
  import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
19
+ import { handleScanSbom } from "./tools/scanSbom.js";
19
20
  import { handleSuggestFix } from "./tools/suggestFix.js";
20
21
  import { ALLOWED_ROOT_ENV, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
21
22
  export { ALLOWED_ROOT_ENV };
@@ -33,7 +34,7 @@ if (startupWarning !== null) {
33
34
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
34
35
  const server = new McpServer({
35
36
  name: "osv-scanner-mcp",
36
- version: "0.2.0",
37
+ version: "0.3.1",
37
38
  });
38
39
  server.registerTool("scan_java_project", {
39
40
  title: "Javaプロジェクトの脆弱性スキャン",
@@ -84,6 +85,15 @@ server.registerTool("scan_java_artifact", {
84
85
  artifact_path: z.string().min(1).describe("JAR/WARファイル、または探索するディレクトリの絶対パス"),
85
86
  },
86
87
  }, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
88
+ server.registerTool("scan_sbom", {
89
+ title: "SBOMの脆弱性スキャン",
90
+ description: "CycloneDX 1.4/1.5/1.6またはSPDX 2.2/2.3のJSON SBOMから識別できる依存をスキャンする。" +
91
+ "ビルドやJARの実行は行わない。入力は16MiB以下のローカルファイル。" +
92
+ "SBOMの網羅性・鮮度・実成果物との一致は未検証であり、検出0件でも安全性を保証しない。",
93
+ inputSchema: {
94
+ sbom_path: z.string().min(1).describe("CycloneDX/SPDX JSON SBOMファイルの絶対パス"),
95
+ },
96
+ }, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
87
97
  const transport = new StdioServerTransport();
88
98
  await server.connect(transport);
89
99
  // stdoutはMCPプロトコル専用のため、起動ログはstderrへ
@@ -5,6 +5,7 @@
5
5
  * - シェルを経由しない`spawn`+引数配列で実行(コマンドインジェクション対策)
6
6
  * - OSV-Scannerへ渡す引数は固定リストのみ。呼び出し側から任意フラグは注入できない
7
7
  * (プロジェクトは検証済みディレクトリ1つ、実体スキャンは列挙済みのJAR/WAR絶対パスだけを渡す)
8
+ * - SBOMは検証・サイズ制限済みの専用一時コピー1つだけを渡す
8
9
  * - タイムアウトと出力サイズ上限を設ける(ハング・巨大出力によるDoS対策)
9
10
  *
10
11
  * 終了コード(2.4.0で実機確認):
@@ -42,13 +43,18 @@ const FIXED_ARTIFACT_ARGS = [
42
43
  "scan", "source", "--format", "json", "--all-packages", "--no-ignore",
43
44
  "--experimental-no-default-plugins", "--experimental-plugins", "java/archive",
44
45
  ];
46
+ const FIXED_SBOM_ARGS = [
47
+ "scan", "source", "--format", "json", "--all-packages", "--no-ignore",
48
+ "--experimental-no-default-plugins", "--experimental-plugins", "sbom",
49
+ ];
50
+ const SCAN_ARGS = { project: FIXED_SCAN_ARGS, artifact: FIXED_ARTIFACT_ARGS, sbom: FIXED_SBOM_ARGS };
45
51
  const EXIT_NO_VULNS = 0;
46
52
  const EXIT_VULNS_FOUND = 1;
47
53
  const EXIT_NO_PACKAGES = 128;
48
- function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, artifactMode) {
54
+ function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, mode) {
49
55
  return new Promise((resolve, reject) => {
50
56
  const child = spawn(binaryPath, [
51
- ...(artifactMode ? FIXED_ARTIFACT_ARGS : FIXED_SCAN_ARGS), ...targetPaths,
57
+ ...SCAN_ARGS[mode], ...targetPaths,
52
58
  ], {
53
59
  shell: false,
54
60
  stdio: ["ignore", "pipe", "pipe"],
@@ -107,39 +113,43 @@ function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, arti
107
113
  * 絶対パスを渡すこと**(このレイヤーではパス検証を行わない)
108
114
  */
109
115
  export async function runOsvScan(projectDir, options = {}) {
110
- return parseOsvScanOutput(await runScan([projectDir], options, false));
116
+ return parseOsvScanOutput(await runScan([projectDir], options, "project"));
111
117
  }
112
118
  /** Accept only the exact absolute files enumerated by detectJavaArtifacts. */
113
119
  export async function runOsvArtifactScan(artifactPaths, options = {}) {
114
120
  if (artifactPaths.length === 0) {
115
121
  throw new ScanToolError("no_scannable_artifacts", "No JAR/WAR archives selected");
116
122
  }
117
- return runScan(artifactPaths, options, true);
123
+ return runScan(artifactPaths, options, "artifact");
124
+ }
125
+ /** Scan only the private, validated snapshot prepared by handleScanSbom. */
126
+ export async function runOsvSbomScan(snapshotPath, options = {}) {
127
+ return runScan([snapshotPath], options, "sbom");
118
128
  }
119
- async function runScan(targetPaths, options, artifactMode) {
129
+ async function runScan(targetPaths, options, mode) {
120
130
  const limit = options.maxConcurrentScans ?? maxConcurrentScansFromEnv();
121
131
  if (activeScans >= limit) {
122
132
  throw new ScanToolError("too_many_concurrent_scans", `同時実行できるスキャンは${limit}件までです(現在${activeScans}件実行中)。実行中のスキャン完了後に再試行してください`);
123
133
  }
124
134
  activeScans++;
125
135
  try {
126
- return await runOsvScanUnguarded(targetPaths, options, artifactMode);
136
+ return await runOsvScanUnguarded(targetPaths, options, mode);
127
137
  }
128
138
  finally {
129
139
  activeScans--;
130
140
  }
131
141
  }
132
- async function runOsvScanUnguarded(targetPaths, options, artifactMode) {
142
+ async function runOsvScanUnguarded(targetPaths, options, mode) {
133
143
  const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
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) {
144
+ const result = await execOsvScanner(binaryPath, targetPaths, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, mode);
145
+ if (result.exitCode === EXIT_NO_PACKAGES && mode === "project") {
136
146
  throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${targetPaths[0]}(pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
137
147
  }
138
- if (artifactMode && result.exitCode === EXIT_NO_PACKAGES && result.stdout.trim() === "") {
148
+ if (mode !== "project" && result.exitCode === EXIT_NO_PACKAGES && result.stdout.trim() === "") {
139
149
  return { results: [] };
140
150
  }
141
151
  if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND &&
142
- !(artifactMode && result.exitCode === EXIT_NO_PACKAGES)) {
152
+ !(mode !== "project" && result.exitCode === EXIT_NO_PACKAGES)) {
143
153
  const status = result.exitCode !== null ? `exit code ${result.exitCode}` : `signal ${result.signal}`;
144
154
  throw new ScanToolError("scan_failed", `OSV-Scannerが異常終了しました(${status})`, result.stderr);
145
155
  }
@@ -0,0 +1,55 @@
1
+ import { ScanToolError } from "../errors.js";
2
+ import { sanitizeExternalText } from "../utils/externalText.js";
3
+ import { asRecord, asString } from "../utils/unknownJson.js";
4
+ import { parseOsvScanOutput } from "./scanReport.js";
5
+ export function buildSbomReport(raw, input, snapshotPath) {
6
+ const results = asRecord(raw)?.results;
7
+ if (!Array.isArray(results))
8
+ throw new ScanToolError("invalid_output", "SBOM scan output is missing its results array");
9
+ const identified = new Set();
10
+ const unidentified = new Map();
11
+ const scannable = [];
12
+ for (const value of results) {
13
+ const result = asRecord(value);
14
+ const source = asRecord(result?.source);
15
+ if (source?.path !== snapshotPath || source.type !== "sbom" || !Array.isArray(result?.packages)) {
16
+ throw new ScanToolError("invalid_output", "SBOM scan output contains an unexpected source or package list");
17
+ }
18
+ for (const item of result.packages) {
19
+ const pkg = asRecord(asRecord(item)?.package);
20
+ if (pkg === null)
21
+ throw new ScanToolError("invalid_output", "SBOM scanner returned a malformed package");
22
+ const name = asString(pkg?.name);
23
+ const version = asString(pkg?.version);
24
+ const ecosystem = asString(pkg?.ecosystem);
25
+ if (!name?.trim() || !version?.trim() || !ecosystem?.trim() ||
26
+ [name, version, ecosystem].some((s) => s.trim().toLowerCase() === "unknown") || name === "unknown:unknown") {
27
+ unidentified.set(JSON.stringify([ecosystem, name, version]), { name, version, ecosystem });
28
+ continue;
29
+ }
30
+ identified.add(JSON.stringify([ecosystem, name, version]));
31
+ scannable.push(item);
32
+ }
33
+ }
34
+ const report = parseOsvScanOutput({ results: [{ packages: scannable }] });
35
+ return {
36
+ coverage: {
37
+ identified_package_count: identified.size,
38
+ unidentified_packages: [...unidentified.values()],
39
+ status: identified.size === 0 ? "no_packages_identified" : "packages_identified",
40
+ completeness: "not_verified",
41
+ artifact_match: "not_verified",
42
+ warning: "Only packages identified from this SBOM are checked. Missing, unsupported or unversioned identifiers can be skipped. SBOM completeness, freshness and correspondence to the actual build are not verified. Zero findings does not establish safety.",
43
+ },
44
+ sbom: {
45
+ path: sanitizeExternalText(input.sourcePath),
46
+ format: input.format,
47
+ spec_version: input.specVersion,
48
+ sha256: input.sha256,
49
+ },
50
+ identified_vulnerability_count: report.vulnerability_count,
51
+ identified_vulnerable_package_count: report.vulnerable_package_count,
52
+ severity_breakdown: report.severity_breakdown,
53
+ packages: report.packages,
54
+ };
55
+ }
@@ -0,0 +1,26 @@
1
+ import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { runOsvSbomScan } from "../osv/runner.js";
5
+ import { buildSbomReport } from "../osv/sbomReport.js";
6
+ import { loadSbom } from "../utils/sbomInput.js";
7
+ import { errorResult, jsonResult } from "./toolResult.js";
8
+ export async function handleScanSbom(args, options = {}) {
9
+ try {
10
+ const input = await loadSbom(args.sbom_path, options);
11
+ const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-sbom-"));
12
+ try {
13
+ // A fixed filename selects the native parser, irrespective of the user's filename.
14
+ const snapshotPath = path.join(dir, input.format === "CycloneDX" ? "input.cdx.json" : "input.spdx.json");
15
+ await writeFile(snapshotPath, input.bytes, { mode: 0o600, flag: "wx" });
16
+ const raw = await runOsvSbomScan(snapshotPath, options);
17
+ return jsonResult(buildSbomReport(raw, input, snapshotPath));
18
+ }
19
+ finally {
20
+ await rm(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+ catch (error) {
24
+ return errorResult(error);
25
+ }
26
+ }
@@ -0,0 +1,99 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { ScanToolError } from "../errors.js";
6
+ import { asRecord } from "./unknownJson.js";
7
+ const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024;
8
+ const CYCLONEDX_VERSIONS = new Set(["1.4", "1.5", "1.6"]);
9
+ const SPDX_VERSIONS = new Set(["SPDX-2.2", "SPDX-2.3"]);
10
+ export async function loadSbom(inputPath, options = {}) {
11
+ if (typeof inputPath !== "string" || !path.isAbsolute(inputPath)) {
12
+ throw new ScanToolError("sbom_not_found", "Specify an absolute path to a JSON SBOM file");
13
+ }
14
+ let sourcePath;
15
+ try {
16
+ sourcePath = await realpath(inputPath);
17
+ }
18
+ catch {
19
+ throw new ScanToolError("sbom_not_found", "The specified SBOM file does not exist");
20
+ }
21
+ if (options.allowedRoot !== undefined) {
22
+ const root = await realpath(options.allowedRoot);
23
+ const relative = path.relative(root, sourcePath);
24
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
25
+ throw new ScanToolError("path_outside_allowed_root", "SBOM path is outside the allowed root");
26
+ }
27
+ }
28
+ const maxBytes = options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
29
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
30
+ throw new RangeError("Invalid SBOM size limit");
31
+ const tooLarge = () => new ScanToolError("sbom_too_large", `SBOM input exceeds ${maxBytes} bytes`);
32
+ let bytes;
33
+ try {
34
+ // Nonblocking open avoids hanging on FIFOs. Refuse a leaf replaced by a symlink.
35
+ const file = await open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
36
+ try {
37
+ const info = await file.stat();
38
+ if (!info.isFile())
39
+ throw new ScanToolError("sbom_not_found", "SBOM input must be a regular file");
40
+ if (info.size > maxBytes)
41
+ throw tooLarge();
42
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1));
43
+ const chunks = [];
44
+ let total = 0;
45
+ while (true) {
46
+ const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
47
+ if (bytesRead === 0)
48
+ break;
49
+ total += bytesRead;
50
+ if (total > maxBytes)
51
+ throw tooLarge();
52
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
53
+ }
54
+ bytes = Buffer.concat(chunks, total);
55
+ }
56
+ finally {
57
+ await file.close();
58
+ }
59
+ }
60
+ catch (error) {
61
+ if (error instanceof ScanToolError)
62
+ throw error;
63
+ throw new ScanToolError("sbom_not_found", "The specified SBOM file could not be read");
64
+ }
65
+ let raw;
66
+ try {
67
+ raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
68
+ }
69
+ catch {
70
+ throw new ScanToolError("invalid_sbom", "SBOM input must be UTF-8 JSON; XML and tag-value formats are not supported");
71
+ }
72
+ const document = asRecord(raw);
73
+ let format;
74
+ let specVersion;
75
+ if (document?.bomFormat === "CycloneDX" && document.spdxVersion === undefined) {
76
+ if (typeof document.specVersion !== "string" || !CYCLONEDX_VERSIONS.has(document.specVersion)) {
77
+ throw new ScanToolError("unsupported_sbom_format", "Supported CycloneDX JSON versions: 1.4, 1.5, 1.6");
78
+ }
79
+ if (!Array.isArray(document.components) || document.components.some((item) => asRecord(item) === null)) {
80
+ throw new ScanToolError("invalid_sbom", "CycloneDX input must contain a components array of objects");
81
+ }
82
+ format = "CycloneDX";
83
+ specVersion = document.specVersion;
84
+ }
85
+ else if (typeof document?.spdxVersion === "string" && document.bomFormat === undefined) {
86
+ if (!SPDX_VERSIONS.has(document.spdxVersion)) {
87
+ throw new ScanToolError("unsupported_sbom_format", "Supported SPDX JSON versions: 2.2, 2.3");
88
+ }
89
+ if (!Array.isArray(document.packages) || document.packages.some((item) => asRecord(item) === null)) {
90
+ throw new ScanToolError("invalid_sbom", "SPDX input must contain a packages array of objects");
91
+ }
92
+ format = "SPDX";
93
+ specVersion = document.spdxVersion;
94
+ }
95
+ else {
96
+ throw new ScanToolError("unsupported_sbom_format", "Expected a CycloneDX or SPDX JSON document");
97
+ }
98
+ return { sourcePath, format, specVersion, bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "MCP server that wraps Google's OSV-Scanner to scan Java projects for known vulnerabilities",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -43,9 +43,9 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.5.0",
46
- "@vitest/coverage-v8": "^4.1.9",
46
+ "@vitest/coverage-v8": "^5.0.1",
47
47
  "typescript": "^7.0.2",
48
- "vitest": "^4.1.9"
48
+ "vitest": "^5.0.1"
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.29.0",