Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions engine/src/engine/reason/proof/corroborate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand All @@ -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
Expand Down
194 changes: 194 additions & 0 deletions engine/src/engine/reason/proof/corroborate_drop_exec_tests.rs
Original file line number Diff line number Diff line change
@@ -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"),
));
}
2 changes: 2 additions & 0 deletions engine/src/engine/reason/proof/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down