From aa8daf8de2117fd2f30efa6170e4d78629fcfde5 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 23:58:44 +0300 Subject: [PATCH] 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 --- cmd/hook.go | 108 ++++++++++++++++++++++++++ cmd/hook_script.sh | 19 +++++ cmd/hook_test.go | 184 +++++++++++++++++++++++++++++++++++++++++++++ cmd/stubs.go | 12 --- 4 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 cmd/hook.go create mode 100644 cmd/hook_script.sh create mode 100644 cmd/hook_test.go diff --git a/cmd/hook.go b/cmd/hook.go new file mode 100644 index 0000000..c92504b --- /dev/null +++ b/cmd/hook.go @@ -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 +} diff --git a/cmd/hook_script.sh b/cmd/hook_script.sh new file mode 100644 index 0000000..e5d259e --- /dev/null +++ b/cmd/hook_script.sh @@ -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 diff --git a/cmd/hook_test.go b/cmd/hook_test.go new file mode 100644 index 0000000..cf73053 --- /dev/null +++ b/cmd/hook_test.go @@ -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") + } +} diff --git a/cmd/stubs.go b/cmd/stubs.go index f1222af..f6357a8 100644 --- a/cmd/stubs.go +++ b/cmd/stubs.go @@ -21,12 +21,6 @@ var verifyCmd = &cobra.Command{ 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{ Use: "recon", Short: "Run OSINT recon across internet sources (Phase 9+)", @@ -47,12 +41,6 @@ var dorksCmd = &cobra.Command{ 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{ Use: "schedule", Short: "Manage scheduled recurring scans (Phase 17)",