diff --git a/Cargo.lock b/Cargo.lock index 5c1550f..8b71318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ dependencies = [ "anyhow", "hound-api", "inotify", + "libc", "serde", "serde_json", "time", diff --git a/Cargo.toml b/Cargo.toml index d3b8fa0..c7aa31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ colored = "2" time = { version = "0.3", features = ["serde", "std", "formatting"] } inotify = "0.10" yara-x = "1.19" +libc = "0.2" [profile.release] lto = true diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index d227443..9008457 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -150,6 +150,18 @@ pub struct Settings { /// Paths (exact or `/`-suffixed prefixes) skipped by scans. pub exclude_paths: Vec, + // Execution gate (fanotify FAN_OPEN_EXEC_PERM) + /// Deny execution until a verdict is returned. + /// + /// Defaults to **off**. It needs CAP_SYS_ADMIN, and a mark on the root + /// filesystem holds every process on the machine — that is not a + /// default to ship before the packaging and soak testing in Phase 2. + #[serde(default)] + pub exec_gate: bool, + /// Mounts the gate covers. Empty means the root filesystem. + #[serde(default)] + pub exec_gate_paths: Vec, + // Real-time interception pub realtime_enabled: bool, /// Directories the realtime monitor watches (recursively). @@ -238,6 +250,8 @@ impl Default for Settings { recursive_default: true, max_file_size_mb: 100, exclude_paths: vec!["/proc".into(), "/sys".into(), "/dev".into()], + exec_gate: false, + exec_gate_paths: Vec::new(), realtime_enabled: true, realtime_watch: vec!["~/Downloads".into()], on_detect: "quarantine".into(), diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index 0f6067b..d6f4496 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -18,3 +18,4 @@ serde_json.workspace = true time.workspace = true inotify.workspace = true yara-x.workspace = true +libc.workspace = true diff --git a/crates/houndd/examples/gate-smoke.rs b/crates/houndd/examples/gate-smoke.rs new file mode 100644 index 0000000..d6e99a1 --- /dev/null +++ b/crates/houndd/examples/gate-smoke.rs @@ -0,0 +1,178 @@ +//! 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 { + 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 +} diff --git a/crates/houndd/src/engine.rs b/crates/houndd/src/engine.rs index 9628a67..498a5f1 100644 --- a/crates/houndd/src/engine.rs +++ b/crates/houndd/src/engine.rs @@ -38,6 +38,18 @@ pub trait ScanEngine: Send + Sync { /// Refresh the signature store. Returns (success, command label, /// combined stdout+stderr tail) for the last attempt made. fn update(&self) -> Result<(bool, String, String)>; + + /// Scan bytes already in hand, returning a detection name. + /// + /// This exists for the execution gate, which is handed an open + /// descriptor and must never re-open the path: an `open()` on a + /// watched mount queues a permission event behind the one being + /// answered and deadlocks the daemon against itself. Engines that + /// can only scan paths return `None` and are simply not usable + /// behind the gate. + fn scan_bytes(&self, _bytes: &[u8]) -> Option { + None + } } /// The ClamAV-backed engine: `clamscan` + `freshclam` over their diff --git a/crates/houndd/src/fanotify.rs b/crates/houndd/src/fanotify.rs new file mode 100644 index 0000000..1ea1a71 --- /dev/null +++ b/crates/houndd/src/fanotify.rs @@ -0,0 +1,873 @@ +//! The execution gate: fanotify permission events. +//! +//! This is the feature that separates Hound from a cron job wrapped +//! around a scanner. inotify tells you a file appeared *after* it +//! appeared, which is forensics. fanotify's `FAN_OPEN_EXEC_PERM` hands us +//! the open and waits for an answer, so we can refuse to let a binary run +//! at all. +//! +//! It is also the single most dangerous thing in the codebase. Every +//! permission event we fail to answer is a process frozen mid-`execve`, +//! and a mark on the root filesystem means *every* process. Three rules +//! follow from that, and none of them are negotiable: +//! +//! 1. **Fail open, always.** A watchdog thread answers anything the scan +//! loop has not answered within [`DEADLINE`]. A missed detection is a +//! bad day; a wedged machine is the end of the product. +//! 2. **Never gate ourselves.** Scanning a file means opening it, which +//! generates an event, which we would then wait on ourselves to +//! answer. Events from our own pid are allowed before anything else +//! happens. +//! 3. **Answer exactly once, and always close the fd.** The kernel hands +//! us an open descriptor per event. Leak them and the daemon runs out +//! of file descriptors, which fails us into the same wedge. +//! +//! Everything that decides *what* to do lives in [`policy`] and is pure, +//! so it is tested without root. The syscall layer below it is thin +//! enough to audit by eye. + +use std::collections::HashSet; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +// ── ABI constants ─────────────────────────────────────────────────────── +// Defined here rather than taken from libc so a libc version bump cannot +// silently change what we ask the kernel for. + +const FAN_CLOEXEC: u32 = 0x0000_0001; +const FAN_NONBLOCK: u32 = 0x0000_0002; +const FAN_CLASS_CONTENT: u32 = 0x0000_0004; + +const FAN_OPEN_PERM: u64 = 0x0001_0000; +const FAN_OPEN_EXEC_PERM: u64 = 0x0004_0000; + +const FAN_MARK_ADD: u32 = 0x0000_0001; +const FAN_MARK_MOUNT: u32 = 0x0000_0010; +const FAN_MARK_FILESYSTEM: u32 = 0x0000_0100; + +const FAN_ALLOW: u32 = 0x01; +const FAN_DENY: u32 = 0x02; + +const FAN_METADATA_VERSION: u8 = 3; + +/// How long a process may be held before we let it through regardless. +/// +/// Our scans are single-digit milliseconds, so this is three orders of +/// magnitude of headroom. It exists for the pathological case — a stalled +/// NFS read, a pauseerd disk — not the normal one. +pub const DEADLINE: Duration = Duration::from_millis(500); + +/// `struct fanotify_event_metadata` — 24 bytes, stable since Linux 2.6.37. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct EventMetadata { + event_len: u32, + vers: u8, + reserved: u8, + metadata_len: u16, + mask: u64, + fd: i32, + pid: i32, +} + +const METADATA_SIZE: usize = std::mem::size_of::(); + +/// `struct fanotify_response`. +#[repr(C)] +struct Response { + fd: i32, + response: u32, +} + +/// One decoded permission event. +#[derive(Debug)] +pub struct Event { + /// Descriptor for the file being opened. Ours to close. + pub fd: RawFd, + /// The process being held. + pub pid: i32, + pub mask: u64, +} + +impl Event { + /// True when this open is an `execve`, as opposed to an ordinary read. + pub fn is_exec(&self) -> bool { + self.mask & FAN_OPEN_EXEC_PERM != 0 + } + + /// Resolve what the descriptor points at, via `/proc/self/fd`. + /// + /// This is a readlink, not an open, so it generates no event. + pub fn path(&self) -> Option { + std::fs::read_link(format!("/proc/self/fd/{}", self.fd)).ok() + } + + /// Size of the file behind the descriptor, via `fstat`. + pub fn size(&self) -> Option { + // SAFETY: zeroed stat is a valid initial value; fd is ours. + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::fstat(self.fd, &mut st) }; + (rc == 0).then(|| st.st_size as u64) + } + + /// Read the file's contents **from the descriptor the kernel gave us**. + /// + /// This is load-bearing, not an optimisation. Re-opening the path with + /// `std::fs::read` issues a fresh `open()` on a watched mount, which + /// queues a permission event behind the one we are currently holding — + /// and we cannot answer that one until we finish this one. The daemon + /// deadlocks against itself and takes every process touching the mount + /// with it. Allowing our own pid in [`policy::decide`] does not save us, + /// because the thread never gets back to the queue to apply it. + /// + /// `pread` also leaves the file offset alone, so the process we are + /// gating sees exactly the file it opened. + pub fn content(&self, max: usize) -> Option> { + let size = self.size()? as usize; + if size > max { + return None; + } + let mut buf = vec![0u8; size]; + let mut read = 0usize; + while read < size { + // SAFETY: writing within buf's allocation, bounded by size. + let n = unsafe { + libc::pread( + self.fd, + buf[read..].as_mut_ptr() as *mut libc::c_void, + size - read, + read as libc::off_t, + ) + }; + if n <= 0 { + break; + } + read += n as usize; + } + buf.truncate(read); + Some(buf) + } +} + +// ── policy: the pure half ─────────────────────────────────────────────── + +pub mod policy { + use super::*; + + /// What to do with an event, decided before any I/O happens. + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum Decision { + /// Let it through without looking. Cheap and by far the common case. + AllowNow(&'static str), + /// Worth scanning before answering. + Scan, + } + + /// Decide without touching the disk. + /// + /// Ordering matters: the self-pid check comes first because getting it + /// wrong deadlocks the daemon against itself, and no other rule can + /// save us from that. + pub fn decide( + event_pid: i32, + our_pid: i32, + path: Option<&Path>, + size: Option, + max_size: u64, + excludes: &[String], + ) -> Decision { + if event_pid == our_pid { + return Decision::AllowNow("self"); + } + let Some(path) = path else { + // A descriptor we cannot resolve is one we cannot scan. Holding + // the process would gain nothing. + return Decision::AllowNow("unresolvable"); + }; + if is_excluded(path, excludes) { + return Decision::AllowNow("excluded"); + } + if size.is_some_and(|s| s > max_size) { + return Decision::AllowNow("oversized"); + } + Decision::Scan + } + + /// Prefix match on path components, so `/var` never matches `/variable`. + pub fn is_excluded(path: &Path, excludes: &[String]) -> bool { + excludes.iter().any(|ex| { + let ex = ex.trim_end_matches('/'); + if ex.is_empty() { + return false; + } + path.starts_with(ex) + }) + } +} + +// ── the responder: answer once, close always ──────────────────────────── + +/// Owns the fanotify descriptor and guarantees each event is answered +/// exactly once, whether by the scan loop or by the watchdog. +pub struct Responder { + fan: OwnedFd, + answered: Mutex>, + allowed: AtomicU64, + denied: AtomicU64, + timed_out: AtomicU64, +} + +impl Responder { + fn new(fan: OwnedFd) -> Self { + Self { + fan, + answered: Mutex::new(HashSet::new()), + allowed: AtomicU64::new(0), + denied: AtomicU64::new(0), + timed_out: AtomicU64::new(0), + } + } + + /// Register an event as in flight so the watchdog can adopt it. + fn register(&self, fd: RawFd) { + self.answered.lock().expect("responder poisoned").remove(&fd); + } + + /// Answer an event. Idempotent: a second call for the same descriptor + /// is a no-op, which is what makes the watchdog safe to race with the + /// scan loop. + pub fn answer(&self, fd: RawFd, allow: bool, timed_out: bool) { + { + let mut answered = self.answered.lock().expect("responder poisoned"); + if !answered.insert(fd) { + return; + } + } + + let response = Response { + fd, + response: if allow { FAN_ALLOW } else { FAN_DENY }, + }; + // SAFETY: writing a fanotify_response to the fanotify descriptor is + // the documented way to answer a permission event. + let written = unsafe { + libc::write( + self.fan.as_raw_fd(), + &response as *const Response as *const libc::c_void, + std::mem::size_of::(), + ) + }; + if written < 0 { + eprintln!("gate: failed to answer event: {}", io::Error::last_os_error()); + } + + if timed_out { + self.timed_out.fetch_add(1, Ordering::Relaxed); + } + if allow { + self.allowed.fetch_add(1, Ordering::Relaxed); + } else { + self.denied.fetch_add(1, Ordering::Relaxed); + } + + // The kernel handed us this descriptor; leaking it exhausts the + // process fd table and wedges us just as surely as not answering. + // SAFETY: we own this fd and have answered for it. + unsafe { libc::close(fd) }; + } + + /// `(allowed, denied, timed_out)`. + pub fn counters(&self) -> (u64, u64, u64) { + ( + self.allowed.load(Ordering::Relaxed), + self.denied.load(Ordering::Relaxed), + self.timed_out.load(Ordering::Relaxed), + ) + } +} + +// ── the syscall layer ─────────────────────────────────────────────────── + +/// An initialised fanotify group. +pub struct Gate { + responder: Arc, + /// Events registered but not yet answered, with their deadlines. + inflight: Arc>>, + running: Arc, +} + +impl Gate { + /// Open a fanotify group in permission mode. + /// + /// Fails with `EPERM` without `CAP_SYS_ADMIN`, which is the expected + /// outcome for an unprivileged run and must be handled by degrading + /// rather than by dying. + pub fn init() -> io::Result { + // SAFETY: plain syscall with constant arguments. + let fd = unsafe { + libc::fanotify_init( + FAN_CLOEXEC | FAN_NONBLOCK | FAN_CLASS_CONTENT, + (libc::O_RDONLY | libc::O_LARGEFILE) as u32, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: fanotify_init returned a valid owned descriptor. + let fan = unsafe { OwnedFd::from_raw_fd(fd) }; + Ok(Self { + responder: Arc::new(Responder::new(fan)), + inflight: Arc::new(Mutex::new(Vec::new())), + running: Arc::new(AtomicBool::new(false)), + }) + } + + pub fn responder(&self) -> Arc { + Arc::clone(&self.responder) + } + + /// Watch a whole mount. Used in tests against a dedicated tmpfs so a + /// bug cannot reach the real filesystem. + pub fn mark_mount(&self, path: &Path) -> io::Result<()> { + self.mark(path, FAN_MARK_ADD | FAN_MARK_MOUNT) + } + + /// Watch an entire filesystem. This is the production mark, and the + /// reason the watchdog is not optional. + pub fn mark_filesystem(&self, path: &Path) -> io::Result<()> { + self.mark(path, FAN_MARK_ADD | FAN_MARK_FILESYSTEM) + } + + fn mark(&self, path: &Path, flags: u32) -> io::Result<()> { + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; + // SAFETY: fd is valid, path is a NUL-terminated C string that + // outlives the call. + let rc = unsafe { + libc::fanotify_mark( + self.responder.fan.as_raw_fd(), + flags, + FAN_OPEN_EXEC_PERM | FAN_OPEN_PERM, + libc::AT_FDCWD, + c_path.as_ptr(), + ) + }; + if rc < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + /// Read whatever events are pending. Returns empty on `EAGAIN`. + pub fn read_events(&self) -> io::Result> { + let mut buf = [0u8; 8192]; + // SAFETY: reading into a buffer we own, bounded by its length. + let n = unsafe { + libc::read( + self.responder.fan.as_raw_fd(), + buf.as_mut_ptr() as *mut libc::c_void, + buf.len(), + ) + }; + if n < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + return Ok(Vec::new()); + } + return Err(err); + } + let events = decode(&buf[..n as usize]); + // Register before returning so the watchdog can adopt an event even + // if the caller panics on the way to scanning it. + { + let deadline = Instant::now() + DEADLINE; + let mut inflight = self.inflight.lock().expect("inflight poisoned"); + for e in &events { + self.responder.register(e.fd); + inflight.push((e.fd, deadline)); + } + } + Ok(events) + } + + /// Start the fail-open watchdog. + /// + /// This is the safety net the whole design rests on: anything still + /// unanswered past its deadline is allowed through, and the daemon + /// records that it happened. + pub fn start_watchdog(&self) -> std::thread::JoinHandle<()> { + self.running.store(true, Ordering::SeqCst); + let responder = Arc::clone(&self.responder); + let inflight = Arc::clone(&self.inflight); + let running = Arc::clone(&self.running); + std::thread::Builder::new() + .name("hound-gate-watchdog".into()) + .spawn(move || { + while running.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(50)); + let now = Instant::now(); + let mut expired = Vec::new(); + { + let mut list = inflight.lock().expect("inflight poisoned"); + list.retain(|(fd, deadline)| { + if now >= *deadline { + expired.push(*fd); + false + } else { + true + } + }); + } + for fd in expired { + // Fail OPEN. Always. + responder.answer(fd, true, true); + } + } + }) + .expect("spawning the watchdog must not fail") + } + + /// Run the gate: one reader thread, `workers` scanner threads. + /// + /// The split is a safety requirement, not a throughput one. The + /// watchdog can only rescue events it has been told about, and it is + /// told about them in [`Gate::read_events`]. If a slow scan runs on the + /// same thread that drains the queue, every event behind it is invisible + /// to the watchdog and the deadline means nothing. Keeping the reader + /// free means every event is registered within microseconds of arriving, + /// whatever the scanners are doing. + pub fn serve( + self: &Arc, + cfg: GateConfig, + scan: Arc Option + Send + Sync>, + ) -> Vec> { + let (tx, rx) = std::sync::mpsc::channel::(); + let rx = Arc::new(Mutex::new(rx)); + let mut handles = Vec::new(); + + // Reader: drains the kernel queue and never blocks on a scan. + { + let gate = Arc::clone(self); + let running = Arc::clone(&self.running); + handles.push( + std::thread::Builder::new() + .name("hound-gate-reader".into()) + .spawn(move || { + while running.load(Ordering::SeqCst) { + match gate.read_events() { + Ok(events) if events.is_empty() => { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(events) => { + for e in events { + let fd = e.fd; + if tx.send(e).is_err() { + // No workers left: fail open. + gate.answer_and_retire(fd, true); + } + } + } + Err(e) => { + eprintln!("gate: read failed: {e}"); + std::thread::sleep(Duration::from_millis(20)); + } + } + } + }) + .expect("spawning the gate reader must not fail"), + ); + } + + let our_pid = std::process::id() as i32; + for n in 0..cfg.workers.max(1) { + let gate = Arc::clone(self); + let rx = Arc::clone(&rx); + let scan = Arc::clone(&scan); + let running = Arc::clone(&self.running); + let excludes = cfg.excludes.clone(); + let max_size = cfg.max_size; + handles.push( + std::thread::Builder::new() + .name(format!("hound-gate-{n}")) + .spawn(move || { + while running.load(Ordering::SeqCst) { + let event = { + let rx = rx.lock().expect("gate channel poisoned"); + rx.recv_timeout(Duration::from_millis(50)) + }; + let Ok(event) = event else { continue }; + + let path = event.path(); + let decision = policy::decide( + event.pid, + our_pid, + path.as_deref(), + event.size(), + max_size, + &excludes, + ); + let allow = match decision { + policy::Decision::AllowNow(_) => true, + policy::Decision::Scan => path + .as_deref() + .and_then(|p| { + event.content(max_size as usize).and_then(|b| scan(p, &b)) + }) + .is_none(), + }; + gate.answer_and_retire(event.fd, allow); + } + }) + .expect("spawning a gate worker must not fail"), + ); + } + handles + } + + /// Mark an event as answered so the watchdog stops tracking it. + pub fn retire(&self, fd: RawFd) { + let mut list = self.inflight.lock().expect("inflight poisoned"); + list.retain(|(f, _)| *f != fd); + } + + pub fn stop(&self) { + self.running.store(false, Ordering::SeqCst); + } + + /// Read whatever is pending and answer all of it. Returns + /// `(handled, denied)`. + /// + /// `scan` is handed the file's **bytes**, already read from the event + /// descriptor, and returns `Some(detection_name)` to block. It must not + /// open the gated file itself — see [`Event::content`] for why that + /// deadlocks. + pub fn serve_once( + &self, + max_size: u64, + excludes: &[String], + scan: &dyn Fn(&Path, &[u8]) -> Option, + ) -> io::Result<(usize, usize)> { + let our_pid = std::process::id() as i32; + let events = self.read_events()?; + let mut denied = 0usize; + + for event in &events { + let path = event.path(); + let size = event.size(); + + let decision = policy::decide( + event.pid, + our_pid, + path.as_deref(), + size, + max_size, + excludes, + ); + + let allow = match decision { + policy::Decision::AllowNow(_) => true, + policy::Decision::Scan => { + let verdict = path.as_deref().and_then(|p| { + event + .content(max_size as usize) + .and_then(|bytes| scan(p, &bytes)) + }); + match verdict { + Some(_name) => { + denied += 1; + false + } + None => true, + } + } + }; + + self.answer_and_retire(event.fd, allow); + } + Ok((events.len(), denied)) + } + + /// Answer an event and stop the watchdog tracking it. + pub fn answer_and_retire(&self, fd: RawFd, allow: bool) { + self.retire(fd); + self.responder.answer(fd, allow, false); + } +} + +/// How the gate should behave once running. +#[derive(Debug, Clone)] +pub struct GateConfig { + /// Scanner threads. More absorbs bursts; each one is a file read. + pub workers: usize, + /// Files above this are allowed through unread. + pub max_size: u64, + /// Path prefixes never held for a verdict. + pub excludes: Vec, +} + +impl Default for GateConfig { + fn default() -> Self { + Self { + workers: 4, + max_size: 100 * 1024 * 1024, + excludes: vec!["/proc".into(), "/sys".into(), "/dev".into(), "/run".into()], + } + } +} + +/// Decode a read buffer into events, skipping anything whose ABI version +/// we do not recognise. +fn decode(buf: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut offset = 0usize; + + while offset + METADATA_SIZE <= buf.len() { + // SAFETY: bounds checked above; EventMetadata is repr(C) and POD. + let meta: EventMetadata = + unsafe { std::ptr::read_unaligned(buf[offset..].as_ptr() as *const EventMetadata) }; + + let len = meta.event_len as usize; + if len < METADATA_SIZE || offset + len > buf.len() { + break; + } + // A version mismatch means the struct we just read may not mean + // what we think. Stop rather than guess. + if meta.vers != FAN_METADATA_VERSION { + break; + } + if meta.fd >= 0 { + out.push(Event { + fd: meta.fd, + pid: meta.pid, + mask: meta.mask, + }); + } + offset += len; + } + out +} + +#[cfg(test)] +mod tests { + use super::policy::*; + use super::*; + + fn ex(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + // ── the rule that prevents self-deadlock ── + + #[test] + fn our_own_opens_are_always_allowed_first() { + // Even with everything else pointing at Scan, our own pid wins. + let d = decide(4242, 4242, Some(Path::new("/tmp/x")), Some(10), 100, &[]); + assert_eq!(d, Decision::AllowNow("self")); + } + + #[test] + fn self_check_beats_exclusions_and_size() { + let d = decide(7, 7, Some(Path::new("/usr/bin/ls")), Some(u64::MAX), 1, &ex(&["/usr"])); + assert_eq!(d, Decision::AllowNow("self")); + } + + // ── ordinary policy ── + + #[test] + fn other_processes_get_scanned() { + let d = decide(100, 7, Some(Path::new("/home/joe/a.bin")), Some(10), 100, &[]); + assert_eq!(d, Decision::Scan); + } + + #[test] + fn unresolvable_paths_are_allowed_not_held() { + let d = decide(100, 7, None, None, 100, &[]); + assert_eq!(d, Decision::AllowNow("unresolvable")); + } + + #[test] + fn excluded_paths_are_allowed() { + let d = decide( + 100, + 7, + Some(Path::new("/var/lib/docker/overlay2/x")), + Some(10), + 100, + &ex(&["/var/lib/docker"]), + ); + assert_eq!(d, Decision::AllowNow("excluded")); + } + + #[test] + fn oversized_files_are_allowed_not_held() { + let d = decide(100, 7, Some(Path::new("/data/big.iso")), Some(5_000), 100, &[]); + assert_eq!(d, Decision::AllowNow("oversized")); + } + + #[test] + fn exclusion_matches_whole_components_only() { + assert!(is_excluded(Path::new("/var/lib/docker/x"), &ex(&["/var/lib/docker"]))); + assert!( + !is_excluded(Path::new("/variable/thing"), &ex(&["/var"])), + "/var must not swallow /variable" + ); + } + + #[test] + fn trailing_slashes_and_empties_are_tolerated() { + assert!(is_excluded(Path::new("/proc/1"), &ex(&["/proc/"]))); + assert!(!is_excluded(Path::new("/proc/1"), &ex(&[""]))); + } + + // ── event decoding ── + + fn encode(vers: u8, mask: u64, fd: i32, pid: i32) -> Vec { + let meta = EventMetadata { + event_len: METADATA_SIZE as u32, + vers, + reserved: 0, + metadata_len: METADATA_SIZE as u16, + mask, + fd, + pid, + }; + // SAFETY: reading a repr(C) POD struct as bytes. + unsafe { + std::slice::from_raw_parts(&meta as *const EventMetadata as *const u8, METADATA_SIZE) + } + .to_vec() + } + + #[test] + fn decodes_a_single_event() { + let buf = encode(FAN_METADATA_VERSION, FAN_OPEN_EXEC_PERM, 9, 1234); + let events = decode(&buf); + assert_eq!(events.len(), 1); + assert_eq!(events[0].fd, 9); + assert_eq!(events[0].pid, 1234); + assert!(events[0].is_exec()); + } + + #[test] + fn decodes_several_events_in_one_read() { + let mut buf = encode(FAN_METADATA_VERSION, FAN_OPEN_PERM, 3, 1); + buf.extend(encode(FAN_METADATA_VERSION, FAN_OPEN_EXEC_PERM, 4, 2)); + let events = decode(&buf); + assert_eq!(events.len(), 2); + assert!(!events[0].is_exec(), "a plain open is not an exec"); + assert!(events[1].is_exec()); + } + + #[test] + fn rejects_an_unknown_abi_version() { + let buf = encode(99, FAN_OPEN_PERM, 3, 1); + assert!( + decode(&buf).is_empty(), + "a struct we cannot interpret must be skipped, not guessed at" + ); + } + + #[test] + fn ignores_a_truncated_trailing_event() { + let mut buf = encode(FAN_METADATA_VERSION, FAN_OPEN_PERM, 3, 1); + buf.extend_from_slice(&[0u8; 7]); // not a whole record + assert_eq!(decode(&buf).len(), 1); + } + + #[test] + fn skips_events_without_a_descriptor() { + // FAN_NOFD (-1) shows up on queue overflow. + let buf = encode(FAN_METADATA_VERSION, FAN_OPEN_PERM, -1, 1); + assert!(decode(&buf).is_empty()); + } + + #[test] + fn empty_read_decodes_to_nothing() { + assert!(decode(&[]).is_empty()); + } + + // ── init behaviour ── + + #[test] + fn init_without_privileges_fails_cleanly() { + // Unprivileged: EPERM. Root: succeeds. Either is a pass — what + // must never happen is a panic or a hang. + match Gate::init() { + Ok(gate) => { + gate.stop(); + assert_eq!(gate.responder().counters(), (0, 0, 0)); + } + Err(e) => assert!( + matches!(e.kind(), io::ErrorKind::PermissionDenied) + || e.raw_os_error() == Some(libc::EPERM), + "expected EPERM without CAP_SYS_ADMIN, got {e}" + ), + } + } + + // ── reading from the descriptor, not the path ── + + fn event_for(path: &Path) -> (Event, std::fs::File) { + let f = std::fs::File::open(path).unwrap(); + let fd = f.as_raw_fd(); + (Event { fd, pid: 1, mask: FAN_OPEN_PERM }, f) + } + + #[test] + fn content_reads_through_the_descriptor() { + let p = std::env::temp_dir().join(format!("hound-fd-{}", std::process::id())); + std::fs::write(&p, b"gate content").unwrap(); + let (e, _f) = event_for(&p); + assert_eq!(e.content(1024).as_deref(), Some(&b"gate content"[..])); + assert_eq!(e.size(), Some(12)); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn content_refuses_oversized_without_reading() { + let p = std::env::temp_dir().join(format!("hound-fd-big-{}", std::process::id())); + std::fs::write(&p, vec![7u8; 4096]).unwrap(); + let (e, _f) = event_for(&p); + assert!(e.content(1024).is_none(), "must not read past the cap"); + assert_eq!(e.content(8192).map(|b| b.len()), Some(4096)); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn content_does_not_disturb_the_file_offset() { + // The gated process sees the file it opened, at the offset it + // expects — pread, not read. + let p = std::env::temp_dir().join(format!("hound-fd-off-{}", std::process::id())); + std::fs::write(&p, b"abcdefgh").unwrap(); + let (e, f) = event_for(&p); + let _ = e.content(1024).unwrap(); + let pos = unsafe { libc::lseek(f.as_raw_fd(), 0, libc::SEEK_CUR) }; + assert_eq!(pos, 0, "pread must leave the offset alone"); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn gate_config_defaults_exclude_pseudo_filesystems() { + let cfg = GateConfig::default(); + assert!(cfg.workers >= 1); + for d in ["/proc", "/sys", "/dev", "/run"] { + assert!( + is_excluded(Path::new(&format!("{d}/thing")), &cfg.excludes), + "{d} must never be held for a verdict" + ); + } + } + + #[test] + fn deadline_leaves_three_orders_of_magnitude_of_headroom() { + // Scans measured at ~4ms. If this ever needs raising, the engine + // regressed and that is the bug. + assert!(DEADLINE >= Duration::from_millis(100)); + assert!(DEADLINE <= Duration::from_secs(2)); + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 96b4a72..95be0ba 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -43,6 +43,7 @@ mod cache; mod engine; mod events; +mod fanotify; mod native; mod quarantine; mod realtime; diff --git a/crates/houndd/src/native.rs b/crates/houndd/src/native.rs index fbd15e8..85b0c03 100644 --- a/crates/houndd/src/native.rs +++ b/crates/houndd/src/native.rs @@ -162,6 +162,17 @@ impl ScanEngine for HoundEngine { }) } + fn scan_bytes(&self, bytes: &[u8]) -> Option { + let set = self.rules.current(); + let mut scanner = yara_x::Scanner::new(&set.rules); + scanner + .scan(bytes) + .ok()? + .matching_rules() + .next() + .map(|r| RuleSet::detection_name(&r)) + } + fn update(&self) -> Result<(bool, String, String)> { let before = self.rules.current().count; match self.rules.reload() { @@ -453,6 +464,16 @@ mod tests { let _ = fs::remove_dir_all(&d); } + #[test] + fn scan_bytes_matches_without_touching_disk() { + let e = HoundEngine::new().unwrap(); + assert_eq!( + e.scan_bytes(EICAR.as_bytes()).as_deref(), + Some("EICAR-Test-Signature") + ); + assert!(e.scan_bytes(b"an ordinary sentence").is_none()); + } + #[test] fn skips_pseudo_filesystems() { assert!(is_skipped_dir(Path::new("/proc/1")));