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
71 changes: 55 additions & 16 deletions engine/src/engine/reason/proof/corroborate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ use petgraph::stable_graph::NodeIndex;
use crate::engine::graph::attack::AttackRef;
use crate::engine::graph::{Behavior, Node, RuntimeSignal, SecurityGraph};

/// The context the entry workload provides to the corroboration predicate (JEF-319). The
/// flat per-behavior [`corroborates`] relation is context-free on purpose (regression-safe);
/// the entry-scoped cross-tenant-lateral shape below needs MORE than `behavior + attack`,
/// so it reads the entry's own namespace and foothold status from here.
/// The context the entry workload provides to the corroboration predicate (JEF-319, JEF-314).
/// The flat per-behavior [`corroborates`] relation is context-free on purpose
/// (regression-safe); the entry-scoped shapes below (cross-tenant lateral, privilege
/// escalation) need MORE than `behavior + attack`, so they read the entry's own namespace
/// and foothold status from here.
///
/// The shape is scoped to a real internet-facing entry so an ordinary cross-namespace service
/// call from a non-entry pod never corroborates (ADR-0011 / ADR-0014 conservatism).
/// `source_ns` is the entry workload's namespace; `is_foothold` is true only when the entry
/// is a proven internet-facing foothold (a critical/KEV front door).
/// Both shapes are scoped to a real internet-facing entry so an ordinary cross-namespace
/// service call — or an ordinary setuid — from a non-entry pod never corroborates
/// (ADR-0011 / ADR-0014 conservatism). `source_ns` is the entry workload's namespace;
/// `is_foothold` is true only when the entry is a proven internet-facing foothold (a
/// critical/KEV front door).
#[derive(Clone, Copy)]
pub(super) struct EntryContext<'a> {
/// The entry workload's own namespace — the SOURCE side of the cross-tenant comparison.
pub source_ns: &'a str,
/// Whether the entry is a proven internet-facing foothold (ADR-0009): the gate that
/// scopes both new shapes to a real front door, not any workload.
/// scopes every entry-scoped shape to a real front door, not any workload.
pub is_foothold: bool,
}

