mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +02:00
feat(report): findings that answer where / why / how to fix, with a pasteable PoC
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8ebe05507
commit
c3a6132153
@@ -682,8 +682,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||
{directives}{react}{depth}{decision}{spa}{safety}{ops}{doctrine}{shots}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret,screenshots}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,location,payload,evidence,repro_steps,impact,remediation,confidence,auth_context,account,secret,screenshots}}. \
|
||||
Write for a developer who has never seen this app and has to fix it today:\n\
|
||||
- `location`: EXACTLY where it is — the parameter, form field, header, JSON key, or flow step (e.g. \"POST /api/orders, JSON field `role`\"). An endpoint alone sends them hunting.\n\
|
||||
- `impact`: what an attacker gets, in one or two plain sentences tied to THIS app's data or users. No boilerplate, no hedging.\n\
|
||||
- `remediation`: the concrete change, naming the control (parameterised query, server-side authorisation check, allowlist), not \"sanitise input\".\n\
|
||||
- `repro_steps`: an ORDERED array of literal commands someone can paste one by one from a clean shell — baseline request first, then the attack, then how to read the result. Include the real URL and the real payload.\n\
|
||||
- `evidence`: the concrete proof (request/response excerpt with status, key headers and the decisive part of the body). \
|
||||
A PoC script is an EXTRA artifact, never a substitute for these steps. \
|
||||
`screenshots` is an array of proof-image paths you saved into the evidence dir (see EVIDENCE SCREENSHOTS above); omit or leave empty when you captured none. \
|
||||
Set `auth_context` to \"authenticated\" or \"unauthenticated\"; set `account` to the test user/role you used (if any); \
|
||||
for a created test account set `secret` to its generated password (it is stored in the run vault and masked in the report).",
|
||||
@@ -1687,7 +1693,10 @@ fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding])
|
||||
put("findings.json", serde_json::to_string_pretty(findings).unwrap_or_else(|_| "[]".into()));
|
||||
put("findings.md", findings_md(&cfg.target, findings));
|
||||
let meta = cfg.workdir.as_deref().map(|d| report::read_meta(Path::new(d))).unwrap_or_default();
|
||||
put("report.html", report::html(&cfg.target, findings, &meta));
|
||||
let pocs: Vec<String> = 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<Finding> {
|
||||
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")),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
<tr><td class=fk>Agent</td><td>{agent}</td>{authcell}</tr>\
|
||||
</table>\
|
||||
{reviewnote}\
|
||||
<h4>Description / Impact</h4><p>{impact}</p>\
|
||||
<h4>Proof of Concept</h4><pre>{payload}</pre>\
|
||||
<h4>Evidence</h4><pre>{evidence}</pre>{shots}\
|
||||
<h4>Remediation</h4><p>{remediation}</p></section>",
|
||||
<h4>Where the problem is</h4><p class=where>{where_}</p>\
|
||||
<h4>What it means</h4><p>{impact}</p>\
|
||||
<h4>How to fix it</h4><p>{remediation}</p>\
|
||||
<h4>Proof of concept — step by step</h4>{steps}\
|
||||
{payloadblock}\
|
||||
<h4>Technical evidence</h4><pre>{evidence}</pre>{shots}\
|
||||
{scripts}</section>",
|
||||
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::<Vec<_>>().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!("<li><pre class=step>{}</pre></li>", esc(st)))
|
||||
.collect();
|
||||
format!("<ol class=steps>{items}</ol>")
|
||||
},
|
||||
payloadblock = if f.payload.trim().is_empty() { String::new() } else {
|
||||
format!("<h4>Payload</h4><pre class=payload>{}</pre>", 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!("<li><a href=\"pocs/{n}\"><code>pocs/{n}</code></a></li>", n = esc(n)))
|
||||
.collect();
|
||||
format!("<h4>Runnable script (extra)</h4><p class=hint>The steps above are the proof; this script automates them.</p><ul class=pocs>{items}</ul>")
|
||||
}
|
||||
},
|
||||
evidence = esc(&technical_evidence(f)),
|
||||
impact = esc(&f.impact), remediation = esc(&f.remediation),
|
||||
status = if needs_review(f) { "<span style=color:#8e44ad>needs-review</span>" } else { "<span style=color:#27ae60>confirmed</span>" },
|
||||
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) { "<redacted — use your own>" } 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<String> {
|
||||
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::<Vec<_>>().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<String> {
|
||||
let cited = format!("{} {} {}", f.evidence, f.payload, f.repro_steps.join(" "));
|
||||
let matches: Vec<String> = 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::<String>());
|
||||
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::<Vec<_>>().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
|
||||
};
|
||||
|
||||
@@ -77,6 +77,16 @@ pub struct Finding {
|
||||
/// report can embed each image next to its vulnerability.
|
||||
#[serde(default)]
|
||||
pub screenshots: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
+126
-8
@@ -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 ? `<div class="field-help">Chains from: ${esc(f.chains_from.join(', '))}</div>` : '') +
|
||||
(attributed ? '<div class="field-help" style="margin-top:6px;">Identified and validated by NeuroSploit (multi-model adversarial validation) — full methodology in the generated report.</div>' : '');
|
||||
@@ -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) => `
|
||||
<div class="poc-file">
|
||||
@@ -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) ? '<redacted — use your own>' : 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) => `<li><pre class="step">${esc(s)}</pre></li>`).join('');
|
||||
const payload = f.payload?.trim()
|
||||
? `<div class="field-group"><label class="field-label">Payload</label><pre class="poc-pre">${esc(f.payload.trim())}</pre></div>` : '';
|
||||
const cited = `${f.evidence || ''} ${f.payload || ''} ${(f.repro_steps || []).join(' ')}`;
|
||||
const scripts = (pocs || []).filter((p) => cited.includes(p));
|
||||
const scriptBlock = scripts.length
|
||||
? `<div class="field-group"><label class="field-label">Runnable script (extra)</label>
|
||||
<div class="field-help">The steps above are the proof; this script automates them.</div>
|
||||
${scripts.map((n) => `<div class="poc-file"><span class="fn">pocs/${esc(n)}</span>
|
||||
<a class="btn btn-sm" href="/api/runs/${esc(runId)}/asset/pocs/${esc(n)}" target="_blank">Open raw</a></div>`).join('')}</div>`
|
||||
: '';
|
||||
return `<div class="field-group"><label class="field-label">Proof of concept — step by step</label>
|
||||
<ol class="poc-steps">${steps}</ol></div>${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);
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@
|
||||
</div>
|
||||
<div class="run-actions">
|
||||
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
|
||||
<button class="btn" id="btnDetailBack">← New engagement</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -442,7 +443,7 @@
|
||||
<div id="fmSection-chains"></div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Proof of concept</label>
|
||||
<label class="field-label">Other scripts from this run</label>
|
||||
<div id="fmPocList" class="field-help">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user