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 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 all but Rust (planned). Setup instructions are provided for Claude Code, Claude Desktop, Codex CLI, Antigravity, and VS Code (GitHub Copilot).
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 fixes all its known vulnerabilities, for Maven, npm, PyPI, and Go. The recommended version is also checked against the OSV database so that it does not introduce other known vulnerabilities
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 Go, and PEP 440 for PyPI
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 by parsing the scanned copies:
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
- - Other formats (`gradle.lockfile`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, `pdm.lock`, and `Cargo.lock` for now), lockfile version 1, and entries not reachable from the project are `unknown`. If lockfiles disagree, the value is `mixed`
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 Java, JavaScript, Python, and Go. Rust packages are returned with `verification: "unsupported_ecosystem"` for now
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 Go (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
+ - `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 Go. 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
+ 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 PyPI, the "same release line" is the range npm's 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
+ 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 known vulnerabilities for the recommended version
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
- - [ ] `suggest_fix` and direct/transitive dependencies for Rust
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.12.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 Go (not yet for Rust).",
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 Go. " +
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 PyPI, a minor update within 0.x is also cross_major (may include breaking changes). " +
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
- "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). " +
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) {
@@ -79,8 +79,54 @@ function updateHint(pkg) {
79
79
  return UPDATE_HINTS[pkg.ecosystem];
80
80
  }
81
81
  }
82
- function hintFields(pkg) {
83
- const hint = updateHint(pkg);
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 details = pkg.vulnerabilities.map((vuln) => {
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.length - unfixedCount;
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: [] });
@@ -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";
@@ -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(package-lock.json)と行形式(go.mod、requirements.txt)だけを扱う。
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 read = await readRegularFile(path.join(path.dirname(lockAbs), "Cargo.toml"), { maxBytes: MAX_CARGO_TOML_BYTES, boundary: projectDir });
160
- const workspace = read.ok ? cargoWorkspace(read.bytes.toString("utf8")) : null;
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 read = await readRegularFile(path.join(projectDir, memberToml), { maxBytes: MAX_CARGO_TOML_BYTES, boundary: projectDir });
178
- const name = read.ok ? cargoTomlPackageName(read.bytes.toString("utf8")) : null;
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
- result.cargoCopies.push({ path: manifest.path, packages: lock.cratesIo });
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "MCP server that wraps Google's OSV-Scanner to scan Java projects for known vulnerabilities",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",