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