src/nvd.rs
//! NVD CVE JSON 2.0 import (feature `scan`) — **CVSS + CWE enrichment**.
//!
//! NVD is product-centric (CPE), not package-manager-centric, so it is an
//! *enrichment* source keyed by CVE id — **not** the package-match authority (OSV
//! is). [`parse_nvd`] extracts each CVE's severity / CVSS vector / CWE ids / English
//! description; [`enrich_with_nvd`] folds them onto advisories matched by id OR
//! alias, never changing which versions match. CPE → `(ecosystem, package)`
//! backfill (NVD producing standalone package advisories) is a deferred follow-up
//! — it is lossy and OSV already covers the package surface.
use std::collections::HashMap;
use serde::Deserialize;
use crate::core::Severity;
use crate::scan::Advisory;
/// The enrichment facts NVD knows about one CVE.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NvdEntry {
pub id: String,
/// `None` when NVD carries no scored metric for the CVE (rejected/awaiting).
pub severity: Option<Severity>,
pub cvss_vector: String,
pub cwes: Vec<String>,
pub summary: String,
}
#[derive(Deserialize, Default)]
struct NvdFeed {
#[serde(default)]
vulnerabilities: Vec<NvdVuln>,
}
#[derive(Deserialize)]
struct NvdVuln {
cve: NvdCve,
}
#[derive(Deserialize)]
struct NvdCve {
id: String,
#[serde(default)]
descriptions: Vec<LangValue>,
#[serde(default)]
metrics: NvdMetrics,
#[serde(default)]
weaknesses: Vec<NvdWeakness>,
#[serde(default)]
configurations: Vec<Configuration>,
}
#[derive(Deserialize, Default)]
struct Configuration {
#[serde(default)]
nodes: Vec<CpeNode>,
}
#[derive(Deserialize, Default)]
struct CpeNode {
#[serde(default, rename = "cpeMatch")]
cpe_match: Vec<CpeMatch>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CpeMatch {
#[serde(default)]
vulnerable: bool,
#[serde(default)]
criteria: String,
#[serde(default)]
version_start_including: Option<String>,
#[serde(default)]
version_start_excluding: Option<String>,
#[serde(default)]
version_end_including: Option<String>,
#[serde(default)]
version_end_excluding: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct NvdMetrics {
#[serde(default)]
cvss_metric_v31: Vec<NvdMetric>,
#[serde(default)]
cvss_metric_v30: Vec<NvdMetric>,
#[serde(default)]
cvss_metric_v2: Vec<NvdMetric>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct NvdMetric {
#[serde(default)]
cvss_data: CvssData,
/// CVSS v2 carries the severity label here (v3 puts it inside `cvssData`).
#[serde(default)]
base_severity: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct CvssData {
#[serde(default)]
base_score: Option<f64>,
#[serde(default)]
base_severity: Option<String>,
#[serde(default)]
vector_string: String,
}
#[derive(Deserialize)]
struct LangValue {
lang: String,
value: String,
}
#[derive(Deserialize)]
struct NvdWeakness {
#[serde(default)]
description: Vec<LangValue>,
}
fn sev_from_label(s: &str) -> Option<Severity> {
match s.to_ascii_uppercase().as_str() {
"CRITICAL" => Some(Severity::Critical),
"HIGH" => Some(Severity::High),
"MEDIUM" | "MODERATE" => Some(Severity::Medium),
"LOW" => Some(Severity::Low),
_ => None,
}
}
fn sev_from_score(s: f64) -> Severity {
match s {
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,
}
}
fn entry_of(cve: &NvdCve) -> NvdEntry {
// Prefer CVSS v3.1, then v3.0, then v2.
let metric = cve
.metrics
.cvss_metric_v31
.first()
.or_else(|| cve.metrics.cvss_metric_v30.first())
.or_else(|| cve.metrics.cvss_metric_v2.first());
let (cvss_vector, severity) = match metric {
Some(m) => {
let label = m
.cvss_data
.base_severity
.as_deref()
.or(m.base_severity.as_deref());
let sev = label
.and_then(sev_from_label)
.or_else(|| m.cvss_data.base_score.map(sev_from_score));
(m.cvss_data.vector_string.clone(), sev)
}
None => (String::new(), None),
};
let cwes = cve
.weaknesses
.iter()
.flat_map(|w| &w.description)
.filter(|d| d.lang == "en")
.map(|d| d.value.clone())
.filter(|v| v.starts_with("CWE-"))
.collect();
let summary = cve
.descriptions
.iter()
.find(|d| d.lang == "en")
.map(|d| d.value.clone())
.unwrap_or_default();
NvdEntry {
id: cve.id.clone(),
severity,
cvss_vector,
cwes,
summary,
}
}
/// Parse an NVD CVE JSON 2.0 feed (`{"vulnerabilities":[{"cve":{…}}]}`) into the
/// per-CVE enrichment entries.
pub fn parse_nvd(json: &str) -> Result<Vec<NvdEntry>, serde_json::Error> {
let feed: NvdFeed = serde_json::from_str(json)?;
Ok(feed
.vulnerabilities
.iter()
.map(|v| entry_of(&v.cve))
.collect())
}
/// The CPE 2.3 product (field 4) of an application (`part = a`) CPE criteria,
/// else `None` — so OS/hardware CPEs don't become package advisories.
fn cpe_app_product(criteria: &str) -> Option<String> {
let parts: Vec<&str> = criteria.split(':').collect();
// cpe:2.3:part:vendor:product:version:...
if parts.len() > 4 && parts.first() == Some(&"cpe") && parts.get(2) == Some(&"a") {
let product = parts[4];
if !product.is_empty() && product != "*" && product != "-" {
return Some(product.to_string());
}
}
None
}
/// The CPE 2.3 **version** (field 5), when it names a concrete version rather than
/// the `*` / `-` (ANY / N/A) wildcards NVD uses for a range-bearing match. This is
/// the *enumerated* form — NVD lists an individual affected version as its own
/// `cpe_match` with the version in field 5 and NO `versionStart/End` attributes.
fn cpe_version(criteria: &str) -> Option<String> {
let parts: Vec<&str> = criteria.split(':').collect();
// cpe:2.3:part:vendor:product:version:...
let version = *parts.get(5)?;
if !version.is_empty() && version != "*" && version != "-" {
Some(version.to_string())
} else {
None
}
}
/// **CPE → package backfill** (the lossy path the master-plan flags): emit a
/// standalone [`Advisory`] for each vulnerable application CPE in an NVD record —
/// for CVEs that OSV doesn't cover. The package is the CPE product; version
/// bounds (`versionStart*`/`versionEnd*`) become range events, while the
/// **enumerated** form (a concrete version in the CPE string, no bounds) becomes an
/// exact `affected_versions` entry — never an empty match (which would flag the
/// whole product). Tagged
/// `ecosystem = "cpe"` (a SEPARATE namespace) and `source = "nvd"` so these never
/// false-match an OSV package; a consumer that knows the CPE↔package-manager
/// mapping can resolve them. CVSS/CWE/severity ride along from [`entry_of`].
pub fn parse_nvd_advisories(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
let feed: NvdFeed = serde_json::from_str(json)?;
let mut out = Vec::new();
for v in &feed.vulnerabilities {
let cve = &v.cve;
let e = entry_of(cve);
for cfg in &cve.configurations {
for node in &cfg.nodes {
for m in &node.cpe_match {
if !m.vulnerable {
continue;
}
let Some(product) = cpe_app_product(&m.criteria) else {
continue;
};
let mut ranges = Vec::new();
if let Some(v) = &m.version_start_including {
ranges.push(crate::versions::RangeEvent::Introduced(v.clone()));
} else if let Some(v) = &m.version_start_excluding {
// `versionStartExcluding` = "affected versions are STRICTLY
// GREATER than this" — the bound itself is explicitly NOT
// affected. Folding it onto `Introduced` (an inclusive lower
// bound) flags the boundary version as vulnerable: a false
// positive on the exact version the vendor declared clean.
ranges.push(crate::versions::RangeEvent::IntroducedExclusive(v.clone()));
}
// An upper bound alone (`versionEnd*` with no `versionStart*`) means
// "every version below the fix" — the common NVD shape. Anchor it with
// an implicit `Introduced("0")` so the range actually opens: without a
// leading `Introduced`, `versions::affected_by` never turns the
// affected-state on and the advisory silently matches NOTHING (a
// false negative). OSV requires ranges to start with `introduced`; the
// CPE backfill must uphold the same invariant it builds ranges by hand.
let has_end =
m.version_end_excluding.is_some() || m.version_end_including.is_some();
if has_end && ranges.is_empty() {
ranges.push(crate::versions::RangeEvent::Introduced("0".into()));
}
if let Some(v) = &m.version_end_excluding {
ranges.push(crate::versions::RangeEvent::Fixed(v.clone()));
} else if let Some(v) = &m.version_end_including {
ranges.push(crate::versions::RangeEvent::LastAffected(v.clone()));
}
// ENUMERATED form: no version bounds at all, but the CPE names a
// concrete version in field 5 → this match affects EXACTLY that
// version. Without this it would land an advisory with empty
// ranges AND empty affected_versions, which `Advisory::matches`
// reads as "every version of the product is affected" — a false
// positive flagging the whole package when only one version is
// vulnerable. A bounded match (field 5 = `*` + versionStart/End)
// keeps ranges; a genuinely-all-versions match (`*`, no bounds)
// correctly stays empty.
let exact = if ranges.is_empty() {
cpe_version(&m.criteria).into_iter().collect()
} else {
Vec::new()
};
let mut adv = Advisory::basic(
cve.id.clone(),
"cpe",
product,
exact,
e.severity.unwrap_or(crate::core::Severity::Medium),
e.summary.clone(),
)
.with_source("nvd")
.with_ranges(ranges);
adv.cvss_vector = e.cvss_vector.clone();
adv.cwe = e.cwes.clone();
out.push(adv);
}
}
}
}
Ok(crate::scan::merge_by_alias(out))
}
/// Fold NVD facts onto `advisories` matched by id OR alias: set the CVSS vector +
/// CWE ids when the advisory lacks them, and lift severity to NVD's when NVD rates
/// it stronger (NVD is the CVSS authority). Never changes which versions match —
/// OSV stays the package-match authority.
pub fn enrich_with_nvd(advisories: &mut [Advisory], entries: &[NvdEntry]) {
let by_id: HashMap<&str, &NvdEntry> = entries.iter().map(|e| (e.id.as_str(), e)).collect();
for a in advisories.iter_mut() {
let hit = std::iter::once(a.id.as_str())
.chain(a.aliases.iter().map(|s| s.as_str()))
.find_map(|i| by_id.get(i).copied());
if let Some(e) = hit {
if a.cvss_vector.is_empty() {
a.cvss_vector = e.cvss_vector.clone();
}
for c in &e.cwes {
if !a.cwe.contains(c) {
a.cwe.push(c.clone());
}
}
if let Some(s) = e.severity {
if s > a.severity {
a.severity = s;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2021-44228",
"descriptions": [ { "lang": "en", "value": "Log4Shell JNDI RCE" } ],
"metrics": { "cvssMetricV31": [ { "cvssData": {
"baseScore": 10.0, "baseSeverity": "CRITICAL",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" } } ] },
"weaknesses": [ { "description": [ { "lang": "en", "value": "CWE-502" } ] } ]
} } ] }"#;
#[test]
fn parses_severity_vector_and_cwe() {
let entries = parse_nvd(SAMPLE).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.id, "CVE-2021-44228");
assert_eq!(e.severity, Some(Severity::Critical));
assert!(e.cvss_vector.starts_with("CVSS:3.1/"));
assert_eq!(e.cwes, vec!["CWE-502"]);
assert!(e.summary.contains("Log4Shell"));
}
#[test]
fn cvss_v2_severity_label_at_metric_level() {
let json = r#"{ "vulnerabilities": [ { "cve": { "id": "CVE-2014-0001",
"metrics": { "cvssMetricV2": [
{ "cvssData": { "baseScore": 7.5, "vectorString": "AV:N/AC:L/Au:N/C:P/I:P/A:P" },
"baseSeverity": "HIGH" } ] } } } ] }"#;
let e = &parse_nvd(json).unwrap()[0];
assert_eq!(e.severity, Some(Severity::High));
assert_eq!(e.cvss_vector, "AV:N/AC:L/Au:N/C:P/I:P/A:P");
}
#[test]
fn cpe_backfill_emits_ranged_advisory() {
use crate::core::ArtifactRef;
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-0001",
"metrics": { "cvssMetricV31": [ { "cvssData": {
"baseScore": 9.8, "baseSeverity": "CRITICAL", "vectorString": "CVSS:3.1/x" } } ] },
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*",
"versionStartIncluding": "2.0", "versionEndExcluding": "2.15.0" },
{ "vulnerable": false, "criteria": "cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*" }
] } ] } ]
} } ] }"#;
let advs = parse_nvd_advisories(json).unwrap();
assert_eq!(
advs.len(),
1,
"only the vulnerable APPLICATION cpe (not the OS one)"
);
let a = &advs[0];
assert_eq!(a.id, "CVE-2099-0001");
assert_eq!(
a.ecosystem, "cpe",
"CPE-keyed namespace, not an OSV ecosystem"
);
assert_eq!(a.package, "log4j");
assert_eq!(a.severity, Severity::Critical);
assert_eq!(a.source, "nvd");
assert_eq!(a.ranges.len(), 1, "one hand-built range object");
assert_eq!(a.ranges[0].len(), 2, "an Introduced + a Fixed event");
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "log4j".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(a.matches(&art("2.14.1")), "inside [2.0, 2.15.0)");
assert!(!a.matches(&art("2.15.0")), "fixed bound exclusive");
}
/// RED-when-broken: `versionStartExcluding` is an EXCLUSIVE lower bound —
/// "affected versions are strictly GREATER than this" — so the boundary version
/// itself must be reported CLEAN. Mapping it onto `RangeEvent::Introduced` (an
/// *inclusive* lower bound, the old behaviour) flags the boundary as vulnerable:
/// a false positive on the exact version the vendor declared unaffected. Under
/// the old mapping `matches(&art("1.0"))` is `true` and this test fails.
#[test]
fn cpe_backfill_version_start_excluding_does_not_flag_the_boundary() {
use crate::core::ArtifactRef;
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-0003",
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
"versionStartExcluding": "1.0", "versionEndExcluding": "2.0" }
] } ] } ]
} } ] }"#;
let advs = parse_nvd_advisories(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
assert_eq!(
a.ranges[0][0],
crate::versions::RangeEvent::IntroducedExclusive("1.0".into()),
"an exclusive lower bound must NOT degrade to an inclusive `Introduced`"
);
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
// The boundary itself: NOT affected. This is the false positive being fixed.
assert!(
!a.matches(&art("1.0")),
"versionStartExcluding=1.0 leaves 1.0 itself clean"
);
// Just above the boundary: affected.
assert!(
a.matches(&art("1.0.1")),
"strictly above the exclusive lower bound is affected"
);
assert!(a.matches(&art("1.9")), "well inside the window is affected");
// Below the boundary and at/above the fix stay clean.
assert!(!a.matches(&art("0.9")), "below the lower bound is clean");
assert!(!a.matches(&art("2.0")), "the fixed bound stays exclusive");
}
/// `versionStartExcluding` with no upper bound: affected from just above the
/// bound to +∞, and still never the bound itself.
#[test]
fn cpe_backfill_version_start_excluding_open_ended() {
use crate::core::ArtifactRef;
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-0004",
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
"versionStartExcluding": "3.1" }
] } ] } ]
} } ] }"#;
let a = &parse_nvd_advisories(json).unwrap()[0];
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(
!a.matches(&art("3.1")),
"the exclusive bound itself is clean"
);
assert!(a.matches(&art("3.1.1")), "strictly above is affected");
assert!(a.matches(&art("9.9")), "open-ended above the bound");
assert!(!a.matches(&art("3.0")), "below the bound is clean");
}
/// RED-when-broken: a very common NVD CPE shape — a `versionEndExcluding` upper
/// bound with NO `versionStart*` lower bound, i.e. "every version before the fix"
/// — MUST match the versions below the fix. Without an anchoring `Introduced`
/// event the backfilled range never opens and `versions::affected_by` returns
/// `false` for every version: the advisory silently matches NOTHING (a
/// false-negative — the worst failure for a security scanner). This pins that a
/// lower-bound-less range is affected from 0 up to (exclusive) the fix.
#[test]
fn cpe_backfill_unbounded_below_range_is_affected_from_zero() {
use crate::core::ArtifactRef;
// Log4Shell as NVD often ships it: a single cpeMatch with only an upper bound.
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2021-44228",
"metrics": { "cvssMetricV31": [ { "cvssData": {
"baseScore": 10.0, "baseSeverity": "CRITICAL", "vectorString": "CVSS:3.1/x" } } ] },
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.15.0" }
] } ] } ]
} } ] }"#;
let advs = parse_nvd_advisories(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "log4j".into(),
version: v.into(),
blob_sha256: String::new(),
};
// Everything below the fix is affected (the whole point of the advisory).
assert!(
a.matches(&art("2.0.0")),
"an unbounded-below CPE range must match versions below the fix"
);
assert!(
a.matches(&art("2.14.1")),
"just below the fix is still affected"
);
assert!(
a.matches(&art("0.0.1")),
"affected from the beginning of time"
);
// The exclusive fix and above are clean.
assert!(!a.matches(&art("2.15.0")), "the fixed bound is exclusive");
assert!(!a.matches(&art("2.17.0")), "above the fix is clean");
}
/// The `versionEndIncluding` variant (inclusive upper bound, no lower bound):
/// affected from 0 through and including the last-affected version.
#[test]
fn cpe_backfill_unbounded_below_last_affected_is_inclusive() {
use crate::core::ArtifactRef;
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-0002",
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*",
"versionEndIncluding": "3.2.1" }
] } ] } ]
} } ] }"#;
let advs = parse_nvd_advisories(json).unwrap();
assert_eq!(advs.len(), 1);
let a = &advs[0];
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(
a.matches(&art("1.0.0")),
"below the last-affected bound is affected"
);
assert!(
a.matches(&art("3.2.1")),
"the last-affected bound is inclusive"
);
assert!(
!a.matches(&art("3.2.2")),
"above the last-affected bound is clean"
);
}
/// RED-when-broken: the **enumerated** NVD CPE form — a `cpeMatch` whose version
/// lives in the CPE string (field 5) with NO `versionStart/End` attributes —
/// affects EXACTLY that version. Before the fix it produced an advisory with
/// empty ranges AND empty affected_versions, which `Advisory::matches` reads as
/// "every version of the product is affected": a false positive flagging the
/// whole package (e.g. all of `log4j`) when only `2.14.1` is listed.
#[test]
fn cpe_backfill_enumerated_version_matches_only_that_version() {
use crate::core::ArtifactRef;
// Two enumerated affected versions, each its own cpeMatch (the NVD shape),
// plus one non-vulnerable version — merge_by_alias unions the two.
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-ENUM",
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*" },
{ "vulnerable": true, "criteria": "cpe:2.3:a:apache:log4j:2.13.0:*:*:*:*:*:*:*" },
{ "vulnerable": false, "criteria": "cpe:2.3:a:apache:log4j:2.15.0:*:*:*:*:*:*:*" }
] } ] } ]
} } ] }"#;
let advs = parse_nvd_advisories(json).unwrap();
assert_eq!(
advs.len(),
1,
"the two enumerated matches merge into one advisory"
);
let a = &advs[0];
assert!(a.ranges.is_empty(), "enumerated form carries no range");
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "log4j".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(
a.matches(&art("2.14.1")),
"the enumerated version is affected"
);
assert!(
a.matches(&art("2.13.0")),
"the other enumerated version is affected"
);
assert!(
!a.matches(&art("2.17.0")),
"an UNLISTED version must NOT match (no whole-product FP)"
);
assert!(
!a.matches(&art("1.0.0")),
"an older unlisted version must NOT match either"
);
}
/// A genuinely all-versions CPE (`*` in field 5, NO bounds) correctly stays a
/// whole-product advisory — the enumerated-version fix must not break the real
/// "every version affected" case.
#[test]
fn cpe_backfill_wildcard_no_bounds_is_still_all_versions() {
use crate::core::ArtifactRef;
let json = r#"{ "vulnerabilities": [ { "cve": {
"id": "CVE-2099-ALL",
"configurations": [ { "nodes": [ { "cpeMatch": [
{ "vulnerable": true, "criteria": "cpe:2.3:a:acme:widget:*:*:*:*:*:*:*:*" }
] } ] } ]
} } ] }"#;
let a = &parse_nvd_advisories(json).unwrap()[0];
assert!(
a.affected_versions.is_empty() && a.ranges.is_empty(),
"all-versions sentinel"
);
let art = |v: &str| ArtifactRef {
ecosystem: "cpe".into(),
name: "widget".into(),
version: v.into(),
blob_sha256: String::new(),
};
assert!(
a.matches(&art("1.0.0")) && a.matches(&art("9.9.9")),
"every version affected"
);
}
#[test]
fn enrich_matches_by_alias_and_lifts_severity() {
// A GHSA advisory (Medium, no CVSS) aliasing the CVE NVD scored Critical.
let mut advs = vec![Advisory::basic(
"GHSA-jfh8-c2jp-5v3q",
"maven",
"log4j-core",
vec!["2.14.1".into()],
Severity::Medium,
"log4shell",
)
.with_aliases(["CVE-2021-44228"])];
let entries = parse_nvd(SAMPLE).unwrap();
enrich_with_nvd(&mut advs, &entries);
assert_eq!(
advs[0].severity,
Severity::Critical,
"lifted to NVD severity via alias"
);
assert!(
advs[0].cvss_vector.starts_with("CVSS:3.1/"),
"CVSS vector folded in"
);
assert_eq!(advs[0].cwe, vec!["CWE-502"], "CWE folded in");
}
}