Installed the .deb on the live server. The daemon did not start, and
everything below is what that one command surfaced.
1. MemoryDenyWriteExecute=yes stopped the service dead.
yara-x compiles rules to WebAssembly and JITs them, so it needs pages
that go writable then executable. With W^X enforced the daemon aborts
at startup: "unable to make memory executable". The unit had passed
systemd-analyze verify, which checks syntax and cannot know this.
Now off, with the reasoning in the unit rather than in a commit
nobody will read: a hardening directive that stops the service is
worse than the exposure it prevents, because the machine ends up with
no antivirus at all. What compensates is spelled out beside it.
2. Hound detected itself.
The goodware gate reported /usr/bin/houndd as Linux.Coinminer.XMRig
and Linux.Rootkit.Preload. Correctly: the built-in pack matches on
"stratum+tcp://", "xmrig", "RTLD_NEXT" and "ld.so.preload", and the
pack was embedded verbatim, so the daemon's own binary contained all
of them.
Not cosmetic. With the execution gate armed, Hound would have refused
to execute itself or quarantined its own binary — a scanner that eats
its own daemon the moment protection is switched on.
The pack is now XOR-masked at build time (build.rs) and unmasked at
startup. Not a secret — the rules are open source — the only job is
keeping the literal bytes out of the executable. Two regression tests:
the embedded blob carries no plaintext rule strings, and a built
daemon binary in target/ carries none either.
3. The postinst copied the built-in pack into /var/lib/hound/rules,
where the daemon compiled it a second time and logged a duplicate
declaration on every start. That directory is for ADDITIONAL packs;
the built-ins live in the binary. Removed from deb, rpm and AUR.
4. The daemon and the CLI disagreed about the socket. systemd gives the
service /run/hound; the CLI looked in $XDG_RUNTIME_DIR and reported
the daemon unreachable — technically true, entirely unhelpful.
default_socket_path() now prefers /run/hound when it exists, the unit
states HOUNDD_SOCK explicitly, and a permission error on the socket
says "try: sudo hound" instead of "Permission denied".
Also: Recommends: clamav-daemon was wrong and apt duly installed clamd,
which took 970 MB of RSS on the live server. clamd is an optional
arm's-length engine, so it is a Suggests. I stopped and disabled the
copy my install pulled in.
Verified on the server after fixing: service active, status reports the
engine and the gate, EICAR caught, rootkit scan clean, and the goodware
gate passes across 3,955 system binaries including the now-installed
houndd.
The gate remains OFF. Turning it on for the host that serves Caddy is a
separate decision.
295 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Phase 2. "Distro-agnostic" was a claim with no packaging behind it.
.deb and AppImage are both built and tested here; the rpm spec and
PKGBUILD are written but not yet built, since neither rpmbuild nor
makepkg exists on this machine.
The AppImage is deliberately not a packaged daemon. An AppImage is
unprivileged by design — no install, no root, no systemd — so the
execution gate cannot exist in one, and pretending otherwise would be
worse than saying so. What it is instead is the try-before-you-install
build: on-demand scanning, quarantine under the user's own data dir,
rootkit and supply-chain checks, all with nothing left behind. Asking
it for the gate prints why it cannot and how to get it. Verified with
an isolated HOME: status, scan, EICAR caught, gate refusal.
Two packaging bugs caught by looking rather than assuming:
* mktemp -d creates 0700 and dpkg applies the staging root's mode to
"/". Installing that package would have chmodded the root directory
to 0700 and broken the machine.
* AppRun wrote its log before creating the directory, and built a
socket path that can exceed sun_path (108 bytes) when
XDG_RUNTIME_DIR is long. Both fixed; the socket falls back rather
than failing with an error nobody can act on.
The systemd unit is hardened as far as this daemon can be. Notably
ProtectSystem=strict is WRONG here and is left off on purpose: it
remounts everything read-only, and quarantine has to remove a threat
from wherever it landed. ProtectHome is off for the same reason. The
CapabilityBoundingSet mirrors what caps.rs drops to, so the machine is
protected even if the binary is replaced by one that does not reduce
itself.
App icon: the mark in white on a periwinkle tile, per Joe. Small sizes
are not the same artwork scaled down — the mark is line-weight, so at
16px a 62% inset leaves about a pixel and a half of stroke and the head
turns to mush. Each size is authored with its own inset and corner
radius, and the ground goes flat below 32px because a gradient across
16 pixels is just noise. The tray ladder is untouched: those glyphs stay
transparent and re-tint per state so they can sit on any panel colour.
Package installs are NOT enabled by default beyond the daemon itself —
exec_gate stays off until the operator turns it on, in every packaging
format.
99 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>