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::cli::args::HookCommands;
use crate::cli::UI;
use crate::core::{PluginScanReport, ScanReport, Severity};
use crate::plugins::builtin::{patterns::PatternScanner, secrets::SecretsScanner};
use crate::plugins::traits::{ScanContext, ScanPhase, SecurityPlugin};
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;

pub async fn execute(action: HookCommands, ui: &UI) -> Result<()> {
    match action {
        HookCommands::Install {
            pre_commit,
            pre_push,
            all,
        } => install_hooks(pre_commit, pre_push, all, ui).await,
        HookCommands::Uninstall { all } => uninstall_hooks(all, ui).await,
        HookCommands::Status => show_status(ui).await,
    }
}

async fn install_hooks(pre_commit: bool, pre_push: bool, all: bool, ui: &UI) -> Result<()> {
    ui.header("Hook Installation");
    ui.blank();

    let git_dir = PathBuf::from(".git");
    if !git_dir.exists() {
        anyhow::bail!("Not in a git repository");
    }

    let hooks_dir = git_dir.join("hooks");
    std::fs::create_dir_all(&hooks_dir)?;

    // If no specific hook is requested, install all
    let install_all = all || (!pre_commit && !pre_push);

    if install_all || pre_commit {
        let hook_path = hooks_dir.join("pre-commit");
        let hook_content = generate_pre_commit_hook();
        std::fs::write(&hook_path, hook_content)?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
        }

        ui.status_item(true, "pre-commit hook installed");
    }

    if install_all || pre_push {
        let hook_path = hooks_dir.join("pre-push");
        let hook_content = generate_pre_push_hook();
        std::fs::write(&hook_path, hook_content)?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
        }

        ui.status_item(true, "pre-push hook installed");
    }

    ui.blank();
    ui.success("Hooks installed successfully");
    ui.blank();
    ui.info("These hooks will scan your code before commit/push");
    ui.info("To bypass: git commit --no-verify");
    ui.blank();

    Ok(())
}

async fn uninstall_hooks(_all: bool, ui: &UI) -> Result<()> {
    ui.header("Hook Removal");
    ui.blank();

    let hooks_dir = PathBuf::from(".git/hooks");

    let hooks = vec!["pre-commit", "pre-push"];

    for hook in hooks {
        let hook_path = hooks_dir.join(hook);
        if hook_path.exists() {
            // Check if it's our hook
            let content = std::fs::read_to_string(&hook_path)?;
            if content.contains("securegit") {
                std::fs::remove_file(&hook_path)?;
                ui.status_item(true, format!("{} hook removed", hook));
            }
        }
    }

    ui.blank();
    Ok(())
}

async fn show_status(ui: &UI) -> Result<()> {
    ui.header("Hook Status");
    ui.blank();

    let hooks_dir = PathBuf::from(".git/hooks");

    if !hooks_dir.exists() {
        ui.info("Not in a git repository");
        return Ok(());
    }

    let hooks = vec!["pre-commit", "pre-push"];

    for hook in hooks {
        let hook_path = hooks_dir.join(hook);
        if hook_path.exists() {
            let content = std::fs::read_to_string(&hook_path)?;
            if content.contains("securegit") {
                ui.status_item(true, hook);
            } else {
                ui.status_item(false, format!("{} (not securegit)", hook));
            }
        } else {
            ui.status_item(false, format!("{} (not installed)", hook));
        }
    }

    ui.blank();
    Ok(())
}

pub async fn pre_commit(fail_on: String, ui: &UI) -> Result<()> {
    ui.info("Running pre-commit scan...");

    let threshold = Severity::parse_str(&fail_on).unwrap_or(Severity::High);
    let repo = crate::ops::open_repo(&std::path::PathBuf::from("."))?;
    let index = repo.index()?;

    let mut plugins: Vec<Box<dyn SecurityPlugin>> = vec![
        Box::new(SecretsScanner::new()),
        Box::new(PatternScanner::new()),
    ];
    for plugin in &mut plugins {
        let _ = plugin.initialize().await;
    }

    let mut report = ScanReport::new();

    for entry in index.iter() {
        let path = String::from_utf8_lossy(&entry.path).to_string();
        let path_buf = PathBuf::from(&path);

        // Read blob content from the index (staged version, not working tree)
        let blob = match repo.find_blob(entry.id) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let content = blob.content().to_vec();

        report.scanned_files += 1;
        report.scanned_bytes += content.len() as u64;

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

        for plugin in &plugins {
            if let Ok(plugin_report) = plugin.scan(&context).await {
                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,
                });
            }
        }
    }

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

    // Apply allowlist filter
    let config = crate::core::Config::default();
    report.findings.retain(|f| {
        f.file_path
            .as_ref()
            .map(|p| !config.scan.is_allowlisted(p))
            .unwrap_or(true)
    });

    if report.has_findings_at_or_above(threshold) {
        let count = report
            .findings
            .iter()
            .filter(|f| f.severity >= threshold)
            .count();
        ui.blank();
        for finding in &report.findings {
            if finding.severity >= threshold {
                let path = finding
                    .file_path
                    .as_ref()
                    .map(|p| p.display().to_string())
                    .unwrap_or_default();
                ui.status_item(
                    false,
                    format!("[{}] {} {}", finding.severity, finding.title, path),
                );
            }
        }
        ui.blank();
        anyhow::bail!(
            "Pre-commit scan failed: {} finding(s) at or above {} severity",
            count,
            threshold
        );
    }

    ui.success(format!(
        "Pre-commit scan passed ({} files scanned)",
        report.scanned_files
    ));
    Ok(())
}

