Commit graph

4 commits

Author SHA1 Message Date
dev
a3f31288fa rootkit: stop reporting every process on the machine as hidden
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>
2026-08-21 11:21:06 -05:00
Hound
4dab08a75a tests: kill a flaky test that reintroduced the race it was testing for
The previous commit went out with a failing test. It passed on a rerun,
which is worse than failing — a flaky test in a security product either
gets ignored or gets deleted, and both are how a real regression ships.

The offender was rootkit's own thread test. It read /proc/self/task and
took the /proc snapshot at different moments, so a thread started by
another test between the two reads looked like a thread that answered
kill() but was missing from the listing. That is precisely the
start/exit race the hidden-process check exists to avoid, reintroduced
in the test written to prove the check avoids it.

Each thread now reports its own tid via gettid and then parks, so all
eight are demonstrably alive across the whole measurement window. Five
consecutive full runs, 124/124 each.

Also isolated persistence's read-only-scan test behind the env lock:
baseline_path() reads XDG_DATA_HOME and the quarantine tests reassign
it, so two scans either side of that disagreed about first_run. It now
takes the lock, points at its own directory, and additionally asserts
the thing the test was named for — that a read-only scan writes no
baseline file, and that --accept does.

256 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:14:09 -05:00
Hound
909afacb19 houndd: rewrite the rootkit checks so they stop crying wolf
Phase 5's correctness half. The previous implementation could not ship:
its two main checks were structurally wrong rather than badly tuned.

  hidden processes  was "any /proc/<pid> whose comm we cannot read",
                    which fires on every process that exits between the
                    listing and the read. A race, not a signal.
  setuid anomalies  compared against a hardcoded allowlist of binary
                    names, written on one distribution.

Replaced with questions that have factual answers:

  A process is hidden when the kernel agrees it exists and /proc does
  not list it. kill(pid, 0) answers the first half for the whole PID
  space — ESRCH means gone, EPERM means it exists and belongs to
  somebody else, which is the case that matters since a rootkit's
  process will not be ours. The sweep is bracketed by two listings and
  candidates are re-verified, so a process that merely started or
  exited during the scan cannot be mistaken for a hidden one.

  A setuid binary is suspicious when no installed package claims it.
  The package manager already knows what belongs on the system.

Two bugs found by testing against this machine rather than reasoning
about it, both of which would have made the feature useless in the
field:

* /proc lists thread-group leaders; kill() accepts any THREAD id. A
  process with twenty threads therefore has nineteen ids that answer
  kill and appear in no /proc listing. Comparing against the pid set
  alone reported dozens of criticals on a completely healthy laptop.
  The honest set is the union of leaders and their /proc/<tgid>/task
  entries.

* Merged-/usr breaks package ownership in BOTH directions. /bin is a
  symlink to usr/bin, so every binary has two names, and dpkg's own
  index is inconsistent about which it records: sudo.list says
  /usr/bin/sudo while fuse3.list says /bin/fusermount3 and cifs-utils
  says /sbin/mount.cifs. String comparison reported the entire setuid
  set as unowned. Both spellings now go into the index, candidates are
  deduplicated by resolved path, and lookups try both.

Also: ld.so.preload is now checked (it is empty on a healthy system and
is the classic userland rootkit), the writable-directory check no
longer counts sticky-bit directories, and the hidden-file check uses
symlink_metadata so an ordinary dangling symlink is not an incident.

Verified on this machine, privileged and not: clean, 0 findings, 2.8s
including the full 4.2-million-pid sweep. The exit criterion asks for
five machines across three distributions and only one was available
here, so treat the cross-distro half as unmet.

The regression tests are the point: a scan run while processes churn
continuously must produce no criticals, and a process with eight live
threads must not produce eight findings.

200 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:34:26 -05:00
6ef296caa1 Full feature set: realtime monitor, quarantine vault, rootkit scan, settings, events
Daemon (houndd)
- realtime.rs: inotify monitor over watched dirs (default ~/Downloads,
  ~/Documents, ~/Desktop), ClamAV scan on touch, on_detect action
  (quarantine/rename/remove), ransomware heuristic (writes/renames per
  minute above threshold -> 'watching'/'alarm' + critical event)
- quarantine.rs: SHA-256-keyed vault under ~/.local/share/hound/quarantine,
  add/list/restore/remove with original-path metadata
- rootkit.rs: setuid anomaly detection (allowlisted stock binaries),
  deleted-but-executing inodes, world-writable /usr /bin; 3 severity levels
- settings.rs: persisted ~/.config/hound/settings.json, hot-reload on set
- events.rs: ring buffer of severity-tagged events, query + clear

API (hound-api): Settings, Event, QuarantineEntry, RootkitScan/
RootkitFinding, RealtimeStatus types + 10 client methods; Status gains
engine field (engine-agnostic seam)

CLI (hound): events, quarantine list|add|restore|remove, settings
[show|paused|auto-update|notify|realtime on|off|watch|on-detect|
max-size|exclude], rootkit, realtime [status|on|off] — color human
output, --json everywhere

GUI (Tauri 2):
- 16 backend commands bridging every client method
- tray watcher: 1s poll loop, 4-state icon ladder (green/amber/red/gray),
  desktop notification on fresh critical events
- 6-tab frontend: Protection (hero + scan + update), Quarantine (vault
  manager + manual add), Realtime (stats + watch list + toggle), Rootkit
  (on-demand scan), Alerts (event log + clear), Settings (full editor)
- capabilities/default.json for dialog/notification/event permissions

Verified: 27/27 workspace tests, live E2E — EICAR dropped in ~/Downloads
auto-quarantined by the running daemon (critical event logged, file
removed from origin).
2026-08-20 20:33:44 -05:00