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::{Deserialize, Serialize};
11
12use super::SecurityError;
13use crate::analyzer::security::{SecurityCategory, SecurityFinding, SecuritySeverity};
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(
48        mut findings: Vec<SecurityFinding>,
49        scan_duration: Duration,
50        files_scanned: usize,
51    ) -> SecurityReport {
52        // Deduplicate findings
53        findings = Self::deduplicate_findings(findings);
54
55        // Sort by severity (critical first)
56        findings.sort_by_key(|f| std::cmp::Reverse(severity_to_number(&f.severity)));
57
58        // Calculate metrics
59        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        // Generate recommendations
66        let recommendations = Self::generate_recommendations(&findings);
67
68        // Create performance metrics (placeholder values for now)
69        let performance_metrics = PerformanceMetrics {
70            total_duration: scan_duration,
71            file_discovery_time: Duration::from_millis(0), // TODO: Track actual time
72            pattern_matching_time: Duration::from_millis(0), // TODO: Track actual time
73            files_per_second: 0.0,                         // TODO: Calculate actual rate
74            cache_hit_rate: 0.0,                           // TODO: Get from cache stats
75            memory_usage_mb: 0.0,                          // TODO: Track memory usage
76        };
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    /// Create an empty report
94    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    /// Deduplicate findings based on content similarity
118    fn deduplicate_findings(findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
119        let mut seen: AHashMap<String, SecurityFinding> = AHashMap::new();
120
121        for finding in findings {
122            // Create a deduplication key
123            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            // Keep the finding with the highest severity
136            match seen.get(&key) {
137                Some(existing)
138                    if severity_to_number(&existing.severity)
139                        >= severity_to_number(&finding.severity) =>
140                {
141                    // Keep existing
142                }
143                _ => {
144                    seen.insert(key, finding);
145                }
146            }
147        }
148
149        seen.into_values().collect()
150    }
151
152    /// Count findings by severity
153    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    /// Count findings by category
162    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    /// Calculate overall security score (0-100)
171    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    /// Determine overall risk level
191    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    /// Generate recommendations based on findings
215    fn generate_recommendations(findings: &[SecurityFinding]) -> Vec<String> {
216        let mut recommendations = Vec::new();
217
218        // Check for unprotected secrets
219        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        // Check for critical findings
236        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        // Framework-specific recommendations
250        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        // Database security
261        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        // General best practices
273        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
283/// Convert severity to numeric value for sorting
284fn 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    /// Create an empty report
296    pub fn empty() -> Self {
297        ResultAggregator::empty()
298    }
299
300    /// Get a summary of the report
301    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    /// Check if the scan found any critical issues
312    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    /// Get findings filtered by severity
320    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    /// Export report as JSON
328    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    /// Export report as SARIF (Static Analysis Results Interchange Format)
334    pub fn to_sarif(&self) -> Result<String, SecurityError> {
335        // TODO: Implement SARIF export for GitHub integration
336        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, // Lower severity
413                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); // Should keep higher severity
428    }
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); // 100 - 25 (critical penalty)
450    }
451}