Expand Down Expand Up @@ -100,7 +102,11 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool {
}
// PrivilegeChange is NON-corroborating here: model evidence, not a per-objective
// "now" signal (legit entrypoints escalate too — the same ADR-0011 false positive).
// Wiring it into a specific attack chain would be a JEF-49-style follow-up.
// Context-free root escalation stays this way for ANY pod on purpose: a root
// escalation on the proven internet-facing foothold DOES corroborate a
// PrivilegeEscalation objective, but that needs the entry's foothold status, which
// this flat relation doesn't have — so that shape lives at the entry-scoped seam
// ([`privilege_escalation_on_foothold`]), not here (JEF-314).
Behavior::PrivilegeChange { .. } => false,
// An *alarming* FileWrite — a sensitive-path / drop-and-execute / config-tamper drift
// write (JEF-309) — corroborates ANY objective like an Alert / notable exec does: a
Expand Down Expand Up @@ -138,13 +144,16 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool {
/// chain is unaffected.
///
/// 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 the entry-scoped
/// **cross-tenant lateral** shape JEF-319 adds fires: a connection from the entry to a peer
/// in a DIFFERENT namespace ([`cross_tenant_lateral`]), scoped to a proven foothold entry.
/// 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
/// 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.
///
/// That shape stays OFF the flat egress predicate: ordinary internet egress and ordinary
/// in-cluster traffic still corroborate nothing (ADR-0011). Like every arm here this only
/// sets `corroborated`; it never actuates (shadow-gated, ADR-0014).
/// 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
/// (shadow-gated, ADR-0014).
pub(super) fn corroborated_for(
runtime: &[RuntimeSignal],
attack: &AttackRef,
Expand All @@ -154,6 +163,7 @@ pub(super) fn corroborated_for(
runtime.iter().any(|s| {
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)
}

/// The cross-tenant lateral-movement shape (JEF-319): a `NetworkConnection` from the entry to
Expand All @@ -177,6 +187,35 @@ pub(super) fn cross_tenant_lateral(runtime: &[RuntimeSignal], entry: EntryContex
})
}

/// 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
/// Falco fires critical on, here scoped to close the parity gap without the false positive
/// Falco doesn't guard against.
///
/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`cross_tenant_lateral`]:
/// corroborates ONLY when the entry is a proven internet-facing foothold (`entry.is_foothold`)
/// AND `attack.tactic` is `PrivilegeEscalation`. The flat [`corroborates`] relation
/// deliberately leaves `PrivilegeChange` non-corroborating everywhere (legit entrypoints and
/// init processes escalate to root on ordinary pods constantly — the ADR-0011 false
/// positive); gating on the proven foothold is what makes a root escalation there mean
/// something a routine setuid on an unrelated pod does not: the attacker who owns the
/// internet-facing front door escalating on that SAME workload.
pub(super) fn privilege_escalation_on_foothold(
runtime: &[RuntimeSignal],
attack: &AttackRef,
entry: EntryContext<'_>,
) -> bool {
use crate::engine::graph::attack::Tactic;
if !entry.is_foothold || attack.tactic != Tactic::PrivilegeEscalation {
return false;
}
runtime.iter().any(|s| match &s.behavior {
Behavior::PrivilegeChange { from_uid, to_uid } => *from_uid != 0 && *to_uid == 0,
_ => 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.
Expand Down
133 changes: 133 additions & 0 deletions engine/src/engine/reason/proof/corroborate_privesc_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Tests for the JEF-314 entry-scoped corroboration shape — privilege escalation 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.
//!
//! The shape closes the Falco-parity gap: Falco fires critical on setuid→root, but the flat
//! `corroborates(PrivilegeChange, _)` relation stays non-corroborating everywhere (ADR-0011:
//! legit entrypoints/inits escalate to root on ordinary pods constantly). This shape is
//! scoped to a proven internet-facing foothold entry ONLY, so an ordinary pod's routine
//! setuid still never corroborates — it is shadow-gated (only sets `corroborated`, never
//! actuates) like every arm here.

use std::time::{Duration, SystemTime};

use super::corroborate::{EntryContext, corroborated_for, privilege_escalation_on_foothold};
use crate::engine::graph::Provenance;
use crate::engine::graph::attack::{AttackRef, 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 priv_change(from_uid: u32, to_uid: u32) -> Behavior {
Behavior::PrivilegeChange { from_uid, to_uid }
}

/// 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: a PrivilegeEscalation-tactic chain (T1611 Escape to Host).
fn priv_esc_objective() -> AttackRef {
ESCAPE_TO_HOST
}

// ---- Positive: root escalation on the foothold entry — end to end -----------------------

#[test]
fn root_escalation_on_the_foothold_entry_corroborates_priv_esc() {
// setuid non-root -> root on the proven internet-facing foothold IS the Falco-parity
// signal: the attacker who owns the front door escalating on that same workload.
let runtime = [sig(priv_change(1000, 0), 0)];
assert!(corroborated_for(
&runtime,
&priv_esc_objective(),
None,
foothold_entry("frontend"),
));
// And the predicate directly.
assert!(privilege_escalation_on_foothold(
&runtime,
&priv_esc_objective(),
foothold_entry("frontend"),
));
}

// ---- Negative: same escalation, non-foothold entry ---------------------------------------

#[test]
fn root_escalation_on_a_non_foothold_entry_does_not_corroborate() {
// The SAME setuid, but the entry is an ordinary pod — a legit entrypoint/init dropping
// privileges (or escalating) on an unrelated workload must NOT corroborate (ADR-0011).
let runtime = [sig(priv_change(1000, 0), 0)];
assert!(!corroborated_for(
&runtime,
&priv_esc_objective(),
None,
ordinary_entry("frontend"),
));
assert!(!privilege_escalation_on_foothold(
&runtime,
&priv_esc_objective(),
ordinary_entry("frontend"),
));
}

// ---- Regression guards: don't widen past root escalation / past PrivilegeEscalation ------

#[test]
fn non_root_privilege_change_on_the_foothold_does_not_corroborate() {
// A privilege change that does NOT land on uid 0 (e.g. dropping from root to a service
// account, or moving between two non-root uids) is not the setuid-to-root signal Falco
// fires on, even on the foothold entry.
let runtime = [
sig(priv_change(0, 1000), 0),
sig(priv_change(1000, 1001), 5),
];
assert!(!privilege_escalation_on_foothold(
&runtime,
&priv_esc_objective(),
foothold_entry("frontend"),
));
}

#[test]
fn root_escalation_on_the_foothold_does_not_corroborate_an_unrelated_objective() {
// The shape only lights up a PrivilegeEscalation-tactic objective — it must not blanket-
// corroborate a CredentialAccess chain just because the entry is a foothold.
let runtime = [sig(priv_change(1000, 0), 0)];
assert!(!corroborated_for(
&runtime,
&CREDENTIAL_ACCESS,
None,
foothold_entry("frontend"),
));
assert!(!privilege_escalation_on_foothold(
&runtime,
&CREDENTIAL_ACCESS,
foothold_entry("frontend"),
));
}
9 changes: 6 additions & 3 deletions engine/src/engine/reason/proof/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,10 @@ pub fn prove_with(
// once here rather than re-looking-up the node inside `corroborated_for` per
// objective.
let runtime = entry_runtime(graph, entry);
// The entry-scoped context for the two JEF-319 corroboration shapes (cross-tenant
// lateral, reverse-shell): the entry's own namespace and whether it's a proven
// internet-facing foothold. Constant across objectives, so build it once.
// The entry-scoped context for the entry-scoped corroboration shapes (JEF-319
// cross-tenant lateral, JEF-314 privilege escalation on the foothold): the entry's
// own namespace and whether it's a proven internet-facing foothold. Constant across
// objectives, so build it once.
let entry_ctx = EntryContext {
source_ns: entry_namespace(graph, entry),
is_foothold: foothold.is_some(),
Expand Down Expand Up @@ -354,6 +355,8 @@ mod corroborate_context_tests;
#[cfg(test)]
mod corroborate_objective_tests;
#[cfg(test)]
mod corroborate_privesc_tests;
#[cfg(test)]
mod corroborate_tests;
#[cfg(test)]
mod tests;