agentshield/output/
html.rs1use std::path::Path;
2
3use crate::error::Result;
4use crate::rules::policy::PolicyVerdict;
5use crate::rules::{Finding, Severity};
6
7pub 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 & 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('&', "&")
271 .replace('<', "<")
272 .replace('>', ">")
273 .replace('"', """)
274}