A clean laptop reported 988 critical rootkit findings; this server
3786, PID 1 among them. Every one was false, and the cause was our own
systemd hardening.
ProtectProc=invisible hides processes the daemon does not own from its
view of /proc, while kill(pid, 0) keeps answering truthfully because it
is a syscall and not a filesystem lookup. The hidden-process check
compares exactly those two sources, so with that setting every process
on the machine looked concealed. Enumerating processes is this daemon's
job, so it needs the default view.
Removing the setting is not enough on its own — hidepid= on the /proc
mount produces the same blindness and we do not control that. So the
detector now recognises when it cannot see:
- PID 1 is the control. It always exists and nothing hides init; a
rootkit that did would break the machine it is living on. If PID 1
answers kill(1, 0) but is absent from the listing, we are blind and
say so as info rather than crying rootkit.
- A plausibility ceiling of 32. Hiding a handful of processes is the
entire point of a rootkit; hundreds means a broken observer. An
antivirus that reports a critical rootkit finding on every clean
machine teaches people to ignore the one time it is real.
Also in this change, from testing on a real desktop:
- Closing the window hides it to the tray instead of exiting, with a
one-time notification so it does not read as a crash. Quit lives
only in the tray menu and confirms first. The settings already had
close_to_tray and confirm_quit fields wired to nothing; they are
honoured now rather than hardcoded.
- The tray menu and Scan Home sent the literal string "~". A shell
would have expanded it, nothing here did, so the daemon was asked
to scan a directory of that name. It failed silently until the
per-peer readability check made it audible.
- Administrative actions elevate through polkit instead of telling
people to open a terminal. The app tries unprivileged first and
only on a privilege refusal runs `pkexec hound admin-rpc`, which
forwards one request as root. auth_admin_keep, because prompting on
every settings toggle trains people to authenticate without reading
the prompt. This grants what `sudo hound` already grants to people
who could already run sudo — a transport, not a new privilege.
- `hound settings exec-gate on|off` now exists. The install script,
the AppImage banner, the rpm spec, the AUR install file and
llms.txt all told users to run `hound settings set exec_gate true`.
There was no `set` subcommand and no way to enable the execution
gate from the CLI at all: the flagship paid feature was unreachable
and the first thing a new user was told to type returned an error.
A test now asserts every documented command parses.
- `settings show` displays the exec gate state, and no longer prints
its own header twice.
- The CLI help still described ClamAV, which has not been the engine
for some time. So did the socket permission error, which now
explains the `hound` group and the log-out-and-back-in it needs.
368 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
737 lines
28 KiB
Rust
737 lines
28 KiB
Rust
//! Rootkit heuristics.
|
|
//!
|
|
//! 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:
|
|
//!
|
|
//! * **Hidden processes** were "any `/proc/<pid>` 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.
|
|
//!
|
|
//! What replaces them:
|
|
//!
|
|
//! * 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::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Run every check.
|
|
pub fn run_scan(watch_dirs: &[String]) -> RootkitScan {
|
|
let mut findings: Vec<RootkitFinding> = Vec::new();
|
|
findings.extend(hidden_processes());
|
|
findings.extend(unowned_setuid());
|
|
findings.extend(preload_hooks());
|
|
findings.extend(writable_system_dirs());
|
|
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;
|
|
let info = findings.iter().filter(|f| f.severity == "info").count() as u32;
|
|
|
|
let clean = critical == 0 && warn == 0;
|
|
let verdict = if clean {
|
|
"clean".into()
|
|
} else {
|
|
format!("{critical} critical, {warn} warning(s)")
|
|
};
|
|
|
|
RootkitScan {
|
|
ts: crate::engine::to_rfc3339(std::time::SystemTime::now()),
|
|
verdict,
|
|
critical,
|
|
warn,
|
|
info,
|
|
findings,
|
|
}
|
|
}
|
|
|
|
fn finding(sev: &str, kind: &str, detail: String) -> RootkitFinding {
|
|
RootkitFinding {
|
|
check: kind.to_string(),
|
|
severity: sev.to_string(),
|
|
detail,
|
|
}
|
|
}
|
|
|
|
// ── check 1: processes the kernel has but /proc does not ────────────────
|
|
|
|
/// Thread-group leaders listed directly in `/proc`.
|
|
pub fn proc_pids() -> HashSet<u32> {
|
|
std::fs::read_dir("/proc")
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<u32>().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/<tgid>/task/<tid>`, so the honest set is the
|
|
/// union of the leaders and their tasks.
|
|
pub fn proc_tids() -> HashSet<u32> {
|
|
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::<u32>().ok()) {
|
|
all.insert(tid);
|
|
}
|
|
}
|
|
}
|
|
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<u32>, after: &HashSet<u32>, max: u32) -> Vec<u32> {
|
|
(1..=max)
|
|
.filter(|p| !before.contains(p) && !after.contains(p) && pid_exists(*p))
|
|
.collect()
|
|
}
|
|
|
|
fn hidden_processes() -> Vec<RootkitFinding> {
|
|
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(),
|
|
)];
|
|
}
|
|
// Before trusting a comparison between /proc and the kernel, check that
|
|
// this process can actually see /proc. `ProtectProc=invisible` (and
|
|
// hidepid= on the mount) filter the listing while kill(pid, 0) keeps
|
|
// answering, so every process on the machine looks concealed. PID 1 is
|
|
// the control: it always exists, and nothing hides init — a rootkit that
|
|
// did would break the machine it is trying to live on. If we cannot see
|
|
// it, we are the ones who are blind.
|
|
if pid_exists(1) && !before.contains(&1) {
|
|
return vec![finding(
|
|
"info",
|
|
"hidden_process",
|
|
"cannot check for hidden processes: this daemon's view of /proc is \
|
|
filtered, so it cannot see other processes. Check for ProtectProc= \
|
|
in the systemd unit or hidepid= on the /proc mount."
|
|
.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<u32> = candidates
|
|
.into_iter()
|
|
.filter(|p| !listed.contains(p) && pid_exists(*p))
|
|
.collect();
|
|
|
|
// Second guard, for the blindness we could not name. A rootkit hides a
|
|
// handful of processes — that is the entire point of hiding. Hundreds of
|
|
// "hidden" processes is a broken observer, not a compromised kernel, and
|
|
// reporting it as critical trains people to ignore the one time it is
|
|
// real.
|
|
const IMPLAUSIBLE: usize = 32;
|
|
if confirmed.len() > IMPLAUSIBLE {
|
|
return vec![finding(
|
|
"info",
|
|
"hidden_process",
|
|
format!(
|
|
"cannot check for hidden processes: {} of {} PIDs appear concealed, \
|
|
which means this daemon cannot read /proc properly rather than that \
|
|
the machine is compromised.",
|
|
confirmed.len(),
|
|
listed.len()
|
|
),
|
|
)];
|
|
}
|
|
|
|
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<HashSet<String>> {
|
|
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<String>) {
|
|
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<bool> {
|
|
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<PathBuf> {
|
|
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<String> = 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<RootkitFinding> {
|
|
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<RootkitFinding> {
|
|
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<RootkitFinding> {
|
|
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<RootkitFinding> {
|
|
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)]
|
|
mod tests {
|
|
/// The bug that shipped: `ProtectProc=invisible` in the systemd unit
|
|
/// filtered the daemon's /proc listing while `kill(pid, 0)` kept
|
|
/// answering truthfully, so every process on the machine looked
|
|
/// concealed. A clean laptop reported 988 critical findings and this
|
|
/// server 3786 — PID 1 among them.
|
|
#[test]
|
|
fn a_filtered_proc_is_reported_as_blindness_not_a_rootkit() {
|
|
// What the daemon saw: only its own threads.
|
|
let mine: HashSet<u32> = [4242, 4243].into_iter().collect();
|
|
assert!(
|
|
!mine.contains(&1),
|
|
"the premise of the guard: init is missing from the listing"
|
|
);
|
|
// Every real PID then looks hidden.
|
|
let candidates = hidden_pid_candidates(&mine, &mine, 5000);
|
|
assert!(
|
|
candidates.len() > 32,
|
|
"a filtered /proc yields implausibly many candidates, got {}",
|
|
candidates.len()
|
|
);
|
|
}
|
|
|
|
/// PID 1 is the control for that guard, so it had better be true.
|
|
#[test]
|
|
fn init_always_exists_and_is_always_listed() {
|
|
assert!(pid_exists(1), "PID 1 must exist");
|
|
assert!(
|
|
proc_tids().contains(&1),
|
|
"PID 1 must appear in /proc — if this fails, the test runner's \
|
|
view of /proc is filtered and the blindness guard is what saves us"
|
|
);
|
|
}
|
|
|
|
/// A real rootkit hides a few processes. Hundreds is a broken observer.
|
|
#[test]
|
|
fn the_implausible_threshold_is_above_any_real_rootkit_and_below_a_broken_proc() {
|
|
let total_pids = proc_tids().len();
|
|
assert!(
|
|
total_pids > 32,
|
|
"this machine should be running more than 32 tasks, saw {total_pids}"
|
|
);
|
|
}
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn scan_shape_is_consistent() {
|
|
let scan = run_scan(&[]);
|
|
let total = scan.findings.len() as u32;
|
|
assert_eq!(scan.critical + scan.warn + scan.info, total);
|
|
assert!(!scan.verdict.is_empty());
|
|
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 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<u32> = [1, 2, 4242].into_iter().collect();
|
|
let after: HashSet<u32> = [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<u32> = [1, 2].into_iter().collect();
|
|
let after: HashSet<u32> = [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.
|
|
//
|
|
// Each thread reports its OWN tid rather than the test reading
|
|
// /proc/self/task afterwards. Reading the task list and the /proc
|
|
// snapshot at different moments reintroduces exactly the race this
|
|
// whole check exists to avoid — the test harness starts and stops
|
|
// threads for other tests throughout, so one captured in the first
|
|
// read may be gone by the second. Threads that report themselves
|
|
// and then wait are alive across the entire window by construction.
|
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let reported: std::sync::Arc<std::sync::Mutex<Vec<u32>>> =
|
|
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
|
|
let handles: Vec<_> = (0..8)
|
|
.map(|_| {
|
|
let s = std::sync::Arc::clone(&stop);
|
|
let r = std::sync::Arc::clone(&reported);
|
|
std::thread::spawn(move || {
|
|
// SAFETY: gettid takes no arguments and cannot fail.
|
|
let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u32;
|
|
r.lock().expect("tid list poisoned").push(tid);
|
|
while !s.load(std::sync::atomic::Ordering::Relaxed) {
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Wait until every thread has reported and is therefore parked.
|
|
let mut waited = 0;
|
|
while reported.lock().unwrap().len() < 8 && waited < 200 {
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
waited += 1;
|
|
}
|
|
let mine: Vec<u32> = reported.lock().unwrap().clone();
|
|
|
|
// Snapshot while all eight are demonstrably alive.
|
|
let tids = proc_tids();
|
|
let pids = proc_pids();
|
|
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_eq!(mine.len(), 8, "all eight threads should have reported");
|
|
assert!(
|
|
tids.len() > pids.len(),
|
|
"this process alone has 8 extra threads, so tids must exceed pids"
|
|
);
|
|
for (tid, exists, in_tids, in_pids) in observed {
|
|
assert!(exists, "thread {tid} was parked and must answer kill()");
|
|
assert!(
|
|
in_tids,
|
|
"thread {tid} answers kill() but proc_tids missed it — it would be \
|
|
reported as a hidden process"
|
|
);
|
|
assert!(
|
|
!in_pids,
|
|
"thread {tid} is not 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<String> = 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);
|
|
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 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());
|
|
}
|
|
}
|
|
}
|