rules: a rule must earn the right to move somebody's file

Quarantine deletes a file from where its owner put it. Until now every
detection did that, so every false positive was destructive rather than
merely wrong — which on this machine cost an 8.5 MB compiler cache and a
4.3 MB session transcript, the latter's history permanently.

Each rule now declares what Hound may do:

    action = "quarantine"   move it to the vault
    action = "alert"        report it, leave it alone

**The default is alert**, and so is an unrecognised value, and so is a
detection name the engine does not know. One misspelt "quarantne" must
not turn an advisory rule into a destructive one across every machine
that updates.

Quarantine has to be earned by an ANCHOR, not by the author's
confidence:

  EICAR-Test-Signature    quarantine  exact 68-byte payload, size-bounded
  Linux.Coinminer.XMRig   quarantine  ELF magic
  Linux.Rootkit.Preload   quarantine  ELF magic
  Linux.Webshell.PHP-Eval ALERT       content-only — PHP has no file
                                      magic, so it can still match a
                                      security write-up, a log or an AI
                                      transcript quoting a webshell

A test asserts that property directly: any rule declaring quarantine
must contain a file-type check or an exact size bound. A future rule
cannot quietly claim the destructive action without one.

Both the execution gate and the inotify fallback consult it, kept in
step deliberately — a fallback more destructive than the primary path is
a trap for whoever ends up running unprivileged.

Verified live on the gated filesystem: a webshell written to disk is
reported and left in place; an ELF miner written beside it is
quarantined. Event text changed to match — "threat detected in X —
reported, not moved" rather than implying something happened.

One process note. The first attempt at this edit silently did nothing:
the replacement did not match because of indentation, the tooling
reported success, and the webshell was still moved. Second time I made
the edit assert its anchor before applying. That is the third silent
no-op edit in this session and the pattern is now obvious enough to
stop assuming an edit landed.

303 tests pass. Gate off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-21 08:48:27 -05:00
parent 2cf9a740c3
commit 020a1fa8bd
7 changed files with 258 additions and 3 deletions

View file

