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>
471 lines
16 KiB
Rust
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());
|
|
}
|
|
}
|