diff --git a/crates/houndd/src/rootkit.rs b/crates/houndd/src/rootkit.rs index ae287e9..b557afc 100644 --- a/crates/houndd/src/rootkit.rs +++ b/crates/houndd/src/rootkit.rs @@ -1,43 +1,43 @@ -//! Rootkit detection. +//! Rootkit heuristics. //! -//! Without a kernel module we can't do full DKM/rootkit detection, but we -//! can run a set of userspace heuristics that catch the *common* rootkit -//! tricks. Each check is a "finding" with a severity; the aggregate scan -//! reports every finding plus a pass/fail verdict. +//! This file is a rewrite. The version it replaces was a false-positive +//! generator, and for an antivirus that is worse than a miss — a rule that +//! quarantines a system binary ends the product. Two of its checks were +//! structurally wrong rather than merely tuned badly: //! -//! Checks performed (each individually testable): +//! * **Hidden processes** were "any `/proc/` whose `comm` we cannot +//! read". That fires on every process that exits between the directory +//! listing and the read, which on a busy machine is several per scan. +//! It is a race, not a signal. +//! * **Setuid anomalies** were compared against a hardcoded allowlist of +//! binary names. That list was written on one distribution and would +//! have alarmed on every other one. //! -//! 1. **Hidden processes** — every `pid` in `/proc` must be readable. -//! Rootkits that `hide` a process by making `/proc/` unreadable -//! (or via a `hidepid` mount) surface here as a "unreadable pid". -//! 2. **Hidden files** — every entry reported by a raw `readdir` of a -//! watched dir must be visible to `fs::read_dir`'s metadata probe. -//! A file that exists in the dir listing but whose `stat` fails is a -//! strong signal (classic `lsof`-vs-`ls` discrepancy). -//! 3. **Writable system dirs** — `/etc`, `/bin`, `/lib`, `/lib64`, -//! `/sbin`, `/usr/bin`, `/usr/lib`, `/usr/lib64`, `/boot` should be -//! write-protected for non-root. A writable system dir is where -//! rootkits drop modified binaries. -//! 4. **Setuid/setgid anomalies** — collect setuid binaries under -//! `/usr` and flag ones we don't expect (a plain list, easy to extend). +//! What replaces them: //! -//! These run without root for the common case; root gives stronger -//! signals (e.g. the hidden-pid check is only meaningful when we can -//! actually read `/proc`). +//! * A process is hidden when the kernel agrees it exists but `/proc` does +//! not list it. `kill(pid, 0)` answers the first question for the entire +//! PID space — `ESRCH` means gone, anything else means present. Bracket +//! that sweep with two `/proc` listings and re-verify each candidate, and +//! a process that merely started or exited during the scan cannot be +//! mistaken for a hidden one. A full 4.2-million-PID sweep costs about a +//! second. +//! * A setuid binary is suspicious when **no installed package claims it**. +//! The package manager already knows what belongs on the system, which +//! makes the question factual instead of a guess about names. use hound_api::{RootkitFinding, RootkitScan}; -use std::path::Path; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; -/// The full rootkit scan. `watch_dirs` are the dirs to run the -/// hidden-file check against (typically the same set the real-time -/// monitor watches, so a dropped rootkit file gets caught on both -/// paths). +/// Run every check. pub fn run_scan(watch_dirs: &[String]) -> RootkitScan { let mut findings: Vec = Vec::new(); - findings.extend(hidden_pids()); - findings.extend(hidden_files(watch_dirs)); + findings.extend(hidden_processes()); + findings.extend(unowned_setuid()); + findings.extend(preload_hooks()); findings.extend(writable_system_dirs()); - findings.extend(setuid_anomalies()); + findings.extend(hidden_files(watch_dirs)); let critical = findings.iter().filter(|f| f.severity == "critical").count() as u32; let warn = findings.iter().filter(|f| f.severity == "warn").count() as u32; @@ -60,224 +60,6 @@ pub fn run_scan(watch_dirs: &[String]) -> RootkitScan { } } -/// Check 1: any pid in /proc that is unreadable. -fn hidden_pids() -> Vec { - let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir("/proc") else { - out.push(finding( - "info", - "proc", - "/proc not readable — running without enough privilege for a hidden-pid check" - .to_string(), - )); - return out; - }; - for e in rd.flatten() { - let name = e.file_name(); - let s = match name.to_str() { - Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s.to_string(), - _ => continue, - }; - let p = Path::new("/proc").join(&s); - // A rootkit hiding a process makes /proc/ unreadable. - if let Ok(md) = e.metadata() { - if !md.is_dir() { - continue; - } - } - // Read the first byte of /proc//comm — a hidden process - // often makes this fail with EACCES/EAGAIN. - let comm = p.join("comm"); - if std::fs::read(&comm).is_err() { - out.push(finding( - "warn", - "hidden_pid", - format!("/proc/{s} exists but is unreadable — process may be hidden"), - )); - } - } - out -} - -/// Check 2: files in a watched dir that readdir sees but stat can't resolve. -fn hidden_files(watch_dirs: &[String]) -> Vec { - let mut out = Vec::new(); - for d in watch_dirs { - let path = Path::new(d); - let Ok(rd) = std::fs::read_dir(path) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - // If readdir gave us the entry but stat-by-path fails, that's - // a discrepancy. We re-stat by full path (not the DirEntry's - // already-cached metadata) to detect this class of rootkit. - if std::fs::metadata(&p).is_err() { - out.push(finding( - "warn", - "hidden_file", - format!( - "{} visible in dir listing but stat-by-path fails", - p.display() - ), - )); - } - } - } - out -} - -/// Check 3: writable system dirs. -fn writable_system_dirs() -> Vec { - const SYSTEM_DIRS: &[&str] = &[ - "/etc", - "/bin", - "/lib", - "/lib64", - "/sbin", - "/usr/bin", - "/usr/lib", - "/usr/lib64", - "/boot", - ]; - let mut out = Vec::new(); - // Determine our effective uid so we know whether "writable" is - // actually a concern (if we're root, everything is writable). - let uid = current_uid(); - let root = uid == 0; - for d in SYSTEM_DIRS { - let p = Path::new(d); - if !p.is_dir() { - continue; - } - // A dir is writable by *someone other than us* if: - // - group/other write bits are set, OR - // - the owning group/other is writable and we're not the owner. - // We approximate with the simple heuristic: group or other write - // bit set. This is intentionally conservative — it flags real - // problems and accepts a few false positives on permissive setups. - let Ok(md) = std::fs::metadata(p) else { - continue; - }; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let mode = md.mode(); - let group_w = mode & 0o020 != 0; - let other_w = mode & 0o002 != 0; - if (group_w || other_w) && !root { - out.push(finding( - "warn", - "writable_system_dir", - format!("{d} is group/other writable (mode {mode:o})"), - )); - } - } - #[cfg(not(unix))] - { - let _ = (uid, md); - } - } - out -} - -/// Check 4: setuid/setgid binaries under /usr that look unusual. -fn setuid_anomalies() -> Vec { - let mut out = Vec::new(); - let expected = [ - "sudo", - "sudoedit", - "su", - "sg", - "newgrp", - "pkexec", - "doas", - "at", - "crontab", - "chfn", - "chsh", - "chage", - "chgpasswd", - "passwd", - "gpasswd", - "expiry", - "unix_chkpwd", - "pam_extrausers_chkpwd", - "pam_timestamp_check", - "mount", - "umount", - "mount.cifs", - "mount.smb3", - "mount.nfs", - "mount.nfs4", - "mount.ecryptfs_private", - "umount.ecryptfs_private", - "fusermount", - "fusermount3", - "newuidmap", - "newgidmap", - "ping", - "ping6", - "ip", - "ip6", - "Xorg", - "ssh-agent", - "gpg-agent", - "dbus-daemon-launch-helper", - "polkit-agent-helper-1", - "dotlockfile", - "locate", - "plocate", - "pppd", - "postdrop", - "postqueue", - "newsyslog", - "mullvad-exclude", - "screen", - "tmux", - ]; - let roots = ["/usr/bin", "/usr/sbin", "/usr/local/bin"]; - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for r in roots { - let Ok(rd) = std::fs::read_dir(r) else { - continue; - }; - for e in rd.flatten() { - let p = e.path(); - if !p.is_file() { - continue; - } - let Ok(md) = std::fs::metadata(&p) else { - continue; - }; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let mode = md.mode(); - let setuid = mode & 0o4000 != 0; - let setgid = mode & 0o2000 != 0; - if !setuid && !setgid { - continue; - } - let name = p.file_name().map(|n| n.to_string_lossy().to_string()); - let Some(name) = name else { continue }; - if seen.insert(name.clone()) && !expected.iter().any(|x| *x == name) { - out.push(finding( - "info", - "setuid_anomaly", - format!("unexpected setuid/setgid binary: {}", p.display()), - )); - } - } - #[cfg(not(unix))] - { - let _ = md; - } - } - } - out -} - fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding { RootkitFinding { check: kind.to_string(), @@ -286,20 +68,337 @@ fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding { } } -fn current_uid() -> u32 { - // /proc/self is the portable, no-dependency way on Linux. - if let Ok(s) = std::fs::read_to_string("/proc/self/status") { - for line in s.lines() { - if let Some(v) = line.strip_prefix("Uid:") { - if let Some(first) = v.split_whitespace().next() { - if let Ok(uid) = first.parse() { - return uid; - } - } +// ── check 1: processes the kernel has but /proc does not ──────────────── + +/// Thread-group leaders listed directly in `/proc`. +pub fn proc_pids() -> HashSet { + std::fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::().ok())) + .collect() +} + +/// Every task id the kernel exposes: thread-group leaders **and threads**. +/// +/// This distinction is the whole check. `/proc` lists only thread-group +/// leaders, but `kill(2)` accepts any thread id — so a process with twenty +/// threads has nineteen ids that answer `kill` and do not appear in a +/// `/proc` listing. Comparing against `proc_pids()` alone reports every +/// thread on the machine as a hidden process, which on this laptop was +/// dozens of criticals against a completely healthy system. +/// +/// Threads live at `/proc//task/`, so the honest set is the +/// union of the leaders and their tasks. +pub fn proc_tids() -> HashSet { + let mut all = HashSet::new(); + for pid in proc_pids() { + all.insert(pid); + let task_dir = format!("/proc/{pid}/task"); + for e in std::fs::read_dir(task_dir).into_iter().flatten().flatten() { + if let Some(tid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) { + all.insert(tid); } } } - u32::MAX + all +} + +/// Whether the kernel believes a PID exists. +/// +/// `kill(pid, 0)` sends no signal. Returning 0 means it exists and we may +/// signal it; `EPERM` means it exists and belongs to somebody else — which +/// is still proof of existence, and is the case that matters most, since a +/// rootkit's process will not be ours. +pub fn pid_exists(pid: u32) -> bool { + // SAFETY: kill with signal 0 performs an existence and permission + // check only; it cannot affect the target. + let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if rc == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +fn pid_max() -> u32 { + std::fs::read_to_string("/proc/sys/kernel/pid_max") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(32_768) +} + +/// Candidates that exist per the kernel but appear in neither listing. +/// +/// Bracketing the sweep is what removes the race the old check drowned in: +/// a process that started during the sweep appears in `after`, and one that +/// exited appears in `before`. Only something absent from both, while still +/// answering `kill`, is unexplained. +pub fn hidden_pid_candidates(before: &HashSet, after: &HashSet, max: u32) -> Vec { + (1..=max) + .filter(|p| !before.contains(p) && !after.contains(p) && pid_exists(*p)) + .collect() +} + +fn hidden_processes() -> Vec { + let before = proc_tids(); + if before.is_empty() { + return vec![finding( + "info", + "hidden_process", + "/proc is not readable, so hidden processes cannot be checked for".into(), + )]; + } + let max = pid_max(); + let candidates = hidden_pid_candidates(&before, &proc_tids(), max); + if candidates.is_empty() { + return Vec::new(); + } + + // Re-verify. A candidate that has stopped answering was a process + // exiting during the sweep, not something hiding. + std::thread::sleep(std::time::Duration::from_millis(200)); + let listed = proc_tids(); + let confirmed: Vec = candidates + .into_iter() + .filter(|p| !listed.contains(p) && pid_exists(*p)) + .collect(); + + confirmed + .into_iter() + .map(|pid| { + finding( + "critical", + "hidden_process", + format!( + "process {pid} is running but does not appear in /proc. Something is \ + concealing it from the tools that list running programs, which is what \ + a rootkit is for." + ), + ) + }) + .collect() +} + +// ── check 2: setuid binaries no package claims ────────────────────────── + +/// Every file path claimed by an installed package, in both spellings. +/// +/// dpkg keeps one `.list` per package, so the whole index is a couple of +/// megabytes and one pass over a directory. rpm and pacman are queried per +/// candidate instead, which is fine because there are only ever a few dozen +/// setuid binaries on a system. +/// +/// **Merged-`/usr` makes this a two-sided problem.** On current Debian and +/// Ubuntu, `/bin` is a symlink to `usr/bin` and `/sbin` to `usr/sbin`, so +/// every binary exists under two names — and dpkg's own index is not +/// consistent about which it records. `sudo.list` says `/usr/bin/sudo` +/// while `fuse3.list` says `/bin/fusermount3` and `cifs-utils` says +/// `/sbin/mount.cifs`. Comparing the strings fails in both directions and +/// reports the entire setuid set as unowned, which is precisely the kind of +/// distribution-specific breakage that made the previous implementation +/// useless. Both the recorded path and its resolved form go in. +pub fn dpkg_owned_paths() -> Option> { + let dir = Path::new("/var/lib/dpkg/info"); + if !dir.is_dir() { + return None; + } + let mut owned = HashSet::new(); + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let p = entry.path(); + if !p.extension().is_some_and(|e| e == "list") { + continue; + } + let Ok(text) = std::fs::read_to_string(&p) else { continue }; + for line in text.lines() { + owned.insert(line.to_string()); + if let Ok(real) = std::fs::canonicalize(line) { + owned.insert(real.to_string_lossy().into_owned()); + } + } + } + (!owned.is_empty()).then_some(owned) +} + +/// Both spellings of a path: as given, and fully resolved. +fn both_spellings(path: &Path) -> (String, Option) { + let given = path.to_string_lossy().into_owned(); + let real = std::fs::canonicalize(path) + .ok() + .map(|p| p.to_string_lossy().into_owned()) + .filter(|r| *r != given); + (given, real) +} + +/// Ask rpm or pacman whether anything owns a path. +fn queried_owner(path: &Path) -> Option { + for (bin, args) in [("rpm", vec!["-qf"]), ("pacman", vec!["-Qo"])] { + if let Ok(out) = std::process::Command::new(bin) + .args(&args) + .arg(path) + .output() + { + return Some(out.status.success()); + } + } + None +} + +/// Setuid and setgid binaries under the usual directories. +pub fn setuid_binaries() -> Vec { + use std::os::unix::fs::MetadataExt; + let mut out = Vec::new(); + // Deduplicate by resolved path, or merged-/usr reports every binary + // twice — once as /bin/x and once as /usr/bin/x. + let mut seen: HashSet = HashSet::new(); + for root in ["/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/libexec"] { + for entry in std::fs::read_dir(root).into_iter().flatten().flatten() { + let p = entry.path(); + let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; + if md.is_symlink() || !md.is_file() { + continue; + } + if md.mode() & 0o6000 == 0 { + continue; + } + let key = std::fs::canonicalize(&p) + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_else(|_| p.to_string_lossy().into_owned()); + if seen.insert(key) { + out.push(p); + } + } + } + out +} + +fn unowned_setuid() -> Vec { + let binaries = setuid_binaries(); + if binaries.is_empty() { + return Vec::new(); + } + let dpkg = dpkg_owned_paths(); + + let mut out = Vec::new(); + for path in binaries { + let (key, real) = both_spellings(&path); + let owned = match &dpkg { + Some(index) => { + index.contains(&key) || real.as_ref().is_some_and(|r| index.contains(r)) + } + None => match queried_owner(&path) { + Some(owned) => owned, + // No package manager we understand. Saying nothing is + // better than guessing at names, which is what the old + // check did. + None => continue, + }, + }; + if !owned { + out.push(finding( + "warn", + "unowned_setuid", + format!( + "{key} runs with elevated privileges but no installed package claims it. \ + Every setuid program on a healthy system arrived with a package; one \ + that did not was put there some other way." + ), + )); + } + } + out +} + +// ── check 3: userland preload hooks ───────────────────────────────────── + +/// `/etc/ld.so.preload` forces a library into *every* dynamically linked +/// program on the machine. It is empty or absent on a normal system, and +/// it is the classic userland rootkit: hook `readdir` and files disappear, +/// hook `read` on `/proc/net/tcp` and connections disappear. +fn preload_hooks() -> Vec { + let path = Path::new("/etc/ld.so.preload"); + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); // absent is the normal, healthy case + }; + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|lib| { + finding( + "critical", + "ld_preload", + format!( + "/etc/ld.so.preload forces {lib} into every program that starts on this \ + machine. That file is empty on a healthy system, and loading code into \ + everything is how a rootkit hides files, processes and connections." + ), + ) + }) + .collect() +} + +// ── check 4: system directories anyone can write to ───────────────────── + +fn writable_system_dirs() -> Vec { + use std::os::unix::fs::MetadataExt; + const SYSTEM_DIRS: &[&str] = &[ + "/etc", "/bin", "/lib", "/lib64", "/sbin", + "/usr/bin", "/usr/lib", "/usr/lib64", "/usr/sbin", "/boot", + ]; + let mut out = Vec::new(); + for d in SYSTEM_DIRS { + let p = Path::new(d); + let Ok(md) = std::fs::metadata(p) else { continue }; + if !md.is_dir() { + continue; + } + let mode = md.mode(); + // The sticky bit makes a shared-writable directory safe (that is + // what /tmp uses), so it is not a finding. + let sticky = mode & 0o1000 != 0; + let group_w = mode & 0o020 != 0; + let other_w = mode & 0o002 != 0; + if (group_w || other_w) && !sticky { + out.push(finding( + "warn", + "writable_system_dir", + format!( + "{d} can be written to by users other than root (mode {:o}). Anyone who \ + can write there can replace a program the whole system runs.", + mode & 0o7777 + ), + )); + } + } + out +} + +// ── check 5: entries a directory lists but cannot resolve ─────────────── + +fn hidden_files(watch_dirs: &[String]) -> Vec { + let mut out = Vec::new(); + for d in watch_dirs { + let path = Path::new(d); + let Ok(rd) = std::fs::read_dir(path) else { continue }; + for e in rd.flatten() { + let p = e.path(); + // A dangling symlink resolves to nothing and is completely + // ordinary, so it must not be reported. Only an entry the + // directory lists whose own metadata cannot be read is odd. + if std::fs::symlink_metadata(&p).is_err() { + out.push(finding( + "warn", + "hidden_file", + format!( + "{} is listed in {d} but the system cannot describe it, which \ + suggests something is interfering with directory listings.", + p.display() + ), + )); + } + } + } + out } #[cfg(test)] @@ -315,21 +414,242 @@ mod tests { assert!(scan.ts.len() >= 10); } + // ── the regression that motivated the rewrite ── + + /// A clean machine must produce no critical findings. This is the whole + /// bar for this file: the previous implementation failed it on any busy + /// system, because a process exiting mid-scan looked like a hidden one. #[test] - fn hidden_file_detects_unstatable() { - // Create a real dir and a real file — should produce no finding. + fn a_healthy_machine_produces_no_criticals() { + // Churn processes throughout, which is exactly what broke the old + // check: each one starts and exits during the scan. + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let s = std::sync::Arc::clone(&stop); + let churn = std::thread::spawn(move || { + while !s.load(std::sync::atomic::Ordering::Relaxed) { + let _ = std::process::Command::new("true").status(); + } + }); + + let scan = run_scan(&[]); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = churn.join(); + + let criticals: Vec<&RootkitFinding> = + scan.findings.iter().filter(|f| f.severity == "critical").collect(); + assert!( + criticals.is_empty(), + "processes churning during a scan must not read as hidden: {criticals:?}" + ); + } + + #[test] + fn a_process_that_exits_during_the_sweep_is_not_hidden() { + // Present in `before`, gone by `after`: explained by exiting. + let before: HashSet = [1, 2, 4242].into_iter().collect(); + let after: HashSet = [1, 2].into_iter().collect(); + assert!( + !hidden_pid_candidates(&before, &after, 5000).contains(&4242), + "a pid seen in the first listing is accounted for" + ); + } + + #[test] + fn a_process_that_starts_during_the_sweep_is_not_hidden() { + // Absent from `before`, present in `after`: explained by starting. + let before: HashSet = [1, 2].into_iter().collect(); + let after: HashSet = [1, 2, 4242].into_iter().collect(); + assert!(!hidden_pid_candidates(&before, &after, 5000).contains(&4242)); + } + + #[test] + fn our_own_pid_is_never_a_candidate() { + let mine = std::process::id(); + let listed = proc_tids(); + assert!(listed.contains(&mine), "/proc must list this test process"); + let candidates = hidden_pid_candidates(&listed, &listed, mine + 10); + assert!(!candidates.contains(&mine)); + } + + #[test] + fn threads_are_task_ids_not_hidden_processes() { + // The regression: /proc lists thread-group leaders, kill() accepts + // any thread id. A multi-threaded process therefore has ids that + // answer kill and are absent from a /proc listing — and reporting + // those as hidden produced dozens of criticals on a healthy laptop. + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handles: Vec<_> = (0..8) + .map(|_| { + let s = std::sync::Arc::clone(&stop); + std::thread::spawn(move || { + while !s.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }) + }) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let tids = proc_tids(); + let pids = proc_pids(); + + // Our own threads: real task ids that answer kill() and do NOT + // appear in a /proc listing. Every one must be accounted for by + // proc_tids, or it becomes a critical finding on a clean machine. + // + // Everything is measured while the threads are still alive and only + // asserted afterwards — checking a thread's existence after joining + // it tests nothing except that join() works. + let mine: Vec = std::fs::read_dir(format!("/proc/{}/task", std::process::id())) + .unwrap() + .flatten() + .filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::().ok())) + .collect(); + let observed: Vec<(u32, bool, bool, bool)> = mine + .iter() + .map(|tid| { + ( + *tid, + pid_exists(*tid), + tids.contains(tid), + pids.contains(tid), + ) + }) + .collect(); + + stop.store(true, std::sync::atomic::Ordering::Relaxed); + for h in handles { + let _ = h.join(); + } + + assert!( + tids.len() > pids.len(), + "this process alone has 8 extra threads, so tids must exceed pids" + ); + assert!(mine.len() >= 9, "expected the leader plus 8 threads, got {}", mine.len()); + for (tid, exists, in_tids, in_pids) in observed { + assert!(exists, "thread {tid} was alive and must answer kill()"); + assert!( + in_tids, + "thread {tid} answers kill() but proc_tids missed it — it would be \ + reported as a hidden process" + ); + if tid != std::process::id() { + assert!( + !in_pids, + "thread {tid} should not be a top-level /proc entry; that is exactly \ + why proc_pids alone is insufficient" + ); + } + } + } + + #[test] + fn ownership_resolves_whichever_spelling_dpkg_used() { + let Some(owned) = dpkg_owned_paths() else { return }; + // The property that matters is not that both literal strings are + // in the index, but that a lookup succeeds either way: dpkg records + // sudo canonically and fusermount3 aliased, and both must resolve. + for p in ["/usr/bin/sudo", "/bin/sudo", "/usr/bin/fusermount3", "/bin/fusermount3"] { + let path = Path::new(p); + if !path.exists() { + continue; + } + let (given, real) = both_spellings(path); + assert!( + owned.contains(&given) || real.as_ref().is_some_and(|r| owned.contains(r)), + "{p} should resolve to an owning package" + ); + } + } + + #[test] + fn setuid_binaries_are_not_reported_twice() { + let bins = setuid_binaries(); + let mut canonical: Vec = bins + .iter() + .map(|p| { + std::fs::canonicalize(p) + .unwrap_or_else(|_| p.clone()) + .to_string_lossy() + .into_owned() + }) + .collect(); + let before = canonical.len(); + canonical.sort(); + canonical.dedup(); + assert_eq!(before, canonical.len(), "merged-/usr duplicated the list"); + } + + #[test] + fn unowned_setuid_does_not_fire_on_a_healthy_system() { + // The old check compared against a name list written on one distro. + // This one asks the package manager, so a clean machine is quiet. + if dpkg_owned_paths().is_none() { + return; + } + let f = unowned_setuid(); + assert!( + f.is_empty(), + "a stock system should have no unowned setuid binaries: {f:?}" + ); + } + + // ── preload ── + + #[test] + fn no_preload_file_is_the_healthy_case() { + // Whatever this machine has, the check must not panic, and an + // absent file must produce nothing. + let f = preload_hooks(); + if !Path::new("/etc/ld.so.preload").exists() { + assert!(f.is_empty()); + } + } + + // ── writable dirs ── + + #[test] + fn stock_system_directories_are_not_writable() { + let f = writable_system_dirs(); + assert!(f.is_empty(), "unexpected writable system dir: {f:?}"); + } + + // ── hidden files ── + + #[test] + fn a_normal_directory_yields_no_hidden_files() { let dir = std::env::temp_dir().join(format!("hound-rk-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); - let f = dir.join("ok.txt"); - std::fs::write(&f, b"hi").unwrap(); + std::fs::write(dir.join("ok.txt"), b"hi").unwrap(); let out = hidden_files(&[dir.to_string_lossy().to_string()]); assert!(out.is_empty(), "unexpected finding: {out:?}"); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn current_uid_is_sane() { - let uid = current_uid(); - assert!(uid < 10_000); + fn a_dangling_symlink_is_not_a_hidden_file() { + // The old check used metadata() (which follows links), so every + // broken symlink — utterly ordinary — was reported. + let dir = std::env::temp_dir().join(format!("hound-rk-dangle-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::os::unix::fs::symlink("/nonexistent/target", dir.join("dangling")).unwrap(); + let out = hidden_files(&[dir.to_string_lossy().to_string()]); + assert!(out.is_empty(), "a broken symlink is normal: {out:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_finding_explains_itself_in_plain_language() { + let scan = run_scan(&[]); + for f in &scan.findings { + assert!( + f.detail.len() > 40, + "a finding nobody can act on: {}", + f.detail + ); + assert!(!f.check.is_empty()); + } } }