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:
parent
2cf9a740c3
commit
020a1fa8bd
7 changed files with 258 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
Loading…
Reference in a new issue