diff --git a/RELEASE.md b/RELEASE.md index e30fd88..b412a78 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index fd62e9e..d34b6d7 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -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) -> RunOutput { pool.set_progress(tx.clone()); @@ -378,7 +418,10 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender, pub cors: Cors, pub scripts: Vec, + /// Business/brand hint extracted from the page (og:site_name, application-name, + /// or a "© " 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 "© " / "Copyright " notice. Helps +/// the report name the organisation/product instead of only the URL. +fn extract_brand(body: &str) -> String { + let low = body.to_lowercase(); + // / 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::().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::>().join(" "); + if name.len() >= 2 && name.len() <= 50 && name.to_lowercase() != "copyright" { return name; } + } + } + String::new() +} + /// Best-effort parse of the HTML `
`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(", + #[serde(default)] pub server: String, + #[serde(default)] pub status: u16, +} + +/// Read `/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}}\

NeuroSploit Penetration Test Report

\ -
Target: {t} · v3.6.5 Rust harness · multi-model validated
\ +
Asset: {asset} · Target: {t}{techline} · v3.6.5 · multi-model validated
\
{chips}
{graph_block}

Findings ({n})

{body}\ -

Authorized testing only. Findings confirmed by multi-model adversarial voting.
NeuroSploit v3.6.5 · by Joas A Santos & Red Team Leaders

", +

Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and adversarial refute; \"needs-review\" are flagged for a human.
NeuroSploit v3.6.5 · by Joas A Santos & Red Team Leaders

", 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 { 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 = 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::>().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/.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 { 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); diff --git a/neurosploit-rs/templates/report.typ b/neurosploit-rs/templates/report.typ index 31da110..259d833 100644 --- a/neurosploit-rs/templates/report.typ +++ b/neurosploit-rs/templates/report.typ @@ -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/.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]