Armed the execution gate on the live server for the first time. It
reported itself armed on a dedicated tmpfs, and then let EICAR execute.
Counters: 0 allowed, 0 blocked. Not one event was ever delivered.
Cause: systemd gives the service a PRIVATE MOUNT NAMESPACE. Several
perfectly ordinary hardening options force one — ProtectProc,
ProtectKernelTunables, ProtectControlGroups — and none of them mention
it. FAN_MARK_MOUNT marks a vfsmount, and a private namespace holds its
own vfsmount for the same filesystem. So the daemon marked its copy,
every other process on the machine used the host's copy, and the gate
protected nothing while claiming to be armed.
That is the worst way for a security feature to fail: silently, with a
reassuring status line. Nothing in the unit tests could have caught it —
they run in the host namespace, where the mount mark works.
Fixed by always using FAN_MARK_FILESYSTEM, which marks the SUPERBLOCK.
A superblock is shared across namespaces, so events arrive from
everywhere, and scoping still works because a superblock is exactly one
filesystem: marking a dedicated mount covers that mount and nothing
else. mark_mount is kept for the smoke-test example, which runs outside
systemd, with a doc comment about when it lies to you.
Two more that only appeared once the gate was actually armed:
* SystemCallFilter=@system-service kills the daemon with SIGSYS the
moment the gate is switched on. fanotify_init and fanotify_mark live
in @privileged, which @system-service deliberately excludes. Granted
individually rather than by adding @privileged, which would also admit
setuid, chroot, bpf and kexec_load. Invisible until armed — the
service starts fine with the gate off.
* The capability reduction reported "60 capabilities could not be
dropped" while the end state was perfectly correct. systemd's
CapabilityBoundingSet had already done the work, and the service does
not hold CAP_SETPCAP afterwards, so every redundant drop failed EPERM.
It now checks what is actually present, attempts only that, and judges
by the end state rather than by return codes.
Also removed AmbientCapabilities from the unit. Ambient capabilities are
inherited by children, the daemon shells out to freshclam/rpm/pacman on
some paths, and a root process already receives the bounding set as
permitted — so it bought nothing except a way for CAP_SYS_ADMIN to leak
into a subprocess.
Performance, measured on the live server rather than guessed at:
+2.70 ms/exec as first written
+1.47 ms/exec after the reader blocked on poll() instead of sleeping
a millisecond between empty reads — that sleep sat on
the critical path of every execve
+1.38 ms/exec after answering cache hits in the reader thread, with
no channel handoff or worker wakeup
2,680 execs/sec sustained through the gate, 16-way parallel, with
ZERO watchdog rescues — the queue never fell behind. Ungated is 7,455.
Caddy stayed at sub-millisecond throughout and load did not rise.
Joe and Henry are right that the exec-heavy paths on this box — Docker
overlays, agent workspaces, PM2 — are the performance bar rather than an
exclusion list. Protecting agent workspaces from injected payloads is
the product. 2,680/sec with no backlog is roughly ten times what this
machine generates, so the bar looks clearable; stage 2 will say for sure.
295 tests pass, and the three-phase gate smoke test still passes
including the fail-open case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.5 KiB
Desktop File
103 lines
4.5 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 is deliberately NOT set. Ambient capabilities are
|
|
# inherited by child processes, and the daemon shells out to freshclam,
|
|
# rpm and pacman on some paths — none of which should start life holding
|
|
# CAP_SYS_ADMIN. A process running as root already receives everything in
|
|
# the bounding set as permitted and effective, so Ambient adds nothing
|
|
# here except a way for it to leak.
|
|
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
|
|
# fanotify_init and fanotify_mark live in @privileged, which
|
|
# @system-service deliberately excludes — so the base filter kills the
|
|
# daemon with SIGSYS the moment the execution gate is switched on. This
|
|
# was invisible until the gate was armed for the first time on a real
|
|
# install: the service starts fine with the gate off.
|
|
#
|
|
# Granted individually rather than by adding @privileged, which would
|
|
# also admit setuid, chroot, bpf, kexec_load and pivot_root. Two
|
|
# syscalls is the whole requirement.
|
|
SystemCallFilter=fanotify_init fanotify_mark
|
|
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
|