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