软件包漏洞扫描服务

一款用于扫描软件包漏洞的服务,可对接OSV.dev漏洞数据库,支持多生态系统软件包的漏洞检测与缓存管理。

  • by adelie-ai
  • Rust
  • 本地部署
  • 更新于
在 GitHub 查看 adelie-ai/cve-mcp

使用说明

cve-mcp

An MCP server for scanning packages against the OSV.dev vulnerability database.

Tools

cve_scan_packages

Scans a list of pinned packages for known CVEs/advisories. Results are cached locally (SQLite, 24 h TTL) to avoid hammering the OSV API.

Parameters

Field Type Required Description
packages array ✓ List of { name, version, ecosystem } objects
min_severity string — Filter threshold: LOW | MEDIUM | HIGH | CRITICAL (default LOW)

Ecosystem identifiers (case-sensitive, matching OSV schema): PyPI, crates.io, npm, Go, Maven, NuGet, RubyGems, Packagist, Hex, Pub, CocoaPods, Swift

Returns — a JSON object:

{
  "scanned": 3,
  "elapsed_ms": 412,
  "findings": [
    {
      "name": "sequoia-openpgp",
      "version": "1.22.0",
      "ecosystem": "crates.io",
      "vuln_id": "RUSTSEC-2025-0136",
      "aliases": ["CVE-2025-67897", "GHSA-v6x3-9r38-r27q"],
      "severity": "MEDIUM",
      "fixed_in": "2.1.0",
      "summary": "Underflow in aes_key_unwrap function"
    }
  ]
}

One finding per advisory

An advisory usually carries several identifiers — a RUSTSEC or GHSA ID plus the CVE it was assigned. Those are collapsed into a single finding rather than counted once per ID. vuln_id is the most specific identifier (CVE > RUSTSEC > GHSA > everything else) and aliases holds the rest, so a caller can still match on any ID it knows. aliases is omitted when the advisory has no other identifiers.

The same advisory affecting two packages — or two versions of one package — is genuinely two findings, and stays that way.

How severity is derived

OSV records carry severity in two places, and either, both, or neither may be present:

  • database_specific.severity — a LOW / MODERATE / HIGH / CRITICAL label. GHSA sets it; RUSTSEC does not.
  • severity[] — CVSS vectors. Both v3.x and v4.0 are scored and banded by the standard thresholds (0.1–3.9 LOW, 4.0–6.9 MEDIUM, 7.0–8.9 HIGH, 9.0–10.0 CRITICAL).

The reported severity is the highest of every signal that parses, across every identifier that collapsed into the finding. It is never lower than the publisher's own label.

When nothing parses, severity is UNKNOWN — not LOW. UNKNOWN findings are always returned regardless of min_severity, because a filter cannot honestly exclude what it could not grade. Drop them yourself if you want a strictly-graded view.

Which fix fixed_in names

fixed_in is the fix for the release branch the scanned version is on, not the first fix the advisory happens to list.

A branch that opens and closes at the same version describes nothing, and is removed before the range is read. last_affected is the exception: it is inclusive, so [introduced 4.1.0, last_affected 4.1.0] says that one version is affected and stays. A range that ends on last_affected reads as affected above that version too, which is wider than the record. That is deliberate: it ends in no fix rather than in a fix that does not apply. That is how Debian and Alpine write "never affected", and RUSTSEC-2024-0399 carries one inside a longer range.

One advisory usually patches several branches at once, and it may cover several packages, or several releases of one distribution. An OSV range is a flat list of events: introduced opens a branch and fixed, last_affected or limit closes it, in whatever order the publisher wrote them. So a vite 5.4.21 pin is fixed by 6.4.2 even though the record lists 8.0.5 first, a spring-boot-starter-web pin is never handed a spring-beans fix, and a Debian 11 host is never handed a Debian 12 package.

A version is reported only when all three of these hold:

  1. every range covering the scanned package can be read,
  2. at least one of those ranges holds the scanned version, and
  3. the version reported sits outside all of them.

The third is the one that is easy to get wrong. OSV ranges union — a version is affected if any one range holds it — so the lowest fix in the record is not the answer unless it is itself clear of every range.

fixed_in is omitted whenever those do not hold: the advisory covers other packages only, the scanned version sits outside every affected branch, the branch holding it has no published fix (last_affected, or a branch never closed), a range carries a bound that will not order, or no candidate fix is clear of every range.

