houndd: replace the clamscan fork with yara-x in process

The old engine shelled out to clamscan for every scan, and clamscan
reloads a 169 MB signature database on every invocation. Measured on a
68-byte EICAR file: 6.5 seconds and ~1.5 GB RSS — paid once per file,
and realtime.rs called it once per inotify event.

Replaces it with HoundEngine: yara-x compiled once at daemon start,
held in memory, one scanner reused across a whole walk, plus a verdict
cache keyed on (dev, ino, mtime, size) so an unchanged file that has
been seen before never reaches the matcher.

Measured after, same machine, same EICAR file:

  single file      6.5 s  ->  4 ms
  400 files cold      --  ->  9 ms
  400 files warm      --  ->  5 ms

Also here:

- rules.rs: hot-swappable rule store. Built-in pack is embedded so a
  fresh install detects something before it has ever reached the
  network; on-disk packs load from $HOUNDD_RULES_DIR, /var/lib/hound
  or the XDG data dir. Reload swaps an Arc, so in-flight scans are
  never torn out from under.
- cache.rs: bounded FIFO verdict cache. Any of the four key fields
  changing means rescan, so edits, truncates and replace-by-rename all
  correctly miss.
- The goodware gate: every rule is scanned against all of /usr/bin,
  /bin and /usr/sbin in CI, and a single hit fails the build. It has
  already earned its keep — it caught a reverse-shell rule that matched
  /usr/bin/sudo, which is now removed rather than tuned. A rule that
  quarantines sudo is worse than no rule at all.
- ScanEngine is Send + Sync and selection stays per-call, so
  HOUNDD_ENGINE=clamav still reaches the legacy path for comparison.
- ScanResult.skipped reports files passed over for size instead of
  quietly counting them as clean.
- Settings gain theme (auto/light/dark), tray_icon_style (color/mono),
  close_to_tray and confirm_quit, normalised daemon-side because
  clients are not trusted to send a theme we can render.

57 tests pass, up from 29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-20 22:05:09 -05:00
parent 6ef296caa1
commit 6746182f18
11 changed files with 3677 additions and 16 deletions

2465
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ clap = { version = "4", features = ["derive"] }
colored = "2" colored = "2"
time = { version = "0.3", features = ["serde", "std", "formatting"] } time = { version = "0.3", features = ["serde", "std", "formatting"] }
inotify = "0.10" inotify = "0.10"
yara-x = "1.19"
[profile.release] [profile.release]
lto = true lto = true

View file

