diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 0dd75f1..0b2667d 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -4,6 +4,8 @@ //! `corroborated`; they never actuate. `corroborates` is the per-objective seam the ADR //! is stated in terms of; `corroborated_for` resolves it for an entry's signals. +use std::time::Duration; + use petgraph::stable_graph::NodeIndex; use crate::engine::graph::attack::AttackRef; @@ -146,13 +148,16 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { /// A chain is corroborated if EITHER the context-free per-behavior relation holds for any /// signal (the objective's tactic OR the foothold's tactic, JEF-77) OR one of the /// entry-scoped shapes fires: **cross-tenant lateral** (JEF-319) — a connection from the -/// entry to a peer in a DIFFERENT namespace ([`cross_tenant_lateral`]) — or **privilege +/// 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`]). Both are scoped to a proven foothold entry. +/// ([`privilege_escalation_on_foothold`]) — or **drop-then-execute** (JEF-321) — a +/// `ProcessExec` of a path a RECENT `FileWrite` dropped ([`drop_then_execute`]). All three +/// are scoped to a proven foothold entry. /// -/// Neither shape widens the flat predicates it sits beside: ordinary internet egress, -/// ordinary in-cluster traffic, and an ordinary setuid all still corroborate nothing -/// (ADR-0011). Like every arm here this only sets `corroborated`; it never actuates +/// None of these shapes widens the flat predicates it sits beside: ordinary internet egress, +/// ordinary in-cluster traffic, an ordinary setuid, and an ordinary write-then-run of a +/// benign path 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], @@ -164,6 +169,7 @@ pub(super) fn corroborated_for( corroborates(&s.behavior, attack) || foothold.is_some_and(|f| corroborates(&s.behavior, f)) }) || cross_tenant_lateral(runtime, entry) || privilege_escalation_on_foothold(runtime, attack, entry) + || drop_then_execute(runtime, entry) } /// The cross-tenant lateral-movement shape (JEF-319): a `NetworkConnection` from the entry to @@ -187,6 +193,59 @@ pub(super) fn cross_tenant_lateral(runtime: &[RuntimeSignal], entry: EntryContex }) } +/// The narrowest gap a `FileWrite` and the `ProcessExec` of the SAME path can sit apart and +/// still read as one drop-then-execute act rather than coincidental path reuse (JEF-321): a +/// script dropped and immediately run is seconds to low minutes apart; the same path written +/// 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 drop-then-execute shape (JEF-321): 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 +/// `ProcessExec` is not a [`notable_exec`](crate::engine::observe::exec_class::notable_exec) and +/// an ordinary `/tmp` `FileWrite` is not an +/// [`alarming_write`](crate::engine::observe::alarm_class::alarming_write) — apps legitimately +/// write and run their own scripts. It's the CORRELATION of the two on the same path that is +/// the tell — and that needs cross-signal state the flat per-behavior [`corroborates`] relation +/// can't carry (it only ever sees one behavior at a time). +/// +/// **Where the cross-signal state lives:** the entry's own `runtime` slice — already a bounded, +/// time-windowed per-workload record of recent signals ([`RuntimeEvents`](crate::engine::observe::runtime::RuntimeEvents), +/// TTL'd + capped at `MAX_EVENTS`, resolved once per entry by [`entry_runtime`]) — IS the +/// "recent writes to match the exec against" this needs. No new store is introduced; this +/// function only correlates two entries already in that slice. [`DROP_EXEC_WINDOW`] narrows +/// "recent" further than the general runtime TTL (default 30 minutes) to the few minutes that +/// actually reads as one drop-and-run act, and requires the write to have happened AT OR BEFORE +/// the exec (an exec that merely precedes an unrelated later write of the same path is not +/// drop-then-execute). +/// +/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`cross_tenant_lateral`]: corroborates +/// ONLY when the entry is a proven internet-facing foothold. Apps legitimately write-then-run +/// scripts under `/tmp` constantly — scoping to the proven entry/foothold is what keeps that the +/// ADR-0011 on-call-engineer false positive, rather than turning every ordinary pod into a +/// corroboration source. +pub(super) fn drop_then_execute(runtime: &[RuntimeSignal], entry: EntryContext<'_>) -> bool { + if !entry.is_foothold { + return false; + } + runtime.iter().any(|exec| { + let Behavior::ProcessExec { path: exec_path } = &exec.behavior else { + return false; + }; + runtime.iter().any(|write| { + let Behavior::FileWrite { path: write_path } = &write.behavior else { + return false; + }; + write_path == exec_path + && exec + .provenance + .observed_at + .duration_since(write.provenance.observed_at) + .is_ok_and(|elapsed| elapsed <= DROP_EXEC_WINDOW) + }) + }) +} + /// The privilege-escalation-on-foothold shape (JEF-314): a `PrivilegeChange` non-root→root /// on the entry itself corroborates a PrivilegeEscalation-tactic objective (T1611 Escape to /// Host / T1098.006 RBAC self-escalation, and any future T1548-tactic technique) — the setuid diff --git a/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs b/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs new file mode 100644 index 0000000..953aeb1 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs @@ -0,0 +1,194 @@ +//! Tests for the JEF-321 entry-scoped corroboration shape — drop-then-execute — 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_context_tests.rs`'s cross-tenant-lateral suite. +//! +//! The shape is shadow-gated (it only sets `corroborated`, never actuates) and scoped to a +//! proven internet-facing foothold entry, so the classic "app writes then runs its own /tmp +//! script" pattern on an ordinary pod must NOT corroborate. It is tested BOTH ways — +//! end-to-end through `corroborated_for` (using an objective neither a bare `ProcessExec` nor +//! a benign `FileWrite` blanket-corroborates on its own, so a positive is attributable to the +//! drop-then-execute shape alone) and on `drop_then_execute` directly. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{DROP_EXEC_WINDOW, EntryContext, corroborated_for, drop_then_execute}; +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 write(path: &str, secs: u64) -> RuntimeSignal { + sig(Behavior::FileWrite { path: path.into() }, secs) +} + +fn exec(path: &str, secs: u64) -> RuntimeSignal { + sig(Behavior::ProcessExec { path: path.into() }, 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 the end-to-end `corroborated_for` tests: CREDENTIAL_ACCESS fires (flat +/// arm) only on a `SecretRead`, and these tests carry only `FileWrite`/`ProcessExec` signals, +/// so a positive is attributable to the drop-then-execute shape alone, never the flat arms. +const OBJECTIVE: crate::engine::graph::attack::AttackRef = CREDENTIAL_ACCESS; + +// ---- Drop-then-execute — end-to-end through corroborated_for -------------------------- + +#[test] +fn exec_of_a_recently_written_path_on_the_foothold_entry_corroborates() { + // /tmp/update.sh is dropped, then run seconds later on the proven foothold entry — the + // classic drop-then-execute pattern. + let runtime = [write("/tmp/update.sh", 0), exec("/tmp/update.sh", 5)]; + assert!(corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry("frontend"), + )); + assert!(drop_then_execute(&runtime, foothold_entry("frontend"))); +} + +#[test] +fn exec_with_no_matching_write_does_not_corroborate() { + // A bare exec with no write of the SAME path anywhere in the window — an ordinary + // entrypoint exec, the ADR-0011 on-call-engineer false positive. + let runtime = [exec("/usr/local/bin/app", 0)]; + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry("frontend"), + )); + assert!(!drop_then_execute(&runtime, foothold_entry("frontend"))); +} + +#[test] +fn write_and_exec_of_different_paths_does_not_corroborate() { + // A write to one path and an exec of a DIFFERENT path — no correlation, just two + // unrelated mundane behaviors. + let runtime = [write("/tmp/report.csv", 0), exec("/usr/local/bin/app", 5)]; + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry("frontend"), + )); + assert!(!drop_then_execute(&runtime, foothold_entry("frontend"))); +} + +#[test] +fn drop_then_execute_on_a_non_foothold_entry_does_not_corroborate() { + // The SAME drop-then-execute pair, but the entry is an ordinary pod, not a proven + // internet-facing foothold — apps legitimately write-then-run their own /tmp scripts + // constantly, so this must stay the FP-aware non-corroborating case. + let runtime = [write("/tmp/update.sh", 0), exec("/tmp/update.sh", 5)]; + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + ordinary_entry("frontend"), + )); + assert!(!drop_then_execute(&runtime, ordinary_entry("frontend"))); +} + +#[test] +fn a_write_long_after_the_exec_does_not_corroborate() { + // The exec happens BEFORE the write of the same path — an unrelated later write (e.g. a + // log the just-run process itself creates) is not drop-then-execute. + let runtime = [exec("/tmp/update.sh", 0), write("/tmp/update.sh", 5)]; + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry("frontend"), + )); + assert!(!drop_then_execute(&runtime, foothold_entry("frontend"))); +} + +// ---- Bounded: the DROP_EXEC_WINDOW TTL is exercised at its boundary ------------------- + +#[test] +fn exec_at_exactly_the_window_boundary_still_corroborates() { + let window_secs = DROP_EXEC_WINDOW.as_secs(); + let runtime = [ + write("/tmp/update.sh", 0), + exec("/tmp/update.sh", window_secs), + ]; + assert!( + drop_then_execute(&runtime, foothold_entry("frontend")), + "exec exactly DROP_EXEC_WINDOW after the write is still \"recent\" (<=, not <)" + ); +} + +#[test] +fn exec_past_the_window_does_not_corroborate() { + // The classic "coincidental path reuse hours later" case the ticket's acceptance + // criteria calls out: a write and an exec of the same path exist in the entry's runtime + // signal set, but far enough apart that they no longer read as one drop-and-run act. + let window_secs = DROP_EXEC_WINDOW.as_secs(); + let runtime = [ + write("/tmp/update.sh", 0), + exec("/tmp/update.sh", window_secs + 1), + ]; + assert!( + !corroborated_for(&runtime, &OBJECTIVE, None, foothold_entry("frontend")), + "outside the drop-exec window must NOT corroborate" + ); + assert!(!drop_then_execute(&runtime, foothold_entry("frontend"))); +} + +#[test] +fn bound_is_narrower_than_the_general_runtime_ttl() { + // The correlation window is deliberately TIGHTER than the general runtime-signal TTL + // (`observe::runtime::DEFAULT_RUNTIME_WINDOW_SECS`, 30 minutes) — two signals can both + // still be live in the entry's runtime slice while sitting further apart than a real + // drop-and-run act would. + assert!( + DROP_EXEC_WINDOW + < Duration::from_secs(crate::engine::observe::runtime::DEFAULT_RUNTIME_WINDOW_SECS), + "the drop-exec correlation window must be narrower than the general runtime TTL" + ); +} + +// ---- Regression guard: don't widen the flat per-behavior predicates ------------------- + +#[test] +fn an_unpaired_benign_write_and_bare_exec_together_still_do_not_corroborate() { + // A benign FileWrite (not a sensitive path) and a bare ProcessExec (not shell/pkg-mgr) of + // DIFFERENT paths on the foothold entry — neither the flat arms nor the drop-then-execute + // shape should fire; only a matching PATH within the window does. + let runtime = [write("/data/app.db", 0), exec("/usr/local/bin/worker", 1)]; + assert!(!corroborated_for( + &runtime, + &OBJECTIVE, + None, + foothold_entry("frontend"), + )); +} diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 023a6c0..bfc12f3 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -353,6 +353,8 @@ pub fn prove_with( #[cfg(test)] mod corroborate_context_tests; #[cfg(test)] +mod corroborate_drop_exec_tests; +#[cfg(test)] mod corroborate_objective_tests; #[cfg(test)] mod corroborate_privesc_tests;