syncable_cli/handlers/
security.rs

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