osv-scanner-mcp 0.7.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
@@ -326,7 +326,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
326
326
  - **対応形式**: UTF-8 JSONのCycloneDX 1.4 / 1.5 / 1.6、SPDX 2.2 / 2.3。XML、SPDX tag-value、SPDX 3は未対応です。
327
327
  - **入力**: 16MiB以下のローカル通常ファイルの絶対パス。ファイル名は任意で、内容から形式を判別します。CycloneDXは`components`、SPDXは`packages`配列が必要です。形式・主要構造の確認であり、仕様全体のJSON Schema検証ではありません。
328
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)を参照してください。
329
- - **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。
329
+ - **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。スキャン中にサーバーが終了した場合(SIGTERM/SIGINT/SIGHUP、MCPクライアントがstdinを閉じた場合)も、一時コピーを削除し実行中のOSV-Scannerを止めてから終了します。
330
330
 
331
331
  出力の先頭に`coverage`を返します。
332
332
 
@@ -446,7 +446,7 @@ npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換
446
446
 
447
447
  - **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します。`OSV_MCP_PREFER_DOWNLOAD=1` でPATH上の未検証バイナリを使わない運用も選べます
448
448
  - **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
449
- - **スナップショット方式(検査と読み込みの不一致の防止)**: 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時間以上経過したものだけ。シンボリックリンクはたどりません)
450
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の扱い))
451
451
  - **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます。本サーバー自身の解析も、同じファイルは1回だけ読んで結果を使い回し(workspaceの収録確認でのlockfile、requirements.txtの共通の取り込み先、親POM)、読む量の合計に上限を設けます
452
452
  - **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
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.7.0",
40
+ version: "0.7.1",
40
41
  });
41
42
  server.registerTool("scan_project", {
42
43
  title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
@@ -116,6 +117,13 @@ server.registerTool("scan_sbom", {
116
117
  sbom_path: z.string().min(1).describe("CycloneDX/SPDX JSON SBOMファイルの絶対パス"),
117
118
  },
118
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
+ }, () => { });
119
127
  const transport = new StdioServerTransport();
120
128
  await server.connect(transport);
121
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;
@@ -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,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.7.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",