Skip to main content

agentshield/output/
html.rs

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