feat: pentest feature improvements — streaming, pause/resume, encryption, browser tool, reports, docs
- True SSE streaming via broadcast channels (DashMap per session) - Session pause/resume with watch channels + dashboard buttons - AES-256-GCM credential encryption at rest (PENTEST_ENCRYPTION_KEY) - Concurrency limiter (Semaphore, max 5 sessions, 429 on overflow) - Browser tool: headless Chrome CDP automation (navigate, click, fill, screenshot, evaluate) - Report code-level correlation: SAST findings, code graph, SBOM linked per DAST finding - Split html.rs (1919 LOC) into html/ module directory (8 files) - Wizard: target/repo dropdowns from existing data, SSH key display, close button on all steps - Auth: auto-register with optional registration URL (Playwright discovery), plus-addressing email, IMAP overrides - Attack chain: tool input/output in detail panel, running node pulse animation - Architecture docs with Mermaid diagrams + 8 screenshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
40
compliance-agent/src/pentest/report/html/appendix.rs
Normal file
40
compliance-agent/src/pentest/report/html/appendix.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn appendix(session_id: &str) -> String {
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 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>"##,
|
||||
session_id = html_escape(session_id),
|
||||
)
|
||||
}
|
||||
175
compliance-agent/src/pentest/report/html/attack_chain.rs
Normal file
175
compliance-agent/src/pentest/report/html/attack_chain.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::pentest::AttackChainNode;
|
||||
|
||||
pub(super) fn attack_chain(chain: &[AttackChainNode]) -> String {
|
||||
let chain_section = if chain.is_empty() {
|
||||
r#"<p style="color: var(--text-muted);">No attack chain steps recorded.</p>"#.to_string()
|
||||
} else {
|
||||
build_chain_html(chain)
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 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>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn build_chain_html(chain: &[AttackChainNode]) -> String {
|
||||
let mut chain_html = String::new();
|
||||
|
||||
// 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 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 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 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> = 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>");
|
||||
}
|
||||
|
||||
chain_html
|
||||
}
|
||||
56
compliance-agent/src/pentest/report/html/cover.rs
Normal file
56
compliance-agent/src/pentest/report/html/cover.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn cover(
|
||||
target_name: &str,
|
||||
session_id: &str,
|
||||
date_short: &str,
|
||||
target_url: &str,
|
||||
requester_name: &str,
|
||||
requester_email: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 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>"##,
|
||||
target_name = html_escape(target_name),
|
||||
session_id = html_escape(session_id),
|
||||
date_short = date_short,
|
||||
target_url = html_escape(target_url),
|
||||
requester_name = html_escape(requester_name),
|
||||
requester_email = html_escape(requester_email),
|
||||
)
|
||||
}
|
||||
238
compliance-agent/src/pentest/report/html/executive_summary.rs
Normal file
238
compliance-agent/src/pentest/report/html/executive_summary.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
|
||||
pub(super) fn executive_summary(
|
||||
findings: &[DastFinding],
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
tool_count: usize,
|
||||
tool_invocations: u32,
|
||||
success_rate: f64,
|
||||
) -> String {
|
||||
let critical = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "critical")
|
||||
.count();
|
||||
let high = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "high")
|
||||
.count();
|
||||
let medium = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "medium")
|
||||
.count();
|
||||
let low = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "low")
|
||||
.count();
|
||||
let info = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "info")
|
||||
.count();
|
||||
let exploitable = findings.iter().filter(|f| f.exploitable).count();
|
||||
let total = 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",
|
||||
};
|
||||
|
||||
let risk_score: usize =
|
||||
std::cmp::min(100, critical * 25 + high * 15 + medium * 8 + low * 3 + info);
|
||||
|
||||
let severity_bar = build_severity_bar(critical, high, medium, low, info, total);
|
||||
|
||||
// Table of contents finding sub-entries
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let toc_findings_sub = if !findings.is_empty() {
|
||||
let mut sub = String::new();
|
||||
let mut fnum = 0usize;
|
||||
for &sev_key in severity_order.iter() {
|
||||
let count = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for f in 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()
|
||||
};
|
||||
|
||||
let critical_high_str = format!("{} / {}", critical, high);
|
||||
let escaped_target_name = html_escape(target_name);
|
||||
let escaped_target_url = html_escape(target_url);
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 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>"##,
|
||||
total_findings = total,
|
||||
findings_plural = if total == 1 { "" } else { "s" },
|
||||
critical_high = critical_high_str,
|
||||
exploitable_count = exploitable,
|
||||
target_name = escaped_target_name,
|
||||
target_url = escaped_target_url,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_severity_bar(
|
||||
critical: usize,
|
||||
high: usize,
|
||||
medium: usize,
|
||||
low: usize,
|
||||
info: usize,
|
||||
total: usize,
|
||||
) -> String {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
369
compliance-agent/src/pentest/report/html/findings.rs
Normal file
369
compliance-agent/src/pentest/report/html/findings.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::CodeContextHint;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
/// Render the findings section with code-level correlation.
|
||||
///
|
||||
/// For each DAST finding, if a linked SAST finding exists (via `linked_sast_finding_id`)
|
||||
/// or if we can match the endpoint to a code entry point, we render a "Code-Level
|
||||
/// Remediation" block showing the exact file, line, code snippet, and suggested fix.
|
||||
pub(super) fn findings(
|
||||
findings_list: &[DastFinding],
|
||||
sast_findings: &[Finding],
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
if findings_list.is_empty() {
|
||||
return r#"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
<p style="color: var(--text-muted);">No vulnerabilities were identified during this assessment.</p>"#.to_string();
|
||||
}
|
||||
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"];
|
||||
let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"];
|
||||
|
||||
// Build SAST lookup by ObjectId hex string
|
||||
let sast_by_id: std::collections::HashMap<String, &Finding> = sast_findings
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let id = f.id.as_ref()?.to_hex();
|
||||
Some((id, f))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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> = findings_list
|
||||
.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 = build_evidence_html(f);
|
||||
|
||||
// ── Code-level correlation ──────────────────────────────
|
||||
let code_correlation =
|
||||
build_code_correlation(f, &sast_by_id, code_context, sbom_entries);
|
||||
|
||||
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}
|
||||
{code_correlation}
|
||||
<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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
{findings_html}"##
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the evidence table HTML for a finding.
|
||||
fn build_evidence_html(f: &DastFinding) -> String {
|
||||
if f.evidence.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Build the code-level correlation block for a DAST finding.
|
||||
///
|
||||
/// Attempts correlation in priority order:
|
||||
/// 1. Direct link via `linked_sast_finding_id` → shows exact file, line, snippet, suggested fix
|
||||
/// 2. Endpoint match via code context → shows handler function, file, known SAST vulns
|
||||
/// 3. CWE/CVE match to SBOM → shows vulnerable dependency + version to upgrade
|
||||
fn build_code_correlation(
|
||||
dast_finding: &DastFinding,
|
||||
sast_by_id: &std::collections::HashMap<String, &Finding>,
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Direct SAST link
|
||||
if let Some(ref sast_id) = dast_finding.linked_sast_finding_id {
|
||||
if let Some(sast) = sast_by_id.get(sast_id) {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">SAST Correlation</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
|
||||
if let Some(ref fp) = sast.file_path {
|
||||
let line_info = sast
|
||||
.line_number
|
||||
.map(|l| format!(":{l}"))
|
||||
.unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Location</td><td><code>{}{}</code></td></tr>",
|
||||
html_escape(fp),
|
||||
line_info,
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Scanner</td><td>{} — {}</td></tr>",
|
||||
html_escape(&sast.scanner),
|
||||
html_escape(&sast.title),
|
||||
));
|
||||
if let Some(ref cwe) = sast.cwe {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CWE</td><td>{}</td></tr>",
|
||||
html_escape(cwe)
|
||||
));
|
||||
}
|
||||
if let Some(ref rule) = sast.rule_id {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Rule</td><td><code>{}</code></td></tr>",
|
||||
html_escape(rule)
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
|
||||
// Code snippet
|
||||
if let Some(ref snippet) = sast.code_snippet {
|
||||
if !snippet.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-snippet-block\"><div class=\"code-snippet-label\">Vulnerable Code</div><pre class=\"code-snippet\">{}</pre></div>",
|
||||
html_escape(snippet)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested fix
|
||||
if let Some(ref fix) = sast.suggested_fix {
|
||||
if !fix.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-fix-block\"><div class=\"code-fix-label\">Suggested Fix</div><pre class=\"code-fix\">{}</pre></div>",
|
||||
html_escape(fix)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Remediation from SAST
|
||||
if let Some(ref rem) = sast.remediation {
|
||||
if !rem.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">{}</div>",
|
||||
html_escape(rem)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Endpoint match via code context
|
||||
let endpoint_lower = dast_finding.endpoint.to_lowercase();
|
||||
let matching_hints: Vec<&CodeContextHint> = code_context
|
||||
.iter()
|
||||
.filter(|hint| {
|
||||
// Match by endpoint pattern overlap
|
||||
let pattern_lower = hint.endpoint_pattern.to_lowercase();
|
||||
endpoint_lower.contains(&pattern_lower)
|
||||
|| pattern_lower.contains(&endpoint_lower)
|
||||
|| hint.file_path.to_lowercase().contains(
|
||||
&endpoint_lower
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("")
|
||||
.replace(".html", "")
|
||||
.replace(".php", ""),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for hint in &matching_hints {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Code Entry Point</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Handler</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.handler_function),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>File</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.file_path),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Route</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.endpoint_pattern),
|
||||
));
|
||||
s.push_str("</table>");
|
||||
|
||||
if !hint.known_vulnerabilities.is_empty() {
|
||||
s.push_str("<div class=\"code-linked-vulns\"><strong>Known SAST issues in this file:</strong><ul>");
|
||||
for vuln in &hint.known_vulnerabilities {
|
||||
s.push_str(&format!("<li>{}</li>", html_escape(vuln)));
|
||||
}
|
||||
s.push_str("</ul></div>");
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
|
||||
// 3. SBOM match — if a linked SAST finding has a CVE, or we can match by CWE
|
||||
let linked_cve = dast_finding
|
||||
.linked_sast_finding_id
|
||||
.as_deref()
|
||||
.and_then(|id| sast_by_id.get(id))
|
||||
.and_then(|f| f.cve.as_deref());
|
||||
|
||||
if let Some(cve_id) = linked_cve {
|
||||
let matching_deps: Vec<&SbomEntry> = sbom_entries
|
||||
.iter()
|
||||
.filter(|e| e.known_vulnerabilities.iter().any(|v| v.id == cve_id))
|
||||
.collect();
|
||||
|
||||
for dep in &matching_deps {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Vulnerable Dependency</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Package</td><td><code>{} {}</code> ({})</td></tr>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(&dep.version),
|
||||
html_escape(&dep.package_manager),
|
||||
));
|
||||
let cve_ids: Vec<&str> = dep
|
||||
.known_vulnerabilities
|
||||
.iter()
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CVEs</td><td>{}</td></tr>",
|
||||
cve_ids.join(", "),
|
||||
));
|
||||
if let Some(ref purl) = dep.purl {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>PURL</td><td><code>{}</code></td></tr>",
|
||||
html_escape(purl),
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">Upgrade <code>{}</code> to the latest patched version to resolve {}.</div>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(cve_id),
|
||||
));
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"<div class="code-correlation">
|
||||
<div class="code-correlation-title">Code-Level Remediation</div>
|
||||
{}
|
||||
</div>"#,
|
||||
sections.join("\n")
|
||||
)
|
||||
}
|
||||
473
compliance-agent/src/pentest/report/html/mod.rs
Normal file
473
compliance-agent/src/pentest/report/html/mod.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
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
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
127
compliance-agent/src/pentest/report/html/scope.rs
Normal file
127
compliance-agent/src/pentest/report/html/scope.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use super::{html_escape, tool_category};
|
||||
use compliance_core::models::pentest::{AuthMode, PentestConfig, PentestSession};
|
||||
|
||||
pub(super) fn scope(
|
||||
session: &PentestSession,
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
date_str: &str,
|
||||
completed_str: &str,
|
||||
tool_names: &[String],
|
||||
config: Option<&PentestConfig>,
|
||||
) -> String {
|
||||
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");
|
||||
|
||||
let engagement_config_section = if let Some(cfg) = config {
|
||||
let mut rows = String::new();
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Environment</td><td>{}</td></tr>",
|
||||
html_escape(&cfg.environment.to_string())
|
||||
));
|
||||
if let Some(ref app_type) = cfg.app_type {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Application Type</td><td>{}</td></tr>",
|
||||
html_escape(app_type)
|
||||
));
|
||||
}
|
||||
let auth_mode = match cfg.auth.mode {
|
||||
AuthMode::None => "No authentication",
|
||||
AuthMode::Manual => "Manual credentials",
|
||||
AuthMode::AutoRegister => "Auto-register",
|
||||
};
|
||||
rows.push_str(&format!("<tr><td>Auth Mode</td><td>{auth_mode}</td></tr>"));
|
||||
if !cfg.scope_exclusions.is_empty() {
|
||||
let excl = cfg
|
||||
.scope_exclusions
|
||||
.iter()
|
||||
.map(|s| html_escape(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Scope Exclusions</td><td><code>{excl}</code></td></tr>"
|
||||
));
|
||||
}
|
||||
if !cfg.tester.name.is_empty() {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Tester</td><td>{} ({})</td></tr>",
|
||||
html_escape(&cfg.tester.name),
|
||||
html_escape(&cfg.tester.email)
|
||||
));
|
||||
}
|
||||
if let Some(ref ts) = cfg.disclaimer_accepted_at {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Disclaimer Accepted</td><td>{}</td></tr>",
|
||||
ts.format("%B %d, %Y at %H:%M UTC")
|
||||
));
|
||||
}
|
||||
if let Some(ref branch) = cfg.branch {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Branch</td><td>{}</td></tr>",
|
||||
html_escape(branch)
|
||||
));
|
||||
}
|
||||
if let Some(ref commit) = cfg.commit_hash {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Commit</td><td><code>{}</code></td></tr>",
|
||||
html_escape(commit)
|
||||
));
|
||||
}
|
||||
format!("<h3>Engagement Configuration</h3>\n<table class=\"info\">\n{rows}\n</table>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"
|
||||
<!-- ═══════════════ 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>
|
||||
|
||||
{engagement_config_section}
|
||||
|
||||
<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>"##,
|
||||
target_name = html_escape(target_name),
|
||||
target_url = html_escape(target_url),
|
||||
strategy = session.strategy,
|
||||
status = session.status,
|
||||
date_str = date_str,
|
||||
completed_str = completed_str,
|
||||
tool_invocations = session.tool_invocations,
|
||||
tool_successes = session.tool_successes,
|
||||
success_rate = session.success_rate(),
|
||||
)
|
||||
}
|
||||
889
compliance-agent/src/pentest/report/html/styles.rs
Normal file
889
compliance-agent/src/pentest/report/html/styles.rs
Normal file
@@ -0,0 +1,889 @@
|
||||
pub(super) fn styles() -> String {
|
||||
r##"<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;
|
||||
}
|
||||
|
||||
/* ──────────────── Code-Level Correlation ──────────────── */
|
||||
.code-correlation {
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-correlation-title {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
padding: 6px 12px;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.code-correlation-item {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.code-correlation-item:last-child { border-bottom: none; }
|
||||
.code-correlation-badge {
|
||||
display: inline-block;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 7pt;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta {
|
||||
width: 100%;
|
||||
font-size: 8.5pt;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta td:first-child {
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.code-meta td:last-child {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.code-snippet-block, .code-fix-block {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.code-snippet-label, .code-fix-label {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.code-snippet-label { color: #dc2626; }
|
||||
.code-fix-label { color: #16a34a; }
|
||||
.code-snippet {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-fix {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-left: 3px solid #16a34a;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-remediation {
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.code-linked-vulns {
|
||||
font-size: 8.5pt;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.code-linked-vulns ul {
|
||||
margin: 2px 0 0 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.code-linked-vulns li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ──────────────── 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>"##
|
||||
.to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user