osv-scanner-mcp 0.7.1 → 0.9.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
@@ -124,6 +124,7 @@ npm run build
124
124
  | `OSV_MCP_MAX_CONCURRENT_SCANS` | 同時実行できるスキャン数の上限(デフォルト `2`、最大 `16`)。超過したリクエストは待たずに即時エラーになります |
125
125
  | `OSV_MCP_AUTO_DOWNLOAD` | `0` または `false` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
126
126
  | `OSV_MCP_PREFER_DOWNLOAD` | `1` または `true` 指定時、PATH上のosv-scannerを使わず、チェックサム検証済みの自動ダウンロードバイナリを常に使用します(PATH汚染による偽バイナリ実行の防止。`OSV_SCANNER_PATH` の明示指定は引き続き最優先) |
127
+ | `OSV_MCP_NO_CANDIDATE_CHECK` | `1` または `true` 指定時、`suggest_fix` の推奨先のOSV照会を行いません(応答の `candidate_check` は `disabled`。推奨先に、現在の版には該当しない既知の脆弱性がないことは確認されません) |
127
128
  | `OSV_MCP_NO_REMOTE_RESOLUTION` | `1` または `true` 指定時、`pom.xml` と `requirements.txt` の推移的依存を deps.dev で解決しません。**止まるのは deps.dev への送信だけで、脆弱性照会のためパッケージの名前とバージョンは引き続き `api.osv.dev` に送られます**。推移的依存の脆弱性は検出できなくなり、その旨が応答の `dependency_resolution.warning` に示されます。詳細は[通信先とプライバシー](#通信先とプライバシー) |
128
129
 
129
130
  > **推奨**: `OSV_MCP_ALLOWED_ROOT` は未設定でも動作しますが、その場合は任意の絶対パスをスキャンできてしまいます。悪意ある指示(プロンプトインジェクション)経由で意図しないディレクトリをスキャンさせられる経路を塞ぐため、プロジェクト置き場のルート(例: `~/projects`)を設定しておくことを推奨します。各クライアントの設定で `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` のように渡せます(Codex CLIのTOMLでは `[mcp_servers.osv-scanner.env]` セクション)。
@@ -145,6 +146,7 @@ osv-scanner v2.4.0 で接続先を実機確認した結果です(2026-10-07)。
145
146
  | `requirements.txt` のスキャン(`scan_project` / `suggest_fix`) | `api.osv.dev`、**`api.deps.dev`** | `pom.xml` と同じく、推移的依存の解決のため記載された依存の名前とバージョンが deps.dev に送られます。`--index-url` 等に書かれた取得先へは接続しません |
146
147
  | lockfileのスキャン(`gradle.lockfile`、`package-lock.json` 等のnpm系、`poetry.lock` 等のPython系、`go.mod`) | `api.osv.dev` | パッケージの名前とバージョン(lockfileに全依存が記載済みのため、解決のための外部接続はしません) |
147
148
  | `scan_java_artifact` / `scan_sbom` | `api.osv.dev` | 同定できたパッケージの名前とバージョン |
149
+ | `suggest_fix` の推奨先の照会 | `api.osv.dev` | 推奨を出したパッケージの名前(スキャンで照会済みのもの)と、推奨候補の版(公開されている修正版)。`OSV_MCP_NO_CANDIDATE_CHECK=1` で無効化できます |
148
150
  | `explain_vulnerability` | `api.osv.dev` | 指定した脆弱性ID |
149
151
  | バイナリの自動ダウンロード(初回のみ) | GitHub(公式Releases) | なし(ピン留めしたバージョンのバイナリを取得) |
150
152
 
@@ -218,7 +220,8 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
218
220
  - `package-lock.json`(v2以降): ルートとworkspaceのpackage.jsonの依存を、Nodeの解決規則(入れ子の `node_modules` から上位へ)で解決したものが直接依存、そこからたどれるものが推移的依存です。直接依存には宣言しているpackage.jsonを `declared_in` に、推移的依存にはそれを要求している直接依存の名前を `introduced_by`(最大10件、超えた分は `introduced_by_omitted`)に示します。直接依存でもあり他の依存からも要求される版は `direct` とし、`introduced_by` も付けます
219
221
  - `go.mod`: `// indirect` の無い `require` が直接依存です。`replace` で置き換えているモジュールには `replaced_in_go_mod: true` を付けます(OSV-Scannerは置換先のパスと版で報告します)
220
222
  - `requirements.txt`: ファイルに書かれた依存が直接依存、deps.devで解決された依存が推移的依存です
221
- - 上記以外の形式(`pom.xml`、`gradle.lockfile`、`yarn.lock`、`pnpm-lock.yaml`、`bun.lock`、`poetry.lock`、`uv.lock`、`Pipfile.lock`、`pdm.lock`)と、lockfileVersion 1・どこからも要求されていないエントリは `unknown` です。複数のlockfileで判定が異なる場合は `mixed` です
223
+ - `pom.xml`: OSV-Scannerは、`pom.xml`(と親POM)に宣言された依存と、deps.devで解決された推移的依存を別々の結果(`source.type` が `lockfile` / `unknown`)に分けて報告するため、それで判定します(親POM・プロファイル・プロパティ・依存管理の解釈はOSV-Scannerと同じになります)。`introduced_by` / `declared_in` は付きません。この判定はOSV-Scannerの文書化されていない出力の形に依存するため、想定外の形の場合は `unknown` にします
224
+ - 上記以外の形式(`gradle.lockfile`、`yarn.lock`、`pnpm-lock.yaml`、`bun.lock`、`poetry.lock`、`uv.lock`、`Pipfile.lock`、`pdm.lock`)と、lockfileVersion 1・どこからも要求されていないエントリは `unknown` です。複数のlockfileで判定が異なる場合は `mixed` です
222
225
  - `dependency_groups` はOSV-Scannerが付けた依存グループ(例: `dev`)の生の値です。lockfileの形式によって欠落・不正確なため(pnpmでは付かず、pdmでは `optional` になる等)、参考情報として扱ってください
223
226
  - 修正版の推奨(`suggest_fix`)はJava・JavaScript・Python・Goに対応しています
224
227
 
@@ -385,7 +388,16 @@ npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換
385
388
  - プレリリース版(`5.0.0-beta.3`、`15.6.0-canary.61`、Goの疑似バージョン、PyPIの `rc`・`dev` 版。post版は正式版扱い)は、正式版の候補では全CVEを解消できない場合だけ推奨し、`recommended_is_prerelease: true` を付けます。同じTierのプレリリースより、上のTierの正式版を優先します。
386
389
  - 推奨時は `verification: "verified"`、CVEごとの `recommended_status` は `affected` / `not_affected` / `unknown` です。推奨保留時は `not_evaluated` になります。`per_cve_detail.fixed_in` は各CVE単独の候補であり、最終推奨先の判定は `recommended_status` を参照してください。
387
390
  - 現在より新しい修正版候補がないCVEは `tier: "unfixed"` として推奨の修正対象から除外します(情報欠落を含む場合があります)。除外したCVEも推奨先で判定し、その状態を表示します。全CVEがunfixedの場合も `recommended_upgrade` は `null` です。修正版の記載はあるがバージョンとして解釈できないCVE(SemVerでない `13.0` 等)は `tier: "unparseable_fix"` とし、修正版が無いとは扱わず修正対象に残すため、推奨は保留(`no_verified_candidate`)になります。
388
- - npm・Go・PyPIの提案には更新方法の `update_hint` を付けます。PyPIでは、requirements.txtやpyproject.toml・Pipfileの指定を更新してlockfileを再生成し、推移的依存はpipの制約ファイル(`-c`)やuv・Poetryの上書き設定で版を指定します。推移的依存の場合、npmでは要求している直接依存の更新か、ルートの `package.json` の `overrides`(ルートのプロジェクトでのみ有効)で版を指定します。Goでは `go get <module>@<version>` で更新できます。Goのv2以上のメジャーは別のモジュールパス(`/v2` 等)としてOSV上も別パッケージになるため、新しいメジャー系列の修正版は候補に含まれません。現在の版が疑似バージョン(タグのないコミット)の場合は `upgrade_note` に示します。
391
+ - npm・Go・PyPIの提案には更新方法の `update_hint` を付けます。PyPIでは、requirements.txtやpyproject.toml・Pipfileの指定を更新してlockfileを再生成し、推移的依存はpipの制約ファイル(`-c`)やuv・Poetryの上書き設定で版を指定します。Maven(`pom.xml` 由来)では、直接依存は `<dependency>` の版(親POM・プロパティ・BOMで管理していればそちら)、推移的依存は `<dependencyManagement>` での上書きを案内します。推移的依存の場合、npmでは要求している直接依存の更新か、ルートの `package.json` の `overrides`(ルートのプロジェクトでのみ有効)で版を指定します。Goでは `go get <module>@<version>` で更新できます。Goのv2以上のメジャーは別のモジュールパス(`/v2` 等)としてOSV上も別パッケージになるため、新しいメジャー系列の修正版は候補に含まれません。現在の版が疑似バージョン(タグのないコミット)の場合は `upgrade_note` に示します。
392
+ - **推奨先のOSV照会**: 推奨はスキャンで分かった脆弱性(現在の版に該当するもの)の範囲だけで検証しているため、推奨先に現在の版には該当しない新しい脆弱性がありえます(例: cryptography 3.2 の推奨候補 49.0.0 は、44.0.0 で混入し 50.0.0 で修正された2件に該当)。そこで推奨先を `api.osv.dev` に照会し、該当する脆弱性があれば、それも避けるよう修正版を候補に加えて選び直します(この例では 50.0.0 を推奨し、`upgrade_note` に理由を示します)。結果は `candidate_check` に示します:
393
+ - `clean`: 推奨先に該当する既知の脆弱性はありません
394
+ - `has_known_vulnerabilities`: 避けられる修正版の候補が見つからず、推奨先が既知の脆弱性に該当します(`recommended_known_vulnerabilities` にID)
395
+ - `conflict`: OSVが、スキャンした脆弱性に候補が該当すると返しました(手元の範囲情報との食い違い)。他に候補がないため推奨を保留します(`recommended_upgrade: null`、`verification: "no_verified_candidate"`)
396
+ - `failed`: 照会に失敗したか、応答の形式が不正でした(推奨はスキャンした脆弱性に対して検証済みのまま返します)
397
+ - `skipped`: 照会回数の上限(1パッケージ4回、1回の呼び出しで合計60回)のため照会していません
398
+ - `disabled`: `OSV_MCP_NO_CANDIDATE_CHECK=1` で無効化されています
399
+
400
+ 照会するのは推奨を出したパッケージだけで、送るのはスキャンで既に照会したパッケージの名前と、推奨候補の版です。OSVの判定が手元の範囲情報と食い違う候補(スキャンした脆弱性に該当と返る候補)は推奨しません。不正な応答(オブジェクトでない応答・レコード、文字列でないページトークン)は「該当なし」とは扱わず失敗とします。照会で見つかった脆弱性は現在の版の脆弱性ではないため、`per_cve_detail` には含めません。
389
401
  - 各提案には `scan_project` と同じ `dependency_relation`(と `introduced_by` / `declared_in` / `replaced_in_go_mod`)を付け、`update_hint` を直接/推移的依存の別に応じて具体化します(npmの推移的依存なら `introduced_by` の直接依存の更新と `overrides`、Goの `replace` ならreplaceの版の更新、等)。`unknown` / `mixed` の場合は両方の場合を案内します。
390
402
  - requirements.txtの `>=X` / `~=X` の行は、OSV-Scannerが下限Xを使用中の版とみなしてスキャンしています。この依存の提案には `version_is_lower_bound: true` を付け、推奨は「下限を推奨版以上に引き上げる」意味であること(実際にインストールされる版とは異なりうること)を `upgrade_note` に示します。
391
403
  - 推奨に未対応のエコシステム(SBOM由来のRubyGems等)は `verification: "unsupported_ecosystem"`、現在の版をバージョンとして解釈できない場合(npmのgit・ローカルパス依存等)は `verification: "unparseable_version"` を返し、どちらもCVEごとの `tier: "unsupported"` として `unfixed` には数えません(修正版の有無は判定していないため。修正版は `scan_project` の `fixed_versions` や `explain_vulnerability` で確認できます)。
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * - OSV_MCP_AUTO_DOWNLOAD: 0/false指定時、バイナリの自動ダウンロードを無効化
11
11
  * - OSV_MCP_PREFER_DOWNLOAD: 1/true指定時、PATH上のバイナリを使わず検証済み自動ダウンロードを優先
12
12
  * - OSV_MCP_NO_REMOTE_RESOLUTION: 1/true指定時、pom.xmlの推移的依存をdeps.devで解決しない
13
+ * - OSV_MCP_NO_CANDIDATE_CHECK: 1/true指定時、suggest_fixの推奨先のOSV照会を行わない
13
14
  */
14
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -37,7 +38,7 @@ if (startupWarning !== null) {
37
38
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
38
39
  const server = new McpServer({
39
40
  name: "osv-scanner-mcp",
40
- version: "0.7.1",
41
+ version: "0.9.0",
41
42
  });
42
43
  server.registerTool("scan_project", {
43
44
  title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
@@ -46,7 +47,7 @@ server.registerTool("scan_project", {
46
47
  "Python(poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt)、Go(go.mod)。" +
47
48
  "パッケージマネージャーやビルドは実行しない。" +
48
49
  "応答先頭のcoverageを必ず確認すること: lockfileが無いマニフェスト、バージョン未固定のrequirements行、スキャン対象から外したファイルを示す。" +
49
- "各パッケージのdependency_relationは直接依存(direct)か推移的依存(transitive)か(package-lock.json・go.mod・requirements.txtで判定。それ以外はunknown)。" +
50
+ "各パッケージのdependency_relationは直接依存(direct)か推移的依存(transitive)か(package-lock.json・go.mod・requirements.txt・pom.xmlで判定。それ以外はunknown)。" +
50
51
  "coverage.complete=falseの場合は、検出0件でも安全とは判断しないこと。修正版の推奨(suggest_fix)はJava・JavaScript・Python・Goに対応。",
51
52
  inputSchema: {
52
53
  project_path: z
@@ -74,7 +75,7 @@ server.registerTool("suggest_fix", {
74
75
  "推奨はJava(Maven / Gradle)・JavaScript(npm)・Python(PyPI)・Goに対応。" +
75
76
  "現在のバージョンに最も近いリリース系統の修正版を優先する3段階フォールバック" +
76
77
  "(same_minor: 同一系統内 → major_internal: 同一メジャー内 → cross_major: メジャーアップグレード)で選定し、" +
77
- "推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。npm・Go・PyPIでは0.x系のマイナー更新もcross_major(破壊的変更の可能性)。requirements.txtの下限(>=)でスキャンした依存はversion_is_lower_boundを付け、推奨は下限の引き上げを意味する。直接/推移的依存の別(dependency_relation、npmはintroduced_by・declared_in)に応じて更新方法(update_hint)を示す。" +
78
+ "推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。npm・Go・PyPIでは0.x系のマイナー更新もcross_major(破壊的変更の可能性)。requirements.txtの下限(>=)でスキャンした依存はversion_is_lower_boundを付け、推奨は下限の引き上げを意味する。直接/推移的依存の別(dependency_relation、npmはintroduced_by・declared_in)に応じて更新方法(update_hint)を示す。推奨先はapi.osv.devに照会し、現在の版には該当しない既知の脆弱性があれば避けて選び直す(結果はcandidate_check。has_known_vulnerabilities・failed・skippedの場合は推奨先の安全性が確認できていない。conflictは判定の食い違いで推奨を保留)。" +
78
79
  "候補を全修正対象CVEの影響範囲と照合し、情報不足の場合は推奨を保留する。プレリリース版は正式版で解消できない場合だけ推奨し、recommended_is_prereleaseを付ける。" +
79
80
  "現在より新しい修正版候補のないCVEはunfixedとして別表示し、推奨先での判定も返す。" +
80
81
  "応答のcoverageを必ず確認すること(complete=falseなら提案に含まれない依存がある)。" +
@@ -19,7 +19,9 @@ import { readResponseBytes } from "../utils/readResponseBytes.js";
19
19
  /**
20
20
  * ピン留めするOSV-Scannerのバージョン。更新時は下のチェックサムも必ず更新すること。
21
21
  * 範囲外の読み込みを防ぐ検証はこの版の挙動に合わせているため、更新時は
22
- * docs/DESIGN_TODO.md「B5」の監査と、requirements.txt・親POMの解釈の実機確認をやり直すこと
22
+ * docs/DESIGN_TODO.md「B5」の監査と、requirements.txt・親POMの解釈の実機確認をやり直すこと。
23
+ * pom.xmlの直接/推移的依存の判定(dependencyRelations.tsのpomRelations)も、この版が同じpom.xmlを
24
+ * source.type "lockfile"(宣言された依存)と"unknown"(deps.devで解決された推移的依存)に分けて報告する挙動に依存するため再確認すること
23
25
  */
24
26
  export const PINNED_OSV_SCANNER_VERSION = "2.4.0";
25
27
  /**
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 推奨先のOSV照会(docs/DESIGN_TODO.md「推奨先のOSV照会(v0.8.0候補)詳細設計メモ」)。
3
+ *
4
+ * suggest_fixの推奨は、スキャンで分かった脆弱性(現在の版に該当するもの)の影響範囲だけで検証している。
5
+ * 推奨先に、現在の版には該当しない新しい脆弱性があっても分からない(実例: cryptography 3.2の推奨49.0.0は、
6
+ * 44.0.0で混入し50.0.0で修正された2件に該当)。推奨先をapi.osv.devに照会し、該当する脆弱性があれば、
7
+ * それも避けるよう候補を選び直す。
8
+ *
9
+ * - 送るのはスキャンで既に照会したパッケージの名前と、公開されている修正版の版だけ(送信先もスキャンと同じ)
10
+ * - 照会に失敗しても推奨は出す(スキャンした脆弱性に対する検証は済んでいる)。ツール全体をエラーにしない
11
+ * - 照会の回数・同時実行数に上限を設ける
12
+ */
13
+ import { ScanToolError } from "../errors.js";
14
+ import { asString, asStrings } from "../utils/unknownJson.js";
15
+ import { sanitizeExternalText } from "../utils/externalText.js";
16
+ import { extractAffectedVersions } from "./affectedVersions.js";
17
+ import { queryOsvPackageVersion } from "./osvApi.js";
18
+ import { extractFixedVersions } from "./scanReport.js";
19
+ import { suggestUpgradeForPackage } from "./suggestFix.js";
20
+ export const NO_CANDIDATE_CHECK_ENV = "OSV_MCP_NO_CANDIDATE_CHECK";
21
+ /** 環境変数で無効化されているか(1/true/yes) */
22
+ export function candidateCheckDisabledFromEnv(env = process.env) {
23
+ const value = env[NO_CANDIDATE_CHECK_ENV]?.trim().toLowerCase();
24
+ return value === "1" || value === "true" || value === "yes";
25
+ }
26
+ /** OSVレコードのIDと別名 */
27
+ function recordIds(record) {
28
+ const id = asString(record.id);
29
+ return [...(id !== null ? [id] : []), ...asStrings(record.aliases)];
30
+ }
31
+ /** 照会の合計回数の残り(パッケージをまたいで共有する) */
32
+ class QueryBudget {
33
+ remaining;
34
+ constructor(remaining) {
35
+ this.remaining = remaining;
36
+ }
37
+ take() {
38
+ if (this.remaining <= 0)
39
+ return false;
40
+ this.remaining--;
41
+ return true;
42
+ }
43
+ }
44
+ async function checkPackage(pkg, initial, budget, options) {
45
+ const known = new Set(pkg.vulnerabilities.flatMap((v) => [v.id, ...v.aliases]));
46
+ const extraTargets = [];
47
+ const excluded = new Set();
48
+ const rejected = [];
49
+ let suggestion = initial;
50
+ const maxQueries = options.maxQueriesPerPackage ?? 4;
51
+ for (let query = 0; query < maxQueries; query++) {
52
+ const candidate = suggestion.recommended_upgrade;
53
+ if (!budget.take())
54
+ return { suggestion, status: "skipped", rejected };
55
+ let records;
56
+ try {
57
+ // queryOsvPackageVersionが各レコードをオブジェクトと検証済み(不正な応答は失敗になる)
58
+ records = (await queryOsvPackageVersion(pkg.ecosystem, pkg.name, candidate, options));
59
+ }
60
+ catch (error) {
61
+ if (error instanceof ScanToolError)
62
+ return { suggestion, status: "failed", rejected };
63
+ throw error;
64
+ }
65
+ if (records.length === 0)
66
+ return { suggestion, status: "clean", rejected };
67
+ // 新しい脆弱性は修正対象に加え、修正版を候補に加える。スキャンで既に知っている脆弱性なのに該当と返った場合は、
68
+ // 手元の範囲情報とOSVの判定が食い違っているため、その候補を除外する(安全側)
69
+ let conflict = false;
70
+ for (const record of records) {
71
+ const ids = recordIds(record);
72
+ if (ids.some((id) => known.has(id))) {
73
+ excluded.add(candidate);
74
+ conflict = true;
75
+ continue;
76
+ }
77
+ for (const id of ids)
78
+ known.add(id);
79
+ extraTargets.push({
80
+ fixed_versions: extractFixedVersions([record], pkg.name, pkg.ecosystem),
81
+ affected_versions: extractAffectedVersions([record], ids.slice(0, 1), pkg.name, pkg.ecosystem),
82
+ });
83
+ }
84
+ rejected.push({ version: candidate, count: records.length });
85
+ const next = suggestUpgradeForPackage(pkg, { extraTargets, excluded });
86
+ if (next.recommended_upgrade === null) {
87
+ rejected.pop();
88
+ const knownVulnerabilities = records.map((record) => asString(record.id)).filter((id) => id !== null);
89
+ // 判定の食い違い: その候補はスキャンした脆弱性の範囲外とは言えないため、推奨しない(保留する)
90
+ if (conflict)
91
+ return { suggestion: withheld(suggestion, candidate), status: "conflict", knownVulnerabilities, rejected };
92
+ // 新しい脆弱性に修正版がない等で選び直せない: 最後に照会した推奨を、該当する脆弱性とともに返す
93
+ return { suggestion, status: "has_known_vulnerabilities", knownVulnerabilities, rejected };
94
+ }
95
+ suggestion = next;
96
+ }
97
+ // 1パッケージの照会回数の上限: 最後に選び直した推奨は照会していない
98
+ return { suggestion, status: "skipped", rejected };
99
+ }
100
+ /** 推奨を保留した提案(推奨先・Tier・推奨先での判定を外し、verificationをno_verified_candidateにする) */
101
+ function withheld(suggestion, candidate) {
102
+ const { recommended_is_prerelease: _prerelease, ...rest } = suggestion;
103
+ return {
104
+ ...rest,
105
+ recommended_upgrade: null,
106
+ upgrade_tier: null,
107
+ upgrade_note: `候補${candidate}は、スキャンした脆弱性の範囲外と判定しましたが、OSVはその脆弱性に該当すると返しました(範囲情報の食い違い)。` +
108
+ "安全と確認できる候補がないため推奨を保留します",
109
+ per_cve_detail: suggestion.per_cve_detail.map((detail) => ({ ...detail, recommended_status: "not_evaluated" })),
110
+ verification: "no_verified_candidate",
111
+ };
112
+ }
113
+ function applyResult(result) {
114
+ const { suggestion, status, knownVulnerabilities, rejected } = result;
115
+ const notes = [];
116
+ if (rejected.length > 0) {
117
+ const found = rejected.map((r) => `${r.version}は${r.count}件の既知の脆弱性に該当`).join("、");
118
+ notes.push(suggestion.recommended_upgrade !== null
119
+ ? `推奨先をOSVに照会し、${found}するため、${suggestion.recommended_upgrade}を推奨しています(現在の版には該当しない脆弱性を含む)`
120
+ : `推奨先をOSVに照会し、${found}するため候補から外しました`);
121
+ }
122
+ if (status === "conflict") {
123
+ // 理由はwithheldの注記に含めた。推奨しかけた版で該当と返った脆弱性はrecommended_known_vulnerabilitiesではなく注記に示す
124
+ notes.push(`OSVが該当と返した脆弱性: ${knownVulnerabilities.join("、")}`);
125
+ }
126
+ else if (status === "has_known_vulnerabilities") {
127
+ notes.push(`推奨先の${suggestion.recommended_upgrade}は、OSVで${knownVulnerabilities.length}件の既知の脆弱性(recommended_known_vulnerabilities)に該当します。` +
128
+ "これらを避けられる修正版の候補が見つかりませんでした");
129
+ }
130
+ else if (status === "failed") {
131
+ notes.push("推奨先のOSV照会に失敗したため、推奨先に現在の版には該当しない既知の脆弱性がないことは確認できていません");
132
+ }
133
+ else if (status === "skipped") {
134
+ notes.push("照会回数の上限のため、推奨先のOSV照会を行っていません(推奨先の既知の脆弱性は未確認)");
135
+ }
136
+ const { per_cve_detail, verification, ...rest } = suggestion;
137
+ return {
138
+ ...rest,
139
+ upgrade_note: [suggestion.upgrade_note, ...notes].join("。"),
140
+ candidate_check: status,
141
+ ...(status === "has_known_vulnerabilities" ? { recommended_known_vulnerabilities: knownVulnerabilities.map(sanitizeExternalText) } : {}),
142
+ per_cve_detail,
143
+ verification,
144
+ };
145
+ }
146
+ /**
147
+ * 推奨を出したパッケージの推奨先をOSVに照会し、必要なら選び直す。
148
+ * suggestionsはpackagesと同じ順(suggestUpgradesの結果)であること。推奨のないものはそのまま返す。
149
+ */
150
+ export async function checkRecommendedCandidates(packages, suggestions, options = {}) {
151
+ if (options === "disabled") {
152
+ return suggestions.map((s) => (s.recommended_upgrade === null ? s : { ...s, candidate_check: "disabled" }));
153
+ }
154
+ const budget = new QueryBudget(options.maxQueriesTotal ?? 60);
155
+ const results = [...suggestions];
156
+ const pending = suggestions.flatMap((s, i) => (s.recommended_upgrade === null ? [] : [i]));
157
+ // 同時実行数を制限して順に処理する(順番は結果に影響しない。予算はパッケージの順に消費されやすい)
158
+ const workers = Array.from({ length: Math.max(1, options.concurrency ?? 4) }, async () => {
159
+ for (let index = pending.shift(); index !== undefined; index = pending.shift()) {
160
+ results[index] = applyResult(await checkPackage(packages[index], suggestions[index], budget, options));
161
+ }
162
+ });
163
+ await Promise.all(workers);
164
+ return results;
165
+ }
@@ -74,3 +74,77 @@ export async function fetchOsvRecord(id, options = {}) {
74
74
  throw new ScanToolError("api_request_failed", "OSV APIのレスポンスをJSONとして解釈できませんでした");
75
75
  }
76
76
  }
77
+ /** queryOsvPackageVersionでたどる続きのページの上限 */
78
+ const MAX_QUERY_PAGES = 5;
79
+ function isPlainObject(value) {
80
+ return typeof value === "object" && value !== null && !Array.isArray(value);
81
+ }
82
+ function malformed() {
83
+ return new ScanToolError("api_request_failed", "OSV APIのレスポンスの形式が想定と異なります");
84
+ }
85
+ async function readJsonBody(response, maxBytes) {
86
+ let body;
87
+ try {
88
+ const bytes = await readResponseBytes(response, maxBytes, new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`));
89
+ body = new TextDecoder().decode(bytes);
90
+ }
91
+ catch (error) {
92
+ if (error instanceof ScanToolError)
93
+ throw error;
94
+ throw new ScanToolError("api_request_failed", "OSV APIのレスポンス本文を受信できませんでした");
95
+ }
96
+ try {
97
+ return JSON.parse(body);
98
+ }
99
+ catch {
100
+ throw new ScanToolError("api_request_failed", "OSV APIのレスポンスをJSONとして解釈できませんでした");
101
+ }
102
+ }
103
+ /**
104
+ * パッケージの版に該当する脆弱性のレコードを取得する(`POST /v1/query`)。
105
+ * suggest_fixの推奨先の照会に使う。送るのはスキャンで既に照会した名前と、公開されている修正版の版だけ。
106
+ * 続きのページ(`next_page_token`)は上限までたどり、超えたら失敗とする(一部だけで「該当なし」と判断しない)。
107
+ *
108
+ * @returns 脆弱性レコードの配列(形式不明な外部データとして扱うこと)
109
+ */
110
+ export async function queryOsvPackageVersion(ecosystem, name, version, options = {}) {
111
+ const fetchFn = options.fetchFn ?? fetch;
112
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
113
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
114
+ const url = `${options.baseUrl ?? DEFAULT_BASE_URL}/v1/query`;
115
+ const vulns = [];
116
+ let pageToken;
117
+ for (let page = 0; page < MAX_QUERY_PAGES; page++) {
118
+ let response;
119
+ try {
120
+ response = await fetchFn(url, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ package: { name, ecosystem }, version, ...(pageToken !== undefined ? { page_token: pageToken } : {}) }),
124
+ signal: AbortSignal.timeout(timeoutMs),
125
+ });
126
+ }
127
+ catch (error) {
128
+ const isTimeout = error instanceof Error && error.name === "TimeoutError";
129
+ throw new ScanToolError("api_request_failed", isTimeout
130
+ ? `OSV APIへのリクエストが${Math.round(timeoutMs / 1000)}秒以内に完了しませんでした`
131
+ : "OSV APIへの接続に失敗しました(ネットワークを確認してください)");
132
+ }
133
+ if (!response.ok) {
134
+ throw new ScanToolError("api_request_failed", `OSV APIがエラーを返しました(HTTP ${response.status})`);
135
+ }
136
+ // 不正な応答を「該当なし」と読まない: ルート・各レコード・ページトークンの型を検証し、違えば失敗とする
137
+ const body = await readJsonBody(response, maxBytes);
138
+ if (!isPlainObject(body))
139
+ throw malformed();
140
+ if (body.vulns !== undefined && (!Array.isArray(body.vulns) || !body.vulns.every(isPlainObject)))
141
+ throw malformed();
142
+ if (body.next_page_token !== undefined && typeof body.next_page_token !== "string")
143
+ throw malformed();
144
+ vulns.push(...(body.vulns ?? []));
145
+ if (body.next_page_token === undefined || body.next_page_token === "")
146
+ return vulns;
147
+ pageToken = body.next_page_token;
148
+ }
149
+ throw new ScanToolError("api_request_failed", `OSV APIの応答が${MAX_QUERY_PAGES}ページを超えました`);
150
+ }
@@ -68,7 +68,7 @@ function extractSummary(vulnDetails) {
68
68
  * 範囲の型はエコシステム別(versionScheme.ts)。GIT型のコミットハッシュは集めない。
69
69
  * v0.4.1以前はMavenだけを集めていたため、npm・Go・PyPI等で常に空になっていた。
70
70
  */
71
- function extractFixedVersions(vulnDetails, packageName, ecosystem) {
71
+ export function extractFixedVersions(vulnDetails, packageName, ecosystem) {
72
72
  const versions = new Set();
73
73
  const rangeTypes = versionRangeTypes(ecosystem);
74
74
  for (const detail of vulnDetails) {
@@ -94,7 +94,7 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
94
94
  return sortVersions(versions, ecosystem);
95
95
  }
96
96
  /**
97
- * パッケージごとのスキャン元ファイル(osv-scannerの`results[].source.path`)。
97
+ * パッケージごとのスキャン元ファイル(osv-scannerの`results[].source.path`と`type`)。
98
98
  * 応答のJSONには出さない(既存の応答を変えない)ため、パッケージのオブジェクトをキーに別に持つ。
99
99
  * 直接/推移的依存の判定(dependencyRelations.ts)が、どのlockfileの依存かを知るために使う
100
100
  */
@@ -116,6 +116,7 @@ export function parseOsvScanOutput(raw) {
116
116
  if (!result)
117
117
  continue;
118
118
  const sourcePath = asString(asRecord(result.source)?.path);
119
+ const sourceType = asString(asRecord(result.source)?.type);
119
120
  if (sourcePath !== null && !sourceFiles.includes(sourcePath)) {
120
121
  sourceFiles.push(sourcePath);
121
122
  }
@@ -133,11 +134,14 @@ export function parseOsvScanOutput(raw) {
133
134
  const key = `${ecosystem}:${name}@${version}`;
134
135
  let entry = packageMap.get(key);
135
136
  if (!entry) {
136
- entry = { name, version, ecosystem, groups: new Set(), sources: new Set(), vulns: new Map() };
137
+ entry = { name, version, ecosystem, groups: new Set(), sources: new Set(), sourceList: [], vulns: new Map() };
137
138
  packageMap.set(key, entry);
138
139
  }
139
- if (sourcePath !== null)
140
- entry.sources.add(sourcePath);
140
+ const sourceKey = JSON.stringify([sourcePath, sourceType]);
141
+ if (sourcePath !== null && !entry.sources.has(sourceKey)) {
142
+ entry.sources.add(sourceKey);
143
+ entry.sourceList.push({ path: sourcePath, type: sourceType });
144
+ }
141
145
  for (const group of asStrings(pkgObj.dependency_groups))
142
146
  entry.groups.add(group);
143
147
  const details = asArray(pkgObj.vulnerabilities);
@@ -196,7 +200,7 @@ function buildReport(sourceFiles, packageMap) {
196
200
  ...(entry.groups.size > 0 ? { dependency_groups: [...entry.groups].sort() } : {}),
197
201
  vulnerabilities,
198
202
  };
199
- packageSourceMap.set(pkg, [...entry.sources]);
203
+ packageSourceMap.set(pkg, entry.sourceList);
200
204
  return pkg;
201
205
  })
202
206
  .sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
@@ -59,6 +59,14 @@ function updateHint(pkg) {
59
59
  }
60
60
  return UPDATE_HINTS.Go;
61
61
  }
62
+ case "Maven":
63
+ if (relation === "direct") {
64
+ return "直接依存です。pom.xmlの<dependency>の版を更新します。版を親POMの<dependencyManagement>・プロパティ・BOMで管理している場合は、そちらを更新します";
65
+ }
66
+ if (relation === "transitive") {
67
+ return "推移的依存です。pom.xmlの<dependencyManagement>で推奨版を指定して上書きする(Mavenの依存の調停で優先されます)か、それを要求している直接依存を更新します";
68
+ }
69
+ return undefined; // gradle.lockfile等: 判定できないため具体的な案内はしない(従来どおり)
62
70
  case "PyPI":
63
71
  if (relation === "direct") {
64
72
  return "直接依存です。requirements.txtの版の指定、またはpyproject.toml・Pipfileの指定を更新し、lockfileを再生成します";
@@ -164,8 +172,11 @@ function buildNote(scheme, pkg, recommended, tier, fixableCount, unfixedCount) {
164
172
  }
165
173
  return note;
166
174
  }
167
- /** 1パッケージ分のアップグレード提案を組み立てる。 */
168
- export function suggestUpgradeForPackage(pkg) {
175
+ /**
176
+ * 1パッケージ分のアップグレード提案を組み立てる(同期・純粋)。
177
+ * contextを渡すと、照会で見つかった脆弱性も避けて候補を選び直す(contextなしの結果が従来の推奨)。
178
+ */
179
+ export function suggestUpgradeForPackage(pkg, context = {}) {
169
180
  const scheme = versionSchemeFor(pkg.ecosystem);
170
181
  if (scheme === null)
171
182
  return notEvaluatedSuggestion(pkg, "unsupported_ecosystem");
@@ -186,9 +197,13 @@ export function suggestUpgradeForPackage(pkg) {
186
197
  const unparseableCount = details.filter((d) => d.tier === "unparseable_fix").length;
187
198
  const fixableCount = details.length - unfixedCount;
188
199
  // 解釈できない修正版のCVEも修正対象に残す。その影響範囲は情報不足のため、どの候補も検証できず推奨を保留する
189
- const targets = pkg.vulnerabilities.filter((_, i) => details[i].tier !== "unfixed");
200
+ const targets = [
201
+ ...pkg.vulnerabilities.filter((_, i) => details[i].tier !== "unfixed"),
202
+ ...(context.extraTargets ?? []),
203
+ ];
190
204
  const candidates = rankCandidates(scheme, pkg.version, targets.flatMap((v) => v.fixed_versions));
191
- const recommended = candidates.find((v) => targets.every((target) => candidateStatus(target.affected_versions, v, pkg.ecosystem) === "not_affected")) ?? null;
205
+ const recommended = candidates.find((v) => !context.excluded?.has(v) &&
206
+ targets.every((target) => candidateStatus(target.affected_versions, v, pkg.ecosystem) === "not_affected")) ?? null;
192
207
  for (let i = 0; i < details.length; i++) {
193
208
  details[i].recommended_status = recommended === null ? "not_evaluated" :
194
209
  candidateStatus(pkg.vulnerabilities[i].affected_versions, recommended, pkg.ecosystem);
@@ -214,5 +229,5 @@ export function suggestUpgradeForPackage(pkg) {
214
229
  }
215
230
  /** スキャンレポート全体からパッケージごとの提案一覧を作る(深刻度順を維持)。 */
216
231
  export function suggestUpgrades(packages) {
217
- return packages.map(suggestUpgradeForPackage);
232
+ return packages.map((pkg) => suggestUpgradeForPackage(pkg));
218
233
  }
@@ -8,7 +8,7 @@ import path from "node:path";
8
8
  import { ScanToolError } from "../errors.js";
9
9
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
10
10
  import { packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
11
- import { combineRelations, goModRelations, npmLockRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
11
+ import { combineRelations, goModRelations, npmLockRelations, pomRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
12
12
  import { sanitizeExternalText } from "../utils/externalText.js";
13
13
  import { readRegularFile } from "../utils/safeRead.js";
14
14
  import { detectProject } from "../utils/manifestDetector.js";
@@ -115,6 +115,9 @@ export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_
115
115
  lookup = format === "go.mod" ? goModRelations(text) : npmLockRelations(JSON.parse(text));
116
116
  }
117
117
  }
118
+ else if (format === "pom.xml") {
119
+ lookup = pomRelations(); // ファイルは読まず、osv-scannerのsource.typeで判定する
120
+ }
118
121
  else if (format === "requirements.txt" && entries !== undefined) {
119
122
  lookup = requirementsRelations(entries);
120
123
  }
@@ -129,15 +132,21 @@ export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_
129
132
  /** パッケージに直接/推移的依存の別を付ける。項目は脆弱性の一覧より前に置く */
130
133
  function annotateRelations(packages, lookups) {
131
134
  return packages.map((pkg) => {
132
- const infos = [];
135
+ // 同じファイルが複数の結果に分かれる(pom.xml・requirements.txtの宣言と推移的依存)ため、ファイルごとに1つにまとめる。
136
+ // 同じファイルで直接依存と判定された結果があれば直接依存とする
137
+ const byFile = new Map();
133
138
  const declaredIn = new Set();
139
+ const rank = { direct: 2, transitive: 1, unknown: 0 };
134
140
  for (const source of packageSources(pkg)) {
135
- const entry = lookups.get(source);
136
- const info = entry?.lookup ? entry.lookup(pkg.name, pkg.version) : { relation: "unknown" };
137
- infos.push(info);
141
+ const entry = lookups.get(source.path);
142
+ const info = entry?.lookup ? entry.lookup(pkg.name, pkg.version, source.type) : { relation: "unknown" };
143
+ const previous = byFile.get(source.path);
144
+ if (previous === undefined || rank[info.relation] > rank[previous.relation])
145
+ byFile.set(source.path, info);
138
146
  for (const manifest of info.declaredIn ?? [])
139
147
  declaredIn.add(path.join(entry.lockDir, manifest));
140
148
  }
149
+ const infos = [...byFile.values()];
141
150
  const { relation, introducedBy, replaced } = combineRelations(infos);
142
151
  const { vulnerabilities, ...rest } = pkg;
143
152
  return {
@@ -5,6 +5,7 @@
5
5
  * それ以外はunsupported_ecosystemとして返す。
6
6
  */
7
7
  import { isRemoteResolutionDisabled } from "../osv/runner.js";
8
+ import { candidateCheckDisabledFromEnv, checkRecommendedCandidates } from "../osv/candidateCheck.js";
8
9
  import { suggestUpgrades } from "../osv/suggestFix.js";
9
10
  import { sanitizeExternalText } from "../utils/externalText.js";
10
11
  import { detectProject } from "../utils/manifestDetector.js";
@@ -16,7 +17,9 @@ export async function handleSuggestFix(args, options = {}) {
16
17
  const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
17
18
  const noRemoteResolution = isRemoteResolutionDisabled(options);
18
19
  const report = await scanFromSnapshot(project, { ...options, noRemoteResolution });
19
- const suggestions = suggestUpgrades(markLowerBounds(project, report.packages));
20
+ const packages = markLowerBounds(project, report.packages);
21
+ const candidateCheck = options.candidateCheck ?? (candidateCheckDisabledFromEnv() ? "disabled" : {});
22
+ const suggestions = await checkRecommendedCandidates(packages, suggestUpgrades(packages), candidateCheck);
20
23
  const unfixedVulnerabilities = suggestions.reduce((sum, s) => sum + s.per_cve_detail.filter((d) => d.tier === "unfixed").length, 0);
21
24
  return jsonResult({
22
25
  project_dir: project.projectDir,
@@ -236,6 +236,15 @@ export function goModRelations(text) {
236
236
  return replaced.has(`${name}@${version}`) || replacedLocal.has(name) ? { relation, replaced: true } : { relation };
237
237
  };
238
238
  }
239
+ /**
240
+ * pom.xmlの依存の関係を、osv-scannerの`source.type`で判定する(pom.xmlは自前で解析しない)。
241
+ * osv-scanner 2.4.0は同じpom.xmlを、宣言された依存(親POM・プロファイル・プロパティ・依存管理をosv-scannerが解釈したもの)を
242
+ * `type: "lockfile"`、deps.devで解決された推移的依存を`type: "unknown"`の結果に分けて報告する(実データで確認)。
243
+ * 文書化された仕様ではないため、想定外のtypeはunknownにする(osv-scannerのピン留め更新時に再確認する)。
244
+ */
245
+ export function pomRelations() {
246
+ return (_name, _version, sourceType) => sourceType === "lockfile" ? { relation: "direct" } : sourceType === "unknown" ? { relation: "transitive" } : UNKNOWN;
247
+ }
239
248
  /** PEP 503の正規化 */
240
249
  function normalizePypi(name) {
241
250
  return name.toLowerCase().replace(/[-_.]+/g, "-");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",