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"
|
||||
time = { version = "0.3", features = ["serde", "std", "formatting"] }
|
||||
inotify = "0.10"
|
||||
yara-x = "1.19"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ pub struct ScanResult {
|
|||
pub scanned: u64,
|
||||
pub clean: 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>,
|
||||
}
|
||||
|
||||
|
|
@ -166,11 +171,67 @@ pub struct Settings {
|
|||
/// Run the signature update automatically (daemon schedules it).
|
||||
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
|
||||
/// Master switch — when true, realtime is suspended and the tray is gray.
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -180,6 +241,10 @@ impl Default for Settings {
|
|||
realtime_enabled: true,
|
||||
realtime_watch: vec!["~/Downloads".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_threshold_per_min: 40,
|
||||
rootkit_enabled: true,
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
time.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 std::fs;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 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.
|
||||
/// "hound-native" when the Rust engine ships.
|
||||
fn name(&self) -> &'static str;
|
||||
|
|
@ -224,6 +229,8 @@ pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
|
|||
scanned,
|
||||
clean,
|
||||
infected,
|
||||
// clamscan does not tell us what it skipped for size.
|
||||
skipped: 0,
|
||||
found,
|
||||
})
|
||||
}
|
||||
|
|
@ -240,25 +247,54 @@ pub fn to_rfc3339(t: std::time::SystemTime) -> String {
|
|||
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
|
||||
/// [`FakeEngine`] — used by the E2E test so it can drive a full
|
||||
/// daemon lifecycle (status, scan, settings, quarantine, rootkit)
|
||||
/// without requiring ClamAV or a real filesystem of .cld files.
|
||||
/// Default is [`HoundEngine`](crate::native::HoundEngine) — yara-x in
|
||||
/// process. `HOUNDD_ENGINE` overrides it:
|
||||
///
|
||||
/// * `clamav` — the legacy `clamscan` subprocess path. Kept so the two
|
||||
/// can be compared directly, and because it still owns the Windows
|
||||
/// malware corpus that our own rules deliberately do not cover.
|
||||
/// * `fake` — the synthetic engine the E2E test drives, so a full
|
||||
/// daemon lifecycle can run without ClamAV or a real rule pack.
|
||||
///
|
||||
/// The whole point of the trait is that this is the only place the
|
||||
/// daemon decides *which* engine it serves.
|
||||
pub fn engine() -> &'static dyn ScanEngine {
|
||||
if std::env::var_os("HOUNDD_ENGINE").is_some_and(|v| v == "fake") {
|
||||
static FAKE: FakeEngine = FakeEngine;
|
||||
&FAKE
|
||||
} else {
|
||||
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
||||
&CLAMAV
|
||||
// Selection is per call — one getenv — so the choice stays live and
|
||||
// two tests in one process cannot contaminate each other. Only the
|
||||
// expensive engine is memoised, below.
|
||||
match std::env::var("HOUNDD_ENGINE").as_deref() {
|
||||
Ok("fake") => {
|
||||
static FAKE: FakeEngine = FakeEngine;
|
||||
&FAKE
|
||||
}
|
||||
Ok("clamav") => {
|
||||
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
||||
&CLAMAV
|
||||
}
|
||||
_ => native_engine(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native engine, built exactly once. Compiling the ruleset is the
|
||||
/// one genuinely expensive thing the daemon does at startup, so it must
|
||||
/// never happen twice.
|
||||
fn native_engine() -> &'static dyn ScanEngine {
|
||||
static ENGINE: OnceLock<Box<dyn ScanEngine>> = OnceLock::new();
|
||||
ENGINE
|
||||
.get_or_init(|| match crate::native::HoundEngine::new() {
|
||||
Ok(e) => Box::new(e) as Box<dyn ScanEngine>,
|
||||
Err(e) => {
|
||||
// Losing detection entirely is worse than falling back to
|
||||
// the slow path, so say so loudly and carry on.
|
||||
eprintln!("engine: rules failed to compile ({e}) — falling back to clamav");
|
||||
Box::new(ClamAvEngine)
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Test engine: reports itself present, scans anything whose name
|
||||
/// contains "EICAR" or ".eicar" as infected, and updates cleanly.
|
||||
/// Lets the E2E test exercise the full wire without ClamAV installed.
|
||||
|
|
@ -301,6 +337,7 @@ impl ScanEngine for FakeEngine {
|
|||
scanned,
|
||||
clean: scanned - infected_u,
|
||||
infected: infected_u,
|
||||
skipped: 0,
|
||||
found,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,14 @@
|
|||
//! swaps in [`engine::engine`]'s fake backend (a full daemon lifecycle
|
||||
//! without ClamAV installed).
|
||||
|
||||
mod cache;
|
||||
mod engine;
|
||||
mod events;
|
||||
mod native;
|
||||
mod quarantine;
|
||||
mod realtime;
|
||||
mod rootkit;
|
||||
mod rules;
|
||||
mod settings;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
|
|
@ -205,11 +208,13 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
|||
// ── settings ──
|
||||
"settings.get" => Ok(serde_json::to_value(st.settings.get())?),
|
||||
"settings.set" => {
|
||||
let incoming: Settings = serde_json::from_value(
|
||||
let mut incoming: Settings = serde_json::from_value(
|
||||
req.params
|
||||
.clone()
|
||||
.context("settings.set requires a params object")?,
|
||||
)?;
|
||||
// Clients are not trusted to send a theme we can render.
|
||||
incoming.normalise_appearance();
|
||||
st.settings
|
||||
.set(&incoming)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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