diff --git a/crates/hound-defs/examples/build-pack.rs b/crates/hound-defs/examples/build-pack.rs index 70dfbc3..d109d39 100644 --- a/crates/hound-defs/examples/build-pack.rs +++ b/crates/hound-defs/examples/build-pack.rs @@ -94,7 +94,8 @@ fn main() { indicators, }; - let signed = pack::sign(&p, &key, "dev").expect("signing the pack"); + 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"); println!("read {files} OSV records"); diff --git a/crates/houndd/rules/hound-builtin.yar b/crates/houndd/rules/hound-builtin.yar index 2e15717..e8f42fd 100644 --- a/crates/houndd/rules/hound-builtin.yar +++ b/crates/houndd/rules/hound-builtin.yar @@ -20,7 +20,17 @@ rule EICAR_Test_File strings: $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" condition: - $eicar + // The standard defines the EICAR file as exactly this 68-byte + // string, optionally padded with whitespace to at most 128 bytes. + // Without the size bound this rule matches any file that merely + // CONTAINS the string — and on a live server it quarantined an + // 8.5 MB rustc incremental-compilation cache, because the test + // source being compiled contained the literal. That killed the + // build with a compiler panic. + // + // Anyone whose source, logs or documentation mention EICAR has + // the same problem, which is most security work. + filesize <= 128 and $eicar } rule Linux_Coinminer_XMRig @@ -62,7 +72,16 @@ rule Linux_Webshell_PHP_Eval $src3 = "$_REQUEST" $src4 = "$_COOKIE" condition: - $php and $eval1 and 1 of ($src*) + // A webshell is a PHP file: small, and opening with a PHP tag. + // Without those bounds this matched a 4.3 MB AI session + // transcript on a live server — the conversation happened to + // discuss webshells, so it contained " Option { @@ -246,26 +261,63 @@ mod tests { } #[test] - fn the_build_ships_no_placeholder_key() { - // A fake key that looks real is how a development shortcut becomes - // a shipped vulnerability. + 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!( - TRUSTED_KEYS.is_empty(), - "a placeholder signing key must never be compiled in" + VerifyingKey::from_bytes(&TRUSTED_KEYS[0].1).is_ok(), + "the compiled-in key must be a valid ed25519 public key" ); } #[test] - fn without_a_trusted_key_nothing_loads_and_it_says_why() { + 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.contains("no signing key"), - "silence here would let an operator believe they were protected: {}", - loaded.detail + !loaded.detail.is_empty(), + "an empty load must explain itself" ); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -336,10 +388,16 @@ mod tests { #[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!(keys.is_empty()); + assert_eq!( + keys.len(), + baseline, + "garbage in the environment must not enter the trust store" + ); } #[test] diff --git a/crates/houndd/src/rules.rs b/crates/houndd/src/rules.rs index e7c0e8d..01e7c4f 100644 --- a/crates/houndd/src/rules.rs +++ b/crates/houndd/src/rules.rs @@ -303,6 +303,136 @@ mod tests { ); } + /// Rules must not match haystacks — and this test builds the haystack + /// FROM the rule pack itself, so a new rule cannot be forgotten. + /// + /// Every content rule needs an anchor: a file-type check, a size + /// bound, or a position constraint. Without one it fires on anything + /// that happens to contain its strings. That has now cost four + /// separate incidents on a live server — the daemon's own binary, an + /// agent's session transcript twice, and an 8.5 MB rustc incremental + /// cache that took the build down with it. + /// + /// The first version of this test hand-listed the strings to include, + /// and duly missed the webshell rule's " = Vec::new(); + for line in src.lines() { + let t = line.trim(); + if !t.starts_with('$') || !t.contains('=') { + continue; + } + let mut chars = t.chars().peekable(); + let mut current = String::new(); + let mut inside = false; + while let Some(c) = chars.next() { + match c { + '\\' if inside => { + // Keep the escape's target, drop the backslash, so + // "\\PZX" contributes the bytes a file would hold. + if let Some(n) = chars.next() { + current.push(n); + } + } + '"' => { + if inside { + if !current.is_empty() { + literals.push(std::mem::take(&mut current)); + } + inside = false; + } else { + inside = true; + } + } + _ if inside => current.push(c), + _ => {} + } + } + } + assert!( + literals.len() >= 15, + "expected to extract the pack's strings, got {}: {literals:?}", + literals.len() + ); + for expected in [" = b"// build artefact / log / session transcript\n".to_vec(); + for l in &literals { + haystack.extend_from_slice(l.as_bytes()); + haystack.push(b'\n'); + } + haystack.resize(4 * 1024 * 1024, b'\n'); + + let set = RuleSet::compile().unwrap(); + let mut scanner = yara_x::Scanner::new(&set.rules); + let hits: Vec = scanner + .scan(&haystack) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!( + hits.is_empty(), + "a 4 MB document mentioning every rule string is not malware: {hits:?}" + ); + } + + #[test] + fn a_real_webshell_is_still_caught() { + // The bounds must not cost the detection they exist for. + let set = RuleSet::compile().unwrap(); + let mut scanner = yara_x::Scanner::new(&set.rules); + let shell = br#""#; + let hits: Vec = scanner + .scan(shell) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!( + hits.iter().any(|h| h == "Linux.Webshell.PHP-Eval"), + "got {hits:?}" + ); + } + + #[test] + fn a_real_eicar_file_is_still_caught() { + // The size bound must not cost the detection it exists for. + let set = RuleSet::compile().unwrap(); + let mut scanner = yara_x::Scanner::new(&set.rules); + let eicar = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + let hits: Vec = scanner + .scan(eicar) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!(hits.iter().any(|h| h == "EICAR-Test-Signature"), "got {hits:?}"); + + // And padded to the 128 bytes the standard allows. + let mut padded = eicar.to_vec(); + padded.resize(128, b' '); + let hits2: Vec = scanner + .scan(&padded) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!(hits2.iter().any(|h| h == "EICAR-Test-Signature")); + } + #[test] fn a_real_elf_miner_is_still_caught() { // Requiring ELF magic must not cost the detection it exists for. diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 29266b2..dc4d6ba 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