osv-scanner-mcp 0.1.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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * OSV-Scannerバイナリの解決(docs/DESIGN_TODO.md 方式C)。
3
+ * MVPでは自動ダウンロードは行わず、「存在チェック → なければ案内メッセージ」で対応する。
4
+ * 探索順: 環境変数 OSV_SCANNER_PATH → PATH上の osv-scanner
5
+ */
6
+ import { access, constants, stat } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { ScanToolError } from "../errors.js";
9
+ export const OSV_SCANNER_PATH_ENV = "OSV_SCANNER_PATH";
10
+ const BINARY_NAME = process.platform === "win32" ? "osv-scanner.exe" : "osv-scanner";
11
+ async function isExecutableFile(filePath) {
12
+ try {
13
+ const stats = await stat(filePath);
14
+ if (!stats.isFile())
15
+ return false;
16
+ await access(filePath, constants.X_OK);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** OSV-Scannerバイナリのパスを返す。見つからなければnull。 */
24
+ export async function findOsvScannerBinary(env = process.env) {
25
+ const explicit = env[OSV_SCANNER_PATH_ENV];
26
+ if (explicit !== undefined && explicit.trim() !== "") {
27
+ // 明示指定が無効な場合はPATHにフォールバックせず失敗させる(意図しないバイナリの実行を防ぐ)
28
+ return (await isExecutableFile(explicit)) ? explicit : null;
29
+ }
30
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
31
+ if (dir === "")
32
+ continue;
33
+ const candidate = path.join(dir, BINARY_NAME);
34
+ if (await isExecutableFile(candidate))
35
+ return candidate;
36
+ }
37
+ return null;
38
+ }
39
+ /** インストール案内メッセージ。バイナリ未検出かつ自動ダウンロード不可のときに返す。 */
40
+ export function installGuidance(env = process.env) {
41
+ const explicit = env[OSV_SCANNER_PATH_ENV];
42
+ const envNote = explicit !== undefined && explicit.trim() !== ""
43
+ ? `環境変数 ${OSV_SCANNER_PATH_ENV}(${explicit})が実行可能ファイルを指していません。パスを確認してください。`
44
+ : `インストール済みの場合は、環境変数 ${OSV_SCANNER_PATH_ENV} でバイナリのパスを指定することもできます。`;
45
+ return [
46
+ "OSV-Scannerが見つかりません。以下のいずれかの方法でインストールしてください:",
47
+ " - Homebrew: brew install osv-scanner",
48
+ " - Go: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest",
49
+ " - 公式リリース: https://github.com/google/osv-scanner/releases (取得後はチェックサム検証を推奨)",
50
+ ` - または環境変数 ${AUTO_DOWNLOAD_ENV} の無効化(=0)を解除すると、検証済み公式バイナリを自動ダウンロードします`,
51
+ envNote,
52
+ ].join("\n");
53
+ }
54
+ export const AUTO_DOWNLOAD_ENV = "OSV_MCP_AUTO_DOWNLOAD";
55
+ function isAutoDownloadEnabled(env) {
56
+ const value = env[AUTO_DOWNLOAD_ENV]?.trim().toLowerCase();
57
+ return value !== "0" && value !== "false";
58
+ }
59
+ /**
60
+ * バイナリのパスを解決する。
61
+ * 探索順: OSV_SCANNER_PATH(明示指定時はダウンロードにフォールバックしない)
62
+ * → PATH → 自動ダウンロード(チェックサム検証付き、OSV_MCP_AUTO_DOWNLOAD=0で無効化)。
63
+ * どれも不可なら案内メッセージ付きのScanToolErrorを投げる。
64
+ */
65
+ export async function resolveOsvScannerBinary(env = process.env, options = {}) {
66
+ const binary = await findOsvScannerBinary(env);
67
+ if (binary !== null) {
68
+ return binary;
69
+ }
70
+ const explicit = env[OSV_SCANNER_PATH_ENV];
71
+ const explicitlySpecified = explicit !== undefined && explicit.trim() !== "";
72
+ // 明示指定が無効な場合は、意図しないバイナリの使用を避けるためダウンロードしない
73
+ if (!explicitlySpecified && isAutoDownloadEnabled(env)) {
74
+ const download = options.downloadFn ??
75
+ (async () => (await import("./binaryDownloader.js")).ensureOsvScannerDownloaded());
76
+ return download();
77
+ }
78
+ throw new ScanToolError("binary_not_found", installGuidance(env));
79
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * OSVデータベースAPI(api.osv.dev)のクライアント。
3
+ *
4
+ * `explain_vulnerability`用に単一の脆弱性レコードを取得する。
5
+ * セキュリティ考慮:
6
+ * - 脆弱性IDはURLパス組み立てに使うため、英数字とハイフンのみの厳格な形式検証を行う
7
+ * (LLM由来の入力によるパス/クエリインジェクション対策)
8
+ * - タイムアウトとレスポンスサイズ上限を設ける(DoS対策)
9
+ * - レスポンスは外部由来データとして防御的にパースする(呼び出し側)
10
+ */
11
+ import { ScanToolError } from "../errors.js";
12
+ const DEFAULT_TIMEOUT_MS = 15_000;
13
+ const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
14
+ const DEFAULT_BASE_URL = "https://api.osv.dev";
15
+ /**
16
+ * OSVのID形式(GHSA-xxxx-xxxx-xxxx、CVE-YYYY-NNNN等)。
17
+ * 英数字とハイフンのみ・先頭は英数字・最大64文字に制限する。
18
+ */
19
+ const OSV_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{2,63}$/;
20
+ /** 脆弱性IDを検証し、不正ならScanToolErrorを投げる。 */
21
+ export function validateVulnerabilityId(id) {
22
+ const trimmed = id.trim();
23
+ if (!OSV_ID_PATTERN.test(trimmed)) {
24
+ throw new ScanToolError("invalid_vulnerability_id", "脆弱性IDの形式が不正です。GHSA-xxxx-xxxx-xxxx または CVE-YYYY-NNNN 形式のIDを指定してください");
25
+ }
26
+ return trimmed;
27
+ }
28
+ /**
29
+ * OSVデータベースから脆弱性レコードを1件取得する。
30
+ *
31
+ * @returns `JSON.parse`済みのOSVレコード(形式不明な外部データとして扱うこと)
32
+ */
33
+ export async function fetchOsvRecord(id, options = {}) {
34
+ const validatedId = validateVulnerabilityId(id);
35
+ const fetchFn = options.fetchFn ?? fetch;
36
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
37
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
38
+ const url = `${options.baseUrl ?? DEFAULT_BASE_URL}/v1/vulns/${encodeURIComponent(validatedId)}`;
39
+ let response;
40
+ try {
41
+ response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
42
+ }
43
+ catch (error) {
44
+ const isTimeout = error instanceof Error && error.name === "TimeoutError";
45
+ throw new ScanToolError("api_request_failed", isTimeout
46
+ ? `OSV APIへのリクエストが${Math.round(timeoutMs / 1000)}秒以内に完了しませんでした`
47
+ : "OSV APIへの接続に失敗しました(ネットワークを確認してください)");
48
+ }
49
+ if (response.status === 404) {
50
+ // OSVの正規IDはGHSA等であり、CVE-IDはエイリアス解決できない場合がある
51
+ const hint = validatedId.toUpperCase().startsWith("CVE-")
52
+ ? "。CVE-IDで見つからない場合は、スキャン結果のid(GHSA-ID)で照会してください"
53
+ : "";
54
+ throw new ScanToolError("vulnerability_not_found", `指定されたIDの脆弱性がOSVデータベースに見つかりません: ${validatedId}${hint}`);
55
+ }
56
+ if (!response.ok) {
57
+ throw new ScanToolError("api_request_failed", `OSV APIがエラーを返しました(HTTP ${response.status})`);
58
+ }
59
+ const contentLength = Number(response.headers.get("content-length") ?? "0");
60
+ if (contentLength > maxBytes) {
61
+ throw new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`);
62
+ }
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}バイト)を超えました`);
67
+ }
68
+ try {
69
+ return JSON.parse(body);
70
+ }
71
+ catch {
72
+ throw new ScanToolError("api_request_failed", "OSV APIのレスポンスをJSONとして解釈できませんでした");
73
+ }
74
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * OSV-Scannerの実行ラッパー。
3
+ *
4
+ * セキュリティ設計(docs/DESIGN_TODO.md):
5
+ * - シェルを経由しない`spawn`+引数配列で実行(コマンドインジェクション対策)
6
+ * - OSV-Scannerへ渡す引数は固定リストのみ。呼び出し側から任意フラグは注入できない
7
+ * (対象パスは位置引数1つだけで、projectDetectorでrealpath解決済みの絶対パスを渡す)
8
+ * - タイムアウトと出力サイズ上限を設ける(ハング・巨大出力によるDoS対策)
9
+ *
10
+ * 終了コード(2.4.0で実機確認):
11
+ * 0 = スキャン成功・脆弱性なし / 1 = スキャン成功・脆弱性あり / 128 = 対象パッケージなし
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { ScanToolError } from "../errors.js";
15
+ import { resolveOsvScannerBinary } from "./binaryManager.js";
16
+ import { parseOsvScanOutput } from "./scanReport.js";
17
+ const DEFAULT_TIMEOUT_MS = 120_000;
18
+ const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
19
+ /** エラー詳細に含めるstderrの上限(外部由来テキストをそのまま膨らませない) */
20
+ const MAX_STDERR_DETAIL_BYTES = 8 * 1024;
21
+ /** OSV-Scannerに渡す固定引数。ここに無いオプションは一切使わない(ホワイトリスト) */
22
+ const FIXED_SCAN_ARGS = ["scan", "source", "-r", "--format", "json"];
23
+ const EXIT_NO_VULNS = 0;
24
+ const EXIT_VULNS_FOUND = 1;
25
+ const EXIT_NO_PACKAGES = 128;
26
+ function execOsvScanner(binaryPath, projectDir, timeoutMs, maxOutputBytes) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(binaryPath, [...FIXED_SCAN_ARGS, projectDir], {
29
+ shell: false,
30
+ stdio: ["ignore", "pipe", "pipe"],
31
+ });
32
+ const stdoutChunks = [];
33
+ const stderrChunks = [];
34
+ let stdoutBytes = 0;
35
+ let stderrBytes = 0;
36
+ let settled = false;
37
+ const fail = (error) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ clearTimeout(timer);
42
+ child.kill("SIGKILL");
43
+ reject(error);
44
+ };
45
+ const timer = setTimeout(() => {
46
+ fail(new ScanToolError("scan_timeout", `OSV-Scannerが${Math.round(timeoutMs / 1000)}秒以内に完了しませんでした`));
47
+ }, timeoutMs);
48
+ child.stdout.on("data", (chunk) => {
49
+ stdoutBytes += chunk.length;
50
+ if (stdoutBytes > maxOutputBytes) {
51
+ fail(new ScanToolError("output_too_large", `OSV-Scannerの出力がサイズ上限(${maxOutputBytes}バイト)を超えました`));
52
+ return;
53
+ }
54
+ stdoutChunks.push(chunk);
55
+ });
56
+ child.stderr.on("data", (chunk) => {
57
+ if (stderrBytes >= MAX_STDERR_DETAIL_BYTES)
58
+ return;
59
+ stderrBytes += chunk.length;
60
+ stderrChunks.push(chunk);
61
+ });
62
+ child.on("error", (error) => {
63
+ fail(new ScanToolError("scan_failed", `OSV-Scannerを起動できませんでした: ${error.message}`));
64
+ });
65
+ child.on("close", (exitCode, signal) => {
66
+ if (settled)
67
+ return;
68
+ settled = true;
69
+ clearTimeout(timer);
70
+ resolve({
71
+ exitCode,
72
+ signal,
73
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
74
+ stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, MAX_STDERR_DETAIL_BYTES),
75
+ });
76
+ });
77
+ });
78
+ }
79
+ /**
80
+ * projectDirをOSV-Scannerでスキャンし、整形済みレポートを返す。
81
+ *
82
+ * @param projectDir スキャン対象ディレクトリ。**必ず`detectJavaProject`で検証済みの
83
+ * 絶対パスを渡すこと**(このレイヤーではパス検証を行わない)
84
+ */
85
+ export async function runOsvScan(projectDir, options = {}) {
86
+ const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
87
+ const result = await execOsvScanner(binaryPath, projectDir, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES);
88
+ if (result.exitCode === EXIT_NO_PACKAGES) {
89
+ throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${projectDir}(pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
90
+ }
91
+ if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND) {
92
+ const status = result.exitCode !== null ? `exit code ${result.exitCode}` : `signal ${result.signal}`;
93
+ throw new ScanToolError("scan_failed", `OSV-Scannerが異常終了しました(${status})`, result.stderr);
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(result.stdout);
98
+ }
99
+ catch {
100
+ throw new ScanToolError("invalid_output", "OSV-Scannerの出力をJSONとして解釈できませんでした", result.stdout.slice(0, 1000));
101
+ }
102
+ return parseOsvScanOutput(parsed);
103
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `scan_java_project`ツールの出力スキーマと、OSV-Scanner JSON出力からの変換。
3
+ *
4
+ * docs/DESIGN_TODO.mdの実機調査で確定した方針:
5
+ * - 脆弱性のグルーピングは`packages[].groups[]`(1エントリ=1脆弱性)をそのまま活用し、
6
+ * 独自グルーピングは持たない
7
+ * - `max_severity`は空文字列のケースが実在する(CVE-2015-6420で確認)ため、
8
+ * 数値化できない値はscore=null / severity="unknown"として例外を投げずに扱う
9
+ * - 同一pom.xmlが複数の`results[]`エントリに分かれるケースがあるため、
10
+ * pathでグルーピングせず全`results[].packages[]`をフラットに集約する
11
+ *
12
+ * OSV-Scannerの出力は外部由来データなので、形式が想定と異なっても例外を投げず
13
+ * 読み取れた範囲でレポートを構築する(欠損フィールドはスキップ)。
14
+ */
15
+ import { compareMavenVersions } from "../utils/mavenVersion.js";
16
+ import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
17
+ /** 外部由来のsummaryをLLMに渡す際の長さ上限(プロンプト肥大・DoS対策) */
18
+ const MAX_SUMMARY_LENGTH = 500;
19
+ const CVSS_CRITICAL = 9.0;
20
+ const CVSS_HIGH = 7.0;
21
+ const CVSS_MEDIUM = 4.0;
22
+ function severityFromScore(score) {
23
+ if (score === null)
24
+ return "unknown";
25
+ if (score >= CVSS_CRITICAL)
26
+ return "critical";
27
+ if (score >= CVSS_HIGH)
28
+ return "high";
29
+ if (score >= CVSS_MEDIUM)
30
+ return "medium";
31
+ return "low";
32
+ }
33
+ /** `max_severity`をCVSSスコアに変換する。空文字・非数値・範囲外はnull(unknown扱い)。 */
34
+ function parseSeverityScore(raw) {
35
+ if (raw === null || raw.trim() === "")
36
+ return null;
37
+ const score = Number(raw);
38
+ return Number.isFinite(score) && score >= 0 && score <= 10 ? score : null;
39
+ }
40
+ function truncateSummary(summary) {
41
+ return summary.length > MAX_SUMMARY_LENGTH ? `${summary.slice(0, MAX_SUMMARY_LENGTH)}…` : summary;
42
+ }
43
+ /** groupのidsに対応するOSVエントリ詳細(`packages[].vulnerabilities[]`)を探す。 */
44
+ function findVulnDetails(details, ids) {
45
+ const found = [];
46
+ for (const detailRaw of details) {
47
+ const detail = asRecord(detailRaw);
48
+ if (!detail)
49
+ continue;
50
+ const id = asString(detail.id);
51
+ if (id !== null && ids.includes(id))
52
+ found.push(detail);
53
+ }
54
+ return found;
55
+ }
56
+ function extractSummary(vulnDetails) {
57
+ for (const detail of vulnDetails) {
58
+ const summary = asString(detail.summary);
59
+ if (summary !== null && summary.trim() !== "")
60
+ return truncateSummary(summary);
61
+ }
62
+ return null;
63
+ }
64
+ /**
65
+ * OSVエントリの`affected[].ranges[].events[].fixed`から修正版を収集する。
66
+ * 1つのOSVエントリが複数パッケージをカバーしうるため、対象パッケージ名で絞り込む。
67
+ */
68
+ function extractFixedVersions(vulnDetails, packageName) {
69
+ const versions = new Set();
70
+ for (const detail of vulnDetails) {
71
+ for (const affectedRaw of asArray(detail.affected)) {
72
+ const affected = asRecord(affectedRaw);
73
+ if (!affected)
74
+ continue;
75
+ const affectedName = asString(asRecord(affected.package)?.name);
76
+ if (affectedName !== null && affectedName !== packageName)
77
+ continue;
78
+ for (const rangeRaw of asArray(affected.ranges)) {
79
+ for (const eventRaw of asArray(asRecord(rangeRaw)?.events)) {
80
+ const fixed = asString(asRecord(eventRaw)?.fixed);
81
+ if (fixed !== null && fixed !== "")
82
+ versions.add(fixed);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return [...versions].sort(compareMavenVersions);
88
+ }
89
+ /**
90
+ * OSV-Scannerの`--format json`出力を`scan_java_project`のレポートに変換する。
91
+ *
92
+ * @param raw `JSON.parse`済みのOSV-Scanner出力(形式不明な外部データとして扱う)
93
+ */
94
+ export function parseOsvScanOutput(raw) {
95
+ const sourceFiles = [];
96
+ const packageMap = new Map();
97
+ for (const resultRaw of asArray(asRecord(raw)?.results)) {
98
+ const result = asRecord(resultRaw);
99
+ if (!result)
100
+ continue;
101
+ const sourcePath = asString(asRecord(result.source)?.path);
102
+ if (sourcePath !== null && !sourceFiles.includes(sourcePath)) {
103
+ sourceFiles.push(sourcePath);
104
+ }
105
+ for (const pkgRaw of asArray(result.packages)) {
106
+ const pkgObj = asRecord(pkgRaw);
107
+ if (!pkgObj)
108
+ continue;
109
+ const info = asRecord(pkgObj.package);
110
+ const name = asString(info?.name);
111
+ const version = asString(info?.version);
112
+ if (name === null || version === null)
113
+ continue;
114
+ const ecosystem = asString(info?.ecosystem) ?? "unknown";
115
+ // 同一パッケージが複数のresults[]エントリに分かれても1つに集約する
116
+ const key = `${ecosystem}:${name}@${version}`;
117
+ let entry = packageMap.get(key);
118
+ if (!entry) {
119
+ entry = { name, version, ecosystem, vulns: new Map() };
120
+ packageMap.set(key, entry);
121
+ }
122
+ const details = asArray(pkgObj.vulnerabilities);
123
+ for (const groupRaw of asArray(pkgObj.groups)) {
124
+ const group = asRecord(groupRaw);
125
+ if (!group)
126
+ continue;
127
+ const ids = asStrings(group.ids);
128
+ const aliases = asStrings(group.aliases);
129
+ const primaryId = ids[0] ?? aliases[0];
130
+ if (primaryId === undefined || entry.vulns.has(primaryId))
131
+ continue;
132
+ const score = parseSeverityScore(asString(group.max_severity));
133
+ const vulnDetails = findVulnDetails(details, ids);
134
+ entry.vulns.set(primaryId, {
135
+ id: primaryId,
136
+ cve: aliases.find((a) => a.startsWith("CVE-")) ??
137
+ (primaryId.startsWith("CVE-") ? primaryId : null),
138
+ aliases: [...new Set([...ids, ...aliases])].filter((a) => a !== primaryId),
139
+ severity_score: score,
140
+ severity: severityFromScore(score),
141
+ summary: extractSummary(vulnDetails),
142
+ fixed_versions: extractFixedVersions(vulnDetails, name),
143
+ });
144
+ }
145
+ }
146
+ }
147
+ return buildReport(sourceFiles, packageMap);
148
+ }
149
+ /** ソート用: unknown(null)はどの数値スコアよりも後ろに置く */
150
+ function sortScore(score) {
151
+ return score ?? -1;
152
+ }
153
+ function buildReport(sourceFiles, packageMap) {
154
+ const severityBreakdown = {
155
+ critical: 0,
156
+ high: 0,
157
+ medium: 0,
158
+ low: 0,
159
+ unknown: 0,
160
+ };
161
+ let vulnerabilityCount = 0;
162
+ const packages = [...packageMap.values()]
163
+ .filter((entry) => entry.vulns.size > 0)
164
+ .map((entry) => {
165
+ const vulnerabilities = [...entry.vulns.values()].sort((a, b) => sortScore(b.severity_score) - sortScore(a.severity_score) || cmpId(a.id, b.id));
166
+ for (const vuln of vulnerabilities) {
167
+ severityBreakdown[vuln.severity]++;
168
+ vulnerabilityCount++;
169
+ }
170
+ return { name: entry.name, version: entry.version, ecosystem: entry.ecosystem, vulnerabilities };
171
+ })
172
+ .sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
173
+ return {
174
+ source_files: sourceFiles,
175
+ vulnerable_package_count: packages.length,
176
+ vulnerability_count: vulnerabilityCount,
177
+ severity_breakdown: severityBreakdown,
178
+ packages,
179
+ };
180
+ }
181
+ function maxScore(vulns) {
182
+ return vulns.reduce((max, v) => Math.max(max, sortScore(v.severity_score)), -1);
183
+ }
184
+ function cmpId(a, b) {
185
+ return a < b ? -1 : a > b ? 1 : 0;
186
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `suggest_fix`の3段階Tierフォールバック(docs/DESIGN_TODO.mdで確定したアルゴリズム)。
3
+ *
4
+ * 同一パッケージの`fixed_versions`には複数のサポートブランチ(major.minor系統)への
5
+ * バックポート修正が混在する(例: log4jの2.3.x / 2.12.x / メインライン)。単純な
6
+ * 最大バージョンではなく「現在のバージョンに最も近い系統の修正版」を優先して提案する。
7
+ *
8
+ * CVEごとの探索順:
9
+ * Tier 1 (same_minor): 現在と同じmajor.minor系統内の修正版(最小の変更で済む)
10
+ * Tier 2 (major_internal): 同一メジャー内の最小の修正版(マイナーバージョンアップ)
11
+ * Tier 3 (cross_major): 全体最小の修正版(メジャーアップグレード、破壊的変更の可能性)
12
+ *
13
+ * パッケージ全体の推奨(recommended_upgrade)は全CVEのTier結果の最大値
14
+ * (= すべての修正可能なCVEを解消できる最小バージョン)。
15
+ * 現在より新しい修正版が存在しないCVEはunfixedとして明示し、推奨計算から除外する。
16
+ */
17
+ import { compareMavenVersions, mavenVersionSeries } from "../utils/mavenVersion.js";
18
+ function classifyTier(currentSeries, candidate) {
19
+ const candidateSeries = mavenVersionSeries(candidate);
20
+ if (currentSeries === null || candidateSeries === null)
21
+ return "cross_major";
22
+ if (currentSeries.major !== candidateSeries.major)
23
+ return "cross_major";
24
+ if (currentSeries.minor !== candidateSeries.minor)
25
+ return "major_internal";
26
+ return "same_minor";
27
+ }
28
+ /**
29
+ * 1つのCVEに対する修正版をTierフォールバックで選ぶ。
30
+ * 現在バージョンより新しい修正版が存在しなければnull(unfixed)。
31
+ */
32
+ function pickFixForCve(currentVersion, currentSeries, fixedVersions) {
33
+ // 現在以下の修正版は別ブランチ向けバックポート(現在も影響を受けたまま)なので除外
34
+ const candidates = fixedVersions
35
+ .filter((v) => compareMavenVersions(v, currentVersion) > 0)
36
+ .sort(compareMavenVersions);
37
+ if (candidates.length === 0)
38
+ return null;
39
+ if (currentSeries !== null) {
40
+ for (const tier of ["same_minor", "major_internal"]) {
41
+ const found = candidates.find((v) => classifyTier(currentSeries, v) === tier);
42
+ if (found !== undefined)
43
+ return { version: found, tier };
44
+ }
45
+ }
46
+ const version = candidates[0];
47
+ return { version, tier: classifyTier(currentSeries, version) };
48
+ }
49
+ function buildNote(currentSeries, recommended, tier, fixableCount, unfixedCount) {
50
+ if (recommended === null) {
51
+ return `全${unfixedCount}件のCVEに現在より新しい修正版が存在しない(unfixed)`;
52
+ }
53
+ const label = currentSeries !== null ? `${currentSeries.major}.${currentSeries.minor}` : null;
54
+ let note;
55
+ switch (tier) {
56
+ case "same_minor":
57
+ note = `現在の${label}系統内の${recommended}で、修正版が存在する${fixableCount}件のCVEをすべて解消できる`;
58
+ break;
59
+ case "major_internal":
60
+ note = `${label}系統向けの修正版は存在しない。同一メジャー(${currentSeries.major}.x)内では${recommended}が${fixableCount}件のCVEを解消する最小版`;
61
+ break;
62
+ default:
63
+ note =
64
+ label !== null
65
+ ? `同一メジャー(${currentSeries.major}.x)内に修正版が存在しない。${recommended}へのメジャーアップグレードが必要(破壊的変更の可能性あり)`
66
+ : `現在バージョンの系統を判定できないため、全体最小の修正版${recommended}を提示`;
67
+ }
68
+ if (unfixedCount > 0) {
69
+ note += `。残り${unfixedCount}件は修正版が存在せず、このアップグレードでは解消されない`;
70
+ }
71
+ return note;
72
+ }
73
+ /** 1パッケージ分のアップグレード提案を組み立てる。 */
74
+ export function suggestUpgradeForPackage(pkg) {
75
+ const currentSeries = mavenVersionSeries(pkg.version);
76
+ const details = [];
77
+ let recommended = null;
78
+ let unfixedCount = 0;
79
+ for (const vuln of pkg.vulnerabilities) {
80
+ const pick = pickFixForCve(pkg.version, currentSeries, vuln.fixed_versions);
81
+ if (pick === null) {
82
+ unfixedCount++;
83
+ details.push({
84
+ id: vuln.id,
85
+ cve: vuln.cve,
86
+ severity: vuln.severity,
87
+ fixed_in: null,
88
+ tier: "unfixed",
89
+ });
90
+ continue;
91
+ }
92
+ details.push({
93
+ id: vuln.id,
94
+ cve: vuln.cve,
95
+ severity: vuln.severity,
96
+ fixed_in: pick.version,
97
+ tier: pick.tier,
98
+ });
99
+ if (recommended === null || compareMavenVersions(pick.version, recommended) > 0) {
100
+ recommended = pick.version;
101
+ }
102
+ }
103
+ const fixableCount = details.length - unfixedCount;
104
+ // 推奨バージョン自体のTierは「現在バージョンとの系統関係」で再分類する
105
+ // (per-CVEのTierの寄せ集めではなく、実際に行うアップグレードの距離を表す)
106
+ const upgradeTier = recommended !== null ? classifyTier(currentSeries, recommended) : null;
107
+ return {
108
+ package: pkg.name,
109
+ current_version: pkg.version,
110
+ ecosystem: pkg.ecosystem,
111
+ recommended_upgrade: recommended,
112
+ upgrade_tier: upgradeTier,
113
+ upgrade_note: buildNote(currentSeries, recommended, upgradeTier, fixableCount, unfixedCount),
114
+ per_cve_detail: details,
115
+ };
116
+ }
117
+ /** スキャンレポート全体からパッケージごとの提案一覧を作る(深刻度順を維持)。 */
118
+ export function suggestUpgrades(packages) {
119
+ return packages.map(suggestUpgradeForPackage);
120
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * OSVレコードから`explain_vulnerability`の出力を組み立てる。
3
+ *
4
+ * OSV APIのレスポンスは外部由来データとして防御的に扱う:
5
+ * - 欠損・型不正のフィールドは例外を投げずスキップ
6
+ * - `details`(markdown)等の自由テキストは長さ上限で切り詰め、
7
+ * 構造化フィールドとして返す(プロンプトインジェクション対策として
8
+ * 「指示」ではなく「データ」として扱われる形を維持する)
9
+ * - 参照URLはhttp/httpsのみ許可し、件数上限を設ける
10
+ */
11
+ import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
12
+ const MAX_DETAILS_LENGTH = 4000;
13
+ const MAX_ALIASES = 20;
14
+ const MAX_SEVERITY_ENTRIES = 5;
15
+ const MAX_AFFECTED = 10;
16
+ const MAX_RANGES_PER_AFFECTED = 10;
17
+ const MAX_REFERENCES = 20;
18
+ function truncate(text, maxLength) {
19
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
20
+ }
21
+ function parseSeverity(raw) {
22
+ const entries = [];
23
+ for (const item of asArray(raw)) {
24
+ const record = asRecord(item);
25
+ const type = asString(record?.type);
26
+ const score = asString(record?.score);
27
+ if (type !== null && score !== null)
28
+ entries.push({ type, score });
29
+ if (entries.length >= MAX_SEVERITY_ENTRIES)
30
+ break;
31
+ }
32
+ return entries;
33
+ }
34
+ /**
35
+ * OSVの`ranges[].events[]`(introduced/fixed/last_affectedのイベント列)を
36
+ * 「introducedで開始し、fixed/last_affectedで閉じる」区間のリストに畳み込む。
37
+ */
38
+ function parseRanges(raw) {
39
+ const ranges = [];
40
+ for (const rangeRaw of asArray(raw)) {
41
+ for (const eventRaw of asArray(asRecord(rangeRaw)?.events)) {
42
+ const event = asRecord(eventRaw);
43
+ if (!event)
44
+ continue;
45
+ const introduced = asString(event.introduced);
46
+ const fixed = asString(event.fixed);
47
+ const lastAffected = asString(event.last_affected);
48
+ const last = ranges[ranges.length - 1];
49
+ if (introduced !== null) {
50
+ ranges.push({ introduced, fixed: null, last_affected: null });
51
+ }
52
+ else if (fixed !== null && last !== undefined && last.fixed === null) {
53
+ last.fixed = fixed;
54
+ }
55
+ else if (lastAffected !== null && last !== undefined && last.last_affected === null) {
56
+ last.last_affected = lastAffected;
57
+ }
58
+ if (ranges.length >= MAX_RANGES_PER_AFFECTED)
59
+ return ranges;
60
+ }
61
+ }
62
+ return ranges;
63
+ }
64
+ function parseAffected(raw) {
65
+ const affected = [];
66
+ for (const item of asArray(raw)) {
67
+ const record = asRecord(item);
68
+ if (!record)
69
+ continue;
70
+ const pkg = asRecord(record.package);
71
+ const name = asString(pkg?.name);
72
+ if (name === null)
73
+ continue;
74
+ affected.push({
75
+ package: name,
76
+ ecosystem: asString(pkg?.ecosystem) ?? "unknown",
77
+ ranges: parseRanges(record.ranges),
78
+ });
79
+ if (affected.length >= MAX_AFFECTED)
80
+ break;
81
+ }
82
+ return affected;
83
+ }
84
+ function parseReferences(raw) {
85
+ const references = [];
86
+ for (const item of asArray(raw)) {
87
+ const record = asRecord(item);
88
+ const url = asString(record?.url);
89
+ // 外部URLはhttp/httpsのみ許可(file:等の想定外スキームを排除)
90
+ if (url === null || !/^https?:\/\//.test(url))
91
+ continue;
92
+ references.push({ type: asString(record?.type) ?? "WEB", url });
93
+ if (references.length >= MAX_REFERENCES)
94
+ break;
95
+ }
96
+ return references;
97
+ }
98
+ /**
99
+ * OSVレコードを整形する。
100
+ *
101
+ * @param requestedId レコードにidが無い場合のフォールバック表示用
102
+ */
103
+ export function buildVulnerabilityExplanation(raw, requestedId) {
104
+ const record = asRecord(raw);
105
+ const summary = asString(record?.summary);
106
+ const details = asString(record?.details);
107
+ return {
108
+ id: asString(record?.id) ?? requestedId,
109
+ aliases: asStrings(record?.aliases).slice(0, MAX_ALIASES),
110
+ summary: summary !== null && summary.trim() !== "" ? truncate(summary, 500) : null,
111
+ details: details !== null && details.trim() !== "" ? truncate(details, MAX_DETAILS_LENGTH) : null,
112
+ severity: parseSeverity(record?.severity),
113
+ published: asString(record?.published),
114
+ modified: asString(record?.modified),
115
+ affected: parseAffected(record?.affected),
116
+ references: parseReferences(record?.references),
117
+ };
118
+ }