mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-14 13:40:23 +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:
@@ -348,6 +348,46 @@ fn looks_like_spa_api(recon: &str) -> bool {
|
||||
.iter().filter(|m| r.contains(*m)).count() >= 1
|
||||
}
|
||||
|
||||
/// Name the ASSET behind the URL — the product + tech stack — so the report says
|
||||
/// what was tested, not just an IP/URL. Recognises common known apps by their
|
||||
/// page title; otherwise uses the title and the fingerprinted tech.
|
||||
fn identify_asset(p: &crate::probe::Probe) -> String {
|
||||
let hay = format!("{} {} {}", p.title, p.tech.join(" "), p.server).to_lowercase();
|
||||
let known = [
|
||||
("juice shop", "OWASP Juice Shop"), ("juice-shop", "OWASP Juice Shop"),
|
||||
("dvwa", "DVWA"), ("webgoat", "WebGoat"), ("gruyere", "Google Gruyere"),
|
||||
("bwapp", "bWAPP"), ("mutillidae", "Mutillidae"), ("gitlab", "GitLab"),
|
||||
("jenkins", "Jenkins"), ("wordpress", "WordPress"), ("drupal", "Drupal"),
|
||||
("joomla", "Joomla"), ("grafana", "Grafana"), ("kibana", "Kibana"),
|
||||
("jira", "Jira"), ("confluence", "Confluence"), ("phpmyadmin", "phpMyAdmin"),
|
||||
];
|
||||
let product = known.iter().find(|(k, _)| hay.contains(k)).map(|(_, n)| n.to_string());
|
||||
let title = if p.title.trim().is_empty() { String::new() } else { p.title.trim().to_string() };
|
||||
let brand = p.brand.trim().to_string();
|
||||
let tech = if p.tech.is_empty() { String::new() } else { format!(" [{}]", p.tech.join(", ")) };
|
||||
// Prefer a KNOWN product; else the org/brand from the page; else the title.
|
||||
let name = product
|
||||
.or_else(|| if brand.is_empty() { None } else { Some(brand) })
|
||||
.or_else(|| if title.is_empty() { None } else { Some(title) });
|
||||
match name {
|
||||
Some(n) => format!("{n}{tech}"),
|
||||
None => if tech.is_empty() { "unidentified web asset".into() } else { format!("web asset{tech}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `meta.json` (asset, tech, server, title) into the run dir so the report
|
||||
/// generator can name the asset and its stack instead of only the URL.
|
||||
fn write_meta(cfg: &RunConfig, p: &crate::probe::Probe, asset: &str) {
|
||||
let Some(dir) = cfg.workdir.as_deref() else { return };
|
||||
let meta = serde_json::json!({
|
||||
"target": cfg.target, "asset": asset, "title": p.title, "brand": p.brand,
|
||||
"tech": p.tech, "server": p.server, "status": p.status,
|
||||
});
|
||||
if let Ok(j) = serde_json::to_string_pretty(&meta) {
|
||||
let _ = std::fs::write(format!("{}/meta.json", dir.trim_end_matches('/')), j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
|
||||
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||
pool.set_progress(tx.clone());
|
||||
@@ -378,7 +418,10 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let artifacts = persist(&cfg, "{}", "", &[]);
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: vec![], candidates: 0, recon: String::new(), artifacts };
|
||||
}
|
||||
let _ = tx.send(format!("✓ target is UP (HTTP {}) — starting recon", p.status)).await;
|
||||
// Identify the ASSET (product + stack), not just the URL, for the report.
|
||||
let asset = identify_asset(&p);
|
||||
let _ = tx.send(format!("✓ target is UP (HTTP {}) — {} — starting recon", p.status, asset)).await;
|
||||
write_meta(&cfg, &p, &asset);
|
||||
crate::probe::probe_json(&p)
|
||||
};
|
||||
let recon = if cfg.offline {
|
||||
@@ -1267,7 +1310,8 @@ 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));
|
||||
put("report.html", report::html(&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));
|
||||
written
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ pub struct Probe {
|
||||
pub cookies: Vec<CookieFlags>,
|
||||
pub cors: Cors,
|
||||
pub scripts: Vec<String>,
|
||||
/// Business/brand hint extracted from the page (og:site_name, application-name,
|
||||
/// or a "© <Name>" copyright) so the report can name the org, not just the URL.
|
||||
pub brand: String,
|
||||
pub forms: usize,
|
||||
/// Parsed forms (action/method/fields) so an agent can auto-submit them —
|
||||
/// e.g. register a test account to reach the authenticated surface.
|
||||
@@ -124,6 +127,40 @@ fn attr(tag: &str, name: &str) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Best-effort business/brand name from the page: `og:site_name` or
|
||||
/// `application-name` meta, else a "© <Name>" / "Copyright <Name>" notice. Helps
|
||||
/// the report name the organisation/product instead of only the URL.
|
||||
fn extract_brand(body: &str) -> String {
|
||||
let low = body.to_lowercase();
|
||||
// <meta property="og:site_name" content="X"> / name="application-name"
|
||||
for key in ["og:site_name", "application-name", "author", "twitter:site"] {
|
||||
if let Some(i) = low.find(key) {
|
||||
let seg = &body[i..(i + 220).min(body.len())];
|
||||
if let Some(c) = between(seg, "content=\"", "\"").or_else(|| between(seg, "content='", "'")) {
|
||||
let c = c.trim();
|
||||
if c.len() >= 2 && c.len() <= 60 { return c.to_string(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copyright notice: "© Company" or "Copyright 2024 Company".
|
||||
for marker in ["©", "©", "copyright"] {
|
||||
if let Some(i) = low.find(marker) {
|
||||
let seg: String = body[i..].chars().take(80).collect();
|
||||
// strip the marker + a year, take the first capitalised words.
|
||||
let cleaned = seg.replace('©', " ").replace("©", " ");
|
||||
let cleaned = cleaned.trim_start_matches(|c: char| !c.is_alphabetic());
|
||||
let name: String = cleaned.split(|c: char| c == '<' || c == '.' || c == '|' || c == '\n')
|
||||
.next().unwrap_or("").chars().filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '&' || *c == '-')
|
||||
.collect::<String>().trim().to_string();
|
||||
// drop a leading year like "2024 "
|
||||
let name = name.split_whitespace().filter(|w| !w.chars().all(|c| c.is_ascii_digit()))
|
||||
.collect::<Vec<_>>().join(" ");
|
||||
if name.len() >= 2 && name.len() <= 50 && name.to_lowercase() != "copyright" { return name; }
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Best-effort parse of the HTML `<form>`s on a page so agents can auto-submit
|
||||
/// them (e.g. register a test account) without re-parsing. Non-destructive: this
|
||||
/// only READS the markup. Bounded to the first few forms.
|
||||
@@ -226,6 +263,7 @@ pub async fn probe(target: &str) -> Probe {
|
||||
}
|
||||
p.forms = body.matches("<form").count();
|
||||
p.form_details = parse_forms(&body);
|
||||
p.brand = extract_brand(&body);
|
||||
// linked scripts (src="...")
|
||||
for cap in body.split("<script").skip(1) {
|
||||
if let Some(src) = between(cap, "src=\"", "\"").or_else(|| between(cap, "src='", "'")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -37,28 +37,38 @@
|
||||
#v(2pt)
|
||||
#text(15pt, fill: gray)[Penetration Test Report]
|
||||
#v(1cm)
|
||||
#text(13pt)[Target: #strong(meta.target)]
|
||||
#text(14pt)[Asset: #strong(meta.asset)]
|
||||
#v(4pt)
|
||||
#text(11pt, fill: gray)[#meta.target]
|
||||
#v(2pt)
|
||||
#if meta.tech != "" [ #text(9pt, fill: gray)[Stack: #meta.tech] #v(2pt) ]
|
||||
#v(6pt)
|
||||
#text(10pt, fill: gray)[Run #meta.run_id · #meta.generated · models: #meta.model]
|
||||
#v(8pt)
|
||||
#text(9pt, fill: gray)[by #strong[Joas A Santos] & #strong[Red Team Leaders]]
|
||||
]
|
||||
#pagebreak()
|
||||
|
||||
// ---- Asset under test ----
|
||||
= Asset Under Test
|
||||
#table(columns: (auto, 1fr), inset: 6pt, stroke: 0.5pt + rgb("#dddddd"), align: left + horizon,
|
||||
text(9pt, fill: gray)[Asset], text(9pt)[#strong(meta.asset)],
|
||||
text(9pt, fill: gray)[URL / target], text(9pt)[#raw(meta.target)],
|
||||
..(if meta.tech != "" { (text(9pt, fill: gray)[Technology], text(9pt)[#meta.tech]) } else { () }),
|
||||
..(if meta.server != "" { (text(9pt, fill: gray)[Server], text(9pt)[#meta.server]) } else { () }),
|
||||
)
|
||||
#v(8pt)
|
||||
|
||||
// ---- Executive summary ----
|
||||
= Executive Summary
|
||||
#text(10pt)[#meta.exec]
|
||||
|
||||
#let counts = (:)
|
||||
#for f in findings {
|
||||
counts.insert(f.severity, counts.at(f.severity, default: 0) + 1)
|
||||
if f.status != "needs-review" { counts.insert(f.severity, counts.at(f.severity, default: 0) + 1) }
|
||||
}
|
||||
#if findings.len() == 0 [
|
||||
No validated findings were produced for this engagement. All candidate issues
|
||||
were either unproven or rejected by multi-model adversarial validation.
|
||||
] else [
|
||||
This engagement produced #strong(str(findings.len())) validated finding(s),
|
||||
each confirmed by multi-model voting.
|
||||
|
||||
#v(6pt)
|
||||
#grid(columns: 5, gutter: 8pt,
|
||||
..("Critical", "High", "Medium", "Low", "Info").map(s => box(
|
||||
@@ -84,14 +94,26 @@
|
||||
stroke: 0.5pt + rgb("#dddddd"),
|
||||
table.header(
|
||||
text(weight: "bold")[\#], text(weight: "bold")[Vulnerability],
|
||||
text(weight: "bold")[Severity], text(weight: "bold")[CVSS], text(weight: "bold")[OWASP / CWE],
|
||||
text(weight: "bold")[Severity], text(weight: "bold")[Status], text(weight: "bold")[OWASP / CWE],
|
||||
),
|
||||
..sorted.enumerate().map(((i, f)) => (
|
||||
str(i + 1), f.title, sevbadge(f.severity), f.cvss, f.owasp,
|
||||
str(i + 1), f.title, sevbadge(f.severity),
|
||||
if f.status == "needs-review" { text(8pt, fill: rgb("#8e44ad"))[needs-review] } else { text(8pt, fill: rgb("#27ae60"))[confirmed] },
|
||||
f.owasp,
|
||||
)).flatten()
|
||||
)
|
||||
]
|
||||
|
||||
// ---- Test accounts created ----
|
||||
#if meta.accounts != "" [
|
||||
#v(8pt)
|
||||
== Test Accounts Created (delete after)
|
||||
#v(3pt)
|
||||
#text(9pt, fill: gray)[Created to reach the authenticated surface. Credentials are in the run vault (.neurosploit/vault/<run-id>.json); delete once testing is complete.]
|
||||
#v(3pt)
|
||||
#block(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#faf7ff"), text(9pt)[#meta.accounts])
|
||||
]
|
||||
|
||||
#v(10pt)
|
||||
#line(length: 100%, stroke: 0.5pt + gray)
|
||||
|
||||
@@ -103,15 +125,18 @@
|
||||
#for (i, f) in sorted.enumerate() [
|
||||
#block(breakable: false, width: 100%, inset: 10pt, radius: 6pt,
|
||||
stroke: (left: 3pt + sevcolor.at(f.severity, default: gray), rest: 0.5pt + rgb("#dddddd")))[
|
||||
#sevbadge(f.severity) #h(6pt) #text(12pt, weight: "bold")[#str(i + 1). #f.title]
|
||||
#sevbadge(f.severity) #h(6pt)
|
||||
#if f.status == "needs-review" [ #box(fill: rgb("#8e44ad"), inset: (x: 5pt, y: 2pt), radius: 3pt, text(fill: white, weight: "bold", size: 8pt)[NEEDS REVIEW]) #h(6pt) ]
|
||||
#text(12pt, weight: "bold")[#str(i + 1). #f.title]
|
||||
#v(4pt)
|
||||
#table(
|
||||
columns: (auto, 1fr, auto, 1fr),
|
||||
inset: 4pt, stroke: none, align: left + horizon,
|
||||
text(8pt, fill: gray)[Criticality], text(8pt)[#f.severity],
|
||||
text(8pt, fill: gray)[CVSS], text(8pt)[#f.cvss],
|
||||
text(8pt, fill: gray)[Status], text(8pt)[#f.status],
|
||||
text(8pt, fill: gray)[OWASP/CWE], text(8pt)[#f.owasp · #f.cwe],
|
||||
text(8pt, fill: gray)[Confidence], text(8pt)[#f.votes votes · #str(f.confidence)],
|
||||
text(8pt, fill: gray)[Auth context], text(8pt)[#f.auth],
|
||||
text(8pt, fill: gray)[Location], text(8pt)[#raw(f.endpoint)],
|
||||
text(8pt, fill: gray)[Agent], text(8pt)[#raw(f.agent)],
|
||||
)
|
||||
@@ -122,3 +147,9 @@
|
||||
]
|
||||
#v(8pt)
|
||||
]
|
||||
|
||||
// ---- Conclusion ----
|
||||
#v(6pt)
|
||||
#line(length: 100%, stroke: 0.5pt + gray)
|
||||
= Conclusion
|
||||
#text(10pt)[#meta.conclusion]
|
||||
|
||||
Reference in New Issue
Block a user