server/cli/src/sigs_cli.rs
//! `holger scanner-sigs …` — scanner signatures across the airgap (lane HOL-NCSC).
//!
//! ```text
//! ONLINE scanner-sigs build --clamav-dir DIR [--yara-dir DIR] --out sigs.holger.znippy --policy trust.ron
//! --release-cert … --release-key … [--approve ROLE=CERT,KEY]… [--sequence N]
//! ── USB ──
//! SECURE bundle allow-first-import --config holger.ron --from-bundle sigs.holger.znippy (the first one only)
//! bundle import --config holger.ron --bundle sigs.holger.znippy
//! scanner-sigs status --config holger.ron
//! ```
//!
//! The bundle is a `holger.update-bundle/v1` with product `holger.scanner-sigs/v1`
//! (`holger_intel::sigs`): every signature file a payload, hashed by the release manifest and
//! signed. `bundle import` (and every import door: HTTP, gRPC, the `bundle.inbox`) verifies it,
//! holds it to the anti-rollback floor, audits it first and installs it where the sandboxed
//! clamd reads its database. A hotfix is the same bundle with the next sequence and one file.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use clap::{Args, Subcommand};
use holger_intel::sigs::{build_sigs_bundle, read_dirs, SigsBuild, SigsDb, DEFAULT_SIGS_SUPPLIER};
use holger_intel::SignerFiles;
use update_bundle::TrustPolicy;
#[derive(Args)]
pub struct SigsArgs {
#[command(subcommand)]
pub op: SigsOp,
}
#[derive(Subcommand)]
pub enum SigsOp {
/// ONLINE side: sign the signature files of `--clamav-dir` (and `--yara-dir`) into a
/// `holger.scanner-sigs/v1` bundle for the USB stick. Every file must be a signature
/// file (a ClamAV database extension, or .yar/.yara); anything else refuses the build.
Build(BuildArgs),
/// What the box's signature database holds (the image's own until the first bundle).
Status(StatusArgs),
}
#[derive(Args)]
pub struct BuildArgs {
/// ClamAV database files (main/daily/bytecode .cvd/.cld, a hotfix .ndb/.hdb/.ldb …).
#[arg(long)]
pub clamav_dir: Option<PathBuf>,
/// YARA rule files (.yar, .yara).
#[arg(long)]
pub yara_dir: Option<PathBuf>,
#[arg(long)]
pub out: PathBuf,
/// The trust policy (RON) the secure side verifies with.
#[arg(long)]
pub policy: PathBuf,
#[arg(long)]
pub release_cert: PathBuf,
#[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>,
/// The monotonic sequence (a hotfix takes the next). Default: the build time as
/// YYYYMMDDHHMMSS.
#[arg(long)]
pub sequence: Option<u64>,
#[arg(long, default_value = DEFAULT_SIGS_SUPPLIER)]
pub supplier: String,
#[arg(long, default_value_t = 30)]
pub valid_days: i64,
}
#[derive(Args)]
pub struct StatusArgs {
#[arg(long)]
pub config: PathBuf,
}
pub fn run(a: SigsArgs) -> Result<()> {
match a.op {
SigsOp::Build(b) => run_build(b),
SigsOp::Status(s) => run_status(s),
}
}
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 run_build(a: BuildArgs) -> Result<()> {
let policy = TrustPolicy::from_ron(
&std::fs::read_to_string(&a.policy)
.with_context(|| format!("read {}", a.policy.display()))?,
)?;
let files = read_dirs(a.clamav_dir.as_deref(), a.yara_dir.as_deref())?;
let created_at = Utc::now();
let approvals = a
.approve
.iter()
.map(|s| parse_approval(s))
.collect::<Result<Vec<_>>>()?;
let built = build_sigs_bundle(
&files,
SigsBuild {
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 scanner-sigs build {}", env!("CARGO_PKG_VERSION")),
policy,
approvals,
release: SignerFiles::read(&a.release_cert, &a.release_key)?,
out: a.out.clone(),
},
)?;
println!(
"BUILT {} seq {}: {} signature file(s) -> {}",
built.bundle_id,
built.sequence,
built.files.len(),
built.path.display()
);
for f in &built.files {
println!(" {f}");
}
Ok(())
}
fn run_status(a: StatusArgs) -> Result<()> {
let holger = server_lib::read_ron_config(&a.config)?;
let db = SigsDb::open(holger_sandbox_layout(&holger.data_root()));
match db.installed()? {
None => println!("scanner signatures: none installed under {}; the sandboxed clamd reads the image's own database", db.root().display()),
Some(i) => {
println!("scanner signatures: {} seq {} from {} installed {} ({} file(s)) under {}", i.bundle_id, i.sequence, i.supplier, i.installed_at.format("%Y-%m-%d %H:%M"), i.files.len(), db.root().display());
for f in &i.files {
println!(" {} sha256 {} ({} bytes, seq {})", f.path, f.sha256, f.size, f.sequence);
}
}
}
Ok(())
}
/// `<data_root>/sandbox/signatures` (`holger_sandbox::media::Layout::signatures`).
fn holger_sandbox_layout(data_root: &Path) -> PathBuf {
data_root.join("sandbox").join("signatures")
}