src/osv.rs
//! OSV import — parse the **Open Source Vulnerability** format (the SCA
//! lingua-franca: OSV.dev / GHSA / RUSTSEC all export it) into [`Advisory`]s.
//!
//! OSV is how the world ships advisories; modgunn imports an offline OSV/GHSA
//! snapshot (a sealed-znippy in airgap) into its `cve_advisories` table so Engine
//! A scans against the warehouse, not a network call.
//!
//! **Scope of this importer.** One [`Advisory`] per `(vuln, affected-package)`.
//! Exact `affected[].versions` are imported verbatim into
//! [`Advisory::affected_versions`]; **range** specs (`affected[].ranges`) need an
//! ecosystem-specific semver-range expander (cargo/PEP440/npm/Maven grammars) and
//! are left to that front-end — an Advisory with empty `affected_versions` means
//! *all versions* (see [`Advisory::matches`]), so a range-only OSV entry imports
//! as all-versions unless the expander narrows it. EPSS/KEV are **not** in OSV
//! (separate FIRST/CISA feeds) — they stay `None`/`false` here, enriched later.
use std::cmp::Ordering;
use serde::Deserialize;
use crate::core::Severity;
use crate::scan::Advisory;
use crate::versions::{compare, expand_range, RangeEvent};
/// A single OSV record (the subset modgunn reads). `serde(default)` keeps it
/// tolerant of the many optional OSV fields.
#[derive(Debug, Deserialize)]
struct OsvRecord {
id: String,
/// Other ids this advisory is known by (CVE ↔ GHSA ↔ RUSTSEC) — the dedup key.
#[serde(default)]
aliases: Vec<String>,
/// Present (an RFC3339 timestamp) ⇒ the advisory was retracted.
#[serde(default)]
withdrawn: Option<String>,
#[serde(default)]
summary: String,
#[serde(default)]
details: String,
#[serde(default)]
severity: Vec<OsvSeverity>,
#[serde(default)]
affected: Vec<OsvAffected>,
#[serde(default)]
database_specific: DatabaseSpecific,
}
#[derive(Debug, Default, Deserialize)]
struct DatabaseSpecific {
/// GHSA/RUSTSEC put a coarse label here ("CRITICAL"/"HIGH"/"MODERATE"/"LOW").
#[serde(default)]
severity: Option<String>,
/// GHSA (and other OSV producers) carry the advisory's CWE ids here as
/// `["CWE-79", …]`. Read so an OSV/GHSA import captures the weakness class —
/// without this, modgunn only ever got a CWE from NVD (by alias), so a GHSA-only
/// advisory reached the warehouse with an empty `cwe` and could not be mapped to
/// an ATT&CK technique ([`crate::capec`]).
#[serde(default)]
cwe_ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OsvSeverity {
#[serde(rename = "type")]
kind: String,
score: String,
}
#[derive(Debug, Deserialize)]
struct OsvAffected {
package: OsvPackage,
#[serde(default)]
versions: Vec<String>,
#[serde(default)]
ranges: Vec<OsvRange>,
/// Per-ecosystem extension. The Go vuln DB (vuln.go.dev) is the one OSV source
/// that ships **symbol-level** granularity here (`imports[].symbols`) — the
/// exact functions the CVE lives in — which is what the A8 reachability gate
/// ([`crate::scip::reachability_gated_vulns`]) needs to kill 80–90% of the
/// present-but-unreachable false positives.
#[serde(default)]
ecosystem_specific: EcosystemSpecific,
}
/// OSV `affected[].ecosystem_specific`. Only the Go DB's `imports` are modelled —
/// other ecosystems put unrelated keys here (ignored via `serde(default)`).
#[derive(Debug, Default, Deserialize)]
struct EcosystemSpecific {
#[serde(default)]
imports: Vec<GoImport>,
}
/// One Go `imports` entry: an import `path` and the exported `symbols` in it the
/// advisory covers. An entry with `path` but **no** `symbols` means the WHOLE
/// package is vulnerable (no function-level narrowing available).
#[derive(Debug, Default, Deserialize)]
struct GoImport {
#[serde(default)]
path: String,
#[serde(default)]
symbols: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OsvPackage {
ecosystem: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct OsvRange {
/// OSV `ranges[].type`: `SEMVER` | `ECOSYSTEM` | `GIT`. A `GIT` range's events
/// are COMMIT HASHES, not versions — they must never reach the version
/// comparator (a hash parses as version `0`, sorts to the front, and its
/// `fixed` event switches the whole advisory off for every real version).
#[serde(default, rename = "type")]
kind: String,
#[serde(default)]
events: Vec<OsvEvent>,
}
impl OsvRange {
/// Is this range expressed in a version grammar we can compare?
/// Everything but `GIT` is; an absent type is assumed version-based (OSV
/// defaults it to `ECOSYSTEM` for a package-scoped `affected` entry).
fn is_version_range(&self) -> bool {
!self.kind.eq_ignore_ascii_case("GIT")
}
}
/// One OSV range event — exactly one of these keys is set per object.
#[derive(Debug, Default, Deserialize)]
struct OsvEvent {
#[serde(default)]
introduced: Option<String>,
#[serde(default)]
fixed: Option<String>,
#[serde(default)]
last_affected: Option<String>,
}
impl OsvAffected {
/// This entry's version ranges, **one event list per OSV range object** — the
/// disjunction [`Advisory::ranges`] carries.
///
/// The range objects are deliberately NOT flattened into one list: an entry
/// routinely carries a `GIT` range beside the `SEMVER` one (dropped here), and
/// a multi-branch advisory carries several `SEMVER` ranges. Splicing their
/// events together makes one range's `fixed` cancel another's window, silencing
/// the advisory for versions it really covers.
fn range_sets(&self) -> Vec<Vec<RangeEvent>> {
self.ranges
.iter()
.filter(|r| r.is_version_range())
.map(|r| {
r.events
.iter()
.filter_map(|e| {
if let Some(v) = &e.introduced {
Some(RangeEvent::Introduced(v.clone()))
} else if let Some(v) = &e.fixed {
Some(RangeEvent::Fixed(v.clone()))
} else {
e.last_affected.clone().map(RangeEvent::LastAffected)
}
})
.collect::<Vec<_>>()
})
.filter(|events| !events.is_empty())
.collect()
}
/// Did this entry declare ranges we could not evaluate (GIT-only)? Such an
/// entry must NOT fall through to the empty-ranges sentinel, which would mean
/// "every version affected".
fn only_unusable_ranges(&self) -> bool {
!self.ranges.is_empty() && !self.ranges.iter().any(|r| r.is_version_range())
}
}
/// A package's affected ranges from an OSV record, ready for
/// [`crate::versions::expand_range`]: `(ecosystem, package, events)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AffectedRange {
pub id: String,
pub ecosystem: String,
pub package: String,
pub severity: Severity,
pub summary: String,
pub events: Vec<RangeEvent>,
}
/// A **symbol-level** vulnerability record extracted from an OSV
/// `affected[].ecosystem_specific.imports` block (the Go vuln DB is the source
/// that ships this). It names the exact function/method a CVE lives in, so the A8
/// reachability gate can suppress a finding whose vulnerable symbol is *present but
/// never called*. `symbol == ""` means the whole `package` is vulnerable (no
/// function-level narrowing) — reachability gating does not apply and the
/// present-on-disk base join covers it instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VulnSymbol {
/// Advisory id (`GO-2024-1234`, or its CVE alias).
pub id: String,
/// Normalized ecosystem (`go` for the Go DB).
pub ecosystem: String,
/// Import path the symbol lives in (`github.com/foo/bar`).
pub package: String,
/// Exported symbol (`Parse`, or a method `Decoder.Decode`); `""` ⇒ whole package.
pub symbol: String,
pub severity: Severity,
}
/// Normalize an OSV ecosystem label onto modgunn's short ecosystem keys.
/// OSV uses `crates.io`/`PyPI`/`Go`/`Maven`/`RubyGems`/`NuGet`; modgunn uses the
/// lowercase package-manager name. Unknown ecosystems pass through lowercased.
fn normalize_ecosystem(eco: &str) -> String {
// OSV may suffix a registry (`Maven:...`, `Packagist`); take the head.
let head = eco.split(':').next().unwrap_or(eco).trim();
match head.to_ascii_lowercase().as_str() {
"crates.io" => "cargo",
"pypi" => "pypi",
"npm" => "npm",
"go" => "go",
"maven" => "maven",
"rubygems" => "gem",
"nuget" => "nuget",
"packagist" => "composer",
// Distro feeds (OSV.dev exports them) → the package FORMAT key so the right
// version comparator applies: Debian/Ubuntu = dpkg, Alpine = apk, the
// RPM family = rpmvercmp. (P4 version grammars in `versions.rs`.)
"debian" | "ubuntu" => "deb",
"alpine" => "apk",
"red hat" | "redhat" | "rocky linux" | "almalinux" | "suse" | "opensuse"
| "opensuse.org" => "rpm",
other => return other.to_string(),
}
.to_string()
}
/// Map an OSV record's severity onto modgunn's [`Severity`], preferring the
/// coarse `database_specific.severity` label, then a CVSS base score bucket.
fn severity_of(rec: &OsvRecord) -> Severity {
if let Some(label) = &rec.database_specific.severity {
match label.to_ascii_uppercase().as_str() {
"CRITICAL" => return Severity::Critical,
"HIGH" => return Severity::High,
"MODERATE" | "MEDIUM" => return Severity::Medium,
"LOW" => return Severity::Low,
_ => {}
}
}
// CVSS vector → base-score bucket. Try EACH CVSS entry, not just the first: a
// record may list several (e.g. `CVSS_V4` before `CVSS_V3`), and modgunn scores
// v2/v3 but not yet v4. Stopping at the first entry would let an uncomputable v4
// shadow a computable v3 in the same record → the whole advisory defaults to
// Medium, UNDER-severing a real Critical. Use the first entry that computes.
for s in rec.severity.iter().filter(|s| s.kind.starts_with("CVSS")) {
if let Some(base) = cvss_base_score(&s.score) {
return severity_from_base(base);
}
}
// Unknown severity is treated as Medium (a Warn) — present-but-unrated, not
// silently dropped.
Severity::Medium
}
/// The CVSS **vector** string to store on the advisory: the first entry that yields
/// a COMPUTABLE base score (so it matches the severity [`severity_of`] derived),
/// else the first CVSS entry present (kept for display even when that version's
/// calculator isn't implemented). Empty when the record carries no CVSS vector.
fn cvss_vector_of(rec: &OsvRecord) -> String {
let cvss = || rec.severity.iter().filter(|s| s.kind.starts_with("CVSS"));
cvss()
.find(|s| cvss_base_score(&s.score).is_some())
.or_else(|| cvss().next())
.map(|s| s.score.trim().to_string())
.unwrap_or_default()
}
/// Compute a [`Severity`] from a CVSS **vector** (v2 or v3.x) or a bare numeric
/// score string, for consumers outside this module (e.g. the gitlab/gemnasium
/// importer). `None` if the vector is unscoreable (a v4 vector / malformed). The
/// single public entry to the offline CVSS scoring the OSV severity path uses.
pub fn severity_from_cvss_vector(vector: &str) -> Option<Severity> {
cvss_base_score(vector).map(severity_from_base)
}
/// Bucket a CVSS base score (0.0–10.0) into a [`Severity`] using the standard
/// qualitative thresholds (Critical ≥9, High ≥7, Medium ≥4, Low >0, else Info). The
/// single source of the mapping, shared by the OSV, rustsec **and OSS-Index**
/// severity paths — `pub` so a feed that ships a numeric CVSS score (OSS-Index's
/// `cvssScore`) buckets it exactly as a vector-derived one does, instead of
/// inventing a second threshold table (L5).
pub fn severity_from_base(base: f64) -> Severity {
match base {
b if b >= 9.0 => Severity::Critical,
b if b >= 7.0 => Severity::High,
b if b >= 4.0 => Severity::Medium,
b if b > 0.0 => Severity::Low,
_ => Severity::Info,
}
}
/// Extract a numeric CVSS base score. Accepts a bare number (`"7.5"`) OR computes it
/// from a **CVSS v3.0/3.1 vector** string (`"CVSS:3.1/AV:N/…"`) via the spec's
/// deterministic base-score formula ([`cvss_v3_base`]). A v2 or v4 vector (different
/// formulae — v4's is far more involved) still returns `None`, so those fall back to
/// the `database_specific` label / the Medium default, unchanged. Computing the v3
/// score offline stops a CVSS-vector-only advisory (very common in OSV) from being
/// silently under-rated to Medium.
fn cvss_base_score(score: &str) -> Option<f64> {
let s = score.trim();
if let Ok(n) = s.parse::<f64>() {
return Some(n);
}
if s.starts_with("CVSS:3") {
return cvss_v3_base(s);
}
// A CVSS **v2** vector carries no `CVSS:` prefix and is distinguished by its
// `Au:` (Authentication) metric, which v3 replaced with PR/UI. Older CVEs still
// ship v2-only, so scoring it keeps them off the Medium default too.
if s.contains("AV:") && s.contains("Au:") {
return cvss_v2_base(s);
}
None
}
/// Compute the CVSS **v2** base score from a vector string, per the FIRST v2 spec.
/// `None` if a required metric (AV/AC/Au/C/I/A) is missing/unrecognized.
fn cvss_v2_base(vector: &str) -> Option<f64> {
let mut m = std::collections::HashMap::new();
for part in vector.split('/') {
if let Some((k, v)) = part.split_once(':') {
m.insert(k, v);
}
}
let av = match *m.get("AV")? {
"L" => 0.395,
"A" => 0.646,
"N" => 1.0,
_ => return None,
};
let ac = match *m.get("AC")? {
"H" => 0.35,
"M" => 0.61,
"L" => 0.71,
_ => return None,
};
let au = match *m.get("Au")? {
"M" => 0.45,
"S" => 0.56,
"N" => 0.704,
_ => return None,
};
let impact_metric = |v: &str| -> Option<f64> {
match v {
"N" => Some(0.0),
"P" => Some(0.275),
"C" => Some(0.660),
_ => None,
}
};
let c = impact_metric(m.get("C")?)?;
let i = impact_metric(m.get("I")?)?;
let a = impact_metric(m.get("A")?)?;
let impact = 10.41 * (1.0 - (1.0 - c) * (1.0 - i) * (1.0 - a));
let exploitability = 20.0 * av * ac * au;
let f_impact = if impact == 0.0 { 0.0 } else { 1.176 };
let base = ((0.6 * impact) + (0.4 * exploitability) - 1.5) * f_impact;
// v2 rounds the base score to one decimal (round half up).
Some((base * 10.0).round() / 10.0)
}
/// Compute the CVSS v3.0/3.1 **base score** from a vector string, per the FIRST
/// specification. Returns `None` if a required base metric (AV/AC/PR/UI/S/C/I/A) is
/// missing or unrecognized. v3.0 and v3.1 differ only in the roundup rule; the v3.1
/// roundup (used here) differs from v3.0 by at most 0.1 and effectively never crosses
/// a severity bucket, so one implementation serves both.
fn cvss_v3_base(vector: &str) -> Option<f64> {
let mut m = std::collections::HashMap::new();
for part in vector.split('/') {
if let Some((k, v)) = part.split_once(':') {
m.insert(k, v);
}
}
let scope_changed = *m.get("S")? == "C";
let av = match *m.get("AV")? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.2,
_ => return None,
};
let ac = match *m.get("AC")? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
// Privileges-Required weights depend on Scope (a changed scope makes L/H heavier).
let pr = match (*m.get("PR")?, scope_changed) {
("N", _) => 0.85,
("L", false) => 0.62,
("L", true) => 0.68,
("H", false) => 0.27,
("H", true) => 0.50,
_ => return None,
};
let ui = match *m.get("UI")? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let impact_metric = |v: &str| -> Option<f64> {
match v {
"H" => Some(0.56),
"L" => Some(0.22),
"N" => Some(0.0),
_ => None,
}
};
let c = impact_metric(m.get("C")?)?;
let i = impact_metric(m.get("I")?)?;
let a = impact_metric(m.get("A")?)?;
let iss = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a));
let impact = if scope_changed {
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
} else {
6.42 * iss
};
if impact <= 0.0 {
return Some(0.0);
}
let exploitability = 8.22 * av * ac * pr * ui;
let raw = if scope_changed {
(1.08 * (impact + exploitability)).min(10.0)
} else {
(impact + exploitability).min(10.0)
};
Some(cvss_roundup(raw))
}
/// The CVSS v3.1 `Roundup` — round UP to one decimal place, but exactly on a
/// tenth-boundary keep it (avoids `4.0` becoming `4.1` from float noise). Defined
/// over integer hundred-thousandths to match the spec's reference pseudocode.
fn cvss_roundup(x: f64) -> f64 {
let int_input = (x * 100_000.0).round() as i64;
if int_input % 10_000 == 0 {
int_input as f64 / 100_000.0
} else {
((int_input / 10_000) + 1) as f64 / 10.0
}
}
/// Parse a single OSV JSON document into its [`Advisory`]s (one per affected
/// package). A record with no `affected` packages yields none.
pub fn parse_osv(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let rec: OsvRecord = serde_json::from_str(json)?;
Ok(advisories_from(&rec))
}
/// Parse an OSV *batch* — a JSON array of OSV records — into all their advisories.
pub fn parse_osv_batch(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let recs: Vec<OsvRecord> = serde_json::from_str(json)?;
Ok(recs.iter().flat_map(advisories_from).collect())
}
/// Parse a single OSV doc, tolerant of both shapes: a batch array first, else a
/// single record. Unlike [`parse_and_merge`], this surfaces the parse error so a
/// caller (e.g. the `import` CLI) can name the offending file and count it as a
/// skipped source instead of silently dropping coverage.
pub fn parse_doc(doc: &str) -> Result<Vec<Advisory>, serde_json::Error> {
match parse_osv_batch(doc) {
Ok(v) => Ok(v),
Err(_) => parse_osv(doc),
}
}
/// Normalize + dedup a set of OSV documents into a warehouse-ready advisory
/// corpus — the **offline normalize step** of the ingest pipeline (P1). Each
/// `doc` may be a single OSV record or a JSON array of them; records are parsed
/// (range-native — ranges carried, not pre-expanded), then folded ACROSS feeds by
/// alias (the same CVE from OSV + GHSA + NVD collapses) via
/// [`crate::scan::merge_by_alias`]. The result is what
/// [`crate::warehouse::Warehouse::append_advisories`] writes under a `db_rev`.
/// Malformed docs are skipped (a bad file never sinks a whole import).
pub fn parse_and_merge<I, S>(docs: I) -> Vec<Advisory>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut all = Vec::new();
for (i, doc) in docs.into_iter().enumerate() {
let d = doc.as_ref();
match parse_doc(d) {
Ok(parsed) => all.extend(parsed),
// Never silently reduce coverage: surface the skipped doc on stderr.
// (Callers with a filename should prefer [`parse_doc`] to name it.)
Err(e) => eprintln!(
"warning: skipping unparseable OSV doc #{i} ({} bytes): {e}",
d.len()
),
}
}
crate::scan::merge_by_alias(all)
}
/// Parse the **range** specs out of an OSV document (single or batch). These are
/// the entries whose affected versions are expressed as `introduced`/`fixed`
/// windows rather than an exact `versions` list — feed them through
/// [`expand_advisories`] (or [`crate::versions::expand_range`]) with the package's
/// known versions to get exact [`Advisory`]s.
pub fn parse_osv_ranges(json: &str) -> Result<Vec<AffectedRange>, serde_json::Error> {
// Accept either a single record or a batch array.
let recs: Vec<OsvRecord> = match serde_json::from_str::<Vec<OsvRecord>>(json) {
Ok(v) => v,
Err(_) => vec![serde_json::from_str(json)?],
};
Ok(recs.iter().flat_map(ranges_from).collect())
}
fn ranges_from(rec: &OsvRecord) -> Vec<AffectedRange> {
let severity = severity_of(rec);
let summary = if !rec.summary.is_empty() {
rec.summary.clone()
} else {
rec.details.clone()
};
// One AffectedRange per RANGE OBJECT, not per affected entry: each range is
// expanded on its own and the results union through `affected_versions`.
rec.affected
.iter()
.flat_map(|aff| {
let (eco, pkg) = (
normalize_ecosystem(&aff.package.ecosystem),
aff.package.name.clone(),
);
let (id, sum) = (rec.id.clone(), summary.clone());
aff.range_sets()
.into_iter()
.map(move |events| AffectedRange {
id: id.clone(),
ecosystem: eco.clone(),
package: pkg.clone(),
severity,
summary: sum.clone(),
events,
})
})
.collect()
}
/// Parse the **symbol-level** vulnerability records out of an OSV document (single
/// or batch) — the `affected[].ecosystem_specific.imports` blocks the Go vuln DB
/// ships. Each `(import path, symbol)` pair becomes one [`VulnSymbol`]; an import
/// with no `symbols` yields one package-level record (`symbol == ""`). A document
/// with no such blocks (every non-Go OSV source) yields an empty vec — never an
/// error, so it composes with the normal advisory import. These seed the A8
/// reachability gate's [`crate::scip::VulnSink`]s.
pub fn parse_osv_vuln_symbols(json: &str) -> Result<Vec<VulnSymbol>, serde_json::Error> {
let recs: Vec<OsvRecord> = match serde_json::from_str::<Vec<OsvRecord>>(json) {
Ok(v) => v,
Err(_) => vec![serde_json::from_str(json)?],
};
Ok(recs.iter().flat_map(vuln_symbols_from).collect())
}
fn vuln_symbols_from(rec: &OsvRecord) -> Vec<VulnSymbol> {
let severity = severity_of(rec);
let mut out = Vec::new();
for aff in &rec.affected {
let eco = normalize_ecosystem(&aff.package.ecosystem);
for imp in &aff.ecosystem_specific.imports {
if imp.path.is_empty() {
continue; // a symbols-only import with no path can't be joined — skip.
}
if imp.symbols.is_empty() {
// Whole-package vulnerability (no function-level narrowing).
out.push(VulnSymbol {
id: rec.id.clone(),
ecosystem: eco.clone(),
package: imp.path.clone(),
symbol: String::new(),
severity,
});
} else {
for sym in &imp.symbols {
if sym.is_empty() {
continue;
}
out.push(VulnSymbol {
id: rec.id.clone(),
ecosystem: eco.clone(),
package: imp.path.clone(),
symbol: sym.clone(),
severity,
});
}
}
}
}
out
}
/// Expand OSV `ranges` into exact-version [`Advisory`]s, given a `known_versions`
/// lookup that returns the versions in existence for an `(ecosystem, package)`.
/// This is the bridge from OSV's range grammar to Engine A's exact matching — a
/// range with no matching known version yields no advisory (nothing affected).
pub fn expand_advisories(
ranges: &[AffectedRange],
mut known_versions: impl FnMut(&str, &str) -> Vec<String>,
) -> Vec<Advisory> {
ranges
.iter()
.filter_map(|r| {
let candidates = known_versions(&r.ecosystem, &r.package);
let affected = expand_range(&r.ecosystem, &r.events, &candidates);
if affected.is_empty() {
return None;
}
Some(Advisory {
id: r.id.clone(),
ecosystem: r.ecosystem.clone(),
package: r.package.clone(),
affected_versions: affected,
severity: r.severity,
summary: r.summary.clone(),
epss: None,
kev: false,
// P1 wires these to carry the OSV ranges range-native; for now the
// pre-expanded affected_versions above is the match surface.
ranges: Vec::new(),
aliases: Vec::new(),
withdrawn: false,
source: "osv".into(),
cvss_vector: String::new(),
cwe: Vec::new(),
})
})
.collect()
}
fn advisories_from(rec: &OsvRecord) -> Vec<Advisory> {
let severity = severity_of(rec);
let summary = if !rec.summary.is_empty() {
rec.summary.clone()
} else {
rec.details.clone()
};
let withdrawn = rec.withdrawn.is_some();
// CWE ids the OSV/GHSA record carries in `database_specific.cwe_ids` (`CWE-79`).
// Shared by every advisory the record yields. Trimmed; empties dropped.
let cwe: Vec<String> = rec
.database_specific
.cwe_ids
.iter()
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect();
// The CVSS **vector** string OSV carries in `severity[].score`. NVD stores its
// vector; OSV's was discarded, losing the precise metrics a consumer
// re-scores/displays. `cvss_vector_of` picks the entry consistent with the
// computed severity (first computable, else first present).
let cvss_vector = cvss_vector_of(rec);
rec.affected
.iter()
// An entry whose ONLY ranges are GIT commit windows carries no evaluable
// version information; emitting it would hit the empty-ranges sentinel and
// flag EVERY version of the package. Drop it instead of over-flagging.
.filter(|aff| !(aff.versions.is_empty() && aff.only_unusable_ranges()))
.map(|aff| Advisory {
id: rec.id.clone(),
ecosystem: normalize_ecosystem(&aff.package.ecosystem),
package: aff.package.name.clone(),
// Carry BOTH the explicit versions and the ranges: matches() ORs the
// exact list with affected_by(ranges) — range-native, no pre-expansion
// against a registry index (that lossy path is gone).
affected_versions: aff.versions.clone(),
severity,
summary: summary.clone(),
epss: None,
kev: false,
ranges: aff.range_sets(),
aliases: rec.aliases.clone(),
withdrawn,
source: "osv".into(),
cvss_vector: cvss_vector.clone(),
cwe: cwe.clone(),
})
.collect()
}
// ── RUSTSEC advisory-db markdown import ──────────────────────────────────────
//
// The rustsec advisory-db ships one Markdown file per advisory with a ```toml
// front-matter block (`[advisory]` + `[versions]`). It is cargo-only and, unlike
// OSV, expresses affected versions as the **complement** of `patched`/`unaffected`
// semver requirements (cargo-audit's rule: a version is affected iff no `patched`
// requirement matches it AND it is not `unaffected`). modgunn matches range-native
// via [`RangeEvent`], so the import inverts those *safe*-version requirements into
// affected `Introduced`/`Fixed` windows — see [`rustsec_affected_ranges`], the one
// non-mechanical piece (and the one the oracle tests pin against cargo-audit's rule).
/// A version bound: `ver` with `incl` = whether the bound itself is included.
#[derive(Clone)]
struct VBound {
ver: String,
incl: bool,
}
/// A *safe* (not-affected) version interval `[lo, hi)`-ish. `lo == None` means
/// "from 0" (the bottom); `hi == None` means "to +∞". Inclusivity per [`VBound`].
struct SafeIv {
lo: Option<VBound>,
hi: Option<VBound>,
}
impl SafeIv {
/// An interval that contains no version (e.g. `>= 2, < 1`) — dropped before
/// inversion so it can't punch a phantom hole in the affected set.
fn is_empty(&self) -> bool {
match (&self.lo, &self.hi) {
(Some(lo), Some(hi)) => match compare("cargo", &lo.ver, &hi.ver) {
Ordering::Greater => true,
Ordering::Equal => !(lo.incl && hi.incl),
Ordering::Less => false,
},
_ => false,
}
}
}
/// Invert rustsec `patched` + `unaffected` semver requirements into the affected
/// [`RangeEvent`] windows modgunn matches against. Empty result ⇒ *every* version
/// is affected (no patched release and nothing unaffected) — an [`Advisory`] with
/// empty ranges + empty `affected_versions` matches all versions (see
/// [`Advisory::matches`]).
///
/// Equivalence target (the oracle): for any `v`,
/// `versions::affected_by("cargo", &out, v)` == `!patched.matches(v) &&
/// !unaffected.matches(v)` (cargo-audit's rule). Exact for the `>=`/`<`/`=` shapes
/// rustsec uses; the rare exclusive-lower / inclusive-upper safe bound is
/// approximated in the *over*-flag (never under-flag) direction.
pub fn rustsec_affected_ranges(patched: &[String], unaffected: &[String]) -> Vec<RangeEvent> {
let mut safe: Vec<SafeIv> = patched
.iter()
.chain(unaffected.iter())
.filter_map(|r| parse_safe_req(r))
.filter(|iv| !iv.is_empty())
.collect();
if safe.is_empty() {
return Vec::new(); // nothing safe ⇒ all versions affected
}
safe.sort_by(|a, b| cmp_lo(&a.lo, &b.lo));
// Merge overlapping / touching safe intervals so the gaps between them are the
// affected windows.
let mut merged: Vec<SafeIv> = Vec::new();
for iv in safe {
match merged.last_mut() {
Some(last) if connects(&last.hi, &iv.lo) => {
last.hi = max_hi(last.hi.take(), iv.hi);
}
_ => merged.push(iv),
}
}
let mut events = Vec::new();
// `cursor` = lower bound of the next affected region; `None` = "from 0".
let mut cursor: Option<VBound> = None;
let mut live = true; // false once safe runs to +∞ (nothing affected above)
for s in &merged {
if !live {
break;
}
if let Some(lo) = &s.lo {
// Affected gap [cursor, lo): on below where this safe interval starts.
push_introduced(&mut events, &cursor);
if lo.incl {
events.push(RangeEvent::Fixed(lo.ver.clone())); // affected < lo
} else {
events.push(RangeEvent::LastAffected(lo.ver.clone())); // affected <= lo
}
}
match &s.hi {
None => live = false, // safe to +∞
// Affected resumes where safe ends (inclusive iff safe was exclusive there).
Some(hi) => {
cursor = Some(VBound {
ver: hi.ver.clone(),
incl: !hi.incl,
})
}
}
}
if live {
// Tail: affected from `cursor` to +∞ (an Introduced with no Fixed).
push_introduced(&mut events, &cursor);
}
events
}
fn push_introduced(events: &mut Vec<RangeEvent>, cursor: &Option<VBound>) {
match cursor {
None => events.push(RangeEvent::Introduced("0".into())),
Some(b) => events.push(RangeEvent::Introduced(b.ver.clone())),
}
}
/// Parse one rustsec requirement string (e.g. `">= 1.2.4, < 1.3.0"`) into the
/// safe interval it describes (the AND of its comma-separated comparators).
fn parse_safe_req(req: &str) -> Option<SafeIv> {
let mut lo: Option<VBound> = None;
let mut hi: Option<VBound> = None;
let mut any = false;
for part in req.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (op, ver) = split_op(part);
if ver.is_empty() {
continue;
}
any = true;
match op {
">=" => tighten_lo(&mut lo, ver, true),
">" => tighten_lo(&mut lo, ver, false),
"<=" => tighten_hi(&mut hi, ver, true),
"<" => tighten_hi(&mut hi, ver, false),
"^" => {
tighten_lo(&mut lo, ver, true);
tighten_hi(&mut hi, &caret_upper(ver), false);
}
"~" => {
tighten_lo(&mut lo, ver, true);
tighten_hi(&mut hi, &tilde_upper(ver), false);
}
// "=" or a bare version ⇒ the exact version is safe.
_ => {
tighten_lo(&mut lo, ver, true);
tighten_hi(&mut hi, ver, true);
}
}
}
any.then_some(SafeIv { lo, hi })
}
/// Split a comparator into `(op, version)`. Longer ops first so `>=` beats `>`.
fn split_op(part: &str) -> (&str, &str) {
for op in [">=", "<=", "^", "~", ">", "<", "="] {
if let Some(rest) = part.strip_prefix(op) {
return (op, rest.trim());
}
}
("=", part.trim())
}
/// Keep the greater (more restrictive) lower bound; exclusive wins ties.
fn tighten_lo(cur: &mut Option<VBound>, ver: &str, incl: bool) {
let cand = VBound {
ver: ver.to_string(),
incl,
};
match cur {
None => *cur = Some(cand),
Some(c) => match compare("cargo", &cand.ver, &c.ver) {
Ordering::Greater => *cur = Some(cand),
Ordering::Equal if c.incl && !incl => *cur = Some(cand),
_ => {}
},
}
}
/// Keep the lesser (more restrictive) upper bound; exclusive wins ties.
fn tighten_hi(cur: &mut Option<VBound>, ver: &str, incl: bool) {
let cand = VBound {
ver: ver.to_string(),
incl,
};
match cur {
None => *cur = Some(cand),
Some(c) => match compare("cargo", &cand.ver, &c.ver) {
Ordering::Less => *cur = Some(cand),
Ordering::Equal if c.incl && !incl => *cur = Some(cand),
_ => {}
},
}
}
/// Order safe-interval lower bounds; `None` (bottom) sorts first, inclusive before
/// exclusive at the same version (the inclusive one starts marginally earlier).
fn cmp_lo(a: &Option<VBound>, b: &Option<VBound>) -> Ordering {
match (a, b) {
(None, None) => Ordering::Equal,
(None, _) => Ordering::Less,
(_, None) => Ordering::Greater,
(Some(x), Some(y)) => match compare("cargo", &x.ver, &y.ver) {
Ordering::Equal => match (x.incl, y.incl) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => Ordering::Equal,
},
o => o,
},
}
}
/// Do two adjacent safe intervals (upper `hi`, then lower `lo`) leave no affected
/// gap between them?
fn connects(hi: &Option<VBound>, lo: &Option<VBound>) -> bool {
let hi = match hi {
None => return true, // +∞ covers everything above
Some(h) => h,
};
let lo = match lo {
None => return true, // next starts at 0 ⇒ overlaps
Some(l) => l,
};
match compare("cargo", &lo.ver, &hi.ver) {
Ordering::Less => true,
Ordering::Greater => false,
Ordering::Equal => hi.incl || lo.incl,
}
}
/// The upper (covers-more) of two safe-interval upper bounds; `None` = +∞ wins.
fn max_hi(a: Option<VBound>, b: Option<VBound>) -> Option<VBound> {
match (a, b) {
(None, _) | (_, None) => None,
(Some(x), Some(y)) => match compare("cargo", &x.ver, &y.ver) {
Ordering::Greater => Some(x),
Ordering::Less => Some(y),
Ordering::Equal => Some(VBound {
ver: x.ver,
incl: x.incl || y.incl,
}),
},
}
}
/// `(major, minor, patch)` of a version's release core (pre-release / build dropped).
fn parse3(v: &str) -> (u64, u64, u64) {
let core = v.split(['-', '+']).next().unwrap_or(v);
let mut it = core.split('.').map(|x| x.parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
/// Caret-range upper bound (`^1.2.3` ⇒ `2.0.0`, `^0.2.3` ⇒ `0.3.0`, `^0.0.3` ⇒
/// `0.0.4`). Fully-specified forms are exact; partial forms are approximated
/// (rustsec uses explicit `>=`/`<`, so this path is rarely hit).
fn caret_upper(v: &str) -> String {
let (maj, min, pat) = parse3(v);
if maj > 0 {
format!("{}.0.0", maj + 1)
} else if min > 0 {
format!("0.{}.0", min + 1)
} else {
format!("0.0.{}", pat + 1)
}
}
/// Tilde-range upper bound (`~1.2.3` ⇒ `1.3.0`).
fn tilde_upper(v: &str) -> String {
let (maj, min, _) = parse3(v);
format!("{}.{}.0", maj, min + 1)
}
/// Extract the ```toml fenced front-matter from a rustsec advisory `.md`.
#[cfg(feature = "rustsec")]
fn rustsec_front_matter(text: &str) -> Option<&str> {
let after = &text[text.find("```toml")? + "```toml".len()..];
let end = after.find("```")?;
Some(after[..end].trim_matches('\n'))
}
#[cfg(feature = "rustsec")]
fn toml_string_list(v: Option<&toml::Value>) -> Vec<String> {
v.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
/// Severity for a non-informational rustsec advisory. `cvss` is usually a v3 vector;
/// [`cvss_base_score`] now computes its base score offline, so it is bucketed via the
/// SAME [`severity_from_base`] the OSV path uses. Only a vector we cannot score (v2/v4
/// or malformed) falls back to `High` (block-worthy — a real rustsec advisory with an
/// unrated vector). Informational advisories are mapped to `Low` by the caller.
#[cfg(feature = "rustsec")]
fn rustsec_severity(cvss: &str) -> Severity {
if let Some(base) = cvss_base_score(cvss) {
return severity_from_base(base);
}
Severity::High
}
/// Parse one rustsec advisory `.md` (toml front-matter) into an [`Advisory`].
/// Returns `None` if the file carries no `[advisory]` block or no package name.
/// Affected versions are carried **range-native** (the `patched`/`unaffected`
/// inversion of [`rustsec_affected_ranges`]); an `informational` advisory
/// (unmaintained / unsound / notice) is mapped to `Severity::Low` (⇒ Warn), never
/// the real-vuln severity (preserving the Warn-not-Block rule).
#[cfg(feature = "rustsec")]
pub fn parse_rustsec_md(md: &str) -> Option<Advisory> {
let front = rustsec_front_matter(md)?;
let doc: toml::Value = toml::from_str(front).ok()?;
let adv = doc.get("advisory")?;
let id = adv.get("id").and_then(|v| v.as_str())?.to_string();
let package = adv.get("package").and_then(|v| v.as_str())?.to_string();
let summary = adv
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let aliases = toml_string_list(adv.get("aliases"));
let informational = adv.get("informational").and_then(|v| v.as_str());
let cvss = adv
.get("cvss")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let versions = doc.get("versions");
let patched = toml_string_list(versions.and_then(|v| v.get("patched")));
let unaffected = toml_string_list(versions.and_then(|v| v.get("unaffected")));
let severity = if informational.is_some() {
Severity::Low
} else {
rustsec_severity(&cvss)
};
Some(Advisory {
id,
ecosystem: "cargo".into(),
package,
affected_versions: Vec::new(),
severity,
summary,
epss: None,
kev: false,
// The inversion already merges its intervals into ONE well-formed event
// walk, so it is a single range object (empty ⇒ all versions affected).
ranges: match rustsec_affected_ranges(&patched, &unaffected) {
r if r.is_empty() => Vec::new(),
r => vec![r],
},
aliases,
withdrawn: false,
source: "rustsec".into(),
cvss_vector: cvss,
cwe: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ArtifactRef;
#[test]
fn parses_ghsa_style_osv_with_label_severity() {
let json = r#"{
"id": "GHSA-xxxx-yyyy-zzzz",
"summary": "Prototype pollution in left-pad",
"severity": [],
"affected": [
{ "package": { "ecosystem": "npm", "name": "left-pad" },
"versions": ["1.0.0", "1.1.0"] }
],
"database_specific": { "severity": "HIGH" }
}"#;
let advs = parse_osv(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
assert_eq!(a.id, "GHSA-xxxx-yyyy-zzzz");
assert_eq!(a.ecosystem, "npm");
assert_eq!(a.package, "left-pad");
assert_eq!(a.severity, Severity::High);
assert_eq!(a.affected_versions, vec!["1.0.0", "1.1.0"]);
assert!(a.summary.contains("Prototype pollution"));
// The imported advisory matches the affected coords.
assert!(a.matches(&ArtifactRef {
ecosystem: "npm".into(),
name: "left-pad".into(),
version: "1.0.0".into(),
blob_sha256: "x".into(),
}));
}
/// Go vuln-DB `ecosystem_specific.imports` → symbol-level [`VulnSymbol`]s: each
/// `(path, symbol)` pair, a method (`Type.Method`) kept intact, a symbols-less
/// import folded to a whole-package record, and a non-Go record contributing
/// nothing. RED if the ecosystem_specific parse or the whole-package fallback
/// regresses (the A8 gate would then have no real sinks to gate on).
/// RED-when-reverted: an OSV/GHSA record carries its CWE ids in
/// `database_specific.cwe_ids`; they must reach the [`Advisory`]. Before this was
/// read, a GHSA-only advisory landed with an empty `cwe` — losing the weakness
/// class NVD would only supply if the CVE happened to alias-merge in, and
/// breaking the CWE→ATT&CK ([`crate::capec`]) enrichment.
#[test]
fn ghsa_database_specific_cwe_ids_are_captured() {
let json = r#"{
"id": "GHSA-xxxx-yyyy-zzzz",
"summary": "XSS in foo",
"database_specific": { "severity": "HIGH", "cwe_ids": ["CWE-79", " CWE-80 ", ""] },
"affected": [ { "package": { "ecosystem": "npm", "name": "foo" }, "versions": ["1.0.0"] } ]
}"#;
let advs = parse_osv(json).unwrap();
assert_eq!(advs.len(), 1);
assert_eq!(
advs[0].cwe,
vec!["CWE-79", "CWE-80"],
"cwe_ids captured + trimmed, empties dropped"
);
}
/// RED-when-reverted: the CVSS **vector** string OSV carries in `severity[].score`
/// must be stored on the advisory (NVD's path does; OSV's dropped it), so a
/// consumer keeps the precise metrics, not just the bucketed severity.
#[test]
fn osv_cvss_vector_is_captured() {
let vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
let json = format!(
r#"{{ "id": "CVE-2024-1", "summary": "x",
"severity": [ {{ "type": "CVSS_V3", "score": "{vector}" }} ],
"affected": [ {{ "package": {{ "ecosystem": "npm", "name": "foo" }}, "versions": ["1.0.0"] }} ] }}"#
);
let advs = parse_osv(&json).unwrap();
assert_eq!(
advs[0].cvss_vector, vector,
"OSV CVSS vector reaches the advisory"
);
assert_eq!(
advs[0].severity,
Severity::Critical,
"and it still drives the severity bucket (9.8 → Critical)"
);
}
/// RED-when-reverted: a record listing an uncomputable `CVSS_V4` entry BEFORE a
/// computable `CVSS_V3` must still derive its severity from the v3 vector — not
/// stop at the v4 and default to Medium (an under-severity). The stored vector is
/// the computable (v3) one, consistent with the derived severity.
#[test]
fn cvss_v4_first_does_not_shadow_computable_v3() {
let v4 = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N";
let v3 = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"; // 9.8
// Pre-req: modgunn does not yet score v4 (else this test proves nothing).
assert!(
cvss_base_score(v4).is_none(),
"v4 is uncomputable today — the premise of the shadowing bug"
);
let json = format!(
r#"{{ "id": "CVE-2024-2",
"severity": [ {{ "type": "CVSS_V4", "score": "{v4}" }}, {{ "type": "CVSS_V3", "score": "{v3}" }} ],
"affected": [ {{ "package": {{ "ecosystem": "npm", "name": "foo" }}, "versions": ["1.0.0"] }} ] }}"#
);
let advs = parse_osv(&json).unwrap();
assert_eq!(
advs[0].severity,
Severity::Critical,
"v3 9.8 wins over the uncomputable v4-first"
);
assert_eq!(
advs[0].cvss_vector, v3,
"the stored vector is the computable v3, matching the severity"
);
}
#[test]
fn go_vulndb_symbols_extracted_per_import() {
let json = r#"[
{ "id": "GO-2024-0001",
"summary": "auth bypass in parser",
"database_specific": { "severity": "HIGH" },
"affected": [
{ "package": { "ecosystem": "Go", "name": "github.com/foo/bar" },
"ranges": [ { "type": "SEMVER", "events": [ {"introduced": "0"}, {"fixed": "1.2.0"} ] } ],
"ecosystem_specific": {
"imports": [
{ "path": "github.com/foo/bar", "symbols": ["Parse", "Decoder.Decode"] },
{ "path": "github.com/foo/bar/internal" }
]
} }
] },
{ "id": "GHSA-cargo", "affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" } } ] }
]"#;
let mut syms = parse_osv_vuln_symbols(json).unwrap();
syms.sort_by(|a, b| {
(a.package.as_str(), a.symbol.as_str()).cmp(&(b.package.as_str(), b.symbol.as_str()))
});
assert_eq!(
syms.len(),
3,
"2 named symbols + 1 whole-package; the cargo record has no imports"
);
assert_eq!(
syms[0],
VulnSymbol {
id: "GO-2024-0001".into(),
ecosystem: "go".into(),
package: "github.com/foo/bar".into(),
symbol: "Decoder.Decode".into(),
severity: Severity::High,
}
);
assert_eq!(syms[1].symbol, "Parse", "exported function symbol kept");
assert_eq!(syms[2].package, "github.com/foo/bar/internal");
assert_eq!(
syms[2].symbol, "",
"an import with no `symbols` ⇒ whole-package record"
);
assert!(
syms.iter().all(|s| s.id == "GO-2024-0001"),
"the cargo record contributes no symbols"
);
}
#[test]
fn normalizes_osv_ecosystem_labels() {
assert_eq!(normalize_ecosystem("crates.io"), "cargo");
assert_eq!(normalize_ecosystem("PyPI"), "pypi");
assert_eq!(normalize_ecosystem("Maven:org.example"), "maven");
assert_eq!(normalize_ecosystem("RubyGems"), "gem");
}
#[test]
fn cvss_score_bucketed_when_no_label() {
let json = r#"{
"id": "CVE-2024-9",
"details": "buffer overflow",
"severity": [ { "type": "CVSS_V3", "score": "9.8" } ],
"affected": [ { "package": { "ecosystem": "crates.io", "name": "openssl" },
"versions": ["0.1.0"] } ]
}"#;
let advs = parse_osv(json).unwrap();
assert_eq!(advs[0].ecosystem, "cargo");
assert_eq!(advs[0].severity, Severity::Critical, "9.8 ⇒ Critical");
assert_eq!(advs[0].summary, "buffer overflow", "falls back to details");
}
/// The offline CVSS v3.1 base-score calculator, validated against FIRST's
/// published reference scores. Each `assert` is a real vector whose score is
/// documented — a wrong formula moves one of these off its known value.
#[test]
fn cvss_v3_base_matches_published_scores() {
let s = |v: &str| cvss_v3_base(v).unwrap();
// Log4Shell — scope-changed, all-High → the canonical 10.0.
assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H") - 10.0).abs() < 1e-9);
// Scope-unchanged all-High → 9.8.
assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - 9.8).abs() < 1e-9);
// Availability-only network RCE → 7.5.
assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") - 7.5).abs() < 1e-9);
// High privileges, all-High, unchanged → 7.2.
assert!((s("CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H") - 7.2).abs() < 1e-9);
// Local, low-priv, confidentiality-only → 5.5.
assert!((s("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N") - 5.5).abs() < 1e-9);
// A genuinely low one → 3.1.
assert!((s("CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N") - 3.1).abs() < 1e-9);
// No impact → 0.0.
assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N") - 0.0).abs() < 1e-9);
// A missing metric is unscoreable (None), not a wrong number.
assert!(cvss_v3_base("CVSS:3.1/AV:N/AC:L").is_none());
}
/// CVSS v2 base scores, validated against FIRST's published reference values.
/// A v2 vector has no `CVSS:` prefix and is detected by its `Au:` metric.
#[test]
fn cvss_v2_base_matches_published_scores() {
let s = |v: &str| cvss_base_score(v).unwrap();
assert!(
(s("AV:N/AC:L/Au:N/C:C/I:C/A:C") - 10.0).abs() < 1e-9,
"full network compromise"
);
assert!((s("AV:N/AC:L/Au:N/C:P/I:P/A:P") - 7.5).abs() < 1e-9);
assert!((s("AV:N/AC:M/Au:N/C:P/I:N/A:N") - 4.3).abs() < 1e-9);
assert!((s("AV:L/AC:H/Au:N/C:C/I:C/A:C") - 6.2).abs() < 1e-9);
assert!(
(s("AV:N/AC:L/Au:N/C:N/I:N/A:N") - 0.0).abs() < 1e-9,
"no impact ⇒ 0"
);
}
/// The whole path: a CVSS-**vector**-only OSV advisory (no numeric score, no
/// database_specific label) is now rated by its computed base score instead of
/// silently defaulting to Medium.
#[test]
fn vector_only_advisory_is_scored_not_defaulted_to_medium() {
let json = r#"{
"id": "CVE-2021-44228",
"details": "log4shell",
"severity": [ { "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" } ],
"affected": [ { "package": { "ecosystem": "Maven", "name": "log4j-core" },
"versions": ["2.14.1"] } ]
}"#;
let advs = parse_osv(json).unwrap();
assert_eq!(
advs[0].severity,
Severity::Critical,
"10.0 vector ⇒ Critical, not the Medium default"
);
}
#[test]
fn ranges_expand_to_exact_versions() {
let json = r#"{
"id": "RUSTSEC-2024-0003",
"summary": "panic in foo",
"affected": [
{ "package": { "ecosystem": "crates.io", "name": "foo" },
"ranges": [ { "type": "SEMVER", "events": [
{ "introduced": "1.0.0" }, { "fixed": "1.2.0" } ] } ] }
],
"database_specific": { "severity": "HIGH" }
}"#;
let ranges = parse_osv_ranges(json).unwrap();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].ecosystem, "cargo");
// Known versions of `foo` in the index.
let known = |_eco: &str, _pkg: &str| {
vec![
"0.9.0".into(),
"1.0.0".into(),
"1.1.0".into(),
"1.2.0".into(),
]
};
let advs = expand_advisories(&ranges, known);
assert_eq!(advs.len(), 1);
assert_eq!(advs[0].affected_versions, vec!["1.0.0", "1.1.0"]);
assert_eq!(advs[0].severity, Severity::High);
assert!(advs[0].matches(&ArtifactRef {
ecosystem: "cargo".into(),
name: "foo".into(),
version: "1.1.0".into(),
blob_sha256: "x".into(),
}));
}
#[test]
fn range_with_no_known_affected_version_yields_no_advisory() {
let json = r#"{
"id": "OSV-X",
"affected": [
{ "package": { "ecosystem": "npm", "name": "bar" },
"ranges": [ { "events": [ { "introduced": "2.0.0" } ] } ] }
]
}"#;
let ranges = parse_osv_ranges(json).unwrap();
let advs = expand_advisories(&ranges, |_, _| vec!["1.0.0".into(), "1.5.0".into()]);
assert!(advs.is_empty(), "no known version is >= 2.0.0");
}
#[test]
fn batch_flattens_multi_package_records() {
let json = r#"[
{ "id": "OSV-1",
"affected": [
{ "package": { "ecosystem": "npm", "name": "a" }, "versions": ["1.0.0"] },
{ "package": { "ecosystem": "npm", "name": "b" }, "versions": ["2.0.0"] }
],
"database_specific": { "severity": "LOW" } }
]"#;
let advs = parse_osv_batch(json).unwrap();
assert_eq!(advs.len(), 2);
assert_eq!(advs[0].package, "a");
assert_eq!(advs[1].package, "b");
assert!(advs.iter().all(|a| a.severity == Severity::Low));
}
// ── P1: range-native ingest + aliases + withdrawn + cross-feed dedup ─────
/// A range OSV record is now carried **range-native** — the advisory holds the
/// range events and matches by `affected_by` directly (no `known_versions`
/// pre-expansion), and the OSV `aliases` ride along.
#[test]
fn range_record_is_carried_range_native_with_aliases() {
use crate::core::ArtifactRef;
let json = r#"{
"id": "GHSA-xxxx",
"aliases": ["CVE-2026-0001"],
"affected": [
{ "package": { "ecosystem": "crates.io", "name": "widget" },
"ranges": [ { "type": "SEMVER", "events": [
{ "introduced": "1.0.0" }, { "fixed": "1.2.0" } ] } ] }
],
"database_specific": { "severity": "HIGH" } }"#;
let advs = parse_osv(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
assert_eq!(a.ecosystem, "cargo");
assert_eq!(
a.ranges,
vec![vec![
RangeEvent::Introduced("1.0.0".into()),
RangeEvent::Fixed("1.2.0".into())
]],
"the range object is carried whole, not pre-expanded"
);
assert!(
a.aliases.iter().any(|x| x == "CVE-2026-0001"),
"OSV aliases parsed"
);
assert_eq!(a.source, "osv");
let aref = |v: &str| ArtifactRef {
ecosystem: "cargo".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(
a.matches(&aref("1.1.0")),
"range-native match inside window"
);
assert!(!a.matches(&aref("1.2.0")), "fixed bound exclusive");
}
/// A withdrawn OSV record parses with `withdrawn = true` and never matches.
#[test]
fn withdrawn_osv_record_never_matches() {
use crate::core::ArtifactRef;
let json = r#"{
"id": "OSV-W",
"withdrawn": "2026-01-01T00:00:00Z",
"affected": [ { "package": { "ecosystem": "npm", "name": "x" }, "versions": ["1.0.0"] } ],
"database_specific": { "severity": "HIGH" } }"#;
let a = &parse_osv(json).unwrap()[0];
assert!(a.withdrawn);
let aref = ArtifactRef {
ecosystem: "npm".into(),
name: "x".into(),
version: "1.0.0".into(),
blob_sha256: String::new(),
};
assert!(!a.matches(&aref), "withdrawn advisory never matches");
}
/// `parse_and_merge` folds the SAME vuln arriving as two feeds (a CVE record
/// and a GHSA record that aliases it) for the same package into one advisory.
#[test]
fn parse_and_merge_dedups_same_vuln_across_feeds() {
let from_nvd = r#"{
"id": "CVE-2026-0001",
"affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" },
"ranges": [ { "events": [ { "introduced": "1.0.0" } ] } ] } ],
"database_specific": { "severity": "MODERATE" } }"#;
let from_ghsa = r#"{
"id": "GHSA-xxxx",
"aliases": ["CVE-2026-0001"],
"affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" },
"versions": ["1.1.0"] } ],
"database_specific": { "severity": "HIGH" } }"#;
let merged = parse_and_merge([from_nvd, from_ghsa]);
assert_eq!(
merged.len(),
1,
"the same vuln from two feeds folds to one row"
);
let a = &merged[0];
assert_eq!(a.severity, Severity::High, "max severity across feeds");
assert!(
!a.ranges.is_empty() && a.affected_versions.contains(&"1.1.0".to_string()),
"ranges + versions unioned across feeds"
);
}
/// An OSV record that carries a GIT range (commit hashes) beside the SEMVER
/// one. Flattening both into a single event list makes the commit hash parse as
/// version `0`, sort to the front, and its `fixed` event switch the advisory
/// OFF for every real version below the SEMVER fix — silencing the whole
/// advisory. The GIT range must be dropped and the SEMVER range kept intact.
#[test]
fn git_range_beside_semver_does_not_silence_the_advisory() {
use crate::core::ArtifactRef;
let json = r#"{
"id": "GHSA-git-semver",
"affected": [
{ "package": { "ecosystem": "crates.io", "name": "widget" },
"ranges": [
{ "type": "GIT", "repo": "https://example.invalid/w",
"events": [ { "introduced": "0" }, { "fixed": "6a3c1f2b9d4e" } ] },
{ "type": "SEMVER",
"events": [ { "introduced": "0" }, { "fixed": "1.0.0" } ] } ] }
],
"database_specific": { "severity": "HIGH" } }"#;
let advs = parse_osv(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
assert_eq!(
a.ranges.len(),
1,
"the GIT range is dropped, the SEMVER one kept"
);
let aref = |v: &str| ArtifactRef {
ecosystem: "cargo".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(a.matches(&aref("0.1.0")), "below the fix ⇒ affected");
assert!(a.matches(&aref("0.9.0")), "below the fix ⇒ affected");
assert!(!a.matches(&aref("1.0.0")), "the fix itself is clean");
}
/// Two SEMVER range objects (a maintained 1.x branch and a 2.x branch) are a
/// UNION. Splicing their events makes the 1.x `fixed` cancel the 2.x window.
#[test]
fn multi_branch_semver_ranges_stay_separate() {
use crate::core::ArtifactRef;
let json = r#"{
"id": "GHSA-two-branches",
"affected": [
{ "package": { "ecosystem": "crates.io", "name": "widget" },
"ranges": [
{ "type": "SEMVER",
"events": [ { "introduced": "0" }, { "fixed": "1.4.2" } ] },
{ "type": "SEMVER",
"events": [ { "introduced": "2.0.0" }, { "fixed": "2.1.3" } ] } ] }
],
"database_specific": { "severity": "HIGH" } }"#;
let a = &parse_osv(json).unwrap()[0];
assert_eq!(a.ranges.len(), 2, "one event list per range object");
let aref = |v: &str| ArtifactRef {
ecosystem: "cargo".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(a.matches(&aref("1.4.1")), "inside the 1.x window");
assert!(a.matches(&aref("2.1.0")), "inside the 2.x window");
assert!(!a.matches(&aref("1.5.0")), "between the windows");
assert!(!a.matches(&aref("2.1.3")), "the 2.x fix");
}
/// An entry whose ONLY ranges are GIT commit windows carries nothing we can
/// evaluate. It must be dropped, never emitted as an empty-ranges advisory —
/// that sentinel means "every version affected" and would flag the whole package.
#[test]
fn git_only_entry_is_dropped_not_turned_into_all_versions() {
let json = r#"{
"id": "OSV-git-only",
"affected": [
{ "package": { "ecosystem": "crates.io", "name": "widget" },
"ranges": [ { "type": "GIT", "repo": "https://example.invalid/w",
"events": [ { "introduced": "0" }, { "fixed": "6a3c1f2b9d4e" } ] } ] }
] }"#;
assert!(
parse_osv(json).unwrap().is_empty(),
"no evaluable version info ⇒ no advisory"
);
}
// ── M1: rustsec patched/unaffected → RangeEvent inversion ─────────────────
/// cargo-audit's rule, expressed with the real `semver` crate — the oracle the
/// range inversion must agree with. Mirrors nornir's `req_matches`, including
/// the pre-release-stripping so `>= 0.10` doesn't wrongly flag `0.11.0-rc`.
fn oracle_affected(patched: &[&str], unaffected: &[&str], v: &str) -> bool {
fn req_matches(reqs: &[&str], v: &semver::Version) -> bool {
let mut bare = v.clone();
bare.pre = semver::Prerelease::EMPTY;
reqs.iter().any(|r| {
semver::VersionReq::parse(r)
.map(|req| req.matches(v) || req.matches(&bare))
.unwrap_or(false)
})
}
let ver = semver::Version::parse(v).unwrap();
!req_matches(patched, &ver) && !req_matches(unaffected, &ver)
}
fn assert_matches_oracle(patched: &[&str], unaffected: &[&str], versions: &[&str]) {
let p: Vec<String> = patched.iter().map(|s| s.to_string()).collect();
let u: Vec<String> = unaffected.iter().map(|s| s.to_string()).collect();
let ranges = rustsec_affected_ranges(&p, &u);
for v in versions {
// Empty ranges ⇒ every version affected (Advisory::matches all-versions).
let got = ranges.is_empty() || crate::versions::affected_by("cargo", &ranges, v);
let want = oracle_affected(patched, unaffected, v);
assert_eq!(
got, want,
"patched={patched:?} unaffected={unaffected:?} v={v} ranges={ranges:?}"
);
}
}
#[test]
fn rustsec_single_patched_branch() {
assert_matches_oracle(
&[">= 1.2.0"],
&[],
&["0.1.0", "1.1.9", "1.2.0", "1.2.1", "2.0.0"],
);
}
#[test]
fn rustsec_patched_plus_unaffected_lower() {
assert_matches_oracle(
&[">= 1.2.0"],
&["< 0.5.0"],
&["0.1.0", "0.5.0", "0.9.0", "1.1.0", "1.2.0", "3.0.0"],
);
}
#[test]
fn rustsec_multi_branch_unpatched_between_ranges() {
// The aws-lc-style case: two maintained branches, affected in the gap.
assert_matches_oracle(
&[">= 1.2.4, < 1.3.0", ">= 1.3.2"],
&[],
&[
"1.2.3", "1.2.4", "1.2.9", "1.3.0", "1.3.1", "1.3.2", "1.4.0",
],
);
}
#[test]
fn rustsec_no_patched_all_affected() {
assert!(
rustsec_affected_ranges(&[], &[]).is_empty(),
"no patched + no unaffected ⇒ all versions affected"
);
assert_matches_oracle(&[], &[], &["0.0.1", "1.0.0", "9.9.9"]);
}
#[test]
fn rustsec_no_patched_but_unaffected_lower() {
assert_matches_oracle(&[], &["< 0.5.0"], &["0.1.0", "0.5.0", "1.0.0"]);
}
#[test]
fn rustsec_exact_unaffected_carveout() {
// `= 1.0.0` unaffected carves a single safe point; we tolerate over-flag at
// exactly that point (never under-flag), so the battery avoids 1.0.0 itself.
assert_matches_oracle(&[">= 1.1.0"], &["= 1.0.0"], &["0.9.0", "1.0.5", "1.1.0"]);
}
#[cfg(feature = "rustsec")]
#[test]
fn parse_rustsec_md_real_advisory() {
let md = "```toml\n\
[advisory]\n\
id = \"RUSTSEC-2021-0001\"\n\
package = \"widget\"\n\
title = \"Use-after-free in widget\"\n\
aliases = [\"CVE-2021-1234\"]\n\
cvss = \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"\n\
\n\
[versions]\n\
patched = [\">= 1.2.0\"]\n\
```\n\n# Use-after-free\n";
let a = parse_rustsec_md(md).expect("parses");
assert_eq!(a.id, "RUSTSEC-2021-0001");
assert_eq!(a.ecosystem, "cargo");
assert_eq!(a.package, "widget");
// The vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H computes to 9.8 —
// now scored offline (was defaulted to High before the CVSS v3 calculator).
assert_eq!(
a.severity,
Severity::Critical,
"9.8 vector ⇒ Critical, computed not defaulted"
);
assert_eq!(a.source, "rustsec");
assert!(a.aliases.iter().any(|x| x == "CVE-2021-1234"));
let aref = |v: &str| ArtifactRef {
ecosystem: "cargo".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(a.matches(&aref("1.1.0")), "below patched ⇒ affected");
assert!(!a.matches(&aref("1.2.0")), "patched ⇒ not affected");
}
#[cfg(feature = "rustsec")]
#[test]
fn parse_rustsec_md_informational_warns() {
let md = "```toml\n\
[advisory]\n\
id = \"RUSTSEC-2020-0099\"\n\
package = \"oldcrate\"\n\
title = \"oldcrate is unmaintained\"\n\
informational = \"unmaintained\"\n\
\n\
[versions]\n\
patched = []\n\
```\nbody\n";
let a = parse_rustsec_md(md).expect("parses");
assert_eq!(
a.severity,
Severity::Low,
"informational ⇒ Low (Warn), not Block"
);
let aref = ArtifactRef {
ecosystem: "cargo".into(),
name: "oldcrate".into(),
version: "0.3.0".into(),
blob_sha256: String::new(),
};
assert!(a.matches(&aref), "no patched ⇒ all versions affected");
}
}