src/vex.rs
//! CSAF **VEX** import (feature `scan`) — consumer "not affected" suppressions.
//!
//! A VEX (Vulnerability-Exploitability eXchange) document is the consumer's
//! assertion that a matched CVE does **not** actually apply (e.g. the vulnerable
//! path is unreachable). [`parse_csaf_vex`] pulls the `known_not_affected` CVE ids
//! out of a CSAF VEX JSON; a scan then suppresses findings carrying those ids
//! (`scan::Policy::vex_not_affected` on the per-artifact gate path, or the fleet
//! scan filtering by id). This is the field's standard way to kill
//! unreachable-CVE noise without pretending the dependency isn't present.
use std::collections::HashSet;
use serde::Deserialize;
#[derive(Deserialize, Default)]
struct Csaf {
#[serde(default)]
vulnerabilities: Vec<CsafVuln>,
}
#[derive(Deserialize)]
struct CsafVuln {
/// CSAF puts the CVE in `cve`; a producer that tracks the vuln under other ids
/// (or has no CVE assigned) uses the generic `ids[]` array. Both are read so a
/// VEX using only `ids[]` still suppresses (was silently dropped — an
/// under-suppression that left an unreachable-CVE false positive standing).
#[serde(default)]
cve: String,
#[serde(default)]
ids: Vec<CsafId>,
#[serde(default)]
product_status: ProductStatus,
}
/// One CSAF tracking id — `{ system_name, text }`; `text` is the id string.
#[derive(Deserialize, Default)]
struct CsafId {
#[serde(default)]
text: String,
}
#[derive(Deserialize, Default)]
struct ProductStatus {
/// Products the issuer asserts ARE affected. Read so an id claimed affected can
/// never be suppressed by a not-affected claim elsewhere (see [`vex_statuses`]).
#[serde(default)]
known_affected: Vec<String>,
/// Products the issuer asserts are NOT affected — the VEX suppression signal.
#[serde(default)]
known_not_affected: Vec<String>,
}
/// The `(not_affected_ids, affected_ids)` a CSAF doc asserts. Each vuln contributes
/// its ids (the `cve` field AND every `ids[].text`) to `not_affected` when its
/// `known_not_affected` list is non-empty, and to `affected` when its
/// `known_affected` list is non-empty — a vuln that carries BOTH (not-affected for
/// one product, affected for another) contributes to both.
///
/// This split is the fix for a dangerous over-suppression: VEX statuses are
/// **product-scoped**, but modgunn suppresses by CVE id alone (it does not resolve
/// CSAF product ids against the SBOM). So a CVE marked not-affected for product A
/// and affected for product B would otherwise be suppressed GLOBALLY, silently
/// dropping the real finding on B (a false negative). The safe rule: an id claimed
/// affected ANYWHERE is never suppressed — [`parse_csaf_vex`] /
/// [`collect_suppressions`] subtract `affected` from `not_affected`. The cost is
/// only lost precision (a genuinely-not-affected A may keep a benign finding); the
/// fail direction is never "hide a real vuln".
fn vex_statuses(doc: &Csaf) -> (Vec<String>, HashSet<String>) {
let (mut not_affected, mut affected) = (Vec::new(), HashSet::new());
for v in &doc.vulnerabilities {
let ids = || {
std::iter::once(v.cve.trim())
.chain(v.ids.iter().map(|i| i.text.trim()))
.filter(|s| !s.is_empty())
.map(String::from)
};
if !v.product_status.known_affected.is_empty() {
affected.extend(ids());
}
if !v.product_status.known_not_affected.is_empty() {
not_affected.extend(ids());
}
}
(not_affected, affected)
}
/// The vulnerability ids a CSAF VEX document asserts as **not affected** (non-empty
/// `known_not_affected` product list) — from the `cve` field AND the `ids[].text`
/// entries, so a suppression keyed to either survives (a scan drops an advisory
/// whose id/alias is in this set; a non-matching tracking id is simply inert). An id
/// the SAME document also marks `known_affected` is EXCLUDED (never suppressed — see
/// [`vex_statuses`]). Tolerant of the many optional CSAF fields.
pub fn parse_csaf_vex(json: &str) -> Result<Vec<String>, serde_json::Error> {
let doc: Csaf = serde_json::from_str(json)?;
let (not_affected, affected) = vex_statuses(&doc);
Ok(not_affected
.into_iter()
.filter(|id| !affected.contains(id))
.collect())
}
/// Collect the suppressed-id set across a set of VEX documents (malformed docs
/// skipped — a bad VEX file never sinks the set). An id marked `known_affected` in
/// ANY document is removed from the union — even if a *different* document marked it
/// not-affected — so cross-document product scoping can't cause an over-suppression.
pub fn collect_suppressions<I, S>(docs: I) -> HashSet<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let (mut not_affected, mut affected): (HashSet<String>, HashSet<String>) = Default::default();
for d in docs {
if let Ok(doc) = serde_json::from_str::<Csaf>(d.as_ref()) {
let (na, a) = vex_statuses(&doc);
not_affected.extend(na);
affected.extend(a);
}
}
not_affected.retain(|id| !affected.contains(id));
not_affected
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_not_affected_cves() {
let json = r#"{ "vulnerabilities": [
{ "cve": "CVE-2021-44228",
"product_status": { "known_not_affected": ["CSAFPID-0001"] } },
{ "cve": "CVE-2022-0001",
"product_status": { "known_affected": ["CSAFPID-0002"] } },
{ "cve": "CVE-2023-9999",
"product_status": { "known_not_affected": [] } }
] }"#;
let ids = parse_csaf_vex(json).unwrap();
assert_eq!(
ids,
vec!["CVE-2021-44228"],
"only not-affected with a product is suppressed"
);
}
#[test]
fn parses_ids_array_form_not_just_cve() {
// A VEX that tracks the vuln under `ids[]` (no `cve` field) must still
// suppress — previously its suppression was silently dropped.
let json = r#"{ "vulnerabilities": [
{ "ids": [ { "system_name": "GitHub", "text": "GHSA-abcd-1234" },
{ "system_name": "CVE", "text": "CVE-2024-5555" } ],
"product_status": { "known_not_affected": ["CSAFPID-1"] } },
{ "cve": "CVE-2024-0001",
"ids": [ { "system_name": "Vendor", "text": "VENDOR-42" } ],
"product_status": { "known_not_affected": ["CSAFPID-2"] } }
] }"#;
let ids = parse_csaf_vex(json).unwrap();
assert!(ids.contains(&"GHSA-abcd-1234".to_string()));
assert!(ids.contains(&"CVE-2024-5555".to_string()));
assert!(
ids.contains(&"CVE-2024-0001".to_string()),
"the cve field too"
);
assert!(
ids.contains(&"VENDOR-42".to_string()),
"and its other tracking id"
);
}
/// RED-when-reverted: a multi-product CSAF that marks a CVE `known_not_affected`
/// for one product AND `known_affected` for another must NOT suppress that CVE —
/// otherwise the real finding on the affected product is silently dropped (a
/// dangerous false negative). VEX statuses are product-scoped; id-only
/// suppression must let an "affected" claim win.
#[test]
fn affected_claim_blocks_suppression_same_document() {
let json = r#"{ "vulnerabilities": [
{ "cve": "CVE-2021-44228",
"product_status": {
"known_affected": ["CSAFPID-serverB"],
"known_not_affected": ["CSAFPID-clientA"] } }
] }"#;
let ids = parse_csaf_vex(json).unwrap();
assert!(
ids.is_empty(),
"a CVE affected for any product is never globally suppressed: {ids:?}"
);
assert!(!collect_suppressions([json]).contains("CVE-2021-44228"));
}
/// Cross-document: doc A says not-affected, doc B (a different product) says
/// affected — the union must still NOT suppress it.
#[test]
fn affected_claim_blocks_suppression_across_documents() {
let not_aff = r#"{ "vulnerabilities": [ { "cve": "CVE-X", "product_status": { "known_not_affected": ["A"] } } ] }"#;
let aff = r#"{ "vulnerabilities": [ { "cve": "CVE-X", "product_status": { "known_affected": ["B"] } } ] }"#;
let set = collect_suppressions([not_aff, aff]);
assert!(
!set.contains("CVE-X"),
"an affected claim in any doc blocks the cross-doc suppression"
);
// Sanity: a genuinely-only-not-affected CVE is still suppressed.
assert!(collect_suppressions([not_aff]).contains("CVE-X"));
}
#[test]
fn collect_unions_and_skips_malformed() {
let a = r#"{ "vulnerabilities": [ { "cve": "CVE-1", "product_status": { "known_not_affected": ["P"] } } ] }"#;
let bad = r#"{ not json"#;
let set = collect_suppressions([a, bad]);
assert!(set.contains("CVE-1"));
assert_eq!(set.len(), 1);
}
}