osv-scanner-mcp 0.6.0 → 0.7.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
@@ -200,7 +200,10 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
200
200
  "vulnerable_package_count": 2,
201
201
  "vulnerability_count": 4,
202
202
  "severity_breakdown": { "critical": 0, "high": 2, "medium": 2, "low": 0, "unknown": 0 },
203
- "packages": [{ "name": "minimist", "version": "1.2.5", "ecosystem": "npm", "dependency_groups": ["dev"], "vulnerabilities": [] }]
203
+ "packages": [
204
+ { "name": "minimist", "version": "1.2.5", "ecosystem": "npm", "dependency_groups": ["dev"], "dependency_relation": "direct", "declared_in": ["package.json"], "vulnerabilities": [] },
205
+ { "name": "qs", "version": "6.7.0", "ecosystem": "npm", "dependency_relation": "transitive", "introduced_by": ["express"], "vulnerabilities": [] }
206
+ ]
204
207
  }
205
208
  ```
206
209
 
@@ -211,6 +214,11 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
211
214
  - `skipped_files`: スキャン対象から外したファイルと理由(requirements.txt自体が読めない・1MiBを超える場合、`pom.xml` の親POMが許可ルートの外を参照する場合)。親POMを読めず、親POMを含めずにスキャンした `pom.xml` もここに理由付きで示します([親POMの扱い](#親pomの扱い)を参照)
212
215
  - 各一覧は200件までで、超えた分の件数を `omitted_items` に返します
213
216
  - `ecosystem_breakdown` は、脆弱性0件のエコシステムも含めて「スキャンした」ことを示します
217
+ - `dependency_relation` は直接依存(`direct`)か推移的依存(`transitive`)かを示します。OSV-Scannerの出力にはこの区別がないため、本サーバーがスキャンしたファイルのコピーを解析して判定します:
218
+ - `package-lock.json`(v2以降): ルートとworkspaceのpackage.jsonの依存を、Nodeの解決規則(入れ子の `node_modules` から上位へ)で解決したものが直接依存、そこからたどれるものが推移的依存です。直接依存には宣言しているpackage.jsonを `declared_in` に、推移的依存にはそれを要求している直接依存の名前を `introduced_by`(最大10件、超えた分は `introduced_by_omitted`)に示します。直接依存でもあり他の依存からも要求される版は `direct` とし、`introduced_by` も付けます
219
+ - `go.mod`: `// indirect` の無い `require` が直接依存です。`replace` で置き換えているモジュールには `replaced_in_go_mod: true` を付けます(OSV-Scannerは置換先のパスと版で報告します)
220
+ - `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` です
214
222
  - `dependency_groups` はOSV-Scannerが付けた依存グループ(例: `dev`)の生の値です。lockfileの形式によって欠落・不正確なため(pnpmでは付かず、pdmでは `optional` になる等)、参考情報として扱ってください
215
223
  - 修正版の推奨(`suggest_fix`)はJava・JavaScript・Python・Goに対応しています
216
224
 
@@ -318,7 +326,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
318
326
  - **対応形式**: UTF-8 JSONのCycloneDX 1.4 / 1.5 / 1.6、SPDX 2.2 / 2.3。XML、SPDX tag-value、SPDX 3は未対応です。
319
327
  - **入力**: 16MiB以下のローカル通常ファイルの絶対パス。ファイル名は任意で、内容から形式を判別します。CycloneDXは`components`、SPDXは`packages`配列が必要です。形式・主要構造の確認であり、仕様全体のJSON Schema検証ではありません。
320
328
  - **識別情報**: 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)を参照してください。
321
- - **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。
329
+ - **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。スキャン中にサーバーが終了した場合(SIGTERM/SIGINT/SIGHUP、MCPクライアントがstdinを閉じた場合)も、一時コピーを削除し実行中のOSV-Scannerを止めてから終了します。
322
330
 
323
331
  出力の先頭に`coverage`を返します。
324
332
 
@@ -378,6 +386,7 @@ npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換
378
386
  - 推奨時は `verification: "verified"`、CVEごとの `recommended_status` は `affected` / `not_affected` / `unknown` です。推奨保留時は `not_evaluated` になります。`per_cve_detail.fixed_in` は各CVE単独の候補であり、最終推奨先の判定は `recommended_status` を参照してください。
379
387
  - 現在より新しい修正版候補がないCVEは `tier: "unfixed"` として推奨の修正対象から除外します(情報欠落を含む場合があります)。除外したCVEも推奨先で判定し、その状態を表示します。全CVEがunfixedの場合も `recommended_upgrade` は `null` です。修正版の記載はあるがバージョンとして解釈できないCVE(SemVerでない `13.0` 等)は `tier: "unparseable_fix"` とし、修正版が無いとは扱わず修正対象に残すため、推奨は保留(`no_verified_candidate`)になります。
