osv-scanner-mcp 0.12.0 → 0.13.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 +14 -11
- package/dist/index.js +9 -7
- package/dist/osv/candidateCheck.js +19 -2
- package/dist/osv/suggestFix.js +106 -9
- package/dist/osv/versionScheme.js +4 -1
- package/dist/tools/scanProject.js +12 -3
- package/dist/tools/suggestFix.js +1 -1
- package/dist/utils/cargoLock.js +1 -1
- package/dist/utils/dependencyRelations.js +107 -1
- package/dist/utils/manifestDetector.js +34 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,19 +7,19 @@
|
|
|
7
7
|
|
|
8
8
|
An MCP server that wraps Google's [OSV-Scanner](https://github.com/google/osv-scanner). Ask an MCP client such as Claude to "check this project for vulnerabilities", and it returns the known vulnerabilities (CVE / GHSA) in your dependencies as a report sorted by severity, together with recommended upgrades.
|
|
9
9
|
|
|
10
|
-
> **Status**: [Published on npm](https://www.npmjs.com/package/osv-scanner-mcp) (`npx -y osv-scanner-mcp`). Supports lockfiles and manifests for Java (Maven / Gradle), JavaScript (npm / yarn / pnpm / bun), Python (Poetry / uv / Pipenv / PDM / requirements.txt), Go, and Rust (`Cargo.lock`), with upgrade recommendations for
|
|
10
|
+
> **Status**: [Published on npm](https://www.npmjs.com/package/osv-scanner-mcp) (`npx -y osv-scanner-mcp`). Supports lockfiles and manifests for Java (Maven / Gradle), JavaScript (npm / yarn / pnpm / bun), Python (Poetry / uv / Pipenv / PDM / requirements.txt), Go, and Rust (`Cargo.lock`), with upgrade recommendations for Maven, npm, PyPI, Go, and crates.io. Setup instructions are provided for Claude Code, Claude Desktop, Codex CLI, Antigravity, and VS Code (GitHub Copilot).
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
14
|
- **One-shot multi-language scan**: Pass a project path to `scan_project`, and it detects and scans the Java, JavaScript, Python, Go, and Rust lockfiles in the project together. Dependencies that could not be scanned (for example, a manifest without a lockfile or an unpinned requirement) are reported up front in `coverage`
|
|
15
|
-
- **Upgrade recommendations**: `suggest_fix` recommends, for each vulnerable package, the upgrade closest to the current release line that
|
|
15
|
+
- **Upgrade recommendations**: `suggest_fix` recommends, for each vulnerable package, the upgrade closest to the current release line that addresses its fix targets, for Maven, npm, PyPI, Go, and Rust (crates.io). Known unfixed and informational advisories remain visible. The recommended version is also checked against the OSV database for additional fix-target vulnerabilities
|
|
16
16
|
- **Rust without false positives**: For `Cargo.lock`, only crates from crates.io are scanned. Your own workspace crates, git dependencies, and crates from private registries are not looked up as crates.io crates (OSV-Scanner alone would match them by name, and send their names to the OSV API). RustSec notices about unmaintained or unsound crates are labeled `informational` and counted apart from vulnerabilities
|
|
17
17
|
- **Direct and transitive dependencies**: Packages are marked as direct or transitive dependencies (from `package-lock.json`, `go.mod`, `requirements.txt`, and `pom.xml`), and update hints are tailored accordingly, for example by naming the direct dependency that pulls in a transitive one
|
|
18
18
|
- **Java project scan**: `scan_java_project` scans only the Java (Maven / Gradle) manifests and returns a formatted report
|
|
19
19
|
- **JAR/WAR scan**: `scan_java_artifact` detects known vulnerabilities from the metadata inside JAR/WAR archives, for projects without a lockfile or with only shaded/fat JARs (best-effort identification, stated explicitly in `coverage`)
|
|
20
20
|
- **SBOM scan**: `scan_sbom` checks the dependencies recorded in a CycloneDX/SPDX JSON SBOM. It states explicitly that the SBOM's completeness and its match with the actual artifacts are not verified
|
|
21
21
|
- **Severity-sorted reports**: Vulnerabilities are grouped by package and sorted by CVSS score, with five severity labels (critical / high / medium / low / unknown) and summary counts
|
|
22
|
-
- **Fixed versions**: Each vulnerability includes `fixed_versions`, sorted correctly by Maven version precedence (including non-semver forms such as `2.17.1-RELEASE`), Semantic Versioning precedence for npm and
|
|
22
|
+
- **Fixed versions**: Each vulnerability includes `fixed_versions`, sorted correctly by Maven version precedence (including non-semver forms such as `2.17.1-RELEASE`), Semantic Versioning precedence for npm, Go, and Rust, and PEP 440 for PyPI
|
|
23
23
|
- **Security first**: No shell execution, a fixed argument allowlist, path normalization and boundary checks, scanning private copies instead of the original files, and timeouts and output size limits are built in
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
@@ -262,14 +262,15 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
|
|
|
262
262
|
- Each list holds up to 200 entries; the number of omitted entries is returned in `omitted_items`
|
|
263
263
|
- `ecosystem_breakdown` includes ecosystems with zero findings, to show that they were scanned
|
|
264
264
|
- **Informational advisories (Rust)**: RustSec also publishes notices that are not vulnerabilities: `unmaintained` (the crate is no longer maintained), `unsound` (a soundness bug), and `notice`. Most have no fixed version. When every advisory in a group is such a notice (every related ID, including aliases, must have its details available and be a notice of a known kind; otherwise it is treated as a vulnerability), the vulnerability has `informational` set to its kind, is listed after the vulnerabilities of the package, and is counted in `informational_count` (and in `ecosystem_breakdown`) instead of `vulnerability_count`, `severity_breakdown`, and `vulnerable_package_count`. If another database (such as GitHub's) lists the same issue as a vulnerability, it stays a vulnerability. `informational_count` appears only when there is at least one
|
|
265
|
-
- `dependency_relation` shows whether a package is a direct (`direct`) or transitive (`transitive`) dependency. OSV-Scanner does not report this, so the server determines it
|
|
265
|
+
- `dependency_relation` shows whether a package is a direct (`direct`) or transitive (`transitive`) dependency. OSV-Scanner does not report this, so the server determines it from validated manifest data and scanned copies:
|
|
266
266
|
- `package-lock.json` (v2 or later): dependencies of the root and workspace `package.json` files, resolved with Node's lookup rules (nested `node_modules` first, then parent directories), are direct; everything reachable from them is transitive. Direct dependencies list the `package.json` files that declare them in `declared_in`; transitive dependencies list the direct dependencies that require them in `introduced_by` (up to 10, with the rest counted in `introduced_by_omitted`). A version that is both a direct dependency and required by another dependency is `direct` and also has `introduced_by`
|
|
267
267
|
- `go.mod`: `require` lines without `// indirect` are direct. Modules affected by a `replace` directive are marked `replaced_in_go_mod: true` (OSV-Scanner reports the replacement module and version)
|
|
268
268
|
- `requirements.txt`: dependencies written in the file are direct; dependencies resolved through deps.dev are transitive
|
|
269
269
|
- `pom.xml`: OSV-Scanner reports the dependencies declared in `pom.xml` (and its parent POMs) and the transitive dependencies resolved through deps.dev as separate results (`source.type` `lockfile` / `unknown`), and this split is used (so parent POMs, profiles, properties, and dependency management are interpreted exactly as OSV-Scanner does). `introduced_by` / `declared_in` are not available. This relies on undocumented OSV-Scanner output, so anything unexpected is reported as `unknown`
|
|
270
|
-
-
|
|
270
|
+
- `Cargo.lock`: crates.io dependencies immediately required by a local/workspace/path crate are direct; crates.io dependencies reachable through them (including git and alternate-registry origins) are transitive. `introduced_by` names the first direct dependency on each path. References are resolved by exact name, version, and source where provided; ambiguous references are not guessed. Replacements are followed. `declared_in` lists project-relative Cargo.toml paths only when every declaring crate has a unique matching manifest. Otherwise `declaring_crates` names the unmapped declaring crates (up to 10, with the rest in `declaring_crates_omitted`). Dev/build/target-specific distinctions are unavailable. Traversal over 2,000,000 edges returns `unknown`
|
|
271
|
+
- Other formats (`gradle.lockfile`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, and `pdm.lock`), npm lockfile version 1, and entries not reachable from the project are `unknown`. If lockfiles disagree, the value is `mixed`
|
|
271
272
|
- `dependency_groups` is the raw dependency group reported by OSV-Scanner (for example `dev`). It is missing or inaccurate for some lockfile formats (absent for pnpm, `optional` for pdm, and so on), so treat it as informational
|
|
272
|
-
- Upgrade recommendations (`suggest_fix`) are available for
|
|
273
|
+
- Upgrade recommendations (`suggest_fix`) are available for Maven, npm, PyPI, Go, and Rust (crates.io)
|
|
273
274
|
|
|
274
275
|
### `scan_java_project`
|
|
275
276
|
|
|
@@ -334,7 +335,7 @@ If an existing parent POM cannot be read (larger than 10 MiB, not a regular file
|
|
|
334
335
|
```
|
|
335
336
|
|
|
336
337
|
- `packages` is sorted by the most severe vulnerability, and each `vulnerabilities` list by severity (unknown last)
|
|
337
|
-
- `fixed_versions` lists the fixed versions recorded in OSV, in ascending order: by Maven precedence for Maven, Semantic Versioning precedence for npm and
|
|
338
|
+
- `fixed_versions` lists the fixed versions recorded in OSV, in ascending order: by Maven precedence for Maven, Semantic Versioning precedence for npm, Go, and Rust (values that are not valid SemVer come last), and PEP 440 for PyPI (in the order listed by OSV up to v0.5.0); other ecosystems keep the order listed by OSV (the order is not guaranteed). Several release lines may be mixed (for example a 2.12 backport and the 2.15 line). Pre-releases (`5.0.0-beta.3`) and Go pseudo-versions (`0.0.0-20180925071336-cf3bd585ca2a`) may be included. An empty array means that OSV lists no fixed version. Up to v0.4.1, this was always empty for packages outside Maven
|
|
338
339
|
- Vulnerabilities without a severity score are reported as `null` / `"unknown"`
|
|
339
340
|
|
|
340
341
|
### `scan_java_artifact`
|
|
@@ -391,7 +392,7 @@ Invalid JSON returns `invalid_sbom`, an unsupported format `unsupported_sbom_for
|
|
|
391
392
|
|
|
392
393
|
### `suggest_fix`
|
|
393
394
|
|
|
394
|
-
Runs the same detection and scan as `scan_project` and recommends an **upgrade version** for each vulnerable package. Recommendations are available for Java (Maven / Gradle), JavaScript (npm), Python (PyPI), and
|
|
395
|
+
Runs the same detection and scan as `scan_project` and recommends an **upgrade version** for each vulnerable package. Recommendations are available for Java (Maven / Gradle), JavaScript (npm), Python (PyPI), Go, and Rust (crates.io). Instead of simply taking the highest fixed version, it picks the fixed version closest to the current release line, falling back through three tiers:
|
|
395
396
|
|
|
396
397
|
| Tier | Meaning |
|
|
397
398
|
|---|---|
|
|
@@ -399,7 +400,7 @@ Runs the same detection and scan as `scan_project` and recommends an **upgrade v
|
|
|
399
400
|
| `major_internal` | A fixed version within the same major version (a minor upgrade) |
|
|
400
401
|
| `cross_major` | A major upgrade is needed (may include breaking changes) |
|
|
401
402
|
|
|
402
|
-
For npm, Go, and
|
|
403
|
+
For npm, Go, PyPI, and Rust, the "same release line" is the range a caret (`^`) treats as compatible (PyPI has no common compatibility rule, but some 0.x packages make breaking changes in minor releases, so the same rule is used; a change of epoch is also `cross_major`). From 1.0.0 the line is major.minor, as for Maven. For 0.x, only the same `0.minor` is the same line, so a minor update (`0.3` → `0.4`) is `cross_major`, and for 0.0.x every update is `cross_major` (SemVer does not guarantee compatibility for 0.x updates).
|
|
403
404
|
|
|
404
405
|
**Input**: the same as `scan_project` (`project_path`)
|
|
405
406
|
|
|
@@ -438,14 +439,16 @@ For npm, Go, and PyPI, the "same release line" is the range npm's caret (`^`) tr
|
|
|
438
439
|
- Pre-releases (`5.0.0-beta.3`, `15.6.0-canary.61`, Go pseudo-versions, PyPI `rc` and `dev` versions; post-releases count as stable) are recommended only when no stable candidate fixes every vulnerability, and are marked `recommended_is_prerelease: true`. A stable version in a higher tier is preferred over a pre-release in a lower tier.
|
|
439
440
|
- When a version is recommended, `verification` is `verified`, and each CVE's `recommended_status` is `affected` / `not_affected` / `unknown` (`not_evaluated` when the recommendation is withheld). `per_cve_detail.fixed_in` is the candidate for that CVE alone; see `recommended_status` for the final recommendation.
|
|
440
441
|
- CVEs with no fixed version newer than the current one get `tier: "unfixed"` and are left out of the recommendation (the data may be incomplete). They are still evaluated against the recommended version, and the result is shown. If every CVE is unfixed, `recommended_upgrade` is `null` as well. A CVE whose fixed version is listed but cannot be parsed as a version (such as `13.0`, which is not SemVer) gets `tier: "unparseable_fix"`; it is not treated as unfixed but stays in the set to be fixed, so the recommendation is withheld (`no_verified_candidate`).
|
|
442
|
+
- Rust uses SemVer (`SEMVER` ranges) and Cargo caret compatibility: 0.x minor updates and 0.0.x patch updates are `cross_major`. `unmaintained` and `notice` advisories, and `unsound` advisories without a newer fixed version, remain in `per_cve_detail` with `tier: "informational"`, their kind, and `recommended_status`, but are not fix targets or counted as unfixed. An `unsound` advisory with a newer fixed version is a version-specific defect, so it is a fix target like a vulnerability (its tier is computed normally and it keeps `informational: "unsound"`). Packages with no fix target left return `verification: "informational_only"` without an upgrade. Unmaintained crates may need replacement; use `explain_vulnerability` for notices.
|
|
441
443
|
- Suggestions include an `update_hint`, tailored to whether the package is a direct or transitive dependency:
|
|
442
444
|
- Maven (from `pom.xml`): for a direct dependency, update the `<dependency>` version (or the parent POM, property, or BOM that manages it); for a transitive dependency, override the version in `<dependencyManagement>` or update the direct dependency that requires it
|
|
443
445
|
- npm: for a direct dependency, update the version in the `package.json` listed in `declared_in`; for a transitive dependency, update the direct dependency named in `introduced_by`, or set the version with `overrides` in the root `package.json` (effective only in the root project)
|
|
444
446
|
- Go: `go get <module>@<version>` (also for `// indirect` modules). For a module affected by `replace`, update the version in the `replace` directive instead of `require`. Major versions 2 and later use a different module path (`/v2` and so on) and are separate packages in OSV, so fixes in a newer major line are not included as candidates. If the current version is a pseudo-version (an untagged commit), `upgrade_note` says so
|
|
445
447
|
- PyPI: update the version in `requirements.txt`, `pyproject.toml`, or `Pipfile` and regenerate the lockfile; for a transitive dependency, use a pip constraints file (`-c`) or the override settings of uv or Poetry
|
|
448
|
+
- Rust: where version requirements permit it, `cargo update -p <crate>@<current> --precise <recommended>` updates the lockfile. Otherwise change the direct requirement in the declaring Cargo.toml or update the parent dependency in `introduced_by`. Cross-major upgrades may break compatibility, including 0.x updates. Pre-releases may require explicit `=<version>` requirements and must be allowed by transitive parents. Build metadata is omitted from `--precise`. `[patch.crates-io]` changes a source, not the allowed version range; backports must satisfy parent requirements. Check yanked versions, MSRV, and compiler compatibility. Cargo is never executed by the server
|
|
446
449
|
- When the relation is `unknown` or `mixed`, both cases are described (no hint is given for Maven packages from `gradle.lockfile`)
|
|
447
450
|
- **Checking recommended versions against OSV**: Recommendations are verified only against the vulnerabilities found in the scan (those affecting the current version), so a recommended version could be affected by newer vulnerabilities (for example, cryptography 3.2's candidate 49.0.0 is affected by two vulnerabilities introduced in 44.0.0 and fixed in 50.0.0). The server therefore queries `api.osv.dev` about the recommended version, and if it is affected, adds those vulnerabilities to the set to be fixed and chooses again with their fixed versions as candidates (50.0.0 in this example, with the reason in `upgrade_note`). The result is reported in `candidate_check`:
|
|
448
|
-
- `clean`: OSV reports no
|
|
451
|
+
- `clean`: OSV reports no additional fix-target vulnerabilities after excluding known `unfixed` advisories, known informational advisories that are not fix targets, and new Rust `unmaintained` / `notice` records. These advisories may still apply; check `per_cve_detail[].recommended_status`. Newly discovered `unsound` records are added as fix targets
|
|
449
452
|
- `has_known_vulnerabilities`: no candidate avoids them, so the recommended version has known vulnerabilities (IDs in `recommended_known_vulnerabilities`)
|
|
450
453
|
- `conflict`: OSV reports that a candidate is affected by a vulnerability the scan had evaluated as not affecting it (the range data disagree), and no other candidate is available, so the recommendation is withheld (`recommended_upgrade: null`, `verification: "no_verified_candidate"`)
|
|
451
454
|
- `failed`: the query failed or returned a malformed response (the recommendation, verified against the scanned vulnerabilities, is still returned)
|
|
@@ -557,7 +560,7 @@ Design notes and open items are in [docs/DESIGN_TODO.md](docs/DESIGN_TODO.md) (i
|
|
|
557
560
|
- [x] Running under Node's permission model
|
|
558
561
|
- [x] English tool descriptions and messages
|
|
559
562
|
- [x] `scan_project` for Rust (`Cargo.lock`, crates.io crates only) and RustSec informational advisories
|
|
560
|
-
- [
|
|
563
|
+
- [x] `suggest_fix` and direct/transitive dependencies for Rust
|
|
561
564
|
- [ ] Direct and transitive dependencies for `gradle.lockfile` and the YAML/TOML lockfiles
|
|
562
565
|
|
|
563
566
|
## License
|
package/dist/index.js
CHANGED
|
@@ -61,7 +61,7 @@ if (startupWarning !== null) {
|
|
|
61
61
|
// NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
|
|
62
62
|
const server = new McpServer({
|
|
63
63
|
name: "osv-scanner-mcp",
|
|
64
|
-
version: "0.
|
|
64
|
+
version: "0.13.0",
|
|
65
65
|
});
|
|
66
66
|
server.registerTool("scan_project", {
|
|
67
67
|
title: "Scan project dependencies for vulnerabilities (Java / JavaScript / Python / Go / Rust)",
|
|
@@ -73,7 +73,7 @@ server.registerTool("scan_project", {
|
|
|
73
73
|
"For Cargo.lock, only crates from crates.io are scanned: the project's own crates are counted in local_crates, and git or private-registry crates are listed in unscannable_crates. " +
|
|
74
74
|
"Vulnerabilities marked informational (RustSec notices such as unmaintained or unsound crates) are not counted in vulnerability_count; they are counted in informational_count. " +
|
|
75
75
|
"Each package's dependency_relation tells whether it is a direct or transitive dependency (determined for package-lock.json, go.mod, requirements.txt, and pom.xml; unknown otherwise). " +
|
|
76
|
-
"If coverage.complete is false, do not conclude that the project is safe even with zero findings. Upgrade recommendations (suggest_fix) are available for Java, JavaScript, Python, and
|
|
76
|
+
"If coverage.complete is false, do not conclude that the project is safe even with zero findings. Upgrade recommendations (suggest_fix) are available for Java, JavaScript, Python, Go, and Rust. Cargo.lock dependency graphs provide direct/transitive relations, introduced_by, and uniquely mapped declaring Cargo.toml paths in declared_in.",
|
|
77
77
|
inputSchema: {
|
|
78
78
|
project_path: z
|
|
79
79
|
.string()
|
|
@@ -97,16 +97,18 @@ server.registerTool("scan_java_project", {
|
|
|
97
97
|
server.registerTool("suggest_fix", {
|
|
98
98
|
title: "Recommend upgrades that fix vulnerabilities",
|
|
99
99
|
description: "Scans the project with the same detection as scan_project and recommends an upgrade version for each vulnerable package. " +
|
|
100
|
-
"Recommendations are available for Java (Maven / Gradle), JavaScript (npm), Python (PyPI), and
|
|
100
|
+
"Recommendations are available for Java (Maven / Gradle), JavaScript (npm), Python (PyPI), Go, and Rust (crates.io). " +
|
|
101
101
|
"The fixed version closest to the current release line is chosen with a three-tier fallback " +
|
|
102
102
|
"(same_minor: same release line -> major_internal: same major version -> cross_major: major upgrade), " +
|
|
103
|
-
"and the response gives the recommended version, the upgrade distance (upgrade_tier), and the fixed version per CVE. For npm, Go, and
|
|
103
|
+
"and the response gives the recommended version, the upgrade distance (upgrade_tier), and the fixed version per CVE. For npm, Go, PyPI, and Rust, a minor update within 0.x is also cross_major (may include breaking changes). " +
|
|
104
104
|
"Dependencies scanned at a requirements.txt lower bound (>=) are marked version_is_lower_bound, and the recommendation then means raising the lower bound. " +
|
|
105
|
-
"update_hint explains how to upgrade, depending on whether the package is a direct or transitive dependency (dependency_relation; for npm also introduced_by and declared_in). " +
|
|
106
|
-
"The recommended version is checked against api.osv.dev, and versions with known vulnerabilities that do not affect the current version are avoided (result in candidate_check
|
|
107
|
-
"
|
|
105
|
+
"update_hint explains how to upgrade, depending on whether the package is a direct or transitive dependency (dependency_relation; for npm and Cargo.lock also introduced_by and declared_in). Cargo updates are conditional on parent version requirements; cross-major updates may require Cargo.toml changes. " +
|
|
106
|
+
"The recommended version is checked against api.osv.dev, and versions with known vulnerabilities that do not affect the current version are avoided (result in candidate_check). " +
|
|
107
|
+
"clean excludes known unfixed advisories, known informational advisories that are not fix targets, and new Rust unmaintained/notice records; these may still apply. New unsound records are fix targets. " +
|
|
108
|
+
"With has_known_vulnerabilities, failed, or skipped, the recommended version has not been confirmed safe; conflict means the data disagreed and the recommendation is withheld. " +
|
|
108
109
|
"Candidates are checked against the affected ranges of every vulnerability to fix, and the recommendation is withheld when the data are insufficient. Pre-releases are recommended only when no stable version fixes everything, and are marked recommended_is_prerelease. " +
|
|
109
110
|
"CVEs with no fixed version newer than the current one are reported separately as unfixed, together with their status at the recommended version. " +
|
|
111
|
+
"Rust unmaintained/notice advisories and unsound advisories without a newer fixed version have tier informational and are not fix targets; unsound advisories with a fixed version are fixed like vulnerabilities. Packages with no fix target left have verification informational_only and no recommendation. " +
|
|
110
112
|
"Always check coverage in the response (if complete is false, some dependencies are not included in the suggestions). " +
|
|
111
113
|
"If dependency_resolution.warning is present, vulnerabilities in transitive dependencies are not included.",
|
|
112
114
|
inputSchema: {
|
|
@@ -15,7 +15,7 @@ import { asString, asStrings } from "../utils/unknownJson.js";
|
|
|
15
15
|
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
16
16
|
import { extractAffectedVersions } from "./affectedVersions.js";
|
|
17
17
|
import { queryOsvPackageVersion } from "./osvApi.js";
|
|
18
|
-
import { extractFixedVersions } from "./scanReport.js";
|
|
18
|
+
import { extractFixedVersions, recordInformationalKind } from "./scanReport.js";
|
|
19
19
|
import { suggestUpgradeForPackage } from "./suggestFix.js";
|
|
20
20
|
export const NO_CANDIDATE_CHECK_ENV = "OSV_MCP_NO_CANDIDATE_CHECK";
|
|
21
21
|
/** 環境変数で無効化されているか(1/true/yes) */
|
|
@@ -43,6 +43,14 @@ class QueryBudget {
|
|
|
43
43
|
}
|
|
44
44
|
async function checkPackage(pkg, initial, budget, options) {
|
|
45
45
|
const known = new Set(pkg.vulnerabilities.flatMap((v) => [v.id, ...v.aliases]));
|
|
46
|
+
const unfixed = new Set(initial.per_cve_detail.filter((v) => v.tier === "unfixed" || v.tier === "informational").map((v) => v.id));
|
|
47
|
+
const ignoredKnown = new Set(pkg.vulnerabilities.filter((v) => unfixed.has(v.id)).flatMap((v) => [v.id, ...v.aliases]));
|
|
48
|
+
// 別名が修正対象とも重なる場合は、その対象の判定を優先する。
|
|
49
|
+
for (const vuln of pkg.vulnerabilities) {
|
|
50
|
+
if (!unfixed.has(vuln.id))
|
|
51
|
+
for (const id of [vuln.id, ...vuln.aliases])
|
|
52
|
+
ignoredKnown.delete(id);
|
|
53
|
+
}
|
|
46
54
|
const extraTargets = [];
|
|
47
55
|
const excluded = new Set();
|
|
48
56
|
const rejected = [];
|
|
@@ -62,9 +70,18 @@ async function checkPackage(pkg, initial, budget, options) {
|
|
|
62
70
|
return { suggestion, status: "failed", rejected };
|
|
63
71
|
throw error;
|
|
64
72
|
}
|
|
73
|
+
// 修正版なしの既知の脆弱性は修正対象ではなく、推奨先でもaffectedであることと矛盾しない。
|
|
74
|
+
// 新しく照会で見つかった対象はknownへ追加されるが、ignoredKnownには入れない。
|
|
75
|
+
records = records.filter((record) => {
|
|
76
|
+
const matched = recordIds(record).filter((id) => known.has(id));
|
|
77
|
+
if (matched.length > 0)
|
|
78
|
+
return matched.some((id) => !ignoredKnown.has(id));
|
|
79
|
+
const kind = pkg.ecosystem === "crates.io" ? recordInformationalKind(record, pkg.name, pkg.ecosystem) : null;
|
|
80
|
+
return kind !== "unmaintained" && kind !== "notice";
|
|
81
|
+
});
|
|
65
82
|
if (records.length === 0)
|
|
66
83
|
return { suggestion, status: "clean", rejected };
|
|
67
|
-
//
|
|
84
|
+
// 新しい脆弱性は修正対象に加え、修正版を候補に加える。既知の修正対象の脆弱性に該当と返った場合は、
|
|
68
85
|
// 手元の範囲情報とOSVの判定が食い違っているため、その候補を除外する(安全側)
|
|
69
86
|
let conflict = false;
|
|
70
87
|
for (const record of records) {
|
package/dist/osv/suggestFix.js
CHANGED
|
@@ -79,8 +79,54 @@ function updateHint(pkg) {
|
|
|
79
79
|
return UPDATE_HINTS[pkg.ecosystem];
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
/** OSV由来の版・crate名を案内のコマンドに埋め込めるか(外部データのため文字の範囲を限定する) */
|
|
83
|
+
const SAFE_VERSION = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
84
|
+
const SAFE_CRATE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
85
|
+
/**
|
|
86
|
+
* crates.ioの更新方法(docs/DESIGN_TODO.md「suggest_fixのRust対応」の表)。直接/推移的依存とTierで分ける。
|
|
87
|
+
* 推奨がない場合はコマンドを示さず、方向だけを示す
|
|
88
|
+
*/
|
|
89
|
+
function cargoUpdateHint(pkg, recommended, tier) {
|
|
90
|
+
const manifest = pkg.declared_in?.join(", ") ?? (pkg.declaring_crates?.length
|
|
91
|
+
? `the Cargo.toml of ${pkg.declaring_crates.join(", ")}` : "the declaring Cargo.toml");
|
|
92
|
+
const parent = pkg.introduced_by?.join(", ") ?? "the direct dependency that requires this crate";
|
|
93
|
+
if (recommended === null) {
|
|
94
|
+
const direct = `No verified upgrade is recommended (see upgrade_note and per_cve_detail). Any fix requires a version allowed by the requirement in ${manifest}.`;
|
|
95
|
+
const transitive = `No verified upgrade is recommended (see upgrade_note and per_cve_detail). Any fix must be allowed by the requirements of ${parent}.`;
|
|
96
|
+
return pkg.dependency_relation === "direct" ? `Direct dependency. ${direct}`
|
|
97
|
+
: pkg.dependency_relation === "transitive" ? `Transitive dependency. ${transitive}`
|
|
98
|
+
: `For a direct dependency: ${direct} For a transitive dependency: ${transitive}`;
|
|
99
|
+
}
|
|
100
|
+
const embeddable = SAFE_VERSION.test(recommended) && SAFE_VERSION.test(pkg.version) && SAFE_CRATE_NAME.test(pkg.name);
|
|
101
|
+
// OSVの修正版はビルドメタデータを含むことがある。cargoは`--precise`・`=`の要求でメタデータなしの版を受け付ける(cargo 1.98で確認)
|
|
102
|
+
const precise = recommended.split("+")[0];
|
|
103
|
+
const target = embeddable ? recommended : "the recommended version";
|
|
104
|
+
const command = embeddable
|
|
105
|
+
? `cargo update -p ${pkg.name}@${pkg.version} --precise ${precise}`
|
|
106
|
+
: "cargo update with an appropriate -p package specification and --precise version";
|
|
107
|
+
const direct = tier === "cross_major"
|
|
108
|
+
? `Change the requirement in ${manifest} to allow ${target}; this may include breaking changes (in 0.x, even a minor update).`
|
|
109
|
+
: `If the requirement in ${manifest} allows ${target} (the default caret requirement does), run ${command}; only Cargo.lock changes. ` +
|
|
110
|
+
`If the requirement pins the version (= or ~), change it in ${manifest}.`;
|
|
111
|
+
const transitive = tier === "cross_major"
|
|
112
|
+
? `Update ${parent} to a version whose requirements permit ${target}. ` +
|
|
113
|
+
"[patch.crates-io] does not override version requirements: use it only for a fix (such as a backport) from another source that still satisfies the requirements of the crates that depend on it."
|
|
114
|
+
: `If the requirements of ${parent} allow ${target}, run ${command}; only Cargo.lock changes. If cargo reports that the requirements cannot be satisfied, update ${parent}.`;
|
|
115
|
+
let hint = pkg.dependency_relation === "direct" ? `Direct dependency. ${direct}`
|
|
116
|
+
: pkg.dependency_relation === "transitive" ? `Transitive dependency. ${transitive}`
|
|
117
|
+
: `For a direct dependency: ${direct} For a transitive dependency: ${transitive}`;
|
|
118
|
+
if (pkg.dependency_relation === "direct" && pkg.introduced_by?.length)
|
|
119
|
+
hint += ` It is also required by ${parent}, whose requirements must allow ${target} as well.`;
|
|
120
|
+
if (versionSchemeFor("crates.io").isPrerelease(recommended)) {
|
|
121
|
+
hint += ` ${target} is a pre-release: Cargo requirements match a pre-release only when they name one, so cargo update --precise alone may fail. ` +
|
|
122
|
+
(embeddable ? `For a direct dependency, set the requirement to "=${precise}"` : "For a direct dependency, set the requirement to the exact pre-release version") +
|
|
123
|
+
"; for a transitive dependency, the crate that requires it must allow it.";
|
|
124
|
+
}
|
|
125
|
+
hint += " Before updating, check that the version is not yanked and that it supports your Rust version (MSRV).";
|
|
126
|
+
return hint;
|
|
127
|
+
}
|
|
128
|
+
function hintFields(pkg, recommended = null, tier = null) {
|
|
129
|
+
const hint = pkg.ecosystem === "crates.io" ? cargoUpdateHint(pkg, recommended, tier) : updateHint(pkg);
|
|
84
130
|
return hint === undefined ? {} : { update_hint: hint };
|
|
85
131
|
}
|
|
86
132
|
/** スキャン結果に付いた直接/推移的依存の項目を提案にも写す(無ければ何も出さない) */
|
|
@@ -90,6 +136,8 @@ function relationFields(pkg) {
|
|
|
90
136
|
...(pkg.introduced_by !== undefined ? { introduced_by: pkg.introduced_by } : {}),
|
|
91
137
|
...(pkg.introduced_by_omitted !== undefined ? { introduced_by_omitted: pkg.introduced_by_omitted } : {}),
|
|
92
138
|
...(pkg.declared_in !== undefined ? { declared_in: pkg.declared_in } : {}),
|
|
139
|
+
...(pkg.declaring_crates !== undefined ? { declaring_crates: pkg.declaring_crates } : {}),
|
|
140
|
+
...(pkg.declaring_crates_omitted !== undefined ? { declaring_crates_omitted: pkg.declaring_crates_omitted } : {}),
|
|
93
141
|
...(pkg.replaced_in_go_mod ? { replaced_in_go_mod: true } : {}),
|
|
94
142
|
};
|
|
95
143
|
}
|
|
@@ -115,7 +163,8 @@ function notEvaluatedSuggestion(pkg, verification) {
|
|
|
115
163
|
cve: vuln.cve,
|
|
116
164
|
severity: vuln.severity,
|
|
117
165
|
fixed_in: null,
|
|
118
|
-
tier: "unsupported",
|
|
166
|
+
tier: vuln.informational !== undefined ? "informational" : "unsupported",
|
|
167
|
+
...(vuln.informational !== undefined ? { informational: vuln.informational, recommended_status: "not_evaluated" } : {}),
|
|
119
168
|
})),
|
|
120
169
|
verification,
|
|
121
170
|
};
|
|
@@ -172,6 +221,36 @@ function buildNote(scheme, pkg, recommended, tier, fixableCount, unfixedCount) {
|
|
|
172
221
|
}
|
|
173
222
|
return note;
|
|
174
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* 1パッケージ分のアップグレード提案を組み立てる(同期・純粋)。
|
|
226
|
+
* contextを渡すと、照会で見つかった脆弱性も避けて候補を選び直す(contextなしの結果が従来の推奨)。
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* 修正対象にしない情報提供のアドバイザリ。unmaintained・noticeは版を上げても解消しない(版と無関係)。
|
|
230
|
+
* unsoundは版ごとの欠陥のため、現在より新しい修正版があれば通常の脆弱性と同じく修正対象にする
|
|
231
|
+
* (v0.13.0の実装中に判明: 修正版のあるunsoundを外すと、lru 0.6.0に0.7.1(RUSTSEC-2026-0253の0.18.2未満)を推奨した)
|
|
232
|
+
*/
|
|
233
|
+
function isExcludedInformational(vuln, scheme, current) {
|
|
234
|
+
if (vuln.informational === undefined)
|
|
235
|
+
return false;
|
|
236
|
+
if (vuln.informational !== "unsound")
|
|
237
|
+
return true;
|
|
238
|
+
return rankCandidates(scheme, current, vuln.fixed_versions).length === 0 && vuln.fixed_versions.every((v) => scheme.isValid(v));
|
|
239
|
+
}
|
|
240
|
+
function informationalNote(pkg, excluded) {
|
|
241
|
+
const notes = [];
|
|
242
|
+
const kinds = (pick) => new Set(pkg.vulnerabilities.filter((v, i) => v.informational !== undefined && pick(excluded[i])).map((v) => v.informational));
|
|
243
|
+
const out = kinds((e) => e);
|
|
244
|
+
if (out.has("unmaintained"))
|
|
245
|
+
notes.push("Unmaintained advisories are not resolved by upgrading; consider an alternative crate.");
|
|
246
|
+
if (out.has("unsound"))
|
|
247
|
+
notes.push("Unsound advisories without a fixed version remain; check recommended_status.");
|
|
248
|
+
if (out.has("notice"))
|
|
249
|
+
notes.push("For notices, use explain_vulnerability for details.");
|
|
250
|
+
if (kinds((e) => !e).has("unsound"))
|
|
251
|
+
notes.push("Unsound advisories with a fixed version are included in the fix.");
|
|
252
|
+
return notes.join(" ");
|
|
253
|
+
}
|
|
175
254
|
/**
|
|
176
255
|
* 1パッケージ分のアップグレード提案を組み立てる(同期・純粋)。
|
|
177
256
|
* contextを渡すと、照会で見つかった脆弱性も避けて候補を選び直す(contextなしの結果が従来の推奨)。
|
|
@@ -182,7 +261,23 @@ export function suggestUpgradeForPackage(pkg, context = {}) {
|
|
|
182
261
|
return notEvaluatedSuggestion(pkg, "unsupported_ecosystem");
|
|
183
262
|
if (!scheme.isValid(pkg.version))
|
|
184
263
|
return { ...notEvaluatedSuggestion(pkg, "unparseable_version"), ...hintFields(pkg) };
|
|
185
|
-
const
|
|
264
|
+
const excluded = pkg.vulnerabilities.map((vuln) => isExcludedInformational(vuln, scheme, pkg.version));
|
|
265
|
+
const informationalNotes = informationalNote(pkg, excluded);
|
|
266
|
+
if (pkg.vulnerabilities.length > 0 && excluded.every((e) => e)) {
|
|
267
|
+
return {
|
|
268
|
+
package: pkg.name, current_version: pkg.version, ecosystem: pkg.ecosystem, ...relationFields(pkg),
|
|
269
|
+
recommended_upgrade: null, upgrade_tier: null, upgrade_note: informationalNotes,
|
|
270
|
+
per_cve_detail: pkg.vulnerabilities.map((v) => ({ id: v.id, cve: v.cve, severity: v.severity,
|
|
271
|
+
fixed_in: null, tier: "informational", informational: v.informational, recommended_status: "not_evaluated" })),
|
|
272
|
+
verification: "informational_only",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const details = pkg.vulnerabilities.map((vuln, i) => {
|
|
276
|
+
if (excluded[i])
|
|
277
|
+
return {
|
|
278
|
+
id: vuln.id, cve: vuln.cve, severity: vuln.severity, fixed_in: null,
|
|
279
|
+
tier: "informational", informational: vuln.informational,
|
|
280
|
+
};
|
|
186
281
|
const fix = rankCandidates(scheme, pkg.version, vuln.fixed_versions)[0];
|
|
187
282
|
return {
|
|
188
283
|
id: vuln.id,
|
|
@@ -191,14 +286,16 @@ export function suggestUpgradeForPackage(pkg, context = {}) {
|
|
|
191
286
|
fixed_in: fix ?? null,
|
|
192
287
|
tier: fix !== undefined ? scheme.classify(pkg.version, fix)
|
|
193
288
|
: vuln.fixed_versions.some((v) => !scheme.isValid(v)) ? "unparseable_fix" : "unfixed",
|
|
289
|
+
// 修正対象にした情報提供(修正版のあるunsound)も種類を示す
|
|
290
|
+
...(vuln.informational !== undefined ? { informational: vuln.informational } : {}),
|
|
194
291
|
};
|
|
195
292
|
});
|
|
196
293
|
const unfixedCount = details.filter((d) => d.tier === "unfixed").length;
|
|
197
294
|
const unparseableCount = details.filter((d) => d.tier === "unparseable_fix").length;
|
|
198
|
-
const fixableCount = details.
|
|
295
|
+
const fixableCount = details.filter((d) => d.tier !== "unfixed" && d.tier !== "informational").length;
|
|
199
296
|
// 解釈できない修正版のCVEも修正対象に残す。その影響範囲は情報不足のため、どの候補も検証できず推奨を保留する
|
|
200
297
|
const targets = [
|
|
201
|
-
...pkg.vulnerabilities.filter((_, i) => details[i].tier !== "unfixed"),
|
|
298
|
+
...pkg.vulnerabilities.filter((_, i) => details[i].tier !== "unfixed" && details[i].tier !== "informational"),
|
|
202
299
|
...(context.extraTargets ?? []),
|
|
203
300
|
];
|
|
204
301
|
const candidates = rankCandidates(scheme, pkg.version, targets.flatMap((v) => v.fixed_versions));
|
|
@@ -217,11 +314,11 @@ export function suggestUpgradeForPackage(pkg, context = {}) {
|
|
|
217
314
|
recommended_upgrade: recommended,
|
|
218
315
|
upgrade_tier: upgradeTier,
|
|
219
316
|
...(recommended !== null && scheme.isPrerelease(recommended) ? { recommended_is_prerelease: true } : {}),
|
|
220
|
-
upgrade_note: recommended === null && fixableCount > 0
|
|
317
|
+
upgrade_note: (recommended === null && fixableCount > 0
|
|
221
318
|
? "No known fixed version could be confirmed to be outside the affected ranges of every CVE to fix. The recommendation is also withheld when the data are insufficient or use unsupported range types." +
|
|
222
319
|
(unparseableCount > 0 ? ` ${unparseableCount} CVEs list fixed versions that cannot be parsed as versions (tier: unparseable_fix), so the recommendation is withheld.` : "")
|
|
223
|
-
: buildNote(scheme, pkg, recommended, upgradeTier, fixableCount, unfixedCount),
|
|
224
|
-
...hintFields(pkg),
|
|
320
|
+
: buildNote(scheme, pkg, recommended, upgradeTier, fixableCount, unfixedCount)) + (informationalNotes ? ` ${informationalNotes}` : ""),
|
|
321
|
+
...hintFields(pkg, recommended, upgradeTier),
|
|
225
322
|
...(pkg.version_is_lower_bound ? { version_is_lower_bound: true } : {}),
|
|
226
323
|
per_cve_detail: details,
|
|
227
324
|
verification: recommended === null ? "no_verified_candidate" : "verified",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - Maven: Mavenの優先順位(mavenVersion.ts)。範囲はECOSYSTEM型
|
|
5
5
|
* - npm・Go: SemVer 2.0.0の優先順位(semverVersion.ts)。範囲はSEMVER型とECOSYSTEM型
|
|
6
|
+
* - crates.io: SemVerの優先順位とCargoキャレット互換。範囲はSEMVER型
|
|
6
7
|
* - PyPI: PEP 440の優先順位(pep440Version.ts)。範囲はECOSYSTEM型。名前はPEP 503で正規化して照合する
|
|
7
8
|
* - その他: 比較器なし。範囲はECOSYSTEM型
|
|
8
9
|
*
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
import { compareMavenVersions, mavenVersionSeries } from "../utils/mavenVersion.js";
|
|
12
13
|
import { compareParsedPep440, parsePep440 } from "../utils/pep440Version.js";
|
|
13
14
|
import { compareParsedSemver, parseSemver } from "../utils/semverVersion.js";
|
|
14
|
-
const SEMVER_ECOSYSTEMS = new Set(["npm", "Go"]);
|
|
15
|
+
const SEMVER_ECOSYSTEMS = new Set(["npm", "Go", "crates.io"]);
|
|
15
16
|
const MAVEN_SCHEME = {
|
|
16
17
|
isValid: () => true,
|
|
17
18
|
compare: compareMavenVersions,
|
|
@@ -126,6 +127,8 @@ export function samePackageName(a, b, ecosystem) {
|
|
|
126
127
|
export function versionRangeTypes(ecosystem) {
|
|
127
128
|
if (ecosystem === "Maven")
|
|
128
129
|
return new Set(["ECOSYSTEM"]);
|
|
130
|
+
if (ecosystem === "crates.io")
|
|
131
|
+
return new Set(["SEMVER"]);
|
|
129
132
|
if (SEMVER_ECOSYSTEMS.has(ecosystem))
|
|
130
133
|
return new Set(["SEMVER", "ECOSYSTEM"]);
|
|
131
134
|
return new Set(["ECOSYSTEM"]);
|
|
@@ -8,7 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { ScanToolError } from "../errors.js";
|
|
9
9
|
import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
|
|
10
10
|
import { hasVulnerability, packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
|
|
11
|
-
import { combineRelations, goModRelations, npmLockRelations, pomRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
|
|
11
|
+
import { combineRelations, cargoLockRelations, goModRelations, npmLockRelations, pomRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
|
|
12
12
|
import { cargoLockCopyContent } from "../utils/cargoLock.js";
|
|
13
13
|
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
14
14
|
import { readRegularFile } from "../utils/safeRead.js";
|
|
@@ -122,7 +122,7 @@ const MAX_INTRODUCED_BY = 10;
|
|
|
122
122
|
export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_LOCKFILE_BYTES) {
|
|
123
123
|
const lookups = new Map();
|
|
124
124
|
let remaining = maxTotalBytes;
|
|
125
|
-
for (const { copy, format, originalRelative, entries } of copies) {
|
|
125
|
+
for (const { copy, format, originalRelative, entries, cargoPackages, declaringManifests } of copies) {
|
|
126
126
|
const lockDir = originalRelative === null ? "" : path.dirname(originalRelative);
|
|
127
127
|
let lookup = null;
|
|
128
128
|
try {
|
|
@@ -141,6 +141,9 @@ export async function buildRelationLookups(copies, maxTotalBytes = MAX_RELATION_
|
|
|
141
141
|
else if (format === "requirements.txt" && entries !== undefined) {
|
|
142
142
|
lookup = requirementsRelations(entries);
|
|
143
143
|
}
|
|
144
|
+
else if (format === "Cargo.lock" && cargoPackages !== undefined) {
|
|
145
|
+
lookup = cargoLockRelations(cargoPackages, declaringManifests);
|
|
146
|
+
}
|
|
144
147
|
}
|
|
145
148
|
catch {
|
|
146
149
|
lookup = null; // 解析できないファイルはunknown(スキャン自体は続ける)
|
|
@@ -156,6 +159,7 @@ function annotateRelations(packages, lookups) {
|
|
|
156
159
|
// 同じファイルで直接依存と判定された結果があれば直接依存とする
|
|
157
160
|
const byFile = new Map();
|
|
158
161
|
const declaredIn = new Set();
|
|
162
|
+
const declaringCrates = new Set();
|
|
159
163
|
const rank = { direct: 2, transitive: 1, unknown: 0 };
|
|
160
164
|
for (const source of packageSources(pkg)) {
|
|
161
165
|
const entry = lookups.get(source.path);
|
|
@@ -165,6 +169,8 @@ function annotateRelations(packages, lookups) {
|
|
|
165
169
|
byFile.set(source.path, info);
|
|
166
170
|
for (const manifest of info.declaredIn ?? [])
|
|
167
171
|
declaredIn.add(path.join(entry.lockDir, manifest));
|
|
172
|
+
for (const name of info.declaringCrates ?? [])
|
|
173
|
+
declaringCrates.add(name);
|
|
168
174
|
}
|
|
169
175
|
const infos = [...byFile.values()];
|
|
170
176
|
const { relation, introducedBy, replaced } = combineRelations(infos);
|
|
@@ -175,6 +181,8 @@ function annotateRelations(packages, lookups) {
|
|
|
175
181
|
...(introducedBy.length > 0 ? { introduced_by: introducedBy.slice(0, MAX_INTRODUCED_BY).map(sanitizeExternalText) } : {}),
|
|
176
182
|
...(introducedBy.length > MAX_INTRODUCED_BY ? { introduced_by_omitted: introducedBy.length - MAX_INTRODUCED_BY } : {}),
|
|
177
183
|
...(declaredIn.size > 0 ? { declared_in: [...declaredIn].sort().map(sanitizeExternalText) } : {}),
|
|
184
|
+
...(declaringCrates.size > 0 ? { declaring_crates: [...declaringCrates].sort().slice(0, MAX_INTRODUCED_BY).map(sanitizeExternalText) } : {}),
|
|
185
|
+
...(declaringCrates.size > MAX_INTRODUCED_BY ? { declaring_crates_omitted: declaringCrates.size - MAX_INTRODUCED_BY } : {}),
|
|
178
186
|
...(replaced ? { replaced_in_go_mod: true } : {}),
|
|
179
187
|
vulnerabilities,
|
|
180
188
|
};
|
|
@@ -213,7 +221,8 @@ export async function scanFromSnapshot(project, options) {
|
|
|
213
221
|
continue;
|
|
214
222
|
const generated = await snapshot.writeGenerated(cargoLockCopyContent(copy.packages));
|
|
215
223
|
targets.push({ path: generated, format: "Cargo.lock" });
|
|
216
|
-
relationSources.push({ copy: generated, format: "Cargo.lock", originalRelative: copy.path
|
|
224
|
+
relationSources.push({ copy: generated, format: "Cargo.lock", originalRelative: copy.path,
|
|
225
|
+
cargoPackages: copy.allPackages, declaringManifests: copy.declaringManifests });
|
|
217
226
|
}
|
|
218
227
|
if (targets.length === 0)
|
|
219
228
|
return parseOsvScanOutput({ results: [] });
|
package/dist/tools/suggestFix.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `suggest_fix`ツールのハンドラ。
|
|
3
3
|
* scan_projectと同じ検出・スキャンを実行し、脆弱なパッケージごとの
|
|
4
|
-
* 推奨アップグレードバージョン(3段階Tier)を返す。推奨はMaven・npm・Go・PyPIに対応し、
|
|
4
|
+
* 推奨アップグレードバージョン(3段階Tier)を返す。推奨はMaven・npm・Go・PyPI・crates.ioに対応し、
|
|
5
5
|
* それ以外はunsupported_ecosystemとして返す。
|
|
6
6
|
*/
|
|
7
7
|
import { isRemoteResolutionDisabled } from "../osv/runner.js";
|
package/dist/utils/cargoLock.js
CHANGED
|
@@ -257,7 +257,7 @@ export const UNSCANNABLE_CRATE_REASONS = {
|
|
|
257
257
|
"so it was not checked (and its name was not sent to api.osv.dev)",
|
|
258
258
|
};
|
|
259
259
|
export function classifyCargoPackages(packages) {
|
|
260
|
-
const result = { cratesIo: [], localCrates: [], unscannable: [] };
|
|
260
|
+
const result = { allPackages: [...packages], cratesIo: [], localCrates: [], unscannable: [] };
|
|
261
261
|
const seen = new Set();
|
|
262
262
|
for (const pkg of packages) {
|
|
263
263
|
// [replace]で置換されたパッケージは使われない(置換先は別のパッケージとして分類する)
|
|
@@ -2,15 +2,121 @@
|
|
|
2
2
|
* 直接/推移的依存の判定(docs/DESIGN_TODO.md「直接/推移的依存の区別(v0.7.0)詳細設計メモ」)。
|
|
3
3
|
*
|
|
4
4
|
* osv-scannerの出力には直接/推移的の区別がないため、スキャンしたファイル(スナップショットのコピー)を解析する。
|
|
5
|
-
* 新しい外部依存は追加せず、JSON
|
|
5
|
+
* 新しい外部依存は追加せず、JSON・行形式・解析済みのCargo.lockグラフを扱う。
|
|
6
6
|
* - package-lock.json(v2以降): ルートとworkspaceの依存をNodeの解決規則で解決したものが直接依存、
|
|
7
7
|
* そこからたどれるものが推移的依存。経由した直接依存の名前(introduced_by)と、宣言したpackage.json(declared_in)を返す
|
|
8
8
|
* - go.mod: `// indirect`のないrequireが直接依存。replaceの置換先にも同じ関係を当てる(osv-scannerは置換先で報告する)
|
|
9
9
|
* - requirements.txt: 本サーバーが書いたコピーの行が直接依存、それ以外(deps.devで解決された依存)が推移的依存
|
|
10
|
+
* - Cargo.lock: local/path/workspace crateの直下を直接依存、そこから到達するcrates.ioのcrateを推移的依存とする
|
|
10
11
|
*/
|
|
12
|
+
import { cargoSourceKind, parsePackageId } from "./cargoLock.js";
|
|
11
13
|
/** たどる依存の辺の上限(巨大・悪意あるlockfileで処理が膨らまないように) */
|
|
12
14
|
export const MAX_DEPENDENCY_EDGES = 2_000_000;
|
|
13
15
|
const UNKNOWN = { relation: "unknown" };
|
|
16
|
+
/** Cargoの省略されたpackage IDは、一意に解決できるときだけたどる。 */
|
|
17
|
+
export function cargoLockRelations(packages, manifests = new Map(), maxEdges = MAX_DEPENDENCY_EDGES) {
|
|
18
|
+
const identity = (p) => JSON.stringify([p.name, p.version, p.source]);
|
|
19
|
+
const entries = new Map(packages.map((p) => [identity(p), p]));
|
|
20
|
+
const names = new Map();
|
|
21
|
+
const versions = new Map();
|
|
22
|
+
for (const [id, p] of entries) {
|
|
23
|
+
const named = names.get(p.name) ?? [];
|
|
24
|
+
named.push(id);
|
|
25
|
+
names.set(p.name, named);
|
|
26
|
+
const nv = JSON.stringify([p.name, p.version]);
|
|
27
|
+
const versioned = versions.get(nv) ?? [];
|
|
28
|
+
versioned.push(id);
|
|
29
|
+
versions.set(nv, versioned);
|
|
30
|
+
}
|
|
31
|
+
let edges = 0;
|
|
32
|
+
let exceeded = false;
|
|
33
|
+
const tick = () => { if (++edges > maxEdges)
|
|
34
|
+
exceeded = true; return !exceeded; };
|
|
35
|
+
const resolve = (reference) => {
|
|
36
|
+
let ref = reference;
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (!tick())
|
|
40
|
+
return null;
|
|
41
|
+
const parsed = parsePackageId(ref);
|
|
42
|
+
const candidates = parsed === null ? names.get(ref)
|
|
43
|
+
: parsed.source === null ? versions.get(JSON.stringify([parsed.name, parsed.version]))
|
|
44
|
+
: entries.has(identity(parsed)) ? [identity(parsed)] : undefined;
|
|
45
|
+
if (candidates?.length !== 1)
|
|
46
|
+
return null;
|
|
47
|
+
const id = candidates[0];
|
|
48
|
+
if (seen.has(id))
|
|
49
|
+
return null;
|
|
50
|
+
seen.add(id);
|
|
51
|
+
const p = entries.get(id);
|
|
52
|
+
if (p.replace === undefined)
|
|
53
|
+
return id;
|
|
54
|
+
ref = p.replace;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const direct = new Map();
|
|
58
|
+
const introduced = new Map();
|
|
59
|
+
const origins = new Set();
|
|
60
|
+
for (const root of entries.values()) {
|
|
61
|
+
if (root.source !== null || root.replace !== undefined)
|
|
62
|
+
continue;
|
|
63
|
+
for (const dep of root.dependencies) {
|
|
64
|
+
const target = resolve(dep);
|
|
65
|
+
if (exceeded)
|
|
66
|
+
return null;
|
|
67
|
+
if (target === null)
|
|
68
|
+
continue;
|
|
69
|
+
// ローカルのcrate(pathの依存)はそれ自体が起点で、その依存は直接依存になる。経由した依存元(introduced_by)には含めない
|
|
70
|
+
if (entries.get(target).source === null)
|
|
71
|
+
continue;
|
|
72
|
+
origins.add(target);
|
|
73
|
+
if (cargoSourceKind(entries.get(target).source) !== "crates_io")
|
|
74
|
+
continue;
|
|
75
|
+
const declared = direct.get(target) ?? new Set();
|
|
76
|
+
declared.add(root.name);
|
|
77
|
+
direct.set(target, declared);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const origin of origins) {
|
|
81
|
+
const visited = new Set([origin]);
|
|
82
|
+
const queue = [origin];
|
|
83
|
+
while (queue.length) {
|
|
84
|
+
for (const dep of entries.get(queue.pop()).dependencies) {
|
|
85
|
+
const target = resolve(dep);
|
|
86
|
+
if (exceeded)
|
|
87
|
+
return null;
|
|
88
|
+
if (target === null || visited.has(target))
|
|
89
|
+
continue;
|
|
90
|
+
visited.add(target);
|
|
91
|
+
queue.push(target);
|
|
92
|
+
if (cargoSourceKind(entries.get(target).source) !== "crates_io")
|
|
93
|
+
continue;
|
|
94
|
+
const by = introduced.get(target) ?? new Set();
|
|
95
|
+
by.add(entries.get(origin).name);
|
|
96
|
+
introduced.set(target, by);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const byPackage = new Map();
|
|
101
|
+
for (const [id, p] of entries) {
|
|
102
|
+
if (p.replace !== undefined || cargoSourceKind(p.source) !== "crates_io")
|
|
103
|
+
continue;
|
|
104
|
+
const declared = direct.get(id);
|
|
105
|
+
const by = introduced.get(id);
|
|
106
|
+
const mapped = declared === undefined ? [] : [...declared].map((name) => manifests.get(name));
|
|
107
|
+
const info = {
|
|
108
|
+
relation: declared ? "direct" : by ? "transitive" : "unknown",
|
|
109
|
+
...(by ? { introducedBy: by } : {}),
|
|
110
|
+
...(mapped.length > 0 && mapped.every((m) => m !== undefined) ? { declaredIn: new Set(mapped) } : {}),
|
|
111
|
+
...(declared && !mapped.every((m) => m !== undefined) ? { declaringCrates: declared } : {}),
|
|
112
|
+
};
|
|
113
|
+
const key = JSON.stringify([p.name, p.version]);
|
|
114
|
+
const prior = byPackage.get(key);
|
|
115
|
+
// Multiple crates.io source identities cannot be distinguished in the scanner output.
|
|
116
|
+
byPackage.set(key, prior === undefined ? info : UNKNOWN);
|
|
117
|
+
}
|
|
118
|
+
return (name, version) => byPackage.get(JSON.stringify([name, version])) ?? UNKNOWN;
|
|
119
|
+
}
|
|
14
120
|
function isRecord(value) {
|
|
15
121
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
122
|
}
|
|
@@ -126,6 +126,19 @@ const MAX_CARGO_TOML_BYTES = 1024 * 1024;
|
|
|
126
126
|
export class CargoLockCache {
|
|
127
127
|
results = new Map();
|
|
128
128
|
remaining;
|
|
129
|
+
tomls = new Map();
|
|
130
|
+
remainingToml = MAX_LOCKFILE_TOTAL_BYTES;
|
|
131
|
+
async toml(file, projectDir) {
|
|
132
|
+
if (this.tomls.has(file))
|
|
133
|
+
return this.tomls.get(file);
|
|
134
|
+
const read = this.remainingToml > 0
|
|
135
|
+
? await readRegularFile(file, { maxBytes: Math.min(MAX_CARGO_TOML_BYTES, this.remainingToml), boundary: projectDir }) : null;
|
|
136
|
+
const text = read?.ok ? read.bytes.toString("utf8") : null;
|
|
137
|
+
if (read?.ok)
|
|
138
|
+
this.remainingToml -= read.bytes.length;
|
|
139
|
+
this.tomls.set(file, text);
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
129
142
|
constructor(maxTotalBytes = MAX_LOCKFILE_TOTAL_BYTES) {
|
|
130
143
|
this.remaining = maxTotalBytes;
|
|
131
144
|
}
|
|
@@ -156,8 +169,8 @@ export class CargoLockCache {
|
|
|
156
169
|
async workspace(lockAbs, projectDir) {
|
|
157
170
|
if (this.workspaces.has(lockAbs))
|
|
158
171
|
return this.workspaces.get(lockAbs);
|
|
159
|
-
const
|
|
160
|
-
const workspace =
|
|
172
|
+
const text = await this.toml(path.join(path.dirname(lockAbs), "Cargo.toml"), projectDir);
|
|
173
|
+
const workspace = text === null ? null : cargoWorkspace(text);
|
|
161
174
|
this.workspaces.set(lockAbs, workspace);
|
|
162
175
|
return workspace;
|
|
163
176
|
}
|
|
@@ -174,8 +187,8 @@ async function cargoLockfileRecords(cache, projectDir, lockfile, memberToml) {
|
|
|
174
187
|
const lock = await cache.get(path.join(projectDir, lockfile.path), projectDir);
|
|
175
188
|
if ("reason" in lock)
|
|
176
189
|
return null;
|
|
177
|
-
const
|
|
178
|
-
const name =
|
|
190
|
+
const text = await cache.toml(path.join(projectDir, memberToml), projectDir);
|
|
191
|
+
const name = text === null ? null : cargoTomlPackageName(text);
|
|
179
192
|
if (name === null)
|
|
180
193
|
return null;
|
|
181
194
|
if (!lock.localCrates.includes(name))
|
|
@@ -262,6 +275,17 @@ async function finalize(projectDir, manifests, markers, allowedRootReal) {
|
|
|
262
275
|
skippedFiles: [],
|
|
263
276
|
};
|
|
264
277
|
const readContext = createRequirementsReadContext();
|
|
278
|
+
const cargoManifests = new Map();
|
|
279
|
+
const cargoMarkers = new Set([
|
|
280
|
+
...markers.filter((m) => m.path.endsWith("Cargo.toml")).map((m) => m.path),
|
|
281
|
+
...manifests.filter((m) => m.format === "Cargo.lock").map((m) => path.join(path.dirname(m.path), "Cargo.toml")),
|
|
282
|
+
]);
|
|
283
|
+
for (const marker of cargoMarkers) {
|
|
284
|
+
const text = await cargoCache.toml(path.join(projectDir, marker), projectDir);
|
|
285
|
+
const name = text === null ? null : cargoTomlPackageName(text);
|
|
286
|
+
if (name !== null)
|
|
287
|
+
cargoManifests.set(name, [...(cargoManifests.get(name) ?? []), marker]);
|
|
288
|
+
}
|
|
265
289
|
for (const manifest of manifests) {
|
|
266
290
|
const absolute = path.join(projectDir, manifest.path);
|
|
267
291
|
if (manifest.format === "requirements.txt") {
|
|
@@ -281,7 +305,12 @@ async function finalize(projectDir, manifests, markers, allowedRootReal) {
|
|
|
281
305
|
result.skippedFiles.push({ path: manifest.path, reason: lock.reason });
|
|
282
306
|
continue;
|
|
283
307
|
}
|
|
284
|
-
|
|
308
|
+
const declaringManifests = new Map();
|
|
309
|
+
for (const [name, paths] of cargoManifests) {
|
|
310
|
+
if (paths.length === 1)
|
|
311
|
+
declaringManifests.set(name, path.relative(path.dirname(manifest.path), paths[0]));
|
|
312
|
+
}
|
|
313
|
+
result.cargoCopies.push({ path: manifest.path, packages: lock.cratesIo, allPackages: lock.allPackages, declaringManifests });
|
|
285
314
|
result.localCrateCount += lock.localCrates.length;
|
|
286
315
|
result.unscannableCrates.push(...lock.unscannable.map((c) => ({ file: manifest.path, name: c.name, version: c.version, reason: UNSCANNABLE_CRATE_REASONS[c.kind] })));
|
|
287
316
|
}
|