holger-intel 3.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 secure side's door: verify a carried intel bundle, hold it to the
//! anti-rollback floor, record it in the audit log, and only then make its
//! advisory set the live local database.
//!
//! The order is the one `holger-server bundle import` keeps (S0/B3), and for the
//! same reasons:
//!
//! 1. the bundle file is read ONCE; the bytes that verify are the bytes kept;
//! 2. the release signature is checked from the control members before any
//!    payload is decompressed (`load_bundle_verified`);
//! 3. the full verification runs against the trusted clock and the floor;
//! 4. the floor decides: a lower sequence, a replay with other bytes, or an
//!    absent floor without `first_import` is refused;
//! 5. the audit sink must be ready, and the audit record is written BEFORE the
//!    floor moves and before the new set goes live. A record that cannot be
//!    written undoes what was staged, and the database stays as it was.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::container::load_bundle_verified;
use holger_update_bundle::core::BundleCore;
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::receipt::ImportReceipt;
use holger_update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
use holger_update_bundle::schema::{CORE_JSON, PAYLOAD_PREFIX};
use holger_update_bundle::time::TrustedTime;
use holger_update_bundle::verify::{verify_member_map_with_time, TargetSpec};
use holger_update_bundle::{atomic_write, TrustPolicy};
use serde::{Deserialize, Serialize};

use crate::db::{InstalledIntel, LocalIntelDb, INSTALLED_SCHEMA};
use crate::set::AdvisorySet;
use crate::{ADVISORIES_REL, INTEL_PRODUCT};

/// What to import, where, under which policy.
pub struct ImportRequest<'a> {
    pub bundle: &'a Path,
    pub policy: &'a TrustPolicy,
    pub db: &'a LocalIntelDb,
    /// Establish the anti-rollback floor with this bundle because there is
    /// none. No effect when a floor exists.
    pub first_import: bool,
}

/// The audit record of one import.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImportRecord {
    pub bundle_id: String,
    pub sequence: u64,
    pub supplier: String,
    pub snapshot_at: DateTime<Utc>,
    pub candidate_digest: String,
    pub bundle_sha256: String,
    pub advisories: u64,
    /// `new` or `idempotent-reimport`.
    pub outcome: String,
}

impl ImportRecord {
    /// The detail line an audit event carries.
    pub fn detail(&self) -> String {
        format!(
            "bundle_id={} seq={} snapshot={} digest={} advisories={} outcome={}",
            self.bundle_id,
            self.sequence,
            self.snapshot_at.format("%Y-%m-%d"),
            self.candidate_digest,
            self.advisories,
            self.outcome
        )
    }
}

/// The audit sink an import writes through. The server's audit log in
/// production; a test double in tests.
pub trait ImportAudit {
    /// Refuse when the sink is off or could not be opened. Asked before anything
    /// is written.
    fn ready(&self) -> Result<()>;
    /// Write and flush the record. An error refuses the import.
    fn record(&self, record: &ImportRecord) -> Result<()>;
}

/// The result of an import.
#[derive(Debug, Clone)]
pub struct ImportOutcome {
    pub record: ImportRecord,
    pub installed: InstalledIntel,
    pub stored_bundle: PathBuf,
    pub receipt: PathBuf,
    /// The verifier's checks, the time source first.
    pub checks: Vec<String>,
}

/// An exclusive import lock: `create_new`, removed on drop.
struct Lock(PathBuf);

impl Lock {
    fn acquire(path: PathBuf) -> Result<Self> {
        match std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&path)
        {
            Ok(mut f) => {
                use std::io::Write;
                let _ = writeln!(f, "pid={}", std::process::id());
                Ok(Lock(path))
            }
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
                "intel import refused: {} exists, so another import is running or one was \
                 killed. Check no import runs, then remove the lock.",
                path.display()
            ),
            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
        }
    }
}

impl Drop for Lock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

fn safe_name(s: &str) -> String {
    let out: String = s
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if out.is_empty() || out == "." || out == ".." {
        "_".into()
    } else {
        out
    }
}

