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.
- package/README.md +10 -6
- package/dist/index.js +7 -7
- package/dist/osv/scanReport.js +7 -3
- package/dist/osv/versionScheme.js +39 -0
- package/dist/tools/scanJavaArtifact.js +34 -3
- package/dist/tools/scanJavaProject.js +63 -9
- package/dist/tools/scanProject.js +33 -17
- package/dist/tools/suggestFix.js +5 -5
- package/dist/utils/manifestDetector.js +50 -24
- package/dist/utils/pomParent.js +5 -61
- package/dist/utils/projectDetector.js +2 -18
- package/dist/utils/requirementsFile.js +43 -14
- package/dist/utils/safeRead.js +150 -0
- package/dist/utils/sbomInput.js +10 -1
- package/dist/utils/scanSnapshot.js +209 -0
- package/dist/utils/semverVersion.js +69 -0
- package/dist/utils/startupConfig.js +11 -4
- package/package.json +1 -1
package/dist/utils/pomParent.js
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pom.xmlの親POM(`<parent><relativePath>`)
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
148
|
-
if (!
|
|
149
|
-
return
|
|
150
|
-
|
|
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
|
+
}
|
package/dist/utils/sbomInput.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|