All checks were successful
CI / Clippy (push) Successful in 4m15s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Clippy (pull_request) Successful in 4m16s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Format (push) Successful in 27s
CI / Format (pull_request) Successful in 3s
Fix dead code warnings, redundant clones, boolean simplification, format-in-format-args, type complexity, and Box::new of Default across compliance-dast, compliance-agent, and compliance-dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1852 lines
54 KiB
Rust
1852 lines
54 KiB
Rust
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<String> = {
|
|
let mut names: Vec<String> = 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#"<div class="sev-bar">"#);
|
|
if critical > 0 {
|
|
bar.push_str(&format!(
|
|
r#"<div class="sev-bar-seg sev-bar-critical" style="width:{}%"><span>{}</span></div>"#,
|
|
std::cmp::max(crit_pct, 4), critical
|
|
));
|
|
}
|
|
if high > 0 {
|
|
bar.push_str(&format!(
|
|
r#"<div class="sev-bar-seg sev-bar-high" style="width:{}%"><span>{}</span></div>"#,
|
|
std::cmp::max(high_pct, 4),
|
|
high
|
|
));
|
|
}
|
|
if medium > 0 {
|
|
bar.push_str(&format!(
|
|
r#"<div class="sev-bar-seg sev-bar-medium" style="width:{}%"><span>{}</span></div>"#,
|
|
std::cmp::max(med_pct, 4), medium
|
|
));
|
|
}
|
|
if low > 0 {
|
|
bar.push_str(&format!(
|
|
r#"<div class="sev-bar-seg sev-bar-low" style="width:{}%"><span>{}</span></div>"#,
|
|
std::cmp::max(low_pct, 4),
|
|
low
|
|
));
|
|
}
|
|
if info > 0 {
|
|
bar.push_str(&format!(
|
|
r#"<div class="sev-bar-seg sev-bar-info" style="width:{}%"><span>{}</span></div>"#,
|
|
std::cmp::max(info_pct, 4),
|
|
info
|
|
));
|
|
}
|
|
bar.push_str("</div>");
|
|
bar.push_str(r#"<div class="sev-bar-legend">"#);
|
|
if critical > 0 {
|
|
bar.push_str(
|
|
r#"<span><i class="sev-dot" style="background:#991b1b"></i> Critical</span>"#,
|
|
);
|
|
}
|
|
if high > 0 {
|
|
bar.push_str(r#"<span><i class="sev-dot" style="background:#c2410c"></i> High</span>"#);
|
|
}
|
|
if medium > 0 {
|
|
bar.push_str(
|
|
r#"<span><i class="sev-dot" style="background:#a16207"></i> Medium</span>"#,
|
|
);
|
|
}
|
|
if low > 0 {
|
|
bar.push_str(r#"<span><i class="sev-dot" style="background:#1d4ed8"></i> Low</span>"#);
|
|
}
|
|
if info > 0 {
|
|
bar.push_str(r#"<span><i class="sev-dot" style="background:#4b5563"></i> Info</span>"#);
|
|
}
|
|
bar.push_str("</div>");
|
|
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#"<h4 class="sev-group-title" style="border-color: {color}">{label} ({count})</h4>"#,
|
|
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#"<span class="badge badge-exploit">EXPLOITABLE</span>"#
|
|
} else {
|
|
""
|
|
};
|
|
let cwe_cell = f
|
|
.cwe
|
|
.as_deref()
|
|
.map(|c| format!("<tr><td>CWE</td><td>{}</td></tr>", html_escape(c)))
|
|
.unwrap_or_default();
|
|
let param_row = f
|
|
.parameter
|
|
.as_deref()
|
|
.map(|p| {
|
|
format!(
|
|
"<tr><td>Parameter</td><td><code>{}</code></td></tr>",
|
|
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#"<div class="evidence-block"><div class="evidence-title">Evidence</div><table class="evidence-table"><thead><tr><th>Request</th><th>Status</th><th>Details</th></tr></thead><tbody>"#,
|
|
);
|
|
for ev in &f.evidence {
|
|
let payload_info = ev
|
|
.payload
|
|
.as_deref()
|
|
.map(|p| format!("<br><span class=\"evidence-payload\">Payload: <code>{}</code></span>", html_escape(p)))
|
|
.unwrap_or_default();
|
|
eh.push_str(&format!(
|
|
"<tr><td><code>{} {}</code></td><td>{}</td><td>{}{}</td></tr>",
|
|
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("</tbody></table></div>");
|
|
eh
|
|
};
|
|
|
|
let linked_sast = f
|
|
.linked_sast_finding_id
|
|
.as_deref()
|
|
.map(|id| {
|
|
format!(
|
|
r#"<div class="linked-sast">Correlated SAST Finding: <code>{id}</code></div>"#
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
findings_html.push_str(&format!(
|
|
r#"
|
|
<div class="finding" style="border-left-color: {sev_color}">
|
|
<div class="finding-header">
|
|
<span class="finding-id">F-{num:03}</span>
|
|
<span class="finding-title">{title}</span>
|
|
{exploitable_badge}
|
|
</div>
|
|
<table class="finding-meta">
|
|
<tr><td>Type</td><td>{vuln_type}</td></tr>
|
|
<tr><td>Endpoint</td><td><code>{method} {endpoint}</code></td></tr>
|
|
{param_row}
|
|
{cwe_cell}
|
|
</table>
|
|
<div class="finding-desc">{description}</div>
|
|
{evidence_html}
|
|
{linked_sast}
|
|
<div class="remediation">
|
|
<div class="remediation-label">Recommendation</div>
|
|
{remediation}
|
|
</div>
|
|
</div>
|
|
"#,
|
|
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<String, usize> =
|
|
std::collections::HashMap::new();
|
|
let mut queue: std::collections::VecDeque<String> = 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#"<div class="phase-block">
|
|
<div class="phase-header">
|
|
<span class="phase-num">Phase {}</span>
|
|
<span class="phase-label">{}</span>
|
|
<span class="phase-count">{} step{}</span>
|
|
</div>
|
|
<div class="phase-steps">"#,
|
|
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#"<span class="step-findings">{} finding{}</span>"#,
|
|
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#"<span class="step-risk {risk_class}">Risk: {r}</span>"#)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let reasoning_html = if node.llm_reasoning.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
r#"<div class="step-reasoning">{}</div>"#,
|
|
html_escape(&node.llm_reasoning)
|
|
)
|
|
};
|
|
|
|
chain_html.push_str(&format!(
|
|
r#"<div class="step-row">
|
|
<div class="step-num">{num}</div>
|
|
<div class="step-connector"></div>
|
|
<div class="step-content">
|
|
<div class="step-header">
|
|
<span class="step-tool">{tool_name}</span>
|
|
<span class="step-status {status_class}">{status_label}</span>
|
|
{findings_badge}
|
|
{risk_badge}
|
|
</div>
|
|
{reasoning_html}
|
|
</div>
|
|
</div>"#,
|
|
num = i + 1,
|
|
tool_name = html_escape(&node.tool_name),
|
|
));
|
|
}
|
|
|
|
chain_html.push_str("</div></div>");
|
|
}
|
|
}
|
|
|
|
// Tools methodology table
|
|
let tools_table: String = tool_names
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, t)| {
|
|
let category = tool_category(t);
|
|
format!(
|
|
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>",
|
|
i + 1,
|
|
html_escape(t),
|
|
category,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.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#"<div class="toc-sub">F-{:03} — {}</div>"#,
|
|
fnum,
|
|
html_escape(&f.title),
|
|
));
|
|
}
|
|
}
|
|
sub
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
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">
|
|
<style>
|
|
/* ──────────────── Base / Print-first ──────────────── */
|
|
@page {{
|
|
size: A4;
|
|
margin: 20mm 18mm 25mm 18mm;
|
|
}}
|
|
@page :first {{
|
|
margin: 0;
|
|
}}
|
|
|
|
*, *::before, *::after {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
|
|
:root {{
|
|
--text: #1a1a2e;
|
|
--text-secondary: #475569;
|
|
--text-muted: #64748b;
|
|
--heading: #0d2137;
|
|
--accent: #1a56db;
|
|
--accent-light: #dbeafe;
|
|
--border: #d1d5db;
|
|
--border-light: #e5e7eb;
|
|
--bg-subtle: #f8fafc;
|
|
--bg-section: #f1f5f9;
|
|
--sev-critical: #991b1b;
|
|
--sev-high: #c2410c;
|
|
--sev-medium: #a16207;
|
|
--sev-low: #1d4ed8;
|
|
--sev-info: #4b5563;
|
|
--font-serif: 'Libre Baskerville', 'Georgia', serif;
|
|
--font-sans: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
|
}}
|
|
|
|
body {{
|
|
font-family: var(--font-sans);
|
|
color: var(--text);
|
|
background: #fff;
|
|
line-height: 1.65;
|
|
font-size: 10.5pt;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}}
|
|
|
|
.report-body {{
|
|
max-width: 190mm;
|
|
margin: 0 auto;
|
|
padding: 0 16px;
|
|
}}
|
|
|
|
/* ──────────────── Cover Page ──────────────── */
|
|
.cover {{
|
|
height: 100vh;
|
|
min-height: 297mm;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: 40mm 30mm;
|
|
page-break-after: always;
|
|
break-after: page;
|
|
position: relative;
|
|
background: #fff;
|
|
}}
|
|
|
|
.cover-shield {{
|
|
width: 72px;
|
|
height: 72px;
|
|
margin-bottom: 32px;
|
|
}}
|
|
|
|
.cover-tag {{
|
|
display: inline-block;
|
|
background: var(--sev-critical);
|
|
color: #fff;
|
|
font-family: var(--font-sans);
|
|
font-size: 8pt;
|
|
font-weight: 700;
|
|
letter-spacing: 0.15em;
|
|
text-transform: uppercase;
|
|
padding: 4px 16px;
|
|
border-radius: 2px;
|
|
margin-bottom: 28px;
|
|
}}
|
|
|
|
.cover-title {{
|
|
font-family: var(--font-serif);
|
|
font-size: 28pt;
|
|
font-weight: 700;
|
|
color: var(--heading);
|
|
line-height: 1.2;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.cover-subtitle {{
|
|
font-family: var(--font-serif);
|
|
font-size: 14pt;
|
|
color: var(--text-secondary);
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
margin-bottom: 48px;
|
|
}}
|
|
|
|
.cover-meta {{
|
|
font-size: 10pt;
|
|
color: var(--text-secondary);
|
|
line-height: 2;
|
|
}}
|
|
|
|
.cover-meta strong {{
|
|
color: var(--text);
|
|
}}
|
|
|
|
.cover-divider {{
|
|
width: 60px;
|
|
height: 2px;
|
|
background: var(--accent);
|
|
margin: 24px auto;
|
|
}}
|
|
|
|
.cover-footer {{
|
|
position: absolute;
|
|
bottom: 30mm;
|
|
left: 0;
|
|
right: 0;
|
|
text-align: center;
|
|
font-size: 8pt;
|
|
color: var(--text-muted);
|
|
letter-spacing: 0.05em;
|
|
}}
|
|
|
|
/* ──────────────── Typography ──────────────── */
|
|
h2 {{
|
|
font-family: var(--font-serif);
|
|
font-size: 16pt;
|
|
font-weight: 700;
|
|
color: var(--heading);
|
|
margin: 36px 0 16px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 2px solid var(--heading);
|
|
page-break-after: avoid;
|
|
break-after: avoid;
|
|
}}
|
|
|
|
h3 {{
|
|
font-family: var(--font-serif);
|
|
font-size: 12pt;
|
|
font-weight: 700;
|
|
color: var(--heading);
|
|
margin: 24px 0 10px;
|
|
page-break-after: avoid;
|
|
break-after: avoid;
|
|
}}
|
|
|
|
h4 {{
|
|
font-family: var(--font-sans);
|
|
font-size: 10pt;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
margin: 16px 0 8px;
|
|
}}
|
|
|
|
p {{
|
|
margin: 8px 0;
|
|
font-size: 10.5pt;
|
|
}}
|
|
|
|
code {{
|
|
font-family: var(--font-mono);
|
|
font-size: 9pt;
|
|
background: var(--bg-section);
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
border: 1px solid var(--border-light);
|
|
word-break: break-all;
|
|
}}
|
|
|
|
/* ──────────────── Section Numbers ──────────────── */
|
|
.section-num {{
|
|
color: var(--accent);
|
|
margin-right: 8px;
|
|
}}
|
|
|
|
/* ──────────────── Table of Contents ──────────────── */
|
|
.toc {{
|
|
page-break-after: always;
|
|
break-after: page;
|
|
padding-top: 24px;
|
|
}}
|
|
|
|
.toc h2 {{
|
|
border-bottom-color: var(--accent);
|
|
margin-top: 0;
|
|
}}
|
|
|
|
.toc-entry {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
padding: 6px 0;
|
|
border-bottom: 1px dotted var(--border);
|
|
font-size: 11pt;
|
|
}}
|
|
|
|
.toc-entry .toc-num {{
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
min-width: 24px;
|
|
margin-right: 10px;
|
|
}}
|
|
|
|
.toc-entry .toc-label {{
|
|
flex: 1;
|
|
font-weight: 600;
|
|
color: var(--heading);
|
|
}}
|
|
|
|
.toc-sub {{
|
|
padding: 3px 0 3px 34px;
|
|
font-size: 9.5pt;
|
|
color: var(--text-secondary);
|
|
}}
|
|
|
|
/* ──────────────── Executive Summary ──────────────── */
|
|
.exec-grid {{
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
gap: 12px;
|
|
margin: 16px 0 20px;
|
|
}}
|
|
|
|
.kpi-card {{
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 14px 12px;
|
|
text-align: center;
|
|
background: var(--bg-subtle);
|
|
}}
|
|
|
|
.kpi-value {{
|
|
font-family: var(--font-serif);
|
|
font-size: 22pt;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}}
|
|
|
|
.kpi-label {{
|
|
font-size: 8pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-muted);
|
|
margin-top: 4px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
/* Risk gauge */
|
|
.risk-gauge {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 16px 20px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg-subtle);
|
|
margin: 16px 0;
|
|
}}
|
|
|
|
.risk-gauge-meter {{
|
|
width: 140px;
|
|
flex-shrink: 0;
|
|
}}
|
|
|
|
.risk-gauge-track {{
|
|
height: 10px;
|
|
background: var(--border-light);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}}
|
|
|
|
.risk-gauge-fill {{
|
|
height: 100%;
|
|
border-radius: 5px;
|
|
transition: width 0.3s;
|
|
}}
|
|
|
|
.risk-gauge-score {{
|
|
font-family: var(--font-serif);
|
|
font-size: 9pt;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
margin-top: 3px;
|
|
}}
|
|
|
|
.risk-gauge-text {{
|
|
flex: 1;
|
|
}}
|
|
|
|
.risk-gauge-label {{
|
|
font-family: var(--font-serif);
|
|
font-size: 14pt;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
.risk-gauge-desc {{
|
|
font-size: 9.5pt;
|
|
color: var(--text-secondary);
|
|
margin-top: 2px;
|
|
}}
|
|
|
|
/* Severity bar */
|
|
.sev-bar {{
|
|
display: flex;
|
|
height: 28px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin: 12px 0 6px;
|
|
border: 1px solid var(--border);
|
|
}}
|
|
|
|
.sev-bar-seg {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
font-size: 8.5pt;
|
|
font-weight: 700;
|
|
min-width: 24px;
|
|
}}
|
|
|
|
.sev-bar-critical {{ background: var(--sev-critical); }}
|
|
.sev-bar-high {{ background: var(--sev-high); }}
|
|
.sev-bar-medium {{ background: var(--sev-medium); }}
|
|
.sev-bar-low {{ background: var(--sev-low); }}
|
|
.sev-bar-info {{ background: var(--sev-info); }}
|
|
|
|
.sev-bar-legend {{
|
|
display: flex;
|
|
gap: 16px;
|
|
font-size: 8.5pt;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 16px;
|
|
}}
|
|
|
|
.sev-dot {{
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 2px;
|
|
margin-right: 4px;
|
|
vertical-align: middle;
|
|
}}
|
|
|
|
/* ──────────────── Info Tables ──────────────── */
|
|
table.info {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 10px 0;
|
|
font-size: 10pt;
|
|
}}
|
|
|
|
table.info td,
|
|
table.info th {{
|
|
padding: 7px 12px;
|
|
border: 1px solid var(--border);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}}
|
|
|
|
table.info td:first-child,
|
|
table.info th:first-child {{
|
|
width: 160px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
background: var(--bg-subtle);
|
|
}}
|
|
|
|
/* Methodology tools table */
|
|
table.tools-table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 10px 0;
|
|
font-size: 10pt;
|
|
}}
|
|
|
|
table.tools-table th {{
|
|
background: var(--heading);
|
|
color: #fff;
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 9pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}}
|
|
|
|
table.tools-table td {{
|
|
padding: 6px 12px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
}}
|
|
|
|
table.tools-table tr:nth-child(even) td {{
|
|
background: var(--bg-subtle);
|
|
}}
|
|
|
|
table.tools-table td:first-child {{
|
|
width: 32px;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
}}
|
|
|
|
/* ──────────────── Badges ──────────────── */
|
|
.badge {{
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-size: 7.5pt;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
vertical-align: middle;
|
|
}}
|
|
|
|
.badge-exploit {{
|
|
background: var(--sev-critical);
|
|
color: #fff;
|
|
}}
|
|
|
|
/* ──────────────── Findings ──────────────── */
|
|
.sev-group-title {{
|
|
font-family: var(--font-sans);
|
|
font-size: 11pt;
|
|
font-weight: 700;
|
|
color: var(--heading);
|
|
padding: 8px 0 6px 12px;
|
|
margin: 20px 0 8px;
|
|
border-left: 4px solid;
|
|
page-break-after: avoid;
|
|
break-after: avoid;
|
|
}}
|
|
|
|
.finding {{
|
|
border: 1px solid var(--border);
|
|
border-left: 4px solid;
|
|
border-radius: 0 4px 4px 0;
|
|
padding: 14px 16px;
|
|
margin-bottom: 12px;
|
|
background: #fff;
|
|
page-break-inside: avoid;
|
|
break-inside: avoid;
|
|
}}
|
|
|
|
.finding-header {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
|
|
.finding-id {{
|
|
font-family: var(--font-mono);
|
|
font-size: 9pt;
|
|
font-weight: 500;
|
|
color: var(--text-muted);
|
|
background: var(--bg-section);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
border: 1px solid var(--border-light);
|
|
}}
|
|
|
|
.finding-title {{
|
|
font-family: var(--font-serif);
|
|
font-weight: 700;
|
|
font-size: 11pt;
|
|
flex: 1;
|
|
color: var(--heading);
|
|
}}
|
|
|
|
.finding-meta {{
|
|
border-collapse: collapse;
|
|
margin: 6px 0;
|
|
font-size: 9.5pt;
|
|
width: 100%;
|
|
}}
|
|
|
|
.finding-meta td {{
|
|
padding: 3px 10px 3px 0;
|
|
vertical-align: top;
|
|
}}
|
|
|
|
.finding-meta td:first-child {{
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
width: 90px;
|
|
white-space: nowrap;
|
|
}}
|
|
|
|
.finding-desc {{
|
|
margin: 8px 0;
|
|
font-size: 10pt;
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
}}
|
|
|
|
.remediation {{
|
|
margin-top: 10px;
|
|
padding: 10px 14px;
|
|
background: var(--accent-light);
|
|
border-left: 3px solid var(--accent);
|
|
border-radius: 0 4px 4px 0;
|
|
font-size: 9.5pt;
|
|
line-height: 1.55;
|
|
}}
|
|
|
|
.remediation-label {{
|
|
font-weight: 700;
|
|
font-size: 8.5pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--accent);
|
|
margin-bottom: 3px;
|
|
}}
|
|
|
|
.evidence-block {{
|
|
margin: 10px 0;
|
|
page-break-inside: avoid;
|
|
break-inside: avoid;
|
|
}}
|
|
|
|
.evidence-title {{
|
|
font-weight: 700;
|
|
font-size: 8.5pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
}}
|
|
|
|
.evidence-table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 9pt;
|
|
}}
|
|
|
|
.evidence-table th {{
|
|
background: var(--bg-section);
|
|
padding: 5px 8px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 8.5pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
color: var(--text-secondary);
|
|
border: 1px solid var(--border-light);
|
|
}}
|
|
|
|
.evidence-table td {{
|
|
padding: 5px 8px;
|
|
border: 1px solid var(--border-light);
|
|
vertical-align: top;
|
|
word-break: break-word;
|
|
}}
|
|
|
|
.evidence-payload {{
|
|
font-size: 8.5pt;
|
|
color: var(--sev-critical);
|
|
}}
|
|
|
|
.linked-sast {{
|
|
font-size: 9pt;
|
|
color: var(--text-muted);
|
|
margin: 6px 0;
|
|
font-style: italic;
|
|
}}
|
|
|
|
/* ──────────────── Attack Chain ──────────────── */
|
|
.phase-block {{
|
|
margin-bottom: 20px;
|
|
page-break-inside: avoid;
|
|
break-inside: avoid;
|
|
}}
|
|
|
|
.phase-header {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 14px;
|
|
background: var(--heading);
|
|
color: #fff;
|
|
border-radius: 4px 4px 0 0;
|
|
font-size: 9.5pt;
|
|
}}
|
|
|
|
.phase-num {{
|
|
font-weight: 700;
|
|
font-size: 8pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
background: rgba(255,255,255,0.15);
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
}}
|
|
|
|
.phase-label {{
|
|
font-weight: 600;
|
|
flex: 1;
|
|
}}
|
|
|
|
.phase-count {{
|
|
font-size: 8.5pt;
|
|
opacity: 0.7;
|
|
}}
|
|
|
|
.phase-steps {{
|
|
border: 1px solid var(--border);
|
|
border-top: none;
|
|
border-radius: 0 0 4px 4px;
|
|
}}
|
|
|
|
.step-row {{
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 8px 14px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
position: relative;
|
|
}}
|
|
|
|
.step-row:last-child {{
|
|
border-bottom: none;
|
|
}}
|
|
|
|
.step-num {{
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
background: var(--bg-section);
|
|
border: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 8pt;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
flex-shrink: 0;
|
|
margin-top: 1px;
|
|
}}
|
|
|
|
.step-connector {{
|
|
display: none;
|
|
}}
|
|
|
|
.step-content {{
|
|
flex: 1;
|
|
min-width: 0;
|
|
}}
|
|
|
|
.step-header {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
|
|
.step-tool {{
|
|
font-family: var(--font-mono);
|
|
font-size: 9.5pt;
|
|
font-weight: 500;
|
|
color: var(--heading);
|
|
}}
|
|
|
|
.step-status {{
|
|
font-size: 7.5pt;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 1px 7px;
|
|
border-radius: 3px;
|
|
}}
|
|
|
|
.step-completed {{ background: #dcfce7; color: #166534; }}
|
|
.step-failed {{ background: #fef2f2; color: #991b1b; }}
|
|
.step-running {{ background: #fef9c3; color: #854d0e; }}
|
|
|
|
.step-findings {{
|
|
font-size: 8pt;
|
|
font-weight: 600;
|
|
color: var(--sev-high);
|
|
background: #fff7ed;
|
|
padding: 1px 7px;
|
|
border-radius: 3px;
|
|
border: 1px solid #fed7aa;
|
|
}}
|
|
|
|
.step-risk {{
|
|
font-size: 7.5pt;
|
|
font-weight: 700;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
}}
|
|
|
|
.risk-high {{ background: #fef2f2; color: var(--sev-critical); border: 1px solid #fecaca; }}
|
|
.risk-med {{ background: #fffbeb; color: var(--sev-medium); border: 1px solid #fde68a; }}
|
|
.risk-low {{ background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }}
|
|
|
|
.step-reasoning {{
|
|
font-size: 9pt;
|
|
color: var(--text-muted);
|
|
margin-top: 3px;
|
|
line-height: 1.5;
|
|
font-style: italic;
|
|
}}
|
|
|
|
/* ──────────────── Footer ──────────────── */
|
|
.report-footer {{
|
|
margin-top: 48px;
|
|
padding-top: 14px;
|
|
border-top: 2px solid var(--heading);
|
|
font-size: 8pt;
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
line-height: 1.8;
|
|
}}
|
|
|
|
.report-footer .footer-company {{
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
}}
|
|
|
|
/* ──────────────── Page Break Utilities ──────────────── */
|
|
.page-break {{
|
|
page-break-before: always;
|
|
break-before: page;
|
|
}}
|
|
|
|
.avoid-break {{
|
|
page-break-inside: avoid;
|
|
break-inside: avoid;
|
|
}}
|
|
|
|
/* ──────────────── Print Overrides ──────────────── */
|
|
@media print {{
|
|
body {{
|
|
font-size: 10pt;
|
|
}}
|
|
.cover {{
|
|
height: auto;
|
|
min-height: 250mm;
|
|
padding: 50mm 20mm;
|
|
}}
|
|
.report-body {{
|
|
padding: 0;
|
|
}}
|
|
.no-print {{
|
|
display: none !important;
|
|
}}
|
|
a {{
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}}
|
|
}}
|
|
|
|
/* ──────────────── Screen Enhancements ──────────────── */
|
|
@media screen {{
|
|
body {{
|
|
background: #e2e8f0;
|
|
}}
|
|
.cover {{
|
|
background: #fff;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
|
}}
|
|
.report-body {{
|
|
background: #fff;
|
|
padding: 20px 32px 40px;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
|
margin-bottom: 40px;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ═══════════════ COVER PAGE ═══════════════ -->
|
|
<div class="cover">
|
|
<svg class="cover-shield" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
|
<defs>
|
|
<linearGradient id="sg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stop-color="#0d2137"/>
|
|
<stop offset="100%" stop-color="#1a56db"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M48 6 L22 22 L22 48 C22 66 34 80 48 86 C62 80 74 66 74 48 L74 22 Z"
|
|
fill="none" stroke="url(#sg)" stroke-width="3.5" stroke-linejoin="round"/>
|
|
<path d="M48 12 L26 26 L26 47 C26 63 36 76 48 82 C60 76 70 63 70 47 L70 26 Z"
|
|
fill="url(#sg)" opacity="0.07"/>
|
|
<circle cx="44" cy="44" r="11" fill="none" stroke="#0d2137" stroke-width="2.5"/>
|
|
<line x1="52" y1="52" x2="62" y2="62" stroke="#0d2137" stroke-width="2.5" stroke-linecap="round"/>
|
|
<path d="M39 44 L42.5 47.5 L49 41" fill="none" stroke="#166534" stroke-width="2.5"
|
|
stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
|
|
<div class="cover-tag">CONFIDENTIAL</div>
|
|
|
|
<div class="cover-title">Penetration Test Report</div>
|
|
<div class="cover-subtitle">{target_name}</div>
|
|
|
|
<div class="cover-divider"></div>
|
|
|
|
<div class="cover-meta">
|
|
<strong>Report ID:</strong> {session_id}<br>
|
|
<strong>Date:</strong> {date_short}<br>
|
|
<strong>Target:</strong> {target_url}<br>
|
|
<strong>Prepared for:</strong> {requester_name} ({requester_email})
|
|
</div>
|
|
|
|
<div class="cover-footer">
|
|
Compliance Scanner — AI-Powered Security Assessment Platform
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════ TABLE OF CONTENTS ═══════════════ -->
|
|
<div class="report-body">
|
|
|
|
<div class="toc">
|
|
<h2>Table of Contents</h2>
|
|
<div class="toc-entry"><span class="toc-num">1</span><span class="toc-label">Executive Summary</span></div>
|
|
<div class="toc-entry"><span class="toc-num">2</span><span class="toc-label">Scope & Methodology</span></div>
|
|
<div class="toc-entry"><span class="toc-num">3</span><span class="toc-label">Findings ({total_findings})</span></div>
|
|
{toc_findings_sub}
|
|
<div class="toc-entry"><span class="toc-num">4</span><span class="toc-label">Attack Chain Timeline</span></div>
|
|
<div class="toc-entry"><span class="toc-num">5</span><span class="toc-label">Appendix</span></div>
|
|
</div>
|
|
|
|
<!-- ═══════════════ 1. EXECUTIVE SUMMARY ═══════════════ -->
|
|
<h2><span class="section-num">1.</span> Executive Summary</h2>
|
|
|
|
<div class="risk-gauge">
|
|
<div class="risk-gauge-meter">
|
|
<div class="risk-gauge-track">
|
|
<div class="risk-gauge-fill" style="width: {risk_score}%; background: {risk_color};"></div>
|
|
</div>
|
|
<div class="risk-gauge-score" style="color: {risk_color};">{risk_score} / 100</div>
|
|
</div>
|
|
<div class="risk-gauge-text">
|
|
<div class="risk-gauge-label" style="color: {risk_color};">Overall Risk: {overall_risk}</div>
|
|
<div class="risk-gauge-desc">
|
|
Based on {total_findings} finding{findings_plural} identified across the target application.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="exec-grid">
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{total_findings}</div>
|
|
<div class="kpi-label">Total Findings</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value" style="color: var(--sev-critical);">{critical_high}</div>
|
|
<div class="kpi-label">Critical / High</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value" style="color: var(--sev-critical);">{exploitable_count}</div>
|
|
<div class="kpi-label">Exploitable</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{tool_count}</div>
|
|
<div class="kpi-label">Tools Used</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>Severity Distribution</h3>
|
|
{severity_bar}
|
|
|
|
<p>
|
|
This report presents the results of an automated penetration test conducted against
|
|
<strong>{target_name}</strong> (<code>{target_url}</code>) using the Compliance Scanner
|
|
AI-powered testing engine. A total of <strong>{total_findings} vulnerabilities</strong> were
|
|
identified, of which <strong>{exploitable_count}</strong> were confirmed exploitable with
|
|
working proof-of-concept payloads. The assessment employed <strong>{tool_count} security tools</strong>
|
|
across <strong>{tool_invocations} invocations</strong> ({success_rate:.0}% success rate).
|
|
</p>
|
|
|
|
<!-- ═══════════════ 2. SCOPE & METHODOLOGY ═══════════════ -->
|
|
<div class="page-break"></div>
|
|
<h2><span class="section-num">2.</span> Scope & Methodology</h2>
|
|
|
|
<p>
|
|
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.
|
|
</p>
|
|
|
|
<h3>Engagement Details</h3>
|
|
<table class="info">
|
|
<tr><td>Target</td><td><strong>{target_name}</strong></td></tr>
|
|
<tr><td>URL</td><td><code>{target_url}</code></td></tr>
|
|
<tr><td>Strategy</td><td>{strategy}</td></tr>
|
|
<tr><td>Status</td><td>{status}</td></tr>
|
|
<tr><td>Started</td><td>{date_str}</td></tr>
|
|
<tr><td>Completed</td><td>{completed_str}</td></tr>
|
|
<tr><td>Tool Invocations</td><td>{tool_invocations} ({tool_successes} successful, {success_rate:.1}% success rate)</td></tr>
|
|
</table>
|
|
|
|
<h3>Tools Employed</h3>
|
|
<table class="tools-table">
|
|
<thead><tr><th>#</th><th>Tool</th><th>Category</th></tr></thead>
|
|
<tbody>{tools_table}</tbody>
|
|
</table>
|
|
|
|
<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
|
<div class="page-break"></div>
|
|
<h2><span class="section-num">3.</span> Findings</h2>
|
|
|
|
{findings_section}
|
|
|
|
<!-- ═══════════════ 4. ATTACK CHAIN ═══════════════ -->
|
|
<div class="page-break"></div>
|
|
<h2><span class="section-num">4.</span> Attack Chain Timeline</h2>
|
|
|
|
<p>
|
|
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.
|
|
</p>
|
|
|
|
<div style="margin-top: 16px;">
|
|
{chain_section}
|
|
</div>
|
|
|
|
<!-- ═══════════════ 5. APPENDIX ═══════════════ -->
|
|
<div class="page-break"></div>
|
|
<h2><span class="section-num">5.</span> Appendix</h2>
|
|
|
|
<h3>Severity Definitions</h3>
|
|
<table class="info">
|
|
<tr><td style="color: var(--sev-critical); font-weight: 700;">Critical</td><td>Vulnerabilities that can be exploited remotely without authentication to execute arbitrary code, exfiltrate sensitive data, or fully compromise the system.</td></tr>
|
|
<tr><td style="color: var(--sev-high); font-weight: 700;">High</td><td>Vulnerabilities that allow significant unauthorized access or data exposure, typically requiring minimal user interaction or privileges.</td></tr>
|
|
<tr><td style="color: var(--sev-medium); font-weight: 700;">Medium</td><td>Vulnerabilities that may lead to limited data exposure or require specific conditions to exploit, but still represent meaningful risk.</td></tr>
|
|
<tr><td style="color: var(--sev-low); font-weight: 700;">Low</td><td>Minor issues with limited direct impact. May contribute to broader attack chains or indicate defense-in-depth weaknesses.</td></tr>
|
|
<tr><td style="color: var(--sev-info); font-weight: 700;">Info</td><td>Observations and best-practice recommendations that do not represent direct security vulnerabilities.</td></tr>
|
|
</table>
|
|
|
|
<h3>Disclaimer</h3>
|
|
<p style="font-size: 9pt; color: var(--text-secondary);">
|
|
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.
|
|
</p>
|
|
|
|
<!-- ═══════════════ FOOTER ═══════════════ -->
|
|
<div class="report-footer">
|
|
<div class="footer-company">Compliance Scanner</div>
|
|
<div>AI-Powered Security Assessment Platform</div>
|
|
<div style="margin-top: 6px;">This document is confidential and intended solely for the named recipient.</div>
|
|
<div>Report ID: {session_id}</div>
|
|
</div>
|
|
|
|
</div><!-- .report-body -->
|
|
</body>
|
|
</html>
|
|
"##,
|
|
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() {
|
|
"<p style=\"color: var(--text-muted);\">No vulnerabilities were identified during this assessment.</p>".to_string()
|
|
} else {
|
|
findings_html
|
|
},
|
|
chain_section = if ctx.attack_chain.is_empty() {
|
|
"<p style=\"color: var(--text-muted);\">No attack chain steps recorded.</p>".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("<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(),
|
|
}
|
|
}
|
|
|
|
#[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")
|
|
);
|
|
}
|
|
}
|