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:
parent
6ef296caa1
commit
6746182f18
11 changed files with 3677 additions and 16 deletions
2465
Cargo.lock
generated
2465
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
97
crates/houndd/rules/hound-builtin.yar
Normal file
97
crates/houndd/rules/hound-builtin.yar
Normal 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
209
crates/houndd/src/cache.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,23 +247,52 @@ 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
|
||||||
|
// 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;
|
static FAKE: FakeEngine = FakeEngine;
|
||||||
&FAKE
|
&FAKE
|
||||||
} else {
|
}
|
||||||
|
Ok("clamav") => {
|
||||||
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
||||||
&CLAMAV
|
&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
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
462
crates/houndd/src/native.rs
Normal 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(¤t) 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(¤t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(¤t).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(¤t, &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
272
crates/houndd/src/rules.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue