osv-scanner-mcp 0.1.12 → 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 +29 -0
- package/dist/index.js +11 -1
- package/dist/osv/artifactReport.js +60 -0
- package/dist/osv/binaryDownloader.js +7 -7
- package/dist/osv/osvApi.js +9 -7
- package/dist/osv/runner.js +30 -10
- package/dist/tools/scanJavaArtifact.js +14 -0
- package/dist/utils/artifactDetector.js +61 -0
- package/dist/utils/readResponseBytes.js +33 -0
- package/package.json +2 -2
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段階フォールバックで選定します:
|
|
@@ -297,6 +325,7 @@ npm run build # dist/ へビルド
|
|
|
297
325
|
- [x] npmパッケージ化(`npx osv-scanner-mcp`)
|
|
298
326
|
- [x] OSV-Scannerバイナリの自動ダウンロード(チェックサム検証付き)
|
|
299
327
|
- [x] Gradle対応(lockfile方式)
|
|
328
|
+
- [x] `scan_java_artifact` ツール: JAR/WAR実体スキャン(lockfileが無い・shaded/fat JARのみのプロジェクト向け)
|
|
300
329
|
|
|
301
330
|
## ライセンス
|
|
302
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.
|
|
36
|
+
version: "0.2.0",
|
|
36
37
|
});
|
|
37
38
|
server.registerTool("scan_java_project", {
|
|
38
39
|
title: "Javaプロジェクトの脆弱性スキャン",
|
|
@@ -74,6 +75,15 @@ server.registerTool("explain_vulnerability", {
|
|
|
74
75
|
.describe("脆弱性のID(例: GHSA-jfh8-c2jp-5v3q、CVE-2021-44228)"),
|
|
75
76
|
},
|
|
76
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] }));
|
|
77
87
|
const transport = new StdioServerTransport();
|
|
78
88
|
await server.connect(transport);
|
|
79
89
|
// stdoutはMCPプロトコル専用のため、起動ログはstderrへ
|
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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をキャッシュから返す。
|
package/dist/osv/osvApi.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new ScanToolError("
|
|
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);
|
package/dist/osv/runner.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* セキュリティ設計(docs/DESIGN_TODO.md):
|
|
5
5
|
* - シェルを経由しない`spawn`+引数配列で実行(コマンドインジェクション対策)
|
|
6
6
|
* - OSV-Scannerへ渡す引数は固定リストのみ。呼び出し側から任意フラグは注入できない
|
|
7
|
-
* (
|
|
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,
|
|
48
|
+
function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, artifactMode) {
|
|
45
49
|
return new Promise((resolve, reject) => {
|
|
46
|
-
const child = spawn(binaryPath, [
|
|
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(
|
|
126
|
+
return await runOsvScanUnguarded(targetPaths, options, artifactMode);
|
|
111
127
|
}
|
|
112
128
|
finally {
|
|
113
129
|
activeScans--;
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
|
-
async function runOsvScanUnguarded(
|
|
132
|
+
async function runOsvScanUnguarded(targetPaths, options, artifactMode) {
|
|
117
133
|
const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
|
|
118
|
-
const result = await execOsvScanner(binaryPath,
|
|
119
|
-
if (result.exitCode === EXIT_NO_PACKAGES) {
|
|
120
|
-
throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${
|
|
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
|
|
153
|
+
return parsed;
|
|
134
154
|
}
|
|
@@ -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.
|
|
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.
|
|
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"
|