holger-intel 3.1.1

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
//! Where a scan's advisories come from, and the evidence that says so.
//!
//! * **Offline** (`intel.offline = true`, the default on the secure side): the
//!   local database, always. The network gate is [`IntelNet::offline`], which
//!   has no fetcher behind it, so a fetch is refused, not attempted.
//! * **Online**: the feeds, fetched and normalized now. When any feed cannot be
//!   fetched (no network), the local database, and the evidence says why.
//!
//! Either way the evidence names the bundle sequence and snapshot date, so a
//! stale database is visible in every verdict, and the [`StalenessRule`] turns
//! age into a warning and then into a block.

use std::sync::{Arc, RwLock};

use anyhow::Result;
use chrono::{DateTime, Utc};
use modgunn::core::{Decision, Finding, Severity, Verdict};
use modgunn::scan::{AdvisoryDb, Artifact, OsvScanner, Policy, ScanEngine};
use serde::{Deserialize, Serialize};

use crate::db::{InstalledIntel, LocalIntelDb};
use crate::feeds::{normalize, FeedSpec, RawFeed};
use crate::net::IntelNet;
use crate::stale::{Staleness, StalenessRule};

/// A scan against a database older than `warn_after_days`.
pub const FINDING_STALE: &str = "HOLGER-INTEL-STALE";
/// A scan against a database older than `block_after_days`.
pub const FINDING_EXPIRED: &str = "HOLGER-INTEL-EXPIRED";
/// A scan with no advisory database at all.
pub const FINDING_MISSING: &str = "HOLGER-INTEL-MISSING";

/// The intel block of a Holger config, as the scanners need it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelSettings {
    pub db_dir: String,
    pub offline: bool,
    pub rule: StalenessRule,
}

/// What a verdict was computed against.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelEvidence {
    /// `local-intel`, `network`, or `none`.
    pub source: String,
    pub bundle_id: Option<String>,
    pub sequence: Option<u64>,
    /// `YYYY-MM-DD`: the snapshot date of the advisories.
    pub snapshot_date: Option<String>,
    pub age_days: Option<i64>,
    pub staleness: Staleness,
    pub advisories: u64,
    /// Why the network was not used, when it was not.
    pub network: Option<String>,
}

impl IntelEvidence {
    fn local(
        meta: &InstalledIntel,
        rule: StalenessRule,
        now: DateTime<Utc>,
        network: Option<String>,
    ) -> Self {
        Self {
            source: "local-intel".into(),
            bundle_id: Some(meta.bundle_id.clone()),
            sequence: Some(meta.sequence),
            snapshot_date: Some(meta.snapshot_at.format("%Y-%m-%d").to_string()),
            age_days: Some(StalenessRule::age_days(meta.snapshot_at, now)),
            staleness: rule.judge(meta.snapshot_at, now),
            advisories: meta.advisories,
            network,
        }
    }

    fn missing(network: Option<String>) -> Self {
        Self {
            source: "none".into(),
            bundle_id: None,
            sequence: None,
            snapshot_date: None,
            age_days: None,
            staleness: Staleness::Block,
            advisories: 0,
            network,
        }
    }

    /// The revision the verdict cache keys on: bundle sequence and date, never
    /// the age, so a cached verdict moves when the database does.
    pub fn rev(&self) -> String {
        match (&self.sequence, &self.snapshot_date) {
            (Some(s), Some(d)) => format!("holger-intel seq {s} dated {d}"),
            _ if self.source == "network" => format!(
                "holger-intel network {}",
                self.snapshot_date.as_deref().unwrap_or("-")
            ),
            _ => "holger-intel none".into(),
        }
    }

    /// One line for a report: `holger-intel seq 42 dated 2026-10-19 (3 d old, fresh; offline)`.
    pub fn describe(&self) -> String {
        let age = self
            .age_days
            .map(|d| format!("{d} d old, "))
            .unwrap_or_default();
        let net = self
            .network
            .as_deref()
            .map(|n| format!("; {n}"))
            .unwrap_or_default();
        format!("{} ({age}{}{net})", self.rev(), self.staleness.as_str())
    }

    /// The finding a stale or missing database adds to every verdict.
    pub fn finding(&self) -> Option<Finding> {
        let what = self.rev();
        match (self.staleness, self.source.as_str()) {
            (Staleness::Fresh, _) => None,
            (Staleness::Block, "none") => Some(Finding {
                severity: Severity::High,
                id: FINDING_MISSING.into(),
                summary: "no advisory database is installed: import a signed intel bundle".into(),
            }),
            (Staleness::Warn, _) => Some(Finding {
                severity: Severity::Medium,
                id: FINDING_STALE.into(),
                summary: format!("advisory database {what} is {} days old", self.age_days.unwrap_or(0)),
            }),
            (Staleness::Block, _) => Some(Finding {
                severity: Severity::High,
                id: FINDING_EXPIRED.into(),
                summary: format!(
                    "advisory database {what} is {} days old, past the block limit: import a newer intel bundle",
                    self.age_days.unwrap_or(0)
                ),
            }),
        }
    }
}

/// A database and its evidence.
#[derive(Debug, Clone)]
pub struct ResolvedAdvisories {
    pub db: AdvisoryDb,
    pub evidence: IntelEvidence,
}

