Skip to main content

actplane_bpf/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3//
4//! ActPlane eBPF loader (aya).
5//!
6//! Loads the prebuilt CO-RE object `process.bpf.o` (compiled from the untouched
7//! kernel C in this directory), installs the compiled policy into `.rodata`,
8//! attaches the enforcer, and surfaces `TAINT_VIOLATION` events. This is the
9//! pure-Rust replacement for the C `process` loader: same behavior, but loaded
10//! in-process via aya with no libbpf/clang at runtime.
11//!
12//! The config blob is exactly the `struct taint_config` the collector's DSL
13//! compiler already produces (the same bytes the C loader read from `--config`).
14
15use std::io::{self, Read};
16use std::os::fd::AsRawFd;
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use aya::maps::{Array, HashMap, RingBuf};
20use aya::programs::{Lsm, TracePoint};
21use aya::{Btf, Ebpf, EbpfLoader};
22
23// ---- prebuilt eBPF object, 8-byte aligned for aya's ELF parser ----
24#[repr(align(8))]
25struct Aligned<T: ?Sized>(T);
26static OBJECT: &Aligned<[u8]> = &Aligned(*include_bytes!(concat!(env!("OUT_DIR"), "/process.bpf.o")));
27fn object_bytes() -> &'static [u8] {
28    &OBJECT.0
29}
30
31// ===================== ABI mirrors (must match bpf/taint.h) =====================
32// Identical to collector/src/dsl/lower.rs; guarded by abi_size_matches() below.
33const PAT: usize = 64;
34const ARG: usize = 24;
35const MAX_SOURCES: usize = 128;
36const MAX_RULES: usize = 128;
37const MAX_XFORMS: usize = 64;
38const MAX_GATES: usize = 64;
39const MAX_INVALS: usize = 64;
40
41#[repr(C)]
42#[derive(Clone, Copy)]
43struct CSource {
44    kind: u8,
45    m: u8,
46    pat: [u8; PAT],
47    label: u64,
48    ipv4: u32,
49    ipv4_mask: u32,
50}
51#[repr(C)]
52#[derive(Clone, Copy)]
53struct CRule {
54    op: u8,
55    m: u8,
56    cond_kind: u8,
57    cond_neg: u8,
58    cond_match: u8,
59    effect: u8,
60    target: [u8; PAT],
61    arg: [u8; ARG],
62    cond_pat: [u8; PAT],
63    req: u64,
64    forbid: u64,
65    gate: u64,
66    rule_id: u32,
67    ipv4: u32,
68    ipv4_mask: u32,
69    cond_ipv4: u32,
70    cond_ipv4_mask: u32,
71    gate_idx: u32,
72    since_mask: u64,
73}
74#[repr(C)]
75#[derive(Clone, Copy)]
76struct CXform {
77    m: u8,
78    add: u8,
79    gate: [u8; PAT],
80    label: u64,
81}
82#[repr(C)]
83#[derive(Clone, Copy)]
84struct CGate {
85    m: u8,
86    pat: [u8; PAT],
87    bit: u64,
88}
89#[repr(C)]
90#[derive(Clone, Copy)]
91struct CInval {
92    op: u8,
93    m: u8,
94    pat: [u8; PAT],
95}
96#[repr(C)]
97#[derive(Clone, Copy)]
98struct CConfig {
99    n_sources: u32,
100    n_rules: u32,
101    n_xforms: u32,
102    n_gates: u32,
103    n_invals: u32,
104    sources: [CSource; MAX_SOURCES],
105    rules: [CRule; MAX_RULES],
106    xforms: [CXform; MAX_XFORMS],
107    gates: [CGate; MAX_GATES],
108    invals: [CInval; MAX_INVALS],
109}
110
111// proc_state seed (bpf/taint_engine.bpf.h: { u64 labels; u64 lin_gates; }).
112#[repr(C)]
113#[derive(Clone, Copy)]
114struct ProcState {
115    labels: u64,
116    lin_gates: u64,
117}
118
119unsafe impl aya::Pod for CSource {}
120unsafe impl aya::Pod for CRule {}
121unsafe impl aya::Pod for CXform {}
122unsafe impl aya::Pod for CGate {}
123unsafe impl aya::Pod for CInval {}
124unsafe impl aya::Pod for ProcState {}
125
126// ringbuf event (bpf/process.h: struct event).
127const EVENT_TYPE_TAINT_VIOLATION: i32 = 3;
128const COMM_LEN: usize = 16;
129const FILENAME_LEN: usize = 127;
130
131#[repr(C)]
132#[derive(Clone, Copy)]
133struct Event {
134    etype: i32,
135    pid: i32,
136    ppid: i32,
137    blocked: u32,
138    killed: u32,
139    effect: u32,
140    timestamp_ns: u64,
141    comm: [u8; COMM_LEN],
142    filename: [u8; FILENAME_LEN],
143    taint_rule_id: u32,
144    conn_ip: u32,
145    taint_label: u64,
146}
147
148/// A policy violation reported by the kernel.
149#[derive(Debug, Clone)]
150pub struct Violation {
151    pub effect: u32, // 0 audit, 1 block, 2 kill
152    pub blocked: bool,
153    pub killed: bool,
154    pub comm: String,
155    pub pid: i32,
156    pub ppid: i32,
157    pub target: String, // exe/path, or "a.b.c.d" for connect
158    pub rule_id: u32,
159    pub label: u64,
160    pub timestamp_ns: u64,
161}
162
163/// Tracepoint programs: (fn name, category, event). Always attached.
164const TRACEPOINTS: &[(&str, &str, &str)] = &[
165    ("handle_fork", "sched", "sched_process_fork"),
166    ("handle_exec", "sched", "sched_process_exec"),
167    ("handle_exit", "sched", "sched_process_exit"),
168    ("trace_openat", "syscalls", "sys_enter_openat"),
169    ("trace_openat_exit", "syscalls", "sys_exit_openat"),
170    ("trace_open", "syscalls", "sys_enter_open"),
171    ("trace_open_exit", "syscalls", "sys_exit_open"),
172    ("trace_openat2", "syscalls", "sys_enter_openat2"),
173    ("trace_openat2_exit", "syscalls", "sys_exit_openat2"),
174    ("trace_creat", "syscalls", "sys_enter_creat"),
175    ("trace_creat_exit", "syscalls", "sys_exit_creat"),
176    ("trace_truncate", "syscalls", "sys_enter_truncate"),
177    ("trace_truncate_exit", "syscalls", "sys_exit_truncate"),
178    ("trace_unlink", "syscalls", "sys_enter_unlink"),
179    ("trace_unlinkat", "syscalls", "sys_enter_unlinkat"),
180    ("trace_rename", "syscalls", "sys_enter_rename"),
181    ("trace_renameat", "syscalls", "sys_enter_renameat"),
182    ("trace_renameat2", "syscalls", "sys_enter_renameat2"),
183    ("trace_connect", "syscalls", "sys_enter_connect"),
184];
185
186/// LSM programs: (fn name, hook). Attached only when BPF LSM is active.
187const LSM_PROGS: &[(&str, &str)] = &[
188    ("enforce_bprm_check_security", "bprm_check_security"),
189    ("enforce_file_open", "file_open"),
190    ("enforce_file_permission", "file_permission"),
191    ("enforce_file_truncate", "file_truncate"),
192    ("enforce_path_truncate", "path_truncate"),
193    ("enforce_path_unlink", "path_unlink"),
194    ("enforce_path_rename", "path_rename"),
195    ("enforce_socket_connect", "socket_connect"),
196];
197
198/// True if `bpf` appears in the active LSM list (enables pre-op `block`).
199pub fn bpf_lsm_active() -> bool {
200    let mut s = String::new();
201    if let Ok(mut f) = std::fs::File::open("/sys/kernel/security/lsm") {
202        let _ = f.read_to_string(&mut s);
203    }
204    s.split(',').any(|x| x.trim() == "bpf")
205}
206
207fn err(msg: impl Into<String>) -> io::Error {
208    io::Error::new(io::ErrorKind::Other, msg.into())
209}
210
211pub struct Loader {
212    bpf: Ebpf,
213    enforce: bool,
214}
215
216impl Loader {
217    /// `config_blob` is the raw `struct taint_config` produced by the collector.
218    pub fn load(config_blob: &[u8]) -> io::Result<Self> {
219        if config_blob.len() != std::mem::size_of::<CConfig>() {
220            return Err(err(format!(
221                "config size mismatch: got {}, expected {}",
222                config_blob.len(),
223                std::mem::size_of::<CConfig>()
224            )));
225        }
226        // Owned, aligned copy so we can borrow fields for set_global.
227        let cfg: Box<CConfig> =
228            Box::new(unsafe { std::ptr::read_unaligned(config_blob.as_ptr() as *const CConfig) });
229
230        let enforce = bpf_lsm_active();
231        let enforce_mode: u32 = if enforce { 1 } else { 0 };
232
233        let mut loader = EbpfLoader::new();
234        loader
235            .set_global("enforce_mode", &enforce_mode, true)
236            .set_global("n_sources", &cfg.n_sources, true)
237            .set_global("n_rules", &cfg.n_rules, true)
238            .set_global("n_xforms", &cfg.n_xforms, true)
239            .set_global("n_gates", &cfg.n_gates, true)
240            .set_global("n_invals", &cfg.n_invals, true)
241            .set_global("taint_sources", &cfg.sources[..], true)
242            .set_global("taint_rules", &cfg.rules[..], true)
243            .set_global("taint_xforms", &cfg.xforms[..], true)
244            .set_global("taint_gates", &cfg.gates[..], true)
245            .set_global("taint_invals", &cfg.invals[..], true);
246
247        let mut bpf = loader
248            .load(object_bytes())
249            .map_err(|e| err(format!("Ebpf::load: {e}")))?;
250
251        // Loop counts in a (non-frozen) map so the verifier analyzes each
252        // bpf_loop callback once. Slots: 0=rules 1=sources 2=xforms 3=gates 4=invals.
253        {
254            let mut counts: Array<_, u32> = Array::try_from(
255                bpf.map_mut("ts_counts").ok_or_else(|| err("map ts_counts missing"))?,
256            )
257            .map_err(|e| err(format!("ts_counts: {e}")))?;
258            let vals = [
259                cfg.n_rules,
260                cfg.n_sources,
261                cfg.n_xforms,
262                cfg.n_gates,
263                cfg.n_invals,
264            ];
265            for (i, v) in vals.iter().enumerate() {
266                counts
267                    .set(i as u32, *v, 0)
268                    .map_err(|e| err(format!("ts_counts[{i}]: {e}")))?;
269            }
270        }
271
272        // Attach tracepoints (always) then LSM programs (only with BPF LSM).
273        for (name, cat, event) in TRACEPOINTS {
274            let p: &mut TracePoint = bpf
275                .program_mut(name)
276                .ok_or_else(|| err(format!("program {name} missing")))?
277                .try_into()
278                .map_err(|e| err(format!("{name} not a tracepoint: {e}")))?;
279            p.load().map_err(|e| err(format!("{name}.load: {e}")))?;
280            p.attach(cat, event)
281                .map_err(|e| err(format!("{name}.attach: {e}")))?;
282        }
283        if enforce {
284            let btf = Btf::from_sys_fs().map_err(|e| err(format!("btf: {e}")))?;
285            for (name, hook) in LSM_PROGS {
286                let p: &mut Lsm = bpf
287                    .program_mut(name)
288                    .ok_or_else(|| err(format!("program {name} missing")))?
289                    .try_into()
290                    .map_err(|e| err(format!("{name} not an lsm: {e}")))?;
291                p.load(hook, &btf)
292                    .map_err(|e| err(format!("{name}.load: {e}")))?;
293                p.attach().map_err(|e| err(format!("{name}.attach: {e}")))?;
294            }
295        }
296
297        Ok(Loader { bpf, enforce })
298    }
299
300    pub fn enforce_mode(&self) -> bool {
301        self.enforce
302    }
303
304    /// Seed `pid` (and its future descendants) as the AGENT root with `label`.
305    pub fn seed_agent(&mut self, pid: i32, label: u64) -> io::Result<()> {
306        if pid <= 0 || label == 0 {
307            return Err(err("agent pid and label must both be set"));
308        }
309        {
310            let mut proc: HashMap<_, i32, ProcState> = HashMap::try_from(
311                self.bpf.map_mut("ts_proc").ok_or_else(|| err("ts_proc missing"))?,
312            )
313            .map_err(|e| err(format!("ts_proc: {e}")))?;
314            proc.insert(pid, ProcState { labels: label, lin_gates: 0 }, 0)
315                .map_err(|e| err(format!("seed ts_proc: {e}")))?;
316        }
317        {
318            let mut root: HashMap<_, i32, i32> = HashMap::try_from(
319                self.bpf.map_mut("ts_root").ok_or_else(|| err("ts_root missing"))?,
320            )
321            .map_err(|e| err(format!("ts_root: {e}")))?;
322            root.insert(pid, pid, 0)
323                .map_err(|e| err(format!("seed ts_root: {e}")))?;
324        }
325        Ok(())
326    }
327
328    /// Poll the ring buffer until `stop` is set, delivering each violation.
329    pub fn run(&mut self, stop: &AtomicBool, mut on: impl FnMut(Violation)) -> io::Result<()> {
330        let mut ring = RingBuf::try_from(
331            self.bpf.map_mut("rb").ok_or_else(|| err("rb missing"))?,
332        )
333        .map_err(|e| err(format!("rb: {e}")))?;
334        let fd = ring.as_raw_fd();
335
336        while !stop.load(Ordering::Relaxed) {
337            let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
338            let r = unsafe { libc::poll(&mut pfd, 1, 100) };
339            if r < 0 {
340                let e = io::Error::last_os_error();
341                if e.kind() == io::ErrorKind::Interrupted {
342                    continue;
343                }
344                return Err(e);
345            }
346            while let Some(item) = ring.next() {
347                let bytes: &[u8] = &item;
348                if bytes.len() < std::mem::size_of::<Event>() {
349                    continue;
350                }
351                let e: Event =
352                    unsafe { std::ptr::read_unaligned(bytes.as_ptr() as *const Event) };
353                if e.etype != EVENT_TYPE_TAINT_VIOLATION {
354                    continue;
355                }
356                on(decode(&e));
357            }
358        }
359        Ok(())
360    }
361}
362
363fn cstr(buf: &[u8]) -> String {
364    let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
365    String::from_utf8_lossy(&buf[..end]).into_owned()
366}
367
368fn decode(e: &Event) -> Violation {
369    let target = if e.conn_ip != 0 {
370        let ip = e.conn_ip; // network order: bytes are a.b.c.d in memory
371        format!(
372            "{}.{}.{}.{}",
373            ip & 0xff,
374            (ip >> 8) & 0xff,
375            (ip >> 16) & 0xff,
376            (ip >> 24) & 0xff
377        )
378    } else {
379        cstr(&e.filename)
380    };
381    Violation {
382        effect: e.effect,
383        blocked: e.blocked != 0,
384        killed: e.killed != 0,
385        comm: cstr(&e.comm),
386        pid: e.pid,
387        ppid: e.ppid,
388        target,
389        rule_id: e.taint_rule_id,
390        label: e.taint_label,
391        timestamp_ns: e.timestamp_ns,
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    // The Rust ABI mirror must match the C struct sizes the object was built
400    // with. These are the documented sizes from bpf/taint.h.
401    #[test]
402    fn abi_sizes() {
403        assert_eq!(std::mem::size_of::<ProcState>(), 16);
404        // CConfig = 5 u32 (+pad) + the five arrays; just assert it is non-trivial
405        // and 8-aligned so set_global offsets line up.
406        assert_eq!(std::mem::align_of::<CConfig>(), 8);
407        assert!(std::mem::size_of::<CConfig>() > 0);
408    }
409
410    #[test]
411    fn object_is_aligned_elf() {
412        let b = object_bytes();
413        assert_eq!(b.as_ptr() as usize % 8, 0, "object must be 8-aligned");
414        assert_eq!(&b[..4], b"\x7fELF");
415    }
416}