diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 4177095..09bdabe 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -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) -> anyhow::Result { 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, diff --git a/crates/houndd/build.rs b/crates/houndd/build.rs new file mode 100644 index 0000000..44df791 --- /dev/null +++ b/crates/houndd/build.rs @@ -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 = 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"); +} diff --git a/crates/houndd/src/rules.rs b/crates/houndd/src/rules.rs index 28ae6af..84180c2 100644 --- a/crates/houndd/src/rules.rs +++ b/crates/houndd/src/rules.rs @@ -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 = 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"); diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 01cf8fd..07d3426 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 366a23a..5289347 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -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 diff --git a/packaging/aur/hound.install b/packaging/aur/hound.install new file mode 100644 index 0000000..fcd8296 --- /dev/null +++ b/packaging/aur/hound.install @@ -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 +} diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 927b2b1..5152b6c 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -64,7 +64,7 @@ Priority: optional Architecture: ${ARCH} Maintainer: Hound 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 diff --git a/packaging/rpm/hound.spec b/packaging/rpm/hound.spec index b3fd141..d7a9bd4 100644 --- a/packaging/rpm/hound.spec +++ b/packaging/rpm/hound.spec @@ -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 diff --git a/packaging/systemd/houndd.service b/packaging/systemd/houndd.service index b2d55a4..1301e7f 100644 --- a/packaging/systemd/houndd.service +++ b/packaging/systemd/houndd.service @@ -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