1use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
11use log::{debug, info};
12use rayon::prelude::*;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::Instant;
20use thiserror::Error;
21
22use crate::analyzer::dependency_parser::Language;
23use crate::analyzer::{DetectedLanguage, DetectedTechnology, EnvVar, ProjectAnalysis};
24
25#[derive(Debug, Error)]
26pub enum SecurityError {
27 #[error("Security analysis failed: {0}")]
28 AnalysisFailed(String),
29
30 #[error("Configuration analysis error: {0}")]
31 ConfigAnalysisError(String),
32
33 #[error("Code pattern analysis error: {0}")]
34 CodePatternError(String),
35
36 #[error("Infrastructure analysis error: {0}")]
37 InfrastructureError(String),
38
39 #[error("IO error: {0}")]
40 Io(#[from] std::io::Error),
41
42 #[error("Regex error: {0}")]
43 Regex(#[from] regex::Error),
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum SecuritySeverity {
49 Critical,
50 High,
51 Medium,
52 Low,
53 Info,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
58pub enum SecurityCategory {
59 SecretsExposure,
61 InsecureConfiguration,
63 CodeSecurityPattern,
65 InfrastructureSecurity,
67 AuthenticationSecurity,
69 DataProtection,
71 NetworkSecurity,
73 Compliance,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SecurityFinding {
80 pub id: String,
81 pub title: String,
82 pub description: String,
83 pub severity: SecuritySeverity,
84 pub category: SecurityCategory,
85 pub file_path: Option<PathBuf>,
86 pub line_number: Option<usize>,
87 pub column_number: Option<usize>,
88 pub evidence: Option<String>,
89 pub remediation: Vec<String>,
90 pub references: Vec<String>,
91 pub cwe_id: Option<String>,
92 pub compliance_frameworks: Vec<String>,
93}
94
95#[derive(Debug, Serialize, Deserialize)]
97pub struct SecurityReport {
98 pub analyzed_at: chrono::DateTime<chrono::Utc>,
99 pub overall_score: f32, pub risk_level: SecuritySeverity,
101 pub total_findings: usize,
102 pub findings_by_severity: HashMap<SecuritySeverity, usize>,
103 pub findings_by_category: HashMap<SecurityCategory, usize>,
104 pub findings: Vec<SecurityFinding>,
105 pub recommendations: Vec<String>,
106 pub compliance_status: HashMap<String, ComplianceStatus>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ComplianceStatus {
112 pub framework: String,
113 pub coverage: f32, pub missing_controls: Vec<String>,
115 pub recommendations: Vec<String>,
116}
117
118#[derive(Debug, Clone)]
120pub struct SecurityAnalysisConfig {
121 pub include_low_severity: bool,
122 pub check_secrets: bool,
123 pub check_code_patterns: bool,
124 pub check_infrastructure: bool,
125 pub check_compliance: bool,
126 pub frameworks_to_check: Vec<String>,
127 pub ignore_patterns: Vec<String>,
128 pub skip_gitignored_files: bool,
130 pub downgrade_gitignored_severity: bool,
132}
133
134impl Default for SecurityAnalysisConfig {
135 fn default() -> Self {
136 Self {
137 include_low_severity: false,
138 check_secrets: true,
139 check_code_patterns: true,
140 check_infrastructure: true,
141 check_compliance: true,
142 frameworks_to_check: vec!["SOC2".to_string(), "GDPR".to_string(), "OWASP".to_string()],
143 ignore_patterns: vec![
144 "node_modules".to_string(),
145 ".git".to_string(),
146 "target".to_string(),
147 "build".to_string(),
148 ".next".to_string(),
149 "dist".to_string(),
150 "test".to_string(),
151 "tests".to_string(),
152 "*.json".to_string(), "*.lock".to_string(), "*_sample.*".to_string(), "*audit*".to_string(), ],
157 skip_gitignored_files: true, downgrade_gitignored_severity: false, }
160 }
161}
162
163pub struct SecurityAnalyzer {
164 config: SecurityAnalysisConfig,
165 secret_patterns: Vec<SecretPattern>,
166 security_rules: HashMap<Language, Vec<SecurityRule>>,
167 git_ignore_cache: std::sync::Mutex<HashMap<PathBuf, bool>>,
168 project_root: Option<PathBuf>,
169}
170
171struct SecretPattern {
173 name: String,
174 pattern: Regex,
175 severity: SecuritySeverity,
176 description: String,
177}
178
179#[allow(dead_code)]
181struct SecurityRule {
182 id: String,
183 name: String,
184 pattern: Regex,
185 severity: SecuritySeverity,
186 category: SecurityCategory,
187 description: String,
188 remediation: Vec<String>,
189 cwe_id: Option<String>,
190}
191
192impl SecurityAnalyzer {
193 pub fn new() -> Result<Self, SecurityError> {
194 Self::with_config(SecurityAnalysisConfig::default())
195 }
196
197 pub fn with_config(config: SecurityAnalysisConfig) -> Result<Self, SecurityError> {
198 let secret_patterns = Self::initialize_secret_patterns()?;
199 let security_rules = Self::initialize_security_rules()?;
200
201 Ok(Self {
202 config,
203 secret_patterns,
204 security_rules,
205 git_ignore_cache: std::sync::Mutex::new(HashMap::new()),
206 project_root: None,
207 })
208 }
209
210 pub fn analyze_security(
212 &mut self,
213 analysis: &ProjectAnalysis,
214 ) -> Result<SecurityReport, SecurityError> {
215 let start_time = Instant::now();
216 info!("Starting comprehensive security analysis");
217
218 self.project_root = Some(analysis.project_root.clone());
220
221 let is_verbose = log::max_level() >= log::LevelFilter::Info;
223
224 let multi_progress = MultiProgress::new();
226
227 let mut total_phases = 0;
231 if self.config.check_secrets {
232 total_phases += 1;
233 }
234 if self.config.check_code_patterns {
235 total_phases += 1;
236 }
237 if self.config.check_infrastructure {
238 total_phases += 1;
239 }
240 total_phases += 2; let main_pb = if is_verbose {
244 None } else {
246 let pb = multi_progress.add(ProgressBar::new(100));
248 pb.set_style(
249 ProgressStyle::default_bar()
250 .template("🛡️ {msg} {bar:50.cyan/blue} {percent}% [{elapsed_precise}]")
251 .unwrap()
252 .progress_chars("██▉▊▋▌▍▎▏ "),
253 );
254 Some(pb)
255 };
256
257 let mut findings = Vec::new();
258 let phase_weight = if is_verbose {
259 1u64
260 } else {
261 100 / total_phases as u64
262 };
263 let mut current_progress = 0u64;
264
265 if self.config.check_secrets {
267 if let Some(ref pb) = main_pb {
268 pb.set_message("Analyzing configuration & secrets...");
269 pb.set_position(current_progress);
270 }
271
272 if is_verbose {
273 findings.extend(self.analyze_configuration_security(&analysis.project_root)?);
274 } else {
275 findings.extend(self.analyze_configuration_security_with_progress(
276 &analysis.project_root,
277 &multi_progress,
278 )?);
279 }
280
281 if let Some(ref pb) = main_pb {
282 current_progress += phase_weight;
283 pb.set_position(current_progress);
284 }
285 }
286
287 if self.config.check_code_patterns {
289 if let Some(ref pb) = main_pb {
290 pb.set_message("Analyzing code security patterns...");
291 }
292
293 if is_verbose {
294 findings.extend(
295 self.analyze_code_security_patterns(
296 &analysis.project_root,
297 &analysis.languages,
298 )?,
299 );
300 } else {
301 findings.extend(self.analyze_code_security_patterns_with_progress(
302 &analysis.project_root,
303 &analysis.languages,
304 &multi_progress,
305 )?);
306 }
307
308 if let Some(ref pb) = main_pb {
309 current_progress += phase_weight;
310 pb.set_position(current_progress);
311 }
312 }
313
314 if let Some(ref pb) = main_pb {
320 pb.set_message("Analyzing environment variables...");
321 }
322
323 findings.extend(self.analyze_environment_security(&analysis.environment_variables));
324 if let Some(ref pb) = main_pb {
325 current_progress += phase_weight;
326 pb.set_position(current_progress);
327 }
328
329 if let Some(ref pb) = main_pb {
334 current_progress = 100;
335 pb.set_position(current_progress);
336 }
337
338 if let Some(ref pb) = main_pb {
340 pb.set_message("Processing findings & generating report...");
341 }
342
343 let pre_dedup_count = findings.len();
345 findings = self.deduplicate_findings(findings);
346 let post_dedup_count = findings.len();
347
348 if pre_dedup_count != post_dedup_count {
349 info!(
350 "Deduplicated {} redundant findings, {} unique findings remain",
351 pre_dedup_count - post_dedup_count,
352 post_dedup_count
353 );
354 }
355
356 let pre_filter_count = findings.len();
358 if !self.config.include_low_severity {
359 findings.retain(|f| {
360 f.severity != SecuritySeverity::Low && f.severity != SecuritySeverity::Info
361 });
362 }
363
364 findings.sort_by(|a, b| a.severity.cmp(&b.severity));
366
367 let total_findings = findings.len();
369 let findings_by_severity = self.count_by_severity(&findings);
370 let findings_by_category = self.count_by_category(&findings);
371 let overall_score = self.calculate_security_score(&findings);
372 let risk_level = self.determine_risk_level(&findings);
373
374 let compliance_status = HashMap::new();
377
378 let recommendations = self.generate_recommendations(&findings, &analysis.technologies);
380
381 let duration = start_time.elapsed().as_secs_f32();
383 if let Some(pb) = main_pb {
384 pb.finish_with_message(format!(
385 "✅ Security analysis completed in {:.1}s - Found {} issues",
386 duration, total_findings
387 ));
388 }
389
390 if pre_filter_count != total_findings {
392 info!(
393 "Found {} total findings, showing {} after filtering",
394 pre_filter_count, total_findings
395 );
396 } else {
397 info!("Found {} security findings", total_findings);
398 }
399
400 Ok(SecurityReport {
401 analyzed_at: chrono::Utc::now(),
402 overall_score,
403 risk_level,
404 total_findings,
405 findings_by_severity,
406 findings_by_category,
407 findings,
408 recommendations,
409 compliance_status,
410 })
411 }
412
413 fn is_file_gitignored(&self, file_path: &Path) -> bool {
415 let project_root = match &self.project_root {
417 Some(root) => root,
418 None => return false,
419 };
420
421 if let Ok(cache) = self.git_ignore_cache.lock()
423 && let Some(&cached_result) = cache.get(file_path)
424 {
425 return cached_result;
426 }
427
428 if !project_root.join(".git").exists() {
430 debug!("Not a git repository, treating all files as tracked");
431 return false;
432 }
433
434 let git_result = Command::new("git")
436 .args(["check-ignore", "--quiet"])
437 .arg(file_path)
438 .current_dir(project_root)
439 .output()
440 .map(|output| output.status.success())
441 .unwrap_or(false);
442
443 if git_result {
445 if let Ok(mut cache) = self.git_ignore_cache.lock() {
446 cache.insert(file_path.to_path_buf(), true);
447 }
448 return true;
449 }
450
451 let manual_result = self.check_gitignore_patterns(file_path, project_root);
454
455 let final_result = git_result || manual_result;
457 if let Ok(mut cache) = self.git_ignore_cache.lock() {
458 cache.insert(file_path.to_path_buf(), final_result);
459 }
460
461 final_result
462 }
463
464 fn check_gitignore_patterns(&self, file_path: &Path, project_root: &Path) -> bool {
466 let relative_path = match file_path.strip_prefix(project_root) {
468 Ok(rel) => rel,
469 Err(_) => return false,
470 };
471
472 let path_str = relative_path.to_string_lossy();
473 let file_name = relative_path
474 .file_name()
475 .and_then(|n| n.to_str())
476 .unwrap_or("");
477
478 let gitignore_path = project_root.join(".gitignore");
480 if let Ok(gitignore_content) = fs::read_to_string(&gitignore_path) {
481 for line in gitignore_content.lines() {
482 let pattern = line.trim();
483 if pattern.is_empty() || pattern.starts_with('#') {
484 continue;
485 }
486
487 if self.matches_gitignore_pattern(pattern, &path_str, file_name) {
489 debug!("File {} matches gitignore pattern: {}", path_str, pattern);
490 return true;
491 }
492 }
493 }
494
495 self.matches_common_env_patterns(file_name)
497 }
498
499 fn matches_gitignore_pattern(&self, pattern: &str, path_str: &str, file_name: &str) -> bool {
501 if pattern.contains('*') {
503 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
505 if glob_pattern.matches(path_str) || glob_pattern.matches(file_name) {
507 return true;
508 }
509 }
510 } else if let Some(abs_pattern) = pattern.strip_prefix('/') {
511 if path_str == abs_pattern {
513 return true;
514 }
515 } else {
516 if path_str == pattern
518 || file_name == pattern
519 || path_str.ends_with(&format!("/{}", pattern))
520 {
521 return true;
522 }
523 }
524
525 false
526 }
527
528 fn matches_common_env_patterns(&self, file_name: &str) -> bool {
530 let common_env_patterns = [
531 ".env",
532 ".env.local",
533 ".env.development",
534 ".env.production",
535 ".env.staging",
536 ".env.test",
537 ".env.example", ];
539
540 if common_env_patterns.contains(&file_name) {
542 return file_name != ".env.example"; }
544
545 if file_name.starts_with(".env.")
547 || file_name.ends_with(".env")
548 || (file_name.starts_with(".") && file_name.contains("env"))
549 {
550 return !file_name.contains("example")
552 && !file_name.contains("sample")
553 && !file_name.contains("template");
554 }
555
556 false
557 }
558
559 fn is_file_tracked(&self, file_path: &Path) -> bool {
561 let project_root = match &self.project_root {
562 Some(root) => root,
563 None => return true, };
565
566 if !project_root.join(".git").exists() {
568 return true; }
570
571 Command::new("git")
573 .args(["ls-files", "--error-unmatch"])
574 .arg(file_path)
575 .current_dir(project_root)
576 .output()
577 .map(|output| output.status.success())
578 .unwrap_or(true) }
580
581 fn determine_secret_severity(
583 &self,
584 file_path: &Path,
585 original_severity: SecuritySeverity,
586 ) -> (SecuritySeverity, Vec<String>) {
587 let mut additional_remediation = Vec::new();
588
589 if self.is_file_gitignored(file_path) {
591 if self.config.skip_gitignored_files {
592 return (
594 SecuritySeverity::Info,
595 vec!["File is properly gitignored".to_string()],
596 );
597 } else if self.config.downgrade_gitignored_severity {
598 let downgraded = match original_severity {
600 SecuritySeverity::Critical => SecuritySeverity::Medium,
601 SecuritySeverity::High => SecuritySeverity::Low,
602 SecuritySeverity::Medium => SecuritySeverity::Low,
603 SecuritySeverity::Low => SecuritySeverity::Info,
604 SecuritySeverity::Info => SecuritySeverity::Info,
605 };
606 additional_remediation
607 .push("Note: File is gitignored, reducing severity".to_string());
608 return (downgraded, additional_remediation);
609 }
610 }
611
612 if !self.is_file_tracked(file_path) {
614 additional_remediation.push(
615 "Ensure this file is added to .gitignore to prevent accidental commits".to_string(),
616 );
617 } else {
618 additional_remediation.push(
620 "⚠️ CRITICAL: This file is tracked by git! Secrets may be in version history."
621 .to_string(),
622 );
623 additional_remediation.push(
624 "Consider using git-filter-branch or BFG Repo-Cleaner to remove from history"
625 .to_string(),
626 );
627 additional_remediation.push("Rotate any exposed secrets immediately".to_string());
628
629 let upgraded = match original_severity {
631 SecuritySeverity::High => SecuritySeverity::Critical,
632 SecuritySeverity::Medium => SecuritySeverity::High,
633 SecuritySeverity::Low => SecuritySeverity::Medium,
634 other => other,
635 };
636 return (upgraded, additional_remediation);
637 }
638
639 (original_severity, additional_remediation)
640 }
641
642 fn initialize_secret_patterns() -> Result<Vec<SecretPattern>, SecurityError> {
644 let patterns = vec![
645 (
647 "AWS Access Key",
648 r"AKIA[0-9A-Z]{16}",
649 SecuritySeverity::Critical,
650 ),
651 (
652 "AWS Secret Key",
653 r#"(?i)(aws[_-]?secret|secret[_-]?access[_-]?key)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?"#,
654 SecuritySeverity::Critical,
655 ),
656 (
657 "S3 Secret Key",
658 r#"(?i)(s3[_-]?secret[_-]?key|linode[_-]?s3[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{20,}["']?"#,
659 SecuritySeverity::High,
660 ),
661 (
662 "GitHub Token",
663 r"gh[pousr]_[A-Za-z0-9_]{36,255}",
664 SecuritySeverity::High,
665 ),
666 (
667 "OpenAI API Key",
668 r"sk-[A-Za-z0-9]{48}",
669 SecuritySeverity::High,
670 ),
671 (
672 "Stripe API Key",
673 r"sk_live_[0-9a-zA-Z]{24}",
674 SecuritySeverity::Critical,
675 ),
676 (
677 "Stripe Publishable Key",
678 r"pk_live_[0-9a-zA-Z]{24}",
679 SecuritySeverity::Medium,
680 ),
681 (
683 "Hardcoded Database URL",
684 r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?(postgresql|mysql|mongodb)://[^"'\s]+"#,
685 SecuritySeverity::Critical,
686 ),
687 (
688 "Hardcoded Password",
689 r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}["']?"#,
690 SecuritySeverity::High,
691 ),
692 (
693 "JWT Secret",
694 r#"(?i)(jwt[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{20,}"#,
695 SecuritySeverity::High,
696 ),
697 (
699 "RSA Private Key",
700 r"-----BEGIN RSA PRIVATE KEY-----",
701 SecuritySeverity::Critical,
702 ),
703 (
704 "SSH Private Key",
705 r"-----BEGIN OPENSSH PRIVATE KEY-----",
706 SecuritySeverity::Critical,
707 ),
708 (
709 "PGP Private Key",
710 r"-----BEGIN PGP PRIVATE KEY BLOCK-----",
711 SecuritySeverity::Critical,
712 ),
713 (
715 "Google Cloud Service Account",
716 r#""type":\s*"service_account""#,
717 SecuritySeverity::High,
718 ),
719 (
720 "Azure Storage Key",
721 r"DefaultEndpointsProtocol=https;AccountName=",
722 SecuritySeverity::High,
723 ),
724 (
726 "Client-side Exposed Secret",
727 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,}"#,
728 SecuritySeverity::High,
729 ),
730 (
732 "Hardcoded API Key",
733 r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}["']?"#,
734 SecuritySeverity::High,
735 ),
736 (
738 "Hardcoded Secret",
739 r#"(?i)(secret|token)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}["']?"#,
740 SecuritySeverity::Medium,
741 ),
742 ];
743
744 patterns
745 .into_iter()
746 .map(|(name, pattern, severity)| {
747 Ok(SecretPattern {
748 name: name.to_string(),
749 pattern: Regex::new(pattern)?,
750 severity,
751 description: format!("Potential {} found in code", name),
752 })
753 })
754 .collect()
755 }
756
757 fn initialize_security_rules() -> Result<HashMap<Language, Vec<SecurityRule>>, SecurityError> {
759 let mut rules = HashMap::new();
760
761 rules.insert(Language::JavaScript, vec![
763 SecurityRule {
764 id: "js-001".to_string(),
765 name: "Eval Usage".to_string(),
766 pattern: Regex::new(r"\beval\s*\(")?,
767 severity: SecuritySeverity::High,
768 category: SecurityCategory::CodeSecurityPattern,
769 description: "Use of eval() can lead to code injection vulnerabilities".to_string(),
770 remediation: vec![
771 "Avoid using eval() with user input".to_string(),
772 "Use JSON.parse() for parsing JSON data".to_string(),
773 "Consider using safer alternatives like Function constructor with validation".to_string(),
774 ],
775 cwe_id: Some("CWE-95".to_string()),
776 },
777 SecurityRule {
778 id: "js-002".to_string(),
779 name: "innerHTML Usage".to_string(),
780 pattern: Regex::new(r"\.innerHTML\s*=")?,
781 severity: SecuritySeverity::Medium,
782 category: SecurityCategory::CodeSecurityPattern,
783 description: "innerHTML can lead to XSS vulnerabilities if used with unsanitized data".to_string(),
784 remediation: vec![
785 "Use textContent instead of innerHTML for text".to_string(),
786 "Sanitize HTML content before setting innerHTML".to_string(),
787 "Consider using secure templating libraries".to_string(),
788 ],
789 cwe_id: Some("CWE-79".to_string()),
790 },
791 ]);
792
793 rules.insert(
795 Language::Python,
796 vec![
797 SecurityRule {
798 id: "py-001".to_string(),
799 name: "SQL Injection Risk".to_string(),
800 pattern: Regex::new(r#"\.execute\s*\(\s*[f]?["'][^"']*%[sd]"#)?,
801 severity: SecuritySeverity::High,
802 category: SecurityCategory::CodeSecurityPattern,
803 description: "String formatting in SQL queries can lead to SQL injection"
804 .to_string(),
805 remediation: vec![
806 "Use parameterized queries instead of string formatting".to_string(),
807 "Use ORM query builders where possible".to_string(),
808 "Validate and sanitize all user inputs".to_string(),
809 ],
810 cwe_id: Some("CWE-89".to_string()),
811 },
812 SecurityRule {
813 id: "py-002".to_string(),
814 name: "Pickle Usage".to_string(),
815 pattern: Regex::new(r"\bpickle\.loads?\s*\(")?,
816 severity: SecuritySeverity::High,
817 category: SecurityCategory::CodeSecurityPattern,
818 description: "Pickle can execute arbitrary code during deserialization"
819 .to_string(),
820 remediation: vec![
821 "Avoid pickle for untrusted data".to_string(),
822 "Use JSON or other safe serialization formats".to_string(),
823 "If pickle is necessary, validate data sources".to_string(),
824 ],
825 cwe_id: Some("CWE-502".to_string()),
826 },
827 ],
828 );
829
830 Ok(rules)
833 }
834
835 fn analyze_configuration_security_with_progress(
837 &self,
838 project_root: &Path,
839 multi_progress: &MultiProgress,
840 ) -> Result<Vec<SecurityFinding>, SecurityError> {
841 debug!("Analyzing configuration security");
842 let mut findings = Vec::new();
843
844 let config_files = self.collect_config_files(project_root)?;
846
847 if config_files.is_empty() {
848 info!("No configuration files found");
849 return Ok(findings);
850 }
851
852 let is_verbose = log::max_level() >= log::LevelFilter::Info;
853
854 info!(
855 "📁 Found {} configuration files to analyze",
856 config_files.len()
857 );
858
859 let file_pb = if is_verbose {
861 None } else {
863 let pb = multi_progress.add(ProgressBar::new(config_files.len() as u64));
865 pb.set_style(
866 ProgressStyle::default_bar()
867 .template(" 🔍 {msg} {bar:40.cyan/blue} {pos}/{len} files ({percent}%)")
868 .unwrap()
869 .progress_chars("████▉▊▋▌▍▎▏ "),
870 );
871 pb.set_message("Scanning configuration files...");
872 Some(pb)
873 };
874
875 use std::sync::Arc;
877 use std::sync::atomic::{AtomicUsize, Ordering};
878 let processed_count = Arc::new(AtomicUsize::new(0));
879
880 let file_findings: Vec<Vec<SecurityFinding>> = config_files
882 .par_iter()
883 .map(|file_path| {
884 let result = self.analyze_file_for_secrets(file_path);
885
886 if let Some(ref pb) = file_pb {
888 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
889 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str()) {
890 let display_name = if file_name.len() > 30 {
892 format!("...{}", &file_name[file_name.len() - 27..])
893 } else {
894 file_name.to_string()
895 };
896 pb.set_message(format!("Scanning {}", display_name));
897 }
898 pb.set_position(current as u64);
899 }
900
901 result
902 })
903 .filter_map(|result| result.ok())
904 .collect();
905
906 if let Some(pb) = file_pb {
908 pb.finish_with_message(format!(
909 "✅ Scanned {} configuration files",
910 config_files.len()
911 ));
912 }
913
914 for mut file_findings in file_findings {
915 findings.append(&mut file_findings);
916 }
917
918 findings.extend(self.check_insecure_configurations(project_root)?);
920
921 info!(
922 "🔍 Found {} configuration security findings",
923 findings.len()
924 );
925 Ok(findings)
926 }
927
928 fn analyze_configuration_security(
930 &self,
931 project_root: &Path,
932 ) -> Result<Vec<SecurityFinding>, SecurityError> {
933 debug!("Analyzing configuration security");
934 let mut findings = Vec::new();
935
936 let config_files = self.collect_config_files(project_root)?;
938
939 if config_files.is_empty() {
940 info!("No configuration files found");
941 return Ok(findings);
942 }
943
944 info!(
945 "📁 Found {} configuration files to analyze",
946 config_files.len()
947 );
948
949 let file_findings: Vec<Vec<SecurityFinding>> = config_files
951 .par_iter()
952 .map(|file_path| self.analyze_file_for_secrets(file_path))
953 .filter_map(|result| result.ok())
954 .collect();
955
956 for mut file_findings in file_findings {
957 findings.append(&mut file_findings);
958 }
959
960 findings.extend(self.check_insecure_configurations(project_root)?);
962
963 info!(
964 "🔍 Found {} configuration security findings",
965 findings.len()
966 );
967 Ok(findings)
968 }
969
970 fn analyze_code_security_patterns_with_progress(
972 &self,
973 project_root: &Path,
974 languages: &[DetectedLanguage],
975 multi_progress: &MultiProgress,
976 ) -> Result<Vec<SecurityFinding>, SecurityError> {
977 debug!("Analyzing code security patterns");
978 let mut findings = Vec::new();
979
980 let mut total_files = 0;
982 let mut language_files = Vec::new();
983
984 for language in languages {
985 if let Some(lang) = Language::from_string(&language.name)
986 && let Some(_rules) = self.security_rules.get(&lang)
987 {
988 let source_files = self.collect_source_files(project_root, &language.name)?;
989 total_files += source_files.len();
990 language_files.push((language, source_files));
991 }
992 }
993
994 if total_files == 0 {
995 info!("No source files found for code pattern analysis");
996 return Ok(findings);
997 }
998
999 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1000
1001 info!(
1002 "📄 Found {} source files across {} languages",
1003 total_files,
1004 language_files.len()
1005 );
1006
1007 let code_pb = if is_verbose {
1009 None
1011 } else {
1012 let pb = multi_progress.add(ProgressBar::new(total_files as u64));
1014 pb.set_style(
1015 ProgressStyle::default_bar()
1016 .template(" 📄 {msg} {bar:40.yellow/white} {pos}/{len} files ({percent}%)")
1017 .unwrap()
1018 .progress_chars("████▉▊▋▌▍▎▏ "),
1019 );
1020 pb.set_message("Scanning source code...");
1021 Some(pb)
1022 };
1023
1024 use std::sync::Arc;
1026 use std::sync::atomic::{AtomicUsize, Ordering};
1027 let processed_count = Arc::new(AtomicUsize::new(0));
1028
1029 for (language, source_files) in language_files {
1031 if let Some(lang) = Language::from_string(&language.name)
1032 && let Some(rules) = self.security_rules.get(&lang)
1033 {
1034 let file_findings: Vec<Vec<SecurityFinding>> = source_files
1035 .par_iter()
1036 .map(|file_path| {
1037 let result = self.analyze_file_with_rules(file_path, rules);
1038
1039 if let Some(ref pb) = code_pb {
1041 let current = processed_count.fetch_add(1, Ordering::Relaxed) + 1;
1042 if let Some(file_name) = file_path.file_name().and_then(|n| n.to_str())
1043 {
1044 let display_name = if file_name.len() > 25 {
1045 format!("...{}", &file_name[file_name.len() - 22..])
1046 } else {
1047 file_name.to_string()
1048 };
1049 pb.set_message(format!(
1050 "Scanning {} ({})",
1051 display_name, language.name
1052 ));
1053 }
1054 pb.set_position(current as u64);
1055 }
1056
1057 result
1058 })
1059 .filter_map(|result| result.ok())
1060 .collect();
1061
1062 for mut file_findings in file_findings {
1063 findings.append(&mut file_findings);
1064 }
1065 }
1066 }
1067
1068 if let Some(pb) = code_pb {
1070 pb.finish_with_message(format!("✅ Scanned {} source files", total_files));
1071 }
1072
1073 info!("🔍 Found {} code security findings", findings.len());
1074 Ok(findings)
1075 }
1076
1077 fn analyze_code_security_patterns(
1079 &self,
1080 project_root: &Path,
1081 languages: &[DetectedLanguage],
1082 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1083 debug!("Analyzing code security patterns");
1084 let mut findings = Vec::new();
1085
1086 let mut total_files = 0;
1088 let mut language_files = Vec::new();
1089
1090 for language in languages {
1091 if let Some(lang) = Language::from_string(&language.name)
1092 && let Some(_rules) = self.security_rules.get(&lang)
1093 {
1094 let source_files = self.collect_source_files(project_root, &language.name)?;
1095 total_files += source_files.len();
1096 language_files.push((language, source_files));
1097 }
1098 }
1099
1100 if total_files == 0 {
1101 info!("No source files found for code pattern analysis");
1102 return Ok(findings);
1103 }
1104
1105 info!(
1106 "📄 Found {} source files across {} languages",
1107 total_files,
1108 language_files.len()
1109 );
1110
1111 for (language, source_files) in language_files {
1113 if let Some(lang) = Language::from_string(&language.name)
1114 && let Some(rules) = self.security_rules.get(&lang)
1115 {
1116 let file_findings: Vec<Vec<SecurityFinding>> = source_files
1117 .par_iter()
1118 .map(|file_path| self.analyze_file_with_rules(file_path, rules))
1119 .filter_map(|result| result.ok())
1120 .collect();
1121
1122 for mut file_findings in file_findings {
1123 findings.append(&mut file_findings);
1124 }
1125 }
1126 }
1127
1128 info!("🔍 Found {} code security findings", findings.len());
1129 Ok(findings)
1130 }
1131
1132 #[allow(dead_code)]
1134 fn analyze_infrastructure_security_with_progress(
1135 &self,
1136 project_root: &Path,
1137 _technologies: &[DetectedTechnology],
1138 multi_progress: &MultiProgress,
1139 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1140 debug!("Analyzing infrastructure security");
1141 let mut findings = Vec::new();
1142
1143 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1144
1145 let infra_pb = if is_verbose {
1147 None
1149 } else {
1150 let pb = multi_progress.add(ProgressBar::new_spinner());
1152 pb.set_style(
1153 ProgressStyle::default_spinner()
1154 .template(" 🏗️ {msg} {spinner:.magenta}")
1155 .unwrap()
1156 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
1157 );
1158 pb.enable_steady_tick(std::time::Duration::from_millis(100));
1159 Some(pb)
1160 };
1161
1162 if let Some(ref pb) = infra_pb {
1164 pb.set_message("Checking Dockerfiles & Compose files...");
1165 }
1166 findings.extend(self.analyze_dockerfile_security(project_root)?);
1167 findings.extend(self.analyze_compose_security(project_root)?);
1168
1169 if let Some(ref pb) = infra_pb {
1171 pb.set_message("Checking CI/CD configurations...");
1172 }
1173 findings.extend(self.analyze_cicd_security(project_root)?);
1174
1175 if let Some(pb) = infra_pb {
1177 pb.finish_with_message("✅ Infrastructure analysis complete");
1178 }
1179 info!(
1180 "🔍 Found {} infrastructure security findings",
1181 findings.len()
1182 );
1183
1184 Ok(findings)
1185 }
1186
1187 #[allow(dead_code)]
1189 fn analyze_infrastructure_security(
1190 &self,
1191 project_root: &Path,
1192 _technologies: &[DetectedTechnology],
1193 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1194 debug!("Analyzing infrastructure security");
1195 let mut findings = Vec::new();
1196
1197 findings.extend(self.analyze_dockerfile_security(project_root)?);
1199 findings.extend(self.analyze_compose_security(project_root)?);
1200
1201 findings.extend(self.analyze_cicd_security(project_root)?);
1203
1204 info!(
1205 "🔍 Found {} infrastructure security findings",
1206 findings.len()
1207 );
1208 Ok(findings)
1209 }
1210
1211 fn analyze_environment_security(&self, env_vars: &[EnvVar]) -> Vec<SecurityFinding> {
1213 let mut findings = Vec::new();
1214
1215 for env_var in env_vars {
1216 if self.is_sensitive_env_var(&env_var.name) && env_var.default_value.is_some() {
1218 findings.push(SecurityFinding {
1219 id: format!("env-{}", env_var.name.to_lowercase()),
1220 title: "Sensitive Environment Variable with Default Value".to_string(),
1221 description: format!("Environment variable '{}' appears to contain sensitive data but has a default value", env_var.name),
1222 severity: SecuritySeverity::Medium,
1223 category: SecurityCategory::SecretsExposure,
1224 file_path: None,
1225 line_number: None,
1226 column_number: None,
1227 evidence: Some(format!("Variable: {} = {:?}", env_var.name, env_var.default_value)),
1228 remediation: vec![
1229 "Remove default value for sensitive environment variables".to_string(),
1230 "Use a secure secret management system".to_string(),
1231 "Document required environment variables separately".to_string(),
1232 ],
1233 references: vec![
1234 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
1235 ],
1236 cwe_id: Some("CWE-200".to_string()),
1237 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1238 });
1239 }
1240 }
1241
1242 findings
1243 }
1244
1245 #[allow(dead_code)]
1247 fn analyze_framework_security_with_progress(
1248 &self,
1249 project_root: &Path,
1250 technologies: &[DetectedTechnology],
1251 multi_progress: &MultiProgress,
1252 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1253 debug!("Analyzing framework-specific security");
1254 let mut findings = Vec::new();
1255
1256 let framework_count = technologies.len();
1257 if framework_count == 0 {
1258 info!("No frameworks detected for security analysis");
1259 return Ok(findings);
1260 }
1261
1262 let is_verbose = log::max_level() >= log::LevelFilter::Info;
1263
1264 info!("🔧 Found {} frameworks to analyze", framework_count);
1265
1266 let fw_pb = if is_verbose {
1268 None
1270 } else {
1271 let pb = multi_progress.add(ProgressBar::new_spinner());
1273 pb.set_style(
1274 ProgressStyle::default_spinner()
1275 .template(" 🔧 {msg} {spinner:.cyan}")
1276 .unwrap()
1277 .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
1278 );
1279 pb.enable_steady_tick(std::time::Duration::from_millis(120));
1280 Some(pb)
1281 };
1282
1283 for tech in technologies {
1284 if let Some(ref pb) = fw_pb {
1285 pb.set_message(format!("Checking {} configuration...", tech.name));
1286 }
1287
1288 match tech.name.as_str() {
1289 "Express.js" | "Express" => {
1290 findings.extend(self.analyze_express_security(project_root)?);
1291 }
1292 "Django" => {
1293 findings.extend(self.analyze_django_security(project_root)?);
1294 }
1295 "Spring Boot" => {
1296 findings.extend(self.analyze_spring_security(project_root)?);
1297 }
1298 "Next.js" => {
1299 findings.extend(self.analyze_nextjs_security(project_root)?);
1300 }
1301 _ => {}
1303 }
1304 }
1305
1306 if let Some(pb) = fw_pb {
1308 pb.finish_with_message("✅ Framework analysis complete");
1309 }
1310 info!("🔍 Found {} framework security findings", findings.len());
1311
1312 Ok(findings)
1313 }
1314
1315 #[allow(dead_code)]
1317 fn analyze_framework_security(
1318 &self,
1319 project_root: &Path,
1320 technologies: &[DetectedTechnology],
1321 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1322 debug!("Analyzing framework-specific security");
1323 let mut findings = Vec::new();
1324
1325 let framework_count = technologies.len();
1326 if framework_count == 0 {
1327 info!("No frameworks detected for security analysis");
1328 return Ok(findings);
1329 }
1330
1331 info!("🔧 Found {} frameworks to analyze", framework_count);
1332
1333 for tech in technologies {
1334 match tech.name.as_str() {
1335 "Express.js" | "Express" => {
1336 findings.extend(self.analyze_express_security(project_root)?);
1337 }
1338 "Django" => {
1339 findings.extend(self.analyze_django_security(project_root)?);
1340 }
1341 "Spring Boot" => {
1342 findings.extend(self.analyze_spring_security(project_root)?);
1343 }
1344 "Next.js" => {
1345 findings.extend(self.analyze_nextjs_security(project_root)?);
1346 }
1347 _ => {}
1349 }
1350 }
1351
1352 info!("🔍 Found {} framework security findings", findings.len());
1353 Ok(findings)
1354 }
1355
1356 fn collect_config_files(&self, project_root: &Path) -> Result<Vec<PathBuf>, SecurityError> {
1359 let patterns = vec![
1360 "*.env*",
1361 "*.conf",
1362 "*.config",
1363 "*.ini",
1364 "*.yaml",
1365 "*.yml",
1366 "*.toml",
1367 "docker-compose*.yml",
1368 "Dockerfile*",
1369 ".github/**/*.yml",
1370 ".gitlab-ci.yml",
1371 "package.json",
1372 "requirements.txt",
1373 "Cargo.toml",
1374 "go.mod",
1375 "pom.xml",
1376 ];
1377
1378 let mut files = crate::common::file_utils::find_files_by_patterns(project_root, &patterns)
1379 .map_err(SecurityError::Io)?;
1380
1381 files.retain(|file| {
1383 let file_name = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
1384 let file_path = file.to_string_lossy();
1385
1386 !self.config.ignore_patterns.iter().any(|pattern| {
1387 if pattern.contains('*') {
1388 glob::Pattern::new(pattern)
1390 .map(|p| p.matches(&file_path) || p.matches(file_name))
1391 .unwrap_or(false)
1392 } else {
1393 file_path.contains(pattern) || file_name.contains(pattern)
1395 }
1396 })
1397 });
1398
1399 Ok(files)
1400 }
1401
1402 fn analyze_file_for_secrets(
1403 &self,
1404 file_path: &Path,
1405 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1406 let content = fs::read_to_string(file_path)?;
1407 let mut findings = Vec::new();
1408
1409 for (line_num, line) in content.lines().enumerate() {
1410 for pattern in &self.secret_patterns {
1411 if let Some(match_) = pattern.pattern.find(line) {
1412 if self.is_likely_placeholder(line) {
1414 continue;
1415 }
1416
1417 if self.is_legitimate_env_var_usage(line, file_path) {
1419 debug!("Skipping legitimate env var usage: {}", line.trim());
1420 continue;
1421 }
1422
1423 let (severity, additional_remediation) =
1425 self.determine_secret_severity(file_path, pattern.severity.clone());
1426
1427 if self.config.skip_gitignored_files && severity == SecuritySeverity::Info {
1429 debug!(
1430 "Skipping secret in gitignored file: {}",
1431 file_path.display()
1432 );
1433 continue;
1434 }
1435
1436 let mut remediation = vec![
1438 "Remove sensitive data from source code".to_string(),
1439 "Use environment variables for secrets".to_string(),
1440 "Consider using a secure secret management service".to_string(),
1441 ];
1442
1443 remediation.extend(additional_remediation);
1445
1446 if !self.is_file_gitignored(file_path) && !self.is_file_tracked(file_path) {
1448 remediation.push(
1449 "Add this file to .gitignore to prevent accidental commits".to_string(),
1450 );
1451 }
1452
1453 let mut description = pattern.description.clone();
1455 if self.is_file_tracked(file_path) {
1456 description.push_str(" (⚠️ WARNING: File is tracked by git - secrets may be in version history!)");
1457 } else if self.is_file_gitignored(file_path) {
1458 description.push_str(" (ℹ️ Note: File is gitignored)");
1459 }
1460
1461 findings.push(SecurityFinding {
1462 id: format!("secret-{}-{}", pattern.name.to_lowercase().replace(' ', "-"), line_num),
1463 title: format!("Potential {} Exposure", pattern.name),
1464 description,
1465 severity,
1466 category: SecurityCategory::SecretsExposure,
1467 file_path: Some(file_path.to_path_buf()),
1468 line_number: Some(line_num + 1),
1469 column_number: Some(match_.start() + 1), evidence: Some(format!("Line: {}", line.trim())),
1471 remediation,
1472 references: vec![
1473 "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(),
1474 ],
1475 cwe_id: Some("CWE-200".to_string()),
1476 compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()],
1477 });
1478 }
1479 }
1480 }
1481
1482 Ok(findings)
1483 }
1484
1485 fn is_legitimate_env_var_usage(&self, line: &str, file_path: &Path) -> bool {
1487 let line_trimmed = line.trim();
1488
1489 let legitimate_env_patterns = [
1491 r"process\.env\.[A-Z_]+",
1493 r#"process\.env\[['""][A-Z_]+['"]\]"#,
1494 r"import\.meta\.env\.[A-Z_]+",
1496 r#"import\.meta\.env\[['""][A-Z_]+['"]\]"#,
1497 r#"os\.environ\.get\(["'][A-Z_]+["']\)"#,
1499 r#"os\.environ\[["'][A-Z_]+["']\]"#,
1500 r#"getenv\(["'][A-Z_]+["']\)"#,
1501 r#"env::var\("([A-Z_]+)"\)"#,
1503 r#"std::env::var\("([A-Z_]+)"\)"#,
1504 r#"os\.Getenv\(["'][A-Z_]+["']\)"#,
1506 r#"System\.getenv\(["'][A-Z_]+["']\)"#,
1508 r"\$\{?[A-Z_]+\}?",
1510 r"ENV [A-Z_]+",
1511 r"config\.[a-z_]+\.[A-Z_]+",
1513 r"settings\.[A-Z_]+",
1514 r"env\.[A-Z_]+",
1515 ];
1516
1517 for pattern_str in &legitimate_env_patterns {
1519 if let Ok(pattern) = Regex::new(pattern_str)
1520 && pattern.is_match(line_trimmed)
1521 {
1522 if self.is_server_side_file(file_path) {
1526 return true;
1527 }
1528
1529 if !self.is_client_side_exposed_env_var(line_trimmed) {
1531 return true;
1532 }
1533 }
1534 }
1535
1536 if self.is_env_var_assignment_context(line_trimmed, file_path) {
1539 return true;
1540 }
1541
1542 false
1543 }
1544
1545 fn is_server_side_file(&self, file_path: &Path) -> bool {
1547 let path_str = file_path.to_string_lossy().to_lowercase();
1548 let file_name = file_path
1549 .file_name()
1550 .and_then(|n| n.to_str())
1551 .unwrap_or("")
1552 .to_lowercase();
1553
1554 let server_indicators = [
1556 "/server/",
1557 "/api/",
1558 "/backend/",
1559 "/src/app/api/",
1560 "/pages/api/",
1561 "/routes/",
1562 "/controllers/",
1563 "/middleware/",
1564 "/models/",
1565 "/lib/",
1566 "/utils/",
1567 "/services/",
1568 "/config/",
1569 "server.js",
1570 "index.js",
1571 "app.js",
1572 "main.js",
1573 ".env",
1574 "dockerfile",
1575 "docker-compose",
1576 ];
1577
1578 let client_indicators = [
1580 "/public/",
1581 "/static/",
1582 "/assets/",
1583 "/components/",
1584 "/pages/",
1585 "/src/components/",
1586 "/src/pages/",
1587 "/client/",
1588 "/frontend/",
1589 "index.html",
1590 ".html",
1591 "/dist/",
1592 "/build/",
1593 "dist/",
1594 "build/",
1595 "public/",
1596 "static/",
1597 "assets/",
1598 ];
1599
1600 if client_indicators
1602 .iter()
1603 .any(|indicator| path_str.contains(indicator))
1604 {
1605 return false;
1606 }
1607
1608 if server_indicators
1610 .iter()
1611 .any(|indicator| path_str.contains(indicator) || file_name.contains(indicator))
1612 {
1613 return true;
1614 }
1615
1616 true
1618 }
1619
1620 fn is_client_side_exposed_env_var(&self, line: &str) -> bool {
1622 let client_prefixes = [
1623 "REACT_APP_",
1624 "NEXT_PUBLIC_",
1625 "VUE_APP_",
1626 "VITE_",
1627 "GATSBY_",
1628 "PUBLIC_",
1629 "NUXT_PUBLIC_",
1630 ];
1631
1632 client_prefixes.iter().any(|prefix| line.contains(prefix))
1633 }
1634
1635 fn is_env_var_assignment_context(&self, line: &str, file_path: &Path) -> bool {
1637 let path_str = file_path.to_string_lossy().to_lowercase();
1638 let file_name = file_path
1639 .file_name()
1640 .and_then(|n| n.to_str())
1641 .unwrap_or("")
1642 .to_lowercase();
1643
1644 let env_config_files = [
1647 ".env",
1648 "docker-compose.yml",
1649 "docker-compose.yaml",
1650 ".env.example",
1651 ".env.sample",
1652 ".env.template",
1653 ".env.local",
1654 ".env.development",
1655 ".env.production",
1656 ".env.staging",
1657 ];
1658
1659 if env_config_files.iter().any(|pattern| file_name == *pattern) {
1661 return true;
1662 }
1663
1664 if file_name.starts_with("dockerfile") || file_name == "dockerfile" {
1666 return true;
1667 }
1668
1669 if file_name.ends_with(".sh")
1671 || file_name.ends_with(".bash")
1672 || path_str.contains(".github/workflows/")
1673 || path_str.contains(".gitlab-ci")
1674 {
1675 return true;
1676 }
1677
1678 let setup_patterns = [
1681 r"export [A-Z_]+=", r"ENV [A-Z_]+=", r"^\s*environment:\s*$", r"^\s*env:\s*$", r"process\.env\.[A-Z_]+ =", ];
1687
1688 for pattern_str in &setup_patterns {
1689 if let Ok(pattern) = Regex::new(pattern_str)
1690 && pattern.is_match(line)
1691 {
1692 return true;
1693 }
1694 }
1695
1696 false
1697 }
1698
1699 fn is_likely_placeholder(&self, line: &str) -> bool {
1700 let placeholder_indicators = [
1701 "example",
1702 "placeholder",
1703 "your_",
1704 "insert_",
1705 "replace_",
1706 "xxx",
1707 "yyy",
1708 "zzz",
1709 "fake",
1710 "dummy",
1711 "test_key",
1712 "sk-xxxxxxxx",
1713 "AKIA00000000",
1714 ];
1715
1716 let hash_indicators = [
1717 "checksum",
1718 "hash",
1719 "sha1",
1720 "sha256",
1721 "md5",
1722 "commit",
1723 "fingerprint",
1724 "digest",
1725 "advisory",
1726 "ghsa-",
1727 "cve-",
1728 "rustc_fingerprint",
1729 "last-commit",
1730 "references",
1731 ];
1732
1733 let line_lower = line.to_lowercase();
1734
1735 if placeholder_indicators
1737 .iter()
1738 .any(|indicator| line_lower.contains(indicator))
1739 {
1740 return true;
1741 }
1742
1743 if hash_indicators
1745 .iter()
1746 .any(|indicator| line_lower.contains(indicator))
1747 {
1748 return true;
1749 }
1750
1751 if line_lower.contains("http") || line_lower.contains("github.com") {
1753 return true;
1754 }
1755
1756 if let Some(potential_hash) = self.extract_potential_hash(line)
1758 && potential_hash.len() >= 32
1759 && self.is_hex_only(&potential_hash)
1760 {
1761 return true; }
1763
1764 false
1765 }
1766
1767 fn extract_potential_hash(&self, line: &str) -> Option<String> {
1768 if let Some(start) = line.find('"')
1770 && let Some(end) = line[start + 1..].find('"')
1771 {
1772 let potential = &line[start + 1..start + 1 + end];
1773 if potential.len() >= 32 {
1774 return Some(potential.to_string());
1775 }
1776 }
1777 None
1778 }
1779
1780 fn is_hex_only(&self, s: &str) -> bool {
1781 s.chars().all(|c| c.is_ascii_hexdigit())
1782 }
1783
1784 fn is_sensitive_env_var(&self, name: &str) -> bool {
1785 let sensitive_patterns = [
1786 "password",
1787 "secret",
1788 "key",
1789 "token",
1790 "auth",
1791 "api",
1792 "private",
1793 "credential",
1794 "cert",
1795 "ssl",
1796 "tls",
1797 ];
1798
1799 let name_lower = name.to_lowercase();
1800 sensitive_patterns
1801 .iter()
1802 .any(|pattern| name_lower.contains(pattern))
1803 }
1804
1805 fn analyze_express_security(
1807 &self,
1808 _project_root: &Path,
1809 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1810 Ok(vec![])
1812 }
1813
1814 fn analyze_django_security(
1815 &self,
1816 _project_root: &Path,
1817 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1818 Ok(vec![])
1820 }
1821
1822 fn analyze_spring_security(
1823 &self,
1824 _project_root: &Path,
1825 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1826 Ok(vec![])
1828 }
1829
1830 fn analyze_nextjs_security(
1831 &self,
1832 _project_root: &Path,
1833 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1834 Ok(vec![])
1836 }
1837
1838 fn analyze_dockerfile_security(
1839 &self,
1840 _project_root: &Path,
1841 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1842 Ok(vec![])
1844 }
1845
1846 fn analyze_compose_security(
1847 &self,
1848 _project_root: &Path,
1849 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1850 Ok(vec![])
1852 }
1853
1854 fn analyze_cicd_security(
1855 &self,
1856 _project_root: &Path,
1857 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1858 Ok(vec![])
1860 }
1861
1862 fn collect_source_files(
1864 &self,
1865 _project_root: &Path,
1866 _language: &str,
1867 ) -> Result<Vec<PathBuf>, SecurityError> {
1868 Ok(vec![])
1870 }
1871
1872 fn analyze_file_with_rules(
1873 &self,
1874 _file_path: &Path,
1875 _rules: &[SecurityRule],
1876 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1877 Ok(vec![])
1879 }
1880
1881 fn check_insecure_configurations(
1882 &self,
1883 _project_root: &Path,
1884 ) -> Result<Vec<SecurityFinding>, SecurityError> {
1885 Ok(vec![])
1887 }
1888
1889 fn deduplicate_findings(&self, mut findings: Vec<SecurityFinding>) -> Vec<SecurityFinding> {
1891 use std::collections::HashSet;
1892
1893 let mut seen_secrets: HashSet<String> = HashSet::new();
1894 let mut deduplicated = Vec::new();
1895
1896 findings.sort_by(|a, b| {
1898 let a_priority = self.get_pattern_priority(&a.title);
1900 let b_priority = self.get_pattern_priority(&b.title);
1901
1902 match a_priority.cmp(&b_priority) {
1903 std::cmp::Ordering::Equal => {
1904 a.severity.cmp(&b.severity)
1906 }
1907 other => other,
1908 }
1909 });
1910
1911 for finding in findings {
1912 let key = self.generate_finding_key(&finding);
1913
1914 if !seen_secrets.contains(&key) {
1915 seen_secrets.insert(key);
1916 deduplicated.push(finding);
1917 }
1918 }
1919
1920 deduplicated
1921 }
1922
1923 fn generate_finding_key(&self, finding: &SecurityFinding) -> String {
1925 match finding.category {
1926 SecurityCategory::SecretsExposure => {
1927 if let Some(evidence) = &finding.evidence
1929 && let Some(file_path) = &finding.file_path
1930 {
1931 if let Some(secret_value) = self.extract_secret_value(evidence) {
1933 return format!("secret:{}:{}", file_path.display(), secret_value);
1934 }
1935 if let Some(line_num) = finding.line_number {
1937 return format!("secret:{}:{}", file_path.display(), line_num);
1938 }
1939 }
1940 format!("secret:{}", finding.title)
1942 }
1943 _ => {
1944 if let Some(file_path) = &finding.file_path
1946 && let Some(line_num) = finding.line_number
1947 {
1948 format!(
1949 "other:{}:{}:{}",
1950 file_path.display(),
1951 line_num,
1952 finding.title
1953 )
1954 } else if let Some(file_path) = &finding.file_path {
1955 format!("other:{}:{}", file_path.display(), finding.title)
1956 } else {
1957 format!("other:{}", finding.title)
1958 }
1959 }
1960 }
1961 }
1962
1963 fn extract_secret_value(&self, evidence: &str) -> Option<String> {
1965 if let Some(pos) = evidence.find('=') {
1967 let value = evidence[pos + 1..].trim();
1968 let value = value.trim_matches('"').trim_matches('\'');
1970 if value.len() > 10 {
1971 return Some(value.to_string());
1973 }
1974 }
1975
1976 if let Some(pos) = evidence.find(':') {
1978 let value = evidence[pos + 1..].trim();
1979 let value = value.trim_matches('"').trim_matches('\'');
1980 if value.len() > 10 {
1981 return Some(value.to_string());
1982 }
1983 }
1984
1985 None
1986 }
1987
1988 fn get_pattern_priority(&self, title: &str) -> u8 {
1990 if title.contains("AWS Access Key") {
1992 return 1;
1993 }
1994 if title.contains("AWS Secret Key") {
1995 return 1;
1996 }
1997 if title.contains("S3 Secret Key") {
1998 return 1;
1999 }
2000 if title.contains("GitHub Token") {
2001 return 1;
2002 }
2003 if title.contains("OpenAI API Key") {
2004 return 1;
2005 }
2006 if title.contains("Stripe") {
2007 return 1;
2008 }
2009 if title.contains("RSA Private Key") {
2010 return 1;
2011 }
2012 if title.contains("SSH Private Key") {
2013 return 1;
2014 }
2015
2016 if title.contains("JWT Secret") {
2018 return 2;
2019 }
2020 if title.contains("Database URL") {
2021 return 2;
2022 }
2023
2024 if title.contains("API Key") {
2026 return 3;
2027 }
2028
2029 if title.contains("Environment Variable") {
2031 return 4;
2032 }
2033
2034 if title.contains("Generic Secret") {
2036 return 5;
2037 }
2038
2039 3
2041 }
2042
2043 fn count_by_severity(&self, findings: &[SecurityFinding]) -> HashMap<SecuritySeverity, usize> {
2044 let mut counts = HashMap::new();
2045 for finding in findings {
2046 *counts.entry(finding.severity.clone()).or_insert(0) += 1;
2047 }
2048 counts
2049 }
2050
2051 fn count_by_category(&self, findings: &[SecurityFinding]) -> HashMap<SecurityCategory, usize> {
2052 let mut counts = HashMap::new();
2053 for finding in findings {
2054 *counts.entry(finding.category.clone()).or_insert(0) += 1;
2055 }
2056 counts
2057 }
2058
2059 fn calculate_security_score(&self, findings: &[SecurityFinding]) -> f32 {
2060 if findings.is_empty() {
2061 return 100.0;
2062 }
2063
2064 let total_penalty = findings
2065 .iter()
2066 .map(|f| match f.severity {
2067 SecuritySeverity::Critical => 25.0,
2068 SecuritySeverity::High => 15.0,
2069 SecuritySeverity::Medium => 8.0,
2070 SecuritySeverity::Low => 3.0,
2071 SecuritySeverity::Info => 1.0,
2072 })
2073 .sum::<f32>();
2074
2075 (100.0 - total_penalty).max(0.0)
2076 }
2077
2078 fn determine_risk_level(&self, findings: &[SecurityFinding]) -> SecuritySeverity {
2079 if findings
2080 .iter()
2081 .any(|f| f.severity == SecuritySeverity::Critical)
2082 {
2083 SecuritySeverity::Critical
2084 } else if findings
2085 .iter()
2086 .any(|f| f.severity == SecuritySeverity::High)
2087 {
2088 SecuritySeverity::High
2089 } else if findings
2090 .iter()
2091 .any(|f| f.severity == SecuritySeverity::Medium)
2092 {
2093 SecuritySeverity::Medium
2094 } else if !findings.is_empty() {
2095 SecuritySeverity::Low
2096 } else {
2097 SecuritySeverity::Info
2098 }
2099 }
2100
2101 #[allow(dead_code)]
2102 fn assess_compliance(
2103 &self,
2104 _findings: &[SecurityFinding],
2105 _technologies: &[DetectedTechnology],
2106 ) -> HashMap<String, ComplianceStatus> {
2107 HashMap::new()
2109 }
2110
2111 fn generate_recommendations(
2112 &self,
2113 findings: &[SecurityFinding],
2114 _technologies: &[DetectedTechnology],
2115 ) -> Vec<String> {
2116 let mut recommendations = Vec::new();
2117
2118 if findings
2119 .iter()
2120 .any(|f| f.category == SecurityCategory::SecretsExposure)
2121 {
2122 recommendations.push("Implement a secure secret management strategy".to_string());
2123 }
2124
2125 if findings
2126 .iter()
2127 .any(|f| f.severity == SecuritySeverity::Critical)
2128 {
2129 recommendations.push("Address critical security findings immediately".to_string());
2130 }
2131
2132 recommendations
2133 }
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138 use super::*;
2139
2140 #[test]
2141 fn test_security_score_calculation() {
2142 let analyzer = SecurityAnalyzer::new().unwrap();
2143
2144 let findings = vec![SecurityFinding {
2145 id: "test-1".to_string(),
2146 title: "Test Critical".to_string(),
2147 description: "Test".to_string(),
2148 severity: SecuritySeverity::Critical,
2149 category: SecurityCategory::SecretsExposure,
2150 file_path: None,
2151 line_number: None,
2152 column_number: None,
2153 evidence: None,
2154 remediation: vec![],
2155 references: vec![],
2156 cwe_id: None,
2157 compliance_frameworks: vec![],
2158 }];
2159
2160 let score = analyzer.calculate_security_score(&findings);
2161 assert_eq!(score, 75.0); }
2163
2164 #[test]
2165 fn test_secret_pattern_matching() {
2166 let analyzer = SecurityAnalyzer::new().unwrap();
2167
2168 assert!(analyzer.is_likely_placeholder("API_KEY=sk-xxxxxxxxxxxxxxxx"));
2170 assert!(!analyzer.is_likely_placeholder("API_KEY=sk-1234567890abcdef"));
2171 }
2172
2173 #[test]
2174 fn test_sensitive_env_var_detection() {
2175 let analyzer = SecurityAnalyzer::new().unwrap();
2176
2177 assert!(analyzer.is_sensitive_env_var("DATABASE_PASSWORD"));
2178 assert!(analyzer.is_sensitive_env_var("JWT_SECRET"));
2179 assert!(!analyzer.is_sensitive_env_var("PORT"));
2180 assert!(!analyzer.is_sensitive_env_var("NODE_ENV"));
2181 }
2182
2183 #[test]
2184 fn test_gitignore_aware_severity() {
2185 use std::fs;
2186 use std::process::Command;
2187 use tempfile::TempDir;
2188
2189 let temp_dir = TempDir::new().unwrap();
2190 let project_root = temp_dir.path();
2191
2192 let git_init = Command::new("git")
2194 .args(["init"])
2195 .current_dir(project_root)
2196 .output();
2197
2198 if git_init.is_err() {
2200 println!("Skipping gitignore test - git not available");
2201 return;
2202 }
2203
2204 fs::write(project_root.join(".gitignore"), ".env\n.env.local\n").unwrap();
2206
2207 let _ = Command::new("git")
2209 .args(["add", ".gitignore"])
2210 .current_dir(project_root)
2211 .output();
2212 let _ = Command::new("git")
2213 .args(["config", "user.email", "test@example.com"])
2214 .current_dir(project_root)
2215 .output();
2216 let _ = Command::new("git")
2217 .args(["config", "user.name", "Test User"])
2218 .current_dir(project_root)
2219 .output();
2220 let _ = Command::new("git")
2221 .args(["commit", "-m", "Add gitignore"])
2222 .current_dir(project_root)
2223 .output();
2224
2225 let mut analyzer = SecurityAnalyzer::new().unwrap();
2226 analyzer.project_root = Some(project_root.to_path_buf());
2227
2228 let env_file = project_root.join(".env");
2230 fs::write(&env_file, "API_KEY=sk-1234567890abcdef").unwrap();
2231
2232 let (severity, remediation) =
2234 analyzer.determine_secret_severity(&env_file, SecuritySeverity::High);
2235
2236 assert_eq!(severity, SecuritySeverity::Info);
2238 assert!(remediation.iter().any(|r| r.contains("gitignored")));
2239 }
2240
2241 #[test]
2242 fn test_gitignore_config_options() {
2243 let mut config = SecurityAnalysisConfig::default();
2244
2245 assert!(config.skip_gitignored_files);
2247 assert!(!config.downgrade_gitignored_severity);
2248
2249 config.skip_gitignored_files = false;
2251 config.downgrade_gitignored_severity = true;
2252
2253 let _analyzer = SecurityAnalyzer::with_config(config).unwrap();
2254 }
2256
2257 #[test]
2258 fn test_gitignore_pattern_matching() {
2259 let analyzer = SecurityAnalyzer::new().unwrap();
2260
2261 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"));
2266
2267 assert!(analyzer.matches_gitignore_pattern(".env", ".env", ".env"));
2269 assert!(!analyzer.matches_gitignore_pattern(".env", ".env.local", ".env.local"));
2270
2271 assert!(analyzer.matches_gitignore_pattern("/config.json", "config.json", "config.json"));
2273 assert!(!analyzer.matches_gitignore_pattern(
2274 "/config.json",
2275 "src/config.json",
2276 "config.json"
2277 ));
2278
2279 assert!(analyzer.matches_gitignore_pattern(".env*", ".env", ".env"));
2281 assert!(analyzer.matches_gitignore_pattern(".env*", ".env.local", ".env.local"));
2282 assert!(analyzer.matches_gitignore_pattern(".env.*", ".env.production", ".env.production"));
2283 }
2284
2285 #[test]
2286 fn test_common_env_patterns() {
2287 let analyzer = SecurityAnalyzer::new().unwrap();
2288
2289 assert!(analyzer.matches_common_env_patterns(".env"));
2291 assert!(analyzer.matches_common_env_patterns(".env.local"));
2292 assert!(analyzer.matches_common_env_patterns(".env.production"));
2293 assert!(analyzer.matches_common_env_patterns(".env.development"));
2294 assert!(analyzer.matches_common_env_patterns(".env.test"));
2295
2296 assert!(!analyzer.matches_common_env_patterns(".env.example"));
2298 assert!(!analyzer.matches_common_env_patterns(".env.sample"));
2299 assert!(!analyzer.matches_common_env_patterns(".env.template"));
2300
2301 assert!(!analyzer.matches_common_env_patterns("config.json"));
2303 assert!(!analyzer.matches_common_env_patterns("package.json"));
2304 }
2305
2306 #[test]
2307 fn test_legitimate_env_var_usage() {
2308 let analyzer = SecurityAnalyzer::new().unwrap();
2309
2310 let server_file = Path::new("src/server/config.js");
2312 let client_file = Path::new("src/components/MyComponent.js");
2313
2314 assert!(analyzer.is_legitimate_env_var_usage(
2316 "const apiKey = process.env.RESEND_API_KEY;",
2317 server_file
2318 ));
2319 assert!(
2320 analyzer.is_legitimate_env_var_usage(
2321 "const dbUrl = process.env.DATABASE_URL;",
2322 server_file
2323 )
2324 );
2325 assert!(
2326 analyzer
2327 .is_legitimate_env_var_usage("api_key = os.environ.get('API_KEY')", server_file)
2328 );
2329 assert!(
2330 analyzer.is_legitimate_env_var_usage(
2331 "let secret = env::var(\"JWT_SECRET\")?;",
2332 server_file
2333 )
2334 );
2335
2336 assert!(
2338 analyzer
2339 .is_legitimate_env_var_usage("const apiUrl = process.env.API_URL;", client_file)
2340 );
2341
2342 assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET_KEY"));
2344 assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_API_SECRET"));
2345
2346 assert!(
2348 !analyzer
2349 .is_legitimate_env_var_usage("const apiKey = 'sk-1234567890abcdef';", server_file)
2350 );
2351 assert!(!analyzer.is_legitimate_env_var_usage("password = 'hardcoded123'", server_file));
2352 }
2353
2354 #[test]
2355 fn test_server_vs_client_side_detection() {
2356 let analyzer = SecurityAnalyzer::new().unwrap();
2357
2358 assert!(analyzer.is_server_side_file(Path::new("src/server/app.js")));
2360 assert!(analyzer.is_server_side_file(Path::new("src/api/users.js")));
2361 assert!(analyzer.is_server_side_file(Path::new("pages/api/auth.js")));
2362 assert!(analyzer.is_server_side_file(Path::new("src/lib/database.js")));
2363 assert!(analyzer.is_server_side_file(Path::new(".env")));
2364 assert!(analyzer.is_server_side_file(Path::new("server.js")));
2365
2366 assert!(!analyzer.is_server_side_file(Path::new("src/components/Button.jsx")));
2368 assert!(!analyzer.is_server_side_file(Path::new("public/index.html")));
2369 assert!(!analyzer.is_server_side_file(Path::new("src/pages/home.js")));
2370 assert!(!analyzer.is_server_side_file(Path::new("dist/bundle.js")));
2371
2372 assert!(analyzer.is_server_side_file(Path::new("src/utils/helper.js")));
2374 assert!(analyzer.is_server_side_file(Path::new("config/settings.js")));
2375 }
2376
2377 #[test]
2378 fn test_client_side_exposed_env_vars() {
2379 let analyzer = SecurityAnalyzer::new().unwrap();
2380
2381 assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET"));
2383 assert!(analyzer.is_client_side_exposed_env_var("import.meta.env.VITE_API_KEY"));
2384 assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_SECRET"));
2385 assert!(analyzer.is_client_side_exposed_env_var("process.env.VUE_APP_TOKEN"));
2386
2387 assert!(!analyzer.is_client_side_exposed_env_var("process.env.DATABASE_URL"));
2389 assert!(!analyzer.is_client_side_exposed_env_var("process.env.JWT_SECRET"));
2390 assert!(!analyzer.is_client_side_exposed_env_var("process.env.API_KEY"));
2391 }
2392
2393 #[test]
2394 fn test_env_var_assignment_context() {
2395 let analyzer = SecurityAnalyzer::new().unwrap();
2396
2397 assert!(analyzer.is_env_var_assignment_context("API_KEY=sk-test123", Path::new(".env")));
2399 assert!(analyzer.is_env_var_assignment_context(
2400 "DATABASE_URL=postgres://",
2401 Path::new("docker-compose.yml")
2402 ));
2403 assert!(
2404 analyzer.is_env_var_assignment_context("export SECRET=test", Path::new("setup.sh"))
2405 );
2406
2407 assert!(
2409 !analyzer.is_env_var_assignment_context(
2410 "const secret = 'hardcoded'",
2411 Path::new("src/app.js")
2412 )
2413 );
2414 }
2415
2416 #[test]
2417 fn test_enhanced_secret_patterns() {
2418 let analyzer = SecurityAnalyzer::new().unwrap();
2419
2420 let hardcoded_patterns = [
2422 "apikey = 'sk-1234567890abcdef1234567890abcdef12345678'",
2423 "const secret = 'my-super-secret-token-12345678901234567890'",
2424 "password = 'hardcoded123456'",
2425 ];
2426
2427 for pattern in &hardcoded_patterns {
2428 let has_secret = analyzer
2429 .secret_patterns
2430 .iter()
2431 .any(|sp| sp.pattern.is_match(pattern));
2432 assert!(has_secret, "Should detect hardcoded secret in: {}", pattern);
2433 }
2434
2435 let legitimate_patterns = [
2437 "const apiKey = process.env.API_KEY;",
2438 "const dbUrl = process.env.DATABASE_URL || 'fallback';",
2439 "api_key = os.environ.get('API_KEY')",
2440 "let secret = env::var(\"JWT_SECRET\")?;",
2441 ];
2442
2443 for pattern in &legitimate_patterns {
2444 let _matches_old_generic_pattern =
2446 pattern.to_lowercase().contains("secret") || pattern.to_lowercase().contains("key");
2447
2448 let matches_new_patterns = analyzer
2450 .secret_patterns
2451 .iter()
2452 .filter(|sp| sp.name.contains("Hardcoded"))
2453 .any(|sp| sp.pattern.is_match(pattern));
2454
2455 assert!(
2456 !matches_new_patterns,
2457 "Should NOT detect legitimate env var usage as hardcoded secret: {}",
2458 pattern
2459 );
2460 }
2461 }
2462
2463 #[test]
2464 fn test_context_aware_false_positive_reduction() {
2465 use tempfile::TempDir;
2466
2467 let temp_dir = TempDir::new().unwrap();
2468 let server_file = temp_dir.path().join("src/server/config.js");
2469
2470 std::fs::create_dir_all(server_file.parent().unwrap()).unwrap();
2472
2473 let content = r#"
2475const config = {
2476 apiKey: process.env.RESEND_API_KEY,
2477 databaseUrl: process.env.DATABASE_URL,
2478 jwtSecret: process.env.JWT_SECRET,
2479 port: process.env.PORT || 3000
2480};
2481"#;
2482
2483 std::fs::write(&server_file, content).unwrap();
2484
2485 let analyzer = SecurityAnalyzer::new().unwrap();
2486 let findings = analyzer.analyze_file_for_secrets(&server_file).unwrap();
2487
2488 assert_eq!(
2490 findings.len(),
2491 0,
2492 "Should not flag legitimate environment variable usage as security issues"
2493 );
2494 }
2495}