osv-scanner-mcp 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/dist/index.js +7 -7
- package/dist/osv/scanReport.js +7 -3
- package/dist/osv/versionScheme.js +39 -0
- package/dist/tools/scanJavaArtifact.js +34 -3
- package/dist/tools/scanJavaProject.js +63 -9
- package/dist/tools/scanProject.js +33 -17
- package/dist/tools/suggestFix.js +5 -5
- package/dist/utils/manifestDetector.js +50 -24
- package/dist/utils/pomParent.js +5 -61
- package/dist/utils/projectDetector.js +2 -18
- package/dist/utils/requirementsFile.js +43 -14
- package/dist/utils/safeRead.js +150 -0
- package/dist/utils/sbomInput.js +10 -1
- package/dist/utils/scanSnapshot.js +209 -0
- package/dist/utils/semverVersion.js +69 -0
- package/dist/utils/startupConfig.js +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップす
|
|
|
16
16
|
- **JAR/WAR実体スキャン**: `scan_java_artifact` ツールで、lockfileが無い・shaded/fat JARしか手元にないプロジェクトでもアーカイブ内メタデータから既知の脆弱性を検出します(ベストエフォート同定であることを明示するcoverage情報付き)
|
|
17
17
|
- **SBOM入力スキャン**: `scan_sbom` ツールでCycloneDX/SPDXのJSON SBOMに記録された依存を検査します。SBOMの網羅性や実成果物との一致は未検証であることを明示します
|
|
18
18
|
- **深刻度順のレポート**: パッケージごとに脆弱性をCVSSスコア順に整理し、5段階の深刻度ラベル(critical / high / medium / low / unknown)とサマリ集計付きで返します
|
|
19
|
-
- **修正版の提示**: 各脆弱性の `fixed_versions`
|
|
19
|
+
- **修正版の提示**: 各脆弱性の `fixed_versions` を含めます。MavenはMavenバージョン優先順位規則(`2.17.1-RELEASE` のようなsemver非対応の表記にも対応)、npm・GoはSemantic Versioningの優先順位で正しくソートします
|
|
20
20
|
- **セキュリティ第一の設計**: シェル非経由の実行・引数ホワイトリスト・パス正規化と境界チェック・タイムアウト/出力サイズ上限を実装段階から組み込んでいます
|
|
21
21
|
|
|
22
22
|
## 動作要件
|
|
@@ -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` でバイナリの自動ダウンロードを無効化(デフォルト有効) |
|
|
@@ -208,7 +208,7 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
|
|
|
208
208
|
- `lockfile_missing`: lockfileの無いマニフェスト(`package.json`、`pyproject.toml`、`Pipfile`、`setup.py`、`build.gradle` 等)。同じディレクトリに同じエコシステムのlockfileがあれば記録しません。上位のディレクトリのlockfileだけがある場合は、`package-lock.json`(v2以降)にそのディレクトリが収録されていることを確認できたときだけ記録しません(npm workspaces)。収録されていなければ `status: "missing"`、確認できない形式(yarn.lock、Python系等)なら `status: "unconfirmed"` として記録します。`hint` の手順でlockfileを生成してから再スキャンしてください(生成は信頼できる環境で)
|
|
209
209
|
- `unpinned_requirements`: requirements.txtのうち、版を固定していない行。`kind` は `unpinned`(版の指定なし)・`range`(`>`、`<`、`!=`、`==1.*`、範囲の組み合わせ等)・`lower_bound`(`>=`、`~=`)。`unpinned` と `range` の行はOSV-Scannerがスキャンせず、`lower_bound` の行は下限の版を使用中の版とみなしてスキャンします(該当パッケージには `version_is_lower_bound: true` が付き、実際の版とは異なる可能性があります)
|
|
210
210
|
- `unscannable_requirements`: スキャンされない行と理由。`-e`、`name @ URL`、パス指定、展開しなかった取り込み(プロジェクトディレクトリの外・存在しない・URL・上限超過)、制約ファイル(`-c`、適用しません)、解釈できないオプションや版の指定。解釈できない行は無視せず、ここに記録します
|
|
211
|
-
- `skipped_files`: スキャン対象から外したファイルと理由(requirements.txt自体が読めない・1MiBを超える場合、`pom.xml` の親POM
|
|
211
|
+
- `skipped_files`: スキャン対象から外したファイルと理由(requirements.txt自体が読めない・1MiBを超える場合、`pom.xml` の親POMが許可ルートの外を参照する場合)。親POMを読めず、親POMを含めずにスキャンした `pom.xml` もここに理由付きで示します([親POMの扱い](#親pomの扱い)を参照)
|
|
212
212
|
- 各一覧は200件までで、超えた分の件数を `omitted_items` に返します
|
|
213
213
|
- `ecosystem_breakdown` は、脆弱性0件のエコシステムも含めて「スキャンした」ことを示します
|
|
214
214
|
- `dependency_groups` はOSV-Scannerが付けた依存グループ(例: `dev`)の生の値です。lockfileの形式によって欠落・不正確なため(pnpmでは付かず、pdmでは `optional` になる等)、参考情報として扱ってください
|
|
@@ -234,6 +234,8 @@ OSV-Scannerは `pom.xml` の `<parent>` が参照する親POM(`<relativePath>`
|
|
|
234
234
|
|
|
235
235
|
`OSV_MCP_ALLOWED_ROOT` を設定している場合、親POMの連鎖のどこかが**許可ルートの外**のファイルを参照する `pom.xml` は、スキャン対象から外します(許可ルート外のファイルの内容を結果や照会先に出さないため)。外したファイルは応答の `skipped_manifests`(`scan_project` では `coverage.skipped_files`)に理由付きで示し、`scope_warning` で「検出0件でも安全とは判断しない」旨を伝えます。全件が外れた場合や、該当する `pom.xml` を直接指定した場合は `path_outside_allowed_root` を返します。
|
|
236
236
|
|
|
237
|
+
存在する親POMを読めない場合(10MiBを超える、名前付きパイプ等の通常のファイルでない、末尾がシンボリックリンク等)は、その `pom.xml` を親POMを含めずにスキャンし、親から継承する依存が欠ける可能性を応答の `incomplete_manifests`(`scan_project` では `coverage.skipped_files`、`coverage.complete` は `false`)に理由付きで示します。子の `pom.xml` に依存が無く `no_packages_found` になる場合も、エラーのメッセージに同じ理由を含めます。親POMが存在しない場合(ルートの `pom.xml` で既定の `../pom.xml` が無い等)は、元の配置でもOSV-Scannerは親を読まないため欠落として扱いません。
|
|
238
|
+
|
|
237
239
|
- 許可ルート内の親POMは従来どおり読みます(サブモジュールのスキャンは許可ルート内なら引き続き使えます)
|
|
238
240
|
- `<relativePath/>`(空)はローカルの親POMを参照しないため対象外です
|
|
239
241
|
- 親POMの指定は、OSV-Scanner(Go)のXMLの解釈に合わせて読みます。要素は名前空間の接頭辞に関係なく要素名で照合し(`<m:parent>` も親として扱う)、ルート要素の直下の `parent` だけを対象にし、文字参照を展開します
|
|
@@ -275,7 +277,7 @@ OSV-Scannerは `pom.xml` の `<parent>` が参照する親POM(`<relativePath>`
|
|
|
275
277
|
```
|
|
276
278
|
|
|
277
279
|
- `packages` は最も深刻な脆弱性を持つ順、各 `vulnerabilities` は深刻度順(unknownは末尾)
|
|
278
|
-
- `fixed_versions` はMaven
|
|
280
|
+
- `fixed_versions` はOSVに記載された修正版です。MavenはMaven優先順位、npm・GoはSemantic Versioningの優先順位で昇順(SemVerとして解釈できない表記は末尾)、PyPI等はOSVの記載順のまま(並び順は保証しません)。複数のリリース系統(例: 2.12系バックポートと2.15系)が混在することがあります。プレリリース版(`5.0.0-beta.3`)やGoの疑似バージョン(`0.0.0-20180925071336-cf3bd585ca2a`)が含まれることもあります。空配列は「OSVに修正版の記載がない」ことを意味します。v0.4.1以前はMaven以外のパッケージで常に空配列を返していました
|
|
279
281
|
- `severity_score` が取得できない脆弱性は `null` / `"unknown"` として扱います
|
|
280
282
|
|
|
281
283
|
### `scan_java_artifact`
|
|
@@ -371,7 +373,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
371
373
|
- MavenのOSV `ECOSYSTEM` 範囲(`introduced` / `fixed` / `last_affected` / 上限なし)を照合します。`versions` に明示された影響も確認します。範囲欠落・不正・未対応形式・`limit` による不完全な情報では安全と推定せず、候補を検証できなければ `recommended_upgrade: null`、`verification: "no_verified_candidate"` を返します。
|
|
372
374
|
- 推奨時は `verification: "verified"`、CVEごとの `recommended_status` は `affected` / `not_affected` / `unknown` です。推奨保留時は `not_evaluated` になります。`per_cve_detail.fixed_in` は各CVE単独の候補であり、最終推奨先の判定は `recommended_status` を参照してください。
|
|
373
375
|
- 現在より新しい修正版候補がないCVEは `tier: "unfixed"` として推奨の修正対象から除外します(情報欠落を含む場合があります)。除外したCVEも推奨先で判定し、その状態を表示します。全CVEがunfixedの場合も `recommended_upgrade` は `null` です。
|
|
374
|
-
- 修正版の推奨はMavenのみ対応です。Maven以外のパッケージは `verification: "unsupported_ecosystem"`、CVEごとの `tier: "unsupported"` を返し、`unfixed` には数えません(修正版の有無は判定していないため。修正版は `explain_vulnerability` で確認できます)。
|
|
376
|
+
- 修正版の推奨はMavenのみ対応です。Maven以外のパッケージは `verification: "unsupported_ecosystem"`、CVEごとの `tier: "unsupported"` を返し、`unfixed` には数えません(修正版の有無は判定していないため。修正版は `scan_project` の `fixed_versions` や `explain_vulnerability` で確認できます)。
|
|
375
377
|
|
|
376
378
|
### `explain_vulnerability`
|
|
377
379
|
|
|
@@ -405,6 +407,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
405
407
|
| `binary_not_found` | OSV-Scannerが見つからない(インストール案内をmessageに含む) |
|
|
406
408
|
| `project_not_found` | 指定パスが存在しない・ディレクトリ/pom.xmlでない |
|
|
407
409
|
| `no_manifest_found` | 対応マニフェスト(pom.xml / gradle.lockfile)が見つからない |
|
|
410
|
+
| `scan_input_too_large` | スキャン対象ファイル(一時ディレクトリへのコピー)の合計サイズが上限(2GiB)を超えた。対象を絞って再実行する |
|
|
408
411
|
| `manifest_search_limit_exceeded` | マニフェスト探索が上限(20万エントリ・1,000マニフェスト)に達した。より狭いディレクトリかマニフェストを直接指定する |
|
|
409
412
|
| `binary_download_failed` | バイナリのダウンロード失敗(未対応プラットフォーム含む) |
|
|
410
413
|
| `binary_checksum_mismatch` | ダウンロードしたバイナリのチェックサム不一致(改ざん/破損の可能性) |
|
|
@@ -427,8 +430,9 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
427
430
|
|
|
428
431
|
- **サプライチェーン対策**: バイナリの自動ダウンロードは公式GitHub Releasesに限定し、バージョンをピン留め。**パッケージに埋め込まれたSHA256チェックサム**で検証します(配布元のSHA256SUMSファイルは信用しないため、リリース側が改ざんされても検出可能)。検証合格まで実行権限を与えず、キャッシュ済みバイナリも使用のたびに再検証します。`OSV_MCP_PREFER_DOWNLOAD=1` でPATH上の未検証バイナリを使わない運用も選べます
|
|
429
432
|
- **コマンドインジェクション対策**: シェルを経由しない `spawn` + 引数配列で実行。OSV-Scannerへの引数は固定リストのみで、可変部は検証済み絶対パス1つだけ
|
|
433
|
+
- **スナップショット方式(検査と読み込みの不一致の防止)**: OSV-Scannerには元のファイルを一切渡しません。lockfile・`pom.xml`(親POMの連鎖を含む)・JAR/WARは、本サーバーが安全に1回だけ読んだ内容を専用の一時ディレクトリ(所有者のみアクセス可、終了時に削除)へコピーしてスキャンし、検査もそのコピーに対して行います。検査の後で元のファイルやディレクトリを差し替えても結果には影響しません。親POMは元の配置を一時ディレクトリ内に再現してコピーするため、OSV-Scannerが相対パスで親をたどっても、見つかるのは検証してコピーしたファイルだけです(`..` を重ねて一時ディレクトリの外に届く参照は除外)。読み込みは末尾のシンボリックリンクをたどらず、名前付きパイプ等の通常のファイル以外は読まず(処理が止まらない)、読み終えた後にパスを解決し直して境界の内側かつ開いた実体と同じファイルかを確認します。コピーの合計サイズは2GiBまでです(超えると `scan_input_too_large`)
|
|
430
434
|
- **パストラバーサル対策**: 入力パスは `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)
|
|
435
|
+
- **DoS対策**: タイムアウト・stdout上限・stderr抜粋上限を設定。スキャン結果は防御的にパースし、形式不正でも例外を投げません。同時実行スキャン数も上限(デフォルト2)を設け、並列リクエストによるプロセスの無制限起動を防ぎます。本サーバー自身の解析も、同じファイルは1回だけ読んで結果を使い回し(workspaceの収録確認でのlockfile、requirements.txtの共通の取り込み先、親POM)、読む量の合計に上限を設けます
|
|
432
436
|
- **fail-closedな運用モード**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` で、スキャン許可ルート未設定時にサーバーの起動自体を拒否できます
|
|
433
437
|
- **通信先の固定と明示**: osv-scannerの依存解決先は `deps.dev` を明示指定し、スキャン対象のpom.xmlが指定する任意のリポジトリへ接続するモード(`--data-source native`)は使いません(テストで保証)。通信先の一覧と、deps.devへの送信を止める `OSV_MCP_NO_REMOTE_RESOLUTION=1` は[通信先とプライバシー](#通信先とプライバシー)を参照
|
|
434
438
|
- **情報漏えい対策**: 想定外の例外はスタックトレース等を含めず `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.2",
|
|
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へ
|
package/dist/osv/scanReport.js
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* OSV-Scannerの出力は外部由来データなので、形式が想定と異なっても例外を投げず
|
|
13
13
|
* 読み取れた範囲でレポートを構築する(欠損フィールドはスキップ)。
|
|
14
14
|
*/
|
|
15
|
-
import { compareMavenVersions } from "../utils/mavenVersion.js";
|
|
16
15
|
import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
|
|
17
16
|
import { extractAffectedVersions } from "./affectedVersions.js";
|
|
17
|
+
import { sortVersions, versionRangeTypes } from "./versionScheme.js";
|
|
18
18
|
/** 外部由来のsummaryをLLMに渡す際の長さ上限(プロンプト肥大・DoS対策) */
|
|
19
19
|
const MAX_SUMMARY_LENGTH = 500;
|
|
20
20
|
const CVSS_CRITICAL = 9.0;
|
|
@@ -65,9 +65,12 @@ function extractSummary(vulnDetails) {
|
|
|
65
65
|
/**
|
|
66
66
|
* OSVエントリの`affected[].ranges[].events[].fixed`から修正版を収集する。
|
|
67
67
|
* 1つのOSVエントリが複数パッケージをカバーしうるため、対象パッケージ名で絞り込む。
|
|
68
|
+
* 範囲の型はエコシステム別(versionScheme.ts)。GIT型のコミットハッシュは集めない。
|
|
69
|
+
* v0.4.1以前はMavenだけを集めていたため、npm・Go・PyPI等で常に空になっていた。
|
|
68
70
|
*/
|
|
69
71
|
function extractFixedVersions(vulnDetails, packageName, ecosystem) {
|
|
70
72
|
const versions = new Set();
|
|
73
|
+
const rangeTypes = versionRangeTypes(ecosystem);
|
|
71
74
|
for (const detail of vulnDetails) {
|
|
72
75
|
for (const affectedRaw of asArray(detail.affected)) {
|
|
73
76
|
const affected = asRecord(affectedRaw);
|
|
@@ -77,7 +80,8 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
|
|
|
77
80
|
if (affectedName !== packageName || asString(asRecord(affected.package)?.ecosystem) !== ecosystem)
|
|
78
81
|
continue;
|
|
79
82
|
for (const rangeRaw of asArray(affected.ranges)) {
|
|
80
|
-
|
|
83
|
+
const type = asString(asRecord(rangeRaw)?.type);
|
|
84
|
+
if (type === null || !rangeTypes.has(type))
|
|
81
85
|
continue;
|
|
82
86
|
for (const eventRaw of asArray(asRecord(rangeRaw)?.events)) {
|
|
83
87
|
const fixed = asString(asRecord(eventRaw)?.fixed);
|
|
@@ -87,7 +91,7 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
|
-
return
|
|
94
|
+
return sortVersions(versions, ecosystem);
|
|
91
95
|
}
|
|
92
96
|
/**
|
|
93
97
|
* OSV-Scannerの`--format json`出力を`scan_java_project`のレポートに変換する。
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* エコシステムごとのバージョンの扱い(docs/DESIGN_TODO.md「suggest_fix npm/Go対応」)。
|
|
3
|
+
*
|
|
4
|
+
* - Maven: Mavenの優先順位(mavenVersion.ts)。範囲はECOSYSTEM型
|
|
5
|
+
* - npm・Go: SemVer 2.0.0の優先順位(semverVersion.ts)。範囲はSEMVER型とECOSYSTEM型
|
|
6
|
+
* - その他(PyPI等): 比較器なし。範囲はECOSYSTEM型
|
|
7
|
+
*
|
|
8
|
+
* GIT型の範囲(コミットハッシュ)はリリース版と無関係のため、どのエコシステムでも版として扱わない。
|
|
9
|
+
*/
|
|
10
|
+
import { compareMavenVersions } from "../utils/mavenVersion.js";
|
|
11
|
+
import { compareParsedSemver, parseSemver } from "../utils/semverVersion.js";
|
|
12
|
+
const SEMVER_ECOSYSTEMS = new Set(["npm", "Go"]);
|
|
13
|
+
/** 修正版・影響範囲として読み取るOSVの範囲の型 */
|
|
14
|
+
export function versionRangeTypes(ecosystem) {
|
|
15
|
+
if (ecosystem === "Maven")
|
|
16
|
+
return new Set(["ECOSYSTEM"]);
|
|
17
|
+
if (SEMVER_ECOSYSTEMS.has(ecosystem))
|
|
18
|
+
return new Set(["SEMVER", "ECOSYSTEM"]);
|
|
19
|
+
return new Set(["ECOSYSTEM"]);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 重複を除いた版の一覧を、エコシステムの優先順位で昇順に並べる。
|
|
23
|
+
* - npm・Go: SemVerとして解釈できない版は末尾に記載順のまま置く
|
|
24
|
+
* - 比較器のないエコシステム: 記載順のまま返す
|
|
25
|
+
*/
|
|
26
|
+
export function sortVersions(versions, ecosystem) {
|
|
27
|
+
const unique = [...new Set(versions)];
|
|
28
|
+
if (ecosystem === "Maven")
|
|
29
|
+
return unique.sort(compareMavenVersions);
|
|
30
|
+
if (!SEMVER_ECOSYSTEMS.has(ecosystem))
|
|
31
|
+
return unique;
|
|
32
|
+
const parsed = unique.flatMap((raw) => {
|
|
33
|
+
const version = parseSemver(raw);
|
|
34
|
+
return version === null ? [] : [{ raw, version }];
|
|
35
|
+
});
|
|
36
|
+
const unparsed = unique.filter((raw) => parseSemver(raw) === null);
|
|
37
|
+
parsed.sort((a, b) => compareParsedSemver(a.version, b.version) || (a.raw < b.raw ? -1 : a.raw > b.raw ? 1 : 0));
|
|
38
|
+
return [...parsed.map((p) => p.raw), ...unparsed];
|
|
39
|
+
}
|
|
@@ -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 snapshot.guard(() => 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に直接記載された依存の先にある推移的依存の脆弱性は含まれません。" +
|
|
@@ -18,31 +21,82 @@ export function dependencyResolution(noRemoteResolution, warning = TRANSITIVE_OM
|
|
|
18
21
|
? { transitive_resolution: "disabled", warning }
|
|
19
22
|
: { transitive_resolution: "enabled" };
|
|
20
23
|
}
|
|
21
|
-
const SKIPPED_MANIFESTS_WARNING = "
|
|
24
|
+
const SKIPPED_MANIFESTS_WARNING = "一部のマニフェストをスキャン対象から外したか、親POMを含めずにスキャンしました(skipped_manifests / incomplete_manifestsを参照)。" +
|
|
22
25
|
"それらの依存の脆弱性は結果に含まれないため、検出0件でも安全とは判断しないでください";
|
|
23
26
|
/**
|
|
24
|
-
*
|
|
25
|
-
* (既存の出力を変えない)。件数より前に置き、検出0件を安全と誤読させない
|
|
27
|
+
* 外したマニフェスト(skipped)と、親POMを再現できずにスキャンしたマニフェスト(incomplete)。
|
|
28
|
+
* どちらも無ければ何も出力しない(既存の出力を変えない)。件数より前に置き、検出0件を安全と誤読させない
|
|
26
29
|
*/
|
|
27
|
-
export function skippedManifestsFields(skipped) {
|
|
28
|
-
if (skipped.length === 0)
|
|
30
|
+
export function skippedManifestsFields(skipped, incomplete = []) {
|
|
31
|
+
if (skipped.length === 0 && incomplete.length === 0)
|
|
29
32
|
return {};
|
|
33
|
+
const list = (items) => items.map((s) => ({ path: sanitizeExternalText(s.path), reason: sanitizeExternalText(s.reason) }));
|
|
30
34
|
return {
|
|
31
|
-
|
|
35
|
+
...(skipped.length > 0 ? { skipped_manifests: list(skipped) } : {}),
|
|
36
|
+
...(incomplete.length > 0 ? { incomplete_manifests: list(incomplete) } : {}),
|
|
32
37
|
scope_warning: SKIPPED_MANIFESTS_WARNING,
|
|
33
38
|
};
|
|
34
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* スキャン対象を外した・親POMを含めずにスキャンしたマニフェストがある状態で「パッケージなし」になった場合、
|
|
42
|
+
* エラーにもその旨を含める(応答の一覧が出ないため、欠落を黙って「依存なし」と読ませない)。
|
|
43
|
+
*/
|
|
44
|
+
export async function withScopeNotes(notes, scan) {
|
|
45
|
+
try {
|
|
46
|
+
return await scan();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!(error instanceof ScanToolError) || error.kind !== "no_packages_found" || notes.length === 0)
|
|
50
|
+
throw error;
|
|
51
|
+
throw new ScanToolError(error.kind, `${error.message}。ただし次のマニフェストはスキャン対象から外したか、親POMを含めずにスキャンしたため、` +
|
|
52
|
+
`検出0件でも安全とは判断しないでください: ${notes.map((n) => `${n.path}: ${n.reason}`).join(" / ")}`, error.detail);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 検出したマニフェストをスナップショットへコピーし、コピーをスキャンする(元のファイルは渡さない)。
|
|
57
|
+
* 応答に一時ディレクトリのパスを出さないよう、スキャナーのパスは元のファイルに戻す。
|
|
58
|
+
* スナップショットは成功・失敗とも削除する。全件除外ならエラー。
|
|
59
|
+
*/
|
|
60
|
+
export async function scanJavaManifests(project, options) {
|
|
61
|
+
const snapshot = await ScanSnapshot.create();
|
|
62
|
+
try {
|
|
63
|
+
const { targets, skipped, incomplete } = await snapshotManifests(snapshot, project.targets, {
|
|
64
|
+
projectDir: project.projectDir,
|
|
65
|
+
allowedRootReal: project.allowedRootReal,
|
|
66
|
+
});
|
|
67
|
+
const skippedPaths = new Set(skipped.map((s) => s.path));
|
|
68
|
+
const scanned = project.targets.filter((t) => !skippedPaths.has(t.path));
|
|
69
|
+
const relative = (file) => path.relative(project.projectDir, file);
|
|
70
|
+
const skippedRelative = skipped.map((s) => ({ path: relative(s.path), reason: s.reason }));
|
|
71
|
+
if (targets.length === 0) {
|
|
72
|
+
const details = skippedRelative.map((s) => `${s.path}: ${s.reason}`).join(" / ");
|
|
73
|
+
throw new ScanToolError(skipped.every((s) => s.kind === "outside_allowed_root") ? "path_outside_allowed_root" : "no_manifest_found", `スキャンできるマニフェストがありません(${details})`);
|
|
74
|
+
}
|
|
75
|
+
const originals = new Map(targets.map((copy, i) => [copy.path, scanned[i].path]));
|
|
76
|
+
const incompleteRelative = incomplete.map((s) => ({ path: relative(s.path), reason: s.reason }));
|
|
77
|
+
const report = await withScopeNotes([...skippedRelative, ...incompleteRelative], () => snapshot.guard(() => runOsvScan(targets, options)));
|
|
78
|
+
return {
|
|
79
|
+
manifests: scanned.map((t) => relative(t.path)),
|
|
80
|
+
skipped: skippedRelative,
|
|
81
|
+
incomplete: incompleteRelative,
|
|
82
|
+
report: { ...report, source_files: report.source_files.map((file) => originals.get(file) ?? file) },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await snapshot.cleanup();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
35
89
|
export async function handleScanJavaProject(args, options = {}) {
|
|
36
90
|
try {
|
|
37
91
|
const project = await detectJavaProject(args.project_path, {
|
|
38
92
|
allowedRoot: options.allowedRoot,
|
|
39
93
|
});
|
|
40
94
|
const noRemoteResolution = isRemoteResolutionDisabled(options);
|
|
41
|
-
const report = await
|
|
95
|
+
const { manifests, skipped, incomplete, report } = await scanJavaManifests(project, { ...options, noRemoteResolution });
|
|
42
96
|
return jsonResult({
|
|
43
97
|
project_dir: project.projectDir,
|
|
44
|
-
manifests
|
|
45
|
-
...skippedManifestsFields(
|
|
98
|
+
manifests,
|
|
99
|
+
...skippedManifestsFields(skipped, incomplete),
|
|
46
100
|
dependency_resolution: dependencyResolution(noRemoteResolution),
|
|
47
101
|
...report,
|
|
48
102
|
});
|
|
@@ -4,15 +4,15 @@
|
|
|
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";
|
|
15
|
-
import {
|
|
14
|
+
import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
|
|
15
|
+
import { dependencyResolution, withScopeNotes } 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)からの推移的依存の解決を省略しています。" +
|
|
18
18
|
"lockfile(package-lock.json / poetry.lock / go.mod / gradle.lockfile等)に記録された依存は対象ですが、" +
|
|
@@ -88,30 +88,46 @@ 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, incomplete } = await snapshotManifests(snapshot, project.targets, {
|
|
100
|
+
projectDir: project.projectDir,
|
|
101
|
+
allowedRootReal: project.allowedRootReal,
|
|
102
|
+
});
|
|
103
|
+
// 親POMを再現できずにスキャンしたpom.xmlも、欠落の可能性としてcoverageに出す(complete=falseになる)
|
|
104
|
+
project.skippedFiles.push(...incomplete.map((s) => ({ path: path.relative(project.projectDir, s.path), reason: s.reason })));
|
|
105
|
+
if (skipped.length > 0) {
|
|
106
|
+
const skippedPaths = new Set(skipped.map((s) => s.path));
|
|
107
|
+
project.manifests = project.manifests.filter((m) => !skippedPaths.has(path.join(project.projectDir, m.path)));
|
|
108
|
+
project.skippedFiles.push(...skipped.map((s) => ({ path: path.relative(project.projectDir, s.path), reason: s.reason })));
|
|
109
|
+
if (project.manifests.length === 0) {
|
|
110
|
+
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(" / ")})`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const copy of project.requirementsCopies) {
|
|
114
|
+
if (copy.entries.length === 0)
|
|
115
|
+
continue;
|
|
116
|
+
targets.push({ path: await snapshot.writeGenerated(`${copy.entries.join("\n")}\n`), format: "requirements.txt" });
|
|
102
117
|
}
|
|
103
|
-
|
|
118
|
+
if (targets.length === 0)
|
|
119
|
+
return parseOsvScanOutput({ results: [] });
|
|
120
|
+
return await withScopeNotes(project.skippedFiles, () => snapshot.guard(() => runOsvScan(targets, options)));
|
|
104
121
|
}
|
|
105
122
|
finally {
|
|
106
|
-
|
|
107
|
-
await rm(dir, { recursive: true, force: true });
|
|
123
|
+
await snapshot.cleanup();
|
|
108
124
|
}
|
|
109
125
|
}
|
|
110
126
|
export async function handleScanProject(args, options = {}) {
|
|
111
127
|
try {
|
|
112
128
|
const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
|
|
113
129
|
const noRemoteResolution = isRemoteResolutionDisabled(options);
|
|
114
|
-
const report = await
|
|
130
|
+
const report = await scanFromSnapshot(project, { ...options, noRemoteResolution });
|
|
115
131
|
return jsonResult({
|
|
116
132
|
project_dir: project.projectDir,
|
|
117
133
|
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, incomplete, 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, incomplete),
|
|
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);
|