diff --git a/docs/adr/0044-per-entity-labels-on-kev-reachable-metric.md b/docs/adr/0044-per-entity-labels-on-kev-reachable-metric.md new file mode 100644 index 0000000..a712e61 --- /dev/null +++ b/docs/adr/0044-per-entity-labels-on-kev-reachable-metric.md @@ -0,0 +1,115 @@ +# 0044. Per-entity labels on the KEV-reachable findings metric + +- Status: Accepted +- Date: 2026-11-08 +- Relates to: [0009](0009-asymmetric-action-bar.md) (the `corroborated` runtime signal this + metric is deliberately NOT keyed on), [0001](0001-async-mitigation-engine.md) / + [0014](0014-behavioral-telemetry-ebpf.md) (the KEV / exploited-in-wild predicate), and the + prior counts-only metric convention this reverses (`engine/src/engine/metrics.rs`) + +## Context + +Until now every OTLP instrument the engine emits was **counts-only**: a gauge or counter with, +at most, a low-cardinality variant label (`action`, `verdict`, `outcome`, a probe `reason`), and +never a label naming a specific workload, node, or image. The convention was written into the +instrument docstrings as a hard prohibition — a per-node or per-entry series "would be a +cardinality/DoS vector" because node/workload/image names are "attacker-influenceable." The +entity name was pushed into the accompanying structured log instead, and the metric carried only +the count. + +That rule guarded two things: unbounded time-series cardinality (one series per distinct label +tuple), and a denial-of-service shape where an attacker who could mint arbitrary workload/node +names could explode the metrics store. Both are real concerns **on a large or multi-tenant +cluster.** + +Protector runs on a **bounded, single-operator homelab**. The operator judged the rule +over-cautious there, for two reasons: + +1. **The threat it guards presupposes prior compromise.** Workload, node, and image names come + from the operator's own cluster. For an attacker to influence them — to drive cardinality or + forge a misleading label — they must already be creating or renaming Kubernetes objects in + this cluster. At that point the cardinality of a metric series is not the problem to solve; + the attacker is already inside. The bound that makes the guard unnecessary (a small, operator- + controlled namespace of names) is exactly the bound a large/multi-tenant cluster does not + have. + +2. **The response value is concrete and immediate.** A CVE that CISA KEV lists as + exploited-in-wild, reachable on an internet-facing (or proven-foothold) entry, is the single + highest-priority finding protector produces. Forcing the operator to cross-reference a bare + count against a separate structured log to learn *which* workload and image to patch costs + minutes at exactly the moment minutes matter. A per-finding labelled series routes the alert + straight to the workload. + +Weighed against each other for this deployment, rapid KEV response wins over a cardinality/DoS +guard whose precondition (an attacker who can forge cluster object names) already implies a +worse breach. + +## Decision + +**We will remove the "no per-entity labels on metrics" invariant and add a KEV-reachable +findings metric that carries per-finding entity labels.** + +1. **New counter `protector.engine.kev_reachable_findings`.** Incremented once per pass per + distinct entry workload that is both *reachable* — `is_breach_relevant()` (internet-facing + entry) OR `foothold.is_some()` (a proven exploitable front door) — and *KEV-backed*: its + entry image carries a `Vulnerability` with `exploited_in_wild` set (the CISA KEV catalogue + flag, `observe/exploit_intel.rs`). Emitted from the per-pass chain-fold loop in + `engine/src/engine/mod.rs`, beside the existing `breach_paths` / `corroborations` snapshot. + +2. **Labels:** `k8s.namespace.name`, `k8s.workload.kind`, `k8s.workload.name` (all parsed from + the entry `workload///` key), `image.reference` (the entry's deployed image + reference(s), via a new `SecurityGraph::entry_image_reference` walk over the `RunsImage` + edge), and `cve.ids` (the entry image's exploited-in-wild CVE ids, sorted and joined into one + value). These labels are **attacker-influenceable by design** — that is the protection this + ADR removes, accepted for the homelab bound. + +3. **"KEV" is `exploited_in_wild`, not `corroborated`.** This metric's gate is the static CISA + KEV catalogue flag. It is unrelated to [ADR-0009](0009-asymmetric-action-bar.md)'s runtime + `corroborated` signal, which the separate `protector.engine.corroborations` counter tracks. + The two predicates must not be conflated: a finding can be KEV-reachable with no live runtime + corroboration (the latent-foothold case) and vice versa. + +4. **Series shape, chosen to avoid gratuitous blowup WITHOUT reinstating the removed guard.** + The counter fires once per distinct entry workload per pass, de-duplicated by entry key. An + entry yields one chain per reachable objective, but every label on this metric is + entry-scoped, so counting each objective-chain would only inflate a single series' value, not + add information — the dedupe keeps the series count equal to the number of KEV-reachable + workloads. Likewise, the several exploited-in-wild CVE ids on one entry are joined into a + single `cve.ids` value rather than fanned out into one series per CVE. This is a + series-hygiene choice, **not** a cardinality cap or an entity-name guard; it does not limit + how many distinct workloads may appear. + +## Consequences + +- An operator watching `/metrics` (or an alert rule) sees KEV-reachable findings broken down by + the exact workload, image, and CVE — the fast path from "a KEV CVE is reachable" to "patch + *this* image on *this* workload," with no structured-log cross-reference. +- The engine now emits at least one metric whose label values are attacker-influenceable. On + this homelab that is bounded by the operator-controlled name space (see Context); the + structured-log-only discipline no longer applies to new instruments, and the counts-only + docstrings that stated the prohibition are reworded to say per-entity labels are permitted + (the coverage/narrowing-delta gauges stay counts-only because *they* have no use for an entity + label, not because one is forbidden). +- **Revisit trigger — large or multi-tenant deployment.** The entire justification rests on the + single-operator, bounded-name-space bound. If protector is ever deployed on a cluster where + workload/node/image names are not under one trusted operator's control — a shared or + multi-tenant cluster — the cardinality/DoS guard this ADR removed becomes load-bearing again, + and this metric's entity labels (and any that followed it) must be re-gated: bound the series + (e.g. a top-N or a cardinality cap), drop to counts-only with the entity in a log, or + otherwise constrain the attacker-influenceable dimension. This is a deployment-shape decision, + recorded here so the reversal is not silently inherited. +- **Residual worth noting (a consideration, not a control):** even on the homelab the series set + grows with the number of distinct KEV-reachable workloads over the exporter's retention + window. For this cluster's scale that is a handful of series and a non-issue; were it to grow, + a top-N or a cardinality cap would be the place to bound it. It is deliberately NOT added here + — adding it now would reinstate the very protection this ADR removes. + +## References + +- `engine/src/engine/metrics.rs` — the instrument (`kev_reachable_findings`) and its + `record_kev_reachable_finding` recorder. +- `engine/src/engine/mod.rs` — the per-pass emission in the chain-fold loop. +- `engine/src/engine/graph/mod.rs` — `entry_image_reference` (the `RunsImage` image-ref walk) + and `NodeKey::workload_kind` (the kind-segment accessor) added for the labels. +- [ADR-0009](0009-asymmetric-action-bar.md) — the runtime `corroborated` signal this metric is + explicitly NOT keyed on. diff --git a/docs/adr/README.md b/docs/adr/README.md index a8ecb52..3af146e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -49,5 +49,6 @@ Copy [`0000-template.md`](0000-template.md) to start one. | [0041](0041-narrow-blanket-notable-exec-corroboration.md) | Narrow the blanket notable-exec corroboration arm to shapes: a bare interactive-shell/pkg-mgr `ProcessExec` stops blanket-corroborating every objective (`Behavior::ProcessExec → false` in the flat arm — symmetric with PrivilegeChange/PtraceAttach/ModuleLoad, restoring the 0011 on-call-engineer FP guard), and exec-based corroboration re-homes in an entry-scoped `reverse_shell_on_foothold` shape (interactive-shell exec + internet NetworkConnection within a symmetric 60s window, foothold-gated) — unmasking the deferred reverse-shell shape per 0024. Corrects the framing: the model never sees `corroborated` (removed from the prompt by 0034); narrowing buys deterministic auto-apply-gate precision + honest latent/live presentation + 0024 unmasking, NOT model-input precision. Safe **now** because Falco's `Alert` arm still backstops (`falco.enabled: true`), pre-arming in `audit`. One real behavior change (inert under audit): a shell-only + decisive-attack incident becomes a proposal not an auto-cut in enforce (0011-correct). Package managers excluded (always egress); `is_alarming_now`/menu seeding untouched; a narrowing-delta counter feeds the retire-Falco parity bake (0037). Amends 0009's action-bar input | Proposed | | [0042](0042-flag-remaining-consumer-count-on-consumer-removal.md) | Flag a shared layer's remaining live-consumer count when a PR removes a consumer: a consumer-removal PR states the layer's remaining live-consumer count in one line, and a drop-to-one is flagged in review as a refactor smell rather than merged silently — the discipline PR #189's shared model-completion cache and PR #190's hypothesis-stage deletion needed (their conjunction, invisible to either single-PR review, left a redundant, `Uncertain`-pinning cache that PR #191 had to revert). Extends 0024's single-PR redundant-by-construction rule across a two-PR conjunction | Accepted | | [0043](0043-internal-serviceaccount-token-auth-on-dashboard-read.md) | Accept an internal Kubernetes ServiceAccount-token on the dashboard read surface, IN ADDITION TO the edge Cloudflare Access path (0030): an in-cluster caller (the cluster repo's `fixer`), authorized by its SA identity, reads `/api/*.json` with its projected SA-token JWT (cluster issuer, RS256, required `aud`, `sub` allowlist) instead of an edge Access JWT it cannot mint in-cluster. Pure addition + read-only: reuses the 0030 `Verifier` (alg-pinned, iss/aud/exp/nbf/sub) pointed at the cluster JWKS via a `ClusterJwksFetcher` (trusts the cluster CA, presents protector's own SA token read fresh per fetch); one gate, two lanes tried in order — a verified+allowlisted token admits read-only at the floor `Redacted` tier, a verified-but-non-allowlisted one is a hard 401 (never a 302 or fall-through), an unverifiable one falls through to the unchanged edge path. FAILS CLOSED (empty allowlist / unreadable CA / JWKS-unreachable ⇒ the lane admits no one; edge untouched). Rides the Oidc-mode Enforcer, so it needs the edge issuer set. Extends 0030; read-only sibling of 0031 | Accepted | +| [0044](0044-per-entity-labels-on-kev-reachable-metric.md) | Remove the "no per-entity labels on metrics" invariant and add `protector.engine.kev_reachable_findings`, a per-pass counter carrying per-finding entity labels (`k8s.namespace.name` / `k8s.workload.kind` / `k8s.workload.name` / `image.reference` / `cve.ids`) for every reachable chain (internet-facing OR proven-foothold) whose entry image runs a CISA-KEV exploited-in-wild CVE. Operator decision: the old counts-only rule (entity names are attacker-influenceable ⇒ a cardinality/DoS vector) is over-cautious for this bounded single-operator homelab — forging those names already implies cluster compromise, and routing a KEV hit straight to the workload/image outweighs the guard. "KEV" is `exploited_in_wild`, NOT the runtime `corroborated` signal (0009). One series per distinct entry workload per pass (labels are entry-scoped) and CVE ids joined into one `cve.ids` value — series hygiene, not a reinstated cap. **Revisit on any large/multi-tenant deployment**: there the bound fails and the removed guard is load-bearing again. Reverses the prior metrics convention | Accepted | See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes. diff --git a/engine/src/engine/graph/mod.rs b/engine/src/engine/graph/mod.rs index 4cc7809..abea84a 100644 --- a/engine/src/engine/graph/mod.rs +++ b/engine/src/engine/graph/mod.rs @@ -26,7 +26,7 @@ pub mod attack; pub mod delta; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::SystemTime; use petgraph::stable_graph::{NodeIndex, StableGraph}; @@ -168,6 +168,17 @@ impl NodeKey { } } + /// The Kubernetes workload kind of a workload key (`workload///`) — + /// the owning kind as observed (`Pod`, `Deployment`, `DaemonSet`, …). `None` for any + /// non-workload or malformed key. Distinct from [`NodeKey::kind`], which names the + /// NODE kind (the literal `workload` discriminant), not the Kubernetes kind. + pub fn workload_kind(&self) -> Option<&str> { + let mut parts = self.0.split('/'); + (parts.next()? == "workload") + .then(|| parts.nth(1)) + .flatten() + } + /// Everything after the kind prefix — the human-facing remainder of the key with the /// leading `/` stripped (e.g. `app/Pod/web` for `workload/app/Pod/web`). Falls /// back to the whole key when there is no `/`. Used for compact labels where the kind is @@ -791,6 +802,26 @@ impl SecurityGraph { } (exposed_secrets, misconfigs, rbac_findings) } + + /// The deployed reference(s) of the image(s) an `entry` workload runs, followed across + /// its `RunsImage` edges (the same walk [`entry_evidence`](Self::entry_evidence) uses) — + /// for human narration and the per-finding telemetry label. Distinct references are + /// de-duplicated and joined with `,` (a multi-container pod can run several); `None` when + /// the key is unknown, the node is not a workload, or no image carried a reference + /// (references are human-narration-only and may be absent, see [`Image::reference`]). + pub fn entry_image_reference(&self, entry_key: &NodeKey) -> Option { + let entry = self.index_of(entry_key)?; + let mut refs: BTreeSet<&str> = BTreeSet::new(); + for edge in self.graph.edges(entry) { + if matches!(edge.weight().relation, Relation::RunsImage) + && let Some(Node::Image(image)) = self.graph.node_weight(edge.target()) + && let Some(reference) = image.reference.as_deref() + { + refs.insert(reference); + } + } + (!refs.is_empty()).then(|| refs.into_iter().collect::>().join(",")) + } } #[cfg(test)] diff --git a/engine/src/engine/metrics.rs b/engine/src/engine/metrics.rs index 7b29351..f7bee52 100644 --- a/engine/src/engine/metrics.rs +++ b/engine/src/engine/metrics.rs @@ -2,6 +2,10 @@ //! CLAUDE.md). A cohesive unit: the counters/gauges/histograms the engine records against the //! global meter, and their one-shot construction. `pub(super)` so the engine core reads the fields. +use std::collections::BTreeSet; + +use crate::engine::graph::{NodeKey, SecurityGraph}; +use crate::engine::reason::proof::ProvenChain; use crate::engine::state::RuntimeCoverage; /// OTLP instruments for the engine, recorded against the global meter (see @@ -19,6 +23,20 @@ pub(super) struct EngineMetrics { pub(super) chains: opentelemetry::metrics::Gauge, /// Breach-relevant findings (internet-facing) in the last pass. pub(super) breach_paths: opentelemetry::metrics::Gauge, + /// KEV-corroborated-and-reachable findings this pass, carrying **per-finding entity + /// labels** (`k8s.namespace.name`, `k8s.workload.kind`, `k8s.workload.name`, + /// `image.reference`, `cve.ids`). A "finding" here is a reachable chain — internet-facing + /// OR a proven foothold — whose entry image runs a CVE that CISA KEV lists as + /// exploited-in-wild (`Vulnerability::exploited_in_wild`). The entity labels let an + /// operator route a KEV hit straight to the workload and image without a second lookup; + /// ADR-0044 traded the former no-entity-labels rule for exactly that rapid-response value + /// (the rule was over-cautious for this bounded single-operator homelab — an attacker who + /// could forge these names has already compromised the cluster). The series is one per + /// distinct entry workload per pass: the labels are entry-scoped, so counting each of an + /// entry's several objective-chains would only inflate one series. "KEV" is the + /// `exploited_in_wild` catalogue flag — NOT the runtime `corroborated` signal that + /// [`Self::corroborations`] counts (ADR-0009); the two are unrelated predicates. + pub(super) kev_reachable_findings: opentelemetry::metrics::Counter, /// Active mitigations currently in the ledger. pub(super) active_mitigations: opentelemetry::metrics::Gauge, /// Whether the model adjudicator looks degraded RIGHT NOW (`1`) or healthy (`0`) — the @@ -52,9 +70,10 @@ pub(super) struct EngineMetrics { /// 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). + /// The Falco-retirement parity-matrix input. No labels: the count alone is the bake + /// signal this metric needs, and the entry name already rides the accompanying structured + /// log for the per-entry view. (Per-entity metric labels are permitted since ADR-0044 — + /// see [`Self::kev_reachable_findings`] — this counter simply has no use for one.) 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"}` @@ -90,8 +109,9 @@ pub(super) struct EngineMetrics { /// [`derive_runtime_coverage`](crate::engine::state::derive_runtime_coverage) the dashboard /// reads (they must never disagree). In-scope expected nodes as of this pass — the agent /// DaemonSet's own pods, matching `RuntimeCoverage::expected_count`; out-of-scope reporters are - /// excluded. Counts ONLY, with NO per-node label dimension: node names are attacker-influenceable, - /// so a per-node series would be a cardinality/DoS vector (see `agent_liveness`). + /// excluded. Counts only: the readiness row consumes aggregate healthy/degraded/blind tallies, + /// so a per-node label dimension would buy nothing here. (Per-entity labels are allowed since + /// ADR-0044 — see [`Self::kev_reachable_findings`] — this gauge just doesn't need one.) pub(super) coverage_expected: opentelemetry::metrics::Gauge, /// Expected nodes reporting with their probes loaded this pass (quiet counts as healthy). pub(super) coverage_healthy: opentelemetry::metrics::Gauge, @@ -174,6 +194,13 @@ impl EngineMetrics { .u64_gauge("protector.engine.breach_paths") .with_description("Breach-relevant (internet-facing) findings in the last pass.") .build(), + kev_reachable_findings: m + .u64_counter("protector.engine.kev_reachable_findings") + .with_description( + "Reachable findings whose entry image runs a CISA-KEV exploited-in-wild CVE, \ + by namespace/workload/image/CVE (ADR-0044).", + ) + .build(), active_mitigations: m .u64_gauge("protector.engine.active_mitigations") .with_description("Active mitigations in the ledger.") @@ -321,8 +348,9 @@ impl EngineMetrics { /// Mirror this pass's runtime-corroboration coverage into the OTLP gauges. A pure /// mirror of already-derived state: it takes the SAME [`RuntimeCoverage`] the dashboard reads /// (the caller passes back what `stamp_runtime_coverage` just stored), so the two can never - /// disagree. Counts ONLY — no per-node label dimension, because node names are - /// attacker-influenceable and a per-node series would be a cardinality/DoS vector. + /// disagree. Counts only — the readiness row needs the aggregate tallies, not a per-node + /// series (per-entity labels themselves are permitted since ADR-0044, see + /// [`Self::record_kev_reachable_finding`]). pub(super) fn record_coverage(&self, coverage: &RuntimeCoverage) { let CoverageGaugeValues { expected, @@ -337,6 +365,85 @@ impl EngineMetrics { self.coverage_blind.record(blind, &[]); self.coverage_signals.record(signals, &[]); } + + /// Emit the KEV-corroborated-and-reachable findings for one pass (ADR-0044). A finding is + /// a chain that is *reachable* — `is_breach_relevant()` (internet-facing entry) OR + /// `foothold.is_some()` (a proven exploitable front door) — AND *KEV-backed*: its entry + /// image carries a CVE with `exploited_in_wild` (the CISA KEV flag, NOT the runtime + /// `corroborated` signal of [`Self::corroborations`], ADR-0009). One series per distinct + /// entry workload per pass: every label here is entry-scoped, so an entry's several + /// objective-chains would only inflate one series — the dedupe keeps the series count + /// equal to the number of KEV-reachable workloads. Series hygiene, not a cardinality cap. + pub(super) fn record_kev_reachable_findings( + &self, + graph: &SecurityGraph, + chains: &[ProvenChain], + ) { + let mut seen: BTreeSet<&NodeKey> = BTreeSet::new(); + for chain in chains + .iter() + .filter(|c| c.is_breach_relevant() || c.foothold.is_some()) + { + if !seen.insert(&chain.entry) { + continue; + } + let (vulns, _) = graph.entry_evidence(&chain.entry); + let mut kev_ids: Vec<&str> = vulns + .iter() + .filter(|v| v.exploited_in_wild) + .map(|v| v.id.as_str()) + .collect(); + if kev_ids.is_empty() { + continue; + } + kev_ids.sort_unstable(); + kev_ids.dedup(); + self.record_kev_reachable_finding( + &chain.entry, + graph.entry_image_reference(&chain.entry).as_deref(), + &kev_ids, + ); + } + } + + /// Record one KEV-corroborated-and-reachable finding (ADR-0044) with its per-finding + /// entity labels. `entry` supplies the namespace / workload kind / workload name (parsed + /// from the `workload///` key); `image` is the entry's deployed image + /// reference(s); `cve_ids` are the entry image's exploited-in-wild CVE ids, joined into a + /// single `cve.ids` value so the whole finding stays ONE series rather than fanning out + /// per CVE. The labels are attacker-influenceable by construction (ADR-0044 accepts this + /// for the homelab's bound); a missing key segment renders as `unknown` rather than + /// dropping the label. "KEV" is `exploited_in_wild`, never the runtime `corroborated` + /// signal. + pub(super) fn record_kev_reachable_finding( + &self, + entry: &NodeKey, + image: Option<&str>, + cve_ids: &[&str], + ) { + self.kev_reachable_findings.add( + 1, + &[ + opentelemetry::KeyValue::new( + "k8s.namespace.name", + entry.namespace().unwrap_or("unknown").to_string(), + ), + opentelemetry::KeyValue::new( + "k8s.workload.kind", + entry.workload_kind().unwrap_or("unknown").to_string(), + ), + opentelemetry::KeyValue::new( + "k8s.workload.name", + entry.name().unwrap_or("unknown").to_string(), + ), + opentelemetry::KeyValue::new( + "image.reference", + image.unwrap_or("unknown").to_string(), + ), + opentelemetry::KeyValue::new("cve.ids", cve_ids.join(",")), + ], + ); + } } /// The five runtime-coverage gauge values, derived from a [`RuntimeCoverage`]. Kept as a @@ -533,4 +640,201 @@ mod tests { "only healthy-node signals count; the out-of-scope node's 99 is excluded" ); } + + use std::collections::HashMap; + + use opentelemetry::metrics::MeterProvider as _; + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}; + + use super::{ProvenChain, SecurityGraph}; + use crate::engine::graph::attack::{CREDENTIAL_ACCESS, EXPLOIT_PUBLIC_FACING}; + use crate::engine::graph::{ + Edge, Exposure, Image, Node, NodeKey, Provenance, Relation, Severity, Trust, Vulnerability, + Workload, + }; + + /// One KEV-reachable data point read back from the in-memory exporter: the five entity + /// labels keyed by attribute name, so a test reads `points[…]` by label rather than index. + type LabelSet = BTreeMap; + + fn kev_points(exporter: &InMemoryMetricExporter) -> Vec<(LabelSet, u64)> { + exporter + .get_finished_metrics() + .expect("read back the in-memory exporter") + .iter() + .flat_map(|rm| rm.scope_metrics()) + .flat_map(|sm| sm.metrics()) + .find(|m| m.name() == "protector.engine.kev_reachable_findings") + .map(|m| match m.data() { + AggregatedMetrics::U64(MetricData::Sum(sum)) => sum + .data_points() + .map(|dp| { + let labels = dp + .attributes() + .map(|kv| (kv.key.to_string(), kv.value.to_string())) + .collect(); + (labels, dp.value()) + }) + .collect(), + _ => panic!("kev_reachable_findings is a u64 sum"), + }) + .unwrap_or_default() + } + + fn prov() -> Provenance { + Provenance::new("test", std::time::SystemTime::UNIX_EPOCH) + } + + fn vuln(id: &str, kev: bool) -> Vulnerability { + Vulnerability { + id: id.into(), + severity: Severity::Critical, + exploited_in_wild: kev, + ..Default::default() + } + } + + /// Add a workload running one image (a `RunsImage` edge) to `graph`. The image carries + /// `vulns`; `reference` is its deployed ref. Returns the workload's `NodeKey` — the entry + /// a chain is keyed on. + fn add_workload( + graph: &mut SecurityGraph, + name: &str, + exposure: Exposure, + reference: &str, + vulns: Vec, + ) -> NodeKey { + let wl = graph.upsert_node(Node::Workload(Workload { + namespace: "app".into(), + name: name.into(), + kind: "Pod".into(), + labels: Default::default(), + meshed: false, + exposure, + runtime: Vec::new(), + persistent: false, + misconfigs: Vec::new(), + rbac_findings: Vec::new(), + })); + let img = graph.upsert_node(Node::Image(Image { + digest: format!("sha256:{name}"), + reference: Some(reference.into()), + trust: Trust::Unknown, + vulnerabilities: vulns, + exposed_secrets: Vec::new(), + static_binary: None, + })); + graph.add_edge( + wl, + img, + Edge { + relation: Relation::RunsImage, + provenance: prov(), + }, + ); + NodeKey::workload("app", "Pod", name) + } + + /// A chain over `entry` with the reachability flags set directly (as `narrowing_delta`'s + /// tests do) — the KEV gate reads `exposed_entry` / `foothold` plus the graph's entry + /// evidence, never how the chain was derived. + fn chain_over(entry: NodeKey, exposed: bool, foothold: bool, objective: &str) -> ProvenChain { + ProvenChain { + entry, + objective: NodeKey::workload("app", "Secret", objective), + attack: CREDENTIAL_ACCESS, + foothold: foothold.then_some(EXPLOIT_PUBLIC_FACING), + corroborated: false, + adjudicated: true, + promoted: false, + exposed_entry: exposed, + verdict: None, + links: Vec::new(), + paths: Vec::new(), + paths_truncated: false, + single_edge_cuts: Vec::new(), + quarantine_targets: Vec::new(), + } + } + + /// The ADR-0044 metric emits one labelled series — namespace / workload kind+name / image + /// reference / joined-and-sorted CVE ids — for a KEV + internet-facing finding, and emits + /// NOTHING for a non-KEV internet finding or a KEV internal-only finding. Also guards the + /// one-series-per-entry dedupe: a KEV entry with two objective-chains still yields one + /// series of value 1. Reads values back through a local in-memory exporter (never the + /// process-global meter), like `a_single_pass_never_double_counts_one_label`. + #[test] + fn kev_reachable_emits_entity_labels_only_for_kev_and_reachable_findings() { + let mut graph = SecurityGraph::new(); + // (1) internet-facing + two KEV CVEs → emits, cve.ids sorted and joined. + let web = add_workload( + &mut graph, + "web", + Exposure::Internet, + "ghcr.io/x/web:1", + vec![vuln("CVE-2021-45046", true), vuln("CVE-2021-44228", true)], + ); + // (2) internet-facing but its only CVE is critical-not-KEV → reachable, no KEV → skip. + let nokev = add_workload( + &mut graph, + "nokev", + Exposure::Internet, + "ghcr.io/x/nokev:1", + vec![vuln("CVE-2020-0001", false)], + ); + // (3) internal-only, KEV CVE → not reachable → skip. + let internal = add_workload( + &mut graph, + "internal", + Exposure::Internal, + "ghcr.io/x/internal:1", + vec![vuln("CVE-2021-44228", true)], + ); + + let chains = vec![ + chain_over(web.clone(), true, true, "session-key"), + // Same KEV entry, a second objective: must collapse to one series (dedupe). + chain_over(web, true, true, "db-password"), + chain_over(nokev, true, false, "session-key"), + chain_over(internal, false, false, "session-key"), + ]; + + let exporter = InMemoryMetricExporter::default(); + let provider = SdkMeterProvider::builder() + .with_reader(PeriodicReader::builder(exporter.clone()).build()) + .build(); + let metrics = EngineMetrics::from_meter(provider.meter("test")); + + metrics.record_kev_reachable_findings(&graph, &chains); + provider.force_flush().expect("flush the exporter"); + + let points = kev_points(&exporter); + assert_eq!( + points.len(), + 1, + "only the KEV + internet-facing entry emits, and its two chains dedupe to one series" + ); + let (labels, value) = &points[0]; + assert_eq!(*value, 1, "one dedup'd finding this pass"); + let expected: HashMap<&str, &str> = HashMap::from([ + ("k8s.namespace.name", "app"), + ("k8s.workload.kind", "Pod"), + ("k8s.workload.name", "web"), + ("image.reference", "ghcr.io/x/web:1"), + ("cve.ids", "CVE-2021-44228,CVE-2021-45046"), + ]); + assert_eq!( + labels.len(), + expected.len(), + "exactly the entity labels, no more" + ); + for (key, want) in expected { + assert_eq!( + labels.get(key).map(String::as_str), + Some(want), + "label {key} must be {want}" + ); + } + } } diff --git a/engine/src/engine/mod.rs b/engine/src/engine/mod.rs index 60eb159..12e7d3a 100644 --- a/engine/src/engine/mod.rs +++ b/engine/src/engine/mod.rs @@ -598,6 +598,10 @@ impl Engine { if corroborations > 0 { self.metrics.corroborations.add(corroborations, &[]); } + // KEV-corroborated-and-reachable findings (ADR-0044): per-finding entity labels on + // every reachable chain whose entry image runs a CISA-KEV exploited-in-wild CVE. Gate + // and dedupe live in the recorder (see its doc) so the pass stays unit-testable. + self.metrics.record_kev_reachable_findings(&graph, &chains); // 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. @@ -619,8 +623,9 @@ impl Engine { // Runtime-corroboration coverage per node for the readiness row, and its // OTLP mirror. Reading the coverage back after stamping means the gauges are // sourced from the SAME `derive_runtime_coverage` the dashboard reads — they can never - // disagree. Counts only, no per-node label dimension: node names are attacker- - // influenceable, so a per-node series would be a cardinality/DoS vector. + // disagree. Counts only — the readiness row consumes aggregate healthy/degraded/blind + // tallies, so a per-node label dimension would buy nothing here (per-entity labels + // themselves are permitted since ADR-0044, see `record_kev_reachable_finding`). if let Some(liveness) = &self.agent_liveness { let edge = self .findings