diff --git a/Cargo.lock b/Cargo.lock index 6f8af31..ea5d3e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "hound" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "hound-api" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "serde", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "hound-defs" -version = "0.1.4" +version = "0.1.5" dependencies = [ "ed25519-dalek", "serde", @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "hound-mcp" -version = "0.1.4" +version = "0.1.5" dependencies = [ "hound-api", "hound-supply", @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "hound-supply" -version = "0.1.4" +version = "0.1.5" dependencies = [ "hound-defs", "serde", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "houndd" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index ee66e19..5eb4b44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.4" +version = "0.1.5" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus.git" diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 5e20ad3..83ae14b 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -35,6 +35,12 @@ struct Cli { enum Cmd { /// Show engine status (daemon version, engine, signature-DB age) Status, + /// Report what Hound currently cannot see + Selfcheck { + /// Emit machine-readable JSON instead of human text + #[arg(long)] + json: bool, + }, /// Forward one JSON-RPC request from stdin to the daemon (internal). /// /// The desktop app cannot perform administrative actions itself — the @@ -410,6 +416,39 @@ fn client(sock: &Option) -> Result { /// Returns the process exit code. fn run(client: &Client, cmd: &Cmd) -> Result { match cmd { + Cmd::Selfcheck { json } => { + let v = client.raw_call("selfcheck", None)?; + if *json { + println!("{}", serde_json::to_string_pretty(&v)?); + } else { + let checks = v.get("checks").and_then(|c| c.as_array()).cloned().unwrap_or_default(); + for c in &checks { + let state = c.get("state").and_then(|x| x.as_str()).unwrap_or("?"); + let id = c.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let detail = c.get("detail").and_then(|x| x.as_str()).unwrap_or(""); + let mark = match state { + "ok" => "✔".green().bold().to_string(), + "degraded" => "!".yellow().bold().to_string(), + _ => "✘".red().bold().to_string(), + }; + println!("{mark} {:<16} {detail}", id.dimmed()); + } + let blind = v.get("blind").and_then(|x| x.as_u64()).unwrap_or(0); + let degraded = v.get("degraded").and_then(|x| x.as_u64()).unwrap_or(0); + println!(); + if blind == 0 && degraded == 0 { + println!("{} Hound can see everything it checks for", "✔".green().bold()); + } else { + println!( + "{} {blind} blind spot(s), {degraded} degraded — the results above are \ + what Hound cannot currently tell you", + "!".yellow().bold() + ); + } + } + let blind = v.get("blind").and_then(|x| x.as_u64()).unwrap_or(0); + Ok(if blind > 0 { 1 } else { 0 }) + } Cmd::AdminRpc => { use std::io::Read as _; let mut line = String::new(); @@ -865,9 +904,100 @@ fn install_app_update(version: &str, deb_url: &str, sha256: &str, assume_yes: bo anyhow::bail!("the package manager refused the update (staged at {})", path.display()); } let _ = std::fs::remove_file(&path); + restart_desktop_apps(); Ok(true) } +/// Restart any running desktop app so it picks up the new binary. +/// +/// The app can notice its own package being replaced and reopen itself, but +/// only from the version that learned how — updating *from* an older one +/// leaves the old process running the old front-end, which looks exactly like +/// an update that did nothing. Doing it here works regardless of which +/// version was running. +/// +/// We are root and the app is not, so each process is relaunched as its own +/// owner, with the session environment it was already using. Guessing DISPLAY +/// would break on Wayland, a second seat, or a non-standard bus address; the +/// running process already knows the right answer, so take it from there. +fn restart_desktop_apps() { + use std::os::unix::process::CommandExt as _; + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; + }; + let base = entry.path(); + // Only our own GUI, matched on the executable rather than a command + // line anyone could imitate. + match std::fs::read_link(base.join("exe")) { + Ok(exe) if exe.file_name().map(|n| n == "hound-gui").unwrap_or(false) => {} + _ => continue, + } + let Ok(environ) = std::fs::read(base.join("environ")) else { + continue; + }; + let session: Vec<(String, String)> = environ + .split(|b| *b == 0) + .filter_map(|kv| std::str::from_utf8(kv).ok()) + .filter_map(|kv| kv.split_once('=')) + .filter(|(k, _)| { + matches!( + *k, + "DISPLAY" + | "WAYLAND_DISPLAY" + | "XAUTHORITY" + | "DBUS_SESSION_BUS_ADDRESS" + | "XDG_RUNTIME_DIR" + | "XDG_SESSION_TYPE" + | "HOME" + | "PATH" + ) + }) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + // Who owns it — we must not relaunch someone's desktop app as root. + let Ok(status) = std::fs::read_to_string(base.join("status")) else { + continue; + }; + let Some((uid, gid)) = status.lines().find_map(|l| { + let real = l.strip_prefix("Uid:")?.split_whitespace().next()?.parse::().ok()?; + Some((real, 0u32)) + }) else { + continue; + }; + let gid = status + .lines() + .find_map(|l| l.strip_prefix("Gid:")?.split_whitespace().next()?.parse::().ok()) + .unwrap_or(gid); + if uid == 0 { + continue; // not a desktop session we should be resurrecting + } + + // SAFETY: kill with SIGTERM asks the process to exit; it cannot + // corrupt anything, and the app holds no unsaved state — the window + // is a view over the daemon. + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + + let mut cmd = std::process::Command::new("/usr/bin/hound-gui"); + cmd.env_clear().envs(session).uid(uid).gid(gid); + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // Give the old process a moment to release the tray icon, or the + // panel can end up showing two. + std::thread::sleep(std::time::Duration::from_millis(600)); + match cmd.spawn() { + Ok(_) => println!(" reopened the Hound window for uid {uid}"), + Err(e) => eprintln!(" could not reopen the Hound window: {e}"), + } + } +} + fn print_update_human(u: &UpdateResult) { if u.ok { println!( @@ -1064,6 +1194,7 @@ mod tests { use clap::Parser; for argv in [ vec!["hound", "status"], + vec!["hound", "selfcheck"], vec!["hound", "scan", "/tmp"], vec!["hound", "update"], vec!["hound", "update", "--check"], diff --git a/crates/houndd/src/engine.rs b/crates/houndd/src/engine.rs index fb31e8c..54b1956 100644 --- a/crates/houndd/src/engine.rs +++ b/crates/houndd/src/engine.rs @@ -32,6 +32,15 @@ pub trait ScanEngine: Send + Sync { /// "engine online/offline" signal. fn probe(&self) -> (bool, String, Option); + /// How many detection rules are actually compiled and live. + /// + /// The self-check needs this to tell "scanned and found nothing" apart + /// from "scanned with no rules loaded", which look identical from the + /// outside and mean opposite things. + fn rule_count(&self) -> usize { + 0 + } + /// Scan `path` (canonicalize first) and return per-file findings. fn scan(&self, path: &str, recursive: bool) -> Result; diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index e8b393c..e6b6208 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -53,6 +53,7 @@ mod quarantine; mod realtime; mod release; mod rootkit; +mod selfcheck; mod rules; mod settings; mod update; @@ -107,6 +108,18 @@ fn main() -> Result<()> { open_socket_to_hound_group(&sock_path); let state = DaemonState::boot(); + + // Announce any blindness at startup rather than letting it be inferred + // from wrong answers later. Every serious bug found in desktop testing + // was Hound reporting success it had not achieved. + let sc = selfcheck::run(&state.defs, engine::engine().rule_count()); + for c in sc.checks.iter().filter(|c| c.state != "ok") { + eprintln!("selfcheck [{}]: {} — {}", c.state, c.id, c.detail); + } + if sc.healthy() { + eprintln!("selfcheck: {} check(s) passed", sc.ok); + } + start_scheduler(state.clone()); eprintln!( @@ -535,6 +548,10 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { // A fresh, verified look at the release manifest. The scheduler // checks daily; someone who typed `hound update` is asking now, and // "your daily check has not run yet" is not an answer. + "selfcheck" => Ok(serde_json::to_value(selfcheck::run( + &st.defs, + engine::engine().rule_count(), + ))?), "release.check" => { let keys = defs::trusted_keys(); let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> = @@ -859,7 +876,7 @@ static KNOWN_RELEASE: std::sync::Mutex> = std::sync::Mu /// Returns None rather than 0 when the version cannot be parsed. A zero would /// read as "published today", which is the reassuring answer, and guessing /// reassuringly is how a security product ends up lying. -fn defs_age_days(version: &str) -> Option { +pub(crate) fn defs_age_days(version: &str) -> Option { let mut parts = version.split(['.', '-']); let y: i32 = parts.next()?.parse().ok()?; let m: u8 = parts.next()?.parse().ok()?; diff --git a/crates/houndd/src/native.rs b/crates/houndd/src/native.rs index c28d737..b17f381 100644 --- a/crates/houndd/src/native.rs +++ b/crates/houndd/src/native.rs @@ -89,6 +89,10 @@ impl ScanEngine for HoundEngine { "hound" } + fn rule_count(&self) -> usize { + self.rules.current().count + } + fn probe(&self) -> (bool, String, Option) { let set = self.rules.current(); let summary = format!( diff --git a/crates/houndd/src/peer.rs b/crates/houndd/src/peer.rs index b1dd6f9..10e7b80 100644 --- a/crates/houndd/src/peer.rs +++ b/crates/houndd/src/peer.rs @@ -109,7 +109,10 @@ pub fn access_for(method: &str) -> Access { | "persistence.scan" // Asking whether a newer version exists changes nothing, and reveals // nothing the website does not already say. - | "release.check" => Access::Read, + | "release.check" + // Reports on this daemon's own blind spots. Reveals nothing about + // the machine that its own operator cannot already see. + | "selfcheck" => Access::Read, "scan" | "supply.sweep" => Access::ReadsPath, diff --git a/crates/houndd/src/quarantine.rs b/crates/houndd/src/quarantine.rs index 20e4f3f..7bdbdc0 100644 --- a/crates/houndd/src/quarantine.rs +++ b/crates/houndd/src/quarantine.rs @@ -18,8 +18,27 @@ use hound_api::QuarantineEntry; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -/// Where the quarantine store lives for this user. +/// Where the packaged daemon keeps quarantined files. Created 0700 by the +/// installer, on a path no user can execute from by accident. +const SYSTEM_VAULT: &str = "/var/lib/hound/vault"; + +/// Where the quarantine store lives. +/// +/// The system daemon runs as root, and root has a home directory, so the XDG +/// rules below quietly put the vault in /root/.local/share/hound/quarantine. +/// That is wrong twice over: the package creates and hardens +/// /var/lib/hound/vault, which then sat empty, and a desktop app reading the +/// *user's* vault disagreed with the daemon writing root's — which is why the +/// Quarantine tab said "vault is empty" while two quarantined files existed. +/// +/// So: running as root means the system vault. Anyone else gets their own, +/// because a user running `houndd` by hand must not need write access to +/// /var/lib. pub fn store_dir() -> PathBuf { + // SAFETY: geteuid takes no arguments and cannot fail. + if unsafe { libc::geteuid() } == 0 { + return PathBuf::from(SYSTEM_VAULT); + } let data = std::env::var("XDG_DATA_HOME") .ok() .filter(|s| !s.is_empty()); @@ -207,6 +226,25 @@ fn make_id(path: &std::path::Path) -> String { #[cfg(test)] mod tests { + /// Root got /root/.local/share/hound/quarantine from the XDG rules, so + /// the vault the installer creates and hardens sat empty while the + /// desktop app — reading the user's own vault — reported it empty too, + /// with quarantined files sitting in neither of the places anyone looked. + #[test] + fn root_quarantines_into_the_system_vault() { + if unsafe { libc::geteuid() } != 0 { + // Non-root must never be sent to /var/lib, which it cannot write. + assert_ne!(store_dir(), std::path::Path::new(SYSTEM_VAULT)); + assert!( + store_dir().ends_with("hound/quarantine"), + "an unprivileged daemon keeps its own vault, got {}", + store_dir().display() + ); + return; + } + assert_eq!(store_dir(), std::path::Path::new(SYSTEM_VAULT)); + } + use super::*; /// Per-test data dir (tag it so tests never share a directory — one diff --git a/crates/houndd/src/selfcheck.rs b/crates/houndd/src/selfcheck.rs new file mode 100644 index 0000000..08a34cd --- /dev/null +++ b/crates/houndd/src/selfcheck.rs @@ -0,0 +1,268 @@ +//! What can this installation not see? +//! +//! Every serious bug found on the first day of desktop testing had the same +//! shape: Hound reported success it had not achieved. A build script that +//! said "built" without building. A window showing "Protected" while its +//! front-end had failed to load. A rootkit scanner that called 988 processes +//! hidden when it was the one that had been blinded. A settings write that +//! was refused while the switch stayed where the user put it. +//! +//! A visible error is something a person can act on. A false green is not. +//! So this module asks, deliberately and out loud, what Hound is currently +//! unable to do — and the answers are reported at startup and on demand +//! rather than waiting to be inferred from behaviour. +//! +//! Every check here answers a question with a factual answer. None of them +//! guess, and a check that cannot run says so rather than passing. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Check { + /// Stable identifier, for scripts and for suppressing a known-benign one. + pub id: String, + /// "ok" | "degraded" | "blind" + /// + /// The third is the one that matters: "blind" means a detector is + /// running and cannot see, which is the state that produces confident + /// wrong answers. + pub state: String, + /// What was actually checked, in a sentence a person can act on. + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SelfCheck { + pub checks: Vec, + pub ok: u32, + pub degraded: u32, + pub blind: u32, +} + +impl SelfCheck { + /// True when something is wrong enough that a green interface would be + /// dishonest. + pub fn healthy(&self) -> bool { + self.blind == 0 && self.degraded == 0 + } + + fn push(&mut self, id: &str, state: &str, detail: impl Into) { + match state { + "ok" => self.ok += 1, + "degraded" => self.degraded += 1, + _ => self.blind += 1, + } + self.checks.push(Check { + id: id.into(), + state: state.into(), + detail: detail.into(), + }); + } +} + +/// Run every check. Cheap enough for startup and for a status poll. +pub fn run(defs: &crate::defs::DefsStore, rules_loaded: usize) -> SelfCheck { + let mut r = SelfCheck::default(); + check_proc_visibility(&mut r); + check_rules(&mut r, rules_loaded); + check_definitions(&mut r, defs); + check_vault(&mut r); + check_engine(&mut r); + r +} + +/// Can this process see other processes? +/// +/// `ProtectProc=invisible` in our own systemd unit hid every process from the +/// daemon while `kill(pid, 0)` kept answering, so the hidden-process check +/// reported thousands of rootkits on clean machines. `hidepid=` on the /proc +/// mount does the same thing and is not ours to remove. PID 1 is the control: +/// it always exists, and nothing hides init. +fn check_proc_visibility(r: &mut SelfCheck) { + let listed = crate::rootkit::proc_tids(); + if listed.is_empty() { + r.push( + "proc_visibility", + "blind", + "/proc cannot be read at all, so no process check can run", + ); + } else if crate::rootkit::pid_exists(1) && !listed.contains(&1) { + r.push( + "proc_visibility", + "blind", + "this daemon's view of /proc is filtered, so hidden-process \ + detection cannot run. Check ProtectProc= in the systemd unit or \ + hidepid= on the /proc mount", + ); + } else { + r.push( + "proc_visibility", + "ok", + format!("{} tasks visible in /proc", listed.len()), + ); + } +} + +fn check_rules(r: &mut SelfCheck, rules_loaded: usize) { + if rules_loaded == 0 { + r.push( + "rules", + "blind", + "no YARA rules compiled — file scanning cannot detect anything", + ); + } else { + r.push("rules", "ok", format!("{rules_loaded} rule(s) compiled")); + } +} + +fn check_definitions(r: &mut SelfCheck, defs: &crate::defs::DefsStore) { + let d = defs.current(); + if d.indicators == 0 { + r.push( + "definitions", + "blind", + format!( + "no supply-chain indicators loaded{}", + if d.detail.is_empty() { + String::new() + } else { + format!(" — {}", d.detail) + } + ), + ); + return; + } + match crate::defs_age_days(&d.version) { + Some(days) if days >= hound_api::DEFS_VERY_STALE_DAYS => r.push( + "definitions", + "degraded", + format!( + "definitions are {days} days old; nothing found since then is detectable" + ), + ), + Some(days) if days >= hound_api::DEFS_STALE_DAYS => r.push( + "definitions", + "degraded", + format!("definitions are {days} days old"), + ), + Some(days) => r.push( + "definitions", + "ok", + format!("{} indicators, {days} day(s) old", d.indicators), + ), + None => r.push( + "definitions", + "degraded", + format!( + "{} indicators loaded but the feed version {:?} cannot be dated, \ + so staleness is unknown", + d.indicators, d.version + ), + ), + } +} + +/// A vault that cannot be written to means a detection has nowhere to go — +/// and quarantine failing at the moment it matters is not something to +/// discover then. +fn check_vault(r: &mut SelfCheck) { + let dir = crate::quarantine::store_dir(); + if !dir.is_dir() { + r.push( + "quarantine", + "degraded", + format!("{} does not exist; it is created on first use", dir.display()), + ); + return; + } + let probe = dir.join(".hound-write-probe"); + match std::fs::write(&probe, b"") { + Ok(()) => { + let _ = std::fs::remove_file(&probe); + r.push("quarantine", "ok", format!("{} is writable", dir.display())); + } + Err(e) => r.push( + "quarantine", + "blind", + format!( + "{} is not writable ({e}); a detected file could not be quarantined", + dir.display() + ), + ), + } +} + +fn check_engine(r: &mut SelfCheck) { + let (present, summary, _) = crate::engine::engine().probe(); + if present { + r.push("engine", "ok", summary); + } else { + r.push("engine", "blind", format!("scanning engine unavailable: {summary}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_clean_result_is_healthy_and_a_blind_one_is_not() { + let mut r = SelfCheck::default(); + r.push("a", "ok", "fine"); + assert!(r.healthy()); + r.push("b", "blind", "cannot see"); + assert!(!r.healthy(), "a blind check must not report healthy"); + assert_eq!(r.blind, 1); + assert_eq!(r.ok, 1); + } + + /// Degraded is not healthy either. Definitions a month old are the case + /// this exists for: everything is technically working and the machine is + /// not protected against anything recent. + #[test] + fn degraded_is_not_healthy() { + let mut r = SelfCheck::default(); + r.push("defs", "degraded", "30 days old"); + assert!(!r.healthy()); + } + + /// The check must be able to fail. If this machine's /proc is visible — + /// which it is, or nearly every other test would be failing too — then + /// the visibility check must say ok, and it must count PID 1. + #[test] + fn proc_visibility_passes_on_a_normal_machine() { + let mut r = SelfCheck::default(); + check_proc_visibility(&mut r); + assert_eq!(r.checks[0].state, "ok", "{}", r.checks[0].detail); + } + + #[test] + fn no_rules_is_reported_as_blind_not_as_a_clean_scan() { + let mut r = SelfCheck::default(); + check_rules(&mut r, 0); + assert_eq!(r.checks[0].state, "blind"); + let mut r = SelfCheck::default(); + check_rules(&mut r, 4); + assert_eq!(r.checks[0].state, "ok"); + } + + /// Every check must explain itself well enough to act on. An id and a + /// state with no detail is the kind of diagnostic that gets ignored. + #[test] + fn every_check_explains_itself() { + let mut r = SelfCheck::default(); + check_proc_visibility(&mut r); + check_rules(&mut r, 4); + check_vault(&mut r); + check_engine(&mut r); + for c in &r.checks { + assert!(!c.id.is_empty(), "a check needs an id"); + assert!( + c.detail.len() > 12, + "{} has no useful detail: {:?}", + c.id, + c.detail + ); + } + } +} diff --git a/dist/hound_0.1.5_amd64.deb b/dist/hound_0.1.5_amd64.deb new file mode 100644 index 0000000..11ca137 Binary files /dev/null and b/dist/hound_0.1.5_amd64.deb differ diff --git a/gui/package-lock.json b/gui/package-lock.json index ca25279..e4989e3 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "hound-gui", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hound-gui", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.2", diff --git a/gui/package.json b/gui/package.json index 67b1c8d..295f8f3 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "hound-gui", - "version": "0.1.4", + "version": "0.1.5", "description": "Hound Antivirus — desktop app", "type": "module", "scripts": { diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 9ee7589..30608b0 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hound-api" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "serde", @@ -1477,7 +1477,7 @@ dependencies = [ [[package]] name = "hound-gui" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "hound-api", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index da1a587..529fe2f 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hound-gui" description = "Hound Antivirus desktop app (Tauri 2)" -version = "0.1.4" +version = "0.1.5" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus" diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index ff4e253..666210e 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hound Antivirus", - "version": "0.1.4", + "version": "0.1.5", "identifier": "com.joelovestech.hound", "build": { "frontendDist": "../dist",