syncable-cli 0.37.1

A Rust-based CLI that analyzes code repositories and generates Infrastructure as Code configurations
Documentation
use log::{info, warn};
use std::path::Path;
use std::process::Command;

use super::LanguageVulnerabilityChecker;
use crate::analyzer::dependency_parser::{DependencyInfo, Language};
use crate::analyzer::tool_management::ToolDetector;
use crate::analyzer::vulnerability::{
    VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity, VulnerableDependency,
};

pub struct RustVulnerabilityChecker;

impl Default for RustVulnerabilityChecker {
    fn default() -> Self {
        Self
    }
}

impl RustVulnerabilityChecker {
    pub fn new() -> Self {
        Self
    }
}

impl LanguageVulnerabilityChecker for RustVulnerabilityChecker {
    fn check_vulnerabilities(
        &self,
        dependencies: &[DependencyInfo],
        _project_path: &Path,
    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
        info!("Checking Rust dependencies with cargo-audit");

        // Check if cargo-audit is installed
        let mut detector = ToolDetector::new();
        let cargo_audit_status = detector.detect_tool("cargo-audit");

        if !cargo_audit_status.available {
            warn!("cargo-audit not installed. Install with: cargo install cargo-audit");
            warn!("Skipping Rust vulnerability checks");
            return Ok(vec![]);
        }

        info!(
            "Using cargo-audit {} at {:?}",
            cargo_audit_status.version.as_deref().unwrap_or("unknown"),
            cargo_audit_status
                .path
                .as_deref()
                .unwrap_or_else(|| std::path::Path::new("cargo-audit"))
        );

        // Run cargo audit in JSON format
        let output = Command::new("cargo")
            .args(["audit", "--json"])
            .output()
            .map_err(|e| {
                VulnerabilityError::CommandError(format!("Failed to run cargo audit: {}", e))
            })?;

        if output.stdout.is_empty() {
            return Ok(vec![]);
        }

        // Parse cargo audit output
        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;

        self.parse_cargo_audit_output(&audit_data, dependencies)
    }
}

impl RustVulnerabilityChecker {
    // Make this method public for testing
    pub fn parse_cargo_audit_output(
        &self,
        audit_data: &serde_json::Value,
        dependencies: &[DependencyInfo],
    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();

        // Process actual vulnerabilities
        if let Some(vulnerabilities) = audit_data
            .get("vulnerabilities")
            .and_then(|v| v.get("list"))
            .and_then(|l| l.as_array())
        {
            self.parse_cargo_audit_vulnerabilities(
                vulnerabilities,
                dependencies,
                &mut vulnerable_deps,
            )?;
        }

        // Process warnings (unmaintained/yanked)
        if let Some(warnings) = audit_data.get("warnings") {
            // Handle unmaintained warnings
            if let Some(unmaintained) = warnings.get("unmaintained").and_then(|w| w.as_array()) {
                self.parse_cargo_audit_warnings(unmaintained, dependencies, &mut vulnerable_deps)?;
            }

            // Handle yanked warnings
            if let Some(yanked) = warnings.get("yanked").and_then(|w| w.as_array()) {
                self.parse_cargo_audit_warnings(yanked, dependencies, &mut vulnerable_deps)?;
            }
        }

        Ok(vulnerable_deps)
    }

