diff --git a/src/agentsight/build.rs b/src/agentsight/build.rs index f6b420a64..2b9acac38 100644 --- a/src/agentsight/build.rs +++ b/src/agentsight/build.rs @@ -61,7 +61,11 @@ fn main() { // Generate tcpsniff skeleton (no header — reuses sslsniff.h event format) generate_skeleton(&mut out, "tcpsniff"); - + + // Generate lsmaudit skeleton and bindings + generate_skeleton(&mut out, "lsmaudit"); + generate_header(&mut out, "lsmaudit"); + // generate_header(&mut out, "frametypes"); // generate_header(&mut out, "errors"); // generate_header(&mut out, "stackdeltatypes"); diff --git a/src/agentsight/src/bin/cli/trace.rs b/src/agentsight/src/bin/cli/trace.rs index 8a5dec188..bf961bbaa 100644 --- a/src/agentsight/src/bin/cli/trace.rs +++ b/src/agentsight/src/bin/cli/trace.rs @@ -22,6 +22,11 @@ pub struct TraceCommand { #[structopt(long)] pub enable_filewatch: bool, + /// Enable LSM security-audit probe (observe-only: records outbound + /// connections and file opens for traced agents; requires BPF LSM active) + #[structopt(long)] + pub enable_lsm_audit: bool, + /// Path to JSON configuration file #[structopt(short, long, default_value = "/etc/agentsight/config.json")] pub config: String, @@ -72,6 +77,7 @@ impl TraceCommand { let config = AgentsightConfig::new() .set_verbose(self.verbose) .set_enable_filewatch(self.enable_filewatch) + .set_enable_lsm_audit(self.enable_lsm_audit) .set_config_path(std::path::PathBuf::from(&self.config)); // Create AgentSight (auto-attaches probes and starts polling) diff --git a/src/agentsight/src/bpf/common.h b/src/agentsight/src/bpf/common.h index c99dbb79a..aa348a35f 100644 --- a/src/agentsight/src/bpf/common.h +++ b/src/agentsight/src/bpf/common.h @@ -23,6 +23,7 @@ typedef enum { EVENT_SOURCE_FILEWATCH = 4, // File watch events (filewatch) EVENT_SOURCE_FILEWRITE = 5, // File write events (filewrite) EVENT_SOURCE_UDPDNS = 6, // UDP DNS query events (udpdns) + EVENT_SOURCE_LSM = 8, // LSM security-audit events (lsmaudit) } event_source_t; // Common event header - every ringbuffer event MUST start with this diff --git a/src/agentsight/src/bpf/lsmaudit.bpf.c b/src/agentsight/src/bpf/lsmaudit.bpf.c new file mode 100644 index 000000000..b3b1c06cc --- /dev/null +++ b/src/agentsight/src/bpf/lsmaudit.bpf.c @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// LSM audit BPF program — observe-only security auditing for traced Agent +// families. It attaches to BPF LSM hooks but NEVER changes the verdict: every +// program returns the incoming `ret` unchanged, so no operation is ever denied. +// (The LSM attach point keeps the door open to future enforcement.) +// +// - lsm/socket_connect: records each outbound connection (dst IP:port). +// This is the signal the other probes miss — sslsniff/tcpsniff/udpdns only +// see TLS / specific ports / DNS, whereas this catches every connect() +// regardless of protocol or port. +// - lsm/file_open: records each file opened (basename + open flags), bounded +// by a per-(pid,inode) LRU so a chatty Agent cannot flood the ring buffer. +// NOTE: bpf_d_path is allowlist-rejected for lsm/file_open (the allowlist +// keys on security_file_open, not the bpf_lsm_file_open attach point), so we +// record the basename via the proven filewrite pattern; full path is future +// work. +// +// Filtered to traced Agent families via the shared traced_processes map +// (is_pid_traced). Emits through the shared ring buffer (EVENT_SOURCE_LSM). +#include "vmlinux.h" +#include +#include +#include +#include "lsmaudit.h" +#include "common.h" + +#ifndef AF_INET +#define AF_INET 2 +#endif +#ifndef AF_INET6 +#define AF_INET6 10 +#endif + +// Per-(pid,inode) dedup for file_open: emit once per file per process until LRU +// eviction, so repeated opens of the same file don't flood the ring buffer. +struct file_open_key { + u64 pid; + u64 ino; +}; + +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 8192); + __type(key, struct file_open_key); + __type(value, u8); +} file_open_seen SEC(".maps"); + +// lsm/socket_connect — one event per outbound connection attempt. +SEC("lsm/socket_connect") +int BPF_PROG(audit_socket_connect, struct socket *sock, struct sockaddr *address, + int addrlen, int ret) +{ + u64 pid_tgid = bpf_get_current_pid_tgid(); + u32 pid = pid_tgid >> 32; + u32 ns_pid = is_pid_traced(pid); + if (!ns_pid) + return ret; // observe-only: propagate prior verdict unchanged + + u16 family = BPF_CORE_READ(address, sa_family); + if (family != AF_INET && family != AF_INET6) + return ret; // skip AF_UNIX and friends + + // LSM hook fires before the protocol-level length check, so the sockaddr + // fields beyond what addrlen covers may be uninitialized stack residue. + // Drop the event rather than read garbage as the destination. + if (family == AF_INET && addrlen < (int)sizeof(struct sockaddr_in)) + return ret; + if (family == AF_INET6 && addrlen < (int)sizeof(struct sockaddr_in6)) + return ret; + + struct lsm_audit_event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0); + if (!e) + return ret; + + // Zero the whole record so any unwritten byte (especially path[]'s tail + // after the null terminator) cannot leak previous ringbuf contents — the + // ringbuf is shared with sslsniff/filewrite, so leftover bytes can be + // real TLS plaintext or file content. + __builtin_memset(e, 0, sizeof(*e)); + + e->source = EVENT_SOURCE_LSM; + e->kind = LSM_EVENT_CONNECT; + e->pid = ns_pid; + e->tid = (u32)pid_tgid; + e->uid = bpf_get_current_uid_gid(); + e->timestamp_ns = bpf_ktime_get_ns(); + e->family = (u8)family; + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + + if (family == AF_INET) { + struct sockaddr_in *in4 = (struct sockaddr_in *)address; + e->dport = BPF_CORE_READ(in4, sin_port); + u32 a = BPF_CORE_READ(in4, sin_addr.s_addr); + __builtin_memcpy(e->daddr, &a, sizeof(a)); + } else { + struct sockaddr_in6 *in6 = (struct sockaddr_in6 *)address; + e->dport = BPF_CORE_READ(in6, sin6_port); + BPF_CORE_READ_INTO(&e->daddr, in6, sin6_addr); + } + + bpf_ringbuf_submit(e, 0); + return ret; +} + +// lsm/file_open — one event per (pid,inode), deduped via LRU. +SEC("lsm/file_open") +int BPF_PROG(audit_file_open, struct file *file, int ret) +{ + u64 pid_tgid = bpf_get_current_pid_tgid(); + u32 pid = pid_tgid >> 32; + u32 ns_pid = is_pid_traced(pid); + if (!ns_pid) + return ret; // observe-only: propagate prior verdict unchanged + + struct file_open_key key = { + .pid = ns_pid, + .ino = BPF_CORE_READ(file, f_inode, i_ino), + }; + if (bpf_map_lookup_elem(&file_open_seen, &key)) + return ret; // already recorded this file for this process + + // basename from file->f_path.dentry->d_name.name (proven filewrite pattern) + const unsigned char *name = BPF_CORE_READ(file, f_path.dentry, d_name.name); + if (!name) + return ret; + + struct lsm_audit_event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0); + if (!e) + return ret; + + // Zero the whole record — see audit_socket_connect for rationale (path[]'s + // tail after the null terminator must not leak ringbuf residue). + __builtin_memset(e, 0, sizeof(*e)); + + e->source = EVENT_SOURCE_LSM; + e->kind = LSM_EVENT_FILE_OPEN; + e->pid = ns_pid; + e->tid = (u32)pid_tgid; + e->uid = bpf_get_current_uid_gid(); + e->timestamp_ns = bpf_ktime_get_ns(); + e->open_flags = BPF_CORE_READ(file, f_flags); + bpf_get_current_comm(&e->comm, sizeof(e->comm)); + + int n = bpf_probe_read_kernel_str(e->path, sizeof(e->path), name); + if (n <= 0) { + // Path read failed — discard the slot and DO NOT mark the dedup so a + // future open of the same file still gets a chance to be recorded. + bpf_ringbuf_discard(e, 0); + return ret; + } + + bpf_ringbuf_submit(e, 0); + + // Mark dedup AFTER successful submit so reserve / read failures above do + // not permanently suppress the file for this pid until LRU eviction. + u8 one = 1; + bpf_map_update_elem(&file_open_seen, &key, &one, BPF_ANY); + return ret; +} + +char LICENSE[] SEC("license") = "GPL"; diff --git a/src/agentsight/src/bpf/lsmaudit.h b/src/agentsight/src/bpf/lsmaudit.h new file mode 100644 index 000000000..a02a47f20 --- /dev/null +++ b/src/agentsight/src/bpf/lsmaudit.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// LSM audit BPF program header +// Observe-only security auditing of traced Agent families via BPF LSM hooks: +// - socket_connect: every outbound connection attempt (dst IP:port) +// - file_open: every file opened (basename + open flags) +#ifndef __LSMAUDIT_H +#define __LSMAUDIT_H + +#define LSM_COMM_LEN 16 +#define LSM_PATH_LEN 256 + +typedef signed char s8; +typedef unsigned char u8; +typedef signed short s16; +typedef unsigned short u16; +typedef signed int s32; +typedef unsigned int u32; +typedef signed long long s64; +typedef unsigned long long u64; + +enum lsm_event_kind { + LSM_EVENT_CONNECT = 1, + LSM_EVENT_FILE_OPEN = 2, +}; + +// A single fixed-size record covers both hooks; `kind` selects which fields are +// meaningful. Unused fields are zeroed by the producer. +// NB: named lsm_audit_event (not lsm_event) — the kernel's vmlinux.h already +// declares `enum lsm_event`, which would collide. +struct lsm_audit_event { + u32 source; // EVENT_SOURCE_LSM + u32 pid; // namespace PID of the traced Agent (is_pid_traced result) + u32 tid; // thread id + u32 uid; + u64 timestamp_ns; + u8 kind; // enum lsm_event_kind + u8 family; // connect: AF_INET(2) / AF_INET6(10); file_open: 0 + u16 dport; // connect: destination port, network byte order + s32 open_flags; // file_open: file->f_flags; connect: 0 + u8 daddr[16]; // connect: IPv4 in [0..4] / IPv6 in [0..16]; file_open: 0 + char comm[LSM_COMM_LEN]; + char path[LSM_PATH_LEN]; // file_open: file basename; connect: empty +}; + +#endif /* __LSMAUDIT_H */ diff --git a/src/agentsight/src/config.rs b/src/agentsight/src/config.rs index 1bf37f436..bf508a5e7 100644 --- a/src/agentsight/src/config.rs +++ b/src/agentsight/src/config.rs @@ -418,6 +418,9 @@ pub struct AgentsightConfig { pub poll_timeout_ms: u64, /// Enable file watch probe (monitors .jsonl file opens from traced processes) pub enable_filewatch: bool, + /// Enable LSM security-audit probe (observe-only socket_connect + file_open). + /// Requires BPF LSM to be active (`bpf` in /sys/kernel/security/lsm). + pub enable_lsm_audit: bool, /// TCP capture targets for plain HTTP capture (empty = disabled). /// Each entry specifies destination IP, port, or both. pub tcp_targets: Vec, @@ -490,6 +493,7 @@ impl Default for AgentsightConfig { target_uid: None, poll_timeout_ms: DEFAULT_POLL_TIMEOUT_MS, enable_filewatch: false, + enable_lsm_audit: false, tcp_targets: Vec::new(), // HTTP/Aggregation defaults @@ -580,6 +584,12 @@ impl AgentsightConfig { self } + /// Set enable_lsm_audit + pub fn set_enable_lsm_audit(mut self, enable: bool) -> Self { + self.enable_lsm_audit = enable; + self + } + /// Set connection capacity pub fn set_connection_capacity(mut self, capacity: usize) -> Self { self.connection_capacity = capacity; diff --git a/src/agentsight/src/event.rs b/src/agentsight/src/event.rs index fde41b4c1..e6fdbd8ff 100644 --- a/src/agentsight/src/event.rs +++ b/src/agentsight/src/event.rs @@ -4,6 +4,7 @@ use crate::probes::procmon::Event as ProcMonEvent; use crate::probes::filewatch::FileWatchEvent; use crate::probes::filewrite::FileWriteEvent; use crate::probes::udpdns::UdpDnsEvent; +use crate::probes::lsmaudit::LsmEvent; /// Unified event type that can represent any probe event /// @@ -16,6 +17,7 @@ pub enum Event { FileWatch(FileWatchEvent), FileWrite(FileWriteEvent), UdpDns(UdpDnsEvent), + Lsm(LsmEvent), } impl Event { @@ -28,6 +30,7 @@ impl Event { Event::FileWatch(_) => "FileWatch", Event::FileWrite(_) => "FileWrite", Event::UdpDns(_) => "UdpDns", + Event::Lsm(_) => "Lsm", } } } @@ -110,6 +113,19 @@ impl Event { _ => None, } } + + /// Check if this is an LSM audit event + pub fn is_lsm(&self) -> bool { + matches!(self, Event::Lsm(_)) + } + + /// Get LSM audit event if this is one + pub fn as_lsm(&self) -> Option<&LsmEvent> { + match self { + Event::Lsm(e) => Some(e), + _ => None, + } + } } #[cfg(test)] @@ -235,4 +251,31 @@ mod tests { assert!(e.as_filewatch().is_none()); assert!(e.as_filewrite().is_none()); } + + fn make_lsm_event() -> LsmEvent { + LsmEvent::FileOpen(crate::probes::lsmaudit::LsmFileOpen { + pid: 4242, + tid: 4242, + uid: 0, + timestamp_ns: 400, + comm: "agent".to_string(), + path: "shadow".to_string(), + open_flags: 0, + }) + } + + #[test] + fn test_event_type_lsm() { + let e = Event::Lsm(make_lsm_event()); + assert_eq!(e.event_type(), "Lsm"); + } + + #[test] + fn test_is_and_as_lsm() { + let e = Event::Lsm(make_lsm_event()); + assert!(e.is_lsm()); + assert!(!e.is_ssl()); + assert!(e.as_lsm().is_some()); + assert!(e.as_ssl().is_none()); + } } diff --git a/src/agentsight/src/parser/unified.rs b/src/agentsight/src/parser/unified.rs index 70e62ff8b..cdc95d06a 100644 --- a/src/agentsight/src/parser/unified.rs +++ b/src/agentsight/src/parser/unified.rs @@ -141,6 +141,7 @@ impl Parser { Event::FileWatch(_) => ParseResult { messages: Vec::new() }, Event::FileWrite(_) => ParseResult { messages: Vec::new() }, Event::UdpDns(_) => ParseResult { messages: Vec::new() }, + Event::Lsm(_) => ParseResult { messages: Vec::new() }, } } diff --git a/src/agentsight/src/probes/lsmaudit.rs b/src/agentsight/src/probes/lsmaudit.rs new file mode 100644 index 000000000..b19869a96 --- /dev/null +++ b/src/agentsight/src/probes/lsmaudit.rs @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// LSM audit probe — observe-only security auditing of traced Agent families via +// the BPF LSM hooks lsm/socket_connect (outbound connections) and lsm/file_open +// (file access). It records, it never denies. + +use crate::config; +use anyhow::{Context, Result}; +use libbpf_rs::{ + Link, MapHandle, + skel::{OpenSkel, SkelBuilder}, +}; +use std::{ + mem::MaybeUninit, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + os::fd::AsFd, +}; + +mod bpf { + include!(concat!(env!("OUT_DIR"), "/lsmaudit.skel.rs")); + include!(concat!(env!("OUT_DIR"), "/lsmaudit.rs")); +} +use bpf::*; + +// Re-export the raw event type so probes.rs can size-check ring buffer records. +pub type RawLsmEvent = bpf::lsm_audit_event; + +pub const LSM_EVENT_CONNECT: u8 = 1; +pub const LSM_EVENT_FILE_OPEN: u8 = 2; + +// IPv6 address family as the kernel sees it (stored in lsm_audit_event.family as +// u8); any other family in a CONNECT event is decoded as IPv4. +const AF_INET6: u8 = 10; + +/// An outbound connection attempt by a traced Agent. +#[derive(Debug, Clone)] +pub struct LsmConnect { + pub pid: u32, + pub tid: u32, + pub uid: u32, + pub timestamp_ns: u64, + pub comm: String, + pub dst_ip: IpAddr, + pub dport: u16, +} + +/// A file opened by a traced Agent. +#[derive(Debug, Clone)] +pub struct LsmFileOpen { + pub pid: u32, + pub tid: u32, + pub uid: u32, + pub timestamp_ns: u64, + pub comm: String, + /// File basename (full path is future work — see lsmaudit.bpf.c). + pub path: String, + /// Raw file->f_flags (O_RDONLY/O_WRONLY/O_RDWR in the low bits, plus O_CREAT…). + pub open_flags: i32, +} + +/// User-space LSM audit event — one variant per hook. +#[derive(Debug, Clone)] +pub enum LsmEvent { + Connect(LsmConnect), + FileOpen(LsmFileOpen), +} + +/// Decode a fixed-size, NUL-terminated C char buffer into a String. +fn c_buf_to_string(buf: &[std::os::raw::c_char]) -> String { + let bytes: Vec = buf.iter().take_while(|&&c| c != 0).map(|&c| c as u8).collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Always-on leak detector — scans `path[]` AFTER the null terminator and +/// warns if any byte is non-zero. Non-zero tail means the BPF producer left +/// uninitialized ringbuf bytes (a previous event's residue, e.g. TLS plaintext +/// from sslsniff or file content from filewrite) leaking into userspace. +/// +/// Permanent regression net for the producer-side +/// `__builtin_memset(e, 0, sizeof(*e))` invariant. Cost is one ~256-byte +/// scan per LSM event (a few hundred ns), well within the budget for an +/// already-low-frequency probe. +fn check_path_tail_zeroed(raw_path: &[std::os::raw::c_char]) { + let nul = raw_path.iter().position(|&c| c == 0).unwrap_or(raw_path.len()); + for (off, byte) in raw_path.iter().enumerate().skip(nul + 1) { + if *byte != 0 { + log::warn!( + "[lsm-audit] ringbuf path-tail leak: byte at offset {} after null = 0x{:02x}; \ + BPF producer must memset the whole record after bpf_ringbuf_reserve", + off, + *byte as u8, + ); + break; // only log first leak byte per event to avoid spam + } + } +} + +impl LsmEvent { + /// Parse an event from raw ring buffer data. + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < std::mem::size_of::() { + return None; + } + + // SAFETY: BPF guarantees proper alignment and layout. + let raw = unsafe { &*(data.as_ptr() as *const RawLsmEvent) }; + + check_path_tail_zeroed(&raw.path); + + let pid = raw.pid; + let tid = raw.tid; + let uid = raw.uid; + let timestamp_ns = config::ktime_to_unix_ns(raw.timestamp_ns); + let comm = c_buf_to_string(&raw.comm); + + match raw.kind { + LSM_EVENT_CONNECT => { + let dst_ip = if raw.family == AF_INET6 { + IpAddr::V6(Ipv6Addr::from(raw.daddr)) + } else { + IpAddr::V4(Ipv4Addr::new( + raw.daddr[0], + raw.daddr[1], + raw.daddr[2], + raw.daddr[3], + )) + }; + Some(LsmEvent::Connect(LsmConnect { + pid, + tid, + uid, + timestamp_ns, + comm, + dst_ip, + // Port is stored in network byte order. + dport: u16::from_be(raw.dport), + })) + } + LSM_EVENT_FILE_OPEN => Some(LsmEvent::FileOpen(LsmFileOpen { + pid, + tid, + uid, + timestamp_ns, + comm, + path: c_buf_to_string(&raw.path), + open_flags: raw.open_flags, + })), + _ => None, + } + } +} + +/// Returns true if the kernel has BPF LSM active (`bpf` present in the active +/// LSM list). Attaching lsm/ programs requires this; without it the probe is +/// skipped with a warning rather than failing the whole run. +pub fn bpf_lsm_available() -> bool { + std::fs::read_to_string("/sys/kernel/security/lsm") + .map(|s| s.split(',').any(|lsm| lsm.trim() == "bpf")) + .unwrap_or(false) +} + +pub struct LsmAudit { + _open_object: Box>, + skel: Box>, + _links: Vec, +} + +impl LsmAudit { + /// Re-expose the capability check at the type level for ergonomic call sites. + pub fn bpf_lsm_available() -> bool { + bpf_lsm_available() + } + + /// Create a new LsmAudit that reuses the shared traced_processes and ring + /// buffer maps. + pub fn new_with_maps(traced_processes: &MapHandle, rb: &MapHandle) -> Result { + let mut builder = LsmauditSkelBuilder::default(); + builder.obj_builder.debug(config::verbose()); + + let open_object = Box::new(MaybeUninit::::uninit()); + let mut open_skel = builder.open().context("failed to open lsmaudit BPF object")?; + + open_skel + .maps_mut() + .traced_processes() + .reuse_fd(traced_processes.as_fd()) + .context("failed to reuse traced_processes map for lsmaudit")?; + + open_skel + .maps_mut() + .rb() + .reuse_fd(rb.as_fd()) + .context("failed to reuse rb map for lsmaudit")?; + + let skel = open_skel.load().context("failed to load lsmaudit BPF object")?; + + // SAFETY: skel borrows open_object which lives in a Box. + let skel = + unsafe { Box::from_raw(Box::into_raw(Box::new(skel)) as *mut LsmauditSkel<'static>) }; + + Ok(Self { + _open_object: open_object, + skel, + _links: Vec::new(), + }) + } + + /// Attach both LSM programs (socket_connect + file_open). + pub fn attach(&mut self) -> Result<()> { + let mut links = Vec::new(); + + let link = self + .skel + .progs_mut() + .audit_socket_connect() + .attach() + .context("failed to attach lsm/socket_connect")?; + links.push(link); + + let link = self + .skel + .progs_mut() + .audit_file_open() + .attach() + .context("failed to attach lsm/file_open")?; + links.push(link); + + self._links = links; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lsm_event_from_bytes_too_short() { + let data = [0u8; 8]; + assert!(LsmEvent::from_bytes(&data).is_none()); + } + + // Build a raw lsm_event byte buffer for tests. + fn raw_bytes(mut fill: impl FnMut(&mut RawLsmEvent)) -> Vec { + // SAFETY: RawLsmEvent is a plain-old-data C struct. + let mut raw: RawLsmEvent = unsafe { std::mem::zeroed() }; + fill(&mut raw); + let size = std::mem::size_of::(); + let ptr = &raw as *const RawLsmEvent as *const u8; + unsafe { std::slice::from_raw_parts(ptr, size) }.to_vec() + } + + fn set_comm(raw: &mut RawLsmEvent, s: &str) { + for (i, b) in s.bytes().enumerate() { + if i < raw.comm.len() { + raw.comm[i] = b as std::os::raw::c_char; + } + } + } + + fn set_path(raw: &mut RawLsmEvent, s: &str) { + for (i, b) in s.bytes().enumerate() { + if i < raw.path.len() { + raw.path[i] = b as std::os::raw::c_char; + } + } + } + + #[test] + fn test_parse_connect_ipv4() { + let data = raw_bytes(|raw| { + raw.kind = LSM_EVENT_CONNECT; + raw.pid = 4321; + raw.tid = 4322; + raw.family = 2; // AF_INET + // 1.2.3.4 + raw.daddr[0] = 1; + raw.daddr[1] = 2; + raw.daddr[2] = 3; + raw.daddr[3] = 4; + // port 443 in network byte order + raw.dport = 443u16.to_be(); + set_comm(raw, "curl"); + }); + + match LsmEvent::from_bytes(&data).unwrap() { + LsmEvent::Connect(c) => { + assert_eq!(c.pid, 4321); + assert_eq!(c.tid, 4322); + assert_eq!(c.comm, "curl"); + assert_eq!(c.dst_ip, IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + assert_eq!(c.dport, 443); + } + other => panic!("expected Connect, got {other:?}"), + } + } + + #[test] + fn test_parse_connect_ipv6() { + let data = raw_bytes(|raw| { + raw.kind = LSM_EVENT_CONNECT; + raw.family = AF_INET6; + // ::1 (loopback) → last byte = 1 + raw.daddr[15] = 1; + raw.dport = 8080u16.to_be(); + }); + + match LsmEvent::from_bytes(&data).unwrap() { + LsmEvent::Connect(c) => { + assert_eq!(c.dst_ip, IpAddr::V6(Ipv6Addr::LOCALHOST)); + assert_eq!(c.dport, 8080); + } + other => panic!("expected Connect, got {other:?}"), + } + } + + #[test] + fn test_parse_file_open() { + let data = raw_bytes(|raw| { + raw.kind = LSM_EVENT_FILE_OPEN; + raw.pid = 99; + raw.open_flags = 2; // O_RDWR + set_path(raw, "shadow"); + set_comm(raw, "agent"); + }); + + match LsmEvent::from_bytes(&data).unwrap() { + LsmEvent::FileOpen(f) => { + assert_eq!(f.pid, 99); + assert_eq!(f.path, "shadow"); + assert_eq!(f.comm, "agent"); + assert_eq!(f.open_flags, 2); + } + other => panic!("expected FileOpen, got {other:?}"), + } + } + + #[test] + fn test_parse_unknown_kind_is_none() { + let data = raw_bytes(|raw| { + raw.kind = 99; + }); + assert!(LsmEvent::from_bytes(&data).is_none()); + } + + // ─── path-tail leak detector (paired with the BPF-side memset fix) ───── + + /// Discriminating signal: garbage in path[null+1..] must trigger the + /// detector. If the BPF producer ever forgets the post-reserve memset, + /// this is what fires in debug builds. + #[test] + fn test_debug_check_path_tail_warns_on_garbage() { + // Simulate a "leaked" record: short path "x\0", followed by non-zero. + let mut path = [0i8; 256]; // matches LSM_PATH_LEN in lsmaudit.h + path[0] = b'x' as i8; + // path[1] = 0 (null term) + path[2] = 0x41; // 'A' — leaked byte from a previous reserve + path[100] = 0x42; + // Build a parseable record so from_bytes runs the check. + let data = raw_bytes(|raw| { + raw.kind = LSM_EVENT_FILE_OPEN; + raw.path = path; + set_comm(raw, "agent"); + }); + // We can't capture log::warn output without a fixture; instead, exercise + // the check directly to assert it's wired and would fire under debug. + // (In release builds the function is a no-op by construction.) + check_path_tail_zeroed(&path); + // And confirm parsing still produces clean string (decoder stops at null): + match LsmEvent::from_bytes(&data).unwrap() { + LsmEvent::FileOpen(f) => assert_eq!(f.path, "x"), + other => panic!("expected FileOpen, got {other:?}"), + } + } + + /// Reverse direction: a clean record (everything after null is zero) must + /// NOT fire the detector. Without this, the detector could be spuriously + /// log-spammy and we'd lose its signal value. + #[test] + fn test_debug_check_path_tail_silent_on_clean_record() { + // Use mem::zeroed via raw_bytes default; path is all zero. + let data = raw_bytes(|raw| { + raw.kind = LSM_EVENT_FILE_OPEN; + set_path(raw, "shadow"); // writes 6 bytes + null term, rest stays 0 + }); + let raw_struct = unsafe { &*(data.as_ptr() as *const RawLsmEvent) }; + check_path_tail_zeroed(&raw_struct.path); + // Decoder still produces correct path: + match LsmEvent::from_bytes(&data).unwrap() { + LsmEvent::FileOpen(f) => assert_eq!(f.path, "shadow"), + other => panic!("expected FileOpen, got {other:?}"), + } + } +} diff --git a/src/agentsight/src/probes/mod.rs b/src/agentsight/src/probes/mod.rs index ebd022ea1..97c959c5a 100644 --- a/src/agentsight/src/probes/mod.rs +++ b/src/agentsight/src/probes/mod.rs @@ -7,6 +7,7 @@ pub mod filewatch; pub mod filewrite; pub mod udpdns; pub mod tcpsniff; +pub mod lsmaudit; pub mod probes; // Re-export commonly used types @@ -17,4 +18,5 @@ pub use procmon::{ProcMon, ProcMonEvent, Event as ProcMonEventExt}; pub use filewatch::{FileWatch, FileWatchEvent}; pub use filewrite::{FileWrite as FileWriteProbe, FileWriteEvent}; pub use udpdns::{UdpDns, UdpDnsEvent}; -pub use tcpsniff::TcpSniff; \ No newline at end of file +pub use tcpsniff::TcpSniff; +pub use lsmaudit::{LsmAudit, LsmEvent}; \ No newline at end of file diff --git a/src/agentsight/src/probes/probes.rs b/src/agentsight/src/probes/probes.rs index 626e1e2c0..a4a2c84a6 100644 --- a/src/agentsight/src/probes/probes.rs +++ b/src/agentsight/src/probes/probes.rs @@ -24,6 +24,7 @@ use super::filewrite::{FileWrite as FileWriteProbe, RawFileWriteEvent}; use super::udpdns::{UdpDns, RawUdpDnsEvent}; use crate::config::TcpTarget; use super::tcpsniff::TcpSniff; +use super::lsmaudit::{LsmAudit, RawLsmEvent}; const POLL_TIMEOUT_MS: u64 = 100; @@ -34,6 +35,7 @@ const EVENT_SOURCE_PROCMON: u32 = 3; const EVENT_SOURCE_FILEWATCH: u32 = 4; const EVENT_SOURCE_FILEWRITE: u32 = 5; const EVENT_SOURCE_UDPDNS: u32 = 6; +const EVENT_SOURCE_LSM: u32 = 8; /// Unified probe manager that coordinates sslsniff and proctrace /// @@ -57,6 +59,8 @@ pub struct Probes { udpdns: Option, /// TCP sniff probe (captures plain HTTP traffic on configured ports, optional) tcpsniff: Option, + /// LSM audit probe (observe-only security auditing, optional, opt-in) + lsmaudit: Option, /// Shared ring buffer handle (cloned from proctrace) for polling rb_handle: MapHandle, /// Unified event channel - events are converted to Event type inside the poller @@ -70,7 +74,7 @@ impl Probes { /// # Arguments /// * `target_pids` - Initial PIDs to trace (empty means trace all matching UID) /// * `target_uid` - Optional UID filter - pub fn new(target_pids: &[u32], target_uid: Option, enable_filewatch: bool, enable_udpdns: bool, tcp_targets: &[TcpTarget]) -> Result { + pub fn new(target_pids: &[u32], target_uid: Option, enable_filewatch: bool, enable_udpdns: bool, enable_lsm_audit: bool, tcp_targets: &[TcpTarget]) -> Result { // Create proctrace first - it will own the traced_processes map and ring buffer let proctrace = ProcTrace::new_with_target(target_pids, target_uid) .context("failed to create proctrace")?; @@ -126,6 +130,26 @@ impl Probes { None }; + // Optionally create lsmaudit - observe-only LSM security auditing. + // Requires BPF LSM to be active; if not, skip with a warning rather than + // failing the whole run. + let lsmaudit = if enable_lsm_audit { + if !LsmAudit::bpf_lsm_available() { + log::warn!( + "LSM audit requested but BPF LSM is not active \ + (no 'bpf' in /sys/kernel/security/lsm); skipping lsmaudit probe" + ); + None + } else { + let la = LsmAudit::new_with_maps(&map_handle, &rb_handle) + .context("failed to create lsmaudit")?; + Some(la) + } + } else { + log::info!("LSM audit probe disabled"); + None + }; + let (event_tx, event_rx) = crossbeam_channel::unbounded(); Ok(Self { @@ -136,6 +160,7 @@ impl Probes { filewrite, udpdns, tcpsniff, + lsmaudit, rb_handle, event_tx, event_rx, @@ -166,6 +191,11 @@ impl Probes { tcp.attach() .context("failed to attach tcpsniff")?; } + // Attach lsmaudit LSM programs for security auditing (if enabled) + if let Some(ref mut la) = self.lsmaudit { + la.attach() + .context("failed to attach lsmaudit")?; + } // sslsniff uses uprobes attached per-process via attach_process() Ok(()) } @@ -193,6 +223,7 @@ impl Probes { let filewatch_event_size = mem::size_of::(); let filewrite_event_size = mem::size_of::(); let udpdns_event_size = mem::size_of::(); + let lsm_event_size = mem::size_of::(); let event_tx = self.event_tx.clone(); let stop_flag = Arc::new(AtomicBool::new(false)); @@ -259,6 +290,14 @@ impl Probes { None } } + EVENT_SOURCE_LSM => { + // LSM audit event (socket_connect / file_open) + if data.len() >= lsm_event_size { + super::lsmaudit::LsmEvent::from_bytes(data).map(Event::Lsm) + } else { + None + } + } _ => { // Unknown source - ignore log::warn!("probes: unknown event source {source}"); diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index fcca60c30..12ab339a3 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -210,7 +210,7 @@ impl AgentSight { // Create probes - agent discovery is handled by AgentScanner via ProcMon events let enable_udpdns = !config.https_rules.is_empty() || !http_domains.is_empty(); let mut probes = - Probes::new(&[], config.target_uid, config.enable_filewatch, enable_udpdns, &tcp_targets).context("Failed to create probes")?; + Probes::new(&[], config.target_uid, config.enable_filewatch, enable_udpdns, config.enable_lsm_audit, &tcp_targets).context("Failed to create probes")?; // Attach procmon for process monitoring probes.attach().context("Failed to attach probes")?; @@ -555,6 +555,12 @@ impl AgentSight { return None; } + // Handle LSM audit events (observe-only security audit log) + if let Event::Lsm(ref lsm_event) = event { + self.handle_lsm_event(lsm_event); + return None; + } + // Handle UDP DNS events (domain-based attachment) if let Event::UdpDns(ref dns_event) = event { log::debug!( @@ -756,6 +762,40 @@ impl AgentSight { } } + /// Handle an observe-only LSM audit event by emitting a structured audit log + /// line. The probe records, it never denies. (Future work: structured export + /// to the audit store / SLS instead of just logging.) + fn handle_lsm_event(&self, event: &crate::probes::lsmaudit::LsmEvent) { + use crate::probes::lsmaudit::LsmEvent; + match event { + LsmEvent::Connect(c) => { + log::info!( + "[LSM] connect pid={} comm={} dst={}:{}", + c.pid, + c.comm, + c.dst_ip, + c.dport + ); + } + LsmEvent::FileOpen(f) => { + // Decode the access mode from the low bits of f_flags (O_ACCMODE). + let mode = match f.open_flags & 0o3 { + 0 => "r", + 1 => "w", + 2 => "rw", + _ => "?", + }; + log::info!( + "[LSM] open pid={} comm={} mode={} path={}", + f.pid, + f.comm, + mode, + f.path + ); + } + } + } + /// Register a callback for file watch events (.jsonl file opens) pub fn on_filewatch(&mut self, callback: F) where