diff --git a/env.sh b/env.sh new file mode 100755 index 0000000..ddeea73 --- /dev/null +++ b/env.sh @@ -0,0 +1,52 @@ +# NeuroSploit environment — source this to use `neurosploit` in the CURRENT shell +# without reinstalling or opening a new terminal: +# +# source env.sh # from a repo checkout or an install dir +# source ~/.neurosploit-app/env.sh +# +# It exports: +# NEUROSPLOIT_BASE the app/agents base dir (agents_md lives here) +# NEUROSPLOIT full path to the neurosploit binary +# PATH prepended with the binary's dir so `neurosploit` resolves +# +# Honors NEUROSPLOIT_DIR to point at a custom install dir. Safe to source twice. + +# Resolve where this script lives (works when sourced from bash or zsh). +if [ -n "${BASH_SOURCE:-}" ]; then _ns_self="${BASH_SOURCE[0]}" +elif [ -n "${ZSH_VERSION:-}" ]; then _ns_self="${(%):-%N}" +else _ns_self="$0"; fi +_ns_here="$(cd "$(dirname "$_ns_self")" >/dev/null 2>&1 && pwd)" + +# Pick the base dir: explicit override → this script's dir → default install dir. +_ns_base="${NEUROSPLOIT_DIR:-$_ns_here}" + +# Find the binary: alongside the base, in a repo release build, or on PATH. +_ns_bin="" +for _c in \ + "$_ns_base/neurosploit" \ + "$_ns_here/neurosploit" \ + "$_ns_here/neurosploit-rs/target/release/neurosploit" \ + "$_ns_here/target/release/neurosploit" \ + "$HOME/.neurosploit-app/neurosploit" +do + if [ -x "$_c" ]; then _ns_bin="$_c"; break; fi +done +if [ -z "$_ns_bin" ] && command -v neurosploit >/dev/null 2>&1; then + _ns_bin="$(command -v neurosploit)" +fi + +if [ -z "$_ns_bin" ]; then + echo "neurosploit binary not found — run setup.sh (or set NEUROSPLOIT_DIR) first." >&2 +else + # agents_md sits next to the binary unless the base already has it. + if [ -d "$_ns_base/agents_md" ]; then :; else _ns_base="$(dirname "$_ns_bin")"; fi + export NEUROSPLOIT_BASE="$_ns_base" + export NEUROSPLOIT="$_ns_bin" + case ":$PATH:" in + *":$(dirname "$_ns_bin"):"*) : ;; # already on PATH + *) export PATH="$(dirname "$_ns_bin"):$PATH" ;; + esac + echo "NeuroSploit ready — NEUROSPLOIT=$NEUROSPLOIT · NEUROSPLOIT_BASE=$NEUROSPLOIT_BASE" +fi + +unset _ns_self _ns_here _ns_base _ns_bin _c diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 4d09f64..c49872e 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -504,6 +504,13 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender)> = stream::iter(selected.iter().cloned()) @@ -512,6 +519,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender-N.png so the report + // can embed each image beside its vulnerability. + if let Some(dir) = cfg.workdir.as_deref() { + let imgs = collect_evidence(&mut findings, Path::new(dir)); + if imgs > 0 { + let _ = tx.send(format!("notify: 📸 {imgs} proof screenshot(s) collected → evidence/")).await; + } + } // RL update (robust reward shaping): an agent's reward per run = // + strong for each CONFIRMED finding (severity × confidence), @@ -1391,12 +1409,128 @@ fn extract_findings(text: &str, agent: &str) -> Vec { confidence: conf(o.get("confidence")), validated: false, votes: String::new(), + screenshots: screenshot_refs(o), ..Default::default() }) }) .collect() } +/// Pull screenshot path(s) an agent referenced in its finding JSON. Accepts a +/// single `screenshot` string or a `screenshots` array (any scalar coerced to +/// string). These are raw refs (whatever the agent named/where it saved); the +/// evidence-collection pass later resolves and renames them to stable, +/// finding-correlated paths under the run's `evidence/` dir. +fn screenshot_refs(o: &serde_json::Map) -> Vec { + let mut out = Vec::new(); + match o.get("screenshots") { + Some(serde_json::Value::Array(a)) => { + for v in a { + let p = match v { + serde_json::Value::String(t) => t.trim().to_string(), + _ => v.to_string(), + }; + if !p.is_empty() { out.push(p); } + } + } + Some(serde_json::Value::String(t)) if !t.trim().is_empty() => out.push(t.trim().to_string()), + _ => {} + } + let one = s(o, "screenshot"); + if !one.is_empty() && !out.contains(&one) { out.push(one); } + out +} + +/// The convention prompt that tells agents WHERE to drop proof screenshots and +/// HOW to reference them, so each image can be correlated back to its finding. +/// `evidence_dir` is the absolute `/evidence` path (already created). +fn screenshot_doctrine(evidence_dir: &str) -> String { + format!( + "EVIDENCE SCREENSHOTS (correlate each image to its finding):\n\ + - When you PROVE a finding visually (XSS firing, an admin panel reached, \ + data exposed, a client-side auth bypass), capture a screenshot as proof.\n\ + - Save every proof PNG into this exact directory: `{dir}` (it already exists). \ + Name each file after the vulnerability using a short kebab-case slug, e.g. \ + `{dir}/reflected-xss-search.png`, `{dir}/idor-order-42.png`.\n\ + - In that finding's JSON, add a `screenshots` array listing the file paths you saved \ + (absolute like `{dir}/idor-order-42.png`, or just the basename `idor-order-42.png`). \ + One image per distinct proof; multiple allowed. Omit the field when you have no image.\n\ + - The screenshot must belong to THAT finding — never reuse one image across unrelated findings.\n\n", + dir = evidence_dir, + ) +} + +/// Resolve, dedupe and copy each finding's referenced proof screenshots into +/// `/evidence/-.png`, rewriting `Finding.screenshots` to +/// those stable, run-relative paths. Unresolved refs are dropped (so the report +/// never embeds a missing image). Returns the number of images collected. +fn collect_evidence(findings: &mut [Finding], workdir: &Path) -> usize { + let evidence = workdir.join("evidence"); + if std::fs::create_dir_all(&evidence).is_err() { return 0; } + let mut total = 0usize; + for f in findings.iter_mut() { + if f.screenshots.is_empty() { continue; } + let slug = slugify(if f.id.is_empty() { &f.title } else { &f.id }); + let mut stable = Vec::new(); + let mut n = 0usize; + for raw in f.screenshots.clone() { + let Some(src) = resolve_screenshot(&raw, workdir, &evidence) else { continue }; + n += 1; + let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_lowercase(); + let fname = format!("{slug}-{n}.{ext}"); + let dst = evidence.join(&fname); + // Copy unless the agent already wrote it exactly there. + let ok = src == dst || std::fs::copy(&src, &dst).is_ok(); + if ok { + stable.push(format!("evidence/{fname}")); + total += 1; + } + } + stable.dedup(); + f.screenshots = stable; + } + total +} + +/// Find the actual file an agent referenced, trying the sensible locations a +/// screenshot could have landed in: absolute, relative to the workdir, inside +/// the evidence dir, or by basename in the evidence dir / `/tmp`. +fn resolve_screenshot(raw: &str, workdir: &Path, evidence: &Path) -> Option { + let is_img = |p: &Path| p.is_file() + && matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(), + Some("png" | "jpg" | "jpeg" | "webp" | "gif")); + let base = Path::new(raw).file_name().map(PathBuf::from); + let mut cands: Vec = vec![ + PathBuf::from(raw), + workdir.join(raw), + evidence.join(raw), + ]; + if let Some(b) = &base { + cands.push(evidence.join(b)); + cands.push(workdir.join(b)); + cands.push(Path::new("/tmp").join(b)); + } + cands.into_iter().find(|p| is_img(p)) +} + +/// Filesystem-safe kebab slug for correlating an image filename to a finding. +fn slugify(s: &str) -> String { + let mut out = String::new(); + let mut dash = false; + for c in s.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + dash = false; + } else if !dash && !out.is_empty() { + out.push('-'); + dash = true; + } + } + let trimmed = out.trim_matches('-'); + let slug: String = trimmed.chars().take(48).collect(); + if slug.is_empty() { "finding".into() } else { slug } +} + /// Coerce any JSON scalar to a trimmed string. fn s(o: &serde_json::Map, k: &str) -> String { match o.get(k) { @@ -1811,3 +1945,52 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await; finish(cfg, lib, "{}".into(), transcript, findings, agents, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await } + +#[cfg(test)] +mod evidence_tests { + use super::*; + use crate::types::Finding; + + fn write_png(p: &Path) { + // Minimal valid 1x1 PNG. + const PNG: &[u8] = &[ + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53, + 0xde,0x00,0x00,0x00,0x0c,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0x00, + 0x00,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x49,0x45,0x4e, + 0x44,0xae,0x42,0x60,0x82, + ]; + std::fs::write(p, PNG).unwrap(); + } + + #[test] + fn slugify_correlates_filename_to_finding() { + assert_eq!(slugify("Reflected XSS in /search?q="), "reflected-xss-in-search-q"); + assert_eq!(slugify("IDOR \u{2014} order #42"), "idor-order-42"); + assert_eq!(slugify("!!!"), "finding"); + } + + #[test] + fn collect_evidence_resolves_and_renames_by_finding_id() { + let base = std::env::temp_dir().join(format!("nrs-ev-{}", std::process::id())); + let wd = base.join("run"); + let ev = wd.join("evidence"); + std::fs::create_dir_all(&ev).unwrap(); + write_png(&ev.join("whatever-agent-named-it.png")); + let mut fs = vec![Finding { + id: "xss-search".into(), + title: "Reflected XSS".into(), + screenshots: vec!["whatever-agent-named-it.png".into()], + ..Default::default() + }]; + let n = collect_evidence(&mut fs, &wd); + assert_eq!(n, 1); + assert_eq!(fs[0].screenshots, vec!["evidence/xss-search-1.png".to_string()]); + assert!(wd.join("evidence/xss-search-1.png").is_file()); + let mut miss = vec![Finding { id: "x".into(), title: "t".into(), + screenshots: vec!["nope.png".into()], ..Default::default() }]; + assert_eq!(collect_evidence(&mut miss, &wd), 0); + assert!(miss[0].screenshots.is_empty()); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index aa6501b..290db74 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -75,11 +75,17 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String "

