1use sentri_core::{Finding, Severity};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy)]
10pub enum ReportFormat {
11 Markdown,
13 Json,
15 Html,
17 Csv,
19}
20
21#[derive(Debug, Clone)]
23pub struct SeverityStats {
24 pub critical: usize,
26 pub high: usize,
28 pub medium: usize,
30 pub low: usize,
32 pub info: usize,
34}
35
36impl SeverityStats {
37 pub fn from_findings(findings: &[Finding]) -> Self {
39 let mut stats = Self {
40 critical: 0,
41 high: 0,
42 medium: 0,
43 low: 0,
44 info: 0,
45 };
46
47 for finding in findings {
48 match finding.severity {
49 Severity::Critical => stats.critical += 1,
50 Severity::High => stats.high += 1,
51 Severity::Medium => stats.medium += 1,
52 Severity::Low => stats.low += 1,
53 Severity::Info => stats.info += 1,
54 }
55 }
56
57 stats
58 }
59
60 pub fn total(&self) -> usize {
62 self.critical + self.high + self.medium + self.low + self.info
63 }
64
65 pub fn risk_score(&self) -> f64 {
67 let total = self.total() as f64;
68 if total == 0.0 {
69 return 0.0;
70 }
71
72 let weighted = (self.critical as f64 * 100.0)
73 + (self.high as f64 * 75.0)
74 + (self.medium as f64 * 50.0)
75 + (self.low as f64 * 25.0)
76 + (self.info as f64 * 10.0);
77
78 (weighted / (total * 100.0)).min(100.0)
79 }
80}
81
82pub struct SecurityReport {
84 pub title: String,
86 pub timestamp: String,
88 pub analyzed_targets: Vec<String>,
90 pub findings: Vec<Finding>,
92 pub severity_stats: SeverityStats,
94 pub chain_breakdown: HashMap<String, usize>,
96 pub executive_summary: String,
98}
99
100impl SecurityReport {
101 pub fn new(
103 title: String,
104 analyzed_targets: Vec<String>,
105 findings: Vec<Finding>,
106 executive_summary: String,
107 ) -> Self {
108 let severity_stats = SeverityStats::from_findings(&findings);
109
110 let mut chain_breakdown = HashMap::new();
111 for finding in &findings {
112 let count = chain_breakdown
113 .entry(finding.invariant_id.clone())
114 .or_insert(0);
115 *count += 1;
116 }
117
118 Self {
119 title,
120 timestamp: chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
121 analyzed_targets,
122 findings,
123 severity_stats,
124 chain_breakdown,
125 executive_summary,
126 }
127 }
128
129 pub fn generate(&self, format: ReportFormat) -> String {
131 match format {
132 ReportFormat::Markdown => self.generate_markdown(),
133 ReportFormat::Json => self.generate_json(),
134 ReportFormat::Html => self.generate_html(),
135 ReportFormat::Csv => self.generate_csv(),
136 }
137 }
138
139 fn generate_markdown(&self) -> String {
141 let mut report = format!("# {}\n\n", self.title);
142 report.push_str("**Generated:** ");
143 report.push_str(&self.timestamp);
144 report.push_str("\n\n");
145
146 report.push_str("## Executive Summary\n\n");
148 report.push_str(&self.executive_summary);
149 report.push_str("\n\n");
150
151 report.push_str("## Security Statistics\n\n");
153 report.push_str(&format!(
154 "- **Total Findings:** {}\n",
155 self.severity_stats.total()
156 ));
157 report.push_str(&format!(
158 "- **Critical:** {}\n",
159 self.severity_stats.critical
160 ));
161 report.push_str(&format!("- **High:** {}\n", self.severity_stats.high));
162 report.push_str(&format!("- **Medium:** {}\n", self.severity_stats.medium));
163 report.push_str(&format!("- **Low:** {}\n", self.severity_stats.low));
164 report.push_str(&format!("- **Info:** {}\n", self.severity_stats.info));
165 report.push_str(&format!(
166 "- **Risk Score:** {:.1}/100.0\n\n",
167 self.severity_stats.risk_score()
168 ));
169
170 report.push_str("## Analyzed Targets\n\n");
172 for target in &self.analyzed_targets {
173 report.push_str(&format!("- {}\n", target));
174 }
175 report.push('\n');
176
177 report.push_str("## Detailed Findings\n\n");
179
180 for severity_level in &[
181 Severity::Critical,
182 Severity::High,
183 Severity::Medium,
184 Severity::Low,
185 Severity::Info,
186 ] {
187 let severity_findings: Vec<_> = self
188 .findings
189 .iter()
190 .filter(|f| f.severity == *severity_level)
191 .collect();
192
193 if !severity_findings.is_empty() {
194 report.push_str(&format!("### {:?} Severity\n\n", severity_level));
195
196 for finding in severity_findings {
197 report.push_str(&format!("#### {}\n", finding.message));
198 report.push_str(&format!("- **File:** {}\n", finding.file));
199 report.push_str(&format!(
200 "- **Location:** Line {}, Column {}\n",
201 finding.line, finding.col
202 ));
203 report.push_str(&format!("- **Code:** {}\n", finding.snippet));
204 report.push('\n');
205 }
206 }
207 }
208
209 report
210 }
211
212 fn generate_json(&self) -> String {
214 format!(
215 r#"{{
216 "title": "{}",
217 "timestamp": "{}",
218 "statistics": {{
219 "total_findings": {},
220 "critical": {},
221 "high": {},
222 "medium": {},
223 "low": {},
224 "info": {},
225 "risk_score": {:.1}
226 }},
227 "target_count": {},
228 "summary": "{}"
229}}"#,
230 self.title,
231 self.timestamp,
232 self.severity_stats.total(),
233 self.severity_stats.critical,
234 self.severity_stats.high,
235 self.severity_stats.medium,
236 self.severity_stats.low,
237 self.severity_stats.info,
238 self.severity_stats.risk_score(),
239 self.analyzed_targets.len(),
240 self.executive_summary.replace('"', "\\\"")
241 )
242 }
243
244 fn generate_html(&self) -> String {
246 format!(
247 r#"<!DOCTYPE html>
248<html>
249<head>
250 <title>{}</title>
251 <style>
252 body {{ font-family: Arial, sans-serif; margin: 20px; }}
253 .critical {{ color: red; font-weight: bold; }}
254 .high {{ color: orange; font-weight: bold; }}
255 .medium {{ color: #FFD700; }}
256 .low {{ color: blue; }}
257 table {{ border-collapse: collapse; width: 100%; }}
258 th, td {{ border: 1px solid black; padding: 8px; text-align: left; }}
259 </style>
260</head>
261<body>
262 <h1>{}</h1>
263 <p><strong>Generated:</strong> {}</p>
264 <h2>Summary</h2>
265 <p>Total Findings: {}</p>
266 <p>Risk Score: {:.1}/100.0</p>
267</body>
268</html>"#,
269 self.title,
270 self.title,
271 self.timestamp,
272 self.severity_stats.total(),
273 self.severity_stats.risk_score()
274 )
275 }
276
277 fn generate_csv(&self) -> String {
279 let mut csv = "Severity,Vulnerability_ID,File,Line,Message\n".to_string();
280
281 for finding in &self.findings {
282 csv.push_str(&format!(
283 "{:?},{},{},{},{}\n",
284 finding.severity,
285 finding.invariant_id,
286 finding.file,
287 finding.line,
288 finding.message.replace(',', ";")
289 ));
290 }
291
292 csv
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn severity_stats_from_findings() {
302 let findings = vec![
303 Finding::new(
304 "test".to_string(),
305 Severity::Critical,
306 "file.sol".to_string(),
307 1,
308 0,
309 "Test".to_string(),
310 "code".to_string(),
311 ),
312 Finding::new(
313 "test".to_string(),
314 Severity::High,
315 "file.sol".to_string(),
316 2,
317 0,
318 "Test".to_string(),
319 "code".to_string(),
320 ),
321 ];
322
323 let stats = SeverityStats::from_findings(&findings);
324 assert_eq!(stats.critical, 1);
325 assert_eq!(stats.high, 1);
326 assert_eq!(stats.total(), 2);
327 }
328
329 #[test]
330 fn risk_score_calculation() {
331 let stats = SeverityStats {
332 critical: 1,
333 high: 2,
334 medium: 3,
335 low: 4,
336 info: 0,
337 };
338
339 let score = stats.risk_score();
340 assert!(score > 0.0);
341 assert!(score <= 100.0);
342 }
343
344 #[test]
345 fn report_generation() {
346 let findings = vec![];
347 let report = SecurityReport::new(
348 "Test Report".to_string(),
349 vec!["test.sol".to_string()],
350 findings,
351 "No issues found".to_string(),
352 );
353
354 let md = report.generate(ReportFormat::Markdown);
355 assert!(md.contains("Test Report"));
356 }
357}