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};
19use rayon::prelude::*;
20use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
21
22use crate::analyzer::{ProjectAnalysis, DetectedLanguage, DetectedTechnology, EnvVar};
23use crate::analyzer::dependency_parser::Language;
24use crate::analyzer::security::{
25 ModularSecurityAnalyzer, SecurityAnalysisConfig as NewSecurityAnalysisConfig
26};
27use crate::analyzer::security::core::SecurityReport as NewSecurityReport;
28
29#[derive(Debug, Error)]
30pub enum SecurityError {
31 #[error("Security analysis failed: {0}")]
32 AnalysisFailed(String),
33
34 #[error("Configuration analysis error: {0}")]
35 ConfigAnalysisError(String),
36
37 #[error("Code pattern analysis error: {0}")]
38 CodePatternError(String),
39
40 #[error("Infrastructure analysis error: {0}")]
41 InfrastructureError(String),
42
43 #[error("IO error: {0}")]
44 Io(#[from] std::io::Error),
45
46 #[error("Regex error: {0}")]
47 Regex(#[from] regex::Error),
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub enum SecuritySeverity {
53 Critical,
54 High,
55 Medium,
56 Low,
57 Info,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
62pub enum SecurityCategory {
63 SecretsExposure,
65 InsecureConfiguration,
67 CodeSecurityPattern,
69 InfrastructureSecurity,
71 AuthenticationSecurity,
73 DataProtection,
75 NetworkSecurity,
77 Compliance,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SecurityFinding {
84 pub id: String,
85 pub title: String,
86 pub description: String,
87 pub severity: SecuritySeverity,
88 pub category: SecurityCategory,
89 pub file_path: Option<PathBuf>,
90 pub line_number: Option<usize>,
91 pub column_number: Option<usize>,
92 pub evidence: Option<String>,
93 pub remediation: Vec<String>,
94 pub references: Vec<String>,
95 pub cwe_id: Option<String>,
96 pub compliance_frameworks: Vec<String>,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
101pub struct SecurityReport {
102 pub analyzed_at: chrono::DateTime<chrono::Utc>,
103 pub overall_score: f32, pub risk_level: SecuritySeverity,
105 pub total_findings: usize,
106 pub findings_by_severity: HashMap<SecuritySeverity, usize>,
107 pub findings_by_category: HashMap<SecurityCategory, usize>,
108 pub findings: Vec<SecurityFinding>,
109 pub recommendations: Vec<String>,
110 pub compliance_status: HashMap<String, ComplianceStatus>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ComplianceStatus {
116 pub framework: String,
117 pub coverage: f32, pub missing_controls: Vec<String>,
119 pub recommendations: Vec<String>,
120}
121
122#[derive(Debug, Clone)]
124pub struct SecurityAnalysisConfig {
125 pub include_low_severity: bool,
126 pub check_secrets: bool,
127 pub check_code_patterns: bool,
128 pub check_infrastructure: bool,
129 pub check_compliance: bool,
130 pub frameworks_to_check: Vec<String>,
131 pub ignore_patterns: Vec<String>,
132 pub skip_gitignored_files: bool,
134 pub downgrade_gitignored_severity: bool,
136}
137
138impl Default for SecurityAnalysisConfig {
139 fn default() -> Self {
140 Self {
141 include_low_severity: false,
142 check_secrets: true,
143 check_code_patterns: true,
144 check_infrastructure: true,
145 check_compliance: true,
146 frameworks_to_check: vec![
147 "SOC2".to_string(),
148 "GDPR".to_string(),
149 "OWASP".to_string(),
150 ],
151 ignore_patterns: vec![
152 "node_modules".to_string(),
153 ".git".to_string(),
154 "target".to_string(),
155 "build".to_string(),
156 ".next".to_string(),
157 "dist".to_string(),
158 "test".to_string(),
159 "tests".to_string(),
160 "*.json".to_string(), "*.lock".to_string(), "*_sample.*".to_string(), "*audit*".to_string(), ],
165 skip_gitignored_files: true, downgrade_gitignored_severity: false, }
168 }
169}
170
171pub struct SecurityAnalyzer {
172 config: SecurityAnalysisConfig,
173 secret_patterns: Vec<SecretPattern>,
174 security_rules: HashMap<Language, Vec<SecurityRule>>,
175 git_ignore_cache: std::sync::Mutex<HashMap<PathBuf, bool>>,
176 project_root: Option<PathBuf>,
177}
178
179struct SecretPattern {
181 name: String,
182 pattern: Regex,
183 severity: SecuritySeverity,
184 description: String,
185}
186
187struct SecurityRule {
189 id: String,
190 name: String,
191 pattern: Regex,
192 severity: SecuritySeverity,
193 category: SecurityCategory,
194 description: String,
195 remediation: Vec<String>,
196 cwe_id: Option<String>,
197}
198
199impl SecurityAnalyzer {
200 pub fn new() -> Result<Self, SecurityError> {
201 Self::with_config(SecurityAnalysisConfig::default())
202 }
203
204 pub fn with_config(config: SecurityAnalysisConfig) -> Result<Self, SecurityError> {
205 let secret_patterns = Self::initialize_secret_patterns()?;
206 let security_rules = Self::initialize_security_rules()?;
207
208 Ok(Self {
209 config,
210 secret_patterns,
211 security_rules,
212 git_ignore_cache: std::sync::Mutex::new(HashMap::new()),
213 project_root: None,
214 })
215 }
216
217 pub fn analyze_security_enhanced(&mut self, analysis: &ProjectAnalysis) -> Result<NewSecurityReport, SecurityError> {
219 let start_time = Instant::now();
220 info!("Starting enhanced modular security analysis");
221
222 let has_javascript = analysis.languages.iter()
224 .any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX"));
225
226 let config = if has_javascript {
227 NewSecurityAnalysisConfig::for_javascript()
228 } else {
229 NewSecurityAnalysisConfig::default()
230 };
231
232 let mut modular_analyzer = ModularSecurityAnalyzer::with_config(config)
233 .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?;
234
235 let enhanced_report = modular_analyzer.analyze_project(&analysis.project_root, &analysis.languages)
237 .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?;
238
239 let duration = start_time.elapsed().as_secs_f32();
244 info!("Enhanced security analysis completed in {:.1}s - Found {} issues", duration, enhanced_report.total_findings);
245
246 Ok(enhanced_report)
247 }
248
249 pub fn analyze_security(&mut self, analysis: &ProjectAnalysis) -> Result<SecurityReport, SecurityError> {
251 let start_time = Instant::now();
252 info!("Starting comprehensive security analysis");
253
254 self.project_root = Some(analysis.project_root.clone());
256
257 let is_verbose = log::max_level() >= log::LevelFilter::Info;
259
260 let multi_progress = MultiProgress::new();
262
263 let mut total_phases = 0;
267 if self.config.check_secrets { total_phases += 1; }
268 if self.config.check_code_patterns { total_phases += 1; }
269 if self.config.check_infrastructure { total_phases += 1; }
270 total_phases += 2; let main_pb = if is_verbose {
274 None } else {
276 let pb = multi_progress.add(ProgressBar::new(100));
278 pb.set_style(
279 ProgressStyle::default_bar()
280 .template("🛡️ {msg} {bar:50.cyan/blue} {percent}% [{elapsed_precise}]")
281 .unwrap()
282 .progress_chars("██▉▊▋▌▍▎▏ "),
283 );
284 Some(pb)
285 };
286
287 let mut findings = Vec::new();
288 let phase_weight = if is_verbose { 1u64 } else { 100 / total_phases as u64 };
289 let mut current_progress = 0u64;
290
291 if self.config.check_secrets {
293 if let Some(ref pb) = main_pb {
294 pb.set_message("Analyzing configuration & secrets...");
295 pb.set_position(current_progress);
296 }
297
298 if is_verbose {
299 findings.extend(self.analyze_configuration_security(&analysis.project_root)?);
300 } else {
301 findings.extend(self.analyze_configuration_security_with_progress(&analysis.project_root, &multi_progress)?);
302 }
303
304 if let Some(ref pb) = main_pb {
305 current_progress += phase_weight;
306 pb.set_position(current_progress);
307 }
308 }
309
310 if self.config.check_code_patterns {
312 if let Some(ref pb) = main_pb {
313 pb.set_message("Analyzing code security patterns...");
314 }
315
316 if is_verbose {
317 findings.extend(self.analyze_code_security_patterns(&analysis.project_root, &analysis.languages)?);
318 } else {
319 findings.extend(self.analyze_code_security_patterns_with_progress(&analysis.project_root, &analysis.languages, &multi_progress)?);
320 }
321
322 if let Some(ref pb) = main_pb {
323 current_progress += phase_weight;
324 pb.set_position(current_progress);
325 }
326 }
327
328 if let Some(ref pb) = main_pb {
334 pb.set_message("Analyzing environment variables...");
335 }
336
337 findings.extend(self.analyze_environment_security(&analysis.environment_variables));
338 if let Some(ref pb) = main_pb {
339 current_progress += phase_weight;
340 pb.set_position(current_progress);
341 }
342
343 if let Some(ref pb) = main_pb {
348 current_progress = 100;
349 pb.set_position(current_progress);
350 }
351
352 if let Some(ref pb) = main_pb {
354 pb.set_message("Processing findings & generating report...");
355 }
356
357 let pre_dedup_count = findings.len();
359 findings = self.deduplicate_findings(findings);
360 let post_dedup_count = findings.len();
361
362 if pre_dedup_count != post_dedup_count {
363 info!("Deduplicated {} redundant findings, {} unique findings remain",
364 pre_dedup_count - post_dedup_count, post_dedup_count);
365 }
366
367 let pre_filter_count = findings.len();
369 if !self.config.include_low_severity {
370 findings.retain(|f| f.severity != SecuritySeverity::Low && f.severity != SecuritySeverity::Info);
371 }
372
373 findings.sort_by(|a, b| a.severity.cmp(&b.severity));
375
376 let total_findings = findings.len();
378 let findings_by_severity = self.count_by_severity(&findings);
379 let findings_by_category = self.count_by_category(&findings);
380 let overall_score = self.calculate_security_score(&findings);
381 let risk_level = self.determine_risk_level(&findings);
382
383 let compliance_status = HashMap::new();
386
387 let recommendations = self.generate_recommendations(&findings, &analysis.technologies);
389
390 let duration = start_time.elapsed().as_secs_f32();
392 if let Some(pb) = main_pb {
393 pb.finish_with_message(format!("✅ Security analysis completed in {:.1}s - Found {} issues", duration, total_findings));
394 }
395
396 if pre_filter_count != total_findings {
398 info!("Found {} total findings, showing {} after filtering", pre_filter_count, total_findings);
399 } else {
400 info!("Found {} security findings", total_findings);
401 }
402
403 Ok(SecurityReport {
404 analyzed_at: chrono::Utc::now(),
405 overall_score,
406 risk_level,
407 total_findings,
408 findings_by_severity,
409 findings_by_category,
410 findings,
411 recommendations,
412 compliance_status,
413 })
414 }
415
416 fn is_file_gitignored(&self, file_path: &Path) -> bool {
418 let project_root = match &self.project_root {
420 Some(root) => root,
421 None => return false,
422 };
423
424 if let Ok(cache) = self.git_ignore_cache.lock() {
426 if let Some(&cached_result) = cache.get(file_path) {
427 return cached_result;
428 }
429 }
430
431 if !project_root.join(".git").exists() {
433 debug!("Not a git repository, treating all files as tracked");
434 return false;
435 }
436
437 let git_result = Command::new("git")
439 .args(&["check-ignore", "--quiet"])
440 .arg(file_path)
441 .current_dir(project_root)
442 .output()
443 .map(|output| output.status.success())
444 .unwrap_or(false);
445
446 if git_result {
448 if let Ok(mut cache) = self.git_ignore_cache.lock() {
449 cache.insert(file_path.to_path_buf(), true);
450 }
451 return true;
452 }
453
454 let manual_result = self.check_gitignore_patterns(file_path, project_root);
457
458 let final_result = git_result || manual_result;
460 if let Ok(mut cache) = self.git_ignore_cache.lock() {
461 cache.insert(file_path.to_path_buf(), final_result);
462 }
463
464 final_result
465 }
466
467 fn check_gitignore_patterns(&self, file_path: &Path, project_root: &Path) -> bool {
469 let relative_path = match file_path.strip_prefix(project_root) {
471 Ok(rel) => rel,
472 Err(_) => return false,
473 };
474
475 let path_str = relative_path.to_string_lossy();
476 let file_name = relative_path.file_name()
477 .and_then(|n| n.to_str())
478 .unwrap_or("");
479
480 let gitignore_path = project_root.join(".gitignore");
482 if let Ok(gitignore_content) = fs::read_to_string(&gitignore_path) {
483 for line in gitignore_content.lines() {
484 let pattern = line.trim();
485 if pattern.is_empty() || pattern.starts_with('#') {
486 continue;
487 }
488
489 if self.matches_gitignore_pattern(pattern, &path_str, file_name) {
491 debug!("File {} matches gitignore pattern: {}", path_str, pattern);
492 return true;
493 }
494 }
495 }
496
497 self.matches_common_env_patterns(file_name)
499 }
500
501 fn matches_gitignore_pattern(&self, pattern: &str, path_str: &str, file_name: &str) -> bool {
503 if pattern.contains('*') {
505 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
507 if glob_pattern.matches(path_str) || glob_pattern.matches(file_name) {
509 return true;
510 }
511 }
512 } else if pattern.starts_with('/') {
513 let abs_pattern = &pattern[1..];
515 if path_str == abs_pattern {
516 return true;
517 }
518 } else {
519 if path_str == pattern ||
521 file_name == pattern ||
522 path_str.ends_with(&format!("/{}", pattern)) {
523 return true;
524 }
525 }
526
527 false
528 }
529
530 fn matches_common_env_patterns(&self, file_name: &str) -> bool {
532 let common_env_patterns = [
533 ".env",
534 ".env.local",
535 ".env.development",
536 ".env.production",
537 ".env.staging",
538 ".env.test",
539 ".env.example", ];
541
542 if common_env_patterns.contains(&file_name) {
544 return file_name != ".env.example"; }
546
547 if file_name.starts_with(".env.") ||
549 file_name.ends_with(".env") ||
550 (file_name.starts_with(".") && file_name.contains("env")) {
551 return !file_name.contains("example") &&
553 !file_name.contains("sample") &&
554 !file_name.contains("template");
555 }
556
557 false
558 }
559
560 fn is_file_tracked(&self, file_path: &Path) -> bool {
562 let project_root = match &self.project_root {
563 Some(root) => root,
564 None => return true, };
566
567 if !project_root.join(".git").exists() {
569 return true; }
571
572 Command::new("git")
574 .args(&["ls-files", "--error-unmatch"])
575 .arg(file_path)
576 .current_dir(project_root)
577 .output()
578 .map(|output| output.status.success())
579 .unwrap_or(true) }
581
582 fn determine_secret_severity(&self, file_path: &Path, original_severity: SecuritySeverity) -> (SecuritySeverity, Vec<String>) {
584 let mut additional_remediation = Vec::new();
585
586 if self.is_file_gitignored(file_path) {
588 if self.config.skip_gitignored_files {
589 return (SecuritySeverity::Info, vec!["File is properly gitignored".to_string()]);
591 } else if self.config.downgrade_gitignored_severity {
592 let downgraded = match original_severity {
594 SecuritySeverity::Critical => SecuritySeverity::Medium,
595 SecuritySeverity::High => SecuritySeverity::Low,
596 SecuritySeverity::Medium => SecuritySeverity::Low,
597 SecuritySeverity::Low => SecuritySeverity::Info,
598 SecuritySeverity::Info => SecuritySeverity::Info,
599 };
600 additional_remediation.push("Note: File is gitignored, reducing severity".to_string());
601 return (downgraded, additional_remediation);
602 }
603 }
604
605 if !self.is_file_tracked(file_path) {
607 additional_remediation.push("Ensure this file is added to .gitignore to prevent accidental commits".to_string());
608 } else {
609 additional_remediation.push("⚠️ CRITICAL: This file is tracked by git! Secrets may be in version history.".to_string());
611 additional_remediation.push("Consider using git-filter-branch or BFG Repo-Cleaner to remove from history".to_string());
612 additional_remediation.push("Rotate any exposed secrets immediately".to_string());
613
614 let upgraded = match original_severity {
616 SecuritySeverity::High => SecuritySeverity::Critical,
617 SecuritySeverity::Medium => SecuritySeverity::High,
618 SecuritySeverity::Low => SecuritySeverity::Medium,
619 other => other,
620 };
621 return (upgraded, additional_remediation);
622 }
623
624 (original_severity, additional_remediation)
625 }
626
627 fn initialize_secret_patterns() -> Result<Vec<SecretPattern>, SecurityError> {
629 let patterns = vec![
630 ("AWS Access Key", r"AKIA[0-9A-Z]{16}", SecuritySeverity::Critical),
632 ("AWS Secret Key", r#"(?i)(aws[_-]?secret|secret[_-]?access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?"#, SecuritySeverity::Critical),
633 ("S3 Secret Key", r#"(?i)(s3[_-]?secret[_-]?key|linode[_-]?s3[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{20,}["']?"#, SecuritySeverity::High),
634 ("GitHub Token", r"gh[pousr]_[A-Za-z0-9_]{36,255}", SecuritySeverity::High),
635 ("OpenAI API Key", r"sk-[A-Za-z0-9]{48}", SecuritySeverity::High),
636 ("Stripe API Key", r"sk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Critical),
637 ("Stripe Publishable Key", r"pk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Medium),
638
639 ("Hardcoded Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?(postgresql|mysql|mongodb)://[^"'\s]+"#, SecuritySeverity::Critical),
641 ("Hardcoded Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}["']?"#, SecuritySeverity::High),
642 ("JWT Secret", r#"(?i)(jwt[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{20,}"#, SecuritySeverity::High),
643
644 ("RSA Private Key", r"-----BEGIN RSA PRIVATE KEY-----", SecuritySeverity::Critical),
646 ("SSH Private Key", r"-----BEGIN OPENSSH PRIVATE KEY-----", SecuritySeverity::Critical),
647 ("PGP Private Key", r"-----BEGIN PGP PRIVATE KEY BLOCK-----", SecuritySeverity::Critical),
648
649 ("Google Cloud Service Account", r#""type":\s*"service_account""#, SecuritySeverity::High),
651 ("Azure Storage Key", r"DefaultEndpointsProtocol=https;AccountName=", SecuritySeverity::High),
652
653 ("Client-side Exposed Secret", r#"(?i)(REACT_APP_|NEXT_PUBLIC_|VUE_APP_|VITE_)[A-Z_]*(?:SECRET|KEY|TOKEN|PASSWORD|API)[A-Z_]*["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{10,}"#, SecuritySeverity::High),
655
656 ("Hardcoded API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}["']?"#, SecuritySeverity::High),
658
659 ("Hardcoded Secret", r#"(?i)(secret|token)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}["']?"#, SecuritySeverity::Medium),
661 ];
662
663 patterns.into_iter()
664 .map(|(name, pattern, severity)| {
665 Ok(SecretPattern {
666 name: name.to_string(),
667 pattern: Regex::new(pattern)?,
668 severity,
669 description: format!("Potential {} found in code", name),
670 })
671 })
672 .collect()
673 }
674
675 fn initialize_security_rules() -> Result<HashMap<Language, Vec<SecurityRule>>, SecurityError> {
677 let mut rules = HashMap::new();
678
679 rules.insert(Language::JavaScript, vec![
681 SecurityRule {
682 id: "js-001".to_string(),
683 name: "Eval Usage".to_string(),
684 pattern: Regex::new(r"\beval\s*\(")?,
685 severity: SecuritySeverity::High,
686 category: SecurityCategory::CodeSecurityPattern,
687 description: "Use of eval() can lead to code injection vulnerabilities".to_string(),
688 remediation: vec![
689 "Avoid using eval() with user input".to_string(),
690 "Use JSON.parse() for parsing JSON data".to_string(),
691 "Consider using safer alternatives like Function constructor with validation".to_string(),
692 ],
693 cwe_id: Some("CWE-95".to_string()),
694 },
695 SecurityRule {
696 id: "js-002".to_string(),
697 name: "innerHTML Usage".to_string(),
698 pattern: Regex::new(r"\.innerHTML\s*=")?,
699 severity: SecuritySeverity::Medium,
700 category: SecurityCategory::CodeSecurityPattern,
701 description: "innerHTML can lead to XSS vulnerabilities if used with unsanitized data".to_string(),
702 remediation: vec![
703 "Use textContent instead of innerHTML for text".to_string(),
704 "Sanitize HTML content before setting innerHTML".to_string(),
705 "Consider using secure templating libraries".to_string(),
706 ],
707 cwe_id: Some("CWE-79".to_string()),
708 },
709 ]);
710
711 rules.insert(Language::Python, vec![
713 SecurityRule {
714 id: "py-001".to_string(),
715 name: "SQL Injection Risk".to_string(),
716 pattern: Regex::new(r#"\.execute\s*\(\s*[f]?["'][^"']*%[sd]"#)?,
717 severity: SecuritySeverity::High,
718 category: SecurityCategory::CodeSecurityPattern,
719 description: "String formatting in SQL queries can lead to SQL injection".to_string(),
720 remediation: vec![
721 "Use parameterized queries instead of string formatting".to_string(),
722 "Use ORM query builders where possible".to_string(),
723 "Validate and sanitize all user inputs".to_string(),
724 ],
725 cwe_id: Some("CWE-89".to_string()),
726 },
727 SecurityRule {
728 id: "py-002".to_string(),
729 name: "Pickle Usage".to_string(),
730 pattern: Regex::new(r"\bpickle\.loads?\s*\(")?,
731 severity: SecuritySeverity::High,
732 category: SecurityCategory::CodeSecurityPattern,
733 description: "Pickle can execute arbitrary code during deserialization".to_string(),
734 remediation: vec![
735 "Avoid pickle for untrusted data".to_string(),
736 "Use JSON or other safe serialization formats".to_string(),
737 "If pickle is necessary, validate data sources".to_string(),
738 ],
739 cwe_id: Some("CWE-502".to_string()),
740 },
741 ]);
742
743 Ok(rules)
746 }
747
748 fn analyze_configuration_security_with_progress(&self, project_root: &Path, multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
750 debug!("Analyzing configuration security");
751 let mut findings = Vec::new();
752
753 let config_files = self.collect_config_files(project_root)?;
755
756 if config_files.is_empty() {
757 info!("No configuration files found");
758 return Ok(findings);
759 }
760
761 let is_verbose = log::max_level() >= log::LevelFilter::Info;
762
763 info!("📁 Found {} configuration files to analyze", config_files.len());
764
765 let file_pb = if is_verbose {
767 None } else {
769 let pb = multi_progress.add(ProgressBar::new(config_files.len() as u64));
771 pb.set_style(
772 ProgressStyle::default_bar()
773 .template(" 🔍 {msg} {bar:40.cyan/blue} {pos}/{len} files ({percent}%)")
774 .unwrap()
775 .progress_chars("████▉▊▋▌▍▎▏ "),
776 );
777 pb.set_message("Scanning configuration files...");
778 Some(pb)
779 };
780
781 use std::sync::atomic::{AtomicUsize, Ordering};
783 use std::sync::Arc;
784 let processed_count = Arc::new(AtomicUsize::new(0));
785
786 let file_findings: Vec<Vec<SecurityFinding>> = config_files
788 .par_iter()
789 .map(|file_path| {
790 let result = self.analyze_file_for_secrets(file_path);
791
792 if let Some(ref pb) = file_pb {
794 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
795 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str()) {
796 let display_name = if file_name.len() > 30 {
798 format!("...{}", &file_name[file_name.len()-27..])
799 } else {
800 file_name.to_string()
801 };
802 pb.set_message(format!("Scanning {}", display_name));
803 }
804 pb.set_position(current as u64);
805 }
806
807 result
808 })
809 .filter_map(|result| result.ok())
810 .collect();
811
812 if let Some(pb) = file_pb {
814 pb.finish_with_message(format!("✅ Scanned {} configuration files", config_files.len()));
815 }
816
817 for mut file_findings in file_findings {
818 findings.append(&mut file_findings);
819 }
820
821 findings.extend(self.check_insecure_configurations(project_root)?);
823
824 info!("🔍 Found {} configuration security findings", findings.len());
825 Ok(findings)
826 }
827
828 fn analyze_configuration_security(&self, project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
830 debug!("Analyzing configuration security");
831 let mut findings = Vec::new();
832
833 let config_files = self.collect_config_files(project_root)?;
835
836 if config_files.is_empty() {
837 info!("No configuration files found");
838 return Ok(findings);
839 }
840
841 info!("📁 Found {} configuration files to analyze", config_files.len());
842
843 let file_findings: Vec<Vec<SecurityFinding>> = config_files
845 .par_iter()
846 .map(|file_path| self.analyze_file_for_secrets(file_path))
847 .filter_map(|result| result.ok())
848 .collect();
849
850 for mut file_findings in file_findings {
851 findings.append(&mut file_findings);
852 }
853
854 findings.extend(self.check_insecure_configurations(project_root)?);
856
857 info!("🔍 Found {} configuration security findings", findings.len());
858 Ok(findings)
859 }
860
861 fn analyze_code_security_patterns_with_progress(&self, project_root: &Path, languages: &[DetectedLanguage], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
863 debug!("Analyzing code security patterns");
864 let mut findings = Vec::new();
865
866 let mut total_files = 0;
868 let mut language_files = Vec::new();
869
870 for language in languages {
871 if let Some(lang) = Language::from_string(&language.name) {
872 if let Some(_rules) = self.security_rules.get(&lang) {
873 let source_files = self.collect_source_files(project_root, &language.name)?;
874 total_files += source_files.len();
875 language_files.push((language, source_files));
876 }
877 }
878 }
879
880 if total_files == 0 {
881 info!("No source files found for code pattern analysis");
882 return Ok(findings);
883 }
884
885 let is_verbose = log::max_level() >= log::LevelFilter::Info;
886
887 info!("📄 Found {} source files across {} languages", total_files, language_files.len());
888
889 let code_pb = if is_verbose {
891 None
893 } else {
894 let pb = multi_progress.add(ProgressBar::new(total_files as u64));
896 pb.set_style(
897 ProgressStyle::default_bar()
898 .template(" 📄 {msg} {bar:40.yellow/white} {pos}/{len} files ({percent}%)")
899 .unwrap()
900 .progress_chars("████▉▊▋▌▍▎▏ "),
901 );
902 pb.set_message("Scanning source code...");
903 Some(pb)
904 };
905
906
907 use std::sync::atomic::{AtomicUsize, Ordering};
909 use std::sync::Arc;
910 let processed_count = Arc::new(AtomicUsize::new(0));
911
912 for (language, source_files) in language_files {
914 if let Some(lang) = Language::from_string(&language.name) {
915 if let Some(rules) = self.security_rules.get(&lang) {
916 let file_findings: Vec<Vec<SecurityFinding>> = source_files
917 .par_iter()
918 .map(|file_path| {
919 let result = self.analyze_file_with_rules(file_path, rules);
920
921 if let Some(ref pb) = code_pb {
923 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
924 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str()) {
925 let display_name = if file_name.len() > 25 {
926 format!("...{}", &file_name[file_name.len()-22..])
927 } else {
928 file_name.to_string()
929 };
930 pb.set_message(format!("Scanning {} ({})", display_name, language.name));
931 }
932 pb.set_position(current as u64);
933 }
934
935 result
936 })
937 .filter_map(|result| result.ok())
938 .collect();
939
940 for mut file_findings in file_findings {
941 findings.append(&mut file_findings);
942 }
943 }
944 }
945 }
946
947 if let Some(pb) = code_pb {
949 pb.finish_with_message(format!("✅ Scanned {} source files", total_files));
950 }
951
952 info!("🔍 Found {} code security findings", findings.len());
953 Ok(findings)
954 }
955
956 fn analyze_code_security_patterns(&self, project_root: &Path, languages: &[DetectedLanguage]) -> Result<Vec<SecurityFinding>, SecurityError> {
958 debug!("Analyzing code security patterns");
959 let mut findings = Vec::new();
960
961 let mut total_files = 0;
963 let mut language_files = Vec::new();
964
965 for language in languages {
966 if let Some(lang) = Language::from_string(&language.name) {
967 if let Some(_rules) = self.security_rules.get(&lang) {
968 let source_files = self.collect_source_files(project_root, &language.name)?;
969 total_files += source_files.len();
970 language_files.push((language, source_files));
971 }
972 }
973 }
974
975 if total_files == 0 {
976 info!("No source files found for code pattern analysis");
977 return Ok(findings);
978 }
979
980 info!("📄 Found {} source files across {} languages", total_files, language_files.len());
981
982 for (language, source_files) in language_files {
984 if let Some(lang) = Language::from_string(&language.name) {
985 if let Some(rules) = self.security_rules.get(&lang) {
986 let file_findings: Vec<Vec<SecurityFinding>> = source_files
987 .par_iter()
988 .map(|file_path| self.analyze_file_with_rules(file_path, rules))
989 .filter_map(|result| result.ok())
990 .collect();
991
992 for mut file_findings in file_findings {
993 findings.append(&mut file_findings);
994 }
995 }
996 }
997 }
998
999 info!("🔍 Found {} code security findings", findings.len());
1000 Ok(findings)
1001 }
1002
1003 fn analyze_infrastructure_security_with_progress(&self, project_root: &Path, _technologies: &[DetectedTechnology], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
1005 debug!("Analyzing infrastructure security");
1006 let mut findings = Vec::new();
1007
1008 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1009
1010 let infra_pb = if is_verbose {
1012 None
1014 } else {
1015 let pb = multi_progress.add(ProgressBar::new_spinner());
1017 pb.set_style(
1018 ProgressStyle::default_spinner()
1019 .template(" 🏗️ {msg} {spinner:.magenta}")
1020 .unwrap()
1021 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
1022 );
1023 pb.enable_steady_tick(std::time::Duration::from_millis(100));
1024 Some(pb)
1025 };
1026
1027 if let Some(ref pb) = infra_pb {
1029 pb.set_message("Checking Dockerfiles & Compose files...");
1030 }
1031 findings.extend(self.analyze_dockerfile_security(project_root)?);
1032 findings.extend(self.analyze_compose_security(project_root)?);
1033
1034 if let Some(ref pb) = infra_pb {
1036 pb.set_message("Checking CI/CD configurations...");
1037 }
1038 findings.extend(self.analyze_cicd_security(project_root)?);
1039
1040 if let Some(pb) = infra_pb {
1042 pb.finish_with_message("✅ Infrastructure analysis complete");
1043 }
1044 info!("🔍 Found {} infrastructure security findings", findings.len());
1045
1046 Ok(findings)
1047 }
1048
1049 fn analyze_infrastructure_security(&self, project_root: &Path, _technologies: &[DetectedTechnology]) -> Result<Vec<SecurityFinding>, SecurityError> {
1051 debug!("Analyzing infrastructure security");
1052 let mut findings = Vec::new();
1053
1054 findings.extend(self.analyze_dockerfile_security(project_root)?);
1056 findings.extend(self.analyze_compose_security(project_root)?);
1057
1058 findings.extend(self.analyze_cicd_security(project_root)?);
1060
1061 info!("🔍 Found {} infrastructure security findings", findings.len());
1062 Ok(findings)
1063 }
1064
1065 fn analyze_environment_security(&self, env_vars: &[EnvVar]) -> Vec<SecurityFinding> {
1067 let mut findings = Vec::new();
1068
1069 for env_var in env_vars {
1070 if self.is_sensitive_env_var(&env_var.name) && env_var.default_value.is_some() {
1072 findings.push(SecurityFinding {
1073 id: format!("env-{}", env_var.name.to_lowercase()),
1074 title: "Sensitive Environment Variable with Default Value".to_string(),
1075 description: format!("Environment variable '{}' appears to contain sensitive data but has a default value", env_var.name),
1076 severity: SecuritySeverity::Medium,
1077 category: SecurityCategory::SecretsExposure,
1078 file_path: None,
1079 line_number: None,
1080 column_number: None,
1081 evidence: Some(format!("Variable: {} = {:?}", env_var.name, env_var.default_value)),
1082 remediation: vec![
1083 "Remove default value for sensitive environment variables".to_string(),
1084 "Use a secure secret management system".to_string(),
1085 "Document required environment variables separately".to_string(),
1086 ],
1087 references: vec![
1088 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
1089 ],
1090 cwe_id: Some("CWE-200".to_string()),
1091 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1092 });
1093 }
1094 }
1095
1096 findings
1097 }
1098
1099 fn analyze_framework_security_with_progress(&self, project_root: &Path, technologies: &[DetectedTechnology], multi_progress: &MultiProgress) -> Result<Vec<SecurityFinding>, SecurityError> {
1101 debug!("Analyzing framework-specific security");
1102 let mut findings = Vec::new();
1103
1104 let framework_count = technologies.len();
1105 if framework_count == 0 {
1106 info!("No frameworks detected for security analysis");
1107 return Ok(findings);
1108 }
1109
1110 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1111
1112 info!("🔧 Found {} frameworks to analyze", framework_count);
1113
1114 let fw_pb = if is_verbose {
1116 None
1118 } else {
1119 let pb = multi_progress.add(ProgressBar::new_spinner());
1121 pb.set_style(
1122 ProgressStyle::default_spinner()
1123 .template(" 🔧 {msg} {spinner:.cyan}")
1124 .unwrap()
1125 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
1126 );
1127 pb.enable_steady_tick(std::time::Duration::from_millis(120));
1128 Some(pb)
1129 };
1130
1131 for tech in technologies {
1132 if let Some(ref pb) = fw_pb {
1133 pb.set_message(format!("Checking {} configuration...", tech.name));
1134 }
1135
1136 match tech.name.as_str() {
1137 "Express.js" | "Express" => {
1138 findings.extend(self.analyze_express_security(project_root)?);
1139 },
1140 "Django" => {
1141 findings.extend(self.analyze_django_security(project_root)?);
1142 },
1143 "Spring Boot" => {
1144 findings.extend(self.analyze_spring_security(project_root)?);
1145 },
1146 "Next.js" => {
1147 findings.extend(self.analyze_nextjs_security(project_root)?);
1148 },
1149 _ => {}
1151 }
1152 }
1153
1154 if let Some(pb) = fw_pb {
1156 pb.finish_with_message("✅ Framework analysis complete");
1157 }
1158 info!("🔍 Found {} framework security findings", findings.len());
1159
1160 Ok(findings)
1161 }
1162
1163 fn analyze_framework_security(&self, project_root: &Path, technologies: &[DetectedTechnology]) -> Result<Vec<SecurityFinding>, SecurityError> {
1165 debug!("Analyzing framework-specific security");
1166 let mut findings = Vec::new();
1167
1168 let framework_count = technologies.len();
1169 if framework_count == 0 {
1170 info!("No frameworks detected for security analysis");
1171 return Ok(findings);
1172 }
1173
1174 info!("🔧 Found {} frameworks to analyze", framework_count);
1175
1176 for tech in technologies {
1177 match tech.name.as_str() {
1178 "Express.js" | "Express" => {
1179 findings.extend(self.analyze_express_security(project_root)?);
1180 },
1181 "Django" => {
1182 findings.extend(self.analyze_django_security(project_root)?);
1183 },
1184 "Spring Boot" => {
1185 findings.extend(self.analyze_spring_security(project_root)?);
1186 },
1187 "Next.js" => {
1188 findings.extend(self.analyze_nextjs_security(project_root)?);
1189 },
1190 _ => {}
1192 }
1193 }
1194
1195 info!("🔍 Found {} framework security findings", findings.len());
1196 Ok(findings)
1197 }
1198
1199 fn collect_config_files(&self, project_root: &Path) -> Result<Vec<PathBuf>, SecurityError> {
1202 let patterns = vec![
1203 "*.env*", "*.conf", "*.config", "*.ini", "*.yaml", "*.yml",
1204 "*.toml", "docker-compose*.yml", "Dockerfile*",
1205 ".github/**/*.yml", ".gitlab-ci.yml", "package.json",
1206 "requirements.txt", "Cargo.toml", "go.mod", "pom.xml",
1207 ];
1208
1209 let mut files = crate::common::file_utils::find_files_by_patterns(project_root, &patterns)
1210 .map_err(|e| SecurityError::Io(e))?;
1211
1212 files.retain(|file| {
1214 let file_name = file.file_name()
1215 .and_then(|n| n.to_str())
1216 .unwrap_or("");
1217 let file_path = file.to_string_lossy();
1218
1219 !self.config.ignore_patterns.iter().any(|pattern| {
1220 if pattern.contains('*') {
1221 glob::Pattern::new(pattern)
1223 .map(|p| p.matches(&file_path) || p.matches(file_name))
1224 .unwrap_or(false)
1225 } else {
1226 file_path.contains(pattern) || file_name.contains(pattern)
1228 }
1229 })
1230 });
1231
1232 Ok(files)
1233 }
1234
1235 fn analyze_file_for_secrets(&self, file_path: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1236 let content = fs::read_to_string(file_path)?;
1237 let mut findings = Vec::new();
1238
1239 for (line_num, line) in content.lines().enumerate() {
1240 for pattern in &self.secret_patterns {
1241 if let Some(match_) = pattern.pattern.find(line) {
1242 if self.is_likely_placeholder(line) {
1244 continue;
1245 }
1246
1247 if self.is_legitimate_env_var_usage(line, file_path) {
1249 debug!("Skipping legitimate env var usage: {}", line.trim());
1250 continue;
1251 }
1252
1253 let (severity, additional_remediation) = self.determine_secret_severity(file_path, pattern.severity.clone());
1255
1256 if self.config.skip_gitignored_files && severity == SecuritySeverity::Info {
1258 debug!("Skipping secret in gitignored file: {}", file_path.display());
1259 continue;
1260 }
1261
1262 let mut remediation = vec![
1264 "Remove sensitive data from source code".to_string(),
1265 "Use environment variables for secrets".to_string(),
1266 "Consider using a secure secret management service".to_string(),
1267 ];
1268
1269 remediation.extend(additional_remediation);
1271
1272 if !self.is_file_gitignored(file_path) && !self.is_file_tracked(file_path) {
1274 remediation.push("Add this file to .gitignore to prevent accidental commits".to_string());
1275 }
1276
1277 let mut description = pattern.description.clone();
1279 if self.is_file_tracked(file_path) {
1280 description.push_str(" (⚠️ WARNING: File is tracked by git - secrets may be in version history!)");
1281 } else if self.is_file_gitignored(file_path) {
1282 description.push_str(" (ℹ️ Note: File is gitignored)");
1283 }
1284
1285 findings.push(SecurityFinding {
1286 id: format!("secret-{}-{}", pattern.name.to_lowercase().replace(' ', "-"), line_num),
1287 title: format!("Potential {} Exposure", pattern.name),
1288 description,
1289 severity,
1290 category: SecurityCategory::SecretsExposure,
1291 file_path: Some(file_path.to_path_buf()),
1292 line_number: Some(line_num + 1),
1293 column_number: Some(match_.start() + 1), evidence: Some(format!("Line: {}", line.trim())),
1295 remediation,
1296 references: vec![
1297 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
1298 ],
1299 cwe_id: Some("CWE-200".to_string()),
1300 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1301 });
1302 }
1303 }
1304 }
1305
1306 Ok(findings)
1307 }
1308
1309 fn is_legitimate_env_var_usage(&self, line: &str, file_path: &Path) -> bool {
1311 let line_trimmed = line.trim();
1312
1313 let legitimate_env_patterns = [
1315 r"process\.env\.[A-Z_]+",
1317 r#"process\.env\[['""][A-Z_]+['"]\]"#,
1318
1319 r"import\.meta\.env\.[A-Z_]+",
1321 r#"import\.meta\.env\[['""][A-Z_]+['"]\]"#,
1322
1323 r#"os\.environ\.get\(["'][A-Z_]+["']\)"#,
1325 r#"os\.environ\[["'][A-Z_]+["']\]"#,
1326 r#"getenv\(["'][A-Z_]+["']\)"#,
1327
1328 r#"env::var\("([A-Z_]+)"\)"#,
1330 r#"std::env::var\("([A-Z_]+)"\)"#,
1331
1332 r#"os\.Getenv\(["'][A-Z_]+["']\)"#,
1334
1335 r#"System\.getenv\(["'][A-Z_]+["']\)"#,
1337
1338 r"\$\{?[A-Z_]+\}?",
1340 r"ENV [A-Z_]+",
1341
1342 r"config\.[a-z_]+\.[A-Z_]+",
1344 r"settings\.[A-Z_]+",
1345 r"env\.[A-Z_]+",
1346 ];
1347
1348 for pattern_str in &legitimate_env_patterns {
1350 if let Ok(pattern) = Regex::new(pattern_str) {
1351 if pattern.is_match(line_trimmed) {
1352 if self.is_server_side_file(file_path) {
1356 return true;
1357 }
1358
1359 if !self.is_client_side_exposed_env_var(line_trimmed) {
1361 return true;
1362 }
1363 }
1364 }
1365 }
1366
1367 if self.is_env_var_assignment_context(line_trimmed, file_path) {
1370 return true;
1371 }
1372
1373 false
1374 }
1375
1376 fn is_server_side_file(&self, file_path: &Path) -> bool {
1378 let path_str = file_path.to_string_lossy().to_lowercase();
1379 let file_name = file_path.file_name()
1380 .and_then(|n| n.to_str())
1381 .unwrap_or("")
1382 .to_lowercase();
1383
1384 let server_indicators = [
1386 "/server/", "/api/", "/backend/", "/src/app/api/", "/pages/api/",
1387 "/routes/", "/controllers/", "/middleware/", "/models/",
1388 "/lib/", "/utils/", "/services/", "/config/",
1389 "server.js", "index.js", "app.js", "main.js",
1390 ".env", "dockerfile", "docker-compose",
1391 ];
1392
1393 let client_indicators = [
1395 "/public/", "/static/", "/assets/", "/components/", "/pages/",
1396 "/src/components/", "/src/pages/", "/client/", "/frontend/",
1397 "index.html", ".html", "/dist/", "/build/",
1398 "dist/", "build/", "public/", "static/", "assets/",
1399 ];
1400
1401 if client_indicators.iter().any(|indicator| path_str.contains(indicator)) {
1403 return false;
1404 }
1405
1406 if server_indicators.iter().any(|indicator|
1408 path_str.contains(indicator) || file_name.contains(indicator)
1409 ) {
1410 return true;
1411 }
1412
1413 true
1415 }
1416
1417 fn is_client_side_exposed_env_var(&self, line: &str) -> bool {
1419 let client_prefixes = [
1420 "REACT_APP_", "NEXT_PUBLIC_", "VUE_APP_", "VITE_",
1421 "GATSBY_", "PUBLIC_", "NUXT_PUBLIC_",
1422 ];
1423
1424 client_prefixes.iter().any(|prefix| line.contains(prefix))
1425 }
1426
1427 fn is_env_var_assignment_context(&self, line: &str, file_path: &Path) -> bool {
1429 let path_str = file_path.to_string_lossy().to_lowercase();
1430 let file_name = file_path.file_name()
1431 .and_then(|n| n.to_str())
1432 .unwrap_or("")
1433 .to_lowercase();
1434
1435 let env_config_files = [
1438 ".env",
1439 "docker-compose.yml", "docker-compose.yaml",
1440 ".env.example", ".env.sample", ".env.template",
1441 ".env.local", ".env.development", ".env.production", ".env.staging",
1442 ];
1443
1444 if env_config_files.iter().any(|pattern| file_name == *pattern) {
1446 return true;
1447 }
1448
1449 if file_name.starts_with("dockerfile") || file_name == "dockerfile" {
1451 return true;
1452 }
1453
1454 if file_name.ends_with(".sh") ||
1456 file_name.ends_with(".bash") ||
1457 path_str.contains(".github/workflows/") ||
1458 path_str.contains(".gitlab-ci") {
1459 return true;
1460 }
1461
1462 let setup_patterns = [
1465 r"export [A-Z_]+=", r"ENV [A-Z_]+=", r"^\s*environment:\s*$", r"^\s*env:\s*$", r"process\.env\.[A-Z_]+ =", ];
1471
1472 for pattern_str in &setup_patterns {
1473 if let Ok(pattern) = Regex::new(pattern_str) {
1474 if pattern.is_match(line) {
1475 return true;
1476 }
1477 }
1478 }
1479
1480 false
1481 }
1482
1483 fn is_likely_placeholder(&self, line: &str) -> bool {
1484 let placeholder_indicators = [
1485 "example", "placeholder", "your_", "insert_", "replace_",
1486 "xxx", "yyy", "zzz", "fake", "dummy", "test_key",
1487 "sk-xxxxxxxx", "AKIA00000000",
1488 ];
1489
1490 let hash_indicators = [
1491 "checksum", "hash", "sha1", "sha256", "md5", "commit",
1492 "fingerprint", "digest", "advisory", "ghsa-", "cve-",
1493 "rustc_fingerprint", "last-commit", "references",
1494 ];
1495
1496 let line_lower = line.to_lowercase();
1497
1498 if placeholder_indicators.iter().any(|indicator| line_lower.contains(indicator)) {
1500 return true;
1501 }
1502
1503 if hash_indicators.iter().any(|indicator| line_lower.contains(indicator)) {
1505 return true;
1506 }
1507
1508 if line_lower.contains("http") || line_lower.contains("github.com") {
1510 return true;
1511 }
1512
1513 if let Some(potential_hash) = self.extract_potential_hash(line) {
1515 if potential_hash.len() >= 32 && self.is_hex_only(&potential_hash) {
1516 return true; }
1518 }
1519
1520 false
1521 }
1522
1523 fn extract_potential_hash(&self, line: &str) -> Option<String> {
1524 if let Some(start) = line.find('"') {
1526 if let Some(end) = line[start + 1..].find('"') {
1527 let potential = &line[start + 1..start + 1 + end];
1528 if potential.len() >= 32 {
1529 return Some(potential.to_string());
1530 }
1531 }
1532 }
1533 None
1534 }
1535
1536 fn is_hex_only(&self, s: &str) -> bool {
1537 s.chars().all(|c| c.is_ascii_hexdigit())
1538 }
1539
1540 fn is_sensitive_env_var(&self, name: &str) -> bool {
1541 let sensitive_patterns = [
1542 "password", "secret", "key", "token", "auth", "api",
1543 "private", "credential", "cert", "ssl", "tls",
1544 ];
1545
1546 let name_lower = name.to_lowercase();
1547 sensitive_patterns.iter().any(|pattern| name_lower.contains(pattern))
1548 }
1549
1550 fn analyze_express_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1552 Ok(vec![])
1554 }
1555
1556 fn analyze_django_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1557 Ok(vec![])
1559 }
1560
1561 fn analyze_spring_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1562 Ok(vec![])
1564 }
1565
1566 fn analyze_nextjs_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1567 Ok(vec![])
1569 }
1570
1571 fn analyze_dockerfile_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1572 Ok(vec![])
1574 }
1575
1576 fn analyze_compose_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1577 Ok(vec![])
1579 }
1580
1581 fn analyze_cicd_security(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1582 Ok(vec![])
1584 }
1585
1586 fn collect_source_files(&self, project_root: &Path, language: &str) -> Result<Vec<PathBuf>, SecurityError> {
1588 Ok(vec![])
1590 }
1591
1592 fn analyze_file_with_rules(&self, _file_path: &Path, _rules: &[SecurityRule]) -> Result<Vec<SecurityFinding>, SecurityError> {
1593 Ok(vec![])
1595 }
1596
1597 fn check_insecure_configurations(&self, _project_root: &Path) -> Result<Vec<SecurityFinding>, SecurityError> {
1598 Ok(vec![])
1600 }
1601
1602 fn deduplicate_findings(&self, mut findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
1604 use std::collections::HashSet;
1605
1606 let mut seen_secrets: HashSet<String> = HashSet::new();
1607 let mut deduplicated = Vec::new();
1608
1609 findings.sort_by(|a, b| {
1611 let a_priority = self.get_pattern_priority(&a.title);
1613 let b_priority = self.get_pattern_priority(&b.title);
1614
1615 match a_priority.cmp(&b_priority) {
1616 std::cmp::Ordering::Equal => {
1617 a.severity.cmp(&b.severity)
1619 }
1620 other => other
1621 }
1622 });
1623
1624 for finding in findings {
1625 let key = self.generate_finding_key(&finding);
1626
1627 if !seen_secrets.contains(&key) {
1628 seen_secrets.insert(key);
1629 deduplicated.push(finding);
1630 }
1631 }
1632
1633 deduplicated
1634 }
1635
1636 fn generate_finding_key(&self, finding: &SecurityFinding) -> String {
1638 match finding.category {
1639 SecurityCategory::SecretsExposure => {
1640 if let Some(evidence) = &finding.evidence {
1642 if let Some(file_path) = &finding.file_path {
1643 if let Some(secret_value) = self.extract_secret_value(evidence) {
1645 return format!("secret:{}:{}", file_path.display(), secret_value);
1646 }
1647 if let Some(line_num) = finding.line_number {
1649 return format!("secret:{}:{}", file_path.display(), line_num);
1650 }
1651 }
1652 }
1653 format!("secret:{}", finding.title)
1655 }
1656 _ => {
1657 if let Some(file_path) = &finding.file_path {
1659 if let Some(line_num) = finding.line_number {
1660 format!("other:{}:{}:{}", file_path.display(), line_num, finding.title)
1661 } else {
1662 format!("other:{}:{}", file_path.display(), finding.title)
1663 }
1664 } else {
1665 format!("other:{}", finding.title)
1666 }
1667 }
1668 }
1669 }
1670
1671 fn extract_secret_value(&self, evidence: &str) -> Option<String> {
1673 if let Some(pos) = evidence.find('=') {
1675 let value = evidence[pos + 1..].trim();
1676 let value = value.trim_matches('"').trim_matches('\'');
1678 if value.len() > 10 { return Some(value.to_string());
1680 }
1681 }
1682
1683 if let Some(pos) = evidence.find(':') {
1685 let value = evidence[pos + 1..].trim();
1686 let value = value.trim_matches('"').trim_matches('\'');
1687 if value.len() > 10 {
1688 return Some(value.to_string());
1689 }
1690 }
1691
1692 None
1693 }
1694
1695 fn get_pattern_priority(&self, title: &str) -> u8 {
1697 if title.contains("AWS Access Key") { return 1; }
1699 if title.contains("AWS Secret Key") { return 1; }
1700 if title.contains("S3 Secret Key") { return 1; }
1701 if title.contains("GitHub Token") { return 1; }
1702 if title.contains("OpenAI API Key") { return 1; }
1703 if title.contains("Stripe") { return 1; }
1704 if title.contains("RSA Private Key") { return 1; }
1705 if title.contains("SSH Private Key") { return 1; }
1706
1707 if title.contains("JWT Secret") { return 2; }
1709 if title.contains("Database URL") { return 2; }
1710
1711 if title.contains("API Key") { return 3; }
1713
1714 if title.contains("Environment Variable") { return 4; }
1716
1717 if title.contains("Generic Secret") { return 5; }
1719
1720 3
1722 }
1723
1724 fn count_by_severity(&self, findings: &[SecurityFinding]) -> HashMap<SecuritySeverity, usize> {
1725 let mut counts = HashMap::new();
1726 for finding in findings {
1727 *counts.entry(finding.severity.clone()).or_insert(0) += 1;
1728 }
1729 counts
1730 }
1731
1732 fn count_by_category(&self, findings: &[SecurityFinding]) -> HashMap<SecurityCategory, usize> {
1733 let mut counts = HashMap::new();
1734 for finding in findings {
1735 *counts.entry(finding.category.clone()).or_insert(0) += 1;
1736 }
1737 counts
1738 }
1739
1740 fn calculate_security_score(&self, findings: &[SecurityFinding]) -> f32 {
1741 if findings.is_empty() {
1742 return 100.0;
1743 }
1744
1745 let total_penalty = findings.iter().map(|f| match f.severity {
1746 SecuritySeverity::Critical => 25.0,
1747 SecuritySeverity::High => 15.0,
1748 SecuritySeverity::Medium => 8.0,
1749 SecuritySeverity::Low => 3.0,
1750 SecuritySeverity::Info => 1.0,
1751 }).sum::<f32>();
1752
1753 (100.0 - total_penalty).max(0.0)
1754 }
1755
1756 fn determine_risk_level(&self, findings: &[SecurityFinding]) -> SecuritySeverity {
1757 if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
1758 SecuritySeverity::Critical
1759 } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) {
1760 SecuritySeverity::High
1761 } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) {
1762 SecuritySeverity::Medium
1763 } else if !findings.is_empty() {
1764 SecuritySeverity::Low
1765 } else {
1766 SecuritySeverity::Info
1767 }
1768 }
1769
1770 fn assess_compliance(&self, _findings: &[SecurityFinding], _technologies: &[DetectedTechnology]) -> HashMap<String, ComplianceStatus> {
1771 HashMap::new()
1773 }
1774
1775 fn generate_recommendations(&self, findings: &[SecurityFinding], _technologies: &[DetectedTechnology]) -> Vec<String> {
1776 let mut recommendations = Vec::new();
1777
1778 if findings.iter().any(|f| f.category == SecurityCategory::SecretsExposure) {
1779 recommendations.push("Implement a secure secret management strategy".to_string());
1780 }
1781
1782 if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) {
1783 recommendations.push("Address critical security findings immediately".to_string());
1784 }
1785
1786 recommendations
1787 }
1788}
1789
1790
1791
1792#[cfg(test)]
1793mod tests {
1794 use super::*;
1795
1796 #[test]
1797 fn test_security_score_calculation() {
1798 let analyzer = SecurityAnalyzer::new().unwrap();
1799
1800 let findings = vec![
1801 SecurityFinding {
1802 id: "test-1".to_string(),
1803 title: "Test Critical".to_string(),
1804 description: "Test".to_string(),
1805 severity: SecuritySeverity::Critical,
1806 category: SecurityCategory::SecretsExposure,
1807 file_path: None,
1808 line_number: None,
1809 column_number: None,
1810 evidence: None,
1811 remediation: vec![],
1812 references: vec![],
1813 cwe_id: None,
1814 compliance_frameworks: vec![],
1815 }
1816 ];
1817
1818 let score = analyzer.calculate_security_score(&findings);
1819 assert_eq!(score, 75.0); }
1821
1822 #[test]
1823 fn test_secret_pattern_matching() {
1824 let analyzer = SecurityAnalyzer::new().unwrap();
1825
1826 assert!(analyzer.is_likely_placeholder("API_KEY=sk-xxxxxxxxxxxxxxxx"));
1828 assert!(!analyzer.is_likely_placeholder("API_KEY=sk-1234567890abcdef"));
1829 }
1830
1831 #[test]
1832 fn test_sensitive_env_var_detection() {
1833 let analyzer = SecurityAnalyzer::new().unwrap();
1834
1835 assert!(analyzer.is_sensitive_env_var("DATABASE_PASSWORD"));
1836 assert!(analyzer.is_sensitive_env_var("JWT_SECRET"));
1837 assert!(!analyzer.is_sensitive_env_var("PORT"));
1838 assert!(!analyzer.is_sensitive_env_var("NODE_ENV"));
1839 }
1840
1841 #[test]
1842 fn test_gitignore_aware_severity() {
1843 use tempfile::TempDir;
1844 use std::fs;
1845 use std::process::Command;
1846
1847 let temp_dir = TempDir::new().unwrap();
1848 let project_root = temp_dir.path();
1849
1850 let git_init = Command::new("git")
1852 .args(&["init"])
1853 .current_dir(project_root)
1854 .output();
1855
1856 if git_init.is_err() {
1858 println!("Skipping gitignore test - git not available");
1859 return;
1860 }
1861
1862 fs::write(project_root.join(".gitignore"), ".env\n.env.local\n").unwrap();
1864
1865 let _ = Command::new("git")
1867 .args(&["add", ".gitignore"])
1868 .current_dir(project_root)
1869 .output();
1870 let _ = Command::new("git")
1871 .args(&["config", "user.email", "test@example.com"])
1872 .current_dir(project_root)
1873 .output();
1874 let _ = Command::new("git")
1875 .args(&["config", "user.name", "Test User"])
1876 .current_dir(project_root)
1877 .output();
1878 let _ = Command::new("git")
1879 .args(&["commit", "-m", "Add gitignore"])
1880 .current_dir(project_root)
1881 .output();
1882
1883 let mut analyzer = SecurityAnalyzer::new().unwrap();
1884 analyzer.project_root = Some(project_root.to_path_buf());
1885
1886 let env_file = project_root.join(".env");
1888 fs::write(&env_file, "API_KEY=sk-1234567890abcdef").unwrap();
1889
1890 let (severity, remediation) = analyzer.determine_secret_severity(&env_file, SecuritySeverity::High);
1892
1893 assert_eq!(severity, SecuritySeverity::Info);
1895 assert!(remediation.iter().any(|r| r.contains("gitignored")));
1896 }
1897
1898 #[test]
1899 fn test_gitignore_config_options() {
1900 let mut config = SecurityAnalysisConfig::default();
1901
1902 assert!(config.skip_gitignored_files);
1904 assert!(!config.downgrade_gitignored_severity);
1905
1906 config.skip_gitignored_files = false;
1908 config.downgrade_gitignored_severity = true;
1909
1910 let analyzer = SecurityAnalyzer::with_config(config).unwrap();
1911 }
1913
1914 #[test]
1915 fn test_gitignore_pattern_matching() {
1916 let analyzer = SecurityAnalyzer::new().unwrap();
1917
1918 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"));
1923
1924 assert!(analyzer.matches_gitignore_pattern(".env", ".env", ".env"));
1926 assert!(!analyzer.matches_gitignore_pattern(".env", ".env.local", ".env.local"));
1927
1928 assert!(analyzer.matches_gitignore_pattern("/config.json", "config.json", "config.json"));
1930 assert!(!analyzer.matches_gitignore_pattern("/config.json", "src/config.json", "config.json"));
1931
1932 assert!(analyzer.matches_gitignore_pattern(".env*", ".env", ".env"));
1934 assert!(analyzer.matches_gitignore_pattern(".env*", ".env.local", ".env.local"));
1935 assert!(analyzer.matches_gitignore_pattern(".env.*", ".env.production", ".env.production"));
1936 }
1937
1938 #[test]
1939 fn test_common_env_patterns() {
1940 let analyzer = SecurityAnalyzer::new().unwrap();
1941
1942 assert!(analyzer.matches_common_env_patterns(".env"));
1944 assert!(analyzer.matches_common_env_patterns(".env.local"));
1945 assert!(analyzer.matches_common_env_patterns(".env.production"));
1946 assert!(analyzer.matches_common_env_patterns(".env.development"));
1947 assert!(analyzer.matches_common_env_patterns(".env.test"));
1948
1949 assert!(!analyzer.matches_common_env_patterns(".env.example"));
1951 assert!(!analyzer.matches_common_env_patterns(".env.sample"));
1952 assert!(!analyzer.matches_common_env_patterns(".env.template"));
1953
1954 assert!(!analyzer.matches_common_env_patterns("config.json"));
1956 assert!(!analyzer.matches_common_env_patterns("package.json"));
1957 }
1958
1959 #[test]
1960 fn test_legitimate_env_var_usage() {
1961 let analyzer = SecurityAnalyzer::new().unwrap();
1962
1963 let server_file = Path::new("src/server/config.js");
1965 let client_file = Path::new("src/components/MyComponent.js");
1966
1967 assert!(analyzer.is_legitimate_env_var_usage("const apiKey = process.env.RESEND_API_KEY;", server_file));
1969 assert!(analyzer.is_legitimate_env_var_usage("const dbUrl = process.env.DATABASE_URL;", server_file));
1970 assert!(analyzer.is_legitimate_env_var_usage("api_key = os.environ.get('API_KEY')", server_file));
1971 assert!(analyzer.is_legitimate_env_var_usage("let secret = env::var(\"JWT_SECRET\")?;", server_file));
1972
1973 assert!(analyzer.is_legitimate_env_var_usage("const apiUrl = process.env.API_URL;", client_file));
1975
1976 assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET_KEY"));
1978 assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_API_SECRET"));
1979
1980 assert!(!analyzer.is_legitimate_env_var_usage("const apiKey = 'sk-1234567890abcdef';", server_file));
1982 assert!(!analyzer.is_legitimate_env_var_usage("password = 'hardcoded123'", server_file));
1983 }
1984
1985 #[test]
1986 fn test_server_vs_client_side_detection() {
1987 let analyzer = SecurityAnalyzer::new().unwrap();
1988
1989 assert!(analyzer.is_server_side_file(Path::new("src/server/app.js")));
1991 assert!(analyzer.is_server_side_file(Path::new("src/api/users.js")));
1992 assert!(analyzer.is_server_side_file(Path::new("pages/api/auth.js")));
1993 assert!(analyzer.is_server_side_file(Path::new("src/lib/database.js")));
1994 assert!(analyzer.is_server_side_file(Path::new(".env")));
1995 assert!(analyzer.is_server_side_file(Path::new("server.js")));
1996
1997 assert!(!analyzer.is_server_side_file(Path::new("src/components/Button.jsx")));
1999 assert!(!analyzer.is_server_side_file(Path::new("public/index.html")));
2000 assert!(!analyzer.is_server_side_file(Path::new("src/pages/home.js")));
2001 assert!(!analyzer.is_server_side_file(Path::new("dist/bundle.js")));
2002
2003 assert!(analyzer.is_server_side_file(Path::new("src/utils/helper.js")));
2005 assert!(analyzer.is_server_side_file(Path::new("config/settings.js")));
2006 }
2007
2008 #[test]
2009 fn test_client_side_exposed_env_vars() {
2010 let analyzer = SecurityAnalyzer::new().unwrap();
2011
2012 assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET"));
2014 assert!(analyzer.is_client_side_exposed_env_var("import.meta.env.VITE_API_KEY"));
2015 assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_SECRET"));
2016 assert!(analyzer.is_client_side_exposed_env_var("process.env.VUE_APP_TOKEN"));
2017
2018 assert!(!analyzer.is_client_side_exposed_env_var("process.env.DATABASE_URL"));
2020 assert!(!analyzer.is_client_side_exposed_env_var("process.env.JWT_SECRET"));
2021 assert!(!analyzer.is_client_side_exposed_env_var("process.env.API_KEY"));
2022 }
2023
2024 #[test]
2025 fn test_env_var_assignment_context() {
2026 let analyzer = SecurityAnalyzer::new().unwrap();
2027
2028 assert!(analyzer.is_env_var_assignment_context("API_KEY=sk-test123", Path::new(".env")));
2030 assert!(analyzer.is_env_var_assignment_context("DATABASE_URL=postgres://", Path::new("docker-compose.yml")));
2031 assert!(analyzer.is_env_var_assignment_context("export SECRET=test", Path::new("setup.sh")));
2032
2033 assert!(!analyzer.is_env_var_assignment_context("const secret = 'hardcoded'", Path::new("src/app.js")));
2035 }
2036
2037 #[test]
2038 fn test_enhanced_secret_patterns() {
2039 let analyzer = SecurityAnalyzer::new().unwrap();
2040
2041 let hardcoded_patterns = [
2043 "apikey = 'sk-1234567890abcdef1234567890abcdef12345678'",
2044 "const secret = 'my-super-secret-token-12345678901234567890'",
2045 "password = 'hardcoded123456'",
2046 ];
2047
2048 for pattern in &hardcoded_patterns {
2049 let has_secret = analyzer.secret_patterns.iter().any(|sp| sp.pattern.is_match(pattern));
2050 assert!(has_secret, "Should detect hardcoded secret in: {}", pattern);
2051 }
2052
2053 let legitimate_patterns = [
2055 "const apiKey = process.env.API_KEY;",
2056 "const dbUrl = process.env.DATABASE_URL || 'fallback';",
2057 "api_key = os.environ.get('API_KEY')",
2058 "let secret = env::var(\"JWT_SECRET\")?;",
2059 ];
2060
2061 for pattern in &legitimate_patterns {
2062 let matches_old_generic_pattern = pattern.to_lowercase().contains("secret") ||
2064 pattern.to_lowercase().contains("key");
2065
2066 let matches_new_patterns = analyzer.secret_patterns.iter()
2068 .filter(|sp| sp.name.contains("Hardcoded"))
2069 .any(|sp| sp.pattern.is_match(pattern));
2070
2071 assert!(!matches_new_patterns, "Should NOT detect legitimate env var usage as hardcoded secret: {}", pattern);
2072 }
2073 }
2074
2075 #[test]
2076 fn test_context_aware_false_positive_reduction() {
2077 use tempfile::TempDir;
2078
2079 let temp_dir = TempDir::new().unwrap();
2080 let server_file = temp_dir.path().join("src/server/config.js");
2081
2082 std::fs::create_dir_all(server_file.parent().unwrap()).unwrap();
2084
2085 let content = r#"
2087const config = {
2088 apiKey: process.env.RESEND_API_KEY,
2089 databaseUrl: process.env.DATABASE_URL,
2090 jwtSecret: process.env.JWT_SECRET,
2091 port: process.env.PORT || 3000
2092};
2093"#;
2094
2095 std::fs::write(&server_file, content).unwrap();
2096
2097 let analyzer = SecurityAnalyzer::new().unwrap();
2098 let findings = analyzer.analyze_file_for_secrets(&server_file).unwrap();
2099
2100 assert_eq!(findings.len(), 0, "Should not flag legitimate environment variable usage as security issues");
2102 }
2103}