Antivirus/crates/houndd/src/engine.rs
Hound 020a1fa8bd rules: a rule must earn the right to move somebody's file
Quarantine deletes a file from where its owner put it. Until now every
detection did that, so every false positive was destructive rather than
merely wrong — which on this machine cost an 8.5 MB compiler cache and a
4.3 MB session transcript, the latter's history permanently.

Each rule now declares what Hound may do:

    action = "quarantine"   move it to the vault
    action = "alert"        report it, leave it alone

**The default is alert**, and so is an unrecognised value, and so is a
detection name the engine does not know. One misspelt "quarantne" must
not turn an advisory rule into a destructive one across every machine
that updates.

Quarantine has to be earned by an ANCHOR, not by the author's
confidence:

  EICAR-Test-Signature    quarantine  exact 68-byte payload, size-bounded
  Linux.Coinminer.XMRig   quarantine  ELF magic
  Linux.Rootkit.Preload   quarantine  ELF magic
  Linux.Webshell.PHP-Eval ALERT       content-only — PHP has no file
                                      magic, so it can still match a
                                      security write-up, a log or an AI
                                      transcript quoting a webshell

A test asserts that property directly: any rule declaring quarantine
must contain a file-type check or an exact size bound. A future rule
cannot quietly claim the destructive action without one.

Both the execution gate and the inotify fallback consult it, kept in
step deliberately — a fallback more destructive than the primary path is
a trap for whoever ends up running unprivileged.

Verified live on the gated filesystem: a webshell written to disk is
reported and left in place; an ELF miner written beside it is
quarantined. Event text changed to match — "threat detected in X —
reported, not moved" rather than implying something happened.

One process note. The first attempt at this edit silently did nothing:
the replacement did not match because of indentation, the tooling
reported success, and the webshell was still moved. Second time I made
the edit assert its anchor before applying. That is the third silent
no-op edit in this session and the pattern is now obvious enough to
stop assuming an edit landed.

303 tests pass. Gate off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:48:27 -05:00

471 lines
16 KiB
Rust

