1use crate::core::{AuditResult, Finding, ForgeGuardError, RiskLevel, Severity};
7
8pub fn generate_report(result: &AuditResult) -> Result<String, ForgeGuardError> {
10 let mut html = String::new();
11 html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
12 html.push_str("<meta charset=\"UTF-8\">\n");
13 html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
14 html.push_str("<title>Forge Guard — Security Audit Report</title>\n");
15 html.push_str("<style>\n");
16 html.push_str(STYLESHEET);
17 html.push_str("</style>\n</head>\n<body>\n");
18 html.push_str("<div class=\"container\">\n");
19
20 html.push_str(
22 r#"<header>
23 <div class="logo">🔒 Forge Guard</div>
24 <div class="subtitle">Security Audit Report</div>
25 </header>"#,
26 );
27
28 html.push_str(r#"<div class="meta-bar">"#);
30 html.push_str(&format!(
31 r#"<div class="meta-item"><span class="meta-label">Project</span><span class="meta-value">{}</span></div>"#,
32 escape_html(&result.project_name)
33 ));
34 html.push_str(&format!(
35 r#"<div class="meta-item"><span class="meta-label">Chain</span><span class="meta-value">{}</span></div>"#,
36 escape_html(&result.chain)
37 ));
38 html.push_str(&format!(
39 r#"<div class="meta-item"><span class="meta-label">Duration</span><span class="meta-value">{:.2}s</span></div>"#,
40 result.duration_seconds
41 ));
42 html.push_str(&format!(
43 r#"<div class="meta-item"><span class="meta-label">Files</span><span class="meta-value">{}</span></div>"#,
44 result.summary.files_analyzed
45 ));
46 html.push_str(&format!(
47 r#"<div class="meta-item"><span class="meta-label">Date</span><span class="meta-value">{}</span></div>"#,
48 &result.timestamp[..10]
49 ));
50 html.push_str("</div>\n");
51
52 let verdict_class = if result.deployment_approved && result.production_ready {
54 "verdict-pass"
55 } else {
56 "verdict-fail"
57 };
58 let verdict_text = if result.deployment_approved && result.production_ready {
59 "✅ PASS — Ready for Deployment"
60 } else if result.production_ready {
61 "⚠️ PASS WITH WARNINGS — Review findings before deploying"
62 } else {
63 "❌ FAIL — Must fix issues before deployment"
64 };
65 html.push_str(&format!(
66 r#"<div class="verdict {}"><div class="verdict-text">{}</div></div>"#,
67 verdict_class, verdict_text
68 ));
69
70 html.push_str(r#"<div class="section"><h2>📊 Score Overview</h2><div class="score-grid">"#);
72 let overall_color = score_color(result.overall_score);
73 html.push_str(&format!(
74 r#"<div class="score-card overall" style="border-left: 4px solid {};">
75 <div class="score-label">Overall Score</div>
76 <div class="score-value" style="color: {};">{}/100</div>
77 </div>"#,
78 overall_color, overall_color, result.overall_score
79 ));
80 let risk_color = risk_color(result.risk_level);
81 html.push_str(&format!(
82 r#"<div class="score-card" style="border-left: 4px solid {};">
83 <div class="score-label">Risk Level</div>
84 <div class="score-value" style="color: {};">{}</div>
85 </div>"#,
86 risk_color, risk_color, result.risk_level
87 ));
88 let dep_icon = if result.deployment_approved {
89 "✅"
90 } else {
91 "❌"
92 };
93 let dep_color = if result.deployment_approved {
94 "#22c55e"
95 } else {
96 "#ef4444"
97 };
98 html.push_str(&format!(
99 r#"<div class="score-card" style="border-left: 4px solid {};">
100 <div class="score-label">Deployment</div>
101 <div class="score-value" style="color: {};">{} {}</div>
102 </div>"#,
103 dep_color,
104 dep_color,
105 dep_icon,
106 if result.deployment_approved {
107 "APPROVED"
108 } else {
109 "BLOCKED"
110 }
111 ));
112 html.push_str("</div></div>\n");
113
114 html.push_str(r#"<div class="section"><h2>🐛 Finding Breakdown</h2><div class="finding-bar">"#);
116 let total = result.summary.total_findings.max(1);
117 let crit_pct = result.summary.critical_count as f64 / total as f64 * 100.0;
118 let high_pct = result.summary.high_count as f64 / total as f64 * 100.0;
119 let med_pct = result.summary.medium_count as f64 / total as f64 * 100.0;
120 let low_pct = result.summary.low_count as f64 / total as f64 * 100.0;
121 let info_pct = result.summary.info_count as f64 / total as f64 * 100.0;
122 if crit_pct > 0.0 {
123 html.push_str(&format!(r#"<div class="bar-segment bar-critical" style="width: {:.1}%;" title="Critical: {}"></div>"#, crit_pct, result.summary.critical_count));
124 }
125 if high_pct > 0.0 {
126 html.push_str(&format!(
127 r#"<div class="bar-segment bar-high" style="width: {:.1}%;" title="High: {}"></div>"#,
128 high_pct, result.summary.high_count
129 ));
130 }
131 if med_pct > 0.0 {
132 html.push_str(&format!(r#"<div class="bar-segment bar-medium" style="width: {:.1}%;" title="Medium: {}"></div>"#, med_pct, result.summary.medium_count));
133 }
134 if low_pct > 0.0 {
135 html.push_str(&format!(
136 r#"<div class="bar-segment bar-low" style="width: {:.1}%;" title="Low: {}"></div>"#,
137 low_pct, result.summary.low_count
138 ));
139 }
140 if info_pct > 0.0 {
141 html.push_str(&format!(
142 r#"<div class="bar-segment bar-info" style="width: {:.1}%;" title="Info: {}"></div>"#,
143 info_pct, result.summary.info_count
144 ));
145 }
146 html.push_str("</div><div class=\"finding-legend\">");
147 html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#dc2626;"></span> Critical <strong>{}</strong></span>"#, result.summary.critical_count));
148 html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#f97316;"></span> High <strong>{}</strong></span>"#, result.summary.high_count));
149 html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#eab308;"></span> Medium <strong>{}</strong></span>"#, result.summary.medium_count));
150 html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#3b82f6;"></span> Low <strong>{}</strong></span>"#, result.summary.low_count));
151 html.push_str(&format!(r#"<span class="legend-item"><span class="legend-dot" style="background:#6b7280;"></span> Info <strong>{}</strong></span>"#, result.summary.info_count));
152 html.push_str("</div></div>\n");
153
154 html.push_str(r#"<div class="section"><h2>📈 Security Scores</h2><div class="scores-table">"#);
156 let score_pairs = [
157 ("🔐 Access Control", result.scores.access_control),
158 ("🛡️ Security", result.scores.security),
159 ("🎯 Fuzzing", result.scores.fuzzing),
160 ("⛽ Gas", result.scores.gas),
161 ("🏗️ Architecture", result.scores.architecture),
162 ("⬆️ Upgradeability", result.scores.upgradeability),
163 ("📦 Dependencies", result.scores.dependencies),
164 ("🚀 Deployment", result.scores.deployment),
165 ("🔗 Proxy Safety", result.scores.proxy_safety),
166 ("⛓️ Chain Compat", result.scores.chain_compatibility),
167 ("✅ Production Ready", result.scores.production_readiness),
168 ("💥 Exploit Resistance", result.scores.exploit_resistance),
169 ];
170 for (label, score) in &score_pairs {
171 let bar_color = score_color(*score);
172 html.push_str(&format!(
173 r#"<div class="score-row">
174 <div class="score-row-label">{}</div>
175 <div class="score-row-bar"><div class="score-row-fill" style="width:{}%;background:{};"></div></div>
176 <div class="score-row-value" style="color:{};">{}/100</div>
177 </div>"#,
178 label, score, bar_color, bar_color, score
179 ));
180 }
181 html.push_str("</div></div>\n");
182
183 if !result.findings.is_empty() {
185 html.push_str(&format!(
186 r#"<div class="section"><h2>📋 Detailed Findings <span class="badge">{}</span></h2>"#,
187 result.findings.len()
188 ));
189
190 let severity_order = [
191 Severity::Critical,
192 Severity::High,
193 Severity::Medium,
194 Severity::Low,
195 Severity::Informational,
196 ];
197 let severity_labels = [
198 ("🛑 CRITICAL", "critical"),
199 ("🔴 HIGH", "high"),
200 ("🟡 MEDIUM", "medium"),
201 ("🔵 LOW", "low"),
202 ("⚪ INFO", "info"),
203 ];
204
205 for (sev_idx, severity) in severity_order.iter().enumerate() {
206 let sev_findings: Vec<&Finding> = result
207 .findings
208 .iter()
209 .filter(|f| f.severity == *severity)
210 .collect();
211 if sev_findings.is_empty() {
212 continue;
213 }
214 let (sev_label, sev_class) = severity_labels[sev_idx];
215 html.push_str(&format!(
216 r#"<details open><summary class="finding-group-header {}">{} ({} found)</summary>"#,
217 sev_class,
218 sev_label,
219 sev_findings.len()
220 ));
221
222 for (i, finding) in sev_findings.iter().enumerate() {
223 html.push_str(&format!(
224 r#"<div class="finding-card {}">
225 <div class="finding-title">{}. <span class="finding-id">{}</span> {}</div>
226 <div class="finding-meta">
227 <span class="meta-tag severity-{}">{}</span>
228 <span class="meta-tag">📂 {}</span>
229 <span class="meta-tag">📁 {}</span>"#,
230 sev_class,
231 i + 1,
232 escape_html(&finding.id),
233 escape_html(&finding.title),
234 sev_class,
235 finding.severity,
236 escape_html(&finding.category),
237 finding.file.as_deref().unwrap_or("unknown"),
238 ));
239 if let Some(chain) = &finding.chain {
240 html.push_str(&format!(
241 r#"<span class="meta-tag">⛓️ {}</span>"#,
242 escape_html(chain)
243 ));
244 }
245 if let Some(line) = finding.line {
246 html.push_str(&format!(
247 r#"<span class="meta-tag">📍 Line {}</span>"#,
248 line
249 ));
250 }
251 html.push_str("</div>");
252
253 if !finding.description.is_empty() {
254 html.push_str(&format!(
255 r#"<div class="finding-desc">{}</div>"#,
256 escape_html(&finding.description)
257 ));
258 }
259 if let Some(snippet) = &finding.code_snippet {
260 html.push_str(&format!(
261 r#"<pre class="code-snippet"><code>{}</code></pre>"#,
262 escape_html(snippet)
263 ));
264 }
265 html.push_str(&format!(
266 r#"<div class="finding-rec"><strong>💡 Recommendation:</strong> {}</div>"#,
267 escape_html(&finding.recommendation)
268 ));
269 if let Some(exploit_path) = &finding.exploit_path {
270 html.push_str(
271 r#"<div class="finding-exploit"><strong>🔗 Exploit Path:</strong><ol>"#,
272 );
273 for step in exploit_path {
274 html.push_str(&format!("<li>{}</li>", escape_html(step)));
275 }
276 html.push_str("</ol></div>");
277 }
278 if !finding.references.is_empty() {
279 html.push_str(
280 r#"<div class="finding-refs"><strong>📎 References:</strong><ul>"#,
281 );
282 for ref_ in &finding.references {
283 html.push_str(&format!("<li>{}</li>", escape_html(ref_)));
284 }
285 html.push_str("</ul></div>");
286 }
287 html.push_str("</div>\n");
288 }
289 html.push_str("</details>\n");
290 }
291 html.push_str("</div>\n");
292 }
293
294 html.push_str(&format!(
296 r#"<footer>
297 <p>Report generated by <a href="https://github.com/codetibo/forge-guard">Forge Guard</a>
298 on {}</p>
299 </footer>"#,
300 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
301 ));
302
303 html.push_str("</div>\n</body>\n</html>\n");
304 Ok(html)
305}
306
307const STYLESHEET: &str = r##"
310*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
311:root {
312 --bg: #0f172a; --surface: #1e293b; --surface2: #334155;
313 --text: #f1f5f9; --text2: #94a3b8; --accent: #38bdf8;
314 --critical: #dc2626; --high: #f97316; --medium: #eab308;
315 --low: #3b82f6; --info: #6b7280;
316 --border: #334155; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.3);
317}
318body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
319 background: var(--bg); color: var(--text); line-height: 1.6; padding: 20px; }
320.container { max-width: 960px; margin: 0 auto; }
321
322header { text-align: center; padding: 32px 0 16px; }
323.logo { font-size: 28px; font-weight: 800; background: linear-gradient(135deg,#38bdf8,#818cf8);
324 -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
325.subtitle { font-size: 14px; color: var(--text2); margin-top: 4px; letter-spacing: 1px;
326 text-transform: uppercase; }
327
328.meta-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
329 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
330 padding: 16px 20px; margin: 16px 0; }
331.meta-item { display: flex; flex-direction: column; align-items: center; min-width: 90px; }
332.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
333.meta-value { font-size: 15px; font-weight: 600; margin-top: 2px; }
334
335.verdict { text-align: center; padding: 20px; border-radius: var(--radius);
336 margin: 16px 0; font-size: 18px; font-weight: 700; }
337.verdict-pass { background: linear-gradient(135deg,#064e3b,#065f46);
338 border: 1px solid #22c55e; color: #bbf7d0; }
339.verdict-fail { background: linear-gradient(135deg,#450a0a,#7f1d1d);
340 border: 1px solid #ef4444; color: #fecaca; }
341
342.section { background: var(--surface); border: 1px solid var(--border);
343 border-radius: var(--radius); padding: 24px; margin: 16px 0; }
344.section h2 { font-size: 18px; font-weight: 700; margin-bottom: 16px;
345 display: flex; align-items: center; gap: 8px; }
346
347.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
348 gap: 12px; }
349.score-card { background: var(--surface2); border-radius: 6px; padding: 16px; }
350.score-label { font-size: 12px; color: var(--text2); text-transform: uppercase;
351 letter-spacing: 0.5px; }
352.score-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
353
354.finding-bar { display: flex; height: 24px; border-radius: 12px; overflow: hidden;
355 background: var(--surface2); }
356.bar-segment { transition: width 0.3s; }
357.bar-critical { background: var(--critical); }
358.bar-high { background: var(--high); }
359.bar-medium { background: var(--medium); }
360.bar-low { background: var(--low); }
361.bar-info { background: var(--info); }
362.finding-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px;
363 justify-content: center; }
364.legend-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
365.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
366
367.scores-table { display: flex; flex-direction: column; gap: 8px; }
368.score-row { display: flex; align-items: center; gap: 12px; }
369.score-row-label { width: 160px; font-size: 13px; flex-shrink: 0; }
370.score-row-bar { flex: 1; height: 20px; background: var(--surface2); border-radius: 10px;
371 overflow: hidden; }
372.score-row-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
373.score-row-value { width: 60px; text-align: right; font-size: 13px; font-weight: 700;
374 flex-shrink: 0; }
375
376.badge { background: var(--accent); color: #0f172a; font-size: 13px; font-weight: 700;
377 padding: 2px 10px; border-radius: 12px; }
378
379details { margin-bottom: 12px; }
380summary { cursor: pointer; padding: 12px 16px; border-radius: 6px; font-weight: 700;
381 font-size: 15px; }
382summary:hover { filter: brightness(1.1); }
383.finding-group-header.critical { background: #450a0a; color: #fca5a5; }
384.finding-group-header.high { background: #431407; color: #fdba74; }
385.finding-group-header.medium { background: #422006; color: #fde68a; }
386.finding-group-header.low { background: #172554; color: #93c5fd; }
387.finding-group-header.info { background: #1f2937; color: #d1d5db; }
388
389.finding-card { background: var(--surface2); border-radius: 6px; padding: 16px;
390 margin: 8px 0; }
391.finding-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
392.finding-id { color: var(--accent); font-family: monospace; font-size: 13px; }
393.finding-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
394.meta-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px;
395 background: var(--surface); border: 1px solid var(--border); }
396.severity-critical { border-color: var(--critical); color: #fca5a5; }
397.severity-high { border-color: var(--high); color: #fdba74; }
398.severity-medium { border-color: var(--medium); color: #fde68a; }
399.severity-low { border-color: var(--low); color: #93c5fd; }
400.finding-desc { font-size: 14px; color: var(--text2); margin-bottom: 8px; }
401.code-snippet { background: #0f172a; border: 1px solid var(--border); border-radius: 4px;
402 padding: 12px; font-size: 13px; overflow-x: auto; margin-bottom: 8px; }
403.code-snippet code { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
404.finding-rec { font-size: 14px; padding: 8px 12px; background: #064e3b;
405 border-radius: 4px; margin-bottom: 8px; }
406.finding-exploit { font-size: 14px; padding: 8px 12px; background: #1c1917;
407 border-radius: 4px; margin-bottom: 8px; }
408.finding-exploit ol { padding-left: 20px; margin-top: 4px; }
409.finding-refs { font-size: 14px; padding: 8px 12px; background: #172554;
410 border-radius: 4px; }
411.finding-refs ul { padding-left: 20px; margin-top: 4px; }
412
413footer { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; }
414footer a { color: var(--accent); text-decoration: none; }
415footer a:hover { text-decoration: underline; }
416
417@media (max-width: 640px) {
418 body { padding: 12px; }
419 .score-grid { grid-template-columns: 1fr; }
420 .score-row { flex-wrap: wrap; }
421 .score-row-label { width: 100%; }
422 .score-row-value { width: auto; }
423 .meta-bar { flex-direction: column; align-items: stretch; }
424 .meta-item { flex-direction: row; justify-content: space-between; }
425}
426"##;
427
428fn escape_html(s: &str) -> String {
431 s.replace('&', "&")
432 .replace('<', "<")
433 .replace('>', ">")
434 .replace('"', """)
435 .replace('\'', "'")
436}
437
438fn score_color(score: u8) -> &'static str {
439 if score >= 85 {
440 "#22c55e"
441 } else if score >= 70 {
442 "#eab308"
443 } else if score >= 50 {
444 "#f97316"
445 } else {
446 "#ef4444"
447 }
448}
449
450fn risk_color(risk: RiskLevel) -> &'static str {
451 match risk {
452 RiskLevel::Critical => "#ef4444",
453 RiskLevel::High => "#f97316",
454 RiskLevel::Medium => "#eab308",
455 RiskLevel::Low => "#22c55e",
456 RiskLevel::Minimal => "#22c55e",
457 }
458}
459
460pub fn write_report(result: &AuditResult, path: &std::path::Path) -> Result<(), ForgeGuardError> {
462 let html = generate_report(result)?;
463 std::fs::write(path, html)?;
464 Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::core::*;
471
472 fn sample_result() -> AuditResult {
473 AuditResult {
474 project_name: "test-project".into(),
475 chain: "ethereum".into(),
476 chains: vec!["ethereum".into()],
477 timestamp: "2026-07-28T12:00:00Z".into(),
478 duration_seconds: 2.5,
479 findings: vec![
480 Finding::builder()
481 .id("FA-H-001")
482 .title("Reentrancy Vulnerability")
483 .description("CEI violation in withdraw function")
484 .severity(Severity::High)
485 .file("Vault.sol")
486 .location(42, 8)
487 .code("msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
488 .recommendation(
489 "Use ReentrancyGuard or apply checks-effects-interactions pattern",
490 )
491 .category("Logic")
492 .blocks_deployment(true)
493 .build(),
494 Finding::builder()
495 .id("FA-M-002")
496 .title("Unbounded Loop")
497 .description("Loop over dynamic array may cause out-of-gas")
498 .severity(Severity::Medium)
499 .file("Vault.sol")
500 .location(88, 12)
501 .code("for (uint i; i < holders.length; i++) { ... }")
502 .recommendation("Track array length or use pagination")
503 .category("Gas")
504 .blocks_deployment(false)
505 .reference("SWC-128")
506 .build(),
507 Finding::builder()
508 .id("FA-L-003")
509 .title("Unused Variable")
510 .description("Variable 'oldBalance' is declared but never used")
511 .severity(Severity::Low)
512 .file("Vault.sol")
513 .location(15, 20)
514 .code("uint256 oldBalance = balances[msg.sender];")
515 .recommendation("Remove unused variable")
516 .category("Best Practices")
517 .blocks_deployment(false)
518 .build(),
519 ],
520 scores: SecurityScores {
521 access_control: 100,
522 security: 70,
523 fuzzing: 100,
524 gas: 85,
525 architecture: 65,
526 upgradeability: 100,
527 dependencies: 100,
528 deployment: 100,
529 proxy_safety: 100,
530 chain_compatibility: 100,
531 production_readiness: 55,
532 exploit_resistance: 100,
533 },
534 overall_score: 76,
535 risk_level: RiskLevel::High,
536 production_ready: false,
537 deployment_approved: false,
538 summary: AuditSummary {
539 total_findings: 3,
540 critical_count: 0,
541 high_count: 1,
542 medium_count: 1,
543 low_count: 1,
544 info_count: 0,
545 files_analyzed: 5,
546 lines_analyzed: 500,
547 contracts_analyzed: 3,
548 },
549 }
550 }
551
552 #[test]
553 fn test_html_report_contains_basic_elements() {
554 let result = sample_result();
555 let html = generate_report(&result).unwrap();
556 assert!(html.contains("<!DOCTYPE html>"));
557 assert!(html.contains("Forge Guard"));
558 assert!(html.contains("Security Audit Report"));
559 assert!(html.contains("test-project"));
560 assert!(html.contains("ethereum"));
561 assert!(html.contains("FAIL"));
562 assert!(html.contains("76/100"));
563 assert!(html.contains("HIGH"));
564 }
565
566 #[test]
567 fn test_html_report_contains_findings() {
568 let result = sample_result();
569 let html = generate_report(&result).unwrap();
570 assert!(html.contains("Reentrancy Vulnerability"));
571 assert!(html.contains("FA-H-001"));
572 assert!(html.contains("Unbounded Loop"));
573 assert!(html.contains("FA-M-002"));
574 assert!(html.contains("Unused Variable"));
575 assert!(html.contains("FA-L-003"));
576 }
577
578 #[test]
579 fn test_html_report_empty_findings() {
580 let result = AuditResult {
581 project_name: "clean".into(),
582 chain: "base".into(),
583 chains: vec!["base".into()],
584 timestamp: "2026-07-28T00:00:00Z".into(),
585 duration_seconds: 1.0,
586 findings: vec![],
587 scores: SecurityScores::perfect(),
588 overall_score: 100,
589 risk_level: RiskLevel::Minimal,
590 production_ready: true,
591 deployment_approved: true,
592 summary: AuditSummary {
593 total_findings: 0,
594 critical_count: 0,
595 high_count: 0,
596 medium_count: 0,
597 low_count: 0,
598 info_count: 0,
599 files_analyzed: 3,
600 lines_analyzed: 200,
601 contracts_analyzed: 2,
602 },
603 };
604 let html = generate_report(&result).unwrap();
605 assert!(html.contains("PASS"));
606 assert!(html.contains("APPROVED"));
607 assert!(html.contains("100/100"));
608 assert!(html.contains("MINIMAL"));
609 }
610
611 #[test]
612 fn test_html_report_all_severities() {
613 let result = AuditResult {
614 project_name: "all-sev".into(),
615 chain: "polygon".into(),
616 chains: vec!["polygon".into()],
617 timestamp: "2026-01-01T00:00:00Z".into(),
618 duration_seconds: 0.5,
619 findings: vec![
620 Finding::builder()
621 .id("C1")
622 .title("Critical Bug")
623 .description("")
624 .severity(Severity::Critical)
625 .file("c.sol")
626 .code("x")
627 .recommendation("Fix")
628 .category("Security")
629 .build(),
630 Finding::builder()
631 .id("H1")
632 .title("High Bug")
633 .description("")
634 .severity(Severity::High)
635 .file("h.sol")
636 .code("x")
637 .recommendation("Fix")
638 .category("Security")
639 .build(),
640 Finding::builder()
641 .id("M1")
642 .title("Medium Bug")
643 .description("")
644 .severity(Severity::Medium)
645 .file("m.sol")
646 .code("x")
647 .recommendation("Fix")
648 .category("Gas")
649 .build(),
650 Finding::builder()
651 .id("L1")
652 .title("Low Bug")
653 .description("")
654 .severity(Severity::Low)
655 .file("l.sol")
656 .code("x")
657 .recommendation("Fix")
658 .category("Style")
659 .build(),
660 Finding::builder()
661 .id("I1")
662 .title("Info Note")
663 .description("")
664 .severity(Severity::Informational)
665 .file("i.sol")
666 .code("x")
667 .recommendation("Note")
668 .category("Style")
669 .build(),
670 ],
671 scores: SecurityScores::perfect(),
672 overall_score: 50,
673 risk_level: RiskLevel::High,
674 production_ready: false,
675 deployment_approved: false,
676 summary: AuditSummary {
677 total_findings: 5,
678 critical_count: 1,
679 high_count: 1,
680 medium_count: 1,
681 low_count: 1,
682 info_count: 1,
683 files_analyzed: 1,
684 lines_analyzed: 50,
685 contracts_analyzed: 1,
686 },
687 };
688 let html = generate_report(&result).unwrap();
689 assert!(html.contains("CRITICAL"));
690 assert!(html.contains("HIGH"));
691 assert!(html.contains("MEDIUM"));
692 assert!(html.contains("LOW"));
693 assert!(html.contains("INFO"));
694 assert!(html.contains("Critical Bug"));
696 assert!(html.contains("High Bug"));
697 assert!(html.contains("Medium Bug"));
698 assert!(html.contains("Low Bug"));
699 assert!(html.contains("Info Note"));
700 }
701
702 #[test]
703 fn test_html_report_contains_exploit_path() {
704 let mut result = sample_result();
705 result.findings[0].exploit_path = Some(vec![
706 "Attacker calls withdraw()".into(),
707 "Fallback receives ETH before balance update".into(),
708 "Re-enters withdraw() recursively".into(),
709 ]);
710 let html = generate_report(&result).unwrap();
711 assert!(html.contains("Exploit Path"));
712 assert!(html.contains("Attacker calls withdraw()"));
713 }
714
715 #[test]
716 fn test_html_report_contains_references() {
717 let html = generate_report(&sample_result()).unwrap();
718 assert!(html.contains("SWC-128"));
719 }
720
721 #[test]
722 fn test_html_escape_prevents_injection() {
723 let result = AuditResult {
724 project_name: "<script>alert('xss')</script>".into(),
725 chain: "test".into(),
726 chains: vec!["test".into()],
727 timestamp: "2026-01-01T00:00:00Z".into(),
728 duration_seconds: 0.1,
729 findings: vec![Finding::builder()
730 .id("X1")
731 .title("<img src=x onerror=alert(1)>")
732 .description("<b>bold</b>")
733 .severity(Severity::Low)
734 .file("<script>evil</script>")
735 .code("<script>alert(1)</script>")
736 .recommendation("Sanitize")
737 .category("Security")
738 .build()],
739 scores: SecurityScores::perfect(),
740 overall_score: 100,
741 risk_level: RiskLevel::Minimal,
742 production_ready: true,
743 deployment_approved: true,
744 summary: AuditSummary {
745 total_findings: 1,
746 critical_count: 0,
747 high_count: 0,
748 medium_count: 0,
749 low_count: 1,
750 info_count: 0,
751 files_analyzed: 1,
752 lines_analyzed: 10,
753 contracts_analyzed: 1,
754 },
755 };
756 let html = generate_report(&result).unwrap();
757 assert!(html.contains("<script>"));
759 assert!(html.contains("<img"));
760 assert!(html.contains("<b>"));
761 assert!(!html.contains("<script>alert"));
762 assert!(!html.contains("<img src=x"));
763 }
764
765 #[test]
766 fn test_html_report_score_colors() {
767 assert_eq!(score_color(100), "#22c55e");
768 assert_eq!(score_color(85), "#22c55e");
769 assert_eq!(score_color(75), "#eab308");
770 assert_eq!(score_color(70), "#eab308");
771 assert_eq!(score_color(60), "#f97316");
772 assert_eq!(score_color(50), "#f97316");
773 assert_eq!(score_color(30), "#ef4444");
774 assert_eq!(score_color(0), "#ef4444");
775 }
776
777 #[test]
778 fn test_html_report_risk_colors() {
779 assert_eq!(risk_color(RiskLevel::Critical), "#ef4444");
780 assert_eq!(risk_color(RiskLevel::High), "#f97316");
781 assert_eq!(risk_color(RiskLevel::Medium), "#eab308");
782 assert_eq!(risk_color(RiskLevel::Low), "#22c55e");
783 assert_eq!(risk_color(RiskLevel::Minimal), "#22c55e");
784 }
785
786 #[test]
787 fn test_html_write_report() {
788 let result = sample_result();
789 let dir = tempfile::tempdir().unwrap();
790 let path = dir.path().join("report.html");
791 write_report(&result, &path).unwrap();
792 assert!(path.exists());
793 let content = std::fs::read_to_string(&path).unwrap();
794 assert!(content.contains("Forge Guard"));
795 }
796
797 #[test]
798 fn test_html_report_responsive_viewport() {
799 let html = generate_report(&sample_result()).unwrap();
800 assert!(html.contains("viewport"));
801 assert!(html.contains("width=device-width"));
802 }
803
804 #[test]
805 fn test_html_report_dark_theme_css_vars() {
806 let html = generate_report(&sample_result()).unwrap();
807 assert!(html.contains("--bg: #0f172a"));
808 }
809}