osv-scanner-mcp 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/dist/index.js +5 -4
- package/dist/osv/affectedVersions.js +54 -30
- package/dist/osv/scanReport.js +18 -4
- package/dist/osv/suggestFix.js +65 -4
- package/dist/osv/versionScheme.js +72 -19
- package/dist/tools/scanProject.js +73 -4
- package/dist/tools/suggestFix.js +4 -4
- package/dist/utils/dependencyRelations.js +269 -0
- package/dist/utils/pep440Version.js +100 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップするMCPサーバーです。Claude等のMCPクライアントから「このプロジェクトの脆弱性をチェックして」と自然言語で依頼するだけで、依存ライブラリの既知の脆弱性(CVE / GHSA)を深刻度順のレポートで取得できます。
|
|
9
9
|
|
|
10
|
-
> **ステータス**: [npmで公開中](https://www.npmjs.com/package/osv-scanner-mcp)(`npx -y osv-scanner-mcp`)。Java(Maven / Gradle)、JavaScript(npm / yarn / pnpm / bun)、Python(Poetry / uv / Pipenv / PDM / requirements.txt)、Go のlockfileに対応しています(
|
|
10
|
+
> **ステータス**: [npmで公開中](https://www.npmjs.com/package/osv-scanner-mcp)(`npx -y osv-scanner-mcp`)。Java(Maven / Gradle)、JavaScript(npm / yarn / pnpm / bun)、Python(Poetry / uv / Pipenv / PDM / requirements.txt)、Go のlockfileに対応しています(修正版の推奨も4言語に対応)。MCPクライアントは Claude Code / Claude Desktop / Codex CLI / Antigravity / VS Code(GitHub Copilot)での利用手順を用意しています。
|
|
11
11
|
|
|
12
12
|
## 特徴
|
|
13
13
|
|
|
@@ -200,7 +200,10 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
|
|
|
200
200
|
"vulnerable_package_count": 2,
|
|
201
201
|
"vulnerability_count": 4,
|
|
202
202
|
"severity_breakdown": { "critical": 0, "high": 2, "medium": 2, "low": 0, "unknown": 0 },
|
|
203
|
-
"packages": [
|
|
203
|
+
"packages": [
|
|
204
|
+
{ "name": "minimist", "version": "1.2.5", "ecosystem": "npm", "dependency_groups": ["dev"], "dependency_relation": "direct", "declared_in": ["package.json"], "vulnerabilities": [] },
|
|
205
|
+
{ "name": "qs", "version": "6.7.0", "ecosystem": "npm", "dependency_relation": "transitive", "introduced_by": ["express"], "vulnerabilities": [] }
|
|
206
|
+
]
|
|
204
207
|
}
|
|
205
208
|
```
|
|
206
209
|
|
|
@@ -211,8 +214,13 @@ api.osv.dev と deps.dev はどちらも Google が運営するサービスで
|
|
|
211
214
|
- `skipped_files`: スキャン対象から外したファイルと理由(requirements.txt自体が読めない・1MiBを超える場合、`pom.xml` の親POMが許可ルートの外を参照する場合)。親POMを読めず、親POMを含めずにスキャンした `pom.xml` もここに理由付きで示します([親POMの扱い](#親pomの扱い)を参照)
|
|
212
215
|
- 各一覧は200件までで、超えた分の件数を `omitted_items` に返します
|
|
213
216
|
- `ecosystem_breakdown` は、脆弱性0件のエコシステムも含めて「スキャンした」ことを示します
|
|
217
|
+
- `dependency_relation` は直接依存(`direct`)か推移的依存(`transitive`)かを示します。OSV-Scannerの出力にはこの区別がないため、本サーバーがスキャンしたファイルのコピーを解析して判定します:
|
|
218
|
+
- `package-lock.json`(v2以降): ルートとworkspaceのpackage.jsonの依存を、Nodeの解決規則(入れ子の `node_modules` から上位へ)で解決したものが直接依存、そこからたどれるものが推移的依存です。直接依存には宣言しているpackage.jsonを `declared_in` に、推移的依存にはそれを要求している直接依存の名前を `introduced_by`(最大10件、超えた分は `introduced_by_omitted`)に示します。直接依存でもあり他の依存からも要求される版は `direct` とし、`introduced_by` も付けます
|
|
219
|
+
- `go.mod`: `// indirect` の無い `require` が直接依存です。`replace` で置き換えているモジュールには `replaced_in_go_mod: true` を付けます(OSV-Scannerは置換先のパスと版で報告します)
|
|
220
|
+
- `requirements.txt`: ファイルに書かれた依存が直接依存、deps.devで解決された依存が推移的依存です
|
|
221
|
+
- 上記以外の形式(`pom.xml`、`gradle.lockfile`、`yarn.lock`、`pnpm-lock.yaml`、`bun.lock`、`poetry.lock`、`uv.lock`、`Pipfile.lock`、`pdm.lock`)と、lockfileVersion 1・どこからも要求されていないエントリは `unknown` です。複数のlockfileで判定が異なる場合は `mixed` です
|
|
214
222
|
- `dependency_groups` はOSV-Scannerが付けた依存グループ(例: `dev`)の生の値です。lockfileの形式によって欠落・不正確なため(pnpmでは付かず、pdmでは `optional` になる等)、参考情報として扱ってください
|
|
215
|
-
- 修正版の推奨(`suggest_fix`)はJava・JavaScript・Goに対応しています
|
|
223
|
+
- 修正版の推奨(`suggest_fix`)はJava・JavaScript・Python・Goに対応しています
|
|
216
224
|
|
|
217
225
|
### `scan_java_project`
|
|
218
226
|
|
|
@@ -277,7 +285,7 @@ OSV-Scannerは `pom.xml` の `<parent>` が参照する親POM(`<relativePath>`
|
|
|
277
285
|
```
|
|
278
286
|
|
|
279
287
|
- `packages` は最も深刻な脆弱性を持つ順、各 `vulnerabilities` は深刻度順(unknownは末尾)
|
|
280
|
-
- `fixed_versions` はOSVに記載された修正版です。MavenはMaven優先順位、npm・GoはSemantic Versioningの優先順位で昇順(SemVerとして解釈できない表記は末尾)、PyPI
|
|
288
|
+
- `fixed_versions` はOSVに記載された修正版です。MavenはMaven優先順位、npm・GoはSemantic Versioningの優先順位で昇順(SemVerとして解釈できない表記は末尾)、PyPIはPEP 440の優先順位で昇順(v0.5.0以前は記載順)、その他のエコシステムはOSVの記載順のまま(並び順は保証しません)。複数のリリース系統(例: 2.12系バックポートと2.15系)が混在することがあります。プレリリース版(`5.0.0-beta.3`)やGoの疑似バージョン(`0.0.0-20180925071336-cf3bd585ca2a`)が含まれることもあります。空配列は「OSVに修正版の記載がない」ことを意味します。v0.4.1以前はMaven以外のパッケージで常に空配列を返していました
|
|
281
289
|
- `severity_score` が取得できない脆弱性は `null` / `"unknown"` として扱います
|
|
282
290
|
|
|
283
291
|
### `scan_java_artifact`
|
|
@@ -333,7 +341,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
333
341
|
|
|
334
342
|
### `suggest_fix`
|
|
335
343
|
|
|
336
|
-
`scan_project` と同じ検出・スキャンを実行し、脆弱なパッケージごとに**推奨アップグレードバージョン**を提案します。推奨はJava(Maven / Gradle)・JavaScript(npm)・
|
|
344
|
+
`scan_project` と同じ検出・スキャンを実行し、脆弱なパッケージごとに**推奨アップグレードバージョン**を提案します。推奨はJava(Maven / Gradle)・JavaScript(npm)・Python(PyPI)・Goに対応しています。単純な最大バージョンではなく、現在のバージョンに最も近いリリース系統の修正版を3段階フォールバックで選定します:
|
|
337
345
|
|
|
338
346
|
| Tier | 意味 |
|
|
339
347
|
|---|---|
|
|
@@ -341,7 +349,7 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
341
349
|
| `major_internal` | 同一メジャー内の修正版(マイナーバージョンアップが必要) |
|
|
342
350
|
| `cross_major` | メジャーアップグレードが必要(破壊的変更の可能性あり) |
|
|
343
351
|
|
|
344
|
-
npm・Goの「同じ系統」は、npmの `^`(キャレット)
|
|
352
|
+
npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換とみなす範囲です(PyPIには共通の互換規則がありませんが、0.x系でマイナー更新が破壊的変更になるパッケージがあるため同じ規則で扱います。epochが変わる更新も `cross_major`)。1.0.0以上は Maven と同じく major.minor 単位ですが、0.x では同じ `0.minor` 内だけを同じ系統とし、マイナー更新(`0.3` → `0.4`)は `cross_major`、0.0.x ではどの更新も `cross_major` として扱います(SemVerでは0.xの更新は互換を保証しないため)。
|
|
345
353
|
|
|
346
354
|
**入力**: `scan_project` と同じ(`project_path`)
|
|
347
355
|
|
|
@@ -373,12 +381,14 @@ npm・Goの「同じ系統」は、npmの `^`(キャレット)が互換とみな
|
|
|
373
381
|
```
|
|
374
382
|
|
|
375
383
|
- `recommended_upgrade` は既知の修正版を候補に、修正対象の全CVEの影響範囲外と確認できたものを3段階Tier順・バージョン昇順で選びます。CVEごとの修正版の最大値を単純に採用せず、別系統で再び影響を受ける候補も除外します。全公開版の中での最小性や未検出の脆弱性がないことは保証しません。
|
|
376
|
-
- OSVの影響範囲(`introduced` / `fixed` / `last_affected` / 上限なし)を照合します。MavenはMavenの優先順位で `ECOSYSTEM` 範囲を、npm・GoはSemantic Versioningの優先順位で `SEMVER` / `ECOSYSTEM`
|
|
377
|
-
- プレリリース版(`5.0.0-beta.3`、`15.6.0-canary.61`、Go
|
|
384
|
+
- OSVの影響範囲(`introduced` / `fixed` / `last_affected` / 上限なし)を照合します。MavenはMavenの優先順位で `ECOSYSTEM` 範囲を、npm・GoはSemantic Versioningの優先順位で `SEMVER` / `ECOSYSTEM` 範囲を、PyPIはPEP 440の優先順位(`1.8c1` や `2.8.0-rc0` のような正規形でない表記も正規化)で `ECOSYSTEM` 範囲を使います。同じエントリに `ECOSYSTEM` 範囲があれば、コミット単位の `GIT` 範囲は無視します。`versions` に明示された影響も確認します(Gitのタグ名など版として解釈できない値は、解釈できる候補と一致しえないため無視します)。範囲欠落・不正・未対応形式(`GIT` 等)・`limit` による不完全な情報や、範囲の境界に解釈できない版(一部のGHSAに残る `19.03.9`、PyTorchの `2.6.0-cu124` のような表記)を含む場合は安全と推定せず、候補を検証できなければ `recommended_upgrade: null`、`verification: "no_verified_candidate"` を返します。
|
|
385
|
+
- プレリリース版(`5.0.0-beta.3`、`15.6.0-canary.61`、Goの疑似バージョン、PyPIの `rc`・`dev` 版。post版は正式版扱い)は、正式版の候補では全CVEを解消できない場合だけ推奨し、`recommended_is_prerelease: true` を付けます。同じTierのプレリリースより、上のTierの正式版を優先します。
|
|
378
386
|
- 推奨時は `verification: "verified"`、CVEごとの `recommended_status` は `affected` / `not_affected` / `unknown` です。推奨保留時は `not_evaluated` になります。`per_cve_detail.fixed_in` は各CVE単独の候補であり、最終推奨先の判定は `recommended_status` を参照してください。
|
|
379
387
|
- 現在より新しい修正版候補がないCVEは `tier: "unfixed"` として推奨の修正対象から除外します(情報欠落を含む場合があります)。除外したCVEも推奨先で判定し、その状態を表示します。全CVEがunfixedの場合も `recommended_upgrade` は `null` です。修正版の記載はあるがバージョンとして解釈できないCVE(SemVerでない `13.0` 等)は `tier: "unparseable_fix"` とし、修正版が無いとは扱わず修正対象に残すため、推奨は保留(`no_verified_candidate`)になります。
|
|
380
|
-
- npm・Goの提案には更新方法の `update_hint`
|
|
381
|
-
-
|
|
388
|
+
- npm・Go・PyPIの提案には更新方法の `update_hint` を付けます。PyPIでは、requirements.txtやpyproject.toml・Pipfileの指定を更新してlockfileを再生成し、推移的依存はpipの制約ファイル(`-c`)やuv・Poetryの上書き設定で版を指定します。推移的依存の場合、npmでは要求している直接依存の更新か、ルートの `package.json` の `overrides`(ルートのプロジェクトでのみ有効)で版を指定します。Goでは `go get <module>@<version>` で更新できます。Goのv2以上のメジャーは別のモジュールパス(`/v2` 等)としてOSV上も別パッケージになるため、新しいメジャー系列の修正版は候補に含まれません。現在の版が疑似バージョン(タグのないコミット)の場合は `upgrade_note` に示します。
|
|
389
|
+
- 各提案には `scan_project` と同じ `dependency_relation`(と `introduced_by` / `declared_in` / `replaced_in_go_mod`)を付け、`update_hint` を直接/推移的依存の別に応じて具体化します(npmの推移的依存なら `introduced_by` の直接依存の更新と `overrides`、Goの `replace` ならreplaceの版の更新、等)。`unknown` / `mixed` の場合は両方の場合を案内します。
|
|
390
|
+
- requirements.txtの `>=X` / `~=X` の行は、OSV-Scannerが下限Xを使用中の版とみなしてスキャンしています。この依存の提案には `version_is_lower_bound: true` を付け、推奨は「下限を推奨版以上に引き上げる」意味であること(実際にインストールされる版とは異なりうること)を `upgrade_note` に示します。
|
|
391
|
+
- 推奨に未対応のエコシステム(SBOM由来のRubyGems等)は `verification: "unsupported_ecosystem"`、現在の版をバージョンとして解釈できない場合(npmのgit・ローカルパス依存等)は `verification: "unparseable_version"` を返し、どちらもCVEごとの `tier: "unsupported"` として `unfixed` には数えません(修正版の有無は判定していないため。修正版は `scan_project` の `fixed_versions` や `explain_vulnerability` で確認できます)。
|
|
382
392
|
- 応答の `coverage` は `scan_project` と同じです。lockfileの無いマニフェストや外したファイルがあれば `complete: false` になり、それらの依存は提案に含まれません。v0.4.2以前の `skipped_manifests` / `scope_warning` は `coverage.skipped_files` / `coverage.warning` に統合しました。
|
|
383
393
|
|
|
384
394
|
### `explain_vulnerability`
|
|
@@ -465,7 +475,8 @@ npm run build # dist/ へビルド
|
|
|
465
475
|
- [x] `scan_java_artifact` ツール: JAR/WAR実体スキャン(lockfileが無い・shaded/fat JARのみのプロジェクト向け)
|
|
466
476
|
- [x] `scan_project` ツール: Java / JavaScript / Python / Go のlockfileをまとめてスキャン
|
|
467
477
|
- [x] `suggest_fix` のJavaScript / Go対応(semver)
|
|
468
|
-
- [
|
|
478
|
+
- [x] `suggest_fix` のPython対応(PEP 440)
|
|
479
|
+
- [x] 直接/推移的依存の区別(npm・Go・requirements.txt)
|
|
469
480
|
|
|
470
481
|
## ライセンス
|
|
471
482
|
|
package/dist/index.js
CHANGED
|
@@ -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.
|
|
39
|
+
version: "0.7.0",
|
|
40
40
|
});
|
|
41
41
|
server.registerTool("scan_project", {
|
|
42
42
|
title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
|
|
@@ -45,7 +45,8 @@ server.registerTool("scan_project", {
|
|
|
45
45
|
"Python(poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt)、Go(go.mod)。" +
|
|
46
46
|
"パッケージマネージャーやビルドは実行しない。" +
|
|
47
47
|
"応答先頭のcoverageを必ず確認すること: lockfileが無いマニフェスト、バージョン未固定のrequirements行、スキャン対象から外したファイルを示す。" +
|
|
48
|
-
"
|
|
48
|
+
"各パッケージのdependency_relationは直接依存(direct)か推移的依存(transitive)か(package-lock.json・go.mod・requirements.txtで判定。それ以外はunknown)。" +
|
|
49
|
+
"coverage.complete=falseの場合は、検出0件でも安全とは判断しないこと。修正版の推奨(suggest_fix)はJava・JavaScript・Python・Goに対応。",
|
|
49
50
|
inputSchema: {
|
|
50
51
|
project_path: z
|
|
51
52
|
.string()
|
|
@@ -69,10 +70,10 @@ server.registerTool("scan_java_project", {
|
|
|
69
70
|
server.registerTool("suggest_fix", {
|
|
70
71
|
title: "脆弱性を解消する推奨アップグレードの提案",
|
|
71
72
|
description: "scan_projectと同じ検出でプロジェクトをスキャンし、脆弱な依存パッケージごとに推奨アップグレードバージョンを提案する。" +
|
|
72
|
-
"推奨はJava(Maven / Gradle)・JavaScript(npm)・
|
|
73
|
+
"推奨はJava(Maven / Gradle)・JavaScript(npm)・Python(PyPI)・Goに対応。" +
|
|
73
74
|
"現在のバージョンに最も近いリリース系統の修正版を優先する3段階フォールバック" +
|
|
74
75
|
"(same_minor: 同一系統内 → major_internal: 同一メジャー内 → cross_major: メジャーアップグレード)で選定し、" +
|
|
75
|
-
"推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。npm・Goでは0.x系のマイナー更新もcross_major(破壊的変更の可能性)。" +
|
|
76
|
+
"推奨バージョン・アップグレード距離(upgrade_tier)・CVEごとの修正版を返す。npm・Go・PyPIでは0.x系のマイナー更新もcross_major(破壊的変更の可能性)。requirements.txtの下限(>=)でスキャンした依存はversion_is_lower_boundを付け、推奨は下限の引き上げを意味する。直接/推移的依存の別(dependency_relation、npmはintroduced_by・declared_in)に応じて更新方法(update_hint)を示す。" +
|
|
76
77
|
"候補を全修正対象CVEの影響範囲と照合し、情報不足の場合は推奨を保留する。プレリリース版は正式版で解消できない場合だけ推奨し、recommended_is_prereleaseを付ける。" +
|
|
77
78
|
"現在より新しい修正版候補のないCVEはunfixedとして別表示し、推奨先での判定も返す。" +
|
|
78
79
|
"応答のcoverageを必ず確認すること(complete=falseなら提案に含まれない依存がある)。" +
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { asArray, asRecord, asString } from "../utils/unknownJson.js";
|
|
2
|
-
import { versionRangeTypes, versionSchemeFor } from "./versionScheme.js";
|
|
2
|
+
import { samePackageName, versionRangeTypes, versionSchemeFor } from "./versionScheme.js";
|
|
3
3
|
/**
|
|
4
4
|
* Preserve uncertainty: unsupported or malformed ranges cannot prove a candidate safe.
|
|
5
5
|
* 比較・範囲の型はエコシステム別(versionScheme.ts)。比較器のないエコシステムは常に情報不足。
|
|
6
6
|
* 解釈できない版(SemVerでない値等)を含む範囲は判定に使わず、情報不足とする。
|
|
7
|
+
* - `versions[]`の解釈できない値(Gitのタグ名等)は無視する。`versions[]`は等価判定にだけ使い、
|
|
8
|
+
* 解釈できない文字列は解釈できる候補と等しくなりえないため、無視しても候補の判定は変わらない
|
|
9
|
+
* - 同じaffectedエントリにECOSYSTEM等の版の範囲があれば、GIT範囲(コミット単位)は無視する。GIT範囲だけなら情報不足
|
|
7
10
|
*/
|
|
8
11
|
export function extractAffectedVersions(details, ids, name, ecosystem) {
|
|
9
12
|
const scheme = versionSchemeFor(ecosystem);
|
|
@@ -18,7 +21,8 @@ export function extractAffectedVersions(details, ids, name, ecosystem) {
|
|
|
18
21
|
for (const raw of asArray(detail.affected)) {
|
|
19
22
|
const affected = asRecord(raw);
|
|
20
23
|
const pkg = asRecord(affected?.package);
|
|
21
|
-
|
|
24
|
+
const affectedName = asString(pkg?.name);
|
|
25
|
+
if (affectedName === null || !samePackageName(affectedName, name, ecosystem) || asString(pkg?.ecosystem) !== ecosystem) {
|
|
22
26
|
if (!asString(pkg?.name) || !asString(pkg?.ecosystem))
|
|
23
27
|
evidence.complete = false;
|
|
24
28
|
continue;
|
|
@@ -28,30 +32,32 @@ export function extractAffectedVersions(details, ids, name, ecosystem) {
|
|
|
28
32
|
evidence.complete = false;
|
|
29
33
|
for (const version of asArray(affected?.versions)) {
|
|
30
34
|
const value = asString(version);
|
|
31
|
-
if (!value
|
|
35
|
+
if (!value)
|
|
32
36
|
evidence.complete = false;
|
|
33
|
-
else
|
|
37
|
+
else if (valid(value))
|
|
34
38
|
evidence.versions.push(value);
|
|
35
39
|
}
|
|
36
40
|
const ranges = asArray(affected?.ranges);
|
|
37
41
|
// A versions-only list does not establish that unlisted releases are unaffected.
|
|
38
42
|
if (ranges.length === 0)
|
|
39
43
|
evidence.complete = false;
|
|
44
|
+
const hasVersionRange = ranges.some((r) => rangeTypes.has(asString(asRecord(r)?.type) ?? ""));
|
|
40
45
|
for (const rawRange of ranges) {
|
|
41
46
|
const range = asRecord(rawRange);
|
|
42
47
|
const type = asString(range?.type);
|
|
48
|
+
if (type === "GIT" && hasVersionRange)
|
|
49
|
+
continue;
|
|
43
50
|
if (range === null || type === null || !rangeTypes.has(type)) {
|
|
44
51
|
evidence.complete = false;
|
|
45
52
|
continue;
|
|
46
53
|
}
|
|
47
54
|
const events = asArray(range.events);
|
|
48
|
-
let start = null;
|
|
49
|
-
let previousEnd = null;
|
|
50
|
-
let previousInclusive = false;
|
|
51
|
-
/** この区間の境界に解釈できない版がある(区間を判定に使わない) */
|
|
52
|
-
let startInvalid = false;
|
|
53
55
|
if (events.length === 0)
|
|
54
56
|
evidence.complete = false;
|
|
57
|
+
// OSVの仕様では範囲内のeventsの並び順は保証されず、評価時に版の順に並べる(実データ: PYSECの
|
|
58
|
+
// `introduced 2.0.0 → fixed 2.0.6, introduced 0 → fixed 1.26.17`)。仕様どおり並べてから区間にする
|
|
59
|
+
const parsed = [];
|
|
60
|
+
let usable = true;
|
|
55
61
|
for (const rawEvent of events) {
|
|
56
62
|
const event = asRecord(rawEvent);
|
|
57
63
|
const keys = event ? Object.keys(event) : [];
|
|
@@ -61,37 +67,55 @@ export function extractAffectedVersions(details, ids, name, ecosystem) {
|
|
|
61
67
|
evidence.complete = false;
|
|
62
68
|
continue;
|
|
63
69
|
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
70
|
+
// 解釈できない境界を含む範囲は判定に使わない
|
|
71
|
+
if (!(kind === "introduced" && value === "0") && !valid(value)) {
|
|
72
|
+
usable = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// limit bounds known affected versions, but is not evidence of a fix beyond it.
|
|
76
|
+
if (kind === "limit")
|
|
66
77
|
evidence.complete = false;
|
|
78
|
+
parsed.push({ kind: kind, value });
|
|
79
|
+
}
|
|
80
|
+
if (!usable) {
|
|
81
|
+
evidence.complete = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// 同じ版では終点(fixed等)を始点(introduced)より前に置く(その版を影響ありとみなす安全側)
|
|
85
|
+
const rank = (kind) => (kind === "introduced" ? 1 : 0);
|
|
86
|
+
const isZero = (e) => e.kind === "introduced" && e.value === "0";
|
|
87
|
+
parsed.sort((a, b) => {
|
|
88
|
+
// introduced: "0" はすべての版より前
|
|
89
|
+
if (isZero(a) || isZero(b))
|
|
90
|
+
return isZero(a) === isZero(b) ? 0 : isZero(a) ? -1 : 1;
|
|
91
|
+
return compare(a.value, b.value) || rank(a.kind) - rank(b.kind);
|
|
92
|
+
});
|
|
93
|
+
// 並べた結果が「始点 → 終点」の交互にならない(始点の重複、始点のない終点)場合は解釈が曖昧なため使わない
|
|
94
|
+
const intervals = [];
|
|
95
|
+
let start = null;
|
|
96
|
+
for (const { kind, value } of parsed) {
|
|
67
97
|
if (kind === "introduced") {
|
|
68
|
-
if (start !== null
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
if (start !== null) {
|
|
99
|
+
usable = false;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
72
102
|
start = value;
|
|
73
|
-
startInvalid = invalid;
|
|
74
103
|
}
|
|
75
104
|
else {
|
|
76
105
|
if (start === null) {
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
usable = false;
|
|
107
|
+
break;
|
|
79
108
|
}
|
|
80
|
-
|
|
81
|
-
if (!invalid && !startInvalid && start !== "0" && (compare(start, value) > 0 ||
|
|
82
|
-
(!inclusive && compare(start, value) === 0)))
|
|
83
|
-
evidence.complete = false;
|
|
84
|
-
if (!invalid && !startInvalid)
|
|
85
|
-
evidence.intervals.push({ introduced: start, end: value, inclusive });
|
|
86
|
-
// limit bounds known affected versions, but is not evidence of a fix beyond it.
|
|
87
|
-
if (kind === "limit")
|
|
88
|
-
evidence.complete = false;
|
|
89
|
-
previousEnd = invalid ? null : value;
|
|
90
|
-
previousInclusive = inclusive;
|
|
109
|
+
intervals.push({ introduced: start, end: value, inclusive: kind === "last_affected" });
|
|
91
110
|
start = null;
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
|
-
if (
|
|
113
|
+
if (!usable || parsed.every((e) => e.kind !== "introduced")) {
|
|
114
|
+
evidence.complete = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
evidence.intervals.push(...intervals);
|
|
118
|
+
if (start !== null)
|
|
95
119
|
evidence.intervals.push({ introduced: start, end: null, inclusive: false });
|
|
96
120
|
}
|
|
97
121
|
}
|
package/dist/osv/scanReport.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
|
|
16
16
|
import { extractAffectedVersions } from "./affectedVersions.js";
|
|
17
|
-
import { sortVersions, versionRangeTypes } from "./versionScheme.js";
|
|
17
|
+
import { samePackageName, sortVersions, versionRangeTypes } from "./versionScheme.js";
|
|
18
18
|
/** 外部由来のsummaryをLLMに渡す際の長さ上限(プロンプト肥大・DoS対策) */
|
|
19
19
|
const MAX_SUMMARY_LENGTH = 500;
|
|
20
20
|
const CVSS_CRITICAL = 9.0;
|
|
@@ -77,7 +77,7 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
|
|
|
77
77
|
if (!affected)
|
|
78
78
|
continue;
|
|
79
79
|
const affectedName = asString(asRecord(affected.package)?.name);
|
|
80
|
-
if (affectedName
|
|
80
|
+
if (affectedName === null || !samePackageName(affectedName, packageName, ecosystem) || asString(asRecord(affected.package)?.ecosystem) !== ecosystem)
|
|
81
81
|
continue;
|
|
82
82
|
for (const rangeRaw of asArray(affected.ranges)) {
|
|
83
83
|
const type = asString(asRecord(rangeRaw)?.type);
|
|
@@ -93,6 +93,16 @@ function extractFixedVersions(vulnDetails, packageName, ecosystem) {
|
|
|
93
93
|
}
|
|
94
94
|
return sortVersions(versions, ecosystem);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* パッケージごとのスキャン元ファイル(osv-scannerの`results[].source.path`)。
|
|
98
|
+
* 応答のJSONには出さない(既存の応答を変えない)ため、パッケージのオブジェクトをキーに別に持つ。
|
|
99
|
+
* 直接/推移的依存の判定(dependencyRelations.ts)が、どのlockfileの依存かを知るために使う
|
|
100
|
+
*/
|
|
101
|
+
const packageSourceMap = new WeakMap();
|
|
102
|
+
/** parseOsvScanOutputが返したパッケージのスキャン元ファイル。それ以外のオブジェクトは空 */
|
|
103
|
+
export function packageSources(pkg) {
|
|
104
|
+
return packageSourceMap.get(pkg) ?? [];
|
|
105
|
+
}
|
|
96
106
|
/**
|
|
97
107
|
* OSV-Scannerの`--format json`出力を`scan_java_project`のレポートに変換する。
|
|
98
108
|
*
|
|
@@ -123,9 +133,11 @@ export function parseOsvScanOutput(raw) {
|
|
|
123
133
|
const key = `${ecosystem}:${name}@${version}`;
|
|
124
134
|
let entry = packageMap.get(key);
|
|
125
135
|
if (!entry) {
|
|
126
|
-
entry = { name, version, ecosystem, groups: new Set(), vulns: new Map() };
|
|
136
|
+
entry = { name, version, ecosystem, groups: new Set(), sources: new Set(), vulns: new Map() };
|
|
127
137
|
packageMap.set(key, entry);
|
|
128
138
|
}
|
|
139
|
+
if (sourcePath !== null)
|
|
140
|
+
entry.sources.add(sourcePath);
|
|
129
141
|
for (const group of asStrings(pkgObj.dependency_groups))
|
|
130
142
|
entry.groups.add(group);
|
|
131
143
|
const details = asArray(pkgObj.vulnerabilities);
|
|
@@ -177,13 +189,15 @@ function buildReport(sourceFiles, packageMap) {
|
|
|
177
189
|
severityBreakdown[vuln.severity]++;
|
|
178
190
|
vulnerabilityCount++;
|
|
179
191
|
}
|
|
180
|
-
|
|
192
|
+
const pkg = {
|
|
181
193
|
name: entry.name,
|
|
182
194
|
version: entry.version,
|
|
183
195
|
ecosystem: entry.ecosystem,
|
|
184
196
|
...(entry.groups.size > 0 ? { dependency_groups: [...entry.groups].sort() } : {}),
|
|
185
197
|
vulnerabilities,
|
|
186
198
|
};
|
|
199
|
+
packageSourceMap.set(pkg, [...entry.sources]);
|
|
200
|
+
return pkg;
|
|
187
201
|
})
|
|
188
202
|
.sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
|
|
189
203
|
return {
|
package/dist/osv/suggestFix.js
CHANGED
|
@@ -24,13 +24,67 @@ const UPDATE_HINTS = {
|
|
|
24
24
|
"ルートのpackage.jsonのoverrides(ルートのプロジェクトでのみ有効)で版を指定します",
|
|
25
25
|
Go: "go get <module>@<version>で更新します(推移的依存もgo.modのrequireに追加されて更新されます)。" +
|
|
26
26
|
"Goではv2以上のメジャーは別のモジュールパス(/v2等)として別パッケージ扱いのため、新しいメジャー系列の修正版はここに含まれません",
|
|
27
|
+
PyPI: "requirements.txtの版の指定、またはpyproject.toml・Pipfileの指定を更新し、lockfile(poetry.lock・uv.lock等)を再生成します。" +
|
|
28
|
+
"推移的依存の場合は、pipの制約ファイル(-c)や、uv・Poetry等の上書き設定で版を指定します",
|
|
27
29
|
};
|
|
28
30
|
/** Goの疑似バージョン(タグのないコミット): 末尾がタイムスタンプ14桁-コミットハッシュ12桁 */
|
|
29
31
|
const GO_PSEUDO_VERSION = /(?:^|[.-])\d{14}-[0-9a-f]{12}(?:\+|$)/;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const GO_MAJOR_NOTE = "Goではv2以上のメジャーは別のモジュールパス(/v2等)として別パッケージ扱いのため、新しいメジャー系列の修正版はここに含まれません";
|
|
33
|
+
/**
|
|
34
|
+
* 推奨版への更新方法。直接/推移的依存の別が分かれば具体化し、分からなければ(unknown・mixed)両方を案内する
|
|
35
|
+
*/
|
|
36
|
+
function updateHint(pkg) {
|
|
37
|
+
const relation = pkg.dependency_relation;
|
|
38
|
+
const by = pkg.introduced_by?.join("、");
|
|
39
|
+
switch (pkg.ecosystem) {
|
|
40
|
+
case "npm":
|
|
41
|
+
if (relation === "direct") {
|
|
42
|
+
return `直接依存です。${pkg.declared_in?.join("、") ?? "package.json"}の指定を更新します` +
|
|
43
|
+
(by ? `。${by}からも推移的に要求されているため、それらの更新が必要な場合もあります` : "");
|
|
44
|
+
}
|
|
45
|
+
if (relation === "transitive") {
|
|
46
|
+
return `推移的依存です。${by ? `要求している直接依存(${by})` : "要求している直接依存"}を、推奨版以上を要求する版に更新します。` +
|
|
47
|
+
"直接依存の更新で直らない場合は、ルートのpackage.jsonのoverrides(ルートのプロジェクトでのみ有効)で版を指定します";
|
|
48
|
+
}
|
|
49
|
+
return UPDATE_HINTS.npm;
|
|
50
|
+
case "Go": {
|
|
51
|
+
if (pkg.replaced_in_go_mod) {
|
|
52
|
+
return `go.modのreplaceで置き換えているため、requireではなくreplaceの版を更新します。${GO_MAJOR_NOTE}`;
|
|
53
|
+
}
|
|
54
|
+
if (relation === "direct")
|
|
55
|
+
return `直接依存です。go get <module>@<version>で更新します。${GO_MAJOR_NOTE}`;
|
|
56
|
+
if (relation === "transitive") {
|
|
57
|
+
return "間接依存(go.modの// indirect)です。go get <module>@<version>でgo.modの版を引き上げられます" +
|
|
58
|
+
`(依存元のモジュールの更新で解消できる場合もあります)。${GO_MAJOR_NOTE}`;
|
|
59
|
+
}
|
|
60
|
+
return UPDATE_HINTS.Go;
|
|
61
|
+
}
|
|
62
|
+
case "PyPI":
|
|
63
|
+
if (relation === "direct") {
|
|
64
|
+
return "直接依存です。requirements.txtの版の指定、またはpyproject.toml・Pipfileの指定を更新し、lockfileを再生成します";
|
|
65
|
+
}
|
|
66
|
+
if (relation === "transitive") {
|
|
67
|
+
return "推移的依存です。それを要求している直接依存の更新か、pipの制約ファイル(-c)、uv・Poetry等の上書き設定で版を指定します";
|
|
68
|
+
}
|
|
69
|
+
return UPDATE_HINTS.PyPI;
|
|
70
|
+
default:
|
|
71
|
+
return UPDATE_HINTS[pkg.ecosystem];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function hintFields(pkg) {
|
|
75
|
+
const hint = updateHint(pkg);
|
|
32
76
|
return hint === undefined ? {} : { update_hint: hint };
|
|
33
77
|
}
|
|
78
|
+
/** スキャン結果に付いた直接/推移的依存の項目を提案にも写す(無ければ何も出さない) */
|
|
79
|
+
function relationFields(pkg) {
|
|
80
|
+
return {
|
|
81
|
+
...(pkg.dependency_relation !== undefined ? { dependency_relation: pkg.dependency_relation } : {}),
|
|
82
|
+
...(pkg.introduced_by !== undefined ? { introduced_by: pkg.introduced_by } : {}),
|
|
83
|
+
...(pkg.introduced_by_omitted !== undefined ? { introduced_by_omitted: pkg.introduced_by_omitted } : {}),
|
|
84
|
+
...(pkg.declared_in !== undefined ? { declared_in: pkg.declared_in } : {}),
|
|
85
|
+
...(pkg.replaced_in_go_mod ? { replaced_in_go_mod: true } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
34
88
|
/**
|
|
35
89
|
* 未対応エコシステム・解釈できない現在の版は推奨を出さず、CVEをunfixedにも数えない。
|
|
36
90
|
* 修正版の抽出がMaven専用だった頃、そのまま処理すると修正版のある脆弱性を
|
|
@@ -41,6 +95,7 @@ function notEvaluatedSuggestion(pkg, verification) {
|
|
|
41
95
|
package: pkg.name,
|
|
42
96
|
current_version: pkg.version,
|
|
43
97
|
ecosystem: pkg.ecosystem,
|
|
98
|
+
...relationFields(pkg),
|
|
44
99
|
recommended_upgrade: null,
|
|
45
100
|
upgrade_tier: null,
|
|
46
101
|
upgrade_note: (verification === "unsupported_ecosystem"
|
|
@@ -100,6 +155,10 @@ function buildNote(scheme, pkg, recommended, tier, fixableCount, unfixedCount) {
|
|
|
100
155
|
if (unfixedCount > 0)
|
|
101
156
|
note += `。残り${unfixedCount}件は現在より新しい修正版候補がなく、修正対象から除外しています。recommended_statusを確認してください`;
|
|
102
157
|
}
|
|
158
|
+
if (pkg.version_is_lower_bound) {
|
|
159
|
+
note += `。現在の版(${pkg.version})はrequirements.txtの下限(>=・~=)で、実際にインストールされる版とは異なる可能性があります。` +
|
|
160
|
+
"推奨は下限をその版以上に引き上げる意味です";
|
|
161
|
+
}
|
|
103
162
|
if (pkg.ecosystem === "Go" && GO_PSEUDO_VERSION.test(pkg.version)) {
|
|
104
163
|
note += "。現在の版は疑似バージョン(タグのないコミット)です";
|
|
105
164
|
}
|
|
@@ -111,7 +170,7 @@ export function suggestUpgradeForPackage(pkg) {
|
|
|
111
170
|
if (scheme === null)
|
|
112
171
|
return notEvaluatedSuggestion(pkg, "unsupported_ecosystem");
|
|
113
172
|
if (!scheme.isValid(pkg.version))
|
|
114
|
-
return { ...notEvaluatedSuggestion(pkg, "unparseable_version"), ...hintFields(pkg
|
|
173
|
+
return { ...notEvaluatedSuggestion(pkg, "unparseable_version"), ...hintFields(pkg) };
|
|
115
174
|
const details = pkg.vulnerabilities.map((vuln) => {
|
|
116
175
|
const fix = rankCandidates(scheme, pkg.version, vuln.fixed_versions)[0];
|
|
117
176
|
return {
|
|
@@ -139,6 +198,7 @@ export function suggestUpgradeForPackage(pkg) {
|
|
|
139
198
|
package: pkg.name,
|
|
140
199
|
current_version: pkg.version,
|
|
141
200
|
ecosystem: pkg.ecosystem,
|
|
201
|
+
...relationFields(pkg),
|
|
142
202
|
recommended_upgrade: recommended,
|
|
143
203
|
upgrade_tier: upgradeTier,
|
|
144
204
|
...(recommended !== null && scheme.isPrerelease(recommended) ? { recommended_is_prerelease: true } : {}),
|
|
@@ -146,7 +206,8 @@ export function suggestUpgradeForPackage(pkg) {
|
|
|
146
206
|
? "既知の修正版候補から、全修正対象CVEの影響範囲外と確認できる版が見つかりません。情報不足・未対応の範囲形式を含む場合も推奨を保留します。" +
|
|
147
207
|
(unparseableCount > 0 ? `${unparseableCount}件のCVEは修正版の記載をバージョンとして解釈できないため(tier: unparseable_fix)、推奨を保留しています。` : "")
|
|
148
208
|
: buildNote(scheme, pkg, recommended, upgradeTier, fixableCount, unfixedCount),
|
|
149
|
-
...hintFields(pkg
|
|
209
|
+
...hintFields(pkg),
|
|
210
|
+
...(pkg.version_is_lower_bound ? { version_is_lower_bound: true } : {}),
|
|
150
211
|
per_cve_detail: details,
|
|
151
212
|
verification: recommended === null ? "no_verified_candidate" : "verified",
|
|
152
213
|
};
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - Maven: Mavenの優先順位(mavenVersion.ts)。範囲はECOSYSTEM型
|
|
5
5
|
* - npm・Go: SemVer 2.0.0の優先順位(semverVersion.ts)。範囲はSEMVER型とECOSYSTEM型
|
|
6
|
-
* -
|
|
6
|
+
* - PyPI: PEP 440の優先順位(pep440Version.ts)。範囲はECOSYSTEM型。名前はPEP 503で正規化して照合する
|
|
7
|
+
* - その他: 比較器なし。範囲はECOSYSTEM型
|
|
7
8
|
*
|
|
8
9
|
* GIT型の範囲(コミットハッシュ)はリリース版と無関係のため、どのエコシステムでも版として扱わない。
|
|
9
10
|
*/
|
|
10
11
|
import { compareMavenVersions, mavenVersionSeries } from "../utils/mavenVersion.js";
|
|
12
|
+
import { compareParsedPep440, parsePep440 } from "../utils/pep440Version.js";
|
|
11
13
|
import { compareParsedSemver, parseSemver } from "../utils/semverVersion.js";
|
|
12
14
|
const SEMVER_ECOSYSTEMS = new Set(["npm", "Go"]);
|
|
13
15
|
const MAVEN_SCHEME = {
|
|
@@ -41,16 +43,9 @@ const SEMVER_SCHEME = {
|
|
|
41
43
|
classify(current, candidate) {
|
|
42
44
|
const a = parseSemver(current);
|
|
43
45
|
const b = parseSemver(candidate);
|
|
44
|
-
if (a === null || b === null
|
|
46
|
+
if (a === null || b === null)
|
|
45
47
|
return "cross_major";
|
|
46
|
-
|
|
47
|
-
if (a.minor !== b.minor)
|
|
48
|
-
return "cross_major";
|
|
49
|
-
if (a.minor === 0 && a.patch !== b.patch)
|
|
50
|
-
return "cross_major";
|
|
51
|
-
return "same_minor";
|
|
52
|
-
}
|
|
53
|
-
return a.minor === b.minor ? "same_minor" : "major_internal";
|
|
48
|
+
return classifyCaret(a, b);
|
|
54
49
|
},
|
|
55
50
|
seriesLabel(version) {
|
|
56
51
|
const v = parseSemver(version);
|
|
@@ -59,14 +54,74 @@ const SEMVER_SCHEME = {
|
|
|
59
54
|
return v.major === 0 && v.minor === 0 ? `0.0.${v.patch}` : `${v.major}.${v.minor}`;
|
|
60
55
|
},
|
|
61
56
|
};
|
|
57
|
+
/** major・minor・patchから、npmのキャレットに合わせた距離を判定する(npm・Go・PyPI共通) */
|
|
58
|
+
function classifyCaret(a, b) {
|
|
59
|
+
if (a.major !== b.major)
|
|
60
|
+
return "cross_major";
|
|
61
|
+
if (Number(a.major) === 0) {
|
|
62
|
+
if (a.minor !== b.minor)
|
|
63
|
+
return "cross_major";
|
|
64
|
+
if (Number(a.minor) === 0 && a.patch !== b.patch)
|
|
65
|
+
return "cross_major";
|
|
66
|
+
return "same_minor";
|
|
67
|
+
}
|
|
68
|
+
return a.minor === b.minor ? "same_minor" : "major_internal";
|
|
69
|
+
}
|
|
70
|
+
function pep440Parts(v) {
|
|
71
|
+
return { major: v.release[0] ?? 0n, minor: v.release[1] ?? 0n, patch: v.release[2] ?? 0n };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* PyPI: PEP 440。共通の互換規則はないが、0.x系でマイナー更新が破壊的変更になるパッケージが実在する(FastAPI等)ため、
|
|
75
|
+
* npm・Goと同じ規則で破壊的変更の可能性を少なく見積もらない側に倒す。epochが変わる更新もcross_major。
|
|
76
|
+
* プレリリースはプレリリース・dev版(post版は正式版扱い)。
|
|
77
|
+
*/
|
|
78
|
+
const PEP440_SCHEME = {
|
|
79
|
+
isValid: (version) => parsePep440(version) !== null,
|
|
80
|
+
compare(a, b) {
|
|
81
|
+
const x = parsePep440(a);
|
|
82
|
+
const y = parsePep440(b);
|
|
83
|
+
return x === null || y === null ? 0 : compareParsedPep440(x, y);
|
|
84
|
+
},
|
|
85
|
+
isPrerelease(version) {
|
|
86
|
+
const v = parsePep440(version);
|
|
87
|
+
return v !== null && (v.pre !== null || v.dev !== null);
|
|
88
|
+
},
|
|
89
|
+
classify(current, candidate) {
|
|
90
|
+
const a = parsePep440(current);
|
|
91
|
+
const b = parsePep440(candidate);
|
|
92
|
+
if (a === null || b === null || a.epoch !== b.epoch)
|
|
93
|
+
return "cross_major";
|
|
94
|
+
return classifyCaret(pep440Parts(a), pep440Parts(b));
|
|
95
|
+
},
|
|
96
|
+
seriesLabel(version) {
|
|
97
|
+
const v = parsePep440(version);
|
|
98
|
+
if (v === null)
|
|
99
|
+
return null;
|
|
100
|
+
const { major, minor, patch } = pep440Parts(v);
|
|
101
|
+
const epoch = v.epoch === 0n ? "" : `${v.epoch}!`;
|
|
102
|
+
return major === 0n && minor === 0n ? `${epoch}0.0.${patch}` : `${epoch}${major}.${minor}`;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
62
105
|
/** 修正版の推奨に対応するエコシステムの操作。未対応ならnull */
|
|
63
106
|
export function versionSchemeFor(ecosystem) {
|
|
64
107
|
if (ecosystem === "Maven")
|
|
65
108
|
return MAVEN_SCHEME;
|
|
66
109
|
if (SEMVER_ECOSYSTEMS.has(ecosystem))
|
|
67
110
|
return SEMVER_SCHEME;
|
|
111
|
+
if (ecosystem === "PyPI")
|
|
112
|
+
return PEP440_SCHEME;
|
|
68
113
|
return null;
|
|
69
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* OSVレコードのパッケージ名とスキャン結果の名前が同じパッケージか。
|
|
117
|
+
* PyPIはPEP 503の正規化(小文字化、`-` `_` `.`の連続を`-`)で比較する(実データでは完全一致したが防御的に)
|
|
118
|
+
*/
|
|
119
|
+
export function samePackageName(a, b, ecosystem) {
|
|
120
|
+
if (ecosystem !== "PyPI")
|
|
121
|
+
return a === b;
|
|
122
|
+
const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
123
|
+
return normalize(a) === normalize(b);
|
|
124
|
+
}
|
|
70
125
|
/** 修正版・影響範囲として読み取るOSVの範囲の型 */
|
|
71
126
|
export function versionRangeTypes(ecosystem) {
|
|
72
127
|
if (ecosystem === "Maven")
|
|
@@ -77,20 +132,18 @@ export function versionRangeTypes(ecosystem) {
|
|
|
77
132
|
}
|
|
78
133
|
/**
|
|
79
134
|
* 重複を除いた版の一覧を、エコシステムの優先順位で昇順に並べる。
|
|
80
|
-
* - npm・Go:
|
|
135
|
+
* - npm・Go・PyPI: 解釈できない版は末尾に記載順のまま置く
|
|
81
136
|
* - 比較器のないエコシステム: 記載順のまま返す
|
|
82
137
|
*/
|
|
83
138
|
export function sortVersions(versions, ecosystem) {
|
|
84
139
|
const unique = [...new Set(versions)];
|
|
85
140
|
if (ecosystem === "Maven")
|
|
86
141
|
return unique.sort(compareMavenVersions);
|
|
87
|
-
|
|
142
|
+
const scheme = versionSchemeFor(ecosystem);
|
|
143
|
+
if (scheme === null)
|
|
88
144
|
return unique;
|
|
89
|
-
const parsed = unique.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const unparsed = unique.filter((raw) => parseSemver(raw) === null);
|
|
94
|
-
parsed.sort((a, b) => compareParsedSemver(a.version, b.version) || (a.raw < b.raw ? -1 : a.raw > b.raw ? 1 : 0));
|
|
95
|
-
return [...parsed.map((p) => p.raw), ...unparsed];
|
|
145
|
+
const parsed = unique.filter((raw) => scheme.isValid(raw));
|
|
146
|
+
const unparsed = unique.filter((raw) => !scheme.isValid(raw));
|
|
147
|
+
parsed.sort((a, b) => scheme.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
148
|
+
return [...parsed, ...unparsed];
|
|
96
149
|
}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { ScanToolError } from "../errors.js";
|
|
9
9
|
import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
|
|
10
|
-
import { parseOsvScanOutput } from "../osv/scanReport.js";
|
|
10
|
+
import { packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
|
|
11
|
+
import { combineRelations, goModRelations, npmLockRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
|
|
11
12
|
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
13
|
+
import { readRegularFile } from "../utils/safeRead.js";
|
|
12
14
|
import { detectProject } from "../utils/manifestDetector.js";
|
|
13
15
|
import { normalizePypiName } from "../utils/requirementsFile.js";
|
|
14
16
|
import { ScanSnapshot, snapshotManifests } from "../utils/scanSnapshot.js";
|
|
@@ -78,7 +80,7 @@ function ecosystemBreakdown(project, packages) {
|
|
|
78
80
|
return breakdown;
|
|
79
81
|
}
|
|
80
82
|
/** `>=X` / `~=X` の行は、osv-scannerが下限Xを使用中の版とみなしてスキャンしている */
|
|
81
|
-
function markLowerBounds(project, packages) {
|
|
83
|
+
export function markLowerBounds(project, packages) {
|
|
82
84
|
return packages.map((pkg) => pkg.ecosystem === "PyPI" &&
|
|
83
85
|
project.lowerBounds.some((lb) => lb.name === normalizePypiName(pkg.name) && lb.version === pkg.version)
|
|
84
86
|
? { ...pkg, version_is_lower_bound: true }
|
|
@@ -90,6 +92,65 @@ function markLowerBounds(project, packages) {
|
|
|
90
92
|
* 検証済みの正規化行だけを書いたコピー。コピーできず外したファイルはskippedFilesに記録する。
|
|
91
93
|
* スナップショットは成功・失敗とも削除する。suggest_fixも同じスキャンを使う。
|
|
92
94
|
*/
|
|
95
|
+
/** 直接/推移的依存の判定で読むpackage-lock.json・go.modの合計サイズの上限(LockfileKeyCacheと同じ) */
|
|
96
|
+
const MAX_RELATION_LOCKFILE_BYTES = 256 * 1024 * 1024;
|
|
97
|
+
const MAX_INTRODUCED_BY = 10;
|
|
98
|
+
/**
|
|
99
|
+
* スキャンしたファイル(スナップショットのコピー)ごとに、直接/推移的依存の判定を用意する。
|
|
100
|
+
* 解析するのは検証済みのコピーだけで、元のファイルは読まない。判定できない形式・上限超過はnull(unknown)。
|
|
101
|
+
*/
|
|
102
|
+
export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_LOCKFILE_BYTES) {
|
|
103
|
+
const lookups = new Map();
|
|
104
|
+
let remaining = maxTotalBytes;
|
|
105
|
+
for (const { copy, format, originalRelative, entries } of copies) {
|
|
106
|
+
const lockDir = originalRelative === null ? "" : path.dirname(originalRelative);
|
|
107
|
+
let lookup = null;
|
|
108
|
+
try {
|
|
109
|
+
if (format === "package-lock.json" || format === "go.mod") {
|
|
110
|
+
// 残りの予算を上限に、読み込みの途中で打ち切る(予算を超えるファイルは全体を読まずにunknownにする)
|
|
111
|
+
const read = await readRegularFile(copy, { maxBytes: remaining });
|
|
112
|
+
if (read.ok) {
|
|
113
|
+
remaining -= read.bytes.length;
|
|
114
|
+
const text = read.bytes.toString("utf8");
|
|
115
|
+
lookup = format === "go.mod" ? goModRelations(text) : npmLockRelations(JSON.parse(text));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (format === "requirements.txt" && entries !== undefined) {
|
|
119
|
+
lookup = requirementsRelations(entries);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
lookup = null; // 解析できないファイルはunknown(スキャン自体は続ける)
|
|
124
|
+
}
|
|
125
|
+
lookups.set(copy, { lookup, lockDir });
|
|
126
|
+
}
|
|
127
|
+
return lookups;
|
|
128
|
+
}
|
|
129
|
+
/** パッケージに直接/推移的依存の別を付ける。項目は脆弱性の一覧より前に置く */
|
|
130
|
+
function annotateRelations(packages, lookups) {
|
|
131
|
+
return packages.map((pkg) => {
|
|
132
|
+
const infos = [];
|
|
133
|
+
const declaredIn = new Set();
|
|
134
|
+
for (const source of packageSources(pkg)) {
|
|
135
|
+
const entry = lookups.get(source);
|
|
136
|
+
const info = entry?.lookup ? entry.lookup(pkg.name, pkg.version) : { relation: "unknown" };
|
|
137
|
+
infos.push(info);
|
|
138
|
+
for (const manifest of info.declaredIn ?? [])
|
|
139
|
+
declaredIn.add(path.join(entry.lockDir, manifest));
|
|
140
|
+
}
|
|
141
|
+
const { relation, introducedBy, replaced } = combineRelations(infos);
|
|
142
|
+
const { vulnerabilities, ...rest } = pkg;
|
|
143
|
+
return {
|
|
144
|
+
...rest,
|
|
145
|
+
dependency_relation: relation,
|
|
146
|
+
...(introducedBy.length > 0 ? { introduced_by: introducedBy.slice(0, MAX_INTRODUCED_BY).map(sanitizeExternalText) } : {}),
|
|
147
|
+
...(introducedBy.length > MAX_INTRODUCED_BY ? { introduced_by_omitted: introducedBy.length - MAX_INTRODUCED_BY } : {}),
|
|
148
|
+
...(declaredIn.size > 0 ? { declared_in: [...declaredIn].sort().map(sanitizeExternalText) } : {}),
|
|
149
|
+
...(replaced ? { replaced_in_go_mod: true } : {}),
|
|
150
|
+
vulnerabilities,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
93
154
|
export async function scanFromSnapshot(project, options) {
|
|
94
155
|
const snapshot = await ScanSnapshot.create();
|
|
95
156
|
try {
|
|
@@ -97,6 +158,10 @@ export async function scanFromSnapshot(project, options) {
|
|
|
97
158
|
projectDir: project.projectDir,
|
|
98
159
|
allowedRootReal: project.allowedRootReal,
|
|
99
160
|
});
|
|
161
|
+
// コピーと元のファイルの対応(snapshotManifestsは外したもの以外を入力の順に返す)
|
|
162
|
+
const skippedOriginals = new Set(skipped.map((s) => s.path));
|
|
163
|
+
const scannedOriginals = project.targets.filter((t) => !skippedOriginals.has(t.path));
|
|
164
|
+
const relationSources = targets.map((copy, i) => ({ copy: copy.path, format: copy.format, originalRelative: path.relative(project.projectDir, scannedOriginals[i].path) }));
|
|
100
165
|
// 親POMを再現できずにスキャンしたpom.xmlも、欠落の可能性としてcoverageに出す(complete=falseになる)
|
|
101
166
|
project.skippedFiles.push(...incomplete.map((s) => ({ path: path.relative(project.projectDir, s.path), reason: s.reason })));
|
|
102
167
|
if (skipped.length > 0) {
|
|
@@ -110,11 +175,15 @@ export async function scanFromSnapshot(project, options) {
|
|
|
110
175
|
for (const copy of project.requirementsCopies) {
|
|
111
176
|
if (copy.entries.length === 0)
|
|
112
177
|
continue;
|
|
113
|
-
|
|
178
|
+
const generated = await snapshot.writeGenerated(`${copy.entries.join("\n")}\n`);
|
|
179
|
+
targets.push({ path: generated, format: "requirements.txt" });
|
|
180
|
+
relationSources.push({ copy: generated, format: "requirements.txt", originalRelative: copy.path, entries: copy.entries });
|
|
114
181
|
}
|
|
115
182
|
if (targets.length === 0)
|
|
116
183
|
return parseOsvScanOutput({ results: [] });
|
|
117
|
-
|
|
184
|
+
const report = await withScopeNotes(project.skippedFiles, () => snapshot.guard(() => runOsvScan(targets, options)));
|
|
185
|
+
// 判定はコピーを読むため、スナップショットを消す前に行う
|
|
186
|
+
return { ...report, packages: annotateRelations(report.packages, await buildRelationLookups(relationSources)) };
|
|
118
187
|
}
|
|
119
188
|
finally {
|
|
120
189
|
await snapshot.cleanup();
|
package/dist/tools/suggestFix.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `suggest_fix`ツールのハンドラ。
|
|
3
3
|
* scan_projectと同じ検出・スキャンを実行し、脆弱なパッケージごとの
|
|
4
|
-
* 推奨アップグレードバージョン(3段階Tier)を返す。推奨はMaven・npm・Goに対応し、
|
|
5
|
-
*
|
|
4
|
+
* 推奨アップグレードバージョン(3段階Tier)を返す。推奨はMaven・npm・Go・PyPIに対応し、
|
|
5
|
+
* それ以外はunsupported_ecosystemとして返す。
|
|
6
6
|
*/
|
|
7
7
|
import { isRemoteResolutionDisabled } from "../osv/runner.js";
|
|
8
8
|
import { suggestUpgrades } from "../osv/suggestFix.js";
|
|
9
9
|
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
10
10
|
import { detectProject } from "../utils/manifestDetector.js";
|
|
11
11
|
import { dependencyResolution, } from "./scanJavaProject.js";
|
|
12
|
-
import { buildCoverage, scanFromSnapshot, TRANSITIVE_OMITTED_WARNING } from "./scanProject.js";
|
|
12
|
+
import { buildCoverage, markLowerBounds, scanFromSnapshot, TRANSITIVE_OMITTED_WARNING } from "./scanProject.js";
|
|
13
13
|
import { errorResult, jsonResult } from "./toolResult.js";
|
|
14
14
|
export async function handleSuggestFix(args, options = {}) {
|
|
15
15
|
try {
|
|
16
16
|
const project = await detectProject(args.project_path, { allowedRoot: options.allowedRoot });
|
|
17
17
|
const noRemoteResolution = isRemoteResolutionDisabled(options);
|
|
18
18
|
const report = await scanFromSnapshot(project, { ...options, noRemoteResolution });
|
|
19
|
-
const suggestions = suggestUpgrades(report.packages);
|
|
19
|
+
const suggestions = suggestUpgrades(markLowerBounds(project, report.packages));
|
|
20
20
|
const unfixedVulnerabilities = suggestions.reduce((sum, s) => sum + s.per_cve_detail.filter((d) => d.tier === "unfixed").length, 0);
|
|
21
21
|
return jsonResult({
|
|
22
22
|
project_dir: project.projectDir,
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 直接/推移的依存の判定(docs/DESIGN_TODO.md「直接/推移的依存の区別(v0.7.0)詳細設計メモ」)。
|
|
3
|
+
*
|
|
4
|
+
* osv-scannerの出力には直接/推移的の区別がないため、スキャンしたファイル(スナップショットのコピー)を解析する。
|
|
5
|
+
* 新しい外部依存は追加せず、JSON(package-lock.json)と行形式(go.mod、requirements.txt)だけを扱う。
|
|
6
|
+
* - package-lock.json(v2以降): ルートとworkspaceの依存をNodeの解決規則で解決したものが直接依存、
|
|
7
|
+
* そこからたどれるものが推移的依存。経由した直接依存の名前(introduced_by)と、宣言したpackage.json(declared_in)を返す
|
|
8
|
+
* - go.mod: `// indirect`のないrequireが直接依存。replaceの置換先にも同じ関係を当てる(osv-scannerは置換先で報告する)
|
|
9
|
+
* - requirements.txt: 本サーバーが書いたコピーの行が直接依存、それ以外(deps.devで解決された依存)が推移的依存
|
|
10
|
+
*/
|
|
11
|
+
/** たどる依存の辺の上限(巨大・悪意あるlockfileで処理が膨らまないように) */
|
|
12
|
+
export const MAX_DEPENDENCY_EDGES = 2_000_000;
|
|
13
|
+
const UNKNOWN = { relation: "unknown" };
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function depNames(entry, fields) {
|
|
18
|
+
const names = [];
|
|
19
|
+
for (const field of fields) {
|
|
20
|
+
const deps = entry[field];
|
|
21
|
+
if (isRecord(deps))
|
|
22
|
+
names.push(...Object.keys(deps));
|
|
23
|
+
}
|
|
24
|
+
return names;
|
|
25
|
+
}
|
|
26
|
+
const NODE_MODULES = "node_modules/";
|
|
27
|
+
const INSTALLED_DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
|
|
28
|
+
const ROOT_DEP_FIELDS = [...INSTALLED_DEP_FIELDS, "devDependencies"];
|
|
29
|
+
/** エントリの本来の名前: `name`があればそれ(別名のインストール)、なければキーの最後のnode_modules/以降 */
|
|
30
|
+
function entryName(key, entry) {
|
|
31
|
+
if (typeof entry.name === "string" && entry.name !== "")
|
|
32
|
+
return entry.name;
|
|
33
|
+
const index = key.lastIndexOf(NODE_MODULES);
|
|
34
|
+
return index === -1 ? key : key.slice(index + NODE_MODULES.length);
|
|
35
|
+
}
|
|
36
|
+
/** ルート("")とworkspace(node_modules/の外のディレクトリ)が起点 */
|
|
37
|
+
function isRoot(key) {
|
|
38
|
+
return key === "" || (!key.startsWith(NODE_MODULES) && !key.includes(`/${NODE_MODULES}`));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* package-lock.json(v2以降)の依存の関係を判定する。`packages`が無い(v1形式)・上限を超える場合はnull。
|
|
42
|
+
*/
|
|
43
|
+
export function npmLockRelations(lock, maxEdges = MAX_DEPENDENCY_EDGES) {
|
|
44
|
+
const packages = isRecord(lock) ? lock.packages : undefined;
|
|
45
|
+
if (!isRecord(packages))
|
|
46
|
+
return null;
|
|
47
|
+
const entries = new Map();
|
|
48
|
+
for (const [key, value] of Object.entries(packages))
|
|
49
|
+
if (isRecord(value))
|
|
50
|
+
entries.set(key, value);
|
|
51
|
+
/** link(workspaceへのシンボリックリンク)をたどった実体のキー */
|
|
52
|
+
const follow = (key) => {
|
|
53
|
+
const entry = entries.get(key);
|
|
54
|
+
if (entry === undefined)
|
|
55
|
+
return null;
|
|
56
|
+
if (entry.link === true)
|
|
57
|
+
return typeof entry.resolved === "string" && entries.has(entry.resolved) ? entry.resolved : null;
|
|
58
|
+
return key;
|
|
59
|
+
};
|
|
60
|
+
/** Nodeの解決規則: <from>/node_modules/<名前>から上位のnode_modulesへ順に探す */
|
|
61
|
+
const resolve = (from, dep) => {
|
|
62
|
+
let base = from;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const candidate = `${base === "" ? "" : `${base}/`}${NODE_MODULES}${dep}`;
|
|
65
|
+
if (entries.has(candidate))
|
|
66
|
+
return follow(candidate);
|
|
67
|
+
if (base === "")
|
|
68
|
+
return null;
|
|
69
|
+
const parent = base.lastIndexOf(`/${NODE_MODULES}`);
|
|
70
|
+
base = parent === -1 ? "" : base.slice(0, parent);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
let edges = 0;
|
|
74
|
+
const direct = new Map(); // エントリ → 宣言したpackage.json
|
|
75
|
+
const introducedBy = new Map(); // エントリ → 経由した直接依存の名前
|
|
76
|
+
for (const root of [...entries.keys()].filter(isRoot)) {
|
|
77
|
+
const manifest = root === "" ? "package.json" : `${root}/package.json`;
|
|
78
|
+
for (const dep of depNames(entries.get(root), ROOT_DEP_FIELDS)) {
|
|
79
|
+
if (++edges > maxEdges)
|
|
80
|
+
return null;
|
|
81
|
+
const target = resolve(root, dep);
|
|
82
|
+
if (target === null || isRoot(target))
|
|
83
|
+
continue; // 未インストール、またはworkspace自身
|
|
84
|
+
let declared = direct.get(target);
|
|
85
|
+
if (declared === undefined)
|
|
86
|
+
direct.set(target, (declared = new Set()));
|
|
87
|
+
declared.add(manifest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 直接依存ごとに、到達できるエントリへその名前を記録する(訪問済みを記録して循環で止める)
|
|
91
|
+
for (const origin of direct.keys()) {
|
|
92
|
+
const originName = entryName(origin, entries.get(origin));
|
|
93
|
+
const visited = new Set([origin]);
|
|
94
|
+
const queue = [origin];
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const current = queue.pop();
|
|
97
|
+
for (const dep of depNames(entries.get(current), INSTALLED_DEP_FIELDS)) {
|
|
98
|
+
if (++edges > maxEdges)
|
|
99
|
+
return null;
|
|
100
|
+
const target = resolve(current, dep);
|
|
101
|
+
if (target === null || isRoot(target) || visited.has(target))
|
|
102
|
+
continue;
|
|
103
|
+
visited.add(target);
|
|
104
|
+
queue.push(target);
|
|
105
|
+
let names = introducedBy.get(target);
|
|
106
|
+
if (names === undefined)
|
|
107
|
+
introducedBy.set(target, (names = new Set()));
|
|
108
|
+
names.add(originName);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 名前と版ごとにまとめる(同じ名前と版が複数の位置にインストールされうる)
|
|
113
|
+
const byPackage = new Map();
|
|
114
|
+
for (const [key, entry] of entries) {
|
|
115
|
+
if (isRoot(key) || entry.link === true || typeof entry.version !== "string")
|
|
116
|
+
continue;
|
|
117
|
+
const id = `${entryName(key, entry)}@${entry.version}`;
|
|
118
|
+
let info = byPackage.get(id);
|
|
119
|
+
if (info === undefined)
|
|
120
|
+
byPackage.set(id, (info = { direct: false, transitive: false, introducedBy: new Set(), declaredIn: new Set() }));
|
|
121
|
+
const declared = direct.get(key);
|
|
122
|
+
if (declared !== undefined) {
|
|
123
|
+
info.direct = true;
|
|
124
|
+
for (const manifest of declared)
|
|
125
|
+
info.declaredIn.add(manifest);
|
|
126
|
+
}
|
|
127
|
+
const names = introducedBy.get(key);
|
|
128
|
+
if (names !== undefined) {
|
|
129
|
+
info.transitive = true;
|
|
130
|
+
for (const name of names)
|
|
131
|
+
info.introducedBy.add(name);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return (name, version) => {
|
|
135
|
+
const info = byPackage.get(`${name}@${version}`);
|
|
136
|
+
if (info === undefined || (!info.direct && !info.transitive))
|
|
137
|
+
return UNKNOWN; // 未収録、またはどこからも到達しない
|
|
138
|
+
return {
|
|
139
|
+
relation: info.direct ? "direct" : "transitive",
|
|
140
|
+
...(info.introducedBy.size > 0 ? { introducedBy: info.introducedBy } : {}),
|
|
141
|
+
...(info.declaredIn.size > 0 ? { declaredIn: info.declaredIn } : {}),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** go.modの1行からコメントを除いた本体と、`// indirect`の有無 */
|
|
146
|
+
function splitGoComment(line) {
|
|
147
|
+
const index = line.indexOf("//");
|
|
148
|
+
if (index === -1)
|
|
149
|
+
return { body: line.trim(), indirect: false };
|
|
150
|
+
// Goと同じく、コメントが`indirect`だけか`indirect;`で始まる場合に間接依存とみなす
|
|
151
|
+
return { body: line.slice(0, index).trim(), indirect: /^indirect(?:;|$)/.test(line.slice(index + 2).trim()) };
|
|
152
|
+
}
|
|
153
|
+
/** go.modの版(`v1.2.3`)を、osv-scannerの報告と同じ`v`なしの形にする */
|
|
154
|
+
function stripV(version) {
|
|
155
|
+
return version.startsWith("v") ? version.slice(1) : version;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* go.modの依存の関係を判定する(requireの`// indirect`で判定し、版には依らない)。
|
|
159
|
+
* requireの単一行・括弧のブロック、replaceの単一行・ブロックを扱う。
|
|
160
|
+
* replaceはGoと同じく、版を限定したもの(`replace a v1.0.0 => ...`)はrequireの版が一致する場合だけ、
|
|
161
|
+
* 版を限定しないものは全版に適用する(同じモジュールでは版を限定したものが優先)。osv-scanner 2.4.0で同じ挙動を確認。
|
|
162
|
+
* 適用されたreplaceの置換先(osv-scannerはこのパスと版で報告する)に、置換元と同じ関係とreplacedを付ける。
|
|
163
|
+
*/
|
|
164
|
+
export function goModRelations(text) {
|
|
165
|
+
const requires = new Map();
|
|
166
|
+
const replaces = [];
|
|
167
|
+
let block = null;
|
|
168
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
169
|
+
const { body, indirect } = splitGoComment(raw);
|
|
170
|
+
if (body === "")
|
|
171
|
+
continue;
|
|
172
|
+
if (block !== null && body === ")") {
|
|
173
|
+
block = null;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
let directive = block;
|
|
177
|
+
let rest = body;
|
|
178
|
+
if (block === null) {
|
|
179
|
+
const head = /^(require|replace)\b\s*(.*)$/.exec(body);
|
|
180
|
+
if (head === null)
|
|
181
|
+
continue;
|
|
182
|
+
directive = head[1];
|
|
183
|
+
rest = head[2].trim();
|
|
184
|
+
if (rest === "(") {
|
|
185
|
+
block = directive;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const fields = rest.split(/\s+/);
|
|
190
|
+
if (directive === "require" && fields.length >= 2) {
|
|
191
|
+
const existing = requires.get(fields[0]);
|
|
192
|
+
// 同じモジュールが複数回requireされた場合は、直接依存を優先する
|
|
193
|
+
if (existing?.relation !== "direct") {
|
|
194
|
+
requires.set(fields[0], { relation: indirect ? "transitive" : "direct", version: stripV(fields[1]) });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (directive === "replace") {
|
|
198
|
+
const arrow = fields.indexOf("=>");
|
|
199
|
+
if ((arrow === 1 || arrow === 2) && fields[arrow + 1] !== undefined) {
|
|
200
|
+
replaces.push({
|
|
201
|
+
from: fields[0],
|
|
202
|
+
fromVersion: arrow === 2 ? stripV(fields[1]) : null,
|
|
203
|
+
to: fields[arrow + 1],
|
|
204
|
+
toVersion: fields[arrow + 2] !== undefined ? stripV(fields[arrow + 2]) : null,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** osv-scannerが報告する名前 → 関係(置換されたモジュールは置換先の名前) */
|
|
210
|
+
const relations = new Map();
|
|
211
|
+
/** 適用されたreplaceで報告される「名前@版」(ローカルへの置換は置換元の名前) */
|
|
212
|
+
const replaced = new Set();
|
|
213
|
+
const replacedLocal = new Set();
|
|
214
|
+
const setRelation = (name, relation) => {
|
|
215
|
+
if (relations.get(name) !== "direct")
|
|
216
|
+
relations.set(name, relation);
|
|
217
|
+
};
|
|
218
|
+
for (const [modulePath, { relation, version }] of requires) {
|
|
219
|
+
setRelation(modulePath, relation);
|
|
220
|
+
const replace = replaces.find((r) => r.from === modulePath && r.fromVersion === version) ??
|
|
221
|
+
replaces.find((r) => r.from === modulePath && r.fromVersion === null);
|
|
222
|
+
if (replace === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
if (replace.toVersion === null) {
|
|
225
|
+
replacedLocal.add(modulePath);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
setRelation(replace.to, relation);
|
|
229
|
+
replaced.add(`${replace.to}@${replace.toVersion}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return (name, version) => {
|
|
233
|
+
const relation = relations.get(name);
|
|
234
|
+
if (relation === undefined)
|
|
235
|
+
return UNKNOWN;
|
|
236
|
+
return replaced.has(`${name}@${version}`) || replacedLocal.has(name) ? { relation, replaced: true } : { relation };
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** PEP 503の正規化 */
|
|
240
|
+
function normalizePypi(name) {
|
|
241
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* requirements.txtのコピーに書いた行(`名前==版`等)から関係を判定する。
|
|
245
|
+
* 書いた名前が直接依存、それ以外(deps.devで解決された依存)が推移的依存。
|
|
246
|
+
*/
|
|
247
|
+
export function requirementsRelations(entries) {
|
|
248
|
+
const direct = new Set();
|
|
249
|
+
for (const line of entries) {
|
|
250
|
+
const name = /^[A-Za-z0-9][A-Za-z0-9._-]*/.exec(line)?.[0];
|
|
251
|
+
if (name !== undefined)
|
|
252
|
+
direct.add(normalizePypi(name));
|
|
253
|
+
}
|
|
254
|
+
return (name) => ({ relation: direct.has(normalizePypi(name)) ? "direct" : "transitive" });
|
|
255
|
+
}
|
|
256
|
+
/** 複数のファイルでの判定をまとめる。ファイルごとの関係がすべて同じならその値、異なればmixed */
|
|
257
|
+
export function combineRelations(infos) {
|
|
258
|
+
const relations = new Set(infos.map((info) => info.relation));
|
|
259
|
+
const relation = relations.size === 1 ? [...relations][0] : relations.size === 0 ? "unknown" : "mixed";
|
|
260
|
+
const introducedBy = new Set();
|
|
261
|
+
const declaredIn = new Set();
|
|
262
|
+
for (const info of infos) {
|
|
263
|
+
for (const name of info.introducedBy ?? [])
|
|
264
|
+
introducedBy.add(name);
|
|
265
|
+
for (const manifest of info.declaredIn ?? [])
|
|
266
|
+
declaredIn.add(manifest);
|
|
267
|
+
}
|
|
268
|
+
return { relation, introducedBy: [...introducedBy].sort(), declaredIn: [...declaredIn].sort(), replaced: infos.some((info) => info.replaced) };
|
|
269
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PEP 440(Pythonパッケージのバージョン)の解釈と優先順位比較。
|
|
3
|
+
*
|
|
4
|
+
* 正規化と比較はPyPA `packaging`(packaging.version)と同じ規則に従う:
|
|
5
|
+
* - 大文字小文字を無視し、先頭の`v`と前後の空白を許す
|
|
6
|
+
* - プレリリースの別表記: alpha→a、beta→b、c/pre/preview→rc。区切り文字(`.` `-` `_`)と番号は省略可(番号の省略は0)
|
|
7
|
+
* - post版: `.post1`、`-1`、`rev1`、`r1`。dev版: `.dev1`
|
|
8
|
+
* - ローカル版: `+`以降。区切りは`.` `-` `_`
|
|
9
|
+
* - 比較: epoch → リリース番号(末尾のゼロは無視: `1.0` = `1.0.0`) → プレリリース → post → dev → ローカル版
|
|
10
|
+
* `1.0.dev1` < `1.0a1.dev1` < `1.0a1` < `1.0a1.post1` < `1.0b1` < `1.0rc1` < `1.0` < `1.0.post1.dev1` < `1.0.post1` < `1.0.post1+local`
|
|
11
|
+
*
|
|
12
|
+
* 実データ(docs/DESIGN_TODO.md「suggest_fix PyPI対応」)では、Djangoの`1.8c1`、TensorFlowの`2.8.0-rc0`のような
|
|
13
|
+
* 正規形でない値と、PyTorchの`2.6.0-cu124`のようなPEP 440でない値がある。後者はnullを返す。
|
|
14
|
+
* 数値はBigIntで保持する(2^53を超える値を丸めない)。
|
|
15
|
+
*/
|
|
16
|
+
const PEP440_RE = new RegExp("^\\s*v?" +
|
|
17
|
+
"(?:(?<epoch>[0-9]+)!)?" +
|
|
18
|
+
"(?<release>[0-9]+(?:\\.[0-9]+)*)" +
|
|
19
|
+
"(?<pre>[-_.]?(?<pre_l>alpha|a|beta|b|preview|pre|c|rc)[-_.]?(?<pre_n>[0-9]+)?)?" +
|
|
20
|
+
"(?<post>(?:-(?<post_n1>[0-9]+))|(?:[-_.]?(?<post_l>post|rev|r)[-_.]?(?<post_n2>[0-9]+)?))?" +
|
|
21
|
+
"(?<dev>[-_.]?(?<dev_l>dev)[-_.]?(?<dev_n>[0-9]+)?)?" +
|
|
22
|
+
"(?:\\+(?<local>[a-z0-9]+(?:[-_.][a-z0-9]+)*))?" +
|
|
23
|
+
"\\s*$", "i");
|
|
24
|
+
const PRE_KINDS = {
|
|
25
|
+
a: "a", alpha: "a", b: "b", beta: "b", c: "rc", pre: "rc", preview: "rc", rc: "rc",
|
|
26
|
+
};
|
|
27
|
+
/** PEP 440として解釈する。解釈できなければnull */
|
|
28
|
+
export function parsePep440(version) {
|
|
29
|
+
const m = PEP440_RE.exec(version);
|
|
30
|
+
const g = m?.groups;
|
|
31
|
+
if (!g)
|
|
32
|
+
return null;
|
|
33
|
+
const preLetter = g.pre_l?.toLowerCase();
|
|
34
|
+
return {
|
|
35
|
+
epoch: BigInt(g.epoch ?? "0"),
|
|
36
|
+
release: g.release.split(".").map(BigInt),
|
|
37
|
+
pre: preLetter === undefined ? null : { kind: PRE_KINDS[preLetter], number: BigInt(g.pre_n ?? "0") },
|
|
38
|
+
post: g.post === undefined ? null : BigInt(g.post_n1 ?? g.post_n2 ?? "0"),
|
|
39
|
+
dev: g.dev === undefined ? null : BigInt(g.dev_n ?? "0"),
|
|
40
|
+
local: g.local === undefined ? null : g.local.toLowerCase().split(/[-_.]/).map((s) => (/^\d+$/.test(s) ? BigInt(s) : s)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function cmp(a, b) {
|
|
44
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
45
|
+
}
|
|
46
|
+
const PRE_ORDER = { a: 0, b: 1, rc: 2 };
|
|
47
|
+
/** プレリリースの順位: dev版だけの版はすべてのプレリリースより前、プレリリースのない版は後 */
|
|
48
|
+
function preKey(v) {
|
|
49
|
+
if (v.pre === null)
|
|
50
|
+
return v.post === null && v.dev !== null ? [0, 0, 0n] : [2, 0, 0n];
|
|
51
|
+
return [1, PRE_ORDER[v.pre.kind], v.pre.number];
|
|
52
|
+
}
|
|
53
|
+
function compareRelease(a, b) {
|
|
54
|
+
// 末尾のゼロは比較に影響しない(1.0 = 1.0.0)
|
|
55
|
+
const length = Math.max(a.length, b.length);
|
|
56
|
+
for (let i = 0; i < length; i++) {
|
|
57
|
+
const result = cmp(a[i] ?? 0n, b[i] ?? 0n);
|
|
58
|
+
if (result !== 0)
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
function compareLocal(a, b) {
|
|
64
|
+
if (a === null || b === null)
|
|
65
|
+
return a === b ? 0 : a === null ? -1 : 1;
|
|
66
|
+
const length = Math.min(a.length, b.length);
|
|
67
|
+
for (let i = 0; i < length; i++) {
|
|
68
|
+
const x = a[i];
|
|
69
|
+
const y = b[i];
|
|
70
|
+
if (typeof x === typeof y) {
|
|
71
|
+
const result = cmp(x, y);
|
|
72
|
+
if (result !== 0)
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// 数値の区切りは英字の区切りより大きい
|
|
77
|
+
return typeof x === "bigint" ? 1 : -1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return cmp(a.length, b.length);
|
|
81
|
+
}
|
|
82
|
+
/** 解釈済みの2つの版をPEP 440の優先順位で比較する */
|
|
83
|
+
export function compareParsedPep440(a, b) {
|
|
84
|
+
const [ap0, ap1, ap2] = preKey(a);
|
|
85
|
+
const [bp0, bp1, bp2] = preKey(b);
|
|
86
|
+
return (cmp(a.epoch, b.epoch) ||
|
|
87
|
+
compareRelease(a.release, b.release) ||
|
|
88
|
+
cmp(ap0, bp0) || cmp(ap1, bp1) || cmp(ap2, bp2) ||
|
|
89
|
+
// post版のない版はpost版より前
|
|
90
|
+
cmp(a.post === null ? -1n : a.post, b.post === null ? -1n : b.post) ||
|
|
91
|
+
// dev版でない版はdev版より後
|
|
92
|
+
(a.dev === b.dev ? 0 : a.dev === null ? 1 : b.dev === null ? -1 : cmp(a.dev, b.dev)) ||
|
|
93
|
+
compareLocal(a.local, b.local));
|
|
94
|
+
}
|
|
95
|
+
/** 2つの版をPEP 440の優先順位で比較する。どちらかを解釈できなければnull */
|
|
96
|
+
export function comparePep440(a, b) {
|
|
97
|
+
const x = parsePep440(a);
|
|
98
|
+
const y = parsePep440(b);
|
|
99
|
+
return x === null || y === null ? null : compareParsedPep440(x, y);
|
|
100
|
+
}
|