1use crate::reporter::Reporter;
2use crate::rules::{Category, ScanResult};
3
4pub struct HtmlReporter;
5
6impl HtmlReporter {
7 pub fn new() -> Self {
8 Self
9 }
10}
11
12impl Default for HtmlReporter {
13 fn default() -> Self {
14 Self::new()
15 }
16}
17
18impl Reporter for HtmlReporter {
19 fn report(&self, result: &ScanResult) -> String {
20 let status_class = if result.summary.passed {
21 "passed"
22 } else {
23 "failed"
24 };
25 let status_text = if result.summary.passed {
26 "PASSED"
27 } else {
28 "FAILED"
29 };
30
31 let risk_score_html = if let Some(ref score) = result.risk_score {
32 let level_lower = format!("{:?}", score.level).to_lowercase();
33 let level_display = format!("{:?}", score.level);
34 let percentage = (score.total as f32 / 10.0).min(100.0);
35 format!(
36 r#"
37 <div class="risk-score">
38 <h2>Risk Score</h2>
39 <div class="score-display">
40 <span class="score-value risk-{level_lower}">{}</span>
41 <span class="score-label">{level_display}</span>
42 </div>
43 <div class="score-bar">
44 <div class="score-fill" style="width: {percentage}%"></div>
45 </div>
46 </div>"#,
47 score.total,
48 )
49 } else {
50 String::new()
51 };
52
53 let findings_html: String = result
54 .findings
55 .iter()
56 .map(|f| {
57 let severity_class = format!("{:?}", f.severity).to_lowercase();
58 let category_display = format_category(&f.category);
59 format!(
60 r#"
61 <div class="finding severity-{}">
62 <div class="finding-header">
63 <span class="finding-id">{}</span>
64 <span class="severity-badge {}">{:?}</span>
65 <span class="category-badge">{}</span>
66 </div>
67 <div class="finding-message">{}</div>
68 <div class="finding-location">
69 <code>{}:{}</code>
70 </div>
71 <div class="finding-code">
72 <pre><code>{}</code></pre>
73 </div>
74 <div class="finding-recommendation">
75 <strong>Recommendation:</strong> {}
76 </div>
77 </div>"#,
78 severity_class,
79 f.id,
80 severity_class,
81 f.severity,
82 category_display,
83 html_escape(&f.message),
84 html_escape(&f.location.file),
85 f.location.line,
86 html_escape(&f.code),
87 html_escape(&f.recommendation)
88 )
89 })
90 .collect();
91
92 format!(
93 r#"<!DOCTYPE html>
94<html lang="en">
95<head>
96 <meta charset="UTF-8">
97 <meta name="viewport" content="width=device-width, initial-scale=1.0">
98 <title>cc-audit Security Report</title>
99 <style>
100 :root {{
101 --critical: #dc2626;
102 --high: #ea580c;
103 --medium: #ca8a04;
104 --low: #2563eb;
105 --passed: #16a34a;
106 --failed: #dc2626;
107 }}
108
109 * {{
110 margin: 0;
111 padding: 0;
112 box-sizing: border-box;
113 }}
114
115 body {{
116 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
117 line-height: 1.6;
118 color: #1f2937;
119 background: #f3f4f6;
120 padding: 2rem;
121 }}
122
123 .container {{
124 max-width: 1200px;
125 margin: 0 auto;
126 }}
127
128 .header {{
129 background: white;
130 border-radius: 12px;
131 padding: 2rem;
132 margin-bottom: 2rem;
133 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
134 }}
135
136 .header h1 {{
137 font-size: 1.75rem;
138 margin-bottom: 0.5rem;
139 }}
140
141 .header-meta {{
142 color: #6b7280;
143 font-size: 0.9rem;
144 }}
145
146 .status {{
147 display: inline-flex;
148 align-items: center;
149 padding: 0.5rem 1rem;
150 border-radius: 9999px;
151 font-weight: 600;
152 margin-top: 1rem;
153 }}
154
155 .status.passed {{
156 background: #dcfce7;
157 color: var(--passed);
158 }}
159
160 .status.failed {{
161 background: #fee2e2;
162 color: var(--failed);
163 }}
164
165 .summary {{
166 display: grid;
167 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
168 gap: 1rem;
169 margin-bottom: 2rem;
170 }}
171
172 .summary-card {{
173 background: white;
174 border-radius: 12px;
175 padding: 1.5rem;
176 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
177 }}
178
179 .summary-card h3 {{
180 font-size: 0.875rem;
181 color: #6b7280;
182 text-transform: uppercase;
183 margin-bottom: 0.5rem;
184 }}
185
186 .summary-value {{
187 font-size: 2rem;
188 font-weight: 700;
189 }}
190
191 .summary-value.critical {{ color: var(--critical); }}
192 .summary-value.high {{ color: var(--high); }}
193 .summary-value.medium {{ color: var(--medium); }}
194 .summary-value.low {{ color: var(--low); }}
195
196 .risk-score {{
197 background: white;
198 border-radius: 12px;
199 padding: 1.5rem;
200 margin-bottom: 2rem;
201 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
202 }}
203
204 .risk-score h2 {{
205 margin-bottom: 1rem;
206 }}
207
208 .score-display {{
209 display: flex;
210 align-items: baseline;
211 gap: 1rem;
212 margin-bottom: 1rem;
213 }}
214
215 .score-value {{
216 font-size: 3rem;
217 font-weight: 700;
218 }}
219
220 .score-value.risk-safe {{ color: var(--passed); }}
221 .score-value.risk-low {{ color: var(--low); }}
222 .score-value.risk-medium {{ color: var(--medium); }}
223 .score-value.risk-high {{ color: var(--high); }}
224 .score-value.risk-critical {{ color: var(--critical); }}
225
226 .score-label {{
227 font-size: 1.25rem;
228 color: #6b7280;
229 }}
230
231 .score-bar {{
232 height: 8px;
233 background: #e5e7eb;
234 border-radius: 4px;
235 overflow: hidden;
236 }}
237
238 .score-fill {{
239 height: 100%;
240 background: linear-gradient(90deg, var(--low), var(--medium), var(--critical));
241 border-radius: 4px;
242 transition: width 0.5s ease;
243 }}
244
245 .findings {{
246 background: white;
247 border-radius: 12px;
248 padding: 1.5rem;
249 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
250 }}
251
252 .findings h2 {{
253 margin-bottom: 1rem;
254 }}
255
256 .finding {{
257 border: 1px solid #e5e7eb;
258 border-radius: 8px;
259 padding: 1rem;
260 margin-bottom: 1rem;
261 }}
262
263 .finding.severity-critical {{ border-left: 4px solid var(--critical); }}
264 .finding.severity-high {{ border-left: 4px solid var(--high); }}
265 .finding.severity-medium {{ border-left: 4px solid var(--medium); }}
266 .finding.severity-low {{ border-left: 4px solid var(--low); }}
267
268 .finding-header {{
269 display: flex;
270 align-items: center;
271 gap: 0.5rem;
272 margin-bottom: 0.5rem;
273 }}
274
275 .finding-id {{
276 font-weight: 600;
277 font-family: monospace;
278 }}
279
280 .severity-badge {{
281 padding: 0.25rem 0.5rem;
282 border-radius: 4px;
283 font-size: 0.75rem;
284 font-weight: 600;
285 text-transform: uppercase;
286 }}
287
288 .severity-badge.critical {{ background: #fee2e2; color: var(--critical); }}
289 .severity-badge.high {{ background: #ffedd5; color: var(--high); }}
290 .severity-badge.medium {{ background: #fef3c7; color: var(--medium); }}
291 .severity-badge.low {{ background: #dbeafe; color: var(--low); }}
292
293 .category-badge {{
294 padding: 0.25rem 0.5rem;
295 border-radius: 4px;
296 font-size: 0.75rem;
297 background: #f3f4f6;
298 color: #4b5563;
299 }}
300
301 .finding-message {{
302 font-size: 0.95rem;
303 margin-bottom: 0.5rem;
304 }}
305
306 .finding-location {{
307 font-size: 0.875rem;
308 color: #6b7280;
309 margin-bottom: 0.5rem;
310 }}
311
312 .finding-code {{
313 background: #1f2937;
314 border-radius: 6px;
315 padding: 0.75rem;
316 margin-bottom: 0.5rem;
317 overflow-x: auto;
318 }}
319
320 .finding-code pre {{
321 margin: 0;
322 }}
323
324 .finding-code code {{
325 color: #e5e7eb;
326 font-family: 'SF Mono', Monaco, monospace;
327 font-size: 0.875rem;
328 }}
329
330 .finding-recommendation {{
331 font-size: 0.875rem;
332 color: #4b5563;
333 }}
334
335 .no-findings {{
336 text-align: center;
337 padding: 3rem;
338 color: #6b7280;
339 }}
340
341 .footer {{
342 text-align: center;
343 margin-top: 2rem;
344 color: #9ca3af;
345 font-size: 0.875rem;
346 }}
347
348 .footer a {{
349 color: #6b7280;
350 text-decoration: none;
351 }}
352
353 .footer a:hover {{
354 text-decoration: underline;
355 }}
356 </style>
357</head>
358<body>
359 <div class="container">
360 <div class="header">
361 <h1>cc-audit Security Report</h1>
362 <div class="header-meta">
363 <div>Target: <code>{}</code></div>
364 <div>Version: {}</div>
365 <div>Generated: {}</div>
366 </div>
367 <div class="status {}">
368 {}
369 </div>
370 </div>
371
372 <div class="summary">
373 <div class="summary-card">
374 <h3>Critical</h3>
375 <div class="summary-value critical">{}</div>
376 </div>
377 <div class="summary-card">
378 <h3>High</h3>
379 <div class="summary-value high">{}</div>
380 </div>
381 <div class="summary-card">
382 <h3>Medium</h3>
383 <div class="summary-value medium">{}</div>
384 </div>
385 <div class="summary-card">
386 <h3>Low</h3>
387 <div class="summary-value low">{}</div>
388 </div>
389 <div class="summary-card">
390 <h3>Total Findings</h3>
391 <div class="summary-value">{}</div>
392 </div>
393 </div>
394
395 {}
396
397 <div class="findings">
398 <h2>Findings</h2>
399 {}
400 </div>
401
402 <div class="footer">
403 Generated by <a href="https://github.com/anthropics/cc-audit">cc-audit</a> v{}
404 </div>
405 </div>
406</body>
407</html>"#,
408 html_escape(&result.target),
409 result.version,
410 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
411 status_class,
412 status_text,
413 result.summary.critical,
414 result.summary.high,
415 result.summary.medium,
416 result.summary.low,
417 result.summary.critical
418 + result.summary.high
419 + result.summary.medium
420 + result.summary.low,
421 risk_score_html,
422 if result.findings.is_empty() {
423 "<div class=\"no-findings\">No security issues found.</div>".to_string()
424 } else {
425 findings_html
426 },
427 result.version
428 )
429 }
430}
431
432fn format_category(category: &Category) -> &'static str {
433 match category {
434 Category::Exfiltration => "Exfiltration",
435 Category::PromptInjection => "Prompt Injection",
436 Category::Persistence => "Persistence",
437 Category::PrivilegeEscalation => "Privilege Escalation",
438 Category::Obfuscation => "Obfuscation",
439 Category::SupplyChain => "Supply Chain",
440 Category::SecretLeak => "Secret Leak",
441 Category::Overpermission => "Overpermission",
442 }
443}
444
445fn html_escape(s: &str) -> String {
446 s.replace('&', "&")
447 .replace('<', "<")
448 .replace('>', ">")
449 .replace('"', """)
450 .replace('\'', "'")
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::rules::{Category, Severity};
457 use crate::test_utils::fixtures::{create_finding, create_test_result};
458
459 #[test]
460 fn test_html_output_structure() {
461 let reporter = HtmlReporter::new();
462 let result = create_test_result(vec![]);
463 let output = reporter.report(&result);
464
465 assert!(output.contains("<!DOCTYPE html>"));
466 assert!(output.contains("cc-audit Security Report"));
467 assert!(output.contains("PASSED"));
468 }
469
470 #[test]
471 fn test_html_output_with_findings() {
472 let reporter = HtmlReporter::new();
473 let finding = create_finding(
474 "EX-001",
475 Severity::Critical,
476 Category::Exfiltration,
477 "Test finding",
478 "test.sh",
479 10,
480 );
481 let result = create_test_result(vec![finding]);
482 let output = reporter.report(&result);
483
484 assert!(output.contains("EX-001"));
485 assert!(output.contains("severity-critical"));
486 assert!(output.contains("FAILED"));
487 }
488
489 #[test]
490 fn test_html_escapes_special_chars() {
491 let reporter = HtmlReporter::new();
492 let mut finding = create_finding(
493 "TEST-001",
494 Severity::High,
495 Category::Exfiltration,
496 "Test <script>alert('xss')</script>",
497 "test.sh",
498 1,
499 );
500 finding.code = "<script>malicious</script>".to_string();
501 let result = create_test_result(vec![finding]);
502 let output = reporter.report(&result);
503
504 assert!(!output.contains("<script>alert"));
505 assert!(output.contains("<script>"));
506 }
507
508 #[test]
509 #[allow(clippy::default_constructed_unit_structs)]
510 fn test_html_default_trait() {
511 let reporter = HtmlReporter::default();
512 let result = create_test_result(vec![]);
513 let output = reporter.report(&result);
514 assert!(output.contains("cc-audit"));
515 }
516
517 #[test]
518 fn test_format_category_all_variants() {
519 assert_eq!(format_category(&Category::Exfiltration), "Exfiltration");
521 assert_eq!(
522 format_category(&Category::PromptInjection),
523 "Prompt Injection"
524 );
525 assert_eq!(format_category(&Category::Persistence), "Persistence");
526 assert_eq!(
527 format_category(&Category::PrivilegeEscalation),
528 "Privilege Escalation"
529 );
530 assert_eq!(format_category(&Category::Obfuscation), "Obfuscation");
531 assert_eq!(format_category(&Category::SupplyChain), "Supply Chain");
532 assert_eq!(format_category(&Category::SecretLeak), "Secret Leak");
533 assert_eq!(format_category(&Category::Overpermission), "Overpermission");
534 }
535
536 #[test]
537 fn test_html_output_with_all_categories() {
538 let reporter = HtmlReporter::new();
539 let findings = vec![
540 create_finding(
541 "PI-001",
542 Severity::Critical,
543 Category::PromptInjection,
544 "Prompt injection",
545 "test.md",
546 1,
547 ),
548 create_finding(
549 "PS-001",
550 Severity::High,
551 Category::Persistence,
552 "Persistence",
553 "test.sh",
554 2,
555 ),
556 create_finding(
557 "PE-001",
558 Severity::High,
559 Category::PrivilegeEscalation,
560 "Privilege escalation",
561 "test.sh",
562 3,
563 ),
564 create_finding(
565 "OB-001",
566 Severity::Medium,
567 Category::Obfuscation,
568 "Obfuscation",
569 "test.js",
570 4,
571 ),
572 create_finding(
573 "SC-001",
574 Severity::Critical,
575 Category::SupplyChain,
576 "Supply chain",
577 "package.json",
578 5,
579 ),
580 create_finding(
581 "SL-001",
582 Severity::Critical,
583 Category::SecretLeak,
584 "Secret leak",
585 "config.yaml",
586 6,
587 ),
588 create_finding(
589 "OP-001",
590 Severity::Medium,
591 Category::Overpermission,
592 "Overpermission",
593 "mcp.json",
594 7,
595 ),
596 ];
597 let result = create_test_result(findings);
598 let output = reporter.report(&result);
599
600 assert!(output.contains("Prompt Injection"));
602 assert!(output.contains("Persistence"));
603 assert!(output.contains("Privilege Escalation"));
604 assert!(output.contains("Obfuscation"));
605 assert!(output.contains("Supply Chain"));
606 assert!(output.contains("Secret Leak"));
607 assert!(output.contains("Overpermission"));
608 }
609}