diff --git a/crates/houndd/rules/hound-builtin.yar b/crates/houndd/rules/hound-builtin.yar index e8f42fd..43840bd 100644 --- a/crates/houndd/rules/hound-builtin.yar +++ b/crates/houndd/rules/hound-builtin.yar @@ -1,6 +1,28 @@ /* * Hound built-in starter pack. * + * ── the `action` field ── + * + * Every rule declares what Hound may do when it matches: + * + * action = "quarantine" move the file to the vault + * action = "alert" report it and leave it alone + * + * **The default is "alert".** A rule that forgets to declare gets the + * non-destructive behaviour, because the failure mode of guessing wrong + * is somebody's file disappearing. + * + * Only a rule that cannot plausibly match a document deserves + * "quarantine": one anchored to a file type (ELF magic) or pinned to an + * exact, size-bounded payload. A rule matching loose text — a webshell + * pattern, a suspicious string — alerts, however confident it looks, + * because text appears inside logs, transcripts, build caches and + * documentation about the very thing being detected. + * + * This is not hypothetical. Before this field existed, Hound quarantined + * an 8.5 MB compiler cache and a 4.3 MB session transcript on a live + * server, and the transcript's history was lost. + * * Deliberately tiny and deliberately tight. Every rule here requires * several independent strings before it fires, because a false positive * in an antivirus is worse than a miss — one rule that quarantines a @@ -16,6 +38,9 @@ rule EICAR_Test_File meta: name = "EICAR-Test-Signature" severity = "info" + // Exact 68-byte payload, size-bounded below. It cannot match + // anything that is not deliberately the EICAR file. + action = "quarantine" desc = "Industry-standard antivirus test file. Harmless." strings: $eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" @@ -38,6 +63,8 @@ rule Linux_Coinminer_XMRig meta: name = "Linux.Coinminer.XMRig" severity = "critical" + // ELF-anchored: a document about mining cannot match. + action = "quarantine" desc = "XMRig cryptocurrency miner. Requires pool protocol plus two config keys." strings: $pool1 = "stratum+tcp://" ascii @@ -63,6 +90,10 @@ rule Linux_Webshell_PHP_Eval meta: name = "Linux.Webshell.PHP-Eval" severity = "critical" + // Content-only. PHP has no file magic, so this can still match a + // document that quotes a webshell — a security write-up, a log, + // an AI transcript. It reports; it does not move anybody's file. + action = "alert" desc = "PHP webshell: request-driven eval of decoded input." strings: $php = " bool { + false + } + /// Answer from memory alone, without reading the file. /// /// `Some(verdict)` means we have judged this exact file version diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index dbda670..56d8f9f 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -334,11 +334,25 @@ impl DaemonState { // already on disk. This is the path that replaces // what inotify used to do, with whole-filesystem // coverage and no watch-descriptor ceiling. - if !quarantine_on_write { + // Two gates before anything is moved: the + // operator's policy, and the RULE's own + // declaration that it is anchored enough to + // justify destroying a file. A content-only + // rule reports and leaves the file alone, + // however confident it looks — text matches + // turn up inside logs, transcripts, build + // caches and documentation about the very + // thing being detected. + if !quarantine_on_write + || !engine::engine().may_quarantine(name) + { ev.push( "gate", "critical", - format!("threat written to {} ({name})", path.display()), + format!( + "threat detected in {} ({name}) — reported, not moved", + path.display() + ), ); return; } diff --git a/crates/houndd/src/native.rs b/crates/houndd/src/native.rs index eceb6ee..1754709 100644 --- a/crates/houndd/src/native.rs +++ b/crates/houndd/src/native.rs @@ -162,6 +162,10 @@ impl ScanEngine for HoundEngine { }) } + fn may_quarantine(&self, detection: &str) -> bool { + self.rules.current().may_quarantine(detection) + } + fn cached_verdict(&self, md: &std::fs::Metadata) -> Option> { self.cache .get(&FileKey::from_metadata(md)) @@ -480,6 +484,28 @@ mod tests { assert!(e.scan_bytes(b"an ordinary sentence").is_none()); } + #[test] + fn the_engine_reports_which_detections_may_move_a_file() { + // The policy the whole quarantine-vs-alert change rests on. + let e = HoundEngine::new().unwrap(); + assert!( + e.may_quarantine("EICAR-Test-Signature"), + "an exact, size-bounded payload may be moved" + ); + assert!( + e.may_quarantine("Linux.Coinminer.XMRig"), + "an ELF-anchored rule may be moved" + ); + assert!( + !e.may_quarantine("Linux.Webshell.PHP-Eval"), + "a content-only rule must only ever alert" + ); + assert!( + !e.may_quarantine("Something.We.Have.Never.Heard.Of"), + "an unknown detection must never move a file" + ); + } + #[test] fn skips_pseudo_filesystems() { assert!(is_skipped_dir(Path::new("/proc/1"))); diff --git a/crates/houndd/src/realtime.rs b/crates/houndd/src/realtime.rs index 31c66f1..58dfd23 100644 --- a/crates/houndd/src/realtime.rs +++ b/crates/houndd/src/realtime.rs @@ -318,7 +318,13 @@ fn run_monitor( .as_ref() .map(|f| f.virus.clone()) .unwrap_or_else(|| "unknown".into()); - if s.on_detect == "quarantine" { + // Same two gates as the execution gate: the + // operator's policy AND the rule's own declaration + // that it is anchored enough to justify moving a + // file. Kept in step deliberately — a fallback that + // is more destructive than the primary path is a trap + // for whoever ends up running unprivileged. + if s.on_detect == "quarantine" && crate::engine::engine().may_quarantine(&virus) { match quarantine.add(path.to_str().unwrap_or(""), &virus) { Ok(entry) => { let mut c = counters.lock().unwrap(); diff --git a/crates/houndd/src/rules.rs b/crates/houndd/src/rules.rs index 01e7c4f..4a5a165 100644 --- a/crates/houndd/src/rules.rs +++ b/crates/houndd/src/rules.rs @@ -22,6 +22,28 @@ //! running scan. use anyhow::{Context, Result}; + +/// What Hound is permitted to do when a rule matches. +/// +/// Declared per rule, and **`Alert` is the default**: a rule that does +/// not say otherwise gets the behaviour that cannot destroy anything. +/// Quarantine moves somebody's file, so it has to be earned by an anchor +/// — a file-type check or an exact size-bounded payload — rather than +/// assumed from the author's confidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Report it. Leave the file where it is. + Alert, + /// Move it to the vault. + Quarantine, +} + +/// A match, and what may be done about it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + pub name: String, + pub action: Action, +} use std::path::PathBuf; use std::sync::{Arc, RwLock}; use std::time::SystemTime; @@ -56,6 +78,20 @@ pub struct RuleSet { pub loaded_at: SystemTime, /// Human-readable list of what went in, for `hound status`. pub sources: Vec, + /// Detection name -> what Hound may do about it. Built once at + /// compile time so the decision never depends on re-reading metadata + /// while a process is being held. + pub actions: std::collections::HashMap, +} + +impl RuleSet { + /// Whether a detection name is allowed to move somebody's file. + /// + /// Unknown names alert. A detection whose rule we cannot find is + /// exactly the case where guessing "quarantine" would be worst. + pub fn may_quarantine(&self, name: &str) -> bool { + matches!(self.actions.get(name), Some(Action::Quarantine)) + } } impl RuleSet { @@ -118,6 +154,10 @@ impl RuleSet { let rules = compiler.build(); let count = rules.iter().count(); + let actions = rules + .iter() + .map(|r| (Self::detection_name(&r), Self::action_for(&r))) + .collect(); Ok(Self { rules, @@ -125,9 +165,41 @@ impl RuleSet { count, loaded_at: SystemTime::now(), sources, + actions, }) } + /// What a matching rule permits. + /// + /// Anything other than a literal "quarantine" is `Alert`, including a + /// missing field, an unrecognised value and a typo. Defaulting the + /// other way would mean one misspelling turns an advisory rule into a + /// destructive one across every machine that updates. + pub fn action_for(rule: &yara_x::Rule) -> Action { + for (key, value) in rule.metadata() { + if key != "action" { + continue; + } + let text = match value { + yara_x::MetaValue::String(s) => s.to_string(), + yara_x::MetaValue::Bytes(b) => b.to_string(), + _ => continue, + }; + if text.eq_ignore_ascii_case("quarantine") { + return Action::Quarantine; + } + } + Action::Alert + } + + /// Name and permitted action together. + pub fn detection(rule: &yara_x::Rule) -> Detection { + Detection { + name: Self::detection_name(rule), + action: Self::action_for(rule), + } + } + /// The detection name to report for a matching rule. /// /// Rules carry a `name` metadata field holding the public signature @@ -251,6 +323,102 @@ mod tests { assert!(recovered.contains("Linux_Coinminer_XMRig")); } + // ── quarantine has to be earned ── + + #[test] + fn a_rule_without_an_action_field_only_alerts() { + // The default must be the one that cannot destroy anything. + let mut c = yara_x::Compiler::new(); + c.add_source( + r#"rule Undeclared { meta: name = "X" strings: $a = "zzq-marker" condition: $a }"#, + ) + .unwrap(); + let rules = c.build(); + let mut sc = yara_x::Scanner::new(&rules); + let r = sc.scan(b"zzq-marker").unwrap(); + let m = r.matching_rules().next().unwrap(); + assert_eq!(RuleSet::action_for(&m), Action::Alert); + } + + #[test] + fn an_unrecognised_action_only_alerts() { + // A typo must not turn an advisory rule into a destructive one on + // every machine that updates. + let mut c = yara_x::Compiler::new(); + c.add_source( + r#"rule Typo { meta: action = "quarantne" strings: $a = "zzq2" condition: $a }"#, + ) + .unwrap(); + let rules = c.build(); + let mut sc = yara_x::Scanner::new(&rules); + let r = sc.scan(b"zzq2").unwrap(); + assert_eq!( + RuleSet::action_for(&r.matching_rules().next().unwrap()), + Action::Alert + ); + } + + #[test] + fn only_anchored_rules_may_quarantine() { + // The property that keeps this honest: a rule allowed to move + // somebody's file must be anchored to a file type or an exact + // bounded payload. A content-only rule alerts. + let src = builtin(); + let set = RuleSet::compile().unwrap(); + + for rule in set.rules.iter() { + let name = rule.identifier().to_string(); + // Find the rule's own text so its condition can be inspected. + let start = src.find(&format!("rule {name}")).unwrap_or(0); + let end = src[start..].find("\n}").map(|i| start + i).unwrap_or(src.len()); + let body = &src[start..end]; + let declares_quarantine = body.contains("\"quarantine\""); + if !declares_quarantine { + continue; + } + let anchored = body.contains("uint32(0) == 0x464c457f") || body.contains("filesize <="); + assert!( + anchored, + "{name} may quarantine but is not anchored to a file type or an exact size" + ); + } + } + + #[test] + fn the_webshell_rule_alerts_rather_than_quarantining() { + // PHP has no file magic, so this rule can still match a document + // that quotes a webshell. It must never move that document. + let set = RuleSet::compile().unwrap(); + let mut sc = yara_x::Scanner::new(&set.rules); + let shell = br#""#; + let d: Vec = sc + .scan(shell) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection(&r)) + .collect(); + let hit = d + .iter() + .find(|d| d.name == "Linux.Webshell.PHP-Eval") + .expect("the webshell must still be detected"); + assert_eq!(hit.action, Action::Alert); + } + + #[test] + fn the_anchored_rules_may_quarantine() { + let set = RuleSet::compile().unwrap(); + let mut sc = 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 d = sc + .scan(eicar) + .unwrap() + .matching_rules() + .map(|r| RuleSet::detection(&r)) + .find(|d| d.name == "EICAR-Test-Signature") + .expect("EICAR must be detected"); + assert_eq!(d.action, Action::Quarantine); + } + #[test] fn builtin_pack_compiles() { let set = RuleSet::compile().expect("built-in pack must always compile"); diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index dc4d6ba..66e0a1b 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