syncable_cli/analyzer/
tool_installer.rs

1use crate::analyzer::dependency_parser::Language;
2use crate::error::{AnalysisError, IaCGeneratorError, Result};
3use log::{info, warn, debug};
4use std::process::Command;
5use std::collections::HashMap;
6
7/// Tool installer for vulnerability scanning dependencies
8pub struct ToolInstaller {
9    installed_tools: HashMap<String, bool>,
10}
11
12impl ToolInstaller {
13    pub fn new() -> Self {
14        Self {
15            installed_tools: HashMap::new(),
16        }
17    }
18    
19    /// Ensure all required tools for vulnerability scanning are available
20    pub fn ensure_tools_for_languages(&mut self, languages: &[Language]) -> Result<()> {
21        for language in languages {
22            match language {
23                Language::Rust => self.ensure_cargo_audit()?,
24                Language::JavaScript | Language::TypeScript => self.ensure_npm()?,
25                Language::Python => self.ensure_pip_audit()?,
26                Language::Go => self.ensure_govulncheck()?,
27                Language::Java | Language::Kotlin => self.ensure_grype()?,
28                _ => {} // Unknown languages don't need tools
29            }
30        }
31        Ok(())
32    }
33    
34    /// Check if cargo-audit is installed, install if needed
35    fn ensure_cargo_audit(&mut self) -> Result<()> {
36        if self.is_tool_installed("cargo-audit") {
37            return Ok(());
38        }
39        
40        info!("šŸ”§ Installing cargo-audit for Rust vulnerability scanning...");
41        
42        let output = Command::new("cargo")
43            .args(&["install", "cargo-audit"])
44            .output()
45            .map_err(|e| IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
46                file: "cargo-audit installation".to_string(),
47                reason: format!("Failed to install cargo-audit: {}", e),
48            }))?;
49        
50        if output.status.success() {
51            info!("āœ… cargo-audit installed successfully");
52            self.installed_tools.insert("cargo-audit".to_string(), true);
53        } else {
54            let stderr = String::from_utf8_lossy(&output.stderr);
55            warn!("āŒ Failed to install cargo-audit: {}", stderr);
56            return Err(IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
57                file: "cargo-audit installation".to_string(),
58                reason: format!("Installation failed: {}", stderr),
59            }));
60        }
61        
62        Ok(())
63    }
64    
65    /// Check if npm is available (comes with Node.js)
66    fn ensure_npm(&mut self) -> Result<()> {
67        if self.is_tool_installed("npm") {
68            return Ok(());
69        }
70        
71        warn!("šŸ“¦ npm not found. Please install Node.js from https://nodejs.org/");
72        warn!("   npm audit is required for JavaScript/TypeScript vulnerability scanning");
73        
74        Ok(()) // Don't fail, just warn
75    }
76    
77    /// Check if pip-audit is installed, install if needed
78    fn ensure_pip_audit(&mut self) -> Result<()> {
79        if self.is_tool_installed("pip-audit") {
80            return Ok(());
81        }
82        
83        info!("šŸ”§ Installing pip-audit for Python vulnerability scanning...");
84        
85        // Try different installation methods
86        let install_commands = vec![
87            vec!["pipx", "install", "pip-audit"],
88            vec!["pip3", "install", "--user", "pip-audit"],
89            vec!["pip", "install", "--user", "pip-audit"],
90        ];
91        
92        for cmd in install_commands {
93            debug!("Trying installation command: {:?}", cmd);
94            
95            let output = Command::new(&cmd[0])
96                .args(&cmd[1..])
97                .output();
98                
99            if let Ok(result) = output {
100                if result.status.success() {
101                    info!("āœ… pip-audit installed successfully using {}", cmd[0]);
102                    self.installed_tools.insert("pip-audit".to_string(), true);
103                    return Ok(());
104                }
105            }
106        }
107        
108        warn!("šŸ“¦ Failed to auto-install pip-audit. Please install manually:");
109        warn!("   Option 1: pipx install pip-audit");
110        warn!("   Option 2: pip3 install --user pip-audit");
111        
112        Ok(()) // Don't fail, just warn
113    }
114    
115    /// Check if govulncheck is installed, install if needed
116    fn ensure_govulncheck(&mut self) -> Result<()> {
117        if self.is_tool_installed("govulncheck") {
118            return Ok(());
119        }
120        
121        info!("šŸ”§ Installing govulncheck for Go vulnerability scanning...");
122        
123        let output = Command::new("go")
124            .args(&["install", "golang.org/x/vuln/cmd/govulncheck@latest"])
125            .output()
126            .map_err(|e| IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
127                file: "govulncheck installation".to_string(),
128                reason: format!("Failed to install govulncheck (is Go installed?): {}", e),
129            }))?;
130        
131        if output.status.success() {
132            info!("āœ… govulncheck installed successfully");
133            self.installed_tools.insert("govulncheck".to_string(), true);
134            
135            // Also add Go bin directory to PATH hint
136            info!("šŸ’” Note: Make sure ~/go/bin is in your PATH to use govulncheck");
137        } else {
138            let stderr = String::from_utf8_lossy(&output.stderr);
139            warn!("āŒ Failed to install govulncheck: {}", stderr);
140            warn!("šŸ“¦ Please install Go from https://golang.org/ first");
141        }
142        
143        Ok(())
144    }
145    
146    /// Check if Grype is available, install if possible
147    fn ensure_grype(&mut self) -> Result<()> {
148        if self.is_tool_installed("grype") {
149            return Ok(());
150        }
151        
152        info!("šŸ”§ Installing grype for vulnerability scanning...");
153        
154        // Detect platform and architecture
155        let os = std::env::consts::OS;
156        let arch = std::env::consts::ARCH;
157        
158        // Try platform-specific installation methods
159        match os {
160            "macos" => {
161                // Try to install with Homebrew
162                let output = Command::new("brew")
163                    .args(&["install", "grype"])
164                    .output();
165                    
166                match output {
167                    Ok(result) if result.status.success() => {
168                        info!("āœ… grype installed successfully via Homebrew");
169                        self.installed_tools.insert("grype".to_string(), true);
170                        return Ok(());
171                    }
172                    _ => {
173                        warn!("āŒ Failed to install via Homebrew. Trying manual installation...");
174                    }
175                }
176            }
177            _ => {}
178        }
179        
180        // Try manual installation via curl
181        self.install_grype_manually(os, arch)
182    }
183    
184    /// Install grype manually by downloading from GitHub releases
185    fn install_grype_manually(&mut self, os: &str, arch: &str) -> Result<()> {
186        use std::fs;
187        use std::path::PathBuf;
188        
189        info!("šŸ“„ Downloading grype from GitHub releases...");
190        
191        let version = "v0.92.2"; // Latest stable version
192        let home_dir = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
193        let bin_dir = PathBuf::from(&home_dir).join(".local").join("bin");
194        
195        // Create bin directory
196        fs::create_dir_all(&bin_dir).map_err(|e| {
197            IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
198                file: "grype installation".to_string(),
199                reason: format!("Failed to create directory: {}", e),
200            })
201        })?;
202        
203        // Determine the correct binary name based on OS and architecture
204        let (os_name, arch_name) = match (os, arch) {
205            ("macos", "x86_64") => ("darwin", "amd64"),
206            ("macos", "aarch64") => ("darwin", "arm64"),
207            ("linux", "x86_64") => ("linux", "amd64"),
208            ("linux", "aarch64") => ("linux", "arm64"),
209            _ => {
210                warn!("āŒ Unsupported platform: {} {}", os, arch);
211                return Ok(());
212            }
213        };
214        
215        let archive_name = format!("grype_{}_{}.tar.gz", os_name, arch_name);
216        let download_url = format!(
217            "https://github.com/anchore/grype/releases/download/{}/grype_{}_{}_{}.tar.gz",
218            version, version.trim_start_matches('v'), os_name, arch_name
219        );
220        
221        let archive_path = bin_dir.join(&archive_name);
222        
223        info!("šŸ“¦ Downloading from: {}", download_url);
224        let output = Command::new("curl")
225            .args(&["-L", "-o", archive_path.to_str().unwrap(), &download_url])
226            .output();
227            
228        match output {
229            Ok(result) if result.status.success() => {
230                info!("āœ… Download complete. Extracting...");
231                
232                // Extract the archive
233                let extract_output = Command::new("tar")
234                    .args(&["-xzf", archive_path.to_str().unwrap(), "-C", bin_dir.to_str().unwrap()])
235                    .output();
236                    
237                if extract_output.map(|o| o.status.success()).unwrap_or(false) {
238                    // Make it executable
239                    let grype_path = bin_dir.join("grype");
240                    Command::new("chmod")
241                        .args(&["+x", grype_path.to_str().unwrap()])
242                        .output()
243                        .ok();
244                    
245                    info!("āœ… grype installed successfully to {}", bin_dir.display());
246                    info!("šŸ’” Make sure ~/.local/bin is in your PATH");
247                    self.installed_tools.insert("grype".to_string(), true);
248                    
249                    // Clean up archive
250                    fs::remove_file(&archive_path).ok();
251                    
252                    return Ok(());
253                }
254            }
255            _ => {}
256        }
257        
258        warn!("āŒ Automatic installation failed. Please install manually:");
259        warn!("   • macOS: brew install grype");
260        warn!("   • Download: https://github.com/anchore/grype/releases");
261        
262        Ok(())
263    }
264    
265    /// Check if OWASP dependency-check is available, install if possible
266    fn ensure_dependency_check(&mut self) -> Result<()> {
267        if self.is_tool_installed("dependency-check") {
268            return Ok(());
269        }
270        
271        info!("šŸ”§ Installing dependency-check for Java/Kotlin vulnerability scanning...");
272        
273        // Detect platform and try to install
274        let os = std::env::consts::OS;
275        
276        match os {
277            "macos" => {
278                // Try to install with Homebrew
279                let output = Command::new("brew")
280                    .args(&["install", "dependency-check"])
281                    .output();
282                    
283                match output {
284                    Ok(result) if result.status.success() => {
285                        info!("āœ… dependency-check installed successfully via Homebrew");
286                        self.installed_tools.insert("dependency-check".to_string(), true);
287                        return Ok(());
288                    }
289                    _ => {
290                        warn!("āŒ Failed to install via Homebrew. Trying manual installation...");
291                    }
292                }
293            }
294            "linux" => {
295                // Try to install via snap
296                let output = Command::new("snap")
297                    .args(&["install", "dependency-check"])
298                    .output();
299                    
300                if output.map(|o| o.status.success()).unwrap_or(false) {
301                    info!("āœ… dependency-check installed successfully via snap");
302                    self.installed_tools.insert("dependency-check".to_string(), true);
303                    return Ok(());
304                }
305            }
306            _ => {}
307        }
308        
309        // Try manual installation
310        self.install_dependency_check_manually()
311    }
312    
313    /// Install dependency-check manually by downloading from GitHub
314    fn install_dependency_check_manually(&mut self) -> Result<()> {
315        use std::fs;
316        use std::path::PathBuf;
317        
318        info!("šŸ“„ Downloading dependency-check from GitHub releases...");
319        
320        let version = "10.0.4"; // Latest stable version
321        let home_dir = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
322        let install_dir = PathBuf::from(&home_dir).join(".local").join("dependency-check");
323        
324        // Create installation directory
325        fs::create_dir_all(&install_dir).map_err(|e| {
326            IaCGeneratorError::Analysis(AnalysisError::DependencyParsing {
327                file: "dependency-check installation".to_string(),
328                reason: format!("Failed to create directory: {}", e),
329            })
330        })?;
331        
332        let archive_name = format!("dependency-check-{}-release.zip", version);
333        let download_url = format!(
334            "https://github.com/jeremylong/DependencyCheck/releases/download/v{}/{}",
335            version, archive_name
336        );
337        
338        // Download the archive
339        let archive_path = install_dir.join(&archive_name);
340        
341        info!("šŸ“¦ Downloading from: {}", download_url);
342        let output = Command::new("curl")
343            .args(&["-L", "-o", archive_path.to_str().unwrap(), &download_url])
344            .output();
345            
346        match output {
347            Ok(result) if result.status.success() => {
348                info!("āœ… Download complete. Extracting...");
349                
350                // Extract the archive
351                let extract_output = Command::new("unzip")
352                    .args(&["-o", archive_path.to_str().unwrap(), "-d", install_dir.to_str().unwrap()])
353                    .output();
354                    
355                if extract_output.map(|o| o.status.success()).unwrap_or(false) {
356                    // Create symlink to make it available in PATH
357                    let bin_dir = PathBuf::from(&home_dir).join(".local").join("bin");
358                    fs::create_dir_all(&bin_dir).ok();
359                    
360                    let dc_script = install_dir.join("dependency-check").join("bin").join("dependency-check.sh");
361                    let symlink = bin_dir.join("dependency-check");
362                    
363                    // Remove old symlink if exists
364                    fs::remove_file(&symlink).ok();
365                    
366                    // Create new symlink
367                    if std::os::unix::fs::symlink(&dc_script, &symlink).is_ok() {
368                        info!("āœ… dependency-check installed successfully to {}", install_dir.display());
369                        info!("šŸ’” Added to ~/.local/bin/dependency-check");
370                        info!("šŸ’” Make sure ~/.local/bin is in your PATH");
371                        self.installed_tools.insert("dependency-check".to_string(), true);
372                        return Ok(());
373                    }
374                }
375            }
376            _ => {}
377        }
378        
379        warn!("āŒ Automatic installation failed. Please install manually:");
380        warn!("   • macOS: brew install dependency-check");
381        warn!("   • Download: https://github.com/jeremylong/DependencyCheck/releases");
382        warn!("   • Documentation: https://owasp.org/www-project-dependency-check/");
383        
384        Ok(())
385    }
386    
387    /// Check if a command-line tool is available
388    fn is_tool_installed(&mut self, tool: &str) -> bool {
389        // Check cache first
390        if let Some(&cached) = self.installed_tools.get(tool) {
391            return cached;
392        }
393        
394        // Test if tool is available
395        let available = self.test_tool_availability(tool);
396        self.installed_tools.insert(tool.to_string(), available);
397        available
398    }
399    
400    /// Test if a tool is available by running --version
401    fn test_tool_availability(&self, tool: &str) -> bool {
402        let test_commands = match tool {
403            "cargo-audit" => vec!["cargo", "audit", "--version"],
404            "npm" => vec!["npm", "--version"],
405            "pip-audit" => vec!["pip-audit", "--version"],
406            "govulncheck" => vec!["govulncheck", "-version"],
407            "dependency-check" => vec!["dependency-check", "--version"],
408            "grype" => vec!["grype", "version"],
409            _ => return false,
410        };
411        
412        let result = Command::new(&test_commands[0])
413            .args(&test_commands[1..])
414            .output();
415            
416        match result {
417            Ok(output) => output.status.success(),
418            Err(_) => {
419                // Try with ~/go/bin prefix for Go tools
420                if tool == "govulncheck" {
421                    let go_bin_path = std::env::var("HOME")
422                        .map(|home| format!("{}/go/bin/govulncheck", home))
423                        .unwrap_or_else(|_| "govulncheck".to_string());
424                        
425                    return Command::new(&go_bin_path)
426                        .arg("-version")
427                        .output()
428                        .map(|out| out.status.success())
429                        .unwrap_or(false);
430                }
431                
432                // Try with ~/.local/bin prefix for dependency-check
433                if tool == "dependency-check" {
434                    let dc_path = std::env::var("HOME")
435                        .map(|home| format!("{}/.local/bin/dependency-check", home))
436                        .unwrap_or_else(|_| "dependency-check".to_string());
437                        
438                    return Command::new(&dc_path)
439                        .arg("--version")
440                        .output()
441                        .map(|out| out.status.success())
442                        .unwrap_or(false);
443                }
444                
445                // Try with ~/.local/bin prefix for grype
446                if tool == "grype" {
447                    let grype_path = std::env::var("HOME")
448                        .map(|home| format!("{}/.local/bin/grype", home))
449                        .unwrap_or_else(|_| "grype".to_string());
450                        
451                    return Command::new(&grype_path)
452                        .arg("version")
453                        .output()
454                        .map(|out| out.status.success())
455                        .unwrap_or(false);
456                }
457                
458                false
459            }
460        }
461    }
462    
463    /// Get installation status summary
464    pub fn get_tool_status(&self) -> HashMap<String, bool> {
465        self.installed_tools.clone()
466    }
467    
468    /// Print tool installation status
469    pub fn print_tool_status(&self, languages: &[Language]) {
470        println!("\nšŸ”§ Vulnerability Scanning Tools Status:");
471        println!("{}", "=".repeat(50));
472        
473        for language in languages {
474            let (tool, status) = match language {
475                Language::Rust => ("cargo-audit", self.installed_tools.get("cargo-audit").unwrap_or(&false)),
476                Language::JavaScript | Language::TypeScript => ("npm", self.installed_tools.get("npm").unwrap_or(&false)),
477                Language::Python => ("pip-audit", self.installed_tools.get("pip-audit").unwrap_or(&false)),
478                Language::Go => ("govulncheck", self.installed_tools.get("govulncheck").unwrap_or(&false)),
479                Language::Java | Language::Kotlin => ("grype", self.installed_tools.get("grype").unwrap_or(&false)),
480                _ => continue,
481            };
482            
483            let status_icon = if *status { "āœ…" } else { "āŒ" };
484            println!("  {} {:?}: {} {}", status_icon, language, tool, if *status { "installed" } else { "missing" });
485        }
486        println!();
487    }
488}