feat(07-05): implement keyhunter hook install/uninstall with embedded pre-commit script

- cmd/hook.go: install/uninstall subcommands with --force flag
- cmd/hook_script.sh: embedded via go:embed, runs keyhunter scan on staged files
- KEYHUNTER-HOOK v1 marker prevents accidental deletion of non-owned hooks
- Backup existing hooks on --force install
- cmd/hook_test.go: 10 tests covering fresh install, non-repo, force/backup, overwrite, uninstall lifecycle
- Remove hookCmd stub from cmd/stubs.go
This commit is contained in:
salvacybersec
2026-04-05 23:58:44 +03:00
parent 87c5a00203
commit aa8daf8de2
4 changed files with 311 additions and 12 deletions

108
cmd/hook.go Normal file
View File

@@ -0,0 +1,108 @@
package cmd
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
)
//go:embed hook_script.sh
var hookScript string
// hookMarker identifies a KeyHunter-managed hook. Uninstall refuses to
// delete pre-commit files that don't contain this marker unless --force.
const hookMarker = "KEYHUNTER-HOOK v1"
var (
hookForce bool
)
var hookCmd = &cobra.Command{
Use: "hook",
Short: "Install or manage git pre-commit hooks",
}
var hookInstallCmd = &cobra.Command{
Use: "install",
Short: "Install the keyhunter pre-commit hook into .git/hooks/",
RunE: runHookInstall,
}
var hookUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Remove the keyhunter pre-commit hook",
RunE: runHookUninstall,
}
func init() {
hookInstallCmd.Flags().BoolVar(&hookForce, "force", false, "overwrite any existing pre-commit hook without prompt")
hookUninstallCmd.Flags().BoolVar(&hookForce, "force", false, "delete pre-commit even if it is not KeyHunter-managed")
hookCmd.AddCommand(hookInstallCmd)
hookCmd.AddCommand(hookUninstallCmd)
}
func hookPath() (string, error) {
gitDir := ".git"
info, err := os.Stat(gitDir)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("not a git repository (no .git/ in current directory)")
}
return filepath.Join(gitDir, "hooks", "pre-commit"), nil
}
func runHookInstall(cmd *cobra.Command, args []string) error {
target, err := hookPath()
if err != nil {
return err
}
if _, err := os.Stat(target); err == nil {
existing, _ := os.ReadFile(target)
if strings.Contains(string(existing), hookMarker) {
// Already ours — overwrite silently to update script.
} else if !hookForce {
return fmt.Errorf("pre-commit hook already exists at %s (use --force to overwrite; a .bak backup will be kept)", target)
} else {
backup := target + ".bak." + time.Now().Format("20060102150405")
if err := os.Rename(target, backup); err != nil {
return fmt.Errorf("backing up existing hook: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Backed up existing hook to %s\n", backup)
}
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("creating hooks dir: %w", err)
}
if err := os.WriteFile(target, []byte(hookScript), 0o755); err != nil {
return fmt.Errorf("writing hook: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Installed pre-commit hook at %s\n", target)
return nil
}
func runHookUninstall(cmd *cobra.Command, args []string) error {
target, err := hookPath()
if err != nil {
return err
}
data, err := os.ReadFile(target)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintln(cmd.OutOrStdout(), "No pre-commit hook to remove.")
return nil
}
return fmt.Errorf("reading hook: %w", err)
}
if !strings.Contains(string(data), hookMarker) && !hookForce {
return fmt.Errorf("pre-commit at %s is not KeyHunter-managed (use --force to remove anyway)", target)
}
if err := os.Remove(target); err != nil {
return fmt.Errorf("removing hook: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Removed pre-commit hook at %s\n", target)
return nil
}

19
cmd/hook_script.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# KEYHUNTER-HOOK v1 — managed by `keyhunter hook install`
# Remove via `keyhunter hook uninstall`.
set -e
files=$(git diff --cached --name-only --diff-filter=ACMR)
if [ -z "$files" ]; then
exit 0
fi
# Run keyhunter against each staged file. Exit code 1 from keyhunter
# means findings present; 2 means scan error. Either blocks the commit.
echo "$files" | xargs -r keyhunter scan --exit-code
status=$?
if [ $status -ne 0 ]; then
echo "keyhunter: pre-commit blocked (exit $status). Run 'git commit --no-verify' to bypass." >&2
exit $status
fi
exit 0

184
cmd/hook_test.go Normal file
View File

@@ -0,0 +1,184 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func setupGitRepo(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
t.Chdir(tmp)
if err := os.MkdirAll(filepath.Join(tmp, ".git", "hooks"), 0o755); err != nil {
t.Fatalf("mkdir .git/hooks: %v", err)
}
// Reset force flag between tests.
hookForce = false
return tmp
}
func TestHookInstall_FreshRepo(t *testing.T) {
tmp := setupGitRepo(t)
if err := runHookInstall(hookInstallCmd, nil); err != nil {
t.Fatalf("install: %v", err)
}
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o755 {
t.Errorf("mode = %v, want 0o755", info.Mode().Perm())
}
data, _ := os.ReadFile(p)
if !strings.Contains(string(data), hookMarker) {
t.Errorf("hook missing marker %q", hookMarker)
}
}
func TestHookInstall_NotAGitRepo(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
hookForce = false
err := runHookInstall(hookInstallCmd, nil)
if err == nil || !strings.Contains(err.Error(), "not a git repository") {
t.Fatalf("want not-a-git-repo error, got %v", err)
}
}
func TestHookInstall_ExistingNonKeyhunterRefuses(t *testing.T) {
tmp := setupGitRepo(t)
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
original := "# my hook\n"
if err := os.WriteFile(p, []byte(original), 0o755); err != nil {
t.Fatal(err)
}
err := runHookInstall(hookInstallCmd, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
data, _ := os.ReadFile(p)
if string(data) != original {
t.Errorf("file changed; got %q", string(data))
}
}
func TestHookInstall_ForceBackupsExisting(t *testing.T) {
tmp := setupGitRepo(t)
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
original := "# my hook\n"
if err := os.WriteFile(p, []byte(original), 0o755); err != nil {
t.Fatal(err)
}
hookForce = true
defer func() { hookForce = false }()
if err := runHookInstall(hookInstallCmd, nil); err != nil {
t.Fatalf("install --force: %v", err)
}
// backup file should exist
entries, _ := os.ReadDir(filepath.Join(tmp, ".git", "hooks"))
foundBackup := false
for _, e := range entries {
if strings.HasPrefix(e.Name(), "pre-commit.bak.") {
foundBackup = true
data, _ := os.ReadFile(filepath.Join(tmp, ".git", "hooks", e.Name()))
if string(data) != original {
t.Errorf("backup content mismatch: %q", string(data))
}
}
}
if !foundBackup {
t.Error("no backup file found")
}
data, _ := os.ReadFile(p)
if !strings.Contains(string(data), hookMarker) {
t.Error("new hook missing marker")
}
}
func TestHookInstall_ExistingKeyhunterOverwrites(t *testing.T) {
tmp := setupGitRepo(t)
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
stale := "#!/bin/sh\n# " + hookMarker + " stale\n"
if err := os.WriteFile(p, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
if err := runHookInstall(hookInstallCmd, nil); err != nil {
t.Fatalf("install: %v", err)
}
data, _ := os.ReadFile(p)
if string(data) == stale {
t.Error("hook not updated")
}
if !strings.Contains(string(data), hookMarker) {
t.Error("updated hook missing marker")
}
}
func TestHookUninstall_RemovesKeyhunter(t *testing.T) {
tmp := setupGitRepo(t)
if err := runHookInstall(hookInstallCmd, nil); err != nil {
t.Fatal(err)
}
if err := runHookUninstall(hookUninstallCmd, nil); err != nil {
t.Fatalf("uninstall: %v", err)
}
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
}
func TestHookUninstall_RefusesForeign(t *testing.T) {
tmp := setupGitRepo(t)
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
original := "#!/bin/sh\n# foreign\n"
if err := os.WriteFile(p, []byte(original), 0o755); err != nil {
t.Fatal(err)
}
err := runHookUninstall(hookUninstallCmd, nil)
if err == nil {
t.Fatal("expected error")
}
data, _ := os.ReadFile(p)
if string(data) != original {
t.Error("file was modified")
}
}
func TestHookUninstall_Force(t *testing.T) {
tmp := setupGitRepo(t)
p := filepath.Join(tmp, ".git", "hooks", "pre-commit")
if err := os.WriteFile(p, []byte("# foreign\n"), 0o755); err != nil {
t.Fatal(err)
}
hookForce = true
defer func() { hookForce = false }()
if err := runHookUninstall(hookUninstallCmd, nil); err != nil {
t.Fatalf("uninstall --force: %v", err)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Error("file still exists")
}
}
func TestHookUninstall_Missing(t *testing.T) {
setupGitRepo(t)
if err := runHookUninstall(hookUninstallCmd, nil); err != nil {
t.Fatalf("uninstall missing: %v", err)
}
}
func TestHookScript_ContainsRequired(t *testing.T) {
if !strings.Contains(hookScript, "keyhunter scan") {
t.Error("embedded script missing 'keyhunter scan'")
}
if !strings.Contains(hookScript, "git diff --cached") {
t.Error("embedded script missing 'git diff --cached'")
}
if !strings.Contains(hookScript, hookMarker) {
t.Error("embedded script missing marker")
}
}

View File

@@ -21,12 +21,6 @@ var verifyCmd = &cobra.Command{
RunE: notImplemented("verify", "Phase 5"), RunE: notImplemented("verify", "Phase 5"),
} }
var importCmd = &cobra.Command{
Use: "import",
Short: "Import findings from TruffleHog or Gitleaks output (Phase 7)",
RunE: notImplemented("import", "Phase 7"),
}
var reconCmd = &cobra.Command{ var reconCmd = &cobra.Command{
Use: "recon", Use: "recon",
Short: "Run OSINT recon across internet sources (Phase 9+)", Short: "Run OSINT recon across internet sources (Phase 9+)",
@@ -47,12 +41,6 @@ var dorksCmd = &cobra.Command{
RunE: notImplemented("dorks", "Phase 8"), RunE: notImplemented("dorks", "Phase 8"),
} }
var hookCmd = &cobra.Command{
Use: "hook",
Short: "Install or manage git pre-commit hooks (Phase 7)",
RunE: notImplemented("hook", "Phase 7"),
}
var scheduleCmd = &cobra.Command{ var scheduleCmd = &cobra.Command{
Use: "schedule", Use: "schedule",
Short: "Manage scheduled recurring scans (Phase 17)", Short: "Manage scheduled recurring scans (Phase 17)",