linprov 0.3.0

eBPF mark-of-the-web for Linux: tag network-touched files and enforce who can exec them.
//! Daemon-side rule control behind the `linprov allow` control socket:
//! the recent-blocks token table and the in-memory transient (`--once`)
//! rules. The BPF rules map is always (re)seeded from `combined()` =
//! persistent file rules ∪ transient rules, so an `allow --once` survives
//! SIGHUP file-reloads but is gone on daemon restart (never written to
//! disk), while a plain `allow` is appended to the allowlist file.

use std::{
    collections::{HashMap, VecDeque},
    fs::OpenOptions,
    io::Write,
    path::PathBuf,
};

use anyhow::{anyhow, Context, Result};
use linprov_common::{fnv_hash, MAX_RULES};

use crate::{
    allowlist::{RuleSpec, Rules},
    config::restrict_perms,
    encoding::escape,
};

/// Cap on remembered block tokens (LRU). Plenty for "see it in the log,
/// allow it" without unbounded growth on a noisy enforce box.
const MAX_BLOCKS: usize = 512;

/// A blocked exec, broadcast to control-socket `subscribe`rs (the
/// `linprov notify` tray agent). Tab-serialized on the wire as
/// `BLOCK\t<token>\t<kind>\t<target>\t<creator>`.
#[derive(Clone, Debug)]
pub struct BlockEvent {
    /// `"exec"` (ELF/shebang, bprm) or `"script"` (interpreter-loaded).
    pub kind: &'static str,
    /// The `[allow: <token>]` handle — feeds `allow`/`once`.
    pub token: String,
    /// The blocked path (exec target / script).
    pub target: String,
    /// Resolved creator exe path, or the creator `comm` if the path is
    /// unknown — for display in the tray/notification.
    pub creator: String,
}

impl BlockEvent {
    /// One-line wire form for the subscribe stream. Fields are
    /// tab-separated and assumed newline/tab-free (paths from `d_path`,
    /// a hex token, a short kind).
    pub fn to_wire(&self) -> String {
        // target/creator are kernel-derived paths; escape them so an embedded
        // tab or newline can't desync this tab-separated, newline-framed wire.
        format!(
            "BLOCK\t{}\t{}\t{}\t{}",
            self.token,
            self.kind,
            escape(&self.target),
            escape(&self.creator)
        )
    }
}

/// Bounded `token → candidate-rule-line` table of recent blocked execs.
#[derive(Default)]
pub struct BlocksTable {
    map: HashMap<String, String>,
    order: VecDeque<String>,
}

impl BlocksTable {
    /// Record the candidate rule for a blocked exec and return its stable
    /// token (short FNV of the rule line — identical blocks reuse it, so
    /// the token an operator copies from the log keeps resolving).
    pub fn record(&mut self, rule_line: String) -> String {
        let token = format!("{:08x}", fnv_hash(&rule_line) as u32);
        if !self.map.contains_key(&token) {
            if self.order.len() >= MAX_BLOCKS {
                if let Some(old) = self.order.pop_front() {
                    self.map.remove(&old);
                }
            }
            self.order.push_back(token.clone());
            self.map.insert(token.clone(), rule_line);
        }
        token
    }

    fn rule_for(&self, token: &str) -> Option<&str> {
        self.map.get(token).map(String::as_str)
    }
}

/// Daemon-side rule control: the persistent allowlist path, the in-memory
/// transient (`allow --once`) rules, and the recent-blocks table.
pub struct Control {
    allowlist_path: Option<PathBuf>,
    transient: Vec<RuleSpec>,
    pub blocks: BlocksTable,
}

impl Control {
    pub fn new(allowlist_path: Option<PathBuf>) -> Self {
        Self {
            allowlist_path,
            transient: Vec::new(),
            blocks: BlocksTable::default(),
        }
    }

    /// The full rule set to seed into the BPF map: file rules (freshly
    /// read, so a SIGHUP/allow picks up external edits) plus the in-memory
    /// transient rules.
    pub fn combined(&self) -> Result<Vec<RuleSpec>> {
        let mut rules = match &self.allowlist_path {
            Some(p) => Rules::load(p)?.rules,
            None => Vec::new(),
        };
        rules.extend(self.transient.iter().cloned());
        Ok(rules)
    }

    /// Apply the rule for `token`. `once` → add to the in-memory transient
    /// set (active until daemon restart, never written to the file).
    /// Otherwise append it to the allowlist file (deduped against disk).
    /// Returns the applied rule line; the caller reseeds from `combined()`.
    pub fn apply(&mut self, token: &str, once: bool) -> Result<String> {
        let line = self
            .blocks
            .rule_for(token)
            .ok_or_else(|| {
                anyhow!("unknown token `{token}` (expired, or nothing blocked this daemon session)")
            })?
            .to_string();
        // Validate it still parses (and normalize it through the parser).
        RuleSpec::parse(&line).with_context(|| format!("re-parsing candidate rule `{line}`"))?;

        if once {
            if self.transient.iter().any(|r| r.to_line() == line) {
                return Ok(line); // already active this session
            }
            // Transient rules seed into the BPF map AFTER the file rules
            // (see `combined`), so once file ∪ transient fills the map any
            // new transient rule is silently truncated at seed time. Refuse
            // loudly instead of replying OK for a rule that won't take effect.
            if self.combined()?.len() >= MAX_RULES {
                return Err(anyhow!(
                    "allowlist is at capacity ({MAX_RULES} rules); `allow --once` can't \
                     take effect — transient rules seed after the file rules and would \
                     be dropped. Trim the allowlist (or restart) first."
                ));
            }
            self.transient.push(RuleSpec::parse(&line)?);
        } else {
            self.append_persistent(&line)?;
        }
        Ok(line)
    }

    fn append_persistent(&mut self, line: &str) -> Result<()> {
        let path = self.allowlist_path.as_ref().ok_or_else(|| {
            anyhow!("no allowlist file configured; cannot persist this rule (try --once)")
        })?;
        // Dedup against what's already on disk so repeated `allow` of the
        // same block doesn't pile up duplicate lines.
        if Rules::load(path)?.rules.iter().any(|r| r.to_line() == line) {
            return Ok(());
        }
        let mut f = OpenOptions::new()
            .create(true)
            .append(true)
            .open(path)
            .with_context(|| format!("opening `{}` to append", path.display()))?;
        restrict_perms(path); // first `allow` may create it — keep it 0640
        writeln!(f, "{line}").with_context(|| format!("appending `{line}`"))?;
        f.sync_data().ok();
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn once_refused_when_allowlist_at_capacity() {
        // A full allowlist file (MAX_RULES distinct rules) leaves no room for
        // a transient rule — it would be silently truncated at seed time — so
        // `allow --once` must error rather than reply OK (finding N5).
        let mut f = tempfile::NamedTempFile::new().unwrap();
        for i in 0..MAX_RULES {
            writeln!(f, "creator_uid={i}").unwrap();
        }
        f.flush().unwrap();
        let mut control = Control::new(Some(f.path().to_path_buf()));
        let token = control.blocks.record("creator_uid=4294967295".to_string());
        let err = control.apply(&token, true).unwrap_err();
        assert!(format!("{err:#}").contains("at capacity"), "{err:#}");
    }

    #[test]
    fn once_added_when_under_capacity() {
        let mut control = Control::new(None); // no file → 0 rules
        let token = control.blocks.record("creator_uid=1000".to_string());
        let rule = control.apply(&token, true).unwrap();
        assert_eq!(rule, "creator_uid=1000");
        assert_eq!(control.combined().unwrap().len(), 1);
    }
}