From c3a613215392bf17268e437b13cb0d08407c4010 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 13 Sep 2026 17:48:14 -0300 Subject: [PATCH] feat(report): findings that answer where / why / how to fix, with a pasteable PoC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finding is useful only if the reader can find the problem, see why it matters, fix it, and reproduce it without trusting us. The report answered the last one badly and the other three not at all: it printed a payload blob and an evidence blob, and "payload: ' OR 1=1--" tells a developer nothing about WHERE to look. A PoC script attached as a file is a black box unless you run it. Findings are now rendered in the order a reader works through them — where the problem is, what it means, how to fix it, then the proof — in the HTML report, the Markdown, the Typst/PDF and the web console's finding modal. The proof is numbered, pasteable steps: baseline request, attack request, how to read the result, with the real URL and the real payload. They come from the agent's `repro_steps` when it recorded them, and are derived from the endpoint/payload/identity pair otherwise, so every finding carries something runnable. The generated curl redacts Authorization/Cookie/API-key headers — a report gets shared, and a live session cookie inside one is a new bug. A PoC script is now offered as an extra artifact that automates the steps, never as the proof itself. Technical evidence is the measured difference, not a paraphrase: baseline vs attack status, size, timing and delta; how many repeats reproduced it; the controlled marker and whether a browser or a callback observed it; then each recorded exchange with the headers that decide a class (Location, Set-Cookie, Access-Control-*, X-Frame-Options, CSP, Retry-After) and a body excerpt. Finding gains `location` (the parameter/field/flow step, not just the URL) and `repro_steps`, and the agent contract now asks for them explicitly, along with impact tied to this app's data and remediation that names the control rather than saying "sanitise input". The web console offers the run's PDF when Typst produced one — and only then, since a dead download button is worse than none. Co-Authored-By: Claude Opus 5 (1M context) --- neurosploit-rs/crates/harness/src/pipeline.rs | 19 +- neurosploit-rs/crates/harness/src/replay.rs | 2 +- neurosploit-rs/crates/harness/src/report.rs | 250 +++++++++++++++++- neurosploit-rs/crates/harness/src/types.rs | 12 + neurosploit-rs/templates/report.typ | 14 +- web/public/app.js | 134 +++++++++- web/public/index.html | 3 +- web/public/style.css | 10 + 8 files changed, 414 insertions(+), 30 deletions(-) diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 1cc18fe..01a9787 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -682,8 +682,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender = std::fs::read_dir(dir.join("pocs")) + .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect()) + .unwrap_or_default(); + put("report.html", report::html_with_pocs(&cfg.target, findings, &meta, &pocs)); written } @@ -1769,6 +1778,10 @@ fn extract_findings(text: &str, agent: &str) -> Vec { endpoint: s(o, "endpoint"), payload: s(o, "payload"), evidence: s(o, "evidence"), + location: s(o, "location"), + repro_steps: o.get("repro_steps").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(|t| t.to_string())).collect()) + .unwrap_or_default(), impact: s(o, "impact"), remediation: s(o, "remediation"), confidence: conf(o.get("confidence")), diff --git a/neurosploit-rs/crates/harness/src/replay.rs b/neurosploit-rs/crates/harness/src/replay.rs index 08b3704..17e98f6 100644 --- a/neurosploit-rs/crates/harness/src/replay.rs +++ b/neurosploit-rs/crates/harness/src/replay.rs @@ -24,7 +24,7 @@ //! - **Bodies are truncated.** Evidence is stored with the run and shipped in //! the report; a 40MB response is not evidence, it is a liability. -use crate::scope::{Action, ScopePolicy}; +use crate::scope::ScopePolicy; use crate::validation::{Evidence, Exchange}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index f68fb93..0afb201 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -55,6 +55,13 @@ fn esc(s: &str) -> String { /// grid, and a vulnerability summary table. No attack-path/kill-chain /// section — that lives in the interactive web console's live graph instead. pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String { + html_with_pocs(target, findings, meta, &[]) +} + +/// As [`html`], but told which scripts exist in the run's `pocs/` directory so +/// each finding can link the ones it cites. +pub fn html_with_pocs(target: &str, findings: &[Finding], meta: &EngagementMeta, available_pocs: &[String]) -> String { + let available_pocs = available_pocs.to_vec(); let mut sorted = findings.to_vec(); sorted.sort_by_key(|f| sev_rank(&f.severity)); @@ -103,15 +110,42 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String Agent{agent}{authcell}\ \ {reviewnote}\ -

