1use std::collections::HashMap;
6use std::time::Duration;
7
8use ahash::AHashMap;
9use chrono::{DateTime, Utc};
10use serde::{Serialize, Deserialize};
11
12use crate::analyzer::security::{SecurityFinding, SecuritySeverity, SecurityCategory};
13use super::SecurityError;
14
15#[derive(Debug, Serialize, Deserialize)]
17pub struct SecurityReport {
18 pub analyzed_at: DateTime<Utc>,
19 pub scan_duration: Duration,
20 pub overall_score: f32,
21 pub risk_level: SecuritySeverity,
22 pub total_findings: usize,
23 pub files_scanned: usize,
24 pub findings_by_severity: HashMap<SecuritySeverity, usize>,
25 pub findings_by_category: HashMap<SecurityCategory, usize>,
26 pub findings: Vec<SecurityFinding>,
27 pub recommendations: Vec<String>,
28 pub performance_metrics: PerformanceMetrics,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
33pub struct PerformanceMetrics {
34 pub total_duration: Duration,
35 pub file_discovery_time: Duration,
36 pub pattern_matching_time: Duration,
37 pub files_per_second: f64,
38 pub cache_hit_rate: f64,
39 pub memory_usage_mb: f64,
40}
41
42pub struct ResultAggregator;
44
45impl ResultAggregator {
46 pub fn aggregate(mut findings: Vec<SecurityFinding>, scan_duration: Duration) -> SecurityReport {
48 findings = Self::deduplicate_findings(findings);
50
51 findings.sort_by_key(|f| std::cmp::Reverse(severity_to_number(&f.severity)));
53
54 let total_findings = findings.len();
56 let findings_by_severity = Self::count_by_severity(&findings);
57 let findings_by_category = Self::count_by_category(&findings);
58 let overall_score = Self::calculate_security_score(&findings);
59 let risk_level = Self::determine_risk_level(&findings);
60
61 let recommendations = Self::generate_recommendations(&findings);
63
64 let performance_metrics = PerformanceMetrics {
66 total_duration: scan_duration,
67 file_discovery_time: Duration::from_millis(0), pattern_matching_time: Duration::from_millis(0), files_per_second: 0.0, cache_hit_rate: 0.0, memory_usage_mb: 0.0, };
73
74 SecurityReport {
75 analyzed_at: Utc::now(),
76 scan_duration,
77 overall_score,
78 risk_level,
79 total_findings,
80 files_scanned: 0, findings_by_severity,
82 findings_by_category,
83 findings,
84 recommendations,
85 performance_metrics,
86 }
87 }
88
89 pub fn empty() -> SecurityReport {
91 SecurityReport {
92 analyzed_at: Utc::now(),
93 scan_duration: Duration::from_secs(0),
94 overall_score: 100.0,
95 risk_level: SecuritySeverity::Info,
96 total_findings: 0,
97 files_scanned: 0,
98 findings_by_severity: HashMap::new(),
99 findings_by_category: HashMap::new(),
100 findings: Vec::new(),
101 recommendations: vec!["No security issues detected.".to_string()],
102 performance_metrics: PerformanceMetrics {
103 total_duration: Duration::from_secs(0),
104 file_discovery_time: Duration::from_secs(0),
105 pattern_matching_time: Duration::from_secs(0),
106 files_per_second: 0.0,
107 cache_hit_rate: 0.0,
108 memory_usage_mb: 0.0,
109 },
110 }
111 }
112
113 fn deduplicate_findings(findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
115 let mut seen: AHashMap<String, SecurityFinding> = AHashMap::new();
116
117 for finding in findings {
118 let key = format!(
120 "{}-{}-{}-{}",
121 finding.id,
122 finding.file_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default(),
123 finding.line_number.unwrap_or(0),
124 finding.title
125 );
126
127 match seen.get(&key) {
129 Some(existing) if severity_to_number(&existing.severity) >= severity_to_number(&finding.severity) => {
130 }
132 _ => {
133 seen.insert(key, finding);
134 }
135 }
136 }
137
138 seen.into_values().collect()
139 }
140
141 fn count_by_severity(findings: &[SecurityFinding]) -> HashMap<SecuritySeverity, usize> {
143 let mut counts = HashMap::new();
144 for finding in findings {
145 *counts.entry(finding.severity.clone()).or_insert(0) += 1;
146 }
147 counts
148 }
149
150 fn count_by_category(findings: &[SecurityFinding]) -> HashMap<SecurityCategory, usize> {
152 let mut counts = HashMap::new();
153 for finding in findings {
154 *counts.entry(finding.category.clone()).or_insert(0) += 1;
155 }
156 counts
157 }
158
159 fn calculate_security_score(findings: &[SecurityFinding]) -> f32 {
161 if findings.is_empty() {
162 return 100.0;
163 }
164
165 let total_penalty: f32 = findings.iter().map(|f| match f.severity {
166 SecuritySeverity::Critical => 25.0,
167 SecuritySeverity::High => 15.0,
168 SecuritySeverity::Medium => 8.0,
169 SecuritySeverity::Low => 3.0,
170 SecuritySeverity::Info => 1.0,
171 }).sum();
172
173 (100.0 - total_penalty).max(0.0)
174 }
175
176 fn determine_risk_level(findings: &[SecurityFinding]) -> SecuritySeverity {
178 if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
179 SecuritySeverity::Critical
180 } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) {
181 SecuritySeverity::High
182 } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) {
183 SecuritySeverity::Medium
184 } else if !findings.is_empty() {
185 SecuritySeverity::Low
186 } else {
187 SecuritySeverity::Info
188 }
189 }
190
191 fn generate_recommendations(findings: &[SecurityFinding]) -> Vec<String> {
193 let mut recommendations = Vec::new();
194
195 if findings.iter().any(|f| f.category == SecurityCategory::SecretsExposure && !f.file_path.as_ref().map(|p| p.to_string_lossy().contains(".gitignore")).unwrap_or(false)) {
197 recommendations.push("š Implement comprehensive secret management:".to_string());
198 recommendations.push(" ⢠Add sensitive files to .gitignore immediately".to_string());
199 recommendations.push(" ⢠Use environment variables for all secrets".to_string());
200 recommendations.push(" ⢠Consider using a secure vault service (e.g., HashiCorp Vault)".to_string());
201 }
202
203 let critical_count = findings.iter().filter(|f| f.severity == SecuritySeverity::Critical).count();
205 if critical_count > 0 {
206 recommendations.push(format!("šØ Address {} CRITICAL security issues immediately", critical_count));
207 recommendations.push(" ⢠Review and rotate any exposed credentials".to_string());
208 recommendations.push(" ⢠Check git history for committed secrets".to_string());
209 }
210
211 if findings.iter().any(|f| f.description.contains("React") || f.description.contains("Next.js")) {
213 recommendations.push("āļø React/Next.js Security:".to_string());
214 recommendations.push(" ⢠Use NEXT_PUBLIC_ prefix only for truly public values".to_string());
215 recommendations.push(" ⢠Keep sensitive API keys server-side only".to_string());
216 }
217
218 if findings.iter().any(|f| f.title.contains("Database") || f.title.contains("SQL")) {
220 recommendations.push("šļø Database Security:".to_string());
221 recommendations.push(" ⢠Use connection pooling with encrypted credentials".to_string());
222 recommendations.push(" ⢠Implement least-privilege database access".to_string());
223 recommendations.push(" ⢠Enable SSL/TLS for database connections".to_string());
224 }
225
226 recommendations.push("\nš General Security Best Practices:".to_string());
228 recommendations.push(" ⢠Enable automated security scanning in CI/CD".to_string());
229 recommendations.push(" ⢠Regularly update dependencies".to_string());
230 recommendations.push(" ⢠Implement security headers".to_string());
231 recommendations.push(" ⢠Use HTTPS everywhere".to_string());
232
233 recommendations
234 }
235}
236
237fn severity_to_number(severity: &SecuritySeverity) -> u8 {
239 match severity {
240 SecuritySeverity::Critical => 5,
241 SecuritySeverity::High => 4,
242 SecuritySeverity::Medium => 3,
243 SecuritySeverity::Low => 2,
244 SecuritySeverity::Info => 1,
245 }
246}
247
248impl SecurityReport {
249 pub fn empty() -> Self {
251 ResultAggregator::empty()
252 }
253
254 pub fn summary(&self) -> String {
256 format!(
257 "Security Score: {:.0}/100 | Risk: {:?} | Findings: {} | Duration: {:.1}s",
258 self.overall_score,
259 self.risk_level,
260 self.total_findings,
261 self.scan_duration.as_secs_f64()
262 )
263 }
264
265 pub fn has_critical_issues(&self) -> bool {
267 self.findings_by_severity.get(&SecuritySeverity::Critical)
268 .map(|&count| count > 0)
269 .unwrap_or(false)
270 }
271
272 pub fn findings_by_severity_level(&self, severity: SecuritySeverity) -> Vec<&SecurityFinding> {
274 self.findings.iter()
275 .filter(|f| f.severity == severity)
276 .collect()
277 }
278
279 pub fn to_json(&self) -> Result<String, SecurityError> {
281 serde_json::to_string_pretty(&self)
282 .map_err(|e| SecurityError::Cache(format!("Failed to serialize report: {}", e)))
283 }
284
285 pub fn to_sarif(&self) -> Result<String, SecurityError> {
287 Err(SecurityError::Cache("SARIF export not yet implemented".to_string()))
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use std::path::PathBuf;
296
297 #[test]
298 fn test_result_aggregation() {
299 let findings = vec![
300 SecurityFinding {
301 id: "test-1".to_string(),
302 title: "Critical Finding".to_string(),
303 description: "Test critical".to_string(),
304 severity: SecuritySeverity::Critical,
305 category: SecurityCategory::SecretsExposure,
306 file_path: Some(PathBuf::from("test.js")),
307 line_number: Some(10),
308 column_number: Some(5),
309 evidence: None,
310 remediation: vec![],
311 references: vec![],
312 cwe_id: None,
313 compliance_frameworks: vec![],
314 },
315 SecurityFinding {
316 id: "test-2".to_string(),
317 title: "Medium Finding".to_string(),
318 description: "Test medium".to_string(),
319 severity: SecuritySeverity::Medium,
320 category: SecurityCategory::InsecureConfiguration,
321 file_path: Some(PathBuf::from("config.json")),
322 line_number: Some(20),
323 column_number: Some(1),
324 evidence: None,
325 remediation: vec![],
326 references: vec![],
327 cwe_id: None,
328 compliance_frameworks: vec![],
329 },
330 ];
331
332 let report = ResultAggregator::aggregate(findings, Duration::from_secs(5));
333
334 assert_eq!(report.total_findings, 2);
335 assert_eq!(report.risk_level, SecuritySeverity::Critical);
336 assert!(report.overall_score < 100.0);
337 assert!(!report.recommendations.is_empty());
338 }
339
340 #[test]
341 fn test_deduplication() {
342 let findings = vec![
343 SecurityFinding {
344 id: "dup-1".to_string(),
345 title: "Duplicate Finding".to_string(),
346 description: "Test".to_string(),
347 severity: SecuritySeverity::High,
348 category: SecurityCategory::SecretsExposure,
349 file_path: Some(PathBuf::from("test.js")),
350 line_number: Some(10),
351 column_number: Some(5),
352 evidence: None,
353 remediation: vec![],
354 references: vec![],
355 cwe_id: None,
356 compliance_frameworks: vec![],
357 },
358 SecurityFinding {
359 id: "dup-1".to_string(),
360 title: "Duplicate Finding".to_string(),
361 description: "Test".to_string(),
362 severity: SecuritySeverity::Medium, category: SecurityCategory::SecretsExposure,
364 file_path: Some(PathBuf::from("test.js")),
365 line_number: Some(10),
366 column_number: Some(5),
367 evidence: None,
368 remediation: vec![],
369 references: vec![],
370 cwe_id: None,
371 compliance_frameworks: vec![],
372 },
373 ];
374
375 let deduplicated = ResultAggregator::deduplicate_findings(findings);
376 assert_eq!(deduplicated.len(), 1);
377 assert_eq!(deduplicated[0].severity, SecuritySeverity::High); }
379
380 #[test]
381 fn test_security_score_calculation() {
382 let findings = vec![
383 SecurityFinding {
384 id: "test".to_string(),
385 title: "Test".to_string(),
386 description: "Test".to_string(),
387 severity: SecuritySeverity::Critical,
388 category: SecurityCategory::SecretsExposure,
389 file_path: None,
390 line_number: None,
391 column_number: None,
392 evidence: None,
393 remediation: vec![],
394 references: vec![],
395 cwe_id: None,
396 compliance_frameworks: vec![],
397 },
398 ];
399
400 let score = ResultAggregator::calculate_security_score(&findings);
401 assert_eq!(score, 75.0); }
403}