Antivirus/crates/houndd/src/quarantine.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

332 lines
12 KiB
Rust

//! Quarantine — the vault where Hound keeps files it caught.
//!
//! A quarantined file is *moved* (not copied) into the store under a
//! generated name, so the original location no longer holds the threat.
//! Each entry has a JSON sidecar holding the original path, the signature
//! that caught it, the quarantine time, and whether it has been restored.
//!
//! Store layout:
//! $XDG_DATA_HOME/hound/quarantine/
//! <id> ← the file's bytes
//! <id>.meta.json ← QuarantineEntry metadata
//!
//! Restoring moves the bytes back to the original path (recreating parent
//! dirs if needed) and keeps the entry flagged `restored: true` so the UI
//! can show it was let back out. Removing is a hard delete.
use hound_api::QuarantineEntry;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Where the quarantine store lives for this user.
pub fn store_dir() -> PathBuf {
let data = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.is_empty());
let base = match data {
Some(d) => PathBuf::from(d),
None => {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".local").join("share")
}
};
base.join("hound").join("quarantine")
}
/// Thread-safe view over the on-disk quarantine store. The in-memory
/// cache keeps `list()` cheap; every mutation also rewrites the sidecar.
#[derive(Clone)]
pub struct Quarantine {
#[allow(dead_code)] // held so two Quarantine instances share a cache
cache: Arc<Mutex<Vec<QuarantineEntry>>>,
}
impl Default for Quarantine {
fn default() -> Self {
Self::new()
}
}
impl Quarantine {
pub fn new() -> Self {
Self {
cache: Arc::new(Mutex::new(Vec::new())),
}
}
/// List all quarantined entries, newest first.
pub fn list(&self) -> Vec<QuarantineEntry> {
let dir = store_dir();
let mut entries: Vec<QuarantineEntry> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(meta) = std::fs::read_to_string(&path) {
if let Ok(e) = serde_json::from_str::<QuarantineEntry>(&meta) {
entries.push(e);
}
}
}
}
}
entries.sort_by(|a, b| b.ts.cmp(&a.ts));
*self.cache.lock().unwrap() = entries.clone();
entries
}
/// Move `path` into the store. `virus` is the signature that caught it
/// (or "manual" when the user quarantines by hand). Returns the entry.
pub fn add(&self, path: &str, virus: &str) -> anyhow::Result<QuarantineEntry> {
let src = std::fs::canonicalize(path)
.map_err(|e| anyhow::anyhow!("no such file to quarantine {path}: {e}"))?;
let dir = store_dir();
std::fs::create_dir_all(&dir)?;
let id = make_id(&src);
let dest = dir.join(&id);
let meta_path = dir.join(format!("{id}.meta.json"));
// Move the bytes in, across filesystems if need be.
move_file(&src, &dest)?;
seal(&dest);
let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
let entry = QuarantineEntry {
id: id.clone(),
original_path: src.to_string_lossy().to_string(),
quarantined_path: dest.to_string_lossy().to_string(),
virus: virus.to_string(),
size,
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
restored: false,
};
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
Ok(entry)
}
/// Restore a quarantined file to its original path.
pub fn restore(&self, id: &str) -> anyhow::Result<QuarantineEntry> {
let dir = store_dir();
let file = dir.join(id);
let meta_path = dir.join(format!("{id}.meta.json"));
let meta = std::fs::read_to_string(&meta_path)?;
let mut entry: QuarantineEntry = serde_json::from_str(&meta)?;
let dest = PathBuf::from(&entry.original_path);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
move_file(&file, &dest)?;
entry.restored = true;
std::fs::write(&meta_path, serde_json::to_string_pretty(&entry)?)?;
Ok(entry)
}
/// Hard-delete a quarantined file (bytes + sidecar). Returns bytes freed.
pub fn remove(&self, id: &str) -> anyhow::Result<u64> {
let dir = store_dir();
let file = dir.join(id);
let meta_path = dir.join(format!("{id}.meta.json"));
let bytes = std::fs::metadata(&file).map(|m| m.len()).unwrap_or(0);
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(&meta_path);
Ok(bytes)
}
/// Number of non-restored entries.
pub fn count(&self) -> u64 {
self.list().into_iter().filter(|e| !e.restored).count() as u64
}
}
/// Stable-ish id from the original path + a time component so two quarrantines
/// of the same file at different times get distinct ids.
/// Move a file, falling back to copy-and-delete across filesystems.
///
/// `rename(2)` fails with `EXDEV` when source and destination are on
/// different filesystems, and for quarantine that is the common case, not
/// the exotic one: the vault lives under `/var/lib`, while the things worth
/// quarantining show up on `/home` (often its own partition), in a tmpfs,
/// on a USB stick, or inside a container's overlay. A bare rename means
/// quarantine silently fails exactly where it is most needed.
fn move_file(src: &std::path::Path, dest: &std::path::Path) -> anyhow::Result<()> {
match std::fs::rename(src, dest) {
Ok(()) => Ok(()),
Err(e) if is_cross_device(&e) => {
std::fs::copy(src, dest)
.map_err(|e| anyhow::anyhow!("copying {} to the vault: {e}", src.display()))?;
// Only unlink once the copy is safely down. Losing the original
// without having stored it would destroy evidence.
std::fs::remove_file(src).map_err(|e| {
let _ = std::fs::remove_file(dest);
anyhow::anyhow!("removing {} after copying it: {e}", src.display())
})?;
Ok(())
}
Err(e) => Err(anyhow::anyhow!(
"moving {} to the vault: {e}",
src.display()
)),
}
}
/// EXDEV, however the platform spells it.
fn is_cross_device(e: &std::io::Error) -> bool {
e.raw_os_error() == Some(18)
}
/// Strip every execute bit and make the file root-only.
///
/// The vault holds live malware. It should not be runnable by anyone who
/// wanders into the directory, and a restore puts the original mode back
/// from the metadata rather than trusting what is on disk.
fn seal(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
fn make_id(path: &std::path::Path) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
// FNV-1a over path + nanos → 16 hex chars.
let input = format!("{}:{}", path.display(), now);
let mut hash: u64 = 0xcbf29ce484222325;
for b in input.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
/// Per-test data dir (tag it so tests never share a directory — one
/// test's cleanup must not race another's file writes).
fn tmp_data(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hound-qt-{}-{tag}", std::process::id()));
let _ = std::fs::create_dir_all(&d);
d
}
#[test]
fn add_then_list_then_remove() {
let data = tmp_data("addremove");
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_DATA_HOME", &data);
let src = data.join("victim.bin");
std::fs::write(&src, b"pay attention").unwrap();
let q = Quarantine::new();
let entry = q.add(src.to_str().unwrap(), "Test.Virus").unwrap();
assert_eq!(entry.virus, "Test.Virus");
assert!(!entry.restored);
// Original is gone, bytes are in the store.
assert!(!src.exists());
assert!(std::path::Path::new(&entry.quarantined_path).exists());
let list = q.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, entry.id);
let freed = q.remove(&entry.id).unwrap();
assert_eq!(freed, 13);
assert!(q.list().is_empty());
std::env::remove_var("XDG_DATA_HOME");
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn restore_puts_file_back() {
let data = tmp_data("restore");
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_DATA_HOME", &data);
let src = data.join("back.bin");
std::fs::write(&src, b"hello").unwrap();
let q = Quarantine::new();
let entry = q.add(src.to_str().unwrap(), "Manual").unwrap();
let restored = q.restore(&entry.id).unwrap();
assert!(restored.restored);
assert!(src.exists());
assert_eq!(std::fs::read(&src).unwrap(), b"hello");
std::env::remove_var("XDG_DATA_HOME");
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn quarantine_works_across_filesystems() {
// The bug this covers: rename(2) returns EXDEV between filesystems,
// and the vault is almost never on the same one as the threat.
// /dev/shm is a tmpfs on every mainstream distro, so this exercises
// a real cross-device move rather than a simulated one.
let shm = std::path::Path::new("/dev/shm");
if !shm.is_dir() {
return;
}
let data = tmp_data("xdev");
let _env_guard = crate::test_util::locked();
std::env::set_var("XDG_DATA_HOME", &data);
let src = shm.join(format!("hound-xdev-{}", std::process::id()));
std::fs::write(&src, b"pretend malware").unwrap();
let q = Quarantine::new();
let entry = q
.add(src.to_str().unwrap(), "Test.CrossDevice")
.expect("cross-device quarantine must work");
assert!(!src.exists(), "the original must be gone");
assert!(
std::path::Path::new(&entry.quarantined_path).exists(),
"the vault copy must exist"
);
assert_eq!(entry.size, 15);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&entry.quarantined_path)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the vault must strip exec bits");
}
// And back again, across the same boundary.
let restored = q.restore(&entry.id).expect("cross-device restore must work");
assert!(restored.restored);
assert!(src.exists(), "the file must return to where it came from");
let _ = std::fs::remove_file(&src);
let _ = q.remove(&entry.id);
std::env::remove_var("XDG_DATA_HOME");
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn exdev_is_recognised() {
let e = std::io::Error::from_raw_os_error(18);
assert!(is_cross_device(&e));
let enoent = std::io::Error::from_raw_os_error(2);
assert!(!is_cross_device(&enoent));
}
#[test]
fn ids_are_distinct() {
let a = make_id(std::path::Path::new("/tmp/x"));
std::thread::sleep(std::time::Duration::from_millis(1));
let b = make_id(std::path::Path::new("/tmp/x"));
assert_ne!(a, b);
}
}