Skip to main content

agentshield/output/
html.rs

1use crate::error::Result;
2use crate::rules::policy::PolicyVerdict;
3use crate::rules::{Finding, Severity};
4
5/// Render findings as a self-contained HTML report.
6pub fn render(findings: &[Finding], verdict: &PolicyVerdict, target_name: &str) -> Result<String> {
7    let mut sorted: Vec<&Finding> = findings.iter().collect();
8    sorted.sort_by(|a, b| {
9        b.severity
10            .cmp(&a.severity)
11            .then_with(|| a.rule_id.cmp(&b.rule_id))
12    });
13
14    let severity_counts = SeverityCounts::from_findings(findings);
15    let status_class = if verdict.pass { "pass" } else { "fail" };
16    let status_text = if verdict.pass { "PASS" } else { "FAIL" };
17
18    let finding_rows: String = sorted
19        .iter()
20        .map(|f| {
21            let sev_class = severity_class(f.severity);
22            let location = f
23                .location
24                .as_ref()
25                .map(|l| format!("{}:{}", l.file.display(), l.line))
26                .unwrap_or_else(|| "-".into());
27            let cwe = f
28                .cwe_id
29                .as_ref()
30                .map(|c| {
31                    format!(
32                        "<a href=\"https://cwe.mitre.org/data/definitions/{}.html\">{}</a>",
33                        c.trim_start_matches("CWE-"),
34                        c
35                    )
36                })
37                .unwrap_or_else(|| "-".into());
38            let remediation = f.remediation.as_deref().unwrap_or("-");
39            let evidence_html: String = f
40                .evidence
41                .iter()
42                .map(|e| {
43                    let mut s = format!("<li>{}", html_escape(&e.description));
44                    if let Some(snippet) = &e.snippet {
45                        s.push_str(&format!("<pre><code>{}</code></pre>", html_escape(snippet)));
46                    }
47                    s.push_str("</li>");
48                    s
49                })
50                .collect();
51
52            format!(
53                r#"<tr class="{sev_class}">
54  <td><span class="badge {sev_class}">{severity}</span></td>
55  <td><code>{rule_id}</code></td>
56  <td>{rule_name}</td>
57  <td class="msg">{message}</td>
58  <td><code>{location}</code></td>
59  <td>{cwe}</td>
60  <td>{confidence}</td>
61</tr>
62<tr class="detail-row {sev_class}">
63  <td colspan="7">
64    <details>
65      <summary>Evidence &amp; Remediation</summary>
66      <ul>{evidence}</ul>
67      <p><strong>Fix:</strong> {remediation}</p>
68    </details>
69  </td>
70</tr>"#,
71                sev_class = sev_class,
72                severity = f.severity.to_string().to_uppercase(),
73                rule_id = f.rule_id,
74                rule_name = html_escape(&f.rule_name),
75                message = html_escape(&f.message),
76                location = html_escape(&location),
77                cwe = cwe,
78                confidence = f.confidence,
79                evidence = evidence_html,
80                remediation = html_escape(remediation),
81            )
82        })
83        .collect();
84
85    let html = format!(
86        r##"<!DOCTYPE html>
87<html lang="en">
88<head>
89<meta charset="UTF-8">
90<meta name="viewport" content="width=device-width, initial-scale=1.0">
91<title>AgentShield Report — {target}</title>
92<style>
93  :root {{
94    --bg: #0d1117; --fg: #c9d1d9; --border: #30363d;
95    --card: #161b22; --badge-crit: #f85149; --badge-high: #f0883e;
96    --badge-med: #d29922; --badge-low: #58a6ff; --badge-info: #8b949e;
97    --pass: #3fb950; --fail: #f85149;
98  }}
99  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
100  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
101    background: var(--bg); color: var(--fg); line-height: 1.5; padding: 2rem; }}
102  .container {{ max-width: 1200px; margin: 0 auto; }}
103  header {{ display: flex; align-items: center; justify-content: space-between;
104    padding: 1.5rem; background: var(--card); border: 1px solid var(--border);
105    border-radius: 8px; margin-bottom: 1.5rem; }}
106  header h1 {{ font-size: 1.4rem; }}
107  header h1 span {{ color: var(--badge-low); font-weight: 400; }}
108  .verdict {{ font-size: 1.2rem; font-weight: 700; padding: 0.4rem 1.2rem;
109    border-radius: 6px; }}
110  .verdict.pass {{ background: var(--pass); color: #000; }}
111  .verdict.fail {{ background: var(--fail); color: #fff; }}
112  .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
113    gap: 1rem; margin-bottom: 1.5rem; }}
114  .stat {{ background: var(--card); border: 1px solid var(--border);
115    border-radius: 8px; padding: 1rem; text-align: center; }}
116  .stat .count {{ font-size: 2rem; font-weight: 700; }}
117  .stat .label {{ font-size: 0.85rem; color: var(--badge-info); }}
118  .stat.critical .count {{ color: var(--badge-crit); }}
119  .stat.high .count {{ color: var(--badge-high); }}
120  .stat.medium .count {{ color: var(--badge-med); }}
121  .stat.low .count {{ color: var(--badge-low); }}
122  .stat.info .count {{ color: var(--badge-info); }}
123  table {{ width: 100%; border-collapse: collapse; background: var(--card);
124    border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }}
125  th {{ text-align: left; padding: 0.75rem 1rem; border-bottom: 2px solid var(--border);
126    font-size: 0.8rem; text-transform: uppercase; color: var(--badge-info); }}
127  td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border);
128    font-size: 0.9rem; vertical-align: top; }}
129  .msg {{ max-width: 350px; }}
130  .badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px;
131    font-size: 0.75rem; font-weight: 700; color: #fff; }}
132  .badge.critical {{ background: var(--badge-crit); }}
133  .badge.high {{ background: var(--badge-high); }}
134  .badge.medium {{ background: var(--badge-med); color: #000; }}
135  .badge.low {{ background: var(--badge-low); color: #000; }}
136  .badge.info {{ background: var(--badge-info); color: #000; }}
137  .detail-row td {{ padding: 0.25rem 1rem 0.75rem; }}
138  details {{ cursor: pointer; }}
139  details summary {{ color: var(--badge-low); font-size: 0.85rem; }}
140  details ul {{ margin: 0.5rem 0 0.5rem 1.5rem; }}
141  details li {{ margin-bottom: 0.3rem; font-size: 0.85rem; }}
142  details pre {{ background: var(--bg); padding: 0.5rem; border-radius: 4px;
143    margin-top: 0.3rem; overflow-x: auto; font-size: 0.8rem; }}
144  details p {{ font-size: 0.85rem; margin-top: 0.5rem; }}
145  footer {{ margin-top: 1.5rem; text-align: center; font-size: 0.8rem;
146    color: var(--badge-info); }}
147  footer a {{ color: var(--badge-low); }}
148  .empty {{ text-align: center; padding: 3rem; color: var(--pass); font-size: 1.2rem; }}
149  @media (max-width: 768px) {{
150    .summary {{ grid-template-columns: repeat(3, 1fr); }}
151    .msg {{ max-width: 200px; }}
152  }}
153</style>
154</head>
155<body>
156<div class="container">
157  <header>
158    <h1>AgentShield <span>v{version}</span></h1>
159    <div class="verdict {status_class}">{status_text}</div>
160  </header>
161
162  <div class="summary">
163    <div class="stat"><div class="count">{total}</div><div class="label">Total</div></div>
164    <div class="stat critical"><div class="count">{critical}</div><div class="label">Critical</div></div>
165    <div class="stat high"><div class="count">{high}</div><div class="label">High</div></div>
166    <div class="stat medium"><div class="count">{medium}</div><div class="label">Medium</div></div>
167    <div class="stat low"><div class="count">{low}</div><div class="label">Low</div></div>
168    <div class="stat info"><div class="count">{info_count}</div><div class="label">Info</div></div>
169  </div>
170
171  {content}
172
173  <footer>
174    Scanned <strong>{target}</strong> with
175    <a href="https://github.com/limaronaldo/agentshield">AgentShield</a> {version}
176    — threshold: {threshold}
177  </footer>
178</div>
179</body>
180</html>"##,
181        target = html_escape(target_name),
182        version = env!("CARGO_PKG_VERSION"),
183        status_class = status_class,
184        status_text = status_text,
185        total = findings.len(),
186        critical = severity_counts.critical,
187        high = severity_counts.high,
188        medium = severity_counts.medium,
189        low = severity_counts.low,
190        info_count = severity_counts.info,
191        threshold = verdict.fail_threshold,
192        content = if findings.is_empty() {
193            "<div class=\"empty\">No security findings detected.</div>".to_string()
194        } else {
195            format!(
196                r#"<table>
197  <thead>
198    <tr>
199      <th>Severity</th><th>Rule</th><th>Name</th><th>Finding</th>
200      <th>Location</th><th>CWE</th><th>Confidence</th>
201    </tr>
202  </thead>
203  <tbody>
204    {rows}
205  </tbody>
206</table>"#,
207                rows = finding_rows
208            )
209        },
210    );
211
212    Ok(html)
213}
214
215struct SeverityCounts {
216    critical: usize,
217    high: usize,
218    medium: usize,
219    low: usize,
220    info: usize,
221}
222
223impl SeverityCounts {
224    fn from_findings(findings: &[Finding]) -> Self {
225        let mut counts = Self {
226            critical: 0,
227            high: 0,
228            medium: 0,
229            low: 0,
230            info: 0,
231        };
232        for f in findings {
233            match f.severity {
234                Severity::Critical => counts.critical += 1,
235                Severity::High => counts.high += 1,
236                Severity::Medium => counts.medium += 1,
237                Severity::Low => counts.low += 1,
238                Severity::Info => counts.info += 1,
239            }
240        }
241        counts
242    }
243}
244
245fn severity_class(s: Severity) -> &'static str {
246    match s {
247        Severity::Critical => "critical",
248        Severity::High => "high",
249        Severity::Medium => "medium",
250        Severity::Low => "low",
251        Severity::Info => "info",
252    }
253}
254
255fn html_escape(s: &str) -> String {
256    s.replace('&', "&amp;")
257        .replace('<', "&lt;")
258        .replace('>', "&gt;")
259        .replace('"', "&quot;")
260}