diff --git a/crates/houndd/rules/hound-builtin.yar b/crates/houndd/rules/hound-builtin.yar index 4454456..2e15717 100644 --- a/crates/houndd/rules/hound-builtin.yar +++ b/crates/houndd/rules/hound-builtin.yar @@ -37,7 +37,15 @@ rule Linux_Coinminer_XMRig $cfg3 = "randomx" ascii nocase $name = "xmrig" ascii nocase condition: - ($pool1 or $pool2) and 2 of ($cfg*) and $name + // ELF magic is not optional here. + // + // Without it this rule matches any TEXT that mentions mining: + // a blog post, a support ticket, a threat-intelligence report, + // or — as happened on a live server — an AI session transcript + // in which somebody was writing this very rule. Malware is a + // program; a document about malware is not. + uint32(0) == 0x464c457f + and ($pool1 or $pool2) and 2 of ($cfg*) and $name } rule Linux_Webshell_PHP_Eval diff --git a/crates/houndd/src/fanotify.rs b/crates/houndd/src/fanotify.rs index df58004..b0907bc 100644 --- a/crates/houndd/src/fanotify.rs +++ b/crates/houndd/src/fanotify.rs @@ -434,7 +434,24 @@ impl Gate { libc::fanotify_mark( self.responder.fan.as_raw_fd(), flags, - FAN_OPEN_EXEC_PERM | FAN_OPEN_PERM | FAN_CLOSE_WRITE, + // FAN_OPEN_PERM is deliberately NOT requested. + // + // It fires on every open of every file on the filesystem, + // which is an enormous event volume for no protective + // value we can actually use: we do not block reads (see + // the deny-only-on-exec note in `serve`), so each one is a + // read, a scan and an allow. On a live server that meant + // re-scanning a multi-megabyte agent transcript on every + // open, which is what pushed an event past the watchdog + // deadline within seconds of arming. + // + // What remains covers the threat: execution is refused + // before it happens, and anything malicious written to + // disk is quarantined when the write completes. An + // interpreted script is caught as it lands rather than as + // it is read, which is the same protection one step + // earlier. + FAN_OPEN_EXEC_PERM | FAN_CLOSE_WRITE, libc::AT_FDCWD, c_path.as_ptr(), ) @@ -690,12 +707,29 @@ impl Gate { } if event.needs_response() { - // Someone is blocked. Answer first, report - // second — the report must never sit between - // a process and its verdict. - gate.answer_and_retire(event.seq, event.fd, hit.is_none()); + // DENY ONLY ON EXECUTION. + // + // A permission event covers both execve and + // an ordinary open. Denying the open means a + // matching file cannot be READ by anything — + // which on a live server blocked reads of an + // AI agent's own transcript and came within a + // whisker of breaking the session doing the + // testing. Blocking execution is the feature; + // blocking every read of a matching file is a + // denial of service against the operator. + // + // A read that matches is still reported, and + // the write path still quarantines. The file + // simply is not held hostage. + let deny = hit.is_some() && event.is_exec(); + gate.answer_and_retire(event.seq, event.fd, !deny); if let (Some(name), Some(p)) = (&hit, path.as_deref()) { - on_detect(p, name, Verdict::Blocked); + on_detect( + p, + name, + if deny { Verdict::Blocked } else { Verdict::Seen }, + ); } } else { // A completed write: nothing is waiting, so @@ -792,6 +826,9 @@ pub enum Verdict { /// Caught after a completed write. It is on disk and should be /// quarantined — this is the path that replaces inotify. Written, + /// Matched while being read rather than executed. Reported and + /// allowed through: see the deny-only-on-exec note in `serve`. + Seen, } /// How the gate should behave once running. @@ -805,11 +842,20 @@ pub struct GateConfig { pub excludes: Vec, } +/// Files above this are allowed through unread by the gate, whatever the +/// on-demand scan limit is. +/// +/// A process is blocked while we decide, so the budget is a deadline +/// rather than a size: reading and matching 100 MB inline cannot finish +/// inside [`DEADLINE`], and every attempt is a watchdog rescue — a +/// process released unscanned, which is worse than never having looked. +pub const GATE_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; + impl Default for GateConfig { fn default() -> Self { Self { workers: 4, - max_size: 100 * 1024 * 1024, + max_size: GATE_MAX_FILE_BYTES, excludes: vec!["/proc".into(), "/sys".into(), "/dev".into(), "/run".into()], } } diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index dc30d21..dbda670 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -283,8 +283,21 @@ impl DaemonState { // no process is held past the deadline, including during startup. gate.start_watchdog(); - let excludes = s.exclude_paths.clone(); - let max_size = s.max_file_size_mb.saturating_mul(1024 * 1024); + let mut excludes = s.exclude_paths.clone(); + // Never gate our own state. The vault holds live malware by + // definition, and holding a process hostage over our own database + // is a way to deadlock the daemon against itself. + for own in ["/var/lib/hound", "/run/hound"] { + if !excludes.iter().any(|e| e == own) { + excludes.push(own.to_string()); + } + } + // The gate holds a process while it decides, so its budget is the + // deadline, not the on-demand scan limit. + let max_size = s + .max_file_size_mb + .saturating_mul(1024 * 1024) + .min(fanotify::GATE_MAX_FILE_BYTES); let ev = events.clone(); let quarantine_on_write = s.on_detect == "quarantine"; let q = quarantine.clone(); @@ -307,7 +320,16 @@ impl DaemonState { format!("blocked execution of {} ({name})", path.display()), ); } - fanotify::Verdict::Written => { + fanotify::Verdict::Seen => { + // Only reachable if read events are ever requested + // again. Reported, never blocked. + ev.push( + "gate", + "warn", + format!("{} matched {name} while being read", path.display()), + ); + } + fanotify::Verdict::Written => { // Nothing was waiting on this one, so the file is // already on disk. This is the path that replaces // what inotify used to do, with whole-filesystem diff --git a/crates/houndd/src/rules.rs b/crates/houndd/src/rules.rs index 84180c2..e7c0e8d 100644 --- a/crates/houndd/src/rules.rs +++ b/crates/houndd/src/rules.rs @@ -275,6 +275,57 @@ mod tests { ); } + /// The false positive that aborted the first live rollout. + /// + /// Hound blocked reads of an AI session transcript because the + /// transcript contained the miner rule's own strings — somebody had + /// been writing that rule in that session. Every threat-intelligence + /// report, security blog post and support ticket has the same shape. + #[test] + fn a_document_about_malware_is_not_malware() { + let set = RuleSet::compile().unwrap(); + let mut scanner = yara_x::Scanner::new(&set.rules); + + let transcript = r#" + {"role":"assistant","content":"The miner rule matches on stratum+tcp:// + plus donate-level and rig-id, and the binary is usually named xmrig. + For rootkits we look for dlsym with RTLD_NEXT and /etc/ld.so.preload."} + "#; + let hits: Vec = scanner + .scan(transcript.as_bytes()) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!( + hits.is_empty(), + "a text file discussing malware must not be malware: {hits:?}" + ); + } + + #[test] + fn a_real_elf_miner_is_still_caught() { + // Requiring ELF magic must not cost the detection it exists for. + let set = RuleSet::compile().unwrap(); + let mut scanner = yara_x::Scanner::new(&set.rules); + + let mut fake_elf = vec![0x7f, b'E', b'L', b'F']; + fake_elf.extend_from_slice(&[0u8; 60]); + fake_elf.extend_from_slice( + b"stratum+tcp://pool.example:3333 --donate-level=1 rig-id=x randomx xmrig", + ); + let hits: Vec = scanner + .scan(&fake_elf) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection_name(&r)) + .collect(); + assert!( + hits.iter().any(|h| h == "Linux.Coinminer.XMRig"), + "an ELF with miner strings must still match: {hits:?}" + ); + } + #[test] fn clean_text_is_clean() { let set = RuleSet::compile().unwrap(); diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index ffc8a82..29266b2 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