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>
This commit is contained in:
Hound 2026-08-21 07:43:03 -05:00
parent 42cd97d59f
commit 432e2b825e
9 changed files with 190 additions and 16 deletions

View file

@ -14,14 +14,31 @@ use std::os::unix::net::UnixStream;
use std::os::windows::net::UnixStream;
/// The default socket path when `$HOUNDD_SOCK` is unset.
///
/// A system daemon and a per-user one live in different places, and the
/// CLI has to find whichever is actually running. On the first packaged
/// install the daemon bound `/run/hound/houndd.sock` (systemd's
/// `RuntimeDirectory=`) while `hound status` looked in
/// `$XDG_RUNTIME_DIR` and reported the daemon unreachable — technically
/// true and completely unhelpful.
///
/// `/run/hound` exists only when systemd created it for the service, so
/// its presence is a reliable signal that the system daemon is the one to
/// talk to.
pub fn default_socket_path() -> String {
if let Ok(sock) = std::env::var("HOUNDD_SOCK") {
return sock;
}
if std::path::Path::new(SYSTEM_RUNTIME_DIR).is_dir() {
return format!("{SYSTEM_RUNTIME_DIR}/houndd.sock");
}
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
format!("{runtime}/houndd.sock")
}
/// Created by systemd's `RuntimeDirectory=hound` for the packaged service.
pub const SYSTEM_RUNTIME_DIR: &str = "/run/hound";
// ── Request / Response envelopes ────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -452,7 +469,22 @@ impl Client {
/// Connect and issue a single request. Returns the decoded `result`.
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
let mut stream = UnixStream::connect(&self.sock)
.map_err(|e| anyhow::anyhow!("cannot reach houndd at {}: {e}", self.sock))?;
.map_err(|e| {
// "Permission denied" on a root-owned socket is technically
// accurate and useless. The system daemon's socket is
// root-only on purpose — anything that can reach it can
// quarantine files — so the answer is almost always sudo.
let hint = match e.kind() {
std::io::ErrorKind::PermissionDenied => {
"\n the system daemon's socket is root-only — try: sudo hound …"
}
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
"\n is the daemon running? try: sudo systemctl status houndd"
}
_ => "",
};
anyhow::anyhow!("cannot reach houndd at {}: {e}{hint}", self.sock)
})?;
let req = Request {
jsonrpc: "2.0".into(),
id,

34
crates/houndd/build.rs Normal file
View file

@ -0,0 +1,34 @@
//! Obfuscate the embedded rule pack at build time.
//!
//! An antivirus that ships its signatures as literal strings inside its
//! own binary detects itself. Hound's built-in pack matches on
//! "stratum+tcp://", "donate-level", "xmrig", "RTLD_NEXT" and
//! "ld.so.preload"; with the pack embedded verbatim, `/usr/bin/houndd`
//! matched Linux.Coinminer.XMRig and Linux.Rootkit.Preload. The goodware
//! gate caught it on the first packaged install.
//!
//! That is 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.
//!
//! A single-byte XOR is enough. This is not a secret: the rules are open
//! source and anybody can read them in the repository. The only job is to
//! stop the literal bytes appearing in the executable, and a trivial
//! transform does that as well as an elaborate one would.
use std::io::Write;
/// Chosen only so the transform is not the identity function.
const MASK: u8 = 0x5A;
fn main() {
let src = "rules/hound-builtin.yar";
println!("cargo:rerun-if-changed={src}");
let plain = std::fs::read(src).expect("reading the built-in rule pack");
let masked: Vec<u8> = plain.iter().map(|b| b ^ MASK).collect();
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"))
.join("hound-builtin.yar.masked");
let mut f = std::fs::File::create(&out).expect("creating the masked pack");
f.write_all(&masked).expect("writing the masked pack");
}

View file

@ -26,8 +26,24 @@ use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
/// The starter pack, baked into the binary.
const BUILTIN: &str = include_str!("../rules/hound-builtin.yar");
/// The starter pack, baked into the binary — masked so its own strings do
/// not appear literally in the executable.
///
/// See `build.rs`. Without this, Hound matches itself: the pack detects
/// miners on "stratum+tcp://" and "xmrig", the binary contains those
/// bytes, and the goodware gate reported `/usr/bin/houndd` as a
/// coinminer. With the execution gate armed that becomes a daemon that
/// refuses to run itself.
const BUILTIN_MASKED: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/hound-builtin.yar.masked"));
/// Must match `build.rs`.
const MASK: u8 = 0x5A;
/// Recover the built-in pack.
fn builtin() -> String {
let plain: Vec<u8> = BUILTIN_MASKED.iter().map(|b| b ^ MASK).collect();
String::from_utf8(plain).expect("the built-in pack is valid UTF-8")
}
/// A compiled ruleset plus the provenance a client needs to display it.
pub struct RuleSet {
@ -52,8 +68,9 @@ impl RuleSet {
let mut compiler = yara_x::Compiler::new();
let mut sources = Vec::new();
let builtin_src = builtin();
compiler
.add_source(yara_x::SourceCode::from(BUILTIN).with_origin("hound-builtin.yar"))
.add_source(yara_x::SourceCode::from(builtin_src.as_str()).with_origin("hound-builtin.yar"))
.map_err(|e| anyhow::anyhow!("built-in rules failed to compile: {e}"))?;
sources.push("hound-builtin.yar (embedded)".to_string());
@ -179,6 +196,61 @@ impl RuleStore {
mod tests {
use super::*;
/// Hound must not be detectable by Hound.
///
/// The regression: the pack was embedded verbatim, so the daemon's own
/// binary contained "stratum+tcp://" and "ld.so.preload" and matched
/// its own miner and rootkit rules. With the execution gate armed that
/// is a daemon that refuses to run itself.
#[test]
fn the_embedded_pack_holds_no_plaintext_rule_strings() {
// Asserted against the embedded blob rather than against
// current_exe(): in a test run that IS the test harness, which
// legitimately contains these strings as fixtures. The blob is
// what ends up in the shipped daemon, and it is what build.rs
// guarantees.
let haystack = String::from_utf8_lossy(BUILTIN_MASKED);
for needle in [
"stratum+tcp://",
"donate-level",
"ld.so.preload",
"RTLD_NEXT",
"EICAR-STANDARD-ANTIVIRUS-TEST-FILE",
] {
assert!(
!haystack.contains(needle),
"{needle:?} survives into the embedded pack — Hound will detect itself"
);
}
}
/// Belt and braces: if a built daemon is sitting in target/, check the
/// real artefact too. Skipped when there is not one.
#[test]
fn a_built_daemon_binary_does_not_detect_itself() {
let mut checked = 0;
for candidate in ["target/release/houndd", "target/debug/houndd"] {
let Ok(bytes) = std::fs::read(candidate) else { continue };
checked += 1;
let haystack = String::from_utf8_lossy(&bytes);
for needle in ["stratum+tcp://", "ld.so.preload", "RTLD_NEXT"] {
assert!(
!haystack.contains(needle),
"{needle:?} appears literally in {candidate}"
);
}
}
let _ = checked; // a fresh checkout has neither; that is fine
}
#[test]
fn the_masked_pack_recovers_exactly() {
let recovered = builtin();
assert!(recovered.starts_with("/*"), "the pack should begin with its header comment");
assert!(recovered.contains("rule EICAR_Test_File"));
assert!(recovered.contains("Linux_Coinminer_XMRig"));
}
#[test]
fn builtin_pack_compiles() {
let set = RuleSet::compile().expect("built-in pack must always compile");

Binary file not shown.

View file

@ -7,7 +7,8 @@ arch=('x86_64' 'aarch64')
url="https://houndav.com"
license=('Apache-2.0')
depends=('systemd-libs')
optdepends=('clamav: the Windows-malware corpus, for the file-server carrier case')
optdepends=('clamav: the Windows-malware corpus, for the file-server carrier case'
'clamav-daemon: needed only if you enable the optional clamd engine')
makedepends=('rust>=1.91' 'cargo')
backup=('etc/hound/hound.toml')
install=hound.install

View file

@ -0,0 +1,19 @@
post_install() {
cat <<'MSG'
Hound is installed and scanning on demand.
hound status what the daemon sees
hound scan ~/Downloads scan a directory
Real-time execution blocking is OFF until you turn it on:
sudo systemctl enable --now houndd
sudo hound settings set exec_gate true
MSG
}
post_upgrade() {
post_install
}

View file

@ -64,7 +64,7 @@ Priority: optional
Architecture: ${ARCH}
Maintainer: Hound <support@houndav.com>
Depends: libc6 (>= 2.34)
Recommends: clamav-daemon
Suggests: clamav-daemon
Homepage: https://houndav.com
Description: Hound Antivirus for Linux
Endpoint and supply-chain protection built for the distributions people
@ -118,11 +118,10 @@ case "$1" in
chmod 0755 /var/lib/hound /var/lib/hound/rules
chmod 0750 /var/log/hound
# Seed the built-in rules where the daemon looks for packs, so an
# offline install still detects something.
if [ -f /usr/share/hound/rules/hound-builtin.yar ]; then
cp -n /usr/share/hound/rules/hound-builtin.yar /var/lib/hound/rules/ || true
fi
# The built-in rules are compiled INTO the binary; /var/lib/hound/rules
# is for additional packs only. Copying the built-ins there made the
# daemon compile them twice and log a duplicate-declaration error on
# every start. The copy under /usr/share is documentation, not input.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true

View file

@ -7,7 +7,7 @@ URL: https://houndav.com
Source0: %{name}-%{version}.tar.gz
BuildRequires: rust >= 1.91, cargo, systemd-rpm-macros
Requires: systemd
Recommends: clamd
Suggests: clamd
%description
Endpoint and supply-chain protection built for the distributions people
@ -42,9 +42,8 @@ install -Dm644 assets/icons/hound-app.svg \
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/hound.svg
%post
# Seed the built-in rules so an offline install still detects something.
cp -n %{_datadir}/hound/rules/hound-builtin.yar \
%{_sharedstatedir}/hound/rules/ 2>/dev/null || :
# The built-in rules are compiled into the binary; %{_sharedstatedir}/hound/rules
# is for additional packs only. Seeding it there compiles them twice.
%systemd_post houndd.service
%preun

View file

@ -8,6 +8,8 @@ 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
@ -52,7 +54,23 @@ RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=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