//! The engine seam.
//!
//! Everything ClamAV-specific (version probe, signature freshness, the
//! `clamscan` subprocess + output parsing, `freshclam`) lives behind
//! [`ScanEngine`]. The daemon's `status` / `scan` / `update` RPCs are
//! defined against the trait, so a native Rust engine later — ours or a
//! rewritten ClamAV — plugs in by implementing four methods and flipping
//! the const at the bottom. Nothing on the wire, in the CLI, or in the
//! GUI needs to change.
//!
//! The wire API is deliberately engine-agnostic: `Status.engine`
//! identifies the implementation, and DB freshness travels as a file
//! name + timestamp (any signature store has those two facts).
use anyhow::{Context, Result};
use hound_api::{DbFile, ScanResult};
use std::fs;
use std::process::Command;
use std::sync::OnceLock;
/// What an engine implementation must answer.
///
/// `Send + Sync` because the daemon holds exactly one engine for its
/// whole life and hands it to every connection thread and to the
/// real-time monitor.
pub trait ScanEngine: Send + Sync {
/// Stable id for the wire (`Status.engine`): "clamav" today, e.g.
/// "hound-native" when the Rust engine ships.
fn name(&self) -> &'static str;
/// Presence + signature-DB freshness. `present` is the tray's
/// "engine online/offline" signal.
fn probe(&self) -> (bool, String, Option<DbFile>);
/// Scan `path` (canonicalize first) and return per-file findings.
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult>;
/// 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<String> {
None
}
/// Whether a detection may move the file, rather than only report it.
///
/// Defaults to `false`. An engine that does not model this — or a
/// detection name it does not recognise — must not destroy anything.
fn may_quarantine(&self, _detection: &str) -> bool {
false
}
/// Answer from memory alone, without reading the file.
///
/// `Some(verdict)` means we have judged this exact file version
/// before; `None` means it must be scanned. The execution gate uses
/// this to answer repeat executions without waking a worker, which is
/// most of the traffic on a machine that is actually doing something.
fn cached_verdict(&self, _md: &std::fs::Metadata) -> Option<Option<String>> {
None
}
}
/// The ClamAV-backed engine: `clamscan` + `freshclam` over their
/// well-behaved text interfaces.
pub struct ClamAvEngine;
impl ScanEngine for ClamAvEngine {
fn name(&self) -> &'static str {
"clamav"
}
fn probe(&self) -> (bool, String, Option<DbFile>) {
let version = Command::new("clamscan")
.arg("--version")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if version.is_empty() {
return (false, String::new(), None);
}
// freshclam's DB files live in /var/lib/clamav; report the newest.
let mut newest: Option<(String, std::time::SystemTime)> = None;
for entry in fs::read_dir("/var/lib/clamav")
.into_iter()
.flatten()
.flatten()
{
let path = entry.path();
if !path
.extension()
.is_some_and(|ext| ext == "cld" || ext == "ndb")
{
continue;
}
let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) else {
continue;
};
let name = path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let replace = match &newest {
None => true,
Some((_, cur)) => mtime > *cur,
};
if replace {
newest = Some((name, mtime));
}
}
match newest {
Some((file, t)) => {
let days = std::time::SystemTime::now()
.duration_since(t)
.map(|d| d.as_secs() / 86_400)
.unwrap_or(0);
(
true,
format!("signatures updated {days}d ago ({file}) [clamav {version}]"),
Some(DbFile {
file,
updated_at: to_rfc3339(t),
}),
)
}
None => (
true,
format!("no signature DB found (run: sudo freshclam) [clamav {version}]"),
None,
),
}
}
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult> {
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
let mut cmd = Command::new("clamscan");
cmd.arg("--no-summary")
.arg("--stdout")
.arg("--max-filesize=100M")
.arg("--max-scansize=250M");
if recursive {
cmd.arg("-r");
}
cmd.arg("--allmatch").arg(path);
let out = cmd
.output()
.context("running clamscan (is ClamAV installed?)")?;
Ok(parse_clamscan(
&out.stdout,
out.status.code().unwrap_or(-1),
)?)
}
fn update(&self) -> Result<(bool, String, String)> {
let attempts: [(&str, &[&str]); 2] = [
("sudo freshclam", &["sudo", "freshclam", "--no-dns"]),
("freshclam", &["freshclam", "--no-dns"]),
];
let (ok, label, out) = if std::env::var_os("HOUNDD_NO_SUDO").is_some() {
// Daemon already running as root: skip sudo (it would prompt).
let (label, argv) = attempts[1];
let (label, out) = run_cmd(label, argv)?;
(out.status.success(), label, out)
} else {
let (label, argv) = attempts[0];
let (label, out) = run_cmd(label, argv)?;
if out.status.success() {
(true, label, out)
} else {
// Plain user without group perms: report the honest reason.
let (label2, argv2) = attempts[1];
let (label2, out2) = run_cmd(label2, argv2)?;
(out2.status.success(), label2, out2)
}
};
let mut combined = String::from_utf8_lossy(&out.stdout).to_string();
if !out.stderr.is_empty() {
combined.push_str(&String::from_utf8_lossy(&out.stderr));
}
Ok((ok, label.to_string(), combined))
}
}
fn run_cmd(label: &str, argv: &[&str]) -> Result<(String, std::process::Output)> {
let out = Command::new(argv[0])
.args(&argv[1..])
.output()
.with_context(|| format!("running {label} failed (installed?)"))?;
Ok((label.to_string(), out))
}
/// Parse `clamscan --stdout` output into findings.
///
/// Every file ClamAV looks at emits exactly one line:
///
/// ```text
/// /abs/path: OK
/// /abs/path: VirusName FOUND
/// /abs/path: INCOMPLETE
/// ```
///
/// We parse those lines (not `--json`) because the text format is stable
/// across ClamAV 0.103 → 1.x while `--json` fields have churned.
/// `--allmatch` reports *every* signature a file matches (EICAR trips 3),
/// so each path is counted once for the scanned total and reported once
/// as a finding.
pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
use hound_api::Found;
use std::collections::HashSet;
let text = String::from_utf8_lossy(stdout);
let mut found: Vec<Found> = Vec::new();
let mut seen_files: HashSet<String> = HashSet::new();
let mut reported: HashSet<String> = HashSet::new();
for line in text.lines() {
// A per-file result line starts with the path then ": ".
let Some(idx) = line.find(": ") else { continue };
let file = line[..idx].trim();
if !file.starts_with('/') || file.is_empty() {
continue;
}
seen_files.insert(file.to_string());
let body = &line[idx + 2..];
if let Some(end) = body.rfind(" FOUND") {
let key = file.to_string();
if reported.insert(key.clone()) {
found.push(Found {
path: key,
virus: body[..end].to_string(),
});
}
}
}
// 0 = no infections, 1 = infections found, >1 = real error.
if !(exit_code == 0 || exit_code == 1) {
anyhow::bail!("clamscan exited {exit_code}");
}
let scanned = seen_files.len() as u64;
let infected = found.len() as u64;
let clean = scanned.saturating_sub(infected);
Ok(ScanResult {
scanned,
clean,
infected,
// clamscan does not tell us what it skipped for size.
skipped: 0,
found,
})
}
/// Format a `SystemTime` as RFC3339 UTC for the wire.
pub fn to_rfc3339(t: std::time::SystemTime) -> String {
use time::format_description::well_known::Rfc3339;
let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) else {
return "unknown".into();
};
let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(d.as_secs() as i64) else {
return "unknown".into();
};
dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into())
}
/// The active engine, chosen once at daemon startup.
///
/// Default is [`HoundEngine`](crate::native::HoundEngine) — yara-x in
/// process. `HOUNDD_ENGINE` overrides it:
///
/// * `clamav` — the legacy `clamscan` subprocess path. Kept so the two
/// can be compared directly, and because it still owns the Windows
/// malware corpus that our own rules deliberately do not cover.
/// * `fake` — the synthetic engine the E2E test drives, so a full
/// daemon lifecycle can run without ClamAV or a real rule pack.
///
/// The whole point of the trait is that this is the only place the
/// daemon decides *which* engine it serves.
pub fn engine() -> &'static dyn ScanEngine {
// Selection is per call — one getenv — so the choice stays live and
// two tests in one process cannot contaminate each other. Only the
// expensive engine is memoised, below.
match std::env::var("HOUNDD_ENGINE").as_deref() {
Ok("fake") => {
static FAKE: FakeEngine = FakeEngine;
&FAKE
}
Ok("clamav") => {
static CLAMAV: ClamAvEngine = ClamAvEngine;
&CLAMAV
}
_ => native_engine(),
}
}
/// The native engine, built exactly once. Compiling the ruleset is the
/// one genuinely expensive thing the daemon does at startup, so it must
/// never happen twice.
fn native_engine() -> &'static dyn ScanEngine {
static ENGINE: OnceLock<Box<dyn ScanEngine>> = OnceLock::new();
ENGINE
.get_or_init(|| match crate::native::HoundEngine::new() {
Ok(e) => Box::new(e) as Box<dyn ScanEngine>,
Err(e) => {
// Losing detection entirely is worse than falling back to
// the slow path, so say so loudly and carry on.
eprintln!("engine: rules failed to compile ({e}) — falling back to clamav");
Box::new(ClamAvEngine)
}
})
.as_ref()
}
/// Test engine: reports itself present, scans anything whose name
/// contains "EICAR" or ".eicar" as infected, and updates cleanly.
/// Lets the E2E test exercise the full wire without ClamAV installed.
pub struct FakeEngine;
impl ScanEngine for FakeEngine {
fn name(&self) -> &'static str {
"fake"
}
fn probe(&self) -> (bool, String, Option<DbFile>) {
(
true,
"signatures: synthetic [fake]".to_string(),
Some(DbFile {
file: "fake.cld".to_string(),
updated_at: to_rfc3339(std::time::SystemTime::now()),
}),
)
}
fn scan(&self, path: &str, _recursive: bool) -> Result<ScanResult> {
use hound_api::Found;
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
let infected = path
.to_string_lossy()
.to_ascii_lowercase()
.contains("eicar");
let found = if infected {
vec![Found {
path: path.to_string_lossy().to_string(),
virus: "Fake-Eicar".to_string(),
}]
} else {
Vec::new()
};
let scanned: u64 = 1;
let infected_u: u64 = if infected { 1 } else { 0 };
Ok(ScanResult {
scanned,
clean: scanned - infected_u,
infected: infected_u,
skipped: 0,
found,
})
}
fn update(&self) -> Result<(bool, String, String)> {
Ok((
true,
"fake update".to_string(),
"OK: fake DB refreshed\n".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_clean_scan() {
let out = br"/tmp/a.txt: OK
/tmp/b.txt: OK
";
let r = parse_clamscan(out, 0).unwrap();
assert_eq!(r.scanned, 2);
assert_eq!(r.clean, 2);
assert_eq!(r.infected, 0);
assert!(r.found.is_empty());
assert!(r.is_clean());
}
#[test]
fn parses_infected_scan_dedup_allmatch() {
// --allmatch: one EICAR file trips several signatures, one line each.
let out = br"/tmp/eicar.com: Eicar-Test-Signature FOUND\n\
/tmp/eicar.com: Test.Virus TWO FOUND\n\
/tmp/clean.txt: OK\n";
let r = parse_clamscan(out, 1).unwrap();
assert_eq!(r.scanned, 2, "two unique files, not three lines");
assert_eq!(r.infected, 1, "EICAR must count as one threat");
assert_eq!(r.clean, 1);
assert_eq!(r.found.len(), 1);
assert_eq!(r.found[0].path, "/tmp/eicar.com");
assert_eq!(r.found[0].virus, "Eicar-Test-Signature");
}
#[test]
fn incomplete_lines_counted_not_infected() {
let out = br"/tmp/big.bin: INCOMPLETE
/tmp/x: OK
";
let r = parse_clamscan(out, 0).unwrap();
assert_eq!(r.scanned, 2);
assert_eq!(r.infected, 0);
}
#[test]
fn nonzero_exit_is_error() {
let r = parse_clamscan(b"", 7);
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("7"));
}
#[test]
fn e2e_eicar_via_real_clamscan() {
// Skips itself when ClamAV isn't installed (CI boxes).
let which = Command::new("clamscan").arg("--version").output();
if which.as_ref().is_err() {
return;
}
let tmp = std::env::temp_dir().join(format!("houndd-engine-test-{}", std::process::id()));
let _ = fs::create_dir_all(&tmp);
std::fs::write(
tmp.join("eicar.bin"),
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
)
.unwrap();
let engine = ClamAvEngine;
let r = engine.scan(tmp.to_str().unwrap(), true).unwrap();
assert_eq!(r.scanned, 1);
assert_eq!(r.infected, 1);
assert_eq!(r.found[0].virus, "Eicar-Test-Signature");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn probe_reports_present() {
let (present, _summary, _db) = ClamAvEngine.probe();
// CI boxes without ClamAV: presence is whatever the OS says.
let via_cmd = Command::new("clamscan").arg("--version").output();
assert_eq!(
present,
via_cmd.map(|o| o.status.success()).unwrap_or(false)
);
}
#[test]
fn trait_is_object_safe() {
fn take(_e: &dyn ScanEngine) {}
take(engine());
}
}