osv-scanner-mcp 0.4.0 → 0.4.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
@@ -119,7 +119,7 @@ npm run build
119
119
  | 変数 | 説明 |
120
120
  |---|---|
121
121
  | `OSV_SCANNER_PATH` | 使用するosv-scannerバイナリの明示指定。省略時はPATH→自動ダウンロードの順で解決。**指定が無効な場合はフォールバックせずエラーになります**(意図しないバイナリの実行防止) |
122
- | `OSV_MCP_ALLOWED_ROOT` | 指定時、このディレクトリ配下以外のスキャンを拒否します(パストラバーサル対策の境界)。**設定を推奨** |
122
+ | `OSV_MCP_ALLOWED_ROOT` | 指定時、このディレクトリ配下以外のスキャンを拒否します(パストラバーサル対策の境界)。**設定を推奨**。空文字・空白のみは未設定として扱います |
123
123
  | `OSV_MCP_REQUIRE_ALLOWED_ROOT` | `1` または `true` 指定時、`OSV_MCP_ALLOWED_ROOT` が未設定ならサーバーの起動自体を拒否します(運用環境向けのfail-closedモード) |
124
124
  | `OSV_MCP_MAX_CONCURRENT_SCANS` | 同時実行できるスキャン数の上限(デフォルト `2`、最大 `16`)。超過したリクエストは待たずに即時エラーになります |
125
125
  | `OSV_MCP_AUTO_DOWNLOAD` | `0` または `false` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
@@ -405,6 +405,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
405
405
  | `binary_not_found` | OSV-Scannerが見つからない(インストール案内をmessageに含む) |
406
406
  | `project_not_found` | 指定パスが存在しない・ディレクトリ/pom.xmlでない |
407
407
  | `no_manifest_found` | 対応マニフェスト(pom.xml / gradle.lockfile)が見つからない |
408
+ | `scan_input_too_large` | スキャン対象ファイル(一時ディレクトリへのコピー)の合計サイズが上限(2GiB)を超えた。対象を絞って再実行する |
408
409
  | `manifest_search_limit_exceeded` | マニフェスト探索が上限(20万エントリ・1,000マニフェスト)に達した。より狭いディレクトリかマニフェストを直接指定する |
409
410
  | `binary_download_failed` | バイナリのダウンロード失敗(未対応プラットフォーム含む) |
410
411
  | `binary_checksum_mismatch` | ダウンロードしたバイナリのチェックサム不一致(改ざん/破損の可能性) |
@@ -427,8 +428,9 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
427
428
 
428
429
  - **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します。`OSV_MCP_PREFER_DOWNLOAD=1` でPATH上の未検証バイナリを使わない運用も選べます
429
430
  - **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