/// The local database, judged by `rule` at `now`. No database is not an error:
/// it is a `none` evidence that blocks every scan.
pub fn resolve_local(
    db: &LocalIntelDb,
    rule: StalenessRule,
    now: DateTime<Utc>,
    network: Option<String>,
) -> Result<ResolvedAdvisories> {
    match db.load()? {
        None => Ok(ResolvedAdvisories {
            db: AdvisoryDb::new("holger-intel none", vec![]),
            evidence: IntelEvidence::missing(network),
        }),
        Some((meta, set)) => {
            let evidence = IntelEvidence::local(&meta, rule, now, network);
            Ok(ResolvedAdvisories {
                db: set.to_advisory_db(evidence.rev()),
                evidence,
            })
        }
    }
}

/// The advisories for a scan: offline → local; online → the feeds, or local
/// when the network is not there.
pub fn resolve(
    db: &LocalIntelDb,
    rule: StalenessRule,
    net: &IntelNet,
    feeds: &[FeedSpec],
    now: DateTime<Utc>,
) -> Result<ResolvedAdvisories> {
    if net.is_offline() {
        return resolve_local(db, rule, now, Some("offline: network refused".into()));
    }
    let mut raw = Vec::with_capacity(feeds.len());
    for spec in feeds {
        match net.get(&spec.uri) {
            Ok(bytes) => raw.push(RawFeed {
                spec: spec.clone(),
                bytes,
                fetched_at: now,
            }),
            Err(e) => return resolve_local(db, rule, now, Some(format!("no network: {e}"))),
        }
    }
    let set = normalize(&raw, now)?;
    let evidence = IntelEvidence {
        source: "network".into(),
        bundle_id: None,
        sequence: None,
        snapshot_date: Some(now.format("%Y-%m-%d").to_string()),
        age_days: Some(0),
        staleness: Staleness::Fresh,
        advisories: set.advisories.len() as u64,
        network: None,
    };
    Ok(ResolvedAdvisories {
        db: set.to_advisory_db(evidence.rev()),
        evidence,
    })
}

/// A verdict and what it was computed against.
#[derive(Debug, Clone)]
pub struct IntelVerdict {
    pub verdict: Verdict,
    pub evidence: IntelEvidence,
}

/// modgunn's scanner over a resolved database, with the staleness rule folded
/// into every verdict.
pub struct IntelScanner {
    inner: OsvScanner,
    evidence: IntelEvidence,
}

impl IntelScanner {
    pub fn new(policy: Policy, resolved: ResolvedAdvisories) -> Self {
        Self {
            inner: OsvScanner::new(policy, resolved.db),
            evidence: resolved.evidence,
        }
    }

    pub fn evidence(&self) -> &IntelEvidence {
        &self.evidence
    }

    pub fn scanner(&self) -> &OsvScanner {
        &self.inner
    }

    pub fn scan(&self, artifact: &Artifact) -> IntelVerdict {
        IntelVerdict {
            verdict: fold_staleness(self.inner.scan(artifact), &self.evidence),
            evidence: self.evidence.clone(),
        }
    }
}

/// Scan `artifact` under `policy` against a shared database, staleness folded
/// in. The database is borrowed, never cloned, so a server holding one
/// [`LiveIntel`] set scans every request against the same allocation.
pub fn scan_against(
    policy: &Policy,
    db: &AdvisoryDb,
    evidence: &IntelEvidence,
    artifact: &Artifact,
) -> Verdict {
    let matched = db.query(&artifact.artifact);
    fold_staleness(policy.assemble(&db.rev, artifact, &matched), evidence)
}

/// Add the staleness finding to `v` and raise its decision: a warning to at
/// least Warn, an expired or missing database to Block.
pub fn fold_staleness(mut v: Verdict, evidence: &IntelEvidence) -> Verdict {
    if let Some(f) = evidence.finding() {
        v.findings.push(f);
        let floor = match evidence.staleness {
            Staleness::Fresh => Decision::Pass,
            Staleness::Warn => Decision::Warn,
            Staleness::Block => Decision::Block,
        };
        v.decision = v.decision.max(floor);
    }
    v
}

/// The server's live view of the local database: re-reads the small install
/// record per call and reloads the set only when the sequence moved, so an
/// import goes live without a restart.
pub struct LiveIntel {
    db: LocalIntelDb,
    rule: StalenessRule,
    cache: RwLock<Option<(String, Arc<AdvisoryDb>)>>,
}

impl LiveIntel {
    pub fn new(db: LocalIntelDb, rule: StalenessRule) -> Self {
        Self {
            db,
            rule,
            cache: RwLock::new(None),
        }
    }

    pub fn rule(&self) -> StalenessRule {
        self.rule
    }

    /// The current database and its evidence at `now`.
    pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, IntelEvidence)> {
        let Some(meta) = self.db.installed()? else {
            let e = IntelEvidence::missing(Some("offline: network refused".into()));
            return Ok((Arc::new(AdvisoryDb::new(e.rev(), vec![])), e));
        };
        let evidence = IntelEvidence::local(
            &meta,
            self.rule,
            now,
            Some("offline: network refused".into()),
        );
        let key = format!("{}#{}", meta.sequence, meta.advisories_sha256);
        if let Some((k, db)) = self
            .cache
            .read()
            .map_err(|_| anyhow::anyhow!("intel cache poisoned"))?
            .as_ref()
        {
            if *k == key {
                return Ok((db.clone(), evidence));
            }
        }
        let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
        let db = Arc::new(resolved.db);
        *self
            .cache
            .write()
            .map_err(|_| anyhow::anyhow!("intel cache poisoned"))? = Some((key, db.clone()));
        Ok((db, resolved.evidence))
    }
}