keyhog 0.5.38

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;

/// The canonical `keyhog scan` invocation that EVERY pre-commit mechanism
/// must run, verbatim. This is the single source of truth: the git-hooks
/// template below (`HOOK_CONTENT`, written by `hook install`), the
/// pre-commit framework hook (`.pre-commit-hooks.yaml`, id `keyhog`), and
/// the local script hook (`scripts/pre-commit`) all claim to mirror this
/// command. The external coherence tests in `crates/cli/tests/unit`
/// assert the template, the YAML, and the script all line up with `keyhog
/// CANONICAL_SCAN_ARGS`, so the three paths can never silently drift (e.g.
/// `pass_filenames` flipping true, or a missing `--git-staged`, which would
/// abort every adopter's commit with clap exit 2).
///
/// `--fast`: cheap pre-commit pass; CI is the real gate.
/// `--git-staged`: self-discovers staged blobs and scans their STAGED
/// content, taking no positional filename (the framework otherwise appends
/// changed filenames, and `keyhog scan` accepts only ONE positional PATH).
pub const CANONICAL_SCAN_ARGS: &str = "scan --fast --git-staged";

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

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 {
        /// Replace an existing non-KeyHog pre-commit hook.
        #[arg(long, default_value_t = false)]
        force: bool,
    },
    /// Remove the KeyHog pre-commit hook from the current repository
    Uninstall,
}

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

fn install(force: bool) -> 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) && !force {
            eprintln!(
                "KeyHog pre-commit hook is already installed at {}.",
                hook_path.display()
            );
            return Ok(ExitCode::SUCCESS);
        }
        if !force {
            anyhow::bail!(
                "a pre-commit hook already exists at {}. Remove it manually, \
                 run `keyhog hook uninstall` if it was installed by KeyHog, \
                 or pass `--force` to replace it.",
                hook_path.display()
            );
        }
    }

    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(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 {} at {}.",
        if force {
            "installed/updated"
        } else {
            "installed"
        },
        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))
}