holger-intel 2.0.0

Holger offline vulnerability intelligence: OSV / NVD / CISA KEV normalized into one advisory set, carried across the airgap as a signed holger.update-bundle/v1 (product holger.intel/v1), verified with anti-rollback, and kept in a persistent local advisory database the scanners read. Network-free unless the `fetch` feature is on.
Documentation
//! The normalized advisory set: one compact, deterministic JSON document that
//! every feed folds into, and that both sides of the airgap agree on.

use chrono::{DateTime, Utc};
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
use modgunn::versions::RangeEvent;
use serde::{Deserialize, Serialize};

/// The schema the set document carries.
pub const SET_SCHEMA: &str = "holger.intel-advisories/v1";

/// One advisory, in the compact form the bundle carries. Empty fields are left
/// out of the JSON, so a plain OSV range advisory costs a few hundred bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelAdvisory {
    pub id: String,
    pub ecosystem: String,
    pub package: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub versions: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ranges: Vec<Vec<RangeEvent>>,
    pub severity: Severity,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub summary: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aliases: Vec<String>,
    /// In CISA's Known Exploited Vulnerabilities catalog.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub kev: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub epss: Option<f64>,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub cvss: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cwe: Vec<String>,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source: String,
}

impl IntelAdvisory {
    pub fn from_advisory(a: &Advisory) -> Self {
        Self {
            id: a.id.clone(),
            ecosystem: a.ecosystem.clone(),
            package: a.package.clone(),
            versions: a.affected_versions.clone(),
            ranges: a.ranges.clone(),
            severity: a.severity,
            summary: a.summary.clone(),
            aliases: a.aliases.clone(),
            kev: a.kev,
            epss: a.epss,
            cvss: a.cvss_vector.clone(),
            cwe: a.cwe.clone(),
            source: a.source.clone(),
        }
    }

    pub fn to_advisory(&self) -> Advisory {
        let mut a = Advisory::basic(
            self.id.clone(),
            self.ecosystem.clone(),
            self.package.clone(),
            self.versions.clone(),
            self.severity,
            self.summary.clone(),
        )
        .with_source(self.source.clone())
        .with_range_sets(self.ranges.clone());
        a.aliases = self.aliases.clone();
        a.kev = self.kev;
        a.epss = self.epss;
        a.cvss_vector = self.cvss.clone();
        a.cwe = self.cwe.clone();
        a
    }
}

/// Where one feed's bytes came from, and what they hashed to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceRecord {
    /// `osv:PyPI`, `nvd:recent`, `kev`.
    pub feed: String,
    pub uri: String,
    pub sha256: String,
    pub bytes: u64,
    pub fetched_at: DateTime<Utc>,
    /// Records the feed contributed (advisories, NVD entries or KEV ids).
    pub records: u64,
}

/// The whole normalized set.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdvisorySet {
    pub schema: String,
    pub generated_at: DateTime<Utc>,
    pub sources: Vec<SourceRecord>,
    pub advisories: Vec<IntelAdvisory>,
}

impl AdvisorySet {
    /// A set from modgunn advisories: withdrawn ones are dropped, and the rest
    /// are sorted so the same feeds always give the same bytes.
    pub fn from_advisories(
        generated_at: DateTime<Utc>,
        sources: Vec<SourceRecord>,
        advisories: &[Advisory],
    ) -> Self {
        let mut out: Vec<IntelAdvisory> = advisories
            .iter()
            .filter(|a| !a.withdrawn)
            .map(IntelAdvisory::from_advisory)
            .collect();
        out.sort_by(|a, b| {
            (&a.ecosystem, &a.package, &a.id).cmp(&(&b.ecosystem, &b.package, &b.id))
        });
        Self { schema: SET_SCHEMA.to_string(), generated_at, sources, advisories: out }
    }

    pub fn to_json_bytes(&self) -> serde_json::Result<Vec<u8>> {
        serde_json::to_vec(self)
    }

    /// Parse and check the schema.
    pub fn from_json_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
        let set: Self = serde_json::from_slice(bytes)
            .map_err(|e| anyhow::anyhow!("the advisory set is not valid JSON: {e}"))?;
        anyhow::ensure!(
            set.schema == SET_SCHEMA,
            "the advisory set has schema {:?}; this build reads {SET_SCHEMA:?}",
            set.schema
        );
        Ok(set)
    }

    /// The set as the scanner's database, under revision `rev`.
    pub fn to_advisory_db(&self, rev: impl Into<String>) -> AdvisoryDb {
        AdvisoryDb::new(rev, self.advisories.iter().map(IntelAdvisory::to_advisory).collect())
    }
}