@ -109,6 +109,11 @@ pub struct ScanResult {
pub scanned: u64, pub scanned: u64,
pub clean: u64, pub clean: u64,
pub infected: u64, pub infected: u64,
/// Files the engine deliberately did not read — over the size cap.
/// Reported separately because counting them as scanned would be a
/// lie and dropping them silently would be worse.
#[serde(default)]
pub skipped: u64,
pub found: Vec<Found>, pub found: Vec<Found>,
} }
@ -166,11 +171,67 @@ pub struct Settings {
/// Run the signature update automatically (daemon schedules it). /// Run the signature update automatically (daemon schedules it).
pub auto_update_signatures: bool, pub auto_update_signatures: bool,
// Appearance (GUI-only; the daemon stores them so the setting follows
// the machine rather than a per-user GUI config file, and so the CLI
// can read and set them too).
/// Window theme: "auto" (follow the desktop) | "light" | "dark".
#[serde(default = "default_theme")]
pub theme: String,
/// Tray icon treatment. Colour uses the 4-state ladder; monochrome
/// renders a single-tone glyph that follows the panel's own theme,
/// which is what most desktop styleguides actually ask for.
#[serde(default = "default_tray_style")]
pub tray_icon_style: String,
// Window behaviour
/// Closing the window hides it to the tray instead of exiting. Quitting
/// is only ever possible from the tray menu, and is confirmed there —
/// an antivirus that can be shut off by a stray click on the X is not
/// protecting anything.
#[serde(default = "default_true")]
pub close_to_tray: bool,
/// Whether the tray's Quit entry must be confirmed before it exits.
#[serde(default = "default_true")]
pub confirm_quit: bool,
// Global // Global
/// Master switch — when true, realtime is suspended and the tray is gray. /// Master switch — when true, realtime is suspended and the tray is gray.
pub paused: bool, pub paused: bool,
} }
fn default_theme() -> String {
"auto".into()
}
fn default_tray_style() -> String {
"color".into()
}
fn default_true() -> bool {
true
}
impl Settings {
/// Accepted values for [`Settings::theme`].
pub const THEMES: [&'static str; 3] = ["auto", "light", "dark"];
/// Accepted values for [`Settings::tray_icon_style`].
pub const TRAY_STYLES: [&'static str; 2] = ["color", "mono"];
/// Clamp free-text enum fields back to something the GUI can render.
///
/// These arrive over a JSON socket from clients we do not control, so
/// an unknown value is normalised rather than trusted — a typo'd theme
/// must not leave the window unstyled.
pub fn normalise_appearance(&mut self) {
if !Self::THEMES.contains(&self.theme.as_str()) {
self.theme = default_theme();
}
if !Self::TRAY_STYLES.contains(&self.tray_icon_style.as_str()) {
self.tray_icon_style = default_tray_style();
}
}
}
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -180,6 +241,10 @@ impl Default for Settings {
realtime_enabled: true, realtime_enabled: true,
realtime_watch: vec!["~/Downloads".into()], realtime_watch: vec!["~/Downloads".into()],
on_detect: "quarantine".into(), on_detect: "quarantine".into(),
theme: default_theme(),
tray_icon_style: default_tray_style(),
close_to_tray: true,
confirm_quit: true,
ransomware_guard: true, ransomware_guard: true,
ransomware_threshold_per_min: 40, ransomware_threshold_per_min: 40,
rootkit_enabled: true, rootkit_enabled: true,

View file

@ -17,3 +17,4 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
time.workspace = true time.workspace = true
inotify.workspace = true inotify.workspace = true
yara-x.workspace = true

View file

@ -0,0 +1,97 @@
/*
* Hound built-in starter pack.
*
* Deliberately tiny and deliberately tight. Every rule here requires
* several independent strings before it fires, because a false positive
* in an antivirus is worse than a miss — one rule that quarantines a
* system binary ends the product.
*
* The real corpus lands in Phase 3 (the signed Hound Linux pack, gated
* behind the goodware CI regression suite). This pack exists so a fresh
* install detects *something* before it has ever contacted the network.
*/
rule EICAR_Test_File
{
meta:
name = "EICAR-Test-Signature"
severity = "info"
desc = "Industry-standard antivirus test file. Harmless."
strings:
$eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
condition:
$eicar
}
rule Linux_Coinminer_XMRig
{
meta:
name = "Linux.Coinminer.XMRig"
severity = "critical"
desc = "XMRig cryptocurrency miner. Requires pool protocol plus two config keys."
strings:
$pool1 = "stratum+tcp://" ascii
$pool2 = "stratum+ssl://" ascii
$cfg1 = "donate-level" ascii
$cfg2 = "rig-id" ascii
$cfg3 = "randomx" ascii nocase
$name = "xmrig" ascii nocase
condition:
($pool1 or $pool2) and 2 of ($cfg*) and $name
}
rule Linux_Webshell_PHP_Eval
{
meta:
name = "Linux.Webshell.PHP-Eval"
severity = "critical"
desc = "PHP webshell: request-driven eval of decoded input."
strings:
$php = "<?php"
$eval1 = /eval\s*\(\s*(base64_decode|gzinflate|str_rot13|gzuncompress)\s*\(/
$src1 = "$_POST"
$src2 = "$_GET"
$src3 = "$_REQUEST"
$src4 = "$_COOKIE"
condition:
$php and $eval1 and 1 of ($src*)
}
rule Linux_Rootkit_Preload
{
meta:
name = "Linux.Rootkit.Preload"
severity = "critical"
desc = "LD_PRELOAD userland rootkit: hooks libc lookup calls and hides itself."
strings:
$dlsym = "dlsym" ascii
$libc = "RTLD_NEXT" ascii
$hook1 = "readdir64" ascii
$hook2 = "readdir" ascii
$hook3 = "lxstat" ascii
$hook4 = "fopen" ascii
$hide1 = "ld.so.preload" ascii
$hide2 = "/proc/net/tcp" ascii
condition:
uint32(0) == 0x464c457f // ELF magic
and $dlsym and $libc
and 2 of ($hook*)
and 1 of ($hide*)
}
/*
* REMOVED: Linux_Backdoor_ReverseShell_ELF
*
* It required an ELF containing "/bin/sh" plus four of
* {dup2, socket, connect, inet_addr, execve}. That is a perfect
* description of a reverse shell and also a perfect description of
* /usr/bin/sudo, which the goodware test caught immediately. Any
* dynamically linked network-capable binary imports those symbols
* legitimately, so no threshold tweak saves this rule — it would only
* move the false positive to a different binary on a different distro.
*
* Catching reverse shells properly needs either ELF structure (statically
* linked, tiny, no libc) or the behaviour itself, which is Phase 7's job.
* Left out rather than shipped loose: a rule that quarantines sudo is
* worse than no rule at all.
*/

209
crates/houndd/src/cache.rs Normal file
View file

@ -0,0 +1,209 @@
//! The verdict cache.
//!
//! Scanning the same unchanged bytes twice is pure waste, and on a
//! developer machine it is nearly all of the work: a `cargo build` opens
//! the same crate sources hundreds of times, and Phase 1's execution gate
//! will ask for a verdict on every `execve` of every binary on the box.
//!
//! The key is `(dev, ino, mtime, size)`. If any of those four change the
//! file is treated as new, so an edit, a truncate, a replace-by-rename or
//! a move across filesystems all correctly miss the cache. Content is
//! never hashed — hashing to avoid reading would mean reading.
//!
//! Eviction is FIFO with a hard capacity. An LRU would hold a slightly
//! better working set, but FIFO costs one `VecDeque` push and cannot
//! degrade pathologically, and the cost of a miss here is one scan.
use std::collections::{HashMap, VecDeque};
use std::fs::Metadata;
use std::os::unix::fs::MetadataExt;
use std::sync::{Arc, Mutex};
/// Identity of a file *version*. Any field changing means rescan.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct FileKey {
dev: u64,
ino: u64,
mtime: i64,
mtime_nsec: i64,
size: u64,
}
impl FileKey {
pub fn from_metadata(md: &Metadata) -> Self {
Self {
dev: md.dev(),
ino: md.ino(),
mtime: md.mtime(),
mtime_nsec: md.mtime_nsec(),
size: md.size(),
}
}
}
/// What a previous scan concluded. `None` is clean.
pub type Verdict = Option<Arc<str>>;
struct Inner {
map: HashMap<FileKey, Verdict>,
order: VecDeque<FileKey>,
hits: u64,
misses: u64,
}
/// A bounded, thread-safe cache of scan verdicts.
#[derive(Clone)]
pub struct VerdictCache {
inner: Arc<Mutex<Inner>>,
capacity: usize,
}
impl VerdictCache {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
map: HashMap::with_capacity(capacity.min(4096)),
order: VecDeque::with_capacity(capacity.min(4096)),
hits: 0,
misses: 0,
})),
capacity: capacity.max(1),
}
}
/// Look up a verdict. The outer `Option` is cache presence; the inner
/// one is the verdict itself, so a cached-clean answer is
/// `Some(None)` and is deliberately distinguishable from a miss.
pub fn get(&self, key: &FileKey) -> Option<Verdict> {
let mut inner = self.inner.lock().expect("verdict cache poisoned");
match inner.map.get(key) {
Some(v) => {
let v = v.clone();
inner.hits += 1;
Some(v)
}
None => {
inner.misses += 1;
None
}
}
}
/// Record a verdict, evicting the oldest entry when full.
pub fn put(&self, key: FileKey, verdict: Verdict) {
let mut inner = self.inner.lock().expect("verdict cache poisoned");
if inner.map.insert(key, verdict).is_none() {
inner.order.push_back(key);
while inner.order.len() > self.capacity {
if let Some(old) = inner.order.pop_front() {
inner.map.remove(&old);
}
}
}
}
/// Drop everything. Called whenever the ruleset changes — a verdict
/// reached under the old rules says nothing about the new ones.
pub fn clear(&self) {
let mut inner = self.inner.lock().expect("verdict cache poisoned");
inner.map.clear();
inner.order.clear();
}
/// `(entries, hits, misses)` for `hound status` and the benchmarks.
pub fn stats(&self) -> (usize, u64, u64) {
let inner = self.inner.lock().expect("verdict cache poisoned");
(inner.map.len(), inner.hits, inner.misses)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(ino: u64, size: u64) -> FileKey {
FileKey {
dev: 1,
ino,
mtime: 100,
mtime_nsec: 0,
size,
}
}
#[test]
fn miss_then_hit() {
let c = VerdictCache::new(8);
assert!(c.get(&key(1, 10)).is_none(), "cold lookup must miss");
c.put(key(1, 10), None);
assert_eq!(c.get(&key(1, 10)), Some(None), "cached clean is a hit");
}
#[test]
fn cached_infected_round_trips() {
let c = VerdictCache::new(8);
c.put(key(2, 20), Some("Linux.Coinminer.XMRig".into()));
let got = c.get(&key(2, 20)).expect("should hit");
assert_eq!(got.as_deref(), Some("Linux.Coinminer.XMRig"));
}
#[test]
fn any_field_change_misses() {
let c = VerdictCache::new(8);
c.put(key(3, 30), None);
// Same inode, different size — the file was rewritten.
assert!(c.get(&key(3, 31)).is_none());
// Same size, different inode — replaced by rename.
assert!(c.get(&key(4, 30)).is_none());
}
#[test]
fn mtime_change_misses() {
let c = VerdictCache::new(8);
let mut k = key(5, 50);
c.put(k, None);
k.mtime_nsec = 1;
assert!(c.get(&k).is_none(), "a nanosecond of edit is still an edit");
}
#[test]
fn evicts_fifo_at_capacity() {
let c = VerdictCache::new(2);
c.put(key(1, 1), None);
c.put(key(2, 2), None);
c.put(key(3, 3), None);
assert!(c.get(&key(1, 1)).is_none(), "oldest should be evicted");
assert!(c.get(&key(3, 3)).is_some(), "newest should be resident");
let (entries, _, _) = c.stats();
assert_eq!(entries, 2, "capacity must be honoured");
}
#[test]
fn reinsert_does_not_grow_order_queue() {
let c = VerdictCache::new(4);
for _ in 0..50 {
c.put(key(9, 9), None);
}
let (entries, _, _) = c.stats();
assert_eq!(entries, 1);
}
#[test]
fn clear_empties() {
let c = VerdictCache::new(4);
c.put(key(1, 1), None);
c.clear();
assert!(c.get(&key(1, 1)).is_none());
assert_eq!(c.stats().0, 0);
}
#[test]
fn stats_count_hits_and_misses() {
let c = VerdictCache::new(4);
c.get(&key(1, 1)); // miss
c.put(key(1, 1), None);
c.get(&key(1, 1)); // hit
let (_, hits, misses) = c.stats();
assert_eq!((hits, misses), (1, 1));
}
}