431
+ - **スナップショット方式(検査と読み込みの不一致の防止)**: OSV-Scannerには元のファイルを一切渡しません。lockfile・`pom.xml`(親POMの連鎖を含む)・JAR/WARは、本サーバーが安全に1回だけ読んだ内容を専用の一時ディレクトリ(所有者のみアクセス可、終了時に削除)へコピーしてスキャンし、検査もそのコピーに対して行います。検査の後で元のファイルやディレクトリを差し替えても結果には影響しません。親POMは元の配置を一時ディレクトリ内に再現してコピーするため、OSV-Scannerが相対パスで親をたどっても、見つかるのは検証してコピーしたファイルだけです(`..` を重ねて一時ディレクトリの外に届く参照は除外)。読み込みは末尾のシンボリックリンクをたどらず、名前付きパイプ等の通常のファイル以外は読まず(処理が止まらない)、読み終えた後にパスを解決し直して境界の内側かつ開いた実体と同じファイルかを確認します。コピーの合計サイズは2GiBまでです(超えると `scan_input_too_large`)
430
432
  - **パストラバーサル対策**: 入力パスは `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の扱い))
431
- - **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます
433
+ - **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます。本サーバー自身の解析も、同じファイルは1回だけ読んで結果を使い回し(workspaceの収録確認でのlockfile、requirements.txtの共通の取り込み先、親POM)、読む量の合計に上限を設けます
432
434
  - **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
433
435
  - **通信先の固定と明示**: osv-scannerの依存解決先は `deps.dev` を明示指定し、スキャン対象のpom.xmlが指定する任意のリポジトリへ接続するモード(`--data-source native`)は使いません(テストで保証)。通信先の一覧と、deps.devへの送信を止める `OSV_MCP_NO_REMOTE_RESOLUTION=1` は[通信先とプライバシー](#通信先とプライバシー)を参照
434
436
  - **情報漏えい対策**: 想定外の例外はスタックトレース等を含めず `internal_error` に丸めます。外部由来のテキスト(脆弱性summary等)は長さ上限付きの「データ」として構造化して返します
package/dist/index.js CHANGED
@@ -20,7 +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 { ALLOWED_ROOT_ENV, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
23
+ import { ALLOWED_ROOT_ENV, allowedRootFromEnv, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
24
24
  export { ALLOWED_ROOT_ENV };
25
25
  // fail-closed: 運用モードで許可ルートが未設定なら、ツールを一切公開せず終了する
26
26
  const startupError = allowedRootStartupError();
@@ -36,7 +36,7 @@ if (startupWarning !== null) {
36
36
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
37
37
  const server = new McpServer({
38
38
  name: "osv-scanner-mcp",
39
- version: "0.4.0",
39
+ version: "0.4.1",
40
40
  });
41
41
  server.registerTool("scan_project", {
42
42
  title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
@@ -52,7 +52,7 @@ server.registerTool("scan_project", {
52
52
  .min(1)
53
53
  .describe("スキャン対象のプロジェクトディレクトリ、または対応するlockfile・マニフェストの絶対パス"),
54
54
  },
55
- }, async ({ project_path }) => handleScanProject({ project_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
55
+ }, async ({ project_path }) => handleScanProject({ project_path }, { allowedRoot: allowedRootFromEnv() }));
56
56
  server.registerTool("scan_java_project", {
57
57
  title: "Javaプロジェクトの脆弱性スキャン",
58
58
  description: "Java(Maven)プロジェクトをGoogle OSV-Scannerでスキャンし、依存ライブラリの既知の脆弱性(CVE/GHSA)を深刻度順のJSONレポートで返す。" +
@@ -65,7 +65,7 @@ server.registerTool("scan_java_project", {
65
65
  .min(1)
66
66
  .describe("スキャン対象のプロジェクトディレクトリ、またはpom.xml/gradle.lockfileの絶対パス"),
67
67
  },
68
- }, async ({ project_path }) => handleScanJavaProject({ project_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
68
+ }, async ({ project_path }) => handleScanJavaProject({ project_path }, { allowedRoot: allowedRootFromEnv() }));
69
69
  server.registerTool("suggest_fix", {
70
70
  title: "脆弱性を解消する推奨アップグレードの提案",
71
71
  description: "Java(Maven)プロジェクトをスキャンし、脆弱な依存パッケージごとに推奨アップグレードバージョンを提案する。" +
@@ -81,7 +81,7 @@ server.registerTool("suggest_fix", {
81
81
  .min(1)
82
82
  .describe("スキャン対象のプロジェクトディレクトリ、またはpom.xml/gradle.lockfileの絶対パス"),
83
83
  },
84
- }, async ({ project_path }) => handleSuggestFix({ project_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
84
+ }, async ({ project_path }) => handleSuggestFix({ project_path }, { allowedRoot: allowedRootFromEnv() }));
85
85
  server.registerTool("explain_vulnerability", {
86
86
  title: "脆弱性の詳細説明の取得",
87
87
  description: "指定したGHSA-IDまたはCVE-IDの脆弱性の詳細をOSVデータベース(api.osv.dev)から取得して返す。" +
@@ -103,7 +103,7 @@ server.registerTool("scan_java_artifact", {
103
103
  inputSchema: {
104
104
  artifact_path: z.string().min(1).describe("JAR/WARファイル、または探索するディレクトリの絶対パス"),
105
105
  },
106
- }, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
106
+ }, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: allowedRootFromEnv() }));
107
107
  server.registerTool("scan_sbom", {
108
108
  title: "SBOMの脆弱性スキャン",
109
109
  description: "CycloneDX 1.4/1.5/1.6またはSPDX 2.2/2.3のJSON SBOMから識別できる依存をスキャンする。" +
@@ -112,7 +112,7 @@ server.registerTool("scan_sbom", {
112
112
  inputSchema: {
113
113
  sbom_path: z.string().min(1).describe("CycloneDX/SPDX JSON SBOMファイルの絶対パス"),
114
114
  },
115
- }, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
115
+ }, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: allowedRootFromEnv() }));
116
116
  const transport = new StdioServerTransport();
117
117
  await server.connect(transport);
118
118
  // stdoutはMCPプロトコル専用のため、起動ログはstderrへ
@@ -1,12 +1,43 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { buildArtifactReport } from "../osv/artifactReport.js";
2
4
  import { runOsvArtifactScan } from "../osv/runner.js";
3
5
  import { detectJavaArtifacts } from "../utils/artifactDetector.js";
6
+ import { ScanSnapshot } from "../utils/scanSnapshot.js";
7
+ import { asArray, asRecord } from "../utils/unknownJson.js";
4
8
  import { errorResult, jsonResult } from "./toolResult.js";
9
+ /** スキャナーが返したパス(スナップショットのコピー)を元のファイルに戻す */
10
+ function restoreSourcePaths(raw, originals) {
11
+ for (const result of asArray(asRecord(raw)?.results)) {
12
+ const source = asRecord(asRecord(result)?.source);
13
+ if (source && typeof source.path === "string") {
14
+ const original = originals.get(source.path);
15
+ if (original !== undefined)
16
+ source.path = original;
17
+ }
18
+ }
19
+ return raw;
20
+ }
5
21
  export async function handleScanJavaArtifact(args, options = {}) {
6
22
  try {
7
- const { artifactPaths } = await detectJavaArtifacts(args.artifact_path, options);
8
- const raw = await runOsvArtifactScan(artifactPaths, options);
9
- return jsonResult(buildArtifactReport(raw, artifactPaths));
23
+ const { targetPath, artifactPaths } = await detectJavaArtifacts(args.artifact_path, options);
24
+ const boundary = (await stat(targetPath)).isDirectory() ? targetPath : path.dirname(targetPath);
25
+ // 元のJAR/WARは渡さず、安全に読んだ内容のコピーをスキャンする(検査後の差し替え対策)。
26
+ // コピーできなかったものはスキャンされず、同定不能として報告される
27
+ const snapshot = await ScanSnapshot.create();
28
+ try {
29
+ const originals = new Map();
30
+ for (const artifact of artifactPaths) {
31
+ const copy = await snapshot.copy(artifact, boundary);
32
+ if (copy.ok)
33
+ originals.set(copy.path, artifact);
34
+ }
35
+ const raw = originals.size > 0 ? await runOsvArtifactScan([...originals.keys()], options) : { results: [] };
36
+ return jsonResult(buildArtifactReport(restoreSourcePaths(raw, originals), artifactPaths));
37
+ }
38
+ finally {
39
+ await snapshot.cleanup();
40
+ }
10
41
  }
11
42
  catch (error) {
12
43
  return errorResult(error);
@@ -2,9 +2,12 @@
2
2
  * `scan_java_project`ツールのハンドラ。
3
3
  * レスポンス形式(成功/エラー)は`toolResult.ts`参照。
4
4
  */
5
+ import path from "node:path";
6
+ import { ScanToolError } from "../errors.js";
5
7
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
6
8
  import { sanitizeExternalText } from "../utils/externalText.js";
7
9
  import { detectJavaProject } from "../utils/projectDetector.js";
10
+ import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
8
11
  import { errorResult, jsonResult } from "./toolResult.js";
9
12
  const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定により、マニフェスト(pom.xml)からの推移的依存の解決を省略しています。" +
10
13
  "lockfile(gradle.lockfile)に記録された依存は対象ですが、pom.xmlに直接記載された依存の先にある推移的依存の脆弱性は含まれません。" +
@@ -32,17 +35,49 @@ export function skippedManifestsFields(skipped) {
32
35
  scope_warning: SKIPPED_MANIFESTS_WARNING,
33
36
  };
34
37
  }
38
+ /**
39
+ * 検出したマニフェストをスナップショットへコピーし、コピーをスキャンする(元のファイルは渡さない)。
40
+ * 応答に一時ディレクトリのパスを出さないよう、スキャナーのパスは元のファイルに戻す。
41
+ * スナップショットは成功・失敗とも削除する。全件除外ならエラー。
42
+ */
43
+ export async function scanJavaManifests(project, options) {
44
+ const snapshot = await ScanSnapshot.create();
45
+ try {
46
+ const { targets, skipped } = await snapshotManifests(snapshot, project.targets, {
47
+ projectDir: project.projectDir,
48
+ allowedRootReal: project.allowedRootReal,
49
+ });
50
+ const skippedPaths = new Set(skipped.map((s) => s.path));
51
+ const scanned = project.targets.filter((t) => !skippedPaths.has(t.path));
52
+ const relative = (file) => path.relative(project.projectDir, file);
53
+ const skippedRelative = skipped.map((s) => ({ path: relative(s.path), reason: s.reason }));
54
+ if (targets.length === 0) {
55
+ const details = skippedRelative.map((s) => `${s.path}: ${s.reason}`).join(" / ");
56
+ throw new ScanToolError(skipped.every((s) => s.kind === "outside_allowed_root") ? "path_outside_allowed_root" : "no_manifest_found", `スキャンできるマニフェストがありません(${details})`);
57
+ }
58
+ const originals = new Map(targets.map((copy, i) => [copy.path, scanned[i].path]));
59
+ const report = await runOsvScan(targets, options);
60
+ return {
61
+ manifests: scanned.map((t) => relative(t.path)),
62
+ skipped: skippedRelative,
63
+ report: { ...report, source_files: report.source_files.map((file) => originals.get(file) ?? file) },
64
+ };
65
+ }
66
+ finally {
67
+ await snapshot.cleanup();
68
+ }
69
+ }
35
70
  export async function handleScanJavaProject(args, options = {}) {
36
71
  try {
37
72
  const project = await detectJavaProject(args.project_path, {
38
73
  allowedRoot: options.allowedRoot,
39
74
  });
40
75
  const noRemoteResolution = isRemoteResolutionDisabled(options);
41
- const report = await runOsvScan(project.targets, { ...options, noRemoteResolution });
76
+ const { manifests, skipped, report } = await scanJavaManifests(project, { ...options, noRemoteResolution });
42
77
  return jsonResult({
43
78
  project_dir: project.projectDir,
44
- manifests: project.manifests,
45
- ...skippedManifestsFields(project.skipped),
79
+ manifests,
80
+ ...skippedManifestsFields(skipped),
46
81
  dependency_resolution: dependencyResolution(noRemoteResolution),
47
82
  ...report,
48
83
  });
@@ -4,14 +4,14 @@
4
4
  * 誤要約対策(JAR実体スキャンと同じ原則): スキャンできなかった依存を示すcoverageを件数より前に置き、
5
5
  * complete=falseの場合は「検出0件でも安全とは言えない」旨の警告を付ける。
6
6
  */
7
- import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
8
- import os from "node:os";
9
7
  import path from "node:path";
8
+ import { ScanToolError } from "../errors.js";
10
9
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
11
10
  import { parseOsvScanOutput } from "../osv/scanReport.js";
12
11
  import { sanitizeExternalText } from "../utils/externalText.js";
13
12
  import { detectProject } from "../utils/manifestDetector.js";
14
13
  import { normalizePypiName } from "../utils/requirementsFile.js";
14
+ import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
15
15
  import { dependencyResolution } from "./scanJavaProject.js";
16
16
  import { errorResult, jsonResult } from "./toolResult.js";
17
17
  const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定により、マニフェスト(pom.xml / requirements.txt)からの推移的依存の解決を省略しています。" +
@@ -88,30 +88,44 @@ function markLowerBounds(project, packages) {
88
88
  * requirements.txtは検証済みの正規化行だけを専用の一時ディレクトリに書いてスキャンする
89
89
  * (元ファイルの取り込み指定をosv-scannerにたどらせない)。成功・失敗とも削除する。
90
90
  */
91
- async function scanWithRequirementsCopies(project, options) {
92
- const copies = project.requirementsCopies.filter((copy) => copy.entries.length > 0);
93
- if (project.targets.length === 0 && copies.length === 0)
94
- return parseOsvScanOutput({ results: [] });
95
- const dir = copies.length > 0 ? await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-req-")) : null;
91
+ /**
92
+ * 元のファイルはosv-scannerに渡さず、スナップショット(scanSnapshot.ts)のコピーをスキャンする。
93
+ * lockfile・pom.xml(親POMの連鎖を含む)は安全に読んだ内容のコピー、requirements.txtは
94
+ * 検証済みの正規化行だけを書いたコピー。コピーできず外したファイルはskippedFilesに記録する。
95
+ */
96
+ async function scanFromSnapshot(project, options) {
97
+ const snapshot = await ScanSnapshot.create();
96
98
  try {
97
- const targets = [...project.targets];
98
- for (const [index, copy] of copies.entries()) {
99
- const copyPath = path.join(dir, `${index}.txt`);
100
- await writeFile(copyPath, `${copy.entries.join("\n")}\n`, { mode: 0o600, flag: "wx" });
101
- targets.push({ path: copyPath, format: "requirements.txt" });
99
+ const { targets, skipped } = await snapshotManifests(snapshot, project.targets, {
100
+ projectDir: project.projectDir,
101
+ allowedRootReal: project.allowedRootReal,
102
+ });
103
+ if (skipped.length > 0) {
104
+ const skippedPaths = new Set(skipped.map((s) => s.path));
105
+ project.manifests = project.manifests.filter((m) => !skippedPaths.has(path.join(project.projectDir, m.path)));
106
+ project.skippedFiles.push(...skipped.map((s) => ({ path: path.relative(project.projectDir, s.path), reason: s.reason })));
107
+ if (project.manifests.length === 0) {
108
+ throw new ScanToolError(skipped.every((s) => s.kind === "outside_allowed_root") ? "path_outside_allowed_root" : "no_manifest_found", `スキャンできるlockfile・マニフェストがありません(${project.skippedFiles.map((s) => `${s.path}: ${s.reason}`).join(" / ")})`);
109
+ }
110
+ }
111
+ for (const copy of project.requirementsCopies) {
112
+ if (copy.entries.length === 0)
113
+ continue;
114
+ targets.push({ path: await snapshot.writeGenerated(`${copy.entries.join("\n")}\n`), format: "requirements.txt" });
102
115
  }
116
+ if (targets.length === 0)
117
+ return parseOsvScanOutput({ results: [] });
103
118
  return await runOsvScan(targets, options);
104
119
  }
105
120
  finally {
106
- if (dir !== null)
107
- await rm(dir, { recursive: true, force: true });
121
+ await snapshot.cleanup();
108
122
  }
109
123
  }
110
124
  export async function handleScanProject(args, options = {}) {
111
125
  try {
112
126
  const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
113
127
  const noRemoteResolution = isRemoteResolutionDisabled(options);
114
- const report = await scanWithRequirementsCopies(project, { ...options, noRemoteResolution });
128
+ const report = await scanFromSnapshot(project, { ...options, noRemoteResolution });
115
129
  return jsonResult({
116
130
  project_dir: project.projectDir,
117
131
  dependency_resolution: dependencyResolution(noRemoteResolution, TRANSITIVE_OMITTED_WARNING),
@@ -3,10 +3,10 @@
3
3
  * scan_java_projectと同じスキャンを実行し、脆弱なパッケージごとの
4
4
  * 推奨アップグレードバージョン(3段階Tier)を返す。
5
5
  */
6
- import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
6
+ import { isRemoteResolutionDisabled } from "../osv/runner.js";
7
7
  import { suggestUpgrades } from "../osv/suggestFix.js";
8
8
  import { detectJavaProject } from "../utils/projectDetector.js";
9
- import { dependencyResolution, skippedManifestsFields, } from "./scanJavaProject.js";
9
+ import { dependencyResolution, scanJavaManifests, skippedManifestsFields, } from "./scanJavaProject.js";
10
10
  import { errorResult, jsonResult } from "./toolResult.js";
11
11
  export async function handleSuggestFix(args, options = {}) {
12
12
  try {
@@ -14,13 +14,13 @@ export async function handleSuggestFix(args, options = {}) {
14
14
  allowedRoot: options.allowedRoot,
15
15
  });
16
16
  const noRemoteResolution = isRemoteResolutionDisabled(options);
17
- const report = await runOsvScan(project.targets, { ...options, noRemoteResolution });
17
+ const { manifests, skipped, report } = await scanJavaManifests(project, { ...options, noRemoteResolution });
18
18
  const suggestions = suggestUpgrades(report.packages);
19
19
  const unfixedVulnerabilities = suggestions.reduce((sum, s) => sum + s.per_cve_detail.filter((d) => d.tier === "unfixed").length, 0);
20
20
  return jsonResult({
21
21
  project_dir: project.projectDir,
22
- manifests: project.manifests,
23
- ...skippedManifestsFields(project.skipped),
22
+ manifests,
23
+ ...skippedManifestsFields(skipped),
24
24
  dependency_resolution: dependencyResolution(noRemoteResolution),
25
25
  vulnerable_package_count: suggestions.length,
26
26
  unfixed_vulnerability_count: unfixedVulnerabilities,
@@ -7,12 +7,12 @@
7
7
  *
8
8
  * 検出・除外の根拠はdocs/DESIGN_TODO.md「対象エコシステム拡大 詳細設計メモ」を参照。
9
9
  */
10
- import { readFile, stat } from "node:fs/promises";
10
+ import { stat } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { ScanToolError } from "../errors.js";
13
- import { pomParentOutsideRoot } from "./pomParent.js";
14
13
  import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
15
- import { analyzeRequirementsFile, } from "./requirementsFile.js";
14
+ import { readRegularFile } from "./safeRead.js";
15
+ import { analyzeRequirementsFile, createRequirementsReadContext, } from "./requirementsFile.js";
16
16
  /** ファイル名→(エコシステム, 解析形式)。npm-shrinkwrap.jsonはpackage-lock.json形式で解析する */
17
17
  const LOCKFILES = new Map([
18
18
  ["pom.xml", { ecosystem: "Maven", format: "pom.xml" }],
@@ -73,26 +73,54 @@ function satisfiesGroup(manifest, group) {
73
73
  function isSameOrDescendant(dir, ancestor) {
74
74
  return ancestor === "." || dir === ancestor || dir.startsWith(`${ancestor}${path.sep}`);
75
75
  }
76
- /** workspaceの収録確認で読むlockfileの上限 */
76
+ /** workspaceの収録確認で読むlockfile1件の上限と、1回の検出での合計の上限 */
77
77
  const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024;
78
+ const MAX_LOCKFILE_TOTAL_BYTES = 256 * 1024 * 1024;
79
+ /**
80
+ * workspaceの収録確認用に、package-lock.jsonの`packages`のキー一覧を1回だけ読む。
81
+ * package.jsonの数だけ同じlockfileを解析し直すと、巨大なlockfileで負荷が増大するため、
82
+ * lockfileごとに結果を使い回し、読む量の合計にも上限を設ける。
83
+ */
84
+ export class LockfileKeyCache {
85
+ keys = new Map();
86
+ remaining;
87
+ constructor(maxTotalBytes = MAX_LOCKFILE_TOTAL_BYTES) {
88
+ this.remaining = maxTotalBytes;
89
+ }
90
+ /** 収録済みのキー。null=確認できない(v1形式・読めない・大きすぎる・上限超過) */
91
+ async get(lockAbs, projectDir) {
92
+ if (this.keys.has(lockAbs))
93
+ return this.keys.get(lockAbs);
94
+ let keys = null;
95
+ if (this.remaining > 0) {
96
+ const read = await readRegularFile(lockAbs, { maxBytes: Math.min(MAX_LOCKFILE_BYTES, this.remaining), boundary: projectDir });
97
+ if (read.ok) {
98
+ this.remaining -= read.bytes.length;
99
+ try {
100
+ const packages = JSON.parse(read.bytes.toString("utf8")).packages;
101
+ if (typeof packages === "object" && packages !== null)
102
+ keys = new Set(Object.keys(packages));
103
+ }
104
+ catch {
105
+ keys = null;
106
+ }
107
+ }
108
+ }
109
+ this.keys.set(lockAbs, keys);
110
+ return keys;
111
+ }
112
+ }
78
113
  /**
79
114
  * package-lock.json(v2以降)の`packages`に、memberDirのエントリがあるか。
80
- * true=収録 / false=未収録 / null=確認できない(v1形式・読めない・大きすぎる)
115
+ * true=収録 / false=未収録 / null=確認できない
81
116
  */
82
- async function npmLockfileRecords(projectDir, lockfile, memberDir) {
117
+ async function npmLockfileRecords(cache, projectDir, lockfile, memberDir) {
83
118
  const lockAbs = path.join(projectDir, lockfile.path);
84
- try {
85
- if ((await stat(lockAbs)).size > MAX_LOCKFILE_BYTES)
86
- return null;
87
- const packages = JSON.parse(await readFile(lockAbs, "utf8")).packages;
88
- if (typeof packages !== "object" || packages === null)
89
- return null;
90
- const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
91
- return Object.hasOwn(packages, key);
92
- }
93
- catch {
119
+ const keys = await cache.get(lockAbs, projectDir);
120
+ if (keys === null)
94
121
  return null;
95
- }
122
+ const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
123
+ return keys.has(key);
96
124
  }
97
125
  /**
98
126
  * lockfileの無いマーカー(package.json等)を判定する。
@@ -101,6 +129,7 @@ async function npmLockfileRecords(projectDir, lockfile, memberDir) {
101
129
  * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
102
130
  */
103
131
  async function findLockfileMissing(projectDir, markers, manifests) {
132
+ const cache = new LockfileKeyCache();
104
133
  const missing = [];
105
134
  for (const marker of markers) {
106
135
  const markerDir = path.dirname(marker.path);
@@ -116,7 +145,7 @@ async function findLockfileMissing(projectDir, markers, manifests) {
116
145
  let recorded = false;
117
146
  for (const candidate of candidates) {
118
147
  const result = candidate.format === "package-lock.json"
119
- ? await npmLockfileRecords(projectDir, candidate, markerDir)
148
+ ? await npmLockfileRecords(cache, projectDir, candidate, markerDir)
120
149
  : null;
121
150
  if (result === true)
122
151
  recorded = true;
@@ -139,6 +168,7 @@ async function findLockfileMissing(projectDir, markers, manifests) {
139
168
  async function finalize(projectDir, manifests, markers, allowedRootReal) {
140
169
  const result = {
141
170
  projectDir,
171
+ allowedRootReal,
142
172
  manifests: [],
143
173
  targets: [],
144
174
  requirementsCopies: [],
@@ -148,10 +178,11 @@ async function finalize(projectDir, manifests, markers, allowedRootReal) {
148
178
  lowerBounds: [],
149
179
  skippedFiles: [],
150
180
  };
181
+ const readContext = createRequirementsReadContext();
151
182
  for (const manifest of manifests) {
152
183
  const absolute = path.join(projectDir, manifest.path);
153
184
  if (manifest.format === "requirements.txt") {
154
- const analysis = await analyzeRequirementsFile(absolute, projectDir);
185
+ const analysis = await analyzeRequirementsFile(absolute, projectDir, readContext);
155
186
  if (!analysis.ok) {
156
187
  result.skippedFiles.push({ path: manifest.path, reason: analysis.reason });
157
188
  continue;
@@ -162,11 +193,6 @@ async function finalize(projectDir, manifests, markers, allowedRootReal) {
162
193
  result.requirementsCopies.push({ path: manifest.path, entries: analysis.entries });
163
194
  }
164
195
  else {
165
- const outside = manifest.format === "pom.xml" ? await pomParentOutsideRoot(absolute, allowedRootReal) : null;
166
- if (outside !== null) {
167
- result.skippedFiles.push({ path: manifest.path, reason: outside });
168
- continue;
169
- }
170
196
  result.targets.push({ path: absolute, format: manifest.format });
171
197
  }
172
198
  result.manifests.push(manifest);
@@ -1,21 +1,15 @@
1
1
  /**
2
- * pom.xmlの親POM(`<parent><relativePath>`)が、スキャン許可ルート(OSV_MCP_ALLOWED_ROOT)の外を
3
- * 参照していないかを検証する。
2
+ * pom.xmlの親POM(`<parent><relativePath>`)の参照の解釈。連鎖のコピーと検証はscanSnapshot.tsが行う。
4
3
  *
5
4
  * osv-scanner v2.4.0の実機確認(2026-10-07):
6
5
  * - `<relativePath>`の指す親POMを読み、その依存を結果に含める(`--no-resolve`でも同じ)。親の親もたどる
7
6
  * - `<relativePath>`省略時はMavenの既定値`../pom.xml`を参照する。ディレクトリを指す場合はその中のpom.xml
8
7
  * - `<relativePath/>`(空)はローカルを参照しない。親のGAVが一致しない場合も読まない
9
8
  *
10
- * サブモジュールのスキャンで親POMを読むのは正当な動作のため、境界はプロジェクトディレクトリではなく
11
- * 許可ルートとする(未設定時は任意のパスをスキャンできる状態のため検証しない)。GAVの一致は判定が
12
- * 複雑(親から引き継がれる)なため確認せず、許可ルートの外に参照先のファイルがあれば安全側に除外する。
9
+ * スナップショット方式では、osv-scannerが読めるのはコピーしたファイルだけのため、ここでの解釈が
10
+ * osv-scannerとずれても範囲外は読まれない(最悪でも親が見つからない)。解釈できない場合の除外は、
11
+ * 親から引き継ぐ依存を黙って欠落させないためのもの。
13
12
  */
14
- import { readFile, realpath, stat } from "node:fs/promises";
15
- import path from "node:path";
16
- import { isInsideDir } from "./projectWalk.js";
17
- const MAX_POM_BYTES = 10 * 1024 * 1024;
18
- const MAX_PARENT_DEPTH = 10;
19
13
  const DEFAULT_RELATIVE_PATH = "../pom.xml";
20
14
  const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", apos: "'", quot: '"' };
21
15
  /** XMLの文字参照・定義済み実体参照を展開する。未知の実体はundefined */
@@ -144,7 +138,7 @@ export function parentRelativePath(source) {
144
138
  return trimmed;
145
139
  }
146
140
  /** UTF-8として厳密に読む。不正なバイト列・NULを含む場合はnull(Goと同じ解釈を保証できない) */
147
- function decodePomBytes(bytes) {
141
+ export function decodePomBytes(bytes) {
148
142
  let text;
149
143
  try {
150
144
  text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
@@ -154,53 +148,3 @@ function decodePomBytes(bytes) {
154
148
  }
155
149
  return text.includes("\u0000") ? null : text;
156
150
  }
157
- /** 参照先を実ファイルに解決する。存在しなければnull(osv-scannerもローカルからは読まない) */
158
- async function resolveParentFile(fromPom, relativePath) {
159
- let candidate = path.resolve(path.dirname(fromPom), relativePath);
160
- try {
161
- if ((await stat(candidate)).isDirectory())
162
- candidate = path.join(candidate, "pom.xml");
163
- return await realpath(candidate);
164
- }
165
- catch {
166
- return null;
167
- }
168
- }
169
- /**
170
- * 親POMの連鎖が許可ルートの外を参照していれば、除外理由を返す。問題なければnull。
171
- * @param pomPath 検出済みのpom.xmlの絶対パス
172
- * @param allowedRootReal 解決済みの許可ルート。未設定なら検証しない
173
- */
174
- export async function pomParentOutsideRoot(pomPath, allowedRootReal) {
175
- if (allowedRootReal === undefined)
176
- return null;
177
- let current = pomPath;
178
- for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) {
179
- let bytes;
180
- try {
181
- if ((await stat(current)).size > MAX_POM_BYTES)
182
- return `親POMの確認でサイズ上限を超えるpom.xmlがあります(${path.basename(current)})`;
183
- bytes = await readFile(current);
184
- }
185
- catch {
186
- return null; // 読めないファイルはosv-scannerも読めない
187
- }
188
- const xml = decodePomBytes(bytes);
189
- const relativePath = xml === null ? undefined : parentRelativePath(xml);
190
- if (relativePath === null || relativePath === "")
191
- return null;
192
- if (relativePath === undefined) {
193
- return ("親POMの指定を確実に解釈できないため(親要素の重複、CDATA・DOCTYPE、プロパティ参照、UTF-8以外の文字コード等)、" +
194
- "許可ルート内か確認できずスキャン対象から外しました");
195
- }
196
- const parentFile = await resolveParentFile(current, relativePath);
197
- if (parentFile === null)
198
- return null;
199
- if (!isInsideDir(allowedRootReal, parentFile)) {
200
- return (`親POM(relativePath: ${relativePath})が許可ルート(OSV_MCP_ALLOWED_ROOT)の外を参照しているため、スキャン対象から外しました` +
201
- "(親のGAVが一致しなければosv-scannerは読みませんが、境界の外のため確認せず除外します)");
202
- }
203
- current = parentFile;
204
- }
205
- return `親POMの連鎖が上限(${MAX_PARENT_DEPTH}段)を超えています`;
206
- }
@@ -18,7 +18,6 @@
18
18
  import { stat } from "node:fs/promises";
19
19
  import path from "node:path";
20
20
  import { ScanToolError } from "../errors.js";
21
- import { pomParentOutsideRoot } from "./pomParent.js";
22
21
  import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
23
22
  /** ビルド成果物・VCS等、マニフェスト探索でスキップするディレクトリ */
24
23
  const SKIPPED_DIRS = new Set([".git", "node_modules", "target", "build", ".idea", ".vscode"]);
@@ -39,27 +38,12 @@ const GRADLE_LOCKFILE_GUIDANCE = "Gradleプロジェクトを検出しました
39
38
  "本ツールはlockfile方式のみ対応しています(ビルド実行方式はbuild.gradleの任意コード実行を伴うため非対応)。" +
40
39
  "`./gradlew dependencies --write-locks` でlockfileを生成してから再実行してください" +
41
40
  "(依存ロックが未設定の場合は build.gradle に dependencyLocking { lockAllConfigurations() } の追加が必要です)";
42
- /** 親POMが許可ルートの外を参照するpom.xmlを除外して結果を組み立てる。全件除外ならエラー */
43
- async function buildResult(projectDir, detected, allowedRootReal) {
44
- const manifests = [];
45
- const skipped = [];
46
- for (const manifest of detected) {
47
- const reason = path.basename(manifest) === "pom.xml"
48
- ? await pomParentOutsideRoot(path.join(projectDir, manifest), allowedRootReal)
49
- : null;
50
- if (reason !== null)
51
- skipped.push({ path: manifest, reason });
52
- else
53
- manifests.push(manifest);
54
- }
55
- if (manifests.length === 0) {
56
- throw new ScanToolError("path_outside_allowed_root", `スキャンできるマニフェストがありません(${skipped.map((s) => `${s.path}: ${s.reason}`).join(" / ")})`);
57
- }
41
+ function buildResult(projectDir, manifests, allowedRootReal) {
58
42
  const targets = manifests.map((manifest) => ({
59
43
  path: path.join(projectDir, manifest),
60
44
  format: path.basename(manifest),
61
45
  }));
62
- return { projectDir, manifests, targets, skipped };
46
+ return { projectDir, manifests, targets, allowedRootReal };
63
47
  }
64
48
  /**
65
49
  * 入力パスを検証し、スキャン対象のJavaプロジェクトとして解決する。
@@ -16,9 +16,10 @@
16
16
  * 外・存在しない・URL・上限超過の取り込み、制約ファイル(`-c`)、解釈できない行は、
17
17
  * スキャンされない参照として報告する(黙って無視しない)。
18
18
  */
19
- import { readFile, realpath, stat } from "node:fs/promises";
19
+ import { realpath } from "node:fs/promises";
20
20
  import path from "node:path";
21
21
  import { isInsideDir } from "./projectWalk.js";
22
+ import { readRegularFile } from "./safeRead.js";
22
23
  const MAX_FILE_BYTES = 1024 * 1024;
23
24
  const MAX_INCLUDE_DEPTH = 5;
24
25
  const MAX_FILES = 50;
@@ -109,7 +110,7 @@ function classifyRequirement(file, line, content, out) {
109
110
  }
110
111
  out.issues.push({ file, line, name, specifier, kind: "range" });
111
112
  }
112
- async function include(file, line, content, target, projectDir, depth, out) {
113
+ async function include(file, line, content, target, projectDir, depth, out, context) {
113
114
  const reference = (reason) => {
114
115
  out.references.push({ file, line, text: content, reason });
115
116
  };
@@ -133,7 +134,7 @@ async function include(file, line, content, target, projectDir, depth, out) {
133
134
  return reference(`取り込みファイルが上限(${MAX_FILES}件)を超えています`);
134
135
  let failure;
135
136
  try {
136
- failure = await analyze(resolved, projectDir, depth + 1, out);
137
+ failure = await analyze(resolved, projectDir, depth + 1, out, context);
137
138
  }
138
139
  catch {
139
140
  failure = "取り込み先を読み込めません";
@@ -141,19 +142,47 @@ async function include(file, line, content, target, projectDir, depth, out) {
141
142
  if (failure !== null)
142
143
  reference(failure);
143
144
  }
145
+ /** 1回のスキャンで読むrequirements.txt(取り込み先を含む)の合計の上限 */
146
+ const DEFAULT_TOTAL_BYTES = 64 * 1024 * 1024;
147
+ export function createRequirementsReadContext(maxTotalBytes = DEFAULT_TOTAL_BYTES) {
148
+ return { remainingBytes: maxTotalBytes, texts: new Map() };
149
+ }
150
+ /** 安全に読む(FIFOで止まらず、差し替え・範囲外を検出する)。同じファイルは1回だけ読む */
151
+ async function readText(file, projectDir, context) {
152
+ const cached = context.texts.get(file);
153
+ if (cached !== undefined)
154
+ return { ok: true, text: cached };
155
+ const budgetLimited = context.remainingBytes < MAX_FILE_BYTES;
156
+ const result = await readRegularFile(file, {
157
+ maxBytes: Math.min(MAX_FILE_BYTES, context.remainingBytes),
158
+ boundary: projectDir,
159
+ });
160
+ if (!result.ok) {
161
+ if (result.failure === "too_large") {
162
+ return {
163
+ ok: false,
164
+ reason: budgetLimited
165
+ ? "requirements.txtの解析量の合計が上限を超えたため読みませんでした"
166
+ : `サイズが上限(${MAX_FILE_BYTES}バイト)を超えています`,
167
+ };
168
+ }
169
+ return { ok: false, reason: result.message };
170
+ }
171
+ context.remainingBytes -= result.bytes.length;
172
+ const text = result.bytes.toString("utf8");
173
+ context.texts.set(file, text);
174
+ return { ok: true, text };
175
+ }
144
176
  /** ファイルを解析してoutに追加する。読めない・大きすぎる場合は理由を返す */
145
- async function analyze(file, projectDir, depth, out) {
177
+ async function analyze(file, projectDir, depth, out, context) {
146
178
  out.visited.add(file);
147
- const info = await stat(file);
148
- if (!info.isFile())
149
- return "通常ファイルではありません";
150
- if (info.size > MAX_FILE_BYTES)
151
- return `サイズが上限(${MAX_FILE_BYTES}バイト)を超えています`;
152
- const text = await readFile(file, "utf8");
153
- for (const { line, content } of logicalLines(text)) {
179
+ const read = await readText(file, projectDir, context);
180
+ if (!read.ok)
181
+ return read.reason;
182
+ for (const { line, content } of logicalLines(read.text)) {
154
183
  const requirementInclude = INCLUDE_REQUIREMENT.exec(content);
155
184
  if (requirementInclude) {
156
- await include(file, line, content, requirementInclude[1], projectDir, depth, out);
185
+ await include(file, line, content, requirementInclude[1], projectDir, depth, out, context);
157
186
  continue;
158
187
  }
159
188
  if (INCLUDE_CONSTRAINT.test(content)) {
@@ -179,11 +208,11 @@ async function analyze(file, projectDir, depth, out) {
179
208
  * @param file 検出済みの絶対パス
180
209
  * @param projectDir 取り込みを展開する範囲(解決済みの絶対パス)
181
210
  */
182
- export async function analyzeRequirementsFile(file, projectDir) {
211
+ export async function analyzeRequirementsFile(file, projectDir, context = createRequirementsReadContext()) {
183
212
  const out = { entries: [], issues: [], references: [], lowerBounds: [], visited: new Set() };
184
213
  let reason;
185
214
  try {
186
- reason = await analyze(await realpath(file), projectDir, 0, out);
215
+ reason = await analyze(await realpath(file), projectDir, 0, out, context);
187
216
  }
188
217
  catch {
189
218
  reason = "読み込めません";
@@ -0,0 +1,144 @@
1
+ /**
2
+ * スキャン対象ファイルの安全な読み込み(検査と読み込みの不一致を起こさないための共通部品)。
3
+ *
4
+ * - `O_NOFOLLOW`で開き、末尾がシンボリックリンクに差し替えられたファイルを読まない
5
+ * - `O_NONBLOCK`で開き、名前付きパイプ(FIFO)等で処理が止まらないようにする。開いた実体を
6
+ * `fstat`し、通常のファイル以外は読まない
7
+ * - サイズの上限付きで読む
8
+ * - 読み終えた後、パスを解決し直して境界の内側にあることと、開いた実体と同じファイル
9
+ * (デバイス番号・inodeが一致)であることを確認する。途中のディレクトリをシンボリックリンクに
10
+ * 差し替えて範囲外を読ませ、確認前に戻す、といった差し替えを検出する
11
+ *
12
+ * 呼び出し側は読んだ内容(またはそのコピー)だけを使い、元のパスを他のプロセスに渡さない。
13
+ * (ハードリンクはパスとしては境界の内側にあるため区別しない)
14
+ */
15
+ import { constants } from "node:fs";
16
+ import { open, realpath, rm, stat } from "node:fs/promises";
17
+ import { isInsideDir } from "./projectWalk.js";
18
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
19
+ const MESSAGES = {
20
+ not_found: "ファイルを開けません",
21
+ not_regular: "通常のファイルではありません(名前付きパイプ・デバイス等)",
22
+ too_large: "サイズが上限を超えています",
23
+ outside: "スキャン範囲の外のファイルです",
24
+ changed: "検査中にファイルが差し替えられました",
25
+ };
26
+ function failure(kind) {
27
+ return { ok: false, failure: kind, message: MESSAGES[kind] };
28
+ }
29
+ /**
30
+ * 読み終えた後の確認: パスを解決し直し、境界の内側で、開いた実体と同じファイルか。
31
+ * 問題が無ければnull、あれば失敗の種類を返す。
32
+ */
33
+ export async function verifyOpenedFile(filePath, opened, boundary) {
34
+ let resolved;
35
+ let current;
36
+ try {
37
+ resolved = await realpath(filePath);
38
+ current = await stat(resolved);
39
+ }
40
+ catch {
41
+ return "changed";
42
+ }
43
+ if (current.dev !== opened.dev || current.ino !== opened.ino)
44
+ return "changed";
45
+ if (boundary !== undefined && !isInsideDir(boundary, resolved))
46
+ return "outside";
47
+ return null;
48
+ }
49
+ /**
50
+ * 通常のファイルを上限付きで安全にdestへコピーする(大きなJAR/WAR向けに全体をメモリに載せない)。
51
+ * destは新規作成(既存なら失敗)・所有者のみ読み書き可。失敗時はdestを削除する。
52
+ * 成功時はコピーしたバイト数を返す。
53
+ */
54
+ export async function copyRegularFile(filePath, dest, options) {
55
+ let source;
56
+ try {
57
+ source = await open(filePath, OPEN_FLAGS);
58
+ }
59
+ catch {
60
+ return failure("not_found");
61
+ }
62
+ let total = 0;
63
+ let result = null;
64
+ try {
65
+ const info = await source.stat();
66
+ if (!info.isFile())
67
+ result = "not_regular";
68
+ else if (info.size > options.maxBytes)
69
+ result = "too_large";
70
+ else {
71
+ const target = await open(dest, "wx", 0o600);
72
+ try {
73
+ const chunk = Buffer.alloc(1024 * 1024);
74
+ while (true) {
75
+ const { bytesRead } = await source.read(chunk, 0, chunk.length, null);
76
+ if (bytesRead === 0)
77
+ break;
78
+ total += bytesRead;
79
+ if (total > options.maxBytes) {
80
+ result = "too_large";
81
+ break;
82
+ }
83
+ await target.write(chunk, 0, bytesRead);
84
+ }
85
+ }
86
+ finally {
87
+ await target.close();
88
+ }
89
+ if (result === null)
90
+ result = await verifyOpenedFile(filePath, info, options.boundary);
91
+ }
92
+ }
93
+ catch {
94
+ result = "not_found";
95
+ }
96
+ finally {
97
+ await source.close();
98
+ }
99
+ if (result !== null) {
100
+ await rm(dest, { force: true });
101
+ return failure(result);
102
+ }
103
+ return { ok: true, bytes: total };
104
+ }
105
+ /** 通常のファイルを上限付きで安全に読む */
106
+ export async function readRegularFile(filePath, options) {
107
+ let handle;
108
+ try {
109
+ handle = await open(filePath, OPEN_FLAGS);
110
+ }
111
+ catch {
112
+ return failure("not_found");
113
+ }
114
+ let info;
115
+ const chunks = [];
116
+ let total = 0;
117
+ try {
118
+ info = await handle.stat();
119
+ if (!info.isFile())
120
+ return failure("not_regular");
121
+ if (info.size > options.maxBytes)
122
+ return failure("too_large");
123
+ const chunk = Buffer.alloc(Math.min(1024 * 1024, options.maxBytes + 1));
124
+ while (true) {
125
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, options.maxBytes - total + 1), null);
126
+ if (bytesRead === 0)
127
+ break;
128
+ total += bytesRead;
129
+ if (total > options.maxBytes)
130
+ return failure("too_large");
131
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
132
+ }
133
+ }
134
+ catch {
135
+ return failure("not_found");
136
+ }
137
+ finally {
138
+ await handle.close();
139
+ }
140
+ const problem = await verifyOpenedFile(filePath, info, options.boundary);
141
+ if (problem !== null)
142
+ return failure(problem);
143
+ return { ok: true, bytes: Buffer.concat(chunks, total) };
144
+ }
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
3
3
  import { open, realpath } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { ScanToolError } from "../errors.js";
6
+ import { verifyOpenedFile } from "./safeRead.js";
6
7
  import { asRecord } from "./unknownJson.js";
7
8
  const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024;
8
9
  const CYCLONEDX_VERSIONS = new Set(["1.4", "1.5", "1.6"]);
@@ -33,8 +34,9 @@ export async function loadSbom(inputPath, options = {}) {
33
34
  try {
34
35
  // Nonblocking open avoids hanging on FIFOs. Refuse a leaf replaced by a symlink.
35
36
  const file = await open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
37
+ let info;
36
38
  try {
37
- const info = await file.stat();
39
+ info = await file.stat();
38
40
  if (!info.isFile())
39
41
  throw new ScanToolError("sbom_not_found", "SBOM input must be a regular file");
40
42
  if (info.size > maxBytes)
@@ -56,6 +58,13 @@ export async function loadSbom(inputPath, options = {}) {
56
58
  finally {
57
59
  await file.close();
58
60
  }
61
+ // 読んだ後に、差し替え(途中のディレクトリのシンボリックリンク化等)と境界を確認する
62
+ const allowedRootReal = options.allowedRoot !== undefined ? await realpath(options.allowedRoot) : undefined;
63
+ const problem = await verifyOpenedFile(sourcePath, info, allowedRootReal);
64
+ if (problem === "outside")
65
+ throw new ScanToolError("path_outside_allowed_root", "SBOM path is outside the allowed root");
66
+ if (problem !== null)
67
+ throw new ScanToolError("sbom_not_found", "The SBOM file was replaced while it was being read");
59
68
  }
60
69
  catch (error) {
61
70
  if (error instanceof ScanToolError)
@@ -0,0 +1,164 @@
1
+ /**
2
+ * スキャン対象のスナップショット: osv-scannerには元のファイルを渡さず、安全に1回だけ読んだ内容を
3
+ * 専用の一時ディレクトリ(所有者のみアクセス可、終了時に削除)へコピーしてスキャンする。
4
+ *
5
+ * - 検査(境界・親POMの検証)はコピーの内容に対して行うため、検査とosv-scannerの読み込みの間に
6
+ * 元のファイルを差し替えても結果に影響しない
7
+ * - 親POMは元の配置をスナップショット内に再現してコピーする。osv-scannerが相対パスで親をたどっても、
8
+ * 見つかるのは検証してコピーしたファイルだけになる(解析が多少ずれても範囲外は読まれず、
9
+ * 最悪でも親が見つからないだけ)
10
+ * - `..`を重ねた参照でスナップショットの外(本物のファイルシステム)に出る親POMは除外する
11
+ * - コピーの合計サイズに上限を設ける
12
+ */
13
+ import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { ScanToolError } from "../errors.js";
17
+ import { decodePomBytes, parentRelativePath } from "./pomParent.js";
18
+ import { isInsideDir } from "./projectWalk.js";
19
+ import { copyRegularFile } from "./safeRead.js";
20
+ const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
21
+ const MAX_POM_BYTES = 10 * 1024 * 1024;
22
+ const MAX_PARENT_DEPTH = 10;
23
+ export class ScanSnapshot {
24
+ dir;
25
+ maxTotalBytes;
26
+ copied = new Map();
27
+ parentCache = new Map();
28
+ used = 0;
29
+ generated = 0;
30
+ constructor(dir, maxTotalBytes) {
31
+ this.dir = dir;
32
+ this.maxTotalBytes = maxTotalBytes;
33
+ }
34
+ static async create(maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES) {
35
+ const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-snap-"));
36
+ return new ScanSnapshot(dir, maxTotalBytes);
37
+ }
38
+ /** 再現した配置のルート(実際のファイルシステムの"/"に相当) */
39
+ get treeRoot() {
40
+ return path.join(this.dir, "tree");
41
+ }
42
+ /** 元の絶対パスに対応する、スナップショット内のパス */
43
+ mirror(absolute) {
44
+ const { root } = path.parse(absolute);
45
+ return path.join(this.treeRoot, root.replace(/[^A-Za-z0-9]/g, "_"), path.relative(root, absolute));
46
+ }
47
+ /** 元のファイルを安全にコピーする。同じファイルは1回だけコピーする */
48
+ async copy(absolute, boundary, maxBytes = Infinity) {
49
+ const existing = this.copied.get(absolute);
50
+ if (existing !== undefined)
51
+ return { ok: true, path: existing };
52
+ const remaining = this.maxTotalBytes - this.used;
53
+ const dest = this.mirror(absolute);
54
+ await mkdir(path.dirname(dest), { recursive: true, mode: 0o700 });
55
+ const result = await copyRegularFile(absolute, dest, { maxBytes: Math.min(maxBytes, remaining), boundary });
56
+ if (!result.ok) {
57
+ if (result.failure === "too_large" && remaining < maxBytes) {
58
+ throw new ScanToolError("scan_input_too_large", `スキャン対象ファイルの合計サイズが上限(${this.maxTotalBytes}バイト)を超えるため、スキャンを中止しました。対象を絞って再実行してください`);
59
+ }
60
+ return result;
61
+ }
62
+ this.used += result.bytes;
63
+ this.copied.set(absolute, dest);
64
+ return { ok: true, path: dest };
65
+ }
66
+ /** 本サーバーが生成した内容(requirements.txtの正規化コピー等)を書く */
67
+ async writeGenerated(content) {
68
+ const dir = path.join(this.dir, "generated");
69
+ await mkdir(dir, { recursive: true, mode: 0o700 });
70
+ const file = path.join(dir, `${this.generated++}.txt`);
71
+ await writeFile(file, content, { mode: 0o600, flag: "wx" });
72
+ return file;
73
+ }
74
+ /** コピー済みのpom.xmlの親参照(同じファイルは1回だけ解析する) */
75
+ async parentOf(copyPath) {
76
+ if (this.parentCache.has(copyPath))
77
+ return this.parentCache.get(copyPath);
78
+ const xml = decodePomBytes(await readFile(copyPath));
79
+ const relativePath = xml === null ? undefined : parentRelativePath(xml);
80
+ this.parentCache.set(copyPath, relativePath);
81
+ return relativePath;
82
+ }
83
+ async cleanup() {
84
+ await rm(this.dir, { recursive: true, force: true });
85
+ }
86
+ }
87
+ const UNPARSEABLE = "親POMの指定を確実に解釈できないため(親要素の重複、CDATA・DOCTYPE、プロパティ参照、UTF-8以外の文字コード等)、" +
88
+ "許可ルート内か確認できずスキャン対象から外しました";
89
+ /**
90
+ * pom.xmlと親POMの連鎖をスナップショットへコピーする。
91
+ * 許可ルート設定時、連鎖が許可ルートの外・スナップショットの外を参照するか、解釈できない場合は除外理由を返す。
92
+ */
93
+ async function snapshotPom(snapshot, pomPath, context) {
94
+ const first = await snapshot.copy(pomPath, context.projectDir, MAX_POM_BYTES);
95
+ if (!first.ok)
96
+ return { ok: false, reason: first.message, kind: "unreadable" };
97
+ const restricted = context.allowedRootReal !== undefined;
98
+ let current = pomPath;
99
+ let currentCopy = first.path;
100
+ for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) {
101
+ const relativePath = await snapshot.parentOf(currentCopy);
102
+ if (relativePath === null || relativePath === "")
103
+ return first;
104
+ if (relativePath === undefined)
105
+ return restricted ? { ok: false, reason: UNPARSEABLE, kind: "unreadable" } : first;
106
+ // Goのfilepath.Joinと同じ規則で結合する(絶対パスも連結し、..はルートで止まる)
107
+ const inCopy = path.join(path.dirname(currentCopy), relativePath);
108
+ if (!isInsideDir(snapshot.treeRoot, inCopy)) {
109
+ // ..を重ねるとスナップショットの外(本物のファイルシステム)に届くため、コピーではなく元のファイルが読まれる
110
+ return restricted
111
+ ? { ok: false, reason: `親POM(relativePath: ${relativePath})の参照がスキャン範囲の外に出るため、スキャン対象から外しました`, kind: "outside_allowed_root" }
112
+ : first;
113
+ }
114
+ let candidate = path.join(path.dirname(current), relativePath);
115
+ try {
116
+ if ((await stat(candidate)).isDirectory())
117
+ candidate = path.join(candidate, "pom.xml");
118
+ }
119
+ catch {
120
+ return first; // 参照先が無い: スナップショット内にも無いため、osv-scannerは親を読まない
121
+ }
122
+ const parent = await snapshot.copy(candidate, context.allowedRootReal, MAX_POM_BYTES);
123
+ if (!parent.ok) {
124
+ if (parent.failure === "outside") {
125
+ return {
126
+ ok: false,
127
+ reason: `親POM(relativePath: ${relativePath})が許可ルート(OSV_MCP_ALLOWED_ROOT)の外を参照しているため、スキャン対象から外しました` +
128
+ "(親のGAVが一致しなければosv-scannerは読みませんが、境界の外のため確認せず除外します)",
129
+ kind: "outside_allowed_root",
130
+ };
131
+ }
132
+ if (parent.failure === "changed")
133
+ return { ok: false, reason: `親POMの${parent.message}`, kind: "unreadable" };
134
+ return first; // 読めない・通常のファイルでない等: コピーしないため、osv-scannerも読まない
135
+ }
136
+ current = candidate;
137
+ currentCopy = parent.path;
138
+ }
139
+ return restricted ? { ok: false, reason: `親POMの連鎖が上限(${MAX_PARENT_DEPTH}段)を超えています`, kind: "unreadable" } : first;
140
+ }
141
+ /**
142
+ * 検出済みのマニフェストをスナップショットへコピーし、osv-scannerに渡す対象(コピー)を返す。
143
+ * コピーできない・除外すべきものはskippedに理由付きで返す。
144
+ */
145
+ export async function snapshotManifests(snapshot, targets, context) {
146
+ const copies = [];
147
+ const skipped = [];
148
+ for (const target of targets) {
149
+ if (target.format === "pom.xml") {
150
+ const result = await snapshotPom(snapshot, target.path, context);
151
+ if (result.ok)
152
+ copies.push({ path: result.path, format: target.format });
153
+ else
154
+ skipped.push({ path: target.path, reason: result.reason, kind: result.kind });
155
+ continue;
156
+ }
157
+ const result = await snapshot.copy(target.path, context.projectDir);
158
+ if (result.ok)
159
+ copies.push({ path: result.path, format: target.format });
160
+ else
161
+ skipped.push({ path: target.path, reason: result.message, kind: "unreadable" });
162
+ }
163
+ return { targets: copies, skipped };
164
+ }
@@ -14,14 +14,22 @@ function isTruthy(value) {
14
14
  const normalized = value.trim().toLowerCase();
15
15
  return normalized === "1" || normalized === "true" || normalized === "yes";
16
16
  }
17
+ /**
18
+ * 許可ルートの設定値。空文字・空白のみは未設定(undefined)として扱う。
19
+ * 起動時の検証とツールへの受け渡しで判定がずれないよう、必ずこの関数で読む
20
+ * (空文字をそのまま渡すと`path.resolve("")`=起動ディレクトリが許可ルートになっていた)。
21
+ */
22
+ export function allowedRootFromEnv(env = process.env) {
23
+ const allowedRoot = env[ALLOWED_ROOT_ENV];
24
+ return allowedRoot !== undefined && allowedRoot.trim() !== "" ? allowedRoot : undefined;
25
+ }
17
26
  /**
18
27
  * 起動を拒否すべき設定不備があればエラーメッセージを返す。問題なければnull。
19
28
  */
20
29
  export function allowedRootStartupError(env = process.env) {
21
30
  if (!isTruthy(env[REQUIRE_ALLOWED_ROOT_ENV]))
22
31
  return null;
23
- const allowedRoot = env[ALLOWED_ROOT_ENV];
24
- if (allowedRoot !== undefined && allowedRoot.trim() !== "")
32
+ if (allowedRootFromEnv(env) !== undefined)
25
33
  return null;
26
34
  return (`${REQUIRE_ALLOWED_ROOT_ENV}が有効ですが、${ALLOWED_ROOT_ENV}が未設定のため起動を中止します。` +
27
35
  `スキャンを許可するルートディレクトリを${ALLOWED_ROOT_ENV}に設定してください`);
@@ -31,8 +39,7 @@ export function allowedRootStartupError(env = process.env) {
31
39
  * (許可ルート未設定=任意の絶対パスをスキャン可能な状態の可視化)
32
40
  */
33
41
  export function allowedRootStartupWarning(env = process.env) {
34
- const allowedRoot = env[ALLOWED_ROOT_ENV];
35
- if (allowedRoot !== undefined && allowedRoot.trim() !== "")
42
+ if (allowedRootFromEnv(env) !== undefined)
36
43
  return null;
37
44
  return (`${ALLOWED_ROOT_ENV}が未設定のため、任意の絶対パスをスキャンできる状態です。` +
38
45
  `プロジェクト置き場のルートを${ALLOWED_ROOT_ENV}に設定することを推奨します` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",