Antivirus/crates/houndd/examples/gate-smoke.rs
Hound 85234842f9 houndd: gate covers writes too; inotify becomes the fallback
Completes Phase 1. The gate now asks for FAN_CLOSE_WRITE alongside the
permission events, so a threat written to disk is quarantined and a
threat being executed is refused — one mechanism, one mark, no
watch-descriptor ceiling and no blind spots outside a configured list.

Verified live. The nicest evidence is an error message:

  $ chmod +x /tmp/hound-live/malware.sh
  chmod: cannot access '/tmp/hound-live/malware.sh': No such file or directory

Hound had already quarantined it. `hound quarantine list` shows the
entry, the clean binary beside it still runs, and CapPrm/CapEff/CapBnd
read 000000000020000e.

Three bugs, each of which looked like working code:

* A file descriptor number is not an identity. The kernel allocates an
  fd per event and recycles the number the moment we close it, so one
  write arrives as FAN_OPEN_PERM on fd 6 and then FAN_CLOSE_WRITE on fd
  6 again. Idempotency keyed on the fd treated the second as a duplicate
  of the first and dropped it — detection ran, matched EICAR, and threw
  the result away. Events now carry a monotonic seq that is never reused.

* rename(2) fails EXDEV across filesystems, and for quarantine that is
  the common case rather than the exotic one: the vault is under
  /var/lib while threats land on /home, in a tmpfs, on a USB stick or
  in a container overlay. Quarantine now falls back to copy-then-unlink,
  unlinking only once the copy is safely down, and seals the stored file
  at 0600 with every execute bit cleared.

* The capability set was too small to do the job. CAP_DAC_READ_SEARCH
  lets us read a threat but not unlink it, so quarantine failed EACCES
  as root. The set is now four capabilities — SYS_ADMIN, DAC_READ_SEARCH,
  DAC_OVERRIDE, FOWNER. DAC_OVERRIDE is close to "write anywhere" and
  that is worth being honest about; an antivirus that quarantines cannot
  avoid it, because the threat is by definition in a directory somebody
  else owns. What the reduction still buys is what it excludes, and
  there is a test asserting SYS_MODULE, SYS_BOOT, SYS_PTRACE, NET_ADMIN,
  NET_RAW, AUDIT_CONTROL and SETUID never creep back in. Narrowing
  further means a separate privileged helper for quarantine.

realtime.rs is now documented as the unprivileged fallback and does not
start when the gate is armed — running both would scan everything twice
and quarantine the same file from two threads.

99 tests pass. HOUNDD_GATE_DEBUG=1 dumps every event and decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:27:34 -05:00

181 lines
6.1 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,
Arc::new(|p: &Path, name: &str, v: fanotify::Verdict| {
println!(" detect: {} ({name}) {v:?}", p.display());
}),
);
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
}