osv-scanner-mcp 0.3.4 → 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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `scan_project`ツールのハンドラ: Java / JavaScript / Python / Goのlockfileをまとめてスキャンする。
3
+ *
4
+ * 誤要約対策(JAR実体スキャンと同じ原則): スキャンできなかった依存を示すcoverageを件数より前に置き、
5
+ * complete=falseの場合は「検出0件でも安全とは言えない」旨の警告を付ける。
6
+ */
7
+ import path from "node:path";
8
+ import { ScanToolError } from "../errors.js";
9
+ import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
10
+ import { parseOsvScanOutput } from "../osv/scanReport.js";
11
+ import { sanitizeExternalText } from "../utils/externalText.js";
12
+ import { detectProject } from "../utils/manifestDetector.js";
13
+ import { normalizePypiName } from "../utils/requirementsFile.js";
14
+ import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
15
+ import { dependencyResolution } from "./scanJavaProject.js";
16
+ import { errorResult, jsonResult } from "./toolResult.js";
17
+ const TRANSITIVE_OMITTED_WARNING = "OSV_MCP_NO_REMOTE_RESOLUTIONの設定により、マニフェスト(pom.xml / requirements.txt)からの推移的依存の解決を省略しています。" +
18
+ "lockfile(package-lock.json / poetry.lock / go.mod / gradle.lockfile等)に記録された依存は対象ですが、" +
19
+ "pom.xml・requirements.txtに直接記載された依存の先にある推移的依存の脆弱性は含まれません。" +
20
+ "検出0件でも推移的依存の安全性は確認できていません";
21
+ const INCOMPLETE_WARNING = "一部の依存はスキャンされていないか、版を推測してスキャンしています" +
22
+ "(lockfile_missing / unpinned_requirements / unscannable_requirements / skipped_filesを参照)。" +
23
+ "検出0件でも、それらの依存の安全性は確認できていません";
24
+ /** coverageの各一覧の上限(巨大なrequirements.txtで応答を膨らませない) */
25
+ const MAX_COVERAGE_ITEMS = 200;
26
+ function capped(items) {
27
+ return { items: items.slice(0, MAX_COVERAGE_ITEMS), omitted: Math.max(0, items.length - MAX_COVERAGE_ITEMS) };
28
+ }
29
+ function buildCoverage(project) {
30
+ const rel = (file) => sanitizeExternalText(path.relative(project.projectDir, file));
31
+ const lockfileMissing = capped(project.lockfileMissing);
32
+ const unpinned = capped(project.requirementIssues);
33
+ const unscannable = capped(project.requirementReferences);
34
+ const skipped = capped(project.skippedFiles);
35
+ const complete = project.lockfileMissing.length === 0 &&
36
+ project.requirementIssues.length === 0 &&
37
+ project.requirementReferences.length === 0 &&
38
+ project.skippedFiles.length === 0;
39
+ const omitted = lockfileMissing.omitted + unpinned.omitted + unscannable.omitted + skipped.omitted;
40
+ return {
41
+ complete,
42
+ ...(complete ? {} : { warning: INCOMPLETE_WARNING }),
43
+ manifests: project.manifests.map((m) => ({ path: sanitizeExternalText(m.path), ecosystem: m.ecosystem, format: m.format })),
44
+ lockfile_missing: lockfileMissing.items.map((m) => ({
45
+ path: sanitizeExternalText(m.path),
46
+ ecosystem: m.ecosystem,
47
+ status: m.status,
48
+ hint: sanitizeExternalText(m.hint),
49
+ })),
50
+ unpinned_requirements: unpinned.items.map((issue) => ({
51
+ file: rel(issue.file),
52
+ line: issue.line,
53
+ name: sanitizeExternalText(issue.name),
54
+ specifier: sanitizeExternalText(issue.specifier),
55
+ kind: issue.kind,
56
+ })),
57
+ unscannable_requirements: unscannable.items.map((ref) => ({
58
+ file: rel(ref.file),
59
+ line: ref.line,
60
+ text: sanitizeExternalText(ref.text),
61
+ reason: ref.reason,
62
+ })),
63
+ skipped_files: skipped.items.map((s) => ({ path: sanitizeExternalText(s.path), reason: sanitizeExternalText(s.reason) })),
64
+ ...(omitted > 0 ? { omitted_items: omitted } : {}),
65
+ };
66
+ }
67
+ function ecosystemBreakdown(project, packages) {
68
+ const breakdown = {};
69
+ for (const manifest of project.manifests) {
70
+ breakdown[manifest.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 };
71
+ breakdown[manifest.ecosystem].manifests++;
72
+ }
73
+ for (const pkg of packages) {
74
+ breakdown[pkg.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 };
75
+ breakdown[pkg.ecosystem].vulnerable_package_count++;
76
+ breakdown[pkg.ecosystem].vulnerability_count += pkg.vulnerabilities.length;
77
+ }
78
+ return breakdown;
79
+ }
80
+ /** `>=X` / `~=X` の行は、osv-scannerが下限Xを使用中の版とみなしてスキャンしている */
81
+ function markLowerBounds(project, packages) {
82
+ return packages.map((pkg) => pkg.ecosystem === "PyPI" &&
83
+ project.lowerBounds.some((lb) => lb.name === normalizePypiName(pkg.name) && lb.version === pkg.version)
84
+ ? { ...pkg, version_is_lower_bound: true }
85
+ : pkg);
86
+ }
87
+ /**
88
+ * requirements.txtは検証済みの正規化行だけを専用の一時ディレクトリに書いてスキャンする
89
+ * (元ファイルの取り込み指定をosv-scannerにたどらせない)。成功・失敗とも削除する。
90
+ */
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();
98
+ try {
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" });
115
+ }
116
+ if (targets.length === 0)
117
+ return parseOsvScanOutput({ results: [] });
118
+ return await runOsvScan(targets, options);
119
+ }
120
+ finally {
121
+ await snapshot.cleanup();
122
+ }
123
+ }
124
+ export async function handleScanProject(args, options = {}) {
125
+ try {
126
+ const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
127
+ const noRemoteResolution = isRemoteResolutionDisabled(options);
128
+ const report = await scanFromSnapshot(project, { ...options, noRemoteResolution });
129
+ return jsonResult({
130
+ project_dir: project.projectDir,
131
+ dependency_resolution: dependencyResolution(noRemoteResolution, TRANSITIVE_OMITTED_WARNING),
132
+ coverage: buildCoverage(project),
133
+ ecosystem_breakdown: ecosystemBreakdown(project, report.packages),
134
+ vulnerable_package_count: report.vulnerable_package_count,
135
+ vulnerability_count: report.vulnerability_count,
136
+ severity_breakdown: report.severity_breakdown,
137
+ packages: markLowerBounds(project, report.packages),
138
+ });
139
+ }
140
+ catch (error) {
141
+ return errorResult(error);
142
+ }
143
+ }
@@ -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, } 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,12 +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.manifestPaths, { ...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,
22
+ manifests,
23
+ ...skippedManifestsFields(skipped),
23
24
  dependency_resolution: dependencyResolution(noRemoteResolution),
24
25
  vulnerable_package_count: suggestions.length,
25
26
  unfixed_vulnerability_count: unfixedVulnerabilities,
@@ -0,0 +1,252 @@
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 { stat } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { ScanToolError } from "../errors.js";
13
+ import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
14
+ import { readRegularFile } from "./safeRead.js";
15
+ import { analyzeRequirementsFile, createRequirementsReadContext, } 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の収録確認で読むlockfile1件の上限と、1回の検出での合計の上限 */
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
+ }
113
+ /**
114
+ * package-lock.json(v2以降)の`packages`に、memberDirのエントリがあるか。
115
+ * true=収録 / false=未収録 / null=確認できない
116
+ */
117
+ async function npmLockfileRecords(cache, projectDir, lockfile, memberDir) {
118
+ const lockAbs = path.join(projectDir, lockfile.path);
119
+ const keys = await cache.get(lockAbs, projectDir);
120
+ if (keys === null)
121
+ return null;
122
+ const key = path.relative(path.dirname(lockAbs), path.join(projectDir, memberDir)).split(path.sep).join("/");
123
+ return keys.has(key);
124
+ }
125
+ /**
126
+ * lockfileの無いマーカー(package.json等)を判定する。
127
+ * - 同じディレクトリに同じエコシステムのlockfileがあれば充足
128
+ * - 上位のlockfileだけの場合は、収録を確認できたときだけ充足(npm workspacesのpackage-lock.json)。
129
+ * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
130
+ */
131
+ async function findLockfileMissing(projectDir, markers, manifests) {
132
+ const cache = new LockfileKeyCache();
133
+ const missing = [];
134
+ for (const marker of markers) {
135
+ const markerDir = path.dirname(marker.path);
136
+ const candidates = manifests.filter((m) => satisfiesGroup(m, marker.group) && isSameOrDescendant(markerDir, path.dirname(m.path)));
137
+ if (candidates.some((m) => path.dirname(m.path) === markerDir))
138
+ continue;
139
+ const base = { path: marker.path, ecosystem: LOCK_GROUP_ECOSYSTEM[marker.group] };
140
+ if (candidates.length === 0) {
141
+ missing.push({ ...base, status: "missing", hint: LOCKFILE_HINTS[marker.group] });
142
+ continue;
143
+ }
144
+ let confirmedAbsent = true;
145
+ let recorded = false;
146
+ for (const candidate of candidates) {
147
+ const result = candidate.format === "package-lock.json"
148
+ ? await npmLockfileRecords(cache, projectDir, candidate, markerDir)
149
+ : null;
150
+ if (result === true)
151
+ recorded = true;
152
+ if (result !== false)
153
+ confirmedAbsent = false;
154
+ }
155
+ if (recorded)
156
+ continue;
157
+ const lockfiles = candidates.map((m) => m.path).join(", ");
158
+ missing.push(confirmedAbsent
159
+ ? { ...base, status: "missing", hint: `上位のlockfile(${lockfiles})には収録されていません。${LOCKFILE_HINTS[marker.group]}` }
160
+ : {
161
+ ...base,
162
+ status: "unconfirmed",
163
+ hint: `上位のlockfile(${lockfiles})に収録されているか確認できません。workspaceのメンバーでない場合は、${LOCKFILE_HINTS[marker.group]}`,
164
+ });
165
+ }
166
+ return missing;
167
+ }
168
+ async function finalize(projectDir, manifests, markers, allowedRootReal) {
169
+ const result = {
170
+ projectDir,
171
+ allowedRootReal,
172
+ manifests: [],
173
+ targets: [],
174
+ requirementsCopies: [],
175
+ lockfileMissing: await findLockfileMissing(projectDir, markers, manifests),
176
+ requirementIssues: [],
177
+ requirementReferences: [],
178
+ lowerBounds: [],
179
+ skippedFiles: [],
180
+ };
181
+ const readContext = createRequirementsReadContext();
182
+ for (const manifest of manifests) {
183
+ const absolute = path.join(projectDir, manifest.path);
184
+ if (manifest.format === "requirements.txt") {
185
+ const analysis = await analyzeRequirementsFile(absolute, projectDir, readContext);
186
+ if (!analysis.ok) {
187
+ result.skippedFiles.push({ path: manifest.path, reason: analysis.reason });
188
+ continue;
189
+ }
190
+ result.requirementIssues.push(...analysis.issues);
191
+ result.requirementReferences.push(...analysis.references);
192
+ result.lowerBounds.push(...analysis.lowerBounds);
193
+ result.requirementsCopies.push({ path: manifest.path, entries: analysis.entries });
194
+ }
195
+ else {
196
+ result.targets.push({ path: absolute, format: manifest.format });
197
+ }
198
+ result.manifests.push(manifest);
199
+ }
200
+ if (result.manifests.length === 0) {
201
+ const details = [
202
+ ...result.lockfileMissing.map((m) => `${m.path}: ${m.hint}`),
203
+ ...result.skippedFiles.map((s) => `${s.path}: ${s.reason}`),
204
+ ];
205
+ throw new ScanToolError("no_manifest_found", `スキャンできるlockfile・マニフェストが見つかりません: ${projectDir}` +
206
+ (details.length > 0 ? `(${details.join(" / ")})` : ""));
207
+ }
208
+ return result;
209
+ }
210
+ /**
211
+ * 入力パスを検証し、プロジェクト内のlockfile・マニフェストを検出する。
212
+ * 対応ファイルの直接指定は、そのファイル1件だけを対象にする(親ディレクトリを探索しない)。
213
+ */
214
+ export async function detectProject(inputPath, options = {}) {
215
+ if (typeof inputPath !== "string" || inputPath.trim() === "") {
216
+ throw new ScanToolError("project_not_found", "スキャン対象のパスが指定されていません");
217
+ }
218
+ const resolved = await resolveExistingPath(inputPath);
219
+ const stats = await stat(resolved);
220
+ const name = path.basename(resolved);
221
+ const direct = stats.isFile() ? classifyFile(name) : null;
222
+ let projectDir;
223
+ if (stats.isDirectory()) {
224
+ projectDir = resolved;
225
+ }
226
+ else if (stats.isFile() && (direct !== null || LOCK_MARKERS.has(name))) {
227
+ projectDir = path.dirname(resolved);
228
+ }
229
+ else {
230
+ throw new ScanToolError("project_not_found", `指定されたパスはディレクトリでも対応するlockfile・マニフェストでもありません: ${inputPath}`);
231
+ }
232
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
233
+ if (allowedRootReal !== undefined)
234
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
235
+ if (direct !== null) {
236
+ return finalize(projectDir, [{ path: name, ...direct }], [], allowedRootReal);
237
+ }
238
+ const manifests = [];
239
+ const markers = [];
240
+ await walkProjectFiles(projectDir, options, SKIPPED_DIRS, (relativePath, fileName) => {
241
+ const found = classifyFile(fileName);
242
+ if (found) {
243
+ manifests.push({ path: relativePath, ...found });
244
+ return true;
245
+ }
246
+ const group = LOCK_MARKERS.get(fileName);
247
+ if (group)
248
+ markers.push({ path: relativePath, group });
249
+ return false;
250
+ });
251
+ return finalize(projectDir, manifests, markers, allowedRootReal);
252
+ }
@@ -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,150 @@
1
+ /**
2
+ * pom.xmlの親POM(`<parent><relativePath>`)の参照の解釈。連鎖のコピーと検証はscanSnapshot.tsが行う。
3
+ *
4
+ * osv-scanner v2.4.0の実機確認(2026-10-07):
5
+ * - `<relativePath>`の指す親POMを読み、その依存を結果に含める(`--no-resolve`でも同じ)。親の親もたどる
6
+ * - `<relativePath>`省略時はMavenの既定値`../pom.xml`を参照する。ディレクトリを指す場合はその中のpom.xml
7
+ * - `<relativePath/>`(空)はローカルを参照しない。親のGAVが一致しない場合も読まない
8
+ *
9
+ * スナップショット方式では、osv-scannerが読めるのはコピーしたファイルだけのため、ここでの解釈が
10
+ * osv-scannerとずれても範囲外は読まれない(最悪でも親が見つからない)。解釈できない場合の除外は、
11
+ * 親から引き継ぐ依存を黙って欠落させないためのもの。
12
+ */
13
+ const DEFAULT_RELATIVE_PATH = "../pom.xml";
14
+ const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", apos: "'", quot: '"' };
15
+ /** XMLの文字参照・定義済み実体参照を展開する。未知の実体はundefined */
16
+ function decodeEntities(text) {
17
+ let unknown = false;
18
+ const decoded = text.replace(/&([^;\s&]*);?/g, (match, body) => {
19
+ if (!match.endsWith(";"))
20
+ unknown = true;
21
+ else if (/^#x[0-9a-fA-F]+$/.test(body))
22
+ return String.fromCodePoint(parseInt(body.slice(2), 16));
23
+ else if (/^#[0-9]+$/.test(body))
24
+ return String.fromCodePoint(parseInt(body.slice(1), 10));
25
+ else if (body in NAMED_ENTITIES)
26
+ return NAMED_ENTITIES[body];
27
+ else
28
+ unknown = true;
29
+ return match;
30
+ });
31
+ return unknown ? undefined : decoded;
32
+ }
33
+ /**
34
+ * 親POMのrelativePath。null=親なし / ""=ローカルを参照しない / undefined=解釈できない(除外する)
35
+ *
36
+ * osv-scannerのXMLデコーダー(Go)の挙動に合わせる(2026-10-07 実機確認):
37
+ * - 要素は名前空間・接頭辞に関係なくローカル名で照合する(`<m:parent>`も親として読む)。大文字小文字は区別する
38
+ * - ルート要素の直下の`parent`だけが対象(入れ子の`parent`は無視される)。複数あると後のものが有効になる
39
+ * - 実体参照・CDATAは展開され、relativePathの前後の空白は除かれる
40
+ * 正規表現で「最初の<parent>」を探すと、おとりの入れ子・重複・接頭辞で迂回されるため、先頭から順に読む。
41
+ * 判断を誤ると範囲外を読ませるので、複数のparent/relativePath、CDATA、DOCTYPE、閉じていないタグなど、
42
+ * Goと同じ解釈を保証できない構文は解釈できない(undefined)として扱う。
43
+ */
44
+ export function parentRelativePath(source) {
45
+ // XML 1.0の行末処理: 解析前にCRLF・単独のCRをLFへ正規化する(Goのデコーダーも同じ)。
46
+ // 正規化しないと、検査側は"a\rb"を探して存在しないと判断し、osv-scannerは"a\nb"を読む
47
+ const xml = source.replace(/\r\n?/g, "\n");
48
+ const stack = [];
49
+ let parents = 0;
50
+ let relativePaths = 0;
51
+ let relativeText = null;
52
+ let capturing = false;
53
+ let pos = 0;
54
+ while (pos < xml.length) {
55
+ const lt = xml.indexOf("<", pos);
56
+ const textEnd = lt === -1 ? xml.length : lt;
57
+ if (capturing)
58
+ relativeText += xml.slice(pos, textEnd);
59
+ if (lt === -1)
60
+ break;
61
+ if (xml.startsWith("<!--", lt)) {
62
+ const end = xml.indexOf("-->", lt + 4);
63
+ if (end === -1)
64
+ return undefined;
65
+ pos = end + 3;
66
+ continue;
67
+ }
68
+ if (xml.startsWith("<!", lt))
69
+ return undefined; // CDATA・DOCTYPE・ENTITY宣言
70
+ if (xml.startsWith("<?", lt)) {
71
+ const end = xml.indexOf("?>", lt + 2);
72
+ if (end === -1)
73
+ return undefined;
74
+ pos = end + 2;
75
+ continue;
76
+ }
77
+ // タグの終わりを探す(属性値の引用符内の'>'は除く)
78
+ let i = lt + 1;
79
+ let quote = null;
80
+ for (; i < xml.length; i++) {
81
+ const c = xml[i];
82
+ if (quote !== null) {
83
+ if (c === quote)
84
+ quote = null;
85
+ }
86
+ else if (c === '"' || c === "'")
87
+ quote = c;
88
+ else if (c === ">")
89
+ break;
90
+ }
91
+ if (i >= xml.length)
92
+ return undefined;
93
+ const raw = xml.slice(lt + 1, i);
94
+ pos = i + 1;
95
+ if (raw.startsWith("/")) {
96
+ const name = raw.slice(1).trim();
97
+ if (stack.pop() !== name)
98
+ return undefined; // 対応しない終了タグ
99
+ if (capturing && stack.length === 2)
100
+ capturing = false; // relativePathの終了
101
+ continue;
102
+ }
103
+ const qname = /^[^\s/]+/.exec(raw)?.[0];
104
+ if (qname === undefined)
105
+ return undefined;
106
+ const local = qname.slice(qname.lastIndexOf(":") + 1);
107
+ const selfClosing = raw.endsWith("/");
108
+ const depth = stack.length; // 0=ルート
109
+ if (capturing)
110
+ return undefined; // relativePathの中に要素がある
111
+ if (depth === 1 && local === "parent")
112
+ parents++;
113
+ if (depth === 2 && local === "relativePath" && stack[1].slice(stack[1].lastIndexOf(":") + 1) === "parent") {
114
+ relativePaths++;
115
+ relativeText = "";
116
+ capturing = !selfClosing;
117
+ }
118
+ if (!selfClosing)
119
+ stack.push(qname);
120
+ }
121
+ if (stack.length !== 0 || capturing)
122
+ return undefined; // 閉じていないタグ
123
+ if (parents === 0)
124
+ return null;
125
+ if (parents > 1 || relativePaths > 1)
126
+ return undefined;
127
+ if (relativePaths === 0)
128
+ return DEFAULT_RELATIVE_PATH;
129
+ const decoded = decodeEntities(relativeText);
130
+ // プロパティ参照(${...})は評価できない
131
+ if (decoded === undefined || decoded.includes("${"))
132
+ return undefined;
133
+ const trimmed = decoded.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
134
+ // 制御文字・通常の空白以外の空白・書式文字は、文字の正規化や前後の空白の除去の細部
135
+ // (JSとGoで扱いが異なる)で参照先がずれうるため、解釈できないものとして除外する
136
+ if (/[\p{Cc}\p{Cf}\u0085]|(?! )\p{Z}/u.test(trimmed))
137
+ return undefined;
138
+ return trimmed;
139
+ }
140
+ /** UTF-8として厳密に読む。不正なバイト列・NULを含む場合はnull(Goと同じ解釈を保証できない) */
141
+ export function decodePomBytes(bytes) {
142
+ let text;
143
+ try {
144
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ return text.includes("\u0000") ? null : text;
150
+ }