Antivirus/crates/hound-defs/examples/build-pack.rs
Hound 13d86c167e houndd: fetch definitions from defs.houndav.com, and refuse anything unsigned
Closes Phase 3's delivery half. `hound update` now asks the definitions
host what exists, downloads what this machine lacks, verifies it, and
installs it. Verified end to end against the live host over TLS:

  starting from an empty directory:
    defs: no verified packs were found
  hound update:
    definitions: 1 pack(s) installed, 0 already current
    crates-io-2026.08.21.pack — 19 indicators, version 2026.08.21
    loaded 19 indicators from 1 pack(s) [2026.08.21]
  and immediately afterwards a lockfile naming rustdecimal is flagged,
  while tokio beside it is not.

  running it again:
    definitions: definitions are up to date (1 pack(s))

  a pack with one byte flipped in its signed payload, advertised in the
  index with a correct hash and a version of 9999.1.1:
    definitions: 0 pack(s) installed, 1 up to date, 1 REJECTED
    tampered-test.pack: the definitions pack is not signed by Hound and
    was discarded

Three refusals are the design:

* THE INDEX IS A HINT, NEVER AN AUTHORITY. It says which packs exist and
  what they hash to, and both are unverified — anyone who can serve the
  index can lie about either. Only the Ed25519 signature decides whether
  a pack is real. A tampered index can waste bandwidth and nothing else,
  which is exactly what the test above demonstrates: correct hash,
  higher version, still refused.

* NOTHING UNVERIFIED REACHES THE DEFINITIONS DIRECTORY. Downloaded to a
  temp file, verified there, then moved with a rename inside the same
  directory so it is atomic. The daemon cannot observe a half-written
  pack, and a crash mid-download leaves a stray temp file rather than a
  loadable one.

* A FILENAME FROM A REMOTE INDEX IS UNTRUSTED INPUT. The updater runs as
  root, so an entry of ../../../etc/cron.d/evil.pack would be remote code
  execution. Only a plain basename ending in .pack, with no separators,
  no dot-dot and no leading dot, is accepted. Tested against eight
  hostile shapes.

Sizes and timeouts are bounded — a pack is a list of package names, so a
server offering a hundred gigabytes is broken or hostile and the
difference does not matter to a full disk. The read is bounded
independently of Content-Length, which is also just something the server
said.

Definitions are fetched BEFORE rules are reloaded, and a failure to fetch
does not stop the reload. No network, a mirror down, a pack that will not
verify — none of those are a reason to skip the half that still works.

HOUNDD_DEFS_URL points the client at a mirror, which matters for the
air-gapped deployments that are a real part of the Fleet story.

build-pack now writes index.json beside the pack, so publishing is one
command.

358 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:15:41 -05:00

143 lines
5.7 KiB
Rust

//! Build and sign a definitions pack from a directory of OSV records.
//!
//! build-pack <osv-dir> <out.pack> <key-file> [version]
//!
//! If the key file does not exist a new signing key is generated into it
//! at mode 0600 and its public half is printed. That public key is what
//! goes into the agent's trust store; the private half never leaves the
//! build machine and is never committed.
//!
//! Key material comes straight from /dev/urandom rather than through a
//! random-number crate: it is thirty-two bytes from the kernel CSPRNG,
//! and fewer moving parts between the entropy source and the key file is
//! the right trade for something this consequential.
use ed25519_dalek::SigningKey;
use hound_defs::{osv, pack, Pack};
use std::io::Read;
fn load_or_create_key(path: &str) -> SigningKey {
if let Ok(bytes) = std::fs::read(path) {
if bytes.len() == 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
return SigningKey::from_bytes(&seed);
}
eprintln!("{path} is not a 32-byte key; refusing to overwrite it");
std::process::exit(2);
}
let mut seed = [0u8; 32];
std::fs::File::open("/dev/urandom")
.expect("opening /dev/urandom")
.read_exact(&mut seed)
.expect("reading key material");
std::fs::write(path, seed).expect("writing the key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
let key = SigningKey::from_bytes(&seed);
let hex: String = key
.verifying_key()
.to_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
eprintln!("generated a new signing key in {path} (mode 0600)");
eprintln!("public key: {hex}");
eprintln!(" put that in the agent's trust store, or export it for development:");
eprintln!(" export HOUNDD_DEFS_KEY={hex}");
key
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 3 {
eprintln!("usage: build-pack <osv-dir> <out.pack> <key-file> [version]");
std::process::exit(2);
}
let (dir, out, key_path) = (&args[0], &args[1], &args[2]);
let version = args
.get(3)
.cloned()
.unwrap_or_else(|| "0.0.0-dev".to_string());
let key = load_or_create_key(key_path);
let mut indicators = Vec::new();
let mut files = 0usize;
for entry in std::fs::read_dir(dir).expect("readable OSV directory").flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
files += 1;
if let Ok(text) = std::fs::read_to_string(&path) {
indicators.extend(osv::parse_record(&text));
}
}
// Sorted so the same input always produces the same bytes. A pack's
// hash is its identity; directory order is not stable across machines
// and would make two identical builds disagree.
indicators.sort_by(|a, b| (&a.ecosystem, &a.name, &a.id).cmp(&(&b.ecosystem, &b.name, &b.id)));
indicators.dedup();
let p = Pack {
version: version.clone(),
// Passed in rather than read from the clock, for the same reason.
created: args.get(4).cloned().unwrap_or_else(|| "1970-01-01T00:00:00Z".into()),
sources: vec!["ossf/malicious-packages (Apache-2.0)".into(), "osv.dev".into()],
indicators,
};
let key_id = std::env::var("HOUND_KEY_ID").unwrap_or_else(|_| "hound-2026".into());
let signed = pack::sign(&p, &key, &key_id).expect("signing the pack");
std::fs::write(out, serde_json::to_string(&signed).expect("encoding")).expect("writing");
// Write/refresh index.json beside the pack, so the update client has
// something to read. The index is advisory — the pack's signature is
// what decides whether it is genuine — but a correct hash here saves
// every agent a download it does not need.
let out_path = std::path::Path::new(out);
if let Some(dir) = out_path.parent() {
let mut entries: Vec<serde_json::Value> = Vec::new();
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.extension().is_none_or(|x| x != "pack") {
continue;
}
let Ok(bytes) = std::fs::read(&p) else { continue };
let mut h = <sha2::Sha256 as sha2::Digest>::new();
sha2::Digest::update(&mut h, &bytes);
let digest = format!("{:x}", sha2::Digest::finalize(h));
let name = p.file_name().unwrap().to_string_lossy().into_owned();
let ver = serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| {
let payload = v.get("payload")?.as_str()?.to_string();
Some(payload)
})
.map(|_| version.clone())
.unwrap_or_default();
entries.push(serde_json::json!({
"file": name, "version": ver, "sha256": digest, "size": bytes.len()
}));
}
entries.sort_by(|a, b| a["file"].as_str().cmp(&b["file"].as_str()));
let index = serde_json::json!({ "packs": entries });
let index_path = dir.join("index.json");
std::fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap())
.expect("writing index.json");
println!("index {} ({} pack(s))", index_path.display(), index["packs"].as_array().unwrap().len());
}
println!("read {files} OSV records");
println!("packed {} indicators", p.indicators.len());
println!("version {version}");
println!("sha256 {}", p.sha256().expect("hashing"));
println!("wrote {out}");
}