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