Skip to content
Merged
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
16 changes: 10 additions & 6 deletions engine/src/engine/dashboard/view_model/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Option<String> {
if f.corroborated {
return None;
Expand All @@ -314,7 +318,7 @@ fn blind_node_caveat(f: &Finding, blind_nodes: &HashSet<String>) -> Option<Strin
return None;
}
Some(format!(
"no live runtime sensor on node {node} \u{2014} absence of a signal here is not evidence of safety"
"runtime-blind on {node} \u{2014} no live sensor here, so absence of a signal is not evidence of safety"
))
}

Expand Down
49 changes: 44 additions & 5 deletions engine/src/engine/dashboard/view_model/findings/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ fn finding(entry: &str, objective: &str, verdict: Option<Verdict>) -> 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 <node>" 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);
Expand All @@ -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 <node>" (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!(
Expand All @@ -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<String> = ["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(
Expand Down
59 changes: 59 additions & 0 deletions engine/web/test/blind-caveat.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<FindingsView view={findingsView([f])} />);
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(<FindingsView view={findingsView([f])} />);
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<img src=x onerror="window.__pwned=1">';
const f = finding("blind-xss", {
"blind-node-caveat": `runtime-blind on ${XSS} — absence of a signal is not evidence of safety`,
});
const { container } = render(<FindingsView view={findingsView([f])} />);
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);
});
});
Loading