mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-28 04:10:25 +02:00
feat: richer report — asset/business identification, exec summary, vuln table, accounts, conclusion
- Identify the ASSET (product/org + tech stack), not just the URL: probe extracts page title, fingerprints tech, matches known apps (Juice Shop, DVWA, WordPress…) and reads a business/brand hint (og:site_name / application-name / © copyright). Written to meta.json after the liveness probe; the run log now prints the asset. - report.rs: EngagementMeta + read_meta; markdown() rebuilt with Asset-under-test, written Executive Summary, Vulnerability table (severity/status/CWE-OWASP), Test accounts created (from the vault), detailed confirmed findings, Needs-review section, and a written Conclusion. html() names the asset+stack. json_report() gains an asset block. typst_report() reads meta and injects asset/exec/conclusion/ accounts/status/auth; Typst template upgraded (cover asset, asset table, status column, needs-review badge, accounts + conclusion sections). - probe.rs: Probe.brand + extract_brand(); parse_forms/brand covered by tests. - Verified: Typst template compiles to PDF with the new fields; 16 tests pass.
This commit is contained in:
@@ -1,6 +1,26 @@
|
||||
use crate::types::Finding;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Engagement metadata for the report: names the ASSET (product + stack), not just
|
||||
/// the URL. Read from `meta.json` written by the pipeline after the probe.
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct EngagementMeta {
|
||||
#[serde(default)] pub target: String,
|
||||
#[serde(default)] pub asset: String,
|
||||
#[serde(default)] pub title: String,
|
||||
#[serde(default)] pub tech: Vec<String>,
|
||||
#[serde(default)] pub server: String,
|
||||
#[serde(default)] pub status: u16,
|
||||
}
|
||||
|
||||
/// Read `<dir>/meta.json` if present (best-effort).
|
||||
pub fn read_meta(dir: &Path) -> EngagementMeta {
|
||||
std::fs::read_to_string(dir.join("meta.json")).ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
/// The blank, structured Typst template (rendering logic). Data (`meta`,
|
||||
/// `findings`) is prepended by `typst_report` to make a self-contained file.
|
||||
const TYPST_TEMPLATE: &str = include_str!("../../../templates/report.typ");
|
||||
@@ -30,7 +50,7 @@ fn esc(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Render an HTML report for the validated findings.
|
||||
pub fn html(target: &str, findings: &[Finding]) -> String {
|
||||
pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String {
|
||||
let mut sorted = findings.to_vec();
|
||||
sorted.sort_by_key(|f| sev_rank(&f.severity));
|
||||
|
||||
@@ -110,10 +130,12 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
|
||||
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
|
||||
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
|
||||
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
||||
<div class=meta>Target: <b>{t}</b> · v3.6.5 Rust harness · multi-model validated</div>\
|
||||
<div class=meta>Asset: <b>{asset}</b> · Target: <b>{t}</b>{techline} · v3.6.5 · multi-model validated</div>\
|
||||
<div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\
|
||||
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.6.5 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
||||
<p class=meta>Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and adversarial refute; \"needs-review\" are flagged for a human.<br>NeuroSploit v3.6.5 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
||||
t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block,
|
||||
asset = esc(if meta.asset.is_empty() { "unidentified web asset" } else { &meta.asset }),
|
||||
techline = if meta.tech.is_empty() { String::new() } else { format!(" · {}", esc(&meta.tech.join(", "))) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,20 +166,34 @@ fn tq(s: &str) -> String {
|
||||
pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
|
||||
let meta = read_meta(dir);
|
||||
|
||||
// Prose blocks + account list rendered in Rust, passed as strings to Typst.
|
||||
let sorted = sorted_findings(findings);
|
||||
let confirmed: Vec<&Finding> = sorted.iter().filter(|f| !needs_review(f)).collect();
|
||||
let review: Vec<&Finding> = sorted.iter().filter(|f| needs_review(f)).collect();
|
||||
let asset = if meta.asset.is_empty() { "unidentified web asset".to_string() } else { meta.asset.clone() };
|
||||
let accounts = findings.iter().find(|f| f.id == "test-accounts").map(|f| f.evidence.clone()).unwrap_or_default();
|
||||
|
||||
let mut data = String::new();
|
||||
data.push_str(&format!(
|
||||
"#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n",
|
||||
tq(target), tq(&run_id), tq("NeuroSploit v3.6.5"), tq("multi-model")
|
||||
"#let meta = (target: {}, asset: {}, tech: {}, server: {}, run_id: {}, generated: {}, model: {}, exec: {}, conclusion: {}, accounts: {})\n",
|
||||
tq(target), tq(&asset), tq(&meta.tech.join(", ")), tq(&meta.server),
|
||||
tq(&run_id), tq("July 2026"), tq("multi-model"),
|
||||
tq(&strip_md(&exec_summary(target, &meta, &confirmed, &review))),
|
||||
tq(&strip_md(&conclusion(target, &meta, &confirmed, &review))),
|
||||
tq(&strip_md(&accounts)),
|
||||
));
|
||||
data.push_str("#let findings = (\n");
|
||||
for f in sorted_findings(findings) {
|
||||
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" };
|
||||
data.push_str(&format!(
|
||||
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}),\n",
|
||||
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}),\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(&f.remediation), tq(&f.votes), f.confidence, tq(status),
|
||||
tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }),
|
||||
));
|
||||
}
|
||||
data.push_str(")\n\n");
|
||||
@@ -182,30 +218,61 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
|
||||
/// True if a finding is flagged for human review (kept, not deleted).
|
||||
fn needs_review(f: &Finding) -> bool { f.review_status == "needs-review" }
|
||||
|
||||
/// Render a Markdown report. Confirmed findings first, then a clearly separated
|
||||
/// "Needs human review" section (uncertain findings the harness kept instead of
|
||||
/// deleting, so the human makes the final call).
|
||||
pub fn markdown(target: &str, findings: &[Finding]) -> String {
|
||||
/// Strip Markdown emphasis/backticks so prose renders cleanly inside Typst.
|
||||
fn strip_md(s: &str) -> String {
|
||||
s.replace("**", "").replace('`', "").replace('*', "")
|
||||
}
|
||||
|
||||
/// Written prose executive summary: names the asset, the counts, and the top risks.
|
||||
fn exec_summary(target: &str, meta: &EngagementMeta, confirmed: &[&Finding], review: &[&Finding]) -> String {
|
||||
let asset = if meta.asset.is_empty() { format!("the web asset at `{target}`") }
|
||||
else { format!("**{}** (`{target}`)", meta.asset) };
|
||||
let stack = if meta.tech.is_empty() { String::new() }
|
||||
else { format!(" The asset fingerprints as {}.", meta.tech.join(", ")) };
|
||||
if confirmed.is_empty() && review.is_empty() {
|
||||
return format!("This authorized engagement assessed {asset}.{stack} No findings were \
|
||||
produced: candidate issues were either unproven or rejected by multi-model adversarial \
|
||||
validation. The asset presented no confirmed weaknesses within the tested scope.\n\n");
|
||||
}
|
||||
let by_sev = |list: &[&Finding], s: &str| list.iter().filter(|f| f.severity == s).count();
|
||||
let crit = by_sev(confirmed, "Critical");
|
||||
let high = by_sev(confirmed, "High");
|
||||
let med = by_sev(confirmed, "Medium");
|
||||
let low = by_sev(confirmed, "Low");
|
||||
let mut risk = Vec::new();
|
||||
if crit > 0 { risk.push(format!("{crit} critical")); }
|
||||
if high > 0 { risk.push(format!("{high} high")); }
|
||||
if med > 0 { risk.push(format!("{med} medium")); }
|
||||
if low > 0 { risk.push(format!("{low} low")); }
|
||||
let riskline = if risk.is_empty() { "no severity-rated confirmed".into() } else { risk.join(", ") };
|
||||
let top: Vec<String> = confirmed.iter().take(3).map(|f| format!("*{}*", f.title)).collect();
|
||||
let topline = if top.is_empty() { String::new() } else { format!(" The most significant confirmed issues are {}.", top.join(", ")) };
|
||||
let reviewline = if review.is_empty() { String::new() }
|
||||
else { format!(" A further **{}** finding(s) are flagged **needs-review** — kept for a human analyst to adjudicate rather than discarded.", review.len()) };
|
||||
format!("This authorized penetration test assessed {asset}.{stack} The engagement confirmed \
|
||||
**{} finding(s)** ({riskline}) via multi-model voting, tool-receipt grounding and an adversarial \
|
||||
refute pass.{topline}{reviewline} Details, evidence and remediation follow.\n\n", confirmed.len())
|
||||
}
|
||||
|
||||
/// Render a Markdown report: asset identification, executive summary, a
|
||||
/// vulnerability table, created test accounts (from the vault), detailed
|
||||
/// confirmed findings, a separate needs-review section, and a written conclusion.
|
||||
pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String {
|
||||
let sorted = sorted_findings(findings);
|
||||
let confirmed: Vec<&Finding> = sorted.iter().filter(|f| !needs_review(f)).collect();
|
||||
let review: Vec<&Finding> = sorted.iter().filter(|f| needs_review(f)).collect();
|
||||
|
||||
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for f in &confirmed { *counts.entry(f.severity.as_str()).or_default() += 1; }
|
||||
let chips = if counts.is_empty() { "none".to_string() }
|
||||
else { counts.iter().map(|(s, n)| format!("{s}: {n}")).collect::<Vec<_>>().join(" · ") };
|
||||
|
||||
let fmt = |f: &Finding, i: usize| -> String {
|
||||
let mut s = format!("### {}. [{}] {}\n\n", i + 1, f.severity, f.title);
|
||||
let mut meta = vec![format!("**Agent:** {}", f.agent)];
|
||||
if !f.cwe.is_empty() { meta.push(format!("**CWE:** {}", f.cwe)); }
|
||||
if !f.owasp.is_empty() { meta.push(format!("**OWASP:** {}", f.owasp)); }
|
||||
if !f.cvss.is_empty() { meta.push(format!("**CVSS:** {}", f.cvss)); }
|
||||
if !f.votes.is_empty() { meta.push(format!("**Votes:** {}", f.votes)); }
|
||||
meta.push(format!("**Confidence:** {:.2}", f.confidence));
|
||||
if !f.auth_context.is_empty() { meta.push(format!("**Auth:** {}", f.auth_context)); }
|
||||
if !f.account.is_empty() { meta.push(format!("**Account:** {}", f.account)); }
|
||||
s.push_str(&meta.join(" · "));
|
||||
let mut m = vec![format!("**Agent:** {}", f.agent)];
|
||||
if !f.cwe.is_empty() { m.push(format!("**CWE:** {}", f.cwe)); }
|
||||
if !f.owasp.is_empty() { m.push(format!("**OWASP:** {}", f.owasp)); }
|
||||
if !f.cvss.is_empty() { m.push(format!("**CVSS:** {}", f.cvss)); }
|
||||
if !f.votes.is_empty() { m.push(format!("**Votes:** {}", f.votes)); }
|
||||
m.push(format!("**Confidence:** {:.2}", f.confidence));
|
||||
if !f.auth_context.is_empty() { m.push(format!("**Auth:** {}", f.auth_context)); }
|
||||
if !f.account.is_empty() { m.push(format!("**Account:** {}", f.account)); }
|
||||
s.push_str(&m.join(" · "));
|
||||
s.push_str("\n\n");
|
||||
if needs_review(f) && !f.review_reason.is_empty() {
|
||||
s.push_str(&format!("> ⚠️ **Needs human review** — {}\n\n", f.review_reason));
|
||||
@@ -220,25 +287,89 @@ pub fn markdown(target: &str, findings: &[Finding]) -> String {
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("# NeuroSploit Report — {target}\n\n"));
|
||||
out.push_str("_by Joas A Santos & Red Team Leaders · NeuroSploit v3.6.5_\n\n");
|
||||
out.push_str(&format!("**Confirmed:** {} ({}) · **Needs review:** {}\n\n", confirmed.len(), chips, review.len()));
|
||||
out.push_str("> Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and an adversarial refute pass. \"Needs review\" findings are uncertain — kept for a human to adjudicate, not deleted.\n\n");
|
||||
out.push_str("# NeuroSploit Penetration Test Report\n\n");
|
||||
out.push_str("_by Joas A Santos & Red Team Leaders · NeuroSploit v3.6.5 · confidential_\n\n");
|
||||
|
||||
// --- Asset under test ---
|
||||
out.push_str("## Asset under test\n\n");
|
||||
out.push_str(&format!("- **Asset:** {}\n", if meta.asset.is_empty() { "unidentified web asset".into() } else { meta.asset.clone() }));
|
||||
out.push_str(&format!("- **URL / target:** `{target}`\n"));
|
||||
if !meta.title.is_empty() { out.push_str(&format!("- **Page title:** {}\n", meta.title)); }
|
||||
if !meta.tech.is_empty() { out.push_str(&format!("- **Technology:** {}\n", meta.tech.join(", "))); }
|
||||
if !meta.server.is_empty() { out.push_str(&format!("- **Server:** {}\n", meta.server)); }
|
||||
out.push_str("\n");
|
||||
|
||||
// --- Executive summary ---
|
||||
out.push_str("## Executive summary\n\n");
|
||||
out.push_str(&exec_summary(target, meta, &confirmed, &review));
|
||||
|
||||
// --- Vulnerability table ---
|
||||
out.push_str("## Vulnerability summary\n\n");
|
||||
if confirmed.is_empty() && review.is_empty() {
|
||||
out.push_str("_No findings._\n\n");
|
||||
} else {
|
||||
out.push_str("| # | Vulnerability | Severity | CWE / OWASP | Status | Auth |\n");
|
||||
out.push_str("|---|---------------|----------|-------------|--------|------|\n");
|
||||
for (i, f) in sorted.iter().enumerate() {
|
||||
let owc = if !f.owasp.is_empty() { f.owasp.clone() } else { f.cwe.clone() };
|
||||
let status = if needs_review(f) { "needs-review" } else { "confirmed" };
|
||||
let auth = if f.auth_context.is_empty() { "-" } else { f.auth_context.as_str() };
|
||||
out.push_str(&format!("| {} | {} | {} | {} | {} | {} |\n",
|
||||
i + 1, f.title.replace('|', "\\|"), f.severity, owc.replace('|', "\\|"), status, auth));
|
||||
}
|
||||
out.push_str("\n");
|
||||
}
|
||||
|
||||
// --- Test accounts created (from the vault cleanup finding) ---
|
||||
if let Some(acc) = findings.iter().find(|f| f.id == "test-accounts") {
|
||||
out.push_str("## Test accounts created (delete after)\n\n");
|
||||
out.push_str("These accounts were created to reach the authenticated surface. Credentials are in the run vault (`.neurosploit/vault/<run-id>.json`); delete them once testing is complete.\n\n");
|
||||
out.push_str(&format!("{}\n\n", acc.evidence));
|
||||
}
|
||||
|
||||
// --- Detailed confirmed findings ---
|
||||
out.push_str(&format!("## Confirmed findings ({})\n\n", confirmed.len()));
|
||||
if confirmed.is_empty() { out.push_str("_None._\n\n"); }
|
||||
if confirmed.is_empty() { out.push_str("_None confirmed._\n\n"); }
|
||||
else { for (i, f) in confirmed.iter().enumerate() { out.push_str(&fmt(f, i)); } }
|
||||
|
||||
// --- Needs-review ---
|
||||
if !review.is_empty() {
|
||||
out.push_str(&format!("## Needs human review ({}) — signalled, not deleted\n\n", review.len()));
|
||||
out.push_str("The harness kept these uncertain findings for a human to adjudicate instead of discarding them.\n\n");
|
||||
for (i, f) in review.iter().enumerate() { out.push_str(&fmt(f, i)); }
|
||||
}
|
||||
|
||||
// --- Conclusion ---
|
||||
out.push_str("## Conclusion\n\n");
|
||||
out.push_str(&conclusion(target, meta, &confirmed, &review));
|
||||
out
|
||||
}
|
||||
|
||||
/// Written conclusion paragraph.
|
||||
fn conclusion(target: &str, meta: &EngagementMeta, confirmed: &[&Finding], review: &[&Finding]) -> String {
|
||||
let asset = if meta.asset.is_empty() { format!("the asset at `{target}`") } else { format!("**{}**", meta.asset) };
|
||||
let has_high = confirmed.iter().any(|f| f.severity == "Critical" || f.severity == "High");
|
||||
let mut s = String::new();
|
||||
if confirmed.is_empty() && review.is_empty() {
|
||||
s.push_str(&format!("Within the tested scope, {asset} did not yield confirmed vulnerabilities. \
|
||||
This is not proof of absence — re-test after changes and widen scope (authenticated flows, \
|
||||
business logic, and any endpoints not reachable during this run).\n"));
|
||||
} else {
|
||||
s.push_str(&format!("The assessment of {asset} confirmed {} finding(s)", confirmed.len()));
|
||||
if has_high { s.push_str(" including high-impact issues that warrant prompt remediation"); }
|
||||
s.push_str(". Prioritise fixes by severity, then re-test to verify closure.");
|
||||
if !review.is_empty() {
|
||||
s.push_str(&format!(" {} additional finding(s) are flagged for human review — a security \
|
||||
analyst should adjudicate these before they are accepted or dismissed.", review.len()));
|
||||
}
|
||||
s.push_str(" Remediation guidance accompanies each finding above.\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Structured JSON report: run metadata + findings split into confirmed and
|
||||
/// needs-review buckets (plus the flat list). Machine-consumable.
|
||||
pub fn json_report(target: &str, findings: &[Finding], run_id: &str) -> String {
|
||||
pub fn json_report(target: &str, findings: &[Finding], run_id: &str, meta: &EngagementMeta) -> String {
|
||||
let confirmed: Vec<&Finding> = findings.iter().filter(|f| !needs_review(f)).collect();
|
||||
let review: Vec<&Finding> = findings.iter().filter(|f| needs_review(f)).collect();
|
||||
let v = serde_json::json!({
|
||||
@@ -246,6 +377,12 @@ pub fn json_report(target: &str, findings: &[Finding], run_id: &str) -> String {
|
||||
"version": "3.6.5",
|
||||
"target": target,
|
||||
"run_id": run_id,
|
||||
"asset": {
|
||||
"name": if meta.asset.is_empty() { "unidentified web asset" } else { &meta.asset },
|
||||
"title": meta.title,
|
||||
"tech": meta.tech,
|
||||
"server": meta.server,
|
||||
},
|
||||
"summary": {
|
||||
"confirmed": confirmed.len(),
|
||||
"needs_review": review.len(),
|
||||
@@ -263,9 +400,10 @@ pub fn json_report(target: &str, findings: &[Finding], run_id: &str) -> String {
|
||||
pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
|
||||
std::fs::write(dir.join("report.md"), markdown(target, findings))?;
|
||||
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id))?;
|
||||
std::fs::write(dir.join("report.html"), html(target, findings))?;
|
||||
let meta = read_meta(dir);
|
||||
std::fs::write(dir.join("report.md"), markdown(target, findings, &meta))?;
|
||||
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?;
|
||||
std::fs::write(dir.join("report.html"), html(target, findings, &meta))?;
|
||||
typst_report(target, findings, dir)
|
||||
}
|
||||
|
||||
@@ -282,12 +420,14 @@ mod tests {
|
||||
let review = Finding { title: "Maybe SSRF".into(), severity: "Medium".into(),
|
||||
review_status: "needs-review".into(), review_reason: "below vote quorum (1/3)".into(),
|
||||
confidence: 0.33, ..Default::default() };
|
||||
let md = markdown("http://t", &[confirmed, review.clone()]);
|
||||
let meta = EngagementMeta { asset: "OWASP Juice Shop".into(), ..Default::default() };
|
||||
let md = markdown("http://t", &[confirmed, review.clone()], &meta);
|
||||
assert!(md.contains("## Confirmed findings (1)"));
|
||||
assert!(md.contains("## Needs human review (1)"));
|
||||
assert!(md.contains("Needs human review") && md.contains("below vote quorum"));
|
||||
assert!(md.contains("OWASP Juice Shop")); // asset named, not just URL
|
||||
|
||||
let js = json_report("http://t", &[review], "run1");
|
||||
let js = json_report("http://t", &[review], "run1", &meta);
|
||||
let v: serde_json::Value = serde_json::from_str(&js).unwrap();
|
||||
assert_eq!(v["summary"]["needs_review"], 1);
|
||||
assert_eq!(v["summary"]["confirmed"], 0);
|
||||
|
||||
Reference in New Issue
Block a user