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};
}