diff --git a/agent/common/src/lib.rs b/agent/common/src/lib.rs index 859e043..6a61793 100644 --- a/agent/common/src/lib.rs +++ b/agent/common/src/lib.rs @@ -20,8 +20,9 @@ pub const KIND_FILE_OPEN: u32 = 2; /// a LibraryLoaded with the basename. Reuses [`FileEvent`] (kind discriminates). pub const KIND_LIBRARY_LOAD: u32 = 3; /// A process was exec'd (fentry on `security_bprm_check`). Carries the exec'd binary's -/// path, read from `linux_binprm->filename`; userspace emits a ProcessExec. Reuses -/// [`FileEvent`] (kind discriminates) — the runtime signal for "unexpected process +/// path, read from `linux_binprm->filename`, PLUS the anon-inode kernel fact (JEF-317, +/// Route A) read from `bprm->file->f_inode`; userspace emits a `ProcessExec`. Its own +/// [`ExecEvent`] body (not [`FileEvent`]) — the runtime signal for "unexpected process /// spawned" (ADR-0014). pub const KIND_EXEC: u32 = 4; /// A process gained root (fentry on `security_task_fix_setuid`). The eBPF side filters to @@ -53,6 +54,31 @@ pub struct FileEvent { pub path: [u8; PATH_CAP], } +/// One observed process exec (kind [`KIND_EXEC`]) — the same `header`/`len`/`path` shape +/// as [`FileEvent`], plus the pure-data anon-inode fact (JEF-317, Route A): +/// [`Self::exe_anon_inode`]. A dedicated struct rather than a [`FileEvent`] field, since +/// this fact is exec-specific — the file-open/library-load/file-write probes have no +/// `bprm` to read it from, so folding it into the shared `FileEvent` would mean carrying a +/// meaningless byte on every OTHER kind's wire event. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ExecEvent { + pub header: EventHeader, + /// Valid bytes in `path` (≤ [`PATH_CAP`]). + pub len: u32, + /// The exec'd binary's path (`linux_binprm->filename`), not NUL-terminated past `len`. + pub path: [u8; PATH_CAP], + /// `1` if the exec'd binary's backing inode is anonymous — memfd/shmem-backed + /// (`inode->i_sb->s_magic` is the tmpfs/shmem magic) or unlinked (`i_nlink == 0`) — + /// rather than a normal, linked, on-disk file; `0` otherwise. A `u8`, not `bool`: a + /// kernel-written byte is not guaranteed a valid Rust `bool` bit pattern, and `no_std` + /// eBPF code writing this field directly must not rely on that guarantee. A KERNEL- + /// OBSERVABLE FACT (JEF-113), not a verdict — whether an anon-inode exec is alarming + /// is engine policy, conservatively scoped (see `engine::observe::exec_class` / + /// `engine::reason::proof::corroborate`), NOT decided here. + pub exe_anon_inode: u8, +} + /// The fixed prefix of every event in the ring buffer. `repr(C)`, at offset 0 of each /// body, so userspace can read `kind` (and `pid`/`cgroup_id`) before it knows which body /// follows. diff --git a/agent/protector-agent-ebpf/src/main.rs b/agent/protector-agent-ebpf/src/main.rs index 10d0c99..8711006 100644 --- a/agent/protector-agent-ebpf/src/main.rs +++ b/agent/protector-agent-ebpf/src/main.rs @@ -35,9 +35,9 @@ use aya_ebpf::{ // dedup key/window/decision (JEF-65) live here too so the kernel probe and the userspace // tests share one definition and can't drift. use protector_agent_common::{ - should_coalesce, ConnEvent, ConnKey, EventHeader, FileEvent, PrivEvent, ReadKey, WriteKey, - DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, KIND_FILE_WRITE, - KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, + should_coalesce, ConnEvent, ConnKey, EventHeader, ExecEvent, FileEvent, PrivEvent, ReadKey, + WriteKey, DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, + KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, }; /// Ring buffer of behavioral events (all kinds) drained by userspace. @@ -519,11 +519,27 @@ fn try_fix_setuid(ctx: &FEntryContext) -> Result<(), i64> { /// fentry on `security_bprm_check(struct linux_binprm *bprm)` — the process-exec probe /// (ADR-0014, JEF-53). This LSM hook fires on every `execve` once the new binary is -/// resolved, so `bprm->filename` is the path the kernel is about to exec. Emits a -/// [`FileEvent`] (kind [`KIND_EXEC`]) carrying that path; userspace turns it into a -/// `ProcessExec`. Observe-only. NOTE: the attach point is `security_bprm_check` (the -/// exported LSM call, in BTF — like the other `security_*` probes); the un-prefixed -/// `bprm_check_security` is NOT a BTF function on 6.8 (verified on-node: JEF-53 deploy). +/// resolved, so `bprm->filename` is the path the kernel is about to exec. Emits an +/// [`ExecEvent`] (kind [`KIND_EXEC`]) carrying that path plus the anon-inode fact +/// (JEF-317, below); userspace turns it into a `ProcessExec`. Observe-only. NOTE: the +/// attach point is `security_bprm_check` (the exported LSM call, in BTF — like the other +/// `security_*` probes); the un-prefixed `bprm_check_security` is NOT a BTF function on +/// 6.8 (verified on-node: JEF-53 deploy). Attached via **fentry, not `lsm/*`**: the fleet +/// does not carry `bpf` in its active LSM list (`CONFIG_LSM` omits it, no `lsm=` on the +/// kernel cmdline — confirmed on-node over SSH on both arches), so an `lsm/` program would +/// never attach here; fentry on the `security_*` function works regardless of the active +/// LSM list, which is why every probe in this file uses it. +/// +/// JEF-317 (fileless exec / memfd_create parity with Falco), Route A: an EARLIER version +/// of this signal classified the exec *path's shape* (`/dev/fd/` etc.) — withdrawn by +/// security review, because the kernel synthesizes that identical string for a benign +/// `fexecve()` of an on-disk file too, and runc copies itself into a memfd and re-execs on +/// ~every container start (the CVE-2019-5736 mitigation), so path shape alone forged +/// corroboration on routine behavior. The real signal is the *inode*, not the path: a +/// memfd/anonymous-fd exec's backing file lives on a shmem/tmpfs superblock and/or is +/// unlinked (`i_nlink == 0`), which a normal on-disk, directory-linked executable is not. +/// [`exe_is_anon_inode`] reads `bprm->file->f_inode` to determine that, straight from the +/// kernel's own resolution — no path parsing at all. #[fentry(function = "security_bprm_check")] pub fn bprm_check(ctx: FEntryContext) -> u32 { let _ = try_bprm_check(&ctx); @@ -536,19 +552,21 @@ fn try_bprm_check(ctx: &FEntryContext) -> Result<(), i64> { if bprm.is_null() { return Ok(()); } - emit_exec_path(bprm); + emit_exec_path(bprm, exe_is_anon_inode(bprm)); Ok(()) } -/// Emit the exec'd binary's path as a [`KIND_EXEC`] event. `bprm->filename` is a kernel -/// `char *` (the resolved exec path), so — like the library-load probe — read the string -/// directly with `bpf_probe_read_kernel_str`. NOT `bpf_d_path`: `security_bprm_check` -/// isn't on the kernel's d_path allowlist, so the verifier would reject it (JEF-68). -fn emit_exec_path(bprm: *const vmlinux::linux_binprm) { - let mut ev = FileEvent { +/// Emit the exec'd binary's path (plus the anon-inode fact, JEF-317) as a [`KIND_EXEC`] +/// [`ExecEvent`]. `bprm->filename` is a kernel `char *` (the resolved exec path), so — +/// like the library-load probe — read the string directly with `bpf_probe_read_kernel_str`. +/// NOT `bpf_d_path`: `security_bprm_check` isn't on the kernel's d_path allowlist, so the +/// verifier would reject it (JEF-68). +fn emit_exec_path(bprm: *const vmlinux::linux_binprm, exe_anon_inode: bool) { + let mut ev = ExecEvent { header: make_header(KIND_EXEC), len: 0, path: [0u8; PATH_CAP], + exe_anon_inode: exe_anon_inode as u8, }; // Read the `char *filename` pointer out of the binprm, then the string it points to. let mut name_ptr: *const u8 = core::ptr::null(); @@ -574,7 +592,7 @@ fn emit_exec_path(bprm: *const vmlinux::linux_binprm) { } else { PATH_CAP as u32 }; - if let Some(mut slot) = EVENTS.reserve::(0) { + if let Some(mut slot) = EVENTS.reserve::(0) { slot.write(ev); slot.submit(0); } else { @@ -582,6 +600,38 @@ fn emit_exec_path(bprm: *const vmlinux::linux_binprm) { } } +/// Whether the exec'd binary's backing inode is anonymous (JEF-317, Route A): a +/// memfd/shmem-backed file (`inode->i_sb->s_magic` is the tmpfs magic — `memfd_create` is +/// shmem-backed under the hood) OR an unlinked file (`inode->i_nlink == 0` — covers a +/// memfd, which is never linked into any directory, AND the separate "delete the binary +/// while it's still executing" technique on a normal filesystem). Reads +/// `bprm->file->f_inode`: `bprm->file` is the ALREADY-OPENED executable (opened before +/// this hook fires — see the doc on [`bprm_check`]), so this is the SAME file the kernel +/// is about to run, not a TOCTOU-able separate lookup. A failed read = "not anonymous" +/// (fail closed on the flag, matching [`is_tmpfs`]/[`inode_ino`]'s existing convention). +/// +/// PURE DATA (JEF-113): this reports a kernel fact only. Whether an anon-inode exec is +/// alarming — and the runc-memfd-reexec false-positive risk that makes this conservative +/// — is engine policy, not decided here. +fn exe_is_anon_inode(bprm: *const vmlinux::linux_binprm) -> bool { + unsafe { + let mut file: *mut vmlinux::file = core::ptr::null_mut(); + if read_kernel(&mut file, core::ptr::addr_of!((*bprm).file)) != 0 || file.is_null() { + return false; + } + let Some(inode) = inode_of(file as *const vmlinux::file) else { + return false; + }; + let mut nlink: u32 = 1; // fail closed: a failed read must not read as "unlinked" + if read_kernel(&mut nlink, core::ptr::addr_of!((*inode).i_nlink)) == 0 && nlink == 0 { + return true; + } + // Reuse the SAME `inode` already fetched above (not `is_tmpfs(file)`, which would + // re-walk `file->f_inode` a second time for the same fact). + superblock_magic(inode) == Some(TMPFS_MAGIC) + } +} + /// bpf_d_path the file's path into a [`FileEvent`] of `kind` and submit it. Shared by the /// secret-read (file_open) probe — it needs the full path so the engine can match it to a /// Secret mount. (Library-load uses [`emit_lib_name`]: bpf_d_path is disallowed in its hook.) @@ -714,24 +764,45 @@ fn is_sensitive_credential_basename(file: *const vmlinux::file) -> bool { .any(|&want| want == &buf[..len]) } -/// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The -/// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux), -/// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop). -fn is_tmpfs(file: *const vmlinux::file) -> bool { +/// Read `file->f_inode` — the pointer chase every inode-fact reader below starts from +/// ([`is_tmpfs`], [`inode_ino`], [`exe_is_anon_inode`]). `None` on a failed read or a null +/// inode; every caller treats that as "the fact I wanted isn't available" (fail closed for +/// an alarm-shaped bool, fail open for the dedup key — each caller's own choice). +unsafe fn inode_of(file: *const vmlinux::file) -> Option<*mut vmlinux::inode> { unsafe { let mut inode: *mut vmlinux::inode = core::ptr::null_mut(); if read_kernel(&mut inode, core::ptr::addr_of!((*file).f_inode)) != 0 || inode.is_null() { - return false; + return None; } + Some(inode) + } +} + +/// Read `inode->i_sb->s_magic` — the superblock magic every magic-comparing reader below +/// starts from ([`is_tmpfs`], [`exe_is_anon_inode`]). `None` on any failed read. +unsafe fn superblock_magic(inode: *mut vmlinux::inode) -> Option { + unsafe { let mut sb: *mut vmlinux::super_block = core::ptr::null_mut(); if read_kernel(&mut sb, core::ptr::addr_of!((*inode).i_sb)) != 0 || sb.is_null() { - return false; + return None; } let mut magic: u64 = 0; if read_kernel(&mut magic, core::ptr::addr_of!((*sb).s_magic).cast()) != 0 { - return false; + return None; } - magic == TMPFS_MAGIC + Some(magic) + } +} + +/// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The +/// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux), +/// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop). +fn is_tmpfs(file: *const vmlinux::file) -> bool { + unsafe { + let Some(inode) = inode_of(file) else { + return false; + }; + superblock_magic(inode) == Some(TMPFS_MAGIC) } } @@ -741,10 +812,7 @@ fn is_tmpfs(file: *const vmlinux::file) -> bool { /// then emits without deduping (fail open), never dropping a real write for a bookkeeping miss. fn inode_ino(file: *const vmlinux::file) -> Option { unsafe { - let mut inode: *mut vmlinux::inode = core::ptr::null_mut(); - if read_kernel(&mut inode, core::ptr::addr_of!((*file).f_inode)) != 0 || inode.is_null() { - return None; - } + let inode = inode_of(file)?; let mut ino: u64 = 0; if read_kernel(&mut ino, core::ptr::addr_of!((*inode).i_ino).cast()) != 0 { return None; diff --git a/agent/protector-agent-ebpf/src/vmlinux.rs b/agent/protector-agent-ebpf/src/vmlinux.rs index ffd6754..1c44db8 100644 --- a/agent/protector-agent-ebpf/src/vmlinux.rs +++ b/agent/protector-agent-ebpf/src/vmlinux.rs @@ -30,6 +30,19 @@ //! which on 7.0.0 lands in the `f_wb_err`/`f_ep` region — the verifier rejection that //! degraded the two `bpf_d_path` probes (secret-read `file_open` + `file_write`) to //! loaded=4/6 fleet-wide. Regenerate (re-verify the offsets) on any kernel struct change. +//! +//! # `linux_binprm.file` / `inode.i_nlink` — ON-NODE BTF VERIFICATION PENDING (JEF-317) +//! +//! Two fields added for the fileless-exec (anon-inode) probe were derived from kernel +//! *source* layout, not dumped from live BTF like everything else above: `linux_binprm.file` +//! (+64) and `inode.i_nlink` (+72). Each carries its own derivation in its struct's doc +//! comment. Both must be confirmed against `bpftool btf dump` on BOTH fleet arches — the +//! same process that produced the offsets above — before this probe ships past a spike +//! deploy (docs/ebpf-testing-on-nodes.md). A wrong offset here fails the SAME way a wrong +//! `f_path` offset would have (JEF-324): either a verifier rejection (probe degrades, +//! loud in the heartbeat) or, worse, a silently wrong bool if the misread pointer happens +//! to still verify — which is why this module keeps every derivation reasoning explicit +//! rather than asserting a bare number. // Padding fields (and `mnt`, present only to place `dentry` at +8) are never read — they // exist solely to position the fields the probes DO read at the right byte offset. @@ -75,15 +88,27 @@ pub struct qstr { pub name: *const u8, // +8 const unsigned char * } -/// `struct inode` — prefix through `i_ino` (+64). `i_sb` (+40) reaches the superblock -/// (tmpfs magic); `i_ino` (+64) is the file-write dedup key. +/// `struct inode` — prefix through `i_nlink` (+72). `i_sb` (+40) reaches the superblock +/// (tmpfs magic); `i_ino` (+64) is the file-write dedup key; `i_nlink` (+72, JEF-317) is +/// the anon-inode discriminator — `0` for an unlinked inode (a memfd, or any file `rm`'d +/// while still executing), non-zero for a normal directory-linked file. +/// +/// **ON-NODE BTF VERIFICATION PENDING for `i_nlink` (JEF-317):** derived from kernel +/// source, not dumped from live BTF like the fields above it. `i_nlink` is the first field +/// of an anonymous union (`union { const unsigned int i_nlink; unsigned int __i_nlink; }`) +/// immediately after `i_ino` in `struct inode` — no padding needed since `i_ino` (an +/// 8-byte `unsigned long`) already leaves the next field 8-aligned. +72 = +64 (`i_ino`'s +/// offset) + 8 (`i_ino`'s size). Must be confirmed against BOTH fleet arches' live BTF +/// (`bpftool btf dump … format c`) before this ships past a spike deploy — see +/// docs/ebpf-testing-on-nodes.md. #[repr(C)] #[derive(Copy, Clone)] pub struct inode { _pad0: [u8; 40], pub i_sb: *mut super_block, // +40 _pad1: [u8; 16], - pub i_ino: u64, // +64 unsigned long + pub i_ino: u64, // +64 unsigned long + pub i_nlink: u32, // +72 ON-NODE BTF VERIFICATION PENDING (JEF-317, see doc above) } /// `struct super_block` — prefix through `s_magic` (+96), the tmpfs filter's discriminator. @@ -111,11 +136,29 @@ pub struct cred { } /// `struct linux_binprm` — prefix through `filename` (+96), the resolved exec path -/// (`char *`) the process-exec probe emits. +/// (`char *`) the process-exec probe emits. `file` (+64, JEF-317) is the ALREADY-OPENED +/// executable's `struct file*` — by the time `security_bprm_check` fires, `bprm_execve()` +/// (fs/exec.c) has already opened it (`do_open_execat`/`bprm->file = …`), before +/// `exec_binprm()` → `search_binary_handler()` → `security_bprm_check()` is reached — so +/// this read is safe at this hook, no ordering hazard. +/// +/// **ON-NODE BTF VERIFICATION PENDING for `file` (JEF-317):** derived from kernel source +/// layout (`struct linux_binprm` in linux/binfmts.h), not dumped from live BTF like +/// `filename` below (already verified on-node, JEF-53). Derivation: `vma`(+0) + +/// `vma_pages`(+8) + `mm`(+16) + `p`(+24) + `argmin`(+32) + the four-bit `unsigned int` +/// bitfield (+40, padded to +48 for the next pointer's alignment) + `executable`(+48) + +/// `interpreter`(+56) + `file`(+64) + `cred`(+72) + `unsafe`(+80) + `per_clear`(+84) + +/// `argc`(+88) + `envc`(+92) = `filename` at +96 — which matches the INDEPENDENTLY +/// on-node-verified `filename` offset below exactly, a strong (but not certain) signal +/// this derivation tracks the real fleet layout. Must still be confirmed against BOTH +/// fleet arches' live BTF (`bpftool btf dump … format c`) before this ships past a spike +/// deploy — see docs/ebpf-testing-on-nodes.md. #[repr(C)] #[derive(Copy, Clone)] pub struct linux_binprm { - _pad0: [u8; 96], + _pad0: [u8; 64], + pub file: *mut file, // +64 ON-NODE BTF VERIFICATION PENDING (JEF-317, see doc above) + _pad1: [u8; 24], pub filename: *const c_char, // +96 } @@ -135,8 +178,10 @@ const _: () = { assert!(offset_of!(qstr, name) == 8); assert!(offset_of!(inode, i_sb) == 40); assert!(offset_of!(inode, i_ino) == 64); + assert!(offset_of!(inode, i_nlink) == 72); // JEF-317, ON-NODE PENDING assert!(offset_of!(super_block, s_magic) == 96); assert!(offset_of!(cred, uid) == 8); assert!(offset_of!(kuid_t, val) == 0); + assert!(offset_of!(linux_binprm, file) == 64); // JEF-317, ON-NODE PENDING assert!(offset_of!(linux_binprm, filename) == 96); }; diff --git a/agent/protector-agent/src/coalesce/tests.rs b/agent/protector-agent/src/coalesce/tests.rs index 458db0e..dafcabd 100644 --- a/agent/protector-agent/src/coalesce/tests.rs +++ b/agent/protector-agent/src/coalesce/tests.rs @@ -75,6 +75,7 @@ fn distinct_behaviors_all_survive() { "p1", Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }, 3, )); // exec:bash @@ -82,6 +83,7 @@ fn distinct_behaviors_all_survive() { "p1", Behavior::ProcessExec { path: "/usr/bin/python".into(), + exe_anon_inode: false, }, 4, )); // exec:python @@ -132,6 +134,7 @@ fn exec_churn_collapses_by_basename() { "p1", Behavior::ProcessExec { path: "/usr/bin/bash".into(), + exe_anon_inode: false, }, 1, )); @@ -139,6 +142,7 @@ fn exec_churn_collapses_by_basename() { "p1", Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }, 2, )); @@ -215,7 +219,8 @@ fn max_size_forces_a_flush() { c.offer(obs( "p1", Behavior::ProcessExec { - path: "/bin/sh".into() + path: "/bin/sh".into(), + exe_anon_inode: false, }, 3 )) diff --git a/agent/protector-agent/src/observer.rs b/agent/protector-agent/src/observer.rs index 45811ce..9492bb1 100644 --- a/agent/protector-agent/src/observer.rs +++ b/agent/protector-agent/src/observer.rs @@ -79,7 +79,7 @@ mod ebpf { // The repr(C) event layouts are shared with the eBPF crate via this one crate, so the // kernel↔userspace byte contract can't drift (ADR-0014). use protector_agent_common::{ - ConnEvent, EventHeader, FileEvent, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, + ConnEvent, EventHeader, ExecEvent, FileEvent, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, PrivEvent, }; use protector_behavior::{Attribution, Behavior}; @@ -147,8 +147,15 @@ mod ebpf { old_uid: u32, new_uid: u32, }, - /// Process exec: the exec'd binary path (e.g. `/usr/bin/bash`), NUL-trimmed. - Exec { attr: EventAttr, path: String }, + /// Process exec: the exec'd binary path (e.g. `/usr/bin/bash`), NUL-trimmed, plus + /// the anon-inode kernel fact (JEF-317, Route A) the probe read from + /// `bprm->file->f_inode` — memfd/shmem-backed or unlinked, rather than a normal + /// on-disk file. + Exec { + attr: EventAttr, + path: String, + exe_anon_inode: bool, + }, /// File write: the written file's path (e.g. `/etc/cron.d/x`), NUL-trimmed. The /// eBPF side already filtered to write-intent opens and deduped repeats to the same /// `(pid, inode)`; this just carries the path through (JEF-306). @@ -208,7 +215,14 @@ mod ebpf { from_uid: old_uid, to_uid: new_uid, }, - RawEvent::Exec { path, .. } => Behavior::ProcessExec { path }, + RawEvent::Exec { + path, + exe_anon_inode, + .. + } => Behavior::ProcessExec { + path, + exe_anon_inode, + }, RawEvent::FileWrite { path, .. } => Behavior::FileWrite { path }, } } @@ -663,11 +677,11 @@ mod ebpf { Self::priv_change(&ev) } KIND_EXEC => { - if data.len() < std::mem::size_of::() { + if data.len() < std::mem::size_of::() { return None; } - // SAFETY: kind says this is a FileEvent of exactly this layout. - let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::()) }; + // SAFETY: kind says this is an ExecEvent of exactly this layout. + let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::()) }; Self::exec(&ev) } KIND_FILE_WRITE => { @@ -739,9 +753,11 @@ mod ebpf { /// Parse a process-exec event into a raw Exec. `path` is the exec'd binary path as /// the kernel saw it (`linux_binprm->filename`), NUL-trimmed; the behavior crate - /// coarsens it to the basename for the fingerprint. Drops empty paths. Pure (no - /// `/proc`). - fn exec(ev: &FileEvent) -> Option { + /// coarsens it to the basename for the fingerprint. `exe_anon_inode` (JEF-317, + /// Route A) carries the probe's `bprm->file->f_inode` fact straight through — a + /// non-zero kernel byte is `true`, never inferred from `path`. Drops empty paths. + /// Pure (no `/proc`). + fn exec(ev: &ExecEvent) -> Option { let len = (ev.len as usize).min(PATH_CAP); let path = String::from_utf8_lossy(&ev.path[..len]) .trim_end_matches('\0') @@ -752,6 +768,7 @@ mod ebpf { Some(RawEvent::Exec { attr: EventAttr::from_header(&ev.header), path, + exe_anon_inode: ev.exe_anon_inode != 0, }) } diff --git a/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs b/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs index 81f9f3a..db9ef6b 100644 --- a/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs +++ b/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs @@ -137,44 +137,65 @@ fn decode_priv_change_parses_uids() { ); } -#[test] -fn decode_exec_parses_path_and_maps_to_process_exec() { - // A KIND_EXEC FileEvent carrying a NUL-terminated exec path must decode to a - // RawEvent::Exec, and into_behavior must map it to Behavior::ProcessExec whose - // fingerprint coarsens to the basename (JEF-53). +/// Build an [`ExecEvent`] with a NUL-terminated `path` and the given `exe_anon_inode` byte +/// (JEF-317, Route A). +fn exec_event(kind_pid_cgroup: (u32, u32, u64), bin: &[u8], exe_anon_inode: u8) -> ExecEvent { + let (kind, pid, cgroup_id) = kind_pid_cgroup; let mut path = [0u8; PATH_CAP]; - let bin = b"/usr/bin/bash\0"; path[..bin.len()].copy_from_slice(bin); - let ev = FileEvent { + ExecEvent { header: EventHeader { - kind: KIND_EXEC, - pid: 4321, - cgroup_id: 999, + kind, + pid, + cgroup_id, }, len: bin.len() as u32, path, - }; + exe_anon_inode, + } +} + +#[test] +fn decode_exec_parses_path_and_maps_to_process_exec() { + // A KIND_EXEC ExecEvent carrying a NUL-terminated exec path must decode to a + // RawEvent::Exec, and into_behavior must map it to Behavior::ProcessExec whose + // fingerprint coarsens to the basename (JEF-53). exe_anon_inode == 0 here — the + // ordinary, non-anonymous case. + let ev = exec_event((KIND_EXEC, 4321, 999), b"/usr/bin/bash\0", 0); let bytes = unsafe { std::slice::from_raw_parts( - (&ev as *const FileEvent).cast::(), - std::mem::size_of::(), + (&ev as *const ExecEvent).cast::(), + std::mem::size_of::(), ) }; let raw = EbpfObserver::decode(bytes).expect("KIND_EXEC should decode"); match &raw { - RawEvent::Exec { attr, path } => { + RawEvent::Exec { + attr, + path, + exe_anon_inode, + } => { assert_eq!(attr.pid, 4321); assert_eq!(attr.cgroup_id, 999); assert_eq!(path, "/usr/bin/bash"); + assert!(!exe_anon_inode); } _ => panic!("expected RawEvent::Exec"), } assert_eq!(raw.attr().pid, 4321); match raw.into_behavior() { - Behavior::ProcessExec { path } => { + Behavior::ProcessExec { + path, + exe_anon_inode, + } => { assert_eq!(path, "/usr/bin/bash"); + assert!(!exe_anon_inode); assert_eq!( - Behavior::ProcessExec { path }.fingerprint_key(), + Behavior::ProcessExec { + path, + exe_anon_inode + } + .fingerprint_key(), "exec:bash" ); } @@ -182,6 +203,29 @@ fn decode_exec_parses_path_and_maps_to_process_exec() { } } +#[test] +fn decode_exec_carries_the_anon_inode_flag_through() { + // A KIND_EXEC ExecEvent with exe_anon_inode == 1 (JEF-317, Route A: the kernel's own + // f_inode read, not a path-shape guess) must decode and map the flag through verbatim + // — never inferred from the path, which here looks like an ordinary on-disk binary. + let ev = exec_event((KIND_EXEC, 1, 2), b"/bin/bash\0", 1); + let bytes = unsafe { + std::slice::from_raw_parts( + (&ev as *const ExecEvent).cast::(), + std::mem::size_of::(), + ) + }; + let raw = EbpfObserver::decode(bytes).expect("KIND_EXEC should decode"); + match &raw { + RawEvent::Exec { exe_anon_inode, .. } => assert!(exe_anon_inode), + _ => panic!("expected RawEvent::Exec"), + } + match raw.into_behavior() { + Behavior::ProcessExec { exe_anon_inode, .. } => assert!(exe_anon_inode), + other => panic!("expected ProcessExec, got {other:?}"), + } +} + #[test] fn decode_file_write_parses_path_and_maps_to_file_write() { // A KIND_FILE_WRITE FileEvent carrying a NUL-terminated path must decode to a diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index c107cc6..e5245fc 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -57,10 +57,30 @@ pub enum Behavior { /// corroborate a specific attack is JEF-49's job. PrivilegeChange { from_uid: u32, to_uid: u32 }, /// A process was exec'd in the workload — the runtime signal for "unexpected process - /// spawned" (ADR-0014). `path` is the exec'd binary's path as the - /// kernel saw it (`linux_binprm->filename`). Evidence for the model only today; - /// wiring exec → corroboration is JEF-49. - ProcessExec { path: String }, + /// spawned" (ADR-0014). `path` is the exec'd binary's path as the kernel saw it + /// (`linux_binprm->filename`). PURE DATA: whether a `path` is a shell / package manager + /// is engine classification (`observe::exec_class`, JEF-113), not a property of this + /// shared wire type. + /// + /// `exe_anon_inode` (JEF-317, Route A) is a SEPARATE kernel-observed fact, not derived + /// from `path`: whether the exec'd binary's backing inode is anonymous — memfd/shmem- + /// backed, or unlinked (`i_nlink == 0`) — rather than a normal, linked, on-disk file. + /// This is the Falco-parity signal ("memfd_create + execve of an anonymous fd") a path + /// string alone cannot carry: the kernel synthesizes the SAME `/dev/fd/`-shaped + /// `bprm->filename` for a benign `fexecve()` of an on-disk file as it does for a real + /// memfd payload, so an earlier version of this signal that classified the *path shape* + /// was withdrawn (a security review caught it forging corroboration on routine + /// behavior — see JEF-317). The exec probe now reads `bprm->file->f_inode` directly + /// instead. Defaulted `false` (an older sensor, or a sensor without inode access, omits + /// it) — never inferred, so an unset flag reads as "not anonymous", never guessed + /// `true`. A raw kernel fact, not a verdict: whether it's alarming is engine policy + /// (`observe::exec_class`, `reason::proof::corroborate`), scoped conservatively — see + /// those modules for why (the runc-memfd-reexec false-positive risk). + ProcessExec { + path: String, + #[serde(default, skip_serializing_if = "is_false")] + exe_anon_inode: bool, + }, /// A **write** to a file — the runtime signal for container drift: drop-and-execute /// (a new file created then run) and config tampering (an existing file overwritten). /// The eBPF agent's file-write probe (fentry on `security_file_open` filtered to @@ -119,6 +139,14 @@ impl SecretReadSource { } } +/// Whether `b` is `false` — a named predicate for `#[serde(skip_serializing_if)]` (no +/// built-in one exists for `bool`). Used to omit a `false` anon-inode-exec flag from the +/// wire (JEF-317), keeping the common (non-anonymous) exec's JSON byte-identical to before +/// this field existed. +fn is_false(b: &bool) -> bool { + !b +} + /// The basename of a binary path as the kernel saw it (`/usr/bin/apt` -> `apt`) — the /// last `/`-separated segment. Used by [`Behavior::fingerprint_key`] to coarsen an exec /// path to a stable, low-cardinality cache token. @@ -199,11 +227,23 @@ impl Behavior { Behavior::PrivilegeChange { from_uid, to_uid } => { format!("privilege change uid {from_uid} -> {to_uid}") } - // Just the exec'd path. Whether it's a *notable* exec (a shell or package - // manager run in the container — JEF-55) is engine classification policy - // (`engine::observe::exec_class`), applied by the engine when it builds the - // prompt/output line — this shared wire type stays pure data (JEF-113). - Behavior::ProcessExec { path } => format!("executed {path}"), + // The exec'd path, plus the raw `exe_anon_inode` kernel fact when set (JEF-317) + // — unlike the shell/package-manager CLASSIFICATION (a curated list, engine + // policy in `engine::observe::exec_class`), this is a single kernel-computed + // boolean the agent already resolved, so it rides the bare summary like + // `PrivilegeChange`'s uids do, not as an engine annotation. + Behavior::ProcessExec { + path, + exe_anon_inode, + } => { + if *exe_anon_inode { + format!( + "executed {path} (anonymous-inode: memfd/unlinked backing, no on-disk file)" + ) + } else { + format!("executed {path}") + } + } // Just the written path. Whether the write is *sensitive* (container drift / // config tampering) is engine corroboration policy (JEF-306 F3), not a property // of this shared wire type — the agent emits the path, the engine classifies. @@ -256,7 +296,18 @@ impl Behavior { // Coarsen to the basename so repeated execs of the same binary from different // absolute paths collapse to one stable key (mirrors how LibraryLoaded keys on // the lib name, not the full path) — keeps exec churn from busting the cache. - Behavior::ProcessExec { path } => format!("exec:{}", basename(path)), + // `exe_anon_inode` is kept in the key (JEF-317): it is a genuinely different + // security-relevant fact about the SAME binary name (an on-disk `bash` vs. an + // anonymous-inode exec that happens to report itself as "bash"), so folding it + // in must not silently collapse the two into one cache entry. + Behavior::ProcessExec { + path, + exe_anon_inode, + } => format!( + "exec:{}{}", + basename(path), + if *exe_anon_inode { ":anon-inode" } else { "" } + ), // Coarsen to the DIRNAME so per-file write churn within a directory // (drop-and-execute writing many files, a config dir rewritten file-by-file) // collapses to one stable key — writes are high-frequency, so keying on the diff --git a/behavior/src/tests.rs b/behavior/src/tests.rs index 85fd1ad..7662a1c 100644 --- a/behavior/src/tests.rs +++ b/behavior/src/tests.rs @@ -1,6 +1,7 @@ //! Unit tests for the behavioral wire contract. Moved out of `lib.rs`'s //! `#[cfg(test)] mod tests` block into its own file (JEF-320) per the repo's 1,000-line -//! file cap — `lib.rs` was approaching it. No test content changed by the move. +//! file cap — `lib.rs` was approaching it. `use super::*` resolves to `lib.rs`, exactly +//! as the inline `mod tests` block did. No test content changed by the move. use super::*; @@ -153,9 +154,11 @@ fn process_exec_fingerprint_coarsens_to_basename() { // exec churn doesn't bust the verdict cache (mirrors LibraryLoaded's basename key). let a = Behavior::ProcessExec { path: "/usr/bin/bash".into(), + exe_anon_inode: false, }; let b = Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }; assert_eq!(a.fingerprint_key(), "exec:bash"); assert_eq!(a.fingerprint_key(), b.fingerprint_key()); @@ -171,9 +174,11 @@ fn process_exec_summary_is_the_bare_path() { // (a shell / package manager) and annotates the prompt/output line (JEF-113). let shell = Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }; let normal = Behavior::ProcessExec { path: "/app/server".into(), + exe_anon_inode: false, }; assert_eq!(shell.summary(), "executed /bin/bash"); assert_eq!(normal.summary(), "executed /app/server"); @@ -182,6 +187,71 @@ fn process_exec_summary_is_the_bare_path() { assert!(!shell.is_alert()); } +#[test] +fn exe_anon_inode_is_a_raw_fact_distinct_from_path_shape_classification() { + // JEF-317 (Route A): `exe_anon_inode` is a kernel-observed inode fact, independent + // of the path — a `/bin/bash`-looking exec can still be anon-inode-backed (the + // path is whatever `bprm->filename` resolved to; the flag is a separate read). + let anon = Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: true, + }; + let normal = Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: false, + }; + // The bare summary carries the raw fact (a kernel-computed bool, not a curated + // classification — unlike shell/package-manager it is NOT engine-annotated). + assert_eq!( + anon.summary(), + "executed /bin/bash (anonymous-inode: memfd/unlinked backing, no on-disk file)" + ); + assert_eq!(normal.summary(), "executed /bin/bash"); + // The verdict-cache fingerprint distinguishes the two — a genuinely different fact + // about the same-named binary must not collapse into one cache entry. + assert_ne!(anon.fingerprint_key(), normal.fingerprint_key()); + assert_eq!(anon.fingerprint_key(), "exec:bash:anon-inode"); + assert_eq!(normal.fingerprint_key(), "exec:bash"); + // Neither is an Alert-style blanket corroboration source from the wire type's own + // view — only Alerts corroborate here; scoped corroboration is engine policy. + assert!(!anon.is_alert()); +} + +#[test] +fn exe_anon_inode_serializes_only_when_true() { + // JEF-317: the common (non-anonymous) exec omits the field entirely, keeping the + // JSON byte-identical to before this field existed (mirrors SecretReadSource's + // `Mounted`-is-omitted convention). A `true` flag serializes explicitly and both + // round-trip; an older sensor's JSON with the field absent defaults to `false`. + let normal = Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: false, + }; + let v = serde_json::to_value(&normal).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "process_exec", "path": "/bin/bash"}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), normal); + + let anon = Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: true, + }; + let v = serde_json::to_value(&anon).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "process_exec", "path": "/bin/bash", "exe_anon_inode": true}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), anon); + + // A legacy `process_exec` with no `exe_anon_inode` key deserializes to `false`. + let legacy: Behavior = + serde_json::from_value(serde_json::json!({"kind": "process_exec", "path": "/bin/bash"})) + .unwrap(); + assert_eq!(legacy, normal); +} + #[test] fn variant_label_is_a_stable_low_cardinality_token() { // Each variant maps to a fixed token carrying NO per-instance payload (no peer, @@ -214,6 +284,7 @@ fn variant_label_is_a_stable_low_cardinality_token() { ( Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }, "exec", ), @@ -302,6 +373,7 @@ fn observation_carries_the_node_and_omits_it_when_absent() { node: Some("node-a".into()), behavior: Behavior::ProcessExec { path: "/bin/sh".into(), + exe_anon_inode: false, }, }; let v = serde_json::to_value(&with_node).unwrap(); diff --git a/engine/examples/dashboard_preview.rs b/engine/examples/dashboard_preview.rs index 1abf6d3..00787ab 100644 --- a/engine/examples/dashboard_preview.rs +++ b/engine/examples/dashboard_preview.rs @@ -82,6 +82,7 @@ fn breach_finding() -> Finding { }, Behavior::ProcessExec { path: "/bin/sh".into(), + exe_anon_inode: false, }, Behavior::NetworkConnection { peer: "185.220.101.4:9001".into(), diff --git a/engine/src/engine/dashboard/view_model/alerts.rs b/engine/src/engine/dashboard/view_model/alerts.rs index 49b141c..40255fe 100644 --- a/engine/src/engine/dashboard/view_model/alerts.rs +++ b/engine/src/engine/dashboard/view_model/alerts.rs @@ -47,7 +47,7 @@ fn alarming_now_label(behavior: &Behavior) -> Option<(&'static str, String)> { // the fixed phrasing; the exec path is the untrusted payload. if let Some(label) = exec_class::notable_exec(behavior) { let path = match behavior { - Behavior::ProcessExec { path } => path.as_str(), + Behavior::ProcessExec { path, .. } => path.as_str(), _ => "", }; return Some(("exec", format!("notable exec: {label} ({path})"))); diff --git a/engine/src/engine/observe/alarm_class.rs b/engine/src/engine/observe/alarm_class.rs index dc83f95..764b163 100644 --- a/engine/src/engine/observe/alarm_class.rs +++ b/engine/src/engine/observe/alarm_class.rs @@ -225,6 +225,7 @@ mod tests { }, Behavior::ProcessExec { path: "/usr/bin/dropper".into(), + exe_anon_inode: false, }, Behavior::SecretRead { secret: "/var/run/secrets/kubernetes.io/serviceaccount/token".into(), @@ -247,6 +248,7 @@ mod tests { })); assert!(is_alarming_now(&Behavior::ProcessExec { path: "/bin/bash".into(), // notable exec (interactive shell) + exe_anon_inode: false, })); assert!(is_alarming_now(&write("/etc/cron.d/dropper"))); // alarming write @@ -254,6 +256,7 @@ mod tests { assert!(!is_alarming_now(&write("/data/app.log"))); assert!(!is_alarming_now(&Behavior::ProcessExec { path: "/app/server".into(), // bare exec, not a shell/pkg-mgr + exe_anon_inode: false, })); assert!(!is_alarming_now(&Behavior::NetworkConnection { peer: "10.42.0.1:8086".into(), diff --git a/engine/src/engine/observe/exec_class.rs b/engine/src/engine/observe/exec_class.rs index 7705d76..4865a3e 100644 --- a/engine/src/engine/observe/exec_class.rs +++ b/engine/src/engine/observe/exec_class.rs @@ -1,5 +1,5 @@ //! Exec-classification policy (JEF-55 / JEF-113): is a process-exec a *notable* runtime -//! signal — an interactive shell or a package manager run inside a container? +//! signal — an interactive shell, or a package manager, run inside a container? //! //! This is **engine policy**, not part of the wire type. The shared [`Behavior`] crate is //! pure data (agent + engine both depend on it), so the lists of "what counts as a shell / @@ -12,6 +12,18 @@ //! An interactive-shell exec is a "terminal shell in container"; a package-manager exec is //! "package management launched" — both classic container-tamper signals, classified //! ENGINE-SIDE from the path the agent already emits (no wire change). +//! +//! **JEF-317 (fileless / anon-inode exec) deliberately does NOT live here.** An earlier +//! version classified the exec *path's shape* (`/dev/fd/` etc.) as fileless and fed it +//! into this module's blanket "notable exec" gate — withdrawn by security review, because +//! the kernel synthesizes that identical path for a benign `fexecve()` of an on-disk file +//! too, and runc's own memfd re-exec on ~every container start (CVE-2019-5736's +//! mitigation) would forge corroboration on routine behavior at a high base rate. The real +//! signal — the exec'd binary's backing *inode* (`Behavior::ProcessExec::exe_anon_inode`, +//! a kernel-observed fact set by the agent, not derived from `path` here) — is +//! deliberately scoped MORE narrowly than this module's blanket gate: see +//! `reason::proof::corroborate::anon_inode_exec_on_foothold`, which requires a proven +//! foothold entry AND an Execution-tactic objective, not "any objective like an alert". use crate::engine::graph::Behavior; @@ -58,7 +70,7 @@ fn basename(path: &str) -> &str { /// `false` for any other behavior. pub fn is_interactive_shell(behavior: &Behavior) -> bool { match behavior { - Behavior::ProcessExec { path } => INTERACTIVE_SHELLS.contains(&basename(path)), + Behavior::ProcessExec { path, .. } => INTERACTIVE_SHELLS.contains(&basename(path)), _ => false, } } @@ -69,12 +81,12 @@ pub fn is_interactive_shell(behavior: &Behavior) -> bool { /// on the binary's basename. Always `false` for any other behavior. pub fn is_package_manager(behavior: &Behavior) -> bool { match behavior { - Behavior::ProcessExec { path } => PACKAGE_MANAGERS.contains(&basename(path)), + Behavior::ProcessExec { path, .. } => PACKAGE_MANAGERS.contains(&basename(path)), _ => false, } } -/// A short, human label for a *notable* runtime exec — a shell or package manager run +/// A short, human label for a *notable* runtime exec — a shell, or a package manager, run /// inside the container (JEF-55) — or `None` for an unremarkable behavior. Used to /// annotate the adjudication prompt ("executed /bin/bash (interactive shell in /// container)") and as the corroboration predicate (a notable exec corroborates like an @@ -82,6 +94,9 @@ pub fn is_package_manager(behavior: &Behavior) -> bool { /// corroborate the action bar from the wire type's view — the engine decides what it /// means. The label is a fixed internal string (never untrusted input), safe to embed in /// the prompt. +/// +/// Deliberately does NOT include the JEF-317 anon-inode-exec fact — see the module doc for +/// why: that signal is scoped MORE narrowly than this blanket gate, not folded into it. pub fn notable_exec(behavior: &Behavior) -> Option<&'static str> { if is_interactive_shell(behavior) { Some("interactive shell in container") @@ -117,7 +132,10 @@ mod tests { fn classifies_shells_and_package_managers_from_the_exec_path() { // (exec path, is_shell, is_pkg_mgr) — positives across both lists, with absolute // and bare paths to exercise basename extraction. - let exec = |p: &str| Behavior::ProcessExec { path: p.into() }; + let exec = |p: &str| Behavior::ProcessExec { + path: p.into(), + exe_anon_inode: false, + }; let cases = [ // Interactive shells — "terminal shell in container". ("/bin/sh", true, false), @@ -196,12 +214,15 @@ mod tests { // the evidence blocks saw before the classifier moved out of the wire type (JEF-113). let shell = Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }; let pkg = Behavior::ProcessExec { path: "/usr/bin/apt".into(), + exe_anon_inode: false, }; let normal = Behavior::ProcessExec { path: "/app/server".into(), + exe_anon_inode: false, }; let secret = Behavior::SecretRead { secret: "app/session-key".into(), @@ -224,12 +245,15 @@ mod tests { fn notable_exec_labels_shells_and_package_managers() { let shell = Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }; let pkg = Behavior::ProcessExec { path: "/usr/bin/apt".into(), + exe_anon_inode: false, }; let normal = Behavior::ProcessExec { path: "/app/server".into(), + exe_anon_inode: false, }; // The notable label is a fixed internal token, safe to embed in the prompt. assert_eq!(notable_exec(&shell), Some("interactive shell in container")); @@ -237,4 +261,21 @@ mod tests { // An unremarkable exec is not notable. assert_eq!(notable_exec(&normal), None); } + + /// JEF-317 regression guard: `exe_anon_inode` — the real (inode) fileless-exec fact — + /// must NOT feed this module's blanket "notable exec" gate on its own. The withdrawn + /// v1 approach classified path *shape* into this same gate; Route A deliberately keeps + /// the two separate (see the module doc and + /// `reason::proof::corroborate::anon_inode_exec_on_foothold`, which scopes it far more + /// narrowly than "notable = corroborates any objective"). + #[test] + fn anon_inode_exec_alone_is_not_notable_here() { + let anon = Behavior::ProcessExec { + path: "/app/server".into(), + exe_anon_inode: true, + }; + assert!(!is_interactive_shell(&anon)); + assert!(!is_package_manager(&anon)); + assert_eq!(notable_exec(&anon), None); + } } diff --git a/engine/src/engine/observe/peer_class.rs b/engine/src/engine/observe/peer_class.rs index 820ae95..5817578 100644 --- a/engine/src/engine/observe/peer_class.rs +++ b/engine/src/engine/observe/peer_class.rs @@ -396,6 +396,7 @@ mod tests { }, Behavior::ProcessExec { path: "169.254.169.254:80".into(), + exe_anon_inode: false, }, ]; for b in others { diff --git a/engine/src/engine/reason/adjudicate/tests/group_1.rs b/engine/src/engine/reason/adjudicate/tests/group_1.rs index 7e930ec..4cab3e4 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_1.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_1.rs @@ -240,6 +240,7 @@ fn prompt_hash_is_deterministic_and_order_independent() { let behaviors = vec![ Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }, Behavior::NetworkConnection { peer: "10.0.0.2:5432".into(), @@ -516,6 +517,7 @@ fn unsupported_exploitable_guard_preserves_each_anchored_case() { // Anchor 3b — a corroborating runtime behavior: a notable exec (notable_exec(), JEF-117). let notable = vec![Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }]; assert!(matches!( guard_unsupported_exploitable( diff --git a/engine/src/engine/reason/adjudicate/tests/group_3.rs b/engine/src/engine/reason/adjudicate/tests/group_3.rs index dc7363f..d22aae0 100644 --- a/engine/src/engine/reason/adjudicate/tests/group_3.rs +++ b/engine/src/engine/reason/adjudicate/tests/group_3.rs @@ -288,12 +288,15 @@ fn prompt_keeps_the_notable_exec_annotation_after_the_classifier_move() { let (g, e) = graph_with_behaviors(vec![ Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }, Behavior::ProcessExec { path: "/usr/bin/apt".into(), + exe_anon_inode: false, }, Behavior::ProcessExec { path: "/app/server".into(), + exe_anon_inode: false, }, ]); let prompt = build_judgment_prompt(&e, &[], &g); diff --git a/engine/src/engine/reason/adjudicate/tests/sections.rs b/engine/src/engine/reason/adjudicate/tests/sections.rs index 402018f..a8433e8 100644 --- a/engine/src/engine/reason/adjudicate/tests/sections.rs +++ b/engine/src/engine/reason/adjudicate/tests/sections.rs @@ -17,6 +17,7 @@ use crate::engine::observe::asn::AsnDb; fn section_hashes_isolate_the_changed_section() { let (g_a, entry_a) = graph_with_behaviors(vec![Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }]); let (g_b, entry_b) = graph_with_behaviors(vec![Behavior::FileRead { path: "/etc/passwd".into(), diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 33a6ac0..27bdcde 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -41,12 +41,21 @@ pub(super) struct EntryContext<'a> { /// package-manager exec (JEF-55) corroborates the same broad way (JEF-117): a /// hands-on-keyboard / tamper-now signal that, like the alert, evidences active intrusion /// irrespective of which chain it lands on. An *alarming* file write (JEF-309) — a write to -/// a sensitive path (drop-and-execute / config tamper) — is the third such blanket source +/// a sensitive path (drop-and-execute / config tamper) — is a further such blanket source /// (`observe::alarm_class::alarming_write`). The agent's own mundane behaviors /// (connection / secret-read / library-load) corroborate per objective — each only for /// the objective class whose ATT&CK *tactic* it evidences (JEF-49), so they are never the /// "everything corroborates everything" blanket the alert gate intentionally is. /// +/// **JEF-317 (anon-inode exec) is deliberately NOT one of these blanket sources.** An +/// earlier version routed a "fileless exec" classification (matched on exec *path shape*) +/// into this same blanket gate — withdrawn by security review: the kernel synthesizes the +/// identical path shape for a benign `fexecve()` of an on-disk file, and runc copies +/// itself into a memfd and re-execs via that shape on ~every container start, so it forged +/// corroboration on routine behavior at a high base rate. The real (inode-based) signal — +/// `Behavior::ProcessExec::exe_anon_inode` — is scoped MUCH more narrowly, at the +/// entry-scoped seam: see [`anon_inode_exec_on_foothold`]. +/// /// Matching on `attack.tactic` (not the precise technique) is the stable key: the /// recognizers tag a Secret-read chain CREDENTIAL_ACCESS (T1552), an internet-egress /// chain EXFILTRATION (T1041), and a proven foothold INITIAL_ACCESS / EXPLOIT_PUBLIC_FACING @@ -102,14 +111,15 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { // FileRead never reaches here — the RuntimeAdapter refines it to SecretRead or // drops it before it becomes graph state. Behavior::FileRead { .. } => false, - // A *notable* exec — an interactive shell or package manager in the container + // A *notable* exec — an interactive shell or a package manager run in the container // (JEF-55) — corroborates ANY objective like an Alert does (JEF-117): a tamper-now // signal that evidences active intrusion regardless of chain. Conservative on - // purpose: a *bare* ProcessExec - // (anything else) stays NON-corroborating — legit entrypoints exec constantly - // (the ADR-0011 on-call-engineer false positive), so it remains model evidence - // only. `notable_exec` is `Some` exactly for shell/pkg-mgr execs (JEF-113: the - // classifier is engine policy in `observe::exec_class`, not on the wire type). + // purpose: a *bare* ProcessExec (anything else, including one with + // `exe_anon_inode: true` — see [`anon_inode_exec_on_foothold`] for that shape's + // own, much narrower gate) stays NON-corroborating here — legit entrypoints exec + // constantly (the ADR-0011 on-call-engineer false positive), so it remains model + // evidence only. `notable_exec` is `Some` exactly for shell/pkg-mgr execs (JEF-113: + // the classifier is engine policy in `observe::exec_class`, not on the wire type). Behavior::ProcessExec { .. } => { crate::engine::observe::exec_class::notable_exec(behavior).is_some() } @@ -162,10 +172,12 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { /// entry to a peer in a DIFFERENT namespace ([`cross_tenant_lateral`]) — **privilege /// escalation on the foothold** (JEF-314) — a root escalation on the entry itself /// ([`privilege_escalation_on_foothold`]) — **drop-then-execute** (JEF-321) — a -/// `ProcessExec` of a path a RECENT `FileWrite` dropped ([`drop_then_execute`]) — or **an +/// `ProcessExec` of a path a RECENT `FileWrite` dropped ([`drop_then_execute`]) — **an /// on-host credential read on the foothold** (JEF-320 security rework) — a `SecretRead` with /// [`SecretReadSource::HostPath`] on the entry itself -/// ([`host_credential_read_on_foothold`]). All four are scoped to a proven foothold entry. +/// ([`host_credential_read_on_foothold`]) — or **anon-inode exec on the foothold** (JEF-317, +/// Route A) — an Execution-tactic objective with an `exe_anon_inode` exec on the entry +/// ([`anon_inode_exec_on_foothold`]). All five are scoped to a proven foothold entry. /// /// None of these shapes widens the flat predicates it sits beside: ordinary internet egress, /// ordinary in-cluster traffic, an ordinary setuid, an ordinary write-then-run of a benign @@ -185,6 +197,7 @@ pub(super) fn corroborated_for( || privilege_escalation_on_foothold(runtime, attack, entry) || drop_then_execute(runtime, entry) || host_credential_read_on_foothold(runtime, attack, entry) + || anon_inode_exec_on_foothold(runtime, attack, entry) } /// The cross-tenant lateral-movement shape (JEF-319): a `NetworkConnection` from the entry to @@ -244,7 +257,10 @@ pub(super) fn drop_then_execute(runtime: &[RuntimeSignal], entry: EntryContext<' return false; } runtime.iter().any(|exec| { - let Behavior::ProcessExec { path: exec_path } = &exec.behavior else { + let Behavior::ProcessExec { + path: exec_path, .. + } = &exec.behavior + else { return false; }; runtime.iter().any(|write| { @@ -324,6 +340,49 @@ pub(super) fn host_credential_read_on_foothold( }) } +/// The anon-inode-exec-on-foothold shape (JEF-317, Route A): a `ProcessExec` with +/// `exe_anon_inode: true` on the entry corroborates an Execution-tactic objective (T1610 +/// Deploy Container, T1609 Container Administration Command, and any future T1059-family +/// technique) — the memfd_create/anonymous-fd `execve` Falco fires critical on, here +/// scoped to close the parity gap without forging corroboration on routine behavior. +/// +/// **Why this is conservative in TWO ways, mirroring [`privilege_escalation_on_foothold`]:** +/// scoped to a proven internet-facing foothold entry (`entry.is_foothold`) AND to an +/// Execution-tactic objective — a bare `exe_anon_inode` exec is NEVER routed into the flat +/// [`corroborates`] blanket gate (unlike a shell/package-manager exec), so it can only ever +/// corroborate this one specific tactic on this one specific entry, never "any objective" +/// the way an Alert does. +/// +/// This predicate runs unconditionally once the foothold/tactic gate above is met — no +/// operator-facing flag; like every arm in this module it is shadow-gated (ADR-0014): it +/// only ever sets `corroborated`, never actuates. That scoping is also why running it is +/// safe: it can never actuate, and it can't fire for a pod that isn't the proven entry. +/// +/// **CAVEAT this scoping does NOT fully close (flagged, not silently assumed away):** the +/// real inode signal is genuinely more specific than the withdrawn path-shape one, but it +/// is not proven false-positive-free. runc copies itself into a memfd and re-execs via that +/// memfd on ~every container start (the CVE-2019-5736 mitigation) — whether that re-exec +/// attributes to the WORKLOAD's cgroup (this entry) or to the host container runtime +/// depends on `setns` timing relative to `security_bprm_check` firing, which is UNKNOWN +/// until measured on a live node. If it attributes to the workload, this shape will need an +/// additional discriminator (e.g. pairing with another signal) before it can be trusted at +/// face value — do not widen this scoping further without that on-node measurement (an +/// on-node runc-attribution item to track, not a reason to gate this behind a flag). +pub(super) fn anon_inode_exec_on_foothold( + runtime: &[RuntimeSignal], + attack: &AttackRef, + entry: EntryContext<'_>, +) -> bool { + use crate::engine::graph::attack::Tactic; + if !entry.is_foothold || attack.tactic != Tactic::Execution { + return false; + } + runtime.iter().any(|s| match &s.behavior { + Behavior::ProcessExec { exe_anon_inode, .. } => *exe_anon_inode, + _ => false, + }) +} + /// The entry workload's runtime signals (empty for a non-workload node), resolved once /// per entry so [`corroborated_for`] doesn't re-look-up the constant entry node on every /// objective in the per-objective loop. diff --git a/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs b/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs new file mode 100644 index 0000000..e4ea601 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs @@ -0,0 +1,144 @@ +//! Tests for the JEF-317 (Route A) entry-scoped corroboration shape — anon-inode exec on +//! the foothold — kept in its own `*_tests.rs` file (repo CLAUDE.md: tests count toward the +//! 1,000-line cap). `super` resolves to the proof module, so these exercise the +//! `pub(super)` `corroborate` seam directly, mirroring `corroborate_privesc_tests.rs`. +//! +//! Route A replaces a WITHDRAWN v1 approach (a security review caught it): v1 classified +//! the exec *path shape* (`/dev/fd/` etc.) and fed it into the flat, blanket +//! `corroborates` gate, forging corroboration on routine `fexecve()`/runc-memfd-reexec +//! behavior. This shape instead reads the real (agent-supplied, kernel-observed) +//! `exe_anon_inode` inode fact, and is scoped BOTH to a proven internet-facing foothold +//! entry AND to an Execution-tactic objective — never the "corroborates any objective" +//! blanket gate a shell/package-manager exec gets. It runs unconditionally (no settings +//! flag): shadow-gated like every arm in this module, and inert off the foothold/Execution +//! scope, so it can't actuate and can't fire for a non-foothold pod. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{EntryContext, anon_inode_exec_on_foothold, corroborated_for}; +use crate::engine::graph::Provenance; +use crate::engine::graph::attack::{ + AttackRef, CONTAINER_ADMIN_COMMAND, CREDENTIAL_ACCESS, ESCAPE_TO_HOST, +}; +use crate::engine::graph::{Behavior, RuntimeSignal}; + +/// A base time all `at()` offsets are relative to, so timing is exact regardless of clock. +fn base() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} + +/// A `RuntimeSignal` for `behavior` observed `secs` after [`base`]. +fn sig(behavior: Behavior, secs: u64) -> RuntimeSignal { + RuntimeSignal { + behavior, + provenance: Provenance::new("test", base() + Duration::from_secs(secs)), + } +} + +fn exec(path: &str, exe_anon_inode: bool, secs: u64) -> RuntimeSignal { + sig( + Behavior::ProcessExec { + path: path.into(), + exe_anon_inode, + }, + secs, + ) +} + +/// The entry is a proven internet-facing foothold in namespace `ns`. +fn foothold_entry(ns: &str) -> EntryContext<'_> { + EntryContext { + source_ns: ns, + is_foothold: true, + } +} + +/// The entry is an ordinary (non-foothold) workload in namespace `ns`. +fn ordinary_entry(ns: &str) -> EntryContext<'_> { + EntryContext { + source_ns: ns, + is_foothold: false, + } +} + +/// The objective for these tests: an Execution-tactic chain (T1609 Container +/// Administration Command). +fn execution_objective() -> AttackRef { + CONTAINER_ADMIN_COMMAND +} + +// ---- Positive: anon-inode exec on the foothold entry — end to end ------------------------ + +#[test] +fn anon_inode_exec_on_the_foothold_entry_corroborates_execution() { + // A memfd/unlinked-backed exec on the proven internet-facing foothold IS the + // Falco-parity signal (JEF-317): the attacker who owns the front door running a + // fileless payload on that same workload. + let runtime = [exec("/tmp/payload", true, 0)]; + assert!(corroborated_for( + &runtime, + &execution_objective(), + None, + foothold_entry("frontend"), + )); + // And the predicate directly. + assert!(anon_inode_exec_on_foothold( + &runtime, + &execution_objective(), + foothold_entry("frontend"), + )); +} + +// ---- Negative: same exec, non-foothold entry ---------------------------------------------- + +#[test] +fn anon_inode_exec_on_a_non_foothold_entry_does_not_corroborate() { + // The SAME anon-inode exec, but the entry is an ordinary pod — must NOT corroborate + // (ADR-0011): this is exactly where an unmeasured runc-memfd-reexec false positive + // would land if it attributed to an arbitrary workload rather than a proven foothold. + let runtime = [exec("/tmp/payload", true, 0)]; + assert!(!corroborated_for( + &runtime, + &execution_objective(), + None, + ordinary_entry("frontend"), + )); + assert!(!anon_inode_exec_on_foothold( + &runtime, + &execution_objective(), + ordinary_entry("frontend"), + )); +} + +// ---- Regression guards: don't widen past exe_anon_inode / past Execution ----------------- + +#[test] +fn a_normal_exec_on_the_foothold_does_not_corroborate() { + // exe_anon_inode: false — an ordinary on-disk exec, even on the proven foothold — is + // not the fileless-exec signal Falco fires on. + let runtime = [exec("/app/server", false, 0)]; + assert!(!anon_inode_exec_on_foothold( + &runtime, + &execution_objective(), + foothold_entry("frontend"), + )); +} + +#[test] +fn anon_inode_exec_on_the_foothold_does_not_corroborate_an_unrelated_objective() { + // The shape only lights up an Execution-tactic objective — it must not blanket- + // corroborate a CredentialAccess or PrivilegeEscalation chain just because the entry + // is a foothold. This is the conservative-scoping guard the withdrawn v1 approach + // lacked entirely (it corroborated ANY objective). + let runtime = [exec("/tmp/payload", true, 0)]; + for objective in [CREDENTIAL_ACCESS, ESCAPE_TO_HOST] { + assert!( + !corroborated_for(&runtime, &objective, None, foothold_entry("frontend")), + "{objective:?}" + ); + assert!( + !anon_inode_exec_on_foothold(&runtime, &objective, foothold_entry("frontend")), + "{objective:?}" + ); + } +} diff --git a/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs b/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs index 953aeb1..03326d8 100644 --- a/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs @@ -35,7 +35,13 @@ fn write(path: &str, secs: u64) -> RuntimeSignal { } fn exec(path: &str, secs: u64) -> RuntimeSignal { - sig(Behavior::ProcessExec { path: path.into() }, secs) + sig( + Behavior::ProcessExec { + path: path.into(), + exe_anon_inode: false, + }, + secs, + ) } /// The entry is a proven internet-facing foothold in namespace `ns`. diff --git a/engine/src/engine/reason/proof/corroborate_objective_tests.rs b/engine/src/engine/reason/proof/corroborate_objective_tests.rs index f1263bb..91409c8 100644 --- a/engine/src/engine/reason/proof/corroborate_objective_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_objective_tests.rs @@ -180,6 +180,7 @@ fn alert_still_corroborates_any_objective() { fn shell_exec_corroborates_any_objective() { let shell = Behavior::ProcessExec { path: "/bin/bash".into(), + exe_anon_inode: false, }; assert!(crate::engine::observe::exec_class::is_interactive_shell( &shell @@ -196,6 +197,7 @@ fn shell_exec_corroborates_any_objective() { fn package_manager_exec_corroborates_any_objective() { let pkg = Behavior::ProcessExec { path: "/usr/bin/apt".into(), + exe_anon_inode: false, }; assert!(crate::engine::observe::exec_class::is_package_manager(&pkg)); assert!(corroborates(&pkg, &CREDENTIAL_ACCESS)); @@ -204,13 +206,36 @@ fn package_manager_exec_corroborates_any_objective() { assert!(corroborates(&pkg, &EXPLOIT_PUBLIC_FACING)); } -/// NEGATIVE: a *bare* (non-shell, non-pkg-mgr) ProcessExec stays non-corroborating — -/// legit entrypoints exec constantly (the ADR-0011 false positive). It is model -/// evidence only, never the broad tamper-now gate (JEF-117). +/// NEGATIVE / REGRESSION GUARD (JEF-317): an anon-inode exec (`exe_anon_inode: true`) must +/// NOT blanket-corroborate via the flat [`corroborates`] relation, even though it is a real +/// Falco-parity signal — this is the exact shape a security review flagged in an earlier, +/// withdrawn version (which routed a path-shape classification into this same blanket +/// gate, forging corroboration on routine `fexecve()`/runc-memfd-reexec behavior). The real +/// signal has its own, far narrower, entry-scoped gate — see +/// `corroborate_anon_inode_exec_tests.rs`. +#[test] +fn anon_inode_exec_does_not_blanket_corroborate() { + let anon = Behavior::ProcessExec { + // NOT a shell/package-manager path — isolates exe_anon_inode as the only thing + // under test (a shell path would ALSO blanket-corroborate via notable_exec, for + // an unrelated reason). + path: "/tmp/payload".into(), + exe_anon_inode: true, + }; + assert!(!corroborates(&anon, &CREDENTIAL_ACCESS)); + assert!(!corroborates(&anon, &EXFILTRATION)); + assert!(!corroborates(&anon, &ESCAPE_TO_HOST)); + assert!(!corroborates(&anon, &EXPLOIT_PUBLIC_FACING)); +} + +/// NEGATIVE: a *bare* (non-shell, non-pkg-mgr) ProcessExec stays non-corroborating — legit +/// entrypoints exec constantly (the ADR-0011 false positive). It is model evidence only, +/// never the broad tamper-now gate (JEF-117). #[test] fn bare_exec_does_not_corroborate() { let bare = Behavior::ProcessExec { path: "/app/server".into(), + exe_anon_inode: false, }; assert!(crate::engine::observe::exec_class::notable_exec(&bare).is_none()); assert!(!corroborates(&bare, &CREDENTIAL_ACCESS)); diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 81019d2..848ff1a 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -350,6 +350,8 @@ pub fn prove_with( chains } +#[cfg(test)] +mod corroborate_anon_inode_exec_tests; #[cfg(test)] mod corroborate_context_tests; #[cfg(test)]