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:
108
cmd/hook.go
Normal file
108
cmd/hook.go
Normal 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
19
cmd/hook_script.sh
Normal 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
184
cmd/hook_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
12
cmd/stubs.go
12
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)",
|
||||
|
||||
Reference in New Issue
Block a user