It is never borrowed from an alias either. Collapsing several identifiers into one finding takes the summary from whichever record has one, but the fix comes from the advisory the finding is reported under and nowhere else: a record that closes its branch with last_affected names no fix on purpose, and filling that in from a sibling would report the package as patched when its own advisory says nobody has patched it.

Ordering is semantic-version ordering, widened for the OSV 0 sentinel, a Go-style leading v, and short numeric versions such as 1.0.

An Alpine revision is written after -, as in 1.31.1-r21. Semver reads a prerelease there and compares the identifiers as text, so r9 would sort above r21. For the Alpine ecosystem, and only there, the revision is rewritten to the number it is, which semver compares numerically, so Alpine keeps its own order. A crate publishing a genuine -r2 prerelease keeps semver's own rule.

Two kinds of version are refused rather than ordered wrongly:

  • anything semver cannot parse, such as Maven's 5.2.20.RELEASE, and
  • a Debian revision, written after + as in 7.74.0-1.3+deb11u10 — semver reads that as build metadata and drops it from the comparison, so two distinct packages compare equal.

The ecosystem is matched whole, release qualifier included. Debian:11 and Debian:12 are separate version spaces, and a curl version from one does not exist in the other.

The cost of one ordering for every ecosystem is that Debian and Maven records often report no fix at all, because their versions carry forms semver cannot order. An ecosystem-aware ordering would recover those answers.

Naming a fix that does not apply would report a package as patched when it is not, so silence is the answer wherever the record cannot be read with confidence.


cve_lookup_vuln

Looks up a single advisory by ID. Accepts OSV IDs (RUSTSEC-…), CVE IDs (CVE-…), or GHSA IDs (GHSA-…).

Parameters

Field Type Required Description
id string ✓ Vulnerability identifier

Returns — the raw OSV advisory JSON.

Running

# Stdio mode (default, for VS Code / Claude Desktop)
cargo run -- serve

# WebSocket mode
cargo run -- serve --mode websocket --port 9000

# Custom cache DB path
cargo run -- serve --db ~/my-caches/cve.db

# Disable cache (always hits OSV.dev)
cargo run -- serve --no-cache

Cache

The advisory cache lives at ~/.local/share/cve-mcp/cache.db by default (SQLite). Entries expire after 24 hours. Run cve_scan_packages again after expiry to refresh.

Cached advisory records carry the model version they were written with. An upgrade that reads a field the older model did not store leaves those records unreadable, so the first scan after such an upgrade fetches them again rather than answering from a record that no longer says what it used to.

Logging

mcp-core's run installs the process subscriber; this crate calls nothing to get it. Logs go to stderr, never stdout — the stdio transport frames JSON-RPC on stdout, and one log line there would corrupt the protocol stream. RUST_LOG sets the level (default info); see mcp-core's own README for the full level contract, the request/tool-call spans, and the standard OTEL_* environment variables.

What this server adds on top of what it inherits:

  • Both tool handlers (cve_scan_packages, cve_lookup_vuln) open their own span, named after the tool, nested under mcp-core's mcp.tools.call span.
  • A debug! line on each outbound OSV.dev call: the package count for a querybatch request, the advisory id for a vuln lookup. An advisory id is a tool argument, so it stays at DEBUG and is never attached to a span; RUST_LOG=debug is what it takes to see it.
  • cve.osv_request.duration, a latency histogram for each outbound OSV.dev call, labelled by endpoint (querybatch or vuln).
  • cve.upstream_failures, a counter labelled by endpoint and a bounded reason (http or unexpected_response), for a fault reaching OSV.dev. A cache, serialization, or filesystem fault never left the process, so it is not counted here.
  • mcp-core already records a tool-call counter and a latency histogram by tool and outcome (mcp.tools.call, mcp.tools.call.duration); this server does not duplicate them.

The otel feature

Off by default. A pure passthrough — cve-mcp -> mcp-core -> adelie-telemetry — so this crate takes no direct dependency on adelie-telemetry or on any opentelemetry crate. With the feature off, cargo tree resolves no opentelemetry crate at all.

cargo build --features otel
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 ./target/debug/cve-mcp serve

Testing

just check         # default features: fmt, lint, build, test
just check-otel    # the same, built with --features otel

tests/telemetry_*.rs is the telemetry acceptance suite: that stdout carries only JSON-RPC at RUST_LOG=trace, that no package name, version, or advisory id reaches an INFO line or a span field for any registered tool, and that a default build resolves no opentelemetry crate. Both content tests iterate the server's live tool list rather than a hardcoded pair, so a tool added later without a matching entry in tests/support/mod.rs fails the test by name instead of being silently uncovered.