Some checks failed
CI / Clippy (push) Failing after 1m51s
CI / Security Audit (push) Successful in 2m1s
CI / Tests (push) Has been skipped
CI / Detect Changes (push) 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 / Format (push) Failing after 42s
CI / Deploy MCP (push) Has been skipped
1602 lines
49 KiB
Rust
1602 lines
49 KiB
Rust
use std::io::{Cursor, Write};
|
|
|
|
use compliance_core::models::dast::DastFinding;
|
|
use compliance_core::models::pentest::{AttackChainNode, PentestSession};
|
|
use sha2::{Digest, Sha256};
|
|
use zip::write::SimpleFileOptions;
|
|
use zip::AesMode;
|
|
|
|
/// Report archive with metadata
|
|
pub struct ReportArchive {
|
|
/// The password-protected ZIP bytes
|
|
pub archive: Vec<u8>,
|
|
/// SHA-256 hex digest of the archive
|
|
pub sha256: String,
|
|
}
|
|
|
|
/// Report context gathered from the database
|
|
pub struct ReportContext {
|
|
pub session: PentestSession,
|
|
pub target_name: String,
|
|
pub target_url: String,
|
|
pub findings: Vec<DastFinding>,
|
|
pub attack_chain: Vec<AttackChainNode>,
|
|
pub requester_name: String,
|
|
pub requester_email: String,
|
|
}
|
|
|
|
/// Generate a password-protected ZIP archive containing the pentest report.
|
|
///
|
|
/// The archive contains:
|
|
/// - `report.pdf` — Professional pentest report (PDF)
|
|
/// - `report.html` — HTML source (fallback)
|
|
/// - `findings.json` — Raw findings data
|
|
/// - `attack-chain.json` — Attack chain timeline
|
|
///
|
|
/// Files are encrypted with AES-256 inside the ZIP (standard WinZip AES format,
|
|
/// supported by 7-Zip, WinRAR, macOS Archive Utility, etc.).
|
|
pub async fn generate_encrypted_report(
|
|
ctx: &ReportContext,
|
|
password: &str,
|
|
) -> Result<ReportArchive, String> {
|
|
let html = build_html_report(ctx);
|
|
|
|
// Convert HTML to PDF via headless Chrome
|
|
let pdf_bytes = html_to_pdf(&html).await?;
|
|
|
|
let zip_bytes = build_zip(ctx, password, &html, &pdf_bytes)
|
|
.map_err(|e| format!("Failed to create archive: {e}"))?;
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&zip_bytes);
|
|
let sha256 = hex::encode(hasher.finalize());
|
|
|
|
Ok(ReportArchive { archive: zip_bytes, sha256 })
|
|
}
|
|
|
|
/// Convert HTML string to PDF bytes using headless Chrome/Chromium.
|
|
async fn html_to_pdf(html: &str) -> Result<Vec<u8>, String> {
|
|
let tmp_dir = std::env::temp_dir();
|
|
let run_id = uuid::Uuid::new_v4().to_string();
|
|
let html_path = tmp_dir.join(format!("pentest-report-{run_id}.html"));
|
|
let pdf_path = tmp_dir.join(format!("pentest-report-{run_id}.pdf"));
|
|
|
|
// Write HTML to temp file
|
|
std::fs::write(&html_path, html)
|
|
.map_err(|e| format!("Failed to write temp HTML: {e}"))?;
|
|
|
|
// Find Chrome/Chromium binary
|
|
let chrome_bin = find_chrome_binary()
|
|
.ok_or_else(|| "Chrome/Chromium not found. Install google-chrome or chromium to generate PDF reports.".to_string())?;
|
|
|
|
tracing::info!(chrome = %chrome_bin, "Generating PDF report via headless Chrome");
|
|
|
|
let html_url = format!("file://{}", html_path.display());
|
|
|
|
let output = tokio::process::Command::new(&chrome_bin)
|
|
.args([
|
|
"--headless",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--disable-software-rasterizer",
|
|
"--run-all-compositor-stages-before-draw",
|
|
"--disable-dev-shm-usage",
|
|
&format!("--print-to-pdf={}", pdf_path.display()),
|
|
"--no-pdf-header-footer",
|
|
&html_url,
|
|
])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to run Chrome: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
// Clean up temp files
|
|
let _ = std::fs::remove_file(&html_path);
|
|
let _ = std::fs::remove_file(&pdf_path);
|
|
return Err(format!("Chrome PDF generation failed: {stderr}"));
|
|
}
|
|
|
|
let pdf_bytes = std::fs::read(&pdf_path)
|
|
.map_err(|e| format!("Failed to read generated PDF: {e}"))?;
|
|
|
|
// Clean up temp files
|
|
let _ = std::fs::remove_file(&html_path);
|
|
let _ = std::fs::remove_file(&pdf_path);
|
|
|
|
if pdf_bytes.is_empty() {
|
|
return Err("Chrome produced an empty PDF".to_string());
|
|
}
|
|
|
|
tracing::info!(size_kb = pdf_bytes.len() / 1024, "PDF report generated");
|
|
Ok(pdf_bytes)
|
|
}
|
|
|
|
/// Search for Chrome/Chromium binary on the system.
|
|
fn find_chrome_binary() -> Option<String> {
|
|
let candidates = [
|
|
"google-chrome-stable",
|
|
"google-chrome",
|
|
"chromium-browser",
|
|
"chromium",
|
|
];
|
|
for name in &candidates {
|
|
if let Ok(output) = std::process::Command::new("which").arg(name).output() {
|
|
if output.status.success() {
|
|
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !path.is_empty() {
|
|
return Some(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn build_zip(
|
|
ctx: &ReportContext,
|
|
password: &str,
|
|
html: &str,
|
|
pdf: &[u8],
|
|
) -> Result<Vec<u8>, zip::result::ZipError> {
|
|
let buf = Cursor::new(Vec::new());
|
|
let mut zip = zip::ZipWriter::new(buf);
|
|
|
|
let options = SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Deflated)
|
|
.with_aes_encryption(AesMode::Aes256, password);
|
|
|
|
// report.pdf (primary)
|
|
zip.start_file("report.pdf", options.clone())?;
|
|
zip.write_all(pdf)?;
|
|
|
|
// report.html (fallback)
|
|
zip.start_file("report.html", options.clone())?;
|
|
zip.write_all(html.as_bytes())?;
|
|
|
|
// findings.json
|
|
let findings_json =
|
|
serde_json::to_string_pretty(&ctx.findings).unwrap_or_else(|_| "[]".to_string());
|
|
zip.start_file("findings.json", options.clone())?;
|
|
zip.write_all(findings_json.as_bytes())?;
|
|
|
|
// attack-chain.json
|
|
let chain_json =
|
|
serde_json::to_string_pretty(&ctx.attack_chain).unwrap_or_else(|_| "[]".to_string());
|
|
zip.start_file("attack-chain.json", options)?;
|
|
zip.write_all(chain_json.as_bytes())?;
|
|
|
|
let cursor = zip.finish()?;
|
|
Ok(cursor.into_inner())
|
|
}
|
|
|
|
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 * 1,
|
|
);
|
|
|
|
// 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(|s| html_escape(s))
|
|
.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 (si, &sev_key) in severity_order.iter().enumerate() {
|
|
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('"', """)
|
|
}
|