Skip to main content

syncable_cli/handlers/
vulnerabilities.rs

1use crate::{
2    analyzer::{self, vulnerability::VulnerabilitySeverity},
3    cli::{OutputFormat, SeverityThreshold},
4};
5use std::path::PathBuf;
6
7pub async fn handle_vulnerabilities(
8    path: PathBuf,
9    severity: Option<SeverityThreshold>,
10    format: OutputFormat,
11    output: Option<PathBuf>,
12    quiet: bool,
13) -> crate::Result<()> {
14    let project_path = path.canonicalize().unwrap_or_else(|_| path.clone());
15
16    if !quiet {
17        println!(
18            "šŸ” Scanning for vulnerabilities in: {}",
19            project_path.display()
20        );
21    }
22
23    // Discover all project directories and check vulnerabilities per-directory.
24    // Audit tools (npm audit, cargo audit, etc.) must run inside the directory
25    // that contains the lock file / manifest, not from a parent directory.
26    let parser = analyzer::dependency_parser::DependencyParser::new();
27    let project_dirs = parser.discover_project_dirs(&project_path);
28
29    // Suppress per-directory tool status banners — we'll print progress ourselves
30    // SAFETY: set_var is called on main thread before spawning audit subprocesses
31    let was_quiet = std::env::var("SYNCABLE_QUIET").is_ok();
32    if !was_quiet {
33        unsafe {
34            std::env::set_var("SYNCABLE_QUIET", "1");
35        }
36    }
37
38    // Collect scannable dirs first so we can show progress
39    let mut scannable_dirs = Vec::new();
40    for dir in &project_dirs {
41        let deps = parser.parse_deps_in_dir_standalone(dir)?;
42        if !deps.is_empty() {
43            let langs: Vec<String> = deps.keys().map(|l| format!("{:?}", l)).collect();
44            scannable_dirs.push((dir.clone(), deps, langs));
45        }
46    }
47
48    if !quiet && scannable_dirs.len() > 1 {
49        println!("\nšŸ“¦ Found {} projects to scan\n", scannable_dirs.len());
50    }
51
52    let mut merged_vulnerable_deps = Vec::new();
53    let any_deps_found = !scannable_dirs.is_empty();
54    let total_dirs = scannable_dirs.len();
55
56    for (i, (dir, deps, langs)) in scannable_dirs.into_iter().enumerate() {
57        let dir_name = dir
58            .strip_prefix(&project_path)
59            .unwrap_or(&dir)
60            .display()
61            .to_string();
62        let dir_label = if dir_name.is_empty() || dir_name == "." {
63            ".".to_string()
64        } else {
65            dir_name
66        };
67
68        if !quiet {
69            println!(
70                "  [{}/{}] Scanning {} ({})",
71                i + 1,
72                total_dirs,
73                dir_label,
74                langs.join(", ")
75            );
76        }
77
78        let checker = analyzer::vulnerability::VulnerabilityChecker::new();
79        match checker.check_all_dependencies(&deps, &dir).await {
80            Ok(report) => {
81                let count = report
82                    .vulnerable_dependencies
83                    .iter()
84                    .map(|d| d.vulnerabilities.len())
85                    .sum::<usize>();
86                if !quiet && count > 0 {
87                    println!("    āš ļø  {} vulnerabilities found", count);
88                }
89                // Tag each vulnerable dep with its source directory
90                for mut dep in report.vulnerable_dependencies {
91                    dep.source_dir = Some(dir_label.clone());
92                    merged_vulnerable_deps.push(dep);
93                }
94            }
95            Err(e) => {
96                if !quiet {
97                    eprintln!("    āš ļø  scan failed: {}", e);
98                }
99            }
100        }
101    }
102
103    // Restore env var
104    if !was_quiet {
105        unsafe {
106            std::env::remove_var("SYNCABLE_QUIET");
107        }
108    }
109
110    if !any_deps_found {
111        if !quiet {
112            println!("No dependencies found to check.");
113        }
114        return Ok(());
115    }
116
117    // Deduplicate vulnerable deps (same package may appear in multiple dirs)
118    merged_vulnerable_deps.sort_by(|a, b| a.name.cmp(&b.name));
119    merged_vulnerable_deps.dedup_by(|a, b| a.name == b.name && a.version == b.version);
120
121    // Build merged report
122    let mut critical_count = 0;
123    let mut high_count = 0;
124    let mut medium_count = 0;
125    let mut low_count = 0;
126    let mut total_vulnerabilities = 0;
127
128    for dep in &merged_vulnerable_deps {
129        for vuln in &dep.vulnerabilities {
130            total_vulnerabilities += 1;
131            match vuln.severity {
132                VulnerabilitySeverity::Critical => critical_count += 1,
133                VulnerabilitySeverity::High => high_count += 1,
134                VulnerabilitySeverity::Medium => medium_count += 1,
135                VulnerabilitySeverity::Low => low_count += 1,
136                VulnerabilitySeverity::Info => {}
137            }
138        }
139    }
140
141    let report = analyzer::vulnerability::VulnerabilityReport {
142        checked_at: chrono::Utc::now(),
143        total_vulnerabilities,
144        critical_count,
145        high_count,
146        medium_count,
147        low_count,
148        vulnerable_dependencies: merged_vulnerable_deps,
149    };
150
151    // Filter by severity if requested
152    let filtered_report = if let Some(threshold) = severity {
153        filter_vulnerabilities_by_severity(report, threshold)
154    } else {
155        report
156    };
157
158    // Format output
159    let output_string = match format {
160        OutputFormat::Table => {
161            format_vulnerabilities_table(&filtered_report, &severity, &project_path)
162        }
163        OutputFormat::Json => serde_json::to_string_pretty(&filtered_report)?,
164    };
165
166    // Output results
167    if let Some(output_path) = output {
168        std::fs::write(&output_path, output_string)?;
169        if !quiet {
170            println!("Report saved to: {}", output_path.display());
171        }
172    } else if !quiet {
173        println!("{}", output_string);
174    }
175
176    // Exit with non-zero code if critical/high vulnerabilities found (skip in quiet/agent mode)
177    if !quiet && (filtered_report.critical_count > 0 || filtered_report.high_count > 0) {
178        std::process::exit(1);
179    }
180
181    Ok(())
182}
183
184fn filter_vulnerabilities_by_severity(
185    report: analyzer::vulnerability::VulnerabilityReport,
186    threshold: SeverityThreshold,
187) -> analyzer::vulnerability::VulnerabilityReport {
188    let min_severity = match threshold {
189        SeverityThreshold::Low => VulnerabilitySeverity::Low,
190        SeverityThreshold::Medium => VulnerabilitySeverity::Medium,
191        SeverityThreshold::High => VulnerabilitySeverity::High,
192        SeverityThreshold::Critical => VulnerabilitySeverity::Critical,
193    };
194
195    let filtered_deps: Vec<_> = report
196        .vulnerable_dependencies
197        .into_iter()
198        .filter_map(|mut dep| {
199            dep.vulnerabilities.retain(|v| v.severity >= min_severity);
200            if dep.vulnerabilities.is_empty() {
201                None
202            } else {
203                Some(dep)
204            }
205        })
206        .collect();
207
208    use analyzer::vulnerability::VulnerabilityReport;
209    let mut filtered = VulnerabilityReport {
210        checked_at: report.checked_at,
211        total_vulnerabilities: 0,
212        critical_count: 0,
213        high_count: 0,
214        medium_count: 0,
215        low_count: 0,
216        vulnerable_dependencies: filtered_deps,
217    };
218
219    // Recalculate counts
220    for dep in &filtered.vulnerable_dependencies {
221        for vuln in &dep.vulnerabilities {
222            filtered.total_vulnerabilities += 1;
223            match vuln.severity {
224                VulnerabilitySeverity::Critical => filtered.critical_count += 1,
225                VulnerabilitySeverity::High => filtered.high_count += 1,
226                VulnerabilitySeverity::Medium => filtered.medium_count += 1,
227                VulnerabilitySeverity::Low => filtered.low_count += 1,
228                VulnerabilitySeverity::Info => {}
229            }
230        }
231    }
232
233    filtered
234}
235
236fn format_vulnerabilities_table(
237    report: &analyzer::vulnerability::VulnerabilityReport,
238    severity: &Option<SeverityThreshold>,
239    project_path: &std::path::Path,
240) -> String {
241    let mut output = String::new();
242
243    output.push_str("\nšŸ›”ļø  Vulnerability Scan Report\n");
244    output.push_str(&format!("{}\n", "=".repeat(80)));
245    output.push_str(&format!(
246        "Scanned at: {}\n",
247        report.checked_at.format("%Y-%m-%d %H:%M:%S UTC")
248    ));
249    output.push_str(&format!("Path: {}\n", project_path.display()));
250
251    if let Some(threshold) = severity {
252        output.push_str(&format!("Severity filter: >= {:?}\n", threshold));
253    }
254
255    output.push_str("\nSummary:\n");
256    output.push_str(&format!(
257        "Total vulnerabilities: {}\n",
258        report.total_vulnerabilities
259    ));
260
261    if report.total_vulnerabilities > 0 {
262        output.push_str("\nBy Severity:\n");
263        if report.critical_count > 0 {
264            output.push_str(&format!("  šŸ”“ CRITICAL: {}\n", report.critical_count));
265        }
266        if report.high_count > 0 {
267            output.push_str(&format!("  šŸ”“ HIGH: {}\n", report.high_count));
268        }
269        if report.medium_count > 0 {
270            output.push_str(&format!("  🟔 MEDIUM: {}\n", report.medium_count));
271        }
272        if report.low_count > 0 {
273            output.push_str(&format!("  šŸ”µ LOW: {}\n", report.low_count));
274        }
275
276        output.push_str(&format!("\n{}\n", "-".repeat(80)));
277        output.push_str("Vulnerable Dependencies:\n\n");
278
279        for vuln_dep in &report.vulnerable_dependencies {
280            let source = vuln_dep.source_dir.as_deref().unwrap_or(".");
281            output.push_str(&format!(
282                "šŸ“¦ {} v{} ({}) [{}]\n",
283                vuln_dep.name,
284                vuln_dep.version,
285                vuln_dep.language.as_str(),
286                source,
287            ));
288
289            for vuln in &vuln_dep.vulnerabilities {
290                let severity_str = match vuln.severity {
291                    VulnerabilitySeverity::Critical => "CRITICAL",
292                    VulnerabilitySeverity::High => "HIGH",
293                    VulnerabilitySeverity::Medium => "MEDIUM",
294                    VulnerabilitySeverity::Low => "LOW",
295                    VulnerabilitySeverity::Info => "INFO",
296                };
297
298                output.push_str(&format!("\n  āš ļø  {} [{}]\n", vuln.id, severity_str));
299                output.push_str(&format!("     {}\n", vuln.title));
300
301                if !vuln.description.is_empty() && vuln.description != vuln.title {
302                    // Wrap description
303                    let wrapped = textwrap::fill(&vuln.description, 70);
304                    for line in wrapped.lines() {
305                        output.push_str(&format!("     {}\n", line));
306                    }
307                }
308
309                if let Some(ref cve) = vuln.cve {
310                    output.push_str(&format!("     CVE: {}\n", cve));
311                }
312
313                if let Some(ref ghsa) = vuln.ghsa {
314                    output.push_str(&format!("     GHSA: {}\n", ghsa));
315                }
316
317                output.push_str(&format!("     Affected: {}\n", vuln.affected_versions));
318
319                if let Some(ref patched) = vuln.patched_versions {
320                    output.push_str(&format!("     āœ… Fix: Upgrade to {}\n", patched));
321                }
322            }
323            output.push('\n');
324        }
325    } else {
326        output.push_str("\nāœ… No vulnerabilities found!\n");
327    }
328
329    output
330}