osv-scanner-mcp 0.11.0 → 0.12.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,12 +7,13 @@
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), and Go, with upgrade recommendations for all four. 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 all but Rust (planned). 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
- - **One-shot multi-language scan**: Pass a project path to `scan_project`, and it detects and scans the Java, JavaScript, Python, and Go 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`
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
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
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
16
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
17
18
  - **Java project scan**: `scan_java_project` scans only the Java (Maven / Gradle) manifests and returns a formatted report
18
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`)
@@ -181,7 +182,7 @@ Destinations confirmed with osv-scanner v2.4.0 (2026-10-07):
181
182
  |---|---|---|
182
183
  | Scanning `pom.xml` (`scan_project` / `scan_java_project` / `suggest_fix`) | `api.osv.dev`, **`api.deps.dev`** | Package names and versions. To resolve transitive dependencies, the names and versions of the dependencies declared in `pom.xml` (including internal packages) are sent to deps.dev |
183
184
  | Scanning `requirements.txt` (`scan_project` / `suggest_fix`) | `api.osv.dev`, **`api.deps.dev`** | As with `pom.xml`, the names and versions of the listed dependencies are sent to deps.dev to resolve transitive dependencies. Package indexes given with `--index-url` and similar options are not contacted |
184
- | Scanning lockfiles (`gradle.lockfile`, npm lockfiles such as `package-lock.json`, Python lockfiles such as `poetry.lock`, `go.mod`) | `api.osv.dev` | Package names and versions (lockfiles already list every dependency, so nothing is resolved remotely) |
185
+ | Scanning lockfiles (`gradle.lockfile`, npm lockfiles such as `package-lock.json`, Python lockfiles such as `poetry.lock`, `go.mod`, `Cargo.lock`) | `api.osv.dev` | Package names and versions (lockfiles already list every dependency, so nothing is resolved remotely). For `Cargo.lock`, only crates from crates.io are sent: the names of your own crates, git dependencies, and private-registry crates are not sent (confirmed 2026-10-11) |
185
186
  | `scan_java_artifact` / `scan_sbom` | `api.osv.dev` | Names and versions of the identified packages |
186
187
  | `suggest_fix` checking recommended versions | `api.osv.dev` | The names of packages with a recommendation (already sent during the scan) and the candidate versions (published fixed versions). Disable with `OSV_MCP_NO_CANDIDATE_CHECK=1` |
187
188
  | `explain_vulnerability` | `api.osv.dev` | The vulnerability ID |
@@ -196,7 +197,7 @@ Both api.osv.dev and deps.dev are operated by Google. **In every configuration,
196
197
 
197
198
  ### `scan_project`
198
199
 
199
- Detects the lockfiles and manifests in a project and scans the Java / JavaScript / Python / Go dependencies together. Package managers and builds are never run.
200
+ Detects the lockfiles and manifests in a project and scans the Java / JavaScript / Python / Go / Rust dependencies together. Package managers and builds are never run.
200
201
 
201
202
  **Input**
202
203
 
@@ -212,11 +213,14 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
212
213
  | JavaScript (npm) | `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock` (text format) |
213
214
  | Python (PyPI) | `poetry.lock`, `uv.lock`, `Pipfile.lock`, `pdm.lock`, `requirements.txt` (and variants such as `requirements-dev.txt`) |
214
215
  | Go | `go.mod` |
216
+ | Rust (crates.io) | `Cargo.lock` (lockfile versions 1 to 4) |
215
217
 
216
- `.git`, `node_modules`, `target`, `build`, `.idea`, `.vscode`, `.venv`, `venv`, `site-packages`, `__pycache__`, `.tox`, `vendor`, and symbolic links are not searched. Only the detected files are passed to OSV-Scanner, each with its format given explicitly (`coverage.manifests` in the response is exactly the scan scope). The search limits are the same as for `scan_java_project`.
218
+ `.git`, `node_modules`, `target`, `build`, `.idea`, `.vscode`, `.venv`, `venv`, `site-packages`, `__pycache__`, `.tox`, `vendor`, `.cargo`, and symbolic links are not searched. Only the detected files are passed to OSV-Scanner, each with its format given explicitly (`coverage.manifests` in the response is exactly the scan scope). The search limits are the same as for `scan_java_project`.
217
219
 
218
220
  **`requirements.txt` files are not passed to OSV-Scanner as they are.** The server parses them, rewrites only the dependency lines it can interpret into simple forms such as `name==version`, and scans a private temporary copy (deleted after the scan). OSV-Scanner interprets include directives on its own (it even treats `- r ../x.txt`, with a space, as an include and reads files outside the scan scope), so the copy never contains include directives or options. Includes (`-r` / `--requirement`) are expanded by the server, and only when the included file is inside the project directory.
219
221
 
222
+ **`Cargo.lock` files are not passed to OSV-Scanner as they are either.** OSV-Scanner (v2.4.0) ignores where each package comes from and looks every package up as a crates.io crate, so a workspace crate, a git fork, or a private-registry crate with the same name and version as a vulnerable crates.io crate is reported as vulnerable, and its name is sent to the OSV API. The server parses `Cargo.lock` and scans a private copy that lists only the packages whose source is crates.io (`registry+https://github.com/rust-lang/crates.io-index` or `sparse+https://index.crates.io/`), like `cargo audit`. Packages replaced with `[replace]` are not scanned, because they are not used (the replacement is classified by its own source). The parser accepts only what Cargo writes; if a `Cargo.lock` contains anything else, the whole file is excluded and listed in `skipped_files` instead of being scanned partially. Cargo is never run.
223
+
220
224
  **Reading the output**
221
225
 
222
226
  ```json
@@ -233,6 +237,8 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
233
237
  { "file": "py/requirements.txt", "line": 4, "text": "-e git+https://…", "reason": "…" },
234
238
  { "file": "py/requirements.txt", "line": 5, "text": "-r ../shared/base.txt", "reason": "…" }
235
239
  ],
240
+ "local_crates": 2,
241
+ "unscannable_crates": [{ "file": "Cargo.lock", "name": "my-fork", "version": "1.0.0", "reason": "Git dependency: …" }],
236
242
  "skipped_files": []
237
243
  },
238
244
  "ecosystem_breakdown": { "npm": { "manifests": 1, "vulnerable_package_count": 2, "vulnerability_count": 4 } },
@@ -247,20 +253,23 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
247
253
  ```
248
254
 
249
255
  - **Always check `coverage`.** If `complete` is `false`, some dependencies were not scanned, so zero findings does not mean the project is safe
250
- - `lockfile_missing`: manifests without a lockfile (`package.json`, `pyproject.toml`, `Pipfile`, `setup.py`, `build.gradle`, and so on). Not recorded if a lockfile of the same ecosystem is in the same directory. If there is only a lockfile in a parent directory, the manifest is not recorded only when `package-lock.json` (v2 or later) is confirmed to contain that directory (npm workspaces). Otherwise it is recorded with `status: "missing"`, or `status: "unconfirmed"` for formats that cannot be checked (yarn.lock, Python lockfiles, and so on). Generate the lockfile as described in `hint` (in a trusted environment) and scan again
256
+ - `lockfile_missing`: manifests without a lockfile (`package.json`, `pyproject.toml`, `Pipfile`, `setup.py`, `build.gradle`, `Cargo.toml`, and so on). Not recorded if a lockfile of the same ecosystem is in the same directory. If there is only a lockfile in a parent directory, the manifest is not recorded only when `package-lock.json` (v2 or later) is confirmed to contain that directory (npm workspaces), or when `Cargo.lock` contains a local crate with the `[package] name` of the `Cargo.toml` and the directory is a member of the workspace according to `members` / `exclude` of the `Cargo.toml` next to `Cargo.lock` (Cargo workspaces; `members` globs with `*` and `?` are supported). A crate with the same name that cannot be confirmed as a member, such as a path dependency outside the workspace, is recorded as `unconfirmed`. Library crates often do not commit `Cargo.lock`, so their `Cargo.toml` is recorded here; generate one with `cargo generate-lockfile`. Otherwise it is recorded with `status: "missing"`, or `status: "unconfirmed"` for formats that cannot be checked (yarn.lock, Python lockfiles, and so on). Generate the lockfile as described in `hint` (in a trusted environment) and scan again
251
257
  - `unpinned_requirements`: lines in `requirements.txt` that do not pin a version. `kind` is `unpinned` (no version), `range` (`>`, `<`, `!=`, `==1.*`, combined ranges, and so on), or `lower_bound` (`>=`, `~=`). OSV-Scanner does not scan `unpinned` and `range` lines, and scans `lower_bound` lines at the lower bound (those packages are marked `version_is_lower_bound: true` and may differ from the installed version)
252
258
  - `unscannable_requirements`: lines that are not scanned, with the reason: `-e`, `name @ URL`, paths, includes that were not expanded (outside the project directory, missing, URLs, or over the limits), constraint files (`-c`, not applied), and options or version specifiers that cannot be interpreted. Lines that cannot be interpreted are recorded here instead of being ignored
