Antivirus/crates/houndd/examples/gate-smoke.rs
Hound aa22ddbc38 houndd: execution gate over fanotify, with a fail-open watchdog
FAN_OPEN_EXEC_PERM hands us the open and waits for an answer, so a
binary can be refused before it runs. inotify could only report what
had already happened.

Verified end to end as root against a dedicated tmpfs (examples/
gate-smoke.rs, three phases):

  benign binary          ran      7.2 ms
  EICAR binary           blocked  1.8 ms   never executed
  scanner stalled 5 s    ran      1.6 s    watchdog rescued 3 events

The third phase is the one that matters. A gate that can hold a process
forever is a machine-wedging bug wearing a feature's clothes, so the
watchdog answers ALLOW for anything unanswered past DEADLINE and counts
it. A missed detection is a bad day; a frozen machine ends the product.

Two things this cost, both worth recording:

* Scanning by re-opening the path deadlocks the daemon against itself.
  The open() lands on the watched mount and queues a permission event
  behind the one we are currently answering, and we cannot answer that
  one until we finish this one. Allowing our own pid does not help —
  the thread never gets back to the queue to apply the rule. The gate
  reads through the descriptor the kernel already handed it, with
  pread so the gated process still sees its own file offset. This is
  what hung the first smoke run.

* The watchdog can only rescue events it has been told about, and it
  learns of them when the queue is drained. Scanning on the draining
  thread makes every event behind a slow scan invisible to the
  deadline. Reader and workers are therefore separate threads: the
  reader never blocks on a scan, so every event is registered within
  microseconds of arriving.

Also:

- ScanEngine::scan_bytes — the seam the gate needs, since it must never
  scan by path. Engines that cannot do it return None and simply are
  not usable behind the gate.
- Settings gain exec_gate and exec_gate_paths, defaulting to OFF. It
  needs CAP_SYS_ADMIN and a root-filesystem mark holds every process on
  the box; that is not a default to ship before Phase 2 soak testing.
- ABI constants are defined locally rather than taken from libc, so a
  version bump cannot quietly change what we ask the kernel for.

78 tests pass, up from 57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:29:01 -05:00

178 lines
5.9 KiB
Rust

