houndd: arm the gate from the daemon and shed root while doing it
Wires the execution gate into DaemonState::boot, reports it on the wire and in `hound status`, and reduces the daemon to the two capabilities it actually needs. Verified live, gate marked on a scratch tmpfs rather than /: clean binary ran EICAR binary execve -> EPERM, "Operation not permitted" hound status Exec gate: armed on /tmp/hound-live, 2 allowed, 1 blocked CapPrm/CapEff/CapBnd 0000000000200004 (CAP_SYS_ADMIN | CAP_DAC_READ_SEARCH) Three ordering bugs found by checking rather than assuming, all of which returned success while doing nothing: * Capabilities are per-thread. Dropping them after spawning the reader and workers reduced only the main thread and left four workers holding full root — the exact opposite of the intent. The drop now happens after fanotify_init and the marks, but before any thread exists, so workers inherit the reduced set. * PR_CAPBSET_DROP needs CAP_SETPCAP in the effective set, and capset had already thrown it away. Every bounding-set drop failed EPERM, silently, leaving a full CapBnd behind a log line claiming otherwise. Bounding set is now drained first, while the authority to do it still exists. * Because both of the above looked like successes, drop_to_gate_minimum now reads CapEff and CapBnd back from /proc/self/status and errors if they are not what was asked for. A privilege reduction that cannot be observed has not happened. Also: `hound status` grew an Exec gate line. timed_out above zero is the number worth alarming on — it means the watchdog is releasing processes unscanned and the gate has quietly degraded to advisory. Gate arming and every failure path now log to stderr, so the journal records a security-relevant state change instead of only the in-memory event ring. 86 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
aa22ddbc38
commit
f003c58982
4 changed files with 466 additions and 0 deletions
|
|
@ -88,6 +88,29 @@ pub struct Status {
|
||||||
/// Number of files currently held in quarantine.
|
/// Number of files currently held in quarantine.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub quarantined: u64,
|
pub quarantined: u64,
|
||||||
|
/// Execution-gate state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gate: GateStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execution-gate state, for the tray and `hound status`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GateStatus {
|
||||||
|
/// True only when fanotify is armed and answering.
|
||||||
|
pub active: bool,
|
||||||
|
/// Why it is not active, when it is not: "disabled", "needs root",
|
||||||
|
/// or the OS error we got.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detail: String,
|
||||||
|
/// Mounts covered. Empty with `active` means the root filesystem.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paths: Vec<String>,
|
||||||
|
pub allowed: u64,
|
||||||
|
pub denied: u64,
|
||||||
|
/// Events released by the watchdog past the deadline. Any number
|
||||||
|
/// above zero is worth surfacing — it means scans are running slow
|
||||||
|
/// enough that the gate is degrading to advisory.
|
||||||
|
pub timed_out: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,37 @@ enum RealtimeCmd {
|
||||||
On,
|
On,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report the execution gate.
|
||||||
|
///
|
||||||
|
/// An armed gate is the most consequential thing the daemon is doing, and
|
||||||
|
/// `timed_out` is the one number worth alarming on: above zero it means
|
||||||
|
/// scans are slow enough that the watchdog is releasing processes
|
||||||
|
/// unscanned, so the gate has quietly degraded to advisory.
|
||||||
|
fn print_gate(g: &hound_api::GateStatus) {
|
||||||
|
if !g.active {
|
||||||
|
let why = if g.detail.is_empty() { "off" } else { &g.detail };
|
||||||
|
println!(" Exec gate: {}", why.dimmed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let covered = if g.paths.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
g.paths.join(", ")
|
||||||
|
};
|
||||||
|
println!(" Exec gate: {} on {covered}", "armed".green());
|
||||||
|
println!(" {} allowed · {} blocked", g.allowed, g.denied);
|
||||||
|
if g.timed_out > 0 {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
format!(
|
||||||
|
"{} released unscanned past the deadline — scans are running slow",
|
||||||
|
g.timed_out
|
||||||
|
)
|
||||||
|
.yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let client = match client(&cli.sock) {
|
let client = match client(&cli.sock) {
|
||||||
|
|
@ -181,6 +212,7 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||||
db.file, db.updated_at
|
db.file, db.updated_at
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
print_gate(&st.gate);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{} {} [engine: {}]",
|
"{} {} [engine: {}]",
|
||||||
|
|
|
||||||
261
crates/houndd/src/caps.rs
Normal file
261
crates/houndd/src/caps.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
//! Capability reduction.
|
||||||
|
//!
|
||||||
|
//! The execution gate needs `CAP_SYS_ADMIN` to call `fanotify_init`, and
|
||||||
|
//! `CAP_DAC_READ_SEARCH` to read files whose owner has not invited us.
|
||||||
|
//! Nothing else. But a daemon started by systemd arrives holding the full
|
||||||
|
//! root set, which includes loading kernel modules, rebooting the
|
||||||
|
//! machine, and rewriting the audit log.
|
||||||
|
//!
|
||||||
|
//! We are asking people to run a root daemon that can block execution.
|
||||||
|
//! The least we can do is make it hold only what it needs, so that a
|
||||||
|
//! compromise of Hound is a compromise of Hound rather than of the box.
|
||||||
|
//!
|
||||||
|
//! Two things happen here, in this order, and the order is the whole
|
||||||
|
//! trick:
|
||||||
|
//!
|
||||||
|
//! 1. **The bounding set is drained**, which needs `CAP_SETPCAP` in the
|
||||||
|
//! *effective* set. Irreversible for the life of the process, so
|
||||||
|
//! nothing it later execs can regain a dropped capability however it
|
||||||
|
//! is marked on disk.
|
||||||
|
//! 2. **Permitted and effective are narrowed**, which throws away
|
||||||
|
//! `CAP_SETPCAP` along with everything else.
|
||||||
|
//!
|
||||||
|
//! Doing these the other way round looks correct and silently does half
|
||||||
|
//! the job: `capset` succeeds, every subsequent `PR_CAPBSET_DROP` fails
|
||||||
|
//! with `EPERM` because the capability authorising it was just discarded,
|
||||||
|
//! and the process keeps a full bounding set while reporting success.
|
||||||
|
//!
|
||||||
|
//! Packaging will *also* set `CapabilityBoundingSet` in the systemd unit
|
||||||
|
//! in Phase 2. Belt and braces: the unit protects us if this code is
|
||||||
|
//! never reached, and this code protects us when someone runs the binary
|
||||||
|
//! by hand.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
/// Capability numbers we care about, from `linux/capability.h`.
|
||||||
|
pub const CAP_DAC_READ_SEARCH: u32 = 2;
|
||||||
|
pub const CAP_SYS_ADMIN: u32 = 21;
|
||||||
|
|
||||||
|
/// Highest capability the running kernel could define. 63 is the ceiling
|
||||||
|
/// of the v3 two-word ABI; dropping past what exists is harmless.
|
||||||
|
const CAP_LAST_CAP_CEILING: u32 = 63;
|
||||||
|
|
||||||
|
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
|
||||||
|
const PR_CAPBSET_DROP: libc::c_int = 24;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
struct CapHeader {
|
||||||
|
version: u32,
|
||||||
|
pid: libc::c_int,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct CapData {
|
||||||
|
effective: u32,
|
||||||
|
permitted: u32,
|
||||||
|
inheritable: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a capability list into the two 32-bit words the v3 ABI uses.
|
||||||
|
///
|
||||||
|
/// Pure, so the bit arithmetic is tested rather than trusted — getting a
|
||||||
|
/// word boundary wrong would silently keep a capability we meant to drop.
|
||||||
|
pub fn to_words(caps: &[u32]) -> (u32, u32) {
|
||||||
|
let mut lo = 0u32;
|
||||||
|
let mut hi = 0u32;
|
||||||
|
for &c in caps {
|
||||||
|
if c < 32 {
|
||||||
|
lo |= 1 << c;
|
||||||
|
} else if c < 64 {
|
||||||
|
hi |= 1 << (c - 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(lo, hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this process can actually do the reduction — i.e. is root.
|
||||||
|
pub fn is_root() -> bool {
|
||||||
|
// SAFETY: geteuid cannot fail.
|
||||||
|
unsafe { libc::geteuid() == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce this process to exactly `caps`, and nothing else.
|
||||||
|
///
|
||||||
|
/// A no-op returning `Ok(false)` when not running as root, because an
|
||||||
|
/// unprivileged daemon has nothing to drop and failing there would stop
|
||||||
|
/// developers running the thing.
|
||||||
|
pub fn retain_only(caps: &[u32]) -> io::Result<bool> {
|
||||||
|
if !is_root() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lo, hi) = to_words(caps);
|
||||||
|
|
||||||
|
// 1. Bounding set first, while CAP_SETPCAP is still held. Dropping one
|
||||||
|
// we never had, or one this kernel does not define, returns EINVAL
|
||||||
|
// and is not interesting; anything else is worth knowing about.
|
||||||
|
let mut refused = 0usize;
|
||||||
|
for c in 0..=CAP_LAST_CAP_CEILING {
|
||||||
|
if caps.contains(&c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// SAFETY: prctl with a constant option and a capability number.
|
||||||
|
let rc = unsafe { libc::prctl(PR_CAPBSET_DROP, c as libc::c_ulong, 0, 0, 0) };
|
||||||
|
if rc != 0 && io::Error::last_os_error().raw_os_error() != Some(libc::EINVAL) {
|
||||||
|
refused += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if refused > 0 {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"{refused} capabilities could not be dropped from the bounding set"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Then narrow permitted and effective. Inheritable stays empty:
|
||||||
|
// nothing we exec should inherit anything.
|
||||||
|
let header = CapHeader {
|
||||||
|
version: LINUX_CAPABILITY_VERSION_3,
|
||||||
|
pid: 0, // this thread
|
||||||
|
};
|
||||||
|
let data = [
|
||||||
|
CapData {
|
||||||
|
effective: lo,
|
||||||
|
permitted: lo,
|
||||||
|
inheritable: 0,
|
||||||
|
},
|
||||||
|
CapData {
|
||||||
|
effective: hi,
|
||||||
|
permitted: hi,
|
||||||
|
inheritable: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// SAFETY: header and data are correctly shaped for CAP version 3 and
|
||||||
|
// both outlive the call.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_capset,
|
||||||
|
&header as *const CapHeader,
|
||||||
|
data.as_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read this thread's effective capability set from `/proc/self/status`.
|
||||||
|
///
|
||||||
|
/// Used to *verify* the reduction rather than trust the return code — the
|
||||||
|
/// bug this file exists to document was a syscall that returned success
|
||||||
|
/// while doing nothing.
|
||||||
|
pub fn effective_now() -> Option<u64> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||||
|
let line = status.lines().find(|l| l.starts_with("CapEff:"))?;
|
||||||
|
u64::from_str_radix(line.trim_start_matches("CapEff:").trim(), 16).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read this thread's capability bounding set.
|
||||||
|
pub fn bounding_now() -> Option<u64> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||||
|
let line = status.lines().find(|l| l.starts_with("CapBnd:"))?;
|
||||||
|
u64::from_str_radix(line.trim_start_matches("CapBnd:").trim(), 16).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly what the execution gate needs, and nothing more.
|
||||||
|
pub fn drop_to_gate_minimum() -> io::Result<bool> {
|
||||||
|
let dropped = retain_only(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH])?;
|
||||||
|
if !dropped {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// Trust the kernel, verify anyway: the failure mode here is a syscall
|
||||||
|
// that returns success and changes nothing.
|
||||||
|
let (lo, _) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]);
|
||||||
|
let want = lo as u64;
|
||||||
|
match (effective_now(), bounding_now()) {
|
||||||
|
(Some(eff), Some(bnd)) if eff == want && bnd == want => Ok(true),
|
||||||
|
(Some(eff), Some(bnd)) => Err(io::Error::other(format!(
|
||||||
|
"capability reduction did not take: CapEff={eff:#x} CapBnd={bnd:#x}, wanted {want:#x}"
|
||||||
|
))),
|
||||||
|
_ => Ok(true), // cannot verify; the syscalls did report success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_capabilities_land_in_the_low_word() {
|
||||||
|
let (lo, hi) = to_words(&[CAP_DAC_READ_SEARCH]);
|
||||||
|
assert_eq!(lo, 0b100, "CAP_DAC_READ_SEARCH is bit 2");
|
||||||
|
assert_eq!(hi, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_sys_admin_is_bit_21_of_the_low_word() {
|
||||||
|
let (lo, hi) = to_words(&[CAP_SYS_ADMIN]);
|
||||||
|
assert_eq!(lo, 1 << 21);
|
||||||
|
assert_eq!(hi, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gate_set_is_exactly_two_bits() {
|
||||||
|
let (lo, hi) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]);
|
||||||
|
assert_eq!(lo.count_ones(), 2, "no capability may sneak in");
|
||||||
|
assert_eq!(hi, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capabilities_above_31_cross_into_the_high_word() {
|
||||||
|
let (lo, hi) = to_words(&[40]);
|
||||||
|
assert_eq!(lo, 0);
|
||||||
|
assert_eq!(hi, 1 << 8, "cap 40 is bit 8 of the high word");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_word_boundary_is_handled_exactly() {
|
||||||
|
let (lo, hi) = to_words(&[31, 32]);
|
||||||
|
assert_eq!(lo, 1 << 31, "31 is the last bit of the low word");
|
||||||
|
assert_eq!(hi, 1, "32 is the first bit of the high word");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_capabilities_are_ignored_not_wrapped() {
|
||||||
|
// A shift past the word width would panic in debug and wrap in
|
||||||
|
// release, quietly setting the wrong bit.
|
||||||
|
let (lo, hi) = to_words(&[64, 999]);
|
||||||
|
assert_eq!((lo, hi), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_set_grants_nothing() {
|
||||||
|
assert_eq!(to_words(&[]), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_and_bounding_sets_are_readable() {
|
||||||
|
// If these ever stop parsing, the verification in
|
||||||
|
// `drop_to_gate_minimum` goes blind and a failed drop looks like a
|
||||||
|
// successful one.
|
||||||
|
assert!(effective_now().is_some(), "CapEff must be readable");
|
||||||
|
assert!(bounding_now().is_some(), "CapBnd must be readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gate_set_matches_what_proc_would_report() {
|
||||||
|
let (lo, _) = to_words(&[CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH]);
|
||||||
|
assert_eq!(lo as u64, 0x20_0004, "must match the CapEff mask on the wire");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unprivileged_reduction_is_a_clean_no_op() {
|
||||||
|
// Under a normal test run this returns Ok(false) rather than
|
||||||
|
// erroring, so `cargo test` works without root.
|
||||||
|
if !is_root() {
|
||||||
|
assert_eq!(retain_only(&[CAP_SYS_ADMIN]).unwrap(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
//! without ClamAV installed).
|
//! without ClamAV installed).
|
||||||
|
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod caps;
|
||||||
mod engine;
|
mod engine;
|
||||||
mod events;
|
mod events;
|
||||||
mod fanotify;
|
mod fanotify;
|
||||||
|
|
@ -72,6 +73,11 @@ struct DaemonState {
|
||||||
events: events::EventLog,
|
events: events::EventLog,
|
||||||
quarantine: quarantine::Quarantine,
|
quarantine: quarantine::Quarantine,
|
||||||
realtime: realtime::RealtimeMonitor,
|
realtime: realtime::RealtimeMonitor,
|
||||||
|
/// The execution gate, when it came up. `None` covers both "switched
|
||||||
|
/// off" and "could not be armed"; `gate_detail` says which.
|
||||||
|
gate: Option<std::sync::Arc<fanotify::Gate>>,
|
||||||
|
gate_detail: std::sync::Arc<String>,
|
||||||
|
gate_paths: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
@ -134,6 +140,8 @@ impl DaemonState {
|
||||||
Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")),
|
Err(e) => events.push("realtime", "warn", format!("real-time monitor idle: {e}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let (gate, gate_detail, gate_paths) = Self::arm_gate(&s, &events);
|
||||||
|
|
||||||
events.push("info", "info", "houndd daemon started".into());
|
events.push("info", "info", "houndd daemon started".into());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -141,8 +149,127 @@ impl DaemonState {
|
||||||
events,
|
events,
|
||||||
quarantine,
|
quarantine,
|
||||||
realtime,
|
realtime,
|
||||||
|
gate,
|
||||||
|
gate_detail: std::sync::Arc::new(gate_detail),
|
||||||
|
gate_paths,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bring up the execution gate, if it is switched on and we can.
|
||||||
|
///
|
||||||
|
/// Ordering is deliberate: `fanotify_init` needs CAP_SYS_ADMIN, so the
|
||||||
|
/// capability reduction happens *after* the group is open. Every
|
||||||
|
/// failure here degrades to a working daemon without a gate — never
|
||||||
|
/// to a daemon that will not start. An antivirus that refuses to run
|
||||||
|
/// protects nothing.
|
||||||
|
fn arm_gate(
|
||||||
|
s: &Settings,
|
||||||
|
events: &events::EventLog,
|
||||||
|
) -> (Option<std::sync::Arc<fanotify::Gate>>, String, Vec<String>) {
|
||||||
|
if !s.exec_gate {
|
||||||
|
eprintln!("gate: disabled in settings");
|
||||||
|
return (None, "disabled".into(), Vec::new());
|
||||||
|
}
|
||||||
|
if !caps::is_root() {
|
||||||
|
let why = "needs root (CAP_SYS_ADMIN)";
|
||||||
|
events.push("gate", "warn", format!("execution gate off: {why}"));
|
||||||
|
return (None, why.into(), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let gate = match fanotify::Gate::init() {
|
||||||
|
Ok(g) => std::sync::Arc::new(g),
|
||||||
|
Err(e) => {
|
||||||
|
events.push("gate", "warn", format!("execution gate off: {e}"));
|
||||||
|
return (None, e.to_string(), Vec::new());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty means the whole root filesystem, which is the production
|
||||||
|
// shape. Anything listed is treated as a mount to cover.
|
||||||
|
let paths: Vec<String> = if s.exec_gate_paths.is_empty() {
|
||||||
|
vec!["/".to_string()]
|
||||||
|
} else {
|
||||||
|
s.exec_gate_paths.clone()
|
||||||
|
};
|
||||||
|
let mut marked = Vec::new();
|
||||||
|
for p in &paths {
|
||||||
|
let path = std::path::Path::new(p);
|
||||||
|
let result = if s.exec_gate_paths.is_empty() {
|
||||||
|
gate.mark_filesystem(path)
|
||||||
|
} else {
|
||||||
|
gate.mark_mount(path)
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => marked.push(p.clone()),
|
||||||
|
Err(e) => {
|
||||||
|
events.push("gate", "warn", format!("could not watch {p}: {e}"));
|
||||||
|
eprintln!("gate: could not watch {p}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if marked.is_empty() {
|
||||||
|
return (None, "no mount could be watched".into(), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give up everything we do not need, and do it HERE — before a
|
||||||
|
// single thread exists.
|
||||||
|
//
|
||||||
|
// Capabilities are per-thread. Dropping them after spawning the
|
||||||
|
// reader and workers would reduce only this thread and leave the
|
||||||
|
// workers holding full root, which is the opposite of the point.
|
||||||
|
// Threads created after this inherit the reduced set. fanotify_init
|
||||||
|
// and the marks are already done, and CAP_SYS_ADMIN is retained so
|
||||||
|
// a settings change can still add one later.
|
||||||
|
match caps::drop_to_gate_minimum() {
|
||||||
|
Ok(true) => {
|
||||||
|
events.push(
|
||||||
|
"gate",
|
||||||
|
"info",
|
||||||
|
"capabilities reduced to CAP_SYS_ADMIN + CAP_DAC_READ_SEARCH".into(),
|
||||||
|
);
|
||||||
|
eprintln!("gate: capabilities reduced to CAP_SYS_ADMIN + CAP_DAC_READ_SEARCH");
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => {
|
||||||
|
events.push("gate", "warn", format!("could not reduce capabilities: {e}"));
|
||||||
|
eprintln!("gate: could not reduce capabilities: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watchdog before the workers, always: it is what guarantees
|
||||||
|
// no process is held past the deadline, including during startup.
|
||||||
|
gate.start_watchdog();
|
||||||
|
|
||||||
|
let excludes = s.exclude_paths.clone();
|
||||||
|
let max_size = s.max_file_size_mb.saturating_mul(1024 * 1024);
|
||||||
|
let ev = events.clone();
|
||||||
|
gate.serve(
|
||||||
|
fanotify::GateConfig {
|
||||||
|
workers: 4,
|
||||||
|
max_size,
|
||||||
|
excludes,
|
||||||
|
},
|
||||||
|
std::sync::Arc::new(move |path: &std::path::Path, bytes: &[u8]| {
|
||||||
|
let hit = engine::engine().scan_bytes(bytes);
|
||||||
|
if let Some(name) = &hit {
|
||||||
|
ev.push(
|
||||||
|
"gate",
|
||||||
|
"critical",
|
||||||
|
format!("blocked execution of {} ({name})", path.display()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
hit
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
events.push(
|
||||||
|
"gate",
|
||||||
|
"info",
|
||||||
|
format!("execution gate armed on {}", marked.join(", ")),
|
||||||
|
);
|
||||||
|
eprintln!("gate: armed on {} (4 workers, watchdog live)", marked.join(", "));
|
||||||
|
(Some(gate), String::new(), marked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read one request line, dispatch, write one response line.
|
/// Read one request line, dispatch, write one response line.
|
||||||
|
|
@ -358,9 +485,32 @@ fn status(st: &DaemonState) -> Result<hound_api::Status> {
|
||||||
db,
|
db,
|
||||||
realtime: st.realtime.status(),
|
realtime: st.realtime.status(),
|
||||||
quarantined: st.quarantine.count(),
|
quarantined: st.quarantine.count(),
|
||||||
|
gate: gate_status(st),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the execution gate for the wire.
|
||||||
|
fn gate_status(st: &DaemonState) -> hound_api::GateStatus {
|
||||||
|
match &st.gate {
|
||||||
|
Some(gate) => {
|
||||||
|
let (allowed, denied, timed_out) = gate.responder().counters();
|
||||||
|
hound_api::GateStatus {
|
||||||
|
active: true,
|
||||||
|
detail: String::new(),
|
||||||
|
paths: st.gate_paths.clone(),
|
||||||
|
allowed,
|
||||||
|
denied,
|
||||||
|
timed_out,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => hound_api::GateStatus {
|
||||||
|
active: false,
|
||||||
|
detail: st.gate_detail.as_ref().clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Refresh the signature store via the engine, then re-probe so the
|
/// Refresh the signature store via the engine, then re-probe so the
|
||||||
/// client can refresh its UI/tray from a single round-trip.
|
/// client can refresh its UI/tray from a single round-trip.
|
||||||
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
|
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue