diff --git a/agent/common/src/lib.rs b/agent/common/src/lib.rs index 6a61793d..e6c4b723 100644 --- a/agent/common/src/lib.rs +++ b/agent/common/src/lib.rs @@ -36,6 +36,20 @@ pub const KIND_PRIV_CHANGE: u32 = 5; /// config tampering (ADR-0014). Reuses [`FileEvent`] /// (the `kind` discriminates it from the read/exec/library file events). pub const KIND_FILE_WRITE: u32 = 6; +/// A ptrace ATTACH access check (fentry on `security_ptrace_access_check`, filtered +/// in-kernel to `mode & PTRACE_MODE_ATTACH` — JEF-318, Retire-Falco G2). The classic +/// process-injection primitive Falco fires critical on. Carries NO body beyond the shared +/// [`EventHeader`]: the occurrence, attributed by the header's pid/cgroup, IS the fact — the +/// target `task_struct`'s pid is deliberately NOT read (see the eBPF probe's doc comment for +/// why). Userspace emits a `Behavior::PtraceAttach`. +pub const KIND_PTRACE_ATTACH: u32 = 7; +/// A kernel module load (fentry on `security_kernel_load_data`, filtered in-kernel to +/// `id == LOADING_MODULE` — JEF-318, Retire-Falco G2). Falco fires critical on +/// `init_module`/`finit_module`; `load_module()` calls this hook on BOTH syscalls before any +/// parsing, so one probe covers both. Carries NO body beyond [`EventHeader`], same shape as +/// [`KIND_PTRACE_ATTACH`] — the occurrence is the fact. Userspace emits a +/// `Behavior::ModuleLoad`. +pub const KIND_MODULE_LOAD: u32 = 8; /// Max path bytes carried per [`FileEvent`]. Secret-mount paths are well under this; a /// longer path is truncated (the secret name still lands). Sized to keep the eBPF stack diff --git a/agent/protector-agent-ebpf/src/main.rs b/agent/protector-agent-ebpf/src/main.rs index 8711006d..4cb16481 100644 --- a/agent/protector-agent-ebpf/src/main.rs +++ b/agent/protector-agent-ebpf/src/main.rs @@ -37,7 +37,8 @@ use aya_ebpf::{ use protector_agent_common::{ 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, + KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_MODULE_LOAD, KIND_PRIV_CHANGE, KIND_PTRACE_ATTACH, + PATH_CAP, }; /// Ring buffer of behavioral events (all kinds) drained by userspace. @@ -197,6 +198,40 @@ fn allow_credential_read(key: &ReadKey) -> bool { true } +/// In-kernel dedup map for the ptrace-attach probe (JEF-318): `pid` → last-emit time (ns). +/// `security_ptrace_access_check` fires on every PTRACE_MODE_ATTACH check — not just a +/// `ptrace(PTRACE_ATTACH/PTRACE_SEIZE)` syscall, but also `process_vm_readv`/ +/// `process_vm_writev` (a debugger or monitoring tool reading another process's memory), +/// which a legitimate chatty caller can invoke in a tight loop. The dedup key is JUST the +/// attacking `pid` — no target (see [`try_ptrace_access_check`]'s doc for why the target +/// `task_struct` is never read): a repeat attach check from the SAME attacker inside the +/// window is the same "this pid is ptrace-attaching things" fact refreshed, not a new one. +/// Mirrors [`CREDENTIAL_READ_SEEN`]'s JEF-320 ring-DoS lesson — an unbounded fentry on a hook +/// with a legitimate high-frequency caller is exactly the shape that flooded the ring there. +#[map] +static PTRACE_SEEN: LruHashMap = LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0); + +/// The ptrace-attach dedup gate (JEF-318), mirroring [`allow_credential_read`]. Returns +/// `true` if an attach check from `pid` should be emitted, `false` if it's a repeat inside +/// [`DEDUP_WINDOW_NS`] and was coalesced (the shared [`COALESCED`] counter is bumped here). +/// Fail open: an insert that never fails falls through to emit, so a bookkeeping error never +/// silently loses a real signal. The first sighting of a pid (or one LRU-evicted) always emits. +fn allow_ptrace(pid: u32) -> bool { + let now = unsafe { bpf_ktime_get_ns() }; + if let Some(last) = PTRACE_SEEN.get_ptr_mut(&pid) { + // SAFETY: `last` points at this key's live slot; we read then overwrite it. + let last_ns = unsafe { *last }; + if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) { + record_coalesced(); + return false; + } + unsafe { *last = now }; + return true; + } + let _ = PTRACE_SEEN.insert(&pid, &now, 0); + true +} + // Minimal kernel sockaddr layout for the IPv4 case. We only touch the family and the // `sockaddr_in` address/port; reads are bounds-checked by `bpf_probe_read_kernel`. const AF_INET: u16 = 2; @@ -632,6 +667,108 @@ fn exe_is_anon_inode(bprm: *const vmlinux::linux_binprm) -> bool { } } +/// `PTRACE_MODE_ATTACH` (include/linux/ptrace.h) — set when the caller is asking to ATTACH +/// (`PTRACE_ATTACH`/`PTRACE_SEIZE`, or a `process_vm_readv`/`process_vm_writev` cross-process +/// memory access), as opposed to a `PTRACE_MODE_READ`-only check (e.g. every `/proc//…` +/// stat, which fires constantly and carries no injection signal). Filtering to this bit +/// in-kernel is the FIRST volume cut on this hook — see [`try_ptrace_access_check`]. +const PTRACE_MODE_ATTACH: u32 = 0x02; + +/// fentry on `security_ptrace_access_check(struct task_struct *child, unsigned int mode)` — +/// the ptrace-attach probe (JEF-318, Retire-Falco G2). Falco fires critical on a ptrace +/// ATTACH: the classic process-injection primitive (debugger-attach, code injection via +/// `PTRACE_POKETEXT`, credential/memory scraping via `process_vm_readv`). This hook fires on +/// EVERY ptrace access check, including the read-only `PTRACE_MODE_READ` checks +/// `/proc//…` triggers constantly, so [`try_ptrace_access_check`] filters in-kernel to +/// `mode & PTRACE_MODE_ATTACH` before touching anything else — an ATTACH request +/// specifically, not a read-only check — then further dedups per attacking pid +/// ([`allow_ptrace`]) so a legitimate chatty caller (a debugger single-stepping via repeated +/// `process_vm_readv`) can't flood the ring (the JEF-320 ring-DoS lesson). +/// +/// No vmlinux struct read at all: `mode` is passed BY VALUE (a plain `unsigned int` +/// register), and the attacking workload is already fully identified by [`make_header`]'s +/// pid/cgroup. **DECISION (JEF-318):** the target `task_struct`'s pid is deliberately NOT +/// read — `struct task_struct` is enormous and its layout shifts heavily across kernel +/// configs/versions (far more volatile than the already-ON-NODE-PENDING `linux_binprm`/ +/// `inode` offsets from JEF-317), so adding that offset here would be a materially bigger +/// verifier-rejection risk for a field the corroboration predicate below doesn't need — the +/// attacking pid alone is enough to scope the Falco-parity signal to the foothold entry. +#[fentry(function = "security_ptrace_access_check")] +pub fn ptrace_access_check(ctx: FEntryContext) -> u32 { + let _ = try_ptrace_access_check(&ctx); + 0 +} + +fn try_ptrace_access_check(ctx: &FEntryContext) -> Result<(), i64> { + // security_ptrace_access_check's 2nd argument is `unsigned int mode`. + let mode: u32 = unsafe { ctx.arg(1) }; + if mode & PTRACE_MODE_ATTACH == 0 { + return Ok(()); // a read-only access check — not the attach signal + } + let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32; + if !allow_ptrace(pid) { + return Ok(()); + } + emit_fact(KIND_PTRACE_ATTACH); + Ok(()) +} + +/// `enum kernel_load_data_id`'s `LOADING_MODULE` value (`include/linux/kernel_read_file.h`): +/// the enum is a stable, list-ordered generator macro — `LOADING_UNKNOWN`(0), +/// `LOADING_FIRMWARE`(1), `LOADING_MODULE`(2), `LOADING_KEXEC_IMAGE`(3), +/// `LOADING_KEXEC_INITRAMFS`(4), `LOADING_POLICY`(5), `LOADING_X509_CERTIFICATE`(6), +/// `LOADING_MAX_ID`(7). **ON-NODE BTF VERIFICATION PENDING (JEF-318):** confirm against +/// `bpftool btf dump … format c | grep -A8 'enum kernel_load_data_id'` on BOTH fleet arches +/// before this ships past a spike deploy (docs/ebpf-testing-on-nodes.md). Unlike a struct +/// offset, a wrong value here is NOT verifier-checked — it's a plain integer compare, so a +/// reorder (unlikely; this list has been stable since its 5.x introduction, but unconfirmed +/// on THIS fleet kernel) would misclassify silently rather than fail loud. +const LOADING_MODULE: u32 = 2; + +/// fentry on `security_kernel_load_data(enum kernel_load_data_id id, bool contents)` — the +/// kernel-module-load probe (JEF-318, Retire-Falco G2). Falco fires critical on +/// `init_module`/`finit_module`. `load_module()` (kernel/module/main.c) calls this hook +/// EARLY — before any parsing — on BOTH syscalls: `init_module`'s in-memory buffer AND +/// `finit_module`'s fd (which first reaches `security_kernel_read_file(id=READING_MODULE)` +/// to read the fd into that same buffer, then falls through to the same `load_module()` call +/// this probe hooks). One probe on `security_kernel_load_data` therefore covers both +/// syscalls, with no `struct file`/path chase at all: `id` and `contents` are passed BY +/// VALUE (plain scalars), so — like the ptrace probe above — this touches no vmlinux struct +/// offset whatsoever. Filters in-kernel to `id == LOADING_MODULE`: the SAME hook also fires +/// for firmware/kexec/policy/x509 loads, which are not the Falco-parity signal this closes. +/// No dedup gate (unlike ptrace/credential-read above): a real module load is RARE in a +/// normal container workload (no `modprobe`/`insmod` in the entrypoint) — high signal, low +/// volume by construction. +#[fentry(function = "security_kernel_load_data")] +pub fn kernel_load_data(ctx: FEntryContext) -> u32 { + let _ = try_kernel_load_data(&ctx); + 0 +} + +fn try_kernel_load_data(ctx: &FEntryContext) -> Result<(), i64> { + // security_kernel_load_data's 1st argument is `enum kernel_load_data_id id`. + let id: u32 = unsafe { ctx.arg(0) }; + if id != LOADING_MODULE { + return Ok(()); + } + emit_fact(KIND_MODULE_LOAD); + Ok(()) +} + +/// Emit a bare [`EventHeader`]-only fact of `kind` — shared by the ptrace-attach and +/// module-load probes (JEF-318), whose entire signal IS the occurrence, attributed by +/// [`make_header`]'s pid/cgroup, with no further payload. Unlike every other emitter in this +/// file there is no body struct: the ring event for these two kinds IS the header, so +/// userspace's `decode` needs no kind-specific byte parse beyond the header it already reads. +fn emit_fact(kind: u32) { + if let Some(mut slot) = EVENTS.reserve::(0) { + slot.write(make_header(kind)); + slot.submit(0); + } else { + record_drop(); // ring full — count the loss instead of silently skipping + } +} + /// 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.) diff --git a/agent/protector-agent/src/observer.rs b/agent/protector-agent/src/observer.rs index 9492bb1f..e49aa275 100644 --- a/agent/protector-agent/src/observer.rs +++ b/agent/protector-agent/src/observer.rs @@ -80,7 +80,8 @@ mod ebpf { // kernel↔userspace byte contract can't drift (ADR-0014). use protector_agent_common::{ ConnEvent, EventHeader, ExecEvent, FileEvent, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, - KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, PrivEvent, + KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_MODULE_LOAD, KIND_PRIV_CHANGE, KIND_PTRACE_ATTACH, + PATH_CAP, PrivEvent, }; use protector_behavior::{Attribution, Behavior}; @@ -160,6 +161,18 @@ mod ebpf { /// eBPF side already filtered to write-intent opens and deduped repeats to the same /// `(pid, inode)`; this just carries the path through (JEF-306). FileWrite { attr: EventAttr, path: String }, + /// Ptrace ATTACH access check (JEF-318): a process attempted to PTRACE_ATTACH (or + /// PTRACE_SEIZE / a cross-process memory access) another process — the + /// process-injection primitive Falco fires critical on. No payload beyond + /// attribution: the attacking pid/cgroup IS the fact (the target pid is deliberately + /// not read — see the eBPF probe's doc comment). + PtraceAttach { attr: EventAttr }, + /// Kernel module load (JEF-318): `init_module`/`finit_module` reached + /// `load_module()`'s `security_kernel_load_data(LOADING_MODULE, …)` call — a + /// container loading arbitrary code into the HOST kernel, the module-load parity + /// signal Falco fires critical on. No payload beyond attribution — the occurrence is + /// the fact. + ModuleLoad { attr: EventAttr }, } /// The pair of identities every event carries for attribution (JEF-158): the in-kernel @@ -190,7 +203,9 @@ mod ebpf { | RawEvent::LibraryLoad { attr, .. } | RawEvent::PrivChange { attr, .. } | RawEvent::Exec { attr, .. } - | RawEvent::FileWrite { attr, .. } => *attr, + | RawEvent::FileWrite { attr, .. } + | RawEvent::PtraceAttach { attr, .. } + | RawEvent::ModuleLoad { attr, .. } => *attr, } } @@ -224,6 +239,8 @@ mod ebpf { exe_anon_inode, }, RawEvent::FileWrite { path, .. } => Behavior::FileWrite { path }, + RawEvent::PtraceAttach { .. } => Behavior::PtraceAttach, + RawEvent::ModuleLoad { .. } => Behavior::ModuleLoad, } } } @@ -592,6 +609,8 @@ mod ebpf { ("mmap_file", "security_mmap_file"), ("fix_setuid", "security_task_fix_setuid"), ("bprm_check", "security_bprm_check"), + ("ptrace_access_check", "security_ptrace_access_check"), + ("kernel_load_data", "security_kernel_load_data"), ]; let attempted = FENTRY_PROBES.len() as u32; let btf = match Btf::from_sys_fs() { @@ -692,6 +711,14 @@ mod ebpf { let ev = unsafe { std::ptr::read_unaligned(data.as_ptr().cast::()) }; Self::file_write(&ev) } + // JEF-318: both bodies ARE the header — already parsed above, and its length + // already checked at the top of this function — so no further byte parse. + KIND_PTRACE_ATTACH => Some(RawEvent::PtraceAttach { + attr: EventAttr::from_header(&header), + }), + KIND_MODULE_LOAD => Some(RawEvent::ModuleLoad { + attr: EventAttr::from_header(&header), + }), _ => None, // unknown kind (older/newer probe set) — skip } } 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 db9ef6bd..08f615bd 100644 --- a/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs +++ b/agent/protector-agent/src/observer/ebpf/observer_ebpf_tests.rs @@ -270,3 +270,65 @@ fn decode_file_write_parses_path_and_maps_to_file_write() { other => panic!("expected FileWrite, got {other:?}"), } } + +#[test] +fn decode_ptrace_attach_parses_with_no_body_beyond_the_header() { + // JEF-318: a KIND_PTRACE_ATTACH event IS an EventHeader — no extra bytes, unlike every + // other kind's body. Decode must still succeed on exactly `size_of::()` + // bytes and attribute + map it to Behavior::PtraceAttach. + let header = EventHeader { + kind: KIND_PTRACE_ATTACH, + pid: 4321, + cgroup_id: 999, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + (&header as *const EventHeader).cast::(), + std::mem::size_of::(), + ) + }; + let raw = EbpfObserver::decode(bytes).expect("KIND_PTRACE_ATTACH should decode"); + match &raw { + RawEvent::PtraceAttach { attr } => { + assert_eq!(attr.pid, 4321); + assert_eq!(attr.cgroup_id, 999); + } + _ => panic!("expected RawEvent::PtraceAttach"), + } + assert_eq!(raw.attr().pid, 4321); + assert_eq!(raw.into_behavior(), Behavior::PtraceAttach); +} + +#[test] +fn decode_module_load_parses_with_no_body_beyond_the_header() { + // JEF-318: same header-only shape as KIND_PTRACE_ATTACH, distinct kind + behavior. + let header = EventHeader { + kind: KIND_MODULE_LOAD, + pid: 555, + cgroup_id: 4242, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + (&header as *const EventHeader).cast::(), + std::mem::size_of::(), + ) + }; + let raw = EbpfObserver::decode(bytes).expect("KIND_MODULE_LOAD should decode"); + match &raw { + RawEvent::ModuleLoad { attr } => { + assert_eq!(attr.pid, 555); + assert_eq!(attr.cgroup_id, 4242); + } + _ => panic!("expected RawEvent::ModuleLoad"), + } + assert_eq!(raw.attr().cgroup_id, 4242); + assert_eq!(raw.into_behavior(), Behavior::ModuleLoad); +} + +#[test] +fn decode_drops_a_truncated_event_shorter_than_the_header() { + // A byte slice shorter than EventHeader itself must never be parsed — regardless of + // kind, since decode() reads the header before it can even dispatch. + let too_short = [0u8; 4]; + assert!(EbpfObserver::decode(&too_short).is_none()); +} diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index e5245fc4..a525cc95 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -105,6 +105,28 @@ pub enum Behavior { /// holds — the agent already sees `/proc//exe`). PURE DATA: the agent classifies /// the bytes; the *reachability* consequence is engine policy (JEF-404). ImageLinkage { static_linkage: bool }, + /// A ptrace ATTACH access check (JEF-318, Retire-Falco G2): the eBPF agent's + /// `security_ptrace_access_check` probe, filtered in-kernel to `mode & + /// PTRACE_MODE_ATTACH` so the read-only `PTRACE_MODE_READ` checks `/proc//…` makes + /// constantly never reach the wire. The classic process-injection primitive Falco fires + /// critical on (debugger-attach, code injection, credential/memory scraping via + /// `process_vm_readv`). No fields: unlike a struct-offset read, this is a PURE + /// occurrence fact — the attacking workload is already carried by + /// [`RuntimeObservation::attribution`], and the target process's pid is deliberately not + /// read by the agent (a `struct task_struct` offset read judged too fragile for this + /// signal — see the agent's probe doc). PURE DATA (JEF-113): whether an attach on this + /// entry is alarming is engine policy (`engine::reason::proof::corroborate`), + /// conservatively foothold-scoped, not decided here. + PtraceAttach, + /// A kernel module load (JEF-318, Retire-Falco G2): the eBPF agent's + /// `security_kernel_load_data` probe, filtered in-kernel to `id == LOADING_MODULE` so + /// firmware/kexec/policy/x509 loads on the SAME hook never reach the wire. Covers BOTH + /// `init_module` and `finit_module` — `load_module()` reaches this hook on either path. + /// The module-load parity signal Falco fires critical on: a container loading arbitrary + /// code into the HOST kernel. No fields — the occurrence, attributed by + /// [`RuntimeObservation::attribution`], is the whole fact. PURE DATA (JEF-113): engine + /// policy decides whether it's alarming, conservatively foothold-scoped, not this crate. + ModuleLoad, } /// How a [`Behavior::SecretRead`] was observed — a type distinction, not a string @@ -199,6 +221,8 @@ impl Behavior { Behavior::ProcessExec { .. } => "exec", Behavior::FileWrite { .. } => "file-write", Behavior::ImageLinkage { .. } => "image-linkage", + Behavior::PtraceAttach => "ptrace-attach", + Behavior::ModuleLoad => "module-load", } } @@ -258,6 +282,10 @@ impl Behavior { "entrypoint is a dynamically linked binary".to_string() } } + // No fields to render — the occurrence, attributed by the observation's + // workload, is the whole fact (JEF-318). + Behavior::PtraceAttach => "ptrace attach (process injection primitive)".to_string(), + Behavior::ModuleLoad => "loaded a kernel module".to_string(), } } @@ -318,6 +346,10 @@ impl Behavior { // bool verbatim — the two states are genuinely distinct facts, and it's // low-cardinality by construction (exactly two values). Behavior::ImageLinkage { static_linkage } => format!("linkage:{static_linkage}"), + // No varying fields, so a fixed token is already maximally coarse (JEF-318) — + // mirrors how a fieldless fact would key regardless of source. + Behavior::PtraceAttach => "ptrace-attach".to_string(), + Behavior::ModuleLoad => "module-load".to_string(), } } } diff --git a/behavior/src/tests.rs b/behavior/src/tests.rs index 7662a1c8..b7449332 100644 --- a/behavior/src/tests.rs +++ b/behavior/src/tests.rs @@ -252,11 +252,63 @@ fn exe_anon_inode_serializes_only_when_true() { assert_eq!(legacy, normal); } +#[test] +fn ptrace_attach_and_module_load_are_fieldless_facts() { + // JEF-318: both new variants carry no fields at all — the occurrence, attributed by + // RuntimeObservation::attribution, IS the whole fact. Serde round-trips to a bare + // `{"kind": "..."}`, summary/fingerprint are fixed strings, and neither is a wire-type + // Alert (only Behavior::Alert corroborates from this crate's own view — the foothold + // scoping is engine policy, JEF-113). + let ptrace = Behavior::PtraceAttach; + let v = serde_json::to_value(&ptrace).unwrap(); + assert_eq!(v, serde_json::json!({"kind": "ptrace_attach"})); + assert_eq!(serde_json::from_value::(v).unwrap(), ptrace); + assert_eq!( + ptrace.summary(), + "ptrace attach (process injection primitive)" + ); + assert_eq!(ptrace.fingerprint_key(), "ptrace-attach"); + assert_eq!(ptrace.variant_label(), "ptrace-attach"); + assert!(!ptrace.is_alert()); + + let module_load = Behavior::ModuleLoad; + let v = serde_json::to_value(&module_load).unwrap(); + assert_eq!(v, serde_json::json!({"kind": "module_load"})); + assert_eq!(serde_json::from_value::(v).unwrap(), module_load); + assert_eq!(module_load.summary(), "loaded a kernel module"); + assert_eq!(module_load.fingerprint_key(), "module-load"); + assert_eq!(module_load.variant_label(), "module-load"); + assert!(!module_load.is_alert()); + + // Distinguishable from each other and from every existing variant. + assert_ne!(ptrace.fingerprint_key(), module_load.fingerprint_key()); + assert_ne!(ptrace.variant_label(), module_load.variant_label()); +} + +#[test] +fn ptrace_attach_observation_round_trips_over_the_wire() { + // The full RuntimeObservation the agent POSTs for a ptrace attach — attributed by pod + // UID (the eBPF agent's path), source + node stamped — round-trips (JEF-318). + let obs = RuntimeObservation { + attribution: Attribution::by_pod_uid("uid"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: Some("node-a".into()), + behavior: Behavior::PtraceAttach, + }; + let v = serde_json::to_value(&obs).unwrap(); + assert_eq!(v["behavior"], serde_json::json!({"kind": "ptrace_attach"})); + assert_eq!( + serde_json::from_value::(v).unwrap(), + obs + ); +} + #[test] fn variant_label_is_a_stable_low_cardinality_token() { // Each variant maps to a fixed token carrying NO per-instance payload (no peer, // path, or secret name) — so it's safe as a metric label without cardinality blow-up. - let cases: [(Behavior, &str); 9] = [ + let cases: [(Behavior, &str); 11] = [ (Behavior::Alert { rule: "x".into() }, "alert"), ( Behavior::NetworkConnection { @@ -300,6 +352,8 @@ fn variant_label_is_a_stable_low_cardinality_token() { }, "image-linkage", ), + (Behavior::PtraceAttach, "ptrace-attach"), + (Behavior::ModuleLoad, "module-load"), ]; for (behavior, want) in cases { assert_eq!(behavior.variant_label(), want, "{behavior:?}"); diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 27bdcdeb..af89f889 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -149,6 +149,15 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { // (only `LoadedAtRuntime` is CVE evidence — a static-linkage fact must never read as // exploitation or reassurance). Behavior::ImageLinkage { .. } => false, + // PtraceAttach / ModuleLoad (JEF-318) are NON-corroborating here, mirroring + // PrivilegeChange: a debugger, strace, a supervisor ptrace-attaching its own child, + // or a legitimate driver load (e.g. a CNI/CSI DaemonSet, a kernel-module operator) + // are all ordinary on SOME pods, so blanket-corroborating either here would repeat + // the ADR-0011 on-call-engineer false positive. Both get the SAME entry-scoped + // treatment `PrivilegeChange` does — see [`ptrace_attach_on_foothold`] / + // [`module_load_on_foothold`] below. + Behavior::PtraceAttach => false, + Behavior::ModuleLoad => false, } } @@ -175,15 +184,18 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { /// `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`]) — or **anon-inode exec on the foothold** (JEF-317, +/// ([`host_credential_read_on_foothold`]) — **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. +/// ([`anon_inode_exec_on_foothold`]) — **ptrace-attach on the foothold** (JEF-318) — a +/// `PtraceAttach` on the entry itself ([`ptrace_attach_on_foothold`]) — or **kernel-module +/// load on the foothold** (JEF-318) — a `ModuleLoad` on the entry itself +/// ([`module_load_on_foothold`]). All seven 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 -/// path, and an ordinary in-container process reading a host credential path off a -/// non-foothold pod all still corroborate nothing (ADR-0011). Like every arm here this only -/// sets `corroborated`; it never actuates +/// path, an ordinary in-container process reading a host credential path, and an ordinary +/// ptrace-attach or driver load off a non-foothold pod all still corroborate nothing +/// (ADR-0011). Like every arm here this only sets `corroborated`; it never actuates /// (shadow-gated, ADR-0014). pub(super) fn corroborated_for( runtime: &[RuntimeSignal], @@ -198,6 +210,8 @@ pub(super) fn corroborated_for( || drop_then_execute(runtime, entry) || host_credential_read_on_foothold(runtime, attack, entry) || anon_inode_exec_on_foothold(runtime, attack, entry) + || ptrace_attach_on_foothold(runtime, attack, entry) + || module_load_on_foothold(runtime, attack, entry) } /// The cross-tenant lateral-movement shape (JEF-319): a `NetworkConnection` from the entry to @@ -383,6 +397,67 @@ pub(super) fn anon_inode_exec_on_foothold( }) } +/// The ptrace-attach-on-foothold shape (JEF-318, Retire-Falco G2): a `Behavior::PtraceAttach` +/// on the entry itself corroborates a PrivilegeEscalation-tactic objective — the ptrace +/// ATTACH (process injection: debugger-attach, code injection, credential/memory scraping) +/// Falco fires critical on, here scoped to close the parity gap without the false positive +/// Falco doesn't guard against (a debugger, `strace`, or a supervisor ptrace-attaching its +/// own child is ordinary operational behavior on plenty of pods). +/// +/// **Tactic note:** ATT&CK's T1055 Process Injection is dual-tagged Defense Evasion / +/// Privilege Escalation; this repo's [`Tactic`](crate::engine::graph::attack::Tactic) enum +/// has no `DefenseEvasion` variant, so — like [`privilege_escalation_on_foothold`]'s own +/// T1611/T1098.006 precedent — this lands on `PrivilegeEscalation`. Widening the enum with a +/// dedicated `DefenseEvasion` variant is a follow-up if a future shape needs the distinction +/// (JEF-318 flags it, doesn't resolve it). +/// +/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`privilege_escalation_on_foothold`]: +/// corroborates ONLY when the entry is a proven internet-facing foothold (`entry.is_foothold`) +/// AND `attack.tactic` is `PrivilegeEscalation`. +pub(super) fn ptrace_attach_on_foothold( + runtime: &[RuntimeSignal], + attack: &AttackRef, + entry: EntryContext<'_>, +) -> bool { + use crate::engine::graph::attack::Tactic; + if !entry.is_foothold || attack.tactic != Tactic::PrivilegeEscalation { + return false; + } + runtime + .iter() + .any(|s| matches!(s.behavior, Behavior::PtraceAttach)) +} + +/// The module-load-on-foothold shape (JEF-318, Retire-Falco G2): a `Behavior::ModuleLoad` on +/// the entry itself corroborates a PrivilegeEscalation-tactic objective — a container loading +/// arbitrary code into the HOST kernel (T1547.006 Kernel Modules and Extensions / effectively +/// T1611 Escape to Host — loading a kernel module from inside a container IS host +/// compromise), the module-load parity signal Falco fires critical on. +/// +/// Same tactic-mapping note as [`ptrace_attach_on_foothold`]: no dedicated tactic exists for +/// this repo's enum beyond `PrivilegeEscalation`, which — for a signal this severe (full +/// kernel-mode code execution) — is at least as defensible a home as the T1611 precedent +/// [`privilege_escalation_on_foothold`] already established. +/// +/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`ptrace_attach_on_foothold`]: +/// corroborates ONLY when the entry is a proven internet-facing foothold (`entry.is_foothold`) +/// AND `attack.tactic` is `PrivilegeEscalation` — a legitimate driver-loading DaemonSet (a +/// CNI/CSI plugin, a kernel-module operator) on an ordinary, non-foothold pod never +/// corroborates. +pub(super) fn module_load_on_foothold( + runtime: &[RuntimeSignal], + attack: &AttackRef, + entry: EntryContext<'_>, +) -> bool { + use crate::engine::graph::attack::Tactic; + if !entry.is_foothold || attack.tactic != Tactic::PrivilegeEscalation { + return false; + } + runtime + .iter() + .any(|s| matches!(s.behavior, Behavior::ModuleLoad)) +} + /// 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_module_load_tests.rs b/engine/src/engine/reason/proof/corroborate_module_load_tests.rs new file mode 100644 index 00000000..44db1366 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_module_load_tests.rs @@ -0,0 +1,135 @@ +//! Tests for the JEF-318 entry-scoped corroboration shape — kernel-module load on the +//! foothold — kept in its own `*_tests.rs` file (repo CLAUDE.md: tests count toward the +//! 1,000-line file cap). `super` resolves to the proof module, so these exercise the +//! `pub(super)` `corroborate` seam directly. +//! +//! The shape closes the Falco-parity gap: Falco fires critical on `init_module`/ +//! `finit_module`, but the flat `corroborates(ModuleLoad, _)` relation stays +//! non-corroborating everywhere (ADR-0011: a legitimate driver-loading DaemonSet — a CNI/CSI +//! plugin, a kernel-module operator — loads modules on ordinary pods). This shape is scoped +//! to a proven internet-facing foothold entry ONLY, mirroring +//! [`corroborate_ptrace_tests`](super::corroborate_ptrace_tests) — it is shadow-gated (only +//! sets `corroborated`, never actuates) like every arm here. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{EntryContext, corroborated_for, module_load_on_foothold}; +use crate::engine::graph::Provenance; +use crate::engine::graph::attack::{AttackRef, 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)), + } +} + +/// 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: a PrivilegeEscalation-tactic chain (T1611 Escape to Host) — +/// the same tactic `module_load_on_foothold` gates on (JEF-318: a kernel module load from +/// inside a container IS host compromise, see the predicate's doc comment). +fn priv_esc_objective() -> AttackRef { + ESCAPE_TO_HOST +} + +// ---- Positive: module load on the foothold entry — end to end ----------------------------- + +#[test] +fn module_load_on_the_foothold_entry_corroborates_priv_esc() { + let runtime = [sig(Behavior::ModuleLoad, 0)]; + assert!(corroborated_for( + &runtime, + &priv_esc_objective(), + None, + foothold_entry("frontend"), + )); + // And the predicate directly. + assert!(module_load_on_foothold( + &runtime, + &priv_esc_objective(), + foothold_entry("frontend"), + )); +} + +// ---- Negative: same load, non-foothold entry ----------------------------------------------- + +#[test] +fn module_load_on_a_non_foothold_entry_does_not_corroborate() { + // The SAME module load, but the entry is an ordinary pod — a legit driver-loading + // DaemonSet on an unrelated workload must NOT corroborate (ADR-0011). + let runtime = [sig(Behavior::ModuleLoad, 0)]; + assert!(!corroborated_for( + &runtime, + &priv_esc_objective(), + None, + ordinary_entry("frontend"), + )); + assert!(!module_load_on_foothold( + &runtime, + &priv_esc_objective(), + ordinary_entry("frontend"), + )); +} + +// ---- Regression guard: don't widen past PrivilegeEscalation -------------------------------- + +#[test] +fn module_load_on_the_foothold_does_not_corroborate_an_unrelated_objective() { + // The shape only lights up a PrivilegeEscalation-tactic objective — it must not blanket- + // corroborate a CredentialAccess chain just because the entry is a foothold. + let runtime = [sig(Behavior::ModuleLoad, 0)]; + assert!(!corroborated_for( + &runtime, + &CREDENTIAL_ACCESS, + None, + foothold_entry("frontend"), + )); + assert!(!module_load_on_foothold( + &runtime, + &CREDENTIAL_ACCESS, + foothold_entry("frontend"), + )); +} + +#[test] +fn other_behaviors_on_the_foothold_do_not_trigger_this_shape() { + // A PtraceAttach (JEF-318's OTHER new shape) and an ordinary ProcessExec on the same + // foothold entry must not be mistaken for a module load. + let runtime = [ + sig(Behavior::PtraceAttach, 0), + sig( + Behavior::ProcessExec { + path: "/app/server".into(), + exe_anon_inode: false, + }, + 1, + ), + ]; + assert!(!module_load_on_foothold( + &runtime, + &priv_esc_objective(), + foothold_entry("frontend"), + )); +} diff --git a/engine/src/engine/reason/proof/corroborate_ptrace_tests.rs b/engine/src/engine/reason/proof/corroborate_ptrace_tests.rs new file mode 100644 index 00000000..e6fcd491 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_ptrace_tests.rs @@ -0,0 +1,136 @@ +//! Tests for the JEF-318 entry-scoped corroboration shape — ptrace-attach on the foothold — +//! kept in its own `*_tests.rs` file (repo CLAUDE.md: tests count toward the 1,000-line file +//! cap). `super` resolves to the proof module, so these exercise the `pub(super)` +//! `corroborate` seam directly. +//! +//! The shape closes the Falco-parity gap: Falco fires critical on a ptrace ATTACH (the +//! classic process-injection primitive), but the flat `corroborates(PtraceAttach, _)` +//! relation stays non-corroborating everywhere (ADR-0011: a debugger, `strace`, or a +//! supervisor ptrace-attaching its own child is ordinary operational behavior on plenty of +//! pods). This shape is scoped to a proven internet-facing foothold entry ONLY, mirroring +//! [`corroborate_privesc_tests`](super::corroborate_privesc_tests) — it is shadow-gated +//! (only sets `corroborated`, never actuates) like every arm here. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{EntryContext, corroborated_for, ptrace_attach_on_foothold}; +use crate::engine::graph::Provenance; +use crate::engine::graph::attack::{AttackRef, 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)), + } +} + +/// 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: a PrivilegeEscalation-tactic chain (T1611 Escape to Host) — +/// the same tactic `ptrace_attach_on_foothold` gates on (JEF-318: no dedicated +/// `DefenseEvasion` tactic exists in this repo's enum, see the predicate's doc comment). +fn priv_esc_objective() -> AttackRef { + ESCAPE_TO_HOST +} + +// ---- Positive: ptrace attach on the foothold entry — end to end ------------------------- + +#[test] +fn ptrace_attach_on_the_foothold_entry_corroborates_priv_esc() { + let runtime = [sig(Behavior::PtraceAttach, 0)]; + assert!(corroborated_for( + &runtime, + &priv_esc_objective(), + None, + foothold_entry("frontend"), + )); + // And the predicate directly. + assert!(ptrace_attach_on_foothold( + &runtime, + &priv_esc_objective(), + foothold_entry("frontend"), + )); +} + +// ---- Negative: same attach, non-foothold entry -------------------------------------------- + +#[test] +fn ptrace_attach_on_a_non_foothold_entry_does_not_corroborate() { + // The SAME attach, but the entry is an ordinary pod — a legit debugger/supervisor + // ptrace-attaching its own child on an unrelated workload must NOT corroborate + // (ADR-0011). + let runtime = [sig(Behavior::PtraceAttach, 0)]; + assert!(!corroborated_for( + &runtime, + &priv_esc_objective(), + None, + ordinary_entry("frontend"), + )); + assert!(!ptrace_attach_on_foothold( + &runtime, + &priv_esc_objective(), + ordinary_entry("frontend"), + )); +} + +// ---- Regression guard: don't widen past PrivilegeEscalation -------------------------------- + +#[test] +fn ptrace_attach_on_the_foothold_does_not_corroborate_an_unrelated_objective() { + // The shape only lights up a PrivilegeEscalation-tactic objective — it must not blanket- + // corroborate a CredentialAccess chain just because the entry is a foothold. + let runtime = [sig(Behavior::PtraceAttach, 0)]; + assert!(!corroborated_for( + &runtime, + &CREDENTIAL_ACCESS, + None, + foothold_entry("frontend"), + )); + assert!(!ptrace_attach_on_foothold( + &runtime, + &CREDENTIAL_ACCESS, + foothold_entry("frontend"), + )); +} + +#[test] +fn other_behaviors_on_the_foothold_do_not_trigger_this_shape() { + // A ModuleLoad (JEF-318's OTHER new shape) and an ordinary ProcessExec on the same + // foothold entry must not be mistaken for a ptrace attach. + let runtime = [ + sig(Behavior::ModuleLoad, 0), + sig( + Behavior::ProcessExec { + path: "/app/server".into(), + exe_anon_inode: false, + }, + 1, + ), + ]; + assert!(!ptrace_attach_on_foothold( + &runtime, + &priv_esc_objective(), + foothold_entry("frontend"), + )); +} diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 848ff1ac..a48a9618 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -359,10 +359,14 @@ mod corroborate_drop_exec_tests; #[cfg(test)] mod corroborate_host_credential_tests; #[cfg(test)] +mod corroborate_module_load_tests; +#[cfg(test)] mod corroborate_objective_tests; #[cfg(test)] mod corroborate_privesc_tests; #[cfg(test)] +mod corroborate_ptrace_tests; +#[cfg(test)] mod corroborate_tests; #[cfg(test)] mod pivot_quarantine_tests;