Description / Impact

{impact}

\ -

Proof of Concept

{payload}
\ -

Evidence

{evidence}
{shots}\ -

Remediation

{remediation}

", +

Where the problem is

{where_}

\ +

What it means

{impact}

\ +

How to fix it

{remediation}

\ +

Proof of concept — step by step

{steps}\ + {payloadblock}\ +

Technical evidence

{evidence}
{shots}\ + {scripts}", sevc = sev_color(&f.severity), sev = esc(&f.severity), i = i + 1, title = esc(&f.title), agent = esc(&f.agent), owaspcwe = [esc(&f.owasp), esc(&f.cwe)].into_iter().filter(|s| !s.is_empty()).collect::>().join(" · "), confline = if f.votes.is_empty() { format!("conf {:.2}", f.confidence) } else { format!("{} · conf {:.2}", esc(&f.votes), f.confidence) }, - endpoint = esc(&f.endpoint), payload = esc(&f.payload), evidence = esc(&f.evidence), + endpoint = esc(&f.endpoint), + where_ = esc(&location_line(f)), + steps = { + // Numbered, pasteable commands. A reader who cannot + // reproduce a finding has to take it on faith, and a report + // that must be believed is worth less than one that can be + // checked. + let items: String = repro_steps(f).iter() + .map(|st| format!("
  • {}
  • ", esc(st))) + .collect(); + format!("
      {items}
    ") + }, + payloadblock = if f.payload.trim().is_empty() { String::new() } else { + format!("

    Payload

    {}
    ", esc(f.payload.trim())) + }, + scripts = { + let names = poc_scripts(f, &available_pocs); + if names.is_empty() { String::new() } else { + let items: String = names.iter() + .map(|n| format!("
  • pocs/{n}
  • ", n = esc(n))) + .collect(); + format!("

    Runnable script (extra)

    The steps above are the proof; this script automates them.

      {items}
    ") + } + }, + evidence = esc(&technical_evidence(f)), impact = esc(&f.impact), remediation = esc(&f.remediation), status = if needs_review(f) { "needs-review" } else { "confirmed" }, shots = if f.screenshots.is_empty() { String::new() } else { @@ -186,6 +220,183 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String // ===== Typst report ===== +// --------------------------------------------------------------------------- +// Proof of concept & technical evidence +// +// A finding is only useful if the reader can (a) find the problem, (b) see why +// it matters, (c) fix it, and (d) reproduce it without trusting us. The report +// used to print a payload blob and an evidence blob, which serves (d) badly and +// the rest not at all: "payload: ' OR 1=1--" tells a developer nothing about +// WHERE to look, and a PoC script attached as a file is a black box unless you +// run it. +// +// So the PoC is rendered as steps a person can paste, with the script offered +// as an extra artifact rather than as the proof itself. +// --------------------------------------------------------------------------- + +/// Where the problem is, in one line, as precisely as the finding allows. +pub fn location_line(f: &Finding) -> String { + match (f.location.trim(), f.endpoint.trim()) { + ("", "") => "(location not recorded)".into(), + ("", ep) => ep.to_string(), + (loc, "") => loc.to_string(), + (loc, ep) if loc.contains(ep) => loc.to_string(), + (loc, ep) => format!("{ep} — {loc}"), + } +} + +/// A curl command that reproduces the request, built from the structured +/// evidence when the agent recorded it and from the endpoint/payload otherwise. +pub fn curl_command(f: &Finding) -> String { + if let Some(ev) = &f.evidence_data { + if let Some(a) = &ev.attack { + let mut cmd = String::from("curl -i -s"); + let method = a.method.to_uppercase(); + if !method.is_empty() && method != "GET" { + cmd.push_str(&format!(" -X {method}")); + } + for (k, v) in &a.request_headers { + // Never print a real credential into a document that gets + // shared; the reader substitutes their own. + let val = if is_secret_header(k) { "" } else { v.as_str() }; + cmd.push_str(&format!(" \\\n -H '{k}: {val}'")); + } + if !f.payload.trim().is_empty() && method != "GET" { + cmd.push_str(&format!(" \\\n --data-raw '{}'", f.payload.replace('\'', "'\\''"))); + } + cmd.push_str(&format!(" \\\n '{}'", a.url)); + return cmd; + } + } + if f.endpoint.trim().is_empty() { + return String::new(); + } + format!("curl -i -s '{}'", f.endpoint.trim()) +} + +fn is_secret_header(k: &str) -> bool { + let k = k.to_lowercase(); + k == "authorization" || k == "cookie" || k == "x-api-key" || k.contains("token") || k.contains("secret") +} + +/// Ordered reproduction steps. Uses what the agent recorded; falls back to a +/// minimal derived sequence so every finding carries something runnable. +pub fn repro_steps(f: &Finding) -> Vec { + if !f.repro_steps.is_empty() { + return f.repro_steps.clone(); + } + let mut steps = Vec::new(); + let curl = curl_command(f); + if let Some(ev) = &f.evidence_data { + if let Some(b) = &ev.baseline { + steps.push(format!("Baseline — request the same resource without the payload:\ncurl -i -s '{}'", b.url)); + } + if ev.identity_a.is_some() && ev.identity_b.is_some() { + let a = ev.identity_a.as_ref().unwrap(); + let b = ev.identity_b.as_ref().unwrap(); + steps.push(format!("As {}: curl -i -s '{}'", if a.identity.is_empty() { "the owner" } else { &a.identity }, a.url)); + steps.push(format!("As {}: request the SAME resource:\ncurl -i -s '{}'", if b.identity.is_empty() { "the other identity" } else { &b.identity }, b.url)); + steps.push("Compare the two bodies — the second returning the first's data is the finding.".into()); + return steps; + } + } + if !curl.is_empty() { + steps.push(format!("Send the request carrying the payload:\n{curl}")); + } + if !f.payload.trim().is_empty() { + steps.push(format!("Payload used:\n{}", f.payload.trim())); + } + if steps.is_empty() { + steps.push("No reproduction steps were recorded for this finding.".into()); + } + steps +} + +/// The detailed technical evidence: the measured difference between baseline +/// and attack, then the raw exchanges. This is what turns "it returned a 500" +/// into something a reviewer can check. +pub fn technical_evidence(f: &Finding) -> String { + let mut out = String::new(); + if let Some(ev) = &f.evidence_data { + if let (Some(b), Some(a)) = (&ev.baseline, &ev.attack) { + let d = crate::validation::diff(b, a); + out.push_str(&format!( + "MEASURED DIFFERENCE\n baseline : {} {} · {} bytes · {} ms\n attack : {} {} · {} bytes · {} ms\n delta : {}\n", + b.status, b.url, b.len(), b.elapsed_ms, + a.status, a.url, a.len(), a.elapsed_ms, + d.describe() + )); + if !ev.repeats.is_empty() { + let (ok, hits) = crate::validation::reproducible(b, &ev.repeats, 2); + out.push_str(&format!( + " repeats : {hits}/{} reproduced the same difference{}\n", + ev.repeats.len(), + if ok { "" } else { " — NOT deterministic" } + )); + } + out.push('\n'); + } + if !ev.marker.is_empty() { + out.push_str(&format!( + "CONTROLLED MARKER\n {} — observed: {}{}{}\n\n", + ev.marker, + if ev.marker_observed { "yes" } else { "no" }, + if ev.browser_executed { " · executed in a real browser" } else { "" }, + if ev.callback_received { " · out-of-band callback received" } else { "" }, + )); + } + for (label, x) in [("BASELINE", &ev.baseline), ("ATTACK", &ev.attack), ("AS OWNER", &ev.identity_a), ("AS OTHER IDENTITY", &ev.identity_b)] { + if let Some(x) = x { + out.push_str(&render_exchange(label, x)); + } + } + } + if !f.evidence.trim().is_empty() { + out.push_str("AGENT-RECORDED EVIDENCE\n"); + out.push_str(f.evidence.trim()); + out.push('\n'); + } + out.trim_end().to_string() +} + +fn render_exchange(label: &str, x: &crate::validation::Exchange) -> String { + let mut s = format!("{label}\n {} {} → {}\n", if x.method.is_empty() { "GET" } else { &x.method }, x.url, x.status); + if !x.identity.is_empty() { + s.push_str(&format!(" identity: {}\n", x.identity)); + } + for k in ["location", "set-cookie", "content-type", "access-control-allow-origin", "access-control-allow-credentials", "x-frame-options", "content-security-policy", "retry-after"] { + let v = x.header(k); + if !v.is_empty() { + s.push_str(&format!(" {k}: {}\n", clip(v, 200))); + } + } + if !x.body.trim().is_empty() { + s.push_str(&format!(" body ({} bytes, excerpt):\n{}\n", x.len(), indent(&clip(x.body.trim(), 1200), " "))); + } + s.push('\n'); + s +} + +fn clip(s: &str, n: usize) -> String { + if s.chars().count() <= n { + return s.to_string(); + } + let cut: String = s.chars().take(n).collect(); + format!("{cut}…") +} + +fn indent(s: &str, pad: &str) -> String { + s.lines().map(|l| format!("{pad}{l}")).collect::>().join("\n") +} + +/// PoC scripts this finding cites, or that the run wrote. The script is an +/// extra artifact — the steps above are the proof. +pub fn poc_scripts(f: &Finding, available: &[String]) -> Vec { + let cited = format!("{} {} {}", f.evidence, f.payload, f.repro_steps.join(" ")); + let matches: Vec = available.iter().filter(|p| cited.contains(p.as_str())).cloned().collect(); + matches +} + /// Is the `typst` binary available on PATH? fn typst_available() -> bool { std::env::var_os("PATH") @@ -236,12 +447,16 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io:: let shots = format!("({})", f.screenshots.iter().map(|p| format!("{},", tq(p))).collect::()); data.push_str(&format!( - " (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}),\n", + " (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}, location: {}, steps: {}),\n", tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&owasp), tq(&f.cvss), - tq(&f.endpoint), tq(&f.payload), tq(&f.evidence), tq(&f.impact), + tq(&f.endpoint), tq(&f.payload), tq(&technical_evidence(f)), tq(&f.impact), tq(&f.remediation), tq(&f.votes), f.confidence, tq(status), tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }), shots, + tq(&location_line(f)), + // The PDF gets the same pasteable steps as the HTML — a printed + // report that cannot be reproduced is the one people argue with. + tq(&repro_steps(f).iter().enumerate().map(|(i, st)| format!("{}. {}", i + 1, st)).collect::>().join("\n\n")), )); } data.push_str(")\n\n"); @@ -325,15 +540,26 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St if needs_review(f) && !f.review_reason.is_empty() { s.push_str(&format!("> ⚠️ **Needs human review** — {}\n\n", f.review_reason)); } - if !f.endpoint.is_empty() { s.push_str(&format!("**Endpoint:** `{}`\n\n", f.endpoint)); } - if !f.payload.is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload)); } - if !f.evidence.is_empty() { s.push_str(&format!("**Evidence**\n```\n{}\n```\n\n", f.evidence)); } + // Order follows how a reader works through a finding: where it is, what + // it means, how to fix it, then how to see it for themselves. + s.push_str(&format!("**Where the problem is:** {}\n\n", location_line(f))); + if !f.impact.is_empty() { s.push_str(&format!("**What it means:** {}\n\n", f.impact)); } + if !f.remediation.is_empty() { s.push_str(&format!("**How to fix it:** {}\n\n", f.remediation)); } + let steps = repro_steps(f); + if !steps.is_empty() { + s.push_str("**Proof of concept — step by step**\n\n"); + for (n, st) in steps.iter().enumerate() { + s.push_str(&format!("{}. ```\n{}\n```\n", n + 1, st)); + } + s.push('\n'); + } + if !f.payload.trim().is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload.trim())); } + let tech = technical_evidence(f); + if !tech.is_empty() { s.push_str(&format!("**Technical evidence**\n```\n{}\n```\n\n", tech)); } if !f.screenshots.is_empty() { s.push_str("**Proof screenshots**\n\n"); for p in &f.screenshots { s.push_str(&format!("![{}]({})\n\n", f.title.replace(']', ")"), p)); } } - if !f.impact.is_empty() { s.push_str(&format!("**Impact:** {}\n\n", f.impact)); } - if !f.remediation.is_empty() { s.push_str(&format!("**Remediation:** {}\n\n", f.remediation)); } s.push_str("---\n\n"); s }; diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index eaf57f5..b93cd2b 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -77,6 +77,16 @@ pub struct Finding { /// report can embed each image next to its vulnerability. #[serde(default)] pub screenshots: Vec, + /// Exactly where the problem is: the parameter, field, header, flow step or + /// `file:line` — not just the URL. "POST /api/orders, field `role` in the + /// JSON body" is actionable; an endpoint alone sends the reader hunting. + #[serde(default)] + pub location: String, + /// Literal, ordered commands that reproduce the finding from a clean shell. + /// A reader must be able to paste them one by one — this is the difference + /// between a report that can be verified and one that has to be believed. + #[serde(default)] + pub repro_steps: Vec, /// Structured artifacts for the deterministic validation engine: the /// baseline/attack pair, repeats, markers, identity pair. Agents that /// follow the evidence contract emit this alongside the finding, and @@ -114,6 +124,8 @@ impl Default for Finding { review_status: String::new(), review_reason: String::new(), screenshots: Vec::new(), + location: String::new(), + repro_steps: Vec::new(), evidence_data: None, } } diff --git a/neurosploit-rs/templates/report.typ b/neurosploit-rs/templates/report.typ index a5904fa..015044d 100644 --- a/neurosploit-rs/templates/report.typ +++ b/neurosploit-rs/templates/report.typ @@ -10,7 +10,8 @@ // #let meta = (target: "", run_id: "", generated: "", model: "") // #let findings = ( (severity: "", title: "", agent: "", cwe: "", cvss: "", // endpoint: "", payload: "", evidence: "", impact: "", -// remediation: "", votes: "", confidence: 0.0), ... ) +// remediation: "", votes: "", confidence: 0.0, +// location: "", steps: ""), ... ) #let sevcolor = ( Critical: rgb("#c0392b"), High: rgb("#e67e22"), Medium: rgb("#f1c40f"), @@ -140,9 +141,12 @@ text(8pt, fill: gray)[Location], text(8pt)[#raw(f.endpoint)], text(8pt, fill: gray)[Agent], text(8pt)[#raw(f.agent)], ) - #v(4pt) #strong[Description / Impact] #linebreak() #text(9pt)[#f.impact] - #v(4pt) #strong[Proof of Concept] #linebreak() #raw(f.payload) - #v(3pt) #strong[Evidence] #linebreak() #raw(f.evidence) + #v(4pt) #strong[Where the problem is] #linebreak() #text(9pt)[#f.at("location", default: f.endpoint)] + #v(4pt) #strong[What it means] #linebreak() #text(9pt)[#f.impact] + #v(4pt) #strong[How to fix it] #linebreak() #text(9pt)[#f.remediation] + #v(4pt) #strong[Proof of concept — step by step] #linebreak() #raw(f.at("steps", default: f.payload)) + #if f.payload != "" [ #v(3pt) #strong[Payload] #linebreak() #raw(f.payload) ] + #v(3pt) #strong[Technical evidence] #linebreak() #raw(f.evidence) #let shots = f.at("screenshots", default: ()) #if shots.len() > 0 [ #v(4pt) #strong[Proof Screenshots] @@ -154,7 +158,7 @@ ] ] ] - #v(3pt) #strong[Remediation] #linebreak() #text(9pt)[#f.remediation] + ] #v(8pt) ] diff --git a/web/public/app.js b/web/public/app.js index 816df56..7c25a84 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -827,11 +827,16 @@ function openFindingModal(f, pocs, runId) { const impactCombined = bizText && bizText !== impactText ? [impactText, bizText].filter(Boolean).join('\n\n') : impactText; + // A reader works through a finding in a fixed order — where is it, what does + // it mean, how do I fix it, how do I see it myself. The old layout led with + // an evidence dump, which answers the last question first and the first three + // not at all. $('#fmSection-evidence').innerHTML = - proseBlock('Description', clean(f.evidence)) + - codeBlock('Technical evidence', [f.endpoint, f.payload].filter(Boolean).join('\n\n')); - $('#fmSection-impact').innerHTML = proseBlock('Impact', impactCombined); - $('#fmSection-remediation').innerHTML = proseBlock('Remediation', clean(f.remediation)); + proseBlock('Where the problem is', locationLine(f)) + + proseBlock('What it means', impactCombined || clean(f.evidence)) + + proseBlock('How to fix it', clean(f.remediation)); + $('#fmSection-impact').innerHTML = pocBlock(f, pocs, runId); + $('#fmSection-remediation').innerHTML = codeBlock('Technical evidence', technicalEvidence(f)); $('#fmSection-chains').innerHTML = ((f.chains_from || []).length ? `
    Chains from: ${esc(f.chains_from.join(', '))}
    ` : '') + (attributed ? '
    Identified and validated by NeuroSploit (multi-model adversarial validation) — full methodology in the generated report.
    ' : ''); @@ -839,12 +844,13 @@ function openFindingModal(f, pocs, runId) { // Proof of concept — doctrine tells agents to cite the PoC's file name in // `evidence` (see pocs_line() in pipeline.rs), so match on that text first; // fall back to whatever the run wrote to pocs/ if nothing was cited. - const citedIn = `${f.evidence || ''} ${f.payload || ''}`; - const matches = (pocs || []).filter((p) => citedIn.includes(p)); - const list = matches.length ? matches : (pocs || []); + // Scripts this finding cites are rendered inside the PoC block above; this + // list is the run's remaining scripts, so nothing written is hidden. + const citedIn = `${f.evidence || ''} ${f.payload || ''} ${(f.repro_steps || []).join(' ')}`; + const list = (pocs || []).filter((p) => !citedIn.includes(p)); const pocRoot = $('#fmPocList'); if (!list.length) { - pocRoot.textContent = 'No PoC script written for this finding yet — the exploiting agent only writes one when the finding warrants a runnable repro.'; + pocRoot.textContent = 'No other scripts from this run.'; } else { pocRoot.innerHTML = list.map((name) => `
    @@ -1258,6 +1264,109 @@ function renderAttackPath(container, allFindings, target, graph) { g.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }); }); } +/// Exactly where the problem is: parameter/field/flow, not just the URL. +function locationLine(f) { + const loc = (f.location || '').trim(); + const ep = (f.endpoint || '').trim(); + if (!loc && !ep) return '(location not recorded)'; + if (!loc) return ep; + if (!ep || loc.includes(ep)) return loc; + return `${ep} — ${loc}`; +} + +function isSecretHeader(k) { + const n = k.toLowerCase(); + return n === 'authorization' || n === 'cookie' || n === 'x-api-key' || n.includes('token') || n.includes('secret'); +} + +/// A pasteable curl for the recorded request. Credentials are redacted: a +/// finding gets shared, and a live session cookie in a document is a new bug. +function curlCommand(f) { + const a = f.evidence_data?.attack; + if (a) { + let cmd = 'curl -i -s'; + const m = (a.method || 'GET').toUpperCase(); + if (m !== 'GET') cmd += ` -X ${m}`; + for (const [k, v] of Object.entries(a.request_headers || {})) { + cmd += ` \\\n -H '${k}: ${isSecretHeader(k) ? '' : v}'`; + } + if (f.payload && m !== 'GET') cmd += ` \\\n --data-raw '${String(f.payload).replace(/'/g, "'\\''")}'`; + cmd += ` \\\n '${a.url}'`; + return cmd; + } + return f.endpoint ? `curl -i -s '${f.endpoint}'` : ''; +} + +/// Ordered steps: what the agent recorded, or a minimal derived sequence. +function reproSteps(f) { + if (Array.isArray(f.repro_steps) && f.repro_steps.length) return f.repro_steps; + const steps = []; + const ev = f.evidence_data; + if (ev?.identity_a && ev?.identity_b) { + steps.push(`As ${ev.identity_a.identity || 'the owner'}:\ncurl -i -s '${ev.identity_a.url}'`); + steps.push(`As ${ev.identity_b.identity || 'the other identity'}, request the SAME resource:\ncurl -i -s '${ev.identity_b.url}'`); + steps.push('Compare the two bodies — the second returning the first\u2019s data is the finding.'); + return steps; + } + if (ev?.baseline) steps.push(`Baseline — the same resource without the payload:\ncurl -i -s '${ev.baseline.url}'`); + const curl = curlCommand(f); + if (curl) steps.push(`Send the request carrying the payload:\n${curl}`); + if (f.payload) steps.push(`Payload used:\n${String(f.payload).trim()}`); + return steps.length ? steps : ['No reproduction steps were recorded for this finding.']; +} + +/// The measured difference plus the raw exchanges — what turns "it returned a +/// 500" into something a reviewer can check. +function technicalEvidence(f) { + const ev = f.evidence_data; + let out = ''; + const ex = (label, x) => { + if (!x) return ''; + let s = `${label}\n ${(x.method || 'GET')} ${x.url} → ${x.status}\n`; + if (x.identity) s += ` identity: ${x.identity}\n`; + for (const k of ['location', 'set-cookie', 'content-type', 'access-control-allow-origin', 'access-control-allow-credentials', 'x-frame-options', 'content-security-policy', 'retry-after']) { + const v = (x.headers || {})[k]; + if (v) s += ` ${k}: ${String(v).slice(0, 200)}\n`; + } + if (x.body?.trim()) s += ` body (${x.body.length} bytes, excerpt):\n${x.body.trim().slice(0, 1200).split('\n').map((l) => ' ' + l).join('\n')}\n`; + return s + '\n'; + }; + if (ev?.baseline && ev?.attack) { + const b = ev.baseline, a = ev.attack; + const ratio = b.body?.length ? (a.body.length / b.body.length) : 0; + out += `MEASURED DIFFERENCE\n baseline : ${b.status} · ${b.body?.length ?? 0} bytes · ${b.elapsed_ms ?? 0} ms\n` + + ` attack : ${a.status} · ${a.body?.length ?? 0} bytes · ${a.elapsed_ms ?? 0} ms\n` + + ` delta : status ${b.status} → ${a.status}, body ${((ratio - 1) * 100).toFixed(0)}%\n`; + if (ev.repeats?.length) out += ` repeats : ${ev.repeats.length} recorded\n`; + out += '\n'; + } + if (ev?.marker) { + out += `CONTROLLED MARKER\n ${ev.marker} — observed: ${ev.marker_observed ? 'yes' : 'no'}` + + `${ev.browser_executed ? ' · executed in a real browser' : ''}${ev.callback_received ? ' · out-of-band callback' : ''}\n\n`; + } + out += ex('BASELINE', ev?.baseline) + ex('ATTACK', ev?.attack) + ex('AS OWNER', ev?.identity_a) + ex('AS OTHER IDENTITY', ev?.identity_b); + if (f.evidence?.trim()) out += `AGENT-RECORDED EVIDENCE\n${f.evidence.trim()}\n`; + return out.trim(); +} + +/// The proof: numbered steps, then the payload, with any script offered as an +/// extra artifact rather than as the proof itself. +function pocBlock(f, pocs, runId) { + const steps = reproSteps(f).map((s) => `
  • ${esc(s)}
  • `).join(''); + const payload = f.payload?.trim() + ? `
    ${esc(f.payload.trim())}
    ` : ''; + const cited = `${f.evidence || ''} ${f.payload || ''} ${(f.repro_steps || []).join(' ')}`; + const scripts = (pocs || []).filter((p) => cited.includes(p)); + const scriptBlock = scripts.length + ? `
    +
    The steps above are the proof; this script automates them.
    + ${scripts.map((n) => `
    pocs/${esc(n)} + Open raw
    `).join('')}
    ` + : ''; + return `
    +
      ${steps}
    ${payload}${scriptBlock}`; +} + function trimMid(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; @@ -1454,6 +1563,15 @@ async function loadDetail(id) { reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`; show(reportLink, true); } else show(reportLink, false); + // The PDF is produced by the harness (Typst) when that binary is present, so + // it is offered only when it actually exists — a dead download button is + // worse than none. + const pdfLink = $('#detailOpenPdf'); + if (detail.assets.includes('report.pdf')) { + pdfLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.pdf`; + pdfLink.setAttribute('download', `${id}.pdf`); + show(pdfLink, true); + } else show(pdfLink, false); if (detail.status?.state === 'running') state.detailPoll = setInterval(() => loadDetail(id), 4000); } diff --git a/web/public/index.html b/web/public/index.html index 3021bb0..a7862e0 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -305,6 +305,7 @@
    +
    @@ -442,7 +443,7 @@
    - +
    —
    diff --git a/web/public/style.css b/web/public/style.css index 5cd5a0d..b61a068 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -510,6 +510,16 @@ textarea { resize: vertical; min-height: 72px; } .data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; } .data-table tbody tr { cursor: pointer; } .data-table tbody tr:hover { background: var(--surface-2); } +/* Numbered, pasteable steps — the proof a reader can check without trusting + the report. */ +.poc-steps { margin: var(--sp-2) 0 0; padding-left: 22px; display: flex; flex-direction: column; gap: var(--sp-2); } +.poc-steps li { font-size: 12px; color: var(--text-dim); } +.poc-steps .step { + margin: 4px 0 0; background: var(--surface-2); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); + font-family: var(--mono); font-size: 11.5px; color: var(--text); + white-space: pre-wrap; overflow-wrap: anywhere; +} .poc-file { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); } .poc-file .fn { font-family: var(--mono); font-size: 12px; flex: 1; } .poc-pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); font-family: var(--mono); font-size: 11.5px; max-height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: var(--sp-2); }