syncable_cli/analyzer/security/turbo/
results.rs

1//! # Results Module
2//! 
3//! Aggregation and processing of security scan results.
4
5use 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/// Security analysis report with comprehensive metrics
16#[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/// Performance metrics for the scan
32#[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
42/// Result aggregator for combining and processing findings
43pub struct ResultAggregator;
44
45impl ResultAggregator {
46    /// Aggregate findings into a comprehensive report
47    pub fn aggregate(mut findings: Vec<SecurityFinding>, scan_duration: Duration, files_scanned: usize) -> SecurityReport {
48        // Deduplicate findings
49        findings = Self::deduplicate_findings(findings);
50        
51        // Sort by severity (critical first)
52        findings.sort_by_key(|f| std::cmp::Reverse(severity_to_number(&f.severity)));
53        
54        // Calculate metrics
55        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        // Generate recommendations
62        let recommendations = Self::generate_recommendations(&findings);
63        
64        // Create performance metrics (placeholder values for now)
65        let performance_metrics = PerformanceMetrics {
66            total_duration: scan_duration,
67            file_discovery_time: Duration::from_millis(0), // TODO: Track actual time
68            pattern_matching_time: Duration::from_millis(0), // TODO: Track actual time
69            files_per_second: 0.0, // TODO: Calculate actual rate
70            cache_hit_rate: 0.0, // TODO: Get from cache stats
71            memory_usage_mb: 0.0, // TODO: Track memory usage
72        };
73        
74        SecurityReport {
75            analyzed_at: Utc::now(),
76            scan_duration,
77            overall_score,
78            risk_level,
79            total_findings,
80            files_scanned,
81            findings_by_severity,
82            findings_by_category,
83            findings,
84            recommendations,
85            performance_metrics,
86        }
87    }
88    
89    /// Create an empty report
90    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    /// Deduplicate findings based on content similarity
114    fn deduplicate_findings(findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
115        let mut seen: AHashMap<String, SecurityFinding> = AHashMap::new();
116        
117        for finding in findings {
118            // Create a deduplication key
119            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            // Keep the finding with the highest severity
128            match seen.get(&key) {
129                Some(existing) if severity_to_number(&existing.severity) >= severity_to_number(&finding.severity) => {
130                    // Keep existing
131                }
132                _ => {
133                    seen.insert(key, finding);
134                }
135            }
136        }
137        
138        seen.into_values().collect()
139    }
140    
141    /// Count findings by severity
142    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    /// Count findings by category
151    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    /// Calculate overall security score (0-100)
160    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    /// Determine overall risk level
177    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    /// Generate recommendations based on findings
192    fn generate_recommendations(findings: &[SecurityFinding]) -> Vec<String> {
193        let mut recommendations = Vec::new();
194        
195        // Check for unprotected secrets
196        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        // Check for critical findings
204        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        // Framework-specific recommendations
212        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        // Database security
219        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        // General best practices
227        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
237/// Convert severity to numeric value for sorting
238fn 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    /// Create an empty report
250    pub fn empty() -> Self {
251        ResultAggregator::empty()
252    }
253    
254    /// Get a summary of the report
255    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    /// Check if the scan found any critical issues
266    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    /// Get findings filtered by severity
273    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    /// Export report as JSON
280    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    /// Export report as SARIF (Static Analysis Results Interchange Format)
286    pub fn to_sarif(&self) -> Result<String, SecurityError> {
287        // TODO: Implement SARIF export for GitHub integration
288        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), 10);
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, // Lower severity
363                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); // Should keep higher severity
378    }
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); // 100 - 25 (critical penalty)
402    }
403}