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(line) = finding.line {
240 html.push_str(&format!(
241 r#"<span class="meta-tag">📍 Line {}</span>"#,
242 line
243 ));
244 }
245 html.push_str("</div>");
246
247 if !finding.description.is_empty() {
248 html.push_str(&format!(
249 r#"<div class="finding-desc">{}</div>"#,
250 escape_html(&finding.description)
251 ));
252 }
253 if let Some(snippet) = &finding.code_snippet {
254 html.push_str(&format!(
255 r#"<pre class="code-snippet"><code>{}</code></pre>"#,
256 escape_html(snippet)
257 ));
258 }
259 html.push_str(&format!(
260 r#"<div class="finding-rec"><strong>💡 Recommendation:</strong> {}</div>"#,
261 escape_html(&finding.recommendation)
262 ));
263 if let Some(exploit_path) = &finding.exploit_path {
264 html.push_str(
265 r#"<div class="finding-exploit"><strong>🔗 Exploit Path:</strong><ol>"#,
266 );
267 for step in exploit_path {
268 html.push_str(&format!("<li>{}</li>", escape_html(step)));
269 }
270 html.push_str("</ol></div>");
271 }
272 if !finding.references.is_empty() {
273 html.push_str(
274 r#"<div class="finding-refs"><strong>📎 References:</strong><ul>"#,
275 );
276 for ref_ in &finding.references {
277 html.push_str(&format!("<li>{}</li>", escape_html(ref_)));
278 }
279 html.push_str("</ul></div>");
280 }
281 html.push_str("</div>\n");
282 }
283 html.push_str("</details>\n");
284 }
285 html.push_str("</div>\n");
286 }
287
288 html.push_str(&format!(
290 r#"<footer>
291 <p>Report generated by <a href="https://github.com/codetibo/forge-guard">Forge Guard</a>
292 on {}</p>
293 </footer>"#,
294 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
295 ));
296
297 html.push_str("</div>\n</body>\n</html>\n");
298 Ok(html)
299}
300
301const STYLESHEET: &str = r##"
304*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
305:root {
306 --bg: #0f172a; --surface: #1e293b; --surface2: #334155;
307 --text: #f1f5f9; --text2: #94a3b8; --accent: #38bdf8;
308 --critical: #dc2626; --high: #f97316; --medium: #eab308;
309 --low: #3b82f6; --info: #6b7280;
310 --border: #334155; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.3);
311}
312body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
313 background: var(--bg); color: var(--text); line-height: 1.6; padding: 20px; }
314.container { max-width: 960px; margin: 0 auto; }
315
316header { text-align: center; padding: 32px 0 16px; }
317.logo { font-size: 28px; font-weight: 800; background: linear-gradient(135deg,#38bdf8,#818cf8);
318 -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
319.subtitle { font-size: 14px; color: var(--text2); margin-top: 4px; letter-spacing: 1px;
320 text-transform: uppercase; }
321
322.meta-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
323 background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
324 padding: 16px 20px; margin: 16px 0; }
325.meta-item { display: flex; flex-direction: column; align-items: center; min-width: 90px; }
326.meta-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2); }
327.meta-value { font-size: 15px; font-weight: 600; margin-top: 2px; }
328
329.verdict { text-align: center; padding: 20px; border-radius: var(--radius);
330 margin: 16px 0; font-size: 18px; font-weight: 700; }
331.verdict-pass { background: linear-gradient(135deg,#064e3b,#065f46);
332 border: 1px solid #22c55e; color: #bbf7d0; }
333.verdict-fail { background: linear-gradient(135deg,#450a0a,#7f1d1d);
334 border: 1px solid #ef4444; color: #fecaca; }
335
336.section { background: var(--surface); border: 1px solid var(--border);
337 border-radius: var(--radius); padding: 24px; margin: 16px 0; }
338.section h2 { font-size: 18px; font-weight: 700; margin-bottom: 16px;
339 display: flex; align-items: center; gap: 8px; }
340
341.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
342 gap: 12px; }
343.score-card { background: var(--surface2); border-radius: 6px; padding: 16px; }
344.score-label { font-size: 12px; color: var(--text2); text-transform: uppercase;
345 letter-spacing: 0.5px; }
346.score-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
347
348.finding-bar { display: flex; height: 24px; border-radius: 12px; overflow: hidden;
349 background: var(--surface2); }
350.bar-segment { transition: width 0.3s; }
351.bar-critical { background: var(--critical); }
352.bar-high { background: var(--high); }
353.bar-medium { background: var(--medium); }
354.bar-low { background: var(--low); }
355.bar-info { background: var(--info); }
356.finding-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px;
357 justify-content: center; }
358.legend-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
359.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
360
361.scores-table { display: flex; flex-direction: column; gap: 8px; }
362.score-row { display: flex; align-items: center; gap: 12px; }
363.score-row-label { width: 160px; font-size: 13px; flex-shrink: 0; }
364.score-row-bar { flex: 1; height: 20px; background: var(--surface2); border-radius: 10px;
365 overflow: hidden; }
366.score-row-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
367.score-row-value { width: 60px; text-align: right; font-size: 13px; font-weight: 700;
368 flex-shrink: 0; }
369
370.badge { background: var(--accent); color: #0f172a; font-size: 13px; font-weight: 700;
371 padding: 2px 10px; border-radius: 12px; }
372
373details { margin-bottom: 12px; }
374summary { cursor: pointer; padding: 12px 16px; border-radius: 6px; font-weight: 700;
375 font-size: 15px; }
376summary:hover { filter: brightness(1.1); }
377.finding-group-header.critical { background: #450a0a; color: #fca5a5; }
378.finding-group-header.high { background: #431407; color: #fdba74; }
379.finding-group-header.medium { background: #422006; color: #fde68a; }
380.finding-group-header.low { background: #172554; color: #93c5fd; }
381.finding-group-header.info { background: #1f2937; color: #d1d5db; }
382
383.finding-card { background: var(--surface2); border-radius: 6px; padding: 16px;
384 margin: 8px 0; }
385.finding-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
386.finding-id { color: var(--accent); font-family: monospace; font-size: 13px; }
387.finding-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
388.meta-tag { font-size: 11px; padding: 2px 8px; border-radius: 4px;
389 background: var(--surface); border: 1px solid var(--border); }
390.severity-critical { border-color: var(--critical); color: #fca5a5; }
391.severity-high { border-color: var(--high); color: #fdba74; }
392.severity-medium { border-color: var(--medium); color: #fde68a; }
393.severity-low { border-color: var(--low); color: #93c5fd; }
394.finding-desc { font-size: 14px; color: var(--text2); margin-bottom: 8px; }
395.code-snippet { background: #0f172a; border: 1px solid var(--border); border-radius: 4px;
396 padding: 12px; font-size: 13px; overflow-x: auto; margin-bottom: 8px; }
397.code-snippet code { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
398.finding-rec { font-size: 14px; padding: 8px 12px; background: #064e3b;
399 border-radius: 4px; margin-bottom: 8px; }
400.finding-exploit { font-size: 14px; padding: 8px 12px; background: #1c1917;
401 border-radius: 4px; margin-bottom: 8px; }
402.finding-exploit ol { padding-left: 20px; margin-top: 4px; }
403.finding-refs { font-size: 14px; padding: 8px 12px; background: #172554;
404 border-radius: 4px; }
405.finding-refs ul { padding-left: 20px; margin-top: 4px; }
406
407footer { text-align: center; padding: 24px; color: var(--text2); font-size: 13px; }
408footer a { color: var(--accent); text-decoration: none; }
409footer a:hover { text-decoration: underline; }
410
411@media (max-width: 640px) {
412 body { padding: 12px; }
413 .score-grid { grid-template-columns: 1fr; }
414 .score-row { flex-wrap: wrap; }
415 .score-row-label { width: 100%; }
416 .score-row-value { width: auto; }
417 .meta-bar { flex-direction: column; align-items: stretch; }
418 .meta-item { flex-direction: row; justify-content: space-between; }
419}
420"##;
421
422fn escape_html(s: &str) -> String {
425 s.replace('&', "&")
426 .replace('<', "<")
427 .replace('>', ">")
428 .replace('"', """)
429 .replace('\'', "'")
430}
431
432fn score_color(score: u8) -> &'static str {
433 if score >= 85 {
434 "#22c55e"
435 } else if score >= 70 {
436 "#eab308"
437 } else if score >= 50 {
438 "#f97316"
439 } else {
440 "#ef4444"
441 }
442}
443
444fn risk_color(risk: RiskLevel) -> &'static str {
445 match risk {
446 RiskLevel::Critical => "#ef4444",
447 RiskLevel::High => "#f97316",
448 RiskLevel::Medium => "#eab308",
449 RiskLevel::Low => "#22c55e",
450 RiskLevel::Minimal => "#22c55e",
451 }
452}
453
454pub fn write_report(result: &AuditResult, path: &std::path::Path) -> Result<(), ForgeGuardError> {
456 let html = generate_report(result)?;
457 std::fs::write(path, html)?;
458 Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::core::*;
465
466 fn sample_result() -> AuditResult {
467 AuditResult {
468 project_name: "test-project".into(),
469 chain: "ethereum".into(),
470 timestamp: "2026-07-28T12:00:00Z".into(),
471 duration_seconds: 2.5,
472 findings: vec![
473 Finding::builder()
474 .id("FA-H-001")
475 .title("Reentrancy Vulnerability")
476 .description("CEI violation in withdraw function")
477 .severity(Severity::High)
478 .file("Vault.sol")
479 .location(42, 8)
480 .code("msg.sender.call{value: amount}(\"\");\nbalances[msg.sender] -= amount;")
481 .recommendation(
482 "Use ReentrancyGuard or apply checks-effects-interactions pattern",
483 )
484 .category("Logic")
485 .blocks_deployment(true)
486 .build(),
487 Finding::builder()
488 .id("FA-M-002")
489 .title("Unbounded Loop")
490 .description("Loop over dynamic array may cause out-of-gas")
491 .severity(Severity::Medium)
492 .file("Vault.sol")
493 .location(88, 12)
494 .code("for (uint i; i < holders.length; i++) { ... }")
495 .recommendation("Track array length or use pagination")
496 .category("Gas")
497 .blocks_deployment(false)
498 .reference("SWC-128")
499 .build(),
500 Finding::builder()
501 .id("FA-L-003")
502 .title("Unused Variable")
503 .description("Variable 'oldBalance' is declared but never used")
504 .severity(Severity::Low)
505 .file("Vault.sol")
506 .location(15, 20)
507 .code("uint256 oldBalance = balances[msg.sender];")
508 .recommendation("Remove unused variable")
509 .category("Best Practices")
510 .blocks_deployment(false)
511 .build(),
512 ],
513 scores: SecurityScores {
514 access_control: 100,
515 security: 70,
516 fuzzing: 100,
517 gas: 85,
518 architecture: 65,
519 upgradeability: 100,
520 dependencies: 100,
521 deployment: 100,
522 proxy_safety: 100,
523 chain_compatibility: 100,
524 production_readiness: 55,
525 exploit_resistance: 100,
526 },
527 overall_score: 76,
528 risk_level: RiskLevel::High,
529 production_ready: false,
530 deployment_approved: false,
531 summary: AuditSummary {
532 total_findings: 3,
533 critical_count: 0,
534 high_count: 1,
535 medium_count: 1,
536 low_count: 1,
537 info_count: 0,
538 files_analyzed: 5,
539 lines_analyzed: 500,
540 contracts_analyzed: 3,
541 },
542 }
543 }
544
545 #[test]
546 fn test_html_report_contains_basic_elements() {
547 let result = sample_result();
548 let html = generate_report(&result).unwrap();
549 assert!(html.contains("<!DOCTYPE html>"));
550 assert!(html.contains("Forge Guard"));
551 assert!(html.contains("Security Audit Report"));
552 assert!(html.contains("test-project"));
553 assert!(html.contains("ethereum"));
554 assert!(html.contains("FAIL"));
555 assert!(html.contains("76/100"));
556 assert!(html.contains("HIGH"));
557 }
558
559 #[test]
560 fn test_html_report_contains_findings() {
561 let result = sample_result();
562 let html = generate_report(&result).unwrap();
563 assert!(html.contains("Reentrancy Vulnerability"));
564 assert!(html.contains("FA-H-001"));
565 assert!(html.contains("Unbounded Loop"));
566 assert!(html.contains("FA-M-002"));
567 assert!(html.contains("Unused Variable"));
568 assert!(html.contains("FA-L-003"));
569 }
570
571 #[test]
572 fn test_html_report_empty_findings() {
573 let result = AuditResult {
574 project_name: "clean".into(),
575 chain: "base".into(),
576 timestamp: "2026-07-28T00:00:00Z".into(),
577 duration_seconds: 1.0,
578 findings: vec![],
579 scores: SecurityScores::perfect(),
580 overall_score: 100,
581 risk_level: RiskLevel::Minimal,
582 production_ready: true,
583 deployment_approved: true,
584 summary: AuditSummary {
585 total_findings: 0,
586 critical_count: 0,
587 high_count: 0,
588 medium_count: 0,
589 low_count: 0,
590 info_count: 0,
591 files_analyzed: 3,
592 lines_analyzed: 200,
593 contracts_analyzed: 2,
594 },
595 };
596 let html = generate_report(&result).unwrap();
597 assert!(html.contains("PASS"));
598 assert!(html.contains("APPROVED"));
599 assert!(html.contains("100/100"));
600 assert!(html.contains("MINIMAL"));
601 }
602
603 #[test]
604 fn test_html_report_all_severities() {
605 let result = AuditResult {
606 project_name: "all-sev".into(),
607 chain: "polygon".into(),
608 timestamp: "2026-01-01T00:00:00Z".into(),
609 duration_seconds: 0.5,
610 findings: vec![
611 Finding::builder()
612 .id("C1")
613 .title("Critical Bug")
614 .description("")
615 .severity(Severity::Critical)
616 .file("c.sol")
617 .code("x")
618 .recommendation("Fix")
619 .category("Security")
620 .build(),
621 Finding::builder()
622 .id("H1")
623 .title("High Bug")
624 .description("")
625 .severity(Severity::High)
626 .file("h.sol")
627 .code("x")
628 .recommendation("Fix")
629 .category("Security")
630 .build(),
631 Finding::builder()
632 .id("M1")
633 .title("Medium Bug")
634 .description("")
635 .severity(Severity::Medium)
636 .file("m.sol")
637 .code("x")
638 .recommendation("Fix")
639 .category("Gas")
640 .build(),
641 Finding::builder()
642 .id("L1")
643 .title("Low Bug")
644 .description("")
645 .severity(Severity::Low)
646 .file("l.sol")
647 .code("x")
648 .recommendation("Fix")
649 .category("Style")
650 .build(),
651 Finding::builder()
652 .id("I1")
653 .title("Info Note")
654 .description("")
655 .severity(Severity::Informational)
656 .file("i.sol")
657 .code("x")
658 .recommendation("Note")
659 .category("Style")
660 .build(),
661 ],
662 scores: SecurityScores::perfect(),
663 overall_score: 50,
664 risk_level: RiskLevel::High,
665 production_ready: false,
666 deployment_approved: false,
667 summary: AuditSummary {
668 total_findings: 5,
669 critical_count: 1,
670 high_count: 1,
671 medium_count: 1,
672 low_count: 1,
673 info_count: 1,
674 files_analyzed: 1,
675 lines_analyzed: 50,
676 contracts_analyzed: 1,
677 },
678 };
679 let html = generate_report(&result).unwrap();
680 assert!(html.contains("CRITICAL"));
681 assert!(html.contains("HIGH"));
682 assert!(html.contains("MEDIUM"));
683 assert!(html.contains("LOW"));
684 assert!(html.contains("INFO"));
685 assert!(html.contains("Critical Bug"));
687 assert!(html.contains("High Bug"));
688 assert!(html.contains("Medium Bug"));
689 assert!(html.contains("Low Bug"));
690 assert!(html.contains("Info Note"));
691 }
692
693 #[test]
694 fn test_html_report_contains_exploit_path() {
695 let mut result = sample_result();
696 result.findings[0].exploit_path = Some(vec![
697 "Attacker calls withdraw()".into(),
698 "Fallback receives ETH before balance update".into(),
699 "Re-enters withdraw() recursively".into(),
700 ]);
701 let html = generate_report(&result).unwrap();
702 assert!(html.contains("Exploit Path"));
703 assert!(html.contains("Attacker calls withdraw()"));
704 }
705
706 #[test]
707 fn test_html_report_contains_references() {
708 let html = generate_report(&sample_result()).unwrap();
709 assert!(html.contains("SWC-128"));
710 }
711
712 #[test]
713 fn test_html_escape_prevents_injection() {
714 let result = AuditResult {
715 project_name: "<script>alert('xss')</script>".into(),
716 chain: "test".into(),
717 timestamp: "2026-01-01T00:00:00Z".into(),
718 duration_seconds: 0.1,
719 findings: vec![Finding::builder()
720 .id("X1")
721 .title("<img src=x onerror=alert(1)>")
722 .description("<b>bold</b>")
723 .severity(Severity::Low)
724 .file("<script>evil</script>")
725 .code("<script>alert(1)</script>")
726 .recommendation("Sanitize")
727 .category("Security")
728 .build()],
729 scores: SecurityScores::perfect(),
730 overall_score: 100,
731 risk_level: RiskLevel::Minimal,
732 production_ready: true,
733 deployment_approved: true,
734 summary: AuditSummary {
735 total_findings: 1,
736 critical_count: 0,
737 high_count: 0,
738 medium_count: 0,
739 low_count: 1,
740 info_count: 0,
741 files_analyzed: 1,
742 lines_analyzed: 10,
743 contracts_analyzed: 1,
744 },
745 };
746 let html = generate_report(&result).unwrap();
747 assert!(html.contains("<script>"));
749 assert!(html.contains("<img"));
750 assert!(html.contains("<b>"));
751 assert!(!html.contains("<script>alert"));
752 assert!(!html.contains("<img src=x"));
753 }
754
755 #[test]
756 fn test_html_report_score_colors() {
757 assert_eq!(score_color(100), "#22c55e");
758 assert_eq!(score_color(85), "#22c55e");
759 assert_eq!(score_color(75), "#eab308");
760 assert_eq!(score_color(70), "#eab308");
761 assert_eq!(score_color(60), "#f97316");
762 assert_eq!(score_color(50), "#f97316");
763 assert_eq!(score_color(30), "#ef4444");
764 assert_eq!(score_color(0), "#ef4444");
765 }
766
767 #[test]
768 fn test_html_report_risk_colors() {
769 assert_eq!(risk_color(RiskLevel::Critical), "#ef4444");
770 assert_eq!(risk_color(RiskLevel::High), "#f97316");
771 assert_eq!(risk_color(RiskLevel::Medium), "#eab308");
772 assert_eq!(risk_color(RiskLevel::Low), "#22c55e");
773 assert_eq!(risk_color(RiskLevel::Minimal), "#22c55e");
774 }
775
776 #[test]
777 fn test_html_write_report() {
778 let result = sample_result();
779 let dir = tempfile::tempdir().unwrap();
780 let path = dir.path().join("report.html");
781 write_report(&result, &path).unwrap();
782 assert!(path.exists());
783 let content = std::fs::read_to_string(&path).unwrap();
784 assert!(content.contains("Forge Guard"));
785 }
786
787 #[test]
788 fn test_html_report_responsive_viewport() {
789 let html = generate_report(&sample_result()).unwrap();
790 assert!(html.contains("viewport"));
791 assert!(html.contains("width=device-width"));
792 }
793
794 #[test]
795 fn test_html_report_dark_theme_css_vars() {
796 let html = generate_report(&sample_result()).unwrap();
797 assert!(html.contains("--bg: #0f172a"));
798 }
799}