    // Make this method public for testing
    pub fn parse_cargo_audit_vulnerabilities(
        &self,
        vulnerabilities: &Vec<serde_json::Value>,
        dependencies: &[DependencyInfo],
        vulnerable_deps: &mut Vec<VulnerableDependency>,
    ) -> Result<(), VulnerabilityError> {
        for vuln in vulnerabilities {
            if let Some(advisory) = vuln.get("advisory") {
                let package_name = advisory
                    .get("package")
                    .and_then(|n| n.as_str())
                    .unwrap_or("");

                let package_version = vuln
                    .get("package")
                    .and_then(|p| p.get("version"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("");

                if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
                    let vuln_info =
                        VulnerabilityInfo {
                            id: advisory
                                .get("id")
                                .and_then(|id| id.as_str())
                                .unwrap_or("unknown")
                                .to_string(),
                            vuln_type: "security".to_string(), // Security vulnerability
                            severity: self.parse_rustsec_severity(
                                advisory.get("severity").and_then(|s| s.as_str()),
                            ),
                            title: advisory
                                .get("title")
                                .and_then(|t| t.as_str())
                                .unwrap_or("Unknown vulnerability")
                                .to_string(),
                            description: advisory
                                .get("description")
                                .and_then(|d| d.as_str())
                                .unwrap_or("")
                                .to_string(),
                            cve: advisory.get("aliases").and_then(|a| a.as_array()).and_then(
                                |arr| {
                                    arr.iter()
                                        .filter_map(|v| v.as_str())
                                        .find(|s| s.starts_with("CVE-"))
                                        .map(|s| s.to_string())
                                },
                            ),
                            ghsa: advisory.get("aliases").and_then(|a| a.as_array()).and_then(
                                |arr| {
                                    arr.iter()
                                        .filter_map(|v| v.as_str())
                                        .find(|s| s.starts_with("GHSA-"))
                                        .map(|s| s.to_string())
                                },
                            ),
                            affected_versions: format!(
                                "< {}",
                                vuln.get("versions")
                                    .and_then(|v| v.get("patched"))
                                    .and_then(|p| p.as_array())
                                    .and_then(|arr| arr.first())
                                    .and_then(|s| s.as_str())
                                    .unwrap_or("unknown")
                            ),
                            patched_versions: vuln
                                .get("versions")
                                .and_then(|v| v.get("patched"))
                                .and_then(|p| p.as_array())
                                .and_then(|arr| arr.first())
                                .and_then(|s| s.as_str())
                                .map(|s| s.to_string()),
                            published_date: advisory
                                .get("date")
                                .and_then(|d| d.as_str())
                                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                                .map(|dt| dt.with_timezone(&chrono::Utc)),
                            references: advisory
                                .get("references")
                                .and_then(|r| r.as_array())
                                .map(|refs| {
                                    refs.iter()
                                        .filter_map(|r| r.as_str().map(|s| s.to_string()))
                                        .collect()
                                })
                                .unwrap_or_default(),
                        };

                    // Check if we already have this dependency
                    if let Some(existing) =
                        vulnerable_deps
                            .iter_mut()
                            .find(|vuln_dep: &&mut VulnerableDependency| {
                                vuln_dep.name == dep.name && vuln_dep.version == package_version
                            })
                    {
                        existing.vulnerabilities.push(vuln_info);
                    } else {
                        vulnerable_deps.push(VulnerableDependency {
                            name: dep.name.clone(),
                            version: package_version.to_string(),
                            language: Language::Rust,
                            vulnerabilities: vec![vuln_info],
                            source_dir: None,
                        });
                    }
                }
            }
        }

        Ok(())
    }

    // Make this method public for testing
    pub fn parse_cargo_audit_warnings(
        &self,
        warnings: &Vec<serde_json::Value>,
        dependencies: &[DependencyInfo],
        vulnerable_deps: &mut Vec<VulnerableDependency>,
    ) -> Result<(), VulnerabilityError> {
        for warning in warnings {
            let kind = warning.get("kind").and_then(|k| k.as_str()).unwrap_or("");

            // Extract package info from the nested structure
            let (package_name, package_version) = if let Some(package_obj) = warning.get("package")
            {
                (
                    package_obj
                        .get("name")
                        .and_then(|n| n.as_str())
                        .unwrap_or("")
                        .to_string(),
                    package_obj
                        .get("version")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string(),
                )
            } else {
                ("".to_string(), "".to_string())
            };

            // Only process unmaintained and yanked warnings
            if (kind == "unmaintained" || kind == "yanked")
                && let Some(dep) = dependencies.iter().find(|d| d.name == package_name)
            {
                let (severity, title, description) = match kind {
                    "unmaintained" => (
                        VulnerabilitySeverity::Low,
                        format!("Unmaintained package: {}", package_name),
                        warning
                            .get("advisory")
                            .and_then(|a| a.get("description"))
                            .and_then(|d| d.as_str())
                            .unwrap_or("Package is unmaintained")
                            .to_string(),
                    ),
                    "yanked" => (
                        VulnerabilitySeverity::Medium,
                        format!("Yanked package: {}", package_name),
                        "Package version has been yanked".to_string(),
                    ),
                    _ => continue, // Should not happen due to the if condition above
                };

                let vuln_info = VulnerabilityInfo {
                    id: format!("{}-{}", kind, package_name),
                    vuln_type: kind.to_string(), // "unmaintained" or "yanked"
                    severity,
                    title,
                    description,
                    cve: None,
                    ghsa: None,
                    affected_versions: package_version.to_string(),
                    patched_versions: None,
                    published_date: None,
                    references: vec![],
                };

                // Check if we already have this dependency
                if let Some(existing) =
                    vulnerable_deps
                        .iter_mut()
                        .find(|vuln_dep: &&mut VulnerableDependency| {
                            vuln_dep.name == dep.name && vuln_dep.version == package_version
                        })
                {
                    existing.vulnerabilities.push(vuln_info);
                } else {
                    vulnerable_deps.push(VulnerableDependency {
                        name: dep.name.clone(),
                        version: package_version.to_string(),
                        language: Language::Rust,
                        vulnerabilities: vec![vuln_info],
                        source_dir: None,
                    });
                }
            }
        }

        Ok(())
    }

    fn parse_rustsec_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
        match severity.map(|s| s.to_lowercase()).as_deref() {
            Some("critical") => VulnerabilitySeverity::Critical,
            Some("high") => VulnerabilitySeverity::High,
            Some("medium") | Some("moderate") => VulnerabilitySeverity::Medium,
            Some("low") => VulnerabilitySeverity::Low,
            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
        }
    }
}