osv-scanner-mcp 0.3.4 → 0.4.0

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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `scan_project`用の検出: Java / JavaScript / Python / Goのlockfileを列挙する。
3
+ *
4
+ * - 検出したファイルだけをosv-scannerに渡す(ディレクトリは渡さない)。解析形式も明示する
5
+ * - パッケージマネージャー・ビルドは実行しない。lockfileが無いマニフェストはcoverageに記録する
6
+ * - requirements.txtは取り込み先を検証し、プロジェクト外を読む場合はスキャン対象から外す
7
+ *
8
+ * 検出・除外の根拠はdocs/DESIGN_TODO.md「対象エコシステム拡大 詳細設計メモ」を参照。
9
+ */
10
+ import { readFile, stat } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { ScanToolError } from "../errors.js";
13
+ import { pomParentOutsideRoot } from "./pomParent.js";
14
+ import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
15
+ import { analyzeRequirementsFile, } from "./requirementsFile.js";
16
+ /** ファイル名→(エコシステム, 解析形式)。npm-shrinkwrap.jsonはpackage-lock.json形式で解析する */
17
+ const LOCKFILES = new Map([
18
+ ["pom.xml", { ecosystem: "Maven", format: "pom.xml" }],
19
+ ["gradle.lockfile", { ecosystem: "Maven", format: "gradle.lockfile" }],
20
+ ["buildscript-gradle.lockfile", { ecosystem: "Maven", format: "buildscript-gradle.lockfile" }],
21
+ ["package-lock.json", { ecosystem: "npm", format: "package-lock.json" }],
22
+ ["npm-shrinkwrap.json", { ecosystem: "npm", format: "package-lock.json" }],
23
+ ["yarn.lock", { ecosystem: "npm", format: "yarn.lock" }],
24
+ ["pnpm-lock.yaml", { ecosystem: "npm", format: "pnpm-lock.yaml" }],
25
+ ["bun.lock", { ecosystem: "npm", format: "bun.lock" }],
26
+ ["poetry.lock", { ecosystem: "PyPI", format: "poetry.lock" }],
27
+ ["uv.lock", { ecosystem: "PyPI", format: "uv.lock" }],
28
+ ["Pipfile.lock", { ecosystem: "PyPI", format: "Pipfile.lock" }],
29
+ ["pdm.lock", { ecosystem: "PyPI", format: "pdm.lock" }],
30
+ ["go.mod", { ecosystem: "Go", format: "go.mod" }],
31
+ ]);
32
+ /** requirements.txt / requirements-dev.txt / dev-requirements.txt 等 */
33
+ const REQUIREMENTS_NAME = /^(?:[\w.]+[-_])?requirements(?:[-_][\w.]+)?\.txt$/;
34
+ /** lockfileが無ければスキャンできないマニフェスト */
35
+ const LOCK_MARKERS = new Map([
36
+ ["package.json", "npm"],
37
+ ["pyproject.toml", "pypi"],
38
+ ["Pipfile", "pypi"],
39
+ ["setup.py", "pypi"],
40
+ ["setup.cfg", "pypi"],
41
+ ["build.gradle", "gradle"],
42
+ ["build.gradle.kts", "gradle"],
43
+ ["settings.gradle", "gradle"],
44
+ ["settings.gradle.kts", "gradle"],
45
+ ]);
46
+ const LOCK_GROUP_ECOSYSTEM = { npm: "npm", pypi: "PyPI", gradle: "Maven" };
47
+ const LOCKFILE_HINTS = {
48
+ npm: "lockfileがありません。信頼できる環境で `npm install --package-lock-only --ignore-scripts`" +
49
+ "(yarn / pnpm / bunの場合は各ツールのlockfile生成)を実行してから再スキャンしてください",
50
+ pypi: "lockfile(poetry.lock / uv.lock / Pipfile.lock / pdm.lock)またはバージョンを固定したrequirements.txtがありません。" +
51
+ "信頼できる環境で生成してから再スキャンしてください(生成時にビルドスクリプトが実行される場合があります)",
52
+ gradle: "gradle.lockfileがありません。信頼できる環境で `./gradlew dependencies --write-locks` を実行してから再スキャンしてください" +
53
+ "(依存ロック未設定の場合は build.gradle に dependencyLocking { lockAllConfigurations() } の追加が必要です)",
54
+ };
55
+ const SKIPPED_DIRS = new Set([
56
+ ".git", "node_modules", "target", "build", ".idea", ".vscode",
57
+ ".venv", "venv", "site-packages", "__pycache__", ".tox", "vendor",
58
+ ]);
59
+ function classifyFile(name) {
60
+ const lockfile = LOCKFILES.get(name);
61
+ if (lockfile)
62
+ return lockfile;
63
+ if (REQUIREMENTS_NAME.test(name))
64
+ return { ecosystem: "PyPI", format: "requirements.txt" };
65
+ return null;
66
+ }
67
+ function satisfiesGroup(manifest, group) {
68
+ if (group === "gradle")
69
+ return manifest.format === "gradle.lockfile" || manifest.format === "buildscript-gradle.lockfile";
70
+ return manifest.ecosystem === LOCK_GROUP_ECOSYSTEM[group];
71
+ }
72
+ /** dirがancestor自身またはその配下か(projectDirからの相対パス同士で判定) */
73
+ function isSameOrDescendant(dir, ancestor) {
74
+ return ancestor === "." || dir === ancestor || dir.startsWith(`${ancestor}${path.sep}`);
75
+ }
76
+ /** workspaceの収録確認で読むlockfileの上限 */
77
+ const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024;
78
+ /**
79
+ * package-lock.json(v2以降)の`packages`に、memberDirのエントリがあるか。
80
+ * true=収録 / false=未収録 / null=確認できない(v1形式・読めない・大きすぎる)
81
+ */
82
+ async function npmLockfileRecords(projectDir, lockfile, memberDir) {
83
+ 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 {
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * lockfileの無いマーカー(package.json等)を判定する。
99
+ * - 同じディレクトリに同じエコシステムのlockfileがあれば充足
100
+ * - 上位のlockfileだけの場合は、収録を確認できたときだけ充足(npm workspacesのpackage-lock.json)。
101
+ * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
102
+ */
103
+ async function findLockfileMissing(projectDir, markers, manifests) {
104
+ const missing = [];
105
+ for (const marker of markers) {
106
+ const markerDir = path.dirname(marker.path);
107
+ const candidates = manifests.filter((m) => satisfiesGroup(m, marker.group) && isSameOrDescendant(markerDir, path.dirname(m.path)));
108
+ if (candidates.some((m) => path.dirname(m.path) === markerDir))
109
+ continue;
110
+ const base = { path: marker.path, ecosystem: LOCK_GROUP_ECOSYSTEM[marker.group] };
111
+ if (candidates.length === 0) {
112
+ missing.push({ ...base, status: "missing", hint: LOCKFILE_HINTS[marker.group] });
113
+ continue;
114
+ }
115
+ let confirmedAbsent = true;
116
+ let recorded = false;
117
+ for (const candidate of candidates) {
118
+ const result = candidate.format === "package-lock.json"
119
+ ? await npmLockfileRecords(projectDir, candidate, markerDir)
120
+ : null;
121
+ if (result === true)
122
+ recorded = true;
123
+ if (result !== false)
124
+ confirmedAbsent = false;
125
+ }
126
+ if (recorded)
127
+ continue;
128
+ const lockfiles = candidates.map((m) => m.path).join(", ");
129
+ missing.push(confirmedAbsent
130
+ ? { ...base, status: "missing", hint: `上位のlockfile(${lockfiles})には収録されていません。${LOCKFILE_HINTS[marker.group]}` }
131
+ : {
132
+ ...base,
133
+ status: "unconfirmed",
134
+ hint: `上位のlockfile(${lockfiles})に収録されているか確認できません。workspaceのメンバーでない場合は、${LOCKFILE_HINTS[marker.group]}`,
135
+ });
136
+ }
137
+ return missing;
138
+ }
139
+ async function finalize(projectDir, manifests, markers, allowedRootReal) {
140
+ const result = {
141
+ projectDir,
142
+ manifests: [],
143
+ targets: [],
144
+ requirementsCopies: [],
145
+ lockfileMissing: await findLockfileMissing(projectDir, markers, manifests),
146
+ requirementIssues: [],
147
+ requirementReferences: [],
148
+ lowerBounds: [],
149
+ skippedFiles: [],
150
+ };
151
+ for (const manifest of manifests) {
152
+ const absolute = path.join(projectDir, manifest.path);
153
+ if (manifest.format === "requirements.txt") {
154
+ const analysis = await analyzeRequirementsFile(absolute, projectDir);
155
+ if (!analysis.ok) {
156
+ result.skippedFiles.push({ path: manifest.path, reason: analysis.reason });
157
+ continue;
158
+ }
159
+ result.requirementIssues.push(...analysis.issues);
160
+ result.requirementReferences.push(...analysis.references);
161
+ result.lowerBounds.push(...analysis.lowerBounds);
162
+ result.requirementsCopies.push({ path: manifest.path, entries: analysis.entries });
163
+ }
164
+ 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
+ result.targets.push({ path: absolute, format: manifest.format });
171
+ }
172
+ result.manifests.push(manifest);
173
+ }
174
+ if (result.manifests.length === 0) {
175
+ const details = [
176
+ ...result.lockfileMissing.map((m) => `${m.path}: ${m.hint}`),
177
+ ...result.skippedFiles.map((s) => `${s.path}: ${s.reason}`),
178
+ ];
179
+ throw new ScanToolError("no_manifest_found", `スキャンできるlockfile・マニフェストが見つかりません: ${projectDir}` +
180
+ (details.length > 0 ? `(${details.join(" / ")})` : ""));
181
+ }
182
+ return result;
183
+ }
184
+ /**
185
+ * 入力パスを検証し、プロジェクト内のlockfile・マニフェストを検出する。
186
+ * 対応ファイルの直接指定は、そのファイル1件だけを対象にする(親ディレクトリを探索しない)。
187
+ */
188
+ export async function detectProject(inputPath, options = {}) {
189
+ if (typeof inputPath !== "string" || inputPath.trim() === "") {
190
+ throw new ScanToolError("project_not_found", "スキャン対象のパスが指定されていません");
191
+ }
192
+ const resolved = await resolveExistingPath(inputPath);
193
+ const stats = await stat(resolved);
194
+ const name = path.basename(resolved);
195
+ const direct = stats.isFile() ? classifyFile(name) : null;
196
+ let projectDir;
197
+ if (stats.isDirectory()) {
198
+ projectDir = resolved;
199
+ }
200
+ else if (stats.isFile() && (direct !== null || LOCK_MARKERS.has(name))) {
201
+ projectDir = path.dirname(resolved);
202
+ }
203
+ else {
204
+ throw new ScanToolError("project_not_found", `指定されたパスはディレクトリでも対応するlockfile・マニフェストでもありません: ${inputPath}`);
205
+ }
206
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
207
+ if (allowedRootReal !== undefined)
208
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
209
+ if (direct !== null) {
210
+ return finalize(projectDir, [{ path: name, ...direct }], [], allowedRootReal);
211
+ }
212
+ const manifests = [];
213
+ const markers = [];
214
+ await walkProjectFiles(projectDir, options, SKIPPED_DIRS, (relativePath, fileName) => {
215
+ const found = classifyFile(fileName);
216
+ if (found) {
217
+ manifests.push({ path: relativePath, ...found });
218
+ return true;
219
+ }
220
+ const group = LOCK_MARKERS.get(fileName);
221
+ if (group)
222
+ markers.push({ path: relativePath, group });
223
+ return false;
224
+ });
225
+ return finalize(projectDir, manifests, markers, allowedRootReal);
226
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * osv-scannerに`--lockfile <形式>:<絶対パス>`で個別に渡せる解析形式(2.4.0で実機確認)。
3
+ * ここに無い形式はosv-scannerに渡さない。npm-shrinkwrap.jsonは"package-lock.json"形式で解析する。
4
+ */
5
+ export const MANIFEST_FORMATS = [
6
+ "pom.xml",
7
+ "gradle.lockfile",
8
+ "buildscript-gradle.lockfile",
9
+ "package-lock.json",
10
+ "yarn.lock",
11
+ "pnpm-lock.yaml",
12
+ "bun.lock",
13
+ "poetry.lock",
14
+ "uv.lock",
15
+ "Pipfile.lock",
16
+ "pdm.lock",
17
+ "requirements.txt",
18
+ "go.mod",
19
+ ];
20
+ const FORMAT_SET = new Set(MANIFEST_FORMATS);
21
+ export function isManifestFormat(value) {
22
+ return FORMAT_SET.has(value);
23
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * pom.xmlの親POM(`<parent><relativePath>`)が、スキャン許可ルート(OSV_MCP_ALLOWED_ROOT)の外を
3
+ * 参照していないかを検証する。
4
+ *
5
+ * osv-scanner v2.4.0の実機確認(2026-10-07):
6
+ * - `<relativePath>`の指す親POMを読み、その依存を結果に含める(`--no-resolve`でも同じ)。親の親もたどる
7
+ * - `<relativePath>`省略時はMavenの既定値`../pom.xml`を参照する。ディレクトリを指す場合はその中のpom.xml
8
+ * - `<relativePath/>`(空)はローカルを参照しない。親のGAVが一致しない場合も読まない
9
+ *
10
+ * サブモジュールのスキャンで親POMを読むのは正当な動作のため、境界はプロジェクトディレクトリではなく
11
+ * 許可ルートとする(未設定時は任意のパスをスキャンできる状態のため検証しない)。GAVの一致は判定が
12
+ * 複雑(親から引き継がれる)なため確認せず、許可ルートの外に参照先のファイルがあれば安全側に除外する。
13
+ */
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
+ const DEFAULT_RELATIVE_PATH = "../pom.xml";
20
+ const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", apos: "'", quot: '"' };
21
+ /** XMLの文字参照・定義済み実体参照を展開する。未知の実体はundefined */
22
+ function decodeEntities(text) {
23
+ let unknown = false;
24
+ const decoded = text.replace(/&([^;\s&]*);?/g, (match, body) => {
25
+ if (!match.endsWith(";"))
26
+ unknown = true;
27
+ else if (/^#x[0-9a-fA-F]+$/.test(body))
28
+ return String.fromCodePoint(parseInt(body.slice(2), 16));
29
+ else if (/^#[0-9]+$/.test(body))
30
+ return String.fromCodePoint(parseInt(body.slice(1), 10));
31
+ else if (body in NAMED_ENTITIES)
32
+ return NAMED_ENTITIES[body];
33
+ else
34
+ unknown = true;
35
+ return match;
36
+ });
37
+ return unknown ? undefined : decoded;
38
+ }
39
+ /**
40
+ * 親POMのrelativePath。null=親なし / ""=ローカルを参照しない / undefined=解釈できない(除外する)
41
+ *
42
+ * osv-scannerのXMLデコーダー(Go)の挙動に合わせる(2026-10-07 実機確認):
43
+ * - 要素は名前空間・接頭辞に関係なくローカル名で照合する(`<m:parent>`も親として読む)。大文字小文字は区別する
44
+ * - ルート要素の直下の`parent`だけが対象(入れ子の`parent`は無視される)。複数あると後のものが有効になる
45
+ * - 実体参照・CDATAは展開され、relativePathの前後の空白は除かれる
46
+ * 正規表現で「最初の<parent>」を探すと、おとりの入れ子・重複・接頭辞で迂回されるため、先頭から順に読む。
47
+ * 判断を誤ると範囲外を読ませるので、複数のparent/relativePath、CDATA、DOCTYPE、閉じていないタグなど、
48
+ * Goと同じ解釈を保証できない構文は解釈できない(undefined)として扱う。
49
+ */
50
+ export function parentRelativePath(source) {
51
+ // XML 1.0の行末処理: 解析前にCRLF・単独のCRをLFへ正規化する(Goのデコーダーも同じ)。
52
+ // 正規化しないと、検査側は"a\rb"を探して存在しないと判断し、osv-scannerは"a\nb"を読む
53
+ const xml = source.replace(/\r\n?/g, "\n");
54
+ const stack = [];
55
+ let parents = 0;
56
+ let relativePaths = 0;
57
+ let relativeText = null;
58
+ let capturing = false;
59
+ let pos = 0;
60
+ while (pos < xml.length) {
61
+ const lt = xml.indexOf("<", pos);
62
+ const textEnd = lt === -1 ? xml.length : lt;
63
+ if (capturing)
64
+ relativeText += xml.slice(pos, textEnd);
65
+ if (lt === -1)
66
+ break;
67
+ if (xml.startsWith("<!--", lt)) {
68
+ const end = xml.indexOf("-->", lt + 4);
69
+ if (end === -1)
70
+ return undefined;
71
+ pos = end + 3;
72
+ continue;
73
+ }
74
+ if (xml.startsWith("<!", lt))
75
+ return undefined; // CDATA・DOCTYPE・ENTITY宣言
76
+ if (xml.startsWith("<?", lt)) {
77
+ const end = xml.indexOf("?>", lt + 2);
78
+ if (end === -1)
79
+ return undefined;
80
+ pos = end + 2;
81
+ continue;
82
+ }
83
+ // タグの終わりを探す(属性値の引用符内の'>'は除く)
84
+ let i = lt + 1;
85
+ let quote = null;
86
+ for (; i < xml.length; i++) {
87
+ const c = xml[i];
88
+ if (quote !== null) {
89
+ if (c === quote)
90
+ quote = null;
91
+ }
92
+ else if (c === '"' || c === "'")
93
+ quote = c;
94
+ else if (c === ">")
95
+ break;
96
+ }
97
+ if (i >= xml.length)
98
+ return undefined;
99
+ const raw = xml.slice(lt + 1, i);
100
+ pos = i + 1;
101
+ if (raw.startsWith("/")) {
102
+ const name = raw.slice(1).trim();
103
+ if (stack.pop() !== name)
104
+ return undefined; // 対応しない終了タグ
105
+ if (capturing && stack.length === 2)
106
+ capturing = false; // relativePathの終了
107
+ continue;
108
+ }
109
+ const qname = /^[^\s/]+/.exec(raw)?.[0];
110
+ if (qname === undefined)
111
+ return undefined;
112
+ const local = qname.slice(qname.lastIndexOf(":") + 1);
113
+ const selfClosing = raw.endsWith("/");
114
+ const depth = stack.length; // 0=ルート
115
+ if (capturing)
116
+ return undefined; // relativePathの中に要素がある
117
+ if (depth === 1 && local === "parent")
118
+ parents++;
119
+ if (depth === 2 && local === "relativePath" && stack[1].slice(stack[1].lastIndexOf(":") + 1) === "parent") {
120
+ relativePaths++;
121
+ relativeText = "";
122
+ capturing = !selfClosing;
123
+ }
124
+ if (!selfClosing)
125
+ stack.push(qname);
126
+ }
127
+ if (stack.length !== 0 || capturing)
128
+ return undefined; // 閉じていないタグ
129
+ if (parents === 0)
130
+ return null;
131
+ if (parents > 1 || relativePaths > 1)
132
+ return undefined;
133
+ if (relativePaths === 0)
134
+ return DEFAULT_RELATIVE_PATH;
135
+ const decoded = decodeEntities(relativeText);
136
+ // プロパティ参照(${...})は評価できない
137
+ if (decoded === undefined || decoded.includes("${"))
138
+ return undefined;
139
+ const trimmed = decoded.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
140
+ // 制御文字・通常の空白以外の空白・書式文字は、文字の正規化や前後の空白の除去の細部
141
+ // (JSとGoで扱いが異なる)で参照先がずれうるため、解釈できないものとして除外する
142
+ if (/[\p{Cc}\p{Cf}\u0085]|(?! )\p{Z}/u.test(trimmed))
143
+ return undefined;
144
+ return trimmed;
145
+ }
146
+ /** UTF-8として厳密に読む。不正なバイト列・NULを含む場合はnull(Goと同じ解釈を保証できない) */
147
+ function decodePomBytes(bytes) {
148
+ let text;
149
+ try {
150
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ return text.includes("\u0000") ? null : text;
156
+ }
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
+ }
@@ -11,22 +11,23 @@
11
11
  * `project_path`はLLM・ユーザー由来の信頼できない入力として扱う:
12
12
  * - `realpath`で正規化し、シンボリックリンクを解決した実体パスで判定する
13
13
  * - `allowedRoot`指定時は、解決後のパスがその配下にあることを検証する(パストラバーサル対策)
14
- * - マニフェスト探索はエントリ数・件数・深さに上限を設け、シンボリックリンクのディレクトリは辿らない
14
+ * - 走査は上限付きで、シンボリックリンクのディレクトリは辿らない(projectWalk.ts)
15
15
  *
16
16
  * 検出したマニフェストが、そのままOSV-Scannerのスキャン範囲になる(ディレクトリは渡さない)。
17
- * そのため探索を深さで打ち切らず(Javaのソースツリーは深い)、上限に達した場合は
18
- * 結果を黙って欠落させずにエラーにする。
19
17
  */
20
- import { readdir, realpath, stat } from "node:fs/promises";
18
+ import { stat } from "node:fs/promises";
21
19
  import path from "node:path";
22
20
  import { ScanToolError } from "../errors.js";
23
- const DEFAULT_MAX_DEPTH = 64;
24
- const DEFAULT_MAX_ENTRIES = 200_000;
25
- const DEFAULT_MAX_MANIFESTS = 1_000;
21
+ import { pomParentOutsideRoot } from "./pomParent.js";
22
+ import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
26
23
  /** ビルド成果物・VCS等、マニフェスト探索でスキップするディレクトリ */
27
24
  const SKIPPED_DIRS = new Set([".git", "node_modules", "target", "build", ".idea", ".vscode"]);
28
- /** OSV-Scannerがスキャンできるマニフェスト(実機確認済み) */
29
- const MANIFEST_FILENAMES = new Set(["pom.xml", "gradle.lockfile", "buildscript-gradle.lockfile"]);
25
+ /** OSV-Scannerがスキャンできるマニフェスト(実機確認済み)。ファイル名がそのまま解析形式になる */
26
+ const MANIFEST_FILENAMES = new Set([
27
+ "pom.xml",
28
+ "gradle.lockfile",
29
+ "buildscript-gradle.lockfile",
30
+ ]);
30
31
  /** Gradleプロジェクトの存在を示すが、それ自体はスキャンできないビルドファイル */
31
32
  const GRADLE_BUILD_FILENAMES = new Set([
32
33
  "build.gradle",
@@ -38,62 +39,27 @@ const GRADLE_LOCKFILE_GUIDANCE = "Gradleプロジェクトを検出しました
38
39
  "本ツールはlockfile方式のみ対応しています(ビルド実行方式はbuild.gradleの任意コード実行を伴うため非対応)。" +
39
40
  "`./gradlew dependencies --write-locks` でlockfileを生成してから再実行してください" +
40
41
  "(依存ロックが未設定の場合は build.gradle に dependencyLocking { lockAllConfigurations() } の追加が必要です)";
41
- async function resolveExistingPath(inputPath) {
42
- try {
43
- return await realpath(path.resolve(inputPath));
44
- }
45
- catch {
46
- throw new ScanToolError("project_not_found", `指定されたパスが存在しません: ${inputPath}`);
47
- }
48
- }
49
- function assertInsideAllowedRoot(resolvedDir, allowedRootReal) {
50
- const relative = path.relative(allowedRootReal, resolvedDir);
51
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
52
- throw new ScanToolError("path_outside_allowed_root", `指定されたパスは許可されたディレクトリ(${allowedRootReal})の外にあります`);
53
- }
54
- }
55
- function searchLimitError(reason) {
56
- return new ScanToolError("manifest_search_limit_exceeded", `マニフェスト探索が上限(${reason})に達したため、スキャンを中止しました。` +
57
- "結果の欠落を避けるため途中までの結果は返しません。より狭いディレクトリ、またはpom.xml / gradle.lockfileを直接指定してください");
58
- }
59
- /** 上限付きでマニフェストを探索する。シンボリックリンクは辿らない。上限到達はエラー。 */
60
- async function findManifests(rootDir, limits) {
42
+ /** 親POMが許可ルートの外を参照するpom.xmlを除外して結果を組み立てる。全件除外ならエラー */
43
+ async function buildResult(projectDir, detected, allowedRootReal) {
61
44
  const manifests = [];
62
- let gradleBuildFileFound = false;
63
- let visited = 0;
64
- let currentLevel = [rootDir];
65
- for (let depth = 1; currentLevel.length > 0; depth++) {
66
- if (depth > limits.maxDepth)
67
- throw searchLimitError(`深さ${limits.maxDepth}`);
68
- const nextLevel = [];
69
- for (const dir of currentLevel) {
70
- let entries;
71
- try {
72
- entries = await readdir(dir, { withFileTypes: true });
73
- }
74
- catch {
75
- continue; // 読めないディレクトリはスキップ(権限不足等)
76
- }
77
- for (const entry of entries) {
78
- if (++visited > limits.maxEntries)
79
- throw searchLimitError(`${limits.maxEntries}エントリ`);
80
- if (entry.isFile() && MANIFEST_FILENAMES.has(entry.name)) {
81
- if (manifests.length >= limits.maxManifests)
82
- throw searchLimitError(`マニフェスト${limits.maxManifests}件`);
83
- manifests.push(path.relative(rootDir, path.join(dir, entry.name)));
84
- }
85
- else if (entry.isFile() && GRADLE_BUILD_FILENAMES.has(entry.name)) {
86
- gradleBuildFileFound = true;
87
- }
88
- else if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) {
89
- // isDirectory()はシンボリックリンクに対してfalseを返すため、リンクは自然に除外される
90
- nextLevel.push(path.join(dir, entry.name));
91
- }
92
- }
93
- }
94
- currentLevel = nextLevel;
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(" / ")})`);
95
57
  }
96
- return { manifests, gradleBuildFileFound };
58
+ const targets = manifests.map((manifest) => ({
59
+ path: path.join(projectDir, manifest),
60
+ format: path.basename(manifest),
61
+ }));
62
+ return { projectDir, manifests, targets, skipped };
97
63
  }
98
64
  /**
99
65
  * 入力パスを検証し、スキャン対象のJavaプロジェクトとして解決する。
@@ -110,10 +76,10 @@ export async function detectJavaProject(inputPath, options = {}) {
110
76
  // 親ディレクトリを探索しないため、探索上限エラーの回避手段として使える
111
77
  if (stats.isFile() && MANIFEST_FILENAMES.has(path.basename(resolved))) {
112
78
  const projectDir = path.dirname(resolved);
113
- if (options.allowedRoot !== undefined) {
114
- assertInsideAllowedRoot(projectDir, await resolveExistingPath(options.allowedRoot));
115
- }
116
- return { projectDir, manifests: [path.basename(resolved)], manifestPaths: [resolved] };
79
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
80
+ if (allowedRootReal !== undefined)
81
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
82
+ return buildResult(projectDir, [path.basename(resolved)], allowedRootReal);
117
83
  }
118
84
  let projectDir;
119
85
  if (stats.isDirectory()) {
@@ -127,13 +93,19 @@ export async function detectJavaProject(inputPath, options = {}) {
127
93
  else {
128
94
  throw new ScanToolError("project_not_found", `指定されたパスはディレクトリでも対応マニフェスト(pom.xml / gradle.lockfile)でもありません: ${inputPath}`);
129
95
  }
130
- if (options.allowedRoot !== undefined) {
131
- assertInsideAllowedRoot(projectDir, await resolveExistingPath(options.allowedRoot));
132
- }
133
- const { manifests, gradleBuildFileFound } = await findManifests(projectDir, {
134
- maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
135
- maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
136
- maxManifests: options.maxManifests ?? DEFAULT_MAX_MANIFESTS,
96
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
97
+ if (allowedRootReal !== undefined)
98
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
99
+ const manifests = [];
100
+ let gradleBuildFileFound = false;
101
+ await walkProjectFiles(projectDir, options, SKIPPED_DIRS, (relativePath, name) => {
102
+ if (MANIFEST_FILENAMES.has(name)) {
103
+ manifests.push(relativePath);
104
+ return true;
105
+ }
106
+ if (GRADLE_BUILD_FILENAMES.has(name))
107
+ gradleBuildFileFound = true;
108
+ return false;
137
109
  });
138
110
  if (manifests.length === 0) {
139
111
  if (gradleBuildFileFound) {
@@ -141,9 +113,5 @@ export async function detectJavaProject(inputPath, options = {}) {
141
113
  }
142
114
  throw new ScanToolError("no_manifest_found", `対応マニフェスト(pom.xml / gradle.lockfile)が見つかりません: ${projectDir}`);
143
115
  }
144
- return {
145
- projectDir,
146
- manifests,
147
- manifestPaths: manifests.map((manifest) => path.join(projectDir, manifest)),
148
- };
116
+ return buildResult(projectDir, manifests, allowedRootReal);
149
117
  }