keyhog 0.5.37

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
//! Logic for the `hook` subcommand.

use anyhow::{Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;

const HOOK_CONTENT: &str = r#"#!/bin/sh
# KeyHog pre-commit hook, auto-generated by `keyhog hook install`
#
# If keyhog is not on PATH, skip the scan with a clear message instead of
# failing the commit. A bare `keyhog ...` here makes `sh` exit 127
# ("keyhog: not found"), which git treats as a hook failure and blocks
# EVERY commit - including clean ones - with a cryptic error. A pre-commit
# scan is best-effort (CI is the real gate), so a missing tool must not
# brick a contributor's workflow.
if ! command -v keyhog >/dev/null 2>&1; then
    echo "keyhog: not found on PATH - skipping the pre-commit secret scan." >&2
    echo "  Install keyhog (https://github.com/santhsecurity/keyhog) or remove" >&2
    echo "  .git/hooks/pre-commit to silence this warning." >&2
    exit 0
fi
exec keyhog scan --fast --git-staged
"#;

const HOOK_MARKER: &str = "KeyHog pre-commit hook";

#[derive(clap::Subcommand, Debug, Clone)]
pub enum HookCommand {
    /// Install a git pre-commit hook in the current repository
    Install,
    /// Remove the KeyHog pre-commit hook from the current repository
    Uninstall,
}

pub fn run(command: HookCommand) -> Result<ExitCode> {
    match command {
        HookCommand::Install => install(),
        HookCommand::Uninstall => uninstall(),
    }
}

fn install() -> Result<ExitCode> {
    let git_dir = find_git_dir()?;
    let hooks_dir = git_dir.join("hooks");
    let hook_path = hooks_dir.join("pre-commit");

    std::fs::create_dir_all(&hooks_dir)
        .with_context(|| format!("creating hooks directory at {}", hooks_dir.display()))?;

    if hook_path.exists() {
        let existing = std::fs::read_to_string(&hook_path)
            .with_context(|| format!("reading existing hook at {}", hook_path.display()))?;
        if existing.contains(HOOK_MARKER) {
            eprintln!(
                "KeyHog pre-commit hook is already installed at {}.",
                hook_path.display()
            );
            return Ok(ExitCode::SUCCESS);
        }
        anyhow::bail!(
            "a pre-commit hook already exists at {}. Remove it manually or run `keyhog hook uninstall` first.",
            hook_path.display()
        );
    }

    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&hook_path)
        .with_context(|| format!("creating hook at {}", hook_path.display()))?;

    file.write_all(HOOK_CONTENT.as_bytes())
        .with_context(|| format!("writing hook to {}", hook_path.display()))?;
    drop(file);

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&hook_path)
            .with_context(|| format!("reading permissions of {}", hook_path.display()))?
            .permissions();
        perms.set_mode(perms.mode() | 0o111);
        std::fs::set_permissions(&hook_path, perms)
            .with_context(|| format!("making {} executable", hook_path.display()))?;
    }

    eprintln!(
        "KeyHog pre-commit hook installed at {}.",
        hook_path.display()
    );
    Ok(ExitCode::SUCCESS)
}

fn uninstall() -> Result<ExitCode> {
    let git_dir = find_git_dir()?;
    let hook_path = git_dir.join("hooks").join("pre-commit");

    if !hook_path.exists() {
        eprintln!("No pre-commit hook found at {}.", hook_path.display());
        return Ok(ExitCode::SUCCESS);
    }

    let existing = std::fs::read_to_string(&hook_path)
        .with_context(|| format!("reading hook at {}", hook_path.display()))?;

    if !existing.contains(HOOK_MARKER) {
        anyhow::bail!(
            "pre-commit hook at {} was not installed by KeyHog. Remove it manually if you are sure.",
            hook_path.display()
        );
    }

    std::fs::remove_file(&hook_path)
        .with_context(|| format!("removing hook at {}", hook_path.display()))?;

    eprintln!(
        "KeyHog pre-commit hook removed from {}.",
        hook_path.display()
    );
    Ok(ExitCode::SUCCESS)
}

fn find_git_dir() -> Result<PathBuf> {
    // SECURITY: kimi-wave1 audit finding 3.PATH-git. Use trusted absolute path.
    let git_bin = keyhog_core::safe_bin::resolve_safe_bin("git")
        .ok_or_else(|| anyhow::anyhow!("git binary not found in trusted system bin dirs"))?;
    let output = std::process::Command::new(&git_bin)
        .args(["rev-parse", "--git-dir"])
        .output()
        .context("failed to run `git rev-parse --git-dir`. Is this a git repository?")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("not a git repository (or git failed): {stderr}");
    }

    let git_dir = String::from_utf8(output.stdout)
        .context("git output is not valid UTF-8")?
        .trim()
        .to_string();

    let path = PathBuf::from(git_dir);
    let absolute = if path.is_absolute() {
        path
    } else {
        std::env::current_dir()
            .context("getting current directory")?
            .join(path)
    };

    Ok(absolute.canonicalize().unwrap_or(absolute))
}