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    ///
380    /// Runs `cargo license --json` and flags dependencies with restricted licenses
381    /// (GPL-3.0, AGPL-3.0, SSPL). Falls back to `cargo metadata` parsing if
382    /// cargo-license is not installed.
383    fn check_licenses(&self) -> Result<Vec<SecurityFinding>> {
384        let restricted_licenses = ["GPL-3.0", "AGPL-3.0", "SSPL"];
385
386        // Try cargo-license first (produces clean JSON output)
387        let output = Command::new("cargo").args(["license", "--json"]).output();
388
389        match output {
390            Ok(output) if output.status.success() => {
391                Self::parse_license_findings(&output.stdout, &restricted_licenses)
392            }
393            _ => {
394                // Fallback: parse cargo metadata for license fields
395                let output = Command::new("cargo")
396                    .args(["metadata", "--format-version", "1", "--no-deps"])
397                    .output();
398
399                match output {
400                    Ok(output) if output.status.success() => {
401                        Self::parse_metadata_license_findings(&output.stdout, &restricted_licenses)
402                    }
403                    _ => {
404                        eprintln!(
405                            "License check unavailable - install with: cargo install cargo-license"
406                        );
407                        Ok(Vec::new())
408                    }
409                }
410            }
411        }
412    }
413
414    /// Parse `cargo license --json` output for restricted licenses
415    fn parse_license_findings(stdout: &[u8], restricted: &[&str]) -> Result<Vec<SecurityFinding>> {
416        let mut findings = Vec::new();
417
418        let output_str = String::from_utf8_lossy(stdout);
419        // cargo-license --json returns an array of objects with "name", "version", "license"
420        if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&output_str) {
421            for entry in entries {
422                let license = entry.get("license").and_then(|v| v.as_str()).unwrap_or("");
423                let name = entry
424                    .get("name")
425                    .and_then(|v| v.as_str())
426                    .unwrap_or("unknown");
427                let version = entry.get("version").and_then(|v| v.as_str()).unwrap_or("?");
428
429                for restricted_license in restricted {
430                    if license.contains(restricted_license) {
431                        findings.push(SecurityFinding {
432                            id: format!("LIC-{:03}", findings.len() + 1),
433                            title: format!("Restricted license: {license}"),
434                            description: format!(
435                                "Dependency {name}@{version} uses {license} which is restricted"
436                            ),
437                            severity: Severity::High,
438                            category: FindingCategory::LicenseIssue,
439                            component: format!("{name}@{version}"),
440                            fix: Some(format!(
441                                "Replace {name} with an alternative under a permissive license"
442                            )),
443                            cve: None,
444                        });
445                        break;
446                    }
447                }
448            }
449        }
450
451        Ok(findings)
452    }
453
454    /// Parse `cargo metadata` output as fallback for license scanning
455    fn parse_metadata_license_findings(
456        stdout: &[u8],
457        restricted: &[&str],
458    ) -> Result<Vec<SecurityFinding>> {
459        let mut findings = Vec::new();
460
461        let output_str = String::from_utf8_lossy(stdout);
462        if let Ok(metadata) = serde_json::from_str::<serde_json::Value>(&output_str)
463            && let Some(packages) = metadata.get("packages").and_then(|v| v.as_array())
464        {
465            for pkg in packages {
466                let license = pkg.get("license").and_then(|v| v.as_str()).unwrap_or("");
467                let name = pkg
468                    .get("name")
469                    .and_then(|v| v.as_str())
470                    .unwrap_or("unknown");
471                let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
472
473                for restricted_license in restricted {
474                    if license.contains(restricted_license) {
475                        findings.push(SecurityFinding {
476                            id: format!("LIC-{:03}", findings.len() + 1),
477                            title: format!("Restricted license: {license}"),
478                            description: format!(
479                                "Dependency {name}@{version} uses {license} which is restricted"
480                            ),
481                            severity: Severity::High,
482                            category: FindingCategory::LicenseIssue,
483                            component: format!("{name}@{version}"),
484                            fix: Some(format!(
485                                "Replace {name} with an alternative under a permissive license"
486                            )),
487                            cve: None,
488                        });
489                        break;
490                    }
491                }
492            }
493        }
494
495        Ok(findings)
496    }
497
498    fn calculate_summary(&self, findings: &HashMap<String, Vec<SecurityFinding>>) -> ScanSummary {
499        let mut summary = ScanSummary {
500            total_findings: 0,
501            critical: 0,
502            high: 0,
503            medium: 0,
504            low: 0,
505            info: 0,
506        };
507
508        for findings_vec in findings.values() {
509            for finding in findings_vec {
510                summary.total_findings += 1;
511                match finding.severity {
512                    Severity::Critical => summary.critical += 1,
513                    Severity::High => summary.high += 1,
514                    Severity::Medium => summary.medium += 1,
515                    Severity::Low => summary.low += 1,
516                    Severity::Info => summary.info += 1,
517                }
518            }
519        }
520
521        summary
522    }
523
524    fn determine_status(&self, summary: &ScanSummary) -> ScanStatus {
525        if summary.critical > 0 {
526            return ScanStatus::Fail;
527        }
528
529        if self.config.fail_on_high_severity && summary.high > 0 {
530            return ScanStatus::Fail;
531        }
532
533        if self.config.fail_on_medium_severity && summary.medium > 0 {
534            return ScanStatus::Fail;
535        }
536
537        if summary.high > 0 || summary.medium > 0 {
538            return ScanStatus::Warning;
539        }
540
541        ScanStatus::Pass
542    }
543
544    /// Get last scan result
545    pub fn get_last_result(&self) -> Option<&SecurityScanResult> {
546        self.last_result.as_ref()
547    }
548
549    /// Check if scan is needed
550    pub fn should_scan(&self) -> bool {
551        if !self.config.enabled {
552            return false;
553        }
554
555        match self.last_scan {
556            None => true,
557            Some(last) => {
558                let elapsed = Utc::now() - last;
559                elapsed.num_hours() >= i64::from(self.config.scan_frequency_hours)
560            }
561        }
562    }
563}
564
565/// CI/CD integration helper
566pub struct CiCdIntegration;
567
568impl CiCdIntegration {
569    /// Generate GitHub Actions workflow
570    pub fn generate_github_actions_workflow() -> String {
571        r"name: Security Scan
572
573on:
574  push:
575    branches: [ main, develop ]
576  pull_request:
577    branches: [ main ]
578  schedule:
579    - cron: '0 0 * * *'  # Daily
580
581jobs:
582  security-scan:
583    runs-on: ubuntu-latest
584    steps:
585      - uses: actions/checkout@v3
586
587      - name: Install Rust
588        uses: actions-rs/toolchain@v1
589        with:
590          toolchain: stable
591          components: clippy
592
593      - name: Install cargo-audit
594        run: cargo install cargo-audit
595
596      - name: Dependency Audit
597        run: cargo audit
598
599      - name: Security Clippy
600        run: cargo clippy -- -D warnings
601
602      - name: Run Tests
603        run: cargo test --lib security
604
605      - name: Secret Scanning
606        uses: trufflesecurity/trufflehog@main
607        with:
608          path: ./
609          base: main
610          head: HEAD
611"
612        .to_string()
613    }
614
615    /// Generate GitLab CI configuration
616    pub fn generate_gitlab_ci_config() -> String {
617        r"security-scan:
618  stage: test
619  image: rust:latest
620  script:
621    - cargo install cargo-audit
622    - cargo audit
623    - cargo clippy -- -D warnings
624    - cargo test --lib security
625  allow_failure: false
626"
627        .to_string()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_scanner_creation() {
637        let scanner = SecurityScanner::new(SecurityScanConfig::default());
638        assert!(scanner.last_result.is_none());
639        assert!(scanner.should_scan());
640    }
641
642    #[test]
643    fn test_scan_summary_calculation() {
644        let scanner = SecurityScanner::new(SecurityScanConfig::default());
645        let mut findings = HashMap::new();
646
647        findings.insert(
648            "test".to_string(),
649            vec![
650                SecurityFinding {
651                    id: "1".to_string(),
652                    title: "Test".to_string(),
653                    description: "Test".to_string(),
654                    severity: Severity::Critical,
655                    category: FindingCategory::Dependency,
656                    component: "test".to_string(),
657                    fix: None,
658                    cve: None,
659                },
660                SecurityFinding {
661                    id: "2".to_string(),
662                    title: "Test2".to_string(),
663                    description: "Test2".to_string(),
664                    severity: Severity::High,
665                    category: FindingCategory::Dependency,
666                    component: "test".to_string(),
667                    fix: None,
668                    cve: None,
669                },
670            ],
671        );
672
673        let summary = scanner.calculate_summary(&findings);
674        assert_eq!(summary.total_findings, 2);
675        assert_eq!(summary.critical, 1);
676        assert_eq!(summary.high, 1);
677    }
678
679    #[test]
680    fn test_status_determination() {
681        let scanner = SecurityScanner::new(SecurityScanConfig::default());
682
683        let summary_critical = ScanSummary {
684            total_findings: 1,
685            critical: 1,
686            high: 0,
687            medium: 0,
688            low: 0,
689            info: 0,
690        };
691        assert_eq!(
692            scanner.determine_status(&summary_critical),
693            ScanStatus::Fail
694        );
695
696        let summary_clean = ScanSummary {
697            total_findings: 0,
698            critical: 0,
699            high: 0,
700            medium: 0,
701            low: 0,
702            info: 0,
703        };
704        assert_eq!(scanner.determine_status(&summary_clean), ScanStatus::Pass);
705    }
706
707    #[test]
708    fn test_github_actions_workflow_generation() {
709        let workflow = CiCdIntegration::generate_github_actions_workflow();
710        assert!(workflow.contains("cargo audit"));
711        assert!(workflow.contains("cargo clippy"));
712        assert!(workflow.contains("Security Scan"));
713    }
714
715    #[test]
716    fn test_default_security_scan_config() {
717        let config = SecurityScanConfig::default();
718        assert!(config.enabled);
719        assert_eq!(config.scan_frequency_hours, 24);
720        assert!(config.enable_dependency_scan);
721        assert!(config.enable_secrets_scan);
722        assert!(config.enable_sast);
723        assert!(config.enable_license_check);
724        assert!(config.fail_on_high_severity);
725        assert!(!config.fail_on_medium_severity);
726    }
727
728    #[test]
729    fn test_security_scan_config_serde() {
730        let config = SecurityScanConfig::default();
731        let json = serde_json::to_string(&config).unwrap();
732        let parsed: SecurityScanConfig = serde_json::from_str(&json).unwrap();
733        assert_eq!(parsed.enabled, config.enabled);
734        assert_eq!(parsed.scan_frequency_hours, config.scan_frequency_hours);
735    }
736
737    #[test]
738    fn test_scan_status_equality() {
739        assert_eq!(ScanStatus::Pass, ScanStatus::Pass);
740        assert_eq!(ScanStatus::Fail, ScanStatus::Fail);
741        assert_eq!(ScanStatus::Warning, ScanStatus::Warning);
742        assert_eq!(ScanStatus::Error, ScanStatus::Error);
743        assert_ne!(ScanStatus::Pass, ScanStatus::Fail);
744    }
745
746    #[test]
747    fn test_severity_ordering() {
748        assert!(Severity::Critical < Severity::High);
749        assert!(Severity::High < Severity::Medium);
750        assert!(Severity::Medium < Severity::Low);
751        assert!(Severity::Low < Severity::Info);
752    }
753
754    #[test]
755    fn test_scan_summary_all_severities() {
756        let scanner = SecurityScanner::new(SecurityScanConfig::default());
757        let mut findings = HashMap::new();
758
759        findings.insert(
760            "test".to_string(),
761            vec![
762                SecurityFinding {
763                    id: "1".to_string(),
764                    title: "Critical".to_string(),
765                    description: "Critical finding".to_string(),
766                    severity: Severity::Critical,
767                    category: FindingCategory::Dependency,
768                    component: "test".to_string(),
769                    fix: None,
770                    cve: None,
771                },
772                SecurityFinding {
773                    id: "2".to_string(),
774                    title: "High".to_string(),
775                    description: "High finding".to_string(),
776                    severity: Severity::High,
777                    category: FindingCategory::SecretLeak,
778                    component: "test".to_string(),
779                    fix: Some("Fix it".to_string()),
780                    cve: Some("CVE-2021-1234".to_string()),
781                },
782                SecurityFinding {
783                    id: "3".to_string(),
784                    title: "Medium".to_string(),
785                    description: "Medium finding".to_string(),
786                    severity: Severity::Medium,
787                    category: FindingCategory::CodeVulnerability,
788                    component: "test".to_string(),
789                    fix: None,
790                    cve: None,
791                },
792                SecurityFinding {
793                    id: "4".to_string(),
794                    title: "Low".to_string(),
795                    description: "Low finding".to_string(),
796                    severity: Severity::Low,
797                    category: FindingCategory::LicenseIssue,
798                    component: "test".to_string(),
799                    fix: None,
800                    cve: None,
801                },
802                SecurityFinding {
803                    id: "5".to_string(),
804                    title: "Info".to_string(),
805                    description: "Info finding".to_string(),
806                    severity: Severity::Info,
807                    category: FindingCategory::ConfigurationIssue,
808                    component: "test".to_string(),
809                    fix: None,
810                    cve: None,
811                },
812            ],
813        );
814
815        let summary = scanner.calculate_summary(&findings);
816        assert_eq!(summary.total_findings, 5);
817        assert_eq!(summary.critical, 1);
818        assert_eq!(summary.high, 1);
819        assert_eq!(summary.medium, 1);
820        assert_eq!(summary.low, 1);
821        assert_eq!(summary.info, 1);
822    }
823
824    #[test]
825    fn test_status_high_severity_fail() {
826        let scanner = SecurityScanner::new(SecurityScanConfig {
827            fail_on_high_severity: true,
828            ..Default::default()
829        });
830
831        let summary = ScanSummary {
832            total_findings: 1,
833            critical: 0,
834            high: 1,
835            medium: 0,
836            low: 0,
837            info: 0,
838        };
839        assert_eq!(scanner.determine_status(&summary), ScanStatus::Fail);
840    }
841
842    #[test]
843    fn test_status_high_severity_warning() {
844        let scanner = SecurityScanner::new(SecurityScanConfig {
845            fail_on_high_severity: false,
846            ..Default::default()
847        });
848
849        let summary = ScanSummary {
850            total_findings: 1,
851            critical: 0,
852            high: 1,
853            medium: 0,
854            low: 0,
855            info: 0,
856        };
857        assert_eq!(scanner.determine_status(&summary), ScanStatus::Warning);
858    }
859
860    #[test]
861    fn test_status_medium_severity_fail() {
862        let scanner = SecurityScanner::new(SecurityScanConfig {
863            fail_on_high_severity: false,
864            fail_on_medium_severity: true,
865            ..Default::default()
866        });
867
868        let summary = ScanSummary {
869            total_findings: 1,
870            critical: 0,
871            high: 0,
872            medium: 1,
873            low: 0,
874            info: 0,
875        };
876        assert_eq!(scanner.determine_status(&summary), ScanStatus::Fail);
877    }
878
879    #[test]
880    fn test_status_medium_severity_warning() {
881        let scanner = SecurityScanner::new(SecurityScanConfig {
882            fail_on_high_severity: false,
883            fail_on_medium_severity: false,
884            ..Default::default()
885        });
886
887        let summary = ScanSummary {
888            total_findings: 1,
889            critical: 0,
890            high: 0,
891            medium: 1,
892            low: 0,
893            info: 0,
894        };
895        assert_eq!(scanner.determine_status(&summary), ScanStatus::Warning);
896    }
897
898    #[test]
899    fn test_should_scan_disabled() {
900        let scanner = SecurityScanner::new(SecurityScanConfig {
901            enabled: false,
902            ..Default::default()
903        });
904        assert!(!scanner.should_scan());
905    }
906
907    #[test]
908    fn test_get_last_result_none() {
909        let scanner = SecurityScanner::new(SecurityScanConfig::default());
910        assert!(scanner.get_last_result().is_none());
911    }
912
913    #[test]
914    fn test_parse_license_findings_detects_gpl3() {
915        let json = r#"[
916            {"name": "safe-lib", "version": "1.0.0", "license": "MIT"},
917            {"name": "gpl-lib", "version": "2.0.0", "license": "GPL-3.0"},
918            {"name": "dual-lib", "version": "0.5.0", "license": "MIT/Apache-2.0"}
919        ]"#;
920        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
921        let findings =
922            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
923        assert_eq!(findings.len(), 1);
924        assert_eq!(findings[0].component, "gpl-lib@2.0.0");
925        assert!(findings[0].title.contains("GPL-3.0"));
926        assert_eq!(findings[0].severity, Severity::High);
927    }
928
929    #[test]
930    fn test_parse_license_findings_detects_agpl() {
931        let json = r#"[
932            {"name": "agpl-thing", "version": "3.1.0", "license": "AGPL-3.0-only"}
933        ]"#;
934        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
935        let findings =
936            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
937        assert_eq!(findings.len(), 1);
938        assert!(findings[0].description.contains("agpl-thing"));
939    }
940
941    #[test]
942    fn test_parse_license_findings_detects_sspl() {
943        let json = r#"[
944            {"name": "sspl-db", "version": "1.0.0", "license": "SSPL-1.0"}
945        ]"#;
946        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
947        let findings =
948            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
949        assert_eq!(findings.len(), 1);
950        assert!(findings[0].title.contains("SSPL"));
951    }
952
953    #[test]
954    fn test_parse_license_findings_no_restricted() {
955        let json = r#"[
956            {"name": "lib-a", "version": "1.0.0", "license": "MIT"},
957            {"name": "lib-b", "version": "2.0.0", "license": "Apache-2.0"},
958            {"name": "lib-c", "version": "3.0.0", "license": "BSD-3-Clause"}
959        ]"#;
960        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
961        let findings =
962            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
963        assert!(findings.is_empty());
964    }
965
966    #[test]
967    fn test_parse_license_findings_empty_input() {
968        let json = r"[]";
969        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
970        let findings =
971            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
972        assert!(findings.is_empty());
973    }
974
975    #[test]
976    fn test_parse_metadata_license_findings() {
977        let json = r#"{
978            "packages": [
979                {"name": "ok-lib", "version": "1.0.0", "license": "MIT"},
980                {"name": "bad-lib", "version": "0.1.0", "license": "GPL-3.0-or-later"}
981            ]
982        }"#;
983        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
984        let findings =
985            SecurityScanner::parse_metadata_license_findings(json.as_bytes(), &restricted).unwrap();
986        assert_eq!(findings.len(), 1);
987        assert_eq!(findings[0].component, "bad-lib@0.1.0");
988    }
989
990    #[test]
991    fn test_parse_license_findings_multiple_restricted() {
992        let json = r#"[
993            {"name": "gpl-lib", "version": "1.0.0", "license": "GPL-3.0"},
994            {"name": "agpl-lib", "version": "2.0.0", "license": "AGPL-3.0"},
995            {"name": "sspl-lib", "version": "3.0.0", "license": "SSPL-1.0"}
996        ]"#;
997        let restricted = ["GPL-3.0", "AGPL-3.0", "SSPL"];
998        let findings =
999            SecurityScanner::parse_license_findings(json.as_bytes(), &restricted).unwrap();
1000        assert_eq!(findings.len(), 3);
1001    }
1002
1003    #[test]
1004    fn test_gitlab_ci_config_generation() {
1005        let config = CiCdIntegration::generate_gitlab_ci_config();
1006        assert!(config.contains("cargo audit"));
1007        assert!(config.contains("cargo clippy"));
1008        assert!(config.contains("security-scan"));
1009    }
1010
1011    #[test]
1012    fn test_finding_category_variants() {
1013        let categories = vec![
1014            FindingCategory::Dependency,
1015            FindingCategory::SecretLeak,
1016            FindingCategory::CodeVulnerability,
1017            FindingCategory::LicenseIssue,
1018            FindingCategory::ConfigurationIssue,
1019        ];
1020
1021        for category in categories {
1022            // Ensure each variant can be serialized
1023            let json = serde_json::to_string(&category).unwrap();
1024            assert!(!json.is_empty());
1025        }
1026    }
1027
1028    #[test]
1029    fn test_security_scan_result_serde() {
1030        let result = SecurityScanResult {
1031            timestamp: Utc::now(),
1032            status: ScanStatus::Pass,
1033            findings: HashMap::new(),
1034            summary: ScanSummary {
1035                total_findings: 0,
1036                critical: 0,
1037                high: 0,
1038                medium: 0,
1039                low: 0,
1040                info: 0,
1041            },
1042            recommendations: vec!["Test recommendation".to_string()],
1043        };
1044
1045        let json = serde_json::to_string(&result).unwrap();
1046        let parsed: SecurityScanResult = serde_json::from_str(&json).unwrap();
1047        assert_eq!(parsed.status, result.status);
1048        assert_eq!(parsed.summary.total_findings, 0);
1049    }
1050
1051    #[test]
1052    fn test_security_finding_serde() {
1053        let finding = SecurityFinding {
1054            id: "TEST-001".to_string(),
1055            title: "Test Finding".to_string(),
1056            description: "A test finding".to_string(),
1057            severity: Severity::High,
1058            category: FindingCategory::Dependency,
1059            component: "test-component".to_string(),
1060            fix: Some("Apply fix".to_string()),
1061            cve: Some("CVE-2021-12345".to_string()),
1062        };
1063
1064        let json = serde_json::to_string(&finding).unwrap();
1065        let parsed: SecurityFinding = serde_json::from_str(&json).unwrap();
1066        assert_eq!(parsed.id, finding.id);
1067        assert_eq!(parsed.title, finding.title);
1068        assert_eq!(parsed.cve, finding.cve);
1069    }
1070
1071    #[test]
1072    fn test_scan_status_serde() {
1073        let statuses = vec![
1074            ScanStatus::Pass,
1075            ScanStatus::Warning,
1076            ScanStatus::Fail,
1077            ScanStatus::Error,
1078        ];
1079
1080        for status in statuses {
1081            let json = serde_json::to_string(&status).unwrap();
1082            let parsed: ScanStatus = serde_json::from_str(&json).unwrap();
1083            assert_eq!(parsed, status);
1084        }
1085    }
1086
1087    #[test]
1088    fn test_empty_findings_summary() {
1089        let scanner = SecurityScanner::new(SecurityScanConfig::default());
1090        let findings: HashMap<String, Vec<SecurityFinding>> = HashMap::new();
1091
1092        let summary = scanner.calculate_summary(&findings);
1093        assert_eq!(summary.total_findings, 0);
1094        assert_eq!(summary.critical, 0);
1095        assert_eq!(summary.high, 0);
1096        assert_eq!(summary.medium, 0);
1097        assert_eq!(summary.low, 0);
1098        assert_eq!(summary.info, 0);
1099    }
1100
1101    #[test]
1102    fn test_multiple_categories_summary() {
1103        let scanner = SecurityScanner::new(SecurityScanConfig::default());
1104        let mut findings = HashMap::new();
1105
1106        findings.insert(
1107            "dependencies".to_string(),
1108            vec![SecurityFinding {
1109                id: "DEP-001".to_string(),
1110                title: "Dependency issue".to_string(),
1111                description: "A dependency issue".to_string(),
1112                severity: Severity::High,
1113                category: FindingCategory::Dependency,
1114                component: "deps".to_string(),
1115                fix: None,
1116                cve: None,
1117            }],
1118        );
1119
1120        findings.insert(
1121            "secrets".to_string(),
1122            vec![SecurityFinding {
1123                id: "SEC-001".to_string(),
1124                title: "Secret issue".to_string(),
1125                description: "A secret issue".to_string(),
1126                severity: Severity::Critical,
1127                category: FindingCategory::SecretLeak,
1128                component: "secrets".to_string(),
1129                fix: None,
1130                cve: None,
1131            }],
1132        );
1133
1134        let summary = scanner.calculate_summary(&findings);
1135        assert_eq!(summary.total_findings, 2);
1136        assert_eq!(summary.critical, 1);
1137        assert_eq!(summary.high, 1);
1138    }
1139}