Skip to main content

cc_audit/reporter/
html.rs

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('&', "&amp;")
447        .replace('<', "&lt;")
448        .replace('>', "&gt;")
449        .replace('"', "&quot;")
450        .replace('\'', "&#39;")
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("&lt;script&gt;"));
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        // Test that all Category variants are properly formatted
520        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        // Check that all categories are displayed
601        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}