uv-sbom 2.3.0

SBOM generation tool for uv projects - Generate CycloneDX SBOMs from uv.lock files
Documentation
use super::super::component_view::ComponentView;
use super::super::vulnerability_view::{
    SeverityView, VulnerabilityReportView, VulnerabilitySummary, VulnerabilityView,
};
use crate::sbom_generation::domain::services::VulnerabilityCheckResult;
use crate::sbom_generation::domain::vulnerability::{
    PackageVulnerabilities, Severity, Vulnerability,
};
use std::collections::HashSet;

/// Builds vulnerability report view from vulnerability check result
///
/// Converts above_threshold to actionable and below_threshold to informational.
/// Uses existing VulnerabilityCheckResult semantic methods.
pub(super) fn build_vulnerabilities(
    result: &VulnerabilityCheckResult,
    components: &[ComponentView],
) -> VulnerabilityReportView {
    // Convert above_threshold to actionable vulnerabilities
    let actionable: Vec<VulnerabilityView> = result
        .above_threshold
        .iter()
        .flat_map(|pkg| build_vulnerability_views_for_package(pkg, components))
        .collect();

    // Convert below_threshold to informational vulnerabilities
    let informational: Vec<VulnerabilityView> = result
        .below_threshold
        .iter()
        .flat_map(|pkg| build_vulnerability_views_for_package(pkg, components))
        .collect();

    // Calculate unique affected packages
    let affected_packages: HashSet<&str> = result
        .above_threshold
        .iter()
        .chain(result.below_threshold.iter())
        .map(|pkg| pkg.package_name())
        .collect();

    let summary = VulnerabilitySummary {
        total_count: result.actionable_count() + result.informational_count(),
        affected_package_count: affected_packages.len(),
    };

    VulnerabilityReportView {
        actionable,
        informational,
        summary,
    }
}

/// Builds vulnerability views for all vulnerabilities in a package
fn build_vulnerability_views_for_package(
    package: &PackageVulnerabilities,
    components: &[ComponentView],
) -> Vec<VulnerabilityView> {
    package
        .vulnerabilities()
        .iter()
        .map(|vuln| build_vulnerability_view(vuln, package, components))
        .collect()
}

/// Converts domain vulnerability to view
pub(super) fn build_vulnerability_view(
    vuln: &Vulnerability,
    package: &PackageVulnerabilities,
    components: &[ComponentView],
) -> VulnerabilityView {
    // Find the component bom-ref for this package
    let component = components
        .iter()
        .find(|c| c.name == package.package_name() && c.version == package.current_version());

    let affected_component = component
        .map(|c| c.bom_ref.clone())
        .unwrap_or_else(|| format!("{}-{}", package.package_name(), package.current_version()));

    // Generate vulnerability bom-ref
    let bom_ref = format!("{}-{}", vuln.id(), affected_component);

    VulnerabilityView {
        bom_ref,
        id: vuln.id().to_string(),
        affected_component,
        affected_component_name: package.package_name().to_string(),
        affected_version: package.current_version().to_string(),
        cvss_score: vuln.cvss_score().map(|s| s.value()),
        cvss_vector: None, // OSV API doesn't provide vector in our current implementation
        severity: map_severity(&vuln.severity()),
        fixed_version: vuln.fixed_version().map(|s| s.to_string()),
        description: None, // Summary is not exposed in Vulnerability, could be added later
        source_url: None,  // Not available in current domain model
    }
}

/// Converts domain Severity to SeverityView
pub(super) fn map_severity(severity: &Severity) -> SeverityView {
    match severity {
        Severity::Critical => SeverityView::Critical,
        Severity::High => SeverityView::High,
        Severity::Medium => SeverityView::Medium,
        Severity::Low => SeverityView::Low,
        Severity::None => SeverityView::None,
    }
}

#[cfg(test)]
mod tests {
    use super::super::test_helpers as th;
    use super::*;
    use crate::application::read_models::component_view::ComponentView;
    use crate::sbom_generation::domain::services::VulnerabilityCheckResult;
    use crate::sbom_generation::domain::vulnerability::Severity;

    #[test]
    fn test_map_severity_all_levels() {
        assert_eq!(map_severity(&Severity::Critical), SeverityView::Critical);
        assert_eq!(map_severity(&Severity::High), SeverityView::High);
        assert_eq!(map_severity(&Severity::Medium), SeverityView::Medium);
        assert_eq!(map_severity(&Severity::Low), SeverityView::Low);
        assert_eq!(map_severity(&Severity::None), SeverityView::None);
    }

    #[test]
    fn test_build_vulnerability_view_basic() {
        let vuln = th::vulnerability("CVE-2024-1234", Some(9.8), Severity::Critical);
        let pkg = th::package_vulnerabilities("requests", "2.31.0", vec![vuln.clone()]);
        let components = vec![ComponentView {
            bom_ref: "requests-2.31.0".to_string(),
            name: "requests".to_string(),
            version: "2.31.0".to_string(),
            purl: "pkg:pypi/requests@2.31.0".to_string(),
            license: None,
            description: None,
            sha256_hash: None,
            is_direct_dependency: true,
        }];

        let view = build_vulnerability_view(&vuln, &pkg, &components);

        assert_eq!(view.id, "CVE-2024-1234");
        assert_eq!(view.affected_component, "requests-2.31.0");
        assert_eq!(view.affected_component_name, "requests");
        assert_eq!(view.affected_version, "2.31.0");
        assert_eq!(view.cvss_score, Some(9.8));
        assert_eq!(view.severity, SeverityView::Critical);
        assert_eq!(view.bom_ref, "CVE-2024-1234-requests-2.31.0");
    }

