diff --git a/agent/protector-agent/src/linkage.rs b/agent/protector-agent/src/linkage.rs new file mode 100644 index 0000000..4299220 --- /dev/null +++ b/agent/protector-agent/src/linkage.rs @@ -0,0 +1,144 @@ +//! Per-workload ELF static/dynamic **linkage** classification (JEF-407). +//! +//! The engine has no in-cluster access to a workload's entrypoint bytes, so JEF-404's +//! static-linkage reachability sat dormant — `Image::static_binary` was always `None` in +//! prod and a Go / musl-static CVE rendered `not-observed` forever. The node-local agent DOES +//! see the running binary (`/proc//exe`), so it is the natural byte source: on an exec it +//! reads only the leading ELF header bytes, classifies linkage with the SAME shared parser the +//! engine uses ([`protector_behavior::elf::elf_static_linkage`] — no `PT_INTERP` ⇒ static), and +//! emits a [`Behavior::ImageLinkage`] over the EXISTING behavioral wire. No new egress (the +//! zero-egress invariant holds), no heavy dependency (the parser is std-only). +//! +//! Split into a pure classifier (injectable read, unit-testable without a real `/proc`) and a +//! thin `/proc` reader (compiled only where it's used), keeping the byte source a separate, +//! testable concern from the ELF logic — the same shape the rest of the agent uses. + +use protector_behavior::{Behavior, elf::elf_static_linkage}; + +/// How many leading bytes of the entrypoint binary to read. The classifier inspects only the +/// ELF header and the program-header table; a `PT_INTERP` (the dynamic-loader marker) is +/// among the very first program headers a linker emits, so 4 KiB comfortably covers the +/// header + the whole program-header table of any real executable while staying a single +/// cheap page-sized read. A binary whose table somehow extends past this simply classifies +/// `None` (unknown) — never a wrong answer (the classifier is conservative by construction). +pub const ELF_HEAD_CAP: usize = 4096; + +/// Classify a pid's entrypoint linkage from its ELF header (JEF-407). +/// +/// Returns `Some(true)` for a statically linked binary (no `PT_INTERP`), `Some(false)` for a +/// dynamically linked one, and `None` when the linkage is unknown — the exe couldn't be read +/// (process gone / denied) or the bytes don't parse as an ELF we recognize. `None` is dropped +/// by the caller rather than guessed, so the engine keeps its prior `static_binary == None` +/// behavior for that workload — never a false "static". +/// +/// `read_head` is injected (a `Fn(u32) -> Option>` yielding the leading bytes of +/// `/proc//exe`) so this is unit-testable with synthetic ELF fixtures and no real `/proc`. +pub fn classify_linkage(read_head: impl Fn(u32) -> Option>, pid: u32) -> Option { + elf_static_linkage(&read_head(pid)?) +} + +/// The wire signal for a classified linkage: a [`Behavior::ImageLinkage`] carrying the +/// static/dynamic bit. Trivial, but named so the observer's emit path reads clearly and the +/// mapping lives in one place. +pub fn linkage_behavior(static_linkage: bool) -> Behavior { + Behavior::ImageLinkage { static_linkage } +} + +/// Read up to [`ELF_HEAD_CAP`] leading bytes of a pid's entrypoint binary via +/// `/proc//exe`. `None` if the process is gone or the exe is unreadable (a kernel thread, +/// a denied read) — the linkage is then unknown and the caller drops it, never fatal. Reads +/// only the header prefix, not the whole (possibly huge) binary. +pub fn read_exe_head(pid: u32) -> Option> { + use std::io::Read; + let file = std::fs::File::open(format!("/proc/{pid}/exe")).ok()?; + let mut buf = Vec::with_capacity(ELF_HEAD_CAP); + // `take` bounds the read to the header prefix regardless of the binary's real size. + file.take(ELF_HEAD_CAP as u64).read_to_end(&mut buf).ok()?; + Some(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal 64-bit little-endian ELF header + program-header table over `p_types` + /// (mirrors the shared classifier's own fixtures — the smallest bytes that classify). + fn elf64_le(p_types: &[u32]) -> Vec { + const EHDR64: usize = 0x40; + const PHENT64: usize = 0x38; + let phnum = p_types.len(); + let phoff = EHDR64; + let mut buf = vec![0u8; EHDR64 + phnum * PHENT64]; + buf[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']); + buf[4] = 2; // ELFCLASS64 + buf[5] = 1; // ELFDATA2LSB + buf[0x20..0x28].copy_from_slice(&(phoff as u64).to_le_bytes()); + buf[0x36..0x38].copy_from_slice(&(PHENT64 as u16).to_le_bytes()); + buf[0x38..0x3A].copy_from_slice(&(phnum as u16).to_le_bytes()); + for (i, &p_type) in p_types.iter().enumerate() { + let at = phoff + i * PHENT64; + buf[at..at + 4].copy_from_slice(&p_type.to_le_bytes()); + } + buf + } + + const PT_LOAD: u32 = 1; + const PT_INTERP: u32 = 3; + + #[test] + fn static_entrypoint_classifies_static() { + // A Go / musl-static entrypoint: LOAD segments, no PT_INTERP → Some(true). + let bytes = elf64_le(&[PT_LOAD, PT_LOAD]); + assert_eq!(classify_linkage(|_| Some(bytes.clone()), 42), Some(true)); + } + + #[test] + fn dynamic_entrypoint_classifies_dynamic() { + // A glibc dynamically linked entrypoint: a PT_INTERP among the LOADs → Some(false). + let bytes = elf64_le(&[PT_LOAD, PT_INTERP]); + assert_eq!(classify_linkage(|_| Some(bytes.clone()), 42), Some(false)); + } + + #[test] + fn unreadable_exe_is_unknown() { + // The process is gone / the exe is unreadable → None (dropped, never guessed). + assert_eq!(classify_linkage(|_| None, 42), None); + } + + #[test] + fn non_elf_bytes_are_unknown() { + // A script interpreter or garbage prefix → None, not a wrong classification. + assert_eq!( + classify_linkage(|_| Some(b"#!/bin/sh\n".to_vec()), 42), + None + ); + } + + #[test] + fn read_exe_head_reads_at_most_the_cap_and_never_panics() { + // `/proc/self/exe` exists on Linux (where the agent runs); reading it must return the + // leading header bytes bounded by the cap, and must never read the whole binary. On a + // non-Linux dev box `/proc` is absent → None (the same honest "unknown" the classifier + // drops). Either way the read must not panic. + if let Some(head) = read_exe_head(std::process::id()) { + assert!(head.len() <= ELF_HEAD_CAP, "read is bounded by the cap"); + assert!(!head.is_empty(), "a real binary has a header"); + } + } + + #[test] + fn linkage_behavior_carries_the_bit() { + assert_eq!( + linkage_behavior(true), + Behavior::ImageLinkage { + static_linkage: true + } + ); + assert_eq!( + linkage_behavior(false), + Behavior::ImageLinkage { + static_linkage: false + } + ); + } +} diff --git a/agent/protector-agent/src/main.rs b/agent/protector-agent/src/main.rs index df07d87..22c6f9d 100644 --- a/agent/protector-agent/src/main.rs +++ b/agent/protector-agent/src/main.rs @@ -7,6 +7,11 @@ //! reversible network cut. mod coalesce; +// The linkage classifier (JEF-407) is called only from the ebpf observer's exec path (plus +// its own unit tests), so gate it like `pod` — the default no-eBPF build doesn't carry it as +// dead code (the repo treats warnings as errors). +#[cfg(any(feature = "ebpf", test))] +mod linkage; mod observer; #[cfg(any(feature = "ebpf", test))] mod pod; diff --git a/agent/protector-agent/src/observer.rs b/agent/protector-agent/src/observer.rs index e88ec34..45811ce 100644 --- a/agent/protector-agent/src/observer.rs +++ b/agent/protector-agent/src/observer.rs @@ -439,6 +439,13 @@ mod ebpf { // Per-pid cache for the FALLBACK `/proc` read only — a table miss from a chatty // host pid shouldn't re-read `/proc` per event. Bounded; cleared wholesale at cap. let mut fallback_cache: HashMap = HashMap::new(); + // Pod UIDs we've already reported entrypoint linkage for (JEF-407). Linkage is a + // stable per-image fact, so we classify `/proc//exe` once per pod on its first + // exec and never again — one ELF read per pod, not per exec. Bounded like the pid + // cache; cleared wholesale at the cap (a re-report on a churned pod is harmless — + // the engine's `record()` dedupes an identical linkage signal). + let mut linkage_reported: std::collections::HashSet = + std::collections::HashSet::new(); loop { let raw = tokio::select! { recv = raw_rx.recv() => match recv { @@ -469,8 +476,13 @@ mod ebpf { continue; } }; + // JEF-407: an exec is our chance to classify the workload's ENTRYPOINT linkage + // — `/proc//exe` is the exec'd binary. Capture the pid before `raw` is + // consumed; only an Exec triggers a linkage classification, and only the first + // time we see a given pod (linkage is a stable per-image fact). + let exec_pid = matches!(raw, RawEvent::Exec { .. }).then_some(attr.pid); let obs = RuntimeObservation { - attribution: Attribution::by_pod_uid(uid), + attribution: Attribution::by_pod_uid(uid.clone()), source: Some(SOURCE.into()), observed_at_ms: now_ms(), // The agent's node (JEF-308) is stamped by the flusher in `main` from `K8S_NODE` @@ -483,6 +495,34 @@ mod ebpf { } // A signal successfully attributed and forwarded — the rate numerator. signals.fetch_add(1, Ordering::Relaxed); + + // Emit the entrypoint's static/dynamic linkage once per pod (JEF-407). Bounds + // the ELF read to one-per-pod, and drops an unknown classification (unreadable + // exe / non-ELF) rather than guessing — the engine then keeps its prior + // `static_binary == None` behavior for that workload. + if let Some(pid) = exec_pid + && !linkage_reported.contains(&uid) + { + if linkage_reported.len() >= PID_CACHE_CAP { + linkage_reported.clear(); + } + linkage_reported.insert(uid.clone()); + if let Some(static_linkage) = + crate::linkage::classify_linkage(crate::linkage::read_exe_head, pid) + { + let linkage = RuntimeObservation { + attribution: Attribution::by_pod_uid(uid), + source: Some(SOURCE.into()), + observed_at_ms: now_ms(), + node: None, + behavior: crate::linkage::linkage_behavior(static_linkage), + }; + if tx.send(linkage).await.is_err() { + return; // report receiver gone — shut down + } + signals.fetch_add(1, Ordering::Relaxed); + } + } } } diff --git a/behavior/src/elf.rs b/behavior/src/elf.rs new file mode 100644 index 0000000..ce1c4eb --- /dev/null +++ b/behavior/src/elf.rs @@ -0,0 +1,149 @@ +//! Minimal ELF static-linkage classification (JEF-404), shared by the engine and the +//! first-party agent (JEF-407). +//! +//! Reachability is proven by correlating a CVE's package against runtime +//! [`Behavior::LibraryLoaded`](crate::Behavior::LibraryLoaded) events: a `.so` the kernel +//! loads names the vulnerable package. **Statically linked binaries have no per-library +//! `.so` loads** — Go compiles everything into one executable, a CGO-disabled / musl-static +//! build does the same — so `LibraryLoaded` never names the package and every CVE renders +//! `not-observed` even when the vulnerable code is genuinely on a hot path. That absence +//! must not read as evidence-of-absence. +//! +//! The cheapest reliable signal that a binary is static is its ELF header: a dynamically +//! linked executable carries a `PT_INTERP` program header naming its dynamic loader +//! (`/lib64/ld-linux-x86-64.so.2`, …); a fully static one does **not**. This module reads +//! ONLY the ELF header and the program-header table — no sections, no symbols, no dynamic +//! table — so it is a tiny, well-scoped parser rather than a heavyweight ELF dependency +//! (zero new crate; std-only, so it can live in this shared wire crate and be reused by +//! the node-local agent that reads `/proc//exe` without pulling in any engine deps). +//! +//! It is byte-only and pure: give it the leading bytes of a binary, get back whether it is +//! statically linked. That keeps it fully unit-testable with tiny synthetic fixtures and +//! keeps *where the bytes come from* a separate plumbing concern (the engine had none in +//! prod until JEF-407 wired the agent as the byte source). + +/// The four-byte ELF magic (`0x7f 'E' 'L' 'F'`) every ELF file starts with. +const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F']; + +/// `e_ident[EI_CLASS]` values: 32-bit vs 64-bit. Their layouts differ only in field widths. +const ELFCLASS32: u8 = 1; +const ELFCLASS64: u8 = 2; + +/// `e_ident[EI_DATA]` values: little- vs big-endian. We honor the file's own byte order so +/// a cross-compiled (e.g. big-endian) binary classifies correctly. +const ELFDATA2LSB: u8 = 1; +const ELFDATA2MSB: u8 = 2; + +/// Program-header type `PT_INTERP` — names the dynamic loader. Its presence is the +/// definitive "this binary is dynamically linked" signal; its absence (in an otherwise +/// valid executable) means static linkage. +const PT_INTERP: u32 = 3; + +/// Classify a binary's ELF header as statically vs dynamically linked (JEF-404). +/// +/// Returns: +/// - `Some(true)` — a valid ELF with **no** `PT_INTERP` program header: statically linked. +/// - `Some(false)` — a valid ELF that carries a `PT_INTERP` header: dynamically linked. +/// - `None` — not an ELF, truncated, or a header we can't parse: linkage unknown, so +/// the caller must fall back to the pre-existing behavior (never guess "static"). +/// +/// Deliberately conservative: any malformed or unrecognized field yields `None` rather than +/// a wrong classification — a false "static" would mislabel a `not-observed` CVE as +/// indeterminate, and a false "dynamic" would do the reverse, so an honest "unknown" is the +/// only safe answer when the bytes don't parse cleanly. +/// +/// Only the ELF header (`e_phoff`, `e_phentsize`, `e_phnum`) and the program-header table +/// (each entry's `p_type`) are read — bounded, allocation-free, and it stops at the first +/// `PT_INTERP` it finds. Every byte offset used to slice `bytes` is formed with +/// `checked_add`/`checked_mul`, so a crafted `e_phoff`/`e_phnum` near `usize::MAX` can never +/// overflow-panic (harmless in release, but a debug-build panic without the checks) — it +/// simply returns `None`. +pub fn elf_static_linkage(bytes: &[u8]) -> Option { + // e_ident is 16 bytes; the smallest header we parse (32-bit) is 52 bytes. Reject + // anything too short to hold even the fields we read. + if bytes.len() < 20 || bytes[..4] != ELF_MAGIC { + return None; + } + let class = bytes[4]; + let data = bytes[5]; + let le = match data { + ELFDATA2LSB => true, + ELFDATA2MSB => false, + _ => return None, + }; + + // Field offsets/sizes differ between the 32- and 64-bit ELF header layouts. We need + // e_phoff (program-header table file offset), e_phentsize (per-entry size), and e_phnum + // (entry count) — plus, per entry, the p_type at the entry's start. + let (phoff_off, phentsize_off, phnum_off) = match class { + ELFCLASS64 => (0x20usize, 0x36usize, 0x38usize), + ELFCLASS32 => (0x1Cusize, 0x2Ausize, 0x2Cusize), + _ => return None, + }; + + let phoff = match class { + ELFCLASS64 => read_u64(bytes, phoff_off, le)? as usize, + _ => read_u32(bytes, phoff_off, le)? as usize, + }; + let phentsize = read_u16(bytes, phentsize_off, le)? as usize; + let phnum = read_u16(bytes, phnum_off, le)? as usize; + + // No program headers at all (e.g. a relocatable object, or e_phoff == 0): we cannot see + // an interpreter, so we cannot assert static linkage — report unknown. + if phoff == 0 || phnum == 0 || phentsize < 4 { + return None; + } + + // p_type is the FIRST 4 bytes of every program-header entry in both ELF classes, so we + // only ever read those 4 bytes per entry regardless of 32/64-bit width. + for i in 0..phnum { + let entry = phoff.checked_add(i.checked_mul(phentsize)?)?; + let p_type = read_u32(bytes, entry, le)?; + if p_type == PT_INTERP { + // A dynamic loader is named → dynamically linked. + return Some(false); + } + } + // A valid ELF whose program headers name no interpreter → statically linked. + Some(true) +} + +/// Read a little/big-endian `u16` at `off`, or `None` if `off + 2` overflows or runs past +/// the end. `checked_add` keeps a crafted near-`usize::MAX` offset from overflow-panicking +/// in a debug build (JEF-407 hardening) — the `bytes.get` bound then handles the truncation. +fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option { + let end = off.checked_add(2)?; + let b: [u8; 2] = bytes.get(off..end)?.try_into().ok()?; + Some(if le { + u16::from_le_bytes(b) + } else { + u16::from_be_bytes(b) + }) +} + +/// Read a little/big-endian `u32` at `off`, or `None` if `off + 4` overflows or runs past +/// the end (see [`read_u16`] for the `checked_add` rationale). +fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option { + let end = off.checked_add(4)?; + let b: [u8; 4] = bytes.get(off..end)?.try_into().ok()?; + Some(if le { + u32::from_le_bytes(b) + } else { + u32::from_be_bytes(b) + }) +} + +/// Read a little/big-endian `u64` at `off`, or `None` if `off + 8` overflows or runs past +/// the end (see [`read_u16`] for the `checked_add` rationale). +fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option { + let end = off.checked_add(8)?; + let b: [u8; 8] = bytes.get(off..end)?.try_into().ok()?; + Some(if le { + u64::from_le_bytes(b) + } else { + u64::from_be_bytes(b) + }) +} + +#[cfg(test)] +mod tests; diff --git a/engine/src/engine/observe/elf/tests.rs b/behavior/src/elf/tests.rs similarity index 84% rename from engine/src/engine/observe/elf/tests.rs rename to behavior/src/elf/tests.rs index fa29740..f32bbbe 100644 --- a/engine/src/engine/observe/elf/tests.rs +++ b/behavior/src/elf/tests.rs @@ -152,3 +152,25 @@ fn out_of_range_program_header_table_is_unknown() { bytes[0x20..0x28].copy_from_slice(&(0xFFFF_FFFFu64).to_le_bytes()); assert_eq!(elf_static_linkage(&bytes), None); } + +#[test] +fn crafted_max_offset_returns_none_without_overflow_panicking() { + // JEF-407 hardening: a crafted `e_phoff` near u64::MAX must NOT overflow-panic when the + // parser forms `phoff + i*phentsize` and the per-read `off + N` slice — it must return an + // honest `None`. Without the `checked_add` in the read helpers this panics on a debug + // build (`attempt to add with overflow`); with it, it's a clean unknown. + let mut bytes = elf64_le(&[PT_LOAD, PT_LOAD]); + // e_phoff = u64::MAX: `phoff + i*phentsize` and every `off + N` inside the read overflow. + bytes[0x20..0x28].copy_from_slice(&u64::MAX.to_le_bytes()); + assert_eq!( + elf_static_linkage(&bytes), + None, + "a u64::MAX e_phoff is unknown, never a panic" + ); + + // A large-but-not-MAX e_phoff whose per-entry stride would overflow on a later entry is + // likewise a clean None (the second entry's offset wraps past usize::MAX). + let mut bytes = elf64_le(&[PT_LOAD, PT_LOAD]); + bytes[0x20..0x28].copy_from_slice(&(usize::MAX as u64 - 4).to_le_bytes()); + assert_eq!(elf_static_linkage(&bytes), None); +} diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index 07af847..ee30d75 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize}; +pub mod elf; +pub use elf::elf_static_linkage; + /// An observed runtime **behavior** — what a workload actually did, from any sensor /// (the first-party eBPF agent, or any sensor with an adapter) through the tool-agnostic /// behavioral port (ADR-0003/0014). Typed so the engine reasons about the *signal*, not the source. @@ -64,6 +67,21 @@ pub enum Behavior { /// corroboration policy (JEF-306 F3), not a property of this shared wire type. The agent /// emits the path; the engine classifies. Model evidence only today. FileWrite { path: String }, + /// The workload's entrypoint binary's **static/dynamic linkage** (JEF-407) — read by + /// the node-local agent from the executable's ELF header (`/proc//exe`, no + /// `PT_INTERP` ⇒ statically linked). This is the byte source that ACTIVATES JEF-404's + /// static-linkage reachability in prod: the engine has no in-cluster access to the + /// entrypoint bytes, so without this signal `Image::static_binary` stays `None` and a + /// Go / musl-static CVE renders `not-observed` forever. `static_linkage == true` ⇒ a + /// static binary; the engine maps it onto `Image::static_binary` so a would-be + /// `not-observed` CVE tags `present-static-binary` (indeterminate, not observed-absent). + /// + /// It is a *structural* fact about the image, NOT an attack signal — it never + /// corroborates ([`Self::is_alert`] is false) and is CONTEXT only. Reported over the + /// SAME behavioral channel (ADR-0014), so no new egress (the zero-egress invariant + /// 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 }, } /// How a [`Behavior::SecretRead`] was observed — a type distinction, not a string @@ -143,6 +161,7 @@ impl Behavior { Behavior::PrivilegeChange { .. } => "priv-change", Behavior::ProcessExec { .. } => "exec", Behavior::FileWrite { .. } => "file-write", + Behavior::ImageLinkage { .. } => "image-linkage", } } @@ -177,6 +196,16 @@ impl Behavior { // 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. Behavior::FileWrite { path } => format!("wrote file {path}"), + // A structural linkage fact, not an action. Named so the prompt/dashboard read + // it as CONTEXT (why a Go/musl-static CVE can't be library-load-correlated), + // never as an event that happened. + Behavior::ImageLinkage { static_linkage } => { + if *static_linkage { + "entrypoint is a statically linked binary".to_string() + } else { + "entrypoint is a dynamically linked binary".to_string() + } + } } } @@ -218,6 +247,10 @@ impl Behavior { // full path would thrash the verdict cache (mirrors the exec/library basename // coarsening, one level up the tree for the higher write volume). Behavior::FileWrite { path } => format!("write:{}", dirname(path)), + // The linkage is a stable per-image fact (static vs dynamic), so key on the + // 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}"), } } } @@ -531,7 +564,7 @@ mod tests { 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); 8] = [ + let cases: [(Behavior, &str); 9] = [ (Behavior::Alert { rule: "x".into() }, "alert"), ( Behavior::NetworkConnection { @@ -568,6 +601,12 @@ mod tests { }, "file-write", ), + ( + Behavior::ImageLinkage { + static_linkage: true, + }, + "image-linkage", + ), ]; for (behavior, want) in cases { assert_eq!(behavior.variant_label(), want, "{behavior:?}"); @@ -800,6 +839,92 @@ mod tests { ); } + #[test] + fn image_linkage_serializes_to_the_kind_tagged_contract_and_round_trips() { + // JEF-407: the linkage signal rides the same `{"kind": "...", ...}` behavioral wire. + // A static-linkage report and a dynamic one both round-trip byte-for-byte. + let stat = Behavior::ImageLinkage { + static_linkage: true, + }; + let v = serde_json::to_value(&stat).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "image_linkage", "static_linkage": true}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), stat); + + let dynm = Behavior::ImageLinkage { + static_linkage: false, + }; + let v = serde_json::to_value(&dynm).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "image_linkage", "static_linkage": false}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), dynm); + } + + #[test] + fn image_linkage_is_context_not_corroboration() { + // A structural fact about the image, never an "attack is happening now" signal — + // only Alerts corroborate the action bar (else linkage would fire it, which is wrong). + assert!( + !Behavior::ImageLinkage { + static_linkage: true + } + .is_alert() + ); + // Distinct summaries and fingerprints for the two linkage states. + assert_eq!( + Behavior::ImageLinkage { + static_linkage: true + } + .summary(), + "entrypoint is a statically linked binary" + ); + assert_eq!( + Behavior::ImageLinkage { + static_linkage: false + } + .summary(), + "entrypoint is a dynamically linked binary" + ); + assert_ne!( + Behavior::ImageLinkage { + static_linkage: true + } + .fingerprint_key(), + Behavior::ImageLinkage { + static_linkage: false + } + .fingerprint_key() + ); + } + + #[test] + fn image_linkage_observation_round_trips_over_the_wire() { + // The full RuntimeObservation the agent POSTs for a static entrypoint — attributed by + // pod UID (the eBPF agent's path), source + node stamped — round-trips. + 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::ImageLinkage { + static_linkage: true, + }, + }; + let v = serde_json::to_value(&obs).unwrap(); + assert_eq!( + v["behavior"], + serde_json::json!({"kind": "image_linkage", "static_linkage": true}) + ); + assert_eq!( + serde_json::from_value::(v).unwrap(), + obs + ); + } + #[test] fn only_alert_corroborates() { assert!(Behavior::Alert { rule: "x".into() }.is_alert()); diff --git a/engine/src/engine/observe/adapter/enrich.rs b/engine/src/engine/observe/adapter/enrich.rs index e2c1843..ee6227a 100644 --- a/engine/src/engine/observe/adapter/enrich.rs +++ b/engine/src/engine/observe/adapter/enrich.rs @@ -103,6 +103,14 @@ impl Adapter for RuntimeAdapter { }; let (mut attached, mut unresolved, mut filtered) = (0usize, 0usize, 0usize); + // The static/dynamic linkage the agent reported per workload (JEF-407). Collected + // here and applied to the workload's Images AFTER the event loop (a linkage signal + // is a structural per-image fact, not runtime prompt evidence, so it does NOT land + // on `w.runtime`). `false` overrides `true` for the same workload within a batch: + // a workload with any dynamically linked container is treated as dynamic (a + // library-load correlation CAN fire there), the conservative choice. + let mut linkage_by_workload: std::collections::HashMap = + std::collections::HashMap::new(); for event in &snapshot.runtime_events { // The resolution rule (a namespace/name attribution always resolves; a cgroup // UID resolves iff a pod with that UID is observed) lives on `Attribution`, @@ -129,6 +137,19 @@ impl Adapter for RuntimeAdapter { (namespace.clone(), pod.clone(), None) } }; + // JEF-407: a linkage report is a structural fact about the workload's image, not + // a runtime behavior for the prompt — divert it here (never push to `w.runtime`) + // and apply it to the workload's Images after the loop. This is the byte source + // that populates `Image::static_binary` in prod so JEF-404's static-linkage + // reachability activates. A dynamic report wins ties (see the map's doc). + if let Behavior::ImageLinkage { static_linkage } = &event.behavior { + let wl_key = NodeKey::workload(&ns, "Pod", &name).0; + linkage_by_workload + .entry(wl_key) + .and_modify(|s| *s = *s && *static_linkage) + .or_insert(*static_linkage); + continue; + } // Refine a raw FileRead (a tmpfs open the credential-free agent couldn't // classify) into a SecretRead using the pod's secret volumeMounts — or drop // it if the path isn't under a Secret mount (most tmpfs reads aren't). Other @@ -186,13 +207,51 @@ impl Adapter for RuntimeAdapter { } }); } + // Apply the reported linkage to each workload's Images (JEF-407). Walk the + // `RunsImage` edges of every workload that reported linkage and set + // `Image::static_binary` — the field `CveReachabilityAdapter` (which runs after this + // adapter) reads to tag a static image's unmatched CVEs `PresentStaticBinary`. Two + // passes so the immutable edge walk and the mutable node update don't alias the + // borrow: pass 1 gathers the Image keys to set, pass 2 sets them. + let mut linked = 0usize; + if !linkage_by_workload.is_empty() { + let mut set_static: Vec<(NodeKey, bool)> = Vec::new(); + { + let g = graph.inner(); + for idx in g.node_indices() { + let Some(node @ Node::Workload(_)) = g.node_weight(idx) else { + continue; + }; + let Some(&is_static) = linkage_by_workload.get(&node.key().0) else { + continue; + }; + for edge in g.edges(idx) { + if matches!(edge.weight().relation, Relation::RunsImage) + && let Some(img_key) = g.node_weight(edge.target()).map(Node::key) + { + set_static.push((img_key, is_static)); + } + } + } + } + for (key, is_static) in set_static { + graph.update_node(&key, |node| { + if let Node::Image(img) = node { + img.static_binary = Some(is_static); + linked += 1; + } + }); + } + } // One line per pass so the behavioral pipeline is observable: signals attached, // UIDs that didn't resolve (a persistent nonzero means the agent's cgroup UIDs - // aren't matching pod metadata.uid), and FileReads dropped as non-secret tmpfs. + // aren't matching pod metadata.uid), FileReads dropped as non-secret tmpfs, and + // Images whose static/dynamic linkage was set from an agent ELF report (JEF-407). tracing::info!( attached, unresolved, filtered, + linked, events = snapshot.runtime_events.len(), "runtime behavioral signals" ); diff --git a/engine/src/engine/observe/adapter/enrich/tests.rs b/engine/src/engine/observe/adapter/enrich/tests.rs index d417938..b7c5d4b 100644 --- a/engine/src/engine/observe/adapter/enrich/tests.rs +++ b/engine/src/engine/observe/adapter/enrich/tests.rs @@ -295,6 +295,131 @@ fn static_binary_still_yields_loaded_at_runtime_on_a_real_load() { ); } +/// JEF-407 end-to-end: build the graph for an image carrying a critical CVE on `pkg`, run +/// by pod app/web, where the AGENT reported the entrypoint's linkage over the behavioral +/// wire (an `ImageLinkage` observation, `static_linkage`) — no manual `static_binary` flip. +/// The full pipeline runs: the RuntimeAdapter maps the linkage report onto `Image::static_binary`, +/// then the CveReachabilityAdapter reads it. Returns the CVE's resulting reachability, so a +/// static report → `PresentStaticBinary` while a dynamic report → `NotObserved`, activating the +/// dormant JEF-404 machinery from a real prod-shaped signal source. +fn reachability_via_agent_linkage( + pkg: &str, + static_linkage: bool, + loaded_lib: Option<&str>, +) -> Reachability { + let web = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}}, + "spec": {"containers": [{"name": "web", "image": "web:1"}]} + })); + let mut runtime_events = vec![RuntimeObservation { + attribution: Attribution::by_namespaced_name("app", "web"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: Some("node-a".into()), + behavior: Behavior::ImageLinkage { static_linkage }, + }]; + runtime_events.extend(loaded_lib.map(lib)); + let snap = Snapshot { + pods: vec![web], + image_vulns: vec![ImageVulnerabilities { + image: "web:1".into(), + vulnerabilities: vec![Vulnerability { + id: "CVE-2021-44228".into(), + severity: crate::engine::graph::Severity::Critical, + pkg_name: Some(pkg.into()), + ..Default::default() + }], + }], + runtime_events, + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + let img_key = NodeKey::image(&canonical_image("web:1")); + let idx = graph.index_of(&img_key).expect("image node exists"); + match graph.node(idx) { + Some(Node::Image(img)) => { + // The linkage report must have landed on the Image — this is the plumbing JEF-407 + // adds. A static report sets Some(true); a dynamic one Some(false). + assert_eq!( + img.static_binary, + Some(static_linkage), + "the agent's ImageLinkage report should populate Image::static_binary" + ); + img.vulnerabilities[0].reachability + } + _ => panic!("expected image node"), + } +} + +#[test] +fn agent_static_linkage_report_activates_present_static_binary() { + // JEF-407: a static-linkage report from the agent (the prod byte source) makes a + // would-be `not-observed` critical CVE render `present-static-binary` — the JEF-404 + // feature that was dormant because Image::static_binary was always None in prod. + assert_eq!( + reachability_via_agent_linkage("openssl", true, None), + Reachability::PresentStaticBinary + ); +} + +#[test] +fn agent_dynamic_linkage_report_keeps_not_observed() { + // A dynamic-linkage report is honestly observed-absent: a dynamically linked workload + // WOULD have emitted a `.so` load if the vulnerable code ran, so no load → NotObserved. + assert_eq!( + reachability_via_agent_linkage("openssl", false, None), + Reachability::NotObserved + ); +} + +#[test] +fn agent_linkage_report_never_downgrades_a_real_load() { + // Even on a static-linkage report, an actual matching library load still wins — real + // exploitation evidence (LoadedAtRuntime) is never downgraded (JEF-405). + assert_eq!( + reachability_via_agent_linkage("log4j-core", true, Some("log4j-core-2.14.jar")), + Reachability::LoadedAtRuntime + ); +} + +#[test] +fn image_linkage_report_is_not_pushed_onto_workload_runtime() { + // The linkage signal is a structural per-image fact, not runtime prompt evidence — it + // must be diverted to Image::static_binary and NEVER land on the workload's `runtime` + // (it would otherwise pollute the adjudication prompt and the corroboration path). + let web = pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}}, + "spec": {"containers": [{"name": "web", "image": "web:1"}]} + })); + let snap = Snapshot { + pods: vec![web], + runtime_events: vec![RuntimeObservation { + attribution: Attribution::by_namespaced_name("app", "web"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: None, + behavior: Behavior::ImageLinkage { + static_linkage: true, + }, + }], + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + let wl_key = NodeKey::workload("app", "Pod", "web"); + let idx = graph.index_of(&wl_key).expect("workload node exists"); + match graph.node(idx) { + Some(Node::Workload(w)) => assert!( + w.runtime + .iter() + .all(|s| !matches!(s.behavior, Behavior::ImageLinkage { .. })), + "ImageLinkage must not land on workload runtime — it is diverted to the Image" + ), + _ => panic!("expected workload node"), + } +} + /// A `LibraryLoaded` observation on pod app/web (the fixture these tests use). fn lib(name: &str) -> RuntimeObservation { RuntimeObservation { diff --git a/engine/src/engine/observe/elf.rs b/engine/src/engine/observe/elf.rs index fd5ed28..3c36858 100644 --- a/engine/src/engine/observe/elf.rs +++ b/engine/src/engine/observe/elf.rs @@ -1,135 +1,20 @@ -//! Minimal ELF static-linkage classification (JEF-404). +//! ELF static-linkage classification (JEF-404) — re-exported from the shared `behavior` +//! crate (JEF-407). //! -//! Reachability is proven by correlating a CVE's package against runtime -//! [`Behavior::LibraryLoaded`](protector_behavior::Behavior::LibraryLoaded) events: a -//! `.so` the kernel loads names the vulnerable package. **Statically linked binaries have -//! no per-library `.so` loads** — Go compiles everything into one executable, a -//! CGO-disabled / musl-static build does the same — so `LibraryLoaded` never names the -//! package and every CVE renders `not-observed` even when the vulnerable code is genuinely -//! on a hot path. That absence must not read as evidence-of-absence (see -//! [`Reachability::PresentStaticBinary`](crate::engine::graph::Reachability)). +//! The classifier used to live here, but JEF-407 needs the SAME byte logic in the +//! node-local agent (which reads `/proc//exe` and is the prod byte source that +//! activates this feature), and the agent depends on `protector-behavior` by path, not on +//! the engine. The parser is std-only and dependency-free, so it now lives in +//! [`protector_behavior::elf`] as the single source of truth — both the engine and the +//! agent classify identically and can't drift. The engine keeps this thin re-export so the +//! `engine::observe::elf::elf_static_linkage` path (and the JEF-404 doc references) stay +//! stable. //! -//! The cheapest reliable signal that a binary is static is its ELF header: a dynamically -//! linked executable carries a `PT_INTERP` program header naming its dynamic loader -//! (`/lib64/ld-linux-x86-64.so.2`, …); a fully static one does **not**. This module reads -//! ONLY the ELF header and the program-header table — no sections, no symbols, no dynamic -//! table — so it is a tiny, well-scoped parser rather than a heavyweight ELF dependency -//! (zero new heavy crate; the graph stays zero-egress — this only ever inspects bytes the -//! engine was already handed). -//! -//! It is byte-only and pure: give it the leading bytes of a binary, get back whether it is -//! statically linked. That keeps it fully unit-testable with tiny synthetic fixtures and -//! keeps *where the bytes come from* a separate plumbing concern. - -/// The four-byte ELF magic (`0x7f 'E' 'L' 'F'`) every ELF file starts with. -const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F']; - -/// `e_ident[EI_CLASS]` values: 32-bit vs 64-bit. Their layouts differ only in field widths. -const ELFCLASS32: u8 = 1; -const ELFCLASS64: u8 = 2; - -/// `e_ident[EI_DATA]` values: little- vs big-endian. We honor the file's own byte order so -/// a cross-compiled (e.g. big-endian) binary classifies correctly. -const ELFDATA2LSB: u8 = 1; -const ELFDATA2MSB: u8 = 2; - -/// Program-header type `PT_INTERP` — names the dynamic loader. Its presence is the -/// definitive "this binary is dynamically linked" signal; its absence (in an otherwise -/// valid executable) means static linkage. -const PT_INTERP: u32 = 3; - -/// Classify a binary's ELF header as statically vs dynamically linked (JEF-404). -/// -/// Returns: -/// - `Some(true)` — a valid ELF with **no** `PT_INTERP` program header: statically linked. -/// - `Some(false)` — a valid ELF that carries a `PT_INTERP` header: dynamically linked. -/// - `None` — not an ELF, truncated, or a header we can't parse: linkage unknown, so -/// the caller must fall back to the pre-existing behavior (never guess "static"). -/// -/// Deliberately conservative: any malformed or unrecognized field yields `None` rather than -/// a wrong classification — a false "static" would mislabel a `not-observed` CVE as -/// indeterminate, and a false "dynamic" would do the reverse, so an honest "unknown" is the -/// only safe answer when the bytes don't parse cleanly. -/// -/// Only the ELF header (`e_phoff`, `e_phentsize`, `e_phnum`) and the program-header table -/// (each entry's `p_type`) are read — bounded, allocation-free, and it stops at the first -/// `PT_INTERP` it finds. -pub fn elf_static_linkage(bytes: &[u8]) -> Option { - // e_ident is 16 bytes; the smallest header we parse (32-bit) is 52 bytes. Reject - // anything too short to hold even the fields we read. - if bytes.len() < 20 || bytes[..4] != ELF_MAGIC { - return None; - } - let class = bytes[4]; - let data = bytes[5]; - let le = match data { - ELFDATA2LSB => true, - ELFDATA2MSB => false, - _ => return None, - }; - - // Field offsets/sizes differ between the 32- and 64-bit ELF header layouts. We need - // e_phoff (program-header table file offset), e_phentsize (per-entry size), and e_phnum - // (entry count) — plus, per entry, the p_type at the entry's start. - let (phoff_off, phentsize_off, phnum_off) = match class { - ELFCLASS64 => (0x20usize, 0x36usize, 0x38usize), - ELFCLASS32 => (0x1Cusize, 0x2Ausize, 0x2Cusize), - _ => return None, - }; - - let phoff = match class { - ELFCLASS64 => read_u64(bytes, phoff_off, le)? as usize, - _ => read_u32(bytes, phoff_off, le)? as usize, - }; - let phentsize = read_u16(bytes, phentsize_off, le)? as usize; - let phnum = read_u16(bytes, phnum_off, le)? as usize; - - // No program headers at all (e.g. a relocatable object, or e_phoff == 0): we cannot see - // an interpreter, so we cannot assert static linkage — report unknown. - if phoff == 0 || phnum == 0 || phentsize < 4 { - return None; - } - - // p_type is the FIRST 4 bytes of every program-header entry in both ELF classes, so we - // only ever read those 4 bytes per entry regardless of 32/64-bit width. - for i in 0..phnum { - let entry = phoff.checked_add(i.checked_mul(phentsize)?)?; - let p_type = read_u32(bytes, entry, le)?; - if p_type == PT_INTERP { - // A dynamic loader is named → dynamically linked. - return Some(false); - } - } - // A valid ELF whose program headers name no interpreter → statically linked. - Some(true) -} - -fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option { - let b: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?; - Some(if le { - u16::from_le_bytes(b) - } else { - u16::from_be_bytes(b) - }) -} - -fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option { - let b: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?; - Some(if le { - u32::from_le_bytes(b) - } else { - u32::from_be_bytes(b) - }) -} - -fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option { - let b: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?; - Some(if le { - u64::from_le_bytes(b) - } else { - u64::from_be_bytes(b) - }) -} - -#[cfg(test)] -mod tests; +//! See [`protector_behavior::elf`] for the full rationale: a dynamically linked ELF carries +//! a `PT_INTERP` program header naming its loader; a static one does not. `Some(true)` ⇒ +//! static, `Some(false)` ⇒ dynamic, `None` ⇒ unknown (never guessed). The reachability +//! consequence — a static image's unmatched CVE tags +//! [`Reachability::PresentStaticBinary`](crate::engine::graph::Reachability) rather than +//! `NotObserved` — is engine policy in the `CveReachabilityAdapter`. + +pub use protector_behavior::elf::elf_static_linkage; diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index c12d368..1f0c49f 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -113,6 +113,13 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { Behavior::FileWrite { .. } => { crate::engine::observe::alarm_class::alarming_write(behavior).is_some() } + // ImageLinkage is a structural per-image fact (JEF-407), NOT a runtime "now" signal — + // it never corroborates any objective. It is also diverted by the RuntimeAdapter into + // `Image::static_binary` before it becomes workload runtime state, so in practice it + // never reaches here; this arm keeps the match exhaustive and the invariant explicit + // (only `LoadedAtRuntime` is CVE evidence — a static-linkage fact must never read as + // exploitation or reassurance). + Behavior::ImageLinkage { .. } => false, } }