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::{Config, PluginScanReport, ScanReport};
use crate::plugins::builtin::{
    binary::BinaryScanner, cicd::CicdScanner, container::ContainerScanner,
    dangerous_files::DangerousFilesScanner, deserialization::DeserializationScanner,
    encoding::EncodingScanner, entropy::EntropyScanner, git_internals::GitInternalsScanner,
    iac::IacScanner, patterns::PatternScanner, secrets::SecretsScanner,
    supply_chain::SupplyChainScanner,
};
use crate::plugins::traits::{ScanContext, ScanPhase, SecurityPlugin};
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;
use walkdir::WalkDir;

pub struct ScanEngine {
    config: Config,
}

impl ScanEngine {
    pub fn new(config: Config) -> Self {
        Self { config }
    }

    pub async fn scan_directory(&self, path: &Path) -> Result<ScanReport> {
        let start = Instant::now();
        let mut report = ScanReport::new();

        // Initialize all builtin plugins
        let mut plugins: Vec<Box<dyn SecurityPlugin>> = vec![
            Box::new(SecretsScanner::new()),
            Box::new(PatternScanner::new()),
            Box::new(EntropyScanner::new()),
            Box::new(BinaryScanner::new()),
            Box::new(CicdScanner::new()),
            Box::new(SupplyChainScanner::new()),
            Box::new(ContainerScanner::new()),
            Box::new(IacScanner::new()),
            Box::new(DeserializationScanner::new()),
            Box::new(DangerousFilesScanner::new()),
            Box::new(EncodingScanner::new()),
        ];

        // Load external plugins from ~/.config/securegit/plugins/
        if let Some(config_dir) = dirs::config_dir() {
            let plugins_dir = config_dir.join("securegit").join("plugins");
            if plugins_dir.exists() {
                for entry in std::fs::read_dir(&plugins_dir)
                    .into_iter()
                    .flatten()
                    .flatten()
                {
                    let path = entry.path();
                    if path.is_file() {
                        #[cfg(unix)]
                        {
                            use std::os::unix::fs::PermissionsExt;
                            if let Ok(metadata) = std::fs::metadata(&path) {
                                if metadata.permissions().mode() & 0o111 != 0 {
                                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                                        let mut plugin = crate::plugins::ExternalPlugin::new(
                                            name.to_string(),
                                            path.clone(),
                                            format!("External plugin: {}", name),
                                            crate::plugins::traits::ScanPhase::PostExtract,
                                        );
                                        if plugin.initialize().await.is_ok() {
                                            plugins.push(Box::new(plugin));
                                        }
                                    }
                                }
                            }
                        }
                        #[cfg(not(unix))]
                        {
                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                                let mut plugin = crate::plugins::ExternalPlugin::new(
                                    name.to_string(),
                                    path.clone(),
                                    format!("External plugin: {}", name),
                                    crate::plugins::traits::ScanPhase::PostExtract,
                                );
                                if plugin.initialize().await.is_ok() {
                                    plugins.push(Box::new(plugin));
                                }
                            }
                        }
                    }
                }
            }
        }

        // Initialize each plugin
        for plugin in &mut plugins {
            let _ = plugin.initialize().await;
        }

        // Walk the directory and scan files
        for entry in WalkDir::new(path)
            .follow_links(false)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            let entry_path = entry.path();

            // Skip directories, only scan files
            if !entry_path.is_file() {
                continue;
            }

            // Skip paths in the skip list
            if self.should_skip_path(entry_path) {
                continue;
            }

            // Skip files exceeding the size limit
            let max_bytes = self.config.scan.max_scan_file_mb * 1024 * 1024;
            if max_bytes > 0 {
                if let Ok(meta) = std::fs::metadata(entry_path) {
                    if meta.len() > max_bytes {
                        report.warnings.push(format!(
                            "Skipped {} ({}MB exceeds {}MB limit)",
                            entry_path.display(),
                            meta.len() / (1024 * 1024),
                            self.config.scan.max_scan_file_mb
                        ));
                        continue;
                    }
                }
            }

            // Read file content
            if let Ok(content) = std::fs::read(entry_path) {
                report.scanned_files += 1;
                report.scanned_bytes += content.len() as u64;

                // Create scan context
                let context = ScanContext {
                    path: entry_path,
                    scan_phase: ScanPhase::PostExtract,
                    file_content: Some(&content),
                    metadata: HashMap::new(),
                };

                // Run each plugin
                for plugin in &plugins {
                    match plugin.scan(&context).await {
                        Ok(plugin_report) => {
                            let mut filtered_findings = plugin_report.findings;
                            filtered_findings.retain(|f| {
                                f.file_path
                                    .as_ref()
                                    .map(|p| !self.is_allowlisted(p))
                                    .unwrap_or(true)
                            });
                            let findings_count = filtered_findings.len();
                            report.findings.extend(filtered_findings);
                            report.plugin_reports.push(PluginScanReport {
                                plugin_name: plugin.name().to_string(),
                                findings_count,
                                duration_ms: plugin_report.duration_ms,
                            });
                        }
                        Err(e) => {
                            report.warnings.push(format!(
                                "Plugin '{}' failed on {}: {}",
                                plugin.name(),
                                entry_path.display(),
                                e
                            ));
                        }
                    }
                }
            }
        }

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

        // Cleanup plugins
        for plugin in &mut plugins {
            let _ = plugin.cleanup().await;
        }

        Ok(report)
    }

    /// Scan a single file through all plugins. O(1) instead of O(n) for the repo.
    pub async fn scan_file(&self, file_path: &Path) -> Result<ScanReport> {
        let start = Instant::now();
        let mut report = ScanReport::new();

        let mut plugins: Vec<Box<dyn SecurityPlugin>> = vec![
            Box::new(SecretsScanner::new()),
            Box::new(PatternScanner::new()),
            Box::new(EntropyScanner::new()),
            Box::new(BinaryScanner::new()),
            Box::new(CicdScanner::new()),
            Box::new(SupplyChainScanner::new()),
            Box::new(ContainerScanner::new()),
            Box::new(IacScanner::new()),
            Box::new(DeserializationScanner::new()),
            Box::new(DangerousFilesScanner::new()),
            Box::new(EncodingScanner::new()),
        ];

        for plugin in &mut plugins {
            let _ = plugin.initialize().await;
        }

        if let Ok(content) = std::fs::read(file_path) {
            report.scanned_files = 1;
            report.scanned_bytes = content.len() as u64;

            let context = ScanContext {
                path: file_path,
                scan_phase: ScanPhase::PostExtract,
                file_content: Some(&content),
                metadata: HashMap::new(),
            };

            for plugin in &plugins {
                match plugin.scan(&context).await {
                    Ok(plugin_report) => {
                        let findings_count = plugin_report.findings.len();
                        report.findings.extend(plugin_report.findings);
                        report.plugin_reports.push(PluginScanReport {
                            plugin_name: plugin.name().to_string(),
                            findings_count,
                            duration_ms: plugin_report.duration_ms,
                        });
                    }
                    Err(e) => {
                        report.warnings.push(format!(
                            "Plugin '{}' failed on {}: {}",
                            plugin.name(),
                            file_path.display(),
                            e
                        ));
                    }
                }
            }
        }

        report.findings.retain(|f| {
            f.file_path
                .as_ref()
                .map(|p| !self.is_allowlisted(p))
                .unwrap_or(true)
        });

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

        for plugin in &mut plugins {
            let _ = plugin.cleanup().await;
        }

        Ok(report)
    }

    pub async fn scan_git_directory(&self, path: &Path) -> Result<ScanReport> {
        let start = Instant::now();
        let mut report = ScanReport::new();

        // Initialize git-internals plugin
        let mut plugin = GitInternalsScanner::new();
        plugin.initialize().await?;

        // Create scan context
        let context = ScanContext {
            path,
            scan_phase: ScanPhase::GitInternals,
            file_content: None,
            metadata: HashMap::new(),
        };

        // Scan git directory
        match plugin.scan(&context).await {
            Ok(plugin_report) => {
                let findings_count = plugin_report.findings.len();
                report.findings.extend(plugin_report.findings);
                report.scanned_files += plugin_report.scanned_files;
                report.plugin_reports.push(PluginScanReport {
                    plugin_name: plugin.name().to_string(),
                    findings_count,
                    duration_ms: plugin_report.duration_ms,
                });
            }
            Err(e) => {
                report.warnings.push(format!(
                    "Plugin '{}' failed on git directory {}: {}",
                    plugin.name(),
                    path.display(),
                    e
                ));
            }
        }

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

        plugin.cleanup().await?;

        Ok(report)
    }

    fn is_allowlisted(&self, path: &Path) -> bool {
        self.config.scan.is_allowlisted(path)
    }

    fn should_skip_path(&self, path: &Path) -> bool {
        let path_str = path.to_string_lossy();

        // Skip common build/dependency directories
        for skip_pattern in &self.config.scan.skip_paths {
            if path_str.contains(skip_pattern.trim_end_matches("/**")) {
                return true;
            }
        }

        false
    }
}