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