houndd: the persistence ledger

Completes Phase 5. Half of a Linux compromise is not a file on disk, it
is a line added to a startup file — a curl in a shell profile, a systemd
unit with a dull name, one extra key in authorized_keys. The payload is
often unremarkable; what makes it an incident is that it survives a
reboot and nobody reads those files from one year to the next.

So this is not a scanner but an inventory with a memory. It records
systemd units (system and per-user), cron in all its locations,
autostart entries, shell profiles, authorized_keys and ld.so.preload,
then reports what CHANGED.

Three decisions, all of which are the difference between a report people
read and one they turn off:

* Content is hashed, not stat'd. An mtime can be set backwards with one
  touch, and someone editing a startup file is exactly the person who
  would. Verified: a backdated edit is still caught.

* A first run reports no changes and says so. Everything would be a
  change, and a first-run report full of alarms is one nobody reads.
  What a first run can honestly say is how many entries no package
  claims — 97 of 1,026 on this machine — because that is true
  regardless of history.

* Writing the baseline is an explicit act (`--accept`, or
  update_baseline on the wire). A plain check must never quietly record
  whatever is currently installed as normal; that is how a compromise
  becomes the new baseline.

Package ownership decides what is ordinary: a unit that arrived with a
package is the system working, the same unit unowned is somebody's
decision. Reuses the merged-/usr-aware index from the rootkit rewrite,
with a test asserting most units resolve to a package — if that ratio
collapses, ownership lookup has broken and the whole report is noise.

Exercised end to end against this machine: baseline of 1,026 items,
a planted user unit caught as ADDED, an in-place edit with a backdated
mtime caught as CHANGED, and its deletion caught as REMOVED. The test
artifact was removed afterwards.

Cross-distro verification of the rootkit rewrite is now MET. Henry ran
the suite on the Ubuntu box (26.04, glibc 2.43): 20/20, including both
unowned_setuid_does_not_fire_on_a_healthy_system and
no_false_positives_on_system_binaries.

214 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-21 06:50:35 -05:00
parent 909afacb19
commit 778236822b
9 changed files with 737 additions and 0 deletions

1
Cargo.lock generated
View file

@ -1061,6 +1061,7 @@ dependencies = [
"libc",
"serde",
"serde_json",
"sha2",
"time",
"yara-x",
]

View file

@ -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

View file

@ -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<String>,
}
/// 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<String>,
/// 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<PersistenceChange>,
}
/// 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<PersistenceReport> {
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

View file

@ -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<i32> {
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 {

View file

@ -20,3 +20,4 @@ time.workspace = true
inotify.workspace = true
yara-x.workspace = true
libc.workspace = true
sha2.workspace = true

View file

@ -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<Value> {
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

View file

@ -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<PathBuf> {
let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else {
return Vec::new();
};
let mut homes: Vec<PathBuf> = 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<PersistenceEntry> {
let owned = crate::rootkit::dpkg_owned_paths();
let mut entries: Vec<PersistenceEntry> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut add = |kind: &str, path: &Path, entries: &mut Vec<PersistenceEntry>, seen: &mut HashSet<String>| {
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<PersistenceEntry>,
}
/// 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<PersistenceChange> {
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<Baseline> = 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, &current)),
};
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"
);
}
}

58
dist/HOUND-VERIFY-README.txt vendored Normal file
View file

@ -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

BIN
dist/hound-verify vendored Executable file

Binary file not shown.