Antivirus/packaging/systemd/houndd.service
Hound 432e2b825e packaging: four bugs that only a real install could find
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>
2026-08-21 07:43:03 -05:00

88 lines
3.7 KiB
Desktop File

[Unit]
Description=Hound Antivirus daemon
Documentation=https://houndav.com/docs
After=network.target
# The gate is only useful while the filesystem it watches is mounted.
RequiresMountsFor=/var/lib/hound
[Service]
Type=exec
ExecStart=/usr/bin/houndd
# Stated explicitly so the daemon and the CLI cannot drift apart.
Environment=HOUNDD_SOCK=/run/hound/houndd.sock
Restart=on-failure
RestartSec=2s
# ── Privilege ────────────────────────────────────────────────────────
#
# houndd reduces its own capabilities at startup (see crates/houndd/src/
# caps.rs). This bounding set is the second half of that: it protects the
# machine if the binary is ever replaced by one that does not, and it
# means the daemon never *holds* the rest even momentarily.
#
# It cannot be narrower. fanotify needs SYS_ADMIN; scanning arbitrary
# files needs DAC_READ_SEARCH; quarantining out of a directory owned by
# someone else needs DAC_OVERRIDE; stripping the execute bit off a file
# we do not own needs FOWNER.
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER
AmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER
NoNewPrivileges=yes
# ── Filesystem ───────────────────────────────────────────────────────
#
# ProtectSystem=strict would be the obvious choice and is WRONG here: it
# remounts everything read-only, and quarantine has to remove a threat
# from wherever it landed. ProtectHome is off for the same reason — most
# of what we quarantine is under /home.
ProtectSystem=false
ProtectHome=false
ReadWritePaths=/var/lib/hound /var/log/hound
StateDirectory=hound
LogsDirectory=hound
RuntimeDirectory=hound
PrivateTmp=no
# ── Everything else we can shut off ──────────────────────────────────
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# MemoryDenyWriteExecute is deliberately NOT set, and this is a real
# trade-off rather than an oversight.
#
# yara-x compiles rules to WebAssembly and JITs them, so it needs pages
# that are writable and then executable. With W^X enforced the daemon
# aborts at startup with "unable to make memory executable" — which is
# exactly what happened on the first real install, after the unit had
# passed systemd-analyze verify. 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: the scanner never executes scanned content, the
# capability set is four of forty-one, the syscall filter below blocks
# @module/@mount/@raw-io/@reboot, and the process cannot gain privileges.
# Revisit if yara-x ever ships an interpreter-only mode.
# MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@clock @cpu-emulation @debug @module @mount @obsolete @raw-io @reboot @swap
UMask=0077
# ── Resources ────────────────────────────────────────────────────────
# A scanner that eats the machine is its own denial of service.
MemoryMax=1G
TasksMax=64
LimitNOFILE=65536
OOMScoreAdjust=-500
[Install]
WantedBy=multi-user.target