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_severityKey types (crates/core/src/ports/vulnerability.rs:30-47):
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:
Package-scoped lookup, not keyword search
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 coordinate — not 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 = truegate 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 field | What's needed from the source |
|---|---|
osv_id | A stable vulnerability identifier (CVE, GHSA, RUSTSEC, PYSEC, etc.) |
severity | A 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) |
summary | A human-readable one-line (or short) description of the issue |
fixed_version | Optional 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— implementVulnerabilityScanner::query_batch, mapping the source's response intoOsvMatchvalues. UseSeverity::parse/Severity::from_cvss_scorefor severity mapping. - [ ]
crates/adapters/src/vulnerability/mod.rs— register the new module. - [ ]
crates/config/src/schema/mod.rs— extendVulnerabilityScanConfigwith the new source's base URL and/or an enable flag, following theosv_api_urlpattern. - [ ]
server/src/main.rs— construct the new scanner alongsideOsvScanner(server/src/main.rs:217-234). - [ ]
crates/core/src/services/vulnerability/mod.rs— if a second source is added,VulnerabilityScanServiceneeds to go from a singlescanner: Arc<dyn VulnerabilityScanner>(line 17) toscanners: Vec<Arc<dyn VulnerabilityScanner>>.scan_oneshould callquery_batchon each and merge the results, deduplicatingOsvMatchentries by(purl, osv_id)before converting toArtifactVulnerability— 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, mirroringvulnerability_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, andfixed_versionare parsed correctly - A non-2xx / empty response returns
Ok(vec![]), not an error query_batchover multiple PURLs aggregates results from multiple calls- An empty
purlsslice 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.