Antivirus/crates/houndd/src/peer.rs
dev 79ea89713e Phase 1: license enforcement, threat pack, Apache-2.0, real Action verification
Make the product buyable and the open-source claim true.

Licence system, end to end. license.rs was well-designed dead code; wire
it up: an Ed25519-signed token (same key and verify-before-parse discipline
as definition packs), `hound license install`, houndd loads and verifies at
boot, and the execution gate and full supply-chain feed now gate on
Capability checks. Verification failing always degrades to Free, never to a
locked-out security tool; an expired licence downgrades with the reason
shown. Adds tools/issue-license.py.

Hound Linux threat pack. 34 curated YARA rules — miners, IoT/DDoS bots,
backdoors, rootkits, ransomware, webshells, droppers, reverse shells —
shipped through a new signed rules-pack channel (.rpack) alongside the
definitions feed. Every rule is ELF- or size-anchored and keyed on
family strings, never syscalls; the builder refuses to sign a pack that
matches a system binary (the goodware gate caught two bad rules), and a
regression test proves every rule fires on a sample and stays quiet on a
document about malware.

Action signature verification. The composite action claimed Ed25519
verification "against the same signed manifest the desktop agent uses" but
only compared a same-host sha256. It now fetches latest.json, verifies the
Ed25519 signature over the canonical release statement against the pinned
release key, and installs the checksum from the verified manifest.

Licence resolved to Apache-2.0: Cargo.toml, a real LICENSE file, README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:51:33 -05:00

370 lines
13 KiB
Rust

//! 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"
// Asking whether a newer version exists changes nothing, and reveals
// nothing the website does not already say.
| "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,
// 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);
// 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 [
"update",
"settings.set",
"events.clear",
"quarantine.add",
"quarantine.restore",
"quarantine.remove",
"realtime.set_enabled",
// Installing a licence writes a file the daemon acts on.
"license.install",
] {
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")));
}
}