1use crate::error::Result;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::{collections::HashMap, process::Command};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SecurityScanConfig {
17 pub enabled: bool,
19
20 pub scan_frequency_hours: u32,
22
23 pub enable_dependency_scan: bool,
25
26 pub enable_secrets_scan: bool,
28
29 pub enable_sast: bool,
31
32 pub enable_license_check: bool,
34
35 pub fail_on_high_severity: bool,
37
38 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#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SecurityScanResult {
60 pub timestamp: DateTime<Utc>,
62
63 pub status: ScanStatus,
65
66 pub findings: HashMap<String, Vec<SecurityFinding>>,
68
69 pub summary: ScanSummary,
71
72 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 pub id: String,
88
89 pub title: String,
91
92 pub description: String,
94
95 pub severity: Severity,
97
98 pub category: FindingCategory,
100
101 pub component: String,
103
104 pub fix: Option<String>,
106
107 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
139pub struct SecurityScanner {
141 config: SecurityScanConfig,
142 last_scan: Option<DateTime<Utc>>,
143 last_result: Option<SecurityScanResult>,
144}
145
146impl SecurityScanner {
147 pub fn new(config: SecurityScanConfig) -> Self {
149 Self {
150 config,
151 last_scan: None,
152 last_result: None,
153 }
154 }
155
156 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 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 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 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 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 let summary = self.calculate_summary(&all_findings);
228
229 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 fn scan_dependencies(&self) -> Result<Vec<SecurityFinding>> {
248 let mut findings = Vec::new();
249
250 let output = Command::new("cargo").args(["audit", "--json"]).output();
252
253 match output {
254 Ok(output) if output.status.success() => {
255 if let Ok(output_str) = String::from_utf8(output.stdout) {
257 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 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 eprintln!("cargo audit not available - install with: cargo install cargo-audit");
288 }
289 }
290
291 Ok(findings)
292 }
293
294 fn scan_secrets(&self) -> Result<Vec<SecurityFinding>> {
296 let mut findings = Vec::new();
297
298 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 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 fn run_static_analysis(&self) -> Result<Vec<SecurityFinding>> {
349 let mut findings = Vec::new();
350
351 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 fn check_licenses(&self) -> Result<Vec<SecurityFinding>> {
384 let restricted_licenses = ["GPL-3.0", "AGPL-3.0", "SSPL"];
385
386 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 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 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 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 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 pub fn get_last_result(&self) -> Option<&SecurityScanResult> {
546 self.last_result.as_ref()
547 }
548
549 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
565pub struct CiCdIntegration;
567
568impl CiCdIntegration {
569 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 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 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}