{} {}. {}{review}

\
{} · {} · CVSS {} · votes {} · conf {:.2}
\
Endpoint: {}
{authline}{reviewnote}\ -

Payload

{}

Evidence

{}
\ +

Payload

{}

Evidence

{}
{shots}\

Impact

{}

Remediation

{}

", sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title), esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence, esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation), + shots = if f.screenshots.is_empty() { String::new() } else { + let imgs: String = f.screenshots.iter() + .map(|p| format!("
\"proof
{}
", + esc(p), esc(&f.title), esc(p))).collect(); + format!("

Proof screenshots

{imgs}
") + }, review = if needs_review(f) { " NEEDS REVIEW" } else { "" }, reviewnote = if needs_review(f) && !f.review_reason.is_empty() { format!("
⚠ Needs human review — {}
", esc(&f.review_reason)) @@ -128,6 +134,9 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String .sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\ pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\ h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\ + .shots{{display:flex;flex-wrap:wrap;gap:12px;margin:6px 0}}\ + .shot{{margin:0;max-width:100%}}.shot img{{max-width:100%;border:1px solid #e3e3e3;border-radius:8px;display:block}}\ + .shot figcaption{{color:#888;font-size:11px;margin-top:3px;font-family:ui-monospace,Menlo,monospace}}\ .b{{color:#8b5cf6;font-weight:800}}\

