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>
332 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|