use crate::scanner::{ScanResults, Severity};
pub fn generate_html(results: &ScanResults) -> String {
let severity_data = get_severity_data(results);
let findings_html = generate_findings_html(results);
let summary_html = generate_summary_html(results);
let timestamp = chrono::Utc::now().to_rfc3339();
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCPLint Security Report - {server}</title>
<style>
:root {{
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent-blue: #58a6ff;
--severity-critical: #f85149;
--severity-high: #f0883e;
--severity-medium: #d29922;
--severity-low: #3fb950;
--severity-info: #8b949e;
}}
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
}}
header {{
border-bottom: 1px solid var(--border-color);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}}
h1 {{
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}}
.meta {{
color: var(--text-secondary);
font-size: 0.875rem;
}}
.summary-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}}
.summary-card {{
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
}}
.summary-card.critical {{
border-left: 4px solid var(--severity-critical);
}}
.summary-card.high {{
border-left: 4px solid var(--severity-high);
}}
.summary-card.medium {{
border-left: 4px solid var(--severity-medium);
}}
.summary-card.low {{
border-left: 4px solid var(--severity-low);
}}
.summary-card.info {{
border-left: 4px solid var(--severity-info);
}}
.summary-card .label {{
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}}
.summary-card .value {{
font-size: 2rem;
font-weight: 600;
}}
.chart-container {{
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 2rem;
}}
.chart-title {{
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
}}
.bar-chart {{
display: flex;
height: 24px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-tertiary);
}}
.bar-segment {{
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: white;
min-width: 30px;
}}
.bar-segment.critical {{ background: var(--severity-critical); }}
.bar-segment.high {{ background: var(--severity-high); }}
.bar-segment.medium {{ background: var(--severity-medium); }}
.bar-segment.low {{ background: var(--severity-low); }}
.bar-segment.info {{ background: var(--severity-info); }}
.chart-legend {{
display: flex;
gap: 1.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}}
.legend-dot {{
width: 12px;
height: 12px;
border-radius: 50%;
}}
.legend-dot.critical {{ background: var(--severity-critical); }}
.legend-dot.high {{ background: var(--severity-high); }}
.legend-dot.medium {{ background: var(--severity-medium); }}
.legend-dot.low {{ background: var(--severity-low); }}
.legend-dot.info {{ background: var(--severity-info); }}
.findings-section {{
margin-top: 2rem;
}}
.section-title {{
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}}
.finding-card {{
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 1rem;
overflow: hidden;
}}
.finding-header {{
padding: 1rem;
display: flex;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid var(--border-color);
}}
.severity-badge {{
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}}
.severity-badge.critical {{
background: var(--severity-critical);
color: white;
}}
.severity-badge.high {{
background: var(--severity-high);
color: white;
}}
.severity-badge.medium {{
background: var(--severity-medium);
color: black;
}}
.severity-badge.low {{
background: var(--severity-low);
color: black;
}}
.severity-badge.info {{
background: var(--severity-info);
color: white;
}}
.finding-title {{
font-weight: 600;
margin-bottom: 0.25rem;
}}
.finding-rule {{
font-size: 0.875rem;
color: var(--text-secondary);
}}
.finding-body {{
padding: 1rem;
}}
.finding-description {{
margin-bottom: 1rem;
}}
.finding-detail {{
margin-bottom: 0.75rem;
}}
.finding-detail-label {{
font-size: 0.75rem;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}}
.finding-detail-value {{
font-size: 0.875rem;
background: var(--bg-tertiary);
padding: 0.5rem;
border-radius: 4px;
font-family: monospace;
word-break: break-all;
}}
.remediation {{
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.3);
border-radius: 4px;
padding: 1rem;
margin-top: 1rem;
}}
.remediation-title {{
font-weight: 600;
color: var(--severity-low);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}}
.references {{
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}}
.reference-tag {{
background: var(--bg-tertiary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
color: var(--accent-blue);
text-decoration: none;
}}
.reference-tag:hover {{
background: var(--border-color);
}}
.no-findings {{
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.3);
border-radius: 6px;
padding: 2rem;
text-align: center;
}}
.no-findings-icon {{
font-size: 3rem;
margin-bottom: 1rem;
}}
.no-findings-title {{
font-size: 1.25rem;
font-weight: 600;
color: var(--severity-low);
margin-bottom: 0.5rem;
}}
footer {{
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}}
@media print {{
body {{
background: white;
color: black;
}}
.summary-card, .chart-container, .finding-card {{
border-color: #ddd;
background: white;
}}
}}
</style>
</head>
<body>
<div class="container">
<header>
<h1>MCPLint Security Report</h1>
<div class="meta">
<div>Server: <strong>{server}</strong></div>
<div>Profile: <strong>{profile}</strong></div>
<div>Generated: {timestamp}</div>
<div>Duration: {duration_ms}ms | Checks: {total_checks}</div>
</div>
</header>
{summary_html}
<div class="chart-container">
<div class="chart-title">Severity Distribution</div>
{severity_chart}
<div class="chart-legend">
<div class="legend-item">
<div class="legend-dot critical"></div>
<span>Critical ({critical})</span>
</div>
<div class="legend-item">
<div class="legend-dot high"></div>
<span>High ({high})</span>
</div>
<div class="legend-item">
<div class="legend-dot medium"></div>
<span>Medium ({medium})</span>
</div>
<div class="legend-item">
<div class="legend-dot low"></div>
<span>Low ({low})</span>
</div>
<div class="legend-item">
<div class="legend-dot info"></div>
<span>Info ({info})</span>
</div>
</div>
</div>
<div class="findings-section">
<h2 class="section-title">Findings ({total_findings})</h2>
{findings_html}
</div>
<footer>
Generated by MCPLint v{version} |
<a href="https://github.com/quanticsoul4772/mcplint" style="color: var(--accent-blue);">Documentation</a>
</footer>
</div>
</body>
</html>"##,
server = html_escape(&results.server),
profile = html_escape(&results.profile),
timestamp = timestamp,
duration_ms = results.duration_ms,
total_checks = results.total_checks,
summary_html = summary_html,
severity_chart = generate_severity_chart(&severity_data),
critical = severity_data.critical,
high = severity_data.high,
medium = severity_data.medium,
low = severity_data.low,
info = severity_data.info,
total_findings = results.findings.len(),
findings_html = findings_html,
version = env!("CARGO_PKG_VERSION"),
)
}
struct SeverityData {
critical: usize,
high: usize,
medium: usize,
low: usize,
info: usize,
total: usize,
}
fn get_severity_data(results: &ScanResults) -> SeverityData {
SeverityData {
critical: results.summary.critical,
high: results.summary.high,
medium: results.summary.medium,
low: results.summary.low,
info: results.summary.info,
total: results.findings.len(),
}
}
fn generate_severity_chart(data: &SeverityData) -> String {
if data.total == 0 {
return r#"<div class="bar-chart"><div class="bar-segment info" style="width: 100%;">No findings</div></div>"#.to_string();
}
let mut segments = Vec::new();
let total = data.total as f64;
if data.critical > 0 {
let pct = (data.critical as f64 / total * 100.0).round();
segments.push(format!(
r#"<div class="bar-segment critical" style="width: {}%;">{}</div>"#,
pct, data.critical
));
}
if data.high > 0 {
let pct = (data.high as f64 / total * 100.0).round();
segments.push(format!(
r#"<div class="bar-segment high" style="width: {}%;">{}</div>"#,
pct, data.high
));
}
if data.medium > 0 {
let pct = (data.medium as f64 / total * 100.0).round();
segments.push(format!(
r#"<div class="bar-segment medium" style="width: {}%;">{}</div>"#,
pct, data.medium
));
}
if data.low > 0 {
let pct = (data.low as f64 / total * 100.0).round();
segments.push(format!(
r#"<div class="bar-segment low" style="width: {}%;">{}</div>"#,
pct, data.low
));
}
if data.info > 0 {
let pct = (data.info as f64 / total * 100.0).round();
segments.push(format!(
r#"<div class="bar-segment info" style="width: {}%;">{}</div>"#,
pct, data.info
));
}
format!(r#"<div class="bar-chart">{}</div>"#, segments.join(""))
}
fn generate_summary_html(results: &ScanResults) -> String {
format!(
r#"<div class="summary-grid">
<div class="summary-card critical">
<div class="label">Critical</div>
<div class="value">{}</div>
</div>
<div class="summary-card high">
<div class="label">High</div>
<div class="value">{}</div>
</div>
<div class="summary-card medium">
<div class="label">Medium</div>
<div class="value">{}</div>
</div>
<div class="summary-card low">
<div class="label">Low</div>
<div class="value">{}</div>
</div>
<div class="summary-card info">
<div class="label">Info</div>
<div class="value">{}</div>
</div>
</div>"#,
results.summary.critical,
results.summary.high,
results.summary.medium,
results.summary.low,
results.summary.info,
)
}
fn generate_findings_html(results: &ScanResults) -> String {
if results.findings.is_empty() {
return r#"<div class="no-findings">
<div class="no-findings-icon">✓</div>
<div class="no-findings-title">No Security Issues Found</div>
<div>The scan completed successfully with no vulnerabilities detected.</div>
</div>"#
.to_string();
}
let mut sorted_findings = results.findings.clone();
sorted_findings.sort_by(|a, b| b.severity.cmp(&a.severity));
sorted_findings
.iter()
.map(|f| {
let severity_class = match f.severity {
Severity::Critical => "critical",
Severity::High => "high",
Severity::Medium => "medium",
Severity::Low => "low",
Severity::Info => "info",
};
let location_html = if !f.location.component.is_empty() {
format!(
r#"<div class="finding-detail">
<div class="finding-detail-label">Location</div>
<div class="finding-detail-value">{}: {}</div>
</div>"#,
html_escape(&f.location.component),
html_escape(&f.location.identifier)
)
} else {
String::new()
};
let evidence_html = if !f.evidence.is_empty() {
let evidence_items: Vec<String> = f
.evidence
.iter()
.map(|e| {
format!(
r#"<div class="finding-detail">
<div class="finding-detail-label">{:?}</div>
<div class="finding-detail-value">{}</div>
</div>"#,
e.kind,
html_escape(&e.description)
)
})
.collect();
evidence_items.join("")
} else {
String::new()
};
let remediation_html = if !f.remediation.is_empty() {
format!(
r#"<div class="remediation">
<div class="remediation-title">Remediation</div>
<div>{}</div>
</div>"#,
html_escape(&f.remediation)
)
} else {
String::new()
};
let references_html = if !f.references.is_empty() {
let refs: Vec<String> = f
.references
.iter()
.map(|r| {
if let Some(url) = &r.url {
format!(
r#"<a href="{}" class="reference-tag" target="_blank">{}</a>"#,
html_escape(url),
html_escape(&r.id)
)
} else {
format!(
r#"<span class="reference-tag">{}</span>"#,
html_escape(&r.id)
)
}
})
.collect();
format!(r#"<div class="references">{}</div>"#, refs.join(""))
} else {
String::new()
};
format!(
r#"<div class="finding-card">
<div class="finding-header">
<span class="severity-badge {severity_class}">{severity}</span>
<div>
<div class="finding-title">{title}</div>
<div class="finding-rule">{rule_id}</div>
</div>
</div>
<div class="finding-body">
<div class="finding-description">{description}</div>
{location_html}
{evidence_html}
{remediation_html}
{references_html}
</div>
</div>"#,
severity_class = severity_class,
severity = f.severity.as_str().to_uppercase(),
title = html_escape(&f.title),
rule_id = html_escape(&f.rule_id),
description = html_escape(&f.description),
location_html = location_html,
evidence_html = evidence_html,
remediation_html = remediation_html,
references_html = references_html,
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{Finding, ScanProfile};
#[test]
fn generate_empty_report() {
let results = ScanResults::new("test-server", ScanProfile::Standard);
let html = generate_html(&results);
assert!(html.contains("MCPLint Security Report"));
assert!(html.contains("test-server"));
assert!(html.contains("No Security Issues Found"));
}
#[test]
fn generate_report_with_findings() {
let mut results = ScanResults::new("vulnerable-server", ScanProfile::Full);
results.add_finding(Finding::new(
"MCP-INJ-001",
Severity::Critical,
"Command Injection",
"Found command injection vulnerability",
));
results.add_finding(Finding::new(
"MCP-AUTH-001",
Severity::High,
"Missing Authentication",
"Server lacks authentication",
));
let html = generate_html(&results);
assert!(html.contains("vulnerable-server"));
assert!(html.contains("Command Injection"));
assert!(html.contains("Missing Authentication"));
assert!(html.contains("CRITICAL"));
assert!(html.contains("HIGH"));
}
#[test]
fn html_escape_works() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape("\"test\""), ""test"");
}
#[test]
fn severity_chart_generation() {
let data = SeverityData {
critical: 1,
high: 2,
medium: 3,
low: 4,
info: 0,
total: 10,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("bar-segment critical"));
assert!(chart.contains("bar-segment high"));
assert!(chart.contains("bar-segment medium"));
assert!(chart.contains("bar-segment low"));
}
#[test]
fn severity_chart_empty() {
let data = SeverityData {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
total: 0,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("No findings"));
}
#[test]
fn severity_chart_info_only() {
let data = SeverityData {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 5,
total: 5,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("bar-segment info"));
}
#[test]
fn html_escape_special_chars() {
assert_eq!(html_escape("&"), "&");
assert_eq!(html_escape("<"), "<");
assert_eq!(html_escape(">"), ">");
assert_eq!(html_escape("\""), """);
assert_eq!(html_escape("'"), "'");
}
#[test]
fn html_escape_combined() {
let input = "<script>alert('XSS & stuff')</script>";
let escaped = html_escape(input);
assert!(!escaped.contains('<'));
assert!(!escaped.contains('>'));
assert!(escaped.contains("'"));
assert!(escaped.contains("&"));
}
#[test]
fn generate_summary_html_all_severities() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
results.summary.critical = 1;
results.summary.high = 2;
results.summary.medium = 3;
results.summary.low = 4;
results.summary.info = 5;
let summary = generate_summary_html(&results);
assert!(summary.contains("Critical"));
assert!(summary.contains("High"));
assert!(summary.contains("Medium"));
assert!(summary.contains("Low"));
assert!(summary.contains("Info"));
}
#[test]
fn generate_findings_html_with_location() {
use crate::scanner::FindingLocation;
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new(
"TEST-001",
Severity::High,
"Test Finding",
"Test description",
);
finding.location = FindingLocation {
component: "test_component".to_string(),
identifier: "test_id".to_string(),
context: None,
};
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("test_component"));
assert!(html.contains("test_id"));
}
#[test]
fn generate_findings_html_with_remediation() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new("TEST-001", Severity::Medium, "Test Finding", "Description");
finding.remediation = "Fix the issue by doing X".to_string();
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("Fix the issue by doing X"));
assert!(html.contains("Remediation"));
}
#[test]
fn generate_findings_html_with_evidence() {
use crate::scanner::{Evidence, EvidenceKind};
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new("TEST-001", Severity::Low, "Test Finding", "Description");
finding.evidence.push(Evidence {
kind: EvidenceKind::Observation,
data: "code here".to_string(),
description: "Found vulnerable code".to_string(),
});
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("Found vulnerable code"));
}
#[test]
fn generate_findings_html_sorted_by_severity() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
results.add_finding(Finding::new(
"LOW-001",
Severity::Low,
"Low Finding",
"Low desc",
));
results.add_finding(Finding::new(
"CRIT-001",
Severity::Critical,
"Critical Finding",
"Critical desc",
));
results.add_finding(Finding::new(
"HIGH-001",
Severity::High,
"High Finding",
"High desc",
));
let html = generate_findings_html(&results);
let crit_pos = html.find("Critical Finding").unwrap();
let high_pos = html.find("High Finding").unwrap();
let low_pos = html.find("Low Finding").unwrap();
assert!(crit_pos < high_pos);
assert!(high_pos < low_pos);
}
#[test]
fn full_report_contains_required_elements() {
let mut results = ScanResults::new("test-server", ScanProfile::Full);
results.duration_ms = 1500;
results.total_checks = 50;
let html = generate_html(&results);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("MCPLint Security Report"));
assert!(html.contains("test-server"));
assert!(html.contains("full"));
assert!(html.contains("1500"));
assert!(html.contains("50"));
}
#[test]
fn get_severity_data_correct_counts() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
for _ in 0..2 {
results.add_finding(Finding::new("C", Severity::Critical, "C", ""));
}
for _ in 0..3 {
results.add_finding(Finding::new("H", Severity::High, "H", ""));
}
for _ in 0..5 {
results.add_finding(Finding::new("M", Severity::Medium, "M", ""));
}
results.add_finding(Finding::new("L", Severity::Low, "L", ""));
for _ in 0..4 {
results.add_finding(Finding::new("I", Severity::Info, "I", ""));
}
let data = get_severity_data(&results);
assert_eq!(data.critical, 2);
assert_eq!(data.high, 3);
assert_eq!(data.medium, 5);
assert_eq!(data.low, 1);
assert_eq!(data.info, 4);
assert_eq!(data.total, 15); }
#[test]
fn severity_badge_classes() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
results.add_finding(Finding::new("C", Severity::Critical, "Critical", ""));
results.add_finding(Finding::new("H", Severity::High, "High", ""));
results.add_finding(Finding::new("M", Severity::Medium, "Medium", ""));
results.add_finding(Finding::new("L", Severity::Low, "Low", ""));
results.add_finding(Finding::new("I", Severity::Info, "Info", ""));
let html = generate_findings_html(&results);
assert!(html.contains("severity-badge critical"));
assert!(html.contains("severity-badge high"));
assert!(html.contains("severity-badge medium"));
assert!(html.contains("severity-badge low"));
assert!(html.contains("severity-badge info"));
}
#[test]
fn generate_findings_html_with_references_with_urls() {
use crate::scanner::{Reference, ReferenceKind};
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new("TEST-001", Severity::High, "Test Finding", "Description");
finding.references.push(Reference {
id: "CWE-79".to_string(),
kind: ReferenceKind::Cwe,
url: Some("https://cwe.mitre.org/data/definitions/79.html".to_string()),
});
finding.references.push(Reference {
id: "CVE-2025-1234".to_string(),
kind: ReferenceKind::Cve,
url: Some("https://nvd.nist.gov/vuln/detail/CVE-2025-1234".to_string()),
});
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("CWE-79"));
assert!(html.contains("CVE-2025-1234"));
assert!(html.contains("https://cwe.mitre.org/data/definitions/79.html"));
assert!(html.contains("https://nvd.nist.gov/vuln/detail/CVE-2025-1234"));
assert!(html.contains(r#"<a href="#));
assert!(html.contains(r#"target="_blank"#));
}
#[test]
fn generate_findings_html_with_references_without_urls() {
use crate::scanner::{Reference, ReferenceKind};
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new("TEST-001", Severity::Medium, "Test Finding", "Description");
finding.references.push(Reference {
id: "MCP-ADV-001".to_string(),
kind: ReferenceKind::McpAdvisory,
url: None,
});
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("MCP-ADV-001"));
assert!(html.contains(r#"<span class="reference-tag"#));
let ref_section = html.split("MCP-ADV-001").collect::<Vec<_>>()[0];
assert!(!ref_section.ends_with(r#"<a href="#));
}
#[test]
fn generate_findings_html_with_multiple_evidence() {
use crate::scanner::{Evidence, EvidenceKind};
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new(
"TEST-001",
Severity::Critical,
"Test Finding",
"Description",
);
finding.evidence.push(Evidence {
kind: EvidenceKind::Observation,
data: "suspicious_code_1".to_string(),
description: "First suspicious pattern".to_string(),
});
finding.evidence.push(Evidence {
kind: EvidenceKind::Request,
data: "GET /api/users?id=1' OR '1'='1".to_string(),
description: "Malicious SQL injection in request".to_string(),
});
finding.evidence.push(Evidence {
kind: EvidenceKind::Response,
data: "500 Internal Server Error".to_string(),
description: "Server error response indicating vulnerability".to_string(),
});
finding.evidence.push(Evidence {
kind: EvidenceKind::Configuration,
data: "debug=true".to_string(),
description: "Debug mode enabled in production".to_string(),
});
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("First suspicious pattern"));
assert!(html.contains("Malicious SQL injection in request"));
assert!(html.contains("Server error response indicating vulnerability"));
assert!(html.contains("Debug mode enabled in production"));
assert!(html.contains("Observation"));
assert!(html.contains("Request"));
assert!(html.contains("Response"));
assert!(html.contains("Configuration"));
}
#[test]
fn generate_findings_html_empty_location() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let finding = Finding::new("TEST-001", Severity::Low, "Test Finding", "Description");
assert!(finding.location.component.is_empty());
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(!html.contains("Location"));
}
#[test]
fn generate_findings_html_empty_evidence() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let finding = Finding::new("TEST-001", Severity::Info, "Test Finding", "Description");
assert!(finding.evidence.is_empty());
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("Test Finding"));
assert!(html.contains("Description"));
}
#[test]
fn generate_findings_html_empty_remediation() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let finding = Finding::new("TEST-001", Severity::High, "Test Finding", "Description");
assert!(finding.remediation.is_empty());
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(!html.contains("Remediation"));
}
#[test]
fn generate_findings_html_empty_references() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let finding = Finding::new("TEST-001", Severity::Medium, "Test Finding", "Description");
assert!(finding.references.is_empty());
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(!html.contains(r#"class="references"#));
}
#[test]
fn complete_finding_with_all_fields() {
use crate::scanner::{Evidence, EvidenceKind, FindingLocation, Reference, ReferenceKind};
let mut results = ScanResults::new("complete-test", ScanProfile::Full);
let mut finding = Finding::new(
"COMPLETE-001",
Severity::Critical,
"Complete Test Finding",
"This is a complete description with all fields populated",
);
finding.location = FindingLocation {
component: "authentication".to_string(),
identifier: "login_handler".to_string(),
context: Some("line 42".to_string()),
};
finding.evidence.push(Evidence {
kind: EvidenceKind::Observation,
data: "suspicious_pattern".to_string(),
description: "Observed malicious pattern".to_string(),
});
finding.remediation = "Apply input validation and sanitization".to_string();
finding.references.push(Reference {
id: "CWE-89".to_string(),
kind: ReferenceKind::Cwe,
url: Some("https://cwe.mitre.org/data/definitions/89.html".to_string()),
});
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("Complete Test Finding"));
assert!(html.contains("COMPLETE-001"));
assert!(html.contains("severity-badge critical"));
assert!(html.contains("authentication"));
assert!(html.contains("login_handler"));
assert!(html.contains("Observed malicious pattern"));
assert!(html.contains("Apply input validation and sanitization"));
assert!(html.contains("Remediation"));
assert!(html.contains("CWE-89"));
assert!(html.contains("https://cwe.mitre.org/data/definitions/89.html"));
}
#[test]
fn severity_chart_single_severity() {
let data = SeverityData {
critical: 10,
high: 0,
medium: 0,
low: 0,
info: 0,
total: 10,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("bar-segment critical"));
assert!(chart.contains("width: 100%"));
assert!(!chart.contains("bar-segment high"));
}
#[test]
fn severity_chart_mixed_percentages() {
let data = SeverityData {
critical: 1,
high: 1,
medium: 1,
low: 1,
info: 1,
total: 5,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("bar-segment critical"));
assert!(chart.contains("bar-segment high"));
assert!(chart.contains("bar-segment medium"));
assert!(chart.contains("bar-segment low"));
assert!(chart.contains("bar-segment info"));
assert!(chart.contains("width: 20%"));
}
#[test]
fn html_escape_preserves_safe_text() {
assert_eq!(html_escape("safe text 123"), "safe text 123");
assert_eq!(html_escape(""), "");
assert_eq!(html_escape("no-special-chars"), "no-special-chars");
}
#[test]
fn html_escape_multiple_special_chars() {
let input = r#"<div class="test" data='value' onclick="alert('XSS & more')">Content</div>"#;
let escaped = html_escape(input);
assert!(escaped.contains("<div"));
assert!(escaped.contains(">"));
assert!(escaped.contains(""test""));
assert!(escaped.contains("'value'"));
assert!(escaped.contains("&"));
assert!(!escaped.contains('<'));
assert!(!escaped.contains('>'));
}
#[test]
fn generate_html_escapes_server_name() {
let results = ScanResults::new("<script>alert('xss')</script>", ScanProfile::Standard);
let html = generate_html(&results);
assert!(html.contains("<script>"));
assert!(!html.contains("<script>alert('xss')</script>"));
}
#[test]
fn generate_findings_html_escapes_all_fields() {
let mut results = ScanResults::new("test", ScanProfile::Standard);
let mut finding = Finding::new("<rule>", Severity::High, "<title>", "<description>");
finding.remediation = "<remediation>".to_string();
results.add_finding(finding);
let html = generate_findings_html(&results);
assert!(html.contains("<rule>"));
assert!(html.contains("<title>"));
assert!(html.contains("<description>"));
assert!(html.contains("<remediation>"));
assert!(!html.contains("<rule>"));
assert!(!html.contains("<title>"));
assert!(!html.contains("<description>"));
assert!(!html.contains("<remediation>"));
}
#[test]
fn generate_summary_html_with_zeros() {
let results = ScanResults::new("test", ScanProfile::Standard);
let summary = generate_summary_html(&results);
assert!(summary.contains(r#"<div class="value">0</div>"#));
assert!(summary.contains("Critical"));
assert!(summary.contains("High"));
assert!(summary.contains("Medium"));
assert!(summary.contains("Low"));
assert!(summary.contains("Info"));
}
#[test]
fn full_html_structure_validation() {
let results = ScanResults::new("validation-test", ScanProfile::Standard);
let html = generate_html(&results);
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<html lang=\"en\">"));
assert!(html.contains("</html>"));
assert!(html.contains("<head>"));
assert!(html.contains("</head>"));
assert!(html.contains("<body>"));
assert!(html.contains("</body>"));
assert!(html.contains("<meta charset=\"UTF-8\">"));
assert!(html.contains("<style>"));
assert!(html.contains("</style>"));
assert!(html.contains("<footer>"));
assert!(html.contains("</footer>"));
}
#[test]
fn severity_chart_skips_zero_severities() {
let data = SeverityData {
critical: 5,
high: 0,
medium: 3,
low: 0,
info: 2,
total: 10,
};
let chart = generate_severity_chart(&data);
assert!(chart.contains("bar-segment critical"));
assert!(chart.contains("bar-segment medium"));
assert!(chart.contains("bar-segment info"));
assert!(!chart.contains("bar-segment high"));
assert!(!chart.contains("bar-segment low"));
}
}