    #[test]
    fn test_build_vulnerability_view_with_fixed_version() {
        let vuln = th::vulnerability_with_fix("CVE-2024-5678", Some(7.5), Severity::High, "3.0.0");
        let pkg = th::package_vulnerabilities("requests", "2.31.0", vec![vuln.clone()]);
        let components = vec![];

        let view = build_vulnerability_view(&vuln, &pkg, &components);

        assert_eq!(view.fixed_version, Some("3.0.0".to_string()));
    }

    #[test]
    fn test_build_vulnerability_view_without_cvss() {
        let vuln = th::vulnerability("GHSA-xxxx-yyyy-zzzz", None, Severity::High);
        let pkg = th::package_vulnerabilities("requests", "2.31.0", vec![vuln.clone()]);
        let components = vec![];

        let view = build_vulnerability_view(&vuln, &pkg, &components);

        assert_eq!(view.cvss_score, None);
        assert_eq!(view.severity, SeverityView::High);
    }

    #[test]
    fn test_build_vulnerability_view_component_not_found() {
        let vuln = th::vulnerability("CVE-2024-1234", Some(9.8), Severity::Critical);
        let pkg = th::package_vulnerabilities("unknown-pkg", "1.0.0", vec![vuln.clone()]);
        let components = vec![];

        let view = build_vulnerability_view(&vuln, &pkg, &components);

        assert_eq!(view.affected_component, "unknown-pkg-1.0.0");
        assert_eq!(view.bom_ref, "CVE-2024-1234-unknown-pkg-1.0.0");
    }

    #[test]
    fn test_build_vulnerabilities_actionable_and_informational() {
        let vuln_critical = th::vulnerability("CVE-2024-001", Some(9.8), Severity::Critical);
        let vuln_low = th::vulnerability("CVE-2024-002", Some(2.0), Severity::Low);

        let above_pkg = th::package_vulnerabilities("critical-pkg", "1.0.0", vec![vuln_critical]);
        let below_pkg = th::package_vulnerabilities("low-pkg", "1.0.0", vec![vuln_low]);

        let result = VulnerabilityCheckResult {
            above_threshold: vec![above_pkg],
            below_threshold: vec![below_pkg],
            threshold_exceeded: true,
        };

        let components = vec![];
        let report = build_vulnerabilities(&result, &components);

        assert_eq!(report.actionable.len(), 1);
        assert_eq!(report.actionable[0].id, "CVE-2024-001");
        assert_eq!(report.informational.len(), 1);
        assert_eq!(report.informational[0].id, "CVE-2024-002");
    }

    #[test]
    fn test_build_vulnerabilities_summary_statistics() {
        let vuln1 = th::vulnerability("CVE-2024-001", Some(9.8), Severity::Critical);
        let vuln2 = th::vulnerability("CVE-2024-002", Some(8.0), Severity::High);
        let vuln3 = th::vulnerability("CVE-2024-003", Some(3.0), Severity::Low);

        let above_pkg = th::package_vulnerabilities("critical-pkg", "1.0.0", vec![vuln1, vuln2]);
        let below_pkg = th::package_vulnerabilities("low-pkg", "1.0.0", vec![vuln3]);

        let result = VulnerabilityCheckResult {
            above_threshold: vec![above_pkg],
            below_threshold: vec![below_pkg],
            threshold_exceeded: true,
        };

        let components = vec![];
        let report = build_vulnerabilities(&result, &components);

        assert_eq!(report.summary.total_count, 3);
        assert_eq!(report.actionable.len(), 2);
        assert_eq!(report.informational.len(), 1);
        assert_eq!(report.summary.affected_package_count, 2);
    }

    #[test]
    fn test_build_vulnerabilities_empty_result() {
        let result = VulnerabilityCheckResult {
            above_threshold: vec![],
            below_threshold: vec![],
            threshold_exceeded: false,
        };

        let components = vec![];
        let report = build_vulnerabilities(&result, &components);

        assert!(report.actionable.is_empty());
        assert!(report.informational.is_empty());
        assert_eq!(report.summary.total_count, 0);
        assert_eq!(report.summary.affected_package_count, 0);
    }

    #[test]
    fn test_build_vulnerabilities_multiple_vulns_per_package() {
        let vuln1 = th::vulnerability("CVE-2024-001", Some(9.8), Severity::Critical);
        let vuln2 = th::vulnerability("CVE-2024-002", Some(8.5), Severity::High);
        let vuln3 = th::vulnerability("CVE-2024-003", Some(7.0), Severity::High);

        let pkg = th::package_vulnerabilities("multi-vuln-pkg", "1.0.0", vec![vuln1, vuln2, vuln3]);

        let result = VulnerabilityCheckResult {
            above_threshold: vec![pkg],
            below_threshold: vec![],
            threshold_exceeded: true,
        };

        let components = vec![];
        let report = build_vulnerabilities(&result, &components);

        assert_eq!(report.actionable.len(), 3);
        for vuln_view in &report.actionable {
            assert_eq!(vuln_view.affected_component_name, "multi-vuln-pkg");
            assert_eq!(vuln_view.affected_version, "1.0.0");
        }
    }
}