diff --git a/engine/src/engine/metrics.rs b/engine/src/engine/metrics.rs index a001f99..7b29351 100644 --- a/engine/src/engine/metrics.rs +++ b/engine/src/engine/metrics.rs @@ -49,6 +49,13 @@ pub(super) struct EngineMetrics { /// predicate is set (ADR-0009). In shadow this is the countable answer to "would this /// have promoted?" without any behavior change. pub(super) corroborations: opentelemetry::metrics::Counter, + /// Narrowing-delta count this pass (ADR-0041 §6): breach-relevant chains where a notable + /// exec is present on the entry's runtime but the chain is `!corroborated` — the cases the + /// retiring blanket notable-exec corroboration arm would have flipped to `corroborated`. + /// The Falco-retirement parity-matrix input. No labels — the count alone is the bake + /// signal, and any per-entry/per-node dimension would be a cardinality/PII vector (the + /// entry name still appears in the accompanying structured log, never in this counter). + pub(super) narrowing_delta: opentelemetry::metrics::Counter, /// Per-pass adjudications that issued a fresh model call (verdict-cache miss). A /// proper cumulative counter (replaces the prior `verdicts{verdict="judged_this_pass"}` /// gauge hack) so model-call frequency is rate-able. @@ -198,6 +205,13 @@ impl EngineMetrics { .u64_counter("protector.engine.corroborations") .with_description("Corroborations fired (corroborated breach chains) per pass.") .build(), + narrowing_delta: m + .u64_counter("protector.engine.narrowing_delta") + .with_description( + "Breach chains with a notable exec present but uncorroborated (ADR-0041 \ + §6) — the retiring blanket notable-exec corroboration arm's parity delta.", + ) + .build(), judged: m .u64_counter("protector.engine.judged") .with_description("Adjudications that issued a fresh model call (cache miss).") diff --git a/engine/src/engine/mod.rs b/engine/src/engine/mod.rs index 200beee..60eb159 100644 --- a/engine/src/engine/mod.rs +++ b/engine/src/engine/mod.rs @@ -74,6 +74,12 @@ use metrics::EngineMetrics; // and the dashboard's read-only view can share its record/class types. pub mod cut_divergence; +// The narrowing-delta comparator (ADR-0041 §6): the Falco-retirement parity-matrix input — +// counts breach-relevant chains where a notable exec is present on the entry's runtime but the +// chain is uncorroborated (the cases the retiring blanket notable-exec corroboration arm would +// have flipped). View only — see the module docs; mirrors `cut_divergence`'s shape. +mod narrowing_delta; + // The ADJ-MISS-DIAG re-judge diagnostic, extracted to keep this orchestrator under // the file-size cap (CLAUDE.md). Emits the compact, per-section-fingerprinted line the churn // harness ingests. @@ -592,6 +598,18 @@ impl Engine { if corroborations > 0 { self.metrics.corroborations.add(corroborations, &[]); } + // Narrowing-delta comparator (ADR-0041 §6): the Falco-retirement parity-matrix input. + // VIEW ONLY — reads `graph`/`chains`, feeds nothing back into adjudication, the ledger, + // or the arming state; see the module docs. + let narrowing_delta = narrowing_delta::compute(&graph, &chains); + if !narrowing_delta.is_empty() { + self.metrics + .narrowing_delta + .add(narrowing_delta.len() as u64, &[]); + } + for record in &narrowing_delta { + record.emit(); + } // Publish this pass's behavioral-bake snapshot into the output state. Done // here, before the slow adjudication loop, for the same reason the findings are: // the bake snapshot must reflect the current pass even while the model is judging. diff --git a/engine/src/engine/narrowing_delta.rs b/engine/src/engine/narrowing_delta.rs new file mode 100644 index 0000000..2906218 --- /dev/null +++ b/engine/src/engine/narrowing_delta.rs @@ -0,0 +1,86 @@ +//! The narrowing-delta comparator (ADR-0041 §6): a read-only bake instrument that counts the +//! cases the retiring blanket notable-exec corroboration arm would have flipped to +//! `corroborated` — a breach-relevant chain whose entry carries a **notable exec** (an +//! interactive shell or package-manager `ProcessExec`, +//! [`crate::engine::observe::exec_class::notable_exec`]) on its on-pod runtime, but whose +//! chain is `!corroborated` today. This is the Falco-retirement parity-matrix input: the +//! recall the `Behavior::Alert` arm currently backstops (see +//! `docs/adr/0041-narrow-blanket-notable-exec-corroboration.md` §6). +//! +//! **View only** (ADR-0016 — presentation is a view, never a gate): [`compute`] takes its +//! inputs by shared reference and returns owned data; there is no path from this module back +//! into the ledger, the actuator, or the arming state. Mirrors +//! [`crate::engine::cut_divergence`]'s read-only-bake shape (ADR-0037), scoped to a single +//! predicate rather than a cut-set comparison. +//! +//! Before the narrowing this counter measures lands (a separate change), a bare notable exec +//! still corroborates via the blanket arm, so `compute` reads empty in practice — expected, +//! not a bug: the counter is a bake instrument for the AFTER state, and its unit tests +//! construct both states directly rather than depending on the narrowing to land first. + +use crate::engine::graph::{Node, NodeKey, SecurityGraph}; +use crate::engine::observe::exec_class; +use crate::engine::reason::proof::ProvenChain; + +/// One breach-relevant chain where the old blanket notable-exec corroboration arm would have +/// flipped `corroborated`, but the chain is `!corroborated` today. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NarrowingDeltaRecord { + /// The internet-facing entry carrying the notable exec. + pub entry: String, + /// The objective this chain targets. + pub objective: String, + /// The ATT&CK technique this chain's objective achieves — a fixed internal string + /// (never untrusted input), safe to log. + pub technique_id: &'static str, +} + +impl NarrowingDeltaRecord { + /// Log this record as a structured line — the parity-matrix / bake-review artifact + /// (ADR-0041 §6). A view-only side effect: logging never mutates anything else. + pub fn emit(&self) { + tracing::info!( + entry = %self.entry, + objective = %self.objective, + technique = self.technique_id, + "narrowing delta: notable exec present, chain uncorroborated" + ); + } +} + +/// Whether `entry`'s on-pod runtime carries a notable exec — an interactive shell or +/// package-manager `ProcessExec` ([`exec_class::notable_exec`]). Non-workload nodes, and an +/// entry key the graph no longer carries, are never notable. Deliberately scoped to +/// `notable_exec` rather than the broader +/// [`crate::engine::observe::alarm_class::is_alarming_now`] family: an `Alert` or an alarming +/// file write still corroborates through their OWN (unnarrowed) arms, so only the notable-exec +/// shape is the narrowing's delta. +fn entry_has_notable_exec(graph: &SecurityGraph, entry: &NodeKey) -> bool { + graph.index_of(entry).is_some_and(|idx| { + matches!( + graph.inner().node_weight(idx), + Some(Node::Workload(w)) + if w.runtime.iter().any(|s| exec_class::notable_exec(&s.behavior).is_some()) + ) + }) +} + +/// Compute this pass's narrowing-delta records: every breach-relevant chain whose entry has a +/// notable exec in its runtime AND whose chain is `!corroborated`. Pure: reads +/// `graph`/`chains`, returns owned records; mutates neither. +pub fn compute(graph: &SecurityGraph, chains: &[ProvenChain]) -> Vec { + chains + .iter() + .filter(|c| c.is_breach_relevant() && !c.corroborated) + .filter(|c| entry_has_notable_exec(graph, &c.entry)) + .map(|c| NarrowingDeltaRecord { + entry: c.entry.0.clone(), + objective: c.objective.0.clone(), + technique_id: c.attack.technique_id, + }) + .collect() +} + +#[cfg(test)] +#[path = "narrowing_delta_tests.rs"] +mod tests; diff --git a/engine/src/engine/narrowing_delta_tests.rs b/engine/src/engine/narrowing_delta_tests.rs new file mode 100644 index 0000000..71cee51 --- /dev/null +++ b/engine/src/engine/narrowing_delta_tests.rs @@ -0,0 +1,128 @@ +//! Tests for the ADR-0041 §6 narrowing-delta comparator, kept in their own `*_tests.rs` file +//! (repo CLAUDE.md: tests count toward the 1,000-line cap). Both fixtures build the +//! corroborated/uncorroborated `ProvenChain` states DIRECTLY (rather than routing through +//! `reason::proof::corroborate::corroborated_for`), per the ticket's note: the current blanket +//! notable-exec arm corroborates every objective on ANY entry with a shell/pkg-mgr exec, so a +//! real uncorroborated-with-notable-exec breach-relevant chain can't be produced from the +//! live pipeline until the narrowing (a separate change) lands. `compute` itself only reads +//! `entry`/`objective`/`attack`/`exposed_entry`/`corroborated`, so hand-building the chain +//! exercises exactly what it reads without depending on that narrowing. + +use super::*; +use crate::engine::graph::attack::CREDENTIAL_ACCESS; +use crate::engine::graph::{ + Behavior, Exposure, Node, NodeKey, Provenance, RuntimeSignal, Workload, +}; + +/// A `web` entry workload whose runtime carries a shell exec — optionally alongside an +/// in-window internet egress, mirroring the reverse-shell shape's evidence without needing +/// the shape's own window-matching logic (irrelevant to this comparator, which only reads +/// `ProvenChain::corroborated`). +fn web_entry_graph(with_egress: bool) -> SecurityGraph { + let mut graph = SecurityGraph::new(); + let mut runtime = vec![RuntimeSignal { + behavior: Behavior::ProcessExec { + path: "/bin/bash".into(), + exe_anon_inode: false, + }, + provenance: Provenance::new("agent", std::time::SystemTime::UNIX_EPOCH), + }]; + if with_egress { + runtime.push(RuntimeSignal { + behavior: Behavior::NetworkConnection { + peer: "1.2.3.4:4444".into(), + internet: true, + }, + provenance: Provenance::new("agent", std::time::SystemTime::UNIX_EPOCH), + }); + } + graph.upsert_node(Node::Workload(Workload { + namespace: "app".into(), + name: "web".into(), + kind: "Pod".into(), + labels: Default::default(), + meshed: false, + exposure: Exposure::Internet, + runtime, + persistent: false, + misconfigs: Vec::new(), + rbac_findings: Vec::new(), + })); + graph +} + +/// A breach-relevant chain over the `web` entry, `corroborated` set directly by the caller — +/// the state under test, independent of how corroboration is actually derived. +fn web_chain(corroborated: bool) -> ProvenChain { + ProvenChain { + entry: NodeKey::workload("app", "Pod", "web"), + objective: NodeKey::workload("app", "Secret", "session-key"), + attack: CREDENTIAL_ACCESS, + foothold: None, + corroborated, + adjudicated: true, + promoted: false, + exposed_entry: true, + verdict: None, + links: Vec::new(), + paths: Vec::new(), + paths_truncated: false, + single_edge_cuts: Vec::new(), + quarantine_targets: Vec::new(), + } +} + +/// Shell exec present, chain uncorroborated (no in-window egress — the plain bare-shell case, +/// ADR-0011's on-call-engineer false positive under the narrowed shapes): the counter fires — +/// this IS one of the cases the old blanket arm would have corroborated. +#[test] +fn shell_exec_uncorroborated_chain_fires() { + let graph = web_entry_graph(false); + let chains = vec![web_chain(false)]; + + let records = compute(&graph, &chains); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].entry, "workload/app/Pod/web"); + assert_eq!(records[0].objective, "workload/app/Secret/session-key"); + assert_eq!(records[0].technique_id, CREDENTIAL_ACCESS.technique_id); +} + +/// Shell exec present, chain corroborated (e.g. the in-window egress shape fired): NOT a +/// narrowing delta — the chain would be corroborated either way, so the old blanket arm's +/// recall isn't backstopping anything here. No fire. +#[test] +fn shell_exec_corroborated_chain_does_not_fire() { + let graph = web_entry_graph(true); + let chains = vec![web_chain(true)]; + + let records = compute(&graph, &chains); + + assert!(records.is_empty()); +} + +/// A notable exec absent from the entry's runtime never fires, corroborated or not — the +/// comparator is scoped to the notable-exec shape, not every uncorroborated chain. +#[test] +fn no_notable_exec_never_fires() { + let graph = SecurityGraph::new(); + let chains = vec![web_chain(false)]; + + let records = compute(&graph, &chains); + + assert!(records.is_empty()); +} + +/// A notable exec on an entry whose chain is NOT breach-relevant (an internal-only entry) +/// never fires — the parity matrix only concerns internet-facing paths (matches the scope of +/// the `corroborations` metric it sits beside). +#[test] +fn non_breach_relevant_entry_never_fires() { + let graph = web_entry_graph(false); + let mut chain = web_chain(false); + chain.exposed_entry = false; + + let records = compute(&graph, &[chain]); + + assert!(records.is_empty()); +}