Skip to main content

sentri_report/
security_report.rs

1/// Comprehensive Security Report Generator
2///
3/// Generates multi-format security analysis reports with severity aggregation,
4/// remediation guidance, and industry-standard formatting.
5use sentri_core::{Finding, Severity};
6use std::collections::HashMap;
7
8/// Report format enumeration
9#[derive(Debug, Clone, Copy)]
10pub enum ReportFormat {
11    /// Markdown format
12    Markdown,
13    /// JSON format
14    Json,
15    /// HTML format
16    Html,
17    /// CSV format
18    Csv,
19}
20
21/// Severity statistics
22#[derive(Debug, Clone)]
23pub struct SeverityStats {
24    /// Critical severity count
25    pub critical: usize,
26    /// High severity count
27    pub high: usize,
28    /// Medium severity count
29    pub medium: usize,
30    /// Low severity count
31    pub low: usize,
32    /// Info severity count
33    pub info: usize,
34}
35
36impl SeverityStats {
37    /// Create from findings
38    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    /// Total findings count
61    pub fn total(&self) -> usize {
62        self.critical + self.high + self.medium + self.low + self.info
63    }
64
65    /// Risk score (0.0-100.0)
66    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
82/// Security analysis report
83pub struct SecurityReport {
84    /// Report title
85    pub title: String,
86    /// Analysis timestamp
87    pub timestamp: String,
88    /// Analyzed files/contracts
89    pub analyzed_targets: Vec<String>,
90    /// All findings
91    pub findings: Vec<Finding>,
92    /// Severity statistics
93    pub severity_stats: SeverityStats,
94    /// Detector chain breakdown
95    pub chain_breakdown: HashMap<String, usize>,
96    /// Executive summary
97    pub executive_summary: String,
98}
99
100impl SecurityReport {
101    /// Create new security report
102    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    /// Generate report in specified format
130    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    /// Generate Markdown report
140    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        // Executive Summary
147        report.push_str("## Executive Summary\n\n");
148        report.push_str(&self.executive_summary);
149        report.push_str("\n\n");
150
151        // Statistics
152        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        // Analyzed Targets
171        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        // Findings by Severity
178        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    /// Generate JSON report
213    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    /// Generate HTML report
245    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    /// Generate CSV report
278    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}