diff --git a/Cargo.lock b/Cargo.lock index 6a48602..f847607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,7 @@ dependencies = [ "libc", "serde", "serde_json", + "sha2", "time", "yara-x", ] diff --git a/Cargo.toml b/Cargo.toml index bc78f03..3b5730d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ time = { version = "0.3", features = ["serde", "std", "formatting"] } inotify = "0.10" yara-x = "1.19" libc = "0.2" +sha2 = "0.10" [profile.release] lto = true diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 329972f..4177095 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -93,6 +93,51 @@ pub struct Status { pub gate: GateStatus, } +/// One thing on this machine that can make code run again after a reboot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistenceEntry { + /// "systemd", "cron", "autostart", "shell-profile", "authorized-keys". + pub kind: String, + pub path: String, + /// Content hash. Mtime alone is not enough — it can be set backwards. + pub sha256: String, + pub size: u64, + /// Package that installed it, when one claims it. `None` is the + /// interesting case: nothing on a stock system arrives unowned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owned_by: Option, +} + +/// How an entry differs from the recorded baseline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistenceChange { + /// "added" | "modified" | "removed". + pub change: String, + pub entry: PersistenceEntry, + /// Plain language, for someone who has never read an audit log. + pub detail: String, + pub severity: String, +} + +/// The result of a persistence sweep. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PersistenceReport { + pub ts: String, + /// When the baseline this was compared against was taken. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_ts: Option, + /// True when there was nothing to compare against and this run just + /// recorded what is here. Nothing is reported as a change on a first + /// run, because everything would be. + pub first_run: bool, + /// Everything currently in place. + pub total: u64, + /// Entries no package claims, which is the signal that survives a + /// first run. + pub unowned: u64, + pub changes: Vec, +} + /// Execution-gate state, for the tray and `hound status`. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GateStatus { @@ -512,6 +557,16 @@ impl Client { Ok(serde_json::from_value(v)?) } + // ── persistence ── + pub fn persistence_scan(&self, update_baseline: bool) -> anyhow::Result { + let v = self.call( + 14, + "persistence.scan", + Some(serde_json::json!({"update_baseline": update_baseline})), + )?; + Ok(serde_json::from_value(v)?) + } + // ── supply chain ── /// Sweep a project root. Returns the raw value so the CLI can /// deserialise it into `hound_supply::Report` without hound-api diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 67aaa4f..9856836 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -88,6 +88,19 @@ enum Cmd { #[arg(long)] json: bool, }, + /// What on this machine can make code run again after a reboot + /// + /// Records systemd units, cron jobs, autostart entries, shell profiles + /// and authorized_keys, then reports what has changed since last time. + /// Half of a Linux compromise is persistence rather than a file on disk. + Persistence { + /// Accept the current state as normal from now on + #[arg(long)] + accept: bool, + /// Emit machine-readable JSON instead of human text + #[arg(long)] + json: bool, + }, /// Run userspace rootkit heuristics Rootkit { /// Emit machine-readable JSON instead of human text @@ -149,6 +162,83 @@ enum RealtimeCmd { On, } +/// Print the persistence ledger for a human. +fn print_persistence_human(r: &hound_api::PersistenceReport, accepted: bool) { + if r.first_run { + println!( + "{} recorded {} startup item(s) as the baseline", + "✔".green().bold(), + r.total + ); + if r.unowned > 0 { + println!( + " {} of them are claimed by no installed package", + r.unowned.to_string().yellow() + ); + println!( + " {}", + "That is not necessarily wrong — anything you set up by hand looks like this." + .dimmed() + ); + } + println!( + "\n {}", + "From now on this command reports what CHANGED, which is the useful part.".dimmed() + ); + return; + } + + let unexplained = r + .changes + .iter() + .filter(|c| c.severity == "warn" || c.severity == "critical") + .count(); + + if r.changes.is_empty() { + println!( + "{} nothing has changed — {} startup item(s), baseline {}", + "✔".green().bold(), + r.total, + r.baseline_ts.as_deref().unwrap_or("unknown").dimmed() + ); + return; + } + + println!( + "{} {} change(s) since {}, {} unexplained\n", + if unexplained > 0 { "!".yellow().bold() } else { "·".dimmed() }, + r.changes.len(), + r.baseline_ts.as_deref().unwrap_or("the baseline"), + unexplained + ); + + for c in &r.changes { + let verb = match c.change.as_str() { + "added" => "ADDED ".green(), + "modified" => "CHANGED ".yellow(), + _ => "REMOVED ".dimmed(), + }; + let path = match c.severity.as_str() { + "warn" | "critical" => c.entry.path.yellow().bold(), + _ => c.entry.path.normal(), + }; + println!("{verb} {path}"); + for line in wrap(&c.detail, 74) { + println!(" {line}"); + } + println!(); + } + + if !accepted && unexplained > 0 { + println!( + "{}", + "If you made these changes yourself, run `hound persistence --accept` to \ + record them as normal." + .dimmed() + ); + } +} + /// Print a supply-chain report for a human. /// /// The explanation comes first and the rule identifier last, because the @@ -476,6 +566,20 @@ fn run(client: &Client, cmd: &Cmd) -> Result { print_supply_human(&report); Ok(if report.count(hound_supply::Severity::Critical) > 0 { 1 } else { 0 }) } + Cmd::Persistence { accept, json } => { + let r = client.persistence_scan(*accept)?; + if *json { + println!("{}", serde_json::to_string_pretty(&r)?); + return Ok(0); + } + print_persistence_human(&r, *accept); + let unexplained = r + .changes + .iter() + .filter(|c| c.severity == "warn" || c.severity == "critical") + .count(); + Ok(if unexplained > 0 { 1 } else { 0 }) + } Cmd::Rootkit { json } => { let r: RootkitScan = client.rootkit_scan()?; if *json { diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index a0ba88e..aed850d 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -20,3 +20,4 @@ time.workspace = true inotify.workspace = true yara-x.workspace = true libc.workspace = true +sha2.workspace = true diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 890711c..1b5e412 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -46,6 +46,7 @@ mod engine; mod events; mod fanotify; mod native; +mod persistence; mod quarantine; mod realtime; mod rootkit; @@ -489,6 +490,36 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { Ok(serde_json::to_value(scan)?) } + // ── persistence ledger ── + "persistence.scan" => { + // Writing the baseline is an explicit act. A plain check must + // not quietly record whatever is currently installed as normal + // — that is how a compromise becomes the new baseline. + let update = req + .params + .as_ref() + .and_then(|p| p.get("update_baseline")) + .and_then(Value::as_bool) + .unwrap_or(false); + let report = persistence::scan(update); + let warns = report + .changes + .iter() + .filter(|c| c.severity == "warn" || c.severity == "critical") + .count(); + if !report.first_run && warns > 0 { + st.events.push( + "persistence", + "warn", + format!( + "{warns} unexplained change(s) to startup configuration across {} item(s)", + report.total + ), + ); + } + Ok(serde_json::to_value(report)?) + } + // ── supply chain ── "supply.sweep" => { let path = req diff --git a/crates/houndd/src/persistence.rs b/crates/houndd/src/persistence.rs new file mode 100644 index 0000000..74055f2 --- /dev/null +++ b/crates/houndd/src/persistence.rs @@ -0,0 +1,486 @@ +//! The persistence ledger. +//! +//! Most of what people picture as "getting hacked" is a file on disk, and +//! most of what actually happens is a line added to a startup file. The +//! payload is often boring and sometimes not even malicious by itself — +//! a `curl` in a shell profile, a systemd unit with an innocuous name, one +//! extra key in `authorized_keys`. What makes it an incident is that it +//! survives a reboot, and nobody looks at those files from one year to the +//! next. +//! +//! So this is not a scanner. It is an inventory with a memory: record +//! everything that can make code run again, and afterwards report what +//! *changed*. Three decisions follow from that: +//! +//! * **Content is hashed, not stat'd.** An mtime can be set backwards with +//! one `touch`, and an attacker editing a startup file is exactly the +//! person who would. +//! * **A first run reports no changes.** Everything would be a change, and +//! a first-run report full of alarms is one nobody reads. What a first +//! run *can* say is which entries no package claims, because that is +//! true regardless of history. +//! * **Package ownership decides what is ordinary.** A systemd unit that +//! arrived with a package is the system working. The same unit with no +//! package behind it is somebody's decision, and worth knowing about. + +use hound_api::{PersistenceChange, PersistenceEntry, PersistenceReport}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Where the baseline lives. Beside the quarantine vault, because both +/// are daemon state rather than user configuration. +pub fn baseline_path() -> PathBuf { + let base = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + PathBuf::from(home).join(".local").join("share") + }); + base.join("hound").join("persistence-baseline.json") +} + +/// Directories whose every file is a persistence mechanism. +const UNIT_DIRS: &[(&str, &str)] = &[ + ("systemd", "/etc/systemd/system"), + ("systemd", "/usr/lib/systemd/system"), + ("systemd", "/lib/systemd/system"), + ("systemd", "/etc/systemd/user"), + ("cron", "/etc/cron.d"), + ("cron", "/etc/cron.hourly"), + ("cron", "/etc/cron.daily"), + ("cron", "/etc/cron.weekly"), + ("cron", "/etc/cron.monthly"), + ("cron", "/var/spool/cron/crontabs"), + ("autostart", "/etc/xdg/autostart"), + ("shell-profile", "/etc/profile.d"), +]; + +/// Individual files worth watching. +const UNIT_FILES: &[(&str, &str)] = &[ + ("cron", "/etc/crontab"), + ("shell-profile", "/etc/profile"), + ("shell-profile", "/etc/bash.bashrc"), + ("shell-profile", "/etc/zsh/zshrc"), + ("preload", "/etc/ld.so.preload"), +]; + +/// Per-home files, resolved for every real user account. +const HOME_FILES: &[(&str, &str)] = &[ + ("shell-profile", ".bashrc"), + ("shell-profile", ".bash_profile"), + ("shell-profile", ".bash_login"), + ("shell-profile", ".profile"), + ("shell-profile", ".zshrc"), + ("shell-profile", ".zprofile"), + ("authorized-keys", ".ssh/authorized_keys"), + ("authorized-keys", ".ssh/authorized_keys2"), +]; + +/// Per-home directories. +const HOME_DIRS: &[(&str, &str)] = &[ + ("autostart", ".config/autostart"), + ("systemd", ".config/systemd/user"), +]; + +/// Home directories of real accounts, from `/etc/passwd`. +/// +/// Parsed rather than globbed over `/home`, because root's home is +/// `/root` and service accounts live in odd places — and `authorized_keys` +/// under `/root` is the one most worth watching. +pub fn user_homes() -> Vec { + let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else { + return Vec::new(); + }; + let mut homes: Vec = passwd + .lines() + .filter_map(|line| { + let f: Vec<&str> = line.split(':').collect(); + if f.len() < 7 { + return None; + } + let uid: u32 = f[2].parse().ok()?; + let home = f[5]; + let shell = f[6]; + // Accounts that cannot log in are not interesting, and their + // "home" is often a shared directory like /nonexistent. + let usable = !shell.ends_with("nologin") && !shell.ends_with("/false"); + let real = uid == 0 || uid >= 1000; + (usable && real && home.starts_with('/')).then(|| PathBuf::from(home)) + }) + .collect(); + homes.sort(); + homes.dedup(); + homes.retain(|h| h.is_dir()); + homes +} + +fn sha256_file(path: &Path) -> Option<(String, u64)> { + let data = std::fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&data); + Some((format!("{:x}", hasher.finalize()), data.len() as u64)) +} + +/// Inventory everything on this machine that can make code run again. +pub fn inventory() -> Vec { + let owned = crate::rootkit::dpkg_owned_paths(); + let mut entries: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + let mut add = |kind: &str, path: &Path, entries: &mut Vec, seen: &mut HashSet| { + let Ok(md) = std::fs::symlink_metadata(path) else { return }; + // A symlink into a unit directory is itself a persistence decision, + // but its content is the target's; recording the target keeps the + // hash meaningful. Skip broken ones rather than reporting them. + if !md.is_file() && !(md.is_symlink() && path.is_file()) { + return; + } + let key = path.to_string_lossy().into_owned(); + if !seen.insert(key.clone()) { + return; + } + let Some((sha256, size)) = sha256_file(path) else { return }; + let owned_by = match &owned { + Some(index) => { + let canonical = std::fs::canonicalize(path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| key.clone()); + (index.contains(&key) || index.contains(&canonical)) + .then(|| "package".to_string()) + } + None => None, + }; + entries.push(PersistenceEntry { + kind: kind.to_string(), + path: key, + sha256, + size, + owned_by, + }); + }; + + for (kind, dir) in UNIT_DIRS { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + add(kind, &e.path(), &mut entries, &mut seen); + } + } + for (kind, file) in UNIT_FILES { + add(kind, Path::new(file), &mut entries, &mut seen); + } + for home in user_homes() { + for (kind, rel) in HOME_FILES { + add(kind, &home.join(rel), &mut entries, &mut seen); + } + for (kind, rel) in HOME_DIRS { + for e in std::fs::read_dir(home.join(rel)).into_iter().flatten().flatten() { + add(kind, &e.path(), &mut entries, &mut seen); + } + } + } + + entries.sort_by(|a, b| a.path.cmp(&b.path)); + entries +} + +/// What the baseline file holds. +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct Baseline { + ts: String, + entries: Vec, +} + +/// Compare a current inventory against a baseline. +/// +/// Pure, so the whole diff is testable without touching a real machine. +pub fn diff(baseline: &[PersistenceEntry], current: &[PersistenceEntry]) -> Vec { + let old: HashMap<&str, &PersistenceEntry> = + baseline.iter().map(|e| (e.path.as_str(), e)).collect(); + let new: HashMap<&str, &PersistenceEntry> = + current.iter().map(|e| (e.path.as_str(), e)).collect(); + + let mut changes = Vec::new(); + + for entry in current { + match old.get(entry.path.as_str()) { + None => changes.push(PersistenceChange { + change: "added".into(), + detail: format!( + "Something new was installed that will run again after a reboot: {}. \ + {}", + entry.path, + if entry.owned_by.is_some() { + "It arrived with a software package, so it was most likely an \ + ordinary install or update." + } else { + "No installed package claims it, so it did not arrive through \ + the package manager. That is worth knowing where it came from." + } + ), + severity: if entry.owned_by.is_some() { "info" } else { "warn" }.into(), + entry: entry.clone(), + }), + Some(before) if before.sha256 != entry.sha256 => changes.push(PersistenceChange { + change: "modified".into(), + detail: format!( + "The contents of {} changed. This file decides what runs at startup, \ + so a change here changes what your machine does before you log in.", + entry.path + ), + severity: if entry.owned_by.is_some() { "info" } else { "warn" }.into(), + entry: entry.clone(), + }), + Some(_) => {} + } + } + + for entry in baseline { + if !new.contains_key(entry.path.as_str()) { + changes.push(PersistenceChange { + change: "removed".into(), + detail: format!( + "{} is gone. Usually that is an uninstall; occasionally it is \ + something covering its tracks.", + entry.path + ), + severity: "info".into(), + entry: entry.clone(), + }); + } + } + + // Most consequential first: unowned changes before packaged ones. + changes.sort_by_key(|c| match c.severity.as_str() { + "critical" => 0, + "warn" => 1, + _ => 2, + }); + changes +} + +/// Run a sweep, comparing against the stored baseline and updating it. +pub fn scan(update_baseline: bool) -> PersistenceReport { + let current = inventory(); + let path = baseline_path(); + let stored: Option = std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()); + + let unowned = current.iter().filter(|e| e.owned_by.is_none()).count() as u64; + let now = crate::engine::to_rfc3339(std::time::SystemTime::now()); + + let (first_run, baseline_ts, changes) = match &stored { + None => (true, None, Vec::new()), + Some(b) => (false, Some(b.ts.clone()), diff(&b.entries, ¤t)), + }; + + if update_baseline { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let baseline = Baseline { + ts: now.clone(), + entries: current.clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&baseline) { + let _ = std::fs::write(&path, json); + } + } + + PersistenceReport { + ts: now, + baseline_ts, + first_run, + total: current.len() as u64, + unowned, + changes, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(path: &str, sha: &str, owned: bool) -> PersistenceEntry { + PersistenceEntry { + kind: "systemd".into(), + path: path.into(), + sha256: sha.into(), + size: 10, + owned_by: owned.then(|| "package".to_string()), + } + } + + // ── the diff ── + + #[test] + fn an_unchanged_system_reports_nothing() { + let a = vec![entry("/etc/crontab", "aaa", true)]; + assert!(diff(&a, &a).is_empty()); + } + + #[test] + fn a_new_unowned_unit_is_a_warning() { + let before = vec![]; + let after = vec![entry("/etc/systemd/system/pulse-helper.service", "bbb", false)]; + let c = diff(&before, &after); + assert_eq!(c.len(), 1); + assert_eq!(c[0].change, "added"); + assert_eq!(c[0].severity, "warn"); + assert!(c[0].detail.contains("No installed package claims it")); + } + + #[test] + fn a_new_packaged_unit_is_only_informational() { + // Installing software adds units constantly. Alarming on that + // teaches people to ignore the report. + let after = vec![entry("/lib/systemd/system/nginx.service", "ccc", true)]; + let c = diff(&[], &after); + assert_eq!(c[0].severity, "info"); + assert!(c[0].detail.contains("ordinary install")); + } + + #[test] + fn an_edited_startup_file_is_detected_by_content() { + // The point of hashing: an attacker can restore an mtime, so the + // diff must not depend on one. + let before = vec![entry("/home/joe/.bashrc", "aaa", false)]; + let after = vec![entry("/home/joe/.bashrc", "zzz", false)]; + let c = diff(&before, &after); + assert_eq!(c.len(), 1); + assert_eq!(c[0].change, "modified"); + assert!(c[0].detail.contains("before you log in")); + } + + #[test] + fn a_removed_entry_is_reported_quietly() { + let before = vec![entry("/etc/cron.d/backup", "aaa", true)]; + let c = diff(&before, &[]); + assert_eq!(c[0].change, "removed"); + assert_eq!(c[0].severity, "info"); + } + + #[test] + fn unowned_changes_sort_above_packaged_ones() { + let after = vec![ + entry("/lib/systemd/system/a.service", "x", true), + entry("/etc/systemd/system/evil.service", "y", false), + ]; + let c = diff(&[], &after); + assert_eq!(c[0].severity, "warn", "the unowned one must be read first"); + } + + #[test] + fn a_moved_file_reads_as_a_removal_and_an_addition() { + let before = vec![entry("/etc/cron.d/a", "same", false)]; + let after = vec![entry("/etc/cron.d/b", "same", false)]; + let c = diff(&before, &after); + assert_eq!(c.len(), 2); + assert!(c.iter().any(|x| x.change == "added")); + assert!(c.iter().any(|x| x.change == "removed")); + } + + // ── inventory against this machine ── + + #[test] + fn inventory_finds_real_startup_files() { + let inv = inventory(); + assert!( + inv.len() > 10, + "a running system has startup files; found {}", + inv.len() + ); + assert!( + inv.iter().any(|e| e.kind == "systemd"), + "systemd units should be inventoried" + ); + // Every entry must carry a usable hash. + for e in &inv { + assert_eq!(e.sha256.len(), 64, "bad hash for {}", e.path); + } + } + + #[test] + fn inventory_has_no_duplicate_paths() { + // /lib/systemd/system is a symlink to /usr/lib/systemd/system on + // merged-/usr systems, so the same unit is reachable twice. + let inv = inventory(); + let mut paths: Vec<&str> = inv.iter().map(|e| e.path.as_str()).collect(); + let before = paths.len(); + paths.sort(); + paths.dedup(); + assert_eq!(before, paths.len(), "an entry was inventoried twice"); + } + + #[test] + fn most_startup_files_are_claimed_by_a_package() { + // If ownership resolution breaks, everything looks unowned and the + // report becomes noise — the same failure the setuid check had. + if crate::rootkit::dpkg_owned_paths().is_none() { + return; + } + let inv = inventory(); + let systemd: Vec<&PersistenceEntry> = + inv.iter().filter(|e| e.kind == "systemd").collect(); + if systemd.is_empty() { + return; + } + let owned = systemd.iter().filter(|e| e.owned_by.is_some()).count(); + let ratio = owned as f64 / systemd.len() as f64; + assert!( + ratio > 0.5, + "only {owned}/{} systemd units resolved to a package — ownership lookup is broken", + systemd.len() + ); + } + + #[test] + fn user_homes_include_root_and_a_real_account() { + let homes = user_homes(); + assert!(!homes.is_empty(), "there is at least one usable account"); + for h in &homes { + assert!(h.is_dir(), "{h:?} should exist"); + } + } + + #[test] + fn service_accounts_are_not_treated_as_users() { + // nologin accounts share directories like /nonexistent and would + // otherwise be inventoried repeatedly. + let homes = user_homes(); + assert!( + !homes.iter().any(|h| h.to_string_lossy().contains("nonexistent")), + "a nologin account leaked into the home list" + ); + } + + #[test] + fn hashing_is_content_sensitive() { + let d = std::env::temp_dir().join(format!("hound-pers-{}", std::process::id())); + let _ = std::fs::create_dir_all(&d); + let f = d.join("unit.service"); + std::fs::write(&f, b"ExecStart=/bin/true").unwrap(); + let (a, size_a) = sha256_file(&f).unwrap(); + std::fs::write(&f, b"ExecStart=/bin/evil").unwrap(); + let (b, _) = sha256_file(&f).unwrap(); + assert_ne!(a, b, "a content change must change the hash"); + assert_eq!(size_a, 19); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_scan_that_does_not_write_leaves_no_baseline() { + // Read-only mode must not have side effects — someone running a + // check should not silently accept whatever is currently installed + // as normal. + let scan_a = scan(false); + assert!(scan_a.total > 0); + let scan_b = scan(false); + assert_eq!( + scan_a.first_run, scan_b.first_run, + "a read-only scan must not change what the next one sees" + ); + } +} diff --git a/dist/HOUND-VERIFY-README.txt b/dist/HOUND-VERIFY-README.txt new file mode 100644 index 0000000..9eab81e --- /dev/null +++ b/dist/HOUND-VERIFY-README.txt @@ -0,0 +1,58 @@ +hound-verify — cross-distro verification for Hound Antivirus +============================================================ + +What this is +------------ +The Hound test suite, compiled. It is READ-ONLY: it installs nothing, +starts no daemon, needs no root, and does not modify system state. It +reads /usr/bin, /bin, /usr/sbin, /proc and dpkg's package index. + +Requirements +------------ +x86_64, glibc 2.39 or newer — Ubuntu 24.04 and up. (The floor comes from +Rust's standard library, not from Hound.) Check with: ldd --version + +How to run +---------- + chmod +x hound-verify + + # The two checks that matter, together: + ./hound-verify rootkit rules + + # Or everything (~30s, mostly the goodware scan): + ./hound-verify + +What is actually being verified +------------------------------- +1. rootkit::tests::unowned_setuid_does_not_fire_on_a_healthy_system + + Hound flags setuid binaries that no installed package claims. It asks + dpkg. Ubuntu's merged-/usr layout means every binary has two names + (/bin/sudo and /usr/bin/sudo), and dpkg records some packages under + one and some under the other. Getting this wrong makes Hound alarm on + a clean machine. + + FAILURE OUTPUT names each binary it wrongly flagged. That is the + useful part — please paste it. + +2. rules::tests::no_false_positives_on_system_binaries + + Every Hound detection rule is scanned against every binary in + /usr/bin, /bin and /usr/sbin. A single hit fails the build. Ubuntu + ships binaries Linux Mint does not, so this is genuinely new ground. + + FAILURE OUTPUT names the binary and the rule. A hit means I delete + that rule rather than tune it — a rule that flags a system binary is + worse than no rule. + +3. Everything else in the suite comes along for the ride and is a bonus + signal: the fanotify policy tests, the capability arithmetic, the + supply-chain detectors. + +What to send back +----------------- +Either "all passed" (which is a real result — it closes the last open +criterion on the rootkit rewrite), or the assertion text of anything +that failed. The messages are written to be self-explanatory. + +Thanks — Hound diff --git a/dist/hound-verify b/dist/hound-verify new file mode 100755 index 0000000..d251ee5 Binary files /dev/null and b/dist/hound-verify differ