Armed the execution gate on the live server for the first time. It
reported itself armed on a dedicated tmpfs, and then let EICAR execute.
Counters: 0 allowed, 0 blocked. Not one event was ever delivered.
Cause: systemd gives the service a PRIVATE MOUNT NAMESPACE. Several
perfectly ordinary hardening options force one — ProtectProc,
ProtectKernelTunables, ProtectControlGroups — and none of them mention
it. FAN_MARK_MOUNT marks a vfsmount, and a private namespace holds its
own vfsmount for the same filesystem. So the daemon marked its copy,
every other process on the machine used the host's copy, and the gate
protected nothing while claiming to be armed.
That is the worst way for a security feature to fail: silently, with a
reassuring status line. Nothing in the unit tests could have caught it —
they run in the host namespace, where the mount mark works.
Fixed by always using FAN_MARK_FILESYSTEM, which marks the SUPERBLOCK.
A superblock is shared across namespaces, so events arrive from
everywhere, and scoping still works because a superblock is exactly one
filesystem: marking a dedicated mount covers that mount and nothing
else. mark_mount is kept for the smoke-test example, which runs outside
systemd, with a doc comment about when it lies to you.
Two more that only appeared once the gate was actually armed:
* SystemCallFilter=@system-service kills the daemon with SIGSYS the
moment the gate is switched on. fanotify_init and fanotify_mark live
in @privileged, which @system-service deliberately excludes. Granted
individually rather than by adding @privileged, which would also admit
setuid, chroot, bpf and kexec_load. Invisible until armed — the
service starts fine with the gate off.
* The capability reduction reported "60 capabilities could not be
dropped" while the end state was perfectly correct. systemd's
CapabilityBoundingSet had already done the work, and the service does
not hold CAP_SETPCAP afterwards, so every redundant drop failed EPERM.
It now checks what is actually present, attempts only that, and judges
by the end state rather than by return codes.
Also removed AmbientCapabilities from the unit. Ambient capabilities are
inherited by children, the daemon shells out to freshclam/rpm/pacman on
some paths, and a root process already receives the bounding set as
permitted — so it bought nothing except a way for CAP_SYS_ADMIN to leak
into a subprocess.
Performance, measured on the live server rather than guessed at:
+2.70 ms/exec as first written
+1.47 ms/exec after the reader blocked on poll() instead of sleeping
a millisecond between empty reads — that sleep sat on
the critical path of every execve
+1.38 ms/exec after answering cache hits in the reader thread, with
no channel handoff or worker wakeup
2,680 execs/sec sustained through the gate, 16-way parallel, with
ZERO watchdog rescues — the queue never fell behind. Ungated is 7,455.
Caddy stayed at sub-millisecond throughout and load did not rise.
Joe and Henry are right that the exec-heavy paths on this box — Docker
overlays, agent workspaces, PM2 — are the performance bar rather than an
exclusion list. Protecting agent workspaces from injected payloads is
the product. 2,680/sec with no backlog is roughly ten times what this
machine generates, so the bar looks clearable; stage 2 will say for sure.
295 tests pass, and the three-phase gate smoke test still passes
including the fail-open case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
463 lines
16 KiB
Rust
463 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
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|