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>
178 lines
5.9 KiB
Rust
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
|
|
}
|