securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use crate::core::{Confidence, Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Instant;
use tokio::process::Command;

#[derive(Debug, Deserialize)]
struct ExternalFinding {
    id: String,
    title: String,
    description: Option<String>,
    severity: String,
    #[serde(default)]
    confidence: Option<String>,
    file_path: Option<String>,
    line_start: Option<usize>,
    line_end: Option<usize>,
    evidence: Option<String>,
    remediation: Option<String>,
    #[serde(default)]
    references: Vec<String>,
    #[serde(default)]
    cwe_ids: Vec<u32>,
}

#[derive(Debug, Deserialize)]
struct ExternalPluginOutput {
    // Deserialized from plugin JSON protocol; not used in code but part of the spec
    #[serde(default)]
    #[allow(dead_code)]
    plugin_name: String,
    #[serde(default)]
    #[allow(dead_code)]
    version: Option<String>,
    findings: Vec<ExternalFinding>,
    #[serde(default)]
    scanned_files: Option<usize>,
    #[serde(default)]
    duration_ms: Option<u64>,
}

pub struct ExternalPlugin {
    name: String,
    executable_path: PathBuf,
    version: String,
    description: String,
    scan_phase: ScanPhase,
}

impl ExternalPlugin {
    pub fn new(
        name: String,
        executable_path: PathBuf,
        description: String,
        scan_phase: ScanPhase,
    ) -> Self {
        Self {
            name,
            executable_path,
            version: "1.0.0".to_string(),
            description,
            scan_phase,
        }
    }

    fn parse_severity(severity_str: &str) -> Severity {
        match severity_str.to_lowercase().as_str() {
            "critical" => Severity::Critical,
            "high" => Severity::High,
            "medium" => Severity::Medium,
            "low" => Severity::Low,
            "info" => Severity::Info,
            _ => Severity::Medium,
        }
    }

    fn parse_confidence(confidence_str: Option<String>) -> Confidence {
        match confidence_str.as_deref() {
            Some("high") => Confidence::High,
            Some("medium") => Confidence::Medium,
            Some("low") => Confidence::Low,
            _ => Confidence::Medium,
        }
    }

    fn convert_finding(&self, external: ExternalFinding, file_path: &std::path::Path) -> Finding {
        let mut finding = Finding::new(
            external.id,
            external.title,
            Self::parse_severity(&external.severity),
        );

        if let Some(desc) = external.description {
            finding = finding.with_description(desc);
        }

        finding.confidence = Self::parse_confidence(external.confidence);

        if let Some(path_str) = external.file_path {
            finding.file_path = Some(PathBuf::from(path_str));
        } else {
            finding.file_path = Some(file_path.to_path_buf());
        }

        finding.line_start = external.line_start.map(|l| l as u32);
        finding.line_end = external.line_end.map(|l| l as u32);

        if let Some(evidence) = external.evidence {
            finding.evidence.push(evidence);
        }

        if let Some(remediation) = external.remediation {
            finding.remediation = Some(remediation);
        }

        finding.references = external.references;
        finding.cwe_ids = external.cwe_ids;

        finding
    }
}

#[async_trait]
impl SecurityPlugin for ExternalPlugin {
    fn name(&self) -> &str {
        &self.name
    }

    fn version(&self) -> &str {
        &self.version
    }

    fn description(&self) -> &str {
        &self.description
    }

    fn scan_phase(&self) -> ScanPhase {
        self.scan_phase
    }

    async fn initialize(&mut self) -> Result<(), PluginError> {
        if !self.executable_path.exists() {
            return Err(PluginError::InitializationFailed(format!(
                "Plugin executable not found: {}",
                self.executable_path.display()
            )));
        }

        if !self.executable_path.is_file() {
            return Err(PluginError::InitializationFailed(format!(
                "Plugin path is not a file: {}",
                self.executable_path.display()
            )));
        }

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let metadata = std::fs::metadata(&self.executable_path)
                .map_err(|e| PluginError::InitializationFailed(e.to_string()))?;
            let permissions = metadata.permissions();
            if permissions.mode() & 0o111 == 0 {
                return Err(PluginError::InitializationFailed(format!(
                    "Plugin is not executable: {}",
                    self.executable_path.display()
                )));
            }
        }

        Ok(())
    }

    async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
        let start = Instant::now();

        let path_str = context
            .path
            .to_str()
            .ok_or_else(|| PluginError::ScanFailed("Invalid path encoding".to_string()))?;

        let child = Command::new(&self.executable_path)
            .arg(path_str)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .map_err(|e| {
                PluginError::ScanFailed(format!("Failed to spawn plugin process: {}", e))
            })?;

        let output = child
            .wait_with_output()
            .await
            .map_err(|e| PluginError::ScanFailed(format!("Failed to read plugin output: {}", e)))?;

        if !output.status.success() && output.stdout.is_empty() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(PluginError::ScanFailed(format!(
                "Plugin exited with error: {}",
                stderr
            )));
        }

        let stdout = String::from_utf8_lossy(&output.stdout);

        let external_output: ExternalPluginOutput = serde_json::from_str(&stdout).map_err(|e| {
            PluginError::ScanFailed(format!(
                "Failed to parse plugin output as JSON: {} - Output: {}",
                e, stdout
            ))
        })?;

        let findings: Vec<Finding> = external_output
            .findings
            .into_iter()
            .map(|f| self.convert_finding(f, context.path))
            .collect();

        let duration_ms = start.elapsed().as_millis() as u64;

        let mut report = PluginReport::new(self.name.clone());
        report.findings = findings;
        report.scanned_files = external_output.scanned_files.unwrap_or(1);
        report.duration_ms = external_output.duration_ms.unwrap_or(duration_ms);

        Ok(report)
    }
}