[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. # SETUID/SETGID let the daemon fork a child, become the user who sent a scan # request, and ask the kernel whether that user could have opened the file — # which is what stops an unprivileged caller using a root scanner to probe # files it cannot read. It is not an escalation: DAC_OVERRIDE below already # grants this process every file on the system. CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID # 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 RuntimeDirectoryMode=0750 # The daemon stays uid 0 — it has to read files no user can. Its *group* is # `hound`, which is what lets it open the control socket to the desktop app # without CAP_CHOWN: a process can already chgrp a file it owns to a group it # belongs to. Membership of `hound` buys the read side of the API and nothing # that writes; the daemon checks SO_PEERCRED per method (see peer.rs). Group=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