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.
|
||||
*
|
||||
* ── 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 = "<?php"
|
||||
|
|
@ -89,6 +120,8 @@ rule Linux_Rootkit_Preload
|
|||
meta:
|
||||
name = "Linux.Rootkit.Preload"
|
||||
severity = "critical"
|
||||
// ELF-anchored.
|
||||
action = "quarantine"
|
||||
desc = "LD_PRELOAD userland rootkit: hooks libc lookup calls and hides itself."
|
||||
strings:
|
||||
$dlsym = "dlsym" ascii
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ pub trait ScanEngine: Send + Sync {
|
|||
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.
|
||||
///
|
||||
/// `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
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
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")));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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#"<?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]
|
||||
fn builtin_pack_compiles() {
|
||||
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