From f2cb199b1e21f131adfedd0b0b783e92ef21ed42 Mon Sep 17 00:00:00 2001 From: Jeff Larson Date: Fri, 17 Jul 2026 23:22:24 -0700 Subject: [PATCH] feat(dashboard): per-finding "runtime-blind on this node" caveat (JEF-424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finding whose workload sits on a node the agent can't see (no live eBPF sensor) reads as calm/propose-only — but that calm is dishonest there: we can't tell whether the path is being exploited, so blind ≠ green. The finding-level blind-node caveat (shipped as coverage under JEF-308) now leads with the crisp "runtime-blind on " framing the ticket names, keeping the honest tail ("absence of a signal is not evidence of safety") and NAMING the node. METADATA ONLY (ADR-0016 — presentation is a view, never a gate): the caveat is an additive `Option` on `FindingProps`, derived from the SAME `blind_node_set` the Readiness runtime-corroboration row reads (so the two can never disagree). It never touches the verdict, the proposed action (cut), or the report — the finding's decision is unchanged. The client renders it via the existing `.verdict-caveat` / `role="note"` precedent; the node name is UNTRUSTED and auto-escaped at render (Preact). Tests: Rust — a finding on a blind node carries the caveat NAMING the node ("runtime-blind on "); a finding on a healthy/sensored node (and a corroborated one) does not; and a new honesty test asserts the caveat changes NOTHING but that one field — same verdict/posture/live-tag, same cut, same disposition, byte-for-byte-equal report body. JS — the caveat renders as a `role="note"` and an XSS-laden node name renders as escaped text, never a live element. Web bundle built from source; `cargo fmt`/`clippy`/`cargo test -p protector` and `npm test` all green. Closes JEF-424 Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 --- .../engine/dashboard/view_model/findings.rs | 16 +++-- .../dashboard/view_model/findings/tests.rs | 49 +++++++++++++-- engine/web/test/blind-caveat.test.jsx | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 engine/web/test/blind-caveat.test.jsx diff --git a/engine/src/engine/dashboard/view_model/findings.rs b/engine/src/engine/dashboard/view_model/findings.rs index ec248c6..2e007ce 100644 --- a/engine/src/engine/dashboard/view_model/findings.rs +++ b/engine/src/engine/dashboard/view_model/findings.rs @@ -300,11 +300,15 @@ pub(super) fn finding_props( } } -/// The blind-node caveat for a finding (JEF-308), or `None`. Applies when the finding is NOT -/// live-corroborated AND runs on a node with no live sensor: its calm propose-only reading would be -/// dishonest there, because we can't see whether the path is being exploited — absence of a signal -/// is not evidence of safety. A corroborated finding already has a live signal, and a finding whose -/// node is unknown or sensored gets no caveat. +/// The finding-level "runtime-blind on this node" caveat (JEF-424, from the JEF-308 coverage), or +/// `None`. Applies when the finding is NOT live-corroborated AND its workload sits on a node with no +/// live sensor: its calm propose-only reading would be dishonest there, because we can't see whether +/// the path is being exploited — blind ≠ green, absence of a signal is not evidence of safety. This +/// is PRESENTATION METADATA ONLY: it is derived from the SAME `blind_node_set` the Readiness +/// runtime-corroboration row reads (so the two never disagree), and it never touches the verdict, +/// the proposed action, or the report — the finding's decision is unchanged (ADR-0016). A +/// corroborated finding already has a live signal, and a finding whose node is unknown or sensored +/// gets no caveat. fn blind_node_caveat(f: &Finding, blind_nodes: &HashSet) -> Option { if f.corroborated { return None; @@ -314,7 +318,7 @@ fn blind_node_caveat(f: &Finding, blind_nodes: &HashSet) -> Option) -> Finding { } } -/// JEF-308: a latent / propose-only finding on a BLIND node carries the "no live sensor" caveat — -/// its calm propose-only reading would be dishonest, so the detail says absence of a signal isn't -/// evidence of safety. A corroborated finding, or one on a sensored node, gets no caveat. +/// JEF-424 (from JEF-308 coverage): a latent / propose-only finding whose workload sits on a BLIND +/// node carries the finding-level "runtime-blind on " caveat — its calm propose-only reading +/// would be dishonest (blind ≠ green), so the detail says absence of a signal isn't evidence of +/// safety AND NAMES the node. A corroborated finding, or one on a sensored node, gets no caveat. #[test] fn latent_finding_on_a_blind_node_carries_the_caveat() { let mut latent = finding("workload/app/Pod/web-1", "secret/app/db", None); @@ -54,8 +55,9 @@ fn latent_finding_on_a_blind_node_carries_the_caveat() { let caveat = props .blind_node_caveat .expect("a latent finding on a blind node carries the caveat"); - assert!(caveat.contains("node-b")); - assert!(caveat.contains("not evidence of safety")); + // The caveat NAMES the blind node and reads "runtime-blind on " (JEF-424). + assert!(caveat.contains("runtime-blind on node-b"), "{caveat}"); + assert!(caveat.contains("not evidence of safety"), "{caveat}"); // The SAME finding on a sensored node (not in the blind set) gets no caveat. assert!( @@ -74,6 +76,43 @@ fn latent_finding_on_a_blind_node_carries_the_caveat() { ); } +/// JEF-424 honesty invariant: the caveat is PRESENTATION METADATA ONLY. Adding it (the finding is on +/// a blind node) must NOT change the verdict, the proposed action (cut), or the report content — the +/// SAME finding mapped with and without its node in the blind set is identical except for the +/// `blind_node_caveat` field. The verdict is unchanged (ADR-0016: presentation is a view, never a +/// gate). +#[test] +fn blind_node_caveat_does_not_change_the_verdict_action_or_report() { + let mut f = finding( + "workload/app/Pod/api-0", + "secret/app/db", + Some(Verdict::Refuted("internal — no exploit path".into())), + ); + f.node = Some("node-x".into()); + f.cut = Some("workload/app/Pod/api-0 -[reaches/Tcp/5432]-> secret/app/db".into()); + let blind: HashSet = ["node-x".to_string()].into_iter().collect(); + + let with_caveat = finding_props(&f, &[], &blind); + let without_caveat = finding_props(&f, &[], &no_blind()); + + // The caveat IS present only on the blind mapping — that is the one and only difference. + assert!(with_caveat.blind_node_caveat.is_some()); + assert!(without_caveat.blind_node_caveat.is_none()); + + // The VERDICT is unchanged: same posture, same live-tag, same verbatim verdict summary. + assert_eq!(with_caveat.posture, without_caveat.posture); + assert_eq!(with_caveat.live_tag, without_caveat.live_tag); + assert_eq!(with_caveat.verdict_summary, without_caveat.verdict_summary); + // The proposed ACTION (cut) is unchanged. + assert_eq!(with_caveat.cut, without_caveat.cut); + assert_eq!(with_caveat.disposition, without_caveat.disposition); + // The rest of the REPORT (evidence, paths, judgement) is byte-for-byte identical: clearing the + // one caveat field on the blind mapping makes the two props fully equal. + let mut normalized = with_caveat.clone(); + normalized.blind_node_caveat = None; + assert_eq!(normalized, without_caveat); +} + #[test] fn only_breach_relevant_rows_are_surfaced() { let mut keep = finding( diff --git a/engine/web/test/blind-caveat.test.jsx b/engine/web/test/blind-caveat.test.jsx new file mode 100644 index 0000000..55be11d --- /dev/null +++ b/engine/web/test/blind-caveat.test.jsx @@ -0,0 +1,59 @@ +// Finding-level "runtime-blind on this node" caveat (JEF-424): a finding whose workload sits on a +// blind node carries a server-derived caveat string; the detail panel renders it in the verdict +// block as a `role="note"` (the existing `.verdict-caveat` precedent), with the node name — which is +// UNTRUSTED-adjacent — auto-escaped by Preact, never as live HTML. The caveat is metadata only: the +// client SELECTS it, it never derives or re-decides it. + +import { describe, it, expect, beforeEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/preact"; +import { FindingsView } from "../src/findings/table.jsx"; +import { finding, findingsView } from "./fixtures.js"; + +beforeEach(() => { + sessionStorage.clear(); + cleanup(); +}); + +/** Expand a finding row so its detail panel (and the caveat) render. */ +function expand(container, id) { + fireEvent.click(container.querySelector(`tr.row[data-finding="${id}"]`)); +} + +describe("finding-level runtime-blind caveat (JEF-424)", () => { + it("renders the caveat as a role=note when the server ships one", () => { + const f = finding("blind-1", { + "blind-node-caveat": "runtime-blind on node-7 — no live sensor here, so absence of a signal is not evidence of safety", + }); + const { container } = render(); + expand(container, "blind-1"); + + const note = container.querySelector(".blind-node-caveat[role='note']"); + expect(note).not.toBeNull(); + expect(note.textContent).toContain("runtime-blind on node-7"); + }); + + it("renders NO caveat when the server ships none (a sensored / corroborated finding)", () => { + const f = finding("clear-1", { "blind-node-caveat": null }); + const { container } = render(); + expand(container, "clear-1"); + expect(container.querySelector(".blind-node-caveat")).toBeNull(); + }); + + it("escapes an untrusted node name in the caveat — text, never live HTML", () => { + window.__pwned = undefined; + const XSS = 'node'; + const f = finding("blind-xss", { + "blind-node-caveat": `runtime-blind on ${XSS} — absence of a signal is not evidence of safety`, + }); + const { container } = render(); + expand(container, "blind-xss"); + + // The payload never became a real element, and its handler never fired... + expect(container.querySelector("img")).toBeNull(); + expect(window.__pwned).toBeUndefined(); + // ...but the node name IS present as literal, escaped text inside the caveat note. + const note = container.querySelector(".blind-node-caveat[role='note']"); + expect(note).not.toBeNull(); + expect(note.textContent).toContain(XSS); + }); +});