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>
This commit is contained in:
Hound 2026-08-20 22:29:01 -05:00
parent 6746182f18
commit aa22ddbc38
9 changed files with 1102 additions and 0 deletions

1
Cargo.lock generated
View file

@ -1048,6 +1048,7 @@ dependencies = [
"anyhow", "anyhow",
"hound-api", "hound-api",
"inotify", "inotify",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"time", "time",

View file

@ -17,6 +17,7 @@ colored = "2"
time = { version = "0.3", features = ["serde", "std", "formatting"] } time = { version = "0.3", features = ["serde", "std", "formatting"] }
inotify = "0.10" inotify = "0.10"
yara-x = "1.19" yara-x = "1.19"
libc = "0.2"
[profile.release] [profile.release]
lto = true lto = true

View file

@ -150,6 +150,18 @@ pub struct Settings {
/// Paths (exact or `/`-suffixed prefixes) skipped by scans. /// Paths (exact or `/`-suffixed prefixes) skipped by scans.
pub exclude_paths: Vec<String>, pub exclude_paths: Vec<String>,
// 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<String>,
// Real-time interception // Real-time interception
pub realtime_enabled: bool, pub realtime_enabled: bool,
/// Directories the realtime monitor watches (recursively). /// Directories the realtime monitor watches (recursively).
@ -238,6 +250,8 @@ impl Default for Settings {
recursive_default: true, recursive_default: true,
max_file_size_mb: 100, max_file_size_mb: 100,
exclude_paths: vec!["/proc".into(), "/sys".into(), "/dev".into()], exclude_paths: vec!["/proc".into(), "/sys".into(), "/dev".into()],
exec_gate: false,
exec_gate_paths: Vec::new(),
realtime_enabled: true, realtime_enabled: true,
realtime_watch: vec!["~/Downloads".into()], realtime_watch: vec!["~/Downloads".into()],
on_detect: "quarantine".into(), on_detect: "quarantine".into(),

View file

@ -18,3 +18,4 @@ serde_json.workspace = true
time.workspace = true time.workspace = true
inotify.workspace = true inotify.workspace = true
yara-x.workspace = true yara-x.workspace = true
libc.workspace = true

View file

@ -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<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
}

View file

@ -38,6 +38,18 @@ pub trait ScanEngine: Send + Sync {
/// Refresh the signature store. Returns (success, command label, /// Refresh the signature store. Returns (success, command label,
/// combined stdout+stderr tail) for the last attempt made. /// combined stdout+stderr tail) for the last attempt made.
fn update(&self) -> Result<(bool, String, String)>; 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<String> {
None
}
} }
/// The ClamAV-backed engine: `clamscan` + `freshclam` over their /// The ClamAV-backed engine: `clamscan` + `freshclam` over their

View file

@ -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::<EventMetadata>();
/// `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<PathBuf> {
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<u64> {
// 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<Vec<u8>> {
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<u64>,
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<HashSet<RawFd>>,
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::<Response>(),
)
};
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<Responder>,
/// Events registered but not yet answered, with their deadlines.
inflight: Arc<Mutex<Vec<(RawFd, Instant)>>>,
running: Arc<AtomicBool>,
}
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<Self> {
// 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<Responder> {
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<Vec<Event>> {
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<Self>,
cfg: GateConfig,
scan: Arc<dyn Fn(&Path, &[u8]) -> Option<String> + Send + Sync>,
) -> Vec<std::thread::JoinHandle<()>> {
let (tx, rx) = std::sync::mpsc::channel::<Event>();
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<String>,
) -> 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<String>,
}
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<Event> {
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<String> {
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<u8> {
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));
}
}

View file

@ -43,6 +43,7 @@
mod cache; mod cache;
mod engine; mod engine;
mod events; mod events;
mod fanotify;
mod native; mod native;
mod quarantine; mod quarantine;
mod realtime; mod realtime;

View file

@ -162,6 +162,17 @@ impl ScanEngine for HoundEngine {
}) })
} }
fn scan_bytes(&self, bytes: &[u8]) -> Option<String> {
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)> { fn update(&self) -> Result<(bool, String, String)> {
let before = self.rules.current().count; let before = self.rules.current().count;
match self.rules.reload() { match self.rules.reload() {
@ -453,6 +464,16 @@ mod tests {
let _ = fs::remove_dir_all(&d); 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] #[test]
fn skips_pseudo_filesystems() { fn skips_pseudo_filesystems() {
assert!(is_skipped_dir(Path::new("/proc/1"))); assert!(is_skipped_dir(Path::new("/proc/1")));