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); }