253
- - `skipped_files`: files excluded from the scan, with the reason (a `requirements.txt` that cannot be read or is larger than 1 MiB, or a `pom.xml` whose parent POM chain references a file outside the allowed root). A `pom.xml` scanned without its parent POM, because the parent could not be read, is also listed here with the reason (see [Parent POMs](#parent-poms))
259
+ - `local_crates` (only when a `Cargo.lock` was scanned): the number of crates without a source in `Cargo.lock` (workspace members and path dependencies). They are your own code and are not scanned; they do not make `complete` false. A third-party crate vendored as a path dependency is not scanned either (as with `cargo audit`)
260
+ - `unscannable_crates` (only when a `Cargo.lock` was scanned): git dependencies (their versions do not correspond to crates.io releases) and crates from registries other than crates.io (a crate with the same name on crates.io may be a different crate). They are not scanned, so `complete` is false. Source URLs are not included in the response
261
+ - `skipped_files`: files excluded from the scan, with the reason (a `Cargo.lock` that cannot be interpreted with certainty, a `requirements.txt` that cannot be read or is larger than 1 MiB, or a `pom.xml` whose parent POM chain references a file outside the allowed root). A `pom.xml` scanned without its parent POM, because the parent could not be read, is also listed here with the reason (see [Parent POMs](#parent-poms))
254
262
  - Each list holds up to 200 entries; the number of omitted entries is returned in `omitted_items`
255
263
  - `ecosystem_breakdown` includes ecosystems with zero findings, to show that they were scanned
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
256
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:
257
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`
258
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)
259
268
  - `requirements.txt`: dependencies written in the file are direct; dependencies resolved through deps.dev are transitive
260
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`
261
- - Other formats (`gradle.lockfile`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `poetry.lock`, `uv.lock`, `Pipfile.lock`, `pdm.lock`), lockfile version 1, and entries not reachable from the project are `unknown`. If lockfiles disagree, the value is `mixed`
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`
262
271
  - `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
263
- - Upgrade recommendations (`suggest_fix`) are available for Java, JavaScript, Python, and Go
272
+ - Upgrade recommendations (`suggest_fix`) are available for Java, JavaScript, Python, and Go. Rust packages are returned with `verification: "unsupported_ecosystem"` for now
264
273
 
265
274
  ### `scan_java_project`
266
275
 
@@ -376,7 +385,7 @@ Looks up the dependencies recorded in an existing SBOM with OSV-Scanner. It does
376
385
  - `status`: `packages_identified` if anything was identified, otherwise `no_packages_identified`.
377
386
  - `completeness` / `artifact_match`: both `not_verified`. Whether the SBOM covers every dependency, and whether it matches the actual build, are not verified.
378
387
 
379
- `sbom` returns the original file path, the format, the specification version, and the SHA256 of the bytes that were scanned. `identified_vulnerability_count` and `packages` contain the findings for the identified dependencies. **Zero findings does not mean the software is safe.** To cover JARs without metadata, prepare an accurate SBOM for that build.
388
+ `sbom` returns the original file path, the format, the specification version, and the SHA256 of the bytes that were scanned. `identified_vulnerability_count` and `packages` contain the findings for the identified dependencies. RustSec informational advisories for crates in the SBOM are counted in `informational_count` instead (see `scan_project`). **Zero findings does not mean the software is safe.** To cover JARs without metadata, prepare an accurate SBOM for that build.
380
389
 
381
390
  Invalid JSON returns `invalid_sbom`, an unsupported format `unsupported_sbom_format`, input over the size limit `sbom_too_large`, and a missing or unreadable file `sbom_not_found`. An empty SBOM, or one with no identifiable packages, returns a successful report with a warning. The same timeout, output limit, and concurrency limit as the other tools apply.
382
391
 
@@ -512,8 +521,8 @@ The following measures keep the vulnerability scanner itself from becoming an at
512
521
  - **Supply chain**: The automatic download is limited to the official GitHub Releases and a pinned version, and is verified against **a SHA256 checksum embedded in the package** (the SHA256SUMS file from the release is not trusted, so tampering on the release side is detected). The binary is not made executable until it passes verification, and cached binaries are verified again on every use. `OSV_MCP_PREFER_DOWNLOAD=1` avoids unverified binaries on `PATH`
513
522
  - **Command injection**: Commands are run with `spawn` and an argument array, never through a shell. OSV-Scanner receives only a fixed list of arguments, plus verified absolute paths
514
523
  - **Snapshots (no gap between checking and reading)**: OSV-Scanner never receives the original files. Lockfiles, `pom.xml` (including the parent POM chain), and JAR/WARs are read safely once, copied into a private temporary directory (accessible only by the owner, deleted afterwards), and the copies are both checked and scanned. Replacing the original files or directories after the check has no effect on the results. Parent POMs are copied into the temporary directory at their original relative positions, so OSV-Scanner can follow relative paths but only finds the verified copies (references that climb out of the temporary directory with repeated `..` are excluded). Reads refuse a symbolic link at the final path component and anything that is not a regular file (so named pipes cannot stall the server), and afterwards the path is resolved again to confirm that it is still inside the boundary and is the same file that was opened. The copies are limited to 2 GiB in total (`scan_input_too_large`). When the server is terminated (SIGTERM/SIGINT/SIGHUP, or stdin closing), the temporary directories are deleted and running OSV-Scanner processes are stopped. Directories left behind by an uncatchable termination such as SIGKILL are deleted at a later startup (only directories whose names exactly match this server's prefixes, owned by the current user, not symbolic links, and unmodified for 24 hours or more)
515
- - **Path traversal**: Input paths are resolved with `realpath` (following symbolic links) before the boundary check. Manifest searches do not follow symbolic links. OSV-Scanner never receives a directory, only the detected manifests, each with its format given explicitly (given a directory, OSV-Scanner also reads `requirements.txt` files there and follows their `-r ../x.txt` includes outside the scan scope). `requirements.txt` files are not passed as they are: only the dependency lines the server could interpret are normalized and written to a private copy without any include directives, so even if OSV-Scanner's interpretation of includes differs from the server's, files outside the scope are never read. A `pom.xml` whose parent POM chain references a file outside `OSV_MCP_ALLOWED_ROOT` is excluded ([Parent POMs](#parent-poms))
516
- - **Denial of service**: Timeouts and limits on stdout and on the stderr excerpt. Scanner output is parsed defensively and malformed output does not throw. The number of concurrent scans is limited (2 by default) so that parallel requests cannot start unlimited processes. The server's own analysis reads each file once and reuses the result (lockfiles for npm workspace membership, shared `requirements.txt` includes, parent POMs), with a limit on the total amount read. Responses do not include the internal affected-range data, which keeps large scans to a fraction of their former size
524
+ - **Path traversal**: Input paths are resolved with `realpath` (following symbolic links) before the boundary check. Manifest searches do not follow symbolic links. OSV-Scanner never receives a directory, only the detected manifests, each with its format given explicitly (given a directory, OSV-Scanner also reads `requirements.txt` files there and follows their `-r ../x.txt` includes outside the scan scope). `requirements.txt` files are not passed as they are: only the dependency lines the server could interpret are normalized and written to a private copy without any include directives, so even if OSV-Scanner's interpretation of includes differs from the server's, files outside the scope are never read. `Cargo.lock` files are likewise replaced by a copy the server writes, listing only crates.io packages A `pom.xml` whose parent POM chain references a file outside `OSV_MCP_ALLOWED_ROOT` is excluded ([Parent POMs](#parent-poms))
525
+ - **Denial of service**: Timeouts and limits on stdout and on the stderr excerpt. Scanner output is parsed defensively and malformed output does not throw. The number of concurrent scans is limited (2 by default) so that parallel requests cannot start unlimited processes. The server's own analysis reads each file once and reuses the result (lockfiles for npm workspace membership, `Cargo.lock` files, shared `requirements.txt` includes, parent POMs), with a limit on the total amount read. Responses do not include the internal affected-range data, which keeps large scans to a fraction of their former size
517
526
  - **Fail-closed mode**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` refuses to start the server when no allowed root is set
518
527
  - **Restricted permissions (optional)**: The server works under Node's permission model, reports denied access as `permission_denied`, and warns about missing permissions at startup ([Running with restricted permissions](#running-with-restricted-permissions))
519
528
  - **Fixed and documented network destinations**: Dependency resolution is set to `deps.dev` explicitly, and the mode that connects to arbitrary repositories listed in the scanned `pom.xml` (`--data-source native`) is never used (guaranteed by tests). See [Network destinations and privacy](#network-destinations-and-privacy) for the destinations and for `OSV_MCP_NO_REMOTE_RESOLUTION=1`, which stops sending data to deps.dev
@@ -547,6 +556,8 @@ Design notes and open items are in [docs/DESIGN_TODO.md](docs/DESIGN_TODO.md) (i
547
556
  - [x] Flagging inferred coordinates in JAR/WAR scans
548
557
  - [x] Running under Node's permission model
549
558
  - [x] English tool descriptions and messages
559
+ - [x] `scan_project` for Rust (`Cargo.lock`, crates.io crates only) and RustSec informational advisories
560
+ - [ ] `suggest_fix` and direct/transitive dependencies for Rust
550
561
  - [ ] Direct and transitive dependencies for `gradle.lockfile` and the YAML/TOML lockfiles
551
562
 
552
563
  ## License
package/dist/index.js CHANGED
@@ -61,17 +61,19 @@ if (startupWarning !== null) {
61
61
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
62
62
  const server = new McpServer({
63
63
  name: "osv-scanner-mcp",
64
- version: "0.11.0",
64
+ version: "0.12.0",
65
65
  });
66
66
  server.registerTool("scan_project", {
67
- title: "Scan project dependencies for vulnerabilities (Java / JavaScript / Python / Go)",
67
+ title: "Scan project dependencies for vulnerabilities (Java / JavaScript / Python / Go / Rust)",
68
68
  description: "Detects the lockfiles and manifests in a project and scans the dependencies for known vulnerabilities (CVE/GHSA). " +
69
69
  "Supported: Java (pom.xml / gradle.lockfile), JavaScript (package-lock.json / npm-shrinkwrap.json / yarn.lock / pnpm-lock.yaml / bun.lock), " +
70
- "Python (poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt), Go (go.mod). " +
70
+ "Python (poetry.lock / uv.lock / Pipfile.lock / pdm.lock / requirements.txt), Go (go.mod), Rust (Cargo.lock). " +
71
71
  "Package managers and builds are never run. " +
72
72
  "Always check coverage at the top of the response: it lists manifests without a lockfile, requirements lines without a pinned version, and excluded files. " +
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
+ "Vulnerabilities marked informational (RustSec notices such as unmaintained or unsound crates) are not counted in vulnerability_count; they are counted in informational_count. " +
73
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). " +
74
- "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.",
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).",
75
77
  inputSchema: {
76
78
  project_path: z
77
79
  .string()
@@ -49,6 +49,7 @@ export function buildSbomReport(raw, input, snapshotPath) {
49
49
  },
50
50
  identified_vulnerability_count: report.vulnerability_count,
51
51
  identified_vulnerable_package_count: report.vulnerable_package_count,
52
+ ...(report.informational_count !== undefined ? { informational_count: report.informational_count } : {}),
52
53
  severity_breakdown: report.severity_breakdown,
53
54
  packages: report.packages,
54
55
  };
@@ -15,6 +15,59 @@
15
15
  import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
16
16
  import { extractAffectedVersions } from "./affectedVersions.js";
17
17
  import { samePackageName, sortVersions, versionRangeTypes } from "./versionScheme.js";
18
+ /**
19
+ * RustSecの`informational`の値。並びは複数の種類が混在するグループで代表にする優先順
20
+ * (健全性の欠陥は版ごとの欠陥で、保守終了・通知より重い)
21
+ */
22
+ export const INFORMATIONAL_KINDS = ["unsound", "unmaintained", "notice"];
23
+ function isInformationalKind(value) {
24
+ return INFORMATIONAL_KINDS.includes(value);
25
+ }
26
+ /**
27
+ * OSVレコードのうち、対象パッケージの`affected[]`の`database_specific.informational`。
28
+ * 該当する`affected[]`が無い・1つでも値が無い(未知の値を含む)場合はnull
29
+ */
30
+ export function recordInformationalKind(record, packageName, ecosystem) {
31
+ let kind = null;
32
+ let matched = false;
33
+ for (const affectedRaw of asArray(record.affected)) {
34
+ const affected = asRecord(affectedRaw);
35
+ const pkg = asRecord(affected?.package);
36
+ const affectedName = asString(pkg?.name);
37
+ if (affectedName === null || !samePackageName(affectedName, packageName, ecosystem) || asString(pkg?.ecosystem) !== ecosystem)
38
+ continue;
39
+ matched = true;
40
+ const value = asString(asRecord(affected.database_specific)?.informational);
41
+ if (value === null || !isInformationalKind(value))
42
+ return null;
43
+ if (kind === null || INFORMATIONAL_KINDS.indexOf(value) < INFORMATIONAL_KINDS.indexOf(kind))
44
+ kind = value;
45
+ }
46
+ return matched ? kind : null;
47
+ }
48
+ /**
49
+ * グループが情報提供か。関連するすべてのID(グループのids・aliases、各レコードのaliases)の詳細があり、
50
+ * そのすべてが情報提供の場合だけ種類を返す。GHSA等が同じ欠陥を脆弱性として登録していれば(informationalなし)、
51
+ * また詳細の欠けたID(aliasesにだけ現れるGHSA・CVE等)があれば分類を確かめられないため、従来どおり脆弱性として扱う
52
+ */
53
+ function groupInformationalKind(details, ids, aliases, packageName, ecosystem) {
54
+ if (ids.length === 0)
55
+ return null;
56
+ const related = new Set([...ids, ...aliases]);
57
+ for (const record of findVulnDetails(details, ids))
58
+ for (const alias of asStrings(record.aliases))
59
+ related.add(alias);
60
+ let kind = null;
61
+ for (const id of related) {
62
+ const record = findVulnDetails(details, [id])[0];
63
+ const recordKind = record === undefined ? null : recordInformationalKind(record, packageName, ecosystem);
64
+ if (recordKind === null)
65
+ return null;
66
+ if (kind === null || INFORMATIONAL_KINDS.indexOf(recordKind) < INFORMATIONAL_KINDS.indexOf(kind))
67
+ kind = recordKind;
68
+ }
69
+ return kind;
70
+ }
18
71
  /** 外部由来のsummaryをLLMに渡す際の長さ上限(プロンプト肥大・DoS対策) */
19
72
  const MAX_SUMMARY_LENGTH = 500;
20
73
  const CVSS_CRITICAL = 9.0;
@@ -156,6 +209,7 @@ export function parseOsvScanOutput(raw) {
156
209
  continue;
157
210
  const score = parseSeverityScore(asString(group.max_severity));
158
211
  const vulnDetails = findVulnDetails(details, ids);
212
+ const informational = groupInformationalKind(details, ids, aliases, name, ecosystem);
159
213
  entry.vulns.set(primaryId, {
160
214
  id: primaryId,
161
215
  cve: aliases.find((a) => a.startsWith("CVE-")) ??
@@ -163,6 +217,7 @@ export function parseOsvScanOutput(raw) {
163
217
  aliases: [...new Set([...ids, ...aliases])].filter((a) => a !== primaryId),
164
218
  severity_score: score,
165
219
  severity: severityFromScore(score),
220
+ ...(informational !== null ? { informational } : {}),
166
221
  summary: extractSummary(vulnDetails),
167
222
  fixed_versions: extractFixedVersions(vulnDetails, name, ecosystem),
168
223
  });
@@ -189,11 +244,19 @@ function buildReport(sourceFiles, packageMap) {
189
244
  unknown: 0,
190
245
  };
191
246
  let vulnerabilityCount = 0;
247
+ let informationalCount = 0;
192
248
  const packages = [...packageMap.values()]
193
249
  .filter((entry) => entry.vulns.size > 0)
194
250
  .map((entry) => {
195
- const vulnerabilities = [...entry.vulns.values()].sort((a, b) => sortScore(b.severity_score) - sortScore(a.severity_score) || cmpId(a.id, b.id));
251
+ // 情報提供のグループは脆弱性の後ろに置く
252
+ const vulnerabilities = [...entry.vulns.values()].sort((a, b) => Number(a.informational !== undefined) - Number(b.informational !== undefined) ||
253
+ sortScore(b.severity_score) - sortScore(a.severity_score) ||
254
+ cmpId(a.id, b.id));
196
255
  for (const vuln of vulnerabilities) {
256
+ if (vuln.informational !== undefined) {
257
+ informationalCount++;
258
+ continue;
259
+ }
197
260
  severityBreakdown[vuln.severity]++;
198
261
  vulnerabilityCount++;
199
262
  }
@@ -210,14 +273,23 @@ function buildReport(sourceFiles, packageMap) {
210
273
  .sort((a, b) => maxScore(b.vulnerabilities) - maxScore(a.vulnerabilities) || cmpId(a.name, b.name));
211
274
  return {
212
275
  source_files: sourceFiles,
213
- vulnerable_package_count: packages.length,
276
+ vulnerable_package_count: packages.filter(hasVulnerability).length,
214
277
  vulnerability_count: vulnerabilityCount,
278
+ ...(informationalCount > 0 ? { informational_count: informationalCount } : {}),
215
279
  severity_breakdown: severityBreakdown,
216
280
  packages,
217
281
  };
218
282
  }
283
+ /** 情報提供でない脆弱性を1件以上持つか */
284
+ export function hasVulnerability(pkg) {
285
+ return pkg.vulnerabilities.some((v) => v.informational === undefined);
286
+ }
287
+ /** 並び替え用。情報提供だけのパッケージは、脆弱性のあるパッケージ(unknownを含む)の後ろに置く */
219
288
  function maxScore(vulns) {
220
- return vulns.reduce((max, v) => Math.max(max, sortScore(v.severity_score)), -1);
289
+ const counted = vulns.filter((v) => v.informational === undefined);
290
+ if (counted.length === 0)
291
+ return -2;
292
+ return counted.reduce((max, v) => Math.max(max, sortScore(v.severity_score)), -1);
221
293
  }
222
294
  function cmpId(a, b) {
223
295
  return a < b ? -1 : a > b ? 1 : 0;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `scan_project`ツールのハンドラ: Java / JavaScript / Python / Goのlockfileをまとめてスキャンする。
2
+ * `scan_project`ツールのハンドラ: Java / JavaScript / Python / Go / Rustのlockfileをまとめてスキャンする。
3
3
  *
4
4
  * 誤要約対策(JAR実体スキャンと同じ原則): スキャンできなかった依存を示すcoverageを件数より前に置き、
5
5
  * complete=falseの場合は「検出0件でも安全とは言えない」旨の警告を付ける。
@@ -7,8 +7,9 @@
7
7
  import path from "node:path";
8
8
  import { ScanToolError } from "../errors.js";
9
9
  import { isRemoteResolutionDisabled, runOsvScan } from "../osv/runner.js";
10
- import { packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
10
+ import { hasVulnerability, packageSources, parseOsvScanOutput } from "../osv/scanReport.js";
11
11
  import { combineRelations, goModRelations, npmLockRelations, pomRelations, requirementsRelations, } from "../utils/dependencyRelations.js";
12
+ import { cargoLockCopyContent } from "../utils/cargoLock.js";
12
13
  import { sanitizeExternalText } from "../utils/externalText.js";
13
14
  import { readRegularFile } from "../utils/safeRead.js";
14
15
  import { detectProject } from "../utils/manifestDetector.js";
@@ -21,7 +22,7 @@ export const TRANSITIVE_OMITTED_WARNING = "Because OSV_MCP_NO_REMOTE_RESOLUTION
21
22
  "but vulnerabilities in the transitive dependencies of the dependencies listed directly in pom.xml or requirements.txt are not included. " +
22
23
  "Zero findings does not confirm that the transitive dependencies are safe";
23
24
  const INCOMPLETE_WARNING = "Some dependencies were not scanned, or were scanned at an assumed version " +
24
- "(see lockfile_missing / unpinned_requirements / unscannable_requirements / skipped_files). " +
25
+ "(see lockfile_missing / unpinned_requirements / unscannable_requirements / unscannable_crates / skipped_files). " +
25
26
  "Zero findings does not confirm that those dependencies are safe";
26
27
  /** coverageの各一覧の上限(巨大なrequirements.txtで応答を膨らませない) */
27
28
  const MAX_COVERAGE_ITEMS = 200;
@@ -34,11 +35,14 @@ export function buildCoverage(project) {
34
35
  const unpinned = capped(project.requirementIssues);
35
36
  const unscannable = capped(project.requirementReferences);
36
37
  const skipped = capped(project.skippedFiles);
38
+ const crates = capped(project.unscannableCrates);
37
39
  const complete = project.lockfileMissing.length === 0 &&
38
40
  project.requirementIssues.length === 0 &&
39
41
  project.requirementReferences.length === 0 &&
42
+ project.unscannableCrates.length === 0 &&
40
43
  project.skippedFiles.length === 0;
41
- const omitted = lockfileMissing.omitted + unpinned.omitted + unscannable.omitted + skipped.omitted;
44
+ const omitted = lockfileMissing.omitted + unpinned.omitted + unscannable.omitted + crates.omitted + skipped.omitted;
45
+ const hasCargo = project.manifests.some((m) => m.format === "Cargo.lock");
42
46
  return {
43
47
  complete,
44
48
  ...(complete ? {} : { warning: INCOMPLETE_WARNING }),
@@ -62,6 +66,18 @@ export function buildCoverage(project) {
62
66
  text: sanitizeExternalText(ref.text),
63
67
  reason: ref.reason,
64
68
  })),
69
+ // Rust: 従来のエコシステムの応答を変えないよう、Cargo.lockをスキャンした場合だけ付ける
70
+ ...(hasCargo
71
+ ? {
72
+ local_crates: project.localCrateCount,
73
+ unscannable_crates: crates.items.map((c) => ({
74
+ file: sanitizeExternalText(c.file),
75
+ name: sanitizeExternalText(c.name),
76
+ version: sanitizeExternalText(c.version),
77
+ reason: c.reason,
78
+ })),
79
+ }
80
+ : {}),
65
81
  skipped_files: skipped.items.map((s) => ({ path: sanitizeExternalText(s.path), reason: sanitizeExternalText(s.reason) })),
66
82
  ...(omitted > 0 ? { omitted_items: omitted } : {}),
67
83
  };
@@ -73,9 +89,13 @@ function ecosystemBreakdown(project, packages) {
73
89
  breakdown[manifest.ecosystem].manifests++;
74
90
  }
75
91
  for (const pkg of packages) {
76
- breakdown[pkg.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 };
77
- breakdown[pkg.ecosystem].vulnerable_package_count++;
78
- breakdown[pkg.ecosystem].vulnerability_count += pkg.vulnerabilities.length;
92
+ const counts = (breakdown[pkg.ecosystem] ??= { manifests: 0, vulnerable_package_count: 0, vulnerability_count: 0 });
93
+ const informational = pkg.vulnerabilities.filter((v) => v.informational !== undefined).length;
94
+ if (hasVulnerability(pkg))
95
+ counts.vulnerable_package_count++;
96
+ counts.vulnerability_count += pkg.vulnerabilities.length - informational;
97
+ if (informational > 0)
98
+ counts.informational_count = (counts.informational_count ?? 0) + informational;
79
99
  }
80
100
  return breakdown;
81
101
  }
@@ -188,6 +208,13 @@ export async function scanFromSnapshot(project, options) {
188
208
  targets.push({ path: generated, format: "requirements.txt" });
189
209
  relationSources.push({ copy: generated, format: "requirements.txt", originalRelative: copy.path, entries: copy.entries });
190
210
  }
211
+ for (const copy of project.cargoCopies) {
212
+ if (copy.packages.length === 0)
213
+ continue;
214
+ const generated = await snapshot.writeGenerated(cargoLockCopyContent(copy.packages));
215
+ targets.push({ path: generated, format: "Cargo.lock" });
216
+ relationSources.push({ copy: generated, format: "Cargo.lock", originalRelative: copy.path });
217
+ }
191
218
  if (targets.length === 0)
192
219
  return parseOsvScanOutput({ results: [] });
193
220
  const report = await withScopeNotes(project.skippedFiles, () => snapshot.guard(() => runOsvScan(targets, options)));
@@ -210,6 +237,7 @@ export async function handleScanProject(args, options = {}) {
210
237
  ecosystem_breakdown: ecosystemBreakdown(project, report.packages),
211
238
  vulnerable_package_count: report.vulnerable_package_count,
212
239
  vulnerability_count: report.vulnerability_count,
240
+ ...(report.informational_count !== undefined ? { informational_count: report.informational_count } : {}),
213
241
  severity_breakdown: report.severity_breakdown,
214
242
  packages: markLowerBounds(project, report.packages),
215
243
  });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { isRemoteResolutionDisabled } from "../osv/runner.js";
8
8
  import { candidateCheckDisabledFromEnv, checkRecommendedCandidates } from "../osv/candidateCheck.js";
9
+ import { hasVulnerability } from "../osv/scanReport.js";
9
10
  import { suggestUpgrades } from "../osv/suggestFix.js";
10
11
  import { sanitizeExternalText } from "../utils/externalText.js";
11
12
  import { detectProject } from "../utils/manifestDetector.js";
@@ -26,7 +27,8 @@ export async function handleSuggestFix(args, options = {}) {
26
27
  manifests: project.manifests.map((m) => sanitizeExternalText(m.path)),
27
28
  dependency_resolution: dependencyResolution(noRemoteResolution, TRANSITIVE_OMITTED_WARNING),
28
29
  coverage: buildCoverage(project),
29
- vulnerable_package_count: suggestions.length,
30
+ // 情報提供のアドバイザリ(RustSecのunmaintained等)だけのパッケージは数えない
31
+ vulnerable_package_count: packages.filter(hasVulnerability).length,
30
32
  unfixed_vulnerability_count: unfixedVulnerabilities,
31
33
  suggestions,
32
34
  });
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Cargo.lockの解析と、osv-scannerに渡すコピーの内容の生成(cargoは実行しない)。
3
+ *
4
+ * osv-scanner v2.4.0はCargo.lockの`source`を見ずに全パッケージをcrates.ioとして照会する(2026-10-11 実機確認)。
5
+ * workspaceのメンバー・pathの依存・gitの依存・別レジストリの依存も、同名・同版のcrates.ioのアドバイザリが付き(誤検知)、
6
+ * 社内crateの名前がapi.osv.devへ送られる。そこで元のファイルは渡さず、ここで解析したcrates.io由来の
7
+ * パッケージだけを書いたコピーをスキャンする(docs/DESIGN_TODO.md「Rust(crates.io)対応」)。
8
+ *
9
+ * 解析はcargoが生成する範囲のTOMLだけを受け付ける厳格な行解析。想定外の行が1つでもあれば
10
+ * ファイル全体を失敗にする(部分的に読んで取りこぼしを隠さない)。
11
+ */
12
+ /** crates.ioを指すsource(git索引・sparse索引) */
13
+ const CRATES_IO_SOURCES = new Set([
14
+ "registry+https://github.com/rust-lang/crates.io-index",
15
+ "sparse+https://index.crates.io/",
16
+ ]);
17
+ /** 1つのCargo.lockで受け付けるパッケージ数の上限 */
18
+ const MAX_PACKAGES = 50_000;
19
+ /** crateの名前(cargoの制約より緩め。英数字・`-`・`_`) */
20
+ const CRATE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
21
+ /** 版(SemVerの文字の範囲) */
22
+ const CRATE_VERSION = /^[0-9A-Za-z.+-]{1,256}$/;
23
+ export function cargoSourceKind(source) {
24
+ if (source === null)
25
+ return "local";
26
+ if (CRATES_IO_SOURCES.has(source))
27
+ return "crates_io";
28
+ if (source.startsWith("git+"))
29
+ return "git";
30
+ return "registry";
31
+ }
32
+ const BARE_KEY = /^([A-Za-z0-9_-]+)\s*=\s*(.*)$/;
33
+ /**
34
+ * TOMLの基本文字列(`"..."`)を先頭から1つ読む。読めなければnull。
35
+ * cargoが書くエスケープ(`\"` `\\` `\n` `\t` `\uXXXX` `\UXXXXXXXX`等)だけを受け付ける
36
+ */
37
+ function readBasicString(text, start) {
38
+ if (text[start] !== '"')
39
+ return null;
40
+ let value = "";
41
+ for (let i = start + 1; i < text.length; i++) {
42
+ const ch = text[i];
43
+ if (ch === '"')
44
+ return { value, end: i + 1 };
45
+ if (ch === "\\") {
46
+ const next = text[i + 1];
47
+ const simple = { '"': '"', "\\": "\\", n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" };
48
+ if (next !== undefined && next in simple) {
49
+ value += simple[next];
50
+ i++;
51
+ continue;
52
+ }
53
+ const width = next === "u" ? 4 : next === "U" ? 8 : 0;
54
+ const hex = text.slice(i + 2, i + 2 + width);
55
+ if (width === 0 || !/^[0-9A-Fa-f]+$/.test(hex) || hex.length !== width)
56
+ return null;
57
+ const codePoint = Number.parseInt(hex, 16);
58
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff))
59
+ return null;
60
+ value += String.fromCodePoint(codePoint);
61
+ i += 1 + width;
62
+ continue;
63
+ }
64
+ if (ch.charCodeAt(0) < 0x20 && ch !== "\t")
65
+ return null;
66
+ value += ch;
67
+ }
68
+ return null;
69
+ }
70
+ /** 行の残り(値の後ろ)が空白かコメントだけか */
71
+ function restIsBlank(rest) {
72
+ return /^\s*(?:#.*)?$/.test(rest);
73
+ }
74
+ /** 1行全体が文字列1つ(と任意の末尾カンマ・コメント)の場合にその値を返す */
75
+ function lineString(text, allowComma) {
76
+ const parsed = readBasicString(text, 0);
77
+ if (parsed === null)
78
+ return null;
79
+ let rest = text.slice(parsed.end);
80
+ if (allowComma)
81
+ rest = rest.replace(/^\s*,/, "");
82
+ return restIsBlank(rest) ? parsed.value : null;
83
+ }
84
+ /** `["a", "b"]`形式(1行の配列)の要素。読めなければnull */
85
+ function inlineStringArray(text) {
86
+ if (!text.startsWith("["))
87
+ return null;
88
+ const items = [];
89
+ let i = 1;
90
+ for (;;) {
91
+ while (text[i] === " " || text[i] === "\t")
92
+ i++;
93
+ if (text[i] === "]")
94
+ return restIsBlank(text.slice(i + 1)) ? items : null;
95
+ const parsed = readBasicString(text, i);
96
+ if (parsed === null)
97
+ return null;
98
+ items.push(parsed.value);
99
+ i = parsed.end;
100
+ while (text[i] === " " || text[i] === "\t")
101
+ i++;
102
+ if (text[i] === ",")
103
+ i++;
104
+ else if (text[i] !== "]")
105
+ return null;
106
+ }
107
+ }
108
+ /**
109
+ * Cargo.lock(形式v1〜v4)を解析する。
110
+ * 受け付ける行: 空行・コメント、先頭の`version = 整数`、`[[package]]`、`[[patch.unused]]`(読み飛ばす)、
111
+ * `[metadata]`(v1のchecksum。読み飛ばす)、`キー = "文字列"`、`dependencies = [` 〜 `]`の各要素
112
+ */
113
+ export function parseCargoLock(text) {
114
+ const packages = [];
115
+ let current = null;
116
+ let state = "top";
117
+ let sawTable = false;
118
+ const fail = (line, what) => ({
119
+ ok: false,
120
+ reason: `Cargo.lock could not be interpreted with certainty (${what} at line ${line}), so the file was excluded from the scan`,
121
+ });
122
+ const finish = (line) => {
123
+ if (current === null)
124
+ return null;
125
+ const { name, version, source, replace, dependencies } = current;
126
+ current = null;
127
+ if (name === undefined || version === undefined)
128
+ return fail(line, "a package without a name or version");
129
+ if (!CRATE_NAME.test(name))
130
+ return fail(line, "an unexpected crate name");
131
+ if (!CRATE_VERSION.test(version))
132
+ return fail(line, "an unexpected version");
133
+ if (packages.length >= MAX_PACKAGES)
134
+ return fail(line, `more than ${MAX_PACKAGES} packages`);
135
+ packages.push({ name, version, source: source ?? null, dependencies, ...(replace !== undefined ? { replace } : {}) });
136
+ return null;
137
+ };
138
+ const lines = text.replace(/^/, "").split(/\r?\n/);
139
+ for (let index = 0; index < lines.length; index++) {
140
+ const lineNo = index + 1;
141
+ const line = lines[index].trim();
142
+ if (state === "dependencies") {
143
+ if (line === "" || line.startsWith("#"))
144
+ continue;
145
+ if (/^\]\s*(?:#.*)?$/.test(line)) {
146
+ state = "package";
147
+ continue;
148
+ }
149
+ const item = lineString(line, true);
150
+ if (item === null)
151
+ return fail(lineNo, "an unexpected dependency entry");
152
+ current.dependencies.push(item);
153
+ continue;
154
+ }
155
+ if (line === "" || line.startsWith("#"))
156
+ continue;
157
+ if (/^\[\[package\]\]\s*(?:#.*)?$/.test(line)) {
158
+ const failed = finish(lineNo);
159
+ if (failed)
160
+ return failed;
161
+ current = { dependencies: [] };
162
+ state = "package";
163
+ sawTable = true;
164
+ continue;
165
+ }
166
+ if (/^\[\[patch\.unused\]\]\s*(?:#.*)?$/.test(line)) {
167
+ const failed = finish(lineNo);
168
+ if (failed)
169
+ return failed;
170
+ state = "skipped_table";
171
+ sawTable = true;
172
+ continue;
173
+ }
174
+ if (/^\[metadata\]\s*(?:#.*)?$/.test(line)) {
175
+ const failed = finish(lineNo);
176
+ if (failed)
177
+ return failed;
178
+ state = "metadata";
179
+ sawTable = true;
180
+ continue;
181
+ }
182
+ if (line.startsWith("["))
183
+ return fail(lineNo, "an unexpected table");
184
+ if (state === "metadata") {
185
+ // v1: "checksum 名前 版 (source)" = "ハッシュ"
186
+ const key = readBasicString(line, 0);
187
+ const valueMatch = key === null ? null : /^\s*=\s*(.*)$/.exec(line.slice(key.end));
188
+ if (valueMatch === null || lineString(valueMatch[1], false) === null)
189
+ return fail(lineNo, "an unexpected metadata entry");
190
+ continue;
191
+ }
192
+ const match = BARE_KEY.exec(line);
193
+ if (match === null)
194
+ return fail(lineNo, "an unexpected line");
195
+ const [, key, valueText] = match;
196
+ if (state === "top") {
197
+ if (key !== "version" || sawTable || !/^\d{1,3}\s*(?:#.*)?$/.test(valueText))
198
+ return fail(lineNo, "an unexpected top-level key");
199
+ continue;
200
+ }
201
+ if (key === "dependencies" && (state === "package" || state === "skipped_table")) {
202
+ if (/^\[\s*(?:#.*)?$/.test(valueText)) {
203
+ if (state === "skipped_table")
204
+ return fail(lineNo, "an unexpected array");
205
+ state = "dependencies";
206
+ continue;
207
+ }
208
+ const items = inlineStringArray(valueText);
209
+ if (items === null)
210
+ return fail(lineNo, "an unexpected dependencies value");
211
+ if (state === "package")
212
+ current.dependencies.push(...items);
213
+ continue;
214
+ }
215
+ const value = lineString(valueText, false);
216
+ if (value === null)
217
+ return fail(lineNo, `an unexpected value for ${key}`);
218
+ if (state === "package") {
219
+ if (key === "name" || key === "version" || key === "source" || key === "replace") {
220
+ if (current[key] !== undefined)
221
+ return fail(lineNo, `a duplicate ${key}`);
222
+ current[key] = value;
223
+ }
224
+ // checksum等の他の文字列キーはスキャンに使わない
225
+ }
226
+ }
227
+ if (state === "dependencies")
228
+ return fail(lines.length, "an unterminated dependencies array");
229
+ const failed = finish(lines.length);
230
+ if (failed)
231
+ return failed;
232
+ // 置換先が解釈でき、別のパッケージとして記載されていることを確かめる(置換元は使われないためスキャンしない)
233
+ for (const pkg of packages) {
234
+ if (pkg.replace === undefined)
235
+ continue;
236
+ const target = parsePackageId(pkg.replace);
237
+ const found = target !== null && packages.some((other) => other !== pkg && other.name === target.name && other.version === target.version && other.source === target.source);
238
+ if (!found) {
239
+ return {
240
+ ok: false,
241
+ reason: `Cargo.lock could not be interpreted with certainty (the replacement of ${pkg.name} ${pkg.version} by [replace] was not found), so the file was excluded from the scan`,
242
+ };
243
+ }
244
+ }
245
+ return { ok: true, packages };
246
+ }
247
+ /** Cargo.lockのパッケージID(`"名前 版"` / `"名前 版 (source)"`)。解釈できなければnull */
248
+ export function parsePackageId(id) {
249
+ const match = /^(\S+) (\S+)(?: \((.+)\))?$/.exec(id);
250
+ if (match === null)
251
+ return null;
252
+ return { name: match[1], version: match[2], source: match[3] ?? null };
253
+ }
254
+ export const UNSCANNABLE_CRATE_REASONS = {
255
+ git: "Git dependency: its version does not correspond to a crates.io release, so it was not checked against crates.io advisories",
256
+ registry: "Dependency from a registry other than crates.io: a crate with the same name on crates.io may be a different crate, " +
257
+ "so it was not checked (and its name was not sent to api.osv.dev)",
258
+ };
259
+ export function classifyCargoPackages(packages) {
260
+ const result = { cratesIo: [], localCrates: [], unscannable: [] };
261
+ const seen = new Set();
262
+ for (const pkg of packages) {
263
+ // [replace]で置換されたパッケージは使われない(置換先は別のパッケージとして分類する)
264
+ if (pkg.replace !== undefined)
265
+ continue;
266
+ const kind = cargoSourceKind(pkg.source);
267
+ const key = JSON.stringify([pkg.name, pkg.version, pkg.source]);
268
+ if (seen.has(key))
269
+ continue;
270
+ seen.add(key);
271
+ if (kind === "crates_io")
272
+ result.cratesIo.push(pkg);
273
+ else if (kind === "local")
274
+ result.localCrates.push(pkg.name);
275
+ else
276
+ result.unscannable.push({ name: pkg.name, version: pkg.version, kind });
277
+ }
278
+ return result;
279
+ }
280
+ /**
281
+ * osv-scannerに渡すコピーの内容。crates.io由来のパッケージのname・version・sourceだけを書く
282
+ * (名前・版は解析時に文字の範囲を検証済み、sourceは固定の値なので、エスケープは不要)
283
+ */
284
+ export function cargoLockCopyContent(cratesIo) {
285
+ const blocks = cratesIo.map((pkg) => `[[package]]\nname = "${pkg.name}"\nversion = "${pkg.version}"\nsource = "${pkg.source}"\n`);
286
+ return `version = 3\n\n${blocks.join("\n")}`;
287
+ }
288
+ /**
289
+ * Cargo.tomlの行のうち、文字列・配列・インラインテーブルの外で始まる行(テーブルの見出し・キーの行)だけを返す。
290
+ * 複数行文字列(`"""` / `\'\'\'`)の中の`[workspace]`等を見出しと誤認しないため(レビュー指摘、2026-10-11)。
291
+ * 閉じない文字列・括弧の不整合など解釈できない場合はnull
292
+ */
293
+ export function topLevelTomlLines(text) {
294
+ const src = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
295
+ const lines = [];
296
+ let depth = 0;
297
+ let lineStart = 0;
298
+ let i = 0;
299
+ const pushLine = (end) => {
300
+ if (lineStart !== null)
301
+ lines.push({ line: src.slice(lineStart, end), offset: lineStart });
302
+ };
303
+ while (i < src.length) {
304
+ const ch = src[i];
305
+ if (ch === "\n") {
306
+ pushLine(i);
307
+ i++;
308
+ lineStart = depth === 0 ? i : null;
309
+ continue;
310
+ }
311
+ if (ch === "#") {
312
+ while (i < src.length && src[i] !== "\n")
313
+ i++;
314
+ continue;
315
+ }
316
+ if (src.startsWith('"""', i) || src.startsWith("'''", i)) {
317
+ const quote = src.slice(i, i + 3);
318
+ let j = i + 3;
319
+ for (;;) {
320
+ if (j >= src.length)
321
+ return null;
322
+ if (quote === '"""' && src[j] === "\\") {
323
+ j += 2;
324
+ continue;
325
+ }
326
+ if (src.startsWith(quote, j))
327
+ break;
328
+ j++;
329
+ }
330
+ // 閉じの引用符の直前に引用符を最大2つ書ける(`"""a"""""`は`a""`)ため、続く引用符も文字列に含める
331
+ let extra = 0;
332
+ while (extra < 2 && src[j + 3] === quote[0]) {
333
+ j++;
334
+ extra++;
335
+ }
336
+ j += 3;
337
+ const inner = src.slice(i, j);
338
+ if (inner.includes("\n"))
339
+ lineStart = null; // 複数行にまたがる文字列の後の続きは見出し・キーの行として扱わない
340
+ i = j;
341
+ continue;
342
+ }
343
+ if (ch === '"' || ch === "'") {
344
+ let j = i + 1;
345
+ for (;;) {
346
+ if (j >= src.length || src[j] === "\n")
347
+ return null;
348
+ if (ch === '"' && src[j] === "\\") {
349
+ j += 2;
350
+ continue;
351
+ }
352
+ if (src[j] === ch)
353
+ break;
354
+ j++;
355
+ }
356
+ i = j + 1;
357
+ continue;
358
+ }
359
+ if (ch === "[" || ch === "{")
360
+ depth++;
361
+ if (ch === "]" || ch === "}") {
362
+ depth--;
363
+ if (depth < 0)
364
+ return null;
365
+ }
366
+ i++;
367
+ }
368
+ if (depth !== 0)
369
+ return null;
370
+ pushLine(src.length);
371
+ return lines;
372
+ }
373
+ /** 見出しの行(`[名前]`)のテーブル名。配列のテーブル(`[[...]]`)・引用符付きの名前は`""`(どのテーブルにも一致させない) */
374
+ function tableHeader(line) {
375
+ if (!line.startsWith("["))
376
+ return null;
377
+ const header = /^\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(?:#.*)?$/.exec(line);
378
+ return header ? header[1] : "";
379
+ }
380
+ /**
381
+ * Cargo.tomlの`[package]`テーブルの`name`(workspaceのメンバーの確認用)。
382
+ * `name = "..."`の単純な形だけを読み、読めなければnull
383
+ */
384
+ export function cargoTomlPackageName(text) {
385
+ const lines = topLevelTomlLines(text);
386
+ if (lines === null)
387
+ return null;
388
+ let table = null;
389
+ for (const { line: raw } of lines) {
390
+ const line = raw.trim();
391
+ const header = tableHeader(line);
392
+ if (header !== null) {
393
+ table = header;
394
+ continue;
395
+ }
396
+ if (table !== "package")
397
+ continue;
398
+ const match = /^name\s*=\s*(.*)$/.exec(line);
399
+ if (match) {
400
+ const value = lineString(match[1], false);
401
+ return value !== null && CRATE_NAME.test(value) ? value : null;
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ /**
407
+ * `[`の位置から文字列の配列(複数行・コメント・末尾カンマ可)を読む。文字列以外を含めばnull
408
+ */
409
+ function readStringArray(text, start) {
410
+ if (text[start] !== "[")
411
+ return null;
412
+ const items = [];
413
+ let i = start + 1;
414
+ while (i < text.length) {
415
+ const ch = text[i];
416
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === ",") {
417
+ i++;
418
+ }
419
+ else if (ch === "#") {
420
+ while (i < text.length && text[i] !== "\n")
421
+ i++;
422
+ }
423
+ else if (ch === "]") {
424
+ return items;
425
+ }
426
+ else {
427
+ if (text.startsWith('"""', i))
428
+ return null; // 複数行文字列は扱わない
429
+ const parsed = readBasicString(text, i);
430
+ if (parsed === null)
431
+ return null;
432
+ items.push(parsed.value);
433
+ i = parsed.end;
434
+ }
435
+ }
436
+ return null;
437
+ }
438
+ /**
439
+ * workspaceのルートのCargo.tomlから`[workspace]`の`members`・`exclude`を読む。
440
+ * `[workspace]`が無い・値を解釈できない場合はnull(所属を確認できない)
441
+ */
442
+ export function cargoWorkspace(text) {
443
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
444
+ const lines = topLevelTomlLines(normalized);
445
+ if (lines === null)
446
+ return null;
447
+ const result = { members: [], exclude: [] };
448
+ let inWorkspace = false;
449
+ let sawWorkspace = false;
450
+ for (const { line: raw, offset } of lines) {
451
+ const line = raw.trim();
452
+ const header = tableHeader(line);
453
+ if (header !== null) {
454
+ inWorkspace = header === "workspace";
455
+ sawWorkspace ||= inWorkspace;
456
+ continue;
457
+ }
458
+ // ルートのテーブルのドット付きのキー(workspace.members = ...)は扱わない(所属を確認できない)
459
+ if (!inWorkspace && /^workspace\s*\./.test(line))
460
+ return null;
461
+ if (!inWorkspace || line === "")
462
+ continue;
463
+ // 引用符付きのキー("exclude" = ...)は読み落としを避けるため解釈できないものとする
464
+ if (line.startsWith('"') || line.startsWith("'"))
465
+ return null;
466
+ const match = /^(members|exclude)\s*=\s*/.exec(line);
467
+ if (match === null)
468
+ continue;
469
+ const items = readStringArray(normalized, offset + raw.indexOf(line) + match[0].length);
470
+ if (items === null)
471
+ return null;
472
+ result[match[1]] = items;
473
+ }
474
+ return sawWorkspace ? result : null;
475
+ }
476
+ /** workspaceのルートからの相対パス(`/`区切り)を、`./`・末尾の`/`を除いて正規化する */
477
+ function normalizeWorkspacePath(value) {
478
+ const parts = value.replace(/\\/g, "/").split("/").filter((part) => part !== "" && part !== ".");
479
+ if (value.startsWith("/") || parts.some((part) => part === ".."))
480
+ return null; // 絶対パス・ルートの外は扱わない
481
+ return parts.join("/");
482
+ }
483
+ /** パスの区切り単位での前方一致 */
484
+ function isPathPrefix(prefix, target) {
485
+ return prefix === "" || target === prefix || target.startsWith(`${prefix}/`);
486
+ }
487
+ /** `*`・`?`だけのglobの一致。それ以外のglob(`**`・`[...]`・`{...}`)はnull(判定できない) */
488
+ function globMatches(pattern, target) {
489
+ const patternParts = pattern.split("/");
490
+ const targetParts = target === "" ? [] : target.split("/");
491
+ if (patternParts.some((part) => part.includes("**") || /[[\]{}]/.test(part)))
492
+ return null;
493
+ if (patternParts.length !== targetParts.length)
494
+ return false;
495
+ return patternParts.every((part, i) => {
496
+ const regex = new RegExp(`^${part.replace(/[.+^$()|\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
497
+ return regex.test(targetParts[i]);
498
+ });
499
+ }
500
+ /**
501
+ * memberDir(workspaceのルートからの相対パス)が`members`に含まれ、`exclude`で除かれていないか。
502
+ * Cargoの規則(2026-10-11 cargo 1.98で確認): `members`はglobで展開し、`exclude`はパスの前方一致で除く。
503
+ * ただし`members`に(globでなく)文字どおり書かれたパスの配下は`exclude`より優先する。
504
+ * true=所属 / false=所属しない / null=判定できない(解釈できないパス・glob)
505
+ */
506
+ export function workspaceIncludes(workspace, memberDir) {
507
+ const target = normalizeWorkspacePath(memberDir);
508
+ if (target === null)
509
+ return null;
510
+ let undecided = false;
511
+ let matched = false;
512
+ for (const member of workspace.members) {
513
+ const pattern = normalizeWorkspacePath(member);
514
+ const result = pattern === null ? null : globMatches(pattern, target);
515
+ if (result === null)
516
+ undecided = true;
517
+ else if (result)
518
+ matched = true;
519
+ }
520
+ if (!matched)
521
+ return undecided ? null : false;
522
+ const explicit = workspace.members.some((member) => {
523
+ const literal = normalizeWorkspacePath(member);
524
+ return literal !== null && isPathPrefix(literal, target);
525
+ });
526
+ const excluded = workspace.exclude.some((ex) => {
527
+ const prefix = normalizeWorkspacePath(ex);
528
+ return prefix !== null && isPathPrefix(prefix, target);
529
+ });
530
+ if (workspace.exclude.some((ex) => normalizeWorkspacePath(ex) === null))
531
+ return null;
532
+ return !(excluded && !explicit);
533
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * `scan_project`用の検出: Java / JavaScript / Python / Goのlockfileを列挙する。
2
+ * `scan_project`用の検出: Java / JavaScript / Python / Go / Rustのlockfileを列挙する。
3
3
  *
4
4
  * - 検出したファイルだけをosv-scannerに渡す(ディレクトリは渡さない)。解析形式も明示する
5
5
  * - パッケージマネージャー・ビルドは実行しない。lockfileが無いマニフェストはcoverageに記録する
6
6
  * - requirements.txtは取り込み先を検証し、プロジェクト外を読む場合はスキャン対象から外す
7
+ * - Cargo.lockはここで解析し、crates.io由来のパッケージだけをスキャンする(cargoLock.ts)
7
8
  *
8
9
  * 検出・除外の根拠はdocs/DESIGN_TODO.md「対象エコシステム拡大 詳細設計メモ」を参照。
9
10
  */
@@ -11,8 +12,9 @@ import { stat } from "node:fs/promises";
11
12
  import path from "node:path";
12
13
  import { ScanToolError } from "../errors.js";
13
14
  import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
14
- import { readRegularFile } from "./safeRead.js";
15
+ import { capitalizeReason, readRegularFile } from "./safeRead.js";
15
16
  import { analyzeRequirementsFile, createRequirementsReadContext, } from "./requirementsFile.js";
17
+ import { cargoTomlPackageName, cargoWorkspace, workspaceIncludes, classifyCargoPackages, parseCargoLock, UNSCANNABLE_CRATE_REASONS, } from "./cargoLock.js";
16
18
  /** ファイル名→(エコシステム, 解析形式)。npm-shrinkwrap.jsonはpackage-lock.json形式で解析する */
17
19
  const LOCKFILES = new Map([
18
20
  ["pom.xml", { ecosystem: "Maven", format: "pom.xml" }],
@@ -28,6 +30,7 @@ const LOCKFILES = new Map([
28
30
  ["Pipfile.lock", { ecosystem: "PyPI", format: "Pipfile.lock" }],
29
31
  ["pdm.lock", { ecosystem: "PyPI", format: "pdm.lock" }],
30
32
  ["go.mod", { ecosystem: "Go", format: "go.mod" }],
33
+ ["Cargo.lock", { ecosystem: "crates.io", format: "Cargo.lock" }],
31
34
  ]);
32
35
  /** requirements.txt / requirements-dev.txt / dev-requirements.txt 等 */
33
36
  const REQUIREMENTS_NAME = /^(?:[\w.]+[-_])?requirements(?:[-_][\w.]+)?\.txt$/;
@@ -42,8 +45,9 @@ const LOCK_MARKERS = new Map([
42
45
  ["build.gradle.kts", "gradle"],
43
46
  ["settings.gradle", "gradle"],
44
47
  ["settings.gradle.kts", "gradle"],
48
+ ["Cargo.toml", "cargo"],
45
49
  ]);
46
- const LOCK_GROUP_ECOSYSTEM = { npm: "npm", pypi: "PyPI", gradle: "Maven" };
50
+ const LOCK_GROUP_ECOSYSTEM = { npm: "npm", pypi: "PyPI", gradle: "Maven", cargo: "crates.io" };
47
51
  const LOCKFILE_HINTS = {
48
52
  npm: "No lockfile. Run `npm install --package-lock-only --ignore-scripts` in a trusted environment " +
49
53
  "(for yarn / pnpm / bun, generate the lockfile with that tool), then scan again",
@@ -51,10 +55,12 @@ const LOCKFILE_HINTS = {
51
55
  "Generate one in a trusted environment, then scan again (generating it may run build scripts)",
52
56
  gradle: "No gradle.lockfile. Run `./gradlew dependencies --write-locks` in a trusted environment, then scan again " +
53
57
  "(if dependency locking is not configured, add dependencyLocking { lockAllConfigurations() } to build.gradle)",
58
+ cargo: "No Cargo.lock. Run `cargo generate-lockfile` in a trusted environment (it fetches the registry index; " +
59
+ "for a workspace member, run it at the workspace root), then scan again",
54
60
  };
55
61
  const SKIPPED_DIRS = new Set([
56
62
  ".git", "node_modules", "target", "build", ".idea", ".vscode",
57
- ".venv", "venv", "site-packages", "__pycache__", ".tox", "vendor",
63
+ ".venv", "venv", "site-packages", "__pycache__", ".tox", "vendor", ".cargo",
58
64
  ]);
59
65
  function classifyFile(name) {
60
66
  const lockfile = LOCKFILES.get(name);
@@ -110,6 +116,77 @@ export class LockfileKeyCache {
110
116
  return keys;
111
117
  }
112
118
  }
119
+ /** Cargo.lock1件の上限(読み込み自体の上限。合計はLockfileKeyCacheと同じ上限) */
120
+ const MAX_CARGO_LOCK_BYTES = 64 * 1024 * 1024;
121
+ const MAX_CARGO_TOML_BYTES = 1024 * 1024;
122
+ /**
123
+ * Cargo.lockの解析結果を1回だけ作る(workspaceの収録確認とスキャン対象の生成で共用)。
124
+ * 元のファイルは安全に読み(safeRead)、osv-scannerには渡さない
125
+ */
126
+ export class CargoLockCache {
127
+ results = new Map();
128
+ remaining;
129
+ constructor(maxTotalBytes = MAX_LOCKFILE_TOTAL_BYTES) {
130
+ this.remaining = maxTotalBytes;
131
+ }
132
+ async get(lockAbs, projectDir) {
133
+ const cached = this.results.get(lockAbs);
134
+ if (cached !== undefined)
135
+ return cached;
136
+ let result;
137
+ if (this.remaining <= 0) {
138
+ result = { reason: "Not read because the total size of the Cargo.lock files exceeded the limit" };
139
+ }
140
+ else {
141
+ const read = await readRegularFile(lockAbs, { maxBytes: Math.min(MAX_CARGO_LOCK_BYTES, this.remaining), boundary: projectDir });
142
+ if (!read.ok) {
143
+ result = { reason: capitalizeReason(read.message) };
144
+ }
145
+ else {
146
+ this.remaining -= read.bytes.length;
147
+ const parsed = parseCargoLock(read.bytes.toString("utf8"));
148
+ result = parsed.ok ? classifyCargoPackages(parsed.packages) : { reason: parsed.reason };
149
+ }
150
+ }
151
+ this.results.set(lockAbs, result);
152
+ return result;
153
+ }
154
+ workspaces = new Map();
155
+ /** Cargo.lockと同じディレクトリのCargo.tomlの`[workspace]`(1回だけ読む)。無い・読めない場合はnull */
156
+ async workspace(lockAbs, projectDir) {
157
+ if (this.workspaces.has(lockAbs))
158
+ 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;
161
+ this.workspaces.set(lockAbs, workspace);
162
+ return workspace;
163
+ }
164
+ }
165
+ /**
166
+ * 上位のCargo.lockに、メンバーのCargo.tomlのcrateが収録されているか。
167
+ * - 同名のsourceなしパッケージが無ければ未収録(false)。workspaceのメンバーでもpathの依存でもない
168
+ * - 同名があっても名前だけでは所属を確定しない(独立したサブプロジェクトが同名のこともある)。
169
+ * Cargo.lockと同じディレクトリのCargo.tomlの`[workspace]`の`members`・`exclude`で所属を確認できた場合だけ収録(true)
170
+ * - それ以外は確認できない(null): `[package] name`を読めない、lockfileを解析できない、`[workspace]`が無い・解釈できない、
171
+ * `members`に含まれない(pathの依存として収録されている可能性がある)、解釈できないglob
172
+ */
173
+ async function cargoLockfileRecords(cache, projectDir, lockfile, memberToml) {
174
+ const lock = await cache.get(path.join(projectDir, lockfile.path), projectDir);
175
+ if ("reason" in lock)
176
+ 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;
179
+ if (name === null)
180
+ return null;
181
+ if (!lock.localCrates.includes(name))
182
+ return false;
183
+ const lockAbs = path.join(projectDir, lockfile.path);
184
+ const workspace = await cache.workspace(lockAbs, projectDir);
185
+ if (workspace === null)
186
+ return null;
187
+ const memberDir = path.relative(path.dirname(lockAbs), path.join(projectDir, path.dirname(memberToml))).split(path.sep).join("/");
188
+ return workspaceIncludes(workspace, memberDir) === true ? true : null;
189
+ }
113
190
  /**
114
191
  * package-lock.json(v2以降)の`packages`に、memberDirのエントリがあるか。
115
192
  * true=収録 / false=未収録 / null=確認できない
@@ -128,7 +205,7 @@ async function npmLockfileRecords(cache, projectDir, lockfile, memberDir) {
128
205
  * - 上位のlockfileだけの場合は、収録を確認できたときだけ充足(npm workspacesのpackage-lock.json)。
129
206
  * workspace設定の無いルートのlockfileで、独立した子プロジェクトの欠落を隠さないため
130
207
  */
131
- async function findLockfileMissing(projectDir, markers, manifests) {
208
+ async function findLockfileMissing(projectDir, markers, manifests, cargoCache) {
132
209
  const cache = new LockfileKeyCache();
133
210
  const missing = [];
134
211
  for (const marker of markers) {
@@ -146,7 +223,9 @@ async function findLockfileMissing(projectDir, markers, manifests) {
146
223
  for (const candidate of candidates) {
147
224
  const result = candidate.format === "package-lock.json"
148
225
  ? await npmLockfileRecords(cache, projectDir, candidate, markerDir)
149
- : null;
226
+ : candidate.format === "Cargo.lock"
227
+ ? await cargoLockfileRecords(cargoCache, projectDir, candidate, marker.path)
228
+ : null;
150
229
  if (result === true)
151
230
  recorded = true;
152
231
  if (result !== false)
@@ -166,13 +245,17 @@ async function findLockfileMissing(projectDir, markers, manifests) {
166
245
  return missing;
167
246
  }
168
247
  async function finalize(projectDir, manifests, markers, allowedRootReal) {
248
+ const cargoCache = new CargoLockCache();
169
249
  const result = {
170
250
  projectDir,
171
251
  allowedRootReal,
172
252
  manifests: [],
173
253
  targets: [],
174
254
  requirementsCopies: [],
175
- lockfileMissing: await findLockfileMissing(projectDir, markers, manifests),
255
+ cargoCopies: [],
256
+ localCrateCount: 0,
257
+ unscannableCrates: [],
258
+ lockfileMissing: await findLockfileMissing(projectDir, markers, manifests, cargoCache),
176
259
  requirementIssues: [],
177
260
  requirementReferences: [],
178
261
  lowerBounds: [],
@@ -192,6 +275,16 @@ async function finalize(projectDir, manifests, markers, allowedRootReal) {
192
275
  result.lowerBounds.push(...analysis.lowerBounds);
193
276
  result.requirementsCopies.push({ path: manifest.path, entries: analysis.entries });
194
277
  }
278
+ else if (manifest.format === "Cargo.lock") {
279
+ const lock = await cargoCache.get(absolute, projectDir);
280
+ if ("reason" in lock) {
281
+ result.skippedFiles.push({ path: manifest.path, reason: lock.reason });
282
+ continue;
283
+ }
284
+ result.cargoCopies.push({ path: manifest.path, packages: lock.cratesIo });
285
+ result.localCrateCount += lock.localCrates.length;
286
+ result.unscannableCrates.push(...lock.unscannable.map((c) => ({ file: manifest.path, name: c.name, version: c.version, reason: UNSCANNABLE_CRATE_REASONS[c.kind] })));
287
+ }
195
288
  else {
196
289
  result.targets.push({ path: absolute, format: manifest.format });
197
290
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * osv-scannerに`--lockfile <形式>:<絶対パス>`で個別に渡せる解析形式(2.4.0で実機確認)。
3
3
  * ここに無い形式はosv-scannerに渡さない。npm-shrinkwrap.jsonは"package-lock.json"形式で解析する。
4
+ * Cargo.lockは元のファイルを渡さず、crates.io由来のパッケージだけを書いたコピーを渡す(cargoLock.ts)。
4
5
  */
5
6
  export const MANIFEST_FORMATS = [
6
7
  "pom.xml",
@@ -16,6 +17,7 @@ export const MANIFEST_FORMATS = [
16
17
  "pdm.lock",
17
18
  "requirements.txt",
18
19
  "go.mod",
20
+ "Cargo.lock",
19
21
  ];
20
22
  const FORMAT_SET = new Set(MANIFEST_FORMATS);
21
23
  export function isManifestFormat(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",