All checks were successful
Complete pentest feature overhaul: SSE streaming, session-persistent browser tool (CDP), AES-256 credential encryption, auto-screenshots in reports, code-level remediation correlation, SAST triage chunking, context window optimization, test user cleanup (Keycloak/Auth0/Okta), wizard dropdowns, attack chain improvements, architecture docs with Mermaid diagrams. Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #16
519 lines
16 KiB
Rust
519 lines
16 KiB
Rust
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<String> = {
|
|
let mut names: Vec<String> = 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<String> = 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#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Penetration Test Report — {target_name}</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
{styles_html}
|
|
</head>
|
|
<body>
|
|
|
|
{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("<script>"), "<script>");
|
|
}
|
|
|
|
#[test]
|
|
fn html_escape_handles_quotes() {
|
|
assert_eq!(html_escape(r#"key="val""#), "key="val"");
|
|
}
|
|
|
|
#[test]
|
|
fn html_escape_handles_all_special_chars() {
|
|
assert_eq!(
|
|
html_escape(r#"<a href="x">&y</a>"#),
|
|
"<a href="x">&y</a>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn html_escape_no_change_for_plain_text() {
|
|
assert_eq!(html_escape("hello world"), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn html_escape_empty_string() {
|
|
assert_eq!(html_escape(""), "");
|
|
}
|
|
|
|
// ── tool_category ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tool_category_nmap() {
|
|
assert_eq!(tool_category("nmap_scan"), "Network Reconnaissance");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_port_scanner() {
|
|
assert_eq!(tool_category("port_scanner"), "Network Reconnaissance");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_nikto() {
|
|
assert_eq!(tool_category("nikto"), "Web Server Analysis");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_header_check() {
|
|
assert_eq!(
|
|
tool_category("security_header_check"),
|
|
"Web Server Analysis"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_zap_spider() {
|
|
assert_eq!(tool_category("zap_spider"), "Web Application Scanning");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_sqlmap() {
|
|
assert_eq!(tool_category("sqlmap"), "SQL Injection Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_xss_scanner() {
|
|
assert_eq!(tool_category("xss_scanner"), "Cross-Site Scripting Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_dir_bruteforce() {
|
|
assert_eq!(tool_category("dir_bruteforce"), "Directory Enumeration");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_gobuster() {
|
|
assert_eq!(tool_category("gobuster"), "Directory Enumeration");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_ssl_check() {
|
|
assert_eq!(tool_category("ssl_check"), "SSL/TLS Analysis");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_tls_scan() {
|
|
assert_eq!(tool_category("tls_scan"), "SSL/TLS Analysis");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_api_test() {
|
|
assert_eq!(tool_category("api_endpoint_test"), "API Security Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_auth_bypass() {
|
|
assert_eq!(tool_category("auth_bypass_check"), "Authentication Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_cors() {
|
|
assert_eq!(tool_category("cors_check"), "CORS Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_csrf() {
|
|
assert_eq!(tool_category("csrf_scanner"), "CSRF Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_nuclei() {
|
|
assert_eq!(tool_category("nuclei"), "Vulnerability Scanning");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_whatweb() {
|
|
assert_eq!(tool_category("whatweb"), "Technology Fingerprinting");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_unknown_defaults_to_security_testing() {
|
|
assert_eq!(tool_category("custom_tool"), "Security Testing");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_category_is_case_insensitive() {
|
|
assert_eq!(tool_category("NMAP_Scanner"), "Network Reconnaissance");
|
|
assert_eq!(tool_category("SQLMap"), "SQL Injection Testing");
|
|
}
|
|
|
|
// ── build_html_report ────────────────────────────────────────────
|
|
|
|
fn make_session(strategy: PentestStrategy) -> PentestSession {
|
|
let mut s = PentestSession::new("target-1".into(), strategy);
|
|
s.tool_invocations = 5;
|
|
s.tool_successes = 4;
|
|
s.findings_count = 2;
|
|
s.exploitable_count = 1;
|
|
s
|
|
}
|
|
|
|
fn make_finding(severity: Severity, title: &str, exploitable: bool) -> DastFinding {
|
|
let mut f = DastFinding::new(
|
|
"run-1".into(),
|
|
"target-1".into(),
|
|
DastVulnType::Xss,
|
|
title.into(),
|
|
"description".into(),
|
|
severity,
|
|
"https://example.com/test".into(),
|
|
"GET".into(),
|
|
);
|
|
f.exploitable = exploitable;
|
|
f
|
|
}
|
|
|
|
fn make_attack_node(tool_name: &str) -> AttackChainNode {
|
|
let mut node = AttackChainNode::new(
|
|
"session-1".into(),
|
|
"node-1".into(),
|
|
tool_name.into(),
|
|
serde_json::json!({}),
|
|
"Testing this tool".into(),
|
|
);
|
|
node.status = AttackNodeStatus::Completed;
|
|
node
|
|
}
|
|
|
|
fn make_report_context(
|
|
findings: Vec<DastFinding>,
|
|
chain: Vec<AttackChainNode>,
|
|
) -> ReportContext {
|
|
ReportContext {
|
|
session: make_session(PentestStrategy::Comprehensive),
|
|
target_name: "Test App".into(),
|
|
target_url: "https://example.com".into(),
|
|
findings,
|
|
attack_chain: chain,
|
|
requester_name: "Alice".into(),
|
|
requester_email: "alice@example.com".into(),
|
|
config: None,
|
|
sast_findings: Vec::new(),
|
|
sbom_entries: Vec::new(),
|
|
code_context: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn report_contains_target_info() {
|
|
let ctx = make_report_context(vec![], vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("Test App"));
|
|
assert!(html.contains("https://example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_contains_requester_info() {
|
|
let ctx = make_report_context(vec![], vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("Alice"));
|
|
assert!(html.contains("alice@example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_informational_risk_when_no_findings() {
|
|
let ctx = make_report_context(vec![], vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("INFORMATIONAL"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_critical_risk_with_critical_finding() {
|
|
let findings = vec![make_finding(Severity::Critical, "Critical XSS", true)];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("CRITICAL"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_high_risk_without_critical() {
|
|
let findings = vec![make_finding(Severity::High, "High SQLi", false)];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
// Should show HIGH, not CRITICAL
|
|
assert!(html.contains("HIGH"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_medium_risk_level() {
|
|
let findings = vec![make_finding(Severity::Medium, "Medium Issue", false)];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("MEDIUM"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_includes_finding_title() {
|
|
let findings = vec![make_finding(
|
|
Severity::High,
|
|
"Reflected XSS in /search",
|
|
true,
|
|
)];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("Reflected XSS in /search"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_exploitable_badge() {
|
|
let findings = vec![make_finding(Severity::Critical, "SQLi", true)];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
// The report should mark exploitable findings
|
|
assert!(html.contains("EXPLOITABLE"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_includes_attack_chain_tool_names() {
|
|
let chain = vec![make_attack_node("nmap_scan"), make_attack_node("sqlmap")];
|
|
let ctx = make_report_context(vec![], chain);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("nmap_scan"));
|
|
assert!(html.contains("sqlmap"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_is_valid_html_structure() {
|
|
let ctx = make_report_context(vec![], vec![]);
|
|
let html = build_html_report(&ctx);
|
|
assert!(html.contains("<!DOCTYPE html>") || html.contains("<html"));
|
|
assert!(html.contains("</html>"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_strategy_appears() {
|
|
let ctx = make_report_context(vec![], vec![]);
|
|
let html = build_html_report(&ctx);
|
|
// PentestStrategy::Comprehensive => "comprehensive"
|
|
assert!(html.contains("comprehensive") || html.contains("Comprehensive"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_finding_count_is_correct() {
|
|
let findings = vec![
|
|
make_finding(Severity::Critical, "F1", true),
|
|
make_finding(Severity::High, "F2", false),
|
|
make_finding(Severity::Low, "F3", false),
|
|
];
|
|
let ctx = make_report_context(findings, vec![]);
|
|
let html = build_html_report(&ctx);
|
|
// The total count "3" should appear somewhere
|
|
assert!(
|
|
html.contains(">3<")
|
|
|| html.contains(">3 ")
|
|
|| html.contains("3 findings")
|
|
|| html.contains("3 Total")
|
|
);
|
|
}
|
|
}
|