bitvex 0.2.5

Automate CRA compliance: generate OpenVEX reports from Yocto SBOMs by filtering CVEs with kernel config and device tree analysis
Documentation
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum VexStatus {
    NotAffected,
    Affected,
    Fixed,
    UnderInvestigation,
}

impl VexStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            VexStatus::NotAffected => "not_affected",
            VexStatus::Affected => "affected",
            VexStatus::Fixed => "fixed",
            VexStatus::UnderInvestigation => "under_investigation",
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct VexStatement {
    pub vulnerability_name: String,
    pub product_purl: String,
    pub status: VexStatus,
    pub justification: Option<String>,
    pub impact_statement: Option<String>,
}

#[derive(Debug, Serialize)]
struct OpenVexProduct {
    #[serde(rename = "@id")]
    id: String,
}

#[derive(Debug, Serialize)]
struct OpenVexVulnerability {
    name: String,
}

#[derive(Debug, Serialize)]
struct OpenVexStatement {
    vulnerability: OpenVexVulnerability,
    products: Vec<OpenVexProduct>,
    status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    justification: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    impact_statement: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct VexDocument {
    #[serde(rename = "@context")]
    context: String,
    #[serde(rename = "@id")]
    id: String,
    author: String,
    role: String,
    timestamp: String,
    version: u32,
    tooling: String,
    statements: Vec<OpenVexStatement>,
}

pub fn generate_openvex(statements: &[VexStatement], author: &str) -> VexDocument {
    let uuid = Uuid::new_v4();
    let doc_id = format!("https://openvex.dev/docs/bitvex/vex-{}", uuid.as_simple());

    let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);

    let openvex_statements: Vec<OpenVexStatement> = statements
        .iter()
        .map(|s| OpenVexStatement {
            vulnerability: OpenVexVulnerability {
                name: s.vulnerability_name.clone(),
            },
            products: vec![OpenVexProduct {
                id: s.product_purl.clone(),
            }],
            status: s.status.as_str().to_string(),
            justification: s.justification.clone(),
            impact_statement: s.impact_statement.clone(),
        })
        .collect();

    VexDocument {
        context: "https://openvex.dev/ns/v0.2.0".to_string(),
        id: doc_id,
        author: author.to_string(),
        role: "Document Creator".to_string(),
        timestamp,
        version: 1,
        tooling: format!("BitVex {}", env!("CARGO_PKG_VERSION")),
        statements: openvex_statements,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_generate_openvex_document() {
        let statements = vec![VexStatement {
            vulnerability_name: "CVE-2024-1234".into(),
            product_purl: "pkg:generic/openssl@3.0.13".into(),
            status: VexStatus::NotAffected,
            justification: Some("component_not_present".into()),
            impact_statement: Some("Host-only dependency".into()),
        }];

        let doc = generate_openvex(&statements, "Test Author <test@example.com>");
        let json = serde_json::to_string_pretty(&doc).unwrap();

        assert!(json.contains("https://openvex.dev/ns/v0.2.0"));
        assert!(json.contains("CVE-2024-1234"));
        assert!(json.contains("not_affected"));
        assert!(json.contains("component_not_present"));
    }

    #[test]
    fn test_empty_statements() {
        let doc = generate_openvex(&[], "Test");
        assert!(doc.statements.is_empty());
        assert_eq!(doc.version, 1);
    }
}