@ -1,6 +1,28 @@
/* /*
* Hound built-in starter pack. * 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 * Deliberately tiny and deliberately tight. Every rule here requires
* several independent strings before it fires, because a false positive * several independent strings before it fires, because a false positive
* in an antivirus is worse than a miss — one rule that quarantines a * in an antivirus is worse than a miss — one rule that quarantines a
@ -16,6 +38,9 @@ rule EICAR_Test_File
meta: meta:
name = "EICAR-Test-Signature" name = "EICAR-Test-Signature"
severity = "info" 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." desc = "Industry-standard antivirus test file. Harmless."
strings: strings:
$eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" $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: meta:
name = "Linux.Coinminer.XMRig" name = "Linux.Coinminer.XMRig"
severity = "critical" severity = "critical"
// ELF-anchored: a document about mining cannot match.
action = "quarantine"
desc = "XMRig cryptocurrency miner. Requires pool protocol plus two config keys." desc = "XMRig cryptocurrency miner. Requires pool protocol plus two config keys."
strings: strings:
$pool1 = "stratum+tcp://" ascii $pool1 = "stratum+tcp://" ascii
@ -63,6 +90,10 @@ rule Linux_Webshell_PHP_Eval
meta: meta:
name = "Linux.Webshell.PHP-Eval" name = "Linux.Webshell.PHP-Eval"
severity = "critical" 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." desc = "PHP webshell: request-driven eval of decoded input."
strings: strings:
$php = "<?php" $php = "<?php"
@ -89,6 +120,8 @@ rule Linux_Rootkit_Preload
meta: meta:
name = "Linux.Rootkit.Preload" name = "Linux.Rootkit.Preload"
severity = "critical" severity = "critical"
// ELF-anchored.
action = "quarantine"
desc = "LD_PRELOAD userland rootkit: hooks libc lookup calls and hides itself." desc = "LD_PRELOAD userland rootkit: hooks libc lookup calls and hides itself."
strings: strings:
$dlsym = "dlsym" ascii $dlsym = "dlsym" ascii

View file

@ -51,6 +51,14 @@ pub trait ScanEngine: Send + Sync {
None None
} }
/// Whether a detection may move the file, rather than only report it.
///
/// Defaults to `false`. An engine that does not model this — or a
/// detection name it does not recognise — must not destroy anything.
fn may_quarantine(&self, _detection: &str) -> bool {
false
}
/// Answer from memory alone, without reading the file. /// Answer from memory alone, without reading the file.
/// ///
/// `Some(verdict)` means we have judged this exact file version /// `Some(verdict)` means we have judged this exact file version

View file

@ -334,11 +334,25 @@ impl DaemonState {
// already on disk. This is the path that replaces // already on disk. This is the path that replaces
// what inotify used to do, with whole-filesystem // what inotify used to do, with whole-filesystem
// coverage and no watch-descriptor ceiling. // 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( ev.push(
"gate", "gate",
"critical", "critical",
format!("threat written to {} ({name})", path.display()), format!(
"threat detected in {} ({name}) — reported, not moved",
path.display()
),
); );
return; return;
} }

View file

@ -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<Option<String>> { fn cached_verdict(&self, md: &std::fs::Metadata) -> Option<Option<String>> {
self.cache self.cache
.get(&FileKey::from_metadata(md)) .get(&FileKey::from_metadata(md))
@ -480,6 +484,28 @@ mod tests {
assert!(e.scan_bytes(b"an ordinary sentence").is_none()); 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] #[test]
fn skips_pseudo_filesystems() { fn skips_pseudo_filesystems() {
assert!(is_skipped_dir(Path::new("/proc/1"))); assert!(is_skipped_dir(Path::new("/proc/1")));

View file

@ -318,7 +318,13 @@ fn run_monitor(
.as_ref() .as_ref()
.map(|f| f.virus.clone()) .map(|f| f.virus.clone())
.unwrap_or_else(|| "unknown".into()); .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) { match quarantine.add(path.to_str().unwrap_or(""), &virus) {
Ok(entry) => { Ok(entry) => {
let mut c = counters.lock().unwrap(); let mut c = counters.lock().unwrap();

View file

@ -22,6 +22,28 @@
//! running scan. //! running scan.
use anyhow::{Context, Result}; 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::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::SystemTime; use std::time::SystemTime;
@ -56,6 +78,20 @@ pub struct RuleSet {
pub loaded_at: SystemTime, pub loaded_at: SystemTime,
/// Human-readable list of what went in, for `hound status`. /// Human-readable list of what went in, for `hound status`.
pub sources: Vec<String>, pub sources: Vec<String>,
/// 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<String, Action>,
}
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 { impl RuleSet {
@ -118,6 +154,10 @@ impl RuleSet {
let rules = compiler.build(); let rules = compiler.build();
let count = rules.iter().count(); let count = rules.iter().count();
let actions = rules
.iter()
.map(|r| (Self::detection_name(&r), Self::action_for(&r)))
.collect();
Ok(Self { Ok(Self {
rules, rules,
@ -125,9 +165,41 @@ impl RuleSet {
count, count,
loaded_at: SystemTime::now(), loaded_at: SystemTime::now(),
sources, 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. /// The detection name to report for a matching rule.
/// ///
/// Rules carry a `name` metadata field holding the public signature /// Rules carry a `name` metadata field holding the public signature
@ -251,6 +323,102 @@ mod tests {
assert!(recovered.contains("Linux_Coinminer_XMRig")); 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#"<?php @eval(base64_decode($_POST['x'])); ?>"#;
let d: Vec<Detection> = 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] #[test]
fn builtin_pack_compiles() { fn builtin_pack_compiles() {
let set = RuleSet::compile().expect("built-in pack must always compile"); let set = RuleSet::compile().expect("built-in pack must always compile");

Binary file not shown.