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>
181 lines
6.1 KiB
Rust
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
|
|
}
|