/// Import one intel bundle. See the module note for the order.
pub fn import(
    req: &ImportRequest<'_>,
    time: &dyn TrustedTime,
    audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
    let db = req.db;
    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
    let _lock = Lock::acquire(db.lock_path())?;

    let raw = std::fs::read(req.bundle)
        .with_context(|| format!("read bundle {}", req.bundle.display()))?;
    let staged = db.root().join(".intel-import.staged.znippy");
    std::fs::write(&staged, &raw).with_context(|| format!("stage {}", staged.display()))?;
    let loaded = load_bundle_verified(&staged, req.policy);
    let _ = std::fs::remove_file(&staged);
    let members = loaded.map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;

    let core = BundleCore::from_json_bytes(
        members
            .get(CORE_JSON)
            .ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
    )?;
    if core.product != INTEL_PRODUCT {
        bail!(
            "intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
             update bundles with `bundle import`.",
            core.product
        );
    }

    let rollback = RollbackStore::open(db.state_dir());
    let floor = rollback.floor(&core.supplier, &core.product)?;
    let state = floor
        .accepted()
        .cloned()
        .unwrap_or_else(AcceptedState::empty);
    let target = TargetSpec {
        product: Some(INTEL_PRODUCT.to_string()),
        model: None,
        revision: None,
    };
    let report = verify_member_map_with_time(&members, req.policy, &target, Some(&state), time)
        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
    let admissibility = floor
        .check_admissible(&core, &report.candidate_digest, req.first_import)
        .map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;

    let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
    let payload = members
        .get(&payload_path)
        .ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
    let payload_sha = sha256_hex(payload);
    if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
        bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
    }
    let set = AdvisorySet::from_json_bytes(payload)?;

    audit.ready().map_err(|e| {
        anyhow::anyhow!(
            "intel import refused: the audit log is off or could not be opened ({e:#}), and an \
             import that leaves no audit record is not allowed"
        )
    })?;

    let outcome = match admissibility {
        Admissibility::New => "new",
        Admissibility::IdempotentReimport => "idempotent-reimport",
    };
    let live_before = db.installed().ok().flatten();
    let gen_file = LocalIntelDb::generation_file(core.sequence);
    let gen_path = db.stage_generation(core.sequence, payload)?;
    let stored = db
        .bundles_dir()
        .join(format!("{}.holger.znippy", safe_name(&core.bundle_id)));
    let bundle_was_there = stored.exists();
    if !bundle_was_there {
        atomic_write(&stored, &raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
    }
    let undo = || {
        if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
            let _ = std::fs::remove_file(&gen_path);
        }
        if !bundle_was_there {
            let _ = std::fs::remove_file(&stored);
        }
    };

    let record = ImportRecord {
        bundle_id: core.bundle_id.clone(),
        sequence: core.sequence,
        supplier: core.supplier.clone(),
        snapshot_at: core.created_at,
        candidate_digest: report.candidate_digest.clone(),
        bundle_sha256: sha256_hex(&raw),
        advisories: set.advisories.len() as u64,
        outcome: outcome.to_string(),
    };
    if let Err(e) = audit.record(&record) {
        undo();
        bail!(
            "intel import refused: its audit record could not be written ({e:#}). No audit record \
             means no import; the advisory database and the anti-rollback floor are unchanged."
        );
    }

    let now = time.now().unwrap_or_else(|_| Utc::now());
    rollback.record(
        &core.supplier,
        &core.product,
        &AcceptedEntry {
            sequence: core.sequence,
            bundle_id: core.bundle_id.clone(),
            core_digest: report.candidate_digest.clone(),
            accepted_at: now,
        },
    )?;
    let installed = InstalledIntel {
        schema: INSTALLED_SCHEMA.to_string(),
        bundle_id: core.bundle_id.clone(),
        sequence: core.sequence,
        supplier: core.supplier.clone(),
        snapshot_at: core.created_at,
        imported_at: now,
        candidate_digest: report.candidate_digest.clone(),
        release_signer_fingerprint: report.release_signer_fingerprint.clone(),
        advisories_file: gen_file,
        advisories_sha256: payload_sha,
        advisories: set.advisories.len() as u64,
    };
    db.activate(&installed)?;

    let receipt = ImportReceipt {
        bundle_id: core.bundle_id.clone(),
        sequence: core.sequence,
        supplier: core.supplier.clone(),
        product: core.product.clone(),
        release_version: core.release_version.clone(),
        candidate_digest: report.candidate_digest.clone(),
        release_signer_fingerprint: report.release_signer_fingerprint.clone(),
        imported_at: now,
        outcome: outcome.to_string(),
        bundle_sha256: record.bundle_sha256.clone(),
    };
    std::fs::create_dir_all(db.receipts_dir()).ok();
    let receipt_path = db
        .receipts_dir()
        .join(format!("{}.json", safe_name(&core.bundle_id)));
    std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;

    Ok(ImportOutcome {
        record,
        installed,
        stored_bundle: stored,
        receipt: receipt_path,
        checks: report.checks,
    })
}