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:
parent
909afacb19
commit
778236822b
9 changed files with 737 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1061,6 +1061,7 @@ dependencies = [
|
|||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"time",
|
||||
"yara-x",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ time.workspace = true
|
|||
inotify.workspace = true
|
||||
yara-x.workspace = true
|
||||
libc.workspace = true
|
||||
sha2.workspace = true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
486
crates/houndd/src/persistence.rs
Normal file
486
crates/houndd/src/persistence.rs
Normal 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, ¤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"
|
||||
);
|
||||
}
|
||||
}
|
||||
58
dist/HOUND-VERIFY-README.txt
vendored
Normal file
58
dist/HOUND-VERIFY-README.txt
vendored
Normal 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
BIN
dist/hound-verify
vendored
Executable file
Binary file not shown.
Loading…
Reference in a new issue