Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,3 @@ website/build/
website/.docusaurus/
website/node_modules/

# Local ADR documentation
docs/adr/
8 changes: 8 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -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]
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions docs/adr/0002-init-command-and-template-generation.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/adr/0003-fix-command-and-remediation.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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`.
21 changes: 21 additions & 0 deletions docs/adr/0004-git-pre-commit-hooks.md
Original file line number Diff line number Diff line change
@@ -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 <subcommand>`)**:
- `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]`.
28 changes: 24 additions & 4 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
242 changes: 242 additions & 0 deletions internal/cli/hook.go
Original file line number Diff line number Diff line change
@@ -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 <subcommand> [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)
}
Loading
Loading