Skip to main content

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