NeuroSploit Penetration Test Report

\
Asset: {asset} · Target: {t}{techline} · v3.6.5 · multi-model validated
\ @@ -188,12 +197,15 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io:: for f in &sorted { let owasp = if f.owasp.is_empty() { f.cwe.clone() } else { f.owasp.clone() }; let status = if needs_review(f) { "needs-review" } else { "confirmed" }; + 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: {}),\n", + " (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}),\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.remediation), tq(&f.votes), f.confidence, tq(status), tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }), + shots, )); } data.push_str(")\n\n"); @@ -280,6 +292,10 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St 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)); } + 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"); diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index f2ae12b..a704ed7 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -70,6 +70,13 @@ pub struct Finding { /// "below vote quorum", "no receipt", "failed adversarial refute"). #[serde(default)] pub review_reason: String, + /// Proof screenshots for this finding, as paths RELATIVE to the run workdir + /// (e.g. "evidence/-1.png"). Populated by the evidence-collection + /// pass, which resolves whatever the agent captured and copies it under the + /// run's `evidence/` dir with a deterministic, finding-correlated name so the + /// report can embed each image next to its vulnerability. + #[serde(default)] + pub screenshots: Vec, } impl Default for Finding { @@ -100,6 +107,7 @@ impl Default for Finding { secret: String::new(), review_status: String::new(), review_reason: String::new(), + screenshots: Vec::new(), } } } diff --git a/neurosploit-rs/templates/report.typ b/neurosploit-rs/templates/report.typ index 259d833..a5904fa 100644 --- a/neurosploit-rs/templates/report.typ +++ b/neurosploit-rs/templates/report.typ @@ -143,6 +143,17 @@ #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) + #let shots = f.at("screenshots", default: ()) + #if shots.len() > 0 [ + #v(4pt) #strong[Proof Screenshots] + #for sp in shots [ + #v(3pt) + #block(breakable: false, width: 100%)[ + #box(stroke: 0.5pt + rgb("#dddddd"), radius: 4pt, clip: true, image(sp, width: 100%)) + #v(2pt) #text(7pt, fill: gray, font: "Menlo")[#sp] + ] + ] + ] #v(3pt) #strong[Remediation] #linebreak() #text(9pt)[#f.remediation] ] #v(8pt) diff --git a/setup.sh b/setup.sh index 6eb0ccf..24e38e9 100755 --- a/setup.sh +++ b/setup.sh @@ -120,6 +120,15 @@ fi mkdir -p "$PREFIX" ln -sf "$DIR/neurosploit" "$PREFIX/neurosploit" ok "Linked → $PREFIX/neurosploit" + +# ---- write a source-able env script (activate in the CURRENT shell) ---- +cat > "$DIR/env.sh" </dev/null || echo neurosploit)" # ---- persist env (PATH + NEUROSPLOIT_BASE) so it runs from any folder ---- @@ -152,8 +161,9 @@ for t in curl nmap rustscan ffuf node npx typst; do done echo -ok "Installed. Open a NEW terminal — or run now with:" -echo " export NEUROSPLOIT_BASE=\"$DIR\"; export PATH=\"$PREFIX:\$PATH\"" +ok "Installed. Open a NEW terminal — or activate now with:" +echo " source \"$DIR/env.sh\"" +echo " (equivalently: export NEUROSPLOIT_BASE=\"$DIR\"; export PATH=\"$PREFIX:\$PATH\")" echo " then, from ANY folder:" echo " neurosploit # interactive session" echo " neurosploit run http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 -v"