diff --git a/Cargo.lock b/Cargo.lock index a5edabf5..5e48a6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,6 +3363,7 @@ dependencies = [ "serde_yaml", "tempfile", "tera", + "term_size", "termcolor", "textwrap", "thiserror 1.0.69", @@ -3474,6 +3475,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 23e07397..98e67a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ termcolor = "1" chrono = { version = "0.4", features = ["serde"] } colored = "2" prettytable = "0.10" +term_size = "0.3" # Vulnerability checking dependencies rustsec = "0.29" diff --git a/docs/cli-display-modes.md b/docs/cli-display-modes.md index 96a6f01a..a72530d7 100644 --- a/docs/cli-display-modes.md +++ b/docs/cli-display-modes.md @@ -20,34 +20,34 @@ sync-ctl analyze . πŸ“Š PROJECT ANALYSIS DASHBOARD ═══════════════════════════════════════════════════════════════════════════════════════════════════ -β”Œβ”€ Architecture Overview ────────────────────────────────────────────────────────────────────────┐ +β”Œβ”€ Architecture Overview ─────────────────────────────────────────────────────────────────────────┐ β”‚ Type: Monorepo (3 projects) β”‚ β”‚ Pattern: Fullstack β”‚ β”‚ Full-stack app with frontend/backend separation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -β”Œβ”€ Technology Stack ─────────────────────────────────────────────────────────────────────────────┐ +β”Œβ”€ Technology Stack ──────────────────────────────────────────────────────────────────────────────┐ β”‚ Languages: TypeScript β”‚ β”‚ Frameworks: Encore, Tanstack Start β”‚ β”‚ Databases: Drizzle ORM β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€ Projects Matrix ──────────────────────────────────────────────────────────────────────────────┐ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Project β”‚ Type β”‚ Languages β”‚ Main Tech β”‚ Ports β”‚ Docker β”‚ Deps β”‚ β”‚ -β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€ β”‚ -β”‚ β”‚ βš™οΈ backend β”‚ Backend β”‚ TypeScriptβ”‚ Encore β”‚ 4000 β”‚ βœ“ β”‚ 32 β”‚ β”‚ -β”‚ β”‚ πŸ—οΈ devops-agent β”‚ Infrastructureβ”‚ TypeScriptβ”‚ - β”‚ - β”‚ βœ— β”‚ 5 β”‚ β”‚ -β”‚ β”‚ 🌐 frontend β”‚ Frontend β”‚ TypeScriptβ”‚ Tanstack Start β”‚ 3000 β”‚ βœ“ β”‚ 123 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - -β”Œβ”€ Docker Infrastructure ────────────────────────────────────────────────────────────────────────┐ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Project β”‚ Type β”‚ Languages β”‚ Main Tech β”‚ Ports β”‚ Docker β”‚ Deps β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ backend β”‚ Backend β”‚ TypeScriptβ”‚ Encore β”‚ 4000 β”‚ βœ“ β”‚ 32 β”‚ β”‚ +β”‚ β”‚ devops-agent β”‚ Infrastructure β”‚ TypeScript β”‚ - β”‚ - β”‚ βœ— β”‚ 5 β”‚ β”‚ +β”‚ β”‚ frontend β”‚ Frontend β”‚ TypeScriptβ”‚ Tanstack Start β”‚ 3000 β”‚ βœ“ β”‚ 123 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€ Docker Infrastructure ─────────────────────────────────────────────────────────────────────────┐ β”‚ Dockerfiles: 2 β”‚ β”‚ Compose Files: 2 β”‚ β”‚ Total Services: 5 β”‚ β”‚ Orchestration Patterns: Microservices β”‚ -β”‚ ───────────────────────────────────────────────────────────────────────────────────────────── β”‚ +β”‚ ────────────────────────────────────────────────────────────────────────────────────────────────│ β”‚ Service Connectivity: β”‚ β”‚ encore-postgres: 5431:5432 β”‚ β”‚ encore: 4000:8080 β†’ encore-postgres β”‚ @@ -55,8 +55,8 @@ sync-ctl analyze . β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€ Analysis Metrics ─────────────────────────────────────────────────────────────────────────────┐ -β”‚ ⏱️ Duration: 57ms πŸ“ Files: 294 🎯 Score: 87% πŸ”– Version: 0.3.0 β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ Duration: 57ms Files: 294 Score: 87% Version: 0.3.0 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ═══════════════════════════════════════════════════════════════════════════════════════════════════ ``` diff --git a/examples/enhanced_security.rs b/examples/enhanced_security.rs new file mode 100644 index 00000000..3402ac6d --- /dev/null +++ b/examples/enhanced_security.rs @@ -0,0 +1,123 @@ +//! Example: Enhanced Security Analysis +//! +//! This example demonstrates the enhanced security analysis capabilities +//! including the new modular JavaScript/TypeScript security analyzer. + +use std::path::Path; +use syncable_cli::analyzer::{analyze_project, SecurityAnalyzer}; + +fn main() -> Result<(), Box> { + env_logger::init(); + + // For this example, analyze the current directory or a provided path + let project_path = std::env::args() + .nth(1) + .map(|p| Path::new(&p).to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("πŸ” Analyzing project security for: {}", project_path.display()); + + // First, perform regular project analysis to detect languages + let analysis = analyze_project(&project_path)?; + + println!("\nπŸ“‹ Detected Languages:"); + for lang in &analysis.languages { + println!(" β€’ {} (confidence: {:.1}%)", lang.name, lang.confidence * 100.0); + } + + println!("\nπŸ”§ Detected Technologies:"); + for tech in &analysis.technologies { + println!(" β€’ {} v{} ({:?})", + tech.name, + tech.version.as_deref().unwrap_or("unknown"), + tech.category + ); + } + + // Check if this is a JavaScript/TypeScript project + let has_js = analysis.languages.iter() + .any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")); + + if has_js { + println!("\nβœ… JavaScript/TypeScript project detected! Using enhanced security analysis..."); + } else { + println!("\nπŸ“„ Using general security analysis..."); + } + + // Run enhanced security analysis + println!("\nπŸ›‘οΈ Starting enhanced security analysis..."); + + let mut security_analyzer = SecurityAnalyzer::new()?; + let security_report = security_analyzer.analyze_security_enhanced(&analysis)?; + + // Display results + println!("\nπŸ“Š Security Analysis Results:"); + println!(" Overall Score: {:.1}/100", security_report.overall_score); + println!(" Risk Level: {:?}", security_report.risk_level); + println!(" Total Findings: {}", security_report.total_findings); + + if security_report.total_findings > 0 { + println!("\n🚨 Security Findings:"); + + // Group findings by severity + for severity in [ + syncable_cli::analyzer::security::core::SecuritySeverity::Critical, + syncable_cli::analyzer::security::core::SecuritySeverity::High, + syncable_cli::analyzer::security::core::SecuritySeverity::Medium, + syncable_cli::analyzer::security::core::SecuritySeverity::Low, + ] { + let findings: Vec<_> = security_report.findings.iter() + .filter(|f| f.severity == severity) + .collect(); + + if !findings.is_empty() { + let severity_icon = match severity { + syncable_cli::analyzer::security::core::SecuritySeverity::Critical => "πŸ”΄", + syncable_cli::analyzer::security::core::SecuritySeverity::High => "🟠", + syncable_cli::analyzer::security::core::SecuritySeverity::Medium => "🟑", + syncable_cli::analyzer::security::core::SecuritySeverity::Low => "πŸ”΅", + _ => "βšͺ", + }; + + println!("\n{} {:?} Severity ({} findings):", severity_icon, severity, findings.len()); + + for finding in findings.iter().take(3) { // Show first 3 of each severity + println!(" πŸ“ {}", finding.title); + if let Some(ref file_path) = finding.file_path { + let relative_path = file_path.strip_prefix(&project_path) + .unwrap_or(file_path); + print!(" πŸ“„ {}", relative_path.display()); + if let Some(line) = finding.line_number { + print!(":{}", line); + } + println!(); + } + println!(" πŸ’‘ {}", finding.description); + + if !finding.remediation.is_empty() { + println!(" πŸ”§ Remediation: {}", finding.remediation[0]); + } + println!(); + } + + if findings.len() > 3 { + println!(" ... and {} more findings", findings.len() - 3); + } + } + } + + // Show recommendations + if !security_report.recommendations.is_empty() { + println!("\nπŸ’‘ Recommendations:"); + for (i, recommendation) in security_report.recommendations.iter().enumerate() { + println!(" {}. {}", i + 1, recommendation); + } + } + } else { + println!("βœ… No security issues detected!"); + } + + println!("\n✨ Enhanced security analysis complete!"); + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/frameworks/go.rs b/src/analyzer/frameworks/go.rs index 44d1ade8..3faa51ab 100644 --- a/src/analyzer/frameworks/go.rs +++ b/src/analyzer/frameworks/go.rs @@ -232,12 +232,12 @@ fn get_go_technology_rules() -> Vec { // CLI FRAMEWORKS TechnologyRule { name: "Cobra".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["github.com/spf13/cobra".to_string(), "cobra".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec!["spf13/cobra".to_string()], }, diff --git a/src/analyzer/frameworks/rust.rs b/src/analyzer/frameworks/rust.rs index e9c07f1d..1b2c7cff 100644 --- a/src/analyzer/frameworks/rust.rs +++ b/src/analyzer/frameworks/rust.rs @@ -414,32 +414,32 @@ fn get_rust_technology_rules() -> Vec { // CLI FRAMEWORKS TechnologyRule { name: "clap".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["clap".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, TechnologyRule { name: "structopt".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["structopt".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, TechnologyRule { name: "argh".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["argh".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 5d19830f..4951c81a 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -19,6 +19,7 @@ pub mod language_detector; pub mod project_context; pub mod vulnerability_checker; pub mod security_analyzer; +pub mod security; pub mod tool_installer; pub mod monorepo_detector; pub mod docker_analyzer; @@ -36,6 +37,13 @@ pub use security_analyzer::{ SecurityCategory, ComplianceStatus, SecurityAnalysisConfig }; +// Re-export new modular security analysis types +pub use security::{ + ModularSecurityAnalyzer, JavaScriptSecurityAnalyzer, + SecretPatternManager +}; +pub use security::config::SecurityConfigPreset; + // Re-export monorepo analysis types pub use monorepo_detector::{ MonorepoDetectionConfig, analyze_monorepo, analyze_monorepo_with_config @@ -102,6 +110,8 @@ pub enum LibraryType { HttpClient, /// Authentication (Auth0, Firebase Auth) Authentication, + /// CLI frameworks (clap, structopt, argh) + CLI, /// Other specific types Other(String), } diff --git a/src/analyzer/security/config.rs b/src/analyzer/security/config.rs new file mode 100644 index 00000000..473c083e --- /dev/null +++ b/src/analyzer/security/config.rs @@ -0,0 +1,318 @@ +//! # Security Analysis Configuration +//! +//! Configuration options for customizing security analysis behavior. + +use serde::{Deserialize, Serialize}; + +/// Configuration for security analysis +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityAnalysisConfig { + // General settings + pub include_low_severity: bool, + pub include_info_level: bool, + + // Analysis scope + pub check_secrets: bool, + pub check_code_patterns: bool, + pub check_infrastructure: bool, + pub check_compliance: bool, + + // Language-specific settings + pub javascript_enabled: bool, + pub python_enabled: bool, + pub rust_enabled: bool, + + // Framework-specific settings + pub frameworks_to_check: Vec, + + // File filtering + pub ignore_patterns: Vec, + pub include_patterns: Vec, + + // Git integration + pub skip_gitignored_files: bool, + pub downgrade_gitignored_severity: bool, + pub check_git_history: bool, + + // Environment variable handling + pub check_env_files: bool, + pub warn_on_public_env_vars: bool, + pub sensitive_env_keywords: Vec, + + // JavaScript/TypeScript specific + pub check_package_json: bool, + pub check_node_modules: bool, + pub framework_env_prefixes: Vec, + + // Output customization + pub max_findings_per_file: Option, + pub deduplicate_findings: bool, + pub group_by_severity: bool, + + // Performance settings + pub max_file_size_mb: Option, + pub parallel_analysis: bool, + pub analysis_timeout_seconds: Option, +} + +impl Default for SecurityAnalysisConfig { + fn default() -> Self { + Self { + // General settings + include_low_severity: false, + include_info_level: false, + + // Analysis scope + check_secrets: true, + check_code_patterns: true, + check_infrastructure: true, + check_compliance: false, // Disabled by default as it requires more setup + + // Language-specific settings + javascript_enabled: true, + python_enabled: true, + rust_enabled: true, + + // Framework-specific settings + frameworks_to_check: vec![ + "React".to_string(), + "Vue".to_string(), + "Angular".to_string(), + "Next.js".to_string(), + "Vite".to_string(), + "Express".to_string(), + "Django".to_string(), + "Spring Boot".to_string(), + ], + + // File filtering + ignore_patterns: vec![ + "node_modules".to_string(), + ".git".to_string(), + "target".to_string(), + "build".to_string(), + ".next".to_string(), + "coverage".to_string(), + "dist".to_string(), + "*.min.js".to_string(), + "*.bundle.js".to_string(), + "*.map".to_string(), + "*.lock".to_string(), + "*_sample.*".to_string(), + "*example*".to_string(), + "*test*".to_string(), + "*spec*".to_string(), + "*mock*".to_string(), + "*.d.ts".to_string(), // TypeScript declaration files + ], + include_patterns: vec![], // Empty means include all (subject to ignore patterns) + + // Git integration + skip_gitignored_files: true, + downgrade_gitignored_severity: false, + check_git_history: false, // Disabled by default for performance + + // Environment variable handling + check_env_files: true, + warn_on_public_env_vars: true, + sensitive_env_keywords: vec![ + "SECRET".to_string(), + "KEY".to_string(), + "TOKEN".to_string(), + "PASSWORD".to_string(), + "PASS".to_string(), + "AUTH".to_string(), + "API".to_string(), + "PRIVATE".to_string(), + "CREDENTIAL".to_string(), + "CERT".to_string(), + "SSL".to_string(), + "TLS".to_string(), + "OAUTH".to_string(), + "CLIENT_SECRET".to_string(), + "ACCESS_TOKEN".to_string(), + "REFRESH_TOKEN".to_string(), + "DATABASE_URL".to_string(), + "DB_PASS".to_string(), + "STRIPE_SECRET".to_string(), + "AWS_SECRET".to_string(), + "FIREBASE_PRIVATE".to_string(), + ], + + // JavaScript/TypeScript specific + check_package_json: true, + check_node_modules: false, // Usually don't want to scan dependencies + framework_env_prefixes: vec![ + "REACT_APP_".to_string(), + "NEXT_PUBLIC_".to_string(), + "VITE_".to_string(), + "VUE_APP_".to_string(), + "EXPO_PUBLIC_".to_string(), + "NUXT_PUBLIC_".to_string(), + "GATSBY_".to_string(), + "STORYBOOK_".to_string(), + ], + + // Output customization + max_findings_per_file: Some(50), // Prevent overwhelming output + deduplicate_findings: true, + group_by_severity: true, + + // Performance settings + max_file_size_mb: Some(10), // Skip very large files + parallel_analysis: true, + analysis_timeout_seconds: Some(300), // 5 minutes max + } + } +} + +impl SecurityAnalysisConfig { + /// Create a configuration optimized for JavaScript/TypeScript projects + pub fn for_javascript() -> Self { + let mut config = Self::default(); + config.javascript_enabled = true; + config.python_enabled = false; + config.rust_enabled = false; + config.check_package_json = true; + config.frameworks_to_check = vec![ + "React".to_string(), + "Vue".to_string(), + "Angular".to_string(), + "Next.js".to_string(), + "Vite".to_string(), + "Express".to_string(), + "Svelte".to_string(), + "Nuxt".to_string(), + ]; + config + } + + /// Create a configuration optimized for Python projects + pub fn for_python() -> Self { + let mut config = Self::default(); + config.javascript_enabled = false; + config.python_enabled = true; + config.rust_enabled = false; + config.check_package_json = false; + config.frameworks_to_check = vec![ + "Django".to_string(), + "Flask".to_string(), + "FastAPI".to_string(), + "Tornado".to_string(), + ]; + config + } + + /// Create a high-security configuration with strict settings + pub fn high_security() -> Self { + let mut config = Self::default(); + config.include_low_severity = true; + config.include_info_level = true; + config.skip_gitignored_files = false; // Check everything + config.check_git_history = true; + config.warn_on_public_env_vars = true; + config.max_findings_per_file = None; // No limit + config + } + + /// Create a fast configuration for CI/CD pipelines + pub fn fast_ci() -> Self { + let mut config = Self::default(); + config.include_low_severity = false; + config.include_info_level = false; + config.check_compliance = false; + config.check_git_history = false; + config.parallel_analysis = true; + config.max_findings_per_file = Some(20); // Limit output + config.analysis_timeout_seconds = Some(120); // 2 minutes max + config + } + + /// Check if a file should be analyzed based on patterns + pub fn should_analyze_file(&self, file_path: &std::path::Path) -> bool { + let file_path_str = file_path.to_string_lossy(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Check ignore patterns first + for pattern in &self.ignore_patterns { + if self.matches_pattern(pattern, &file_path_str, file_name) { + return false; + } + } + + // If include patterns are specified, file must match at least one + if !self.include_patterns.is_empty() { + return self.include_patterns.iter().any(|pattern| { + self.matches_pattern(pattern, &file_path_str, file_name) + }); + } + + true + } + + /// Check if a pattern matches a file + fn matches_pattern(&self, pattern: &str, file_path: &str, file_name: &str) -> bool { + if pattern.contains('*') { + // Use glob matching for wildcard patterns + glob::Pattern::new(pattern) + .map(|p| p.matches(file_path) || p.matches(file_name)) + .unwrap_or(false) + } else { + // Simple string matching + file_path.contains(pattern) || file_name.contains(pattern) + } + } + + /// Check if an environment variable name appears sensitive + pub fn is_sensitive_env_var(&self, var_name: &str) -> bool { + let var_upper = var_name.to_uppercase(); + self.sensitive_env_keywords.iter() + .any(|keyword| var_upper.contains(keyword)) + } + + /// Check if an environment variable should be public (safe for client-side) + pub fn is_public_env_var(&self, var_name: &str) -> bool { + self.framework_env_prefixes.iter() + .any(|prefix| var_name.starts_with(prefix)) + } + + /// Get the maximum file size to analyze in bytes + pub fn max_file_size_bytes(&self) -> Option { + self.max_file_size_mb.map(|mb| mb * 1024 * 1024) + } +} + +/// Preset configurations for common use cases +#[derive(Debug, Clone, Copy)] +pub enum SecurityConfigPreset { + /// Default balanced configuration + Default, + /// Optimized for JavaScript/TypeScript projects + JavaScript, + /// Optimized for Python projects + Python, + /// High-security configuration with strict settings + HighSecurity, + /// Fast configuration for CI/CD pipelines + FastCI, +} + +impl SecurityConfigPreset { + pub fn to_config(self) -> SecurityAnalysisConfig { + match self { + Self::Default => SecurityAnalysisConfig::default(), + Self::JavaScript => SecurityAnalysisConfig::for_javascript(), + Self::Python => SecurityAnalysisConfig::for_python(), + Self::HighSecurity => SecurityAnalysisConfig::high_security(), + Self::FastCI => SecurityAnalysisConfig::fast_ci(), + } + } +} + +impl From for SecurityAnalysisConfig { + fn from(preset: SecurityConfigPreset) -> Self { + preset.to_config() + } +} \ No newline at end of file diff --git a/src/analyzer/security/core.rs b/src/analyzer/security/core.rs new file mode 100644 index 00000000..edba639f --- /dev/null +++ b/src/analyzer/security/core.rs @@ -0,0 +1,94 @@ +//! # Core Security Analysis Types +//! +//! Base types and functionality shared across all security analyzers. + +use std::collections::HashMap; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +/// Security finding severity levels +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecuritySeverity { + Critical, + High, + Medium, + Low, + Info, +} + +/// Categories of security findings +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum SecurityCategory { + /// Exposed secrets, API keys, passwords + SecretsExposure, + /// Insecure configuration settings + InsecureConfiguration, + /// Language/framework-specific security patterns + CodeSecurityPattern, + /// Infrastructure and deployment security + InfrastructureSecurity, + /// Authentication and authorization issues + AuthenticationSecurity, + /// Data protection and privacy concerns + DataProtection, + /// Network and communication security + NetworkSecurity, + /// Compliance and regulatory requirements + Compliance, +} + +/// A security finding with details and remediation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityFinding { + pub id: String, + pub title: String, + pub description: String, + pub severity: SecuritySeverity, + pub category: SecurityCategory, + pub file_path: Option, + pub line_number: Option, + pub column_number: Option, + pub evidence: Option, + pub remediation: Vec, + pub references: Vec, + pub cwe_id: Option, + pub compliance_frameworks: Vec, +} + +/// Comprehensive security analysis report +#[derive(Debug, Serialize, Deserialize)] +pub struct SecurityReport { + pub analyzed_at: chrono::DateTime, + pub overall_score: f32, // 0-100, higher is better + pub risk_level: SecuritySeverity, + pub total_findings: usize, + pub findings_by_severity: HashMap, + pub findings_by_category: HashMap, + pub findings: Vec, + pub recommendations: Vec, + pub compliance_status: HashMap, +} + +/// Compliance framework status +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplianceStatus { + pub framework: String, + pub coverage: f32, // 0-100% + pub missing_controls: Vec, + pub recommendations: Vec, +} + +/// Base security analyzer trait +pub trait SecurityAnalyzer { + type Config; + type Error: std::error::Error; + + /// Analyze a project for security issues + fn analyze_project(&self, project_root: &std::path::Path) -> Result; + + /// Get the analyzer's configuration + fn config(&self) -> &Self::Config; + + /// Get supported file extensions for this analyzer + fn supported_extensions(&self) -> Vec<&'static str>; +} \ No newline at end of file diff --git a/src/analyzer/security/gitignore.rs b/src/analyzer/security/gitignore.rs new file mode 100644 index 00000000..da70a500 --- /dev/null +++ b/src/analyzer/security/gitignore.rs @@ -0,0 +1,531 @@ +//! # GitIgnore-Aware Security Analysis +//! +//! Comprehensive gitignore parsing and pattern matching for security analysis. +//! This module ensures that secret detection is gitignore-aware and can properly +//! assess whether sensitive files are appropriately protected. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::fs; +use log::{info, warn}; +use regex::Regex; + +/// GitIgnore pattern matcher for security analysis +pub struct GitIgnoreAnalyzer { + patterns: Vec, + project_root: PathBuf, + is_git_repo: bool, +} + +/// A parsed gitignore pattern with matching logic +#[derive(Debug, Clone)] +pub struct GitIgnorePattern { + pub original: String, + pub regex: Regex, + pub is_negation: bool, + pub is_directory_only: bool, + pub is_absolute: bool, // Starts with / + pub pattern_type: PatternType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PatternType { + /// Exact filename match (e.g., ".env") + Exact, + /// Wildcard pattern (e.g., "*.log") + Wildcard, + /// Directory pattern (e.g., "node_modules/") + Directory, + /// Path pattern (e.g., "config/*.env") + Path, +} + +/// Result of gitignore analysis for a file +#[derive(Debug, Clone)] +pub struct GitIgnoreStatus { + pub is_ignored: bool, + pub matched_pattern: Option, + pub is_tracked: bool, // Whether file is tracked by git + pub should_be_ignored: bool, // Whether file contains secrets and should be ignored + pub risk_level: GitIgnoreRisk, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GitIgnoreRisk { + /// File is properly ignored and contains no secrets + Safe, + /// File contains secrets but is properly ignored + Protected, + /// File contains secrets and is NOT ignored (high risk) + Exposed, + /// File contains secrets, not ignored, and is tracked by git (critical risk) + Tracked, +} + +impl GitIgnoreAnalyzer { + pub fn new(project_root: &Path) -> Result { + let project_root = project_root.canonicalize()?; + let is_git_repo = project_root.join(".git").exists(); + + let patterns = if is_git_repo { + Self::parse_gitignore_files(&project_root)? + } else { + Self::create_default_patterns() + }; + + info!("Initialized GitIgnore analyzer with {} patterns for {}", + patterns.len(), project_root.display()); + + Ok(Self { + patterns, + project_root, + is_git_repo, + }) + } + + /// Parse all relevant .gitignore files + fn parse_gitignore_files(project_root: &Path) -> Result, std::io::Error> { + let mut patterns = Vec::new(); + + // Global gitignore patterns for common secret files + patterns.extend(Self::create_default_patterns()); + + // Parse project .gitignore + let gitignore_path = project_root.join(".gitignore"); + if gitignore_path.exists() { + let content = fs::read_to_string(&gitignore_path)?; + patterns.extend(Self::parse_gitignore_content(&content, project_root)?); + info!("Parsed {} patterns from .gitignore", patterns.len()); + } + + // TODO: Parse global gitignore (~/.gitignore_global) + // TODO: Parse .git/info/exclude + + Ok(patterns) + } + + /// Create default patterns for common secret files + fn create_default_patterns() -> Vec { + let default_patterns = [ + ".env", + ".env.local", + ".env.*.local", + ".env.production", + ".env.development", + ".env.staging", + ".env.test", + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + ".aws/credentials", + ".ssh/", + "secrets/", + "private/", + ]; + + default_patterns.iter() + .filter_map(|pattern| Self::parse_pattern(pattern, &PathBuf::from(".")).ok()) + .collect() + } + + /// Parse gitignore content into patterns + fn parse_gitignore_content(content: &str, _root: &Path) -> Result, std::io::Error> { + let mut patterns = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + continue; + } + + match Self::parse_pattern(line, &PathBuf::from(".")) { + Ok(pattern) => patterns.push(pattern), + Err(e) => { + warn!("Failed to parse gitignore pattern on line {}: '{}' - {}", line_num + 1, line, e); + } + } + } + + Ok(patterns) + } + + /// Parse a single gitignore pattern + fn parse_pattern(pattern: &str, _root: &Path) -> Result { + let original = pattern.to_string(); + let mut pattern = pattern.to_string(); + + // Handle negation + let is_negation = pattern.starts_with('!'); + if is_negation { + pattern = pattern[1..].to_string(); + } + + // Handle directory-only patterns + let is_directory_only = pattern.ends_with('/'); + if is_directory_only { + pattern.pop(); + } + + // Handle absolute patterns (starting with /) + let is_absolute = pattern.starts_with('/'); + if is_absolute { + pattern = pattern[1..].to_string(); + } + + // Determine pattern type + let pattern_type = if pattern.contains('/') { + PatternType::Path + } else if pattern.contains('*') || pattern.contains('?') { + PatternType::Wildcard + } else if is_directory_only { + PatternType::Directory + } else { + PatternType::Exact + }; + + // Convert to regex + let regex_pattern = Self::gitignore_to_regex(&pattern, is_absolute, &pattern_type)?; + let regex = Regex::new(®ex_pattern)?; + + Ok(GitIgnorePattern { + original, + regex, + is_negation, + is_directory_only, + is_absolute, + pattern_type, + }) + } + + /// Convert gitignore pattern to regex + fn gitignore_to_regex(pattern: &str, is_absolute: bool, pattern_type: &PatternType) -> Result { + let mut regex = String::new(); + + // Start anchor + if is_absolute { + regex.push_str("^"); + } else { + // Can match anywhere in the path + regex.push_str("(?:^|/)"); + } + + // Process the pattern + for ch in pattern.chars() { + match ch { + '*' => { + // Check if this is a double star (**) + if pattern.contains("**") { + regex.push_str(".*"); + } else { + regex.push_str("[^/]*"); + } + } + '?' => regex.push_str("[^/]"), + '.' => regex.push_str("\\."), + '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '+' | '|' | '\\' => { + regex.push('\\'); + regex.push(ch); + } + '/' => regex.push_str("/"), + _ => regex.push(ch), + } + } + + // Handle directory-only patterns + match pattern_type { + PatternType::Directory => { + regex.push_str("(?:/|$)"); + } + PatternType::Exact => { + regex.push_str("(?:/|$)"); + } + _ => { + regex.push_str("(?:/.*)?$"); + } + } + + Ok(regex) + } + + /// Check if a file path matches gitignore patterns + pub fn analyze_file(&self, file_path: &Path) -> GitIgnoreStatus { + let relative_path = match file_path.strip_prefix(&self.project_root) { + Ok(rel) => rel, + Err(_) => return GitIgnoreStatus { + is_ignored: false, + matched_pattern: None, + is_tracked: false, + should_be_ignored: false, + risk_level: GitIgnoreRisk::Safe, + }, + }; + + let path_str = relative_path.to_string_lossy(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Check against patterns + let mut is_ignored = false; + let mut matched_pattern = None; + + for pattern in &self.patterns { + if pattern.regex.is_match(&path_str) { + if pattern.is_negation { + is_ignored = false; + matched_pattern = None; + } else { + is_ignored = true; + matched_pattern = Some(pattern.original.clone()); + } + } + } + + // Check if file is tracked by git + let is_tracked = if self.is_git_repo { + self.check_git_tracked(file_path) + } else { + false + }; + + // Determine if file should be ignored (contains secrets) + let should_be_ignored = self.should_file_be_ignored(file_path, file_name); + + // Assess risk level + let risk_level = self.assess_risk(is_ignored, is_tracked, should_be_ignored); + + GitIgnoreStatus { + is_ignored, + matched_pattern, + is_tracked, + should_be_ignored, + risk_level, + } + } + + /// Check if file is tracked by git + fn check_git_tracked(&self, file_path: &Path) -> bool { + use std::process::Command; + + Command::new("git") + .args(&["ls-files", "--error-unmatch"]) + .arg(file_path) + .current_dir(&self.project_root) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + /// Check if a file should be ignored based on its name/path + fn should_file_be_ignored(&self, file_path: &Path, file_name: &str) -> bool { + // Common secret file patterns + let secret_indicators = [ + ".env", ".key", ".pem", ".p12", ".pfx", + "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", + "credentials", "secrets", "private" + ]; + + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name_lower = file_name.to_lowercase(); + + secret_indicators.iter().any(|indicator| { + file_name_lower.contains(indicator) || path_str.contains(indicator) + }) + } + + /// Assess the risk level for a file + fn assess_risk(&self, is_ignored: bool, is_tracked: bool, should_be_ignored: bool) -> GitIgnoreRisk { + match (should_be_ignored, is_ignored, is_tracked) { + // File contains secrets + (true, true, _) => GitIgnoreRisk::Protected, // Ignored (good) + (true, false, true) => GitIgnoreRisk::Tracked, // Not ignored AND tracked (critical) + (true, false, false) => GitIgnoreRisk::Exposed, // Not ignored but not tracked (high risk) + // File doesn't contain secrets (or we think it doesn't) + (false, _, _) => GitIgnoreRisk::Safe, + } + } + + /// Get all files that should be analyzed for secrets + pub fn get_files_to_analyze(&self, extensions: &[&str]) -> Result, std::io::Error> { + let mut files = Vec::new(); + self.collect_files_recursive(&self.project_root, extensions, &mut files)?; + + // Filter files that are definitely ignored + let files_to_analyze: Vec = files.into_iter() + .filter(|file| { + let status = self.analyze_file(file); + // Analyze files that are either: + // 1. Not ignored (need to check if they should be) + // 2. Ignored but we want to verify they don't contain secrets anyway + !status.is_ignored || status.should_be_ignored + }) + .collect(); + + info!("Found {} files to analyze for secrets", files_to_analyze.len()); + Ok(files_to_analyze) + } + + /// Recursively collect files with given extensions + fn collect_files_recursive( + &self, + dir: &Path, + extensions: &[&str], + files: &mut Vec + ) -> Result<(), std::io::Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip obviously ignored directories + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if matches!(dir_name, ".git" | "node_modules" | "target" | "build" | "dist" | ".next") { + continue; + } + } + + // Check if directory is ignored + let status = self.analyze_file(&path); + if !status.is_ignored { + self.collect_files_recursive(&path, extensions, files)?; + } + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if extensions.is_empty() || extensions.contains(&ext) { + files.push(path); + } + } else { + // Files without extensions might still be secret files + files.push(path); + } + } + + Ok(()) + } + + /// Generate recommendations for improving gitignore coverage + pub fn generate_gitignore_recommendations(&self, secret_files: &[PathBuf]) -> Vec { + let mut recommendations = Vec::new(); + let mut patterns_to_add = HashSet::new(); + + for file in secret_files { + let status = self.analyze_file(file); + + if status.risk_level == GitIgnoreRisk::Exposed || status.risk_level == GitIgnoreRisk::Tracked { + if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) { + // Suggest specific patterns + if file_name.starts_with(".env") { + patterns_to_add.insert(".env*".to_string()); + } else if file_name.ends_with(".key") || file_name.ends_with(".pem") { + patterns_to_add.insert("*.key".to_string()); + patterns_to_add.insert("*.pem".to_string()); + } else { + patterns_to_add.insert(file_name.to_string()); + } + } + + if status.risk_level == GitIgnoreRisk::Tracked { + recommendations.push(format!( + "CRITICAL: '{}' contains secrets and is tracked by git! Remove from git history.", + file.display() + )); + } + } + } + + if !patterns_to_add.is_empty() { + recommendations.push("Add these patterns to your .gitignore:".to_string()); + for pattern in patterns_to_add { + recommendations.push(format!(" {}", pattern)); + } + } + + recommendations + } +} + +impl GitIgnoreStatus { + /// Get a human-readable description of the status + pub fn description(&self) -> String { + match self.risk_level { + GitIgnoreRisk::Safe => "File appears safe".to_string(), + GitIgnoreRisk::Protected => format!( + "File contains secrets but is protected (ignored by: {})", + self.matched_pattern.as_deref().unwrap_or("default pattern") + ), + GitIgnoreRisk::Exposed => "File contains secrets but is NOT in .gitignore!".to_string(), + GitIgnoreRisk::Tracked => "CRITICAL: File contains secrets and is tracked by git!".to_string(), + } + } + + /// Get recommended action for this file + pub fn recommended_action(&self) -> String { + match self.risk_level { + GitIgnoreRisk::Safe => "No action needed".to_string(), + GitIgnoreRisk::Protected => "Verify secrets are still necessary".to_string(), + GitIgnoreRisk::Exposed => "Add to .gitignore immediately".to_string(), + GitIgnoreRisk::Tracked => "Remove from git history and add to .gitignore".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_gitignore_pattern_parsing() { + let patterns = vec![ + ".env", + "*.log", + "/config.json", + "secrets/", + "!important.env", + ]; + + for pattern_str in patterns { + let pattern = GitIgnoreAnalyzer::parse_pattern(pattern_str, &PathBuf::from(".")); + assert!(pattern.is_ok(), "Failed to parse pattern: {}", pattern_str); + } + } + + #[test] + fn test_pattern_matching() { + let temp_dir = TempDir::new().unwrap(); + let analyzer = GitIgnoreAnalyzer::new(temp_dir.path()).unwrap(); + + // Test exact pattern matching + let env_pattern = GitIgnoreAnalyzer::parse_pattern(".env", &PathBuf::from(".")).unwrap(); + assert!(env_pattern.regex.is_match(".env")); + assert!(env_pattern.regex.is_match("subdir/.env")); + assert!(!env_pattern.regex.is_match("not-env")); + } + + #[test] + fn test_nested_directory_matching() { + let temp_dir = TempDir::new().unwrap(); + let analyzer = GitIgnoreAnalyzer::new(temp_dir.path()).unwrap(); + + // Create a pattern for .env files + let env_pattern = GitIgnoreAnalyzer::parse_pattern(".env*", &PathBuf::from(".")).unwrap(); + + // Test various nested scenarios + let test_paths = [ + ".env", + "secrets/.env", + "config/production/.env.local", + "deeply/nested/folder/.env.production", + ]; + + for path in &test_paths { + assert!(env_pattern.regex.is_match(path), "Pattern should match: {}", path); + } + } +} \ No newline at end of file diff --git a/src/analyzer/security/javascript.rs b/src/analyzer/security/javascript.rs new file mode 100644 index 00000000..2febc26c --- /dev/null +++ b/src/analyzer/security/javascript.rs @@ -0,0 +1,1013 @@ +//! # JavaScript/TypeScript Security Analyzer +//! +//! Specialized security analyzer for JavaScript and TypeScript applications. +//! +//! This analyzer focuses on: +//! - Framework-specific secret patterns (React, Vue, Angular, etc.) +//! - Environment variable misuse +//! - Hardcoded API keys in configuration objects +//! - Client-side secret exposure patterns +//! - Common JS/TS anti-patterns + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::fs; +use regex::Regex; +use log::{debug, info}; + +use super::{SecurityError, SecurityFinding, SecuritySeverity, SecurityCategory, SecurityReport, SecurityAnalysisConfig, GitIgnoreAnalyzer, GitIgnoreRisk}; + +/// JavaScript/TypeScript specific security analyzer +pub struct JavaScriptSecurityAnalyzer { + config: SecurityAnalysisConfig, + js_patterns: Vec, + framework_patterns: HashMap>, + env_var_patterns: Vec, + gitignore_analyzer: Option, +} + +/// JavaScript-specific secret pattern +#[derive(Debug, Clone)] +pub struct JavaScriptSecretPattern { + pub id: String, + pub name: String, + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub context_indicators: Vec, // Code context that increases confidence + pub false_positive_indicators: Vec, // Context that suggests false positive +} + +/// Framework-specific patterns +#[derive(Debug, Clone)] +pub struct FrameworkPattern { + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub file_extensions: Vec, +} + +/// Environment variable patterns +#[derive(Debug, Clone)] +pub struct EnvVarPattern { + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub public_prefixes: Vec, // Prefixes that indicate public env vars +} + +impl JavaScriptSecurityAnalyzer { + pub fn new() -> Result { + Self::with_config(SecurityAnalysisConfig::default()) + } + + pub fn with_config(config: SecurityAnalysisConfig) -> Result { + let js_patterns = Self::initialize_js_patterns()?; + let framework_patterns = Self::initialize_framework_patterns()?; + let env_var_patterns = Self::initialize_env_var_patterns()?; + + Ok(Self { + config, + js_patterns, + framework_patterns, + env_var_patterns, + gitignore_analyzer: None, // Will be initialized in analyze_project + }) + } + + /// Analyze a JavaScript/TypeScript project + pub fn analyze_project(&mut self, project_root: &Path) -> Result { + let mut findings = Vec::new(); + + // Initialize gitignore analyzer for comprehensive file protection assessment + let mut gitignore_analyzer = GitIgnoreAnalyzer::new(project_root) + .map_err(|e| SecurityError::AnalysisFailed(format!("Failed to initialize gitignore analyzer: {}", e)))?; + + info!("πŸ” Using gitignore-aware security analysis for {}", project_root.display()); + + // Get JS/TS files using gitignore-aware collection + let js_extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"]; + let js_files = gitignore_analyzer.get_files_to_analyze(&js_extensions) + .map_err(|e| SecurityError::Io(e))? + .into_iter() + .filter(|file| { + if let Some(ext) = file.extension().and_then(|e| e.to_str()) { + js_extensions.contains(&ext) + } else { + false + } + }) + .collect::>(); + + info!("Found {} JavaScript/TypeScript files to analyze (gitignore-filtered)", js_files.len()); + + // Analyze each file with gitignore context + for file_path in &js_files { + let gitignore_status = gitignore_analyzer.analyze_file(file_path); + let mut file_findings = self.analyze_js_file(file_path)?; + + // Enhance findings with gitignore risk assessment + for finding in &mut file_findings { + self.enhance_finding_with_gitignore_status(finding, &gitignore_status); + } + + findings.extend(file_findings); + } + + // Analyze package.json and other config files with gitignore awareness + findings.extend(self.analyze_config_files_with_gitignore(project_root, &mut gitignore_analyzer)?); + + // Comprehensive environment file analysis with gitignore risk assessment + findings.extend(self.analyze_env_files_with_gitignore(project_root, &mut gitignore_analyzer)?); + + // Generate gitignore recommendations for any secret files found + let secret_files: Vec = findings.iter() + .filter_map(|f| f.file_path.as_ref()) + .cloned() + .collect(); + + let gitignore_recommendations = gitignore_analyzer.generate_gitignore_recommendations(&secret_files); + + // Create report with enhanced recommendations + let mut report = SecurityReport::from_findings(findings); + report.recommendations.extend(gitignore_recommendations); + + Ok(report) + } + + /// Initialize JavaScript-specific secret patterns + fn initialize_js_patterns() -> Result, SecurityError> { + let patterns = vec![ + // Firebase config object + JavaScriptSecretPattern { + id: "js-firebase-config".to_string(), + name: "Firebase Configuration Object".to_string(), + pattern: Regex::new(r#"(?i)(?:const\s+|let\s+|var\s+)?firebaseConfig\s*[=:]\s*\{[^}]*apiKey\s*:\s*["']([^"']+)["'][^}]*\}"#)?, + severity: SecuritySeverity::Medium, + description: "Firebase configuration object with API key detected".to_string(), + context_indicators: vec!["initializeApp".to_string(), "firebase".to_string()], + false_positive_indicators: vec!["example".to_string(), "placeholder".to_string(), "your-api-key".to_string()], + }, + + // Stripe publishable key (less sensitive but should be noted) + JavaScriptSecretPattern { + id: "js-stripe-public-key".to_string(), + name: "Stripe Publishable Key".to_string(), + pattern: Regex::new(r#"(?i)pk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Low, + description: "Stripe publishable key detected (public but should be environment variable)".to_string(), + context_indicators: vec!["stripe".to_string(), "payment".to_string()], + false_positive_indicators: vec![], + }, + + // Supabase anon key + JavaScriptSecretPattern { + id: "js-supabase-anon-key".to_string(), + name: "Supabase Anonymous Key".to_string(), + pattern: Regex::new(r#"(?i)(?:supabase|anon).*?["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Medium, + description: "Supabase anonymous key detected".to_string(), + context_indicators: vec!["supabase".to_string(), "createClient".to_string()], + false_positive_indicators: vec!["example".to_string(), "placeholder".to_string()], + }, + + // Auth0 configuration + JavaScriptSecretPattern { + id: "js-auth0-config".to_string(), + name: "Auth0 Configuration".to_string(), + pattern: Regex::new(r#"(?i)(?:domain|clientId)\s*:\s*["']([a-zA-Z0-9.-]+\.auth0\.com|[a-zA-Z0-9]{32})["']"#)?, + severity: SecuritySeverity::Medium, + description: "Auth0 configuration detected".to_string(), + context_indicators: vec!["auth0".to_string(), "webAuth".to_string()], + false_positive_indicators: vec!["example".to_string(), "your-domain".to_string()], + }, + + // Process.env hardcoded values + JavaScriptSecretPattern { + id: "js-hardcoded-env".to_string(), + name: "Hardcoded process.env Assignment".to_string(), + pattern: Regex::new(r#"process\.env\.[A-Z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::High, + description: "Hardcoded assignment to process.env detected".to_string(), + context_indicators: vec![], + false_positive_indicators: vec!["development".to_string(), "test".to_string()], + }, + + // Clerk keys + JavaScriptSecretPattern { + id: "js-clerk-key".to_string(), + name: "Clerk API Key".to_string(), + pattern: Regex::new(r#"(?i)(?:clerk|pk_test_|pk_live_)[a-zA-Z0-9_-]{20,}"#)?, + severity: SecuritySeverity::Medium, + description: "Clerk API key detected".to_string(), + context_indicators: vec!["clerk".to_string(), "ClerkProvider".to_string()], + false_positive_indicators: vec![], + }, + + // Generic API key in object assignment + JavaScriptSecretPattern { + id: "js-api-key-object".to_string(), + name: "API Key in Object Assignment".to_string(), + pattern: Regex::new(r#"(?i)(?:apiKey|api_key|clientSecret|client_secret|accessToken|access_token|secretKey|secret_key)\s*:\s*["']([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::High, + description: "API key or secret assigned in object literal".to_string(), + context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()], + false_positive_indicators: vec!["process.env".to_string(), "import.meta.env".to_string(), "placeholder".to_string()], + }, + + // Bearer tokens in fetch headers + JavaScriptSecretPattern { + id: "js-bearer-token".to_string(), + name: "Bearer Token in Code".to_string(), + pattern: Regex::new(r#"(?i)(?:authorization|bearer)\s*:\s*["'](?:bearer\s+)?([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::Critical, + description: "Bearer token hardcoded in authorization header".to_string(), + context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()], + false_positive_indicators: vec!["${".to_string(), "process.env".to_string(), "import.meta.env".to_string()], + }, + + // Database connection strings + JavaScriptSecretPattern { + id: "js-database-url".to_string(), + name: "Database Connection URL".to_string(), + pattern: Regex::new(r#"(?i)(?:mongodb|postgres|mysql)://[^"'\s]+:[^"'\s]+@[^"'\s]+"#)?, + severity: SecuritySeverity::Critical, + description: "Database connection string with credentials detected".to_string(), + context_indicators: vec!["connect".to_string(), "mongoose".to_string(), "client".to_string()], + false_positive_indicators: vec!["localhost".to_string(), "example.com".to_string()], + }, + ]; + + Ok(patterns) + } + + /// Initialize framework-specific patterns + fn initialize_framework_patterns() -> Result>, SecurityError> { + let mut frameworks = HashMap::new(); + + // React patterns + frameworks.insert("react".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)react_app_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Medium, + description: "React environment variable potentially exposed in build".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()], + }, + ]); + + // Next.js patterns + frameworks.insert("nextjs".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)next_public_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Low, + description: "Next.js public environment variable (ensure it should be public)".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()], + }, + ]); + + // Vite patterns + frameworks.insert("vite".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)vite_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Medium, + description: "Vite environment variable potentially exposed in build".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string(), "vue".to_string()], + }, + ]); + + Ok(frameworks) + } + + /// Initialize environment variable patterns + fn initialize_env_var_patterns() -> Result, SecurityError> { + let patterns = vec![ + EnvVarPattern { + pattern: Regex::new(r#"process\.env\.([A-Z_]+)"#)?, + severity: SecuritySeverity::Info, + description: "Environment variable usage detected".to_string(), + public_prefixes: vec![ + "REACT_APP_".to_string(), + "NEXT_PUBLIC_".to_string(), + "VITE_".to_string(), + "VUE_APP_".to_string(), + "EXPO_PUBLIC_".to_string(), + "NUXT_PUBLIC_".to_string(), + ], + }, + EnvVarPattern { + pattern: Regex::new(r#"import\.meta\.env\.([A-Z_]+)"#)?, + severity: SecuritySeverity::Info, + description: "Vite environment variable usage detected".to_string(), + public_prefixes: vec!["VITE_".to_string()], + }, + ]; + + Ok(patterns) + } + + /// Collect all JavaScript/TypeScript files + fn collect_js_files(&self, project_root: &Path) -> Result, SecurityError> { + let extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"]; + let mut files = Vec::new(); + + fn collect_recursive(dir: &Path, extensions: &[&str], files: &mut Vec) -> Result<(), std::io::Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip common build/dependency directories + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if matches!(dir_name, "node_modules" | ".git" | "build" | "dist" | ".next" | "coverage") { + continue; + } + } + collect_recursive(&path, extensions, files)?; + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if extensions.contains(&ext) { + files.push(path); + } + } + } + Ok(()) + } + + collect_recursive(project_root, &extensions, &mut files)?; + Ok(files) + } + + /// Analyze a single JavaScript/TypeScript file + fn analyze_js_file(&self, file_path: &Path) -> Result, SecurityError> { + let content = fs::read_to_string(file_path)?; + let mut findings = Vec::new(); + + // Check against JavaScript-specific patterns + for pattern in &self.js_patterns { + findings.extend(self.check_pattern_in_content(&content, pattern, file_path)?); + } + + // Check environment variable usage + findings.extend(self.check_env_var_usage(&content, file_path)?); + + Ok(findings) + } + + /// Check a specific pattern in file content + fn check_pattern_in_content( + &self, + content: &str, + pattern: &JavaScriptSecretPattern, + file_path: &Path, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + if let Some(captures) = pattern.pattern.captures(line) { + // Check for false positive indicators + if pattern.false_positive_indicators.iter().any(|indicator| { + line.to_lowercase().contains(&indicator.to_lowercase()) + }) { + debug!("Skipping potential false positive in {}: {}", file_path.display(), line.trim()); + continue; + } + + // Extract the secret value and position if captured + let (evidence, column_number) = if captures.len() > 1 { + if let Some(match_) = captures.get(1) { + (Some(match_.as_str().to_string()), Some(match_.start() + 1)) + } else { + (Some(line.trim().to_string()), None) + } + } else { + // For patterns without capture groups, use the full match + if let Some(match_) = captures.get(0) { + (Some(line.trim().to_string()), Some(match_.start() + 1)) + } else { + (Some(line.trim().to_string()), None) + } + }; + + // Check context for confidence scoring + let context_score = self.calculate_context_confidence(content, &pattern.context_indicators); + let adjusted_severity = self.adjust_severity_by_context(pattern.severity.clone(), context_score); + + findings.push(SecurityFinding { + id: format!("{}-{}", pattern.id, line_num), + title: format!("{} Detected", pattern.name), + description: format!("{} (Context confidence: {:.1})", pattern.description, context_score), + severity: adjusted_severity, + category: SecurityCategory::SecretsExposure, + file_path: Some(file_path.to_path_buf()), + line_number: Some(line_num + 1), + column_number, + evidence, + remediation: self.generate_js_remediation(&pattern.id), + references: vec![ + "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(), + "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()], + }); + } + } + + Ok(findings) + } + + /// Check environment variable usage patterns with context-aware detection + fn check_env_var_usage(&self, content: &str, file_path: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Determine if this is likely server-side or client-side code + let is_server_side = self.is_server_side_file(file_path, content); + + for pattern in &self.env_var_patterns { + for (line_num, line) in content.lines().enumerate() { + if let Some(captures) = pattern.pattern.captures(line) { + if let Some(var_name) = captures.get(1) { + let var_name = var_name.as_str(); + + // Check if this is a public environment variable + let is_public = pattern.public_prefixes.iter().any(|prefix| var_name.starts_with(prefix)); + + // Context-aware detection: Only flag as problematic if: + // 1. It's a sensitive variable AND + // 2. It's in client-side code AND + // 3. It doesn't have a public prefix + if !is_public && self.is_sensitive_var_name(var_name) && !is_server_side { + // Extract column position from the pattern match + let column_number = captures.get(0) + .map(|m| m.start() + 1); + + findings.push(SecurityFinding { + id: format!("js-env-sensitive-{}", line_num), + title: "Sensitive Environment Variable in Client Code".to_string(), + description: format!("Environment variable '{}' appears sensitive and may be exposed to client in browser code", var_name), + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + file_path: Some(file_path.to_path_buf()), + line_number: Some(line_num + 1), + column_number, + evidence: Some(line.trim().to_string()), + remediation: vec![ + "Move sensitive environment variables to server-side code".to_string(), + "Use public environment variable prefixes only for non-sensitive data".to_string(), + "Consider using a backend API endpoint to handle sensitive operations".to_string(), + ], + references: vec![ + "https://nextjs.org/docs/basic-features/environment-variables".to_string(), + "https://vitejs.dev/guide/env-and-mode.html".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + // For server-side code using environment variables, this is GOOD practice - don't flag it + } + } + } + } + + Ok(findings) + } + + /// Analyze configuration files (package.json, etc.) + fn analyze_config_files(&self, project_root: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check package.json for exposed scripts or configs + let package_json = project_root.join("package.json"); + if package_json.exists() { + findings.extend(self.analyze_package_json(&package_json)?); + } + + Ok(findings) + } + + /// Analyze package.json for security issues + fn analyze_package_json(&self, package_json: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + let content = fs::read_to_string(package_json)?; + + // Look for hardcoded secrets in scripts or config + if content.contains("REACT_APP_") || content.contains("NEXT_PUBLIC_") || content.contains("VITE_") { + for (line_num, line) in content.lines().enumerate() { + if line.contains("sk_") || line.contains("pk_live_") || line.contains("eyJ") { + findings.push(SecurityFinding { + id: format!("package-json-secret-{}", line_num), + title: "Potential Secret in package.json".to_string(), + description: "Potential API key or token found in package.json".to_string(), + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + file_path: Some(package_json.to_path_buf()), + line_number: Some(line_num + 1), + column_number: None, + evidence: Some(line.trim().to_string()), + remediation: vec![ + "Remove secrets from package.json".to_string(), + "Use environment variables instead".to_string(), + "Add package.json to .gitignore if it contains secrets (not recommended)".to_string(), + ], + references: vec![ + "https://docs.npmjs.com/cli/v8/configuring-npm/package-json".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + } + } + + Ok(findings) + } + + /// Analyze environment files + fn analyze_env_files(&self, project_root: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check for .env files that might be accidentally committed + let env_files = [".env", ".env.local", ".env.production", ".env.development"]; + + for env_file in &env_files { + // Skip template/example files + if self.is_template_file(env_file) { + debug!("Skipping template env file: {}", env_file); + continue; + } + + let env_path = project_root.join(env_file); + if env_path.exists() { + // Check if this file should be tracked by git + findings.push(SecurityFinding { + id: format!("env-file-{}", env_file.replace('.', "-")), + title: "Environment File Detected".to_string(), + description: format!("Environment file '{}' found - ensure it's properly protected", env_file), + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + file_path: Some(env_path), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Ensure environment files are in .gitignore".to_string(), + "Use .env.example files for documentation".to_string(), + "Never commit actual environment files to version control".to_string(), + ], + references: vec![ + "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + } + + Ok(findings) + } + + /// Calculate confidence score based on context indicators + fn calculate_context_confidence(&self, content: &str, indicators: &[String]) -> f32 { + let total_indicators = indicators.len() as f32; + if total_indicators == 0.0 { + return 0.5; // Neutral confidence + } + + let found_indicators = indicators.iter() + .filter(|indicator| content.to_lowercase().contains(&indicator.to_lowercase())) + .count() as f32; + + found_indicators / total_indicators + } + + /// Adjust severity based on context confidence + fn adjust_severity_by_context(&self, base_severity: SecuritySeverity, confidence: f32) -> SecuritySeverity { + match base_severity { + SecuritySeverity::Critical => base_severity, // Keep critical as-is + SecuritySeverity::High => { + if confidence < 0.3 { + SecuritySeverity::Medium + } else { + base_severity + } + } + SecuritySeverity::Medium => { + if confidence > 0.7 { + SecuritySeverity::High + } else if confidence < 0.3 { + SecuritySeverity::Low + } else { + base_severity + } + } + _ => base_severity, + } + } + + /// Check if a variable name appears sensitive + fn is_sensitive_var_name(&self, var_name: &str) -> bool { + let sensitive_keywords = [ + "SECRET", "KEY", "TOKEN", "PASSWORD", "PASS", "AUTH", "API", + "PRIVATE", "CREDENTIAL", "CERT", "SSL", "TLS", "OAUTH", + "CLIENT_SECRET", "ACCESS_TOKEN", "REFRESH_TOKEN", + ]; + + let var_upper = var_name.to_uppercase(); + sensitive_keywords.iter().any(|keyword| var_upper.contains(keyword)) + } + + /// Determine if a JavaScript file is likely server-side or client-side + fn is_server_side_file(&self, file_path: &Path, content: &str) -> bool { + // Check file path indicators + let path_str = file_path.to_string_lossy().to_lowercase(); + let server_path_indicators = [ + "/server/", "/backend/", "/api/", "/routes/", "/controllers/", + "/middleware/", "/models/", "/services/", "/utils/", "/lib/", + "server.js", "server.ts", "index.js", "index.ts", "app.js", "app.ts", + "/pages/api/", "/app/api/", // Next.js API routes + "server-side", "backend", "node_modules", // Clear server indicators + ]; + + let client_path_indicators = [ + "/client/", "/frontend/", "/public/", "/static/", "/assets/", + "/components/", "/views/", "/pages/", "/src/components/", + "client.js", "client.ts", "main.js", "main.ts", "app.tsx", "index.html", + ]; + + // Strong server-side path indicators + if server_path_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return true; + } + + // Strong client-side path indicators + if client_path_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return false; + } + + // Check content for server-side indicators + let server_content_indicators = [ + "require(", "module.exports", "exports.", "__dirname", "__filename", + "process.env", "process.exit", "process.argv", "fs.readFile", "fs.writeFile", + "http.createServer", "express(", "app.listen", "app.use", "app.get", "app.post", + "import express", "import fs", "import path", "import http", "import https", + "cors(", "bodyParser", "middleware", "mongoose.connect", "sequelize", + "jwt.sign", "bcrypt", "crypto.createHash", "nodemailer", "socket.io", + "console.log", // While not exclusive, very common in server code + ]; + + let client_content_indicators = [ + "document.", "window.", "navigator.", "localStorage", "sessionStorage", + "addEventListener", "querySelector", "getElementById", "fetch(", + "XMLHttpRequest", "React.", "ReactDOM", "useState", "useEffect", + "Vue.", "Angular", "svelte", "alert(", "confirm(", "prompt(", + "location.href", "history.push", "router.push", "browser", + ]; + + let server_matches = server_content_indicators.iter() + .filter(|&indicator| content.contains(indicator)) + .count(); + + let client_matches = client_content_indicators.iter() + .filter(|&indicator| content.contains(indicator)) + .count(); + + // If we have server indicators and no clear client indicators, assume server-side + if server_matches > 0 && client_matches == 0 { + return true; + } + + // If we have client indicators and no server indicators, assume client-side + if client_matches > 0 && server_matches == 0 { + return false; + } + + // If mixed or unclear, use a heuristic + if server_matches > client_matches { + return true; + } + + // Default to client-side for mixed/unclear files (safer for security) + false + } + + /// Generate JavaScript-specific remediation advice + fn generate_js_remediation(&self, pattern_id: &str) -> Vec { + match pattern_id { + id if id.contains("firebase") => vec![ + "Move Firebase configuration to environment variables".to_string(), + "Use Firebase App Check for additional security".to_string(), + "Implement proper Firebase security rules".to_string(), + ], + id if id.contains("stripe") => vec![ + "Use environment variables for Stripe keys".to_string(), + "Ensure you're using publishable keys in client-side code".to_string(), + "Keep secret keys on the server side only".to_string(), + ], + id if id.contains("bearer") => vec![ + "Never hardcode bearer tokens in client-side code".to_string(), + "Use secure token storage mechanisms".to_string(), + "Implement token refresh flows".to_string(), + ], + _ => vec![ + "Move secrets to environment variables".to_string(), + "Use server-side API routes for sensitive operations".to_string(), + "Implement proper secret management practices".to_string(), + ], + } + } + + /// Enhance a security finding with gitignore risk assessment + fn enhance_finding_with_gitignore_status( + &self, + finding: &mut SecurityFinding, + gitignore_status: &super::gitignore::GitIgnoreStatus, + ) { + // Adjust severity based on gitignore risk + finding.severity = match gitignore_status.risk_level { + GitIgnoreRisk::Tracked => SecuritySeverity::Critical, // Always critical if tracked + GitIgnoreRisk::Exposed => { + // Upgrade severity if exposed + match &finding.severity { + SecuritySeverity::Medium => SecuritySeverity::High, + SecuritySeverity::Low => SecuritySeverity::Medium, + other => other.clone(), + } + } + GitIgnoreRisk::Protected => { + // Downgrade slightly if protected + match &finding.severity { + SecuritySeverity::Critical => SecuritySeverity::High, + SecuritySeverity::High => SecuritySeverity::Medium, + other => other.clone(), + } + } + GitIgnoreRisk::Safe => finding.severity.clone(), + }; + + // Add gitignore context to description + finding.description.push_str(&format!(" (GitIgnore: {})", gitignore_status.description())); + + // Add gitignore-specific remediation + let gitignore_action = gitignore_status.recommended_action(); + if gitignore_action != "No action needed" { + finding.remediation.insert(0, format!("πŸ”’ GitIgnore: {}", gitignore_action)); + } + + // Add git history warning for tracked files + if gitignore_status.risk_level == GitIgnoreRisk::Tracked { + finding.remediation.insert(1, "⚠️ CRITICAL: Remove this file from git history using git-filter-branch or BFG Repo-Cleaner".to_string()); + finding.remediation.insert(2, "πŸ”‘ Rotate any exposed secrets immediately".to_string()); + } + } + + /// Analyze configuration files with gitignore awareness + fn analyze_config_files_with_gitignore( + &self, + project_root: &Path, + gitignore_analyzer: &mut GitIgnoreAnalyzer, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check package.json with gitignore assessment + let package_json = project_root.join("package.json"); + if package_json.exists() { + let gitignore_status = gitignore_analyzer.analyze_file(&package_json); + let mut package_findings = self.analyze_package_json(&package_json)?; + + // Enhance findings with gitignore context + for finding in &mut package_findings { + self.enhance_finding_with_gitignore_status(finding, &gitignore_status); + } + + findings.extend(package_findings); + } + + // Check other common config files + let config_files = [ + "tsconfig.json", + "vite.config.js", + "vite.config.ts", + "next.config.js", + "next.config.ts", + "nuxt.config.js", + "nuxt.config.ts", + // Note: .env.example is now excluded as it's a template file + ]; + + for config_file in &config_files { + // Skip template/example files + if self.is_template_file(config_file) { + debug!("Skipping template config file: {}", config_file); + continue; + } + + let config_path = project_root.join(config_file); + if config_path.exists() { + let gitignore_status = gitignore_analyzer.analyze_file(&config_path); + + // Only analyze if file contains potential secrets or is not properly protected + if gitignore_status.should_be_ignored || !gitignore_status.is_ignored { + if let Ok(content) = fs::read_to_string(&config_path) { + // Basic secret pattern check for config files + if self.contains_potential_secrets(&content) { + let mut finding = SecurityFinding { + id: format!("config-file-{}", config_file.replace('.', "-")), + title: "Potential Secrets in Configuration File".to_string(), + description: format!("Configuration file '{}' may contain secrets", config_file), + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + file_path: Some(config_path.clone()), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Review configuration file for hardcoded secrets".to_string(), + "Use environment variables for sensitive configuration".to_string(), + ], + references: vec![], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }; + + self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status); + findings.push(finding); + } + } + } + } + } + + Ok(findings) + } + + /// Check if a file is a template/example file that should be excluded from security alerts + fn is_template_file(&self, file_name: &str) -> bool { + let template_indicators = [ + "sample", "example", "template", "template.env", "env.template", + "sample.env", "env.sample", "example.env", "env.example", + "examples", "samples", "templates", "demo", "test", + ".env.sample", ".env.example", ".env.template", ".env.demo", ".env.test" + ]; + + let file_name_lower = file_name.to_lowercase(); + + // Check for exact matches or contains patterns + template_indicators.iter().any(|indicator| { + file_name_lower == *indicator || + file_name_lower.contains(indicator) || + file_name_lower.ends_with(indicator) + }) + } + + /// Analyze environment files with comprehensive gitignore risk assessment + fn analyze_env_files_with_gitignore( + &self, + project_root: &Path, + gitignore_analyzer: &mut GitIgnoreAnalyzer, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Get all potential environment files using gitignore analyzer + let env_files = gitignore_analyzer.get_files_to_analyze(&[]) + .map_err(|e| SecurityError::Io(e))? + .into_iter() + .filter(|file| { + if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) { + // Exclude template/example files from security alerts + if self.is_template_file(file_name) { + debug!("Skipping template file: {}", file_name); + return false; + } + + file_name.starts_with(".env") || + file_name.contains("credentials") || + file_name.contains("secrets") || + file_name.contains("config") || + file_name.ends_with(".key") || + file_name.ends_with(".pem") + } else { + false + } + }) + .collect::>(); + + for env_file in env_files { + let gitignore_status = gitignore_analyzer.analyze_file(&env_file); + let relative_path = env_file.strip_prefix(project_root) + .unwrap_or(&env_file); + + // Create finding based on gitignore risk assessment + let (severity, title, description) = match gitignore_status.risk_level { + GitIgnoreRisk::Tracked => ( + SecuritySeverity::Critical, + "Secret File Tracked by Git".to_string(), + format!("Secret file '{}' is tracked by git and may expose credentials in version history", relative_path.display()), + ), + GitIgnoreRisk::Exposed => ( + SecuritySeverity::High, + "Secret File Not in GitIgnore".to_string(), + format!("Secret file '{}' exists but is not protected by .gitignore", relative_path.display()), + ), + GitIgnoreRisk::Protected => ( + SecuritySeverity::Info, + "Secret File Properly Protected".to_string(), + format!("Secret file '{}' is properly ignored but detected for verification", relative_path.display()), + ), + GitIgnoreRisk::Safe => continue, // Skip files that appear safe + }; + + let mut finding = SecurityFinding { + id: format!("env-file-{}", relative_path.to_string_lossy().replace('/', "-").replace('.', "-")), + title, + description, + severity, + category: SecurityCategory::SecretsExposure, + file_path: Some(env_file.clone()), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Ensure sensitive files are in .gitignore".to_string(), + "Use .env.example files for documentation".to_string(), + "Never commit actual environment files to version control".to_string(), + ], + references: vec![ + "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }; + + self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status); + findings.push(finding); + } + + Ok(findings) + } + + /// Check if content contains potential secrets (basic patterns) + fn contains_potential_secrets(&self, content: &str) -> bool { + let secret_indicators = [ + "sk_", "pk_live_", "eyJ", "AKIA", "-----BEGIN", + "client_secret", "api_key", "access_token", + "private_key", "secret_key", "bearer", + ]; + + let content_lower = content.to_lowercase(); + secret_indicators.iter().any(|indicator| content_lower.contains(&indicator.to_lowercase())) + } +} + +impl SecurityReport { + /// Create a security report from a list of findings + pub fn from_findings(findings: Vec) -> Self { + let total_findings = findings.len(); + let mut findings_by_severity = HashMap::new(); + let mut findings_by_category = HashMap::new(); + + for finding in &findings { + *findings_by_severity.entry(finding.severity.clone()).or_insert(0) += 1; + *findings_by_category.entry(finding.category.clone()).or_insert(0) += 1; + } + + // Calculate overall score (simple implementation) + let score_penalty = findings.iter().map(|f| match f.severity { + SecuritySeverity::Critical => 25.0, + SecuritySeverity::High => 15.0, + SecuritySeverity::Medium => 8.0, + SecuritySeverity::Low => 3.0, + SecuritySeverity::Info => 1.0, + }).sum::(); + + let overall_score = (100.0 - score_penalty).max(0.0); + + // Determine risk level + let risk_level = if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) { + SecuritySeverity::Critical + } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) { + SecuritySeverity::High + } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) { + SecuritySeverity::Medium + } else if !findings.is_empty() { + SecuritySeverity::Low + } else { + SecuritySeverity::Info + }; + + Self { + analyzed_at: chrono::Utc::now(), + overall_score, + risk_level, + total_findings, + findings_by_severity, + findings_by_category, + findings, + recommendations: vec![ + "Review all detected secrets and move them to environment variables".to_string(), + "Implement proper secret management practices".to_string(), + "Use framework-specific environment variable patterns correctly".to_string(), + ], + compliance_status: HashMap::new(), + } + } +} \ No newline at end of file diff --git a/src/analyzer/security/mod.rs b/src/analyzer/security/mod.rs new file mode 100644 index 00000000..d56cbab6 --- /dev/null +++ b/src/analyzer/security/mod.rs @@ -0,0 +1,77 @@ +//! # Security Analysis Module +//! +//! Modular security analysis with language-specific analyzers for better threat detection. +//! +//! This module provides a layered approach to security analysis: +//! - Core security patterns (generic) +//! - Language-specific analyzers (JS/TS, Python, etc.) +//! - Framework-specific detection +//! - Context-aware severity assessment + +use std::path::Path; +use thiserror::Error; + +pub mod core; +pub mod javascript; +pub mod patterns; +pub mod config; +pub mod gitignore; + +pub use core::{SecurityAnalyzer, SecurityReport, SecurityFinding, SecuritySeverity, SecurityCategory}; +pub use javascript::JavaScriptSecurityAnalyzer; +pub use patterns::SecretPatternManager; +pub use config::SecurityAnalysisConfig; +pub use gitignore::{GitIgnoreAnalyzer, GitIgnoreStatus, GitIgnoreRisk}; + +/// Modular security analyzer that delegates to language-specific analyzers +pub struct ModularSecurityAnalyzer { + javascript_analyzer: JavaScriptSecurityAnalyzer, + // TODO: Add other language analyzers + // python_analyzer: PythonSecurityAnalyzer, + // rust_analyzer: RustSecurityAnalyzer, +} + +impl ModularSecurityAnalyzer { + pub fn new() -> Result { + Ok(Self { + javascript_analyzer: JavaScriptSecurityAnalyzer::new()?, + }) + } + + pub fn with_config(config: SecurityAnalysisConfig) -> Result { + Ok(Self { + javascript_analyzer: JavaScriptSecurityAnalyzer::with_config(config.clone())?, + }) + } + + /// Analyze a project with appropriate language-specific analyzers + pub fn analyze_project(&mut self, project_root: &Path, languages: &[crate::analyzer::DetectedLanguage]) -> Result { + let mut all_findings = Vec::new(); + + // Analyze JavaScript/TypeScript files + if languages.iter().any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")) { + let js_report = self.javascript_analyzer.analyze_project(project_root)?; + all_findings.extend(js_report.findings); + } + + // TODO: Add other language analyzers based on detected languages + + // Combine results into a comprehensive report + Ok(SecurityReport::from_findings(all_findings)) + } +} + +#[derive(Debug, Error)] +pub enum SecurityError { + #[error("Security analysis failed: {0}")] + AnalysisFailed(String), + + #[error("Pattern compilation error: {0}")] + PatternError(#[from] regex::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JavaScript security analysis error: {0}")] + JavaScriptError(String), +} \ No newline at end of file diff --git a/src/analyzer/security/patterns.rs b/src/analyzer/security/patterns.rs new file mode 100644 index 00000000..8a00258a --- /dev/null +++ b/src/analyzer/security/patterns.rs @@ -0,0 +1,377 @@ +//! # Security Pattern Management +//! +//! Centralized management of security patterns for different tools and services. + +use std::collections::HashMap; +use regex::Regex; + +use super::{SecuritySeverity, SecurityCategory}; + +/// Manager for organizing security patterns by tool/service +pub struct SecretPatternManager { + patterns_by_tool: HashMap>, + generic_patterns: Vec, +} + +/// Tool-specific pattern (e.g., Firebase, Stripe, etc.) +#[derive(Debug, Clone)] +pub struct ToolPattern { + pub tool_name: String, + pub pattern_type: String, // e.g., "api_key", "config_object", "token" + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub public_safe: bool, // Whether this type of key is safe to expose publicly + pub context_keywords: Vec, // Keywords that increase confidence + pub false_positive_keywords: Vec, // Keywords that suggest false positive +} + +/// Generic patterns that apply across tools +#[derive(Debug, Clone)] +pub struct GenericPattern { + pub id: String, + pub name: String, + pub pattern: Regex, + pub severity: SecuritySeverity, + pub category: SecurityCategory, + pub description: String, +} + +impl SecretPatternManager { + pub fn new() -> Result { + let patterns_by_tool = Self::initialize_tool_patterns()?; + let generic_patterns = Self::initialize_generic_patterns()?; + + Ok(Self { + patterns_by_tool, + generic_patterns, + }) + } + + /// Initialize patterns for specific tools/services + fn initialize_tool_patterns() -> Result>, regex::Error> { + let mut patterns = HashMap::new(); + + // Firebase patterns + patterns.insert("firebase".to_string(), vec![ + ToolPattern { + tool_name: "Firebase".to_string(), + pattern_type: "api_key".to_string(), + pattern: Regex::new(r#"(?i)(?:firebase.*)?apiKey\s*[:=]\s*["']([A-Za-z0-9_-]{39})["']"#)?, + severity: SecuritySeverity::Medium, // Firebase API keys are safe to expose + description: "Firebase API key (safe to expose publicly)".to_string(), + public_safe: true, + context_keywords: vec!["firebase".to_string(), "initializeApp".to_string(), "getApps".to_string()], + false_positive_keywords: vec!["example".to_string(), "placeholder".to_string(), "your-api-key".to_string()], + }, + ToolPattern { + tool_name: "Firebase".to_string(), + pattern_type: "service_account".to_string(), + pattern: Regex::new(r#"(?i)(?:type|client_email|private_key).*firebase.*service_account"#)?, + severity: SecuritySeverity::Critical, + description: "Firebase service account credentials (CRITICAL - never expose)".to_string(), + public_safe: false, + context_keywords: vec!["service_account".to_string(), "private_key".to_string(), "client_email".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Stripe patterns + patterns.insert("stripe".to_string(), vec![ + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "publishable_key".to_string(), + pattern: Regex::new(r#"pk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Low, // Publishable keys are meant to be public + description: "Stripe publishable key (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["stripe".to_string(), "publishable".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"sk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Critical, + description: "Stripe secret key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["stripe".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "webhook_secret".to_string(), + pattern: Regex::new(r#"whsec_[a-zA-Z0-9]{32,}"#)?, + severity: SecuritySeverity::High, + description: "Stripe webhook endpoint secret".to_string(), + public_safe: false, + context_keywords: vec!["webhook".to_string(), "endpoint".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Supabase patterns + patterns.insert("supabase".to_string(), vec![ + ToolPattern { + tool_name: "Supabase".to_string(), + pattern_type: "anon_key".to_string(), + pattern: Regex::new(r#"(?i)supabase.*anon.*["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Medium, // Anon keys are meant for client-side + description: "Supabase anonymous key (safe for client-side use with RLS)".to_string(), + public_safe: true, + context_keywords: vec!["supabase".to_string(), "anon".to_string(), "createClient".to_string()], + false_positive_keywords: vec!["example".to_string(), "placeholder".to_string()], + }, + ToolPattern { + tool_name: "Supabase".to_string(), + pattern_type: "service_role_key".to_string(), + pattern: Regex::new(r#"(?i)supabase.*service.*role.*["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Critical, + description: "Supabase service role key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["service".to_string(), "role".to_string(), "bypass".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Clerk patterns + patterns.insert("clerk".to_string(), vec![ + ToolPattern { + tool_name: "Clerk".to_string(), + pattern_type: "publishable_key".to_string(), + pattern: Regex::new(r#"pk_test_[a-zA-Z0-9_-]{60,}|pk_live_[a-zA-Z0-9_-]{60,}"#)?, + severity: SecuritySeverity::Low, + description: "Clerk publishable key (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["clerk".to_string(), "publishable".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Clerk".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"sk_test_[a-zA-Z0-9_-]{60,}|sk_live_[a-zA-Z0-9_-]{60,}"#)?, + severity: SecuritySeverity::Critical, + description: "Clerk secret key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["clerk".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Auth0 patterns + patterns.insert("auth0".to_string(), vec![ + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "domain".to_string(), + pattern: Regex::new(r#"[a-zA-Z0-9-]+\.auth0\.com"#)?, + severity: SecuritySeverity::Low, + description: "Auth0 domain (safe to expose)".to_string(), + public_safe: true, + context_keywords: vec!["auth0".to_string(), "domain".to_string()], + false_positive_keywords: vec!["example".to_string(), "your-domain".to_string()], + }, + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "client_id".to_string(), + pattern: Regex::new(r#"(?i)(?:client_?id|clientId)\s*[:=]\s*["']([a-zA-Z0-9]{32})["']"#)?, + severity: SecuritySeverity::Low, + description: "Auth0 client ID (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["auth0".to_string(), "client".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "client_secret".to_string(), + pattern: Regex::new(r#"(?i)(?:client_?secret|clientSecret)\s*[:=]\s*["']([a-zA-Z0-9_-]{64})["']"#)?, + severity: SecuritySeverity::Critical, + description: "Auth0 client secret (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["auth0".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // AWS patterns + patterns.insert("aws".to_string(), vec![ + ToolPattern { + tool_name: "AWS".to_string(), + pattern_type: "access_key".to_string(), + pattern: Regex::new(r#"AKIA[0-9A-Z]{16}"#)?, + severity: SecuritySeverity::Critical, + description: "AWS access key ID (CRITICAL)".to_string(), + public_safe: false, + context_keywords: vec!["aws".to_string(), "access".to_string(), "key".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "AWS".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"(?i)(?:aws[_-]?secret|secret[_-]?access[_-]?key)\s*[:=]\s*["']([A-Za-z0-9/+=]{40})["']"#)?, + severity: SecuritySeverity::Critical, + description: "AWS secret access key (CRITICAL)".to_string(), + public_safe: false, + context_keywords: vec!["aws".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // OpenAI patterns + patterns.insert("openai".to_string(), vec![ + ToolPattern { + tool_name: "OpenAI".to_string(), + pattern_type: "api_key".to_string(), + pattern: Regex::new(r#"sk-[A-Za-z0-9]{48}"#)?, + severity: SecuritySeverity::High, + description: "OpenAI API key".to_string(), + public_safe: false, + context_keywords: vec!["openai".to_string(), "gpt".to_string(), "api".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Vercel patterns + patterns.insert("vercel".to_string(), vec![ + ToolPattern { + tool_name: "Vercel".to_string(), + pattern_type: "token".to_string(), + pattern: Regex::new(r#"(?i)vercel.*token.*["\'][a-zA-Z0-9]{24,}["\']"#)?, + severity: SecuritySeverity::High, + description: "Vercel deployment token".to_string(), + public_safe: false, + context_keywords: vec!["vercel".to_string(), "deploy".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Netlify patterns + patterns.insert("netlify".to_string(), vec![ + ToolPattern { + tool_name: "Netlify".to_string(), + pattern_type: "access_token".to_string(), + pattern: Regex::new(r#"(?i)netlify.*token.*["\'][a-zA-Z0-9_-]{40,}["\']"#)?, + severity: SecuritySeverity::High, + description: "Netlify access token".to_string(), + public_safe: false, + context_keywords: vec!["netlify".to_string(), "deploy".to_string()], + false_positive_keywords: vec![], + }, + ]); + + Ok(patterns) + } + + /// Initialize generic patterns that apply across tools + fn initialize_generic_patterns() -> Result, regex::Error> { + let patterns = vec![ + GenericPattern { + id: "bearer-token".to_string(), + name: "Bearer Token".to_string(), + pattern: Regex::new(r#"(?i)(?:authorization|bearer)\s*[:=]\s*["'](?:bearer\s+)?([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Bearer token in authorization header".to_string(), + }, + GenericPattern { + id: "jwt-token".to_string(), + name: "JWT Token".to_string(), + pattern: Regex::new(r#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#)?, + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + description: "JSON Web Token detected".to_string(), + }, + GenericPattern { + id: "database-url".to_string(), + name: "Database Connection URL".to_string(), + pattern: Regex::new(r#"(?i)(?:mongodb|postgres|mysql)://[^"'\s]+:[^"'\s]+@[^"'\s]+"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Database connection string with credentials".to_string(), + }, + GenericPattern { + id: "private-key".to_string(), + name: "Private Key".to_string(), + pattern: Regex::new(r#"-----BEGIN (?:RSA |OPENSSH |PGP )?PRIVATE KEY-----"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Private key detected".to_string(), + }, + GenericPattern { + id: "generic-api-key".to_string(), + name: "Generic API Key".to_string(), + pattern: Regex::new(r#"(?i)(?:api[_-]?key|apikey)\s*[:=]\s*["']([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + description: "Generic API key pattern".to_string(), + }, + ]; + + Ok(patterns) + } + + /// Get patterns for a specific tool + pub fn get_tool_patterns(&self, tool: &str) -> Option<&Vec> { + self.patterns_by_tool.get(tool) + } + + /// Get all generic patterns + pub fn get_generic_patterns(&self) -> &Vec { + &self.generic_patterns + } + + /// Get all supported tools + pub fn get_supported_tools(&self) -> Vec { + self.patterns_by_tool.keys().cloned().collect() + } + + /// Get patterns for JavaScript/TypeScript frameworks + pub fn get_js_framework_patterns(&self) -> Vec<&ToolPattern> { + let js_tools = ["firebase", "stripe", "supabase", "clerk", "auth0", "vercel", "netlify"]; + js_tools.iter() + .filter_map(|tool| self.patterns_by_tool.get(*tool)) + .flat_map(|patterns| patterns.iter()) + .collect() + } +} + +impl Default for SecretPatternManager { + fn default() -> Self { + Self::new().expect("Failed to initialize security patterns") + } +} + +impl ToolPattern { + /// Check if this pattern should be treated as a high-confidence match given the context + pub fn assess_confidence(&self, file_content: &str, line_content: &str) -> f32 { + let mut confidence: f32 = 0.5; // Base confidence + + // Increase confidence for context keywords + for keyword in &self.context_keywords { + if file_content.to_lowercase().contains(&keyword.to_lowercase()) { + confidence += 0.2; + } + } + + // Decrease confidence for false positive indicators + for indicator in &self.false_positive_keywords { + if line_content.to_lowercase().contains(&indicator.to_lowercase()) { + confidence -= 0.3; + } + } + + confidence.clamp(0.0, 1.0) + } + + /// Get severity adjusted for public safety + pub fn effective_severity(&self) -> SecuritySeverity { + if self.public_safe { + match &self.severity { + SecuritySeverity::Critical => SecuritySeverity::Medium, + SecuritySeverity::High => SecuritySeverity::Low, + other => other.clone(), + } + } else { + self.severity.clone() + } + } +} \ No newline at end of file diff --git a/src/analyzer/security_analyzer.rs b/src/analyzer/security_analyzer.rs index ce3929b1..39bbed7f 100644 --- a/src/analyzer/security_analyzer.rs +++ b/src/analyzer/security_analyzer.rs @@ -15,12 +15,16 @@ use std::process::Command; use regex::Regex; use serde::{Deserialize, Serialize}; use thiserror::Error; -use log::{info, debug, warn}; +use log::{info, debug}; use rayon::prelude::*; use indicatif::{ProgressBar, ProgressStyle, MultiProgress}; use crate::analyzer::{ProjectAnalysis, DetectedLanguage, DetectedTechnology, EnvVar}; use crate::analyzer::dependency_parser::Language; +use crate::analyzer::security::{ + ModularSecurityAnalyzer, SecurityAnalysisConfig as NewSecurityAnalysisConfig +}; +use crate::analyzer::security::core::SecurityReport as NewSecurityReport; #[derive(Debug, Error)] pub enum SecurityError { @@ -84,6 +88,7 @@ pub struct SecurityFinding { pub category: SecurityCategory, pub file_path: Option, pub line_number: Option, + pub column_number: Option, pub evidence: Option, pub remediation: Vec, pub references: Vec, @@ -209,6 +214,38 @@ impl SecurityAnalyzer { }) } + /// Enhanced security analysis using the new modular approach + pub fn analyze_security_enhanced(&mut self, analysis: &ProjectAnalysis) -> Result { + let start_time = Instant::now(); + info!("Starting enhanced modular security analysis"); + + // Create modular analyzer with JavaScript-specific configuration if JS/TS is detected + let has_javascript = analysis.languages.iter() + .any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")); + + let config = if has_javascript { + NewSecurityAnalysisConfig::for_javascript() + } else { + NewSecurityAnalysisConfig::default() + }; + + let mut modular_analyzer = ModularSecurityAnalyzer::with_config(config) + .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?; + + // Use the modular analyzer + let enhanced_report = modular_analyzer.analyze_project(&analysis.project_root, &analysis.languages) + .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?; + + // For now, just return the enhanced report as-is + // TODO: Combine with existing findings if needed + + // Build final report + let duration = start_time.elapsed().as_secs_f32(); + info!("Enhanced security analysis completed in {:.1}s - Found {} issues", duration, enhanced_report.total_findings); + + Ok(enhanced_report) + } + /// Perform comprehensive security analysis with appropriate progress for verbosity level pub fn analyze_security(&mut self, analysis: &ProjectAnalysis) -> Result { let start_time = Instant::now(); @@ -599,9 +636,9 @@ impl SecurityAnalyzer { ("Stripe API Key", r"sk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Critical), ("Stripe Publishable Key", r"pk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Medium), - // Database URLs and Passwords - ("Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?[^"'\s]+"#, SecuritySeverity::High), - ("Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}"#, SecuritySeverity::Medium), + // Database URLs and Passwords - Enhanced to avoid env var false positives + ("Hardcoded Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?(postgresql|mysql|mongodb)://[^"'\s]+"#, SecuritySeverity::Critical), + ("Hardcoded Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}["']?"#, SecuritySeverity::High), ("JWT Secret", r#"(?i)(jwt[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{20,}"#, SecuritySeverity::High), // Private Keys @@ -613,9 +650,14 @@ impl SecurityAnalyzer { ("Google Cloud Service Account", r#""type":\s*"service_account""#, SecuritySeverity::High), ("Azure Storage Key", r"DefaultEndpointsProtocol=https;AccountName=", SecuritySeverity::High), - // Generic patterns last (lowest priority) - ("Generic API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}"#, SecuritySeverity::High), - ("Generic Secret", r#"(?i)(secret|token|key)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}"#, SecuritySeverity::Medium), + // Client-side exposed environment variables (these are the real security issues) + ("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), + + // Hardcoded API keys (not environment variable access) + ("Hardcoded API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}["']?"#, SecuritySeverity::High), + + // Generic secrets that are clearly hardcoded (not env var access) + ("Hardcoded Secret", r#"(?i)(secret|token)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}["']?"#, SecuritySeverity::Medium), ]; patterns.into_iter() @@ -1035,6 +1077,7 @@ impl SecurityAnalyzer { category: SecurityCategory::SecretsExposure, file_path: None, line_number: None, + column_number: None, evidence: Some(format!("Variable: {} = {:?}", env_var.name, env_var.default_value)), remediation: vec![ "Remove default value for sensitive environment variables".to_string(), @@ -1042,7 +1085,7 @@ impl SecurityAnalyzer { "Document required environment variables separately".to_string(), ], references: vec![ - "https://owasp.org/www-project-top-ten/2017/A3_2017-Sensitive_Data_Exposure".to_string(), + "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(), ], cwe_id: Some("CWE-200".to_string()), compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()], @@ -1195,12 +1238,18 @@ impl SecurityAnalyzer { for (line_num, line) in content.lines().enumerate() { for pattern in &self.secret_patterns { - if let Some(_captures) = pattern.pattern.find(line) { + if let Some(match_) = pattern.pattern.find(line) { // Skip if it looks like a placeholder or example if self.is_likely_placeholder(line) { continue; } + // NEW: Skip if this is legitimate environment variable usage + if self.is_legitimate_env_var_usage(line, file_path) { + debug!("Skipping legitimate env var usage: {}", line.trim()); + continue; + } + // Determine severity based on git status let (severity, additional_remediation) = self.determine_secret_severity(file_path, pattern.severity.clone()); @@ -1241,6 +1290,7 @@ impl SecurityAnalyzer { category: SecurityCategory::SecretsExposure, file_path: Some(file_path.to_path_buf()), line_number: Some(line_num + 1), + column_number: Some(match_.start() + 1), // 1-indexed column position evidence: Some(format!("Line: {}", line.trim())), remediation, references: vec![ @@ -1256,6 +1306,180 @@ impl SecurityAnalyzer { Ok(findings) } + /// Check if a line represents legitimate environment variable usage (not a security issue) + fn is_legitimate_env_var_usage(&self, line: &str, file_path: &Path) -> bool { + let line_trimmed = line.trim(); + + // Check for common legitimate environment variable access patterns + let legitimate_env_patterns = [ + // Node.js/JavaScript patterns + r"process\.env\.[A-Z_]+", + r#"process\.env\[['""][A-Z_]+['"]\]"#, + + // Vite/Modern JS patterns + r"import\.meta\.env\.[A-Z_]+", + r#"import\.meta\.env\[['""][A-Z_]+['"]\]"#, + + // Python patterns + r#"os\.environ\.get\(["'][A-Z_]+["']\)"#, + r#"os\.environ\[["'][A-Z_]+["']\]"#, + r#"getenv\(["'][A-Z_]+["']\)"#, + + // Rust patterns + r#"env::var\("([A-Z_]+)"\)"#, + r#"std::env::var\("([A-Z_]+)"\)"#, + + // Go patterns + r#"os\.Getenv\(["'][A-Z_]+["']\)"#, + + // Java patterns + r#"System\.getenv\(["'][A-Z_]+["']\)"#, + + // Shell/Docker patterns + r"\$\{?[A-Z_]+\}?", + r"ENV [A-Z_]+", + + // Config file access patterns + r"config\.[a-z_]+\.[A-Z_]+", + r"settings\.[A-Z_]+", + r"env\.[A-Z_]+", + ]; + + // Check if the line matches any legitimate environment variable access pattern + for pattern_str in &legitimate_env_patterns { + if let Ok(pattern) = Regex::new(pattern_str) { + if pattern.is_match(line_trimmed) { + // Additional context checks to make sure this is really legitimate + + // Check if this is in a server-side context (not client-side) + if self.is_server_side_file(file_path) { + return true; + } + + // Check if this is NOT a client-side exposed variable + if !self.is_client_side_exposed_env_var(line_trimmed) { + return true; + } + } + } + } + + // Check for assignment vs access - assignments might be setting up environment variables + // which could be legitimate in certain contexts + if self.is_env_var_assignment_context(line_trimmed, file_path) { + return true; + } + + false + } + + /// Check if a file is likely server-side code (vs client-side) + fn is_server_side_file(&self, file_path: &Path) -> bool { + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + + // Server-side indicators + let server_indicators = [ + "/server/", "/api/", "/backend/", "/src/app/api/", "/pages/api/", + "/routes/", "/controllers/", "/middleware/", "/models/", + "/lib/", "/utils/", "/services/", "/config/", + "server.js", "index.js", "app.js", "main.js", + ".env", "dockerfile", "docker-compose", + ]; + + // Client-side indicators (these should return false) + let client_indicators = [ + "/public/", "/static/", "/assets/", "/components/", "/pages/", + "/src/components/", "/src/pages/", "/client/", "/frontend/", + "index.html", ".html", "/dist/", "/build/", + "dist/", "build/", "public/", "static/", "assets/", + ]; + + // If it's clearly client-side, return false + if client_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return false; + } + + // If it has server-side indicators, return true + if server_indicators.iter().any(|indicator| + path_str.contains(indicator) || file_name.contains(indicator) + ) { + return true; + } + + // Default to true for ambiguous cases (be conservative about flagging env var usage) + true + } + + /// Check if an environment variable is exposed to client-side (security issue) + fn is_client_side_exposed_env_var(&self, line: &str) -> bool { + let client_prefixes = [ + "REACT_APP_", "NEXT_PUBLIC_", "VUE_APP_", "VITE_", + "GATSBY_", "PUBLIC_", "NUXT_PUBLIC_", + ]; + + client_prefixes.iter().any(|prefix| line.contains(prefix)) + } + + /// Check if this is a legitimate environment variable assignment context + fn is_env_var_assignment_context(&self, line: &str, file_path: &Path) -> bool { + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + + // Only very specific configuration files where env var assignments are expected + // Be more restrictive to avoid false positives + let env_config_files = [ + ".env", + "docker-compose.yml", "docker-compose.yaml", + ".env.example", ".env.sample", ".env.template", + ".env.local", ".env.development", ".env.production", ".env.staging", + ]; + + // Check for exact filename matches for .env files (most common legitimate case) + if env_config_files.iter().any(|pattern| file_name == *pattern) { + return true; + } + + // Docker files are also legitimate for environment variable assignment + if file_name.starts_with("dockerfile") || file_name == "dockerfile" { + return true; + } + + // Shell scripts or CI/CD files + if file_name.ends_with(".sh") || + file_name.ends_with(".bash") || + path_str.contains(".github/workflows/") || + path_str.contains(".gitlab-ci") { + return true; + } + + // Lines that are clearly setting up environment variables for child processes + // Only match very specific patterns that indicate legitimate environment setup + let setup_patterns = [ + r"export [A-Z_]+=", // Shell export + r"ENV [A-Z_]+=", // Dockerfile ENV + r"^\s*environment:\s*$", // Docker Compose environment section header + r"^\s*env:\s*$", // Kubernetes env section header + r"process\.env\.[A-Z_]+ =", // Explicitly setting process.env (rare but legitimate) + ]; + + for pattern_str in &setup_patterns { + if let Ok(pattern) = Regex::new(pattern_str) { + if pattern.is_match(line) { + return true; + } + } + } + + false + } + fn is_likely_placeholder(&self, line: &str) -> bool { let placeholder_indicators = [ "example", "placeholder", "your_", "insert_", "replace_", @@ -1559,8 +1783,6 @@ impl SecurityAnalyzer { recommendations.push("Address critical security findings immediately".to_string()); } - // Add more generic recommendations... - recommendations } } @@ -1584,6 +1806,7 @@ mod tests { category: SecurityCategory::SecretsExposure, file_path: None, line_number: None, + column_number: None, evidence: None, remediation: vec![], references: vec![], @@ -1732,4 +1955,149 @@ mod tests { assert!(!analyzer.matches_common_env_patterns("config.json")); assert!(!analyzer.matches_common_env_patterns("package.json")); } + + #[test] + fn test_legitimate_env_var_usage() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Create mock file paths + let server_file = Path::new("src/server/config.js"); + let client_file = Path::new("src/components/MyComponent.js"); + + // Test legitimate server-side environment variable usage (should NOT be flagged) + assert!(analyzer.is_legitimate_env_var_usage("const apiKey = process.env.RESEND_API_KEY;", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("const dbUrl = process.env.DATABASE_URL;", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("api_key = os.environ.get('API_KEY')", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("let secret = env::var(\"JWT_SECRET\")?;", server_file)); + + // Test client-side environment variable usage (legitimate if not exposed) + assert!(analyzer.is_legitimate_env_var_usage("const apiUrl = process.env.API_URL;", client_file)); + + // Test client-side exposed variables (these ARE client-side exposed - security issues) + assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET_KEY")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_API_SECRET")); + + // Test hardcoded secrets (should NOT be legitimate) + assert!(!analyzer.is_legitimate_env_var_usage("const apiKey = 'sk-1234567890abcdef';", server_file)); + assert!(!analyzer.is_legitimate_env_var_usage("password = 'hardcoded123'", server_file)); + } + + #[test] + fn test_server_vs_client_side_detection() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Server-side files + assert!(analyzer.is_server_side_file(Path::new("src/server/app.js"))); + assert!(analyzer.is_server_side_file(Path::new("src/api/users.js"))); + assert!(analyzer.is_server_side_file(Path::new("pages/api/auth.js"))); + assert!(analyzer.is_server_side_file(Path::new("src/lib/database.js"))); + assert!(analyzer.is_server_side_file(Path::new(".env"))); + assert!(analyzer.is_server_side_file(Path::new("server.js"))); + + // Client-side files + assert!(!analyzer.is_server_side_file(Path::new("src/components/Button.jsx"))); + assert!(!analyzer.is_server_side_file(Path::new("public/index.html"))); + assert!(!analyzer.is_server_side_file(Path::new("src/pages/home.js"))); + assert!(!analyzer.is_server_side_file(Path::new("dist/bundle.js"))); + + // Ambiguous files (default to server-side for conservative detection) + assert!(analyzer.is_server_side_file(Path::new("src/utils/helper.js"))); + assert!(analyzer.is_server_side_file(Path::new("config/settings.js"))); + } + + #[test] + fn test_client_side_exposed_env_vars() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // These should be flagged as client-side exposed (security issues) + assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET")); + assert!(analyzer.is_client_side_exposed_env_var("import.meta.env.VITE_API_KEY")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_SECRET")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.VUE_APP_TOKEN")); + + // These should NOT be flagged as client-side exposed + assert!(!analyzer.is_client_side_exposed_env_var("process.env.DATABASE_URL")); + assert!(!analyzer.is_client_side_exposed_env_var("process.env.JWT_SECRET")); + assert!(!analyzer.is_client_side_exposed_env_var("process.env.API_KEY")); + } + + #[test] + fn test_env_var_assignment_context() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Configuration files where assignments are legitimate + assert!(analyzer.is_env_var_assignment_context("API_KEY=sk-test123", Path::new(".env"))); + assert!(analyzer.is_env_var_assignment_context("DATABASE_URL=postgres://", Path::new("docker-compose.yml"))); + assert!(analyzer.is_env_var_assignment_context("export SECRET=test", Path::new("setup.sh"))); + + // Regular source files where assignments might be suspicious + assert!(!analyzer.is_env_var_assignment_context("const secret = 'hardcoded'", Path::new("src/app.js"))); + } + + #[test] + fn test_enhanced_secret_patterns() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Test that hardcoded secrets are still detected + let hardcoded_patterns = [ + "apikey = 'sk-1234567890abcdef1234567890abcdef12345678'", + "const secret = 'my-super-secret-token-12345678901234567890'", + "password = 'hardcoded123456'", + ]; + + for pattern in &hardcoded_patterns { + let has_secret = analyzer.secret_patterns.iter().any(|sp| sp.pattern.is_match(pattern)); + assert!(has_secret, "Should detect hardcoded secret in: {}", pattern); + } + + // Test that legitimate env var usage is NOT detected as secret + let legitimate_patterns = [ + "const apiKey = process.env.API_KEY;", + "const dbUrl = process.env.DATABASE_URL || 'fallback';", + "api_key = os.environ.get('API_KEY')", + "let secret = env::var(\"JWT_SECRET\")?;", + ]; + + for pattern in &legitimate_patterns { + // These should either not match any secret pattern, or be filtered out by context detection + let matches_old_generic_pattern = pattern.to_lowercase().contains("secret") || + pattern.to_lowercase().contains("key"); + + // Our new patterns should be more specific and not match env var access + let matches_new_patterns = analyzer.secret_patterns.iter() + .filter(|sp| sp.name.contains("Hardcoded")) + .any(|sp| sp.pattern.is_match(pattern)); + + assert!(!matches_new_patterns, "Should NOT detect legitimate env var usage as hardcoded secret: {}", pattern); + } + } + + #[test] + fn test_context_aware_false_positive_reduction() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let server_file = temp_dir.path().join("src/server/config.js"); + + // Create directory structure + std::fs::create_dir_all(server_file.parent().unwrap()).unwrap(); + + // Write a file with legitimate environment variable usage + let content = r#" +const config = { + apiKey: process.env.RESEND_API_KEY, + databaseUrl: process.env.DATABASE_URL, + jwtSecret: process.env.JWT_SECRET, + port: process.env.PORT || 3000 +}; +"#; + + std::fs::write(&server_file, content).unwrap(); + + let analyzer = SecurityAnalyzer::new().unwrap(); + let findings = analyzer.analyze_file_for_secrets(&server_file).unwrap(); + + // Should have zero findings because all are legitimate env var usage + assert_eq!(findings.len(), 0, "Should not flag legitimate environment variable usage as security issues"); + } } diff --git a/src/main.rs b/src/main.rs index aa6c9ce3..272b700d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ use syncable_cli::{ analyzer::{ self, vulnerability_checker::VulnerabilitySeverity, DetectedTechnology, TechnologyCategory, LibraryType, analyze_monorepo, ProjectCategory, + // Import new modular security types + security::SecuritySeverity, }, cli::{Cli, Commands, ToolsCommand, OutputFormat, SeverityThreshold, DisplayFormat}, config, @@ -1157,10 +1159,10 @@ fn handle_security( // Step 8: Generating Report progress.set_message("Generating security report..."); progress.set_position(100); - let security_report = security_analyzer.analyze_security(&project_analysis) + let security_report = security_analyzer.analyze_security_enhanced(&project_analysis) .map_err(|e| syncable_cli::error::IaCGeneratorError::Analysis( syncable_cli::error::AnalysisError::InvalidStructure( - format!("Security analysis failed: {}", e) + format!("Enhanced security analysis failed: {}", e) ) ))?; @@ -1169,119 +1171,244 @@ fn handle_security( // Format output in the beautiful style requested let output_string = match format { OutputFormat::Table => { + use syncable_cli::analyzer::display::BoxDrawer; + use colored::*; + let mut output = String::new(); - // Beautiful Header - output.push_str("\nπŸ›‘οΈ Security Analysis Results\n"); - output.push_str(&format!("{}\n", "=".repeat(60))); + // Header + output.push_str(&format!("\n{}\n", "πŸ›‘οΈ Security Analysis Results".bright_white().bold())); + output.push_str(&format!("{}\n", "═".repeat(80).bright_blue())); - // Security Summary - output.push_str("\nπŸ“Š SECURITY SUMMARY\n"); - output.push_str(&format!("βœ… Security Score: {:.1}/100\n", security_report.overall_score)); + // Security Score Box + let mut score_box = BoxDrawer::new("Security Summary"); + score_box.add_line("Overall Score:", &format!("{:.0}/100", security_report.overall_score).bright_yellow(), true); + score_box.add_line("Risk Level:", &format!("{:?}", security_report.risk_level).color(match security_report.risk_level { + SecuritySeverity::Critical => "bright_red", + SecuritySeverity::High => "red", + SecuritySeverity::Medium => "yellow", + SecuritySeverity::Low => "green", + SecuritySeverity::Info => "blue", + }), true); + score_box.add_line("Total Findings:", &security_report.total_findings.to_string().cyan(), true); - // Analysis Scope - only show what's actually implemented - output.push_str("\nπŸ” ANALYSIS SCOPE\n"); + // Analysis scope let config_files = security_report.findings.iter() .filter_map(|f| f.file_path.as_ref()) .collect::>() .len(); - let code_files = security_report.findings.iter() - .filter(|f| matches!(f.category, syncable_cli::analyzer::SecurityCategory::CodeSecurityPattern)) - .filter_map(|f| f.file_path.as_ref()) - .collect::>() - .len(); - - output.push_str(&format!("βœ… Secret Detection ({} files analyzed)\n", config_files.max(1))); - output.push_str(&format!("βœ… Environment Variables ({} variables checked)\n", project_analysis.environment_variables.len())); - if code_files > 0 { - output.push_str(&format!("βœ… Code Security Patterns ({} files analyzed)\n", code_files)); - } else { - output.push_str("ℹ️ Code Security Patterns (no applicable files found)\n"); - } - output.push_str("🚧 Infrastructure Security (coming soon)\n"); - output.push_str("🚧 Compliance Frameworks (coming soon)\n"); - - // Findings by Category - output.push_str("\n🎯 FINDINGS BY CATEGORY\n"); + score_box.add_line("Files Analyzed:", &config_files.max(1).to_string().green(), true); + score_box.add_line("Env Variables:", &project_analysis.environment_variables.len().to_string().green(), true); - // Count findings by our categories - let mut secret_findings = 0; - let mut code_findings = 0; - let mut infrastructure_findings = 0; - let mut compliance_findings = 0; + output.push_str(&format!("\n{}\n", score_box.draw())); - for finding in &security_report.findings { - match finding.category { - syncable_cli::analyzer::SecurityCategory::SecretsExposure => secret_findings += 1, - syncable_cli::analyzer::SecurityCategory::CodeSecurityPattern | - syncable_cli::analyzer::SecurityCategory::AuthenticationSecurity | - syncable_cli::analyzer::SecurityCategory::DataProtection => code_findings += 1, - syncable_cli::analyzer::SecurityCategory::InfrastructureSecurity | - syncable_cli::analyzer::SecurityCategory::NetworkSecurity | - syncable_cli::analyzer::SecurityCategory::InsecureConfiguration => infrastructure_findings += 1, - syncable_cli::analyzer::SecurityCategory::Compliance => compliance_findings += 1, - } - } - - output.push_str(&format!("πŸ” Secret Detection: {} findings\n", secret_findings)); - output.push_str(&format!("πŸ”’ Code Security: {} finding{}\n", code_findings, if code_findings == 1 { "" } else { "s" })); - output.push_str(&format!("πŸ—οΈ Infrastructure: {} findings\n", infrastructure_findings)); - output.push_str(&format!("πŸ“‹ Compliance: {} finding{}\n", compliance_findings, if compliance_findings == 1 { "" } else { "s" })); - - // Recommendations - if !security_report.recommendations.is_empty() { - output.push_str("\nπŸ’‘ RECOMMENDATIONS\n"); - for recommendation in &security_report.recommendations { - output.push_str(&format!("β€’ {}\n", recommendation)); - } - } else { - // Add some default recommendations based on the analysis - output.push_str("\nπŸ’‘ RECOMMENDATIONS\n"); - output.push_str("β€’ Enable dependency vulnerability scanning in CI/CD\n"); - output.push_str("β€’ Consider implementing rate limiting for API endpoints\n"); - output.push_str("β€’ Review environment variable security practices\n"); - } - - // If there are actual findings, show them in detail + // Findings in Card Format if !security_report.findings.is_empty() { - output.push_str(&format!("\n{}\n", "=".repeat(60))); - output.push_str("πŸ” DETAILED FINDINGS\n\n"); + // Get terminal width to determine optimal display width + let terminal_width = if let Some((width, _)) = term_size::dimensions() { + width.saturating_sub(10) // Leave some margin + } else { + 120 // Fallback width + }; + + let mut findings_box = BoxDrawer::new("Security Findings"); for (i, finding) in security_report.findings.iter().enumerate() { - let severity_emoji = match finding.severity { - syncable_cli::analyzer::SecuritySeverity::Critical => "🚨", - syncable_cli::analyzer::SecuritySeverity::High => "⚠️ ", - syncable_cli::analyzer::SecuritySeverity::Medium => "⚑", - syncable_cli::analyzer::SecuritySeverity::Low => "ℹ️ ", - syncable_cli::analyzer::SecuritySeverity::Info => "πŸ’‘", + let severity_color = match finding.severity { + SecuritySeverity::Critical => "bright_red", + SecuritySeverity::High => "red", + SecuritySeverity::Medium => "yellow", + SecuritySeverity::Low => "blue", + SecuritySeverity::Info => "green", }; - output.push_str(&format!("{}. {} [{}] {}\n", i + 1, severity_emoji, finding.id, finding.title)); - output.push_str(&format!(" πŸ“ {}\n", finding.description)); - - if let Some(file) = &finding.file_path { - output.push_str(&format!(" πŸ“ File: {}", file.display())); - if let Some(line) = finding.line_number { - output.push_str(&format!(" (line {})", line)); + // Extract relative file path from project root + let file_display = if let Some(file_path) = &finding.file_path { + // Canonicalize both paths to handle symlinks and resolve properly + let canonical_file = file_path.canonicalize().unwrap_or_else(|_| file_path.clone()); + let canonical_project = path.canonicalize().unwrap_or_else(|_| path.clone()); + + // Try to calculate relative path from project root + if let Ok(relative_path) = canonical_file.strip_prefix(&canonical_project) { + format!("./{}", relative_path.display()) + } else { + // Fallback: try to find any common ancestor or use absolute path + let path_str = file_path.to_string_lossy(); + if path_str.starts_with('/') { + // For absolute paths, try to extract meaningful relative portion + if let Some(project_name) = path.file_name().and_then(|n| n.to_str()) { + if let Some(project_idx) = path_str.rfind(project_name) { + let relative_part = &path_str[project_idx + project_name.len()..]; + if relative_part.starts_with('/') { + format!(".{}", relative_part) + } else if !relative_part.is_empty() { + format!("./{}", relative_part) + } else { + format!("./{}", file_path.file_name().unwrap_or_default().to_string_lossy()) + } + } else { + // Last resort: show the full path + path_str.to_string() + } + } else { + // Show full path if we can't determine project context + path_str.to_string() + } + } else { + // For relative paths that don't strip properly, use as-is + if path_str.starts_with("./") { + path_str.to_string() + } else { + format!("./{}", path_str) + } + } } - output.push_str("\n"); - } + } else { + "N/A".to_string() + }; - if let Some(evidence) = &finding.evidence { - output.push_str(&format!(" πŸ” Evidence: {}\n", evidence)); - } + // Parse gitignore status from description (clean colored text) + let gitignore_status = if finding.description.contains("is tracked by git") { + "TRACKED".bright_red().bold() + } else if finding.description.contains("is NOT in .gitignore") { + "EXPOSED".yellow().bold() + } else if finding.description.contains("is protected") || finding.description.contains("properly ignored") { + "SAFE".bright_green().bold() + } else if finding.description.contains("appears safe") { + "OK".bright_blue().bold() + } else { + "UNKNOWN".dimmed() + }; + + // Determine finding type + let finding_type = if finding.title.contains("Environment Variable") { + "ENV VAR" + } else if finding.title.contains("Secret File") { + "SECRET FILE" + } else if finding.title.contains("API Key") || finding.title.contains("Stripe") || finding.title.contains("Firebase") { + "API KEY" + } else if finding.title.contains("Configuration") { + "CONFIG" + } else { + "OTHER" + }; + + // Format position as "line:column" or just "line" if no column info + let position_display = match (finding.line_number, finding.column_number) { + (Some(line), Some(col)) => format!("{}:{}", line, col), + (Some(line), None) => format!("{}", line), + _ => "β€”".to_string(), + }; + + // Card format: File path with intelligent display based on terminal width + let box_margin = 6; // Account for box borders and padding + let available_width = terminal_width.saturating_sub(box_margin); + let max_path_width = available_width.saturating_sub(20); // Leave space for numbering and spacing - if !finding.remediation.is_empty() { - output.push_str(" πŸ”§ Fix:\n"); - for remediation in &finding.remediation { - output.push_str(&format!(" β€’ {}\n", remediation)); + if file_display.len() + 3 <= max_path_width { + // Path fits on one line with numbering + findings_box.add_value_only(&format!("{}. {}", + format!("{}", i + 1).bright_white().bold(), + file_display.cyan().bold() + )); + } else if file_display.len() <= available_width.saturating_sub(4) { + // Path fits on its own line with indentation + findings_box.add_value_only(&format!("{}.", + format!("{}", i + 1).bright_white().bold() + )); + findings_box.add_value_only(&format!(" {}", + file_display.cyan().bold() + )); + } else { + // Path is extremely long - use smart wrapping + findings_box.add_value_only(&format!("{}.", + format!("{}", i + 1).bright_white().bold() + )); + + // Smart path wrapping - prefer breaking at directory separators + let wrap_width = available_width.saturating_sub(4); + let mut remaining = file_display.as_str(); + let mut first_line = true; + + while !remaining.is_empty() { + let prefix = if first_line { " " } else { " " }; + let line_width = wrap_width.saturating_sub(prefix.len()); + + if remaining.len() <= line_width { + // Last chunk fits entirely + findings_box.add_value_only(&format!("{}{}", + prefix, remaining.cyan().bold() + )); + break; + } else { + // Find a good break point (prefer directory separator) + let chunk = &remaining[..line_width]; + let break_point = chunk.rfind('/').unwrap_or(line_width.saturating_sub(1)); + + findings_box.add_value_only(&format!("{}{}", + prefix, chunk[..break_point].cyan().bold() + )); + remaining = &remaining[break_point..]; + if remaining.starts_with('/') { + remaining = &remaining[1..]; // Skip the separator + } + } + first_line = false; } } - output.push_str("\n"); + findings_box.add_value_only(&format!(" {} {} | {} {} | {} {} | {} {}", + "Type:".dimmed(), + finding_type.yellow(), + "Severity:".dimmed(), + format!("{:?}", finding.severity).color(severity_color).bold(), + "Position:".dimmed(), + position_display.bright_cyan(), + "Status:".dimmed(), + gitignore_status + )); + + // Add spacing between findings (except for the last one) + if i < security_report.findings.len() - 1 { + findings_box.add_value_only(""); + } } + + output.push_str(&format!("\n{}\n", findings_box.draw())); + + // GitIgnore Status Legend + let mut legend_box = BoxDrawer::new("Git Status Legend"); + legend_box.add_line(&"TRACKED:".bright_red().bold().to_string(), "File is tracked by git - CRITICAL RISK", false); + legend_box.add_line(&"EXPOSED:".yellow().bold().to_string(), "File contains secrets but not in .gitignore", false); + legend_box.add_line(&"SAFE:".bright_green().bold().to_string(), "File is properly ignored by .gitignore", false); + legend_box.add_line(&"OK:".bright_blue().bold().to_string(), "File appears safe for version control", false); + output.push_str(&format!("\n{}\n", legend_box.draw())); + } else { + let mut no_findings_box = BoxDrawer::new("Security Status"); + no_findings_box.add_value_only(&"βœ… No security issues detected".green()); + no_findings_box.add_value_only("πŸ’‘ Regular security scanning recommended"); + output.push_str(&format!("\n{}\n", no_findings_box.draw())); } + // Recommendations Box + let mut rec_box = BoxDrawer::new("Key Recommendations"); + if !security_report.recommendations.is_empty() { + for (i, rec) in security_report.recommendations.iter().take(5).enumerate() { + // Clean up recommendation text + let clean_rec = rec.replace("Add these patterns to your .gitignore:", "Add to .gitignore:"); + rec_box.add_value_only(&format!("{}. {}", i + 1, clean_rec)); + } + if security_report.recommendations.len() > 5 { + rec_box.add_value_only(&format!("... and {} more recommendations", + security_report.recommendations.len() - 5).dimmed()); + } + } else { + rec_box.add_value_only("βœ… No immediate security concerns detected"); + rec_box.add_value_only("πŸ’‘ Consider implementing dependency scanning"); + rec_box.add_value_only("πŸ’‘ Review environment variable security practices"); + } + output.push_str(&format!("\n{}\n", rec_box.draw())); + output } OutputFormat::Json => { @@ -1300,10 +1427,10 @@ fn handle_security( // Exit with error code if requested and findings exist if fail_on_findings && security_report.total_findings > 0 { let critical_count = security_report.findings_by_severity - .get(&syncable_cli::analyzer::SecuritySeverity::Critical) + .get(&SecuritySeverity::Critical) .unwrap_or(&0); let high_count = security_report.findings_by_severity - .get(&syncable_cli::analyzer::SecuritySeverity::High) + .get(&SecuritySeverity::High) .unwrap_or(&0); if *critical_count > 0 { @@ -1328,7 +1455,7 @@ async fn handle_tools(command: ToolsCommand) -> syncable_cli::Result<()> { match command { ToolsCommand::Status { format, languages } => { - let mut installer = ToolInstaller::new(); + let installer = ToolInstaller::new(); // Determine which languages to check let langs_to_check = if let Some(lang_names) = languages { @@ -1504,7 +1631,7 @@ async fn handle_tools(command: ToolsCommand) -> syncable_cli::Result<()> { } ToolsCommand::Verify { languages, verbose } => { - let mut installer = ToolInstaller::new(); + let installer = ToolInstaller::new(); // Determine which languages to verify let langs_to_verify = if let Some(lang_names) = languages {