View file

@ -16,9 +16,14 @@ use anyhow::{Context, Result};
use hound_api::{DbFile, ScanResult}; use hound_api::{DbFile, ScanResult};
use std::fs; use std::fs;
use std::process::Command; use std::process::Command;
use std::sync::OnceLock;
/// What an engine implementation must answer. /// What an engine implementation must answer.
pub trait ScanEngine { ///
/// `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. /// Stable id for the wire (`Status.engine`): "clamav" today, e.g.
/// "hound-native" when the Rust engine ships. /// "hound-native" when the Rust engine ships.
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -224,6 +229,8 @@ pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
scanned, scanned,
clean, clean,
infected, infected,
// clamscan does not tell us what it skipped for size.
skipped: 0,
found, found,
}) })
} }
@ -240,25 +247,54 @@ pub fn to_rfc3339(t: std::time::SystemTime) -> String {
dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into()) dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into())
} }
/// The active engine, chosen at daemon startup. /// The active engine, chosen once at daemon startup.
/// ///
/// Default is [`ClamAvEngine`]. Set `HOUNDD_ENGINE=fake` to the /// Default is [`HoundEngine`](crate::native::HoundEngine) — yara-x in
/// [`FakeEngine`] — used by the E2E test so it can drive a full /// process. `HOUNDD_ENGINE` overrides it:
/// daemon lifecycle (status, scan, settings, quarantine, rootkit) ///
/// without requiring ClamAV or a real filesystem of .cld files. /// * `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 /// The whole point of the trait is that this is the only place the
/// daemon decides *which* engine it serves. /// daemon decides *which* engine it serves.
pub fn engine() -> &'static dyn ScanEngine { pub fn engine() -> &'static dyn ScanEngine {
if std::env::var_os("HOUNDD_ENGINE").is_some_and(|v| v == "fake") { // Selection is per call — one getenv — so the choice stays live and
static FAKE: FakeEngine = FakeEngine; // two tests in one process cannot contaminate each other. Only the
&FAKE // expensive engine is memoised, below.
} else { match std::env::var("HOUNDD_ENGINE").as_deref() {
static CLAMAV: ClamAvEngine = ClamAvEngine; Ok("fake") => {
&CLAMAV 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 /// Test engine: reports itself present, scans anything whose name
/// contains "EICAR" or ".eicar" as infected, and updates cleanly. /// contains "EICAR" or ".eicar" as infected, and updates cleanly.
/// Lets the E2E test exercise the full wire without ClamAV installed. /// Lets the E2E test exercise the full wire without ClamAV installed.
@ -301,6 +337,7 @@ impl ScanEngine for FakeEngine {
scanned, scanned,
clean: scanned - infected_u, clean: scanned - infected_u,
infected: infected_u, infected: infected_u,
skipped: 0,
found, found,
}) })
} }

