1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::fs;
13use std::time::Instant;
14use std::process::Command;
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use log::{info, debug, warn};
19use rayon::prelude::*;
20use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
21
22use crate::analyzer::{ProjectAnalysis, DetectedLanguage, DetectedTechnology, EnvVar};
23use crate::analyzer::dependency_parser::Language;
24
25#[derive(Debug, Error)]
26pub enum SecurityError {
27 #[error("Security analysis failed: {0}")]
28 AnalysisFailed(String),
29
30 #[error("Configuration analysis error: {0}")]
31 ConfigAnalysisError(String),
32
33 #[error("Code pattern analysis error: {0}")]
34 CodePatternError(String),
35
36 #[error("Infrastructure analysis error: {0}")]
37 InfrastructureError(String),
38
39 #[error("IO error: {0}")]
40 Io(#[from] std::io::Error),
41
42 #[error("Regex error: {0}")]
43 Regex(#[from] regex::Error),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum SecuritySeverity {
49 Critical,
50 High,
51 Medium,
52 Low,
53 Info,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
58pub enum SecurityCategory {
59 SecretsExposure,
61 InsecureConfiguration,
63 CodeSecurityPattern,
65 InfrastructureSecurity,
67 AuthenticationSecurity,
69 DataProtection,
71 NetworkSecurity,
73 Compliance,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SecurityFinding {
80 pub id: String,
81 pub title: String,
82 pub description: String,
83 pub severity: SecuritySeverity,
84 pub category: SecurityCategory,
85 pub file_path: Option<PathBuf>,
86 pub line_number: Option<usize>,
87 pub evidence: Option<String>,
88 pub remediation: Vec<String>,
89 pub references: Vec<String>,
90 pub cwe_id: Option<String>,
91 pub compliance_frameworks: Vec<String>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
96pub struct SecurityReport {
97 pub analyzed_at: chrono::DateTime<chrono::Utc>,
98 pub overall_score: f32, pub risk_level: SecuritySeverity,
100 pub total_findings: usize,
101 pub findings_by_severity: HashMap<SecuritySeverity, usize>,
102 pub findings_by_category: HashMap<SecurityCategory, usize>,
103 pub findings: Vec<SecurityFinding>,
104 pub recommendations: Vec<String>,
105 pub compliance_status: HashMap<String, ComplianceStatus>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ComplianceStatus {
111 pub framework: String,
112 pub coverage: f32, pub missing_controls: Vec<String>,
114 pub recommendations: Vec<String>,
115}
116
117#[derive(Debug, Clone)]
119pub struct SecurityAnalysisConfig {
120 pub include_low_severity: bool,
121 pub check_secrets: bool,
122 pub check_code_patterns: bool,
123 pub check_infrastructure: bool,
124 pub check_compliance: bool,
125 pub frameworks_to_check: Vec<String>,
126 pub ignore_patterns: Vec<String>,
127 pub skip_gitignored_files: bool,
129 pub downgrade_gitignored_severity: bool,
131}
132
133impl Default for SecurityAnalysisConfig {
134 fn default() -> Self {
135 Self {
136 include_low_severity: false,
137 check_secrets: true,
138 check_code_patterns: true,
139 check_infrastructure: true,
140 check_compliance: true,
141 frameworks_to_check: vec![
142 "SOC2".to_string(),
143 "GDPR".to_string(),
144 "OWASP".to_string(),
145 ],
146 ignore_patterns: vec![
147 "node_modules".to_string(),
148 ".git".to_string(),
149 "target".to_string(),
150 "build".to_string(),
151 ".next".to_string(),
152 "dist".to_string(),
153 "test".to_string(),
154 "tests".to_string(),
155 "*.json".to_string(), "*.lock".to_string(), "*_sample.*".to_string(), "*audit*".to_string(), ],
160 skip_gitignored_files: true, downgrade_gitignored_severity: false, }
163 }
164}
165
166pub struct SecurityAnalyzer {
167 config: SecurityAnalysisConfig,
168 secret_patterns: Vec<SecretPattern>,
169 security_rules: HashMap<Language, Vec<SecurityRule>>,
170 git_ignore_cache: std::sync::Mutex<HashMap<PathBuf, bool>>,
171 project_root: Option<PathBuf>,
172}
173
174struct SecretPattern {
176 name: String,
177 pattern: Regex,
178 severity: SecuritySeverity,
179 description: String,
180}
181
182struct SecurityRule {
184 id: String,
185 name: String,
186 pattern: Regex,
187 severity: SecuritySeverity,
188 category: SecurityCategory,
189 description: String,
190 remediation: Vec<String>,
191 cwe_id: Option<String>,
192}
193
194impl SecurityAnalyzer {
195 pub fn new() -> Result<Self, SecurityError> {
196 Self::with_config(SecurityAnalysisConfig::default())
197 }
198
199 pub fn with_config(config: SecurityAnalysisConfig) -> Result<Self, SecurityError> {
200 let secret_patterns = Self::initialize_secret_patterns()?;
201 let security_rules = Self::initialize_security_rules()?;
202
203 Ok(Self {
204 config,
205 secret_patterns,
206 security_rules,
207 git_ignore_cache: std::sync::Mutex::new(HashMap::new()),
208 project_root: None,
209 })
210 }
211
212 pub fn analyze_security(&mut self, analysis: &ProjectAnalysis) -> Result<SecurityReport, SecurityError> {
214 let start_time = Instant::now();
215 info!("Starting comprehensive security analysis");
216
217 self.project_root = Some(analysis.project_root.clone());
219
220 let is_verbose = log::max_level() >= log::LevelFilter::Info;
222
223 let multi_progress = MultiProgress::new();
225
226 let mut total_phases = 0;
230 if self.config.check_secrets { total_phases += 1; }
231 if self.config.check_code_patterns { total_phases += 1; }
232 if self.config.check_infrastructure { total_phases += 1; }
233 total_phases += 2; let main_pb = if is_verbose {
237 None } else {
239 let pb = multi_progress.add(ProgressBar::new(100));
241 pb.set_style(
242 ProgressStyle::default_bar()
243 .template("đĄī¸ {msg} {bar:50.cyan/blue} {percent}% [{elapsed_precise}]")
244 .unwrap()
245 .progress_chars("âââââââââ "),
246 );
247 Some(pb)
248 };
249
250 let mut findings = Vec::new();
251 let phase_weight = if is_verbose { 1u64 } else { 100 / total_phases as u64 };
252 let mut current_progress = 0u64;
253
254 if self.config.check_secrets {
256 if let Some(ref pb) = main_pb {
257 pb.set_message("Analyzing configuration & secrets...");
258 pb.set_position(current_progress);
259 }
260
261 if is_verbose {
262 findings.extend(self.analyze_configuration_security(&analysis.project_root)?);
263 } else {
264 findings.extend(self.analyze_configuration_security_with_progress(&analysis.project_root, &multi_progress)?);
265 }
266
267 if let Some(ref pb) = main_pb {
268 current_progress += phase_weight;
269 pb.set_position(current_progress);
270 }
271 }
272
273 if self.config.check_code_patterns {
275 if let Some(ref pb) = main_pb {
276 pb.set_message("Analyzing code security patterns...");
277 }
278
279 if is_verbose {
280 findings.extend(self.analyze_code_security_patterns(&analysis.project_root, &analysis.languages)?);
281 } else {
282 findings.extend(self.analyze_code_security_patterns_with_progress(&analysis.project_root, &analysis.languages, &multi_progress)?);
283 }
284
285 if let Some(ref pb) = main_pb {
286 current_progress += phase_weight;
287 pb.set_position(current_progress);
288 }
289 }
290
291 if let Some(ref pb) = main_pb {
297 pb.set_message("Analyzing environment variables...");
298 }
299
300 findings.extend(self.analyze_environment_security(&analysis.environment_variables));
301 if let Some(ref pb) = main_pb {
302 current_progress += phase_weight;
303 pb.set_position(current_progress);
304 }
305
306 if let Some(ref pb) = main_pb {
311 current_progress = 100;
312 pb.set_position(current_progress);
313 }
314
315 if let Some(ref pb) = main_pb {
317 pb.set_message("Processing findings & generating report...");
318 }
319
320 let pre_dedup_count = findings.len();
322 findings = self.deduplicate_findings(findings);
323 let post_dedup_count = findings.len();
324
325 if pre_dedup_count != post_dedup_count {
326 info!("Deduplicated {} redundant findings, {} unique findings remain",
327 pre_dedup_count - post_dedup_count, post_dedup_count);
328 }
329
330 let pre_filter_count = findings.len();
332 if !self.config.include_low_severity {
333 findings.retain(|f| f.severity != SecuritySeverity::Low && f.severity != SecuritySeverity::Info);
334 }
335
336 findings.sort_by(|a, b| a.severity.cmp(&b.severity));
338
339 let total_findings = findings.len();
341 let findings_by_severity = self.count_by_severity(&findings);
342 let findings_by_category = self.count_by_category(&findings);
343 let overall_score = self.calculate_security_score(&findings);
344 let risk_level = self.determine_risk_level(&findings);
345
346 let compliance_status = HashMap::new();
349
350 let recommendations = self.generate_recommendations(&findings, &analysis.technologies);
352
353 let duration = start_time.elapsed().as_secs_f32();
355 if let Some(pb) = main_pb {
356 pb.finish_with_message(format!("â
Security analysis completed in {:.1}s - Found {} issues", duration, total_findings));
357 }
358
359 if pre_filter_count != total_findings {
361 info!("Found {} total findings, showing {} after filtering", pre_filter_count, total_findings);
362 } else {
363 info!("Found {} security findings", total_findings);
364 }
365
366 Ok(SecurityReport {
367 analyzed_at: chrono::Utc::now(),
368 overall_score,
369 risk_level,
370 total_findings,
371 findings_by_severity,
372 findings_by_category,
373 findings,
374 recommendations,
375 compliance_status,
376 })
377 }
378
379 fn is_file_gitignored(&self, file_path: &Path) -> bool {
381 let project_root = match &self.project_root {
383 Some(root) => root,
384 None => return false,
385 };
386
387 if let Ok(cache) = self.git_ignore_cache.lock() {
389 if let Some(&cached_result) = cache.get(file_path) {
390 return cached_result;
391 }
392 }
393
394 if !project_root.join(".git").exists() {
396 debug!("Not a git repository, treating all files as tracked");
397 return false;
398 }
399
400 let git_result = Command::new("git")
402 .args(&["check-ignore", "--quiet"])
403 .arg(file_path)
404 .current_dir(project_root)
405 .output()
406 .map(|output| output.status.success())
407 .unwrap_or(false);
408
409 if git_result {
411 if let Ok(mut cache) = self.git_ignore_cache.lock() {
412 cache.insert(file_path.to_path_buf(), true);
413 }
414 return true;
415 }
416
417 let manual_result = self.check_gitignore_patterns(file_path, project_root);
420
421 let final_result = git_result || manual_result;
423 if let Ok(mut cache) = self.git_ignore_cache.lock() {
424 cache.insert(file_path.to_path_buf(), final_result);
425 }
426
427 final_result
428 }
429
430 fn check_gitignore_patterns(&self, file_path: &Path, project_root: &Path) -> bool {
432 let relative_path = match file_path.strip_prefix(project_root) {
434 Ok(rel) => rel,
435 Err(_) => return false,
436 };
437
438 let path_str = relative_path.to_string_lossy();
439 let file_name = relative_path.file_name()
440 .and_then(|n| n.to_str())
441 .unwrap_or("");
442
443 let gitignore_path = project_root.join(".gitignore");
445 if let Ok(gitignore_content) = fs::read_to_string(&gitignore_path) {
446 for line in gitignore_content.lines() {
447 let pattern = line.trim();
448 if pattern.is_empty() || pattern.starts_with('#') {
449 continue;
450 }
451
452 if self.matches_gitignore_pattern(pattern, &path_str, file_name) {
454 debug!("File {} matches gitignore pattern: {}", path_str, pattern);
455 return true;
456 }
457 }
458 }
459
460 self.matches_common_env_patterns(file_name)
462 }
463
464 fn matches_gitignore_pattern(&self, pattern: &str, path_str: &str, file_name: &str) -> bool {
466 if pattern.contains('*') {
468 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
470 if glob_pattern.matches(path_str) || glob_pattern.matches(file_name) {
472 return true;
473 }
474 }
475 } else if pattern.starts_with('/') {
476 let abs_pattern = &pattern[1..];
478 if path_str == abs_pattern {
479 return true;
480 }
481 } else {
482 if path_str == pattern ||
484 file_name == pattern ||
485 path_str.ends_with(&format!("/{}", pattern)) {
486 return true;
487 }
488 }
489
490 false
491 }
492
493 fn matches_common_env_patterns(&self, file_name: &str) -> bool {
495 let common_env_patterns = [
496 ".env",
497 ".env.local",
498 ".env.development",
499 ".env.production",
500 ".env.staging",
501 ".env.test",
502 ".env.example", ];
504
505 if common_env_patterns.contains(&file_name) {
507 return file_name != ".env.example"; }
509
510 if file_name.starts_with(".env.") ||
512 file_name.ends_with(".env") ||
513 (file_name.starts_with(".") && file_name.contains("env")) {
514 return !file_name.contains("example") &&
516 !file_name.contains("sample") &&
517 !file_name.contains("template");
518 }
519
520 false
521 }
522
523 fn is_file_tracked(&self, file_path: &Path) -> bool {
525 let project_root = match &self.project_root {
526 Some(root) => root,
527 None => return true, };
529
530 if !project_root.join(".git").exists() {
532 return true; }
534
535 Command::new("git")
537 .args(&["ls-files", "--error-unmatch"])
538 .arg(file_path)
539 .current_dir(project_root)
540 .output()
541 .map(|output| output.status.success())
542 .unwrap_or(true) }
544
545 fn determine_secret_severity(&self, file_path: &Path, original_severity: SecuritySeverity) -> (SecuritySeverity, Vec<String>) {
547 let mut additional_remediation = Vec::new();
548
549 if self.is_file_gitignored(file_path) {
551 if self.config.skip_gitignored_files {
552 return (SecuritySeverity::Info, vec!["File is properly gitignored".to_string()]);
554 } else if self.config.downgrade_gitignored_severity {
555 let downgraded = match original_severity {
557 SecuritySeverity::Critical => SecuritySeverity::Medium,
558 SecuritySeverity::High => SecuritySeverity::Low,
559 SecuritySeverity::Medium => SecuritySeverity::Low,
560 SecuritySeverity::Low => SecuritySeverity::Info,
561 SecuritySeverity::Info => SecuritySeverity::Info,
562 };
563 additional_remediation.push("Note: File is gitignored, reducing severity".to_string());
564 return (downgraded, additional_remediation);
565 }
566 }
567
568 if !self.is_file_tracked(file_path) {
570 additional_remediation.push("Ensure this file is added to .gitignore to prevent accidental commits".to_string());
571 } else {
572 additional_remediation.push("â ī¸ CRITICAL: This file is tracked by git! Secrets may be in version history.".to_string());
574 additional_remediation.push("Consider using git-filter-branch or BFG Repo-Cleaner to remove from history".to_string());
575 additional_remediation.push("Rotate any exposed secrets immediately".to_string());
576
577 let upgraded = match original_severity {
579 SecuritySeverity::High => SecuritySeverity::Critical,
580 SecuritySeverity::Medium => SecuritySeverity::High,
581 SecuritySeverity::Low => SecuritySeverity::Medium,
582 other => other,
583 };
584 return (upgraded, additional_remediation);
585 }
586
587 (original_severity, additional_remediation)
588 }
589
590 fn initialize_secret_patterns() -> Result<Vec<SecretPattern>, SecurityError> {
592 let patterns = vec![
593 ("AWS Access Key", r"AKIA[0-9A-Z]{16}", SecuritySeverity::Critical),
595 ("AWS Secret Key", r#"(?i)(aws[_-]?secret|secret[_-]?access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?"#, SecuritySeverity::Critical),
596 ("S3 Secret Key", r#"(?i)(s3[_-]?secret[_-]?key|linode[_-]?s3[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{20,}["']?"#, SecuritySeverity::High),
597 ("GitHub Token", r"gh[pousr]_[A-Za-z0-9_]{36,255}", SecuritySeverity::High),
598 ("OpenAI API Key", r"sk-[A-Za-z0-9]{48}", SecuritySeverity::High),
599 ("Stripe API Key", r"sk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Critical),
600 ("Stripe Publishable Key", r"pk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Medium),
601
602 ("Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?[^"'\s]+"#, SecuritySeverity::High),
604 ("Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}"#, SecuritySeverity::Medium),
605 ("JWT Secret", r#"(?i)(jwt[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{20,}"#, SecuritySeverity::High),
606
607 ("RSA Private Key", r"-----BEGIN RSA PRIVATE KEY-----", SecuritySeverity::Critical),
609 ("SSH Private Key", r"-----BEGIN OPENSSH PRIVATE KEY-----", SecuritySeverity::Critical),
610 ("PGP Private Key", r"-----BEGIN PGP PRIVATE KEY BLOCK-----", SecuritySeverity::Critical),
611
612 ("Google Cloud Service Account", r#""type":\s*"service_account""#, SecuritySeverity::High),
614 ("Azure Storage Key", r"DefaultEndpointsProtocol=https;AccountName=", SecuritySeverity::High),
615
616 ("Generic API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}"#, SecuritySeverity::High),
618 ("Generic Secret", r#"(?i)(secret|token|key)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}"#, SecuritySeverity::Medium),
619 ];
620
621 patterns.into_iter()
622 .map(|(name, pattern, severity)| {
623 Ok(SecretPattern {
624 name: name.to_string(),
625 pattern: Regex::new(pattern)?,
626 severity,
627 description: format!("Potential {} found in code", name),
628 })
629 })
630 .collect()
631 }
632
633 fn initialize_security_rules() -> Result<HashMap<Language, Vec<SecurityRule>>, SecurityError> {
635 let mut rules = HashMap::new();
636
637 rules.insert(Language::JavaScript, vec![
639 SecurityRule {
640 id: "js-001".to_string(),
641 name: "Eval Usage".to_string(),
642 pattern: Regex::new(r"\beval\s*\(")?,
643 severity: SecuritySeverity::High,
644 category: SecurityCategory::CodeSecurityPattern,
645 description: "Use of eval() can lead to code injection vulnerabilities".to_string(),
646 remediation: vec![
647 "Avoid using eval() with user input".to_string(),
648 "Use JSON.parse() for parsing JSON data".to_string(),
649 "Consider using safer alternatives like Function constructor with validation".to_string(),
650 ],
651 cwe_id: Some("CWE-95".to_string()),
652 },
653 SecurityRule {
654 id: "js-002".to_string(),
655 name: "innerHTML Usage".to_string(),
656 pattern: Regex::new(r"\.innerHTML\s*=")?,
657 severity: SecuritySeverity::Medium,
658 category: SecurityCategory::CodeSecurityPattern,
659 description: "innerHTML can lead to XSS vulnerabilities if used with unsanitized data".to_string(),
660 remediation: vec![
661 "Use textContent instead of innerHTML for text".to_string(),
662 "Sanitize HTML content before setting innerHTML".to_string(),
663 "Consider using secure templating libraries".to_string(),
664 ],
665 cwe_id: Some("CWE-79".to_string()),
666 },
667 ]);
668
669 rules.insert(Language::Python, vec![
671 SecurityRule {
672 id: "py-001".to_string(),
673 name: "SQL Injection Risk".to_string(),
674 pattern: Regex::new(r#"\.execute\s*\(\s*[f]?["'][^"']*%[sd]"#)?,
675 severity: SecuritySeverity::High,
676 category: SecurityCategory::CodeSecurityPattern,
677 description: "String formatting in SQL queries can lead to SQL injection".to_string(),
678 remediation: vec![
679 "Use parameterized queries instead of string formatting".to_string(),
680 "Use ORM query builders where possible".to_string(),
681 "Validate and sanitize all user inputs".to_string(),
682 ],
683 cwe_id: Some("CWE-89".to_string()),
684 },
685 SecurityRule {
686 id: "py-002".to_string(),
687 name: "Pickle Usage".to_string(),
688 pattern: Regex::new(r"\bpickle\.loads?\s*\(")?,
689 severity: SecuritySeverity::High,
690 category: SecurityCategory::CodeSecurityPattern,
691 description: "Pickle can execute arbitrary code during deserialization".to_string(),
692 remediation: vec![
693 "Avoid pickle for untrusted data".to_string(),
694 "Use JSON or other safe serialization formats".to_string(),
695 "If pickle is necessary, validate data sources".to_string(),
696 ],
697 cwe_id: Some("CWE-502".to_string()),
698 },
699 ]);
700
701 Ok(rules)
704 }
705
706 fn analyze_configuration_security_with_progress(&self, project_root: &Path, multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
708 debug!("Analyzing configuration security");
709 let mut findings = Vec::new();
710
711 let config_files = self.collect_config_files(project_root)?;
713
714 if config_files.is_empty() {
715 info!("No configuration files found");
716 return Ok(findings);
717 }
718
719 let is_verbose = log::max_level() >= log::LevelFilter::Info;
720
721 info!("đ Found {} configuration files to analyze", config_files.len());
722
723 let file_pb = if is_verbose {
725 None } else {
727 let pb = multi_progress.add(ProgressBar::new(config_files.len() as u64));
729 pb.set_style(
730 ProgressStyle::default_bar()
731 .template(" đ {msg} {bar:40.cyan/blue} {pos}/{len} files ({percent}%)")
732 .unwrap()
733 .progress_chars("âââââââââââ "),
734 );
735 pb.set_message("Scanning configuration files...");
736 Some(pb)
737 };
738
739 use std::sync::atomic::{AtomicUsize, Ordering};
741 use std::sync::Arc;
742 let processed_count = Arc::new(AtomicUsize::new(0));
743
744 let file_findings: Vec<Vec<SecurityFinding>> = config_files
746 .par_iter()
747 .map(|file_path| {
748 let result = self.analyze_file_for_secrets(file_path);
749
750 if let Some(ref pb) = file_pb {
752 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
753 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str()) {
754 let display_name = if file_name.len() > 30 {
756 format!("...{}", &file_name[file_name.len()-27..])
757 } else {
758 file_name.to_string()
759 };
760 pb.set_message(format!("Scanning {}", display_name));
761 }
762 pb.set_position(current as u64);
763 }
764
765 result
766 })
767 .filter_map(|result| result.ok())
768 .collect();
769
770 if let Some(pb) = file_pb {
772 pb.finish_with_message(format!("â
Scanned {} configuration files", config_files.len()));
773 }
774
775 for mut file_findings in file_findings {
776 findings.append(&mut file_findings);
777 }
778
779 findings.extend(self.check_insecure_configurations(project_root)?);
781
782 info!("đ Found {} configuration security findings", findings.len());
783 Ok(findings)
784 }
785
786 fn analyze_configuration_security(&self, project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
788 debug!("Analyzing configuration security");
789 let mut findings = Vec::new();
790
791 let config_files = self.collect_config_files(project_root)?;
793
794 if config_files.is_empty() {
795 info!("No configuration files found");
796 return Ok(findings);
797 }
798
799 info!("đ Found {} configuration files to analyze", config_files.len());
800
801 let file_findings: Vec<Vec<SecurityFinding>> = config_files
803 .par_iter()
804 .map(|file_path| self.analyze_file_for_secrets(file_path))
805 .filter_map(|result| result.ok())
806 .collect();
807
808 for mut file_findings in file_findings {
809 findings.append(&mut file_findings);
810 }
811
812 findings.extend(self.check_insecure_configurations(project_root)?);
814
815 info!("đ Found {} configuration security findings", findings.len());
816 Ok(findings)
817 }
818
819 fn analyze_code_security_patterns_with_progress(&self, project_root: &Path, languages: &[DetectedLanguage], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
821 debug!("Analyzing code security patterns");
822 let mut findings = Vec::new();
823
824 let mut total_files = 0;
826 let mut language_files = Vec::new();
827
828 for language in languages {
829 if let Some(lang) = Language::from_string(&language.name) {
830 if let Some(_rules) = self.security_rules.get(&lang) {
831 let source_files = self.collect_source_files(project_root, &language.name)?;
832 total_files += source_files.len();
833 language_files.push((language, source_files));
834 }
835 }
836 }
837
838 if total_files == 0 {
839 info!("No source files found for code pattern analysis");
840 return Ok(findings);
841 }
842
843 let is_verbose = log::max_level() >= log::LevelFilter::Info;
844
845 info!("đ Found {} source files across {} languages", total_files, language_files.len());
846
847 let code_pb = if is_verbose {
849 None
851 } else {
852 let pb = multi_progress.add(ProgressBar::new(total_files as u64));
854 pb.set_style(
855 ProgressStyle::default_bar()
856 .template(" đ {msg} {bar:40.yellow/white} {pos}/{len} files ({percent}%)")
857 .unwrap()
858 .progress_chars("âââââââââââ "),
859 );
860 pb.set_message("Scanning source code...");
861 Some(pb)
862 };
863
864
865 use std::sync::atomic::{AtomicUsize, Ordering};
867 use std::sync::Arc;
868 let processed_count = Arc::new(AtomicUsize::new(0));
869
870 for (language, source_files) in language_files {
872 if let Some(lang) = Language::from_string(&language.name) {
873 if let Some(rules) = self.security_rules.get(&lang) {
874 let file_findings: Vec<Vec<SecurityFinding>> = source_files
875 .par_iter()
876 .map(|file_path| {
877 let result = self.analyze_file_with_rules(file_path, rules);
878
879 if let Some(ref pb) = code_pb {
881 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
882 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str()) {
883 let display_name = if file_name.len() > 25 {
884 format!("...{}", &file_name[file_name.len()-22..])
885 } else {
886 file_name.to_string()
887 };
888 pb.set_message(format!("Scanning {} ({})", display_name, language.name));
889 }
890 pb.set_position(current as u64);
891 }
892
893 result
894 })
895 .filter_map(|result| result.ok())
896 .collect();
897
898 for mut file_findings in file_findings {
899 findings.append(&mut file_findings);
900 }
901 }
902 }
903 }
904
905 if let Some(pb) = code_pb {
907 pb.finish_with_message(format!("â
Scanned {} source files", total_files));
908 }
909
910 info!("đ Found {} code security findings", findings.len());
911 Ok(findings)
912 }
913
914 fn analyze_code_security_patterns(&self, project_root: &Path, languages: &[DetectedLanguage]) -> Result<Vec<SecurityFinding>, SecurityError> {
916 debug!("Analyzing code security patterns");
917 let mut findings = Vec::new();
918
919 let mut total_files = 0;
921 let mut language_files = Vec::new();
922
923 for language in languages {
924 if let Some(lang) = Language::from_string(&language.name) {
925 if let Some(_rules) = self.security_rules.get(&lang) {
926 let source_files = self.collect_source_files(project_root, &language.name)?;
927 total_files += source_files.len();
928 language_files.push((language, source_files));
929 }
930 }
931 }
932
933 if total_files == 0 {
934 info!("No source files found for code pattern analysis");
935 return Ok(findings);
936 }
937
938 info!("đ Found {} source files across {} languages", total_files, language_files.len());
939
940 for (language, source_files) in language_files {
942 if let Some(lang) = Language::from_string(&language.name) {
943 if let Some(rules) = self.security_rules.get(&lang) {
944 let file_findings: Vec<Vec<SecurityFinding>> = source_files
945 .par_iter()
946 .map(|file_path| self.analyze_file_with_rules(file_path, rules))
947 .filter_map(|result| result.ok())
948 .collect();
949
950 for mut file_findings in file_findings {
951 findings.append(&mut file_findings);
952 }
953 }
954 }
955 }
956
957 info!("đ Found {} code security findings", findings.len());
958 Ok(findings)
959 }
960
961 fn analyze_infrastructure_security_with_progress(&self, project_root: &Path, _technologies: &[DetectedTechnology], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
963 debug!("Analyzing infrastructure security");
964 let mut findings = Vec::new();
965
966 let is_verbose = log::max_level() >= log::LevelFilter::Info;
967
968 let infra_pb = if is_verbose {
970 None
972 } else {
973 let pb = multi_progress.add(ProgressBar::new_spinner());
975 pb.set_style(
976 ProgressStyle::default_spinner()
977 .template(" đī¸ {msg} {spinner:.magenta}")
978 .unwrap()
979 .tick_chars("â â â âĄâĸâ â â "),
980 );
981 pb.enable_steady_tick(std::time::Duration::from_millis(100));
982 Some(pb)
983 };
984
985 if let Some(ref pb) = infra_pb {
987 pb.set_message("Checking Dockerfiles & Compose files...");
988 }
989 findings.extend(self.analyze_dockerfile_security(project_root)?);
990 findings.extend(self.analyze_compose_security(project_root)?);
991
992 if let Some(ref pb) = infra_pb {
994 pb.set_message("Checking CI/CD configurations...");
995 }
996 findings.extend(self.analyze_cicd_security(project_root)?);
997
998 if let Some(pb) = infra_pb {
1000 pb.finish_with_message("â
Infrastructure analysis complete");
1001 }
1002 info!("đ Found {} infrastructure security findings", findings.len());
1003
1004 Ok(findings)
1005 }
1006
1007 fn analyze_infrastructure_security(&self, project_root: &Path, _technologies: &[DetectedTechnology]) -> Result<Vec<SecurityFinding>, SecurityError> {
1009 debug!("Analyzing infrastructure security");
1010 let mut findings = Vec::new();
1011
1012 findings.extend(self.analyze_dockerfile_security(project_root)?);
1014 findings.extend(self.analyze_compose_security(project_root)?);
1015
1016 findings.extend(self.analyze_cicd_security(project_root)?);
1018
1019 info!("đ Found {} infrastructure security findings", findings.len());
1020 Ok(findings)
1021 }
1022
1023 fn analyze_environment_security(&self, env_vars: &[EnvVar]) -> Vec<SecurityFinding> {
1025 let mut findings = Vec::new();
1026
1027 for env_var in env_vars {
1028 if self.is_sensitive_env_var(&env_var.name) && env_var.default_value.is_some() {
1030 findings.push(SecurityFinding {
1031 id: format!("env-{}", env_var.name.to_lowercase()),
1032 title: "Sensitive Environment Variable with Default Value".to_string(),
1033 description: format!("Environment variable '{}' appears to contain sensitive data but has a default value", env_var.name),
1034 severity: SecuritySeverity::Medium,
1035 category: SecurityCategory::SecretsExposure,
1036 file_path: None,
1037 line_number: None,
1038 evidence: Some(format!("Variable: {} = {:?}", env_var.name, env_var.default_value)),
1039 remediation: vec![
1040 "Remove default value for sensitive environment variables".to_string(),
1041 "Use a secure secret management system".to_string(),
1042 "Document required environment variables separately".to_string(),
1043 ],
1044 references: vec![
1045 "https://owasp.org/www-project-top-ten/2017/A3_2017-Sensitive_Data_Exposure".to_string(),
1046 ],
1047 cwe_id: Some("CWE-200".to_string()),
1048 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1049 });
1050 }
1051 }
1052
1053 findings
1054 }
1055
1056 fn analyze_framework_security_with_progress(&self, project_root: &Path, technologies: &[DetectedTechnology], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
1058 debug!("Analyzing framework-specific security");
1059 let mut findings = Vec::new();
1060
1061 let framework_count = technologies.len();
1062 if framework_count == 0 {
1063 info!("No frameworks detected for security analysis");
1064 return Ok(findings);
1065 }
1066
1067 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1068
1069 info!("đ§ Found {} frameworks to analyze", framework_count);
1070
1071 let fw_pb = if is_verbose {
1073 None
1075 } else {
1076 let pb = multi_progress.add(ProgressBar::new_spinner());
1078 pb.set_style(
1079 ProgressStyle::default_spinner()
1080 .template(" đ§ {msg} {spinner:.cyan}")
1081 .unwrap()
1082 .tick_chars("â â â âĄâĸâ â â "),
1083 );
1084 pb.enable_steady_tick(std::time::Duration::from_millis(120));
1085 Some(pb)
1086 };
1087
1088 for tech in technologies {
1089 if let Some(ref pb) = fw_pb {
1090 pb.set_message(format!("Checking {} configuration...", tech.name));
1091 }
1092
1093 match tech.name.as_str() {
1094 "Express.js" | "Express" => {
1095 findings.extend(self.analyze_express_security(project_root)?);
1096 },
1097 "Django" => {
1098 findings.extend(self.analyze_django_security(project_root)?);
1099 },
1100 "Spring Boot" => {
1101 findings.extend(self.analyze_spring_security(project_root)?);
1102 },
1103 "Next.js" => {
1104 findings.extend(self.analyze_nextjs_security(project_root)?);
1105 },
1106 _ => {}
1108 }
1109 }
1110
1111 if let Some(pb) = fw_pb {
1113 pb.finish_with_message("â
Framework analysis complete");
1114 }
1115 info!("đ Found {} framework security findings", findings.len());
1116
1117 Ok(findings)
1118 }
1119
1120 fn analyze_framework_security(&self, project_root: &Path, technologies: &[DetectedTechnology]) -> Result<Vec<SecurityFinding>, SecurityError> {
1122 debug!("Analyzing framework-specific security");
1123 let mut findings = Vec::new();
1124
1125 let framework_count = technologies.len();
1126 if framework_count == 0 {
1127 info!("No frameworks detected for security analysis");
1128 return Ok(findings);
1129 }
1130
1131 info!("đ§ Found {} frameworks to analyze", framework_count);
1132
1133 for tech in technologies {
1134 match tech.name.as_str() {
1135 "Express.js" | "Express" => {
1136 findings.extend(self.analyze_express_security(project_root)?);
1137 },
1138 "Django" => {
1139 findings.extend(self.analyze_django_security(project_root)?);
1140 },
1141 "Spring Boot" => {
1142 findings.extend(self.analyze_spring_security(project_root)?);
1143 },
1144 "Next.js" => {
1145 findings.extend(self.analyze_nextjs_security(project_root)?);
1146 },
1147 _ => {}
1149 }
1150 }
1151
1152 info!("đ Found {} framework security findings", findings.len());
1153 Ok(findings)
1154 }
1155
1156 fn collect_config_files(&self, project_root: &Path) -> Result<Vec<PathBuf>, SecurityError> {
1159 let patterns = vec![
1160 "*.env*", "*.conf", "*.config", "*.ini", "*.yaml", "*.yml",
1161 "*.toml", "docker-compose*.yml", "Dockerfile*",
1162 ".github/**/*.yml", ".gitlab-ci.yml", "package.json",
1163 "requirements.txt", "Cargo.toml", "go.mod", "pom.xml",
1164 ];
1165
1166 let mut files = crate::common::file_utils::find_files_by_patterns(project_root, &patterns)
1167 .map_err(|e| SecurityError::Io(e))?;
1168
1169 files.retain(|file| {
1171 let file_name = file.file_name()
1172 .and_then(|n| n.to_str())
1173 .unwrap_or("");
1174 let file_path = file.to_string_lossy();
1175
1176 !self.config.ignore_patterns.iter().any(|pattern| {
1177 if pattern.contains('*') {
1178 glob::Pattern::new(pattern)
1180 .map(|p| p.matches(&file_path) || p.matches(file_name))
1181 .unwrap_or(false)
1182 } else {
1183 file_path.contains(pattern) || file_name.contains(pattern)
1185 }
1186 })
1187 });
1188
1189 Ok(files)
1190 }
1191
1192 fn analyze_file_for_secrets(&self, file_path: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1193 let content = fs::read_to_string(file_path)?;
1194 let mut findings = Vec::new();
1195
1196 for (line_num, line) in content.lines().enumerate() {
1197 for pattern in &self.secret_patterns {
1198 if let Some(_captures) = pattern.pattern.find(line) {
1199 if self.is_likely_placeholder(line) {
1201 continue;
1202 }
1203
1204 let (severity, additional_remediation) = self.determine_secret_severity(file_path, pattern.severity.clone());
1206
1207 if self.config.skip_gitignored_files && severity == SecuritySeverity::Info {
1209 debug!("Skipping secret in gitignored file: {}", file_path.display());
1210 continue;
1211 }
1212
1213 let mut remediation = vec![
1215 "Remove sensitive data from source code".to_string(),
1216 "Use environment variables for secrets".to_string(),
1217 "Consider using a secure secret management service".to_string(),
1218 ];
1219
1220 remediation.extend(additional_remediation);
1222
1223 if !self.is_file_gitignored(file_path) && !self.is_file_tracked(file_path) {
1225 remediation.push("Add this file to .gitignore to prevent accidental commits".to_string());
1226 }
1227
1228 let mut description = pattern.description.clone();
1230 if self.is_file_tracked(file_path) {
1231 description.push_str(" (â ī¸ WARNING: File is tracked by git - secrets may be in version history!)");
1232 } else if self.is_file_gitignored(file_path) {
1233 description.push_str(" (âšī¸ Note: File is gitignored)");
1234 }
1235
1236 findings.push(SecurityFinding {
1237 id: format!("secret-{}-{}", pattern.name.to_lowercase().replace(' ', "-"), line_num),
1238 title: format!("Potential {} Exposure", pattern.name),
1239 description,
1240 severity,
1241 category: SecurityCategory::SecretsExposure,
1242 file_path: Some(file_path.to_path_buf()),
1243 line_number: Some(line_num + 1),
1244 evidence: Some(format!("Line: {}", line.trim())),
1245 remediation,
1246 references: vec![
1247 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
1248 ],
1249 cwe_id: Some("CWE-200".to_string()),
1250 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1251 });
1252 }
1253 }
1254 }
1255
1256 Ok(findings)
1257 }
1258
1259 fn is_likely_placeholder(&self, line: &str) -> bool {
1260 let placeholder_indicators = [
1261 "example", "placeholder", "your_", "insert_", "replace_",
1262 "xxx", "yyy", "zzz", "fake", "dummy", "test_key",
1263 "sk-xxxxxxxx", "AKIA00000000",
1264 ];
1265
1266 let hash_indicators = [
1267 "checksum", "hash", "sha1", "sha256", "md5", "commit",
1268 "fingerprint", "digest", "advisory", "ghsa-", "cve-",
1269 "rustc_fingerprint", "last-commit", "references",
1270 ];
1271
1272 let line_lower = line.to_lowercase();
1273
1274 if placeholder_indicators.iter().any(|indicator| line_lower.contains(indicator)) {
1276 return true;
1277 }
1278
1279 if hash_indicators.iter().any(|indicator| line_lower.contains(indicator)) {
1281 return true;
1282 }
1283
1284 if line_lower.contains("http") || line_lower.contains("github.com") {
1286 return true;
1287 }
1288
1289 if let Some(potential_hash) = self.extract_potential_hash(line) {
1291 if potential_hash.len() >= 32 && self.is_hex_only(&potential_hash) {
1292 return true; }
1294 }
1295
1296 false
1297 }
1298
1299 fn extract_potential_hash(&self, line: &str) -> Option<String> {
1300 if let Some(start) = line.find('"') {
1302 if let Some(end) = line[start + 1..].find('"') {
1303 let potential = &line[start + 1..start + 1 + end];
1304 if potential.len() >= 32 {
1305 return Some(potential.to_string());
1306 }
1307 }
1308 }
1309 None
1310 }
1311
1312 fn is_hex_only(&self, s: &str) -> bool {
1313 s.chars().all(|c| c.is_ascii_hexdigit())
1314 }
1315
1316 fn is_sensitive_env_var(&self, name: &str) -> bool {
1317 let sensitive_patterns = [
1318 "password", "secret", "key", "token", "auth", "api",
1319 "private", "credential", "cert", "ssl", "tls",
1320 ];
1321
1322 let name_lower = name.to_lowercase();
1323 sensitive_patterns.iter().any(|pattern| name_lower.contains(pattern))
1324 }
1325
1326 fn analyze_express_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1328 Ok(vec![])
1330 }
1331
1332 fn analyze_django_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1333 Ok(vec![])
1335 }
1336
1337 fn analyze_spring_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1338 Ok(vec![])
1340 }
1341
1342 fn analyze_nextjs_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1343 Ok(vec![])
1345 }
1346
1347 fn analyze_dockerfile_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1348 Ok(vec![])
1350 }
1351
1352 fn analyze_compose_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1353 Ok(vec![])
1355 }
1356
1357 fn analyze_cicd_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1358 Ok(vec![])
1360 }
1361
1362 fn collect_source_files(&self, project_root: &Path, language: &str) -> Result<Vec<PathBuf>, SecurityError> {
1364 Ok(vec![])
1366 }
1367
1368 fn analyze_file_with_rules(&self, _file_path: &Path, _rules: &[SecurityRule]) -> Result<Vec<SecurityFinding>, SecurityError> {
1369 Ok(vec![])
1371 }
1372
1373 fn check_insecure_configurations(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1374 Ok(vec![])
1376 }
1377
1378 fn deduplicate_findings(&self, mut findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
1380 use std::collections::HashSet;
1381
1382 let mut seen_secrets: HashSet<String> = HashSet::new();
1383 let mut deduplicated = Vec::new();
1384
1385 findings.sort_by(|a, b| {
1387 let a_priority = self.get_pattern_priority(&a.title);
1389 let b_priority = self.get_pattern_priority(&b.title);
1390
1391 match a_priority.cmp(&b_priority) {
1392 std::cmp::Ordering::Equal => {
1393 a.severity.cmp(&b.severity)
1395 }
1396 other => other
1397 }
1398 });
1399
1400 for finding in findings {
1401 let key = self.generate_finding_key(&finding);
1402
1403 if !seen_secrets.contains(&key) {
1404 seen_secrets.insert(key);
1405 deduplicated.push(finding);
1406 }
1407 }
1408
1409 deduplicated
1410 }
1411
1412 fn generate_finding_key(&self, finding: &SecurityFinding) -> String {
1414 match finding.category {
1415 SecurityCategory::SecretsExposure => {
1416 if let Some(evidence) = &finding.evidence {
1418 if let Some(file_path) = &finding.file_path {
1419 if let Some(secret_value) = self.extract_secret_value(evidence) {
1421 return format!("secret:{}:{}", file_path.display(), secret_value);
1422 }
1423 if let Some(line_num) = finding.line_number {
1425 return format!("secret:{}:{}", file_path.display(), line_num);
1426 }
1427 }
1428 }
1429 format!("secret:{}", finding.title)
1431 }
1432 _ => {
1433 if let Some(file_path) = &finding.file_path {
1435 if let Some(line_num) = finding.line_number {
1436 format!("other:{}:{}:{}", file_path.display(), line_num, finding.title)
1437 } else {
1438 format!("other:{}:{}", file_path.display(), finding.title)
1439 }
1440 } else {
1441 format!("other:{}", finding.title)
1442 }
1443 }
1444 }
1445 }
1446
1447 fn extract_secret_value(&self, evidence: &str) -> Option<String> {
1449 if let Some(pos) = evidence.find('=') {
1451 let value = evidence[pos + 1..].trim();
1452 let value = value.trim_matches('"').trim_matches('\'');
1454 if value.len() > 10 { return Some(value.to_string());
1456 }
1457 }
1458
1459 if let Some(pos) = evidence.find(':') {
1461 let value = evidence[pos + 1..].trim();
1462 let value = value.trim_matches('"').trim_matches('\'');
1463 if value.len() > 10 {
1464 return Some(value.to_string());
1465 }
1466 }
1467
1468 None
1469 }
1470
1471 fn get_pattern_priority(&self, title: &str) -> u8 {
1473 if title.contains("AWS Access Key") { return 1; }
1475 if title.contains("AWS Secret Key") { return 1; }
1476 if title.contains("S3 Secret Key") { return 1; }
1477 if title.contains("GitHub Token") { return 1; }
1478 if title.contains("OpenAI API Key") { return 1; }
1479 if title.contains("Stripe") { return 1; }
1480 if title.contains("RSA Private Key") { return 1; }
1481 if title.contains("SSH Private Key") { return 1; }
1482
1483 if title.contains("JWT Secret") { return 2; }
1485 if title.contains("Database URL") { return 2; }
1486
1487 if title.contains("API Key") { return 3; }
1489
1490 if title.contains("Environment Variable") { return 4; }
1492
1493 if title.contains("Generic Secret") { return 5; }
1495
1496 3
1498 }
1499
1500 fn count_by_severity(&self, findings: &[SecurityFinding]) -> HashMap<SecuritySeverity, usize> {
1501 let mut counts = HashMap::new();
1502 for finding in findings {
1503 *counts.entry(finding.severity.clone()).or_insert(0) += 1;
1504 }
1505 counts
1506 }
1507
1508 fn count_by_category(&self, findings: &[SecurityFinding]) -> HashMap<SecurityCategory, usize> {
1509 let mut counts = HashMap::new();
1510 for finding in findings {
1511 *counts.entry(finding.category.clone()).or_insert(0) += 1;
1512 }
1513 counts
1514 }
1515
1516 fn calculate_security_score(&self, findings: &[SecurityFinding]) -> f32 {
1517 if findings.is_empty() {
1518 return 100.0;
1519 }
1520
1521 let total_penalty = findings.iter().map(|f| match f.severity {
1522 SecuritySeverity::Critical => 25.0,
1523 SecuritySeverity::High => 15.0,
1524 SecuritySeverity::Medium => 8.0,
1525 SecuritySeverity::Low => 3.0,
1526 SecuritySeverity::Info => 1.0,
1527 }).sum::<f32>();
1528
1529 (100.0 - total_penalty).max(0.0)
1530 }
1531
1532 fn determine_risk_level(&self, findings: &[SecurityFinding]) -> SecuritySeverity {
1533 if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
1534 SecuritySeverity::Critical
1535 } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) {
1536 SecuritySeverity::High
1537 } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) {
1538 SecuritySeverity::Medium
1539 } else if !findings.is_empty() {
1540 SecuritySeverity::Low
1541 } else {
1542 SecuritySeverity::Info
1543 }
1544 }
1545
1546 fn assess_compliance(&self, _findings: &[SecurityFinding], _technologies: &[DetectedTechnology]) -> HashMap<String, ComplianceStatus> {
1547 HashMap::new()
1549 }
1550
1551 fn generate_recommendations(&self, findings: &[SecurityFinding], _technologies: &[DetectedTechnology]) -> Vec<String> {
1552 let mut recommendations = Vec::new();
1553
1554 if findings.iter().any(|f| f.category == SecurityCategory::SecretsExposure) {
1555 recommendations.push("Implement a secure secret management strategy".to_string());
1556 }
1557
1558 if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
1559 recommendations.push("Address critical security findings immediately".to_string());
1560 }
1561
1562 recommendations
1565 }
1566}
1567
1568
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573
1574 #[test]
1575 fn test_security_score_calculation() {
1576 let analyzer = SecurityAnalyzer::new().unwrap();
1577
1578 let findings = vec![
1579 SecurityFinding {
1580 id: "test-1".to_string(),
1581 title: "Test Critical".to_string(),
1582 description: "Test".to_string(),
1583 severity: SecuritySeverity::Critical,
1584 category: SecurityCategory::SecretsExposure,
1585 file_path: None,
1586 line_number: None,
1587 evidence: None,
1588 remediation: vec![],
1589 references: vec![],
1590 cwe_id: None,
1591 compliance_frameworks: vec![],
1592 }
1593 ];
1594
1595 let score = analyzer.calculate_security_score(&findings);
1596 assert_eq!(score, 75.0); }
1598
1599 #[test]
1600 fn test_secret_pattern_matching() {
1601 let analyzer = SecurityAnalyzer::new().unwrap();
1602
1603 assert!(analyzer.is_likely_placeholder("API_KEY=sk-xxxxxxxxxxxxxxxx"));
1605 assert!(!analyzer.is_likely_placeholder("API_KEY=sk-1234567890abcdef"));
1606 }
1607
1608 #[test]
1609 fn test_sensitive_env_var_detection() {
1610 let analyzer = SecurityAnalyzer::new().unwrap();
1611
1612 assert!(analyzer.is_sensitive_env_var("DATABASE_PASSWORD"));
1613 assert!(analyzer.is_sensitive_env_var("JWT_SECRET"));
1614 assert!(!analyzer.is_sensitive_env_var("PORT"));
1615 assert!(!analyzer.is_sensitive_env_var("NODE_ENV"));
1616 }
1617
1618 #[test]
1619 fn test_gitignore_aware_severity() {
1620 use tempfile::TempDir;
1621 use std::fs;
1622 use std::process::Command;
1623
1624 let temp_dir = TempDir::new().unwrap();
1625 let project_root = temp_dir.path();
1626
1627 let git_init = Command::new("git")
1629 .args(&["init"])
1630 .current_dir(project_root)
1631 .output();
1632
1633 if git_init.is_err() {
1635 println!("Skipping gitignore test - git not available");
1636 return;
1637 }
1638
1639 fs::write(project_root.join(".gitignore"), ".env\n.env.local\n").unwrap();
1641
1642 let _ = Command::new("git")
1644 .args(&["add", ".gitignore"])
1645 .current_dir(project_root)
1646 .output();
1647 let _ = Command::new("git")
1648 .args(&["config", "user.email", "test@example.com"])
1649 .current_dir(project_root)
1650 .output();
1651 let _ = Command::new("git")
1652 .args(&["config", "user.name", "Test User"])
1653 .current_dir(project_root)
1654 .output();
1655 let _ = Command::new("git")
1656 .args(&["commit", "-m", "Add gitignore"])
1657 .current_dir(project_root)
1658 .output();
1659
1660 let mut analyzer = SecurityAnalyzer::new().unwrap();
1661 analyzer.project_root = Some(project_root.to_path_buf());
1662
1663 let env_file = project_root.join(".env");
1665 fs::write(&env_file, "API_KEY=sk-1234567890abcdef").unwrap();
1666
1667 let (severity, remediation) = analyzer.determine_secret_severity(&env_file, SecuritySeverity::High);
1669
1670 assert_eq!(severity, SecuritySeverity::Info);
1672 assert!(remediation.iter().any(|r| r.contains("gitignored")));
1673 }
1674
1675 #[test]
1676 fn test_gitignore_config_options() {
1677 let mut config = SecurityAnalysisConfig::default();
1678
1679 assert!(config.skip_gitignored_files);
1681 assert!(!config.downgrade_gitignored_severity);
1682
1683 config.skip_gitignored_files = false;
1685 config.downgrade_gitignored_severity = true;
1686
1687 let analyzer = SecurityAnalyzer::with_config(config).unwrap();
1688 }
1690
1691 #[test]
1692 fn test_gitignore_pattern_matching() {
1693 let analyzer = SecurityAnalyzer::new().unwrap();
1694
1695 assert!(!analyzer.matches_gitignore_pattern("*.env", ".env.local", ".env.local")); assert!(analyzer.matches_gitignore_pattern("*.env", "production.env", "production.env")); assert!(analyzer.matches_gitignore_pattern(".env*", ".env.production", ".env.production")); assert!(analyzer.matches_gitignore_pattern("*.log", "app.log", "app.log"));
1700
1701 assert!(analyzer.matches_gitignore_pattern(".env", ".env", ".env"));
1703 assert!(!analyzer.matches_gitignore_pattern(".env", ".env.local", ".env.local"));
1704
1705 assert!(analyzer.matches_gitignore_pattern("/config.json", "config.json", "config.json"));
1707 assert!(!analyzer.matches_gitignore_pattern("/config.json", "src/config.json", "config.json"));
1708
1709 assert!(analyzer.matches_gitignore_pattern(".env*", ".env", ".env"));
1711 assert!(analyzer.matches_gitignore_pattern(".env*", ".env.local", ".env.local"));
1712 assert!(analyzer.matches_gitignore_pattern(".env.*", ".env.production", ".env.production"));
1713 }
1714
1715 #[test]
1716 fn test_common_env_patterns() {
1717 let analyzer = SecurityAnalyzer::new().unwrap();
1718
1719 assert!(analyzer.matches_common_env_patterns(".env"));
1721 assert!(analyzer.matches_common_env_patterns(".env.local"));
1722 assert!(analyzer.matches_common_env_patterns(".env.production"));
1723 assert!(analyzer.matches_common_env_patterns(".env.development"));
1724 assert!(analyzer.matches_common_env_patterns(".env.test"));
1725
1726 assert!(!analyzer.matches_common_env_patterns(".env.example"));
1728 assert!(!analyzer.matches_common_env_patterns(".env.sample"));
1729 assert!(!analyzer.matches_common_env_patterns(".env.template"));
1730
1731 assert!(!analyzer.matches_common_env_patterns("config.json"));
1733 assert!(!analyzer.matches_common_env_patterns("package.json"));
1734 }
1735}