gui: make the desktop app actually launch, and let it reach the daemon
The start-menu entry ran `hound` with Terminal=true — the CLI, which
printed help and exited. No GUI binary had ever been built or packaged.
Four separate faults were stacked behind that report:
1. build-deb.sh now builds and ships hound-gui, and writes a .desktop
entry only when that binary exists. A launcher for software that is
not there is worse than no launcher.
2. Tray icons were loaded from a relative "icons/" path, which resolves
only from the build tree. Installed to /usr/bin the setup hook failed
and Tauri panicked before a window appeared. They are include_bytes!
now — four ~1 KB PNGs that can no longer be missing.
3. The front-end never ran at all. app.js opened with a bare module
specifier ("@tauri-apps/api/core") and there is no bundler, so the
webview could not resolve it and the script silently failed to parse.
The window rendered its static HTML forever, which looks exactly like
a daemon that never answered. withGlobalTauri + window.__TAURI__.
4. build-deb.sh ran the Tauri build as `>/dev/null 2>&1 || true`, so a
config error scrolled past unseen and the package shipped the
PREVIOUS binary. Two fixes appeared to do nothing. That step is no
longer silenced or tolerant of failure, and the build fails outright
on a bare import in gui/dist/*.js.
Guards, because each of these failed quietly: index.html flips to an
interface-error message if app.js never sets a boot flag within 5s. An
antivirus showing "Protected - your system looks healthy" while its own
front-end is dead is the worst failure mode there is.
Then the window came up and could not reach the daemon: the socket was
0700 root:root. Widening it needed more than a chmod, because
quarantine.restore writes files back out as root — handing that to a
desktop group would hand out root. So the daemon now checks SO_PEERCRED
per method (crates/houndd/src/peer.rs):
- group `hound`: status, settings.get, events, quarantine.list,
rootkit.scan, persistence.scan
- scan/supply.sweep: only paths the caller could read itself, decided
by forking a child, dropping to the peer's uid, gid and
supplementary groups, and asking access(2) — which honours ACLs and
mount options, unlike anything reconstructed from mode bits
- everything that writes: root, or the uid the daemon runs as
Unclassified methods fall into Admin, so a new mutating method fails
closed rather than becoming public by omission. The end-to-end socket
test caught that "root only" broke every developer run; the owner
clause collapses to "root" under the packaged root daemon and is
verified to do so.
CAP_SETUID/CAP_SETGID join the gate capability set for the readability
check. There was a test asserting CAP_SETUID must never be retained —
it is updated with the reasoning rather than deleted. The daemon
already holds CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH, so becoming
another user widens nothing that matters. The unit gains Group=hound so
the socket can be chgrp'd without CAP_CHOWN; it stays uid 0.
Also: the footer claimed "engine: ClamAV via Unix socket". It reports
what the daemon actually loaded, which has been yara-x since the engine
was replaced. Every error path in the front-end goes through explain(),
so a privilege refusal reads as "run it from a terminal: sudo hound …"
rather than "daemon error -32000".
358 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
13a9333e3f
commit
3b3586b60a
13 changed files with 690 additions and 90 deletions
|
|
@ -53,14 +53,26 @@ use std::io;
|
||||||
pub const CAP_DAC_OVERRIDE: u32 = 1;
|
pub const CAP_DAC_OVERRIDE: u32 = 1;
|
||||||
pub const CAP_DAC_READ_SEARCH: u32 = 2;
|
pub const CAP_DAC_READ_SEARCH: u32 = 2;
|
||||||
pub const CAP_FOWNER: u32 = 3;
|
pub const CAP_FOWNER: u32 = 3;
|
||||||
|
pub const CAP_SETGID: u32 = 6;
|
||||||
|
pub const CAP_SETUID: u32 = 7;
|
||||||
pub const CAP_SYS_ADMIN: u32 = 21;
|
pub const CAP_SYS_ADMIN: u32 = 21;
|
||||||
|
|
||||||
/// Everything the daemon needs and nothing else.
|
/// Everything the daemon needs and nothing else.
|
||||||
pub const GATE_CAPS: [u32; 4] = [
|
///
|
||||||
|
/// SETUID/SETGID are here for one purpose: answering "could the user who
|
||||||
|
/// asked have read this file?" by forking a child, becoming that user, and
|
||||||
|
/// letting `access(2)` decide (see peer.rs). Next to CAP_DAC_OVERRIDE and
|
||||||
|
/// CAP_DAC_READ_SEARCH — which already let this process read and write every
|
||||||
|
/// file on the system — the ability to become another user widens nothing
|
||||||
|
/// that matters, and it is what keeps the scanner from being an oracle for
|
||||||
|
/// files its caller cannot open.
|
||||||
|
pub const GATE_CAPS: [u32; 6] = [
|
||||||
CAP_SYS_ADMIN,
|
CAP_SYS_ADMIN,
|
||||||
CAP_DAC_READ_SEARCH,
|
CAP_DAC_READ_SEARCH,
|
||||||
CAP_DAC_OVERRIDE,
|
CAP_DAC_OVERRIDE,
|
||||||
CAP_FOWNER,
|
CAP_FOWNER,
|
||||||
|
CAP_SETUID,
|
||||||
|
CAP_SETGID,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Highest capability the running kernel could define. 63 is the ceiling
|
/// Highest capability the running kernel could define. 63 is the ceiling
|
||||||
|
|
@ -248,9 +260,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_gate_set_is_exactly_four_bits() {
|
fn the_gate_set_is_exactly_six_bits() {
|
||||||
let (lo, hi) = to_words(&GATE_CAPS);
|
let (lo, hi) = to_words(&GATE_CAPS);
|
||||||
assert_eq!(lo.count_ones(), 4, "no capability may sneak in");
|
assert_eq!(lo.count_ones(), 6, "no capability may sneak in");
|
||||||
assert_eq!(hi, 0);
|
assert_eq!(hi, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +278,14 @@ mod tests {
|
||||||
const CAP_NET_ADMIN: u32 = 12;
|
const CAP_NET_ADMIN: u32 = 12;
|
||||||
const CAP_NET_RAW: u32 = 13;
|
const CAP_NET_RAW: u32 = 13;
|
||||||
const CAP_AUDIT_CONTROL: u32 = 30;
|
const CAP_AUDIT_CONTROL: u32 = 30;
|
||||||
const CAP_SETUID: u32 = 7;
|
// CAP_SETUID was on this list until the desktop app needed a way to
|
||||||
|
// ask "could the user who requested this scan have read the file?".
|
||||||
|
// Answering it means becoming that user in a child process. It is a
|
||||||
|
// deliberate reversal, and it is defensible only because the two
|
||||||
|
// capabilities immediately below it — DAC_OVERRIDE and
|
||||||
|
// DAC_READ_SEARCH — already grant this process every file on the
|
||||||
|
// machine. The ones still listed here grant things it has no other
|
||||||
|
// route to.
|
||||||
let (lo, _) = to_words(&GATE_CAPS);
|
let (lo, _) = to_words(&GATE_CAPS);
|
||||||
for (name, bit) in [
|
for (name, bit) in [
|
||||||
("CAP_SYS_MODULE", CAP_SYS_MODULE),
|
("CAP_SYS_MODULE", CAP_SYS_MODULE),
|
||||||
|
|
@ -275,7 +294,6 @@ mod tests {
|
||||||
("CAP_NET_ADMIN", CAP_NET_ADMIN),
|
("CAP_NET_ADMIN", CAP_NET_ADMIN),
|
||||||
("CAP_NET_RAW", CAP_NET_RAW),
|
("CAP_NET_RAW", CAP_NET_RAW),
|
||||||
("CAP_AUDIT_CONTROL", CAP_AUDIT_CONTROL),
|
("CAP_AUDIT_CONTROL", CAP_AUDIT_CONTROL),
|
||||||
("CAP_SETUID", CAP_SETUID),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(lo & (1 << bit), 0, "{name} must never be retained");
|
assert_eq!(lo & (1 << bit), 0, "{name} must never be retained");
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +338,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_gate_set_matches_what_proc_would_report() {
|
fn the_gate_set_matches_what_proc_would_report() {
|
||||||
let (lo, _) = to_words(&GATE_CAPS);
|
let (lo, _) = to_words(&GATE_CAPS);
|
||||||
assert_eq!(lo as u64, 0x20_000e, "must match the CapEff mask in /proc");
|
assert_eq!(lo as u64, 0x20_00ce, "must match the CapEff mask in /proc");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ mod engine;
|
||||||
mod events;
|
mod events;
|
||||||
mod fanotify;
|
mod fanotify;
|
||||||
mod native;
|
mod native;
|
||||||
|
mod peer;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod quarantine;
|
mod quarantine;
|
||||||
mod realtime;
|
mod realtime;
|
||||||
|
|
@ -63,7 +64,8 @@ use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::PathBuf;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
|
@ -94,6 +96,14 @@ fn main() -> Result<()> {
|
||||||
// Reconnect-friendly startup: drop a stale socket from a dead daemon.
|
// Reconnect-friendly startup: drop a stale socket from a dead daemon.
|
||||||
let _ = fs::remove_file(&sock_path);
|
let _ = fs::remove_file(&sock_path);
|
||||||
let listener = UnixListener::bind(&sock_path).with_context(|| format!("binding {sock}"))?;
|
let listener = UnixListener::bind(&sock_path).with_context(|| format!("binding {sock}"))?;
|
||||||
|
// The desktop app runs as the logged-in user and has to reach the daemon
|
||||||
|
// without a password prompt on every status poll, so the socket is opened
|
||||||
|
// to the `hound` group. What that group can actually do is decided per
|
||||||
|
// method in `peer::access_for` — read-only, with anything that writes
|
||||||
|
// still requiring uid 0. If the group does not exist (running from a
|
||||||
|
// build tree rather than the package) the socket stays root-only, which
|
||||||
|
// is the old behaviour and safe.
|
||||||
|
open_socket_to_hound_group(&sock_path);
|
||||||
|
|
||||||
let state = DaemonState::boot();
|
let state = DaemonState::boot();
|
||||||
|
|
||||||
|
|
@ -271,7 +281,7 @@ impl DaemonState {
|
||||||
"info",
|
"info",
|
||||||
"capabilities reduced to the four the gate needs".into(),
|
"capabilities reduced to the four the gate needs".into(),
|
||||||
);
|
);
|
||||||
eprintln!("gate: capabilities reduced to 4 of 41 (CapEff 0x20000e)");
|
eprintln!("gate: capabilities reduced to 6 of 41 (CapEff 0x2000ce)");
|
||||||
}
|
}
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -398,6 +408,46 @@ impl DaemonState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hand the socket to the `hound` group at 0660, if that group exists.
|
||||||
|
/// Best effort by design: a daemon that refuses to start because a group is
|
||||||
|
/// missing is worse than one that starts root-only.
|
||||||
|
fn open_socket_to_hound_group(sock_path: &Path) {
|
||||||
|
let Some(gid) = hound_group_gid() else {
|
||||||
|
eprintln!("socket: no `hound` group on this system — staying root-only");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(c_path) = std::ffi::CString::new(sock_path.as_os_str().as_encoded_bytes()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// SAFETY: c_path is a valid NUL-terminated path that outlives the call.
|
||||||
|
// -1 for the uid means "leave the owner alone".
|
||||||
|
if unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) } != 0 {
|
||||||
|
eprintln!(
|
||||||
|
"socket: cannot chgrp to hound: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::set_permissions(sock_path, fs::Permissions::from_mode(0o660)) {
|
||||||
|
eprintln!("socket: cannot set mode 0660: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!("socket: readable by group hound (gid {gid}); writes still require root");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hound_group_gid() -> Option<u32> {
|
||||||
|
let group = fs::read_to_string("/etc/group").ok()?;
|
||||||
|
for line in group.lines() {
|
||||||
|
let mut f = line.split(':');
|
||||||
|
if f.next()? != "hound" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _pw = f.next();
|
||||||
|
return f.next()?.parse().ok();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Read one request line, dispatch, write one response line.
|
/// Read one request line, dispatch, write one response line.
|
||||||
fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> {
|
fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> {
|
||||||
let mut reader = BufReader::new(stream);
|
let mut reader = BufReader::new(stream);
|
||||||
|
|
@ -406,7 +456,12 @@ fn handle_conn(stream: UnixStream, state: DaemonState) -> Result<()> {
|
||||||
|
|
||||||
let req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?;
|
let req: hound_api::Request = serde_json::from_str(line.trim()).context("decoding request")?;
|
||||||
|
|
||||||
let result = dispatch(&req, &state);
|
// Who is asking, per the kernel — not per anything in the request.
|
||||||
|
let who = peer::Peer::from_stream(reader.get_ref())?;
|
||||||
|
let result = match authorise(&who, &req) {
|
||||||
|
Ok(()) => dispatch(&req, &state),
|
||||||
|
Err(denied) => Err(denied),
|
||||||
|
};
|
||||||
let resp = match result {
|
let resp = match result {
|
||||||
Ok(value) => Response {
|
Ok(value) => Response {
|
||||||
jsonrpc: "2.0".into(),
|
jsonrpc: "2.0".into(),
|
||||||
|
|
@ -439,6 +494,38 @@ fn writer_flush(reader: &mut BufReader<UnixStream>, bytes: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gate a request on the peer's credentials. Returns the error the client
|
||||||
|
/// will see, phrased so a person knows what to do about it.
|
||||||
|
fn authorise(who: &peer::Peer, req: &hound_api::Request) -> Result<()> {
|
||||||
|
match peer::access_for(&req.method) {
|
||||||
|
peer::Access::Read => Ok(()),
|
||||||
|
peer::Access::Admin if who.may_administer() => Ok(()),
|
||||||
|
peer::Access::Admin => bail!(
|
||||||
|
"{} requires administrator privileges — run it with sudo",
|
||||||
|
req.method
|
||||||
|
),
|
||||||
|
peer::Access::ReadsPath => {
|
||||||
|
if who.is_root() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = req
|
||||||
|
.params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("path"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.with_context(|| format!("{} requires params.path", req.method))?;
|
||||||
|
// The daemon reads as root. Confirming the caller could have read
|
||||||
|
// the path itself is what stops the scanner being used as an
|
||||||
|
// oracle for files the caller cannot open.
|
||||||
|
if peer::peer_can_read(who, std::path::Path::new(path)) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("cannot read {path} as uid {} — scan it with sudo", who.uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
"status" => Ok(serde_json::to_value(status(st)?)?),
|
"status" => Ok(serde_json::to_value(status(st)?)?),
|
||||||
|
|
|
||||||
359
crates/houndd/src/peer.rs
Normal file
359
crates/houndd/src/peer.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
//! Who is on the other end of the socket, and what they may ask for.
|
||||||
|
//!
|
||||||
|
//! The daemon runs as root, so every method it exposes runs as root. Until
|
||||||
|
//! the desktop app existed that was academic — the socket was `0700 root:root`
|
||||||
|
//! and only `sudo hound` could reach it. But a GUI runs as the logged-in user
|
||||||
|
//! and has to reach the daemon without a password prompt on every status poll,
|
||||||
|
//! so the socket is now group-readable by `hound`.
|
||||||
|
//!
|
||||||
|
//! That group is a real trust grant and it is worth being exact about how far
|
||||||
|
//! it goes. `quarantine.add` moves any path into the vault, and
|
||||||
|
//! `quarantine.restore` writes a file back out as root — between them that is
|
||||||
|
//! arbitrary file replacement on the system, which is root. Group members
|
||||||
|
//! therefore get the read side of the API and nothing that writes:
|
||||||
|
//! administrative methods still require a peer with uid 0.
|
||||||
|
//!
|
||||||
|
//! Scanning is the exception worth explaining. It reads as root, so an
|
||||||
|
//! unprivileged caller could otherwise use it as an oracle for files it cannot
|
||||||
|
//! open. So a non-root caller's scan is checked against what *that caller*
|
||||||
|
//! could read, by forking a child, dropping it to the peer's uid, gid and
|
||||||
|
//! supplementary groups, and letting the kernel answer with `access(2)` —
|
||||||
|
//! which honours ACLs and mount options, unlike anything we could reconstruct
|
||||||
|
//! from a mode bitfield.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// The credentials the kernel attaches to a connection. Unforgeable: these
|
||||||
|
/// come from `SO_PEERCRED`, not from anything the client sent us.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Peer {
|
||||||
|
pub pid: i32,
|
||||||
|
pub uid: u32,
|
||||||
|
pub gid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer {
|
||||||
|
pub fn is_root(&self) -> bool {
|
||||||
|
self.uid == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// May this peer use the methods that write?
|
||||||
|
///
|
||||||
|
/// Root, or whoever the daemon is running as. The second clause is not a
|
||||||
|
/// loophole: a user running `houndd` under their own account can already
|
||||||
|
/// kill it, attach a debugger to it, or edit its settings file directly,
|
||||||
|
/// so refusing them the API buys nothing and breaks every developer and
|
||||||
|
/// test run. In the shipped configuration the daemon is uid 0, and this
|
||||||
|
/// collapses to exactly "the peer is root".
|
||||||
|
pub fn may_administer(&self) -> bool {
|
||||||
|
// SAFETY: getuid takes no arguments and cannot fail.
|
||||||
|
self.is_root() || self.uid == unsafe { libc::getuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_stream(stream: &UnixStream) -> Result<Self> {
|
||||||
|
let mut cred = libc::ucred {
|
||||||
|
pid: 0,
|
||||||
|
uid: u32::MAX,
|
||||||
|
gid: u32::MAX,
|
||||||
|
};
|
||||||
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||||
|
// SAFETY: cred and len are correctly sized for SO_PEERCRED, and the fd
|
||||||
|
// is owned by the borrowed stream for the duration of the call.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
stream.as_raw_fd(),
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_PEERCRED,
|
||||||
|
(&mut cred as *mut libc::ucred).cast(),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
bail!(
|
||||||
|
"reading peer credentials: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Peer {
|
||||||
|
pid: cred.pid,
|
||||||
|
uid: cred.uid,
|
||||||
|
gid: cred.gid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a method costs the caller in trust.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Access {
|
||||||
|
/// Reports the daemon's own state. Anyone who can open the socket.
|
||||||
|
Read,
|
||||||
|
/// Reads a caller-supplied path as root; allowed once we have confirmed
|
||||||
|
/// the caller could have read it themselves.
|
||||||
|
ReadsPath,
|
||||||
|
/// Changes the system, or the daemon's configuration. Root only.
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn access_for(method: &str) -> Access {
|
||||||
|
match method {
|
||||||
|
"status"
|
||||||
|
| "settings.get"
|
||||||
|
| "events.list"
|
||||||
|
| "quarantine.list"
|
||||||
|
| "realtime.status"
|
||||||
|
| "rootkit.scan"
|
||||||
|
| "persistence.scan" => Access::Read,
|
||||||
|
|
||||||
|
"scan" | "supply.sweep" => Access::ReadsPath,
|
||||||
|
|
||||||
|
// Everything that writes: definitions updates, settings, the event
|
||||||
|
// log, and every quarantine mutation.
|
||||||
|
_ => Access::Admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can `peer` read `path` in its own right? Answered by the kernel, in a child
|
||||||
|
/// process that has actually become the peer.
|
||||||
|
///
|
||||||
|
/// Errs on the side of refusal: a fork failure, a child that dies on a signal,
|
||||||
|
/// or any unexpected exit status is a no.
|
||||||
|
pub fn peer_can_read(peer: &Peer, path: &Path) -> bool {
|
||||||
|
if peer.is_root() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(c_path) = CString::new(path.as_os_str().as_encoded_bytes()) else {
|
||||||
|
return false; // an interior NUL is not a path we will scan
|
||||||
|
};
|
||||||
|
|
||||||
|
// Impersonating someone requires being root. The packaged daemon is, but
|
||||||
|
// a developer running `houndd` from a build tree is not — and there the
|
||||||
|
// only peer it can answer for is itself.
|
||||||
|
// SAFETY: geteuid takes no arguments and cannot fail.
|
||||||
|
if unsafe { libc::geteuid() } != 0 {
|
||||||
|
// SAFETY: as above.
|
||||||
|
if unsafe { libc::getuid() } != peer.uid {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// SAFETY: c_path is NUL-terminated and outlives the call.
|
||||||
|
return unsafe { libc::access(c_path.as_ptr(), libc::R_OK) } == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = supplementary_groups(peer.uid, peer.gid);
|
||||||
|
|
||||||
|
// SAFETY: between fork and _exit the child calls only async-signal-safe
|
||||||
|
// functions — setgroups, setgid, setuid, access, _exit. It allocates
|
||||||
|
// nothing and unwinds nothing.
|
||||||
|
let pid = unsafe { libc::fork() };
|
||||||
|
if pid < 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if pid == 0 {
|
||||||
|
unsafe {
|
||||||
|
// Order matters: setgroups and setgid must precede setuid, or the
|
||||||
|
// child no longer has the privilege to make those calls.
|
||||||
|
if libc::setgroups(groups.len() as _, groups.as_ptr()) != 0 {
|
||||||
|
libc::_exit(101);
|
||||||
|
}
|
||||||
|
if libc::setgid(peer.gid) != 0 {
|
||||||
|
libc::_exit(102);
|
||||||
|
}
|
||||||
|
if libc::setuid(peer.uid) != 0 {
|
||||||
|
libc::_exit(103);
|
||||||
|
}
|
||||||
|
// Belt and braces: if the drop somehow left us root, refuse.
|
||||||
|
if libc::getuid() == 0 || libc::geteuid() == 0 {
|
||||||
|
libc::_exit(104);
|
||||||
|
}
|
||||||
|
let ok = libc::access(c_path.as_ptr(), libc::R_OK) == 0;
|
||||||
|
libc::_exit(if ok { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status = 0;
|
||||||
|
// SAFETY: pid is a child we just created; status is a valid out-param.
|
||||||
|
if unsafe { libc::waitpid(pid, &mut status, 0) } != pid {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !libc::WIFEXITED(status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match libc::WEXITSTATUS(status) {
|
||||||
|
0 => true,
|
||||||
|
1 => false,
|
||||||
|
// The child could not become the peer, so the question was never
|
||||||
|
// asked. Refuse — but say so, because a daemon that has lost
|
||||||
|
// CAP_SETUID will refuse every non-root scan and the reason should
|
||||||
|
// not have to be guessed from a permission error.
|
||||||
|
code => {
|
||||||
|
eprintln!(
|
||||||
|
"peer check: could not drop to uid {} (stage {code}) — refusing to \
|
||||||
|
vouch for a read this daemon cannot verify",
|
||||||
|
peer.uid
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The peer's supplementary groups, so the check honours group-readable files.
|
||||||
|
/// Falls back to the primary gid alone if the user cannot be resolved.
|
||||||
|
fn supplementary_groups(uid: u32, gid: u32) -> Vec<libc::gid_t> {
|
||||||
|
let Some(name) = username_for(uid) else {
|
||||||
|
return vec![gid];
|
||||||
|
};
|
||||||
|
let Ok(c_name) = CString::new(name) else {
|
||||||
|
return vec![gid];
|
||||||
|
};
|
||||||
|
let mut ngroups: libc::c_int = 32;
|
||||||
|
let mut groups: Vec<libc::gid_t> = vec![0; ngroups as usize];
|
||||||
|
// SAFETY: c_name outlives the call; groups has ngroups capacity. A -1
|
||||||
|
// return rewrites ngroups with the count actually needed, so we retry once.
|
||||||
|
let rc = unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) };
|
||||||
|
if rc < 0 {
|
||||||
|
groups = vec![0; ngroups.max(1) as usize];
|
||||||
|
// SAFETY: as above, now with the size the kernel asked for.
|
||||||
|
if unsafe { libc::getgrouplist(c_name.as_ptr(), gid, groups.as_mut_ptr(), &mut ngroups) } < 0
|
||||||
|
{
|
||||||
|
return vec![gid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.truncate(ngroups.max(0) as usize);
|
||||||
|
if groups.is_empty() {
|
||||||
|
groups.push(gid);
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a uid to a login name via /etc/passwd. Reading the file directly
|
||||||
|
/// keeps this free of NSS, which can block on a network directory service —
|
||||||
|
/// not something a scan request should ever wait on.
|
||||||
|
fn username_for(uid: u32) -> Option<String> {
|
||||||
|
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
|
||||||
|
for line in passwd.lines() {
|
||||||
|
let mut f = line.split(':');
|
||||||
|
let name = f.next()?;
|
||||||
|
let _pw = f.next();
|
||||||
|
let this_uid: u32 = f.next()?.parse().ok()?;
|
||||||
|
if this_uid == uid {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_are_open_and_writes_are_not() {
|
||||||
|
assert_eq!(access_for("status"), Access::Read);
|
||||||
|
assert_eq!(access_for("quarantine.list"), Access::Read);
|
||||||
|
assert_eq!(access_for("scan"), Access::ReadsPath);
|
||||||
|
assert_eq!(access_for("supply.sweep"), Access::ReadsPath);
|
||||||
|
for admin in [
|
||||||
|
"update",
|
||||||
|
"settings.set",
|
||||||
|
"events.clear",
|
||||||
|
"quarantine.add",
|
||||||
|
"quarantine.restore",
|
||||||
|
"quarantine.remove",
|
||||||
|
"realtime.set_enabled",
|
||||||
|
] {
|
||||||
|
assert_eq!(access_for(admin), Access::Admin, "{admin} must require root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unknown method must not fall through to the permissive arm. If
|
||||||
|
/// someone adds a mutating method and forgets to classify it, it lands
|
||||||
|
/// in Admin and fails closed rather than silently becoming public.
|
||||||
|
#[test]
|
||||||
|
fn unclassified_methods_fail_closed() {
|
||||||
|
assert_eq!(access_for("some.future.method"), Access::Admin);
|
||||||
|
assert_eq!(access_for(""), Access::Admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owning user is an administrator of their own daemon; anybody else
|
||||||
|
/// is not. Under a root daemon the two clauses are the same clause.
|
||||||
|
#[test]
|
||||||
|
fn the_owner_administers_and_strangers_do_not() {
|
||||||
|
let me = unsafe { libc::getuid() };
|
||||||
|
assert!(Peer { pid: 1, uid: 0, gid: 0 }.may_administer(), "root always");
|
||||||
|
assert!(Peer { pid: 1, uid: me, gid: 0 }.may_administer(), "the owner");
|
||||||
|
let stranger = if me == 0 { 1000 } else { me + 1 };
|
||||||
|
assert!(
|
||||||
|
!Peer { pid: 1, uid: stranger, gid: 0 }.may_administer(),
|
||||||
|
"a different unprivileged user must not administer the daemon"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_peer_reads_anything() {
|
||||||
|
let root = Peer {
|
||||||
|
pid: 1,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
};
|
||||||
|
assert!(peer_can_read(&root, Path::new("/etc/shadow")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The check is only meaningful if it can say no. Running as an
|
||||||
|
/// unprivileged user, /etc/shadow is the canonical unreadable file;
|
||||||
|
/// running as root there is nothing to prove, so skip. This exercises
|
||||||
|
/// the non-root branch, where the daemon can only answer for itself.
|
||||||
|
#[test]
|
||||||
|
fn unprivileged_peer_is_refused_a_file_it_cannot_open() {
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
if uid == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let gid = unsafe { libc::getgid() };
|
||||||
|
let me = Peer {
|
||||||
|
pid: std::process::id() as i32,
|
||||||
|
uid,
|
||||||
|
gid,
|
||||||
|
};
|
||||||
|
if Path::new("/etc/shadow").exists() {
|
||||||
|
assert!(!peer_can_read(&me, Path::new("/etc/shadow")));
|
||||||
|
}
|
||||||
|
// ...and it must still say yes to something the peer owns.
|
||||||
|
let mine = std::env::temp_dir().join(format!("hound-peer-{uid}-{}", std::process::id()));
|
||||||
|
std::fs::write(&mine, b"x").unwrap();
|
||||||
|
assert!(peer_can_read(&me, &mine));
|
||||||
|
let _ = std::fs::remove_file(&mine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unprivileged daemon cannot impersonate anybody, and must refuse
|
||||||
|
/// rather than answer a question it has no way to check.
|
||||||
|
#[test]
|
||||||
|
fn an_unprivileged_daemon_refuses_to_vouch_for_another_user() {
|
||||||
|
if unsafe { libc::geteuid() } == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let someone_else = Peer {
|
||||||
|
pid: 1,
|
||||||
|
uid: unsafe { libc::getuid() } + 1,
|
||||||
|
gid: 0,
|
||||||
|
};
|
||||||
|
let readable = std::env::temp_dir();
|
||||||
|
assert!(!peer_can_read(&someone_else, &readable));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_is_not_readable() {
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
if uid == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let me = Peer {
|
||||||
|
pid: 1,
|
||||||
|
uid,
|
||||||
|
gid: unsafe { libc::getgid() },
|
||||||
|
};
|
||||||
|
assert!(!peer_can_read(&me, Path::new("/nonexistent/hound/path")));
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
60
gui/dist/app.js
vendored
60
gui/dist/app.js
vendored
|
|
@ -1,10 +1,41 @@
|
||||||
// Hound Antivirus — webview front-end.
|
// Hound Antivirus — webview front-end.
|
||||||
// Thin view over houndd via Tauri commands; 6-tab layout.
|
// Thin view over houndd via Tauri commands; 6-tab layout.
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
// No bundler here — the webview loads this file directly, so a bare module
|
||||||
import { listen } from "@tauri-apps/api/event";
|
// specifier ("@tauri-apps/api/core") cannot be resolved and the entire script
|
||||||
|
// fails to parse. The window then sits on its static HTML forever, looking
|
||||||
|
// exactly like a daemon that never answered. The API comes off the global
|
||||||
|
// that `withGlobalTauri` installs instead.
|
||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// The daemon refuses anything that writes unless the caller is root, because
|
||||||
|
// quarantine restores files back out as root and that is not something group
|
||||||
|
// membership should confer. The app runs as you, so those actions need a
|
||||||
|
// terminal for now. Translate the wire error rather than showing it raw.
|
||||||
|
function explain(e) {
|
||||||
|
const raw = String(e && e.message ? e.message : e);
|
||||||
|
const m = raw.match(/([a-z.]+) requires administrator privileges/);
|
||||||
|
if (m) {
|
||||||
|
return "That needs administrator rights. Run it from a terminal: sudo hound " +
|
||||||
|
({
|
||||||
|
"settings.set": "settings …",
|
||||||
|
"update": "update",
|
||||||
|
"quarantine.add": "quarantine add <file>",
|
||||||
|
"quarantine.restore": "quarantine restore <id>",
|
||||||
|
"quarantine.remove": "quarantine remove <id>",
|
||||||
|
"events.clear": "alerts clear",
|
||||||
|
"realtime.set_enabled": "settings realtime on|off",
|
||||||
|
}[m[1]] || m[1]);
|
||||||
|
}
|
||||||
|
if (/cannot read .* as uid/.test(raw)) {
|
||||||
|
return raw.replace(/^.*?cannot read/, "Hound will not scan") +
|
||||||
|
" (it only scans files you could open yourself).";
|
||||||
|
}
|
||||||
|
return raw.replace(/^daemon error -?\d+: /, "");
|
||||||
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
protected: "state-protected-48.png",
|
protected: "state-protected-48.png",
|
||||||
scanning: "state-scanning-48.png",
|
scanning: "state-scanning-48.png",
|
||||||
|
|
@ -83,6 +114,9 @@ function renderStatus(st) {
|
||||||
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
|
st.engine_present ? `engine online (${st.engine || "unknown"})` : "engine offline");
|
||||||
$("pill-os").textContent = "OS: " + (st.os || "—");
|
$("pill-os").textContent = "OS: " + (st.os || "—");
|
||||||
$("pill-engine").textContent = "engine: " + (st.engine || "—");
|
$("pill-engine").textContent = "engine: " + (st.engine || "—");
|
||||||
|
// The footer used to claim ClamAV. It reports whatever the daemon actually
|
||||||
|
// loaded, which has been yara-x since the engine was replaced.
|
||||||
|
$("foot-engine").textContent = "engine: " + (st.engine || "—");
|
||||||
const pill = $("pill-db");
|
const pill = $("pill-db");
|
||||||
if (st.db) {
|
if (st.db) {
|
||||||
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
|
pill.textContent = "signatures: " + fmtDbAge(st.db.updated_at);
|
||||||
|
|
@ -110,7 +144,7 @@ async function doScan(path) {
|
||||||
const r = await invoke("scan", { path, recursive: true });
|
const r = await invoke("scan", { path, recursive: true });
|
||||||
renderScanResult(r, path);
|
renderScanResult(r, path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("scan-note").textContent = "Scan failed: " + String(e);
|
$("scan-note").textContent = "Scan failed: " + explain(e);
|
||||||
setState(paused ? "paused" : "protected");
|
setState(paused ? "paused" : "protected");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
|
|
@ -175,7 +209,7 @@ async function doUpdate() {
|
||||||
if (u.status) renderStatus(u.status);
|
if (u.status) renderStatus(u.status);
|
||||||
setState(paused ? "paused" : "protected");
|
setState(paused ? "paused" : "protected");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.textContent = "Update failed: " + String(e);
|
log.textContent = "Update failed: " + explain(e);
|
||||||
log.className = "log fail";
|
log.className = "log fail";
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
|
|
@ -252,7 +286,7 @@ async function loadQuarantine() {
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
|
$("qt-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(explain(e))}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +323,7 @@ async function loadRealtime() {
|
||||||
wl.appendChild(chip);
|
wl.appendChild(chip);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("rt-enabled-label").textContent = "error: " + String(e);
|
$("rt-enabled-label").textContent = "error: " + explain(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +360,7 @@ async function runRootkit() {
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(String(e))}</p>`;
|
$("rootkit-body").innerHTML = `<p class="muted empty">Scan failed: ${escapeHtml(explain(e))}</p>`;
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Run Scan";
|
btn.textContent = "Run Scan";
|
||||||
|
|
@ -362,7 +396,7 @@ async function loadAlerts() {
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(String(e))}</p>`;
|
$("alerts-body").innerHTML = `<p class="muted empty">Failed to load: ${escapeHtml(explain(e))}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,7 +420,7 @@ async function loadSettings() {
|
||||||
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
|
$("set-ransom-thresh").value = s.ransomware_threshold_per_min;
|
||||||
$("set-rootkit").checked = s.rootkit_enabled;
|
$("set-rootkit").checked = s.rootkit_enabled;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("settings-msg").textContent = "Failed to load: " + String(e);
|
$("settings-msg").textContent = "Failed to load: " + explain(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,7 +447,7 @@ async function saveSettings() {
|
||||||
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
|
setTimeout(() => ($("settings-msg").textContent = ""), 2500);
|
||||||
loadRealtime().catch(() => {});
|
loadRealtime().catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("settings-msg").textContent = "Save failed: " + String(e);
|
$("settings-msg").textContent = explain(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -507,8 +541,12 @@ async function boot() {
|
||||||
setEngineDot("bad", "engine offline");
|
setEngineDot("bad", "engine offline");
|
||||||
setState("paused");
|
setState("paused");
|
||||||
$("hero-sub").textContent =
|
$("hero-sub").textContent =
|
||||||
"Can't reach houndd. Is the daemon running? (try `cargo run -p houndd`)";
|
"Can't reach houndd. Check that the service is up (`systemctl status houndd`). " +
|
||||||
|
"If it is, you may not be in the `hound` group yet — the installer adds you, " +
|
||||||
|
"but it only takes effect after you log out and back in.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tells the watchdog in index.html that the script actually ran.
|
||||||
|
window.__houndBooted = true;
|
||||||
boot();
|
boot();
|
||||||
|
|
|
||||||
22
gui/dist/index.html
vendored
22
gui/dist/index.html
vendored
|
|
@ -90,7 +90,7 @@
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="progress-bar" id="progress-bar"></div>
|
<div class="progress-bar" id="progress-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="scan-note">ClamAV is working the queue. This can take a while on large folders.</p>
|
<p class="muted" id="scan-note">Working through the queue. This can take a while on large folders.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="results-panel">
|
<section class="panel" id="results-panel">
|
||||||
|
|
@ -276,10 +276,28 @@
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<span id="foot-ver">houndd —</span>
|
<span id="foot-ver">houndd —</span>
|
||||||
<span class="muted">engine: ClamAV via Unix socket</span>
|
<span class="muted" id="foot-engine">engine —</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
|
<script>
|
||||||
|
// A front-end that fails to load looks identical to a daemon that never
|
||||||
|
// answered: the window just sits on its placeholder text. It happened
|
||||||
|
// once (an unresolvable bare module specifier) and cost real debugging
|
||||||
|
// time, so failure now announces itself.
|
||||||
|
setTimeout(function () {
|
||||||
|
if (window.__houndBooted) return;
|
||||||
|
var sub = document.getElementById("hero-sub");
|
||||||
|
if (sub) {
|
||||||
|
sub.textContent =
|
||||||
|
"The Hound interface failed to start. This is a bug in the app, not " +
|
||||||
|
"a problem with your system \u2014 please report it. Scanning still " +
|
||||||
|
"works from the terminal: hound scan ~/Downloads";
|
||||||
|
}
|
||||||
|
var label = document.getElementById("engine-label");
|
||||||
|
if (label) label.textContent = "interface error";
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
15
gui/package-lock.json
generated
15
gui/package-lock.json
generated
|
|
@ -115,9 +115,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -135,9 +132,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -155,9 +149,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -175,9 +166,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -195,9 +183,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = "2"
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@
|
||||||
//!
|
//!
|
||||||
//! protected (green) / scanning (amber) / threat (red) / paused (gray)
|
//! protected (green) / scanning (amber) / threat (red) / paused (gray)
|
||||||
//!
|
//!
|
||||||
|
//! `withGlobalTauri` is on in tauri.conf.json and load-bearing: the front-end
|
||||||
|
//! is plain ES modules with no bundler, so a bare specifier such as
|
||||||
|
//! "@tauri-apps/api/core" cannot resolve in the webview. It does not error
|
||||||
|
//! loudly — the script silently fails to load and the window renders its
|
||||||
|
//! static HTML forever, which is indistinguishable from a daemon that never
|
||||||
|
//! answered. The API comes off `window.__TAURI__` instead.
|
||||||
|
//!
|
||||||
//! A background watcher polls the daemon once a second: it keeps the tray in
|
//! A background watcher polls the daemon once a second: it keeps the tray in
|
||||||
//! step with daemon-side state (realtime threat → red, scan in flight →
|
//! step with daemon-side state (realtime threat → red, scan in flight →
|
||||||
//! amber) and fires a desktop notification for every *new* critical event
|
//! amber) and fires a desktop notification for every *new* critical event
|
||||||
|
|
@ -16,7 +23,6 @@ use hound_api::{
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
|
|
@ -276,41 +282,29 @@ fn notify(app: &tauri::AppHandle, ev: &Event) {
|
||||||
|
|
||||||
// ── Icon resolution ─────────────────────────────────────────────────────────
|
// ── Icon resolution ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn icon_dir(app: &tauri::AppHandle) -> PathBuf {
|
// Tray icons are compiled in rather than read from disk. They were being
|
||||||
// Packaged: resources dir. Dev: the repo's assets/icons.
|
// loaded from a relative "icons/" path, which resolves only when the binary
|
||||||
if let Ok(dir) = app.path().resource_dir() {
|
// runs from its build directory — installed to /usr/bin and launched from the
|
||||||
let d = dir.join("icons");
|
// applications menu, the setup hook failed and the whole app panicked before
|
||||||
if d.join("state-protected-22.png").exists() {
|
// a window appeared. Four PNGs at ~1 KB each is a rounding error on a 12 MB
|
||||||
return d;
|
// binary, and it makes the tray icon unable to be missing.
|
||||||
}
|
const ICON_BYTES: [(&str, &[u8]); 4] = [
|
||||||
}
|
("protected", include_bytes!("../icons/state-protected-22.png")),
|
||||||
for candidate in [
|
("scanning", include_bytes!("../icons/state-scanning-22.png")),
|
||||||
PathBuf::from("icons"),
|
("threat", include_bytes!("../icons/state-threat-22.png")),
|
||||||
PathBuf::from("../src-tauri/icons"),
|
("paused", include_bytes!("../icons/state-paused-22.png")),
|
||||||
PathBuf::from("../assets/icons"),
|
];
|
||||||
] {
|
|
||||||
if candidate.join("state-protected-22.png").exists() {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PathBuf::from("icons")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_state_icons(app: &tauri::AppHandle) -> R<HashMap<String, Image<'static>>> {
|
fn load_state_icons() -> R<HashMap<String, Image<'static>>> {
|
||||||
let dir = icon_dir(app);
|
|
||||||
let mut icons = HashMap::new();
|
let mut icons = HashMap::new();
|
||||||
for state in STATES {
|
for (state, bytes) in ICON_BYTES {
|
||||||
let path = dir.join(format!("state-{state}-22.png"));
|
let img = Image::from_bytes(bytes)
|
||||||
icons.insert(state.to_string(), load_icon(&path)?);
|
.map_err(|e| anyhow::anyhow!("decoding the {state} tray icon: {e}"))?;
|
||||||
|
icons.insert(state.to_string(), img);
|
||||||
}
|
}
|
||||||
Ok(icons)
|
Ok(icons)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a PNG tray icon; Tauri decodes and converts to RGBA for us.
|
|
||||||
fn load_icon(path: &Path) -> R<Image<'static>> {
|
|
||||||
Image::from_path(path).map_err(|e| anyhow::anyhow!("loading tray icon {}: {e}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App ─────────────────────────────────────────────────────────────────────
|
// ── App ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
|
@ -337,7 +331,7 @@ pub fn run() {
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
let icons = TrayIcons(load_state_icons(&handle)?);
|
let icons = TrayIcons(load_state_icons()?);
|
||||||
let initial_icon = icons
|
let initial_icon = icons
|
||||||
.0
|
.0
|
||||||
.get("protected")
|
.get("protected")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"beforeBuildCommand": ""
|
"beforeBuildCommand": ""
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Hound Antivirus",
|
"title": "Hound Antivirus",
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,39 @@ chmod 0755 "$STAGE"
|
||||||
echo "building hound ${VERSION} (${ARCH})"
|
echo "building hound ${VERSION} (${ARCH})"
|
||||||
( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp )
|
( cd "$ROOT" && cargo build --release -p houndd -p hound -p hound-mcp )
|
||||||
|
|
||||||
|
# The desktop app. Optional: a build host without the webkit/gtk
|
||||||
|
# development libraries still produces a working CLI package, it just
|
||||||
|
# does not ship a launcher — which is better than shipping a menu entry
|
||||||
|
# for a binary that is not there.
|
||||||
|
GUI_BIN="$ROOT/gui/src-tauri/target/release/hound-gui"
|
||||||
|
if command -v npx >/dev/null && pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
|
||||||
|
# NOT silenced, and NOT tolerant of failure. Discarding this output once
|
||||||
|
# meant a config error scrolled past unseen and the package shipped the
|
||||||
|
# previous build's binary — the fix looked like it had no effect, twice.
|
||||||
|
( cd "$ROOT/gui" && npm install --no-audit --no-fund >/dev/null \
|
||||||
|
&& npx tauri build --no-bundle )
|
||||||
|
fi
|
||||||
|
# The webview loads dist/*.js directly, with no bundler. A bare module
|
||||||
|
# specifier there does not error loudly — it silently fails to resolve and the
|
||||||
|
# window renders its static HTML forever. Catch it here instead of in a bug
|
||||||
|
# report.
|
||||||
|
if grep -rnE '^\s*import .* from "[^./]' "$ROOT/gui/dist"/*.js 2>/dev/null; then
|
||||||
|
echo "ERROR: bare module specifier in the front-end; the webview cannot resolve it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x "$GUI_BIN" ]; then
|
||||||
|
HAVE_GUI=yes
|
||||||
|
echo " including the desktop app"
|
||||||
|
else
|
||||||
|
HAVE_GUI=no
|
||||||
|
echo " NOTE: no GUI binary — packaging the CLI only, and no launcher"
|
||||||
|
fi
|
||||||
|
|
||||||
install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd"
|
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" "$STAGE/usr/bin/hound"
|
||||||
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
|
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
|
||||||
|
[ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui"
|
||||||
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
|
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
|
||||||
"$STAGE/lib/systemd/system/houndd.service"
|
"$STAGE/lib/systemd/system/houndd.service"
|
||||||
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
|
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
|
||||||
|
|
@ -42,21 +72,32 @@ done
|
||||||
install -Dm644 "$ROOT/assets/icons/hound-app.svg" \
|
install -Dm644 "$ROOT/assets/icons/hound-app.svg" \
|
||||||
"$STAGE/usr/share/icons/hicolor/scalable/apps/hound.svg"
|
"$STAGE/usr/share/icons/hicolor/scalable/apps/hound.svg"
|
||||||
|
|
||||||
|
# A menu entry is a promise that clicking it opens something. It ships
|
||||||
|
# only when the desktop app does, and it launches THAT rather than the
|
||||||
|
# CLI — Exec=hound with Terminal=true opened a terminal, printed help and
|
||||||
|
# exited, which reads to anyone sane as "it does not launch".
|
||||||
|
if [ "$HAVE_GUI" = yes ]; then
|
||||||
install -Dm644 /dev/stdin "$STAGE/usr/share/applications/hound.desktop" <<'DESKTOP'
|
install -Dm644 /dev/stdin "$STAGE/usr/share/applications/hound.desktop" <<'DESKTOP'
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Hound Antivirus
|
Name=Hound Antivirus
|
||||||
GenericName=Antivirus
|
GenericName=Antivirus
|
||||||
Comment=Endpoint and supply-chain protection for Linux
|
Comment=Endpoint and supply-chain protection for Linux
|
||||||
Exec=hound
|
Exec=hound-gui
|
||||||
Icon=hound
|
Icon=hound
|
||||||
Categories=System;Security;Utility;
|
Categories=System;Security;
|
||||||
Keywords=antivirus;malware;security;scan;supply chain;
|
Keywords=antivirus;malware;security;scan;supply chain;
|
||||||
Terminal=true
|
Terminal=false
|
||||||
|
StartupWMClass=hound-gui
|
||||||
|
StartupNotify=true
|
||||||
DESKTOP
|
DESKTOP
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$STAGE/DEBIAN"
|
mkdir -p "$STAGE/DEBIAN"
|
||||||
|
|
||||||
|
GUI_DEPENDS=""
|
||||||
|
[ "$HAVE_GUI" = yes ] && GUI_DEPENDS=", libwebkit2gtk-4.1-0, libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1"
|
||||||
|
|
||||||
cat > "$STAGE/DEBIAN/control" <<CONTROL
|
cat > "$STAGE/DEBIAN/control" <<CONTROL
|
||||||
Package: hound
|
Package: hound
|
||||||
Version: ${VERSION}
|
Version: ${VERSION}
|
||||||
|
|
@ -64,7 +105,7 @@ Section: utils
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: ${ARCH}
|
Architecture: ${ARCH}
|
||||||
Maintainer: Hound <support@houndav.com>
|
Maintainer: Hound <support@houndav.com>
|
||||||
Depends: libc6 (>= 2.34)
|
Depends: libc6 (>= 2.34)${GUI_DEPENDS}
|
||||||
Suggests: clamav-daemon
|
Suggests: clamav-daemon
|
||||||
Homepage: https://houndav.com
|
Homepage: https://houndav.com
|
||||||
Description: Hound Antivirus for Linux
|
Description: Hound Antivirus for Linux
|
||||||
|
|
@ -112,6 +153,29 @@ set -e
|
||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
configure)
|
configure)
|
||||||
|
# The desktop app runs as the logged-in user; the daemon runs as root.
|
||||||
|
# `hound` is how they meet. Membership grants the read side of the API
|
||||||
|
# only — status, scan results, the event log — because quarantine writes
|
||||||
|
# files back out as root and that is not something a group should confer.
|
||||||
|
if ! getent group hound >/dev/null 2>&1; then
|
||||||
|
addgroup --system hound >/dev/null 2>&1 || groupadd -r hound >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Enrol whoever ran the install, since on a desktop that is the person who
|
||||||
|
# will open the app. Group membership only takes effect on their next
|
||||||
|
# login, which is why the notice below says so out loud.
|
||||||
|
ADMIN="${SUDO_USER:-${PKEXEC_UID:-}}"
|
||||||
|
case "$ADMIN" in
|
||||||
|
''|root) ADMIN="" ;;
|
||||||
|
[0-9]*) ADMIN="$(getent passwd "$ADMIN" | cut -d: -f1)" ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$ADMIN" ] && getent group hound >/dev/null 2>&1; then
|
||||||
|
if ! id -nG "$ADMIN" 2>/dev/null | tr ' ' '\n' | grep -qx hound; then
|
||||||
|
adduser "$ADMIN" hound >/dev/null 2>&1 || usermod -aG hound "$ADMIN" >/dev/null 2>&1 || true
|
||||||
|
ADDED_TO_GROUP=yes
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# The vault holds live malware: root-only, and on a filesystem where
|
# The vault holds live malware: root-only, and on a filesystem where
|
||||||
# nothing in it can be executed even by accident.
|
# nothing in it can be executed even by accident.
|
||||||
mkdir -p /var/lib/hound/vault /var/lib/hound/rules /var/log/hound
|
mkdir -p /var/lib/hound/vault /var/lib/hound/rules /var/log/hound
|
||||||
|
|
@ -124,6 +188,15 @@ case "$1" in
|
||||||
# daemon compile them twice and log a duplicate-declaration error on
|
# daemon compile them twice and log a duplicate-declaration error on
|
||||||
# every start. The copy under /usr/share is documentation, not input.
|
# every start. The copy under /usr/share is documentation, not input.
|
||||||
|
|
||||||
|
# Menus cache icons; without this the entry can appear blank until the
|
||||||
|
# user logs out, which is indistinguishable from a broken package.
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -qtf /usr/share/icons/hicolor 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database -q /usr/share/applications 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload || true
|
||||||
systemctl enable houndd.service || true
|
systemctl enable houndd.service || true
|
||||||
|
|
@ -133,6 +206,11 @@ case "$1" in
|
||||||
echo ""
|
echo ""
|
||||||
echo "Hound is installed and scanning on demand."
|
echo "Hound is installed and scanning on demand."
|
||||||
echo ""
|
echo ""
|
||||||
|
if [ "${ADDED_TO_GROUP:-no}" = yes ]; then
|
||||||
|
echo "Added $ADMIN to the 'hound' group so the desktop app can talk to"
|
||||||
|
echo "the daemon. Log out and back in for that to take effect."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
echo " hound status what the daemon sees"
|
echo " hound status what the daemon sees"
|
||||||
echo " hound scan ~/Downloads scan a directory"
|
echo " hound scan ~/Downloads scan a directory"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -174,6 +252,11 @@ case "$1" in
|
||||||
# evidence somebody still needs.
|
# evidence somebody still needs.
|
||||||
echo "Removing the Hound quarantine vault at /var/lib/hound/vault"
|
echo "Removing the Hound quarantine vault at /var/lib/hound/vault"
|
||||||
rm -rf /var/lib/hound /var/log/hound
|
rm -rf /var/lib/hound /var/log/hound
|
||||||
|
# Leave the group behind if anyone is still in it — removing it would
|
||||||
|
# silently strip a gid that could be referenced elsewhere on the system.
|
||||||
|
if getent group hound >/dev/null 2>&1 && [ -z "$(getent group hound | cut -d: -f4)" ]; then
|
||||||
|
delgroup --system hound >/dev/null 2>&1 || groupdel hound >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,12 @@ RestartSec=2s
|
||||||
# files needs DAC_READ_SEARCH; quarantining out of a directory owned by
|
# files needs DAC_READ_SEARCH; quarantining out of a directory owned by
|
||||||
# someone else needs DAC_OVERRIDE; stripping the execute bit off a file
|
# someone else needs DAC_OVERRIDE; stripping the execute bit off a file
|
||||||
# we do not own needs FOWNER.
|
# we do not own needs FOWNER.
|
||||||
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER
|
# SETUID/SETGID let the daemon fork a child, become the user who sent a scan
|
||||||
|
# request, and ask the kernel whether that user could have opened the file —
|
||||||
|
# which is what stops an unprivileged caller using a root scanner to probe
|
||||||
|
# files it cannot read. It is not an escalation: DAC_OVERRIDE below already
|
||||||
|
# grants this process every file on the system.
|
||||||
|
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETUID CAP_SETGID
|
||||||
# AmbientCapabilities is deliberately NOT set. Ambient capabilities are
|
# AmbientCapabilities is deliberately NOT set. Ambient capabilities are
|
||||||
# inherited by child processes, and the daemon shells out to freshclam,
|
# inherited by child processes, and the daemon shells out to freshclam,
|
||||||
# rpm and pacman on some paths — none of which should start life holding
|
# rpm and pacman on some paths — none of which should start life holding
|
||||||
|
|
@ -45,6 +50,13 @@ ReadWritePaths=/var/lib/hound /var/log/hound
|
||||||
StateDirectory=hound
|
StateDirectory=hound
|
||||||
LogsDirectory=hound
|
LogsDirectory=hound
|
||||||
RuntimeDirectory=hound
|
RuntimeDirectory=hound
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
# The daemon stays uid 0 — it has to read files no user can. Its *group* is
|
||||||
|
# `hound`, which is what lets it open the control socket to the desktop app
|
||||||
|
# without CAP_CHOWN: a process can already chgrp a file it owns to a group it
|
||||||
|
# belongs to. Membership of `hound` buys the read side of the API and nothing
|
||||||
|
# that writes; the daemon checks SO_PEERCRED per method (see peer.rs).
|
||||||
|
Group=hound
|
||||||
PrivateTmp=no
|
PrivateTmp=no
|
||||||
|
|
||||||
# ── Everything else we can shut off ──────────────────────────────────
|
# ── Everything else we can shut off ──────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,12 @@ code, .inline {
|
||||||
.tier .who { color: var(--faint); font-size: 13.5px; margin-top: 8px; min-height: 42px; }
|
.tier .who { color: var(--faint); font-size: 13.5px; margin-top: 8px; min-height: 42px; }
|
||||||
.tier hr { border: 0; border-top: 1px solid var(--line); margin: 22px 0 20px; }
|
.tier hr { border: 0; border-top: 1px solid var(--line); margin: 22px 0 20px; }
|
||||||
.tier ul { list-style: none; margin: 0 0 26px; display: flex; flex-direction: column; gap: 12px; flex: 1 1 auto; }
|
.tier ul { list-style: none; margin: 0 0 26px; display: flex; flex-direction: column; gap: 12px; flex: 1 1 auto; }
|
||||||
.tier li { display: grid; grid-template-columns: 20px 1fr; gap: 10px; font-size: 14.5px; color: var(--dim); line-height: 1.5; }
|
/* Two columns, therefore exactly two children: the tick and ONE box
|
||||||
|
holding all the text. A bare text node sitting beside a <strong>
|
||||||
|
becomes its own anonymous grid item, lands in the 20px icon column,
|
||||||
|
and wraps one word per line. */
|
||||||
|
.tier li { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 10px; font-size: 14.5px; color: var(--dim); line-height: 1.55; align-items: start; }
|
||||||
|
.tier li > span { min-width: 0; }
|
||||||
.tier li svg { width: 15px; height: 15px; margin-top: 4px; color: var(--ok); }
|
.tier li svg { width: 15px; height: 15px; margin-top: 4px; color: var(--ok); }
|
||||||
.tier li strong { color: var(--fg); font-weight: 600; }
|
.tier li strong { color: var(--fg); font-weight: 600; }
|
||||||
.tier .btn { width: 100%; justify-content: center; padding: 13px 17px; }
|
.tier .btn { width: 100%; justify-content: center; padding: 13px 17px; }
|
||||||
|
|
@ -606,11 +611,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f
|
||||||
<p class="who">Everyone. No account, no telemetry, no expiry.</p>
|
<p class="who">Everyone. No account, no telemetry, no expiry.</p>
|
||||||
<hr>
|
<hr>
|
||||||
<ul>
|
<ul>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>On-demand scanning, CLI and desktop app</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>On-demand scanning, CLI and desktop app</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Real-time file protection</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Real-time file protection</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Quarantine vault with one-click restore</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Quarantine vault with one-click restore</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>MCP server for your coding assistant</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>MCP server for your coding assistant</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Rootkit and persistence checks</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Rootkit and persistence checks</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="btn ghost" href="#install">Install</a>
|
<a class="btn ghost" href="#install">Install</a>
|
||||||
<p class="fine">Apache-2.0. Yours to read and to fork.</p>
|
<p class="fine">Apache-2.0. Yours to read and to fork.</p>
|
||||||
|
|
@ -623,11 +628,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f
|
||||||
<p class="who">One machine, for someone who builds software on it.</p>
|
<p class="who">One machine, for someone who builds software on it.</p>
|
||||||
<hr>
|
<hr>
|
||||||
<ul>
|
<ul>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Everything in Free</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Everything in Free</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><strong>Execution gate</strong> — a malicious binary is refused before it runs, in 2 ms</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span><strong>Execution gate</strong> — a malicious binary is refused before it runs, in 2 ms</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><strong>Supply-chain scanner</strong> against all 235,577 indicators</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span><strong>Supply-chain scanner</strong> against all 235,577 indicators</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><strong>Hound Linux threat pack</strong> — miners, backdoors, rootkits, webshells</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span><strong>Hound Linux threat pack</strong> — miners, backdoors, rootkits, webshells</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Signed definitions, updated automatically</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Signed definitions, updated automatically</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="btn" href="#install">Get Pro</a>
|
<a class="btn" href="#install">Get Pro</a>
|
||||||
<p class="fine">Annual only. Billed once, cancel any time.</p>
|
<p class="fine">Annual only. Billed once, cancel any time.</p>
|
||||||
|
|
@ -639,11 +644,11 @@ footer { border-top: 1px solid var(--line); padding: 40px 0 56px; color: var(--f
|
||||||
<p class="who">Up to 10 seats, then $15 each. Annual at ten months' price.</p>
|
<p class="who">Up to 10 seats, then $15 each. Annual at ten months' price.</p>
|
||||||
<hr>
|
<hr>
|
||||||
<ul>
|
<ul>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Everything in Pro, on every machine</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Everything in Pro, on every machine</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Central console, enrolment and policy push</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Central console, enrolment and policy push</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><strong>Compliance reports</strong> an auditor accepts</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span><strong>Compliance reports</strong> an auditor accepts</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Air-gapped definition mirrors</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Air-gapped definition mirrors</span></li>
|
||||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Priority support and a named contact</li>
|
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span>Priority support and a named contact</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="btn ghost" href="mailto:sales@houndav.com?subject=Hound%20Fleet">Talk to us</a>
|
<a class="btn ghost" href="mailto:sales@houndav.com?subject=Hound%20Fleet">Talk to us</a>
|
||||||
<p class="fine">Over 20 seats? We will quote you.</p>
|
<p class="fine">Over 20 seats? We will quote you.</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue