feat(prosecutor): a second judge that shrinks claims instead of deleting findings

The existing voters answer "is this finding real?", which invites judging the
whole narrative at once — and that is how a proven missing control got deleted
for having an overstated headline. One lever, one verdict, observation gone.

The Evidence Prosecutor has a narrower brief and four questions in order: what
exactly was observed, which sentences go beyond that, what would have to be
observed for the claimed impact to be factual, and — the one that decides
retain-versus-reject — would anything security-relevant remain if the
unsupported sentences were removed.

It cannot pass sentence. There is no verdict field in its contract (a test
asserts the prompt never offers one), and apply() can only narrow: the minimal
supported statement replaces the mechanic, the asserted impact is demoted to
potential with the conditions that would make it factual attached. Nothing it
returns can raise a severity, add an impact, or drop a finding.

A self-contradicting verdict — "nothing survives" alongside a minimal claim —
is detected and the reading that keeps the observation wins: the claim is a
concrete artifact, the boolean is an opinion about it.

Findings from before the claim contract get a ledger reconstructed from what
they recorded (structured evidence where present, quoted evidence lines
otherwise), so the back catalogue is judged rather than silently skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-09-14 00:35:38 -03:00
co-authored by Claude Opus 5
parent 3800b029f2
commit 9c6b2a3c54
3 changed files with 383 additions and 9 deletions
+2
View File
@@ -21,6 +21,7 @@ pub mod knowledge_graph;
pub mod memory;
pub mod policy;
pub mod pomdp;
pub mod prosecutor;
pub mod models;
pub mod pipeline;
pub mod pool;
@@ -47,6 +48,7 @@ pub use capability::{Capability, TokenError};
pub use browser::{BrowserProbe, BrowserResult};
pub use claims::{Claim, ClaimSet, ClaimStatus, Decision, EvidenceLedger};
pub use policy::{Act, ActionKind, BlastRadius, EngagementPolicy, Environment, Protocol, Risk, RiskDecision, SafetyPolicy};
pub use prosecutor::{ProsecutorVerdict, PROSECUTOR_SYS};
pub use replay::{ReplayEngine, ReqSpec};
pub use scope::{Action as ScopeAction, Decision as ScopeDecision, ScopePolicy};
pub use types::{Finding, RunConfig};
+77 -9
View File
@@ -843,7 +843,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
findings.extend(chained);
findings = dedup_findings(findings);
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
finish(cfg, lib, pool, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
}
/// White-box engagement: analyse a repository's source for vulnerabilities.
@@ -924,7 +924,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
finish(cfg, lib, pool, "{}".into(), transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
}
/// Greybox engagement: review the source code AND exploit the running app in one
@@ -1070,7 +1070,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
findings.extend(chained);
findings = dedup_findings(findings);
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Either, context, tx).await
finish(cfg, lib, pool, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Either, context, tx).await
}
const CHAIN_SYS: &str = "You are a post-exploitation & attack-chaining specialist. You are given ONE confirmed foothold plus any loot already gathered. DECIDE the most promising directions to expand from THIS foothold and pursue them with real tools: post-exploitation (loot credentials/tokens/keys/config/source), credential reuse, privilege escalation (horizontal AND vertical), lateral movement to adjacent services/hosts, data exfiltration, and reaching NEW attack surface the foothold exposes (e.g. SSRF→cloud metadata creds→IAM, SQLi→DB dump→credential reuse→admin, arbitrary file read→secrets→RCE, IDOR→account takeover, auth bypass→internal APIs). PROVE each escalated step with a real tool receipt. Report ONLY NEW findings beyond the input, plus any new loot you discovered (creds, tokens, hosts, internal endpoints) so later stages can reuse it. Authorized engagement; never destructive/DoS.";
@@ -1449,6 +1449,69 @@ async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n:
flagged
}
/// Run the Evidence Prosecutor over findings that carry claims.
///
/// It cannot drop anything — it returns a narrowed claim set, and the decision
/// table downstream does the rest. Findings without claims get a ledger built
/// from whatever they recorded, so the back catalogue is judged too instead of
/// silently skipped.
async fn prosecute(findings: Vec<Finding>, pool: &ModelPool, tx: &Sender<String>) -> Vec<Finding> {
if findings.is_empty() || std::env::var("NEUROSPLOIT_PROSECUTOR").unwrap_or_default() == "off" {
return findings;
}
let mut out = Vec::with_capacity(findings.len());
let mut narrowed = 0usize;
for mut f in findings {
let mut set = match f.claims.clone() {
Some(s) => s,
None => crate::claims::ClaimSet {
mechanic: crate::claims::Claim {
claim: f.title.clone(),
status: Some(crate::claims::ClaimStatus::Proven),
evidence: Vec::new(),
},
impact: crate::claims::Claim { claim: f.impact.clone(), status: None, evidence: Vec::new() },
ledger: crate::prosecutor::ledger_from_finding(&f),
..Default::default()
},
};
if set.ledger.items.is_empty() {
out.push(f);
continue;
}
// Every claim must cite the ledger it was built from; a reconstructed
// set has no citations yet, so seed the mechanic with what exists.
if set.mechanic.evidence.is_empty() {
set.mechanic.evidence = set.ledger.items.iter().map(|e| e.id.clone()).collect();
}
let case = crate::prosecutor::case_file(&f, &set);
let verdict = match pool.complete_routed(Task::Validate, "prosecutor", crate::prosecutor::PROSECUTOR_SYS, &case).await {
Ok((_, text)) => crate::prosecutor::parse_verdict(&text),
Err(_) => None,
};
if let Some(v) = verdict.filter(|v| v.coherent()) {
if crate::prosecutor::apply(&mut set, &v) {
narrowed += 1;
let _ = tx.send(format!(
"prosecutor narrowed '{}' → {}",
trunc_title(&f.title),
trunc_title(&v.effective_claim(&f.title))
)).await;
}
}
f.claims = Some(set);
out.push(f);
}
if narrowed > 0 {
let _ = tx.send(format!("evidence prosecutor: {narrowed} finding(s) narrowed to what the ledger supports")).await;
}
out
}
fn trunc_title(s: &str) -> String {
s.chars().take(64).collect()
}
/// Assemble the claim set from an agent's reply.
///
/// The ledger arrives as a sibling of `claims` (agents produce it as one list
@@ -1517,7 +1580,7 @@ async fn refute_pass(findings: Vec<Finding>, pool: &ModelPool, vote_n: usize, tx
}
#[allow(clippy::too_many_arguments)]
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, mut findings: Vec<Finding>,
async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String, transcript: String, mut findings: Vec<Finding>,
selected: Vec<Agent>, rl: &mut RlState, gmode: crate::grounding::GroundMode, source_ctx: String,
tx: Sender<String>) -> RunOutput {
use crate::grounding::GroundMode;
@@ -1655,6 +1718,11 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
let audit = audit_log(&cfg);
let cap_id = capability_id(&cfg);
// The prosecutor runs first: it shrinks each claim to what the ledger
// supports, so the adjudication below is deciding about a statement that is
// already honest rather than about a story.
findings = prosecute(findings, pool, &tx).await;
// Claim adjudication, before anything reads the prose. Findings that
// arrived with separable claims get their outcome from the decision table
// in `crate::claims` — and an overstated impact is rewritten down to what
@@ -2530,7 +2598,7 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
findings.extend(chained);
findings = dedup_findings(findings);
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
finish(cfg, lib, pool, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
}
/// AI-red-team doctrine prepended to every AI/LLM/agent test prompt.
@@ -2702,7 +2770,7 @@ pub async fn run_ai(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
if cfg.offline {
let _ = tx.send("offline: no AI exploitation performed".into()).await;
return finish(cfg, lib, recon, String::new(), vec![], agents, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await;
return finish(cfg, lib, pool, recon, String::new(), vec![], agents, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await;
}
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(agents.len()) } else { agents.len() };
let selected: Vec<Agent> = agents.into_iter().take(cap).collect();
@@ -2749,7 +2817,7 @@ pub async fn run_ai(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<
findings.extend(chained);
findings = dedup_findings(findings);
let findings = refute_pass(findings, pool, cfg.vote_n, &tx).await;
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
finish(cfg, lib, pool, recon, transcript, findings, selected, &mut rl, crate::grounding::GroundMode::Empirical, String::new(), tx).await
}
/// White-box Skills/plugin audit: read the skill .md file or a folder of them and
@@ -2768,7 +2836,7 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
if cfg.offline || context.is_empty() {
let _ = tx.send("offline or empty skills input — nothing audited".into()).await;
return finish(cfg, lib, "{}".into(), String::new(), vec![], agents, &mut rl, crate::grounding::GroundMode::Symbolic, String::new(), tx).await;
return finish(cfg, lib, pool, "{}".into(), String::new(), vec![], agents, &mut rl, crate::grounding::GroundMode::Symbolic, String::new(), tx).await;
}
let directives = operator_directives(&cfg);
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(agents.iter().cloned())
@@ -2799,7 +2867,7 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t
let transcript = transcript_of(&raw);
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
finish(cfg, lib, "{}".into(), transcript, findings, agents, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
finish(cfg, lib, pool, "{}".into(), transcript, findings, agents, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
}
#[cfg(test)]
@@ -0,0 +1,304 @@
//! The Evidence Prosecutor — a second judge that only asks what was observed.
//!
//! The existing voters answer "is this finding real?", which invites them to
//! judge the whole narrative at once. That is how a proven missing control got
//! deleted for having an overstated headline: one lever, one verdict, and the
//! observation went out with the story.
//!
//! This judge has a narrower brief, and four questions in a fixed order:
//!
//! 1. What exactly did we observe?
//! 2. Which sentence in the finding goes beyond that?
//! 3. What would have to be observed for the claimed impact to become factual?
//! 4. **Can the finding survive if the impact sentence is removed?**
//!
//! The fourth is the one that decides retain-versus-reject, and it is why this
//! judge cannot delete anything. It returns a *minimal claim* — the largest
//! statement the ledger supports — and [`crate::claims`] rebuilds the finding
//! from it. A prosecutor that could also pass sentence would just be the old
//! voter with a better prompt.
use crate::claims::{ClaimSet, EvidenceLedger};
use crate::types::Finding;
use serde::{Deserialize, Serialize};
/// System prompt. Deliberately forbids a verdict: this judge reports what the
/// evidence supports and never whether to keep the finding.
pub const PROSECUTOR_SYS: &str = "You are an evidence prosecutor reviewing a security finding. You do NOT decide whether to keep or drop it — you decide what the recorded evidence actually supports.\n\
Answer four questions, in order:\n\
1. What exactly was OBSERVED? Quote only what appears in the evidence ledger.\n\
2. Which sentences in the finding go BEYOND what was observed? List them verbatim.\n\
3. What would have to be observed for the claimed impact to become factual? Be concrete (a confirmed account, a delivered email, returned data, command output).\n\
4. If every unsupported sentence were removed, would a security-relevant statement remain? If yes, write that statement as `minimal_claim` — the largest claim the evidence fully supports.\n\
Rules: a claim with no supporting evidence id is unsupported no matter how plausible. Absence of a control (no 429, no HSTS, no lockout) IS security-relevant on its own. Do not soften or restate the observation — quote it.\n\
Reply with ONLY this JSON:\n\
{\"observed\":[\"...\"],\"overreaching\":[\"...\"],\"missing_observations\":[\"...\"],\"survives_without_impact\":true|false,\"minimal_claim\":\"...\"}";
/// What the prosecutor found. No verdict field — by design.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ProsecutorVerdict {
/// Quoted observations, from the ledger.
#[serde(default)]
pub observed: Vec<String>,
/// Sentences in the finding that go past the evidence.
#[serde(default)]
pub overreaching: Vec<String>,
/// What would have to be seen for the claimed impact to be a fact.
#[serde(default)]
pub missing_observations: Vec<String>,
/// Does anything security-relevant remain once the overreach is removed?
#[serde(default)]
pub survives_without_impact: bool,
/// The largest fully supported statement.
#[serde(default)]
pub minimal_claim: String,
}
impl ProsecutorVerdict {
/// Did the finding claim more than it showed?
pub fn overreached(&self) -> bool {
!self.overreaching.is_empty()
}
/// Is this verdict internally consistent?
///
/// A model that says "nothing survives" while handing back a minimal claim
/// has contradicted itself, and the safe reading is the one that keeps the
/// observation: the claim is the concrete artifact, the boolean is an
/// opinion about it.
pub fn coherent(&self) -> bool {
!(self.survives_without_impact && self.minimal_claim.trim().is_empty())
}
/// The statement the report should make, given everything above.
pub fn effective_claim(&self, fallback: &str) -> String {
if !self.minimal_claim.trim().is_empty() {
self.minimal_claim.trim().to_string()
} else {
fallback.to_string()
}
}
}
/// Parse the prosecutor's reply, tolerating the fences and preamble models add.
pub fn parse_verdict(text: &str) -> Option<ProsecutorVerdict> {
let mut candidates: Vec<&str> = Vec::new();
// A fenced block is the machine-readable answer when there is one.
let mut rest = text;
let mut blocks: Vec<&str> = Vec::new();
while let Some(open) = rest.find("```") {
let after = &rest[open + 3..];
let Some(close) = after.find("```") else { break };
let inner = after[..close].trim_start_matches("json").trim();
blocks.push(inner);
rest = &after[close + 3..];
}
candidates.extend(blocks.into_iter().rev());
if let (Some(a), Some(b)) = (text.find('{'), text.rfind('}')) {
if b > a {
candidates.push(&text[a..=b]);
}
}
candidates.into_iter().find_map(|c| serde_json::from_str::<ProsecutorVerdict>(c).ok())
}
/// Fold the prosecutor's reading into the finding's claim set.
///
/// It can only ever *narrow*: the impact claim is demoted to potential and the
/// mechanic is replaced by the minimal supported statement. Nothing here can
/// raise a severity, add an impact, or remove a finding — the prosecutor's job
/// is to shrink a claim to its evidence, and a judge able to do more than that
/// would be able to do the damage it was built to prevent.
pub fn apply(set: &mut ClaimSet, v: &ProsecutorVerdict) -> bool {
if !v.overreached() {
return false;
}
if !v.minimal_claim.trim().is_empty() {
set.mechanic.claim = v.minimal_claim.trim().to_string();
}
if !set.impact.claim.trim().is_empty() {
// What was asserted as impact becomes potential impact, with the
// conditions that would make it factual stated alongside it.
let mut potential = set.impact.claim.trim().to_string();
if !v.missing_observations.is_empty() {
potential.push_str(&format!(" (requires: {})", v.missing_observations.join("; ")));
}
if set.potential_impact.trim().is_empty() {
set.potential_impact = potential;
}
set.impact.claim = String::new();
set.impact.evidence.clear();
set.impact.status = Some(crate::claims::ClaimStatus::Unproven);
}
true
}
/// The case file handed to the prosecutor: the finding's own words next to the
/// ledger, so the comparison is possible at all.
pub fn case_file(f: &Finding, set: &ClaimSet) -> String {
let ledger = if set.ledger.items.is_empty() {
"(no evidence ledger was recorded)".to_string()
} else {
set.ledger
.items
.iter()
.map(|e| format!("{}: {} [{}]", e.id, e.observed, e.source))
.collect::<Vec<_>>()
.join("\n")
};
format!(
"FINDING\ntitle: {}\nseverity: {}\nmechanic claim: {}\nimpact claim: {}\nimpact prose: {}\n\nEVIDENCE LEDGER\n{}\n",
f.title,
f.severity,
set.mechanic.claim,
set.impact.claim,
f.impact.chars().take(1200).collect::<String>(),
ledger
)
}
/// A ledger built from whatever a finding already carries, for findings that
/// arrived before the claim contract existed. Weaker than an agent-built one —
/// it can only quote what was written — but it lets the prosecutor work on the
/// back catalogue instead of silently skipping it.
pub fn ledger_from_finding(f: &Finding) -> EvidenceLedger {
let mut l = EvidenceLedger::default();
if let Some(ev) = &f.evidence_data {
if let Some(b) = &ev.baseline {
l.add(&format!("baseline {} {} → {} ({} bytes)", b.method, b.url, b.status, b.len()), "replay");
}
if let Some(a) = &ev.attack {
l.add(&format!("attack {} {} → {} ({} bytes)", a.method, a.url, a.status, a.len()), "replay");
}
for (i, r) in ev.repeats.iter().enumerate() {
l.add(&format!("repeat #{} → {}", i + 1, r.status), "replay");
}
if ev.browser_executed {
l.add("a real browser executed the payload and reported the marker", "browser");
}
if ev.callback_received {
l.add("an out-of-band callback carrying the marker was received", "oob");
}
}
for line in f.evidence.lines().filter(|l| !l.trim().is_empty()).take(20) {
l.add(line.trim(), "agent");
}
l
}
#[cfg(test)]
mod tests {
use super::*;
use crate::claims::{Claim, ClaimStatus};
fn verdict_json() -> &'static str {
r#"Here is my analysis.
```json
{"observed":["25 POSTs returned HTTP 302","no Retry-After header"],
"overreaching":["Reset email flooding against a victim's mailbox"],
"missing_observations":["a confirmed account","mail delivery observed"],
"survives_without_impact":true,
"minimal_claim":"The password-reset endpoint accepts repeated requests with no observable HTTP throttling"}
```"#
}
#[test]
fn the_verdict_is_parsed_out_of_narration_and_fences() {
let v = parse_verdict(verdict_json()).expect("must parse");
assert!(v.overreached());
assert!(v.survives_without_impact);
assert_eq!(v.observed.len(), 2);
assert!(v.minimal_claim.contains("no observable HTTP throttling"));
}
#[test]
fn applying_it_narrows_the_claim_and_never_adds_one() {
let mut set = ClaimSet {
mechanic: Claim { claim: "Reset email flooding".into(), status: Some(ClaimStatus::Proven), evidence: vec!["E01".into()] },
impact: Claim { claim: "Victim receives 25 reset emails".into(), status: Some(ClaimStatus::Proven), evidence: vec![] },
..Default::default()
};
let v = parse_verdict(verdict_json()).unwrap();
assert!(apply(&mut set, &v));
assert!(set.mechanic.claim.contains("throttling"), "the mechanic becomes the supported statement");
assert!(set.impact.claim.is_empty(), "the unsupported impact is removed as a claim");
assert!(set.potential_impact.contains("Victim receives 25 reset emails"), "and survives as potential");
assert!(set.potential_impact.contains("confirmed account"), "with what would make it factual");
assert_eq!(set.impact.status, Some(ClaimStatus::Unproven));
}
#[test]
fn a_finding_that_did_not_overreach_is_left_alone() {
let mut set = ClaimSet {
mechanic: Claim { claim: "userB read userA's invoice".into(), status: Some(ClaimStatus::Proven), evidence: vec!["E01".into()] },
impact: Claim { claim: "cross-tenant data access".into(), status: Some(ClaimStatus::Proven), evidence: vec!["E01".into()] },
..Default::default()
};
let before = set.clone();
let v = ProsecutorVerdict { survives_without_impact: true, minimal_claim: "something else".into(), ..Default::default() };
assert!(!apply(&mut set, &v), "no overreach, nothing to narrow");
assert_eq!(set, before);
}
#[test]
fn a_self_contradicting_verdict_is_detected() {
let bad = ProsecutorVerdict { survives_without_impact: true, minimal_claim: " ".into(), ..Default::default() };
assert!(!bad.coherent(), "claiming survival while producing no claim is a contradiction");
let ok = ProsecutorVerdict { survives_without_impact: false, minimal_claim: String::new(), ..Default::default() };
assert!(ok.coherent());
}
#[test]
fn the_prosecutor_is_not_given_a_verdict_field_to_fill() {
// The prompt must never invite a keep/drop decision — that is the lever
// that deleted a proven finding in the first place.
let p = PROSECUTOR_SYS.to_lowercase();
assert!(p.contains("you do not decide whether to keep or drop"));
assert!(!p.contains("\"verdict\""));
assert!(!p.contains("reject"));
}
#[test]
fn a_case_file_puts_the_claim_next_to_the_ledger() {
let f = Finding { title: "Flooding".into(), severity: "High".into(), impact: "Attackers flood mailboxes.".into(), ..Default::default() };
let mut ledger = EvidenceLedger::default();
ledger.add("POST #1 -> 302", "http");
let set = ClaimSet { ledger, ..Default::default() };
let cf = case_file(&f, &set);
assert!(cf.contains("FINDING") && cf.contains("EVIDENCE LEDGER"));
assert!(cf.contains("E01: POST #1 -> 302 [http]"));
}
#[test]
fn an_old_finding_still_gets_a_ledger_to_be_judged_against() {
let f = Finding {
evidence: "Baseline: 200, 19539 bytes\nBurst of 25 POSTs: all 200, no 429".into(),
..Default::default()
};
let l = ledger_from_finding(&f);
assert_eq!(l.items.len(), 2);
assert_eq!(l.items[0].source, "agent");
assert!(l.get("E02").is_some());
}
#[test]
fn structured_evidence_produces_a_richer_ledger_than_prose() {
use crate::validation::{Evidence, Exchange};
let f = Finding {
evidence_data: Some(Evidence {
baseline: Some(Exchange { method: "GET".into(), url: "https://t/x".into(), status: 200, body: "a".into(), ..Default::default() }),
attack: Some(Exchange { method: "GET".into(), url: "https://t/x?p=1".into(), status: 500, body: "err".into(), ..Default::default() }),
repeats: vec![Exchange { status: 500, ..Default::default() }],
browser_executed: true,
..Default::default()
}),
..Default::default()
};
let l = ledger_from_finding(&f);
let sources: Vec<&str> = l.items.iter().map(|i| i.source.as_str()).collect();
assert!(sources.contains(&"replay") && sources.contains(&"browser"));
assert!(l.items.iter().any(|i| i.observed.contains("baseline")));
}
}