//! End-to-end proof that the execution gate blocks, allows, and — above
//! all — fails open.
//!
//! Run as root. It creates its OWN tmpfs and marks only that mount, so a
//! bug here can freeze processes touching that scratch directory and
//! nothing else. Never point this at `/` while developing.
//!
//! cargo build --example gate-smoke
//! sudo ./target/debug/examples/gate-smoke
//!
//! Three phases:
//!
//! 1. A benign binary runs, and quickly.
//! 2. A malicious one is denied and never executes.
//! 3. With a scanner deliberately stalled past the deadline, the
//! watchdog lets the process through anyway. This is the phase that
//! matters: everything else is a feature, this is the promise that
//! a bug in Hound cannot wedge someone's machine.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[path = "../src/fanotify.rs"]
mod fanotify;
use fanotify::{Gate, GateConfig};
const EICAR: &str = r"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
const MOUNT: &str = "/tmp/hound-gate-smoke";
fn sh(cmd: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(cmd)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn main() {
let mount = PathBuf::from(MOUNT);
let _ = std::fs::create_dir_all(&mount);
if !sh(&format!("mount -t tmpfs -o size=16m tmpfs {MOUNT}")) {
eprintln!("could not mount the scratch tmpfs — are you root?");
std::process::exit(2);
}
let passed = std::panic::catch_unwind(|| run(&mount)).unwrap_or(false);
// Teardown runs whatever happened above.
let _ = sh(&format!("umount -l {MOUNT}"));
let _ = std::fs::remove_dir(&mount);
if passed {
println!("\nGATE SMOKE: PASS");
} else {
println!("\nGATE SMOKE: FAIL");
std::process::exit(1);
}
}
fn run(mount: &Path) -> bool {
let bad = mount.join("miner.sh");
let good = mount.join("hello.sh");
let slow = mount.join("slow.sh");
std::fs::write(&bad, format!("#!/bin/sh\n# {EICAR}\necho SHOULD_NEVER_PRINT\n")).unwrap();
std::fs::write(&good, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::write(&slow, "#!/bin/sh\nexit 0\n").unwrap();
for f in [&bad, &good, &slow] {
sh(&format!("chmod +x {}", f.display()));
}
let gate = match Gate::init() {
Ok(g) => Arc::new(g),
Err(e) => {
eprintln!("fanotify_init failed: {e}");
return false;
}
};
if let Err(e) = gate.mark_mount(mount) {
eprintln!("fanotify_mark failed: {e}");
return false;
}
let watchdog = gate.start_watchdog();
// Flipped on for phase 3 to stall the scanner past the deadline.
let stall = Arc::new(AtomicBool::new(false));
let stall_rx = Arc::clone(&stall);
// Stands in for the engine: the gate is what is under test here.
// Note it receives bytes and never opens the gated file — reopening a
// path on a watched mount deadlocks the daemon against itself.
let scan = Arc::new(move |_p: &Path, bytes: &[u8]| -> Option<String> {
if stall_rx.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_secs(5));
}
String::from_utf8_lossy(bytes)
.contains("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")
.then(|| "EICAR-Test-Signature".to_string())
});
let handles = gate.serve(
GateConfig {
workers: 4,
max_size: 100 * 1024 * 1024,
excludes: Vec::new(),
},
scan,
);
println!("gate armed on {MOUNT} — 1 reader, 4 workers, watchdog live");
std::thread::sleep(Duration::from_millis(100));
let mut pass = true;
// ── 1. benign ──
println!("\n[1] benign binary expect: runs");
let t = Instant::now();
let benign_ran = sh(&format!("{} >/dev/null 2>&1", good.display()));
let benign_took = t.elapsed();
println!(" ran={benign_ran} in {benign_took:?}");
if !benign_ran {
eprintln!(" FAIL: the gate blocked a clean binary");
pass = false;
}
if benign_took > Duration::from_millis(200) {
eprintln!(" FAIL: {benign_took:?} of overhead on a clean exec");
pass = false;
}
// ── 2. malicious ──
println!("\n[2] EICAR binary expect: blocked");
let t = Instant::now();
let malicious_ran = sh(&format!("{} >/dev/null 2>&1", bad.display()));
println!(" ran={malicious_ran} in {:?}", t.elapsed());
if malicious_ran {
eprintln!(" FAIL: the EICAR binary executed");
pass = false;
}
// ── 3. the promise ──
println!("\n[3] scanner stalled 5s expect: watchdog lets it run anyway");
let (_, _, timed_out_before) = gate.responder().counters();
stall.store(true, Ordering::SeqCst);
let t = Instant::now();
let stalled_ran = sh(&format!("{} >/dev/null 2>&1", slow.display()));
let stalled_took = t.elapsed();
stall.store(false, Ordering::SeqCst);
let (_, _, timed_out_after) = gate.responder().counters();
let rescued = timed_out_after - timed_out_before;
println!(" ran={stalled_ran} in {stalled_took:?}, watchdog rescued {rescued} event(s)");
if !stalled_ran {
eprintln!(" FAIL: a stalled scanner blocked a process — this is the wedge");
pass = false;
}
if stalled_took > Duration::from_secs(3) {
eprintln!(" FAIL: held for {stalled_took:?}; the deadline did not apply");
pass = false;
}
if rescued == 0 {
eprintln!(" FAIL: nothing timed out, so the fail-open path never ran");
pass = false;
}
gate.stop();
for h in handles {
let _ = h.join();
}
let _ = watchdog.join();
let (allowed, denied, timed_out) = gate.responder().counters();
println!("\ncounters: allowed={allowed} denied={denied} timed_out={timed_out}");
pass
}