diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 1f0c49f..0dd75f1 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -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, } @@ -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 @@ -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, @@ -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 @@ -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. diff --git a/engine/src/engine/reason/proof/corroborate_privesc_tests.rs b/engine/src/engine/reason/proof/corroborate_privesc_tests.rs new file mode 100644 index 0000000..523d260 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_privesc_tests.rs @@ -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"), + )); +} diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 19ae2fb..023a6c0 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -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(), @@ -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;