rootkit: stop reporting every process on the machine as hidden
A clean laptop reported 988 critical rootkit findings; this server
3786, PID 1 among them. Every one was false, and the cause was our own
systemd hardening.
ProtectProc=invisible hides processes 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 hidden-process check
compares exactly those two sources, so with that setting every process
on the machine looked concealed. Enumerating processes is this daemon's
job, so it needs the default view.
Removing the setting is not enough on its own — hidepid= on the /proc
mount produces the same blindness and we do not control that. So the
detector now recognises when it cannot see:
- PID 1 is the control. It always exists and nothing hides init; a
rootkit that did would break the machine it is living on. If PID 1
answers kill(1, 0) but is absent from the listing, we are blind and
say so as info rather than crying rootkit.
- A plausibility ceiling of 32. Hiding a handful of processes is the
entire point of a rootkit; hundreds means a broken observer. An
antivirus that reports a critical rootkit finding on every clean
machine teaches people to ignore the one time it is real.
Also in this change, from testing on a real desktop:
- Closing the window hides it to the tray instead of exiting, with a
one-time notification so it does not read as a crash. Quit lives
only in the tray menu and confirms first. The settings already had
close_to_tray and confirm_quit fields wired to nothing; they are
honoured now rather than hardcoded.
- The tray menu and Scan Home sent the literal string "~". A shell
would have expanded it, nothing here did, so the daemon was asked
to scan a directory of that name. It failed silently until the
per-peer readability check made it audible.
- Administrative actions elevate through polkit instead of telling
people to open a terminal. The app tries unprivileged first and
only on a privilege refusal runs `pkexec hound admin-rpc`, which
forwards one request as root. auth_admin_keep, because prompting on
every settings toggle trains people to authenticate without reading
the prompt. This grants what `sudo hound` already grants to people
who could already run sudo — a transport, not a new privilege.
- `hound settings exec-gate on|off` now exists. The install script,
the AppImage banner, the rpm spec, the AUR install file and
llms.txt all told users 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.
A test now asserts every documented command parses.
- `settings show` displays the exec gate state, and no longer prints
its own header twice.
- The CLI help still described ClamAV, which has not been the engine
for some time. So did the socket permission error, which now
explains the `hound` group and the log-out-and-back-in it needs.
368 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3b3586b60a
commit
a3f31288fa
12 changed files with 489 additions and 35 deletions
|
|
@ -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<Value>) -> anyhow::Result<Value> {
|
||||
self.call(1, method, params)
|
||||
}
|
||||
|
||||
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<Client> {
|
|||
/// Returns the process exit code.
|
||||
fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||
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<i32> {
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,24 @@ fn hidden_processes() -> Vec<RootkitFinding> {
|
|||
"/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<RootkitFinding> {
|
|||
.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<RootkitFinding> {
|
|||
|
||||
#[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<u32> = [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]
|
||||
|
|
|
|||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
|
|
@ -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<Status, String> {
|
|||
.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<ScanResult, String> {
|
||||
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<ScanResult, String> {
|
|||
#[tauri::command]
|
||||
async fn update() -> Result<UpdateResult, String> {
|
||||
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<Settings, String> {
|
|||
.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<serde_json::Value>) -> Result<serde_json::Value, String> {
|
||||
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<Settings, String> {
|
||||
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<Vec<Event>, String> {
|
|||
#[tauri::command]
|
||||
async fn clear_events() -> Result<u64, String> {
|
||||
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<QuarantineEntry,
|
|||
#[tauri::command]
|
||||
async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> {
|
||||
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<u64, String> {
|
||||
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<RealtimeStatus, String> {
|
|||
#[tauri::command]
|
||||
async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
31
packaging/polkit/com.houndav.hound.policy
Normal file
31
packaging/polkit/com.houndav.hound.policy
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/software/polkit/policyconfig-1.dtd">
|
||||
<policyconfig>
|
||||
<vendor>Hound Antivirus</vendor>
|
||||
<vendor_url>https://houndav.com</vendor_url>
|
||||
<icon_name>hound</icon_name>
|
||||
|
||||
<!--
|
||||
The daemon refuses every method that writes unless the caller is root,
|
||||
because quarantine restores files back out as root. The desktop app runs
|
||||
as the logged-in user, so it asks polkit to authenticate the person before
|
||||
forwarding such a request.
|
||||
|
||||
auth_admin_keep, not auth_admin: re-prompting on every settings toggle
|
||||
trains people to type their password without reading the prompt, which is
|
||||
worse security than a short grace period.
|
||||
-->
|
||||
<action id="com.houndav.hound.admin">
|
||||
<description>Change Hound Antivirus settings or quarantine</description>
|
||||
<message>Authentication is required to change Hound's protection settings</message>
|
||||
<defaults>
|
||||
<allow_any>auth_admin_keep</allow_any>
|
||||
<allow_inactive>auth_admin_keep</allow_inactive>
|
||||
<allow_active>auth_admin_keep</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/hound</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue