use compliance_core::models::dast::DastFinding; use compliance_core::models::pentest::AttackChainNode; 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()); let critical = ctx .findings .iter() .filter(|f| f.severity.to_string() == "critical") .count(); let high = ctx .findings .iter() .filter(|f| f.severity.to_string() == "high") .count(); let medium = ctx .findings .iter() .filter(|f| f.severity.to_string() == "medium") .count(); let low = ctx .findings .iter() .filter(|f| f.severity.to_string() == "low") .count(); let info = ctx .findings .iter() .filter(|f| f.severity.to_string() == "info") .count(); let exploitable = ctx.findings.iter().filter(|f| f.exploitable).count(); let total = ctx.findings.len(); let overall_risk = if critical > 0 { "CRITICAL" } else if high > 0 { "HIGH" } else if medium > 0 { "MEDIUM" } else if low > 0 { "LOW" } else { "INFORMATIONAL" }; let risk_color = match overall_risk { "CRITICAL" => "#991b1b", "HIGH" => "#c2410c", "MEDIUM" => "#a16207", "LOW" => "#1d4ed8", _ => "#4b5563", }; // Risk score 0-100 let risk_score: usize = std::cmp::min(100, critical * 25 + high * 15 + medium * 8 + low * 3 + info); // 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 }; // Severity distribution bar let severity_bar = if total > 0 { let crit_pct = (critical as f64 / total as f64 * 100.0) as usize; let high_pct = (high as f64 / total as f64 * 100.0) as usize; let med_pct = (medium as f64 / total as f64 * 100.0) as usize; let low_pct = (low as f64 / total as f64 * 100.0) as usize; let info_pct = 100_usize.saturating_sub(crit_pct + high_pct + med_pct + low_pct); let mut bar = String::from(r#"
"#); if critical > 0 { bar.push_str(&format!( r#"
{}
"#, std::cmp::max(crit_pct, 4), critical )); } if high > 0 { bar.push_str(&format!( r#"
{}
"#, std::cmp::max(high_pct, 4), high )); } if medium > 0 { bar.push_str(&format!( r#"
{}
"#, std::cmp::max(med_pct, 4), medium )); } if low > 0 { bar.push_str(&format!( r#"
{}
"#, std::cmp::max(low_pct, 4), low )); } if info > 0 { bar.push_str(&format!( r#"
{}
"#, std::cmp::max(info_pct, 4), info )); } bar.push_str("
"); bar.push_str(r#"
"#); if critical > 0 { bar.push_str( r#" Critical"#, ); } if high > 0 { bar.push_str(r#" High"#); } if medium > 0 { bar.push_str( r#" Medium"#, ); } if low > 0 { bar.push_str(r#" Low"#); } if info > 0 { bar.push_str(r#" Info"#); } bar.push_str("
"); bar } else { String::new() }; // Build findings grouped by severity let severity_order = ["critical", "high", "medium", "low", "info"]; let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"]; let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"]; let mut findings_html = String::new(); let mut finding_num = 0usize; for (si, &sev_key) in severity_order.iter().enumerate() { let sev_findings: Vec<&DastFinding> = ctx .findings .iter() .filter(|f| f.severity.to_string() == sev_key) .collect(); if sev_findings.is_empty() { continue; } findings_html.push_str(&format!( r#"

{label} ({count})

"#, color = severity_colors[si], label = severity_labels[si], count = sev_findings.len(), )); for f in sev_findings { finding_num += 1; let sev_color = severity_colors[si]; let exploitable_badge = if f.exploitable { r#"EXPLOITABLE"# } else { "" }; let cwe_cell = f .cwe .as_deref() .map(|c| format!("CWE{}", html_escape(c))) .unwrap_or_default(); let param_row = f .parameter .as_deref() .map(|p| { format!( "Parameter{}", html_escape(p) ) }) .unwrap_or_default(); let remediation = f .remediation .as_deref() .unwrap_or("Refer to industry best practices for this vulnerability class."); let evidence_html = if f.evidence.is_empty() { String::new() } else { let mut eh = String::from( r#"
Evidence
"#, ); for ev in &f.evidence { let payload_info = ev .payload .as_deref() .map(|p| format!("
Payload: {}", html_escape(p))) .unwrap_or_default(); eh.push_str(&format!( "", html_escape(&ev.request_method), html_escape(&ev.request_url), ev.response_status, ev.response_snippet .as_deref() .map(html_escape) .unwrap_or_default(), payload_info, )); } eh.push_str("
RequestStatusDetails
{} {}{}{}{}
"); eh }; let linked_sast = f .linked_sast_finding_id .as_deref() .map(|id| { format!( r#"
Correlated SAST Finding: {id}
"# ) }) .unwrap_or_default(); findings_html.push_str(&format!( r#"
F-{num:03} {title} {exploitable_badge}
{param_row} {cwe_cell}
Type{vuln_type}
Endpoint{method} {endpoint}
{description}
{evidence_html} {linked_sast}
Recommendation
{remediation}
"#, num = finding_num, title = html_escape(&f.title), vuln_type = f.vuln_type, method = f.method, endpoint = html_escape(&f.endpoint), description = html_escape(&f.description), )); } } // Build attack chain — group by phase using BFS let mut chain_html = String::new(); if !ctx.attack_chain.is_empty() { // Compute phases via BFS from root nodes let mut phase_map: std::collections::HashMap = std::collections::HashMap::new(); let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for node in &ctx.attack_chain { if node.parent_node_ids.is_empty() { let nid = node.node_id.clone(); if !nid.is_empty() { phase_map.insert(nid.clone(), 0); queue.push_back(nid); } } } while let Some(nid) = queue.pop_front() { let parent_phase = phase_map.get(&nid).copied().unwrap_or(0); for node in &ctx.attack_chain { if node.parent_node_ids.contains(&nid) { let child_id = node.node_id.clone(); if !child_id.is_empty() && !phase_map.contains_key(&child_id) { phase_map.insert(child_id.clone(), parent_phase + 1); queue.push_back(child_id); } } } } // Assign phase 0 to any unassigned nodes for node in &ctx.attack_chain { let nid = node.node_id.clone(); if !nid.is_empty() && !phase_map.contains_key(&nid) { phase_map.insert(nid, 0); } } // Group nodes by phase let max_phase = phase_map.values().copied().max().unwrap_or(0); let phase_labels = [ "Reconnaissance", "Enumeration", "Exploitation", "Validation", "Post-Exploitation", ]; for phase_idx in 0..=max_phase { let phase_nodes: Vec<&AttackChainNode> = ctx .attack_chain .iter() .filter(|n| { let nid = n.node_id.clone(); phase_map.get(&nid).copied().unwrap_or(0) == phase_idx }) .collect(); if phase_nodes.is_empty() { continue; } let label = if phase_idx < phase_labels.len() { phase_labels[phase_idx] } else { "Additional Testing" }; chain_html.push_str(&format!( r#"
Phase {} {} {} step{}
"#, phase_idx + 1, label, phase_nodes.len(), if phase_nodes.len() == 1 { "" } else { "s" }, )); for (i, node) in phase_nodes.iter().enumerate() { let status_label = format!("{:?}", node.status); let status_class = match status_label.to_lowercase().as_str() { "completed" => "step-completed", "failed" => "step-failed", _ => "step-running", }; let findings_badge = if !node.findings_produced.is_empty() { format!( r#"{} finding{}"#, node.findings_produced.len(), if node.findings_produced.len() == 1 { "" } else { "s" }, ) } else { String::new() }; let risk_badge = node .risk_score .map(|r| { let risk_class = if r >= 70 { "risk-high" } else if r >= 40 { "risk-med" } else { "risk-low" }; format!(r#"Risk: {r}"#) }) .unwrap_or_default(); let reasoning_html = if node.llm_reasoning.is_empty() { String::new() } else { format!( r#"
{}
"#, html_escape(&node.llm_reasoning) ) }; chain_html.push_str(&format!( r#"
{num}
{tool_name} {status_label} {findings_badge} {risk_badge}
{reasoning_html}
"#, num = i + 1, tool_name = html_escape(&node.tool_name), )); } chain_html.push_str("
"); } } // Tools methodology table let tools_table: String = tool_names .iter() .enumerate() .map(|(i, t)| { let category = tool_category(t); format!( "{}{}{}", i + 1, html_escape(t), category, ) }) .collect::>() .join("\n"); // Table of contents let toc_findings_sub = if !ctx.findings.is_empty() { let mut sub = String::new(); let mut fnum = 0usize; for &sev_key in severity_order.iter() { let count = ctx .findings .iter() .filter(|f| f.severity.to_string() == sev_key) .count(); if count == 0 { continue; } for f in ctx .findings .iter() .filter(|f| f.severity.to_string() == sev_key) { fnum += 1; sub.push_str(&format!( r#"
F-{:03} — {}
"#, fnum, html_escape(&f.title), )); } } sub } else { String::new() }; format!( r##" Penetration Test Report — {target_name}
CONFIDENTIAL
Penetration Test Report
{target_name}
Report ID: {session_id}
Date: {date_short}
Target: {target_url}
Prepared for: {requester_name} ({requester_email})

Table of Contents

1Executive Summary
2Scope & Methodology
3Findings ({total_findings})
{toc_findings_sub}
4Attack Chain Timeline
5Appendix

1. Executive Summary

{risk_score} / 100
Overall Risk: {overall_risk}
Based on {total_findings} finding{findings_plural} identified across the target application.
{total_findings}
Total Findings
{critical_high}
Critical / High
{exploitable_count}
Exploitable
{tool_count}
Tools Used

Severity Distribution

{severity_bar}

This report presents the results of an automated penetration test conducted against {target_name} ({target_url}) using the Compliance Scanner AI-powered testing engine. A total of {total_findings} vulnerabilities were identified, of which {exploitable_count} were confirmed exploitable with working proof-of-concept payloads. The assessment employed {tool_count} security tools across {tool_invocations} invocations ({success_rate:.0}% success rate).

2. Scope & Methodology

The assessment was performed using an AI-driven orchestrator that autonomously selects and executes security testing tools based on the target's attack surface, technology stack, and any available static analysis (SAST) findings and SBOM data.

Engagement Details

Target{target_name}
URL{target_url}
Strategy{strategy}
Status{status}
Started{date_str}
Completed{completed_str}
Tool Invocations{tool_invocations} ({tool_successes} successful, {success_rate:.1}% success rate)

Tools Employed

{tools_table}
#ToolCategory

3. Findings

{findings_section}

4. Attack Chain Timeline

The following sequence shows each tool invocation made by the AI orchestrator during the assessment, grouped by phase. Each step includes the tool's name, execution status, and the AI's reasoning for choosing that action.

{chain_section}

5. Appendix

Severity Definitions

CriticalVulnerabilities that can be exploited remotely without authentication to execute arbitrary code, exfiltrate sensitive data, or fully compromise the system.
HighVulnerabilities that allow significant unauthorized access or data exposure, typically requiring minimal user interaction or privileges.
MediumVulnerabilities that may lead to limited data exposure or require specific conditions to exploit, but still represent meaningful risk.
LowMinor issues with limited direct impact. May contribute to broader attack chains or indicate defense-in-depth weaknesses.
InfoObservations and best-practice recommendations that do not represent direct security vulnerabilities.

Disclaimer

This report was generated by an automated AI-powered penetration testing engine. While the system employs advanced techniques to identify vulnerabilities, no automated assessment can guarantee complete coverage. The results should be reviewed by qualified security professionals and validated in the context of the target application's threat model. Findings are point-in-time observations and may change as the application evolves.

"##, target_name = html_escape(&ctx.target_name), target_url = html_escape(&ctx.target_url), session_id = html_escape(&session_id), date_str = date_str, date_short = date_short, completed_str = completed_str, requester_name = html_escape(&ctx.requester_name), requester_email = html_escape(&ctx.requester_email), risk_color = risk_color, risk_score = risk_score, overall_risk = overall_risk, total_findings = total, findings_plural = if total == 1 { "" } else { "s" }, critical_high = format!("{} / {}", critical, high), exploitable_count = exploitable, tool_count = tool_names.len(), strategy = session.strategy, status = session.status, tool_invocations = session.tool_invocations, tool_successes = session.tool_successes, success_rate = session.success_rate(), severity_bar = severity_bar, tools_table = tools_table, toc_findings_sub = toc_findings_sub, findings_section = if ctx.findings.is_empty() { "

No vulnerabilities were identified during this assessment.

".to_string() } else { findings_html }, chain_section = if ctx.attack_chain.is_empty() { "

No attack chain steps recorded.

".to_string() } else { chain_html }, ) } 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("