Skip to content

Adding a Vulnerability Scanner Source

batlehub's periodic SBOM re-check ([vulnerability_scan]) queries an external vulnerability database for the components of every cached SBOM and records the findings. Today there is one source — OSV — wired through the VulnerabilityScanner trait. This guide describes the API shape a vulnerability database needs to provide to be integrated as another source, and the steps to wire it in once one is identified.

1. Architecture Overview

config.toml
  └─ [vulnerability_scan]
       enabled = true
       interval_secs = 86400
       osv_api_url = "https://api.osv.dev"


server/src/main.rs                builds OsvScanner, wraps it in VulnerabilityScanService


crates/adapters/
  └─ vulnerability/osv.rs          implements VulnerabilityScanner
       │                             query_batch(purls) → Vec<OsvMatch>

crates/core/
  └─ services/vulnerability/mod.rs VulnerabilityScanService::scan_all / scan_one
       │                             - pages through every stored CycloneDX SBOM
       │                             - extracts component PURLs (component_purls)
       │                             - calls scanner.query_batch(&purls)
       │                             - converts OsvMatch → ArtifactVulnerability (to_finding)
       │                             - persists via VulnerabilityRepository

crates/core/
  └─ rules/cve_gate.rs             CveGateRule reads findings by (registry, name, version)
                                      and can deny downloads at/above min_severity

Key types (crates/core/src/ports/vulnerability.rs:30-47):

rust
pub struct OsvMatch {
    pub purl: String,
    pub osv_id: String,
    pub severity: Severity,
    pub summary: String,
    pub fixed_version: Option<String>,
}

#[async_trait]
pub trait VulnerabilityScanner: Send + Sync {
    async fn query_batch(&self, purls: &[String]) -> Result<Vec<OsvMatch>, CoreError>;
}

VulnerabilityScanService::scan_one (crates/core/src/services/vulnerability/mod.rs:97-113) calls query_batch once per SBOM with that artifact's component PURLs, and VulnerabilityScanConfig (crates/config/src/schema/mod.rs:76-89) is the config section wired up in server/src/main.rs:217-234. OsvScanner (crates/adapters/src/vulnerability/osv.rs) is the reference implementation for everything below.


2. API Requirements for a New Source

For a vulnerability database to be usable as a VulnerabilityScanner source, it needs to provide:

The endpoint must accept a PackageURL (PURL), or at minimum an ecosystem + package name (optionally + version), and return only the vulnerabilities that affect that exact coordinatenot a free-text, keyword, or product-name search.

This is a hard requirement, not a preference. query_batch is called once per SBOM with that artifact's component PURLs, and the resulting findings feed CveGateRule (crates/core/src/rules/cve_gate.rs), which can deny package downloads when a finding's severity is at or above a configured threshold. A source that can only match on a keyword or product name produces two failure modes that are both unacceptable for a blocking gate:

  • False positives — an unrelated CVE whose description happens to mention a common package name (e.g. "core", "json", "client") gets attributed to every package with that name, and a block = true gate then denies a perfectly safe download.
  • False negatives — a real CVE affecting the package doesn't surface because its text doesn't mention the package name verbatim, silently leaving the gate (and the Package Explorer findings view) blind to it.

OSV's /v1/query (POST { "package": { "purl": "<purl>" } }) is the reference shape: every result it returns is already scoped to that PURL.

Batch-friendly

Either provide a true batch endpoint, or make per-coordinate calls cheap enough that one call per SBOM component stays practical — OsvScanner::query_batch (crates/adapters/src/vulnerability/osv.rs:63-70) calls /v1/query once per PURL and uses this as the baseline cost model. SBOMs are small (the main artifact plus a handful of direct dependencies), so per-PURL round trips are acceptable; an O(n) fan-out per package version re-scanned across the whole instance is not.

Required response fields

Each result needs to map onto OsvMatch { purl, osv_id, severity, summary, fixed_version }:

OsvMatch fieldWhat's needed from the source
osv_idA stable vulnerability identifier (CVE, GHSA, RUSTSEC, PYSEC, etc.)
severityA textual severity bucket (low / medium / high / critical, see Severity::parse) or a numeric CVSS base score (see Severity::from_cvss_score, both in crates/core/src/entities/vulnerability.rs)
summaryA human-readable one-line (or short) description of the issue
fixed_versionOptional but valuable — the first version that fixes the vulnerability for the affected range. Without this, findings tell a user "you're affected" but not "upgrade to X to fix it"

Clear "no findings" vs "error" semantics

A non-result (package has no known vulnerabilities) must be distinguishable from an upstream failure (the API is down, rate-limited, or returned malformed data). OsvScanner::query_one (crates/adapters/src/vulnerability/osv.rs:33-58) treats a non-2xx HTTP response as "no data for this PURL" (Ok(vec![])) and only propagates a CoreError on a transport-level failure. This matters because VulnerabilityScanService::scan_one replaces all prior findings for an artifact with the new result set (VulnerabilityRepository::replace_findings_for_artifact, crates/core/src/ports/vulnerability.rs:14-18) — if a transient outage were reported as "zero findings", a real, previously-recorded vulnerability would be silently cleared.


3. Checklist — Adding a New Scanner Implementation

Once a source meeting the requirements above is identified:

  • [ ] crates/adapters/src/vulnerability/<name>.rs — implement VulnerabilityScanner::query_batch, mapping the source's response into OsvMatch values. Use Severity::parse / Severity::from_cvss_score for severity mapping.
  • [ ] crates/adapters/src/vulnerability/mod.rs — register the new module.
  • [ ] crates/config/src/schema/mod.rs — extend VulnerabilityScanConfig with the new source's base URL and/or an enable flag, following the osv_api_url pattern.
  • [ ] server/src/main.rs — construct the new scanner alongside OsvScanner (server/src/main.rs:217-234).
  • [ ] crates/core/src/services/vulnerability/mod.rs — if a second source is added, VulnerabilityScanService needs to go from a single scanner: Arc<dyn VulnerabilityScanner> (line 17) to scanners: Vec<Arc<dyn VulnerabilityScanner>>. scan_one should call query_batch on each and merge the results, deduplicating OsvMatch entries by (purl, osv_id) before converting to ArtifactVulnerability — this is the aggregation pattern to use; don't build a separate "primary vs. secondary" mechanism.
  • [ ] docs/contributing/security-scanning.md — update the [vulnerability_scan] example and the "Scanning proxied artifacts at runtime" section to mention the new source.
  • [ ] crates/config/src/schema/tests.rs — add config defaulting/parsing tests for the new fields, mirroring vulnerability_scan_defaults / vulnerability_scan_full (crates/config/src/schema/tests.rs:132-150).

4. Testing

Follow the unit test pattern in crates/adapters/src/vulnerability/osv.rs:152-340 using mockito::Server:

  • Base URL defaulting/trimming (base_url_defaults_and_trims)
  • Severity resolution from both textual and numeric sources, including the "no severity info" fallback to Severity::Unknown
  • A successful response with one or more findings, asserting osv_id, severity, and fixed_version are parsed correctly
  • A non-2xx / empty response returns Ok(vec![]), not an error
  • query_batch over multiple PURLs aggregates results from multiple calls
  • An empty purls slice makes no HTTP request

If the new source is added as a second entry in VulnerabilityScanService, also add a crates/core/src/services/vulnerability/mod.rs test covering deduplication when two scanners report the same (purl, osv_id) pair.

Released under the Apache 2.0 License. Made with ❤️ and too much ☕.