380
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` に示します。
389
+ - 各提案には `scan_project` と同じ `dependency_relation`(と `introduced_by` / `declared_in` / `replaced_in_go_mod`)を付け、`update_hint` を直接/推移的依存の別に応じて具体化します(npmの推移的依存なら `introduced_by` の直接依存の更新と `overrides`、Goの `replace` ならreplaceの版の更新、等)。`unknown` / `mixed` の場合は両方の場合を案内します。
381
390
  - requirements.txtの `>=X` / `~=X` の行は、OSV-Scannerが下限Xを使用中の版とみなしてスキャンしています。この依存の提案には `version_is_lower_bound: true` を付け、推奨は「下限を推奨版以上に引き上げる」意味であること(実際にインストールされる版とは異なりうること)を `upgrade_note` に示します。
382
391
  - 推奨に未対応のエコシステム(SBOM由来のRubyGems等)は `verification: "unsupported_ecosystem"`、現在の版をバージョンとして解釈できない場合(npmのgit・ローカルパス依存等)は `verification: "unparseable_version"` を返し、どちらもCVEごとの `tier: "unsupported"` として `unfixed` には数えません(修正版の有無は判定していないため。修正版は `scan_project` の `fixed_versions` や `explain_vulnerability` で確認できます)。
383
392
  - 応答の `coverage` は `scan_project` と同じです。lockfileの無いマニフェストや外したファイルがあれば `complete: false` になり、それらの依存は提案に含まれません。v0.4.2以前の `skipped_manifests` / `scope_warning` は `coverage.skipped_files` / `coverage.warning` に統合しました。
@@ -437,7 +446,7 @@ npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換
437
446
 
438
447
  - **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します。`OSV_MCP_PREFER_DOWNLOAD=1` でPATH上の未検証バイナリを使わない運用も選べます
439
448
  - **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
440
- - **スナップショット方式(検査と読み込みの不一致の防止)**: OSV-Scannerには元のファイルを一切渡しません。lockfile・`pom.xml`(親POMの連鎖を含む)・JAR/WARは、本サーバーが安全に1回だけ読んだ内容を専用の一時ディレクトリ(所有者のみアクセス可、終了時に削除)へコピーしてスキャンし、検査もそのコピーに対して行います。検査の後で元のファイルやディレクトリを差し替えても結果には影響しません。親POMは元の配置を一時ディレクトリ内に再現してコピーするため、OSV-Scannerが相対パスで親をたどっても、見つかるのは検証してコピーしたファイルだけです(`..` を重ねて一時ディレクトリの外に届く参照は除外)。読み込みは末尾のシンボリックリンクをたどらず、名前付きパイプ等の通常のファイル以外は読まず(処理が止まらない)、読み終えた後にパスを解決し直して境界の内側かつ開いた実体と同じファイルかを確認します。コピーの合計サイズは2GiBまでです(超えると `scan_input_too_large`)
449
+ - **スナップショット方式(検査と読み込みの不一致の防止)**: OSV-Scannerには元のファイルを一切渡しません。lockfile・`pom.xml`(親POMの連鎖を含む)・JAR/WARは、本サーバーが安全に1回だけ読んだ内容を専用の一時ディレクトリ(所有者のみアクセス可、終了時に削除)へコピーしてスキャンし、検査もそのコピーに対して行います。検査の後で元のファイルやディレクトリを差し替えても結果には影響しません。親POMは元の配置を一時ディレクトリ内に再現してコピーするため、OSV-Scannerが相対パスで親をたどっても、見つかるのは検証してコピーしたファイルだけです(`..` を重ねて一時ディレクトリの外に届く参照は除外)。読み込みは末尾のシンボリックリンクをたどらず、名前付きパイプ等の通常のファイル以外は読まず(処理が止まらない)、読み終えた後にパスを解決し直して境界の内側かつ開いた実体と同じファイルかを確認します。コピーの合計サイズは2GiBまでです(超えると `scan_input_too_large`)。SIGKILL等の捕捉できない終了で残った一時ディレクトリは、次回以降の起動時に削除します(名前が本サーバーの接頭辞に完全一致し、自分が所有する実体のディレクトリで、最終更新から24時間以上経過したものだけ。シンボリックリンクはたどりません)
441
450
  - **パストラバーサル対策**: 入力パスは `realpath` でシンボリックリンク解決後に境界チェック。pom.xml探索ではシンボリックリンクを辿りません。OSV-Scannerにはディレクトリを渡さず、検出したマニフェストだけを形式を明示して個別に渡します(ディレクトリを渡すと、OSV-Scannerが同じディレクトリの `requirements.txt` も読み、その取り込み指定 `-r ../x.txt` でスキャン範囲の外のファイルを読むため)。`scan_project` でrequirements.txtをスキャンする場合は元ファイルを渡さず、解釈できた依存の行だけを正規化して書いた専用コピーをスキャンします。コピーには取り込み指定を含めないため、OSV-Scannerの取り込みの解釈と本サーバーの解析がずれても、範囲外のファイルは読まれません。`pom.xml` の親POMの連鎖が `OSV_MCP_ALLOWED_ROOT` の外を参照する場合は、その `pom.xml` をスキャン対象から外します([親POMの扱い](#親pomの扱い))
442
451
  - **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます。本サーバー自身の解析も、同じファイルは1回だけ読んで結果を使い回し(workspaceの収録確認でのlockfile、requirements.txtの共通の取り込み先、親POM)、読む量の合計に上限を設けます
443
452
  - **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
@@ -467,7 +476,7 @@ npm run build # dist/ へビルド
467
476
  - [x] `scan_project` ツール: Java / JavaScript / Python / Go のlockfileをまとめてスキャン
468
477
  - [x] `suggest_fix` のJavaScript / Go対応(semver)
469
478
  - [x] `suggest_fix` のPython対応(PEP 440)
470
- - [ ] 直接/推移的依存の区別
479
+ - [x] 直接/推移的依存の区別(npm・Go・requirements.txt)
471
480
 
472
481
  ## ライセンス
473
482
 
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { handleScanProject } from "./tools/scanProject.js";
20
20
  import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
21
21
  import { handleScanSbom } from "./tools/scanSbom.js";
22
22
  import { handleSuggestFix } from "./tools/suggestFix.js";
23
+ import { installShutdownHandlers, removeStaleTempDirs } from "./utils/processCleanup.js";
23
24
  import { ALLOWED_ROOT_ENV, allowedRootFromEnv, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
24
25
  export { ALLOWED_ROOT_ENV };
25
26
  // fail-closed: 運用モードで許可ルートが未設定なら、ツールを一切公開せず終了する
@@ -36,7 +37,7 @@ if (startupWarning !== null) {
36
37
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
37
38
  const server = new McpServer({
38
39
  name: "osv-scanner-mcp",
39
- version: "0.6.0",
40
+ version: "0.7.1",
40
41
  });
41
42
  server.registerTool("scan_project", {
42
43
  title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
@@ -45,6 +46,7 @@ server.registerTool("scan_project", {
45
46
  "Python(poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt)、Go(go.mod)。" +
46
47
  "パッケージマネージャーやビルドは実行しない。" +
47
48
  "応答先頭のcoverageを必ず確認すること: lockfileが無いマニフェスト、バージョン未固定のrequirements行、スキャン対象から外したファイルを示す。" +
49
+ "各パッケージのdependency_relationは直接依存(direct)か推移的依存(transitive)か(package-lock.json・go.mod・requirements.txtで判定。それ以外はunknown)。" +
48
50
  "coverage.complete=falseの場合は、検出0件でも安全とは判断しないこと。修正版の推奨(suggest_fix)はJava・JavaScript・Python・Goに対応。",
49
51
  inputSchema: {
50
52
  project_path: z
@@ -72,7 +74,7 @@ server.registerTool("suggest_fix", {
72
74
  "推奨はJava(Maven / Gradle)・JavaScript(npm)・Python(PyPI)・Goに対応。" +
73
75
  "現在のバージョンに最も近いリリース系統の修正版を優先する3段階フォールバック" +
74
76
  "(same_minor: 同一系統内 → major_internal: 同一メジャー内 → cross_major: メジャーアップグレード)で選定し、" +
75
- "推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。npm・Go・PyPIでは0.x系のマイナー更新もcross_major(破壊的変更の可能性)。requirements.txtの下限(>=)でスキャンした依存はversion_is_lower_boundを付け、推奨は下限の引き上げを意味する。" +
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)を示す。" +
76
78
  "候補を全修正対象CVEの影響範囲と照合し、情報不足の場合は推奨を保留する。プレリリース版は正式版で解消できない場合だけ推奨し、recommended_is_prereleaseを付ける。" +
77
79
  "現在より新しい修正版候補のないCVEはunfixedとして別表示し、推奨先での判定も返す。" +
78
80
  "応答のcoverageを必ず確認すること(complete=falseなら提案に含まれない依存がある)。" +
@@ -115,6 +117,13 @@ server.registerTool("scan_sbom", {
115
117
  sbom_path: z.string().min(1).describe("CycloneDX/SPDX JSON SBOMファイルの絶対パス"),
116
118
  },
117
119
  }, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: allowedRootFromEnv() }));
120
+ // シグナル・stdinの終了時に一時ディレクトリと実行中のosv-scannerを片付ける(接続前に登録する)
121
+ installShutdownHandlers();
122
+ // 前回の異常終了で残った一時ディレクトリの掃除。起動を遅らせないよう待たない
123
+ removeStaleTempDirs().then((removed) => {
124
+ if (removed > 0)
125
+ console.error(`osv-scanner-mcp: 前回の終了時に残った一時ディレクトリを${removed}件削除しました`);
126
+ }, () => { });
118
127
  const transport = new StdioServerTransport();
119
128
  await server.connect(transport);
120
129
  // stdoutはMCPプロトコル専用のため、起動ログはstderrへ
@@ -10,6 +10,7 @@
10
10
  * requirements.txt等も読み、その`-r ../x.txt`の取り込みでスキャン範囲の外のファイルを読むため
11
11
  * - SBOMは検証・サイズ制限済みの専用一時コピー1つだけを渡す
12
12
  * - タイムアウトと出力サイズ上限を設ける(ハング・巨大出力によるDoS対策)
13
+ * - サーバーの終了時に実行中のプロセスを残さない(processCleanup.tsに登録し、終了時にSIGKILL)
13
14
  *
14
15
  * 終了コード(2.4.0で実機確認):
15
16
  * 0 = スキャン成功・脆弱性なし / 1 = スキャン成功・脆弱性あり / 128 = 対象パッケージなし
@@ -18,6 +19,7 @@ import { spawn } from "node:child_process";
18
19
  import path from "node:path";
19
20
  import { ScanToolError } from "../errors.js";
20
21
  import { isManifestFormat } from "../utils/manifestFormats.js";
22
+ import { trackChildProcess } from "../utils/processCleanup.js";
21
23
  import { resolveOsvScannerBinary } from "./binaryManager.js";
22
24
  import { parseOsvScanOutput } from "./scanReport.js";
23
25
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -96,6 +98,7 @@ function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, scan
96
98
  shell: false,
97
99
  stdio: ["ignore", "pipe", "pipe"],
98
100
  });
101
+ trackChildProcess(child);
99
102
  const stdoutChunks = [];
100
103
  const stderrChunks = [];
101
104
  let stdoutBytes = 0;
@@ -93,6 +93,16 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
93
93
  }
94
94
  return sortVersions(versions, ecosystem);
95
95
  }
96
+ /**
97
+ * パッケージごとのスキャン元ファイル(osv-scannerの`results[].source.path`)。
98
+ * 応答のJSONには出さない(既存の応答を変えない)ため、パッケージのオブジェクトをキーに別に持つ。
99
+ * 直接/推移的依存の判定(dependencyRelations.ts)が、どのlockfileの依存かを知るために使う
100
+ */
101
+ const packageSourceMap = new WeakMap();
102
+ /** parseOsvScanOutputが返したパッケージのスキャン元ファイル。それ以外のオブジェクトは空 */
103
+ export function packageSources(pkg) {
104
+ return packageSourceMap.get(pkg) ?? [];
105
+ }
96
106
  /**
97
107
  * OSV-Scannerの`--format json`出力を`scan_java_project`のレポートに変換する。
98
108
  *
@@ -123,9 +133,11 @@ export function parseOsvScanOutput(raw) {
123
133
  const key = `${ecosystem}:${name}@${version}`;
124
134
  let entry = packageMap.get(key);
125
135
  if (!entry) {
126
- entry = { name, version, ecosystem, groups: new Set(), vulns: new Map() };
136
+ entry = { name, version, ecosystem, groups: new Set(), sources: new Set(), vulns: new Map() };
127
137
  packageMap.set(key, entry);
128
138
  }
139
+ if (sourcePath !== null)
140
+ entry.sources.add(sourcePath);
129
141
  for (const group of asStrings(pkgObj.dependency_groups))
130
142
  entry.groups.add(group);
131
143
  const details = asArray(pkgObj.vulnerabilities);
@@ -177,13 +189,15 @@ function buildReport(sourceFiles, packageMap) {
177
189
  severityBreakdown[vuln.severity]++;
178
190
  vulnerabilityCount++;
179
191
  }
180
- return {
192
+ const pkg = {
181
193
  name: entry.name,
182
194
  version: entry.version,
183
195
  ecosystem: entry.ecosystem,
184
196
  ...(entry.groups.size > 0 ? { dependency_groups: [...entry.groups].sort() } : {}),
185
197
  vulnerabilities,
186
198
  };
199
+ packageSourceMap.set(pkg, [...entry.sources]);
200
+ return pkg;
187
201
  })
188
202
  .sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
189
203
  return {
@@ -29,10 +29,62 @@ const UPDATE_HINTS = {
29
29
  };
30
30
  /** Goの疑似バージョン(タグのないコミット): 末尾がタイムスタンプ14桁-コミットハッシュ12桁 */
31
31
  const GO_PSEUDO_VERSION = /(?:^|[.-])\d{14}-[0-9a-f]{12}(?:\+|$)/;
32
- function hintFields(ecosystem) {
33
- const hint = UPDATE_HINTS[ecosystem];
32
+ const GO_MAJOR_NOTE = "Goではv2以上のメジャーは別のモジュールパス(/v2等)として別パッケージ扱いのため、新しいメジャー系列の修正版はここに含まれません";
33
+ /**
34
+ * 推奨版への更新方法。直接/推移的依存の別が分かれば具体化し、分からなければ(unknown・mixed)両方を案内する
35
+ */
36
+ function updateHint(pkg) {
37
+ const relation = pkg.dependency_relation;
38
+ const by = pkg.introduced_by?.join("、");
39
+ switch (pkg.ecosystem) {
40
+ case "npm":
41
+ if (relation === "direct") {
42
+ return `直接依存です。${pkg.declared_in?.join("、") ?? "package.json"}の指定を更新します` +
43
+ (by ? `。${by}からも推移的に要求されているため、それらの更新が必要な場合もあります` : "");
44
+ }
45
+ if (relation === "transitive") {
46
+ return `推移的依存です。${by ? `要求している直接依存(${by})` : "要求している直接依存"}を、推奨版以上を要求する版に更新します。` +
47
+ "直接依存の更新で直らない場合は、ルートのpackage.jsonのoverrides(ルートのプロジェクトでのみ有効)で版を指定します";
48
+ }
49
+ return UPDATE_HINTS.npm;
50
+ case "Go": {
51
+ if (pkg.replaced_in_go_mod) {
52
+ return `go.modのreplaceで置き換えているため、requireではなくreplaceの版を更新します。${GO_MAJOR_NOTE}`;
53
+ }
54
+ if (relation === "direct")
55
+ return `直接依存です。go get <module>@<version>で更新します。${GO_MAJOR_NOTE}`;
56
+ if (relation === "transitive") {
57
+ return "間接依存(go.modの// indirect)です。go get <module>@<version>でgo.modの版を引き上げられます" +
58
+ `(依存元のモジュールの更新で解消できる場合もあります)。${GO_MAJOR_NOTE}`;
59
+ }
60
+ return UPDATE_HINTS.Go;
61
+ }
62
+ case "PyPI":
63
+ if (relation === "direct") {
64
+ return "直接依存です。requirements.txtの版の指定、またはpyproject.toml・Pipfileの指定を更新し、lockfileを再生成します";
65
+ }
66
+ if (relation === "transitive") {
67
+ return "推移的依存です。それを要求している直接依存の更新か、pipの制約ファイル(-c)、uv・Poetry等の上書き設定で版を指定します";
68
+ }
69
+ return UPDATE_HINTS.PyPI;
70
+ default:
71
+ return UPDATE_HINTS[pkg.ecosystem];
72
+ }
73
+ }
74
+ function hintFields(pkg) {
75
+ const hint = updateHint(pkg);
34
76
  return hint === undefined ? {} : { update_hint: hint };
35
77
  }
78
+ /** スキャン結果に付いた直接/推移的依存の項目を提案にも写す(無ければ何も出さない) */
79
+ function relationFields(pkg) {
80
+ return {
81
+ ...(pkg.dependency_relation !== undefined ? { dependency_relation: pkg.dependency_relation } : {}),
82
+ ...(pkg.introduced_by !== undefined ? { introduced_by: pkg.introduced_by } : {}),
83
+ ...(pkg.introduced_by_omitted !== undefined ? { introduced_by_omitted: pkg.introduced_by_omitted } : {}),
84
+ ...(pkg.declared_in !== undefined ? { declared_in: pkg.declared_in } : {}),
85
+ ...(pkg.replaced_in_go_mod ? { replaced_in_go_mod: true } : {}),
86
+ };
87
+ }
36
88
  /**
37
89
  * 未対応エコシステム・解釈できない現在の版は推奨を出さず、CVEをunfixedにも数えない。
38
90
  * 修正版の抽出がMaven専用だった頃、そのまま処理すると修正版のある脆弱性を
@@ -43,6 +95,7 @@ function notEvaluatedSuggestion(pkg, verification) {
43
95
  package: pkg.name,
44
96
  current_version: pkg.version,
45
97
  ecosystem: pkg.ecosystem,
98
+ ...relationFields(pkg),
46
99
  recommended_upgrade: null,
47
100
  upgrade_tier: null,
48
101
  upgrade_note: (verification === "unsupported_ecosystem"
@@ -117,7 +170,7 @@ export function suggestUpgradeForPackage(pkg) {
117
170
  if (scheme === null)
118
171
  return notEvaluatedSuggestion(pkg, "unsupported_ecosystem");
119
172
  if (!scheme.isValid(pkg.version))
120
- return { ...notEvaluatedSuggestion(pkg, "unparseable_version"), ...hintFields(pkg.ecosystem) };
173
+ return { ...notEvaluatedSuggestion(pkg, "unparseable_version"), ...hintFields(pkg) };
121
174
  const details = pkg.vulnerabilities.map((vuln) => {
122
175
  const fix = rankCandidates(scheme, pkg.version, vuln.fixed_versions)[0];
123
176
  return {
@@ -145,6 +198,7 @@ export function suggestUpgradeForPackage(pkg) {
145
198
  package: pkg.name,
146
199
  current_version: pkg.version,
147
200
  ecosystem: pkg.ecosystem,
201
+ ...relationFields(pkg),
148
202
  recommended_upgrade: recommended,
149
203
  upgrade_tier: upgradeTier,
150
204
  ...(recommended !== null && scheme.isPrerelease(recommended) ? { recommended_is_prerelease: true } : {}),
@@ -152,7 +206,7 @@ export function suggestUpgradeForPackage(pkg) {
152
206
  ? "既知の修正版候補から、全修正対象CVEの影響範囲外と確認できる版が見つかりません。情報不足・未対応の範囲形式を含む場合も推奨を保留します。" +
153
207
  (unparseableCount > 0 ? `${unparseableCount}件のCVEは修正版の記載をバージョンとして解釈できないため(tier: unparseable_fix)、推奨を保留しています。` : "")
154
208
  : buildNote(scheme, pkg, recommended, upgradeTier, fixableCount, unfixedCount),
155
- ...hintFields(pkg.ecosystem),
209
+ ...hintFields(pkg),
156
210
  ...(pkg.version_is_lower_bound ? { version_is_lower_bound: true } : {}),
157
211
  per_cve_detail: details,
158
212
  verification: recommended === null ? "no_verified_candidate" : "verified",
@@ -7,8 +7,10 @@
7
7
  import path from "node:path";
8
8
  import { ScanToolError } from "../errors.js";
9
9
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
10
- import { parseOsvScanOutput } from "../osv/scanReport.js";
10
+ import { packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
11
+ import { combineRelations, goModRelations, npmLockRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
11
12
  import { sanitizeExternalText } from "../utils/externalText.js";
13
+ import { readRegularFile } from "../utils/safeRead.js";
12
14
  import { detectProject } from "../utils/manifestDetector.js";
13
15
  import { normalizePypiName } from "../utils/requirementsFile.js";
14
16
  import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
@@ -90,6 +92,65 @@ export function markLowerBounds(project, packages) {
90
92
  * 検証済みの正規化行だけを書いたコピー。コピーできず外したファイルはskippedFilesに記録する。
91
93
  * スナップショットは成功・失敗とも削除する。suggest_fixも同じスキャンを使う。
92
94
  */
95
+ /** 直接/推移的依存の判定で読むpackage-lock.json・go.modの合計サイズの上限(LockfileKeyCacheと同じ) */
96
+ const MAX_RELATION_LOCKFILE_BYTES = 256 * 1024 * 1024;
97
+ const MAX_INTRODUCED_BY = 10;
98
+ /**
99
+ * スキャンしたファイル(スナップショットのコピー)ごとに、直接/推移的依存の判定を用意する。
100
+ * 解析するのは検証済みのコピーだけで、元のファイルは読まない。判定できない形式・上限超過はnull(unknown)。
101
+ */
102
+ export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_LOCKFILE_BYTES) {
103
+ const lookups = new Map();
104
+ let remaining = maxTotalBytes;
105
+ for (const { copy, format, originalRelative, entries } of copies) {
106
+ const lockDir = originalRelative === null ? "" : path.dirname(originalRelative);
107
+ let lookup = null;
108
+ try {
109
+ if (format === "package-lock.json" || format === "go.mod") {
110
+ // 残りの予算を上限に、読み込みの途中で打ち切る(予算を超えるファイルは全体を読まずにunknownにする)
111
+ const read = await readRegularFile(copy, { maxBytes: remaining });
112
+ if (read.ok) {
113
+ remaining -= read.bytes.length;
114
+ const text = read.bytes.toString("utf8");
115
+ lookup = format === "go.mod" ? goModRelations(text) : npmLockRelations(JSON.parse(text));
116
+ }
117
+ }
118
+ else if (format === "requirements.txt" && entries !== undefined) {
119
+ lookup = requirementsRelations(entries);
120
+ }
121
+ }
122
+ catch {
123
+ lookup = null; // 解析できないファイルはunknown(スキャン自体は続ける)
124
+ }
125
+ lookups.set(copy, { lookup, lockDir });
126
+ }
127
+ return lookups;
128
+ }
129
+ /** パッケージに直接/推移的依存の別を付ける。項目は脆弱性の一覧より前に置く */
130
+ function annotateRelations(packages, lookups) {
131
+ return packages.map((pkg) => {
132
+ const infos = [];
133
+ const declaredIn = new Set();
134
+ 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);
138
+ for (const manifest of info.declaredIn ?? [])
139
+ declaredIn.add(path.join(entry.lockDir, manifest));
140
+ }
141
+ const { relation, introducedBy, replaced } = combineRelations(infos);
142
+ const { vulnerabilities, ...rest } = pkg;
143
+ return {
144
+ ...rest,
145
+ dependency_relation: relation,
146
+ ...(introducedBy.length > 0 ? { introduced_by: introducedBy.slice(0, MAX_INTRODUCED_BY).map(sanitizeExternalText) } : {}),
147
+ ...(introducedBy.length > MAX_INTRODUCED_BY ? { introduced_by_omitted: introducedBy.length - MAX_INTRODUCED_BY } : {}),
148
+ ...(declaredIn.size > 0 ? { declared_in: [...declaredIn].sort().map(sanitizeExternalText) } : {}),
149
+ ...(replaced ? { replaced_in_go_mod: true } : {}),
150
+ vulnerabilities,
151
+ };
152
+ });
153
+ }
93
154
  export async function scanFromSnapshot(project, options) {
94
155
  const snapshot = await ScanSnapshot.create();
95
156
  try {
@@ -97,6 +158,10 @@ export async function scanFromSnapshot(project, options) {
97
158
  projectDir: project.projectDir,
98
159
  allowedRootReal: project.allowedRootReal,
99
160
  });
161
+ // コピーと元のファイルの対応(snapshotManifestsは外したもの以外を入力の順に返す)
162
+ const skippedOriginals = new Set(skipped.map((s) => s.path));
163
+ const scannedOriginals = project.targets.filter((t) => !skippedOriginals.has(t.path));
164
+ const relationSources = targets.map((copy, i) => ({ copy: copy.path, format: copy.format, originalRelative: path.relative(project.projectDir, scannedOriginals[i].path) }));
100
165
  // 親POMを再現できずにスキャンしたpom.xmlも、欠落の可能性としてcoverageに出す(complete=falseになる)
101
166
  project.skippedFiles.push(...incomplete.map((s) => ({ path: path.relative(project.projectDir, s.path), reason: s.reason })));
102
167
  if (skipped.length > 0) {
@@ -110,11 +175,15 @@ export async function scanFromSnapshot(project, options) {
110
175
  for (const copy of project.requirementsCopies) {
111
176
  if (copy.entries.length === 0)
112
177
  continue;
113
- targets.push({ path: await snapshot.writeGenerated(`${copy.entries.join("\n")}\n`), format: "requirements.txt" });
178
+ const generated = await snapshot.writeGenerated(`${copy.entries.join("\n")}\n`);
179
+ targets.push({ path: generated, format: "requirements.txt" });
180
+ relationSources.push({ copy: generated, format: "requirements.txt", originalRelative: copy.path, entries: copy.entries });
114
181
  }
115
182
  if (targets.length === 0)
116
183
  return parseOsvScanOutput({ results: [] });
117
- return await withScopeNotes(project.skippedFiles, () => snapshot.guard(() => runOsvScan(targets, options)));
184
+ const report = await withScopeNotes(project.skippedFiles, () => snapshot.guard(() => runOsvScan(targets, options)));
185
+ // 判定はコピーを読むため、スナップショットを消す前に行う
186
+ return { ...report, packages: annotateRelations(report.packages, await buildRelationLookups(relationSources)) };
118
187
  }
119
188
  finally {
120
189
  await snapshot.cleanup();
@@ -3,12 +3,15 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { runOsvSbomScan } from "../osv/runner.js";
5
5
  import { buildSbomReport } from "../osv/sbomReport.js";
6
+ import { SBOM_DIR_PREFIX, trackTempDir } from "../utils/processCleanup.js";
6
7
  import { loadSbom } from "../utils/sbomInput.js";
7
8
  import { errorResult, jsonResult } from "./toolResult.js";
8
9
  export async function handleScanSbom(args, options = {}) {
9
10
  try {
10
11
  const input = await loadSbom(args.sbom_path, options);
11
- const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-sbom-"));
12
+ const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), SBOM_DIR_PREFIX));
13
+ // シグナル等で終了しても残さない(processCleanup.ts)
14
+ const untrack = trackTempDir(dir);
12
15
  try {
13
16
  // A fixed filename selects the native parser, irrespective of the user's filename.
14
17
  const snapshotPath = path.join(dir, input.format === "CycloneDX" ? "input.cdx.json" : "input.spdx.json");
@@ -18,6 +21,7 @@ export async function handleScanSbom(args, options = {}) {
18
21
  }
19
22
  finally {
20
23
  await rm(dir, { recursive: true, force: true });
24
+ untrack();
21
25
  }
22
26
  }
23
27
  catch (error) {
@@ -0,0 +1,269 @@
1
+ /**
2
+ * 直接/推移的依存の判定(docs/DESIGN_TODO.md「直接/推移的依存の区別(v0.7.0)詳細設計メモ」)。
3
+ *
4
+ * osv-scannerの出力には直接/推移的の区別がないため、スキャンしたファイル(スナップショットのコピー)を解析する。
5
+ * 新しい外部依存は追加せず、JSON(package-lock.json)と行形式(go.mod、requirements.txt)だけを扱う。
6
+ * - package-lock.json(v2以降): ルートとworkspaceの依存をNodeの解決規則で解決したものが直接依存、
7
+ * そこからたどれるものが推移的依存。経由した直接依存の名前(introduced_by)と、宣言したpackage.json(declared_in)を返す
8
+ * - go.mod: `// indirect`のないrequireが直接依存。replaceの置換先にも同じ関係を当てる(osv-scannerは置換先で報告する)
9
+ * - requirements.txt: 本サーバーが書いたコピーの行が直接依存、それ以外(deps.devで解決された依存)が推移的依存
10
+ */
11
+ /** たどる依存の辺の上限(巨大・悪意あるlockfileで処理が膨らまないように) */
12
+ export const MAX_DEPENDENCY_EDGES = 2_000_000;
13
+ const UNKNOWN = { relation: "unknown" };
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function depNames(entry, fields) {
18
+ const names = [];
19
+ for (const field of fields) {
20
+ const deps = entry[field];
21
+ if (isRecord(deps))
22
+ names.push(...Object.keys(deps));
23
+ }
24
+ return names;
25
+ }
26
+ const NODE_MODULES = "node_modules/";
27
+ const INSTALLED_DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
28
+ const ROOT_DEP_FIELDS = [...INSTALLED_DEP_FIELDS, "devDependencies"];
29
+ /** エントリの本来の名前: `name`があればそれ(別名のインストール)、なければキーの最後のnode_modules/以降 */
30
+ function entryName(key, entry) {
31
+ if (typeof entry.name === "string" && entry.name !== "")
32
+ return entry.name;
33
+ const index = key.lastIndexOf(NODE_MODULES);
34
+ return index === -1 ? key : key.slice(index + NODE_MODULES.length);
35
+ }
36
+ /** ルート("")とworkspace(node_modules/の外のディレクトリ)が起点 */
37
+ function isRoot(key) {
38
+ return key === "" || (!key.startsWith(NODE_MODULES) && !key.includes(`/${NODE_MODULES}`));
39
+ }
40
+ /**
41
+ * package-lock.json(v2以降)の依存の関係を判定する。`packages`が無い(v1形式)・上限を超える場合はnull。
42
+ */
43
+ export function npmLockRelations(lock, maxEdges = MAX_DEPENDENCY_EDGES) {
44
+ const packages = isRecord(lock) ? lock.packages : undefined;
45
+ if (!isRecord(packages))
46
+ return null;
47
+ const entries = new Map();
48
+ for (const [key, value] of Object.entries(packages))
49
+ if (isRecord(value))
50
+ entries.set(key, value);
51
+ /** link(workspaceへのシンボリックリンク)をたどった実体のキー */
52
+ const follow = (key) => {
53
+ const entry = entries.get(key);
54
+ if (entry === undefined)
55
+ return null;
56
+ if (entry.link === true)
57
+ return typeof entry.resolved === "string" && entries.has(entry.resolved) ? entry.resolved : null;
58
+ return key;
59
+ };
60
+ /** Nodeの解決規則: <from>/node_modules/<名前>から上位のnode_modulesへ順に探す */
61
+ const resolve = (from, dep) => {
62
+ let base = from;
63
+ for (;;) {
64
+ const candidate = `${base === "" ? "" : `${base}/`}${NODE_MODULES}${dep}`;
65
+ if (entries.has(candidate))
66
+ return follow(candidate);
67
+ if (base === "")
68
+ return null;
69
+ const parent = base.lastIndexOf(`/${NODE_MODULES}`);
70
+ base = parent === -1 ? "" : base.slice(0, parent);
71
+ }
72
+ };
73
+ let edges = 0;
74
+ const direct = new Map(); // エントリ → 宣言したpackage.json
75
+ const introducedBy = new Map(); // エントリ → 経由した直接依存の名前
76
+ for (const root of [...entries.keys()].filter(isRoot)) {
77
+ const manifest = root === "" ? "package.json" : `${root}/package.json`;
78
+ for (const dep of depNames(entries.get(root), ROOT_DEP_FIELDS)) {
79
+ if (++edges > maxEdges)
80
+ return null;
81
+ const target = resolve(root, dep);
82
+ if (target === null || isRoot(target))
83
+ continue; // 未インストール、またはworkspace自身
84
+ let declared = direct.get(target);
85
+ if (declared === undefined)
86
+ direct.set(target, (declared = new Set()));
87
+ declared.add(manifest);
88
+ }
89
+ }
90
+ // 直接依存ごとに、到達できるエントリへその名前を記録する(訪問済みを記録して循環で止める)
91
+ for (const origin of direct.keys()) {
92
+ const originName = entryName(origin, entries.get(origin));
93
+ const visited = new Set([origin]);
94
+ const queue = [origin];
95
+ while (queue.length > 0) {
96
+ const current = queue.pop();
97
+ for (const dep of depNames(entries.get(current), INSTALLED_DEP_FIELDS)) {
98
+ if (++edges > maxEdges)
99
+ return null;
100
+ const target = resolve(current, dep);
101
+ if (target === null || isRoot(target) || visited.has(target))
102
+ continue;
103
+ visited.add(target);
104
+ queue.push(target);
105
+ let names = introducedBy.get(target);
106
+ if (names === undefined)
107
+ introducedBy.set(target, (names = new Set()));
108
+ names.add(originName);
109
+ }
110
+ }
111
+ }
112
+ // 名前と版ごとにまとめる(同じ名前と版が複数の位置にインストールされうる)
113
+ const byPackage = new Map();
114
+ for (const [key, entry] of entries) {
115
+ if (isRoot(key) || entry.link === true || typeof entry.version !== "string")
116
+ continue;
117
+ const id = `${entryName(key, entry)}@${entry.version}`;
118
+ let info = byPackage.get(id);
119
+ if (info === undefined)
120
+ byPackage.set(id, (info = { direct: false, transitive: false, introducedBy: new Set(), declaredIn: new Set() }));
121
+ const declared = direct.get(key);
122
+ if (declared !== undefined) {
123
+ info.direct = true;
124
+ for (const manifest of declared)
125
+ info.declaredIn.add(manifest);
126
+ }
127
+ const names = introducedBy.get(key);
128
+ if (names !== undefined) {
129
+ info.transitive = true;
130
+ for (const name of names)
131
+ info.introducedBy.add(name);
132
+ }
133
+ }
134
+ return (name, version) => {
135
+ const info = byPackage.get(`${name}@${version}`);
136
+ if (info === undefined || (!info.direct && !info.transitive))
137
+ return UNKNOWN; // 未収録、またはどこからも到達しない
138
+ return {
139
+ relation: info.direct ? "direct" : "transitive",
140
+ ...(info.introducedBy.size > 0 ? { introducedBy: info.introducedBy } : {}),
141
+ ...(info.declaredIn.size > 0 ? { declaredIn: info.declaredIn } : {}),
142
+ };
143
+ };
144
+ }
145
+ /** go.modの1行からコメントを除いた本体と、`// indirect`の有無 */
146
+ function splitGoComment(line) {
147
+ const index = line.indexOf("//");
148
+ if (index === -1)
149
+ return { body: line.trim(), indirect: false };
150
+ // Goと同じく、コメントが`indirect`だけか`indirect;`で始まる場合に間接依存とみなす
151
+ return { body: line.slice(0, index).trim(), indirect: /^indirect(?:;|$)/.test(line.slice(index + 2).trim()) };
152
+ }
153
+ /** go.modの版(`v1.2.3`)を、osv-scannerの報告と同じ`v`なしの形にする */
154
+ function stripV(version) {
155
+ return version.startsWith("v") ? version.slice(1) : version;
156
+ }
157
+ /**
158
+ * go.modの依存の関係を判定する(requireの`// indirect`で判定し、版には依らない)。
159
+ * requireの単一行・括弧のブロック、replaceの単一行・ブロックを扱う。
160
+ * replaceはGoと同じく、版を限定したもの(`replace a v1.0.0 => ...`)はrequireの版が一致する場合だけ、
161
+ * 版を限定しないものは全版に適用する(同じモジュールでは版を限定したものが優先)。osv-scanner 2.4.0で同じ挙動を確認。
162
+ * 適用されたreplaceの置換先(osv-scannerはこのパスと版で報告する)に、置換元と同じ関係とreplacedを付ける。
163
+ */
164
+ export function goModRelations(text) {
165
+ const requires = new Map();
166
+ const replaces = [];
167
+ let block = null;
168
+ for (const raw of text.split(/\r?\n/)) {
169
+ const { body, indirect } = splitGoComment(raw);
170
+ if (body === "")
171
+ continue;
172
+ if (block !== null && body === ")") {
173
+ block = null;
174
+ continue;
175
+ }
176
+ let directive = block;
177
+ let rest = body;
178
+ if (block === null) {
179
+ const head = /^(require|replace)\b\s*(.*)$/.exec(body);
180
+ if (head === null)
181
+ continue;
182
+ directive = head[1];
183
+ rest = head[2].trim();
184
+ if (rest === "(") {
185
+ block = directive;
186
+ continue;
187
+ }
188
+ }
189
+ const fields = rest.split(/\s+/);
190
+ if (directive === "require" && fields.length >= 2) {
191
+ const existing = requires.get(fields[0]);
192
+ // 同じモジュールが複数回requireされた場合は、直接依存を優先する
193
+ if (existing?.relation !== "direct") {
194
+ requires.set(fields[0], { relation: indirect ? "transitive" : "direct", version: stripV(fields[1]) });
195
+ }
196
+ }
197
+ else if (directive === "replace") {
198
+ const arrow = fields.indexOf("=>");
199
+ if ((arrow === 1 || arrow === 2) && fields[arrow + 1] !== undefined) {
200
+ replaces.push({
201
+ from: fields[0],
202
+ fromVersion: arrow === 2 ? stripV(fields[1]) : null,
203
+ to: fields[arrow + 1],
204
+ toVersion: fields[arrow + 2] !== undefined ? stripV(fields[arrow + 2]) : null,
205
+ });
206
+ }
207
+ }
208
+ }
209
+ /** osv-scannerが報告する名前 → 関係(置換されたモジュールは置換先の名前) */
210
+ const relations = new Map();
211
+ /** 適用されたreplaceで報告される「名前@版」(ローカルへの置換は置換元の名前) */
212
+ const replaced = new Set();
213
+ const replacedLocal = new Set();
214
+ const setRelation = (name, relation) => {
215
+ if (relations.get(name) !== "direct")
216
+ relations.set(name, relation);
217
+ };
218
+ for (const [modulePath, { relation, version }] of requires) {
219
+ setRelation(modulePath, relation);
220
+ const replace = replaces.find((r) => r.from === modulePath && r.fromVersion === version) ??
221
+ replaces.find((r) => r.from === modulePath && r.fromVersion === null);
222
+ if (replace === undefined)
223
+ continue;
224
+ if (replace.toVersion === null) {
225
+ replacedLocal.add(modulePath);
226
+ }
227
+ else {
228
+ setRelation(replace.to, relation);
229
+ replaced.add(`${replace.to}@${replace.toVersion}`);
230
+ }
231
+ }
232
+ return (name, version) => {
233
+ const relation = relations.get(name);
234
+ if (relation === undefined)
235
+ return UNKNOWN;
236
+ return replaced.has(`${name}@${version}`) || replacedLocal.has(name) ? { relation, replaced: true } : { relation };
237
+ };
238
+ }
239
+ /** PEP 503の正規化 */
240
+ function normalizePypi(name) {
241
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
242
+ }
243
+ /**
244
+ * requirements.txtのコピーに書いた行(`名前==版`等)から関係を判定する。
245
+ * 書いた名前が直接依存、それ以外(deps.devで解決された依存)が推移的依存。
246
+ */
247
+ export function requirementsRelations(entries) {
248
+ const direct = new Set();
249
+ for (const line of entries) {
250
+ const name = /^[A-Za-z0-9][A-Za-z0-9._-]*/.exec(line)?.[0];
251
+ if (name !== undefined)
252
+ direct.add(normalizePypi(name));
253
+ }
254
+ return (name) => ({ relation: direct.has(normalizePypi(name)) ? "direct" : "transitive" });
255
+ }
256
+ /** 複数のファイルでの判定をまとめる。ファイルごとの関係がすべて同じならその値、異なればmixed */
257
+ export function combineRelations(infos) {
258
+ const relations = new Set(infos.map((info) => info.relation));
259
+ const relation = relations.size === 1 ? [...relations][0] : relations.size === 0 ? "unknown" : "mixed";
260
+ const introducedBy = new Set();
261
+ const declaredIn = new Set();
262
+ for (const info of infos) {
263
+ for (const name of info.introducedBy ?? [])
264
+ introducedBy.add(name);
265
+ for (const manifest of info.declaredIn ?? [])
266
+ declaredIn.add(manifest);
267
+ }
268
+ return { relation, introducedBy: [...introducedBy].sort(), declaredIn: [...declaredIn].sort(), replaced: infos.some((info) => info.replaced) };
269
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * サーバー終了時の後始末: スキャン用の一時ディレクトリ(元のファイルのコピーを含む)と
3
+ * 実行中のosv-scannerプロセスを、サーバーより長く残さない。
4
+ *
5
+ * - 通常はスキャンごとの`finally`で削除するが、SIGTERM等で強制終了されると`finally`は実行されない
6
+ * (2026-10-07 実機確認: scan_project中にSIGTERMを送るとosv-mcp-snap-*が残った)
7
+ * - そのため作成中の一時ディレクトリと子プロセスをここに登録し、シグナル・stdinの終了・
8
+ * process.exitの時点で同期的に(fs.rmSync・SIGKILL)片付ける
9
+ * - 前回の異常終了(SIGKILL・電源断等)で残ったものは、起動時に条件を絞って削除する(removeStaleTempDirs)
10
+ */
11
+ import { rmSync } from "node:fs";
12
+ import { lstat, readdir, realpath, rm } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ /** 本サーバーが作る一時ディレクトリの接頭辞(起動時の掃除はこれに完全一致するものだけを対象にする) */
16
+ export const SNAPSHOT_DIR_PREFIX = "osv-mcp-snap-";
17
+ export const SBOM_DIR_PREFIX = "osv-mcp-sbom-";
18
+ /** mkdtempは接頭辞の後ろに6文字の英数字を付ける */
19
+ const TEMP_DIR_NAME = /^osv-mcp-(?:snap|sbom)-[A-Za-z0-9]{6}$/;
20
+ /**
21
+ * 起動時に削除する残骸の最終更新からの経過時間。別のサーバープロセス(複数のMCPクライアント等)が
22
+ * 使用中のディレクトリを消さないよう、スキャンのタイムアウト(既定120秒)より十分長くとる
23
+ */
24
+ export const STALE_TEMP_DIR_AGE_MS = 24 * 60 * 60 * 1000;
25
+ const tempDirs = new Set();
26
+ const children = new Set();
27
+ /** 一時ディレクトリを登録する。戻り値の関数で登録を外す(通常の削除後に呼ぶ) */
28
+ export function trackTempDir(dir) {
29
+ tempDirs.add(dir);
30
+ return () => { tempDirs.delete(dir); };
31
+ }
32
+ /** 子プロセスを登録する。終了(close/error)時に自動で登録を外す */
33
+ export function trackChildProcess(child) {
34
+ children.add(child);
35
+ const untrack = () => { children.delete(child); };
36
+ child.once("close", untrack);
37
+ child.once("error", untrack);
38
+ }
39
+ /**
40
+ * 登録済みの子プロセスを強制終了し、一時ディレクトリを同期的に削除する。
41
+ * シグナルハンドラ・exitイベントから呼ぶため、非同期処理を使わず例外も外に出さない
42
+ */
43
+ export function cleanupSync() {
44
+ for (const child of children) {
45
+ // 終了済みのプロセスにはNode側で送らない(PIDの再利用で別プロセスを止めることはない)
46
+ try {
47
+ child.kill("SIGKILL");
48
+ }
49
+ catch { /* 後始末は続ける */ }
50
+ }
51
+ children.clear();
52
+ for (const dir of tempDirs) {
53
+ try {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ catch { /* 他のディレクトリの削除は続ける */ }
57
+ }
58
+ tempDirs.clear();
59
+ }
60
+ /** テスト用: 登録状況 */
61
+ export function trackedCounts() {
62
+ return { tempDirs: tempDirs.size, children: children.size };
63
+ }
64
+ const SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
65
+ let installed = false;
66
+ /**
67
+ * 終了時の後始末を登録する(サーバー起動時に1回だけ呼ぶ)。
68
+ * - SIGTERM/SIGINT/SIGHUP: 後始末して慣例の終了コード(128+シグナル番号)で終了
69
+ * - stdinの終了(MCPクライアントがトランスポートを閉じた): 後始末して終了コード0で終了。
70
+ * 応答の送り先が無いため、実行中のスキャンの完了は待たない
71
+ * - process.exit・未捕捉例外による終了: exitイベントで後始末する
72
+ */
73
+ export function installShutdownHandlers(stdin = process.stdin) {
74
+ if (installed)
75
+ return;
76
+ installed = true;
77
+ let shuttingDown = false;
78
+ const shutdown = (code) => {
79
+ if (shuttingDown)
80
+ return;
81
+ shuttingDown = true;
82
+ cleanupSync();
83
+ process.exit(code);
84
+ };
85
+ for (const signal of SHUTDOWN_SIGNALS) {
86
+ process.on(signal, () => shutdown(128 + (os.constants.signals[signal] ?? 0)));
87
+ }
88
+ stdin.once("end", () => shutdown(0));
89
+ stdin.once("close", () => shutdown(0));
90
+ process.on("exit", cleanupSync);
91
+ }
92
+ /**
93
+ * 前回の異常終了で残った一時ディレクトリを削除し、削除した数を返す。次の条件をすべて満たすものだけが対象:
94
+ * - 名前が本サーバーの接頭辞+mkdtempの6文字に完全一致する
95
+ * - シンボリックリンクではなく実体のディレクトリ(lstatで判定し、リンクはたどらない)
96
+ * - 所有者が現在のユーザー
97
+ * - 最終更新からSTALE_TEMP_DIR_AGE_MS以上経過している
98
+ *
99
+ * 一時ディレクトリは通常スティッキービット付きのため、自分が所有するエントリを他のユーザーが
100
+ * 判定と削除の間に差し替えることはできない。中のシンボリックリンクはrmがリンク自体を消すだけでたどらない。
101
+ * getuidの無い環境(Windows)では所有者を確認できないため何もしない
102
+ */
103
+ export async function removeStaleTempDirs(options = {}) {
104
+ if (typeof process.getuid !== "function")
105
+ return 0;
106
+ const uid = process.getuid();
107
+ const root = options.tmpRoot ?? (await realpath(os.tmpdir()));
108
+ const now = options.now ?? Date.now();
109
+ const maxAgeMs = options.maxAgeMs ?? STALE_TEMP_DIR_AGE_MS;
110
+ let removed = 0;
111
+ for (const name of await readdir(root)) {
112
+ if (!TEMP_DIR_NAME.test(name))
113
+ continue;
114
+ const dir = path.join(root, name);
115
+ try {
116
+ const stats = await lstat(dir);
117
+ if (!stats.isDirectory() || stats.uid !== uid || now - stats.mtimeMs < maxAgeMs)
118
+ continue;
119
+ await rm(dir, { recursive: true, force: true });
120
+ removed++;
121
+ }
122
+ catch {
123
+ // 消えた・読めないものは対象外(他のディレクトリの掃除は続ける)
124
+ }
125
+ }
126
+ return removed;
127
+ }
@@ -9,12 +9,14 @@
9
9
  * 最悪でも親が見つからないだけ)
10
10
  * - `..`を重ねた参照でスナップショットの外(本物のファイルシステム)に出る親POMは除外する
11
11
  * - コピーの合計サイズに上限を設ける
12
+ * - サーバーがシグナル等で終了しても残さないよう、作成直後に後始末の対象へ登録する(processCleanup.ts)
12
13
  */
13
14
  import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
14
15
  import os from "node:os";
15
16
  import path from "node:path";
16
17
  import { ScanToolError } from "../errors.js";
17
18
  import { decodePomBytes, parentRelativePath } from "./pomParent.js";
19
+ import { SNAPSHOT_DIR_PREFIX, trackTempDir } from "./processCleanup.js";
18
20
  import { isInsideDir } from "./projectWalk.js";
19
21
  import { copyRegularFile } from "./safeRead.js";
20
22
  const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
@@ -23,19 +25,22 @@ const MAX_PARENT_DEPTH = 10;
23
25
  export class ScanSnapshot {
24
26
  dir;
25
27
  maxTotalBytes;
28
+ untrack;
26
29
  copied = new Map();
27
30
  parentCache = new Map();
28
31
  /** 再現した配置のルートごとのディレクトリ → 元のルート("/"等) */
29
32
  mirroredRoots = new Map();
30
33
  used = 0;
31
34
  generated = 0;
32
- constructor(dir, maxTotalBytes) {
35
+ constructor(dir, maxTotalBytes, untrack) {
33
36
  this.dir = dir;
34
37
  this.maxTotalBytes = maxTotalBytes;
38
+ this.untrack = untrack;
35
39
  }
36
40
  static async create(maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES) {
37
- const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-snap-"));
38
- return new ScanSnapshot(dir, maxTotalBytes);
41
+ const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), SNAPSHOT_DIR_PREFIX));
42
+ // mkdtempの完了から登録までの間にシグナルの処理は割り込まない(同じ同期処理内で登録する)
43
+ return new ScanSnapshot(dir, maxTotalBytes, trackTempDir(dir));
39
44
  }
40
45
  /** 再現した配置のルート(実際のファイルシステムの"/"に相当) */
41
46
  get treeRoot() {
@@ -107,6 +112,7 @@ export class ScanSnapshot {
107
112
  }
108
113
  async cleanup() {
109
114
  await rm(this.dir, { recursive: true, force: true });
115
+ this.untrack();
110
116
  }
111
117
  }
112
118
  const UNPARSEABLE = "親POMの指定を確実に解釈できないため(親要素の重複、CDATA・DOCTYPE、プロパティ参照、UTF-8以外の文字コード等)、" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",