View file

@ -40,11 +40,14 @@
//! swaps in [`engine::engine`]'s fake backend (a full daemon lifecycle //! swaps in [`engine::engine`]'s fake backend (a full daemon lifecycle
//! without ClamAV installed). //! without ClamAV installed).
mod cache;
mod engine; mod engine;
mod events; mod events;
mod native;
mod quarantine; mod quarantine;
mod realtime; mod realtime;
mod rootkit; mod rootkit;
mod rules;
mod settings; mod settings;
#[cfg(test)] #[cfg(test)]
mod test_util; mod test_util;
@ -205,11 +208,13 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
// ── settings ── // ── settings ──
"settings.get" => Ok(serde_json::to_value(st.settings.get())?), "settings.get" => Ok(serde_json::to_value(st.settings.get())?),
"settings.set" => { "settings.set" => {
let incoming: Settings = serde_json::from_value( let mut incoming: Settings = serde_json::from_value(
req.params req.params
.clone() .clone()
.context("settings.set requires a params object")?, .context("settings.set requires a params object")?,
)?; )?;
// Clients are not trusted to send a theme we can render.
incoming.normalise_appearance();
st.settings st.settings
.set(&incoming) .set(&incoming)
.map_err(|e| anyhow::anyhow!("persisting settings: {e}"))?; .map_err(|e| anyhow::anyhow!("persisting settings: {e}"))?;

462
crates/houndd/src/native.rs Normal file
View file

@ -0,0 +1,462 @@
//! Hound's own engine: yara-x, in process, with a verdict cache.
//!
//! This is the Phase 0 replacement for forking `clamscan`. The old path
//! spent 6.5 seconds and ~1.5 GB of RSS on a 68-byte file because every
//! invocation reloaded a 169 MB signature database. Here the ruleset is
//! compiled once at daemon start, the scanner is reused across every
//! file in a walk, and an unchanged file that has been seen before never
//! reaches the matcher at all.
//!
//! Deliberate choices worth knowing about:
//!
//! * **Symlinks are never followed.** A directory walk that follows links
//! can loop, can be steered outside the requested tree by anyone who
//! can create a link, and re-scans the same inode repeatedly. We stat
//! with `symlink_metadata` and skip links entirely.
//! * **Pseudo-filesystems are skipped.** Reading `/proc` and `/sys` is
//! meaningless here and reading some of their files blocks forever.
//! * **Oversized files are counted, not read.** Reporting them as scanned
//! would be a lie; skipping them silently would be worse. They are
//! counted separately and surfaced in the summary.
use anyhow::{Context, Result};
use hound_api::{DbFile, Found, ScanResult};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::{FileKey, VerdictCache};
use crate::engine::{to_rfc3339, ScanEngine};
use crate::rules::{RuleSet, RuleStore};
/// Files larger than this are skipped. Malware that matters is rarely
/// this big, and reading disk images on every scan makes the product
/// unusable on the developer machines we are targeting.
const DEFAULT_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024;
/// How many file verdicts to remember. ~200k entries is a few tens of MB
/// and comfortably covers a working developer tree plus the system.
const CACHE_CAPACITY: usize = 200_000;
/// Directory prefixes that are never worth walking.
const SKIP_PREFIXES: &[&str] = &[
"/proc",
"/sys",
"/dev",
"/run",
"/var/lib/hound/vault",
];
pub struct HoundEngine {
rules: RuleStore,
cache: VerdictCache,
max_file_bytes: u64,
}
impl HoundEngine {
/// Compile the ruleset and build the cache. Called once, from the
/// engine factory, at daemon start.
pub fn new() -> Result<Self> {
let rules = RuleStore::load()?;
let max_file_bytes = std::env::var("HOUNDD_MAX_FILE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_FILE_BYTES);
Ok(Self {
rules,
cache: VerdictCache::new(CACHE_CAPACITY),
max_file_bytes,
})
}
/// Cache statistics, for `hound status` and the benchmark harness.
pub fn cache_stats(&self) -> (usize, u64, u64) {
self.cache.stats()
}
}
/// Everything a walk accumulated. Kept separate from the wire type so the
/// skipped count can be reported without changing the public API shape.
#[derive(Default)]
struct Tally {
scanned: u64,
infected: u64,
skipped_large: u64,
found: Vec<Found>,
}
impl ScanEngine for HoundEngine {
fn name(&self) -> &'static str {
"hound"
}
fn probe(&self) -> (bool, String, Option<DbFile>) {
let set = self.rules.current();
let summary = format!(
"{} rules loaded from {} source(s) [hound {}]",
set.count,
set.sources.len(),
set.version
);
(
true,
summary,
Some(DbFile {
file: set.version.clone(),
updated_at: to_rfc3339(set.loaded_at),
}),
)
}
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult> {
let root = std::fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
let set = self.rules.current();
// One scanner for the whole walk. Constructing it per file would
// reintroduce a chunk of the per-invocation cost we just removed.
let mut scanner = yara_x::Scanner::new(&set.rules);
let mut tally = Tally::default();
let mut queue: Vec<PathBuf> = vec![root.clone()];
while let Some(current) = queue.pop() {
let Ok(md) = std::fs::symlink_metadata(&current) else {
continue;
};
if md.is_symlink() {
continue;
}
if md.is_dir() {
// The root is always descended into; deeper levels only
// when the caller asked for a recursive scan.
if current != root && !recursive {
continue;
}
if is_skipped_dir(&current) {
continue;
}
for entry in std::fs::read_dir(&current).into_iter().flatten().flatten() {
queue.push(entry.path());
}
continue;
}
if !md.is_file() {
continue;
}
if md.len() > self.max_file_bytes {
tally.skipped_large += 1;
continue;
}
self.scan_one(&current, &md, &set, &mut scanner, &mut tally);
}
let clean = tally.scanned.saturating_sub(tally.infected);
Ok(ScanResult {
scanned: tally.scanned,
clean,
infected: tally.infected,
skipped: tally.skipped_large,
found: tally.found,
})
}
fn update(&self) -> Result<(bool, String, String)> {
let before = self.rules.current().count;
match self.rules.reload() {
Ok(set) => {
// Verdicts reached under the old rules say nothing about
// the new ones.
self.cache.clear();
Ok((
true,
"reload rules".to_string(),
format!(
"OK: {} rules loaded ({}), was {before}\nsources: {}\n",
set.count,
set.version,
set.sources.join(", ")
),
))
}
Err(e) => Ok((false, "reload rules".to_string(), format!("{e}\n"))),
}
}
}
impl HoundEngine {
/// Scan a single regular file, consulting the cache first.
fn scan_one(
&self,
path: &Path,
md: &std::fs::Metadata,
set: &Arc<RuleSet>,
scanner: &mut yara_x::Scanner,
tally: &mut Tally,
) {
let key = FileKey::from_metadata(md);
if let Some(verdict) = self.cache.get(&key) {
tally.scanned += 1;
if let Some(name) = verdict {
tally.infected += 1;
tally.found.push(Found {
path: path.to_string_lossy().into_owned(),
virus: name.to_string(),
});
}
return;
}
let Ok(bytes) = std::fs::read(path) else {
// Unreadable is not clean, so it is not cached and not
// counted as scanned.
return;
};
tally.scanned += 1;
let Ok(results) = scanner.scan(&bytes) else {
return;
};
// A file can trip several rules; report it once, under the first
// match, exactly as the ClamAV path did with --allmatch.
let hit = results
.matching_rules()
.next()
.map(|r| RuleSet::detection_name(&r));
match hit {
Some(name) => {
tally.infected += 1;
tally.found.push(Found {
path: path.to_string_lossy().into_owned(),
virus: name.clone(),
});
self.cache.put(key, Some(name.into()));
}
None => self.cache.put(key, None),
}
let _ = set;
}
}
/// Pseudo-filesystems and our own vault: never walked.
fn is_skipped_dir(path: &Path) -> bool {
SKIP_PREFIXES.iter().any(|p| path.starts_with(p))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"hound-native-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
const EICAR: &str = r"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
#[test]
fn finds_eicar_in_a_directory() {
let d = tmpdir("eicar");
fs::write(d.join("clean.txt"), b"nothing to see").unwrap();
fs::write(d.join("eicar.com"), EICAR).unwrap();
let e = HoundEngine::new().unwrap();
let r = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(r.scanned, 2);
assert_eq!(r.infected, 1);
assert_eq!(r.clean, 1);
assert_eq!(r.found[0].virus, "EICAR-Test-Signature");
assert!(r.found[0].path.ends_with("eicar.com"));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn clean_tree_is_clean() {
let d = tmpdir("clean");
for i in 0..20 {
fs::write(d.join(format!("f{i}.txt")), format!("file number {i}")).unwrap();
}
let e = HoundEngine::new().unwrap();
let r = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(r.scanned, 20);
assert_eq!(r.infected, 0);
assert!(r.is_clean());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn non_recursive_stops_at_the_top() {
let d = tmpdir("shallow");
fs::write(d.join("top.txt"), b"top").unwrap();
let sub = d.join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("eicar.com"), EICAR).unwrap();
let e = HoundEngine::new().unwrap();
let r = e.scan(d.to_str().unwrap(), false).unwrap();
assert_eq!(r.scanned, 1, "must not descend when recursive is false");
assert_eq!(r.infected, 0);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn symlinks_are_not_followed() {
let d = tmpdir("symlink");
let real = d.join("real");
fs::create_dir_all(&real).unwrap();
fs::write(real.join("eicar.com"), EICAR).unwrap();
// A link pointing back at the parent would loop forever if followed.
std::os::unix::fs::symlink(&d, d.join("loop")).unwrap();
let e = HoundEngine::new().unwrap();
let r = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(r.scanned, 1, "the linked tree must not be walked twice");
assert_eq!(r.infected, 1);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn oversized_files_are_skipped_not_scanned() {
let d = tmpdir("large");
fs::write(d.join("big.bin"), vec![0u8; 4096]).unwrap();
fs::write(d.join("small.txt"), b"ok").unwrap();
let mut e = HoundEngine::new().unwrap();
e.max_file_bytes = 1024;
let r = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(r.scanned, 1, "the 4 KB file must not be counted as scanned");
assert_eq!(r.skipped, 1, "and it must be reported, not silently dropped");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn second_scan_is_served_from_cache() {
let d = tmpdir("cache");
for i in 0..30 {
fs::write(d.join(format!("f{i}.txt")), format!("contents {i}")).unwrap();
}
let e = HoundEngine::new().unwrap();
let first = e.scan(d.to_str().unwrap(), true).unwrap();
let (_, hits_after_first, _) = e.cache_stats();
assert_eq!(hits_after_first, 0, "a cold walk cannot hit");
let second = e.scan(d.to_str().unwrap(), true).unwrap();
let (_, hits, _) = e.cache_stats();
assert_eq!(first.scanned, second.scanned);
assert_eq!(hits, 30, "every file should be served from cache the second time");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn editing_a_file_invalidates_its_cache_entry() {
let d = tmpdir("invalidate");
let f = d.join("mutable.txt");
fs::write(&f, b"harmless").unwrap();
let e = HoundEngine::new().unwrap();
let first = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(first.infected, 0);
// Rewrite the same path with EICAR. Size and mtime both change.
fs::write(&f, EICAR).unwrap();
let second = e.scan(d.to_str().unwrap(), true).unwrap();
assert_eq!(second.infected, 1, "a rewritten file must be rescanned");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn scanning_a_single_file_works() {
let d = tmpdir("single");
let f = d.join("eicar.com");
fs::write(&f, EICAR).unwrap();
let e = HoundEngine::new().unwrap();
let r = e.scan(f.to_str().unwrap(), false).unwrap();
assert_eq!(r.scanned, 1);
assert_eq!(r.infected, 1);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn missing_path_is_an_error() {
let e = HoundEngine::new().unwrap();
assert!(e.scan("/definitely/not/here/at/all", true).is_err());
}
#[test]
fn probe_reports_the_ruleset() {
let e = HoundEngine::new().unwrap();
let (present, summary, db) = e.probe();
assert!(present);
assert!(summary.contains("rules loaded"));
assert!(db.unwrap().file.starts_with("builtin-"));
}
#[test]
fn update_reloads_and_clears_the_cache() {
let d = tmpdir("update");
fs::write(d.join("a.txt"), b"a").unwrap();
let e = HoundEngine::new().unwrap();
e.scan(d.to_str().unwrap(), true).unwrap();
assert!(e.cache_stats().0 > 0);
let (ok, label, log) = e.update().unwrap();
assert!(ok, "reload should succeed: {log}");
assert_eq!(label, "reload rules");
assert_eq!(e.cache_stats().0, 0, "stale verdicts must be dropped");
let _ = fs::remove_dir_all(&d);
}
/// The Phase 0 exit criterion, asserted so it can never quietly
/// regress. The path this replaced took 6.5 seconds *per file*
/// because it reloaded a 169 MB database on every invocation.
///
/// The bound is deliberately loose (2 s for 400 files, against a
/// measured ~9 ms) because CI machines are slow and shared, and a
/// flaky performance test gets deleted rather than fixed. It is
/// tight enough to catch the only regression that matters: someone
/// reintroducing per-file setup cost.
#[test]
fn four_hundred_files_scan_in_under_two_seconds() {
let d = tmpdir("perf");
for i in 0..399 {
fs::write(d.join(format!("f{i}.bin")), format!("payload {i}").repeat(64)).unwrap();
}
fs::write(d.join("eicar.com"), EICAR).unwrap();
let e = HoundEngine::new().unwrap();
let started = std::time::Instant::now();
let r = e.scan(d.to_str().unwrap(), true).unwrap();
let elapsed = started.elapsed();
assert_eq!(r.scanned, 400);
assert_eq!(r.infected, 1, "EICAR must still be caught at speed");
assert!(
elapsed < std::time::Duration::from_secs(2),
"400 files took {elapsed:?} — per-file setup cost is back"
);
eprintln!("perf: 400 files cold in {elapsed:?}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn skips_pseudo_filesystems() {
assert!(is_skipped_dir(Path::new("/proc/1")));
assert!(is_skipped_dir(Path::new("/sys/kernel")));
assert!(!is_skipped_dir(Path::new("/home/joe/src")));
}
}

272
crates/houndd/src/rules.rs Normal file
View file

@ -0,0 +1,272 @@
//! The rule store.
//!
//! Hound's detection content is YARA, compiled once at daemon start and
//! held in memory for the process lifetime. This is the whole point of
//! Phase 0: the old engine forked `clamscan` per file and paid a 6.5
//! second signature-database load every single time. Here the ruleset is
//! compiled once and every subsequent scan is a memory operation.
//!
//! Sources, in load order:
//!
//! 1. The built-in starter pack, compiled into the binary. Deliberately
//! tiny and tight so a fresh install detects something before it has
//! ever reached the network.
//! 2. Every `*.yar` / `*.yara` in the rules directory — `$HOUNDD_RULES_DIR`
//! if set, else `/var/lib/hound/rules`, else the XDG data dir for
//! unprivileged runs. This is where the signed Hound pack lands in
//! Phase 3.
//!
//! Reload swaps a fresh `Arc<RuleSet>` into place; in-flight scans keep
//! scanning against the ruleset they started with and the next scan picks
//! up the new one. Nothing blocks and nothing is torn out from under a
//! running scan.
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
/// The starter pack, baked into the binary.
const BUILTIN: &str = include_str!("../rules/hound-builtin.yar");
/// A compiled ruleset plus the provenance a client needs to display it.
pub struct RuleSet {
pub rules: yara_x::Rules,
/// Wire version, e.g. "builtin-0.1.0" or the pack's own version file.
pub version: String,
/// How many rules compiled.
pub count: usize,
/// When this set was compiled.
pub loaded_at: SystemTime,
/// Human-readable list of what went in, for `hound status`.
pub sources: Vec<String>,
}
impl RuleSet {
/// Compile the built-in pack plus anything in the rules directory.
///
/// A malformed file on disk is reported and skipped rather than
/// taking the daemon down — a bad third-party pack must not stop the
/// built-ins from protecting the machine.
fn compile() -> Result<Self> {
let mut compiler = yara_x::Compiler::new();
let mut sources = Vec::new();
compiler
.add_source(yara_x::SourceCode::from(BUILTIN).with_origin("hound-builtin.yar"))
.map_err(|e| anyhow::anyhow!("built-in rules failed to compile: {e}"))?;
sources.push("hound-builtin.yar (embedded)".to_string());
let mut version = format!("builtin-{}", env!("CARGO_PKG_VERSION"));
if let Some(dir) = rules_dir() {
if let Ok(v) = std::fs::read_to_string(dir.join("VERSION")) {
let v = v.trim();
if !v.is_empty() {
version = v.to_string();
}
}
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension()
.is_some_and(|x| x == "yar" || x == "yara")
})
.collect();
files.sort();
for path in files {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("rules: skipping {name}: {e}");
continue;
}
};
match compiler
.add_source(yara_x::SourceCode::from(src.as_str()).with_origin(&name))
{
Ok(_) => sources.push(name),
Err(e) => eprintln!("rules: skipping {name}: {e}"),
}
}
}
let rules = compiler.build();
let count = rules.iter().count();
Ok(Self {
rules,
version,
count,
loaded_at: SystemTime::now(),
sources,
})
}
/// The detection name to report for a matching rule.
///
/// Rules carry a `name` metadata field holding the public signature
/// name ("Linux.Coinminer.XMRig"); the rule identifier is the
/// fallback so a pack that omits the metadata still reports usefully.
pub fn detection_name(rule: &yara_x::Rule) -> String {
for (key, value) in rule.metadata() {
if key != "name" {
continue;
}
match value {
yara_x::MetaValue::String(s) => return s.to_string(),
yara_x::MetaValue::Bytes(b) => return b.to_string(),
_ => {}
}
}
rule.identifier().to_string()
}
}
/// Where on-disk packs live. `None` when no directory exists yet.
pub fn rules_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("HOUNDD_RULES_DIR") {
let p = PathBuf::from(dir);
return p.is_dir().then_some(p);
}
let system = PathBuf::from("/var/lib/hound/rules");
if system.is_dir() {
return Some(system);
}
let home = std::env::var_os("HOME")?;
let user = PathBuf::from(home).join(".local/share/hound/rules");
user.is_dir().then_some(user)
}
/// Hot-swappable handle on the current ruleset.
#[derive(Clone)]
pub struct RuleStore {
inner: Arc<RwLock<Arc<RuleSet>>>,
}
impl RuleStore {
/// Compile at startup. A failure here is fatal for detection, so we
/// surface it rather than silently serving an empty ruleset.
pub fn load() -> Result<Self> {
let set = RuleSet::compile().context("compiling rules")?;
Ok(Self {
inner: Arc::new(RwLock::new(Arc::new(set))),
})
}
/// The ruleset a scan should use. Cheap — one `Arc` clone.
pub fn current(&self) -> Arc<RuleSet> {
Arc::clone(&self.inner.read().expect("rule store poisoned"))
}
/// Recompile from source and swap the result in.
pub fn reload(&self) -> Result<Arc<RuleSet>> {
let fresh = Arc::new(RuleSet::compile().context("recompiling rules")?);
*self.inner.write().expect("rule store poisoned") = Arc::clone(&fresh);
Ok(fresh)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_pack_compiles() {
let set = RuleSet::compile().expect("built-in pack must always compile");
assert!(set.count >= 4, "expected the starter rules, got {}", set.count);
assert!(set.version.starts_with("builtin-"));
assert!(!set.sources.is_empty());
}
#[test]
fn detects_eicar() {
let set = RuleSet::compile().unwrap();
let mut scanner = yara_x::Scanner::new(&set.rules);
let eicar = br"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
let results = scanner.scan(eicar).unwrap();
let names: Vec<String> = results
.matching_rules()
.map(|r| RuleSet::detection_name(&r))
.collect();
assert!(
names.iter().any(|n| n == "EICAR-Test-Signature"),
"EICAR must be detected, got {names:?}"
);
}
#[test]
fn clean_text_is_clean() {
let set = RuleSet::compile().unwrap();
let mut scanner = yara_x::Scanner::new(&set.rules);
let results = scanner.scan(b"the quick brown fox jumps over the lazy dog\n").unwrap();
assert_eq!(results.matching_rules().len(), 0);
}
/// The goodware gate, in miniature.
///
/// Every rule in the starter pack is scanned against every binary in
/// `/usr/bin` and `/bin`. A single hit fails the build. Phase 3 scales
/// this to the Debian and Ubuntu archives plus the npm and PyPI top
/// 5,000, but the principle is already the one that matters: a rule
/// that fires on a system binary never ships.
///
/// This test has already earned its keep — it caught a reverse-shell
/// rule that matched `/usr/bin/sudo`.
#[test]
fn no_false_positives_on_system_binaries() {
let set = RuleSet::compile().unwrap();
let mut scanner = yara_x::Scanner::new(&set.rules);
let mut checked = 0usize;
let mut failures: Vec<String> = Vec::new();
for dir in ["/usr/bin", "/bin", "/usr/sbin"] {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
let Ok(md) = std::fs::symlink_metadata(&path) else { continue };
if md.is_symlink() || !md.is_file() || md.len() > 32 * 1024 * 1024 {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
checked += 1;
let hits: Vec<String> = scanner
.scan(&bytes)
.map(|r| {
r.matching_rules()
.map(|m| RuleSet::detection_name(&m))
.collect()
})
.unwrap_or_default();
if !hits.is_empty() {
failures.push(format!("{} -> {hits:?}", path.display()));
}
}
}
assert!(checked > 50, "only {checked} binaries were readable — gate is not meaningful");
assert!(
failures.is_empty(),
"{} false positive(s) across {checked} system binaries:\n {}",
failures.len(),
failures.join("\n ")
);
eprintln!("goodware gate: {checked} system binaries, 0 false positives");
}
#[test]
fn reload_swaps_in_a_fresh_set() {
let store = RuleStore::load().unwrap();
let before = store.current();
let after = store.reload().unwrap();
assert_eq!(before.count, after.count);
assert!(after.loaded_at >= before.loaded_at);
}
}

View file

@ -149,3 +149,56 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
} }
#[cfg(test)]
mod appearance_tests {
use hound_api::Settings;
#[test]
fn appearance_defaults_are_sane() {
let s = Settings::default();
assert_eq!(s.theme, "auto", "follow the desktop until told otherwise");
assert_eq!(s.tray_icon_style, "color");
assert!(s.close_to_tray, "the X must never stop protection");
assert!(s.confirm_quit, "quitting an antivirus is a deliberate act");
}
#[test]
fn unknown_theme_is_normalised_not_trusted() {
let mut s = Settings::default();
s.theme = "midnight-neon".into();
s.tray_icon_style = "sparkles".into();
s.normalise_appearance();
assert_eq!(s.theme, "auto");
assert_eq!(s.tray_icon_style, "color");
}
#[test]
fn valid_appearance_values_survive() {
for theme in Settings::THEMES {
for style in Settings::TRAY_STYLES {
let mut s = Settings::default();
s.theme = theme.into();
s.tray_icon_style = style.into();
s.normalise_appearance();
assert_eq!(s.theme, theme);
assert_eq!(s.tray_icon_style, style);
}
}
}
/// Settings files written before these fields existed must still load.
#[test]
fn older_settings_json_still_deserialises() {
let legacy = r#"{
"recursive_default": true, "max_file_size_mb": 100, "exclude_paths": [],
"realtime_enabled": true, "realtime_watch": [], "on_detect": "quarantine",
"ransomware_guard": true, "ransomware_threshold_per_min": 40,
"rootkit_enabled": true, "notify_desktop": true,
"auto_update_signatures": true, "paused": false
}"#;
let s: Settings = serde_json::from_str(legacy).expect("legacy settings must load");
assert_eq!(s.theme, "auto");
assert!(s.close_to_tray);
}
}