Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

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);
    }
}