Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/cli/src/intel_cli.rs

//! `holger-server intel …` — offline vulnerability intelligence (S7).
//!
//! ```text
//! ONLINE   intel build  --out intel.holger.znippy --policy trust.ron --release-cert … --release-key …
//!                       [--approve ROLE=CERT,KEY]… [--feeds-dir DIR] [--sequence N]
//!   ── USB ──
//! SECURE   intel import --config holger.ron intel.holger.znippy [--first-import]
//!          intel status --config holger.ron
//!          intel scan   --config holger.ron --path DIR --ecosystem pip
//! ```
//!
//! The bundle is a `holger.update-bundle/v1` with product `holger.intel/v1`,
//! built and verified by the same code as `bundle import` (the network-free
//! `holger-intel` crate over `holger-update-bundle`). `import` and `scan` never
//! touch the network; `build` fetches only in a build that links `online`, and
//! only without `--offline` and `--feeds-dir`.

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

use anyhow::{bail, Context, Result};
use chrono::{Duration, Utc};
use clap::{Args, Subcommand};

use holger_intel::feeds::{normalize, FeedSpec, RawFeed, DEFAULT_ECOSYSTEMS};
use holger_intel::{
    build_bundle, BuildOptions, IntelNet, IntelScanner, LocalIntelDb, SignerFiles, StalenessRule,
};
use update_bundle::TrustPolicy;

use crate::secure_update::establish_time;

#[derive(Args)]
pub struct IntelArgs {
    #[command(subcommand)]
    pub op: IntelOp,
}

#[derive(Subcommand)]
pub enum IntelOp {
    /// ONLINE side: fetch OSV (per-ecosystem all.zip), NVD (CVE JSON 2.0) and
    /// CISA KEV, normalize them into one advisory set, and sign it into a
    /// versioned, dated intel bundle for the USB stick.
    Build(BuildArgs),
    /// SECURE side: verify an intel bundle against the trust policy and the
    /// anti-rollback floor, write its audit record (fail-closed), and make its
    /// set the live local advisory database. Never touches the network.
    Import(ImportArgs),
    /// Print what the local advisory database holds and how stale it is.
    Status(StatusArgs),
    /// Scan a directory of artifacts against the local advisory database, offline.
    /// Non-zero exit when anything BLOCKs (a stale database blocks too).
    Scan(ScanArgs),
}

#[derive(Args)]
pub struct BuildArgs {
    /// Where to write the `.holger.znippy` intel bundle.
    #[arg(long)]
    pub out: PathBuf,
    /// Trust policy (RON) the bundle must verify under; the downstream's policy.
    #[arg(long)]
    pub policy: PathBuf,
    /// Release signer certificate (DER).
    #[arg(long)]
    pub release_cert: PathBuf,
    /// Release signer ed25519 key (PKCS#8 DER). Never written into the bundle.
    #[arg(long)]
    pub release_key: PathBuf,
    /// An approval, `ROLE=CERT,KEY` (DER files). Repeat for the two-person rule.
    #[arg(long = "approve", value_name = "ROLE=CERT,KEY")]
    pub approve: Vec<String>,
    /// Read already-downloaded feeds from this directory instead of fetching:
    /// `osv-<Ecosystem>.zip|.json`, `nvd-<name>.json[.gz]`, `kev.json`.
    #[arg(long)]
    pub feeds_dir: Option<PathBuf>,
    /// OSV ecosystems to fetch (repeat). Default: PyPI crates.io npm Maven Go NuGet RubyGems Packagist.
    #[arg(long = "ecosystem")]
    pub ecosystems: Vec<String>,
    /// Refuse the network: build only from `--feeds-dir`.
    #[arg(long)]
    pub offline: bool,
    /// The bundle's monotonic sequence. Default: the build time as YYYYMMDDHHMMSS.
    #[arg(long)]
    pub sequence: Option<u64>,
    #[arg(long, default_value = holger_intel::DEFAULT_SUPPLIER)]
    pub supplier: String,
    /// Days until the bundle's metadata expires.
    #[arg(long, default_value_t = 365)]
    pub valid_days: i64,
}

#[derive(Args)]
pub struct ImportArgs {
    /// The `.holger.znippy` intel bundle.
    pub bundle: PathBuf,
    /// Holger RON config: its `intel:` block names the database, its `audit:`
    /// block the audit sink.
    #[arg(long)]
    pub config: PathBuf,
    /// Trust policy (RON). Default: `intel.trust_policy` from the config.
    #[arg(long)]
    pub policy: Option<PathBuf>,
}

#[derive(Args)]
pub struct StatusArgs {
    #[arg(long)]
    pub config: PathBuf,
}

#[derive(Args)]
pub struct ScanArgs {
    #[arg(long)]
    pub config: PathBuf,
    /// Directory of artifacts (walked recursively).
    #[arg(long)]
    pub path: PathBuf,
    /// Ecosystem of the artifacts (`pip`, `cargo`, `npm`, `maven`, …).
    #[arg(long, default_value = "pip")]
    pub ecosystem: String,
}

