From 20cdfe27831b38dcfee33c7bc2c873a9dce9cfbd Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 26 Jul 2026 17:30:37 -0700 Subject: [PATCH 1/4] feat(engine): fileless-exec (memfd_create/anonymous-fd) corroboration (JEF-317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falco fires critical on memfd_create + execve of the resulting anonymous fd ("fileless" malware that never touches disk); neither the exec probe's path artifact nor F2's FileWrite (JEF-306) has anything to correlate against, since such an fd never touches security_file_open. This was the one shipped-critical Falco class without a clean agent map (JEF-316 F0 parity audit). The signal already rides the wire: the exec probe (security_bprm_check, ADR-0014/JEF-53) already emits the kernel's own bprm->filename as Behavior::ProcessExec::path verbatim. The kernel's own execveat(fd, "", AT_EMPTY_PATH) synthesis (what fexecve()/every memfd-backed loader uses) resolves that string to an fd-only form ("/dev/fd/", "/proc//fd/") with no on-disk path — no new probe, offset, or wire field needed (JEF-113: agent stays pure data). engine::observe::exec_class now classifies that shape as a fileless exec, feeding the existing notable_exec/is_alarming_now blanket corroboration path (JEF-55/JEF-117/JEF-309) the same way a shell or package-manager exec does. Doc-only touch to the bprm probe and the ProcessExec wire doc explain why no agent-side code changed. Shadow-gated throughout (ADR-0014): only sets `corroborated`, never actuates. eBPF change — ON-NODE LOAD VALIDATION PENDING is not applicable here in the usual sense: no probe, offset, or wire byte changed, so there is no new attach/verifier risk to validate. The bprm_check probe this relies on is already deployed and verified on-node (JEF-53). Recommended follow-up is a functional smoke test (memfd_create + execveat on a real pod) to confirm the classifier fires end-to-end, not an eBPF reload. Closes JEF-317 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- agent/protector-agent-ebpf/src/main.rs | 10 ++ behavior/src/lib.rs | 12 +- engine/src/engine/observe/exec_class.rs | 155 ++++++++++++++++-- engine/src/engine/reason/proof/corroborate.rs | 30 ++-- .../proof/corroborate_objective_tests.rs | 27 ++- 5 files changed, 201 insertions(+), 33 deletions(-) diff --git a/agent/protector-agent-ebpf/src/main.rs b/agent/protector-agent-ebpf/src/main.rs index 10d0c99..77a55a3 100644 --- a/agent/protector-agent-ebpf/src/main.rs +++ b/agent/protector-agent-ebpf/src/main.rs @@ -524,6 +524,16 @@ fn try_fix_setuid(ctx: &FEntryContext) -> Result<(), i64> { /// `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). +/// +/// JEF-317 (fileless exec / memfd_create parity with Falco): for an fd-only exec — +/// `fexecve()`/`execveat(fd, "", AT_EMPTY_PATH)`, the shape every memfd-backed "fileless" +/// payload execs through — the kernel's OWN `bprm->filename` synthesis (not this probe) +/// already resolves to an fd-only string (`/dev/fd/`, `/proc//fd/`) with no +/// on-disk path. This probe therefore needs NO change to surface that fact: the same +/// `filename` read below already carries it, byte for byte. `engine::observe::exec_class` +/// classifies that shape as a fileless exec and blanket-corroborates it, purely from this +/// already-emitted path — see that module for the detection and why no new probe, offset, +/// or wire field was needed (JEF-113: the agent stays pure data, the engine classifies). #[fentry(function = "security_bprm_check")] pub fn bprm_check(ctx: FEntryContext) -> u32 { let _ = try_bprm_check(&ctx); diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index c107cc6..abaf422 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -57,9 +57,15 @@ 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. + /// spawned" (ADR-0014). `path` is the exec'd binary's path as the kernel saw it + /// (`linux_binprm->filename`) — for an exec of an open file descriptor rather than a + /// directory-entry path (`fexecve`/`execveat(fd, "", AT_EMPTY_PATH)`, the shape a + /// `memfd_create`-backed "fileless" payload execs through), the kernel itself resolves + /// this to an fd-only form (`/dev/fd/`, `/proc//fd/`) with no on-disk path at + /// all — engine policy classifies that shape as a fileless exec (JEF-317) purely from + /// this same field, no wire change. PURE DATA: whether a `path` is a shell / package + /// manager / fileless exec is engine classification (`observe::exec_class`, JEF-113), + /// not a property of this shared wire type. ProcessExec { path: String }, /// 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). diff --git a/engine/src/engine/observe/exec_class.rs b/engine/src/engine/observe/exec_class.rs index 7705d76..40e45fe 100644 --- a/engine/src/engine/observe/exec_class.rs +++ b/engine/src/engine/observe/exec_class.rs @@ -1,20 +1,80 @@ //! 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, a package manager, or a **fileless exec** (JEF-317) 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 / -//! package manager" live here — alongside the other engine classification thresholds -//! (CVE severity, corroboration) — rather than on the wire type. Keeping it here means a -//! list change rebuilds only the engine, never the agent. These are free functions that -//! classify a [`Behavior`] from the OUTSIDE, mirroring how the rest of the engine treats -//! `Behavior` as inert evidence. +//! package manager / fileless exec" live here — alongside the other engine classification +//! thresholds (CVE severity, corroboration) — rather than on the wire type. Keeping it here +//! means a list change rebuilds only the engine, never the agent. These are free functions +//! that classify a [`Behavior`] from the OUTSIDE, mirroring how the rest of the engine +//! treats `Behavior` as inert evidence. //! //! 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). +//! ENGINE-SIDE from the path the agent already emits (no wire change). A fileless exec +//! ([`is_fileless_exec`], below) is the same shape: classified from the SAME path field the +//! agent already emits for every exec — no new probe or wire field needed. use crate::engine::graph::Behavior; +/// The Falco-parity gap this closes (JEF-317, ADR-0014's "Retire Falco" epic): Falco fires +/// **critical** on `memfd_create` + `execve` of the resulting anonymous fd — malware that +/// never writes an executable to disk, so neither the exec probe's path artifact nor F2's +/// `FileWrite` (JEF-306) has anything to corroborate against (an fd built purely with +/// `memfd_create` never touches `security_file_open` at all). +/// +/// **Where the signal already lives (no agent/wire change — JEF-113):** the exec probe +/// (`security_bprm_check`, ADR-0014) already emits the kernel's own `bprm->filename` as +/// [`Behavior::ProcessExec::path`] verbatim. The kernel's `do_execveat_common()` / +/// `alloc_bprm()` synthesize that string from the SYSCALL shape, not from anything the +/// exec'd program controls: an `execveat(fd, "", AT_EMPTY_PATH)` — what `fexecve()` and every +/// memfd-backed "fileless" loader use, since a memfd has no path to pass — resolves to +/// `"/dev/fd/"` (a `struct file`, not a directory entry). A caller that instead execs the +/// literal `/proc//fd/` symlink (`fexecve()`'s pre-execveat fallback, and a +/// pattern droppers use directly) carries the identical "resolved only through an open fd, +/// never a pathname" tell. Both forms are therefore already PURE DATA on the wire; this module +/// only classifies a string the agent was already sending — no eBPF probe, offset, or wire +/// schema change (the whole reason this ships with no additional on-node validation risk). +/// +/// [`is_anon_fd_path`] recognizes exactly the two shapes the kernel produces: `/dev/fd/` +/// (`execveat(fd, "", AT_EMPTY_PATH)`'s own synthesis) and `/proc//fd/` (the +/// literal-path form `fexecve()` falls back to, and a pattern droppers use directly). +fn is_anon_fd_path(path: &str) -> bool { + // Segment-match against '/'-separated components, not a string-prefix test, so a + // directory that merely happens to be NAMED "fd" (`/dev/fdisk`) or a longer path past + // the fd number (`/proc/self/fd/3/extra`) can't false-positive. + let mut segs = path.strip_prefix('/').unwrap_or(path).split('/'); + match ( + segs.next(), + segs.next(), + segs.next(), + segs.next(), + segs.next(), + ) { + (Some("dev"), Some("fd"), Some(n), None, None) => is_ascii_digits(n), + (Some("proc"), Some(pid), Some("fd"), Some(n), None) => { + (pid == "self" || is_ascii_digits(pid)) && is_ascii_digits(n) + } + _ => false, + } +} + +/// Whether every byte of `s` is an ASCII digit, and `s` is non-empty (the fd number, or a +/// numeric pid, in a fileless-exec path — [`is_anon_fd_path`]). +fn is_ascii_digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// Whether `behavior` is a [`Behavior::ProcessExec`] of an fd-only "fileless" target — no +/// on-disk path was ever given to `execve` (JEF-317). Always `false` for any other behavior. +pub fn is_fileless_exec(behavior: &Behavior) -> bool { + match behavior { + Behavior::ProcessExec { path } => is_anon_fd_path(path), + _ => false, + } +} + /// Interactive shells a process-exec might be (matched on the binary's basename). /// An exec of one of these inside a container is the classic "terminal shell in /// container" runtime signal (JEF-55). Kept deliberately small and conservative — @@ -74,19 +134,21 @@ pub fn is_package_manager(behavior: &Behavior) -> bool { } } -/// A short, human label for a *notable* runtime exec — a shell or 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 -/// alert, JEF-117). This is a classification, NOT an `is_alert`: it does not by itself -/// 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. +/// A short, human label for a *notable* runtime exec — a shell, a package manager, or a +/// fileless (fd-only) exec run inside the container (JEF-55, JEF-317) — 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 alert, JEF-117). This is a classification, NOT an `is_alert`: it +/// does not by itself 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. pub fn notable_exec(behavior: &Behavior) -> Option<&'static str> { if is_interactive_shell(behavior) { Some("interactive shell in container") } else if is_package_manager(behavior) { Some("package manager in container") + } else if is_fileless_exec(behavior) { + Some("fileless exec via anonymous fd — no on-disk path (memfd_create/execveat)") } else { None } @@ -237,4 +299,67 @@ mod tests { // An unremarkable exec is not notable. assert_eq!(notable_exec(&normal), None); } + + #[test] + fn fileless_exec_flags_fd_only_paths() { + // (exec path, is fileless) — both forms the kernel/`fexecve()` produce for an + // fd-only exec (JEF-317), plus look-alikes that must NOT match. + let exec = |p: &str| Behavior::ProcessExec { path: p.into() }; + let cases = [ + // /dev/fd/ — execveat(fd, "", AT_EMPTY_PATH)'s bprm->filename synthesis. + ("/dev/fd/3", true), + ("/dev/fd/0", true), + ("/dev/fd/12345", true), + // /proc//fd/ — the literal-path form (fexecve()'s fallback, and a + // pattern droppers use directly). + ("/proc/self/fd/3", true), + ("/proc/1/fd/9", true), + ("/proc/48213/fd/17", true), + // Negatives: a normal app binary, and look-alikes that must NOT match. + ("/app/server", false), + ("/dev/fdisk", false), // no `/` boundary after "fd" + ("/dev/fd/", false), // no fd number + ("/dev/fd/abc", false), // non-numeric "fd number" + ("/proc/self/fdinfo/3", false), // "fdinfo", not "fd" + ("/proc/self/fd/3/extra", false), // trailing segment after the fd number + ("/proc/abc/fd/3", false), // non-numeric, non-"self" pid segment + ("/etc/proc/self/fd/3", false), // not rooted at /proc + ]; + for (path, want) in cases { + let b = exec(path); + assert_eq!(is_fileless_exec(&b), want, "is_fileless_exec({path:?})"); + } + } + + #[test] + fn fileless_exec_is_notable_and_scoped_to_process_exec() { + let anon = Behavior::ProcessExec { + path: "/dev/fd/7".into(), + }; + assert_eq!( + notable_exec(&anon), + Some("fileless exec via anonymous fd — no on-disk path (memfd_create/execveat)") + ); + assert_eq!( + annotated_summary(&anon), + "executed /dev/fd/7 (fileless exec via anonymous fd — no on-disk path (memfd_create/execveat))" + ); + + // A path that merely CONTAINS "/dev/fd/" further in must not match — only the exec + // path forms the kernel actually produces count, not a substring anywhere. + let others = [ + Behavior::Alert { + rule: "/dev/fd/3".into(), + }, + Behavior::FileWrite { + path: "/dev/fd/3".into(), + }, + Behavior::LibraryLoaded { + name: "/dev/fd/3".into(), + }, + ]; + for b in others { + assert!(!is_fileless_exec(&b), "{b:?} is_fileless_exec"); + } + } } diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 33a6ac0..63cb41f 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -37,15 +37,16 @@ pub(super) struct EntryContext<'a> { /// /// An *alerting* signal corroborates **any** objective: an alert means "an attack is /// happening now" regardless of which chain. An alert arrives via the tool-agnostic -/// behavioral port (ADR-0003), so any sensor can raise one. An interactive-shell or -/// 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 -/// (`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. +/// behavioral port (ADR-0003), so any sensor can raise one. An interactive-shell, +/// package-manager, or fileless (fd-only, JEF-317) 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 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. /// /// 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 @@ -102,14 +103,19 @@ 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, a package manager, or a **fileless** + // exec (JEF-317: execve of an fd-only target — memfd_create/execveat, no on-disk + // path — the one shipped-critical Falco class the F2 FileWrite corroboration can't + // reach, since such an fd never touches `security_file_open`) 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). + // only. `notable_exec` is `Some` exactly for shell/pkg-mgr/fileless execs (JEF-113: + // the classifier is engine policy in `observe::exec_class`, not on the wire type — + // the fileless-exec case classifies the SAME `path` field the agent already emits, + // no wire change). Behavior::ProcessExec { .. } => { crate::engine::observe::exec_class::notable_exec(behavior).is_some() } diff --git a/engine/src/engine/reason/proof/corroborate_objective_tests.rs b/engine/src/engine/reason/proof/corroborate_objective_tests.rs index f1263bb..a138bc5 100644 --- a/engine/src/engine/reason/proof/corroborate_objective_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_objective_tests.rs @@ -204,9 +204,30 @@ 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). +/// A fileless exec — execve of an fd-only target (`/dev/fd/` / `/proc//fd/`, no +/// on-disk path — JEF-317, the Falco-parity "memfd_create / anonymous-fd execve" critical) +/// corroborates ANY objective like an alert (JEF-117): the same blanket tamper-now gate a +/// shell or package-manager exec triggers, closing the gap where F2's `FileWrite` +/// (JEF-306) has no path artifact to correlate against (a memfd-backed exec never touches +/// `security_file_open`). +#[test] +fn fileless_exec_corroborates_any_objective() { + for path in ["/dev/fd/7", "/proc/self/fd/3", "/proc/1234/fd/9"] { + let anon = Behavior::ProcessExec { path: path.into() }; + assert!( + crate::engine::observe::exec_class::is_fileless_exec(&anon), + "{path:?}" + ); + assert!(corroborates(&anon, &CREDENTIAL_ACCESS), "{path:?}"); + assert!(corroborates(&anon, &EXFILTRATION), "{path:?}"); + assert!(corroborates(&anon, &ESCAPE_TO_HOST), "{path:?}"); + assert!(corroborates(&anon, &EXPLOIT_PUBLIC_FACING), "{path:?}"); + } +} + +/// NEGATIVE: a *bare* (non-shell, non-pkg-mgr, non-fileless) 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 { From acb28347abdf1d00ff24c72c1f3aa3fbef392bc5 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Sun, 26 Jul 2026 20:58:56 -0700 Subject: [PATCH 2/4] feat(agent,engine): fileless-exec (anon-inode) corroboration, Route A rework (JEF-317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REWORK of the withdrawn v1 approach: v1 classified ProcessExec.path SHAPE (/dev/fd/, /proc//fd/) as "fileless" and fed it into the blanket corroborates-ANY-objective arm. A security review caught the flaw — the kernel synthesizes the identical path for a benign fexecve() of an on-disk file, and runc copies itself into a memfd and re-execs via that exact shape on ~every container start (the CVE-2019-5736 mitigation) — so path shape can't distinguish malware from routine behavior, and it forged corroboration on every objective. v1 is fully removed (is_anon_fd_path/is_fileless_exec + its notable_exec/blanket wiring). Route A replaces it with the real signal — the exec'd binary's backing INODE, not its path: - agent/protector-agent-ebpf: the existing bprm_check probe (fentry on security_bprm_check — NOT lsm/*, since `bpf` is not in the fleet's active LSM list) now also reads bprm->file->f_inode and determines whether it's anonymous: shmem/tmpfs-backed (inode->i_sb->s_magic) and/or unlinked (inode->i_nlink == 0, which also catches "delete the binary while it's still executing"). Emitted as a new pure-data ExecEvent.exe_anon_inode field — a kernel-observed FACT, not a verdict (JEF-113: classification policy stays engine-side). - agent/protector-agent-ebpf/src/vmlinux.rs: adds linux_binprm.file (+64) and inode.i_nlink (+72), each derived from kernel source layout (NOT dumped from live BTF) and offset_of!-guarded per the JEF-324 pattern. linux_binprm.file's derivation independently lands on the SAME offset as the already-verified filename field (+96), a strong self-consistency signal, but both fields are ON-NODE BTF VERIFICATION PENDING — see the PR body for exactly what needs bpftool btf dump confirmation. - behavior::Behavior::ProcessExec gains exe_anon_inode: bool (defaulted, omitted from the wire when false — old sensors' JSON is untouched). - engine::reason::proof::corroborate adds anon_inode_exec_on_foothold: conservatively scoped to BOTH a proven foothold entry AND an Execution-tactic objective — never the blanket "any objective" gate a shell/package-manager exec gets. The flat corroborates() relation explicitly does NOT route exe_anon_inode into its blanket arm. CAVEAT (documented, not solved): the runc-memfd-reexec false-positive base rate — specifically whether it attributes to the workload cgroup or the host runtime — is UNKNOWN until measured on a live node. The foothold+ Execution-tactic scoping is the conservative mitigation while that measurement is pending; the code and PR both flag this loudly. Also splits behavior/src/lib.rs's test module into src/tests.rs (repo CLAUDE.md: tests count toward the 1,000-line file cap; the new field's tests pushed it over). Closes JEF-317 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- agent/common/src/lib.rs | 30 ++- agent/protector-agent-ebpf/src/main.rs | 132 ++++++++---- agent/protector-agent-ebpf/src/vmlinux.rs | 55 ++++- agent/protector-agent/src/coalesce/tests.rs | 7 +- agent/protector-agent/src/observer.rs | 37 +++- .../src/observer/ebpf/observer_ebpf_tests.rs | 76 +++++-- behavior/src/lib.rs | 75 +++++-- behavior/src/tests.rs | 74 ++++++- engine/examples/dashboard_preview.rs | 1 + .../src/engine/dashboard/view_model/alerts.rs | 2 +- engine/src/engine/observe/alarm_class.rs | 3 + engine/src/engine/observe/exec_class.rs | 192 +++++------------- engine/src/engine/observe/peer_class.rs | 1 + .../engine/reason/adjudicate/tests/group_1.rs | 2 + .../engine/reason/adjudicate/tests/group_3.rs | 3 + .../reason/adjudicate/tests/sections.rs | 1 + engine/src/engine/reason/proof/corroborate.rs | 99 ++++++--- .../corroborate_anon_inode_exec_tests.rs | 142 +++++++++++++ .../proof/corroborate_drop_exec_tests.rs | 8 +- .../proof/corroborate_objective_tests.rs | 46 +++-- engine/src/engine/reason/proof/mod.rs | 2 + 21 files changed, 716 insertions(+), 272 deletions(-) create mode 100644 engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs 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 77a55a3..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,21 +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): for an fd-only exec — -/// `fexecve()`/`execveat(fd, "", AT_EMPTY_PATH)`, the shape every memfd-backed "fileless" -/// payload execs through — the kernel's OWN `bprm->filename` synthesis (not this probe) -/// already resolves to an fd-only string (`/dev/fd/`, `/proc//fd/`) with no -/// on-disk path. This probe therefore needs NO change to surface that fact: the same -/// `filename` read below already carries it, byte for byte. `engine::observe::exec_class` -/// classifies that shape as a fileless exec and blanket-corroborates it, purely from this -/// already-emitted path — see that module for the detection and why no new probe, offset, -/// or wire field was needed (JEF-113: the agent stays pure data, the engine classifies). +/// 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); @@ -546,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(); @@ -584,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 { @@ -592,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.) @@ -724,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) } } @@ -751,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 abaf422..e5245fc 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -58,15 +58,29 @@ pub enum Behavior { 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`) — for an exec of an open file descriptor rather than a - /// directory-entry path (`fexecve`/`execveat(fd, "", AT_EMPTY_PATH)`, the shape a - /// `memfd_create`-backed "fileless" payload execs through), the kernel itself resolves - /// this to an fd-only form (`/dev/fd/`, `/proc//fd/`) with no on-disk path at - /// all — engine policy classifies that shape as a fileless exec (JEF-317) purely from - /// this same field, no wire change. PURE DATA: whether a `path` is a shell / package - /// manager / fileless exec is engine classification (`observe::exec_class`, JEF-113), - /// not a property of this shared wire type. - ProcessExec { path: String }, + /// (`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 @@ -125,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. @@ -205,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. @@ -262,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 40e45fe..4865a3e 100644 --- a/engine/src/engine/observe/exec_class.rs +++ b/engine/src/engine/observe/exec_class.rs @@ -1,80 +1,32 @@ //! Exec-classification policy (JEF-55 / JEF-113): is a process-exec a *notable* runtime -//! signal — an interactive shell, a package manager, or a **fileless exec** (JEF-317) 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 / -//! package manager / fileless exec" live here — alongside the other engine classification -//! thresholds (CVE severity, corroboration) — rather than on the wire type. Keeping it here -//! means a list change rebuilds only the engine, never the agent. These are free functions -//! that classify a [`Behavior`] from the OUTSIDE, mirroring how the rest of the engine -//! treats `Behavior` as inert evidence. +//! package manager" live here — alongside the other engine classification thresholds +//! (CVE severity, corroboration) — rather than on the wire type. Keeping it here means a +//! list change rebuilds only the engine, never the agent. These are free functions that +//! classify a [`Behavior`] from the OUTSIDE, mirroring how the rest of the engine treats +//! `Behavior` as inert evidence. //! //! 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). A fileless exec -//! ([`is_fileless_exec`], below) is the same shape: classified from the SAME path field the -//! agent already emits for every exec — no new probe or wire field needed. +//! 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; -/// The Falco-parity gap this closes (JEF-317, ADR-0014's "Retire Falco" epic): Falco fires -/// **critical** on `memfd_create` + `execve` of the resulting anonymous fd — malware that -/// never writes an executable to disk, so neither the exec probe's path artifact nor F2's -/// `FileWrite` (JEF-306) has anything to corroborate against (an fd built purely with -/// `memfd_create` never touches `security_file_open` at all). -/// -/// **Where the signal already lives (no agent/wire change — JEF-113):** the exec probe -/// (`security_bprm_check`, ADR-0014) already emits the kernel's own `bprm->filename` as -/// [`Behavior::ProcessExec::path`] verbatim. The kernel's `do_execveat_common()` / -/// `alloc_bprm()` synthesize that string from the SYSCALL shape, not from anything the -/// exec'd program controls: an `execveat(fd, "", AT_EMPTY_PATH)` — what `fexecve()` and every -/// memfd-backed "fileless" loader use, since a memfd has no path to pass — resolves to -/// `"/dev/fd/"` (a `struct file`, not a directory entry). A caller that instead execs the -/// literal `/proc//fd/` symlink (`fexecve()`'s pre-execveat fallback, and a -/// pattern droppers use directly) carries the identical "resolved only through an open fd, -/// never a pathname" tell. Both forms are therefore already PURE DATA on the wire; this module -/// only classifies a string the agent was already sending — no eBPF probe, offset, or wire -/// schema change (the whole reason this ships with no additional on-node validation risk). -/// -/// [`is_anon_fd_path`] recognizes exactly the two shapes the kernel produces: `/dev/fd/` -/// (`execveat(fd, "", AT_EMPTY_PATH)`'s own synthesis) and `/proc//fd/` (the -/// literal-path form `fexecve()` falls back to, and a pattern droppers use directly). -fn is_anon_fd_path(path: &str) -> bool { - // Segment-match against '/'-separated components, not a string-prefix test, so a - // directory that merely happens to be NAMED "fd" (`/dev/fdisk`) or a longer path past - // the fd number (`/proc/self/fd/3/extra`) can't false-positive. - let mut segs = path.strip_prefix('/').unwrap_or(path).split('/'); - match ( - segs.next(), - segs.next(), - segs.next(), - segs.next(), - segs.next(), - ) { - (Some("dev"), Some("fd"), Some(n), None, None) => is_ascii_digits(n), - (Some("proc"), Some(pid), Some("fd"), Some(n), None) => { - (pid == "self" || is_ascii_digits(pid)) && is_ascii_digits(n) - } - _ => false, - } -} - -/// Whether every byte of `s` is an ASCII digit, and `s` is non-empty (the fd number, or a -/// numeric pid, in a fileless-exec path — [`is_anon_fd_path`]). -fn is_ascii_digits(s: &str) -> bool { - !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) -} - -/// Whether `behavior` is a [`Behavior::ProcessExec`] of an fd-only "fileless" target — no -/// on-disk path was ever given to `execve` (JEF-317). Always `false` for any other behavior. -pub fn is_fileless_exec(behavior: &Behavior) -> bool { - match behavior { - Behavior::ProcessExec { path } => is_anon_fd_path(path), - _ => false, - } -} - /// Interactive shells a process-exec might be (matched on the binary's basename). /// An exec of one of these inside a container is the classic "terminal shell in /// container" runtime signal (JEF-55). Kept deliberately small and conservative — @@ -118,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, } } @@ -129,26 +81,27 @@ 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, a package manager, or a -/// fileless (fd-only) exec run inside the container (JEF-55, JEF-317) — 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 alert, JEF-117). This is a classification, NOT an `is_alert`: it -/// does not by itself 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. +/// 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 +/// alert, JEF-117). This is a classification, NOT an `is_alert`: it does not by itself +/// 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") } else if is_package_manager(behavior) { Some("package manager in container") - } else if is_fileless_exec(behavior) { - Some("fileless exec via anonymous fd — no on-disk path (memfd_create/execveat)") } else { None } @@ -179,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), @@ -258,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(), @@ -286,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")); @@ -300,66 +262,20 @@ mod tests { 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 fileless_exec_flags_fd_only_paths() { - // (exec path, is fileless) — both forms the kernel/`fexecve()` produce for an - // fd-only exec (JEF-317), plus look-alikes that must NOT match. - let exec = |p: &str| Behavior::ProcessExec { path: p.into() }; - let cases = [ - // /dev/fd/ — execveat(fd, "", AT_EMPTY_PATH)'s bprm->filename synthesis. - ("/dev/fd/3", true), - ("/dev/fd/0", true), - ("/dev/fd/12345", true), - // /proc//fd/ — the literal-path form (fexecve()'s fallback, and a - // pattern droppers use directly). - ("/proc/self/fd/3", true), - ("/proc/1/fd/9", true), - ("/proc/48213/fd/17", true), - // Negatives: a normal app binary, and look-alikes that must NOT match. - ("/app/server", false), - ("/dev/fdisk", false), // no `/` boundary after "fd" - ("/dev/fd/", false), // no fd number - ("/dev/fd/abc", false), // non-numeric "fd number" - ("/proc/self/fdinfo/3", false), // "fdinfo", not "fd" - ("/proc/self/fd/3/extra", false), // trailing segment after the fd number - ("/proc/abc/fd/3", false), // non-numeric, non-"self" pid segment - ("/etc/proc/self/fd/3", false), // not rooted at /proc - ]; - for (path, want) in cases { - let b = exec(path); - assert_eq!(is_fileless_exec(&b), want, "is_fileless_exec({path:?})"); - } - } - - #[test] - fn fileless_exec_is_notable_and_scoped_to_process_exec() { + fn anon_inode_exec_alone_is_not_notable_here() { let anon = Behavior::ProcessExec { - path: "/dev/fd/7".into(), + path: "/app/server".into(), + exe_anon_inode: true, }; - assert_eq!( - notable_exec(&anon), - Some("fileless exec via anonymous fd — no on-disk path (memfd_create/execveat)") - ); - assert_eq!( - annotated_summary(&anon), - "executed /dev/fd/7 (fileless exec via anonymous fd — no on-disk path (memfd_create/execveat))" - ); - - // A path that merely CONTAINS "/dev/fd/" further in must not match — only the exec - // path forms the kernel actually produces count, not a substring anywhere. - let others = [ - Behavior::Alert { - rule: "/dev/fd/3".into(), - }, - Behavior::FileWrite { - path: "/dev/fd/3".into(), - }, - Behavior::LibraryLoaded { - name: "/dev/fd/3".into(), - }, - ]; - for b in others { - assert!(!is_fileless_exec(&b), "{b:?} is_fileless_exec"); - } + 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 63cb41f..e116107 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -37,16 +37,24 @@ pub(super) struct EntryContext<'a> { /// /// An *alerting* signal corroborates **any** objective: an alert means "an attack is /// happening now" regardless of which chain. An alert arrives via the tool-agnostic -/// behavioral port (ADR-0003), so any sensor can raise one. An interactive-shell, -/// package-manager, or fileless (fd-only, JEF-317) 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 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. +/// behavioral port (ADR-0003), so any sensor can raise one. An interactive-shell or +/// 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 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 @@ -103,19 +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, a package manager, or a **fileless** - // exec (JEF-317: execve of an fd-only target — memfd_create/execveat, no on-disk - // path — the one shipped-critical Falco class the F2 FileWrite corroboration can't - // reach, since such an fd never touches `security_file_open`) 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/fileless execs (JEF-113: - // the classifier is engine policy in `observe::exec_class`, not on the wire type — - // the fileless-exec case classifies the SAME `path` field the agent already emits, - // no wire change). + // 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() } @@ -168,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 @@ -191,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 @@ -250,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| { @@ -330,6 +340,47 @@ 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 — it is +/// NOT behind any operator-facing flag; like every arm in this module it is shadow-gated +/// (ADR-0014): it only ever sets `corroborated`, never actuates. +/// +/// **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. +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..19f525b --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs @@ -0,0 +1,142 @@ +//! 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. + +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 a138bc5..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,34 +206,36 @@ fn package_manager_exec_corroborates_any_objective() { assert!(corroborates(&pkg, &EXPLOIT_PUBLIC_FACING)); } -/// A fileless exec — execve of an fd-only target (`/dev/fd/` / `/proc//fd/`, no -/// on-disk path — JEF-317, the Falco-parity "memfd_create / anonymous-fd execve" critical) -/// corroborates ANY objective like an alert (JEF-117): the same blanket tamper-now gate a -/// shell or package-manager exec triggers, closing the gap where F2's `FileWrite` -/// (JEF-306) has no path artifact to correlate against (a memfd-backed exec never touches -/// `security_file_open`). +/// 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 fileless_exec_corroborates_any_objective() { - for path in ["/dev/fd/7", "/proc/self/fd/3", "/proc/1234/fd/9"] { - let anon = Behavior::ProcessExec { path: path.into() }; - assert!( - crate::engine::observe::exec_class::is_fileless_exec(&anon), - "{path:?}" - ); - assert!(corroborates(&anon, &CREDENTIAL_ACCESS), "{path:?}"); - assert!(corroborates(&anon, &EXFILTRATION), "{path:?}"); - assert!(corroborates(&anon, &ESCAPE_TO_HOST), "{path:?}"); - assert!(corroborates(&anon, &EXPLOIT_PUBLIC_FACING), "{path:?}"); - } +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, non-fileless) 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: 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)] From 70fc65582187d8a7064e4279bf505d3453866816 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Mon, 27 Jul 2026 03:29:32 -0700 Subject: [PATCH 3/4] fix(engine): gate anon-inode-exec corroboration behind PROTECTOR_ANON_EXEC_CORROBORATION (JEF-317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A security review flagged that anon_inode_exec_on_foothold shipped LIVE-corroborating ahead of the on-node measurement its own doc-comment says it depends on: runc copies itself into a memfd and re-execs on ~every container start (CVE-2019-5736 mitigation), and whether that attributes to the workload cgroup or the host runtime is unmeasured. If it attributes to the workload, this shape is a standing false corroboration on every foothold pod (re)start. Puts the shape behind PROTECTOR_ANON_EXEC_CORROBORATION (default OFF), mirroring the Rekor lane's opt-in-off posture (PROTECTOR_REKOR_ENABLE). The flag is read once per predicate call, ahead of the per-signal loop — never re-parsed per RuntimeSignal. Tests exercise both flag states: the existing positive/negative cases now run with the flag explicitly ON (via an EnvGuard mirroring policies::signature::auth_tests's JEF-412 fix for the same env-var-vs-parallel-tests race), plus a new case proving the shape does not corroborate with the flag OFF even on an otherwise-textbook foothold+Execution+anon shape. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/src/engine/reason/proof/corroborate.rs | 33 +++++- .../corroborate_anon_inode_exec_tests.rs | 104 +++++++++++++++++- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index e116107..f34cfc8 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -366,13 +366,25 @@ pub(super) fn host_credential_read_on_foothold( /// 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. +/// +/// **Gated OFF by default pending that on-node measurement (security review follow-up, +/// JEF-317):** until runc-attribution is measured on a live node, shipping this LIVE would +/// make it a standing false corroboration on every foothold pod (re)start if the runc +/// memfd re-exec attributes to the workload cgroup. `PROTECTOR_ANON_EXEC_CORROBORATION` +/// (unset/false by default) is the deliberate gate — mirrors the Rekor lane's opt-in-off +/// posture (`PROTECTOR_REKOR_ENABLE`, [`crate::policies::signature::rekor`]): an operator +/// sets it during the measurement window to turn this shape on; until then it never +/// corroborates. Do NOT flip the default without the on-node measurement landing. 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 { + if !anon_exec_corroboration_enabled() + || !entry.is_foothold + || attack.tactic != Tactic::Execution + { return false; } runtime.iter().any(|s| match &s.behavior { @@ -381,6 +393,25 @@ pub(super) fn anon_inode_exec_on_foothold( }) } +/// Whether the operator has deliberately turned on [`anon_inode_exec_on_foothold`] +/// (`PROTECTOR_ANON_EXEC_CORROBORATION`; unset/anything else is OFF) — see that function's +/// doc-comment for why it defaults off. Read once per predicate call, ahead of the +/// per-signal `runtime.iter()` loop — never re-parsed per `RuntimeSignal` — mirroring +/// `RegistryAuth::from_env`'s uncached-but-called-once-per-resolution env reads +/// (`policies::signature::auth`) rather than a process-lifetime cache: this crate's test +/// suite already hit process-global env-var caching races once (JEF-412), so config reads +/// stay uncached and are exercised under the same `EnvGuard`-style serialization used there. +fn anon_exec_corroboration_enabled() -> bool { + std::env::var("PROTECTOR_ANON_EXEC_CORROBORATION") + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(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 index 19f525b..82987d3 100644 --- a/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs @@ -10,7 +10,17 @@ //! `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. +//! +//! A SECOND security review (still JEF-317) flagged that shipping this LIVE was itself +//! premature: whether the runc CVE-2019-5736 memfd-reexec attributes to the workload +//! cgroup or the host runtime is unmeasured, so the shape now sits behind +//! `PROTECTOR_ANON_EXEC_CORROBORATION` (default OFF). Every case below therefore goes +//! through [`EnvGuard`], mirroring `policies::signature::auth_tests`'s fix for the exact +//! same class of problem (JEF-412): Rust's default test harness runs `#[test]`s as +//! parallel threads in one process, so an unguarded `set_var`/`remove_var` on this +//! process-global var would race sibling tests. +use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, SystemTime}; use super::corroborate::{EntryContext, anon_inode_exec_on_foothold, corroborated_for}; @@ -20,6 +30,57 @@ use crate::engine::graph::attack::{ }; use crate::engine::graph::{Behavior, RuntimeSignal}; +const FLAG_VAR: &str = "PROTECTOR_ANON_EXEC_CORROBORATION"; + +/// Serializes every test that mutates [`FLAG_VAR`] (JEF-412-style race guard). +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// RAII guard giving a test exclusive, restore-on-drop access to [`FLAG_VAR`]. Mirrors +/// `policies::signature::auth_tests::EnvGuard`. +struct EnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Option, +} + +impl EnvGuard { + /// Acquire the lock, snapshot [`FLAG_VAR`], and set it to `Some(value)` or clear it + /// (`None`) — the shared body for [`set`](Self::set)/[`unset`](Self::unset). + fn with(value: Option<&str>) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let saved = std::env::var(FLAG_VAR).ok(); + // SAFETY: the lock guarantees no sibling test reads/writes this var concurrently. + unsafe { + match value { + Some(v) => std::env::set_var(FLAG_VAR, v), + None => std::env::remove_var(FLAG_VAR), + } + } + Self { _lock: lock, saved } + } + + /// Turn [`FLAG_VAR`] on for the guarded window. + fn set(value: &str) -> Self { + Self::with(Some(value)) + } + + /// Ensure [`FLAG_VAR`] is unset for the guarded window (the default/OFF posture). + fn unset() -> Self { + Self::with(None) + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: still holding the lock; restore the pre-test value. + unsafe { + match &self.saved { + Some(v) => std::env::set_var(FLAG_VAR, v), + None => std::env::remove_var(FLAG_VAR), + } + } + } +} + /// 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) @@ -65,13 +126,38 @@ fn execution_objective() -> AttackRef { CONTAINER_ADMIN_COMMAND } -// ---- Positive: anon-inode exec on the foothold entry — end to end ------------------------ +// ---- Flag OFF (the shipped default): the shape never corroborates ------------------------- + +#[test] +fn anon_inode_exec_on_the_foothold_does_not_corroborate_with_the_flag_off() { + // With PROTECTOR_ANON_EXEC_CORROBORATION unset (the shipped default), this shape must + // NOT corroborate even on the otherwise-textbook foothold + Execution + anon-inode-exec + // shape — it stays HELD pending the on-node runc-attribution measurement (JEF-317 + // follow-up: a security review flagged shipping this LIVE ahead of that measurement). + let _guard = EnvGuard::unset(); + let runtime = [exec("/tmp/payload", true, 0)]; + assert!(!corroborated_for( + &runtime, + &execution_objective(), + None, + foothold_entry("frontend"), + )); + assert!(!anon_inode_exec_on_foothold( + &runtime, + &execution_objective(), + foothold_entry("frontend"), + )); +} + +// ---- Positive: anon-inode exec on the foothold entry — end to end (flag ON) --------------- #[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. + // fileless payload on that same workload. Only reachable with the deliberate + // opt-in flag on (operator running the on-node measurement window). + let _guard = EnvGuard::set("1"); let runtime = [exec("/tmp/payload", true, 0)]; assert!(corroborated_for( &runtime, @@ -87,13 +173,15 @@ fn anon_inode_exec_on_the_foothold_entry_corroborates_execution() { )); } -// ---- Negative: same exec, non-foothold entry ---------------------------------------------- +// ---- Negative: same exec, non-foothold entry (flag ON) ------------------------------------ #[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. + // Flag ON so this exercises the foothold gate specifically, not the flag gate. + let _guard = EnvGuard::set("1"); let runtime = [exec("/tmp/payload", true, 0)]; assert!(!corroborated_for( &runtime, @@ -108,12 +196,14 @@ fn anon_inode_exec_on_a_non_foothold_entry_does_not_corroborate() { )); } -// ---- Regression guards: don't widen past exe_anon_inode / past Execution ----------------- +// ---- Regression guards: don't widen past exe_anon_inode / past Execution (flag ON) -------- #[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. + // not the fileless-exec signal Falco fires on. Flag ON so this exercises the + // exe_anon_inode gate specifically, not the flag gate. + let _guard = EnvGuard::set("1"); let runtime = [exec("/app/server", false, 0)]; assert!(!anon_inode_exec_on_foothold( &runtime, @@ -127,7 +217,9 @@ 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). + // lacked entirely (it corroborated ANY objective). Flag ON so this exercises the + // tactic gate specifically, not the flag gate. + let _guard = EnvGuard::set("1"); let runtime = [exec("/tmp/payload", true, 0)]; for objective in [CREDENTIAL_ACCESS, ESCAPE_TO_HOST] { assert!( From 439d6abc4027bd98d499f5192076a092fdb66aba Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Mon, 27 Jul 2026 04:06:55 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(engine):=20retire=20the=20PROTECTOR=5FA?= =?UTF-8?q?NON=5FEXEC=5FCORROBORATION=20flag=20=E2=80=94=20run=20anon-inod?= =?UTF-8?q?e-exec=20corroboration=20unconditionally=20(JEF-317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag was the wrong call: anon_inode_exec_on_foothold is already shadow-gated (ADR-0014, sets `corroborated` only, never actuates) and scoped to a proven foothold entry AND an Execution-tactic objective, so it can't actuate and can't fire for a non-foothold pod. No new settings surface belongs in protector for a predicate this narrowly scoped — remove the env-var read and let it run. Drops PROTECTOR_ANON_EXEC_CORROBORATION, anon_exec_corroboration_enabled(), the flag-off regression test, and the EnvGuard machinery that existed only for this flag (policies::signature::auth_tests's own EnvGuard, unrelated, is untouched). The foothold/tactic positive+negative tests are kept and now run unguarded. The runc-memfd base-rate caveat in the doc-comment stays — it's an on-node measurement item to track, not a reason to gate this behind a flag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP --- engine/src/engine/reason/proof/corroborate.rs | 43 ++----- .../corroborate_anon_inode_exec_tests.rs | 108 ++---------------- 2 files changed, 16 insertions(+), 135 deletions(-) diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index f34cfc8..27bdcde 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -353,9 +353,10 @@ pub(super) fn host_credential_read_on_foothold( /// 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 — it is -/// NOT behind any operator-facing flag; like every arm in this module it is shadow-gated -/// (ADR-0014): it only ever sets `corroborated`, never actuates. +/// 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 @@ -365,26 +366,15 @@ pub(super) fn host_credential_read_on_foothold( /// 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. -/// -/// **Gated OFF by default pending that on-node measurement (security review follow-up, -/// JEF-317):** until runc-attribution is measured on a live node, shipping this LIVE would -/// make it a standing false corroboration on every foothold pod (re)start if the runc -/// memfd re-exec attributes to the workload cgroup. `PROTECTOR_ANON_EXEC_CORROBORATION` -/// (unset/false by default) is the deliberate gate — mirrors the Rekor lane's opt-in-off -/// posture (`PROTECTOR_REKOR_ENABLE`, [`crate::policies::signature::rekor`]): an operator -/// sets it during the measurement window to turn this shape on; until then it never -/// corroborates. Do NOT flip the default without the on-node measurement landing. +/// 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 !anon_exec_corroboration_enabled() - || !entry.is_foothold - || attack.tactic != Tactic::Execution - { + if !entry.is_foothold || attack.tactic != Tactic::Execution { return false; } runtime.iter().any(|s| match &s.behavior { @@ -393,25 +383,6 @@ pub(super) fn anon_inode_exec_on_foothold( }) } -/// Whether the operator has deliberately turned on [`anon_inode_exec_on_foothold`] -/// (`PROTECTOR_ANON_EXEC_CORROBORATION`; unset/anything else is OFF) — see that function's -/// doc-comment for why it defaults off. Read once per predicate call, ahead of the -/// per-signal `runtime.iter()` loop — never re-parsed per `RuntimeSignal` — mirroring -/// `RegistryAuth::from_env`'s uncached-but-called-once-per-resolution env reads -/// (`policies::signature::auth`) rather than a process-lifetime cache: this crate's test -/// suite already hit process-global env-var caching races once (JEF-412), so config reads -/// stay uncached and are exercised under the same `EnvGuard`-style serialization used there. -fn anon_exec_corroboration_enabled() -> bool { - std::env::var("PROTECTOR_ANON_EXEC_CORROBORATION") - .map(|v| { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(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 index 82987d3..e4ea601 100644 --- a/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_anon_inode_exec_tests.rs @@ -9,18 +9,10 @@ //! 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. -//! -//! A SECOND security review (still JEF-317) flagged that shipping this LIVE was itself -//! premature: whether the runc CVE-2019-5736 memfd-reexec attributes to the workload -//! cgroup or the host runtime is unmeasured, so the shape now sits behind -//! `PROTECTOR_ANON_EXEC_CORROBORATION` (default OFF). Every case below therefore goes -//! through [`EnvGuard`], mirroring `policies::signature::auth_tests`'s fix for the exact -//! same class of problem (JEF-412): Rust's default test harness runs `#[test]`s as -//! parallel threads in one process, so an unguarded `set_var`/`remove_var` on this -//! process-global var would race sibling tests. +//! 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::sync::{Mutex, MutexGuard}; use std::time::{Duration, SystemTime}; use super::corroborate::{EntryContext, anon_inode_exec_on_foothold, corroborated_for}; @@ -30,57 +22,6 @@ use crate::engine::graph::attack::{ }; use crate::engine::graph::{Behavior, RuntimeSignal}; -const FLAG_VAR: &str = "PROTECTOR_ANON_EXEC_CORROBORATION"; - -/// Serializes every test that mutates [`FLAG_VAR`] (JEF-412-style race guard). -static ENV_LOCK: Mutex<()> = Mutex::new(()); - -/// RAII guard giving a test exclusive, restore-on-drop access to [`FLAG_VAR`]. Mirrors -/// `policies::signature::auth_tests::EnvGuard`. -struct EnvGuard { - _lock: MutexGuard<'static, ()>, - saved: Option, -} - -impl EnvGuard { - /// Acquire the lock, snapshot [`FLAG_VAR`], and set it to `Some(value)` or clear it - /// (`None`) — the shared body for [`set`](Self::set)/[`unset`](Self::unset). - fn with(value: Option<&str>) -> Self { - let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - let saved = std::env::var(FLAG_VAR).ok(); - // SAFETY: the lock guarantees no sibling test reads/writes this var concurrently. - unsafe { - match value { - Some(v) => std::env::set_var(FLAG_VAR, v), - None => std::env::remove_var(FLAG_VAR), - } - } - Self { _lock: lock, saved } - } - - /// Turn [`FLAG_VAR`] on for the guarded window. - fn set(value: &str) -> Self { - Self::with(Some(value)) - } - - /// Ensure [`FLAG_VAR`] is unset for the guarded window (the default/OFF posture). - fn unset() -> Self { - Self::with(None) - } -} - -impl Drop for EnvGuard { - fn drop(&mut self) { - // SAFETY: still holding the lock; restore the pre-test value. - unsafe { - match &self.saved { - Some(v) => std::env::set_var(FLAG_VAR, v), - None => std::env::remove_var(FLAG_VAR), - } - } - } -} - /// 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) @@ -126,38 +67,13 @@ fn execution_objective() -> AttackRef { CONTAINER_ADMIN_COMMAND } -// ---- Flag OFF (the shipped default): the shape never corroborates ------------------------- - -#[test] -fn anon_inode_exec_on_the_foothold_does_not_corroborate_with_the_flag_off() { - // With PROTECTOR_ANON_EXEC_CORROBORATION unset (the shipped default), this shape must - // NOT corroborate even on the otherwise-textbook foothold + Execution + anon-inode-exec - // shape — it stays HELD pending the on-node runc-attribution measurement (JEF-317 - // follow-up: a security review flagged shipping this LIVE ahead of that measurement). - let _guard = EnvGuard::unset(); - let runtime = [exec("/tmp/payload", true, 0)]; - assert!(!corroborated_for( - &runtime, - &execution_objective(), - None, - foothold_entry("frontend"), - )); - assert!(!anon_inode_exec_on_foothold( - &runtime, - &execution_objective(), - foothold_entry("frontend"), - )); -} - -// ---- Positive: anon-inode exec on the foothold entry — end to end (flag ON) --------------- +// ---- 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. Only reachable with the deliberate - // opt-in flag on (operator running the on-node measurement window). - let _guard = EnvGuard::set("1"); + // fileless payload on that same workload. let runtime = [exec("/tmp/payload", true, 0)]; assert!(corroborated_for( &runtime, @@ -173,15 +89,13 @@ fn anon_inode_exec_on_the_foothold_entry_corroborates_execution() { )); } -// ---- Negative: same exec, non-foothold entry (flag ON) ------------------------------------ +// ---- 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. - // Flag ON so this exercises the foothold gate specifically, not the flag gate. - let _guard = EnvGuard::set("1"); let runtime = [exec("/tmp/payload", true, 0)]; assert!(!corroborated_for( &runtime, @@ -196,14 +110,12 @@ fn anon_inode_exec_on_a_non_foothold_entry_does_not_corroborate() { )); } -// ---- Regression guards: don't widen past exe_anon_inode / past Execution (flag ON) -------- +// ---- 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. Flag ON so this exercises the - // exe_anon_inode gate specifically, not the flag gate. - let _guard = EnvGuard::set("1"); + // not the fileless-exec signal Falco fires on. let runtime = [exec("/app/server", false, 0)]; assert!(!anon_inode_exec_on_foothold( &runtime, @@ -217,9 +129,7 @@ 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). Flag ON so this exercises the - // tactic gate specifically, not the flag gate. - let _guard = EnvGuard::set("1"); + // lacked entirely (it corroborated ANY objective). let runtime = [exec("/tmp/payload", true, 0)]; for objective in [CREDENTIAL_ACCESS, ESCAPE_TO_HOST] { assert!(