agentshield/output/
html.rs1use crate::error::Result;
2use crate::rules::policy::PolicyVerdict;
3use crate::rules::{Finding, Severity};
4
5pub 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 & 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('&', "&")
257 .replace('<', "<")
258 .replace('>', ">")
259 .replace('"', """)
260}