mod appendix; mod attack_chain; mod cover; mod executive_summary; mod findings; mod scope; mod styles; use super::ReportContext; #[allow(clippy::format_in_format_args)] pub(super) fn build_html_report(ctx: &ReportContext) -> String { let session = &ctx.session; let session_id = session .id .map(|oid| oid.to_hex()) .unwrap_or_else(|| "-".to_string()); let date_str = session .started_at .format("%B %d, %Y at %H:%M UTC") .to_string(); let date_short = session.started_at.format("%B %d, %Y").to_string(); let completed_str = session .completed_at .map(|d| d.format("%B %d, %Y at %H:%M UTC").to_string()) .unwrap_or_else(|| "In Progress".to_string()); // Collect unique tool names used let tool_names: Vec = { let mut names: Vec = ctx .attack_chain .iter() .map(|n| n.tool_name.clone()) .collect(); names.sort(); names.dedup(); names }; // Find the best app screenshot for the cover page: // prefer the first navigate to the target URL that has a screenshot, // falling back to any navigate with a screenshot let app_screenshot: Option = ctx .attack_chain .iter() .filter(|n| n.tool_name == "browser") .filter_map(|n| { n.tool_output .as_ref()? .get("screenshot_base64")? .as_str() .filter(|s| !s.is_empty()) .map(|s| s.to_string()) }) // Skip the Keycloak login page screenshots — prefer one that shows the actual app .find(|_| { ctx.attack_chain .iter() .filter(|n| n.tool_name == "browser") .any(|n| { n.tool_output .as_ref() .and_then(|o| o.get("title")) .and_then(|t| t.as_str()) .is_some_and(|t| t.contains("Compliance") || t.contains("Dashboard")) }) }) .or_else(|| { // Fallback: any screenshot ctx.attack_chain .iter() .filter(|n| n.tool_name == "browser") .filter_map(|n| { n.tool_output .as_ref()? .get("screenshot_base64")? .as_str() .filter(|s| !s.is_empty()) .map(|s| s.to_string()) }) .next() }); let styles_html = styles::styles(); let cover_html = cover::cover( &ctx.target_name, &session_id, &date_short, &ctx.target_url, &ctx.requester_name, &ctx.requester_email, app_screenshot.as_deref(), ); let exec_html = executive_summary::executive_summary( &ctx.findings, &ctx.target_name, &ctx.target_url, tool_names.len(), session.tool_invocations, session.success_rate(), ); let scope_html = scope::scope( session, &ctx.target_name, &ctx.target_url, &date_str, &completed_str, &tool_names, ctx.config.as_ref(), ); let findings_html = findings::findings( &ctx.findings, &ctx.sast_findings, &ctx.code_context, &ctx.sbom_entries, ); let chain_html = attack_chain::attack_chain(&ctx.attack_chain); let appendix_html = appendix::appendix(&session_id); format!( r#" Penetration Test Report — {target_name} {styles_html} {cover_html} {exec_html} {scope_html} {findings_html} {chain_html} {appendix_html} "#, target_name = html_escape(&ctx.target_name), ) } fn tool_category(tool_name: &str) -> &'static str { let name = tool_name.to_lowercase(); if name.contains("nmap") || name.contains("port") { return "Network Reconnaissance"; } if name.contains("nikto") || name.contains("header") { return "Web Server Analysis"; } if name.contains("zap") || name.contains("spider") || name.contains("crawl") { return "Web Application Scanning"; } if name.contains("sqlmap") || name.contains("sqli") || name.contains("sql") { return "SQL Injection Testing"; } if name.contains("xss") || name.contains("cross-site") { return "Cross-Site Scripting Testing"; } if name.contains("dir") || name.contains("brute") || name.contains("fuzz") || name.contains("gobuster") { return "Directory Enumeration"; } if name.contains("ssl") || name.contains("tls") || name.contains("cert") { return "SSL/TLS Analysis"; } if name.contains("api") || name.contains("endpoint") { return "API Security Testing"; } if name.contains("auth") || name.contains("login") || name.contains("credential") { return "Authentication Testing"; } if name.contains("cors") { return "CORS Testing"; } if name.contains("csrf") { return "CSRF Testing"; } if name.contains("nuclei") || name.contains("template") { return "Vulnerability Scanning"; } if name.contains("whatweb") || name.contains("tech") || name.contains("wappalyzer") { return "Technology Fingerprinting"; } "Security Testing" } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; use compliance_core::models::dast::{DastFinding, DastVulnType}; use compliance_core::models::finding::Severity; use compliance_core::models::pentest::{ AttackChainNode, AttackNodeStatus, PentestSession, PentestStrategy, }; // ── html_escape ────────────────────────────────────────────────── #[test] fn html_escape_handles_ampersand() { assert_eq!(html_escape("a & b"), "a & b"); } #[test] fn html_escape_handles_angle_brackets() { assert_eq!(html_escape("