keyhog 0.5.86

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
//! Logic for the `hook` subcommand.

use crate::args::HookCommand;
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 deeper gate, not a substitute
/// for the local hook running when installed.
/// `--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).
/// `--backend cpu`: hooks are diagnostic local checks and must not require
/// install-time autoroute calibration or an optional Hyperscan library.
pub(crate) const CANONICAL_SCAN_ARGS: &str = "scan --fast --git-staged --backend cpu";

#[doc(hidden)]
pub(crate) 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, block with a clear message. A missing scanner\n",
    "# means this security control did not run; letting the commit continue would\n",
    "# silently turn the installed hook into a stub.\n",
    "if ! command -v keyhog >/dev/null 2>&1; then\n",
    "    echo \"keyhog: not found on PATH - blocking commit because the pre-commit secret scan did not run.\" >&2\n",
    "    echo \"  Install keyhog (https://github.com/santhreal/keyhog), fix PATH,\" >&2\n",
    "    echo \"  or run 'keyhog hook uninstall' if this repository should not be protected.\" >&2\n",
    "    exit 127\n",
    "fi\n",
    "exec keyhog ",
    "scan --fast --git-staged --backend cpu", // == CANONICAL_SCAN_ARGS; asserted by hook_template_embeds_canonical_scan_args
    "\n",
);

#[doc(hidden)]
pub(crate) mod testing {
    pub(crate) const CANONICAL_SCAN_ARGS: &str = super::CANONICAL_SCAN_ARGS;
    pub(crate) const HOOK_CONTENT: &str = super::HOOK_CONTENT;
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookInstallStatus {
    Installed,
    AlreadyInstalled,
    Updated,
}

pub(crate) async fn run(command: HookCommand) -> Result<ExitCode> {
    match command {
        HookCommand::Install { force } => install(force),
        HookCommand::Uninstall => uninstall(),
        HookCommand::Run(args) => run_hook_scan(*args).await,
    }
}

async fn run_hook_scan(mut args: crate::args::ScanArgs) -> Result<ExitCode> {
    let _hook_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    if args.input.is_empty() && args.path.is_none() && !args.stdin {
        #[cfg(feature = "git")]
        {
            if args.git_blobs.is_none()
                && args.git_diff.is_none()
                && args.git_history.is_none()
                && !args.git_staged
            {
                args.git_staged = true;
            }
        }
        #[cfg(not(feature = "git"))]
        {
            // Without the git source, there is no staged-index path to
            // default to; the hook scans the working tree as-is.
        }
    }
    if !args.deep && !args.precision {
        args.fast = true;
    }
    drop(_hook_span);
    crate::subcommands::scan::run(args).await
}

fn install(force: bool) -> Result<ExitCode> {
    let cur_dir = std::env::current_dir().context("resolving current directory")?;
    let (hook_path, status) = install_at_repo(&cur_dir, force)?;
    let palette = crate::style::for_stderr();
    match status {
        HookInstallStatus::AlreadyInstalled => {
            let msg = format!(
                "KeyHog pre-commit hook is already installed at {}.",
                hook_path.display()
            );
            eprintln!("{}", crate::style::warn(&msg, &palette));
        }
        HookInstallStatus::Installed => {
            let msg = format!(
                "KeyHog pre-commit hook installed at {}.",
                hook_path.display()
            );
            eprintln!("{}", crate::style::pass(&msg, &palette));
        }
        HookInstallStatus::Updated => {
            let msg = format!(
                "KeyHog pre-commit hook installed/updated at {}.",
                hook_path.display()
            );
            eprintln!("{}", crate::style::pass(&msg, &palette));
        }
    }
    Ok(ExitCode::SUCCESS)
}

pub(crate) fn install_at_repo(
    repo_root: &std::path::Path,
    force: bool,
) -> Result<(PathBuf, HookInstallStatus)> {
    // Hook path resolution + existing-hook check: the collect phase.
    let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    let hooks_dir = find_hooks_dir_for_repo(repo_root)?;
    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()))?;

    let mut is_update = false;
    if hook_path.exists() {
        let existing = std::fs::read_to_string(&hook_path)
            .with_context(|| format!("reading existing hook at {}", hook_path.display()))?;
        // Exact-byte match is the only "already installed" no-op. A marker
        // alone is not enough: upgraded HOOK_CONTENT must rewrite so operators
        // never keep a stale scan line after a keyhog upgrade (KH-1333).
        if existing == HOOK_CONTENT && !force {
            return Ok((hook_path, HookInstallStatus::AlreadyInstalled));
        }
        let is_keyhog_owned = existing.contains(HOOK_MARKER);
        if !force && !is_keyhog_owned {
            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()
            );
        }
        // KeyHog-owned but bytes differ (or --force): fall through and rewrite.
        is_update = true;
    }

    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()))?;
    drop(_check_span);
    // Hook publication (write + chmod + receipt).
    let _report_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);

    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()))?;
    }

    let status = if is_update || force {
        HookInstallStatus::Updated
    } else {
        HookInstallStatus::Installed
    };
    Ok((hook_path, status))
}

fn uninstall() -> Result<ExitCode> {
    let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    let cur_dir = std::env::current_dir().context("resolving current directory")?;
    let hooks_dir = find_hooks_dir_for_repo(&cur_dir)?;
    let hook_path = hooks_dir.join("pre-commit");

    if !hook_path.exists() {
        let palette = crate::style::for_stderr();
        let msg = format!("No pre-commit hook found at {}.", hook_path.display());
        eprintln!("{}", crate::style::warn(&msg, &palette));
        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()))?;
    drop(_check_span);

    let _report_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);
    let palette = crate::style::for_stderr();
    let msg = format!(
        "KeyHog pre-commit hook removed from {}.",
        hook_path.display()
    );
    eprintln!("{}", crate::style::pass(&msg, &palette));
    Ok(ExitCode::SUCCESS)
}

pub(crate) fn uninstall_at_repo(repo_root: &std::path::Path) -> Result<Option<PathBuf>> {
    let _check_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
    let hooks_dir = match find_hooks_dir_for_repo(repo_root) {
        Ok(dir) => dir,
        Err(_) => return Ok(None),
    };
    let hook_path = hooks_dir.join("pre-commit");

    if !hook_path.exists() {
        return Ok(None);
    }

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

    if !existing.contains(HOOK_MARKER) {
        return Ok(None);
    }

    std::fs::remove_file(&hook_path)
        .with_context(|| format!("removing hook at {}", hook_path.display()))?;
    drop(_check_span);
    let _report_span = keyhog_profile::span(keyhog_profile::Stage::Reporting);

    Ok(Some(hook_path))
}

pub(crate) fn find_hooks_dir_for_repo(repo_root: &std::path::Path) -> Result<PathBuf> {
    // SECURITY: kimi-wave1 audit finding 3.PATH-git. Use trusted absolute path.
    let git_bin = keyhog_core::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)
        .current_dir(repo_root)
        .args(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
        .output()
        .context("failed to run `git rev-parse --git-path hooks`. 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 hooks_dir = String::from_utf8(output.stdout)
        .context("git output is not valid UTF-8")?
        .trim()
        .to_string();
    anyhow::ensure!(
        !hooks_dir.is_empty(),
        "git returned an empty hooks path. Fix: check core.hooksPath and repository metadata."
    );
    let path = PathBuf::from(hooks_dir);
    anyhow::ensure!(
        path.is_absolute(),
        "git returned a relative hooks path despite --path-format=absolute: {}. Fix: update Git or repair core.hooksPath.",
        path.display()
    );
    Ok(path)
}