osv-scanner-mcp 0.4.0 → 0.4.2

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.
@@ -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,150 @@
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
+ // writeは要求より少ないバイト数で戻りうるため、チャンクを書き切るまで繰り返す
84
+ for (let written = 0; written < bytesRead;) {
85
+ const { bytesWritten } = await target.write(chunk, written, bytesRead - written);
86
+ if (bytesWritten <= 0)
87
+ throw new Error("short write");
88
+ written += bytesWritten;
89
+ }
90
+ }
91
+ }
92
+ finally {
93
+ await target.close();
94
+ }
95
+ if (result === null)
96
+ result = await verifyOpenedFile(filePath, info, options.boundary);
97
+ }
98
+ }
99
+ catch {
100
+ result = "not_found";
101
+ }
102
+ finally {
103
+ await source.close();
104
+ }
105
+ if (result !== null) {
106
+ await rm(dest, { force: true });
107
+ return failure(result);
108
+ }
109
+ return { ok: true, bytes: total };
110
+ }
111
+ /** 通常のファイルを上限付きで安全に読む */
112
+ export async function readRegularFile(filePath, options) {
113
+ let handle;
114
+ try {
115
+ handle = await open(filePath, OPEN_FLAGS);
116
+ }
117
+ catch {
118
+ return failure("not_found");
119
+ }
120
+ let info;
121
+ const chunks = [];
122
+ let total = 0;
123
+ try {
124
+ info = await handle.stat();
125
+ if (!info.isFile())
126
+ return failure("not_regular");
127
+ if (info.size > options.maxBytes)
128
+ return failure("too_large");
129
+ const chunk = Buffer.alloc(Math.min(1024 * 1024, options.maxBytes + 1));
130
+ while (true) {
131
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, options.maxBytes - total + 1), null);
132
+ if (bytesRead === 0)
133
+ break;
134
+ total += bytesRead;
135
+ if (total > options.maxBytes)
136
+ return failure("too_large");
137
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
138
+ }
139
+ }
140
+ catch {
141
+ return failure("not_found");
142
+ }
143
+ finally {
144
+ await handle.close();
145
+ }
146
+ const problem = await verifyOpenedFile(filePath, info, options.boundary);
147
+ if (problem !== null)
148
+ return failure(problem);
149
+ return { ok: true, bytes: Buffer.concat(chunks, total) };
150
+ }
@@ -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,209 @@
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
+ /** 再現した配置のルートごとのディレクトリ → 元のルート("/"等) */
29
+ mirroredRoots = new Map();
30
+ used = 0;
31
+ generated = 0;
32
+ constructor(dir, maxTotalBytes) {
33
+ this.dir = dir;
34
+ this.maxTotalBytes = maxTotalBytes;
35
+ }
36
+ 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);
39
+ }
40
+ /** 再現した配置のルート(実際のファイルシステムの"/"に相当) */
41
+ get treeRoot() {
42
+ return path.join(this.dir, "tree");
43
+ }
44
+ /** 元の絶対パスに対応する、スナップショット内のパス */
45
+ mirror(absolute) {
46
+ const { root } = path.parse(absolute);
47
+ const mirroredRoot = path.join(this.treeRoot, root.replace(/[^A-Za-z0-9]/g, "_"));
48
+ this.mirroredRoots.set(mirroredRoot, root);
49
+ return path.join(mirroredRoot, path.relative(root, absolute));
50
+ }
51
+ /**
52
+ * スキャナーのメッセージ(stderr等)に含まれるコピーのパスを元のパスに戻す。
53
+ * 生成したコピー(requirements.txt)など元のファイルが無いものは一時ディレクトリと分かる表記にする
54
+ */
55
+ restorePaths(text) {
56
+ let restored = text;
57
+ for (const [mirroredRoot, root] of this.mirroredRoots)
58
+ restored = restored.split(mirroredRoot + path.sep).join(root);
59
+ return restored.split(this.dir).join("<一時コピー>");
60
+ }
61
+ /** スキャナーのエラーに一時ディレクトリのパスを出さない(応答のパスは常に元のファイルを指す) */
62
+ async guard(scan) {
63
+ try {
64
+ return await scan();
65
+ }
66
+ catch (error) {
67
+ if (!(error instanceof ScanToolError))
68
+ throw error;
69
+ throw new ScanToolError(error.kind, this.restorePaths(error.message), error.detail === undefined ? undefined : this.restorePaths(error.detail));
70
+ }
71
+ }
72
+ /** 元のファイルを安全にコピーする。同じファイルは1回だけコピーする */
73
+ async copy(absolute, boundary, maxBytes = Infinity) {
74
+ const existing = this.copied.get(absolute);
75
+ if (existing !== undefined)
76
+ return { ok: true, path: existing };
77
+ const remaining = this.maxTotalBytes - this.used;
78
+ const dest = this.mirror(absolute);
79
+ await mkdir(path.dirname(dest), { recursive: true, mode: 0o700 });
80
+ const result = await copyRegularFile(absolute, dest, { maxBytes: Math.min(maxBytes, remaining), boundary });
81
+ if (!result.ok) {
82
+ if (result.failure === "too_large" && remaining < maxBytes) {
83
+ throw new ScanToolError("scan_input_too_large", `スキャン対象ファイルの合計サイズが上限(${this.maxTotalBytes}バイト)を超えるため、スキャンを中止しました。対象を絞って再実行してください`);
84
+ }
85
+ return result;
86
+ }
87
+ this.used += result.bytes;
88
+ this.copied.set(absolute, dest);
89
+ return { ok: true, path: dest };
90
+ }
91
+ /** 本サーバーが生成した内容(requirements.txtの正規化コピー等)を書く */
92
+ async writeGenerated(content) {
93
+ const dir = path.join(this.dir, "generated");
94
+ await mkdir(dir, { recursive: true, mode: 0o700 });
95
+ const file = path.join(dir, `${this.generated++}.txt`);
96
+ await writeFile(file, content, { mode: 0o600, flag: "wx" });
97
+ return file;
98
+ }
99
+ /** コピー済みのpom.xmlの親参照(同じファイルは1回だけ解析する) */
100
+ async parentOf(copyPath) {
101
+ if (this.parentCache.has(copyPath))
102
+ return this.parentCache.get(copyPath);
103
+ const xml = decodePomBytes(await readFile(copyPath));
104
+ const relativePath = xml === null ? undefined : parentRelativePath(xml);
105
+ this.parentCache.set(copyPath, relativePath);
106
+ return relativePath;
107
+ }
108
+ async cleanup() {
109
+ await rm(this.dir, { recursive: true, force: true });
110
+ }
111
+ }
112
+ const UNPARSEABLE = "親POMの指定を確実に解釈できないため(親要素の重複、CDATA・DOCTYPE、プロパティ参照、UTF-8以外の文字コード等)、" +
113
+ "許可ルート内か確認できずスキャン対象から外しました";
114
+ /**
115
+ * pom.xmlと親POMの連鎖をスナップショットへコピーする。
116
+ * 許可ルート設定時、連鎖が許可ルートの外・スナップショットの外を参照するか、解釈できない場合は除外理由を返す。
117
+ * 存在する親POMを再現できなかった場合(サイズ超過・通常のファイルでない等)はスキャンを続けるが、
118
+ * 親から継承する依存が欠ける可能性があるためincompleteに理由を返す(黙って成功扱いにしない)。
119
+ */
120
+ async function snapshotPom(snapshot, pomPath, context) {
121
+ const copied = await snapshot.copy(pomPath, context.projectDir, MAX_POM_BYTES);
122
+ if (!copied.ok)
123
+ return { ok: false, reason: copied.message, kind: "unreadable" };
124
+ const first = { ok: true, path: copied.path };
125
+ const partial = (reason) => ({ ...first, incomplete: reason });
126
+ const restricted = context.allowedRootReal !== undefined;
127
+ let current = pomPath;
128
+ let currentCopy = first.path;
129
+ for (let depth = 0; depth < MAX_PARENT_DEPTH; depth++) {
130
+ const relativePath = await snapshot.parentOf(currentCopy);
131
+ if (relativePath === null || relativePath === "")
132
+ return first;
133
+ if (relativePath === undefined) {
134
+ return restricted
135
+ ? { ok: false, reason: UNPARSEABLE, kind: "unreadable" }
136
+ : partial("親POMの指定を確実に解釈できないため、親POMを含めずにスキャンしました。親から継承する依存が結果に含まれない可能性があります");
137
+ }
138
+ // Goのfilepath.Joinと同じ規則で結合する(絶対パスも連結し、..はルートで止まる)
139
+ const inCopy = path.join(path.dirname(currentCopy), relativePath);
140
+ if (!isInsideDir(snapshot.treeRoot, inCopy)) {
141
+ // ..を重ねるとスナップショットの外(本物のファイルシステム)に届くため、コピーではなく元のファイルが読まれる
142
+ return restricted
143
+ ? { ok: false, reason: `親POM(relativePath: ${relativePath})の参照がスキャン範囲の外に出るため、スキャン対象から外しました`, kind: "outside_allowed_root" }
144
+ : first;
145
+ }
146
+ let candidate = path.join(path.dirname(current), relativePath);
147
+ try {
148
+ if ((await stat(candidate)).isDirectory())
149
+ candidate = path.join(candidate, "pom.xml");
150
+ }
151
+ catch (error) {
152
+ // 参照先が無い: 元の配置でもosv-scannerは親を読まないため、欠落ではない
153
+ const code = error.code;
154
+ if (code === "ENOENT" || code === "ENOTDIR")
155
+ return first;
156
+ return partial(`親POM(relativePath: ${relativePath})を確認できないため、親POMを含めずにスキャンしました。親から継承する依存が結果に含まれない可能性があります`);
157
+ }
158
+ const parent = await snapshot.copy(candidate, context.allowedRootReal, MAX_POM_BYTES);
159
+ if (!parent.ok) {
160
+ if (parent.failure === "outside") {
161
+ return {
162
+ ok: false,
163
+ reason: `親POM(relativePath: ${relativePath})が許可ルート(OSV_MCP_ALLOWED_ROOT)の外を参照しているため、スキャン対象から外しました` +
164
+ "(親のGAVが一致しなければosv-scannerは読みませんが、境界の外のため確認せず除外します)",
165
+ kind: "outside_allowed_root",
166
+ };
167
+ }
168
+ if (parent.failure === "changed")
169
+ return { ok: false, reason: `親POMの${parent.message}`, kind: "unreadable" };
170
+ // 読めない・通常のファイルでない・サイズ超過等: コピーしないため、osv-scannerも読まない
171
+ return partial(`親POM(relativePath: ${relativePath})を読めないため(${parent.message})、親POMを含めずにスキャンしました。` +
172
+ "親から継承する依存が結果に含まれない可能性があります(親のGAVが一致しない場合はもともと読まれません)");
173
+ }
174
+ current = candidate;
175
+ currentCopy = parent.path;
176
+ }
177
+ return restricted
178
+ ? { ok: false, reason: `親POMの連鎖が上限(${MAX_PARENT_DEPTH}段)を超えています`, kind: "unreadable" }
179
+ : partial(`親POMの連鎖が上限(${MAX_PARENT_DEPTH}段)を超えるため、それより上の親POMを含めずにスキャンしました`);
180
+ }
181
+ /**
182
+ * 検出済みのマニフェストをスナップショットへコピーし、osv-scannerに渡す対象(コピー)を返す。
183
+ * コピーできない・除外すべきものはskippedに、親POMを再現できずスキャンしたものはincompleteに理由付きで返す。
184
+ */
185
+ export async function snapshotManifests(snapshot, targets, context) {
186
+ const copies = [];
187
+ const skipped = [];
188
+ const incomplete = [];
189
+ for (const target of targets) {
190
+ if (target.format === "pom.xml") {
191
+ const result = await snapshotPom(snapshot, target.path, context);
192
+ if (result.ok) {
193
+ copies.push({ path: result.path, format: target.format });
194
+ if (result.incomplete !== undefined)
195
+ incomplete.push({ path: target.path, reason: result.incomplete });
196
+ }
197
+ else {
198
+ skipped.push({ path: target.path, reason: result.reason, kind: result.kind });
199
+ }
200
+ continue;
201
+ }
202
+ const result = await snapshot.copy(target.path, context.projectDir);
203
+ if (result.ok)
204
+ copies.push({ path: result.path, format: target.format });
205
+ else
206
+ skipped.push({ path: target.path, reason: result.message, kind: "unreadable" });
207
+ }
208
+ return { targets: copies, skipped, incomplete };
209
+ }