Skip to main content

allsource_core/security/
automation.rs

1/// Security Automation and CI/CD Integration
2///
3/// Automated security scanning and monitoring for:
4/// - Dependency vulnerabilities (cargo audit)
5/// - Code security analysis (static analysis)
6/// - Secret detection
7/// - License compliance
8/// - Security policy enforcement
9use crate::error::Result;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::{collections::HashMap, process::Command};
13
14/// Security scan configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SecurityScanConfig {
17    /// Enable automatic security scanning
18    pub enabled: bool,
19
20    /// Scan frequency in hours
21    pub scan_frequency_hours: u32,
22
23    /// Enable dependency scanning
24    pub enable_dependency_scan: bool,
25
26    /// Enable secrets scanning
27    pub enable_secrets_scan: bool,
28
29    /// Enable SAST (Static Application Security Testing)
30    pub enable_sast: bool,
31
32    /// Enable license compliance checking
33    pub enable_license_check: bool,
34
35    /// Fail build on high severity issues
36    pub fail_on_high_severity: bool,
37
38    /// Fail build on medium severity issues
39    pub fail_on_medium_severity: bool,
40}
41
42impl Default for SecurityScanConfig {
43    fn default() -> Self {
44        Self {
45            enabled: true,
46            scan_frequency_hours: 24,
47            enable_dependency_scan: true,
48            enable_secrets_scan: true,
49            enable_sast: true,
50            enable_license_check: true,
51            fail_on_high_severity: true,
52            fail_on_medium_severity: false,
53        }
54    }
55}
56
57/// Security scan result
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SecurityScanResult {
60    /// Scan timestamp
61    pub timestamp: DateTime<Utc>,
62
63    /// Overall status
64    pub status: ScanStatus,
65
66    /// Findings by category
67    pub findings: HashMap<String, Vec<SecurityFinding>>,
68
69    /// Summary statistics
70    pub summary: ScanSummary,
71
72    /// Recommendations
73    pub recommendations: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub enum ScanStatus {
78    Pass,
79    Warning,
80    Fail,
81    Error,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SecurityFinding {
86    /// Finding ID
87    pub id: String,
88
89    /// Title
90    pub title: String,
91
92    /// Description
93    pub description: String,
94
95    /// Severity
96    pub severity: Severity,
97
98    /// Category
99    pub category: FindingCategory,
100
101    /// Affected component
102    pub component: String,
103
104    /// Fix recommendation
105    pub fix: Option<String>,
106
107    /// CVE ID (if applicable)
108    pub cve: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
112pub enum Severity {
113    Critical,
114    High,
115    Medium,
116    Low,
117    Info,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub enum FindingCategory {
122    Dependency,
123    SecretLeak,
124    CodeVulnerability,
125    LicenseIssue,
126    ConfigurationIssue,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ScanSummary {
131    pub total_findings: usize,
132    pub critical: usize,
133    pub high: usize,
134    pub medium: usize,
135    pub low: usize,
136    pub info: usize,
137}
138
139/// Security scanner
140pub struct SecurityScanner {
141    config: SecurityScanConfig,
142    last_scan: Option<DateTime<Utc>>,
143    last_result: Option<SecurityScanResult>,
144}
145
146impl SecurityScanner {
147    /// Create new security scanner
148    pub fn new(config: SecurityScanConfig) -> Self {
149        Self {
150            config,
151            last_scan: None,
152            last_result: None,
153        }
154    }
155
156    /// Run full security scan
157    pub fn run_full_scan(&mut self) -> Result<SecurityScanResult> {
158        let mut all_findings: HashMap<String, Vec<SecurityFinding>> = HashMap::new();
159        let mut recommendations = Vec::new();
160
161        // Dependency scanning
162        if self.config.enable_dependency_scan {
163            match self.scan_dependencies() {
164                Ok(findings) => {
165                    if !findings.is_empty() {
166                        all_findings.insert("dependencies".to_string(), findings);
167                        recommendations.push(
168                            "Run 'cargo update' to update vulnerable dependencies".to_string(),
169                        );
170                    }
171                }
172                Err(e) => {
173                    eprintln!("Dependency scan failed: {}", e);
174                }
175            }
176        }
177
178        // Secrets scanning
179        if self.config.enable_secrets_scan {
180            match self.scan_secrets() {
181                Ok(findings) => {
182                    if !findings.is_empty() {
183                        all_findings.insert("secrets".to_string(), findings);
184                        recommendations.push(
185                            "Remove hardcoded secrets and use environment variables".to_string(),
186                        );
187                    }
188                }
189                Err(e) => {
190                    eprintln!("Secrets scan failed: {}", e);
191                }
192            }
193        }
194
195        // SAST
196        if self.config.enable_sast {
197            match self.run_static_analysis() {
198                Ok(findings) => {
199                    if !findings.is_empty() {
200                        all_findings.insert("code_analysis".to_string(), findings);
201                        recommendations.push("Review and fix code quality issues".to_string());
202                    }
203                }
204                Err(e) => {
205                    eprintln!("SAST failed: {}", e);
206                }
207            }
208        }
209
210        // License checking
211        if self.config.enable_license_check {
212            match self.check_licenses() {
213                Ok(findings) => {
214                    if !findings.is_empty() {
215                        all_findings.insert("licenses".to_string(), findings);
216                        recommendations
217                            .push("Review dependency licenses for compliance".to_string());
218                    }
219                }
220                Err(e) => {
221                    eprintln!("License check failed: {}", e);
222                }
223            }
224        }
225
226        // Calculate summary
227        let summary = self.calculate_summary(&all_findings);
228
229        // Determine overall status
230        let status = self.determine_status(&summary);
231
232        let result = SecurityScanResult {
233            timestamp: Utc::now(),
234            status,
235            findings: all_findings,
236            summary,
237            recommendations,
238        };
239
240        self.last_scan = Some(Utc::now());
241        self.last_result = Some(result.clone());
242
243        Ok(result)
244    }
245
246    /// Scan dependencies for known vulnerabilities
247    fn scan_dependencies(&self) -> Result<Vec<SecurityFinding>> {
248        let mut findings = Vec::new();
249
250        // Run cargo audit
251        let output = Command::new("cargo").args(["audit", "--json"]).output();
252
253        match output {
254            Ok(output) if output.status.success() => {
255                // Parse cargo audit output
256                if let Ok(output_str) = String::from_utf8(output.stdout) {
257                    // Simple parsing (in production, use proper JSON parsing)
258                    if output_str.contains("Crate:") || output_str.contains("ID:") {
259                        findings.push(SecurityFinding {
260                            id: "DEP-001".to_string(),
261                            title: "Vulnerable dependency detected".to_string(),
262                            description: "cargo audit found vulnerabilities".to_string(),
263                            severity: Severity::High,
264                            category: FindingCategory::Dependency,
265                            component: "dependencies".to_string(),
266                            fix: Some("Run 'cargo update' and review audit output".to_string()),
267                            cve: None,
268                        });
269                    }
270                }
271            }
272            Ok(_) => {
273                // cargo audit found issues (non-zero exit)
274                findings.push(SecurityFinding {
275                    id: "DEP-002".to_string(),
276                    title: "Dependency vulnerabilities found".to_string(),
277                    description: "cargo audit reported vulnerabilities".to_string(),
278                    severity: Severity::High,
279                    category: FindingCategory::Dependency,
280                    component: "Cargo dependencies".to_string(),
281                    fix: Some("Review 'cargo audit' output and update dependencies".to_string()),
282                    cve: None,
283                });
284            }
285            Err(_) => {
286                // cargo audit not installed or failed
287                eprintln!("cargo audit not available - install with: cargo install cargo-audit");
288            }
289        }
290
291        Ok(findings)
292    }
293
294    /// Scan for hardcoded secrets
295    fn scan_secrets(&self) -> Result<Vec<SecurityFinding>> {
296        let mut findings = Vec::new();
297
298        // Common secret patterns
299        let secret_patterns = vec![
300            (
301                r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[a-zA-Z0-9]{20,}",
302                "API Key",
303            ),
304            (
305                r"(?i)(password|passwd|pwd)\s*[:=]\s*[\w@#$%^&*]{8,}",
306                "Password",
307            ),
308            (
309                r"(?i)(secret[_-]?key)\s*[:=]\s*[a-zA-Z0-9]{20,}",
310                "Secret Key",
311            ),
312            (
313                r"(?i)(aws[_-]?access[_-]?key[_-]?id)\s*[:=]\s*[A-Z0-9]{20}",
314                "AWS Access Key",
315            ),
316            (r"(?i)(private[_-]?key)\s*[:=]", "Private Key"),
317        ];
318
319        // Check common files (in production, scan all source files)
320        let files_to_check = vec![".env", ".env.example", "config.toml", "Cargo.toml"];
321
322        for file in files_to_check {
323            if let Ok(content) = std::fs::read_to_string(file) {
324                for (pattern, secret_type) in &secret_patterns {
325                    if content.contains("password")
326                        || content.contains("secret")
327                        || content.contains("key")
328                    {
329                        findings.push(SecurityFinding {
330                            id: format!("SEC-{:03}", findings.len() + 1),
331                            title: format!("Potential {secret_type} found"),
332                            description: format!("Potential hardcoded {secret_type} detected in {file}"),
333                            severity: Severity::High,
334                            category: FindingCategory::SecretLeak,
335                            component: file.to_string(),
336                            fix: Some("Remove hardcoded secrets, use environment variables or secret management".to_string()),
337                            cve: None,
338                        });
339                    }
340                }
341            }
342        }
343
344        Ok(findings)
345    }
346
347    /// Run static application security testing
348    fn run_static_analysis(&self) -> Result<Vec<SecurityFinding>> {
349        let mut findings = Vec::new();
350
351        // Run clippy with security lints
352        let output = Command::new("cargo")
353            .args(["clippy", "--", "-W", "clippy::all"])
354            .output();
355
356        match output {
357            Ok(output) if !output.status.success() => {
358                let stderr = String::from_utf8_lossy(&output.stderr);
359                if stderr.contains("warning:") || stderr.contains("error:") {
360                    findings.push(SecurityFinding {
361                        id: "SAST-001".to_string(),
362                        title: "Code quality issues found".to_string(),
363                        description: "Clippy found potential code issues".to_string(),
364                        severity: Severity::Medium,
365                        category: FindingCategory::CodeVulnerability,
366                        component: "source code".to_string(),
367                        fix: Some("Run 'cargo clippy' and address warnings".to_string()),
368                        cve: None,
369                    });
370                }
371            }
372            _ => {}
373        }
374
375        Ok(findings)
376    }
377
378    /// Check dependency licenses
379    fn check_licenses(&self) -> Result<Vec<SecurityFinding>> {
380        let findings = Vec::new();
381
382        // Restricted licenses (example list)
383        let restricted_licenses = ["GPL-3.0", "AGPL-3.0", "SSPL"];
384
385        // In production, use cargo-license or similar tool
386        // For now, this is a placeholder
387
388        Ok(findings)
389    }
390
391    fn calculate_summary(&self, findings: &HashMap<String, Vec<SecurityFinding>>) -> ScanSummary {
392        let mut summary = ScanSummary {
393            total_findings: 0,
394            critical: 0,
395            high: 0,
396            medium: 0,
397            low: 0,
398            info: 0,
399        };
400
401        for findings_vec in findings.values() {
402            for finding in findings_vec {
403                summary.total_findings += 1;
404                match finding.severity {
405                    Severity::Critical => summary.critical += 1,
406                    Severity::High => summary.high += 1,
407                    Severity::Medium => summary.medium += 1,
408                    Severity::Low => summary.low += 1,
409                    Severity::Info => summary.info += 1,
410                }
411            }
412        }
413
414        summary
415    }
416
417    fn determine_status(&self, summary: &ScanSummary) -> ScanStatus {
418        if summary.critical > 0 {
419            return ScanStatus::Fail;
420        }
421
422        if self.config.fail_on_high_severity && summary.high > 0 {
423            return ScanStatus::Fail;
424        }
425
426        if self.config.fail_on_medium_severity && summary.medium > 0 {
427            return ScanStatus::Fail;
428        }
429
430        if summary.high > 0 || summary.medium > 0 {
431            return ScanStatus::Warning;
432        }
433
434        ScanStatus::Pass
435    }
436
437    /// Get last scan result
438    pub fn get_last_result(&self) -> Option<&SecurityScanResult> {
439        self.last_result.as_ref()
440    }
441
442    /// Check if scan is needed
443    pub fn should_scan(&self) -> bool {
444        if !self.config.enabled {
445            return false;
446        }
447
448        match self.last_scan {
449            None => true,
450            Some(last) => {
451                let elapsed = Utc::now() - last;
452                elapsed.num_hours() >= self.config.scan_frequency_hours as i64
453            }
454        }
455    }
456}
457
458/// CI/CD integration helper
459pub struct CiCdIntegration;
460
461impl CiCdIntegration {
462    /// Generate GitHub Actions workflow
463    pub fn generate_github_actions_workflow() -> String {
464        r#"name: Security Scan
465
466on:
467  push:
468    branches: [ main, develop ]
469  pull_request:
470    branches: [ main ]
471  schedule:
472    - cron: '0 0 * * *'  # Daily
473
474jobs:
475  security-scan:
476    runs-on: ubuntu-latest
477    steps:
478      - uses: actions/checkout@v3
479
480      - name: Install Rust
481        uses: actions-rs/toolchain@v1
482        with:
483          toolchain: stable
484          components: clippy
485
486      - name: Install cargo-audit
487        run: cargo install cargo-audit
488
489      - name: Dependency Audit
490        run: cargo audit
491
492      - name: Security Clippy
493        run: cargo clippy -- -D warnings
494
495      - name: Run Tests
496        run: cargo test --lib security
497
498      - name: Secret Scanning
499        uses: trufflesecurity/trufflehog@main
500        with:
501          path: ./
502          base: main
503          head: HEAD
504"#
505        .to_string()
506    }
507
508    /// Generate GitLab CI configuration
509    pub fn generate_gitlab_ci_config() -> String {
510        r#"security-scan:
511  stage: test
512  image: rust:latest
513  script:
514    - cargo install cargo-audit
515    - cargo audit
516    - cargo clippy -- -D warnings
517    - cargo test --lib security
518  allow_failure: false
519"#
520        .to_string()
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_scanner_creation() {
530        let scanner = SecurityScanner::new(SecurityScanConfig::default());
531        assert!(scanner.last_result.is_none());
532        assert!(scanner.should_scan());
533    }
534
535    #[test]
536    fn test_scan_summary_calculation() {
537        let scanner = SecurityScanner::new(SecurityScanConfig::default());
538        let mut findings = HashMap::new();
539
540        findings.insert(
541            "test".to_string(),
542            vec![
543                SecurityFinding {
544                    id: "1".to_string(),
545                    title: "Test".to_string(),
546                    description: "Test".to_string(),
547                    severity: Severity::Critical,
548                    category: FindingCategory::Dependency,
549                    component: "test".to_string(),
550                    fix: None,
551                    cve: None,
552                },
553                SecurityFinding {
554                    id: "2".to_string(),
555                    title: "Test2".to_string(),
556                    description: "Test2".to_string(),
557                    severity: Severity::High,
558                    category: FindingCategory::Dependency,
559                    component: "test".to_string(),
560                    fix: None,
561                    cve: None,
562                },
563            ],
564        );
565
566        let summary = scanner.calculate_summary(&findings);
567        assert_eq!(summary.total_findings, 2);
568        assert_eq!(summary.critical, 1);
569        assert_eq!(summary.high, 1);
570    }
571
572    #[test]
573    fn test_status_determination() {
574        let scanner = SecurityScanner::new(SecurityScanConfig::default());
575
576        let summary_critical = ScanSummary {
577            total_findings: 1,
578            critical: 1,
579            high: 0,
580            medium: 0,
581            low: 0,
582            info: 0,
583        };
584        assert_eq!(
585            scanner.determine_status(&summary_critical),
586            ScanStatus::Fail
587        );
588
589        let summary_clean = ScanSummary {
590            total_findings: 0,
591            critical: 0,
592            high: 0,
593            medium: 0,
594            low: 0,
595            info: 0,
596        };
597        assert_eq!(scanner.determine_status(&summary_clean), ScanStatus::Pass);
598    }
599
600    #[test]
601    fn test_github_actions_workflow_generation() {
602        let workflow = CiCdIntegration::generate_github_actions_workflow();
603        assert!(workflow.contains("cargo audit"));
604        assert!(workflow.contains("cargo clippy"));
605        assert!(workflow.contains("Security Scan"));
606    }
607
608    #[test]
609    fn test_default_security_scan_config() {
610        let config = SecurityScanConfig::default();
611        assert!(config.enabled);
612        assert_eq!(config.scan_frequency_hours, 24);
613        assert!(config.enable_dependency_scan);
614        assert!(config.enable_secrets_scan);
615        assert!(config.enable_sast);
616        assert!(config.enable_license_check);
617        assert!(config.fail_on_high_severity);
618        assert!(!config.fail_on_medium_severity);
619    }
620
621    #[test]
622    fn test_security_scan_config_serde() {
623        let config = SecurityScanConfig::default();
624        let json = serde_json::to_string(&config).unwrap();
625        let parsed: SecurityScanConfig = serde_json::from_str(&json).unwrap();
626        assert_eq!(parsed.enabled, config.enabled);
627        assert_eq!(parsed.scan_frequency_hours, config.scan_frequency_hours);
628    }
629
630    #[test]
631    fn test_scan_status_equality() {
632        assert_eq!(ScanStatus::Pass, ScanStatus::Pass);
633        assert_eq!(ScanStatus::Fail, ScanStatus::Fail);
634        assert_eq!(ScanStatus::Warning, ScanStatus::Warning);
635        assert_eq!(ScanStatus::Error, ScanStatus::Error);
636        assert_ne!(ScanStatus::Pass, ScanStatus::Fail);
637    }
638
639    #[test]
640    fn test_severity_ordering() {
641        assert!(Severity::Critical < Severity::High);
642        assert!(Severity::High < Severity::Medium);
643        assert!(Severity::Medium < Severity::Low);
644        assert!(Severity::Low < Severity::Info);
645    }
646
647    #[test]
648    fn test_scan_summary_all_severities() {
649        let scanner = SecurityScanner::new(SecurityScanConfig::default());
650        let mut findings = HashMap::new();
651
652        findings.insert(
653            "test".to_string(),
654            vec![
655                SecurityFinding {
656                    id: "1".to_string(),
657                    title: "Critical".to_string(),
658                    description: "Critical finding".to_string(),
659                    severity: Severity::Critical,
660                    category: FindingCategory::Dependency,
661                    component: "test".to_string(),
662                    fix: None,
663                    cve: None,
664                },
665                SecurityFinding {
666                    id: "2".to_string(),
667                    title: "High".to_string(),
668                    description: "High finding".to_string(),
669                    severity: Severity::High,
670                    category: FindingCategory::SecretLeak,
671                    component: "test".to_string(),
672                    fix: Some("Fix it".to_string()),
673                    cve: Some("CVE-2021-1234".to_string()),
674                },
675                SecurityFinding {
676                    id: "3".to_string(),
677                    title: "Medium".to_string(),
678                    description: "Medium finding".to_string(),
679                    severity: Severity::Medium,
680                    category: FindingCategory::CodeVulnerability,
681                    component: "test".to_string(),
682                    fix: None,
683                    cve: None,
684                },
685                SecurityFinding {
686                    id: "4".to_string(),
687                    title: "Low".to_string(),
688                    description: "Low finding".to_string(),
689                    severity: Severity::Low,
690                    category: FindingCategory::LicenseIssue,
691                    component: "test".to_string(),
692                    fix: None,
693                    cve: None,
694                },
695                SecurityFinding {
696                    id: "5".to_string(),
697                    title: "Info".to_string(),
698                    description: "Info finding".to_string(),
699                    severity: Severity::Info,
700                    category: FindingCategory::ConfigurationIssue,
701                    component: "test".to_string(),
702                    fix: None,
703                    cve: None,
704                },
705            ],
706        );
707
708        let summary = scanner.calculate_summary(&findings);
709        assert_eq!(summary.total_findings, 5);
710        assert_eq!(summary.critical, 1);
711        assert_eq!(summary.high, 1);
712        assert_eq!(summary.medium, 1);
713        assert_eq!(summary.low, 1);
714        assert_eq!(summary.info, 1);
715    }
716
717    #[test]
718    fn test_status_high_severity_fail() {
719        let scanner = SecurityScanner::new(SecurityScanConfig {
720            fail_on_high_severity: true,
721            ..Default::default()
722        });
723
724        let summary = ScanSummary {
725            total_findings: 1,
726            critical: 0,
727            high: 1,
728            medium: 0,
729            low: 0,
730            info: 0,
731        };
732        assert_eq!(scanner.determine_status(&summary), ScanStatus::Fail);
733    }
734
735    #[test]
736    fn test_status_high_severity_warning() {
737        let scanner = SecurityScanner::new(SecurityScanConfig {
738            fail_on_high_severity: false,
739            ..Default::default()
740        });
741
742        let summary = ScanSummary {
743            total_findings: 1,
744            critical: 0,
745            high: 1,
746            medium: 0,
747            low: 0,
748            info: 0,
749        };
750        assert_eq!(scanner.determine_status(&summary), ScanStatus::Warning);
751    }
752
753    #[test]
754    fn test_status_medium_severity_fail() {
755        let scanner = SecurityScanner::new(SecurityScanConfig {
756            fail_on_high_severity: false,
757            fail_on_medium_severity: true,
758            ..Default::default()
759        });
760
761        let summary = ScanSummary {
762            total_findings: 1,
763            critical: 0,
764            high: 0,
765            medium: 1,
766            low: 0,
767            info: 0,
768        };
769        assert_eq!(scanner.determine_status(&summary), ScanStatus::Fail);
770    }
771
772    #[test]
773    fn test_status_medium_severity_warning() {
774        let scanner = SecurityScanner::new(SecurityScanConfig {
775            fail_on_high_severity: false,
776            fail_on_medium_severity: false,
777            ..Default::default()
778        });
779
780        let summary = ScanSummary {
781            total_findings: 1,
782            critical: 0,
783            high: 0,
784            medium: 1,
785            low: 0,
786            info: 0,
787        };
788        assert_eq!(scanner.determine_status(&summary), ScanStatus::Warning);
789    }
790
791    #[test]
792    fn test_should_scan_disabled() {
793        let scanner = SecurityScanner::new(SecurityScanConfig {
794            enabled: false,
795            ..Default::default()
796        });
797        assert!(!scanner.should_scan());
798    }
799
800    #[test]
801    fn test_get_last_result_none() {
802        let scanner = SecurityScanner::new(SecurityScanConfig::default());
803        assert!(scanner.get_last_result().is_none());
804    }
805
806    #[test]
807    fn test_gitlab_ci_config_generation() {
808        let config = CiCdIntegration::generate_gitlab_ci_config();
809        assert!(config.contains("cargo audit"));
810        assert!(config.contains("cargo clippy"));
811        assert!(config.contains("security-scan"));
812    }
813
814    #[test]
815    fn test_finding_category_variants() {
816        let categories = vec![
817            FindingCategory::Dependency,
818            FindingCategory::SecretLeak,
819            FindingCategory::CodeVulnerability,
820            FindingCategory::LicenseIssue,
821            FindingCategory::ConfigurationIssue,
822        ];
823
824        for category in categories {
825            // Ensure each variant can be serialized
826            let json = serde_json::to_string(&category).unwrap();
827            assert!(!json.is_empty());
828        }
829    }
830
831    #[test]
832    fn test_security_scan_result_serde() {
833        let result = SecurityScanResult {
834            timestamp: Utc::now(),
835            status: ScanStatus::Pass,
836            findings: HashMap::new(),
837            summary: ScanSummary {
838                total_findings: 0,
839                critical: 0,
840                high: 0,
841                medium: 0,
842                low: 0,
843                info: 0,
844            },
845            recommendations: vec!["Test recommendation".to_string()],
846        };
847
848        let json = serde_json::to_string(&result).unwrap();
849        let parsed: SecurityScanResult = serde_json::from_str(&json).unwrap();
850        assert_eq!(parsed.status, result.status);
851        assert_eq!(parsed.summary.total_findings, 0);
852    }
853
854    #[test]
855    fn test_security_finding_serde() {
856        let finding = SecurityFinding {
857            id: "TEST-001".to_string(),
858            title: "Test Finding".to_string(),
859            description: "A test finding".to_string(),
860            severity: Severity::High,
861            category: FindingCategory::Dependency,
862            component: "test-component".to_string(),
863            fix: Some("Apply fix".to_string()),
864            cve: Some("CVE-2021-12345".to_string()),
865        };
866
867        let json = serde_json::to_string(&finding).unwrap();
868        let parsed: SecurityFinding = serde_json::from_str(&json).unwrap();
869        assert_eq!(parsed.id, finding.id);
870        assert_eq!(parsed.title, finding.title);
871        assert_eq!(parsed.cve, finding.cve);
872    }
873
874    #[test]
875    fn test_scan_status_serde() {
876        let statuses = vec![
877            ScanStatus::Pass,
878            ScanStatus::Warning,
879            ScanStatus::Fail,
880            ScanStatus::Error,
881        ];
882
883        for status in statuses {
884            let json = serde_json::to_string(&status).unwrap();
885            let parsed: ScanStatus = serde_json::from_str(&json).unwrap();
886            assert_eq!(parsed, status);
887        }
888    }
889
890    #[test]
891    fn test_empty_findings_summary() {
892        let scanner = SecurityScanner::new(SecurityScanConfig::default());
893        let findings: HashMap<String, Vec<SecurityFinding>> = HashMap::new();
894
895        let summary = scanner.calculate_summary(&findings);
896        assert_eq!(summary.total_findings, 0);
897        assert_eq!(summary.critical, 0);
898        assert_eq!(summary.high, 0);
899        assert_eq!(summary.medium, 0);
900        assert_eq!(summary.low, 0);
901        assert_eq!(summary.info, 0);
902    }
903
904    #[test]
905    fn test_multiple_categories_summary() {
906        let scanner = SecurityScanner::new(SecurityScanConfig::default());
907        let mut findings = HashMap::new();
908
909        findings.insert(
910            "dependencies".to_string(),
911            vec![SecurityFinding {
912                id: "DEP-001".to_string(),
913                title: "Dependency issue".to_string(),
914                description: "A dependency issue".to_string(),
915                severity: Severity::High,
916                category: FindingCategory::Dependency,
917                component: "deps".to_string(),
918                fix: None,
919                cve: None,
920            }],
921        );
922
923        findings.insert(
924            "secrets".to_string(),
925            vec![SecurityFinding {
926                id: "SEC-001".to_string(),
927                title: "Secret issue".to_string(),
928                description: "A secret issue".to_string(),
929                severity: Severity::Critical,
930                category: FindingCategory::SecretLeak,
931                component: "secrets".to_string(),
932                fix: None,
933                cve: None,
934            }],
935        );
936
937        let summary = scanner.calculate_summary(&findings);
938        assert_eq!(summary.total_findings, 2);
939        assert_eq!(summary.critical, 1);
940        assert_eq!(summary.high, 1);
941    }
942}