1use crate::error::Result;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::process::Command;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SecurityScanConfig {
19 pub enabled: bool,
21
22 pub scan_frequency_hours: u32,
24
25 pub enable_dependency_scan: bool,
27
28 pub enable_secrets_scan: bool,
30
31 pub enable_sast: bool,
33
34 pub enable_license_check: bool,
36
37 pub fail_on_high_severity: bool,
39
40 pub fail_on_medium_severity: bool,
42}
43
44impl Default for SecurityScanConfig {
45 fn default() -> Self {
46 Self {
47 enabled: true,
48 scan_frequency_hours: 24,
49 enable_dependency_scan: true,
50 enable_secrets_scan: true,
51 enable_sast: true,
52 enable_license_check: true,
53 fail_on_high_severity: true,
54 fail_on_medium_severity: false,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SecurityScanResult {
62 pub timestamp: DateTime<Utc>,
64
65 pub status: ScanStatus,
67
68 pub findings: HashMap<String, Vec<SecurityFinding>>,
70
71 pub summary: ScanSummary,
73
74 pub recommendations: Vec<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub enum ScanStatus {
80 Pass,
81 Warning,
82 Fail,
83 Error,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SecurityFinding {
88 pub id: String,
90
91 pub title: String,
93
94 pub description: String,
96
97 pub severity: Severity,
99
100 pub category: FindingCategory,
102
103 pub component: String,
105
106 pub fix: Option<String>,
108
109 pub cve: Option<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
114pub enum Severity {
115 Critical,
116 High,
117 Medium,
118 Low,
119 Info,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub enum FindingCategory {
124 Dependency,
125 SecretLeak,
126 CodeVulnerability,
127 LicenseIssue,
128 ConfigurationIssue,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ScanSummary {
133 pub total_findings: usize,
134 pub critical: usize,
135 pub high: usize,
136 pub medium: usize,
137 pub low: usize,
138 pub info: usize,
139}
140
141pub struct SecurityScanner {
143 config: SecurityScanConfig,
144 last_scan: Option<DateTime<Utc>>,
145 last_result: Option<SecurityScanResult>,
146}
147
148impl SecurityScanner {
149 pub fn new(config: SecurityScanConfig) -> Self {
151 Self {
152 config,
153 last_scan: None,
154 last_result: None,
155 }
156 }
157
158 pub fn run_full_scan(&mut self) -> Result<SecurityScanResult> {
160 let mut all_findings: HashMap<String, Vec<SecurityFinding>> = HashMap::new();
161 let mut recommendations = Vec::new();
162
163 if self.config.enable_dependency_scan {
165 match self.scan_dependencies() {
166 Ok(findings) => {
167 if !findings.is_empty() {
168 all_findings.insert("dependencies".to_string(), findings);
169 recommendations.push("Run 'cargo update' to update vulnerable dependencies".to_string());
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("Remove hardcoded secrets and use environment variables".to_string());
185 }
186 }
187 Err(e) => {
188 eprintln!("Secrets scan failed: {}", e);
189 }
190 }
191 }
192
193 if self.config.enable_sast {
195 match self.run_static_analysis() {
196 Ok(findings) => {
197 if !findings.is_empty() {
198 all_findings.insert("code_analysis".to_string(), findings);
199 recommendations.push("Review and fix code quality issues".to_string());
200 }
201 }
202 Err(e) => {
203 eprintln!("SAST failed: {}", e);
204 }
205 }
206 }
207
208 if self.config.enable_license_check {
210 match self.check_licenses() {
211 Ok(findings) => {
212 if !findings.is_empty() {
213 all_findings.insert("licenses".to_string(), findings);
214 recommendations.push("Review dependency licenses for compliance".to_string());
215 }
216 }
217 Err(e) => {
218 eprintln!("License check failed: {}", e);
219 }
220 }
221 }
222
223 let summary = self.calculate_summary(&all_findings);
225
226 let status = self.determine_status(&summary);
228
229 let result = SecurityScanResult {
230 timestamp: Utc::now(),
231 status,
232 findings: all_findings,
233 summary,
234 recommendations,
235 };
236
237 self.last_scan = Some(Utc::now());
238 self.last_result = Some(result.clone());
239
240 Ok(result)
241 }
242
243 fn scan_dependencies(&self) -> Result<Vec<SecurityFinding>> {
245 let mut findings = Vec::new();
246
247 let output = Command::new("cargo")
249 .args(&["audit", "--json"])
250 .output();
251
252 match output {
253 Ok(output) if output.status.success() => {
254 if let Ok(output_str) = String::from_utf8(output.stdout) {
256 if output_str.contains("Crate:") || output_str.contains("ID:") {
258 findings.push(SecurityFinding {
259 id: "DEP-001".to_string(),
260 title: "Vulnerable dependency detected".to_string(),
261 description: "cargo audit found vulnerabilities".to_string(),
262 severity: Severity::High,
263 category: FindingCategory::Dependency,
264 component: "dependencies".to_string(),
265 fix: Some("Run 'cargo update' and review audit output".to_string()),
266 cve: None,
267 });
268 }
269 }
270 }
271 Ok(_) => {
272 findings.push(SecurityFinding {
274 id: "DEP-002".to_string(),
275 title: "Dependency vulnerabilities found".to_string(),
276 description: "cargo audit reported vulnerabilities".to_string(),
277 severity: Severity::High,
278 category: FindingCategory::Dependency,
279 component: "Cargo dependencies".to_string(),
280 fix: Some("Review 'cargo audit' output and update dependencies".to_string()),
281 cve: None,
282 });
283 }
284 Err(_) => {
285 eprintln!("cargo audit not available - install with: cargo install cargo-audit");
287 }
288 }
289
290 Ok(findings)
291 }
292
293 fn scan_secrets(&self) -> Result<Vec<SecurityFinding>> {
295 let mut findings = Vec::new();
296
297 let secret_patterns = vec![
299 (r"(?i)(api[_-]?key|apikey)\s*[:=]\s*[a-zA-Z0-9]{20,}", "API Key"),
300 (r"(?i)(password|passwd|pwd)\s*[:=]\s*[\w@#$%^&*]{8,}", "Password"),
301 (r"(?i)(secret[_-]?key)\s*[:=]\s*[a-zA-Z0-9]{20,}", "Secret Key"),
302 (r"(?i)(aws[_-]?access[_-]?key[_-]?id)\s*[:=]\s*[A-Z0-9]{20}", "AWS Access Key"),
303 (r"(?i)(private[_-]?key)\s*[:=]", "Private Key"),
304 ];
305
306 let files_to_check = vec![
308 ".env",
309 ".env.example",
310 "config.toml",
311 "Cargo.toml",
312 ];
313
314 for file in files_to_check {
315 if let Ok(content) = std::fs::read_to_string(file) {
316 for (pattern, secret_type) in &secret_patterns {
317 if content.contains("password") || content.contains("secret") || content.contains("key") {
318 findings.push(SecurityFinding {
319 id: format!("SEC-{:03}", findings.len() + 1),
320 title: format!("Potential {} found", secret_type),
321 description: format!("Potential hardcoded {} detected in {}", secret_type, file),
322 severity: Severity::High,
323 category: FindingCategory::SecretLeak,
324 component: file.to_string(),
325 fix: Some("Remove hardcoded secrets, use environment variables or secret management".to_string()),
326 cve: None,
327 });
328 }
329 }
330 }
331 }
332
333 Ok(findings)
334 }
335
336 fn run_static_analysis(&self) -> Result<Vec<SecurityFinding>> {
338 let mut findings = Vec::new();
339
340 let output = Command::new("cargo")
342 .args(&["clippy", "--", "-W", "clippy::all"])
343 .output();
344
345 match output {
346 Ok(output) if !output.status.success() => {
347 let stderr = String::from_utf8_lossy(&output.stderr);
348 if stderr.contains("warning:") || stderr.contains("error:") {
349 findings.push(SecurityFinding {
350 id: "SAST-001".to_string(),
351 title: "Code quality issues found".to_string(),
352 description: "Clippy found potential code issues".to_string(),
353 severity: Severity::Medium,
354 category: FindingCategory::CodeVulnerability,
355 component: "source code".to_string(),
356 fix: Some("Run 'cargo clippy' and address warnings".to_string()),
357 cve: None,
358 });
359 }
360 }
361 _ => {}
362 }
363
364 Ok(findings)
365 }
366
367 fn check_licenses(&self) -> Result<Vec<SecurityFinding>> {
369 let findings = Vec::new();
370
371 let restricted_licenses = vec!["GPL-3.0", "AGPL-3.0", "SSPL"];
373
374 Ok(findings)
378 }
379
380 fn calculate_summary(&self, findings: &HashMap<String, Vec<SecurityFinding>>) -> ScanSummary {
381 let mut summary = ScanSummary {
382 total_findings: 0,
383 critical: 0,
384 high: 0,
385 medium: 0,
386 low: 0,
387 info: 0,
388 };
389
390 for findings_vec in findings.values() {
391 for finding in findings_vec {
392 summary.total_findings += 1;
393 match finding.severity {
394 Severity::Critical => summary.critical += 1,
395 Severity::High => summary.high += 1,
396 Severity::Medium => summary.medium += 1,
397 Severity::Low => summary.low += 1,
398 Severity::Info => summary.info += 1,
399 }
400 }
401 }
402
403 summary
404 }
405
406 fn determine_status(&self, summary: &ScanSummary) -> ScanStatus {
407 if summary.critical > 0 {
408 return ScanStatus::Fail;
409 }
410
411 if self.config.fail_on_high_severity && summary.high > 0 {
412 return ScanStatus::Fail;
413 }
414
415 if self.config.fail_on_medium_severity && summary.medium > 0 {
416 return ScanStatus::Fail;
417 }
418
419 if summary.high > 0 || summary.medium > 0 {
420 return ScanStatus::Warning;
421 }
422
423 ScanStatus::Pass
424 }
425
426 pub fn get_last_result(&self) -> Option<&SecurityScanResult> {
428 self.last_result.as_ref()
429 }
430
431 pub fn should_scan(&self) -> bool {
433 if !self.config.enabled {
434 return false;
435 }
436
437 match self.last_scan {
438 None => true,
439 Some(last) => {
440 let elapsed = Utc::now() - last;
441 elapsed.num_hours() >= self.config.scan_frequency_hours as i64
442 }
443 }
444 }
445}
446
447pub struct CiCdIntegration;
449
450impl CiCdIntegration {
451 pub fn generate_github_actions_workflow() -> String {
453 r#"name: Security Scan
454
455on:
456 push:
457 branches: [ main, develop ]
458 pull_request:
459 branches: [ main ]
460 schedule:
461 - cron: '0 0 * * *' # Daily
462
463jobs:
464 security-scan:
465 runs-on: ubuntu-latest
466 steps:
467 - uses: actions/checkout@v3
468
469 - name: Install Rust
470 uses: actions-rs/toolchain@v1
471 with:
472 toolchain: stable
473 components: clippy
474
475 - name: Install cargo-audit
476 run: cargo install cargo-audit
477
478 - name: Dependency Audit
479 run: cargo audit
480
481 - name: Security Clippy
482 run: cargo clippy -- -D warnings
483
484 - name: Run Tests
485 run: cargo test --lib security
486
487 - name: Secret Scanning
488 uses: trufflesecurity/trufflehog@main
489 with:
490 path: ./
491 base: main
492 head: HEAD
493"#.to_string()
494 }
495
496 pub fn generate_gitlab_ci_config() -> String {
498 r#"security-scan:
499 stage: test
500 image: rust:latest
501 script:
502 - cargo install cargo-audit
503 - cargo audit
504 - cargo clippy -- -D warnings
505 - cargo test --lib security
506 allow_failure: false
507"#.to_string()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_scanner_creation() {
517 let scanner = SecurityScanner::new(SecurityScanConfig::default());
518 assert!(scanner.last_result.is_none());
519 assert!(scanner.should_scan());
520 }
521
522 #[test]
523 fn test_scan_summary_calculation() {
524 let scanner = SecurityScanner::new(SecurityScanConfig::default());
525 let mut findings = HashMap::new();
526
527 findings.insert("test".to_string(), vec![
528 SecurityFinding {
529 id: "1".to_string(),
530 title: "Test".to_string(),
531 description: "Test".to_string(),
532 severity: Severity::Critical,
533 category: FindingCategory::Dependency,
534 component: "test".to_string(),
535 fix: None,
536 cve: None,
537 },
538 SecurityFinding {
539 id: "2".to_string(),
540 title: "Test2".to_string(),
541 description: "Test2".to_string(),
542 severity: Severity::High,
543 category: FindingCategory::Dependency,
544 component: "test".to_string(),
545 fix: None,
546 cve: None,
547 },
548 ]);
549
550 let summary = scanner.calculate_summary(&findings);
551 assert_eq!(summary.total_findings, 2);
552 assert_eq!(summary.critical, 1);
553 assert_eq!(summary.high, 1);
554 }
555
556 #[test]
557 fn test_status_determination() {
558 let scanner = SecurityScanner::new(SecurityScanConfig::default());
559
560 let summary_critical = ScanSummary {
561 total_findings: 1,
562 critical: 1,
563 high: 0,
564 medium: 0,
565 low: 0,
566 info: 0,
567 };
568 assert_eq!(scanner.determine_status(&summary_critical), ScanStatus::Fail);
569
570 let summary_clean = ScanSummary {
571 total_findings: 0,
572 critical: 0,
573 high: 0,
574 medium: 0,
575 low: 0,
576 info: 0,
577 };
578 assert_eq!(scanner.determine_status(&summary_clean), ScanStatus::Pass);
579 }
580
581 #[test]
582 fn test_github_actions_workflow_generation() {
583 let workflow = CiCdIntegration::generate_github_actions_workflow();
584 assert!(workflow.contains("cargo audit"));
585 assert!(workflow.contains("cargo clippy"));
586 assert!(workflow.contains("Security Scan"));
587 }
588}