diff --git a/.gitignore b/.gitignore index c28bd2c..56c0944 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,3 @@ website/build/ website/.docusaurus/ website/node_modules/ -# Local ADR documentation -docs/adr/ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..db72a39 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: envguard + name: envguard + description: Prevent .env files and environment secrets from reaching Git + entry: envguard hook run + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] diff --git a/CONTEXT.md b/CONTEXT.md index 9997ed5..9029b59 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,3 +43,11 @@ _Avoid_: Masking, redacting, cleaning **Fixer**: The remediation component responsible for automatically generating and applying non-destructive `.gitignore` rules for unignored environment files. _Avoid_: Patcher, autofixer, corrector, remediator + +**Hook Manager**: +The component responsible for installing, verifying, and uninstalling local Git pre-commit hook scripts (`.git/hooks/pre-commit`). +_Avoid_: Hook installer, hook script, hook handler + +**Hook Runner**: +The fast execution mode invoked during Git pre-commit lifecycle to inspect staged repository files and prevent accidental commits of environment secrets. +_Avoid_: Commit watcher, stage scanner, commit blocker diff --git a/docs/adr/0002-init-command-and-template-generation.md b/docs/adr/0002-init-command-and-template-generation.md new file mode 100644 index 0000000..54375ac --- /dev/null +++ b/docs/adr/0002-init-command-and-template-generation.md @@ -0,0 +1,7 @@ +# 0002: Subcommand `init` and Safe Template Generation + +To streamline repository onboarding and enforce security best practices from day one, `envguard` provides the `init` subcommand. By default, `envguard init` generates a documented `.envguard.yaml` configuration file containing recommended security defaults and commented configuration blocks. + +When invoked with `--template`, `envguard init` produces a safe `.env.example` file. If an existing `.env` file is present (or specified via `--template-from`), the initializer performs key sanitization, preserving comments, empty lines, and environment variable names while stripping all sensitive values to empty assignments (`KEY=`). If no source `.env` file exists, a curated default `.env.example` boilerplate is created. + +To protect existing configurations, `envguard init` refuses to overwrite existing files unless the `--force` flag is explicitly provided. diff --git a/docs/adr/0003-fix-command-and-remediation.md b/docs/adr/0003-fix-command-and-remediation.md new file mode 100644 index 0000000..c2f3e14 --- /dev/null +++ b/docs/adr/0003-fix-command-and-remediation.md @@ -0,0 +1,21 @@ +# 0003: Subcommand `fix` and Automatic Remediation + +To assist developers in preventing accidental leaks before commits occur, `envguard` provides the `fix` subcommand. By default, `envguard fix` scans the target directory, identifies unprotected local environment files (severity `WARNING`), and automatically updates the root `.gitignore` with the corresponding relative patterns. + +## Context & Problem + +Unignored `.env` files in local working trees pose an imminent risk of accidental staging and committing. While `envguard scan` identifies these files, manual updating of `.gitignore` across deep subdirectories is prone to omissions and syntax mistakes. Furthermore, tracked files (`CRITICAL`) cannot be remediated solely via `.gitignore` and require active Git cache removal (`git rm --cached`) and secret rotation. + +## Decision + +1. **Centralized Root `.gitignore` Remediation**: + `envguard fix` writes remediation patterns to the root repository `.gitignore`. Filepaths located in subdirectories are converted to root-relative ignore paths (e.g., `/packages/backend/.env.local`). + +2. **Identified & Non-Destructive Formatting**: + New ignore patterns are inserted under an identified section (`# Added by envguard`) while preserving all existing comments, whitespace, and formatting. Existing rules are checked to avoid duplicate entries. + +3. **Dry-Run Mode**: + When `--dry-run` is supplied, `envguard fix` previews proposed changes to `.gitignore` without altering the filesystem. + +4. **Handling of Critical Findings & Exit Codes**: + When tracked environment files (`CRITICAL`) are encountered, `envguard fix` outputs actionable remediation guidance (instructing developers to run `git rm --cached ` and rotate credentials) and returns an exit code of `1` to signal remaining unresolved security risks in the repository. If all findings are resolved or no findings exist, it returns `0`. diff --git a/docs/adr/0004-git-pre-commit-hooks.md b/docs/adr/0004-git-pre-commit-hooks.md new file mode 100644 index 0000000..7c90c66 --- /dev/null +++ b/docs/adr/0004-git-pre-commit-hooks.md @@ -0,0 +1,21 @@ +# 0004: Git Pre-Commit Hooks and Staged Inspection + +To safeguard developers from inadvertently committing sensitive environment files, `envguard` provides native Git pre-commit hook management via `envguard hook install`, `envguard hook run`, and `envguard hook uninstall`, alongside official integration with the Python `pre-commit` framework (`.pre-commit-hooks.yaml`). + +## Context & Problem + +Scanning the entire filesystem during every `git commit` invocation can be slow and disruptive in large repositories. Furthermore, developers need a zero-friction mechanism to install local hooks without relying on third-party package managers if they prefer a standalone Go binary. When hooks are installed, they must be idempotent, non-destructive, and execute in milliseconds by inspecting only files currently staged for commit. + +## Decision + +1. **Subcommands Architecture (`envguard hook `)**: + - `envguard hook install`: Resolves the `.git/hooks` directory (respecting repository root and standard Git hooks structure), writes an executable POSIX shell script to `.git/hooks/pre-commit` (with `0755` permissions on Unix platforms), and marks it with the signature `# Installed by envguard`. If a pre-existing hook without the envguard signature exists, it rejects installation unless `--force` is supplied. + - `envguard hook run`: Queries only staged files via `git diff --name-only --cached --diff-filter=ACM` and evaluates each staged path through the `Detector` and `Allowlist`. If any non-allowlisted environment file is staged, it outputs findings and exits with `1`, blocking the commit. If no staged environment files are detected, it exits immediately with `0`. + - `envguard hook uninstall`: Safely removes `.git/hooks/pre-commit` if it was installed by envguard (verified by header signature) or if `--force` is provided. + +2. **Ultra-Fast Staged Inspection**: + - Staged inspection bypasses full recursive filesystem crawling and only analyzes paths staged in the Git index. + - Supports `--path` (repository root) and optional `--config` for custom rule loading. + +3. **Official `pre-commit` Framework Integration**: + - Root repository `.pre-commit-hooks.yaml` configures the `envguard` hook with `language: system`, `entry: envguard hook run`, `pass_filenames: false`, `always_run: true`, and `stages: [pre-commit]`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index efe5436..6007ae9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,14 +5,16 @@ import ( "io" "strings" + "github.com/joaooncode/envguard/internal/git" "github.com/joaooncode/envguard/internal/scanner" ) // App encapsulates the CLI execution environment and dependencies. type App struct { - stdout io.Writer - stderr io.Writer - scanner *scanner.Scanner + stdout io.Writer + stderr io.Writer + scanner *scanner.Scanner + gitClient git.Client } // Option configures an App instance. @@ -25,6 +27,13 @@ func WithScanner(s *scanner.Scanner) Option { } } +// WithGitClient sets a custom Git Client instance (useful for testing). +func WithGitClient(g git.Client) Option { + return func(a *App) { + a.gitClient = g + } +} + // New creates a new App configured with stdout and stderr writers. func New(stdout, stderr io.Writer, opts ...Option) *App { app := &App{ @@ -73,6 +82,9 @@ func (a *App) Run(args []string) int { case "fix": return runFixCommand(args[1:], a.stdout, a.stderr, a.scanner) + case "hook": + return runHookCommand(args[1:], a.stdout, a.stderr, a.gitClient) + default: fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0]) a.printHelpTo(a.stderr) @@ -94,6 +106,7 @@ Available Commands: scan Scan a directory for unprotected environment files check Run verification optimized for CI/CD pipelines fix Automatically add unprotected environment files to .gitignore + hook Manage Git pre-commit hooks and perform staged inspections init Initialize configuration file and safe template files version Show current envguard version help Show help for envguard commands @@ -114,6 +127,11 @@ Fix Flags: -c, --config Path to custom configuration file --no-color Disable ANSI color escape codes in terminal output +Hook Flags (see 'envguard hook --help' for details): + install Install executable pre-commit hook into .git/hooks/pre-commit + run Inspect currently staged files and block commits with sensitive env files + uninstall Remove envguard pre-commit hook from .git/hooks/pre-commit + Init Flags: -p, --path Target directory path to initialize (default: ".") -f, --force Overwrite existing configuration or template files @@ -127,7 +145,9 @@ Examples: envguard check --path . --severity high envguard fix envguard fix --dry-run - envguard fix --path ./my-project + envguard hook install + envguard hook run + envguard hook uninstall envguard init envguard init --template envguard init --path ./my-project --force diff --git a/internal/cli/hook.go b/internal/cli/hook.go new file mode 100644 index 0000000..5b667e2 --- /dev/null +++ b/internal/cli/hook.go @@ -0,0 +1,242 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/joaooncode/envguard/internal/config" + "github.com/joaooncode/envguard/internal/detector" + "github.com/joaooncode/envguard/internal/git" + "github.com/joaooncode/envguard/internal/hook" + "github.com/joaooncode/envguard/internal/scanner" +) + +type hookInstallConfig struct { + path string + force bool +} + +type hookUninstallConfig struct { + path string + force bool +} + +type hookRunConfig struct { + path string + configPath string + noColor bool +} + +func runHookCommand(args []string, stdout, stderr io.Writer, gitClient git.Client) int { + if len(args) == 0 { + printHookHelp(stdout) + return ExitCodeSuccess + } + + subcmd := strings.ToLower(strings.TrimSpace(args[0])) + subargs := args[1:] + + switch subcmd { + case "install": + return runHookInstallCommand(subargs, stdout, stderr, gitClient) + case "uninstall": + return runHookUninstallCommand(subargs, stdout, stderr, gitClient) + case "run": + return runHookRunCommand(subargs, stdout, stderr, gitClient) + case "help", "-h", "--help", "-help": + printHookHelp(stdout) + return ExitCodeSuccess + default: + fmt.Fprintf(stderr, "Error: unknown hook sub-command %q\n\n", args[0]) + printHookHelp(stderr) + return ExitCodeUsageError + } +} + +func runHookInstallCommand(args []string, stdout, stderr io.Writer, gitClient git.Client) int { + fs := flag.NewFlagSet("hook install", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg hookInstallConfig + fs.StringVar(&cfg.path, "path", ".", "Target repository directory path") + fs.StringVar(&cfg.path, "p", ".", "Target repository directory path (shorthand)") + fs.BoolVar(&cfg.force, "force", false, "Overwrite existing pre-commit hooks") + fs.BoolVar(&cfg.force, "f", false, "Overwrite existing pre-commit hooks (shorthand)") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return ExitCodeSuccess + } + return ExitCodeUsageError + } + + mgr := hook.NewManager(gitClient) + hookPath, err := mgr.Install(cfg.path, cfg.force) + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeInternalError + } + + fmt.Fprintf(stdout, "✓ Successfully installed pre-commit hook at %s\n", hookPath) + return ExitCodeSuccess +} + +func runHookUninstallCommand(args []string, stdout, stderr io.Writer, gitClient git.Client) int { + fs := flag.NewFlagSet("hook uninstall", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg hookUninstallConfig + fs.StringVar(&cfg.path, "path", ".", "Target repository directory path") + fs.StringVar(&cfg.path, "p", ".", "Target repository directory path (shorthand)") + fs.BoolVar(&cfg.force, "force", false, "Force removal of non-envguard pre-commit hooks") + fs.BoolVar(&cfg.force, "f", false, "Force removal of hooks (shorthand)") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return ExitCodeSuccess + } + return ExitCodeUsageError + } + + mgr := hook.NewManager(gitClient) + hookPath, err := mgr.Uninstall(cfg.path, cfg.force) + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeInternalError + } + + fmt.Fprintf(stdout, "✓ Successfully removed pre-commit hook from %s\n", hookPath) + return ExitCodeSuccess +} + +func runHookRunCommand(args []string, stdout, stderr io.Writer, gitClient git.Client) int { + fs := flag.NewFlagSet("hook run", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg hookRunConfig + fs.StringVar(&cfg.path, "path", ".", "Target repository directory path") + fs.StringVar(&cfg.path, "p", ".", "Target repository directory path (shorthand)") + fs.StringVar(&cfg.configPath, "config", "", "Path to custom configuration file") + fs.StringVar(&cfg.configPath, "c", "", "Path to custom configuration file (shorthand)") + fs.BoolVar(&cfg.noColor, "no-color", false, "Disable ANSI color escape codes in terminal output") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return ExitCodeSuccess + } + return ExitCodeUsageError + } + + appConfig, _, err := config.DiscoverAndLoad(cfg.path, cfg.configPath) + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeUsageError + } + + det := detector.NewWithPatterns(appConfig.Detector.CustomPatterns, appConfig.Detector.Allowlist) + runner := hook.NewRunner(gitClient, det, appConfig) + + findings, err := runner.RunStagedCheck(cfg.path) + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeInternalError + } + + if len(findings) == 0 { + renderHookRunSuccess(stdout, cfg.noColor) + return ExitCodeSuccess + } + + renderHookRunViolations(stderr, findings, cfg.noColor) + return ExitCodeFindingsFound +} + +func renderHookRunSuccess(w io.Writer, noColor bool) { + green := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[32m" + s + "\033[0m" + } + fmt.Fprintf(w, "%s No unprotected environment files staged for commit.\n", green("✓")) +} + +func renderHookRunViolations(w io.Writer, findings []scanner.Finding, noColor bool) { + bold := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[1m" + s + "\033[0m" + } + red := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[31m" + s + "\033[0m" + } + boldRed := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[1;31m" + s + "\033[0m" + } + cyan := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[36m" + s + "\033[0m" + } + dim := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[90m" + s + "\033[0m" + } + + var sb strings.Builder + sb.WriteString(boldRed("🚨 Git Pre-Commit Check Failed!\n")) + sb.WriteString(red(fmt.Sprintf("Found %d unprotected environment file(s) staged for commit:\n\n", len(findings)))) + + for _, f := range findings { + sb.WriteString(fmt.Sprintf(" %s %s\n", boldRed("✗ [STAGED]"), bold(f.Path))) + if f.Message != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", dim("Message: "), f.Message)) + } + if len(f.Suggestions) > 0 { + sb.WriteString(fmt.Sprintf(" %s\n", cyan("Suggestions:"))) + for _, sug := range f.Suggestions { + sb.WriteString(fmt.Sprintf(" • %s\n", sug)) + } + } + sb.WriteString("\n") + } + + sb.WriteString(bold("Commit blocked to prevent sensitive credentials from reaching Git.\n")) + sb.WriteString(dim("To remediate, unstage the files or add them to .gitignore (or run `envguard fix`).\n\n")) + + fmt.Fprint(w, sb.String()) +} + +func printHookHelp(w io.Writer) { + help := `🛡️ envguard hook - Manage Git pre-commit hooks and perform staged inspections + +Usage: + envguard hook [flags] + +Available Subcommands: + install Install executable pre-commit hook into .git/hooks/pre-commit + run Inspect currently staged files and block commits with sensitive env files + uninstall Remove envguard pre-commit hook from .git/hooks/pre-commit + help Show help for hook commands + +Flags: + -p, --path Target repository directory path (default: ".") + -f, --force Force installation or removal over non-envguard hooks + -c, --config Path to custom configuration file (for run subcommand) + --no-color Disable ANSI color escape codes in output +` + fmt.Fprint(w, help) +} diff --git a/internal/cli/hook_test.go b/internal/cli/hook_test.go new file mode 100644 index 0000000..1510516 --- /dev/null +++ b/internal/cli/hook_test.go @@ -0,0 +1,164 @@ +package cli_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/cli" +) + +func setupTestGitRepo(t *testing.T) string { + t.Helper() + tempDir := t.TempDir() + if evalDir, err := filepath.EvalSymlinks(tempDir); err == nil { + tempDir = evalDir + } + + runGitCmd(t, tempDir, "init") + runGitCmd(t, tempDir, "config", "user.email", "test@envguard.dev") + runGitCmd(t, tempDir, "config", "user.name", "Envguard Test") + runGitCmd(t, tempDir, "config", "commit.gpgsign", "false") + + return tempDir +} + +func runGitCmd(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed in %s: %v\nOutput: %s", args, dir, err, string(out)) + } + return string(out) +} + +func TestHookHelpCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"hook", "--help"}) + if code != cli.ExitCodeSuccess { + t.Errorf("expected exit code %d, got %d", cli.ExitCodeSuccess, code) + } + if !strings.Contains(stdout.String(), "envguard hook - Manage Git pre-commit hooks") { + t.Errorf("expected hook help in output, got: %s", stdout.String()) + } + + stdout.Reset() + code = app.Run([]string{"hook"}) + if code != cli.ExitCodeSuccess { + t.Errorf("expected exit code %d, got %d", cli.ExitCodeSuccess, code) + } + if !strings.Contains(stdout.String(), "envguard hook - Manage Git pre-commit hooks") { + t.Errorf("expected hook help in output, got: %s", stdout.String()) + } +} + +func TestHookInstallAndUninstallCLI(t *testing.T) { + repoDir := setupTestGitRepo(t) + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + // 1. Install hook + code := app.Run([]string{"hook", "install", "--path", repoDir}) + if code != cli.ExitCodeSuccess { + t.Fatalf("hook install failed with exit code %d, stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Successfully installed pre-commit hook") { + t.Errorf("expected install confirmation in stdout, got: %s", stdout.String()) + } + + hookFile := filepath.Join(repoDir, ".git", "hooks", "pre-commit") + if _, err := os.Stat(hookFile); os.IsNotExist(err) { + t.Fatalf("hook file %s was not created", hookFile) + } + + // 2. Uninstall hook + stdout.Reset() + stderr.Reset() + code = app.Run([]string{"hook", "uninstall", "--path", repoDir}) + if code != cli.ExitCodeSuccess { + t.Fatalf("hook uninstall failed with exit code %d, stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Successfully removed pre-commit hook") { + t.Errorf("expected uninstall confirmation in stdout, got: %s", stdout.String()) + } + if _, err := os.Stat(hookFile); !os.IsNotExist(err) { + t.Errorf("hook file %s still exists after uninstall", hookFile) + } +} + +func TestHookRunCLI(t *testing.T) { + repoDir := setupTestGitRepo(t) + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + // 1. Clean stage -> exit code 0 + code := app.Run([]string{"hook", "run", "--path", repoDir, "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code 0 on clean stage, got %d", code) + } + if !strings.Contains(stdout.String(), "No unprotected environment files staged") { + t.Errorf("expected clean message in stdout, got: %s", stdout.String()) + } + + // 2. Stage safe file -> exit code 0 + exampleFile := filepath.Join(repoDir, ".env.example") + _ = os.WriteFile(exampleFile, []byte("KEY=\n"), 0644) + runGitCmd(t, repoDir, "add", ".env.example") + + stdout.Reset() + stderr.Reset() + code = app.Run([]string{"hook", "run", "--path", repoDir, "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code 0 with allowed staged file, got %d", code) + } + + // 3. Stage sensitive file -> exit code 1 + secretFile := filepath.Join(repoDir, ".env.production") + _ = os.WriteFile(secretFile, []byte("SECRET=true\n"), 0644) + runGitCmd(t, repoDir, "add", ".env.production") + + stdout.Reset() + stderr.Reset() + code = app.Run([]string{"hook", "run", "--path", repoDir, "--no-color"}) + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code 1 with staged secret, got %d", code) + } + if !strings.Contains(stderr.String(), "Git Pre-Commit Check Failed") { + t.Errorf("expected failure header in stderr, got: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), ".env.production") { + t.Errorf("expected .env.production in stderr findings, got: %s", stderr.String()) + } +} + +func TestHookCLIUnknownSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"hook", "unknown"}) + if code != cli.ExitCodeUsageError { + t.Errorf("expected ExitCodeUsageError (2), got %d", code) + } +} + +func TestHookCLINonGitRepo(t *testing.T) { + tempNonGit := t.TempDir() + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"hook", "install", "--path", tempNonGit}) + if code != cli.ExitCodeInternalError { + t.Errorf("expected ExitCodeInternalError (3) for non-git repo, got %d", code) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 765a72f..721ec6c 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -34,6 +34,10 @@ type Client interface { IsIgnored(dir string, filePath string) (bool, error) // GetFileStatus returns the consolidated FileStatus for a given file. GetFileStatus(dir string, filePath string) (FileStatus, error) + // GetStagedFiles returns the list of repository-relative paths currently staged in the index. + GetStagedFiles(dir string) ([]string, error) + // GetHooksDir returns the path to the Git hooks directory for the repository. + GetHooksDir(dir string) (string, error) } // GitClient is the standard implementation of Client. @@ -216,6 +220,63 @@ func (c *GitClient) GetFileStatus(dir string, filePath string) (FileStatus, erro }, nil } +// GetStagedFiles returns the list of repository-relative paths currently staged in the index. +func (c *GitClient) GetStagedFiles(dir string) ([]string, error) { + if !c.IsGitRepo(dir) { + return nil, fmt.Errorf("not a git repository: %s", dir) + } + + repoRoot, err := c.GetRepoRoot(dir) + if err != nil { + return nil, err + } + + stdout, stderr, exitCode, err := c.runner.Run(repoRoot, "diff", "--name-only", "--cached", "--diff-filter=ACM") + if err != nil || exitCode != 0 { + return nil, fmt.Errorf("failed to get staged files: %s (exit code %d)", strings.TrimSpace(string(stderr)), exitCode) + } + + trimmed := strings.TrimSpace(string(stdout)) + if trimmed == "" { + return []string{}, nil + } + + lines := strings.Split(trimmed, "\n") + var files []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, filepath.ToSlash(line)) + } + } + return files, nil +} + +// GetHooksDir returns the path to the Git hooks directory for the repository. +func (c *GitClient) GetHooksDir(dir string) (string, error) { + if !c.IsGitRepo(dir) { + return "", fmt.Errorf("not a git repository: %s", dir) + } + + repoRoot, err := c.GetRepoRoot(dir) + if err != nil { + return "", err + } + + stdout, _, exitCode, _ := c.runner.Run(repoRoot, "config", "--get", "core.hooksPath") + if exitCode == 0 { + customPath := strings.TrimSpace(string(stdout)) + if customPath != "" { + if filepath.IsAbs(customPath) { + return filepath.Clean(customPath), nil + } + return filepath.Clean(filepath.Join(repoRoot, customPath)), nil + } + } + + return filepath.Clean(filepath.Join(repoRoot, ".git", "hooks")), nil +} + // DefaultClient is the package-level default Git client. var DefaultClient = NewClient() @@ -253,3 +314,13 @@ func IsIgnored(dir string, filePath string) (bool, error) { func GetFileStatus(dir string, filePath string) (FileStatus, error) { return DefaultClient.GetFileStatus(dir, filePath) } + +// GetStagedFiles returns the list of staged files using the default client. +func GetStagedFiles(dir string) ([]string, error) { + return DefaultClient.GetStagedFiles(dir) +} + +// GetHooksDir returns the Git hooks directory using the default client. +func GetHooksDir(dir string) (string, error) { + return DefaultClient.GetHooksDir(dir) +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index cf40443..2561c7a 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -13,6 +13,9 @@ func setupTestGitRepo(t *testing.T) string { t.Helper() tempDir := t.TempDir() + if evalDir, err := filepath.EvalSymlinks(tempDir); err == nil { + tempDir = evalDir + } // Configure and initialize git repository runGitCmd(t, tempDir, "init") @@ -215,6 +218,84 @@ func TestGitUnavailable(t *testing.T) { } } +func TestGetStagedFiles(t *testing.T) { + repoDir := setupTestGitRepo(t) + client := NewClient() + + // Initial state: no staged files + staged, err := client.GetStagedFiles(repoDir) + if err != nil { + t.Fatalf("unexpected error on empty stage: %v", err) + } + if len(staged) != 0 { + t.Errorf("expected 0 staged files, got %d", len(staged)) + } + + // Create and stage files + file1 := filepath.Join(repoDir, ".env") + file2 := filepath.Join(repoDir, "sub", ".env.local") + if err := os.MkdirAll(filepath.Dir(file2), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file1, []byte("FOO=1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file2, []byte("BAR=2\n"), 0644); err != nil { + t.Fatal(err) + } + + runGitCmd(t, repoDir, "add", ".env", "sub/.env.local") + + staged, err = client.GetStagedFiles(repoDir) + if err != nil { + t.Fatalf("failed to get staged files: %v", err) + } + if len(staged) != 2 { + t.Fatalf("expected 2 staged files, got %d: %v", len(staged), staged) + } + + // Verify non-git directory returns error + tempNonGit := t.TempDir() + _, err = client.GetStagedFiles(tempNonGit) + if err == nil { + t.Errorf("expected error on non-git dir for GetStagedFiles") + } +} + +func TestGetHooksDir(t *testing.T) { + repoDir := setupTestGitRepo(t) + client := NewClient() + + hooksDir, err := client.GetHooksDir(repoDir) + if err != nil { + t.Fatalf("failed to get hooks dir: %v", err) + } + expectedDefault := filepath.Clean(filepath.Join(repoDir, ".git", "hooks")) + if hooksDir != expectedDefault { + t.Errorf("expected hooks dir %s, got %s", expectedDefault, hooksDir) + } + + // Test custom core.hooksPath + customHooksRel := ".githooks" + runGitCmd(t, repoDir, "config", "core.hooksPath", customHooksRel) + + hooksDirCustom, err := client.GetHooksDir(repoDir) + if err != nil { + t.Fatalf("failed to get custom hooks dir: %v", err) + } + expectedCustom := filepath.Clean(filepath.Join(repoDir, customHooksRel)) + if hooksDirCustom != expectedCustom { + t.Errorf("expected custom hooks dir %s, got %s", expectedCustom, hooksDirCustom) + } + + // Test non-git directory + tempNonGit := t.TempDir() + _, err = client.GetHooksDir(tempNonGit) + if err == nil { + t.Errorf("expected error on non-git dir for GetHooksDir") + } +} + func TestPackageLevelDefaults(t *testing.T) { // Test package-level helper methods _ = IsAvailable() @@ -224,4 +305,6 @@ func TestPackageLevelDefaults(t *testing.T) { _, _ = IsStaged(".", ".env") _, _ = IsIgnored(".", ".env") _, _ = GetFileStatus(".", ".env") + _, _ = GetStagedFiles(".") + _, _ = GetHooksDir(".") } diff --git a/internal/hook/manager.go b/internal/hook/manager.go new file mode 100644 index 0000000..e5597c8 --- /dev/null +++ b/internal/hook/manager.go @@ -0,0 +1,121 @@ +package hook + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joaooncode/envguard/internal/git" +) + +// HookSignature is the unique header comment used to identify envguard-managed hook scripts. +const HookSignature = "# Installed by envguard" + +// HookFileName is the Git hook file name for pre-commit. +const HookFileName = "pre-commit" + +// PreCommitHookScript is the POSIX shell script content installed to .git/hooks/pre-commit. +const PreCommitHookScript = `#!/usr/bin/env sh +# Installed by envguard +# Pre-commit hook to prevent uncommitted or leaked environment files + +if command -v envguard >/dev/null 2>&1; then + envguard hook run +else + echo "Warning: envguard is not installed or not found in PATH. Skipping hook check." >&2 +fi +` + +// Manager handles the lifecycle of Git hook scripts in local repositories. +type Manager struct { + gitClient git.Client +} + +// NewManager creates a Manager instance with the given Git client. +func NewManager(client git.Client) *Manager { + if client == nil { + client = git.NewClient() + } + return &Manager{ + gitClient: client, + } +} + +// Install writes the envguard pre-commit hook script to the repository's Git hooks directory. +func (m *Manager) Install(repoPath string, force bool) (string, error) { + if repoPath == "" { + repoPath = "." + } + + if !m.gitClient.IsGitRepo(repoPath) { + return "", fmt.Errorf("not a git repository: %s", repoPath) + } + + hooksDir, err := m.gitClient.GetHooksDir(repoPath) + if err != nil { + return "", fmt.Errorf("failed to locate hooks directory: %w", err) + } + + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", fmt.Errorf("failed to create hooks directory: %w", err) + } + + hookPath := filepath.Join(hooksDir, HookFileName) + + // Check if hook already exists + if data, err := os.ReadFile(hookPath); err == nil { + content := string(data) + isEnvguardHook := strings.Contains(content, HookSignature) + if !isEnvguardHook && !force { + return "", fmt.Errorf("existing pre-commit hook found at %s. Use --force to overwrite", hookPath) + } + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("failed to read existing hook: %w", err) + } + + // Write executable hook script (0755) + if err := os.WriteFile(hookPath, []byte(PreCommitHookScript), 0755); err != nil { + return "", fmt.Errorf("failed to write pre-commit hook: %w", err) + } + + return hookPath, nil +} + +// Uninstall removes the envguard pre-commit hook script from the repository's Git hooks directory. +func (m *Manager) Uninstall(repoPath string, force bool) (string, error) { + if repoPath == "" { + repoPath = "." + } + + if !m.gitClient.IsGitRepo(repoPath) { + return "", fmt.Errorf("not a git repository: %s", repoPath) + } + + hooksDir, err := m.gitClient.GetHooksDir(repoPath) + if err != nil { + return "", fmt.Errorf("failed to locate hooks directory: %w", err) + } + + hookPath := filepath.Join(hooksDir, HookFileName) + + data, err := os.ReadFile(hookPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("no pre-commit hook found at %s", hookPath) + } + return "", fmt.Errorf("failed to inspect pre-commit hook: %w", err) + } + + isEnvguardHook := strings.Contains(string(data), HookSignature) + if !isEnvguardHook && !force { + return "", fmt.Errorf("existing pre-commit hook at %s was not installed by envguard. Use --force to remove", hookPath) + } + + if err := os.Remove(hookPath); err != nil { + return "", fmt.Errorf("failed to remove pre-commit hook: %w", err) + } + + return hookPath, nil +} diff --git a/internal/hook/manager_test.go b/internal/hook/manager_test.go new file mode 100644 index 0000000..7e4d396 --- /dev/null +++ b/internal/hook/manager_test.go @@ -0,0 +1,139 @@ +package hook + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/git" +) + +func setupTestGitRepo(t *testing.T) string { + t.Helper() + tempDir := t.TempDir() + if evalDir, err := filepath.EvalSymlinks(tempDir); err == nil { + tempDir = evalDir + } + + cmd := exec.Command("git", "init") + cmd.Dir = tempDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init failed: %v, out: %s", err, string(out)) + } + return tempDir +} + +func TestManagerInstallAndUninstall(t *testing.T) { + repoDir := setupTestGitRepo(t) + mgr := NewManager(git.NewClient()) + + // 1. Install hook + hookPath, err := mgr.Install(repoDir, false) + if err != nil { + t.Fatalf("Install failed: %v", err) + } + + expectedPath := filepath.Join(repoDir, ".git", "hooks", HookFileName) + if filepath.Clean(hookPath) != filepath.Clean(expectedPath) { + t.Errorf("expected hook path %s, got %s", expectedPath, hookPath) + } + + content, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("failed to read hook file: %v", err) + } + if !strings.Contains(string(content), HookSignature) { + t.Errorf("expected signature %q in hook script", HookSignature) + } + + // 2. Re-installing over envguard hook succeeds without force + _, err = mgr.Install(repoDir, false) + if err != nil { + t.Fatalf("re-installing over envguard hook failed: %v", err) + } + + // 3. Uninstall hook + uninstalledPath, err := mgr.Uninstall(repoDir, false) + if err != nil { + t.Fatalf("Uninstall failed: %v", err) + } + if filepath.Clean(uninstalledPath) != filepath.Clean(expectedPath) { + t.Errorf("expected uninstalled path %s, got %s", expectedPath, uninstalledPath) + } + if _, err := os.Stat(hookPath); !os.IsNotExist(err) { + t.Errorf("expected hook file to be removed") + } + + // 4. Uninstalling when non-existent returns error + _, err = mgr.Uninstall(repoDir, false) + if err == nil { + t.Errorf("expected error when uninstalling missing hook") + } +} + +func TestManagerForeignHookConflict(t *testing.T) { + repoDir := setupTestGitRepo(t) + mgr := NewManager(git.NewClient()) + + hooksDir := filepath.Join(repoDir, ".git", "hooks") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + t.Fatal(err) + } + hookPath := filepath.Join(hooksDir, HookFileName) + + foreignScript := "#!/usr/bin/env sh\necho 'custom linter'\n" + if err := os.WriteFile(hookPath, []byte(foreignScript), 0755); err != nil { + t.Fatal(err) + } + + // Install without force should fail + _, err := mgr.Install(repoDir, false) + if err == nil { + t.Errorf("expected conflict error when installing over foreign hook") + } + + // Uninstall without force should fail + _, err = mgr.Uninstall(repoDir, false) + if err == nil { + t.Errorf("expected error when uninstalling foreign hook without force") + } + + // Install with force should succeed + _, err = mgr.Install(repoDir, true) + if err != nil { + t.Fatalf("expected install with force to succeed, got: %v", err) + } + + // Foreign hook should be replaced by envguard hook + content, _ := os.ReadFile(hookPath) + if !strings.Contains(string(content), HookSignature) { + t.Errorf("expected hook to be overwritten with envguard script") + } + + // Put foreign hook back to test uninstall with force + if err := os.WriteFile(hookPath, []byte(foreignScript), 0755); err != nil { + t.Fatal(err) + } + + _, err = mgr.Uninstall(repoDir, true) + if err != nil { + t.Fatalf("expected uninstall with force to succeed on foreign hook: %v", err) + } +} + +func TestManagerNonGitRepo(t *testing.T) { + tempNonGit := t.TempDir() + mgr := NewManager(nil) + + _, err := mgr.Install(tempNonGit, false) + if err == nil { + t.Errorf("expected error installing in non-git directory") + } + + _, err = mgr.Uninstall(tempNonGit, false) + if err == nil { + t.Errorf("expected error uninstalling in non-git directory") + } +} diff --git a/internal/hook/runner.go b/internal/hook/runner.go new file mode 100644 index 0000000..f2d3bb7 --- /dev/null +++ b/internal/hook/runner.go @@ -0,0 +1,122 @@ +package hook + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/joaooncode/envguard/internal/config" + "github.com/joaooncode/envguard/internal/detector" + "github.com/joaooncode/envguard/internal/git" + "github.com/joaooncode/envguard/internal/scanner" +) + +// Runner executes pre-commit inspection specifically targeting staged Git files. +type Runner struct { + gitClient git.Client + detector *detector.Detector + cfg *config.Config +} + +// NewRunner creates a new pre-commit Runner instance. +func NewRunner(client git.Client, det *detector.Detector, cfg *config.Config) *Runner { + if client == nil { + client = git.NewClient() + } + if cfg == nil { + cfg = config.NewDefault() + } + if det == nil { + det = detector.NewWithPatterns(cfg.Detector.CustomPatterns, cfg.Detector.Allowlist) + } + return &Runner{ + gitClient: client, + detector: det, + cfg: cfg, + } +} + +// RunStagedCheck inspects all files staged in the Git index and returns findings for unprotected environment files. +func (r *Runner) RunStagedCheck(repoPath string) ([]scanner.Finding, error) { + if repoPath == "" { + repoPath = "." + } + + if !r.gitClient.IsGitRepo(repoPath) { + return nil, fmt.Errorf("not a git repository: %s", repoPath) + } + + stagedFiles, err := r.gitClient.GetStagedFiles(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to retrieve staged files: %w", err) + } + + var findings []scanner.Finding + for _, stagedPath := range stagedFiles { + cleanPath := filepath.ToSlash(stagedPath) + isEnv, isAllowed := r.detector.Detect(cleanPath) + if !isEnv { + continue + } + + // Safe templates matching allowlist are ignored in pre-commit blocking check + if isAllowed { + continue + } + + // File is staged and not allowed + severity := scanner.SeverityHigh + message := "Environment file is staged for commit in Git index." + suggestions := []string{ + fmt.Sprintf("Unstage file: git restore --staged %s", cleanPath), + "Add to .gitignore", + } + + // Apply severity overrides if configured + baseName := filepath.Base(cleanPath) + for _, override := range r.cfg.Detector.SeverityOverrides { + patternLower := strings.ToLower(override.Pattern) + baseLower := strings.ToLower(baseName) + relLower := strings.ToLower(cleanPath) + + matched := false + if patternLower == baseLower || patternLower == relLower { + matched = true + } else if m, err := filepath.Match(patternLower, baseLower); err == nil && m { + matched = true + } else if m, err := filepath.Match(patternLower, relLower); err == nil && m { + matched = true + } + + if matched { + switch strings.ToLower(override.Severity) { + case "info": + severity = scanner.SeverityInfo + case "warning", "warn": + severity = scanner.SeverityWarning + case "high": + severity = scanner.SeverityHigh + case "critical": + severity = scanner.SeverityCritical + } + break + } + } + + findings = append(findings, scanner.Finding{ + Path: cleanPath, + Severity: severity, + Message: message, + Suggestions: suggestions, + GitStatus: git.FileStatus{ + IsRepo: true, + IsTracked: false, + IsStaged: true, + IsIgnored: false, + }, + IsAllowed: false, + }) + } + + return findings, nil +} diff --git a/internal/hook/runner_test.go b/internal/hook/runner_test.go new file mode 100644 index 0000000..236a0a5 --- /dev/null +++ b/internal/hook/runner_test.go @@ -0,0 +1,115 @@ +package hook + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/joaooncode/envguard/internal/config" + "github.com/joaooncode/envguard/internal/detector" + "github.com/joaooncode/envguard/internal/git" + "github.com/joaooncode/envguard/internal/scanner" +) + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v, out: %s", args, err, string(out)) + } +} + +func TestRunnerStagedCheck(t *testing.T) { + repoDir := setupTestGitRepo(t) + runGit(t, repoDir, "config", "user.email", "test@envguard.dev") + runGit(t, repoDir, "config", "user.name", "Envguard Test") + + r := NewRunner(git.NewClient(), nil, nil) + + // 1. Empty stage - no findings + findings, err := r.RunStagedCheck(repoDir) + if err != nil { + t.Fatalf("unexpected error on empty stage: %v", err) + } + if len(findings) != 0 { + t.Errorf("expected 0 findings on empty stage, got %d", len(findings)) + } + + // 2. Stage safe file (.env.example) and regular file (main.go) + examplePath := filepath.Join(repoDir, ".env.example") + mainPath := filepath.Join(repoDir, "main.go") + _ = os.WriteFile(examplePath, []byte("API_KEY=\n"), 0644) + _ = os.WriteFile(mainPath, []byte("package main\n"), 0644) + runGit(t, repoDir, "add", ".env.example", "main.go") + + findings, err = r.RunStagedCheck(repoDir) + if err != nil { + t.Fatalf("unexpected error checking allowed staged files: %v", err) + } + if len(findings) != 0 { + t.Errorf("expected 0 findings when only safe files are staged, got %d", len(findings)) + } + + // 3. Stage sensitive .env file and nested .env.production + secretPath := filepath.Join(repoDir, ".env") + nestedDir := filepath.Join(repoDir, "services", "api") + _ = os.MkdirAll(nestedDir, 0755) + nestedSecret := filepath.Join(nestedDir, ".env.production") + + _ = os.WriteFile(secretPath, []byte("SECRET=123\n"), 0644) + _ = os.WriteFile(nestedSecret, []byte("PROD_KEY=xyz\n"), 0644) + runGit(t, repoDir, "add", ".env", "services/api/.env.production") + + findings, err = r.RunStagedCheck(repoDir) + if err != nil { + t.Fatalf("failed to run staged check: %v", err) + } + if len(findings) != 2 { + t.Fatalf("expected 2 findings for staged secrets, got %d", len(findings)) + } + + for _, f := range findings { + if f.Severity != scanner.SeverityHigh { + t.Errorf("expected SeverityHigh, got %s for %s", f.Severity, f.Path) + } + if !f.GitStatus.IsStaged { + t.Errorf("expected IsStaged=true for %s", f.Path) + } + } + + // 4. Test with custom severity override + cfg := config.NewDefault() + cfg.Detector.SeverityOverrides = []config.SeverityOverride{ + { + Pattern: ".env", + Severity: "critical", + }, + } + det := detector.NewWithPatterns(cfg.Detector.CustomPatterns, cfg.Detector.Allowlist) + rWithConfig := NewRunner(git.NewClient(), det, cfg) + + findingsOverride, err := rWithConfig.RunStagedCheck(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + foundCritical := false + for _, f := range findingsOverride { + if f.Path == ".env" && f.Severity == scanner.SeverityCritical { + foundCritical = true + } + } + if !foundCritical { + t.Errorf("expected .env to be overridden to critical severity") + } + + // 5. Test non-git directory + tempNonGit := t.TempDir() + _, err = r.RunStagedCheck(tempNonGit) + if err == nil { + t.Errorf("expected error running on non-git directory") + } +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index b4d711a..1b26bae 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -375,6 +375,10 @@ func (m *mockGitClient) GetRepoRoot(dir string) (string, error) { return d func (m *mockGitClient) IsTracked(dir, filePath string) (bool, error) { return false, nil } func (m *mockGitClient) IsStaged(dir, filePath string) (bool, error) { return false, nil } func (m *mockGitClient) IsIgnored(dir, filePath string) (bool, error) { return false, nil } +func (m *mockGitClient) GetStagedFiles(dir string) ([]string, error) { return nil, nil } +func (m *mockGitClient) GetHooksDir(dir string) (string, error) { + return filepath.Join(dir, ".git", "hooks"), nil +} func (m *mockGitClient) GetFileStatus(dir, filePath string) (git.FileStatus, error) { if m.statusFn != nil { return m.statusFn(dir, filePath)