From ddfc1515d375a74b387e1ca21a262264b2389647 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Thu, 6 Aug 2026 19:04:20 -0700 Subject: [PATCH 1/2] fix(engine): narrow the blanket notable-exec corroboration arm, land reverse_shell_on_foothold (ADR-0041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior::ProcessExec no longer blanket-corroborates via the flat corroborates() relation — it joins PrivilegeChange/PtraceAttach/ModuleLoad as model-evidence-only, restoring the ADR-0011 on-call-engineer (kubectl exec -it ... bash) false-positive guard the old blanket arm had suspended for Falco parity. What replaces it is a shape, not a wider blanket: reverse_shell_on_foothold correlates an is_interactive_shell exec with a same-entry internet NetworkConnection within a symmetric 60s window (covers both exec-then-connect and connect-back-then-spawn), foothold-gated, corroborating any objective. Package managers are excluded from the shape (they always egress fetching packages; a real install still corroborates via alarming_write). This clears the ADR-0024 redundant-by-construction gate that blocked the shape before the blanket arm narrowed (see 9620492). - corroborate.rs: flat arm -> false, REVERSE_SHELL_WINDOW const, the new shape, disjunct in corroborated_for, doc-comment rewrites citing ADR-0041/0024/0011. - corroborate_reverse_shell_tests.rs (new sibling): shape-positive, bare-shell negative, pkg-mgr negative, window-expiry negative, non-foothold negative, symmetric-window positive (egress-before-exec). - corroborate_objective_tests.rs: flip shell/pkg-mgr blanket tests to assert non-corroboration; drift-guard that a notable exec still satisfies observe::alarm_class::is_alarming_now after the narrow. - corroborate_context_tests.rs: update the stale comment noting the reverse-shell shape's prior removal. - incident/mod_tests.rs: pin Assessment::Attack -> Verdict::promotes() == true, the mapping ADR-0041's lane-shift reasoning depends on. Falco's Behavior::Alert arm is unchanged and stays the live floor while Falco coexists (audit is the default posture) — zero operational recall change. Co-Authored-By: Claude Opus 4.8 --- .../reason/adjudicate/incident/mod_tests.rs | 16 ++ engine/src/engine/reason/proof/corroborate.rs | 140 ++++++++++--- .../reason/proof/corroborate_context_tests.rs | 9 +- .../proof/corroborate_objective_tests.rs | 63 ++++-- .../proof/corroborate_reverse_shell_tests.rs | 189 ++++++++++++++++++ engine/src/engine/reason/proof/mod.rs | 2 + 6 files changed, 372 insertions(+), 47 deletions(-) create mode 100644 engine/src/engine/reason/proof/corroborate_reverse_shell_tests.rs diff --git a/engine/src/engine/reason/adjudicate/incident/mod_tests.rs b/engine/src/engine/reason/adjudicate/incident/mod_tests.rs index ab767c8..2673181 100644 --- a/engine/src/engine/reason/adjudicate/incident/mod_tests.rs +++ b/engine/src/engine/reason/adjudicate/incident/mod_tests.rs @@ -22,6 +22,22 @@ fn assessment_values_are_distinct() { assert_ne!(Assessment::NoAttack, Assessment::Uncertain); } +/// Pins the `Assessment` → `Verdict` bridge this ADR-0041's lane-shift reasoning depends +/// on: an `Attack` assessment maps to `Verdict::Exploitable`, the ONLY verdict that +/// `promotes()` a proven-but-uncorroborated chain to auto-eligible. If this ever stopped +/// holding, the narrowed exec arm's "shell-only + decisive-attack becomes a proposal +/// instead of an auto-cut" behavior (the one real behavioral change the ADR names) would +/// silently not hold either. +#[test] +fn attack_assessment_promotes_via_to_verdict() { + let decision = IncidentDecision { + assessment: Assessment::Attack, + reason: "test".into(), + cuts: Vec::new(), + }; + assert!(decision.to_verdict().promotes()); +} + /// End-to-end: a well-formed model reply naming a genuinely grounded downstream node, /// against the real menu for a real chain, survives every guard and resolves to the /// menu's own action/signature. diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 900294f..56bc5b4 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 or -/// package-manager exec corroborates the same broad way: 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 — 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, so they are never the +/// behavioral port (ADR-0003), so any sensor can raise one. An *alarming* file write — 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, so they are never the /// "everything corroborates everything" blanket the alert gate intentionally is. /// +/// **`ProcessExec` is NOT one of these blanket sources (ADR-0041).** An interactive-shell +/// or package-manager exec used to corroborate the same broad way an Alert does — the +/// Falco-parity floor, deliberately broad while Falco was the ingest-time backstop for the +/// exec class. ADR-0041 narrowed that to `false`: a bare exec — including +/// `is_interactive_shell` — is model evidence only, mirroring `PrivilegeChange` / +/// `PtraceAttach` / `ModuleLoad` below, restoring the ADR-0011 on-call-engineer +/// (`kubectl exec -it … bash`) false-positive guard the blanket had suspended. What +/// replaces it is a SHAPE, not a wider blanket: [`reverse_shell_on_foothold`] correlates an +/// `is_interactive_shell` exec with a same-entry internet egress in a tight symmetric +/// window — a live C2 session, not a bare shell. +/// /// ** (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 @@ -111,18 +119,26 @@ 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 a package manager run in the container - // — corroborates ANY objective like an Alert does: a tamper-now - // signal that evidences active intrusion regardless of chain. Conservative on - // 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 ( - // 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() - } + // ProcessExec is NON-corroborating here (ADR-0041 narrowed this from a blanket + // "any notable exec corroborates ANY objective" arm): model evidence, not a + // per-objective "now" signal, mirroring `PrivilegeChange` / `PtraceAttach` / + // `ModuleLoad` below. Legit entrypoints exec constantly — an `is_interactive_shell` + // exec included, the textbook ADR-0011 on-call-engineer `kubectl exec -it … bash` + // false positive the old blanket arm suspended. `notable_exec` (shell OR + // package-manager) still annotates the model's evidence line + // (`observe::exec_class::annotated_summary`) and still satisfies + // `observe::alarm_class::is_alarming_now` (the actively-exploited marking + the + // downstream `contain` menu seeding, both untouched) — only the DETERMINISTIC + // corroboration gate narrows. A shell exec CORRELATED with a same-entry internet + // egress within a tight window is corroboration — that shape lives at the + // entry-scoped seam ([`reverse_shell_on_foothold`]), scoped to `is_interactive_shell` + // only: a package-manager exec is excluded from the shape too, because it always + // egresses (fetching packages) and would just re-create a blanket for that class — + // a real install still corroborates via `alarming_write` (writes under `/usr/bin`, + // `/lib`, …); a no-op package-manager exec stays model evidence. See ADR-0041 / + // ADR-0024 (no redundant-by-construction predicates — this narrowing is what makes + // the shape load-bearing) / ADR-0011 (the false positive this restores). + Behavior::ProcessExec { .. } => false, // PrivilegeChange is NON-corroborating here: model evidence, not a per-objective // "now" signal (legit entrypoints escalate too — the same ADR-0011 false positive). // Context-free root escalation stays this way for ANY pod on purpose: a root @@ -187,16 +203,19 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { /// ([`host_credential_read_on_foothold`]) — **anon-inode exec on the foothold** ( /// Route A) — an Execution-tactic objective with an `exe_anon_inode` exec on the entry /// ([`anon_inode_exec_on_foothold`]) — **ptrace-attach on the foothold** — a -/// `PtraceAttach` on the entry itself ([`ptrace_attach_on_foothold`]) — or **kernel-module +/// `PtraceAttach` on the entry itself ([`ptrace_attach_on_foothold`]) — **kernel-module /// load on the foothold** — a `ModuleLoad` on the entry itself -/// ([`module_load_on_foothold`]). All seven are scoped to a proven foothold entry. +/// ([`module_load_on_foothold`]) — or **reverse-shell on the foothold** (ADR-0041) — an +/// `is_interactive_shell` exec correlated with a same-entry internet `NetworkConnection` +/// within a symmetric window ([`reverse_shell_on_foothold`]), corroborating ANY objective. +/// All eight are scoped to a proven foothold entry. /// /// None of these shapes widens the flat predicates it sits beside: ordinary internet egress, /// ordinary in-cluster traffic, an ordinary setuid, an ordinary write-then-run of a benign -/// path, an ordinary in-container process reading a host credential path, and an ordinary -/// ptrace-attach or driver load off a non-foothold pod all still corroborate nothing -/// (ADR-0011). Like every arm here this only sets `corroborated`; it never actuates -/// (shadow-gated, ADR-0014). +/// path, an ordinary in-container process reading a host credential path, an ordinary +/// ptrace-attach or driver load off a non-foothold pod, and a bare shell exec with no +/// correlated egress all still corroborate nothing (ADR-0011). Like every arm here this only +/// sets `corroborated`; it never actuates (shadow-gated, ADR-0014). pub(super) fn corroborated_for( runtime: &[RuntimeSignal], attack: &AttackRef, @@ -212,6 +231,7 @@ pub(super) fn corroborated_for( || anon_inode_exec_on_foothold(runtime, attack, entry) || ptrace_attach_on_foothold(runtime, attack, entry) || module_load_on_foothold(runtime, attack, entry) + || reverse_shell_on_foothold(runtime, entry) } /// The cross-tenant lateral-movement shape: a `NetworkConnection` from the entry to @@ -241,6 +261,16 @@ pub(super) fn cross_tenant_lateral(runtime: &[RuntimeSignal], entry: EntryContex /// and re-run much later (a build cache, a rotated log re-opened for append) is unrelated. pub(super) const DROP_EXEC_WINDOW: Duration = Duration::from_secs(300); +/// The tight window an `is_interactive_shell` exec and a same-entry internet +/// `NetworkConnection` can sit apart and still read as one live reverse-shell session +/// rather than an unrelated exec and an unrelated later/earlier egress. A `bash -i >& +/// /dev/tcp/…`-style reverse shell dials out within seconds of the exec; kept small on +/// purpose (ADR-0011), mirroring [`DROP_EXEC_WINDOW`], so a wide window doesn't re-admit +/// the ordinary "container execs a shell, then egresses minutes later for something +/// unrelated" false positive. Symmetric (ADR-0041): applied in BOTH directions — see +/// [`reverse_shell_on_foothold`]. +pub(super) const REVERSE_SHELL_WINDOW: Duration = Duration::from_secs(60); + /// The drop-then-execute shape: a `ProcessExec` of a path RECENTLY `FileWrite`n by /// the SAME workload — the classic "drop a payload under a benign-looking path (e.g. `/tmp`), /// then run it" pattern. Neither behavior alone is `corroborates`-blanket here: an ordinary @@ -458,6 +488,64 @@ pub(super) fn module_load_on_foothold( .any(|s| matches!(s.behavior, Behavior::ModuleLoad)) } +/// The reverse-shell-on-foothold shape (ADR-0041): an `is_interactive_shell` `ProcessExec` +/// and an internet `NetworkConnection` on the SAME entry, within [`REVERSE_SHELL_WINDOW`] of +/// each other in EITHER direction, corroborates ANY objective — a live command-and-control +/// session evidences active intrusion on every chain from that entry, the blanket exec arm's +/// semantic (ADR-0041 narrowed away, see [`corroborates`]), now re-homed in shaped form. +/// +/// **Symmetric window (`|Δt| ≤ 60s`, ADR-0041):** covers BOTH `exec-then-connect` +/// (`bash -i >& /dev/tcp/…` dials out right after the shell spawns) AND +/// `connect-back-then-spawn` (the attacker's listener accepts the connection and the shell +/// is spawned into it a moment later) — the two orderings a real reverse shell can present, +/// as opposed to a one-directional "exec at-or-before egress" gate that would miss the +/// second. +/// +/// **Interactive shells ONLY — package managers excluded.** `is_interactive_shell` +/// (`observe::exec_class`), not the broader `notable_exec` (shell OR package manager): a +/// package-manager exec ALWAYS egresses (fetching packages), so folding it into this shape +/// would just re-create a blanket for that class — exactly what ADR-0041 narrows away. A +/// real install still corroborates via `alarming_write` (writes under `/usr/bin`, `/lib`, +/// …); a no-op package-manager exec stays model evidence. +/// +/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`cross_tenant_lateral`]: +/// corroborates ONLY when the entry is a proven internet-facing foothold +/// (`entry.is_foothold`) — the house pattern every entry-scoped shape in this module +/// follows. An ordinary shell into a non-foothold pod, or a shell with no correlated +/// egress anywhere in the window, never corroborates. +pub(super) fn reverse_shell_on_foothold( + runtime: &[RuntimeSignal], + entry: EntryContext<'_>, +) -> bool { + if !entry.is_foothold { + return false; + } + // Interactive-shell exec timestamps on the entry. + let shells: Vec<_> = runtime + .iter() + .filter(|s| crate::engine::observe::exec_class::is_interactive_shell(&s.behavior)) + .map(|s| s.provenance.observed_at) + .collect(); + if shells.is_empty() { + return false; + } + runtime.iter().any(|s| match &s.behavior { + Behavior::NetworkConnection { internet: true, .. } => { + let egress_at = s.provenance.observed_at; + // Symmetric: the absolute gap between the shell and the egress, whichever + // came first — `duration_since` is `Err` when its argument is LATER than + // `self`, so trying both orderings computes `|Δt|` without a manual subtract. + shells.iter().any(|&shell_at| { + egress_at + .duration_since(shell_at) + .or_else(|_| shell_at.duration_since(egress_at)) + .is_ok_and(|gap| gap <= REVERSE_SHELL_WINDOW) + }) + } + _ => 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_context_tests.rs b/engine/src/engine/reason/proof/corroborate_context_tests.rs index ccfa5c3..14d85ed 100644 --- a/engine/src/engine/reason/proof/corroborate_context_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_context_tests.rs @@ -11,12 +11,9 @@ //! on the `cross_tenant_lateral` predicate directly — plus regression guards that ordinary //! egress / ordinary in-cluster traffic still corroborate only via the unchanged flat arms. //! -//! (The reverse-shell shape considered in was dropped at integration: it was -//! redundant-by-construction under the blanket notable-exec arm — a notable exec -//! already corroborates ANY objective, so the narrower exec+egress-timing shape could not -//! flip `corroborated_for` today. It lands load-bearing only WHEN that blanket exec arm is -//! narrowed; a follow-up ticket tracks implementing it then, so it arrives with a test that -//! can actually fail rather than dead-on-arrival.) +//! The sibling reverse-shell-on-foothold shape (ADR-0041) has its own file, +//! `corroborate_reverse_shell_tests.rs` — it needs the blanket notable-exec arm narrowed to +//! `false` (see `corroborate.rs`) to be load-bearing, which landed alongside it. use std::time::{Duration, SystemTime}; diff --git a/engine/src/engine/reason/proof/corroborate_objective_tests.rs b/engine/src/engine/reason/proof/corroborate_objective_tests.rs index de9a546..03e4e20 100644 --- a/engine/src/engine/reason/proof/corroborate_objective_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_objective_tests.rs @@ -174,8 +174,14 @@ fn alert_still_corroborates_any_objective() { assert!(corroborates(&alert, &EXPLOIT_PUBLIC_FACING)); } -/// A shell exec (interactive-shell) corroborates ANY objective like an alert -/// : the "terminal shell in container" tamper-now signal. +/// FLIP-PROOF (ADR-0041): a bare shell exec (interactive-shell) NO LONGER corroborates via +/// the flat [`corroborates`] relation — this is exactly what the blanket-arm narrowing +/// changed. It still classifies as `is_interactive_shell` (the exec-class annotation the +/// model's evidence line still carries) and still satisfies +/// `observe::alarm_class::is_alarming_now` (the drift-guard in this file pins that); only +/// the DETERMINISTIC corroboration gate narrows. A shell CORRELATED with a same-entry +/// internet egress in a tight window is corroboration instead — see +/// `corroborate_reverse_shell_tests.rs`. #[test] fn shell_exec_corroborates_any_objective() { let shell = Behavior::ProcessExec { @@ -185,14 +191,18 @@ fn shell_exec_corroborates_any_objective() { assert!(crate::engine::observe::exec_class::is_interactive_shell( &shell )); - assert!(corroborates(&shell, &CREDENTIAL_ACCESS)); - assert!(corroborates(&shell, &EXFILTRATION)); - assert!(corroborates(&shell, &ESCAPE_TO_HOST)); - assert!(corroborates(&shell, &EXPLOIT_PUBLIC_FACING)); + assert!(!corroborates(&shell, &CREDENTIAL_ACCESS)); + assert!(!corroborates(&shell, &EXFILTRATION)); + assert!(!corroborates(&shell, &ESCAPE_TO_HOST)); + assert!(!corroborates(&shell, &EXPLOIT_PUBLIC_FACING)); } -/// A package-manager exec corroborates ANY objective like an alert: -/// the "package management in container" tamper-now signal. +/// FLIP-PROOF (ADR-0041): a package-manager exec NO LONGER corroborates via the flat +/// [`corroborates`] relation, and — unlike an interactive shell — it never will: package +/// managers are deliberately EXCLUDED from the reverse-shell shape too (they always egress +/// fetching packages, so including them would just re-create a blanket for that class). A +/// real install still corroborates via `alarming_write`; a no-op package-manager exec stays +/// model evidence only. #[test] fn package_manager_exec_corroborates_any_objective() { let pkg = Behavior::ProcessExec { @@ -200,10 +210,10 @@ fn package_manager_exec_corroborates_any_objective() { exe_anon_inode: false, }; assert!(crate::engine::observe::exec_class::is_package_manager(&pkg)); - assert!(corroborates(&pkg, &CREDENTIAL_ACCESS)); - assert!(corroborates(&pkg, &EXFILTRATION)); - assert!(corroborates(&pkg, &ESCAPE_TO_HOST)); - assert!(corroborates(&pkg, &EXPLOIT_PUBLIC_FACING)); + assert!(!corroborates(&pkg, &CREDENTIAL_ACCESS)); + assert!(!corroborates(&pkg, &EXFILTRATION)); + assert!(!corroborates(&pkg, &ESCAPE_TO_HOST)); + assert!(!corroborates(&pkg, &EXPLOIT_PUBLIC_FACING)); } /// NEGATIVE / REGRESSION GUARD: an anon-inode exec (`exe_anon_inode: true`) must @@ -212,13 +222,15 @@ fn package_manager_exec_corroborates_any_objective() { /// 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`. +/// `corroborate_anon_inode_exec_tests.rs`. (Post-ADR-0041 this is no longer the only +/// non-corroborating exec path — a bare shell/pkg-mgr exec joins it — but the isolation +/// this test does (a non-shell/pkg-mgr path) stays meaningful for a future exec-scoped arm.) #[test] fn anon_inode_exec_does_not_blanket_corroborate() { let anon = Behavior::ProcessExec { // NOT a shell/package-manager path — isolates exe_anon_inode as the only thing - // under test (a shell path would ALSO blanket-corroborate via notable_exec, for - // an unrelated reason). + // under test (post-ADR-0041 a shell/pkg-mgr path is ALSO non-corroborating via the + // flat arm now, for the unrelated reason the two tests above pin). path: "/tmp/payload".into(), exe_anon_inode: true, }; @@ -228,6 +240,27 @@ fn anon_inode_exec_does_not_blanket_corroborate() { assert!(!corroborates(&anon, &EXPLOIT_PUBLIC_FACING)); } +/// DRIFT-GUARD (ADR-0041): narrowing the flat corroboration arm must NOT touch +/// `observe::alarm_class::is_alarming_now` — the model's "hands-on-keyboard happened" +/// evidence line and the actively-exploited marking / downstream `contain` menu seeding +/// all key off it, and none of that is in scope here. A notable exec (shell or +/// package-manager) still satisfies it even though it no longer `corroborates`. +#[test] +fn notable_exec_still_satisfies_is_alarming_now_after_the_narrow() { + 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, + }; + assert!(!corroborates(&shell, &CREDENTIAL_ACCESS)); + assert!(!corroborates(&pkg, &CREDENTIAL_ACCESS)); + assert!(crate::engine::observe::alarm_class::is_alarming_now(&shell)); + assert!(crate::engine::observe::alarm_class::is_alarming_now(&pkg)); +} + /// 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. diff --git a/engine/src/engine/reason/proof/corroborate_reverse_shell_tests.rs b/engine/src/engine/reason/proof/corroborate_reverse_shell_tests.rs new file mode 100644 index 0000000..cb94d03 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_reverse_shell_tests.rs @@ -0,0 +1,189 @@ +//! Tests for the reverse-shell-on-foothold entry-scoped corroboration shape (ADR-0041), +//! kept in its own `*_tests.rs` file (repo CLAUDE.md: tests count toward the 1,000-line +//! cap; the per-shape convention every sibling shape follows). `super` resolves to the +//! proof module, so these exercise the `pub(super)` `corroborate` seam directly. +//! +//! This shape only became load-bearing once ADR-0041 narrowed the blanket notable-exec arm +//! (`Behavior::ProcessExec => false` in `corroborate.rs`) — before that, any notable exec +//! already corroborated ANY objective, so this narrower exec+egress-timing correlation could +//! never independently flip `corroborated_for` (ADR-0024's redundant-by-construction bar). +//! Every positive test below therefore asserts through `corroborated_for` with an objective +//! the bare exec would NOT corroborate on its own (per `corroborate_objective_tests.rs`'s +//! flip-proof), so a regression back to the blanket arm would NOT be masked here. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{EntryContext, corroborated_for, reverse_shell_on_foothold}; +use crate::engine::graph::Provenance; +use crate::engine::graph::attack::CREDENTIAL_ACCESS; +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 shell_exec() -> Behavior { + Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: false, + } +} + +fn pkg_mgr_exec() -> Behavior { + Behavior::ProcessExec { + path: "/usr/bin/apt".into(), + exe_anon_inode: false, + } +} + +fn internet_egress(peer: &str) -> Behavior { + Behavior::NetworkConnection { + peer: peer.into(), + internet: true, + } +} + +/// The entry is a proven internet-facing foothold. +fn foothold_entry() -> EntryContext<'static> { + EntryContext { + source_ns: "frontend", + is_foothold: true, + } +} + +/// The entry is an ordinary (non-foothold) workload. +fn ordinary_entry() -> EntryContext<'static> { + EntryContext { + source_ns: "frontend", + is_foothold: false, + } +} + +/// The objective the `corroborated_for` positives below use: CREDENTIAL_ACCESS. The bare +/// (post-ADR-0041) flat arm never fires on a `ProcessExec` or an internet +/// `NetworkConnection` for this tactic, so a `corroborated_for` positive here is +/// attributable to the reverse-shell shape alone — the exact flip-proof ADR-0024 requires. +const OBJECTIVE: crate::engine::graph::attack::AttackRef = CREDENTIAL_ACCESS; + +// ---- Shape-positive: shell + in-window egress corroborates where a bare shell would not -- + +#[test] +fn shell_then_egress_in_window_on_the_foothold_is_a_reverse_shell() { + let runtime = [ + sig(shell_exec(), 0), + sig(internet_egress("203.0.113.7:4444"), 5), + ]; + // Direct predicate. + assert!(reverse_shell_on_foothold(&runtime, foothold_entry())); + // End-to-end: flips `corroborated_for` on an objective the bare exec alone would NOT. + assert!(corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry() + )); + + // Contrast: the bare shell alone (no egress) does NOT corroborate the same objective — + // proves the positive above is attributable to the shape, not the exec alone. + let bare = [sig(shell_exec(), 0)]; + assert!(!reverse_shell_on_foothold(&bare, foothold_entry())); + assert!(!corroborated_for(&bare, &OBJECTIVE, None, foothold_entry())); +} + +// ---- Bare-shell negative --------------------------------------------------------------- + +#[test] +fn bare_shell_with_no_egress_anywhere_does_not_corroborate() { + let runtime = [sig(shell_exec(), 0)]; + assert!(!reverse_shell_on_foothold(&runtime, foothold_entry())); + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry() + )); +} + +// ---- Package-manager negative: excluded from the shape (always egresses) --------------- + +#[test] +fn package_manager_exec_then_egress_in_window_does_not_corroborate() { + // The SAME timing a shell would flip on — but a package-manager exec always egresses + // (fetching packages), so folding it in would re-create a blanket for that class. + let runtime = [ + sig(pkg_mgr_exec(), 0), + sig(internet_egress("203.0.113.7:443"), 5), + ]; + assert!(!reverse_shell_on_foothold(&runtime, foothold_entry())); + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry() + )); +} + +// ---- Window-expiry negative -------------------------------------------------------------- + +#[test] +fn shell_and_egress_outside_the_window_do_not_corroborate() { + // 61s apart — one second past REVERSE_SHELL_WINDOW (60s). + let runtime = [ + sig(shell_exec(), 0), + sig(internet_egress("203.0.113.7:4444"), 61), + ]; + assert!(!reverse_shell_on_foothold(&runtime, foothold_entry())); + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry() + )); +} + +// ---- Non-foothold negative ---------------------------------------------------------------- + +#[test] +fn shell_then_egress_from_a_non_foothold_entry_does_not_corroborate() { + let runtime = [ + sig(shell_exec(), 0), + sig(internet_egress("203.0.113.7:4444"), 5), + ]; + assert!(!reverse_shell_on_foothold(&runtime, ordinary_entry())); + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + ordinary_entry() + )); +} + +// ---- Symmetric-window positive: connect-back-then-spawn (egress before the exec) ------- + +#[test] +fn egress_before_the_exec_in_window_is_also_a_reverse_shell() { + // The connect-back-then-spawn ordering: the attacker's listener accepts the connection, + // THEN the shell is spawned into it a moment later. ADR-0041 requires the symmetric + // window to cover this — the old (withdrawn) asymmetric "exec at-or-before egress" gate + // would have missed it. + let runtime = [ + sig(internet_egress("203.0.113.7:4444"), 0), + sig(shell_exec(), 5), + ]; + assert!(reverse_shell_on_foothold(&runtime, foothold_entry())); + assert!(corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry() + )); +} diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 62ced80..7bfeae1 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -376,6 +376,8 @@ mod corroborate_privesc_tests; #[cfg(test)] mod corroborate_ptrace_tests; #[cfg(test)] +mod corroborate_reverse_shell_tests; +#[cfg(test)] mod corroborate_tests; #[cfg(test)] mod pivot_quarantine_tests; From 11db906d711b7a63a595d3aeb87d1a7722ce3ea1 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Thu, 6 Aug 2026 19:15:59 -0700 Subject: [PATCH 2/2] refactor(engine): rename stale notable-exec corroboration flip-proof tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two ADR-0041 flip-proof tests now assert `!corroborates(...)`, but their names still read `..._corroborates_any_objective` — the opposite of what they pin. Rename to `..._no_longer_corroborates_any_objective` so a future reviewer can't misread the name as license to reintroduce the blanket notable-exec arm. Test-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- engine/src/engine/reason/proof/corroborate_objective_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/src/engine/reason/proof/corroborate_objective_tests.rs b/engine/src/engine/reason/proof/corroborate_objective_tests.rs index 03e4e20..5547de1 100644 --- a/engine/src/engine/reason/proof/corroborate_objective_tests.rs +++ b/engine/src/engine/reason/proof/corroborate_objective_tests.rs @@ -183,7 +183,7 @@ fn alert_still_corroborates_any_objective() { /// internet egress in a tight window is corroboration instead — see /// `corroborate_reverse_shell_tests.rs`. #[test] -fn shell_exec_corroborates_any_objective() { +fn shell_exec_no_longer_corroborates_any_objective() { let shell = Behavior::ProcessExec { path: "/bin/bash".into(), exe_anon_inode: false, @@ -204,7 +204,7 @@ fn shell_exec_corroborates_any_objective() { /// real install still corroborates via `alarming_write`; a no-op package-manager exec stays /// model evidence only. #[test] -fn package_manager_exec_corroborates_any_objective() { +fn package_manager_exec_no_longer_corroborates_any_objective() { let pkg = Behavior::ProcessExec { path: "/usr/bin/apt".into(), exe_anon_inode: false,