syncable_cli/handlers/
security.rs

1use crate::{
2    analyzer::security::{TurboSecurityAnalyzer, TurboConfig, ScanMode},
3    analyzer::security::turbo::results::SecurityReport,
4    analyzer::security::SecuritySeverity as TurboSecuritySeverity,
5    analyzer::display::BoxDrawer,
6    cli::{OutputFormat, SecurityScanMode},
7};
8use colored::*;
9use std::path::PathBuf;
10
11pub fn handle_security(
12    path: PathBuf,
13    mode: SecurityScanMode,
14    include_low: bool,
15    no_secrets: bool,
16    no_code_patterns: bool,
17    _no_infrastructure: bool,
18    _no_compliance: bool,
19    _frameworks: Vec<String>,
20    format: OutputFormat,
21    output: Option<PathBuf>,
22    fail_on_findings: bool,
23) -> crate::Result<String> {
24    let project_path = path.canonicalize()
25        .unwrap_or_else(|_| path.clone());
26    
27    // Build string output while also printing
28    let mut result_output = String::new();
29    
30    // Print and collect header
31    println!("đŸ›Ąī¸  Running security analysis on: {}", project_path.display());
32    result_output.push_str(&format!("đŸ›Ąī¸  Running security analysis on: {}\n", project_path.display()));
33    
34    // Convert CLI mode to internal ScanMode, with flag overrides
35    let scan_mode = determine_scan_mode(mode, include_low, no_secrets, no_code_patterns);
36    
37    // Configure turbo analyzer
38    let config = create_turbo_config(scan_mode, fail_on_findings, no_secrets);
39    
40    // Initialize and run analyzer
41    let analyzer = TurboSecurityAnalyzer::new(config)
42        .map_err(|e| crate::error::IaCGeneratorError::Analysis(
43            crate::error::AnalysisError::InvalidStructure(
44                format!("Failed to create turbo security analyzer: {}", e)
45            )
46        ))?;
47    
48    let start_time = std::time::Instant::now();
49    let security_report = analyzer.analyze_project(&project_path)
50        .map_err(|e| crate::error::IaCGeneratorError::Analysis(
51            crate::error::AnalysisError::InvalidStructure(
52                format!("Turbo security analysis failed: {}", e)
53            )
54        ))?;
55    let scan_duration = start_time.elapsed();
56    
57    // Print and collect scan completion
58    println!("⚡ Scan completed in {:.2}s", scan_duration.as_secs_f64());
59    result_output.push_str(&format!("⚡ Scan completed in {:.2}s\n", scan_duration.as_secs_f64()));
60    
61    // Format output
62    let output_string = match format {
63        OutputFormat::Table => format_security_table(&security_report, scan_mode, &path),
64        OutputFormat::Json => serde_json::to_string_pretty(&security_report)?,
65    };
66    
67    // Add formatted output to result string
68    result_output.push_str(&output_string);
69    
70    // Output results
71    if let Some(output_path) = output {
72        std::fs::write(&output_path, &output_string)?;
73        println!("Security report saved to: {}", output_path.display());
74        result_output.push_str(&format!("\nSecurity report saved to: {}\n", output_path.display()));
75    } else {
76        print!("{}", output_string);
77    }
78    
79    // Exit with error code if requested and findings exist
80    if fail_on_findings && security_report.total_findings > 0 {
81        handle_exit_codes(&security_report);
82    }
83    
84    Ok(result_output)
85}
86
87fn determine_scan_mode(
88    mode: SecurityScanMode,
89    include_low: bool,
90    no_secrets: bool,
91    no_code_patterns: bool,
92) -> ScanMode {
93    if no_secrets && no_code_patterns {
94        // Override: if both secrets and code patterns are disabled, use lightning
95        ScanMode::Lightning
96    } else if include_low {
97        // Override: if including low findings, force paranoid mode
98        ScanMode::Paranoid
99    } else {
100        // Use the requested mode from CLI
101        match mode {
102            SecurityScanMode::Lightning => ScanMode::Lightning,
103            SecurityScanMode::Fast => ScanMode::Fast,
104            SecurityScanMode::Balanced => ScanMode::Balanced,
105            SecurityScanMode::Thorough => ScanMode::Thorough,
106            SecurityScanMode::Paranoid => ScanMode::Paranoid,
107        }
108    }
109}
110
111fn create_turbo_config(scan_mode: ScanMode, fail_on_findings: bool, no_secrets: bool) -> TurboConfig {
112    TurboConfig {
113        scan_mode,
114        max_file_size: 10 * 1024 * 1024, // 10MB
115        worker_threads: 0, // Auto-detect
116        use_mmap: true,
117        enable_cache: true,
118        cache_size_mb: 100,
119        max_critical_findings: if fail_on_findings { Some(1) } else { None },
120        timeout_seconds: Some(60),
121        skip_gitignored: true,
122        priority_extensions: vec![
123            "env".to_string(), "key".to_string(), "pem".to_string(),
124            "json".to_string(), "yml".to_string(), "yaml".to_string(),
125            "toml".to_string(), "ini".to_string(), "conf".to_string(),
126            "config".to_string(), "js".to_string(), "ts".to_string(),
127            "py".to_string(), "rs".to_string(), "go".to_string(),
128        ],
129        pattern_sets: if no_secrets {
130            vec![]
131        } else {
132            vec!["default".to_string(), "aws".to_string(), "gcp".to_string()]
133        },
134    }
135}
136
137fn format_security_table(
138    security_report: &SecurityReport,
139    scan_mode: ScanMode,
140    path: &std::path::Path,
141) -> String {
142    let mut output = String::new();
143    
144    // Header
145    output.push_str(&format!("\n{}\n", "đŸ›Ąī¸  Security Analysis Results".bright_white().bold()));
146    output.push_str(&format!("{}\n", "═".repeat(80).bright_blue()));
147    
148    // Security Score Box
149    output.push_str(&format_security_summary_box(security_report, scan_mode));
150    
151    // Findings
152    if !security_report.findings.is_empty() {
153        output.push_str(&format_security_findings_box(security_report, path));
154        output.push_str(&format_gitignore_legend());
155    } else {
156        output.push_str(&format_no_findings_box());
157    }
158    
159    // Recommendations
160    output.push_str(&format_recommendations_box(security_report));
161    
162    output
163}
164
165fn format_security_summary_box(
166    security_report: &SecurityReport,
167    scan_mode: ScanMode,
168) -> String {
169    let mut score_box = BoxDrawer::new("Security Summary");
170    score_box.add_line("Overall Score:", &format!("{:.0}/100", security_report.overall_score).bright_yellow(), true);
171    score_box.add_line("Risk Level:", &format!("{:?}", security_report.risk_level).color(match security_report.risk_level {
172        TurboSecuritySeverity::Critical => "bright_red",
173        TurboSecuritySeverity::High => "red", 
174        TurboSecuritySeverity::Medium => "yellow",
175        TurboSecuritySeverity::Low => "green",
176        TurboSecuritySeverity::Info => "blue",
177    }), true);
178    score_box.add_line("Total Findings:", &security_report.total_findings.to_string().cyan(), true);
179    
180    // Analysis scope
181    let config_files = security_report.findings.iter()
182        .filter_map(|f| f.file_path.as_ref())
183        .collect::<std::collections::HashSet<_>>()
184        .len();
185    score_box.add_line("Files Analyzed:", &config_files.max(1).to_string().green(), true);
186    score_box.add_line("Scan Mode:", &format!("{:?}", scan_mode).green(), true);
187    
188    format!("\n{}\n", score_box.draw())
189}
190
191fn format_security_findings_box(
192    security_report: &SecurityReport,
193    project_path: &std::path::Path,
194) -> String {
195    // Get terminal width to determine optimal display width
196    let terminal_width = if let Some((width, _)) = term_size::dimensions() {
197        width.saturating_sub(10) // Leave some margin
198    } else {
199        120 // Fallback width
200    };
201    
202    let mut findings_box = BoxDrawer::new("Security Findings");
203    
204    for (i, finding) in security_report.findings.iter().enumerate() {
205        let severity_color = match finding.severity {
206            TurboSecuritySeverity::Critical => "bright_red",
207            TurboSecuritySeverity::High => "red",
208            TurboSecuritySeverity::Medium => "yellow", 
209            TurboSecuritySeverity::Low => "blue",
210            TurboSecuritySeverity::Info => "green",
211        };
212        
213        // Extract relative file path from project root
214        let file_display = calculate_relative_path(finding.file_path.as_ref(), project_path);
215        
216        // Parse gitignore status from description
217        let gitignore_status = determine_gitignore_status(&finding.description);
218        
219        // Determine finding type
220        let finding_type = determine_finding_type(&finding.title);
221        
222        // Format position
223        let position_display = format_position(finding.line_number, finding.column_number);
224        
225        // Display file path with intelligent wrapping
226        format_file_path(&mut findings_box, i + 1, &file_display, terminal_width);
227        
228        findings_box.add_value_only(&format!("   {} {} | {} {} | {} {} | {} {}", 
229            "Type:".dimmed(),
230            finding_type.yellow(),
231            "Severity:".dimmed(),
232            format!("{:?}", finding.severity).color(severity_color).bold(),
233            "Position:".dimmed(),
234            position_display.bright_cyan(),
235            "Status:".dimmed(),
236            gitignore_status
237        ));
238        
239        // Add spacing between findings (except for the last one)
240        if i < security_report.findings.len() - 1 {
241            findings_box.add_value_only("");
242        }
243    }
244    
245    format!("\n{}\n", findings_box.draw())
246}
247
248fn calculate_relative_path(file_path: Option<&PathBuf>, project_path: &std::path::Path) -> String {
249    if let Some(file_path) = file_path {
250        // Cross-platform path normalization
251        let canonical_file = file_path.canonicalize().unwrap_or_else(|_| file_path.clone());
252        let canonical_project = project_path.canonicalize().unwrap_or_else(|_| project_path.to_path_buf());
253        
254        // Try to calculate relative path from project root
255        if let Ok(relative_path) = canonical_file.strip_prefix(&canonical_project) {
256            // Use forward slashes for consistency across platforms
257            let relative_str = relative_path.to_string_lossy().replace('\\', "/");
258            format!("./{}", relative_str)
259        } else {
260            // Fallback logic for complex paths
261            format_fallback_path(file_path, project_path)
262        }
263    } else {
264        "N/A".to_string()
265    }
266}
267
268fn format_fallback_path(file_path: &PathBuf, project_path: &std::path::Path) -> String {
269    let path_str = file_path.to_string_lossy();
270    if path_str.starts_with('/') {
271        // For absolute paths, try to extract meaningful relative portion
272        if let Some(project_name) = project_path.file_name().and_then(|n| n.to_str()) {
273            if let Some(project_idx) = path_str.rfind(project_name) {
274                let relative_part = &path_str[project_idx + project_name.len()..];
275                if relative_part.starts_with('/') {
276                    format!(".{}", relative_part)
277                } else if !relative_part.is_empty() {
278                    format!("./{}", relative_part)
279                } else {
280                    format!("./{}", file_path.file_name().unwrap_or_default().to_string_lossy())
281                }
282            } else {
283                path_str.to_string()
284            }
285        } else {
286            path_str.to_string()
287        }
288    } else {
289        // For relative paths that don't strip properly, use as-is
290        if path_str.starts_with("./") {
291            path_str.to_string()
292        } else {
293            format!("./{}", path_str)
294        }
295    }
296}
297
298fn determine_gitignore_status(description: &str) -> ColoredString {
299    if description.contains("is tracked by git") {
300        "TRACKED".bright_red().bold()
301    } else if description.contains("is NOT in .gitignore") {
302        "EXPOSED".yellow().bold()
303    } else if description.contains("is protected") || description.contains("properly ignored") {
304        "SAFE".bright_green().bold()
305    } else if description.contains("appears safe") {
306        "OK".bright_blue().bold()
307    } else {
308        "UNKNOWN".dimmed()
309    }
310}
311
312fn determine_finding_type(title: &str) -> &'static str {
313    if title.contains("Environment Variable") {
314        "ENV VAR"
315    } else if title.contains("Secret File") {
316        "SECRET FILE"
317    } else if title.contains("API Key") || title.contains("Stripe") || title.contains("Firebase") {
318        "API KEY"
319    } else if title.contains("Configuration") {
320        "CONFIG"
321    } else {
322        "OTHER"
323    }
324}
325
326fn format_position(line_number: Option<usize>, column_number: Option<usize>) -> String {
327    match (line_number, column_number) {
328        (Some(line), Some(col)) => format!("{}:{}", line, col),
329        (Some(line), None) => format!("{}", line),
330        _ => "—".to_string(),
331    }
332}
333
334fn format_file_path(findings_box: &mut BoxDrawer, index: usize, file_display: &str, terminal_width: usize) {
335    let box_margin = 6; // Account for box borders and padding
336    let available_width = terminal_width.saturating_sub(box_margin);
337    let max_path_width = available_width.saturating_sub(20); // Leave space for numbering and spacing
338    
339    if file_display.len() + 3 <= max_path_width {
340        // Path fits on one line with numbering
341        findings_box.add_value_only(&format!("{}. {}", 
342            format!("{}", index).bright_white().bold(),
343            file_display.cyan().bold()
344        ));
345    } else if file_display.len() <= available_width.saturating_sub(4) {
346        // Path fits on its own line with indentation
347        findings_box.add_value_only(&format!("{}.", 
348            format!("{}", index).bright_white().bold()
349        ));
350        findings_box.add_value_only(&format!("   {}", 
351            file_display.cyan().bold()
352        ));
353    } else {
354        // Path is extremely long - use smart wrapping
355        format_long_path(findings_box, index, file_display, available_width);
356    }
357}
358
359fn format_long_path(findings_box: &mut BoxDrawer, index: usize, file_display: &str, available_width: usize) {
360    findings_box.add_value_only(&format!("{}.", 
361        format!("{}", index).bright_white().bold()
362    ));
363    
364    // Smart path wrapping - prefer breaking at directory separators
365    let wrap_width = available_width.saturating_sub(4);
366    let mut remaining = file_display;
367    let mut first_line = true;
368    
369    while !remaining.is_empty() {
370        let prefix = if first_line { "   " } else { "     " };
371        let line_width = wrap_width.saturating_sub(prefix.len());
372        
373        if remaining.len() <= line_width {
374            // Last chunk fits entirely
375            findings_box.add_value_only(&format!("{}{}", 
376                prefix, remaining.cyan().bold()
377            ));
378            break;
379        } else {
380            // Find a good break point (prefer directory separator)
381            let chunk = &remaining[..line_width];
382            let break_point = chunk.rfind('/').unwrap_or(line_width.saturating_sub(1));
383            
384            findings_box.add_value_only(&format!("{}{}", 
385                prefix, chunk[..break_point].cyan().bold()
386            ));
387            remaining = &remaining[break_point..];
388            if remaining.starts_with('/') {
389                remaining = &remaining[1..]; // Skip the separator
390            }
391        }
392        first_line = false;
393    }
394}
395
396fn format_gitignore_legend() -> String {
397    let mut legend_box = BoxDrawer::new("Git Status Legend");
398    legend_box.add_line(&"TRACKED:".bright_red().bold().to_string(), "File is tracked by git - CRITICAL RISK", false);
399    legend_box.add_line(&"EXPOSED:".yellow().bold().to_string(), "File contains secrets but not in .gitignore", false);
400    legend_box.add_line(&"SAFE:".bright_green().bold().to_string(), "File is properly ignored by .gitignore", false);
401    legend_box.add_line(&"OK:".bright_blue().bold().to_string(), "File appears safe for version control", false);
402    format!("\n{}\n", legend_box.draw())
403}
404
405fn format_no_findings_box() -> String {
406    let mut no_findings_box = BoxDrawer::new("Security Status");
407    no_findings_box.add_value_only(&"✅ No security issues detected".green());
408    no_findings_box.add_value_only("💡 Regular security scanning recommended");
409    format!("\n{}\n", no_findings_box.draw())
410}
411
412fn format_recommendations_box(security_report: &SecurityReport) -> String {
413    let mut rec_box = BoxDrawer::new("Key Recommendations");
414    if !security_report.recommendations.is_empty() {
415        for (i, rec) in security_report.recommendations.iter().take(5).enumerate() {
416            // Clean up recommendation text
417            let clean_rec = rec.replace("Add these patterns to your .gitignore:", "Add to .gitignore:");
418            rec_box.add_value_only(&format!("{}. {}", i + 1, clean_rec));
419        }
420        if security_report.recommendations.len() > 5 {
421            rec_box.add_value_only(&format!("... and {} more recommendations", 
422                security_report.recommendations.len() - 5).dimmed());
423        }
424    } else {
425        rec_box.add_value_only("✅ No immediate security concerns detected");
426        rec_box.add_value_only("💡 Consider implementing dependency scanning");
427        rec_box.add_value_only("💡 Review environment variable security practices");
428    }
429    format!("\n{}\n", rec_box.draw())
430}
431
432fn handle_exit_codes(security_report: &SecurityReport) -> ! {
433    let critical_count = security_report.findings_by_severity
434        .get(&TurboSecuritySeverity::Critical)
435        .unwrap_or(&0);
436    let high_count = security_report.findings_by_severity
437        .get(&TurboSecuritySeverity::High)
438        .unwrap_or(&0);
439    
440    if *critical_count > 0 {
441        eprintln!("❌ Critical security issues found. Please address immediately.");
442        std::process::exit(1);
443    } else if *high_count > 0 {
444        eprintln!("âš ī¸  High severity security issues found. Review recommended.");
445        std::process::exit(2);
446    } else {
447        eprintln!("â„šī¸  Security issues found but none are critical or high severity.");
448        std::process::exit(3);
449    }
450}