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 std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::{debug, info, warn};

use super::OsvVuln;
use crate::sbom::SbomPackage;

#[derive(Debug, Deserialize)]
struct OsvVulnerability {
    id: String,
    modified: String,
    affected: Option<Vec<Affected>>,
}

#[derive(Debug, Deserialize)]
struct Affected {
    package: OsvPackage,
    ranges: Option<Vec<Range>>,
    versions: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
struct OsvPackage {
    name: String,
    ecosystem: Option<String>,
    purl: Option<String>,
}

#[derive(Debug, Deserialize)]
struct Range {
    #[serde(rename = "type")]
    range_type: String,
    events: Option<Vec<Event>>,
}

#[derive(Debug, Deserialize)]
struct Event {
    introduced: Option<String>,
    fixed: Option<String>,
}

pub struct OfflineOsvProvider {
    _db_path: PathBuf,
    vulns_by_name: HashMap<String, Vec<OsvVulnerability>>,
}

impl OfflineOsvProvider {
    pub fn new(db_path: &Path) -> Result<Self> {
        info!("Loading offline OSV database from {}", db_path.display());
        let vulns_by_name = load_database(db_path)?;

        let total_vulns: usize = vulns_by_name.values().map(|v| v.len()).sum();
        info!(
            "Loaded {} vulnerabilities for {} packages from offline DB",
            total_vulns,
            vulns_by_name.len()
        );

        Ok(Self {
            _db_path: db_path.to_path_buf(),
            vulns_by_name,
        })
    }

    pub fn query_batch(&self, packages: &[SbomPackage]) -> Result<Vec<super::OsvResult>> {
        let mut results = Vec::with_capacity(packages.len());

        for pkg in packages {
            let vulns = self.query_package(pkg);
            debug!(
                "Offline: {} {} -> {} vulns",
                pkg.name,
                pkg.version.as_deref().unwrap_or("?"),
                vulns.len()
            );
            results.push(super::OsvResult {
                package: pkg.clone(),
                vulns,
            });
        }

        Ok(results)
    }

    fn query_package(&self, pkg: &SbomPackage) -> Vec<OsvVuln> {
        let mut found = Vec::new();

        if let Some(ref purl) = pkg.purl {
            for vuln in self.vulns_by_name.values().flatten() {
                if vuln_affected_by_purl(vuln, purl, pkg.version.as_deref()) {
                    found.push(OsvVuln {
                        id: vuln.id.clone(),
                        _modified: vuln.modified.clone(),
                    });
                }
            }
        }

        if found.is_empty() {
            if let Some(vulns) = self.vulns_by_name.get(&pkg.name) {
                for vuln in vulns {
                    if vuln_affected_by_name(vuln, &pkg.name, pkg.version.as_deref()) {
                        found.push(OsvVuln {
                            id: vuln.id.clone(),
                            _modified: vuln.modified.clone(),
                        });
                    }
                }
            }
        }

        found.sort_by(|a, b| a.id.cmp(&b.id));
        found.dedup_by(|a, b| a.id == b.id);
        found
    }
}

fn vuln_affected_by_purl(vuln: &OsvVulnerability, purl: &str, version: Option<&str>) -> bool {
    let Some(affected) = &vuln.affected else {
        return false;
    };

    for a in affected {
        if let Some(ref pkg_purl) = a.package.purl {
            let vuln_purl_base = pkg_purl.split('@').next().unwrap_or(pkg_purl);
            let query_purl_base = purl.split('@').next().unwrap_or(purl);

            if vuln_purl_base == query_purl_base {
                if let Some(v) = version {
                    if version_in_ranges(v, &a.ranges) || version_in_list(v, &a.versions) {
                        return true;
                    }
                } else {
                    return true;
                }
            }
        }
    }

    false
}

fn vuln_affected_by_name(vuln: &OsvVulnerability, name: &str, version: Option<&str>) -> bool {
    let Some(affected) = &vuln.affected else {
        return false;
    };

    for a in affected {
        if a.package.name == name {
            if let Some(v) = version {
                if version_in_ranges(v, &a.ranges) || version_in_list(v, &a.versions) {
                    return true;
                }
            } else {
                return true;
            }
        }
    }

    false
}

fn version_in_ranges(version: &str, ranges: &Option<Vec<Range>>) -> bool {
    let Some(ranges) = ranges else {
        return false;
    };

    for range in ranges {
        if range.range_type != "ECOSYSTEM" && range.range_type != "SEMVER" {
            continue;
        }

        let Some(events) = &range.events else {
            continue;
        };

        let mut introduced = None;
        let mut fixed = None;

        for event in events {
            if let Some(ref v) = event.introduced {
                introduced = Some(v.as_str());
            }
            if let Some(ref v) = event.fixed {
                fixed = Some(v.as_str());
            }
        }

        let after_intro = introduced.map(|i| version >= i).unwrap_or(true);

        let before_fixed = fixed.map(|f| version < f).unwrap_or(true);

        if after_intro && before_fixed {
            return true;
        }
    }

    false
}

fn version_in_list(version: &str, versions: &Option<Vec<String>>) -> bool {
    let Some(versions) = versions else {
        return false;
    };

    versions.iter().any(|v| v == version)
}

fn load_database(db_path: &Path) -> Result<HashMap<String, Vec<OsvVulnerability>>> {
    let mut all_vulns: HashMap<String, Vec<OsvVulnerability>> = HashMap::new();

    if !db_path.exists() {
        warn!(
            "Offline database directory not found: {}",
            db_path.display()
        );
        return Ok(all_vulns);
    }

    for entry in std::fs::read_dir(db_path)
        .with_context(|| format!("Failed to read DB directory: {}", db_path.display()))?
    {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            let json_path = path.join("all.json");
            if json_path.exists() {
                let data = std::fs::read(&json_path)
                    .with_context(|| format!("Failed to read {}", json_path.display()))?;

                let vulns: Vec<OsvVulnerability> = serde_json::from_slice(&data)
                    .with_context(|| format!("Failed to parse {}", json_path.display()))?;

                for vuln in vulns {
                    if let Some(affected) = &vuln.affected {
                        for a in affected {
                            all_vulns.entry(a.package.name.clone()).or_default().push(
                                OsvVulnerability {
                                    id: vuln.id.clone(),
                                    modified: vuln.modified.clone(),
                                    affected: Some(vec![Affected {
                                        package: a.package.clone(),
                                        ranges: a.ranges.clone(),
                                        versions: a.versions.clone(),
                                    }]),
                                },
                            );
                        }
                    }
                }
            }
        }
    }

    Ok(all_vulns)
}

impl Clone for OsvPackage {
    fn clone(&self) -> Self {
        Self {
            name: self.name.clone(),
            ecosystem: self.ecosystem.clone(),
            purl: self.purl.clone(),
        }
    }
}

impl Clone for Affected {
    fn clone(&self) -> Self {
        Self {
            package: self.package.clone(),
            ranges: self.ranges.clone(),
            versions: self.versions.clone(),
        }
    }
}

impl Clone for Range {
    fn clone(&self) -> Self {
        Self {
            range_type: self.range_type.clone(),
            events: self.events.clone(),
        }
    }
}

impl Clone for Event {
    fn clone(&self) -> Self {
        Self {
            introduced: self.introduced.clone(),
            fixed: self.fixed.clone(),
        }
    }
}