feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
All checks were successful
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
This commit was merged in pull request #16.
This commit is contained in:
518
compliance-agent/src/pentest/report/html/mod.rs
Normal file
518
compliance-agent/src/pentest/report/html/mod.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user