pub async fn pre_push(fail_on: String, ui: &UI) -> Result<()> {
    ui.info("Running pre-push scan...");

    let threshold = Severity::parse_str(&fail_on).unwrap_or(Severity::High);
    let repo = crate::ops::open_repo(&std::path::PathBuf::from("."))?;
    let head = repo.head()?.peel_to_commit()?;
    let tree = head.tree()?;

    let mut plugins: Vec<Box<dyn SecurityPlugin>> = vec![
        Box::new(SecretsScanner::new()),
        Box::new(PatternScanner::new()),
    ];
    for plugin in &mut plugins {
        let _ = plugin.initialize().await;
    }

    // Collect all blob entries from HEAD tree
    let mut entries: Vec<(String, git2::Oid)> = Vec::new();
    tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
        if entry.kind() != Some(git2::ObjectType::Blob) {
            return git2::TreeWalkResult::Ok;
        }
        let name = match entry.name() {
            Some(n) => n,
            None => return git2::TreeWalkResult::Ok,
        };
        let path = if dir.is_empty() {
            name.to_string()
        } else {
            format!("{}{}", dir, name)
        };
        entries.push((path, entry.id()));
        git2::TreeWalkResult::Ok
    })?;

    let mut report = ScanReport::new();

    for (path, oid) in &entries {
        let blob = match repo.find_blob(*oid) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let content = blob.content().to_vec();
        let path_buf = PathBuf::from(path);
        report.scanned_files += 1;
        report.scanned_bytes += content.len() as u64;

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

        for plugin in &plugins {
            if let Ok(plugin_report) = plugin.scan(&context).await {
                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,
                });
            }
        }
    }

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

    // Apply allowlist filter
    let config = crate::core::Config::default();
    report.findings.retain(|f| {
        f.file_path
            .as_ref()
            .map(|p| !config.scan.is_allowlisted(p))
            .unwrap_or(true)
    });

    if report.has_findings_at_or_above(threshold) {
        let count = report
            .findings
            .iter()
            .filter(|f| f.severity >= threshold)
            .count();
        ui.blank();
        for finding in &report.findings {
            if finding.severity >= threshold {
                let path = finding
                    .file_path
                    .as_ref()
                    .map(|p| p.display().to_string())
                    .unwrap_or_default();
                ui.status_item(
                    false,
                    format!("[{}] {} {}", finding.severity, finding.title, path),
                );
            }
        }
        ui.blank();
        anyhow::bail!(
            "Pre-push scan failed: {} finding(s) at or above {} severity",
            count,
            threshold
        );
    }

    ui.success(format!(
        "Pre-push scan passed ({} files scanned)",
        report.scanned_files
    ));
    Ok(())
}

fn generate_pre_commit_hook() -> String {
    r#"#!/bin/sh
# SecureGit pre-commit hook
# Auto-generated - do not edit manually

echo "Running SecureGit pre-commit scan..."

if command -v securegit > /dev/null 2>&1; then
    securegit pre-commit --fail-on high
    exit $?
else
    echo "WARNING: securegit not found in PATH"
    echo "Install securegit or remove this hook"
    exit 1
fi
"#
    .to_string()
}

fn generate_pre_push_hook() -> String {
    r#"#!/bin/sh
# SecureGit pre-push hook
# Auto-generated - do not edit manually

echo "Running SecureGit pre-push scan..."

if command -v securegit > /dev/null 2>&1; then
    securegit pre-push --fail-on high
    exit $?
else
    echo "WARNING: securegit not found in PATH"
    echo "Install securegit or remove this hook"
    exit 1
fi
"#
    .to_string()
}