diff --git a/Cargo.lock b/Cargo.lock index 84dd7e2..a2ce25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -893,6 +893,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "funty" version = "2.0.0" @@ -1144,6 +1153,7 @@ dependencies = [ "serde_json", "sha2", "time", + "ureq", "yara-x", ] @@ -1171,6 +1181,89 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1183,6 +1276,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "ignore" version = "0.4.33" @@ -1331,6 +1445,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" @@ -1607,6 +1727,12 @@ dependencies = [ "base64ct", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1646,6 +1772,15 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1871,6 +2006,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rowan" version = "0.16.1" @@ -1968,6 +2117,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2321,6 +2505,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyzip" version = "0.4.0" @@ -2364,12 +2558,52 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unty" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2684,6 +2918,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -2764,6 +3016,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2846,6 +3107,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "wyz" version = "0.5.1" @@ -2992,6 +3259,29 @@ dependencies = [ "yansi", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -3012,12 +3302,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zip" version = "8.6.0" diff --git a/Cargo.toml b/Cargo.toml index b41d895..0c5803e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ yara-x = "1.19" libc = "0.2" sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } +ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } hound-defs = { path = "crates/hound-defs" } hound-mcp = { path = "crates/hound-mcp" } diff --git a/crates/hound-defs/examples/build-pack.rs b/crates/hound-defs/examples/build-pack.rs index d109d39..5159b5f 100644 --- a/crates/hound-defs/examples/build-pack.rs +++ b/crates/hound-defs/examples/build-pack.rs @@ -98,6 +98,43 @@ fn main() { 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 = 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 = ::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::(&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}"); diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index a9fe74c..ee178ff 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -23,3 +23,4 @@ libc.workspace = true sha2.workspace = true hound-defs.workspace = true ed25519-dalek.workspace = true +ureq.workspace = true diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 56d8f9f..d9aa91e 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -53,6 +53,7 @@ mod realtime; mod rootkit; mod rules; mod settings; +mod update; #[cfg(test)] mod test_util; @@ -705,7 +706,43 @@ fn gate_status(st: &DaemonState) -> hound_api::GateStatus { fn update(st: &DaemonState) -> Result { use hound_api::UpdateResult; + // Definitions first, rules second. Fetching can fail — no network, a + // mirror down, a pack that will not verify — and none of that is a + // reason to skip reloading what is already on disk. An update that + // refuses to do the half it can do is worse than one that reports + // both halves honestly. + let mut lines: Vec = Vec::new(); + let keys = defs::trusted_keys(); + let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> = + keys.iter().map(|(id, k)| (id.as_str(), *k)).collect(); + + match update::run(&update::install_dir(), &trusted) { + Ok(outcome) => { + lines.push(format!("definitions: {}", outcome.summary())); + lines.extend(outcome.log.iter().map(|l| format!(" {l}"))); + if !outcome.installed.is_empty() { + let loaded = st.defs.reload(); + lines.push(format!( + " loaded {} indicators from {} pack(s) [{}]", + loaded.indicators, + loaded.packs.len(), + loaded.version + )); + } + let sev = if outcome.rejected.is_empty() { "info" } else { "warn" }; + st.events + .push("update", sev, format!("definitions: {}", outcome.summary())); + } + Err(e) => { + // Reported, not fatal. + lines.push(format!("definitions: could not update — {e}")); + st.events + .push("update", "warn", format!("definitions update failed: {e}")); + } + } + let (ok, command, combined) = engine::engine().update()?; + let combined = format!("{}\n{combined}", lines.join("\n")); st.events.push( "update", if ok { "info" } else { "warn" }, diff --git a/crates/houndd/src/update.rs b/crates/houndd/src/update.rs new file mode 100644 index 0000000..1eacd0f --- /dev/null +++ b/crates/houndd/src/update.rs @@ -0,0 +1,382 @@ +//! Fetching definition packs. +//! +//! The agent asks `defs.houndav.com` what exists, downloads what it does +//! not have, and installs it. The interesting part is what it refuses to +//! trust along the way. +//! +//! **The index is a hint, never an authority.** It says which packs exist +//! and what they should hash to, and both claims are unverified — anyone +//! who can serve the index can lie about either. The only thing that +//! decides whether a pack is real is its Ed25519 signature, checked +//! against the public key compiled into this binary. A tampered index can +//! therefore waste bandwidth and nothing else. +//! +//! **Nothing unverified ever lands in the definitions directory.** A pack +//! is downloaded to a temporary file, verified there, and only then moved +//! into place with a rename. A rename within one directory is atomic, so +//! the daemon can never observe a half-written pack — and a crash +//! mid-download leaves a stray temp file rather than a loadable one. +//! +//! **Everything is bounded.** A definitions pack is small; a server that +//! offers a hundred gigabytes is either broken or hostile, and the +//! difference does not matter to a disk that is now full. + +use anyhow::{bail, Context, Result}; +use hound_defs::{pack, SignedPack}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// Where packs come from. Overridable for testing and for air-gapped +/// mirrors, which are a real deployment rather than a hypothetical. +pub fn base_url() -> String { + std::env::var("HOUNDD_DEFS_URL").unwrap_or_else(|_| "https://defs.houndav.com".into()) +} + +/// A pack is a list of package names. Anything approaching this is not +/// one. +const MAX_PACK_BYTES: u64 = 64 * 1024 * 1024; + +/// The index is a handful of entries. +const MAX_INDEX_BYTES: u64 = 1024 * 1024; + +/// Long enough for a slow link, short enough that `hound update` cannot +/// hang a terminal indefinitely. +const TIMEOUT_SECS: u64 = 30; + +#[derive(Debug, Clone, Deserialize)] +pub struct IndexEntry { + pub file: String, + #[serde(default)] + pub version: String, + /// Advisory only — see the module note. Used to skip a download we + /// already have, never to decide a pack is genuine. + #[serde(default)] + pub sha256: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Index { + #[serde(default)] + pub packs: Vec, +} + +/// What one update run did. +#[derive(Debug, Default)] +pub struct Outcome { + pub installed: Vec, + pub already_current: Vec, + pub rejected: Vec, + pub log: Vec, +} + +impl Outcome { + pub fn summary(&self) -> String { + if !self.rejected.is_empty() { + return format!( + "{} pack(s) installed, {} up to date, {} REJECTED", + self.installed.len(), + self.already_current.len(), + self.rejected.len() + ); + } + if self.installed.is_empty() { + return format!("definitions are up to date ({} pack(s))", self.already_current.len()); + } + format!( + "{} pack(s) installed, {} already current", + self.installed.len(), + self.already_current.len() + ) + } +} + +/// A filename from a remote index is untrusted input. +/// +/// Without this, an entry of `../../../etc/cron.d/evil` would have the +/// updater write wherever it liked — a path traversal handed to a process +/// running as root. Only a plain basename ending in `.pack` is accepted. +pub fn safe_pack_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name.ends_with(".pack") + && !name.contains('/') + && !name.contains('\\') + && !name.contains("..") + && !name.starts_with('.') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + format!("{:x}", h.finalize()) +} + +fn get(url: &str, max: u64) -> Result> { + let resp = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) + .user_agent(concat!("hound/", env!("CARGO_PKG_VERSION"))) + .build() + .get(url) + .call() + .with_context(|| format!("fetching {url}"))?; + + let mut buf = Vec::new(); + // take() bounds the read regardless of what Content-Length claims, + // because Content-Length is also just something the server said. + resp.into_reader() + .take(max + 1) + .read_to_end(&mut buf) + .with_context(|| format!("reading {url}"))?; + if buf.len() as u64 > max { + bail!("{url} is larger than {max} bytes; refusing it"); + } + Ok(buf) +} + +/// Fetch the index. +pub fn fetch_index(base: &str) -> Result { + let bytes = get(&format!("{base}/index.json"), MAX_INDEX_BYTES)?; + serde_json::from_slice(&bytes).context("the definitions index is not valid JSON") +} + +/// Fetch, verify and install everything the index offers that we lack. +pub fn run(dir: &Path, trusted: &[(&str, ed25519_dalek::VerifyingKey)]) -> Result { + let base = base_url(); + let mut out = Outcome::default(); + + if trusted.is_empty() { + bail!("no signing key is trusted by this build, so no pack could be verified"); + } + std::fs::create_dir_all(dir) + .with_context(|| format!("creating {}", dir.display()))?; + + let index = fetch_index(&base)?; + if index.packs.is_empty() { + out.log.push(format!("{base} offers no packs")); + return Ok(out); + } + + for entry in &index.packs { + if !safe_pack_name(&entry.file) { + out.rejected.push(entry.file.clone()); + out.log + .push(format!("refused a pack name that is not a plain filename: {:?}", entry.file)); + continue; + } + let dest = dir.join(&entry.file); + + // Already have these exact bytes? Nothing to do. + if let Ok(existing) = std::fs::read(&dest) { + if !entry.sha256.is_empty() && sha256_hex(&existing) == entry.sha256 { + out.already_current.push(entry.file.clone()); + continue; + } + } + + let url = format!("{base}/{}", entry.file); + let bytes = match get(&url, MAX_PACK_BYTES) { + Ok(b) => b, + Err(e) => { + out.rejected.push(entry.file.clone()); + out.log.push(format!("{}: {e}", entry.file)); + continue; + } + }; + + // VERIFY BEFORE INSTALL. Not after, and not "install then check". + let signed: SignedPack = match serde_json::from_slice(&bytes) { + Ok(s) => s, + Err(e) => { + out.rejected.push(entry.file.clone()); + out.log.push(format!("{}: not a definitions pack ({e})", entry.file)); + continue; + } + }; + let verified = match pack::verify(&signed, trusted) { + Ok(p) => p, + Err(e) => { + out.rejected.push(entry.file.clone()); + out.log.push(format!("{}: {e}", entry.file)); + continue; + } + }; + + // Write beside the destination so the rename stays within one + // filesystem and is therefore atomic. + let tmp = dir.join(format!(".{}.part", entry.file)); + if let Err(e) = std::fs::write(&tmp, &bytes) { + out.rejected.push(entry.file.clone()); + out.log.push(format!("{}: could not write: {e}", entry.file)); + continue; + } + if let Err(e) = std::fs::rename(&tmp, &dest) { + let _ = std::fs::remove_file(&tmp); + out.rejected.push(entry.file.clone()); + out.log.push(format!("{}: could not install: {e}", entry.file)); + continue; + } + + out.log.push(format!( + "{} — {} indicators, version {}", + entry.file, + verified.indicators.len(), + verified.version + )); + out.installed.push(entry.file.clone()); + } + + Ok(out) +} + +/// Where the daemon keeps packs, creating the system location when root. +pub fn install_dir() -> PathBuf { + if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") { + return PathBuf::from(dir); + } + if crate::caps::is_root() { + return PathBuf::from("/var/lib/hound/defs"); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + PathBuf::from(home).join(".local/share/hound/defs") +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── the untrusted filename ── + + #[test] + fn a_traversing_pack_name_is_refused() { + // The updater runs as root. A remote index that could name + // ../../../etc/cron.d/evil would be remote code execution. + for bad in [ + "../../../etc/cron.d/evil.pack", + "..%2f..%2fevil.pack", + "/etc/evil.pack", + "sub/dir.pack", + "a\\b.pack", + ".hidden.pack", + "no-extension", + "", + ] { + assert!(!safe_pack_name(bad), "{bad:?} should have been refused"); + } + } + + #[test] + fn an_ordinary_pack_name_is_accepted() { + for good in ["crates-io-2026.08.21.pack", "npm_2026.pack", "a.pack"] { + assert!(safe_pack_name(good), "{good:?} should have been accepted"); + } + } + + #[test] + fn an_absurdly_long_name_is_refused() { + assert!(!safe_pack_name(&format!("{}.pack", "a".repeat(200)))); + } + + // ── outcome reporting ── + + #[test] + fn a_rejection_is_never_hidden_behind_a_success_count() { + let mut o = Outcome::default(); + o.installed.push("a.pack".into()); + o.rejected.push("b.pack".into()); + let s = o.summary(); + assert!(s.contains("REJECTED"), "a refused pack must be visible: {s}"); + } + + #[test] + fn nothing_to_do_reads_as_up_to_date() { + let mut o = Outcome::default(); + o.already_current.push("a.pack".into()); + assert!(o.summary().contains("up to date")); + } + + // ── hashing ── + + #[test] + fn hashes_are_stable_and_content_sensitive() { + assert_eq!(sha256_hex(b"abc"), sha256_hex(b"abc")); + assert_ne!(sha256_hex(b"abc"), sha256_hex(b"abd")); + assert_eq!(sha256_hex(b"").len(), 64); + } + + // ── verification gates installation ── + + #[test] + fn an_unsigned_pack_is_never_installed() { + use ed25519_dalek::SigningKey; + use hound_defs::{Indicator, Pack, Versions}; + + let dir = std::env::temp_dir().join(format!("hound-upd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let real = SigningKey::from_bytes(&[1u8; 32]); + let attacker = SigningKey::from_bytes(&[2u8; 32]); + let p = Pack { + version: "9999.99.99".into(), + created: "2026-01-01T00:00:00Z".into(), + sources: vec![], + indicators: vec![Indicator { + ecosystem: "npm".into(), + name: "sudo".into(), + versions: Versions::All, + id: "MAL-EVIL".into(), + summary: "would make Hound quarantine sudo".into(), + }], + }; + let forged = pack::sign(&p, &attacker, "hound-2026").unwrap(); + let trusted = [("hound-2026", real.verifying_key())]; + + // The exact check the updater performs before writing anything. + assert!( + pack::verify(&forged, &trusted).is_err(), + "a pack signed by the wrong key must never reach the defs directory" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn refuses_to_run_with_an_empty_trust_store() { + let dir = std::env::temp_dir().join(format!("hound-upd-nokey-{}", std::process::id())); + let e = run(&dir, &[]).unwrap_err(); + assert!(e.to_string().contains("no signing key")); + } + + // ── configuration ── + + #[test] + fn the_source_can_be_pointed_elsewhere_for_mirrors() { + let _guard = crate::test_util::locked(); + std::env::remove_var("HOUNDD_DEFS_URL"); + assert_eq!(base_url(), "https://defs.houndav.com"); + std::env::set_var("HOUNDD_DEFS_URL", "http://mirror.internal/defs"); + assert_eq!(base_url(), "http://mirror.internal/defs"); + std::env::remove_var("HOUNDD_DEFS_URL"); + } + + #[test] + fn limits_are_small_enough_to_be_a_real_bound() { + // A definitions pack is a list of package names. These exist so a + // broken or hostile server cannot fill a disk. + assert!(MAX_PACK_BYTES <= 64 * 1024 * 1024); + assert!(MAX_INDEX_BYTES <= 1024 * 1024); + assert!(TIMEOUT_SECS <= 60); + } + + #[test] + fn a_malformed_index_is_an_error_not_a_panic() { + assert!(serde_json::from_slice::(b"{not json").is_err()); + // An index with no packs array is empty rather than fatal. + let empty: Index = serde_json::from_slice(b"{}").unwrap(); + assert!(empty.packs.is_empty()); + } +} diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 0fc897c..3951412 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