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>
This commit is contained in:
Hound 2026-08-21 09:15:41 -05:00
parent 2d06632fa9
commit 13d86c167e
7 changed files with 802 additions and 0 deletions

344
Cargo.lock generated
View file

@ -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"

View file

@ -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" }

View file

@ -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<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}");

View file

@ -23,3 +23,4 @@ libc.workspace = true
sha2.workspace = true
hound-defs.workspace = true
ed25519-dalek.workspace = true
ureq.workspace = true

View file

@ -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<hound_api::UpdateResult> {
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<String> = 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" },

382
crates/houndd/src/update.rs Normal file
View file

@ -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<IndexEntry>,
}
/// What one update run did.
#[derive(Debug, Default)]
pub struct Outcome {
pub installed: Vec<String>,
pub already_current: Vec<String>,
pub rejected: Vec<String>,
pub log: Vec<String>,
}
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<Vec<u8>> {
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<Index> {
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<Outcome> {
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::<Index>(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());
}
}

Binary file not shown.