Files
NeuroSploit/neurosploit-rs/crates/harness/src/prosecutor.rs
T
Corentin GoetghebeurandClaude Opus 4.8 01eac8f0c3 fix: robust LLM response handling & JSON extraction (#46)
* fix(pipeline): robust LLM JSON extraction (json5 + truncation repair)

Model replies that did not exactly match the expected JSON syntax were
either dropped silently or surfaced as "[extract_findings] ... JSON parse
failed" / "... no JSON array/object found". Both came from the same two
weak stages in extract_findings: a greedy first-'['-to-last-']' span that
captured prose, and a salvage pass that only stripped trailing commas.

Add a shared, string/escape-aware extractor (crates/harness/src/json_extract.rs):
- locate balanced [..]/{..} regions, ignoring brackets inside prose/strings,
  preferring fenced blocks (last wins);
- parse leniently: serde_json first, then json5 (trailing commas, comments,
  single quotes, unquoted keys);
- repair token-limit truncation by closing the open structure, keeping the
  complete findings instead of discarding the whole batch.

Route extract_findings, reported_nothing, extract_chain, parse_string_array
and prosecutor::parse_verdict through it. Make the diagnostic tail()
char-boundary-safe (the old slice could panic on UTF-8). Add regression
tests for single quotes/comments, capitalised ```JSON fences, truncated
arrays and pure prose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(models): robust LLM response handling + higher token/timeout limits

Harden the OpenAI-compatible chat client against the empty-content and
parse failures hit with reasoning models (GLM/DeepSeek via OpenRouter)
during whitebox runs:

- Accept message `content` as a string, an array of content parts, or a
  `reasoning_content` fallback; surface `finish_reason` and empty-choices
  errors instead of an opaque "no content in response".
- Stop masking mid-stream body-read failures as a bogus "EOF while
  parsing"; report read timeouts and empty bodies explicitly, and
  reassemble SSE-framed responses some gateways return unrequested.
- Raise reasoning-model max_tokens to 32768 and the HTTP timeout to 300s;
  both overridable via NEUROSPLOIT_MAX_TOKENS / NEUROSPLOIT_HTTP_TIMEOUT.

Adds unit tests for content extraction, token sizing, and SSE reassembly.
Cargo.lock syncs the json5 entry from the prior extraction commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(pipeline): unwrap findings/selection replies wrapped in an object

The json5 + truncation work made JSON *parsing* robust, but the *shape*
handling after it still dropped data when a model wrapped its answer in an
object instead of returning the bare array we asked for. Most visible on
black-box runs, where a long tool-use turn ends with the model narrating
into a report object.

extract_findings treated any object as ONE finding, so a real batch returned
as `{"findings":[…]}` (or `{"vulnerabilities":[…]}`, …) became a single
title-less "finding", was filtered out, and surfaced to the operator as
"returned text but 0 parseable findings" while the findings were lost. Add
findings_items() to normalise the shape: an array is the list; an object with
a title is one bare finding; otherwise an object wrapping a known findings key
unwraps to that array. reported_nothing() now recognises the same wrapper keys
so an empty `{"vulnerabilities":[]}` reads as an honest negative.

Two more consumers of the same class:
- parse_string_array (agent selection) accepted only a bare array of strings,
  so a wrapped `{"agents":[…]}` or elements-as-objects `[{"name":"sqli"}]`
  silently fell back to RL ranking. Now unwraps the wrapper and pulls the
  string from object elements.
- extract_chain hard-coded the "findings" key for its object branch, dropping
  the sibling `loot` under any other wrapper key. Now checks all wrapper keys.

Add regression tests for each shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 18:48:50 -03:00

291 lines
13 KiB
Rust

//! 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> {
// The shared extractor handles the fences, preamble and minor syntax drift
// (trailing commas, comments, single quotes) that models add to this object.
let v = crate::json_extract::parse_reply(text)?;
serde_json::from_value::<ProsecutorVerdict>(v).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")));
}
}