pub fn run(a: IntelArgs) -> Result<()> {
    match a.op {
        IntelOp::Build(b) => run_build(b),
        IntelOp::Import(i) => run_import(i),
        IntelOp::Status(s) => run_status(s),
        IntelOp::Scan(s) => run_scan(s),
    }
}

// ───────────────────────── build (online side) ─────────────────────────

fn parse_approval(s: &str) -> Result<(String, SignerFiles)> {
    let (role, files) = s
        .split_once('=')
        .ok_or_else(|| anyhow::anyhow!("--approve {s:?}: expected ROLE=CERT,KEY"))?;
    let (cert, key) = files
        .split_once(',')
        .ok_or_else(|| anyhow::anyhow!("--approve {s:?}: expected ROLE=CERT,KEY"))?;
    Ok((
        role.to_string(),
        SignerFiles::read(Path::new(cert), Path::new(key))?,
    ))
}

fn network(offline: bool) -> IntelNet {
    #[cfg(feature = "online")]
    if !offline {
        return IntelNet::http();
    }
    let _ = offline;
    IntelNet::offline()
}

fn run_build(a: BuildArgs) -> Result<()> {
    let policy = TrustPolicy::from_ron(&std::fs::read_to_string(&a.policy)?)?;
    let created_at = Utc::now();
    let raw: Vec<RawFeed> = match &a.feeds_dir {
        Some(dir) => FeedSpec::from_dir(dir)?
            .into_iter()
            .map(RawFeed::read_file)
            .collect::<Result<_>>()?,
        None => {
            let net = network(a.offline);
            let ecosystems: Vec<String> = if a.ecosystems.is_empty() {
                DEFAULT_ECOSYSTEMS.iter().map(|s| s.to_string()).collect()
            } else {
                a.ecosystems.clone()
            };
            let specs = FeedSpec::default_set(&ecosystems, chrono::Datelike::year(&created_at));
            let mut out = Vec::with_capacity(specs.len());
            for spec in specs {
                eprintln!("fetch {} ({})", spec.kind.label(), spec.uri);
                let bytes = net.get(&spec.uri).map_err(|e| {
                    if e.is_refused() {
                        anyhow::anyhow!(
                            "{e}. This build is offline (no `online` feature, or --offline): \
                             download the feeds on a connected machine and pass --feeds-dir."
                        )
                    } else {
                        anyhow::anyhow!("{e}")
                    }
                })?;
                out.push(RawFeed {
                    spec,
                    bytes,
                    fetched_at: Utc::now(),
                });
            }
            out
        }
    };
    let set = normalize(&raw, created_at)?;
    let approvals = a
        .approve
        .iter()
        .map(|s| parse_approval(s))
        .collect::<Result<Vec<_>>>()?;
    let built = build_bundle(
        &set,
        BuildOptions {
            sequence: a
                .sequence
                .unwrap_or_else(|| holger_intel::bundle::sequence_for(created_at)),
            created_at,
            expires_at: Some(created_at + Duration::days(a.valid_days)),
            supplier: a.supplier.clone(),
            producer: format!("holger-server intel build {}", env!("CARGO_PKG_VERSION")),
            policy,
            approvals,
            release: SignerFiles::read(&a.release_cert, &a.release_key)?,
            out: a.out.clone(),
        },
    )?;
    println!(
        "BUILT {} seq {} dated {}: {} advisories from {} feeds",
        built.bundle_id,
        built.sequence,
        created_at.format("%Y-%m-%d"),
        built.advisories,
        set.sources.len()
    );
    println!("  bundle:     {}", built.path.display());
    println!("  advisories: sha256 {}", built.advisories_sha256);
    for s in &set.sources {
        println!(
            "  feed {:<14} {:>8} records  sha256 {}",
            s.feed, s.records, s.sha256
        );
    }
    Ok(())
}

// ───────────────────────── import (secure side) ─────────────────────────

fn intel_config(config: &Path) -> Result<(server_lib::Holger, server_lib::intel::IntelConfig)> {
    let holger = server_lib::read_ron_config(config)
        .with_context(|| format!("load config {}", config.display()))?;
    let Some(intel) = holger.intel.clone() else {
        bail!(
            "{} has no `intel:` block: name the local advisory database there (db_dir).",
            config.display()
        );
    };
    intel.validate()?;
    Ok((holger, intel))
}

/// The OS user, the actor the CLI's import is recorded under.
fn os_user() -> String {
    std::env::var("USER")
        .or_else(|_| std::env::var("LOGNAME"))
        .unwrap_or_else(|_| "local".into())
}

