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>
143 lines
5.7 KiB
Rust
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}");
|
|
}
|