Antivirus/crates/houndd/src/defs.rs
dev 38d1feeb0c 0.1.1: signed release notifications, and definitions that actually update
Two mechanisms Joe asked for, neither of which existed.

**Definitions now update themselves.** `auto_update_signatures` shipped
from the start and nothing ever read it — definitions only moved when
somebody typed `hound update`, which for a security product means most
installations were running whatever they were installed with. There is
now a scheduler thread: definitions hourly, release check daily, and a
90-second startup delay so a fleet that reboots together does not
arrive at the CDN in one wave. It calls the same install path as
`hound update` rather than a second implementation that could drift.

**Release notifications, signed.** dl.houndav.com/latest.json carries
an Ed25519 signature from the same key as the definition packs, for the
same reason: whoever serves that host must not be able to invent a
version and point our users at a binary of their choosing. A manifest
that does not verify is discarded, and no manifest means "nothing newer
known" — never "update available". The safe default is silence.

The signature covers a canonical byte form defined identically in
release.rs and tools/publish-release.py. Signing a re-serialisation of
a parsed struct is the classic way to verify one thing and act on
another, so a test runs a real manifest produced by the Python tool
through the Rust verifier against the production key. If those drift,
manifests verify nowhere and the only symptom is that nobody ever hears
about an update.

Version comparison is numeric, not lexical, because "0.9.0" > "0.10.0"
as strings and that is precisely the pair where it would first be
noticed. Downgrades are never offered — a signed-but-old manifest must
not become a way to reintroduce a fixed vulnerability.

The app does not replace its own binary. A root daemon that can rewrite
itself is the mechanism a supply-chain attacker most wants. Installing
goes through apt with the user present and authenticating: the package
is downloaded, checked against the manifest's SHA-256, staged into a
root-owned 0700 directory so nothing can substitute it between the
check and the read, and handed over. Two guards beyond the signature —
the URL must be on our own download host, because a signature proves
publisher intent and not that the publisher got the URL right.

Tray, per Joe's design: a new amber "attention" state ranked below a
threat and an in-flight scan, above protected. Deeper amber than
"scanning" so the two are distinguishable — scanning lasts seconds and
the user started it, attention persists. The tooltip carries the reason
rather than only that there is one, and "Install Hound X…" appears
enabled only when there is something to install; a permanently greyed
item teaches people the menu is decorative.

Definition staleness is a first-class signal, not just update
availability: amber at 7 days, and at 30 the wording stops pretending —
"this machine is not currently protected against anything found since
then". An antivirus showing green on month-old definitions is lying in
the same way a dead front-end showing "Protected" was.

Also: the four files that declare a version now have a test asserting
they agree. Drift there means a release looks older than what is
installed and the update silently never offers, or offers forever.

377 tests pass.

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

425 lines
15 KiB
Rust