/// `intel import`: the box's import door (`server_lib::import_door`), the SAME
/// code `AdminService.ImportIntelBundle`, `POST /-/intel/import` and the
/// `intel.inbox` job run.
fn run_import(a: ImportArgs) -> Result<()> {
    let (mut holger, _) = intel_config(&a.config)?;
    if holger.resolved_network() == server_lib::egress::NetworkMode::Offline {
        server_lib::egress::set_offline();
    }
    holger.instantiate_backends()?;
    let policy = match &a.policy {
        Some(p) => Some(TrustPolicy::from_ron(&std::fs::read_to_string(p)?)?),
        None => None,
    };
    let door = server_lib::import_door::ImportDoor::of(&holger)?;
    if let Some(intel) = &holger.intel {
        if LocalIntelDb::open(&intel.db_dir).installed()?.is_none() {
            eprintln!(
                "⚠ the local advisory database is empty: the import establishes its floor only with an admin's \
                 `holger bundle allow-first-import --vendor <supplier> --product holger.intel/v1 --sequence <n>`"
            );
        }
    }
    establish_time();
    let out = door
        .import_intel(&a.bundle, policy.as_ref(), &os_user(), "local")
        .map_err(|e| anyhow::anyhow!("{}", e.tagged()))?;
    println!(
        "IMPORTED ({}) {} seq {} dated {}: {} advisories",
        out.outcome, out.bundle_id, out.sequence, out.snapshot_at, out.advisories
    );
    println!("  database: {}", out.database);
    if let Some(time) = out.checks.first() {
        println!("  {time}");
    }
    Ok(())
}

fn rule(intel: &server_lib::intel::IntelConfig) -> StalenessRule {
    StalenessRule {
        warn_after_days: intel.warn_after_days,
        block_after_days: intel.block_after_days,
    }
}

fn now() -> chrono::DateTime<Utc> {
    chrono::DateTime::<Utc>::from_timestamp_millis(server_lib::clock::now_unix_ms())
        .unwrap_or_else(Utc::now)
}

fn run_status(a: StatusArgs) -> Result<()> {
    let (_, intel) = intel_config(&a.config)?;
    let db = LocalIntelDb::open(&intel.db_dir);
    let r = holger_intel::resolve_local(
        &db,
        rule(&intel),
        now(),
        Some("offline: network refused".into()),
    )?;
    println!("INTEL {}", r.evidence.describe());
    println!("  database:   {}", db.root().display());
    println!("  advisories: {}", r.evidence.advisories);
    println!(
        "  rule:       warn after {} d, block after {} d",
        intel.warn_after_days, intel.block_after_days
    );
    Ok(())
}

fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    for e in std::fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))? {
        let p = e?.path();
        if p.is_dir() {
            walk(&p, out)?;
        } else {
            out.push(p);
        }
    }
    Ok(())
}

fn run_scan(a: ScanArgs) -> Result<()> {
    use modgunn_types::*;
    let (_, intel) = intel_config(&a.config)?;
    // The secure side's scan reads the local database and nothing else: the
    // gate here is offline whatever `intel.offline` says.
    let net = IntelNet::offline();
    let db = LocalIntelDb::open(&intel.db_dir);
    let resolved = holger_intel::resolve(&db, rule(&intel), &net, &[], now())?;
    let policy = Policy {
        version: "holger-intel-scan/1".into(),
        provenance: ProvenancePolicy::Ignore,
        ..Policy::default()
    };
    let scanner = IntelScanner::new(policy, resolved);
    println!("INTEL {}", scanner.evidence().describe());

    let mut files = Vec::new();
    walk(&a.path, &mut files)?;
    files.sort();
    let mut worst = Decision::Pass;
    for f in files {
        let bytes = std::fs::read(&f)?;
        let name = f
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        let Some((pkg, version)) = holger_intel::subject::coords(&name, &bytes) else {
            continue;
        };
        let art = Artifact::new(
            ArtifactRef {
                ecosystem: a.ecosystem.clone(),
                name: pkg,
                version,
                blob_sha256: update_bundle::digest::sha256_hex(&bytes),
            },
            None,
            Provenance::unsigned(),
        );
        let v = scanner.scan(&art).verdict;
        worst = worst.max(v.decision);
        let tag = match v.decision {
            Decision::Pass => "PASS",
            Decision::Warn => "WARN",
            Decision::Block => "BLOCK",
        };
        println!(
            "[{tag}] {} {} ({name})",
            v.artifact.name, v.artifact.version
        );
        for f in &v.findings {
            println!("    {} [{:?}] {}", f.id, f.severity, f.summary);
        }
    }
    match worst {
        Decision::Block => {
            println!("VERDICT: BLOCK");
            bail!("the intel scan blocked");
        }
        Decision::Warn => println!("VERDICT: WARN"),
        Decision::Pass => println!("VERDICT: PASS"),
    }
    Ok(())
}

/// modgunn's scan types, reached through `holger-intel` so this binary needs no
/// modgunn edge of its own.
mod modgunn_types {
    pub use holger_intel::modgunn::core::{ArtifactRef, Decision};
    pub use holger_intel::modgunn::scan::{Artifact, Policy, Provenance, ProvenancePolicy};
}