diff --git a/Cargo.lock b/Cargo.lock index a817961..348083a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,19 +1089,22 @@ dependencies = [ [[package]] name = "hound" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "clap", "colored", "hound-api", "hound-supply", + "libc", "serde_json", + "sha2", + "ureq", ] [[package]] name = "hound-api" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "serde", @@ -1111,7 +1114,7 @@ dependencies = [ [[package]] name = "hound-defs" -version = "0.1.1" +version = "0.1.2" dependencies = [ "ed25519-dalek", "serde", @@ -1121,7 +1124,7 @@ dependencies = [ [[package]] name = "hound-mcp" -version = "0.1.1" +version = "0.1.2" dependencies = [ "hound-api", "hound-supply", @@ -1131,7 +1134,7 @@ dependencies = [ [[package]] name = "hound-supply" -version = "0.1.1" +version = "0.1.2" dependencies = [ "hound-defs", "serde", @@ -1140,7 +1143,7 @@ dependencies = [ [[package]] name = "houndd" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index e335b04..038a9d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus.git" diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 22cb5b4..ccaa165 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -271,7 +271,7 @@ pub struct ScanResult { } /// Result of the `update` method (a freshclam run). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UpdateResult { /// True when freshclam exited 0 (DB actually refreshed or already current). pub ok: bool, diff --git a/crates/hound/Cargo.toml b/crates/hound/Cargo.toml index c6d2b4a..52aeef8 100644 --- a/crates/hound/Cargo.toml +++ b/crates/hound/Cargo.toml @@ -11,6 +11,9 @@ name = "hound" path = "src/main.rs" [dependencies] +sha2.workspace = true +libc.workspace = true +ureq.workspace = true hound-api = { path = "../hound-api" } hound-supply.workspace = true anyhow.workspace = true diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 00cddc5..1fa994f 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -11,7 +11,7 @@ //! //! Exit codes: 0 = clean, 1 = infected, 2 = usage/engine error. -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use colored::Colorize; use hound_api::{Client, RealtimeStatus, RootkitScan, ScanResult, Settings, UpdateResult}; @@ -68,11 +68,20 @@ enum Cmd { #[arg(long)] json: bool, }, - /// Download the latest signed definition packs + /// Bring this machine up to date: definitions, then Hound itself Update { /// Emit machine-readable JSON instead of human text #[arg(long)] json: bool, + /// Definitions only; do not install a new version of Hound + #[arg(long)] + definitions_only: bool, + /// Say what is available without installing anything + #[arg(long)] + check: bool, + /// Install a new version without asking + #[arg(long, short = 'y')] + yes: bool, }, /// Show recent alerts (scan / threat / quarantine / ransomware …) Events { @@ -506,13 +515,89 @@ fn run(client: &Client, cmd: &Cmd) -> Result { print_human(&r, path); Ok(if r.is_clean() { 0 } else { 1 }) } - Cmd::Update { json } => { - let u: UpdateResult = client.update()?; + Cmd::Update { + json, + definitions_only, + check, + yes, + } => { + // Definitions first. They are the half that can always be done + // and the half that matters most between releases. + let u: UpdateResult = if *check { + UpdateResult::default() + } else { + client.update()? + }; + + let app = if *definitions_only { + None + } else { + // A check that fails is not a reason to call the whole + // command a failure — the definitions half may well have + // worked, and a laptop is offline all the time. + match client.raw_call("release.check", None) { + Ok(v) => Some(v), + Err(e) => { + if !*json { + eprintln!("{} could not check for a new version: {e}", "!".yellow()); + } + None + } + } + }; + if *json { - println!("{}", serde_json::to_string_pretty(&u)?); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "definitions": u, + "app": app, + }))? + ); return Ok(if u.ok { 0 } else { 1 }); } - print_update_human(&u); + + if !*check { + print_update_human(&u); + } + + let available = app + .as_ref() + .and_then(|v| v.get("update_available")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if !available { + if let Some(v) = &app { + let cur = v.get("current").and_then(|c| c.as_str()).unwrap_or("?"); + println!("{} Hound {cur} is the latest version", "✔".green().bold()); + } + return Ok(if u.ok { 0 } else { 1 }); + } + + let v = app.as_ref().expect("available implies a response"); + let latest = v.get("latest").and_then(|c| c.as_str()).unwrap_or_default(); + let deb_url = v.get("deb_url").and_then(|c| c.as_str()).unwrap_or_default(); + let sha = v.get("deb_sha256").and_then(|c| c.as_str()).unwrap_or_default(); + println!("{} Hound {latest} is available", "↑".cyan().bold()); + + if *check { + if let Some(notes) = v.get("notes_url").and_then(|c| c.as_str()) { + if !notes.is_empty() { + println!(" what changed: {}", notes.dimmed()); + } + } + println!(" install it with: {}", "sudo hound update".yellow()); + return Ok(0); + } + + match install_app_update(latest, deb_url, sha, *yes) { + Ok(true) => println!("{} Hound {latest} installed", "✔".green().bold()), + Ok(false) => {} + Err(e) => { + eprintln!("{} {e}", "✘".red().bold()); + return Ok(1); + } + } Ok(if u.ok { 0 } else { 1 }) } Cmd::Events { limit, json } => { @@ -746,6 +831,85 @@ fn run(client: &Client, cmd: &Cmd) -> Result { } } +/// Download, verify and install a published release. +/// +/// Hound does not replace its own binary — a root process that rewrites +/// itself is the mechanism a supply-chain attacker most wants. The install +/// goes through the system package manager, which is the same path a person +/// would take by hand. +/// +/// Returns Ok(false) when the user declined, which is not a failure. +fn install_app_update(version: &str, deb_url: &str, sha256: &str, assume_yes: bool) -> Result { + use sha2::{Digest, Sha256}; + use std::io::{IsTerminal, Read as _, Write as _}; + + if unsafe { libc::geteuid() } != 0 { + // The definitions half already ran, so say what worked before saying + // what did not. + anyhow::bail!("installing Hound {version} needs root — run: sudo hound update"); + } + + // A signed manifest establishes what the publisher intended and nothing + // more. It does not stop a publisher's mistake pointing elsewhere. + if !deb_url.starts_with("https://dl.houndav.com/") { + anyhow::bail!("the published update points somewhere unexpected; not downloading it"); + } + + if !assume_yes { + if !std::io::stdin().is_terminal() { + println!(" run with --yes to install it without a prompt"); + return Ok(false); + } + print!(" install Hound {version} now? [y/N] "); + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + println!(" left it for later"); + return Ok(false); + } + } + + println!(" downloading {version}…"); + let resp = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(300)) + .build() + .get(deb_url) + .call() + .with_context(|| format!("downloading {deb_url}"))?; + let mut body = Vec::new(); + // Generous for a package, and far short of filling a disk. + resp.into_reader() + .take(256 << 20) + .read_to_end(&mut body) + .context("reading the download")?; + + let got: String = Sha256::digest(&body).iter().map(|b| format!("{b:02x}")).collect(); + if !sha256.is_empty() && got != sha256 { + anyhow::bail!("the download does not match its signed checksum; discarded"); + } + + // Root-owned staging, so nothing can substitute the file between the + // check above and the package manager reading it. + const STAGE_DIR: &str = "/var/lib/hound/updates"; + std::fs::create_dir_all(STAGE_DIR)?; + std::fs::set_permissions(STAGE_DIR, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; + let path = std::path::Path::new(STAGE_DIR).join(format!("hound_{version}_amd64.deb")); + std::fs::write(&path, &body)?; + std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o600))?; + + let status = std::process::Command::new("apt-get") + .args(["install", "-y"]) + .arg(&path) + .status() + .context("running apt-get")?; + if !status.success() { + anyhow::bail!("the package manager refused the update (staged at {})", path.display()); + } + let _ = std::fs::remove_file(&path); + Ok(true) +} + fn print_update_human(u: &UpdateResult) { if u.ok { println!( @@ -944,6 +1108,10 @@ mod tests { vec!["hound", "status"], vec!["hound", "scan", "/tmp"], vec!["hound", "update"], + vec!["hound", "update", "--check"], + vec!["hound", "update", "--yes"], + vec!["hound", "update", "-y"], + vec!["hound", "update", "--definitions-only"], vec!["hound", "settings", "show"], vec!["hound", "settings", "exec-gate", "on"], vec!["hound", "settings", "exec-gate", "off"], diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 40c9728..e8b393c 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -532,6 +532,29 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { match req.method.as_str() { "status" => Ok(serde_json::to_value(status(st)?)?), "update" => Ok(serde_json::to_value(update(st)?)?), + // 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. + "release.check" => { + let keys = defs::trusted_keys(); + let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> = + keys.iter().map(|(id, k)| (id.as_str(), *k)).collect(); + let found = release::fetch(&release::base_url(), &trusted)?; + if let Some(rel) = found.clone() { + *KNOWN_RELEASE.lock().expect("release lock poisoned") = Some(rel); + } + let newer = found + .as_ref() + .filter(|r| release::is_newer(&r.version, DAEMON_VERSION)); + Ok(serde_json::json!({ + "current": DAEMON_VERSION, + "latest": found.as_ref().map(|r| r.version.clone()).unwrap_or_default(), + "update_available": newer.is_some(), + "deb_url": newer.map(|r| r.deb_url.clone()).unwrap_or_default(), + "deb_sha256": found.as_ref().map(|r| r.deb_sha256.clone()).unwrap_or_default(), + "notes_url": found.as_ref().map(|r| r.notes_url.clone()).unwrap_or_default(), + })) + } "scan" => { let path = req .params diff --git a/crates/houndd/src/peer.rs b/crates/houndd/src/peer.rs index f74531d..b1dd6f9 100644 --- a/crates/houndd/src/peer.rs +++ b/crates/houndd/src/peer.rs @@ -106,7 +106,10 @@ pub fn access_for(method: &str) -> Access { | "quarantine.list" | "realtime.status" | "rootkit.scan" - | "persistence.scan" => Access::Read, + | "persistence.scan" + // Asking whether a newer version exists changes nothing, and reveals + // nothing the website does not already say. + | "release.check" => Access::Read, "scan" | "supply.sweep" => Access::ReadsPath, @@ -253,6 +256,9 @@ mod tests { fn reads_are_open_and_writes_are_not() { assert_eq!(access_for("status"), Access::Read); assert_eq!(access_for("quarantine.list"), Access::Read); + // Asking whether a newer version exists changes nothing and reveals + // nothing the website does not. + assert_eq!(access_for("release.check"), Access::Read); assert_eq!(access_for("scan"), Access::ReadsPath); assert_eq!(access_for("supply.sweep"), Access::ReadsPath); for admin in [ diff --git a/dist/hound_0.1.2_amd64.deb b/dist/hound_0.1.2_amd64.deb new file mode 100644 index 0000000..180524f Binary files /dev/null and b/dist/hound_0.1.2_amd64.deb differ diff --git a/gui/package-lock.json b/gui/package-lock.json index 286b037..f494b79 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "hound-gui", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hound-gui", - "version": "0.1.1", + "version": "0.1.2", "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 caa3869..c21856d 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "hound-gui", - "version": "0.1.1", + "version": "0.1.2", "description": "Hound Antivirus — desktop app", "type": "module", "scripts": { diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index dc3d684..eb3c5e9 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.1" +version = "0.1.2" dependencies = [ "anyhow", "serde", @@ -1477,7 +1477,7 @@ dependencies = [ [[package]] name = "hound-gui" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "hound-api", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 85e5ee7..87cacdb 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.1" +version = "0.1.2" 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 8e6f2aa..f6a1169 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.1", + "version": "0.1.2", "identifier": "com.joelovestech.hound", "build": { "frontendDist": "../dist",