//! Loading definition packs.
//!
//! A pack is data the scanner will act on, so the only question that
//! matters before loading one is whether it is really ours. Everything
//! here fails **closed** on that question and **open** on everything else:
//!
//! * No trusted key configured → load nothing, say so plainly. Running
//! with unverified definitions would be worse than running with none,
//! because the operator would believe they were protected.
//! * A pack that fails verification → skipped, logged, and the others
//! still load. One bad file must not cost you the whole feed.
//! * No packs at all → the daemon runs fine. Install scripts, prompt
//! injection, pickles and MCP audits need no feed, and refusing to start
//! would leave the machine with nothing.
//!
//! The trust store holds the PUBLIC half of the release signing key, in
//! the source, where everyone can read it — that is the point. There was
//! never a placeholder here: a fake key that looks real is how a
//! development shortcut becomes a shipped vulnerability, so until the
//! real one existed the store was empty and said so loudly.
use anyhow::{Context, Result};
use ed25519_dalek::VerifyingKey;
use hound_defs::{pack, Index, SignedPack};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
/// Keys whose packs this build will load.
///
/// The PUBLIC half of Hound's definitions signing key. It belongs in the
/// source: the agent is open source, everyone can read it, and that is
/// the point — anybody can verify that the packs they receive are the
/// ones we published. The private half never leaves the build machine
/// and is not in this repository.
///
/// Rotation: add the new key beside the old one, ship that build, then
/// start signing with the new key and remove the old one a release later.
/// Never swap in one step, or every agent that has not updated yet stops
/// accepting definitions.
const TRUSTED_KEYS: &[(&str, [u8; 32])] = &[(
"hound-2026",
[
0x12, 0xba, 0x51, 0x9f, 0x13, 0xe6, 0xe8, 0x37, 0x00, 0xef, 0x3e, 0xfb, 0x07, 0xe9,
0x32, 0x85, 0xc4, 0x88, 0x79, 0x30, 0x26, 0x04, 0xa3, 0x20, 0xa0, 0x2d, 0xc3, 0x64,
0x29, 0x90, 0xb4, 0x51,
],
)];
/// The production release key, for tests that must check against the real
/// one rather than a key the test made up.
#[cfg(test)]
pub fn production_key() -> [u8; 32] {
TRUSTED_KEYS[0].1
}
/// Where signed packs live.
pub fn defs_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") {
let p = PathBuf::from(dir);
return p.is_dir().then_some(p);
}
let system = PathBuf::from("/var/lib/hound/defs");
if system.is_dir() {
return Some(system);
}
let home = std::env::var_os("HOME")?;
let user = PathBuf::from(home).join(".local/share/hound/defs");
user.is_dir().then_some(user)
}
/// Assemble the trust store: compiled-in keys, plus a development key
/// from the environment when one is set.
pub fn trusted_keys() -> Vec<(String, VerifyingKey)> {
let mut keys: Vec<(String, VerifyingKey)> = TRUSTED_KEYS
.iter()
.filter_map(|(id, bytes)| {
VerifyingKey::from_bytes(bytes)
.ok()
.map(|k| ((*id).to_string(), k))
})
.collect();
if let Ok(hex) = std::env::var("HOUNDD_DEFS_KEY") {
match parse_hex_key(&hex) {
Some(k) => {
let id = std::env::var("HOUNDD_DEFS_KEY_ID").unwrap_or_else(|_| "dev".into());
eprintln!("defs: trusting development key {id} from the environment");
keys.push((id, k));
}
None => eprintln!("defs: HOUNDD_DEFS_KEY is not a 32-byte hex public key — ignored"),
}
}
keys
}
fn parse_hex_key(hex: &str) -> Option<VerifyingKey> {
let hex = hex.trim();
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
VerifyingKey::from_bytes(&bytes).ok()
}
/// A loaded, verified set of definitions.
pub struct Loaded {
pub index: Index,
/// Highest pack version loaded, for `hound status`.
pub version: String,
pub indicators: usize,
pub packs: Vec<String>,
pub loaded_at: SystemTime,
/// Why nothing loaded, when nothing did.
pub detail: String,
}
impl Loaded {
fn empty(detail: impl Into<String>) -> Self {
Self {
index: Index::new(),
version: String::new(),
indicators: 0,
packs: Vec::new(),
loaded_at: SystemTime::now(),
detail: detail.into(),
}
}
}
/// Hot-swappable definitions, mirroring how rules are held.
#[derive(Clone)]
pub struct DefsStore {
inner: Arc<RwLock<Arc<Loaded>>>,
}
impl DefsStore {
/// Load whatever is on disk. Never fails: an empty store is a working
/// daemon with fewer detections, and that beats no daemon.
pub fn load() -> Self {
Self {
inner: Arc::new(RwLock::new(Arc::new(load_all()))),
}
}
pub fn current(&self) -> Arc<Loaded> {
Arc::clone(&self.inner.read().expect("defs store poisoned"))
}
pub fn reload(&self) -> Arc<Loaded> {
let fresh = Arc::new(load_all());
*self.inner.write().expect("defs store poisoned") = Arc::clone(&fresh);
fresh
}
}
fn load_all() -> Loaded {
let keys = trusted_keys();
if keys.is_empty() {
return Loaded::empty(
"no signing key is trusted by this build, so no definitions were loaded",
);
}
let Some(dir) = defs_dir() else {
return Loaded::empty("no definitions directory");
};
let trusted: Vec<(&str, VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
let mut indicators = Vec::new();
let mut packs = Vec::new();
let mut version = String::new();
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "pack"))
.collect();
files.sort();
for path in files {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match load_one(&path, &trusted) {
Ok(p) => {
if p.version > version {
version = p.version.clone();
}
packs.push(format!("{name} ({} indicators)", p.indicators.len()));
indicators.extend(p.indicators);
}
// A bad pack is skipped rather than fatal: losing one file
// must not cost the whole feed.
Err(e) => eprintln!("defs: skipping {name}: {e}"),
}
}
if indicators.is_empty() {
return Loaded::empty("no verified packs were found");
}
let count = indicators.len();
Loaded {
index: Index::build(indicators),
version,
indicators: count,
packs,
loaded_at: SystemTime::now(),
detail: String::new(),
}
}
fn load_one(path: &std::path::Path, trusted: &[(&str, VerifyingKey)]) -> Result<hound_defs::Pack> {
let text = std::fs::read_to_string(path).context("reading the pack")?;
let signed: SignedPack = serde_json::from_str(&text).context("the pack is not valid JSON")?;
// Verification happens inside, on the raw payload bytes, before any
// of the content is parsed.
pack::verify(&signed, trusted).map_err(|e| anyhow::anyhow!("{e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use hound_defs::{Indicator, Pack, Versions};
fn a_pack(name: &str) -> Pack {
Pack {
version: "2026.08.21".into(),
created: "2026-08-21T12:00:00Z".into(),
sources: vec!["test".into()],
indicators: vec![Indicator {
ecosystem: "cratesio".into(),
name: name.into(),
versions: Versions::All,
id: "MAL-2022-1".into(),
summary: "malicious".into(),
}],
}
}
fn write_pack(dir: &std::path::Path, file: &str, p: &Pack, key: &SigningKey, id: &str) {
let signed = pack::sign(p, key, id).unwrap();
std::fs::write(dir.join(file), serde_json::to_string(&signed).unwrap()).unwrap();
}
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hound-defs-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn hex_of(k: &SigningKey) -> String {
k.verifying_key()
.to_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn the_trust_store_holds_exactly_the_release_key() {
// A second key appearing here without a rotation plan is how a
// development shortcut becomes a shipped vulnerability.
assert_eq!(TRUSTED_KEYS.len(), 1);
assert_eq!(TRUSTED_KEYS[0].0, "hound-2026");
assert!(
VerifyingKey::from_bytes(&TRUSTED_KEYS[0].1).is_ok(),
"the compiled-in key must be a valid ed25519 public key"
);
}
#[test]
fn the_release_key_verifies_a_pack_signed_by_it() {
// Catches a fat-fingered byte in the constant, which would
// silently stop every agent from accepting definitions.
let published = std::path::Path::new("/srv/houndav/defs");
if !published.is_dir() {
return; // only meaningful on the build host
}
let trusted: Vec<(&str, VerifyingKey)> = TRUSTED_KEYS
.iter()
.filter_map(|(id, b)| VerifyingKey::from_bytes(b).ok().map(|k| (*id, k)))
.collect();
let mut checked = 0;
for e in std::fs::read_dir(published).into_iter().flatten().flatten() {
let p = e.path();
if p.extension().is_none_or(|x| x != "pack") {
continue;
}
checked += 1;
let text = std::fs::read_to_string(&p).unwrap();
let signed: SignedPack = serde_json::from_str(&text).unwrap();
assert!(
pack::verify(&signed, &trusted).is_ok(),
"the compiled key does not verify {p:?}"
);
}
let _ = checked;
}
#[test]
fn an_empty_definitions_directory_says_so_rather_than_staying_silent() {
// Silence would let an operator believe they were protected when
// nothing had loaded.
let dir = tmp("empty");
let _guard = crate::test_util::locked();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(loaded.indicators, 0);
assert!(
!loaded.detail.is_empty(),
"an empty load must explain itself"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_signed_pack_loads_and_becomes_searchable() {
let dir = tmp("load");
let key = SigningKey::from_bytes(&[3u8; 32]);
write_pack(&dir, "linux.pack", &a_pack("rustdecimal"), &key, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(loaded.indicators, 1);
assert_eq!(loaded.version, "2026.08.21");
assert!(loaded.index.lookup("cratesio", "rustdecimal", "1.0.0").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pack_signed_by_the_wrong_key_is_refused() {
let dir = tmp("wrongkey");
let real = SigningKey::from_bytes(&[3u8; 32]);
let attacker = SigningKey::from_bytes(&[9u8; 32]);
write_pack(&dir, "evil.pack", &a_pack("sudo"), &attacker, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&real));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(
loaded.indicators, 0,
"a pack that could make Hound quarantine sudo must never load"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_bad_pack_does_not_cost_the_others() {
let dir = tmp("mixed");
let key = SigningKey::from_bytes(&[3u8; 32]);
let attacker = SigningKey::from_bytes(&[9u8; 32]);
write_pack(&dir, "a-good.pack", &a_pack("rustdecimal"), &key, "dev");
write_pack(&dir, "b-bad.pack", &a_pack("sudo"), &attacker, "dev");
std::fs::write(dir.join("c-garbage.pack"), b"not json at all").unwrap();
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(loaded.indicators, 1);
assert!(loaded.index.lookup("cratesio", "rustdecimal", "1.0.0").is_some());
assert!(
loaded.index.lookup("cratesio", "sudo", "1.0.0").is_none(),
"the unsigned pack's content must not have leaked in"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_malformed_key_in_the_environment_is_ignored_not_trusted() {
let _guard = crate::test_util::locked();
std::env::remove_var("HOUNDD_DEFS_KEY");
let baseline = trusted_keys().len();
std::env::set_var("HOUNDD_DEFS_KEY", "obviously-not-hex");
let keys = trusted_keys();
std::env::remove_var("HOUNDD_DEFS_KEY");
assert_eq!(
keys.len(),
baseline,
"garbage in the environment must not enter the trust store"
);
}
#[test]
fn hex_keys_of_the_wrong_length_are_rejected() {
assert!(parse_hex_key("aabb").is_none());
assert!(parse_hex_key(&"a".repeat(63)).is_none());
assert!(parse_hex_key(&"zz".repeat(32)).is_none());
}
#[test]
fn a_valid_hex_key_parses() {
let key = SigningKey::from_bytes(&[5u8; 32]);
assert_eq!(
parse_hex_key(&hex_of(&key)).map(|k| k.to_bytes()),
Some(key.verifying_key().to_bytes())
);
}
}