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(security_report.files_scanned));
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    score_box.add_line("Files Scanned:", &security_report.files_scanned.to_string().green(), true);
180    score_box.add_line("Scan Mode:", &format!("{:?}", scan_mode).green(), true);
181    
182    format!("\n{}\n", score_box.draw())
183}
184
185fn format_security_findings_box(
186    security_report: &SecurityReport,
187    project_path: &std::path::Path,
188) -> String {
189    // Get terminal width to determine optimal display width
190    let terminal_width = if let Some((width, _)) = term_size::dimensions() {
191        width.saturating_sub(10) // Leave some margin
192    } else {
193        120 // Fallback width
194    };
195    
196    let mut findings_box = BoxDrawer::new("Security Findings");
197    
198    for (i, finding) in security_report.findings.iter().enumerate() {
199        let severity_color = match finding.severity {
200            TurboSecuritySeverity::Critical => "bright_red",
201            TurboSecuritySeverity::High => "red",
202            TurboSecuritySeverity::Medium => "yellow", 
203            TurboSecuritySeverity::Low => "blue",
204            TurboSecuritySeverity::Info => "green",
205        };
206        
207        // Extract relative file path from project root
208        let file_display = calculate_relative_path(finding.file_path.as_ref(), project_path);
209        
210        // Parse gitignore status from description
211        let gitignore_status = determine_gitignore_status(&finding.description);
212        
213        // Determine finding type
214        let finding_type = determine_finding_type(&finding.title);
215        
216        // Format position
217        let position_display = format_position(finding.line_number, finding.column_number);
218        
219        // Display file path with intelligent wrapping
220        format_file_path(&mut findings_box, i + 1, &file_display, terminal_width);
221        
222        findings_box.add_value_only(&format!("   {} {} | {} {} | {} {} | {} {}", 
223            "Type:".dimmed(),
224            finding_type.yellow(),
225            "Severity:".dimmed(),
226            format!("{:?}", finding.severity).color(severity_color).bold(),
227            "Position:".dimmed(),
228            position_display.bright_cyan(),
229            "Status:".dimmed(),
230            gitignore_status
231        ));
232        
233        // Add spacing between findings (except for the last one)
234        if i < security_report.findings.len() - 1 {
235            findings_box.add_value_only("");
236        }
237    }
238    
239    format!("\n{}\n", findings_box.draw())
240}
241
242fn calculate_relative_path(file_path: Option<&PathBuf>, project_path: &std::path::Path) -> String {
243    if let Some(file_path) = file_path {
244        // Cross-platform path normalization
245        let canonical_file = file_path.canonicalize().unwrap_or_else(|_| file_path.clone());
246        let canonical_project = project_path.canonicalize().unwrap_or_else(|_| project_path.to_path_buf());
247        
248        // Try to calculate relative path from project root
249        if let Ok(relative_path) = canonical_file.strip_prefix(&canonical_project) {
250            // Use forward slashes for consistency across platforms
251            let relative_str = relative_path.to_string_lossy().replace('\\', "/");
252            format!("./{}", relative_str)
253        } else {
254            // Fallback logic for complex paths
255            format_fallback_path(file_path, project_path)
256        }
257    } else {
258        "N/A".to_string()
259    }
260}
261
262fn format_fallback_path(file_path: &PathBuf, project_path: &std::path::Path) -> String {
263    let path_str = file_path.to_string_lossy();
264    if path_str.starts_with('/') {
265        // For absolute paths, try to extract meaningful relative portion
266        if let Some(project_name) = project_path.file_name().and_then(|n| n.to_str()) {
267            if let Some(project_idx) = path_str.rfind(project_name) {
268                let relative_part = &path_str[project_idx + project_name.len()..];
269                if relative_part.starts_with('/') {
270                    format!(".{}", relative_part)
271                } else if !relative_part.is_empty() {
272                    format!("./{}", relative_part)
273                } else {
274                    format!("./{}", file_path.file_name().unwrap_or_default().to_string_lossy())
275                }
276            } else {
277                path_str.to_string()
278            }
279        } else {
280            path_str.to_string()
281        }
282    } else {
283        // For relative paths that don't strip properly, use as-is
284        if path_str.starts_with("./") {
285            path_str.to_string()
286        } else {
287            format!("./{}", path_str)
288        }
289    }
290}
291
292fn determine_gitignore_status(description: &str) -> ColoredString {
293    if description.contains("is tracked by git") {
294        "TRACKED".bright_red().bold()
295    } else if description.contains("is NOT in .gitignore") {
296        "EXPOSED".yellow().bold()
297    } else if description.contains("is protected") || description.contains("properly ignored") {
298        "SAFE".bright_green().bold()
299    } else if description.contains("appears safe") {
300        "OK".bright_blue().bold()
301    } else {
302        "UNKNOWN".dimmed()
303    }
304}
305
306fn determine_finding_type(title: &str) -> &'static str {
307    if title.contains("Environment Variable") {
308        "ENV VAR"
309    } else if title.contains("Secret File") {
310        "SECRET FILE"
311    } else if title.contains("API Key") || title.contains("Stripe") || title.contains("Firebase") {
312        "API KEY"
313    } else if title.contains("Configuration") {
314        "CONFIG"
315    } else {
316        "OTHER"
317    }
318}
319
320fn format_position(line_number: Option<usize>, column_number: Option<usize>) -> String {
321    match (line_number, column_number) {
322        (Some(line), Some(col)) => format!("{}:{}", line, col),
323        (Some(line), None) => format!("{}", line),
324        _ => "—".to_string(),
325    }
326}
327
328fn format_file_path(findings_box: &mut BoxDrawer, index: usize, file_display: &str, terminal_width: usize) {
329    let box_margin = 6; // Account for box borders and padding
330    let available_width = terminal_width.saturating_sub(box_margin);
331    let max_path_width = available_width.saturating_sub(20); // Leave space for numbering and spacing
332    
333    if file_display.len() + 3 <= max_path_width {
334        // Path fits on one line with numbering
335        findings_box.add_value_only(&format!("{}. {}", 
336            format!("{}", index).bright_white().bold(),
337            file_display.cyan().bold()
338        ));
339    } else if file_display.len() <= available_width.saturating_sub(4) {
340        // Path fits on its own line with indentation
341        findings_box.add_value_only(&format!("{}.", 
342            format!("{}", index).bright_white().bold()
343        ));
344        findings_box.add_value_only(&format!("   {}", 
345            file_display.cyan().bold()
346        ));
347    } else {
348        // Path is extremely long - use smart wrapping
349        format_long_path(findings_box, index, file_display, available_width);
350    }
351}
352
353fn format_long_path(findings_box: &mut BoxDrawer, index: usize, file_display: &str, available_width: usize) {
354    findings_box.add_value_only(&format!("{}.", 
355        format!("{}", index).bright_white().bold()
356    ));
357    
358    // Smart path wrapping - prefer breaking at directory separators
359    let wrap_width = available_width.saturating_sub(4);
360    let mut remaining = file_display;
361    let mut first_line = true;
362    
363    while !remaining.is_empty() {
364        let prefix = if first_line { "   " } else { "     " };
365        let line_width = wrap_width.saturating_sub(prefix.len());
366        
367        if remaining.len() <= line_width {
368            // Last chunk fits entirely
369            findings_box.add_value_only(&format!("{}{}", 
370                prefix, remaining.cyan().bold()
371            ));
372            break;
373        } else {
374            // Find a good break point (prefer directory separator)
375            let chunk = &remaining[..line_width];
376            let break_point = chunk.rfind('/').unwrap_or(line_width.saturating_sub(1));
377            
378            findings_box.add_value_only(&format!("{}{}", 
379                prefix, chunk[..break_point].cyan().bold()
380            ));
381            remaining = &remaining[break_point..];
382            if remaining.starts_with('/') {
383                remaining = &remaining[1..]; // Skip the separator
384            }
385        }
386        first_line = false;
387    }
388}
389
390fn format_gitignore_legend() -> String {
391    let mut legend_box = BoxDrawer::new("Git Status Legend");
392    legend_box.add_line(&"TRACKED:".bright_red().bold().to_string(), "File is tracked by git - CRITICAL RISK", false);
393    legend_box.add_line(&"EXPOSED:".yellow().bold().to_string(), "File contains secrets but not in .gitignore", false);
394    legend_box.add_line(&"SAFE:".bright_green().bold().to_string(), "File is properly ignored by .gitignore", false);
395    legend_box.add_line(&"OK:".bright_blue().bold().to_string(), "File appears safe for version control", false);
396    format!("\n{}\n", legend_box.draw())
397}
398
399fn format_no_findings_box(files_scanned: usize) -> String {
400    let mut no_findings_box = BoxDrawer::new("Security Status");
401    if files_scanned == 0 {
402        no_findings_box.add_value_only(&"âš ī¸  No files were scanned".yellow());
403        no_findings_box.add_value_only("This may indicate that all files were filtered out or the scan failed.");
404        no_findings_box.add_value_only("💡 Try running with --mode thorough or --mode paranoid for a deeper scan");
405    } else {
406        no_findings_box.add_value_only(&"✅ No security issues detected".green());
407        no_findings_box.add_value_only("💡 Regular security scanning recommended");
408    }
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}