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:
CyberSecurityUP
2026-07-30 20:30:08 -03:00
parent 76121fd739
commit f3da46886f
5 changed files with 314 additions and 54 deletions
+11 -4
View File
@@ -16,10 +16,17 @@
refute) — kept with a reason so a human makes the final call. Only zero-support
noise is dropped. Every report separates the two buckets.
- **Reports in Markdown + JSON (alongside PDF/HTML).** Each run now writes
`report.md` (human-readable, confirmed vs needs-review), `report.json`
(structured: metadata + confirmed/needs-review/all buckets), plus the existing
`report.html` and Typst **PDF** — all via `report::write_all`.
- **Richer reports in Markdown + JSON (alongside PDF/HTML).** Every run writes
`report.md`, `report.json`, `report.html` and the Typst **PDF** via
`report::write_all`, now with a full structure: **asset identification** (names
the product/organisation + tech stack — e.g. "OWASP Juice Shop [Angular,
Express]" — not just the URL), a **written executive summary**, a
**vulnerability table** (severity · status · CWE/OWASP), a **test-accounts
section** (from the vault, to delete after), detailed confirmed findings, a
separate **needs-review** section, and a **written conclusion**. The asset is
identified during the run: a deterministic probe extracts the page title,
fingerprints the stack, matches known apps, and reads a business/brand hint
(`og:site_name` / `application-name` / copyright) into `meta.json`.
- **Sharper agents on modern SPA/REST apps (Juice-Shop-class).** When recon
detects a JS SPA and/or a REST/GraphQL API, a methodology directive gives agents
+46 -2
View File
@@ -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 ["©", "&copy;", "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("&copy;", " ");
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='", "'")) {
+177 -37
View File
@@ -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> &amp; <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> &amp; <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);
+42 -11
View File
@@ -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]