osv-scanner-mcp 0.14.1 → 0.16.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 +73 -8
- package/dist/history/historyDiff.js +124 -0
- package/dist/history/historyRecord.js +279 -0
- package/dist/history/historyRecorder.js +53 -0
- package/dist/history/historyStore.js +147 -0
- package/dist/index.js +39 -9
- package/dist/osv/binaryManager.js +2 -2
- package/dist/tools/scanHistory.js +162 -0
- package/dist/tools/scanProject.js +55 -9
- package/dist/utils/manifestDetector.js +49 -6
- package/dist/utils/manifestFormats.js +2 -0
- package/dist/utils/nugetFiles.js +548 -0
- package/dist/utils/nugetProject.js +328 -0
- package/dist/utils/openedFilePath.js +65 -0
- package/dist/utils/permissionCheck.js +11 -1
- package/dist/utils/pomParent.js +20 -11
- package/dist/utils/projectWalk.js +5 -2
- package/dist/utils/safeRead.js +18 -8
- package/dist/utils/sbomInput.js +8 -7
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -7,17 +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,
|
|
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, Rust (`Cargo.lock`), and .NET (NuGet: `packages.lock.json`, `obj/project.assets.json`, `packages.config`, `*.deps.json`, and project files), 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
|
-
- **One-shot multi-language scan**: Pass a project path to `scan_project`, and it detects and scans the Java, JavaScript, Python, Go, and
|
|
14
|
+
- **One-shot multi-language scan**: Pass a project path to `scan_project`, and it detects and scans the Java, JavaScript, Python, Go, Rust, and .NET 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 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
|
+
- **.NET without guessing versions**: Each project file is matched to its own lockfile or `obj/project.assets.json`. Without one, only its direct `PackageReference` items are scanned, and a `Version="x"` (which means x or higher) is reported as a lower bound. References whose version cannot be known without running MSBuild (properties, conditions, floating versions, `Update` / `Remove`) are listed instead of being looked up: OSV treats an unparsable version as older than every release and would report every vulnerability for it
|
|
18
|
+
- **Direct and transitive dependencies**: Packages are marked as direct or transitive dependencies (from `package-lock.json`, `go.mod`, `requirements.txt`, `pom.xml`, `Cargo.lock`, and the .NET files), and update hints are tailored accordingly, for example by naming the direct dependency that pulls in a transitive one
|
|
18
19
|
- **Java project scan**: `scan_java_project` scans only the Java (Maven / Gradle) manifests and returns a formatted report
|
|
19
20
|
- **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
21
|
- **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
|
|
22
|
+
- **Scan history (opt-in)**: With `OSV_MCP_HISTORY=1`, each scan is recorded locally, and `scan_history` lists past runs and shows what changed since the previous run (new and resolved vulnerabilities, version changes, coverage changes)
|
|
21
23
|
- **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
24
|
- **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
25
|
- **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
|
|
@@ -130,6 +132,9 @@ npm run build
|
|
|
130
132
|
| `OSV_MCP_PREFER_DOWNLOAD` | If `1` or `true`, always use the checksum-verified downloaded binary instead of osv-scanner on `PATH` (protects against a fake binary planted on `PATH`; an explicit `OSV_SCANNER_PATH` still takes precedence) |
|
|
131
133
|
| `OSV_MCP_NO_CANDIDATE_CHECK` | If `1` or `true`, `suggest_fix` does not query OSV about recommended versions (`candidate_check` is `disabled`, and recommended versions are not checked for known vulnerabilities that do not affect the current version) |
|
|
132
134
|
| `OSV_MCP_NO_REMOTE_RESOLUTION` | If `1` or `true`, transitive dependencies of `pom.xml` and `requirements.txt` are not resolved through deps.dev. **This stops only the requests to deps.dev; package names and versions are still sent to `api.osv.dev` for the vulnerability lookup.** Vulnerabilities in transitive dependencies are then not detected, and responses say so in `dependency_resolution.warning`. See [Network destinations and privacy](#network-destinations-and-privacy) |
|
|
135
|
+
| `OSV_MCP_HISTORY` | If `1` or `true`, records each scan in the local scan history (disabled by default). See [`scan_history`](#scan_history) |
|
|
136
|
+
| `OSV_MCP_HISTORY_DIR` | Absolute path of the history directory (default `$XDG_STATE_HOME/osv-scanner-mcp/history`, or `~/.local/state/osv-scanner-mcp/history`) |
|
|
137
|
+
| `OSV_MCP_HISTORY_MAX_ENTRIES` | Number of runs kept (default `500`, maximum `5000`). Older runs are deleted, and the total size is also capped at 100 MiB |
|
|
133
138
|
|
|
134
139
|
> **Recommendation**: The server works without `OSV_MCP_ALLOWED_ROOT`, but then any absolute path can be scanned. To prevent malicious instructions (prompt injection) from scanning unintended directories, set it to the root of your projects (for example `~/projects`). Pass it in the client configuration, for example `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` (in Codex CLI's TOML, use a `[mcp_servers.osv-scanner.env]` section).
|
|
135
140
|
|
|
@@ -158,6 +163,7 @@ As an additional layer of defense, you can limit what the server can read and wr
|
|
|
158
163
|
"--allow-fs-write=/private/var/folders/xx/yyyy/T",
|
|
159
164
|
"--allow-fs-read=/opt/homebrew/bin/osv-scanner",
|
|
160
165
|
"--allow-child-process",
|
|
166
|
+
"--allow-addons",
|
|
161
167
|
"/path/to/OSV-Scanner-MCP/dist/index.js"
|
|
162
168
|
],
|
|
163
169
|
"env": {
|
|
@@ -172,7 +178,9 @@ As an additional layer of defense, you can limit what the server can read and wr
|
|
|
172
178
|
- Read access: the server itself (the directory containing `dist` and `node_modules`), the scan targets (`OSV_MCP_ALLOWED_ROOT`), the temporary directory, and the osv-scanner binary
|
|
173
179
|
- The temporary directory (`os.tmpdir()`, `$TMPDIR` on macOS) needs read access under **both the symlinked and the resolved path** (on macOS, `/var/folders/...` is a symbolic link to `/private/var/folders/...`). Write access is needed on the resolved path
|
|
174
180
|
- If you use the automatic download, also allow reading and writing the cache (`$XDG_CACHE_HOME/osv-scanner-mcp`, by default `~/.cache/osv-scanner-mcp`). Otherwise, set `OSV_SCANNER_PATH`
|
|
181
|
+
- If you enable the scan history (`OSV_MCP_HISTORY=1`), also allow reading and writing the history directory (`OSV_MCP_HISTORY_DIR`, or the default under `~/.local/state`). The server warns at startup if it is not allowed
|
|
175
182
|
- `--allow-child-process` is required to run osv-scanner. **osv-scanner runs as a child process and is not restricted by the permission model** (Node itself warns that this flag weakens the permission model). The impact is limited because osv-scanner only receives verified private copies, but use an OS-level mechanism such as a container if you need stronger isolation
|
|
183
|
+
- On macOS and Windows, `--allow-addons` is also required for the trusted Koffi native binding used to verify the path of an open file handle. Native code is outside Node's permission sandbox. On Linux, use `--allow-fs-read=/proc/self/fd` instead; `/proc` must be mounted. If the OS cannot verify the opened object, boundary-protected reads fail closed. Keep OS-level isolation when scanning in a stronger sandbox.
|
|
176
184
|
- If a required permission is missing, the server prints a warning to stderr at startup, and scans return `permission_denied` (naming the missing permission and the path)
|
|
177
185
|
|
|
178
186
|
### Network destinations and privacy
|
|
@@ -183,7 +191,7 @@ Destinations confirmed with osv-scanner v2.4.0 (2026-10-07) and v2.7.0, the pinn
|
|
|
183
191
|
|---|---|---|
|
|
184
192
|
| 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 |
|
|
185
193
|
| 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 |
|
|
186
|
-
| 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). **Git dependencies in npm lockfiles** (`package-lock.json`, `yarn.lock`, and so on) are looked up by their repository URL (ecosystem `GIT`) and commit, so the host name and path of a private git repository (for example `https://git.example.com/team/repo`) are sent (since osv-scanner 2.6.0) |
|
|
194
|
+
| 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). **For .NET, packages from private NuGet feeds are sent as well**: lockfiles do not record which feed a package came from, so they cannot be told apart (a `nuget.config` in the scanned directory that lists other sources adds a warning to `coverage.nuget_warnings`). The names of your own projects (`type: project` and so on) are not sent. **Git dependencies in npm lockfiles** (`package-lock.json`, `yarn.lock`, and so on) are looked up by their repository URL (ecosystem `GIT`) and commit, so the host name and path of a private git repository (for example `https://git.example.com/team/repo`) are sent (since osv-scanner 2.6.0) |
|
|
187
195
|
| `scan_java_artifact` / `scan_sbom` | `api.osv.dev` | Names and versions of the identified packages |
|
|
188
196
|
| `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` |
|
|
189
197
|
| `explain_vulnerability` | `api.osv.dev` | The vulnerability ID |
|
|
@@ -198,7 +206,7 @@ Both api.osv.dev and deps.dev are operated by Google. **In every configuration,
|
|
|
198
206
|
|
|
199
207
|
### `scan_project`
|
|
200
208
|
|
|
201
|
-
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.
|
|
209
|
+
Detects the lockfiles and manifests in a project and scans the Java / JavaScript / Python / Go / Rust / .NET dependencies together. Package managers and builds are never run.
|
|
202
210
|
|
|
203
211
|
**Input**
|
|
204
212
|
|
|
@@ -215,13 +223,24 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
|
|
|
215
223
|
| Python (PyPI) | `poetry.lock`, `uv.lock`, `Pipfile.lock`, `pdm.lock`, `requirements.txt` (and variants such as `requirements-dev.txt`) |
|
|
216
224
|
| Go | `go.mod` |
|
|
217
225
|
| Rust (crates.io) | `Cargo.lock` (lockfile versions 1 to 4) |
|
|
226
|
+
| .NET (NuGet) | `packages.lock.json` / `packages.<project>.lock.json`, `obj/project.assets.json`, `packages.config` / `packages.<project>.config`, `*.deps.json`, and `*.csproj` / `*.fsproj` / `*.vbproj` (direct references only, when no lockfile matches) |
|
|
218
227
|
|
|
219
|
-
`.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`.
|
|
228
|
+
`.git`, `node_modules`, `target`, `build`, `.idea`, `.vscode`, `.venv`, `venv`, `site-packages`, `__pycache__`, `.tox`, `vendor`, `.cargo`, `bin` and `obj` in a directory that contains a .NET project file (build output of that project), 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`.
|
|
220
229
|
|
|
221
230
|
**`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.
|
|
222
231
|
|
|
223
232
|
**`Cargo.lock` files are not passed to OSV-Scanner as they are either.** OSV-Scanner (v2.4.0 and v2.7.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.
|
|
224
233
|
|
|
234
|
+
**.NET files are not passed to OSV-Scanner as they are either.** OSV-Scanner (v2.7.0) looks the project's own projects in `*.deps.json` up as NuGet packages, and passes `.csproj` versions such as `$(Version)` or `[1.0,2.0)` to OSV as they are; OSV treats a version it cannot parse as older than every release, so every known vulnerability of the package is reported (confirmed 2026-10-23). The server parses every .NET file and scans a private copy in `packages.lock.json` format that lists only the packages it can look up. Neither `dotnet restore` nor MSBuild is run, and project files are never evaluated.
|
|
235
|
+
|
|
236
|
+
- **Matching per project file.** For each `*.csproj` / `*.fsproj` / `*.vbproj`, the first source confirmed to belong to it is used: `packages.<project>.lock.json`; `packages.lock.json` only when the directory has a single project file; neither, when the project sets `NuGetLockFilePath`; `packages.<project>.config` or `packages.config` under the same rule; then `obj/project.assets.json`, only when its `project.restore.projectPath` names this project file (projects in one directory share `obj`). A lockfile that cannot be matched to a project is still scanned, but no project counts as covered by it
|
|
237
|
+
- **Projects without a matching source** (including projects with no `PackageReference` of their own, since `Directory.Build.props` or the SDK can add references) are scanned from their direct `PackageReference` items only, and listed in `lockfile_missing` with `status: "direct_only"` (or `"unconfirmed"` when a lockfile exists but cannot be matched). Transitive dependencies are not scanned, so `complete` is `false`. Enable `RestorePackagesWithLockFile` to scan them
|
|
238
|
+
- **`PackageReference` versions.** `[x]` is looked up as x. `x`, `[x,)`, and `[x,y)` mean x or higher (NuGet prefers the lowest available version, which may be above x), so they are looked up at x and the packages are marked `version_is_lower_bound: true`. A lower bound that is excluded or missing, `$(...)`, `*`, and a missing version (central package management) are not looked up
|
|
239
|
+
- **References that might not apply are not looked up**: a `Condition` on the reference, its `ItemGroup`, or its `Version`; references inside `Choose` or `Target`; names that also have `Update` or `Remove` in the file, or in a `Directory.Build.props` / `Directory.Build.targets` in the scanned directory; names included more than once; and `VersionOverride`. They are listed in `unresolved_package_references` with the reason
|
|
240
|
+
- **Runtime packs** of self-contained apps in `*.deps.json` are looked up under their package names (the SDK writes them as `runtimepack.<name>`, which OSV does not match)
|
|
241
|
+
- **Not looked up from restore results**: entries of an unknown type, and versions that are not plain NuGet versions (listed in `unscannable_nuget_packages`). Your own projects are counted in `local_projects`
|
|
242
|
+
- XML (project files, `packages.config`, `nuget.config`) is read by a small parser that rejects `DOCTYPE`, entity declarations, and CDATA. JSON files use the same structural checks as the npm lockfiles (16 MiB per file). Up to 200,000 packages and 1,000 .NET files are looked up per scan; files beyond the limit are listed in `skipped_files`. A character reference that is not a valid character (such as `�`) makes the file uninterpretable instead of failing the scan
|
|
243
|
+
|
|
225
244
|
**`package-lock.json` copies get a `resolved` URL where it is missing.** osv-scanner 2.7.0 treats entries without `resolved` as local packages and does not look them up, which would silently skip bundled dependencies (`inBundle`) and registry packages in lockfiles generated with npm's `omit-lockfile-registry-resolved=true`. The private copy that is scanned gets a public-registry URL for those entries (entries under `node_modules/` that are not links and have a plain version, and aliases such as `npm:lodash@4.17.20` in lockfile version 1), so they are looked up as before; the original file is not changed.
|
|
226
245
|
|
|
227
246
|
**npm JSON limits:** Normalization of `package-lock.json` and `npm-shrinkwrap.json` is limited to 16 MiB per file and 64 MiB per scan, for both input and output. Before parsing, JSON nesting is limited to 128 levels and structural markers (containers, commas, and colons outside strings) to 1,000,000 per file and 2,000,000 per analysis phase. Changed copies use compact JSON, with their size checked before serialization. A lockfile that exceeds these limits is scanned as it is, without filling in `resolved` (OSV-Scanner parses large lockfiles itself; the limits only bound this server's own parsing and rewriting), and it is listed in `coverage.skipped_files` with the reason, so `complete` is `false`: entries without `resolved` in it may not have been looked up. Workspace membership and dependency-relation lookups use the same structural checks and remain unconfirmed or unknown when limits are reached.
|
|
@@ -263,6 +282,7 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
|
|
|
263
282
|
- `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
|
|
264
283
|
- `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`)
|
|
265
284
|
- `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
|
|
285
|
+
- `local_projects`, `unscannable_nuget_packages`, `unresolved_package_references`, `nuget_warnings` (only when a .NET file was found): see the .NET notes above. `nuget_warnings` notes lower bounds, a possibly stale `project.assets.json` (more than a minute older than its project file; smaller differences are ignored because a clone writes files in any order), references that cannot be checked from project files alone (SDK-implicit references and `Directory.Build.*` outside the scanned directory), and package sources other than nuget.org. Entries in `unscannable_nuget_packages` and `unresolved_package_references` make `complete` false
|
|
266
286
|
- `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))
|
|
267
287
|
- Each list holds up to 200 entries; the number of omitted entries is returned in `omitted_items`
|
|
268
288
|
- `ecosystem_breakdown` includes ecosystems with zero findings, to show that they were scanned
|
|
@@ -273,9 +293,10 @@ Detects the lockfiles and manifests in a project and scans the Java / JavaScript
|
|
|
273
293
|
- `requirements.txt`: dependencies written in the file are direct; dependencies resolved through deps.dev are transitive
|
|
274
294
|
- `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`
|
|
275
295
|
- `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`
|
|
296
|
+
- .NET: `Direct` / `Transitive` in `packages.lock.json`; the dependencies of `project.frameworks` in `project.assets.json`; the dependencies of the project library in `*.deps.json`; and every reference read from a project file are direct. `packages.config` does not say, so it is `unknown`. `introduced_by` and `declared_in` are not available yet
|
|
276
297
|
- 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`
|
|
277
298
|
- `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
|
|
278
|
-
- Upgrade recommendations (`suggest_fix`) are available for Maven, npm, PyPI, Go, and Rust (crates.io)
|
|
299
|
+
- Upgrade recommendations (`suggest_fix`) are available for Maven, npm, PyPI, Go, and Rust (crates.io). NuGet packages are returned with `verification: "unsupported_ecosystem"` for now
|
|
279
300
|
|
|
280
301
|
### `scan_java_project`
|
|
281
302
|
|
|
@@ -466,6 +487,44 @@ For npm, Go, PyPI, and Rust, the "same release line" is the range a caret (`^`)
|
|
|
466
487
|
- Ecosystems without recommendation support (such as RubyGems from an SBOM) return `verification: "unsupported_ecosystem"`, and current versions that cannot be parsed (such as npm git or local path dependencies) return `verification: "unparseable_version"`. In both cases each CVE gets `tier: "unsupported"` and is not counted as unfixed (whether a fixed version exists is not evaluated; check `fixed_versions` in `scan_project` or `explain_vulnerability`).
|
|
467
488
|
- `coverage` is the same as in `scan_project`. If a manifest has no lockfile or a file was excluded, `complete` is `false`, and those dependencies are not included in the suggestions. The `skipped_manifests` / `scope_warning` fields of v0.4.2 and earlier were merged into `coverage.skipped_files` / `coverage.warning`.
|
|
468
489
|
|
|
490
|
+
### `scan_history`
|
|
491
|
+
|
|
492
|
+
Lists past runs of the scan tools (`scan_project`, `suggest_fix`, `scan_java_project`, `scan_java_artifact`, `scan_sbom`) and shows what changed between the last two runs. No scan is run.
|
|
493
|
+
|
|
494
|
+
**Recording is disabled by default**, because the history keeps package names and paths on disk. Enable it with `OSV_MCP_HISTORY=1` in the server's `env`; runs are recorded from then on. Without it, this tool returns `history_disabled`.
|
|
495
|
+
|
|
496
|
+
**Input**
|
|
497
|
+
|
|
498
|
+
| Parameter | Type | Description |
|
|
499
|
+
|---|---|---|
|
|
500
|
+
| `project_path` | string (optional) | A scanned target (project directory, lockfile, JAR/WAR, or SBOM). Filters the runs and adds `changes` |
|
|
501
|
+
| `limit` | number (optional) | Number of runs to list (default 10, maximum 50) |
|
|
502
|
+
| `run_id` | string (optional) | An `id` from `runs`. Returns the details of that run instead of the list |
|
|
503
|
+
|
|
504
|
+
**Output**
|
|
505
|
+
|
|
506
|
+
- `history`: the directory, `max_entries`, the number of matching runs (`recorded_runs`), and `skipped_records` if some files could not be read or were invalid
|
|
507
|
+
- `runs`: newest first, with `id`, `started_at`, `tool`, the target, `status` (`ok` or `error` with `error_kind`), `coverage_complete`, vulnerability counts, and `duration_ms`
|
|
508
|
+
- `changes` (with `project_path`): for each kind of scan (`project` for `scan_project` and `suggest_fix`, `java_project`, `artifact`, `sbom`), a comparison of the **last two successful runs**:
|
|
509
|
+
- `new_vulnerabilities` / `resolved_vulnerabilities`: compared per package (ecosystem and name). A vulnerability is the same if its ID or any alias matches, so a change of the primary ID in OSV is not reported as new. A vulnerability that remains after a version change is neither new nor resolved
|
|
510
|
+
- `reason` of a resolved vulnerability: `version_changed` (the package is still reported, at another version: `current_versions`), `package_not_reported` (the package is no longer in the results: it was upgraded to a version without known vulnerabilities, removed, or is no longer scanned; scan results list only vulnerable packages, so these cannot be told apart), or `same_version_not_reported` (same version, no longer reported, for example because the OSV data changed)
|
|
511
|
+
- `version_changes`: packages whose versions changed, with `still_affected` (the vulnerabilities that remain)
|
|
512
|
+
- `coverage_changes`: changes of `coverage.complete` and added or removed manifests
|
|
513
|
+
- `incomplete`: set when the comparison is partial (a run exceeded the record size limit or the findings limit)
|
|
514
|
+
- `run` (with `run_id`): the details of one run, including its manifests, coverage gaps, severity breakdown, and the recorded packages and vulnerability IDs (up to 500 packages)
|
|
515
|
+
|
|
516
|
+
> **Note**: Changes are computed from the OSV data at the time of each scan, so they can also appear when only the vulnerability database changed.
|
|
517
|
+
|
|
518
|
+
**What is recorded**: one JSON file per run, with the tool, time, server and pinned OSV-Scanner versions, the target paths, coverage, counts, and the packages with their vulnerability IDs and aliases (and, for `suggest_fix`, the recommended versions). Vulnerability summaries, details, and error messages are not recorded. A run whose record would exceed 1 MiB keeps only the summary (`findings_omitted`), and if that is still too large, only part of its manifest list (`manifests_truncated`; manifest changes are then not compared). Target paths longer than 4,096 characters (longer than any path that can exist) are truncated and marked `target_truncated`.
|
|
519
|
+
|
|
520
|
+
**Storage and safety**:
|
|
521
|
+
|
|
522
|
+
- The directory is created with mode `0700` and files with `0600`. The server refuses a directory that is a symbolic link or is owned by another user (scans still succeed; a warning is printed to stderr)
|
|
523
|
+
- Each run is written to a temporary file and renamed, so concurrent server processes do not corrupt each other's records. Recording failures never fail the scan
|
|
524
|
+
- History files are treated as untrusted input: each is read with a size limit, validated, and its strings are sanitized before being returned
|
|
525
|
+
- When `OSV_MCP_ALLOWED_ROOT` is set, only runs whose target is inside the allowed root are returned, and a `project_path` outside it is refused with `path_outside_allowed_root`
|
|
526
|
+
- To delete the history, remove the directory
|
|
527
|
+
|
|
469
528
|
### `explain_vulnerability`
|
|
470
529
|
|
|
471
530
|
Returns the details of a vulnerability, given its GHSA or CVE ID, **fetched directly from the OSV database API (api.osv.dev)** (no scan is run). IDs from the scan results can be passed as they are. This is especially useful for vulnerabilities published after the client LLM's knowledge cutoff.
|
|
@@ -520,6 +579,9 @@ Returns JSON with a machine-readable `kind`, together with `isError: true`:
|
|
|
520
579
|
| `invalid_vulnerability_id` | The vulnerability ID is malformed |
|
|
521
580
|
| `vulnerability_not_found` | No vulnerability with the ID exists in the OSV database |
|
|
522
581
|
| `api_request_failed` | The request to the OSV API failed (network, timeout, or a non-2xx response) |
|
|
582
|
+
| `history_disabled` | The scan history is disabled (`scan_history`; set `OSV_MCP_HISTORY=1`) |
|
|
583
|
+
| `history_unavailable` | The history directory cannot be used (for example a symbolic link or owned by another user) |
|
|
584
|
+
| `history_run_not_found` | No recorded run with the given `run_id` (or the ID is malformed) |
|
|
523
585
|
| `internal_error` | An unexpected error (no internal details are returned) |
|
|
524
586
|
|
|
525
587
|
## Security design
|
|
@@ -529,7 +591,7 @@ The following measures keep the vulnerability scanner itself from becoming an at
|
|
|
529
591
|
- **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`
|
|
530
592
|
- **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
|
|
531
593
|
- **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)
|
|
532
|
-
- **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))
|
|
594
|
+
- **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, and every .NET file by a `packages.lock.json` copy listing only packages with a known version. A `pom.xml` whose parent POM chain references a file outside `OSV_MCP_ALLOWED_ROOT` is excluded ([Parent POMs](#parent-poms))
|
|
533
595
|
- **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
|
|
534
596
|
- **Fail-closed mode**: `OSV_MCP_REQUIRE_ALLOWED_ROOT=1` refuses to start the server when no allowed root is set
|
|
535
597
|
- **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))
|
|
@@ -566,6 +628,9 @@ Design notes and open items are in [docs/DESIGN_TODO.md](docs/DESIGN_TODO.md) (i
|
|
|
566
628
|
- [x] English tool descriptions and messages
|
|
567
629
|
- [x] `scan_project` for Rust (`Cargo.lock`, crates.io crates only) and RustSec informational advisories
|
|
568
630
|
- [x] `suggest_fix` and direct/transitive dependencies for Rust
|
|
631
|
+
- [x] Scan history and changes between runs (`scan_history`, opt-in)
|
|
632
|
+
- [x] `scan_project` for .NET (NuGet), with per-project matching and lower bounds for project files
|
|
633
|
+
- [ ] `suggest_fix` for .NET (NuGet)
|
|
569
634
|
- [ ] Direct and transitive dependencies for `gradle.lockfile` and the YAML/TOML lockfiles
|
|
570
635
|
|
|
571
636
|
## License
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同じ対象・同じ種類の2回のスキャンの比較(docs/DESIGN_TODO.md「スキャンの実行履歴」)。
|
|
3
|
+
*
|
|
4
|
+
* - 脆弱性は、IDか別名が1つでも重なれば同じとみなす(OSVの代表IDが入れ替わっても新規・解消と誤らない)
|
|
5
|
+
* - パッケージ(エコシステムと名前)単位で比べる。版が変わっても残っている脆弱性は、新規にも解消にも数えない
|
|
6
|
+
* - スキャン結果には脆弱なパッケージしか載らないため、結果から消えたパッケージが「更新された」のか
|
|
7
|
+
* 「削除された」のかは区別できない。解消の理由はその限界に合わせて分ける
|
|
8
|
+
* - 差分はスキャンの時点のOSVのデータに依存する(コードを変えていなくてもデータの更新で増減する)
|
|
9
|
+
*/
|
|
10
|
+
const MAX_LISTED = 500;
|
|
11
|
+
function packageKey(f) {
|
|
12
|
+
return JSON.stringify([f.ecosystem, f.name]);
|
|
13
|
+
}
|
|
14
|
+
function identities(v) {
|
|
15
|
+
return [v.id, ...v.aliases];
|
|
16
|
+
}
|
|
17
|
+
/** パッケージの全ての版の脆弱性のIDと別名の集合(照合を件数に比例する時間で行うため) */
|
|
18
|
+
function identitySet(findings) {
|
|
19
|
+
const ids = new Set();
|
|
20
|
+
for (const f of findings)
|
|
21
|
+
for (const v of f.vulnerabilities)
|
|
22
|
+
for (const id of identities(v))
|
|
23
|
+
ids.add(id);
|
|
24
|
+
return ids;
|
|
25
|
+
}
|
|
26
|
+
/** IDか別名のどれかが集合に含まれるか */
|
|
27
|
+
function overlaps(ids, target) {
|
|
28
|
+
return identities(target).some((id) => ids.has(id));
|
|
29
|
+
}
|
|
30
|
+
function versionsOf(findings) {
|
|
31
|
+
return [...new Set(findings.map((f) => f.version))].sort();
|
|
32
|
+
}
|
|
33
|
+
function byPackage(findings) {
|
|
34
|
+
const map = new Map();
|
|
35
|
+
for (const f of findings) {
|
|
36
|
+
const key = packageKey(f);
|
|
37
|
+
const list = map.get(key) ?? [];
|
|
38
|
+
list.push(f);
|
|
39
|
+
map.set(key, list);
|
|
40
|
+
}
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
function change(f, v) {
|
|
44
|
+
return { ecosystem: f.ecosystem, name: f.name, version: f.version, id: v.id, aliases: v.aliases, ...(v.informational !== undefined ? { informational: v.informational } : {}) };
|
|
45
|
+
}
|
|
46
|
+
export function compareHistoryRecords(older, newer) {
|
|
47
|
+
const result = {
|
|
48
|
+
from: { id: older.id, started_at: older.started_at, tool: older.tool },
|
|
49
|
+
to: { id: newer.id, started_at: newer.started_at, tool: newer.tool },
|
|
50
|
+
new_vulnerabilities: [],
|
|
51
|
+
resolved_vulnerabilities: [],
|
|
52
|
+
version_changes: [],
|
|
53
|
+
coverage_changes: {},
|
|
54
|
+
};
|
|
55
|
+
if (older.coverage_complete !== newer.coverage_complete) {
|
|
56
|
+
result.coverage_changes.complete = { from: older.coverage_complete, to: newer.coverage_complete };
|
|
57
|
+
}
|
|
58
|
+
const reasons = [];
|
|
59
|
+
if (older.manifests_truncated || newer.manifests_truncated) {
|
|
60
|
+
reasons.push("one of the runs recorded only part of its manifest list (the record size limit was reached), so manifest changes are not shown");
|
|
61
|
+
}
|
|
62
|
+
else if (older.manifests !== undefined && newer.manifests !== undefined) {
|
|
63
|
+
const before = new Set(older.manifests.map((m) => m.path));
|
|
64
|
+
const after = new Set(newer.manifests.map((m) => m.path));
|
|
65
|
+
const added = [...after].filter((p) => !before.has(p)).sort();
|
|
66
|
+
const removed = [...before].filter((p) => !after.has(p)).sort();
|
|
67
|
+
if (added.length > 0)
|
|
68
|
+
result.coverage_changes.manifests_added = added.slice(0, MAX_LISTED);
|
|
69
|
+
if (removed.length > 0)
|
|
70
|
+
result.coverage_changes.manifests_removed = removed.slice(0, MAX_LISTED);
|
|
71
|
+
}
|
|
72
|
+
if (older.findings === undefined || newer.findings === undefined) {
|
|
73
|
+
reasons.unshift("One of the runs has no recorded findings (it exceeded the record size limit), so vulnerabilities cannot be compared");
|
|
74
|
+
result.incomplete = reasons.join("; ");
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
if (older.findings_truncated || newer.findings_truncated)
|
|
78
|
+
reasons.push("one of the runs recorded only part of its findings (the findings limit was reached)");
|
|
79
|
+
// パッケージ(エコシステムと名前)単位で比べる。版が変わっても残っている脆弱性は新規・解消のどちらにも数えない
|
|
80
|
+
const before = byPackage(older.findings);
|
|
81
|
+
const after = byPackage(newer.findings);
|
|
82
|
+
const empty = [];
|
|
83
|
+
for (const [key, newList] of after) {
|
|
84
|
+
const oldList = before.get(key) ?? empty;
|
|
85
|
+
const oldIds = identitySet(oldList);
|
|
86
|
+
let stillAffected = 0;
|
|
87
|
+
for (const f of newList) {
|
|
88
|
+
for (const v of f.vulnerabilities) {
|
|
89
|
+
if (overlaps(oldIds, v))
|
|
90
|
+
stillAffected++;
|
|
91
|
+
else
|
|
92
|
+
result.new_vulnerabilities.push(change(f, v));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const fromVersions = versionsOf(oldList);
|
|
96
|
+
const toVersions = versionsOf(newList);
|
|
97
|
+
if (oldList.length > 0 && JSON.stringify(fromVersions) !== JSON.stringify(toVersions)) {
|
|
98
|
+
result.version_changes.push({ ecosystem: newList[0].ecosystem, name: newList[0].name, from: fromVersions, to: toVersions, still_affected: stillAffected });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [key, oldList] of before) {
|
|
102
|
+
const newList = after.get(key) ?? empty;
|
|
103
|
+
const newIds = identitySet(newList);
|
|
104
|
+
const currentVersions = versionsOf(newList);
|
|
105
|
+
const reason = newList.length === 0 ? "package_not_reported"
|
|
106
|
+
: JSON.stringify(versionsOf(oldList)) === JSON.stringify(currentVersions) ? "same_version_not_reported" : "version_changed";
|
|
107
|
+
for (const f of oldList) {
|
|
108
|
+
for (const v of f.vulnerabilities) {
|
|
109
|
+
if (overlaps(newIds, v))
|
|
110
|
+
continue;
|
|
111
|
+
result.resolved_vulnerabilities.push({ ...change(f, v), reason, ...(reason === "version_changed" ? { current_versions: currentVersions } : {}) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const list of [result.new_vulnerabilities, result.resolved_vulnerabilities, result.version_changes]) {
|
|
116
|
+
if (list.length > MAX_LISTED) {
|
|
117
|
+
reasons.push(`only the first ${MAX_LISTED} entries of a list are shown`);
|
|
118
|
+
list.length = MAX_LISTED;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (reasons.length > 0)
|
|
122
|
+
result.incomplete = [...new Set(reasons)].join("; ");
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行履歴の1件の内容(要約と差分用の識別子)。ツールの応答(JSON)から作り、読み出し時に形式を確かめる。
|
|
3
|
+
* 結果の全文・エラーメッセージは保存しない(docs/DESIGN_TODO.md「スキャンの実行履歴」)。
|
|
4
|
+
*/
|
|
5
|
+
import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
|
|
6
|
+
import { MAX_RECORD_BYTES } from "./historyStore.js";
|
|
7
|
+
export const HISTORY_TOOLS = ["scan_project", "suggest_fix", "scan_java_project", "scan_java_artifact", "scan_sbom"];
|
|
8
|
+
export function scanKind(tool) {
|
|
9
|
+
switch (tool) {
|
|
10
|
+
case "scan_project":
|
|
11
|
+
case "suggest_fix":
|
|
12
|
+
return "project";
|
|
13
|
+
case "scan_java_project":
|
|
14
|
+
return "java_project";
|
|
15
|
+
case "scan_java_artifact":
|
|
16
|
+
return "artifact";
|
|
17
|
+
case "scan_sbom":
|
|
18
|
+
return "sbom";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const MAX_FINDING_PACKAGES = 5_000;
|
|
22
|
+
const MAX_FINDING_VULNERABILITIES = 20_000;
|
|
23
|
+
const MAX_ALIASES = 20;
|
|
24
|
+
const MAX_MANIFESTS = 1_000;
|
|
25
|
+
const MAX_TEXT = 512;
|
|
26
|
+
const MAX_SEVERITY_KEYS = 16;
|
|
27
|
+
/** 対象のパスの長さの上限(LinuxのPATH_MAX。これより長いパスは実在しえず、スキャンは失敗する) */
|
|
28
|
+
const MAX_PATH = 4096;
|
|
29
|
+
function text(value) {
|
|
30
|
+
const s = asString(value);
|
|
31
|
+
return s === null ? null : s.slice(0, MAX_TEXT);
|
|
32
|
+
}
|
|
33
|
+
/** 対象のパス。照合に使うため、MAX_TEXTより長いパスもそのまま残す */
|
|
34
|
+
function pathText(value) {
|
|
35
|
+
const s = asString(value);
|
|
36
|
+
return s === null ? null : { path: s.slice(0, MAX_PATH), truncated: s.length > MAX_PATH };
|
|
37
|
+
}
|
|
38
|
+
function count(value) {
|
|
39
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
40
|
+
}
|
|
41
|
+
function vulnerabilitiesOf(raw, budget) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const item of asArray(raw)) {
|
|
44
|
+
const vuln = asRecord(item);
|
|
45
|
+
const id = text(vuln?.id);
|
|
46
|
+
if (id === null)
|
|
47
|
+
continue;
|
|
48
|
+
if (budget.remaining <= 0)
|
|
49
|
+
return null;
|
|
50
|
+
budget.remaining--;
|
|
51
|
+
const aliases = [...new Set([...asStrings(vuln.aliases), ...(text(vuln.cve) !== null ? [text(vuln.cve)] : [])])]
|
|
52
|
+
.filter((a) => a !== id).slice(0, MAX_ALIASES).map((a) => a.slice(0, MAX_TEXT));
|
|
53
|
+
const informational = text(vuln.informational);
|
|
54
|
+
out.push({ id, aliases, ...(informational !== null ? { informational } : {}) });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** ツールの応答から差分用の識別子を取り出す。上限を超えたらtruncated */
|
|
59
|
+
function findingsOf(tool, payload) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const budget = { remaining: MAX_FINDING_VULNERABILITIES };
|
|
62
|
+
const source = tool === "suggest_fix" ? asArray(payload.suggestions) : asArray(payload.packages);
|
|
63
|
+
for (const item of source) {
|
|
64
|
+
const pkg = asRecord(item);
|
|
65
|
+
if (pkg === null)
|
|
66
|
+
continue;
|
|
67
|
+
const name = text(tool === "suggest_fix" ? pkg.package : pkg.name);
|
|
68
|
+
const version = text(tool === "suggest_fix" ? pkg.current_version : pkg.version);
|
|
69
|
+
const ecosystem = text(pkg.ecosystem);
|
|
70
|
+
if (name === null || version === null || ecosystem === null)
|
|
71
|
+
continue;
|
|
72
|
+
if (findings.length >= MAX_FINDING_PACKAGES)
|
|
73
|
+
return { findings, truncated: true };
|
|
74
|
+
const vulnerabilities = vulnerabilitiesOf(tool === "suggest_fix" ? pkg.per_cve_detail : pkg.vulnerabilities, budget);
|
|
75
|
+
if (vulnerabilities === null)
|
|
76
|
+
return { findings, truncated: true };
|
|
77
|
+
findings.push({
|
|
78
|
+
ecosystem, name, version, vulnerabilities,
|
|
79
|
+
...(tool === "suggest_fix"
|
|
80
|
+
? { recommended_upgrade: text(pkg.recommended_upgrade), upgrade_tier: text(pkg.upgrade_tier), verification: text(pkg.verification) ?? "unknown" }
|
|
81
|
+
: {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { findings, truncated: false };
|
|
85
|
+
}
|
|
86
|
+
function manifestsOf(payload) {
|
|
87
|
+
const coverage = asRecord(payload.coverage);
|
|
88
|
+
const raw = coverage?.manifests ?? payload.manifests;
|
|
89
|
+
if (raw === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const item of asArray(raw).slice(0, MAX_MANIFESTS)) {
|
|
93
|
+
const rec = asRecord(item);
|
|
94
|
+
const p = text(rec?.path ?? item);
|
|
95
|
+
if (p !== null)
|
|
96
|
+
out.push({ path: p, ecosystem: text(rec?.ecosystem), format: text(rec?.format) });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** 深刻度別の件数。キーの数も抑える(1件の大きさの上限を保証するため) */
|
|
101
|
+
function severityOf(severity) {
|
|
102
|
+
if (severity === null)
|
|
103
|
+
return null;
|
|
104
|
+
return Object.fromEntries(Object.entries(severity).filter(([, v]) => count(v) !== null).slice(0, MAX_SEVERITY_KEYS).map(([k, v]) => [k.slice(0, 32), count(v)]));
|
|
105
|
+
}
|
|
106
|
+
const GAP_FIELDS = [
|
|
107
|
+
"lockfile_missing", "unpinned_requirements", "unscannable_requirements", "unscannable_crates", "unscannable_nuget_packages",
|
|
108
|
+
"unresolved_package_references", "skipped_files", "unidentified_jars", "unidentified_packages",
|
|
109
|
+
];
|
|
110
|
+
function gapsOf(payload) {
|
|
111
|
+
const coverage = asRecord(payload.coverage);
|
|
112
|
+
const gaps = {};
|
|
113
|
+
for (const field of GAP_FIELDS) {
|
|
114
|
+
const value = coverage?.[field];
|
|
115
|
+
if (Array.isArray(value))
|
|
116
|
+
gaps[field] = value.length;
|
|
117
|
+
}
|
|
118
|
+
const skipped = payload.skipped_manifests; // scan_java_project
|
|
119
|
+
if (Array.isArray(skipped))
|
|
120
|
+
gaps.skipped_manifests = skipped.length;
|
|
121
|
+
return Object.keys(gaps).length > 0 ? gaps : undefined;
|
|
122
|
+
}
|
|
123
|
+
/** ツールの応答(JSONテキスト)から1件を作る。応答を解釈できない場合はエラーの記録にする */
|
|
124
|
+
export function buildHistoryRecord(context, responseText, isError) {
|
|
125
|
+
let payload = null;
|
|
126
|
+
try {
|
|
127
|
+
payload = asRecord(JSON.parse(responseText));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
payload = null;
|
|
131
|
+
}
|
|
132
|
+
const input = pathText(context.inputPath) ?? { path: "", truncated: false };
|
|
133
|
+
const resolved = pathText(payload?.project_dir) ?? pathText(context.resolvedInput) ?? input;
|
|
134
|
+
const base = {
|
|
135
|
+
schema: 1,
|
|
136
|
+
id: context.id,
|
|
137
|
+
started_at: context.startedAt.toISOString(),
|
|
138
|
+
duration_ms: Math.max(0, Math.round(context.durationMs)),
|
|
139
|
+
tool: context.tool,
|
|
140
|
+
kind: scanKind(context.tool),
|
|
141
|
+
server_version: context.serverVersion,
|
|
142
|
+
osv_scanner: { pinned_version: context.pinnedScannerVersion, source: context.scannerSource },
|
|
143
|
+
target: { input: input.path, resolved: resolved.path },
|
|
144
|
+
...(input.truncated || resolved.truncated ? { target_truncated: true } : {}),
|
|
145
|
+
allowed_root_set: context.allowedRootSet,
|
|
146
|
+
};
|
|
147
|
+
const errorKind = text(asRecord(payload?.error)?.kind);
|
|
148
|
+
if (isError || payload === null || errorKind !== null) {
|
|
149
|
+
return { ...base, status: "error", error_kind: errorKind ?? "unknown", coverage_complete: null };
|
|
150
|
+
}
|
|
151
|
+
const coverage = asRecord(payload.coverage);
|
|
152
|
+
const complete = typeof coverage?.complete === "boolean" ? coverage.complete : null;
|
|
153
|
+
const severity = asRecord(payload.severity_breakdown);
|
|
154
|
+
const { findings, truncated } = findingsOf(context.tool, payload);
|
|
155
|
+
const record = {
|
|
156
|
+
...base,
|
|
157
|
+
status: "ok",
|
|
158
|
+
coverage_complete: complete,
|
|
159
|
+
...(manifestsOf(payload) !== undefined ? { manifests: manifestsOf(payload) } : {}),
|
|
160
|
+
...(gapsOf(payload) !== undefined ? { coverage_gaps: gapsOf(payload) } : {}),
|
|
161
|
+
counts: {
|
|
162
|
+
vulnerable_package_count: count(payload.vulnerable_package_count ?? payload.identified_vulnerable_package_count),
|
|
163
|
+
vulnerability_count: count(payload.vulnerability_count ?? payload.identified_vulnerability_count),
|
|
164
|
+
informational_count: count(payload.informational_count) ?? 0,
|
|
165
|
+
severity_breakdown: severityOf(severity),
|
|
166
|
+
},
|
|
167
|
+
findings,
|
|
168
|
+
...(truncated ? { findings_truncated: true } : {}),
|
|
169
|
+
};
|
|
170
|
+
if (context.tool === "suggest_fix") {
|
|
171
|
+
// suggest_fixの応答には脆弱性の総数が無いため、提案の脆弱性の数を数える
|
|
172
|
+
record.counts.vulnerability_count = findings.reduce((n, f) => n + f.vulnerabilities.filter((v) => v.informational === undefined).length, 0);
|
|
173
|
+
}
|
|
174
|
+
return record;
|
|
175
|
+
}
|
|
176
|
+
function fitting(value) {
|
|
177
|
+
const text = JSON.stringify(value);
|
|
178
|
+
return Buffer.byteLength(text, "utf8") <= MAX_RECORD_BYTES ? text : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 保存する文字列。1件の上限(読み出しの上限と同じ)に必ず収める:
|
|
182
|
+
* findingsを省き、それでも超えればマニフェストの一覧を半分ずつ減らし、最後は一覧を省く
|
|
183
|
+
* (残りの項目は文字列の長さ・件数に上限があり、上限を十分に下回る。対象のパスも含む)
|
|
184
|
+
*/
|
|
185
|
+
export function serializeHistoryRecord(record) {
|
|
186
|
+
const full = fitting(record);
|
|
187
|
+
if (full !== null)
|
|
188
|
+
return full;
|
|
189
|
+
const { findings: _findings, findings_truncated: _truncated, manifests, ...rest } = record;
|
|
190
|
+
const summary = { ...rest, findings_omitted: true };
|
|
191
|
+
if (manifests === undefined)
|
|
192
|
+
return JSON.stringify(summary);
|
|
193
|
+
const withAll = fitting({ ...summary, manifests });
|
|
194
|
+
if (withAll !== null)
|
|
195
|
+
return withAll;
|
|
196
|
+
for (let n = Math.floor(manifests.length / 2); n > 0; n = Math.floor(n / 2)) {
|
|
197
|
+
const partial = fitting({ ...summary, manifests: manifests.slice(0, n), manifests_truncated: true });
|
|
198
|
+
if (partial !== null)
|
|
199
|
+
return partial;
|
|
200
|
+
}
|
|
201
|
+
const last = fitting({ ...summary, manifests_truncated: true });
|
|
202
|
+
// 残りの項目はどれも大きさに上限があるため通常は届かない。読み戻せないファイルは書かない(記録側が警告する)
|
|
203
|
+
if (last === null)
|
|
204
|
+
throw new Error("The history record exceeds the size limit even without findings and manifests");
|
|
205
|
+
return last;
|
|
206
|
+
}
|
|
207
|
+
/** 読み出した記録の形式を確かめる(履歴のファイルは信頼しない)。解釈できなければnull */
|
|
208
|
+
export function parseHistoryRecord(id, raw) {
|
|
209
|
+
const rec = asRecord(raw);
|
|
210
|
+
if (rec === null || rec.schema !== 1 || rec.id !== id)
|
|
211
|
+
return null;
|
|
212
|
+
const tool = asString(rec.tool);
|
|
213
|
+
if (tool === null || !HISTORY_TOOLS.includes(tool))
|
|
214
|
+
return null;
|
|
215
|
+
const target = asRecord(rec.target);
|
|
216
|
+
const input = pathText(target?.input)?.path ?? null;
|
|
217
|
+
const resolved = pathText(target?.resolved)?.path ?? null;
|
|
218
|
+
const startedAt = text(rec.started_at);
|
|
219
|
+
const status = rec.status === "ok" || rec.status === "error" ? rec.status : null;
|
|
220
|
+
if (input === null || resolved === null || startedAt === null || status === null)
|
|
221
|
+
return null;
|
|
222
|
+
const osv = asRecord(rec.osv_scanner);
|
|
223
|
+
const source = asString(osv?.source);
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const item of asArray(rec.findings).slice(0, MAX_FINDING_PACKAGES)) {
|
|
226
|
+
const f = asRecord(item);
|
|
227
|
+
const ecosystem = text(f?.ecosystem);
|
|
228
|
+
const name = text(f?.name);
|
|
229
|
+
const version = text(f?.version);
|
|
230
|
+
if (ecosystem === null || name === null || version === null)
|
|
231
|
+
continue;
|
|
232
|
+
const vulnerabilities = (vulnerabilitiesOf(f.vulnerabilities, { remaining: MAX_FINDING_VULNERABILITIES }) ?? []);
|
|
233
|
+
findings.push({
|
|
234
|
+
ecosystem, name, version, vulnerabilities,
|
|
235
|
+
...("recommended_upgrade" in f ? { recommended_upgrade: text(f.recommended_upgrade) } : {}),
|
|
236
|
+
...("upgrade_tier" in f ? { upgrade_tier: text(f.upgrade_tier) } : {}),
|
|
237
|
+
...(text(f.verification) !== null ? { verification: text(f.verification) } : {}),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const counts = asRecord(rec.counts);
|
|
241
|
+
const severity = asRecord(counts?.severity_breakdown);
|
|
242
|
+
return {
|
|
243
|
+
schema: 1,
|
|
244
|
+
id,
|
|
245
|
+
started_at: startedAt,
|
|
246
|
+
duration_ms: count(rec.duration_ms) ?? 0,
|
|
247
|
+
tool: tool,
|
|
248
|
+
kind: scanKind(tool),
|
|
249
|
+
server_version: text(rec.server_version) ?? "unknown",
|
|
250
|
+
osv_scanner: {
|
|
251
|
+
pinned_version: text(osv?.pinned_version) ?? "unknown",
|
|
252
|
+
source: source === "OSV_SCANNER_PATH" || source === "pinned_download" ? source : "PATH_or_pinned_download",
|
|
253
|
+
},
|
|
254
|
+
target: { input, resolved },
|
|
255
|
+
...(rec.target_truncated === true ? { target_truncated: true } : {}),
|
|
256
|
+
allowed_root_set: rec.allowed_root_set === true,
|
|
257
|
+
status,
|
|
258
|
+
...(status === "error" ? { error_kind: text(rec.error_kind) ?? "unknown" } : {}),
|
|
259
|
+
coverage_complete: typeof rec.coverage_complete === "boolean" ? rec.coverage_complete : null,
|
|
260
|
+
...(Array.isArray(rec.manifests) ? { manifests: manifestsOf({ manifests: rec.manifests }) ?? [] } : {}),
|
|
261
|
+
...(asRecord(rec.coverage_gaps) !== null
|
|
262
|
+
? { coverage_gaps: Object.fromEntries(Object.entries(asRecord(rec.coverage_gaps)).filter(([, v]) => count(v) !== null).map(([k, v]) => [k.slice(0, 64), count(v)])) }
|
|
263
|
+
: {}),
|
|
264
|
+
...(counts !== null
|
|
265
|
+
? {
|
|
266
|
+
counts: {
|
|
267
|
+
vulnerable_package_count: count(counts.vulnerable_package_count),
|
|
268
|
+
vulnerability_count: count(counts.vulnerability_count),
|
|
269
|
+
informational_count: count(counts.informational_count) ?? 0,
|
|
270
|
+
severity_breakdown: severityOf(severity),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
...(Array.isArray(rec.findings) ? { findings } : {}),
|
|
275
|
+
...(rec.findings_truncated === true ? { findings_truncated: true } : {}),
|
|
276
|
+
...(rec.findings_omitted === true ? { findings_omitted: true } : {}),
|
|
277
|
+
...(rec.manifests_truncated === true ? { manifests_truncated: true } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|