diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 7761eee..b15ef40 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -484,6 +484,12 @@ impl Client { } /// Connect and issue a single request. Returns the decoded `result`. + /// Send one request by name. Used by the `admin-rpc` transport, which + /// forwards whatever the desktop app could not perform unprivileged. + pub fn raw_call(&self, method: &str, params: Option) -> anyhow::Result { + self.call(1, method, params) + } + pub fn call(&self, id: u64, method: &str, params: Option) -> anyhow::Result { let mut stream = UnixStream::connect(&self.sock) .map_err(|e| { @@ -493,7 +499,14 @@ impl Client { // 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 …" + // The socket is group-readable by `hound`; being + // refused almost always means the account is not in + // that group yet, or was added and has not logged + // back in — group membership only applies to new + // login sessions. + "\n you are not in the `hound` group in this session. The installer adds\n\ + \x20 you, but it only takes effect after you log out and back in.\n\ + \x20 In the meantime: sudo hound …" } std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { "\n is the daemon running? try: sudo systemctl status houndd" diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 7cd186e..60b3c9f 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -33,9 +33,22 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Show engine status (daemon version, ClamAV, signature-DB age) + /// Show engine status (daemon version, engine, signature-DB age) Status, - /// Scan a file or directory with ClamAV + /// Forward one JSON-RPC request from stdin to the daemon (internal). + /// + /// The desktop app cannot perform administrative actions itself — the + /// daemon requires uid 0 for anything that writes. Rather than teach the + /// app to elevate each operation separately, it runs this under `pkexec`, + /// which asks polkit to authenticate the user and then runs us as root. + /// One request, one response, no interactive state. + /// + /// This grants exactly what `sudo hound` already grants, to exactly the + /// people polkit would let run `sudo` — it is a transport, not a new + /// privilege. + #[command(hide = true)] + AdminRpc, + /// Scan a file or directory Scan { /// Path to scan (file or directory) path: String, @@ -46,7 +59,7 @@ enum Cmd { #[arg(long)] json: bool, }, - /// Refresh ClamAV signature databases (freshclam) + /// Download the latest signed definition packs Update { /// Emit machine-readable JSON instead of human text #[arg(long)] @@ -152,6 +165,11 @@ enum SettingsCmd { #[arg(value_parser = ["quarantine", "alert"])] mode: String, }, + /// Refuse malicious binaries at execve, before they run (needs a restart) + ExecGate { + #[arg(value_parser = ["on", "off"])] + mode: String, + }, } #[derive(Subcommand)] @@ -383,6 +401,21 @@ fn client(sock: &Option) -> Result { /// Returns the process exit code. fn run(client: &Client, cmd: &Cmd) -> Result { match cmd { + Cmd::AdminRpc => { + use std::io::Read as _; + let mut line = String::new(); + std::io::stdin().read_to_string(&mut line)?; + let req: serde_json::Value = serde_json::from_str(line.trim()) + .map_err(|e| anyhow::anyhow!("decoding the request: {e}"))?; + let method = req + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| anyhow::anyhow!("the request has no method"))?; + let params = req.get("params").cloned(); + let value = client.raw_call(method, params)?; + println!("{}", serde_json::to_string(&value)?); + Ok(0) + } Cmd::Status => { let st = client.status()?; if st.engine_present { @@ -560,6 +593,30 @@ fn run(client: &Client, cmd: &Cmd) -> Result { s2.ransomware_threshold_per_min ); } + Some(SettingsCmd::ExecGate { mode }) => { + let mut next = s.clone(); + next.exec_gate = mode == "on"; + let s2 = client.set_settings(&next)?; + if s2.exec_gate { + println!( + "{} execution gate enabled — {} to arm it", + "✔".green().bold(), + "sudo systemctl restart houndd".yellow() + ); + println!( + " The gate needs CAP_SYS_ADMIN and covers the whole root" + ); + println!( + " filesystem. Watch it with: journalctl -u houndd -f" + ); + } else { + println!( + "{} execution gate disabled — {} to disarm it", + "✔".green().bold(), + "sudo systemctl restart houndd".yellow() + ); + } + } Some(SettingsCmd::OnDetect { mode }) => { let mut next = s.clone(); next.on_detect = mode.clone(); @@ -711,7 +768,7 @@ fn print_settings_human(s: &Settings) { "off".dimmed().to_string() } }; - println!("{} {}", "Hound settings:", "settings:".bold()); + println!("{}", "Hound settings".bold()); println!(" Recursion: {}", s.recursive_default); println!(" Max file size: {} MB", s.max_file_size_mb); println!( @@ -724,6 +781,9 @@ fn print_settings_human(s: &Settings) { s.realtime_watch.join(", ").dimmed() ); println!(" On detect: {}", s.on_detect.yellow()); + // A setting you can change but cannot see is half a feature — and this + // is the one that decides whether the machine blocks anything at all. + println!(" Exec gate: {}", on(s.exec_gate)); println!( " Ransomware guard: {} ({} writes/min)", on(s.ransomware_guard), @@ -826,4 +886,50 @@ mod tests { let res: Result<()> = Err(anyhow::anyhow!("x")); assert!(res.is_err()); } + + /// Every command we print at users must actually parse. + /// + /// The install script, the AppImage banner, the rpm spec and llms.txt all + /// told people to run `hound settings set exec_gate true`. There was no + /// `set` subcommand and no way to enable the execution gate from the CLI + /// at all — the flagship paid feature was unreachable, and the first + /// thing a new user was told to type returned an error. Nothing caught it + /// because nothing checked that documented commands exist. + #[test] + fn every_documented_command_parses() { + use clap::Parser; + for argv in [ + vec!["hound", "status"], + vec!["hound", "scan", "/tmp"], + vec!["hound", "update"], + vec!["hound", "settings", "show"], + vec!["hound", "settings", "exec-gate", "on"], + vec!["hound", "settings", "exec-gate", "off"], + vec!["hound", "settings", "realtime", "on"], + vec!["hound", "settings", "on-detect", "quarantine"], + vec!["hound", "quarantine", "list"], + vec!["hound", "supply-chain", "."], + vec!["hound", "persistence"], + vec!["hound", "rootkit"], + vec!["hound", "realtime"], + ] { + assert!( + Cli::try_parse_from(&argv).is_ok(), + "documented command does not parse: {}", + argv.join(" ") + ); + } + } + + /// The inverse. If `set` ever becomes a real subcommand this test fails + /// and whoever added it can delete the line — but until then, an + /// accidental re-introduction of the old wrong spelling gets caught. + #[test] + fn the_command_we_wrongly_documented_is_still_wrong() { + use clap::Parser; + assert!( + Cli::try_parse_from(["hound", "settings", "set", "exec_gate", "true"]).is_err(), + "if `settings set` exists now, update the docs and drop this test" + ); + } } diff --git a/crates/houndd/src/rootkit.rs b/crates/houndd/src/rootkit.rs index d51f017..f3aa31f 100644 --- a/crates/houndd/src/rootkit.rs +++ b/crates/houndd/src/rootkit.rs @@ -149,6 +149,24 @@ fn hidden_processes() -> Vec { "/proc is not readable, so hidden processes cannot be checked for".into(), )]; } + // Before trusting a comparison between /proc and the kernel, check that + // this process can actually see /proc. `ProtectProc=invisible` (and + // hidepid= on the mount) filter the listing while kill(pid, 0) keeps + // answering, so every process on the machine looks concealed. PID 1 is + // the control: it always exists, and nothing hides init — a rootkit that + // did would break the machine it is trying to live on. If we cannot see + // it, we are the ones who are blind. + if pid_exists(1) && !before.contains(&1) { + return vec![finding( + "info", + "hidden_process", + "cannot check for hidden processes: this daemon's view of /proc is \ + filtered, so it cannot see other processes. Check for ProtectProc= \ + in the systemd unit or hidepid= on the /proc mount." + .into(), + )]; + } + let max = pid_max(); let candidates = hidden_pid_candidates(&before, &proc_tids(), max); if candidates.is_empty() { @@ -164,6 +182,26 @@ fn hidden_processes() -> Vec { .filter(|p| !listed.contains(p) && pid_exists(*p)) .collect(); + // Second guard, for the blindness we could not name. A rootkit hides a + // handful of processes — that is the entire point of hiding. Hundreds of + // "hidden" processes is a broken observer, not a compromised kernel, and + // reporting it as critical trains people to ignore the one time it is + // real. + const IMPLAUSIBLE: usize = 32; + if confirmed.len() > IMPLAUSIBLE { + return vec![finding( + "info", + "hidden_process", + format!( + "cannot check for hidden processes: {} of {} PIDs appear concealed, \ + which means this daemon cannot read /proc properly rather than that \ + the machine is compromised.", + confirmed.len(), + listed.len() + ), + )]; + } + confirmed .into_iter() .map(|pid| { @@ -403,6 +441,49 @@ fn hidden_files(watch_dirs: &[String]) -> Vec { #[cfg(test)] mod tests { + /// The bug that shipped: `ProtectProc=invisible` in the systemd unit + /// filtered the daemon's /proc listing while `kill(pid, 0)` kept + /// answering truthfully, so every process on the machine looked + /// concealed. A clean laptop reported 988 critical findings and this + /// server 3786 — PID 1 among them. + #[test] + fn a_filtered_proc_is_reported_as_blindness_not_a_rootkit() { + // What the daemon saw: only its own threads. + let mine: HashSet = [4242, 4243].into_iter().collect(); + assert!( + !mine.contains(&1), + "the premise of the guard: init is missing from the listing" + ); + // Every real PID then looks hidden. + let candidates = hidden_pid_candidates(&mine, &mine, 5000); + assert!( + candidates.len() > 32, + "a filtered /proc yields implausibly many candidates, got {}", + candidates.len() + ); + } + + /// PID 1 is the control for that guard, so it had better be true. + #[test] + fn init_always_exists_and_is_always_listed() { + assert!(pid_exists(1), "PID 1 must exist"); + assert!( + proc_tids().contains(&1), + "PID 1 must appear in /proc — if this fails, the test runner's \ + view of /proc is filtered and the blindness guard is what saves us" + ); + } + + /// A real rootkit hides a few processes. Hundreds is a broken observer. + #[test] + fn the_implausible_threshold_is_above_any_real_rootkit_and_below_a_broken_proc() { + let total_pids = proc_tids().len(); + assert!( + total_pids > 32, + "this machine should be running more than 32 tasks, saw {total_pids}" + ); + } + use super::*; #[test] diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 661dd0c..00a8999 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 55db2c7..099dfd3 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -24,7 +24,19 @@ use hound_api::{ use serde_json::json; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; + +/// Whether we have already explained that closing the window is not quitting. +static TRAY_HINT_SHOWN: AtomicBool = AtomicBool::new(false); + +/// The two window-behaviour settings, cached from the daemon by the watcher +/// thread. A window-close handler must answer immediately — it cannot make a +/// socket round-trip while the user is watching the window not close — so +/// these mirror `close_to_tray` and `confirm_quit`, and default to the safe +/// answer if the daemon has not been reached yet. +static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(true); +static CONFIRM_QUIT: AtomicBool = AtomicBool::new(true); use tauri::image::Image; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager, State}; @@ -61,9 +73,28 @@ async fn status() -> Result { .map_err(|e| e.to_string()) } +/// Expand a leading `~` against the running user's home. +/// +/// The tray menu and the Scan Home button both send the literal string "~". +/// A shell would have expanded it; nothing here does, so the daemon was being +/// asked to scan a directory of that name. It failed silently until the +/// per-peer readability check started reporting refusals out loud. +fn expand_home(path: &str) -> String { + let Some(home) = std::env::var_os("HOME") else { + return path.to_string(); + }; + let home = home.to_string_lossy(); + match path { + "~" => home.into_owned(), + p if p.starts_with("~/") => format!("{home}/{}", &p[2..]), + p => p.to_string(), + } +} + #[tauri::command] async fn scan(path: String, recursive: bool) -> Result { let c = client(); + let path = expand_home(&path); SCANNING.store(true, Ordering::Relaxed); let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive)) .await @@ -75,10 +106,18 @@ async fn scan(path: String, recursive: bool) -> Result { #[tauri::command] async fn update() -> Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.update()) + let first = tauri::async_runtime::spawn_blocking(move || c.update()) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("update", None).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } #[tauri::command] @@ -90,13 +129,87 @@ async fn settings() -> Result { .map_err(|e| e.to_string()) } + +// ── Elevation ─────────────────────────────────────────────────────────────── + +/// Run one daemon method as root, via polkit. +/// +/// The daemon refuses everything that writes unless the caller is uid 0 — the +/// app runs as you, so those requests need elevating. `pkexec` asks polkit to +/// authenticate the person (password, or fingerprint on hardware that has +/// one) and then runs `hound admin-rpc` as root, which forwards a single +/// request and prints the response. See packaging/polkit/. +/// +/// pkexec exits 126 when the user dismisses the dialog and 127 when +/// authorisation is refused; both are ordinary outcomes, not errors to +/// apologise for. +fn elevate(method: &str, params: Option) -> Result { + use std::io::Write as _; + use std::process::{Command, Stdio}; + + let mut req = json!({ "jsonrpc": "2.0", "id": 1, "method": method }); + if let Some(p) = params { + req["params"] = p; + } + + let mut child = Command::new("pkexec") + .arg("/usr/bin/hound") + .arg("admin-rpc") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("cannot start pkexec: {e}"))?; + child + .stdin + .as_mut() + .ok_or("pkexec has no stdin")? + .write_all(req.to_string().as_bytes()) + .map_err(|e| format!("sending the request: {e}"))?; + + let out = child + .wait_with_output() + .map_err(|e| format!("waiting for pkexec: {e}"))?; + match out.status.code() { + Some(0) => serde_json::from_slice(&out.stdout) + .map_err(|e| format!("decoding the response: {e}")), + Some(126) => Err("Cancelled.".into()), + Some(127) => Err("Not authorised to make this change.".into()), + _ => { + let err = String::from_utf8_lossy(&out.stderr); + let err = err.trim(); + Err(if err.is_empty() { + "The privileged helper failed.".into() + } else { + err.to_string() + }) + } + } +} + +/// True when the daemon refused us for want of privileges, as opposed to +/// anything else going wrong. Only that case is worth a password prompt. +fn needs_root(e: &str) -> bool { + e.contains("requires administrator privileges") +} + #[tauri::command] async fn set_settings(s: Settings) -> Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.set_settings(&s)) + let for_retry = s.clone(); + let first = tauri::async_runtime::spawn_blocking(move || c.set_settings(&s)) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("settings.set", Some(serde_json::to_value(&for_retry).unwrap_or_default())) + .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } #[tauri::command] @@ -111,10 +224,19 @@ async fn events(limit: u32) -> Result, String> { #[tauri::command] async fn clear_events() -> Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.clear_events()) + let first = tauri::async_runtime::spawn_blocking(move || c.clear_events()) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("events.clear", None) + .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } #[tauri::command] @@ -138,19 +260,39 @@ async fn quarantine_add(path: String, virus: String) -> Result Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id)) + let for_retry = id.clone(); + let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id)) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("quarantine.restore", Some(json!({ "id": for_retry }))) + .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } #[tauri::command] async fn quarantine_remove(id: String) -> Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id)) + let for_retry = id.clone(); + let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id)) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("quarantine.remove", Some(json!({ "id": for_retry }))) + .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } #[tauri::command] @@ -174,10 +316,19 @@ async fn realtime_status() -> Result { #[tauri::command] async fn realtime_set_enabled(enabled: bool) -> Result { let c = client(); - tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled)) + let first = tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled)) .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + match first { + Ok(v) => Ok(v), + Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || { + elevate("realtime.set_enabled", Some(json!({ "enabled": enabled }))) + .and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?, + Err(e) => Err(e.to_string()), + } } /// Swap the tray icon + tooltip. The window calls this to keep the sentinel @@ -237,10 +388,14 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) { if c.status().is_err() { continue; // daemon restarting — skip the tick } - let paused = c - .settings() - .map(|s| s.paused) - .unwrap_or(false); + let paused = match c.settings() { + Ok(s) => { + CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed); + CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed); + s.paused + } + Err(_) => false, + }; // Derive tray state: a fresh critical event wins, then an // in-flight scan, then paused, then protected. let mut state = "protected"; @@ -375,7 +530,36 @@ pub fn run() { let _ = window.show(); let _ = window.set_focus(); } - "quit" => app.exit(0), + "quit" => { + // The only route out of the app, and it asks + // first: closing the window hides it instead, so + // a user who means to quit has come here on + // purpose and a user who clicked by accident has + // not lost their protection silently. + if !CONFIRM_QUIT.load(Ordering::Relaxed) { + app.exit(0); + return; + } + let app = app.clone(); + std::thread::spawn(move || { + let quit = app + .dialog() + .message( + "Real-time protection will keep running in the \ + background, but Hound's window and tray icon will \ + close.", + ) + .title("Quit Hound?") + .buttons(MessageDialogButtons::OkCancelCustom( + "Quit".into(), + "Keep running".into(), + )) + .blocking_show(); + if quit { + app.exit(0); + } + }); + } "scan-home" => { let _ = window.emit("tray-event", json!({ "action": "scan", "path": "~" })); @@ -407,6 +591,31 @@ pub fn run() { Ok(()) }) + .on_window_event(|window, event| { + // Closing the window hides it; it never exits. An antivirus that + // stops protecting you because you clicked the X is not an + // antivirus. Quitting is deliberate, from the tray menu, and + // confirmed there. + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if !CLOSE_TO_TRAY.load(Ordering::Relaxed) { + return; // the user turned this off in Settings + } + api.prevent_close(); + let _ = window.hide(); + // Say so once, so the window vanishing does not read as a + // crash. After that it is expected behaviour and silence is + // the right amount of noise. + if !TRAY_HINT_SHOWN.swap(true, Ordering::Relaxed) { + let _ = window + .app_handle() + .notification() + .builder() + .title("Hound is still running") + .body("Protection continues in the background. Open it again from the tray icon.") + .show(); + } + } + }) .run(tauri::generate_context!()) .expect("error while running Hound"); } diff --git a/packaging/aur/hound.install b/packaging/aur/hound.install index fcd8296..91cc15e 100644 --- a/packaging/aur/hound.install +++ b/packaging/aur/hound.install @@ -9,7 +9,7 @@ Hound is installed and scanning on demand. Real-time execution blocking is OFF until you turn it on: sudo systemctl enable --now houndd - sudo hound settings set exec_gate true + sudo hound settings exec-gate on MSG } diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh index 11085e6..b250a36 100755 --- a/packaging/build-appimage.sh +++ b/packaging/build-appimage.sh @@ -117,7 +117,7 @@ checks and supply-chain checks. For real-time protection, install the package: sudo apt install ./hound_*.deb - sudo hound settings set exec_gate true + sudo hound settings exec-gate on MSG exit 2 ;; diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 39a4714..8252f3e 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -55,6 +55,11 @@ install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd" install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound" install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp" [ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui" + +# Lets the desktop app elevate a single daemon request through polkit rather +# than asking people to open a terminal for every settings change. +install -Dm644 "$ROOT/packaging/polkit/com.houndav.hound.policy" \ + "$STAGE/usr/share/polkit-1/actions/com.houndav.hound.policy" install -Dm644 "$ROOT/packaging/systemd/houndd.service" \ "$STAGE/lib/systemd/system/houndd.service" install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \ @@ -116,7 +121,7 @@ Description: Hound Antivirus for Linux . The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and covers the whole root filesystem, so turning it on is the operator's - decision: hound settings set exec_gate true + decision: hound settings exec-gate on CONTROL cat > "$STAGE/DEBIAN/conffiles" <<'CONFFILES' @@ -216,7 +221,7 @@ case "$1" in echo "" echo "Real-time execution blocking is OFF until you turn it on:" echo "" - echo " sudo hound settings set exec_gate true" + echo " sudo hound settings exec-gate on" echo "" echo "To let a coding assistant check repositories before trusting them," echo "add this to its MCP configuration:" diff --git a/packaging/polkit/com.houndav.hound.policy b/packaging/polkit/com.houndav.hound.policy new file mode 100644 index 0000000..5d1dba3 --- /dev/null +++ b/packaging/polkit/com.houndav.hound.policy @@ -0,0 +1,31 @@ + + + + Hound Antivirus + https://houndav.com + hound + + + + Change Hound Antivirus settings or quarantine + Authentication is required to change Hound's protection settings + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + /usr/bin/hound + true + + diff --git a/packaging/rpm/hound.spec b/packaging/rpm/hound.spec index d7a9bd4..20b3463 100644 --- a/packaging/rpm/hound.spec +++ b/packaging/rpm/hound.spec @@ -17,7 +17,7 @@ it has already run. The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and covers the whole root filesystem, so enabling it is the operator's call: -hound settings set exec_gate true +hound settings exec-gate on %prep %autosetup diff --git a/packaging/systemd/houndd.service b/packaging/systemd/houndd.service index dc987ca..b90b222 100644 --- a/packaging/systemd/houndd.service +++ b/packaging/systemd/houndd.service @@ -66,7 +66,16 @@ ProtectKernelLogs=yes ProtectControlGroups=yes ProtectClock=yes ProtectHostname=yes -ProtectProc=invisible +# ProtectProc is deliberately NOT set, and this one bit hard. `invisible` +# hides every process the daemon does not own from its view of /proc — while +# kill(pid, 0) keeps answering truthfully, because it is a syscall and not a +# filesystem lookup. The rootkit check compares those two sources and reports +# anything present in one but not the other. With ProtectProc=invisible that +# is EVERY process on the machine: a clean laptop reported 988 hidden +# processes, and this server 3786, PID 1 among them. An antivirus that +# announces a rootkit on every clean machine is worse than one that does not +# look. Enumerating processes is this daemon's job, so it needs the default +# view; the detector also now refuses to report when it can tell it is blind. RestrictNamespaces=yes RestrictRealtime=yes RestrictSUIDSGID=yes diff --git a/site/llms.txt b/site/llms.txt index d7103b7..d07b2b4 100644 --- a/site/llms.txt +++ b/site/llms.txt @@ -92,7 +92,7 @@ chmod +x hound && ./hound status The execution gate ships **switched off**. It needs `CAP_SYS_ADMIN` and covers the whole root filesystem, so enabling it is the operator's decision: -`sudo hound settings set exec_gate true`. +`sudo hound settings exec-gate on`. ## Pricing