Skip to content
Open
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
14 changes: 14 additions & 0 deletions engine/src/engine/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
/// 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<u64>,
/// 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.
Expand Down Expand Up @@ -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).")
Expand Down
18 changes: 18 additions & 0 deletions engine/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions engine/src/engine/narrowing_delta.rs
Original file line number Diff line number Diff line change
@@ -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<NarrowingDeltaRecord> {
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;
128 changes: 128 additions & 0 deletions engine/src/engine/narrowing_delta_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}