diff --git a/cmd/envguard/main.go b/cmd/envguard/main.go index 4f1f354..d2bf9ce 100644 --- a/cmd/envguard/main.go +++ b/cmd/envguard/main.go @@ -1,7 +1,13 @@ package main -import "fmt" +import ( + "os" + + "github.com/joaooncode/envguard/internal/cli" +) func main() { - fmt.Println("Hello, World! Bem-vindo ao envguard 🛡️") + code := cli.Run(os.Args[1:], os.Stdout, os.Stderr) + os.Exit(code) } + diff --git a/internal/cli/check.go b/internal/cli/check.go new file mode 100644 index 0000000..56b183b --- /dev/null +++ b/internal/cli/check.go @@ -0,0 +1,94 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "strings" + + "github.com/joaooncode/envguard/internal/reporter" + "github.com/joaooncode/envguard/internal/scanner" +) + +func runCheckCommand(args []string, stdout, stderr io.Writer, scannerInstance *scanner.Scanner) int { + fs := flag.NewFlagSet("check", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg scanConfig + fs.StringVar(&cfg.path, "path", ".", "Target directory path to check") + fs.StringVar(&cfg.path, "p", ".", "Target directory path to check (shorthand)") + fs.StringVar(&cfg.format, "format", "text", "Output format: text|terminal|json") + fs.StringVar(&cfg.format, "f", "text", "Output format (shorthand)") + fs.StringVar(&cfg.severity, "severity", "all", "Minimum severity level to trigger check failure (info, warning, high, critical)") + fs.StringVar(&cfg.severity, "s", "all", "Minimum severity level (shorthand)") + fs.BoolVar(&cfg.noColor, "no-color", false, "Disable ANSI color escape codes in terminal output") + + if err := fs.Parse(args); err != nil { + return ExitCodeUsageError + } + + // Validate format + var repFormat reporter.Format + switch strings.ToLower(strings.TrimSpace(cfg.format)) { + case "text", "terminal", "": + repFormat = reporter.FormatTerminal + case "json": + repFormat = reporter.FormatJSON + default: + fmt.Fprintf(stderr, "Error: invalid format %q. Supported formats: text, terminal, json\n", cfg.format) + return ExitCodeUsageError + } + + // Validate severity + minSev, ok := parseSeverity(cfg.severity) + if !ok { + fmt.Fprintf(stderr, "Error: invalid severity %q. Supported levels: info, warning, high, critical, all\n", cfg.severity) + return ExitCodeUsageError + } + + if scannerInstance == nil { + scannerInstance = scanner.DefaultScanner + } + + result, err := scannerInstance.Scan(cfg.path) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to scan directory %s: %v\n", cfg.path, err) + return ExitCodeInternalError + } + + result.Version = Version + + if minSev != "" { + minRank := severityRank(minSev) + filtered := make([]scanner.Finding, 0, len(result.Findings)) + for _, f := range result.Findings { + if severityRank(f.Severity) >= minRank { + filtered = append(filtered, f) + } + } + result.Findings = filtered + result.CalculateSummary() + } + + rep, err := reporter.New( + repFormat, + reporter.WithNoColor(cfg.noColor), + reporter.WithVersion(Version), + reporter.WithPretty(true), + ) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to initialize reporter: %v\n", err) + return ExitCodeInternalError + } + + if err := rep.Render(result, stdout); err != nil { + fmt.Fprintf(stderr, "Error: failed to render check results: %v\n", err) + return ExitCodeInternalError + } + + if !result.Summary.Passed { + return ExitCodeFindingsFound + } + + return ExitCodeSuccess +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..dc41365 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,111 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "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 +} + +// Option configures an App instance. +type Option func(*App) + +// WithScanner sets a custom Scanner instance (useful for testing). +func WithScanner(s *scanner.Scanner) Option { + return func(a *App) { + a.scanner = s + } +} + +// New creates a new App configured with stdout and stderr writers. +func New(stdout, stderr io.Writer, opts ...Option) *App { + app := &App{ + stdout: stdout, + stderr: stderr, + } + for _, opt := range opts { + opt(app) + } + return app +} + +// Run executes the command line application with the provided arguments and returns an exit code. +func Run(args []string, stdout, stderr io.Writer, opts ...Option) int { + app := New(stdout, stderr, opts...) + return app.Run(args) +} + +// Run parses arguments, dispatches commands, and returns deterministic exit codes. +func (a *App) Run(args []string) int { + if len(args) == 0 { + a.printHelp() + return ExitCodeSuccess + } + + cmd := strings.ToLower(strings.TrimSpace(args[0])) + + switch cmd { + case "version", "-v", "--version", "-version": + fmt.Fprintf(a.stdout, "%s\n", VersionString()) + return ExitCodeSuccess + + case "help", "-h", "--help", "-help": + a.printHelp() + return ExitCodeSuccess + + case "scan": + return runScanCommand(args[1:], a.stdout, a.stderr, a.scanner) + + case "check": + return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner) + + default: + fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0]) + a.printHelpTo(a.stderr) + return ExitCodeUsageError + } +} + +func (a *App) printHelp() { + a.printHelpTo(a.stdout) +} + +func (a *App) printHelpTo(w io.Writer) { + help := `🛡️ envguard - Prevent .env files and environment secrets from reaching Git + +Usage: + envguard [flags] + +Available Commands: + scan Scan a directory for unprotected environment files + check Run verification optimized for CI/CD pipelines + version Show current envguard version + help Show help for envguard commands + +Flags: + -h, --help Show help for envguard + -v, --version Show envguard version + +Scan & Check Flags: + -p, --path Target directory path to scan (default: ".") + -f, --format Output format: text|terminal|json (default: "text") + -s, --severity Minimum severity level: info|warning|high|critical|all (default: "all") + --no-color Disable ANSI color escape codes in terminal output + +Examples: + envguard scan + envguard scan --path ./my-project --format json + envguard scan --severity warning + envguard check --path . --severity high + envguard version +` + fmt.Fprint(w, help) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..e2c1930 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,294 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/cli" + "github.com/joaooncode/envguard/internal/reporter" +) + +func TestCLIVersion(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"version subcommand", []string{"version"}}, + {"-v flag", []string{"-v"}}, + {"--version flag", []string{"--version"}}, + {"-version flag", []string{"-version"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run(tt.args, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "envguard v0.1.0") { + t.Fatalf("expected stdout to contain 'envguard v0.1.0', got: %s", out) + } + }) + } +} + +func TestCLIHelp(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"no args", []string{}}, + {"help subcommand", []string{"help"}}, + {"-h flag", []string{"-h"}}, + {"--help flag", []string{"--help"}}, + {"-help flag", []string{"-help"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run(tt.args, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeSuccess, code) + } + + out := stdout.String() + if !strings.Contains(out, "Usage:") || !strings.Contains(out, "scan") || !strings.Contains(out, "check") { + t.Fatalf("expected help text in stdout, got: %s", out) + } + }) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"unknowncmd"}, &stdout, &stderr) + + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeUsageError, code) + } + + errOut := stderr.String() + if !strings.Contains(errOut, "unknown command or flag") { + t.Fatalf("expected error message in stderr, got: %s", errOut) + } +} + +func TestCLIScanFlagErrors(t *testing.T) { + t.Run("invalid flag", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--nonexistent-flag"}, &stdout, &stderr) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeUsageError, code) + } + }) + + t.Run("invalid format", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--format", "yaml"}, &stdout, &stderr) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeUsageError, code) + } + if !strings.Contains(stderr.String(), "invalid format") { + t.Fatalf("expected format error in stderr, got: %s", stderr.String()) + } + }) + + t.Run("invalid severity", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--severity", "extreme"}, &stdout, &stderr) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeUsageError, code) + } + if !strings.Contains(stderr.String(), "invalid severity") { + t.Fatalf("expected severity error in stderr, got: %s", stderr.String()) + } + }) +} + +func TestCLIScanNonExistentDirectory(t *testing.T) { + var stdout, stderr bytes.Buffer + nonExistentPath := filepath.Join(t.TempDir(), "non_existent_folder") + code := cli.Run([]string{"scan", "--path", nonExistentPath}, &stdout, &stderr) + + if code != cli.ExitCodeInternalError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeInternalError, code) + } + + if !strings.Contains(stderr.String(), "failed to scan directory") { + t.Fatalf("expected scan failure message in stderr, got: %s", stderr.String()) + } +} + +func TestCLIScanCleanDirectory(t *testing.T) { + tmpDir := t.TempDir() + + // Add an allowed template file + err := os.WriteFile(filepath.Join(tmpDir, ".env.example"), []byte("PORT=8080"), 0644) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + t.Run("terminal format default", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--no-color"}, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "PASSED") { + t.Fatalf("expected PASSED status in stdout, got: %s", out) + } + }) + + t.Run("json format", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--format", "json"}, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + var report reporter.JSONReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("failed to parse json output: %v. Output:\n%s", err, stdout.String()) + } + + if !report.Summary.Passed { + t.Fatalf("expected report summary passed to be true, got false") + } + if report.Version != "0.1.0" { + t.Fatalf("expected report version 0.1.0, got %s", report.Version) + } + }) +} + +func TestCLIScanDirectoryWithFindings(t *testing.T) { + tmpDir := t.TempDir() + + // Create an unprotected .env file (not ignored by git) + err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("SECRET_KEY=12345"), 0644) + if err != nil { + t.Fatalf("failed to create temp .env: %v", err) + } + + t.Run("terminal format failure", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--no-color"}, &stdout, &stderr) + + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeFindingsFound, code) + } + + out := stdout.String() + if !strings.Contains(out, "FAILED") || !strings.Contains(out, ".env") { + t.Fatalf("expected FAILED and .env in stdout, got: %s", out) + } + }) + + t.Run("json format failure", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--format", "json"}, &stdout, &stderr) + + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeFindingsFound, code) + } + + var report reporter.JSONReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("failed to parse json output: %v. Output:\n%s", err, stdout.String()) + } + + if report.Summary.Passed { + t.Fatalf("expected report summary passed to be false") + } + if len(report.Findings) != 1 { + t.Fatalf("expected 1 finding, got %d", len(report.Findings)) + } + }) + + t.Run("severity threshold filtering", func(t *testing.T) { + // An unignored .env in a non-git dir yields WARNING severity. + // Filtering for CRITICAL severity should filter it out and result in PASSED. + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--severity", "critical"}, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d when filtering for critical, got %d", cli.ExitCodeSuccess, code) + } + + // Filtering for WARNING severity should retain it and result in failure. + stdout.Reset() + stderr.Reset() + code = cli.Run([]string{"scan", "--path", tmpDir, "--severity", "warning"}, &stdout, &stderr) + + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code %d when filtering for warning, got %d", cli.ExitCodeFindingsFound, code) + } + }) +} + +func TestCLICheckCommand(t *testing.T) { + t.Run("flag error", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"check", "--bad-flag"}, &stdout, &stderr) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeUsageError, code) + } + }) + + t.Run("non existent directory", func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"check", "--path", "/path/that/does/not/exist"}, &stdout, &stderr) + if code != cli.ExitCodeInternalError { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeInternalError, code) + } + }) + + t.Run("clean directory check", func(t *testing.T) { + tmpDir := t.TempDir() + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"check", "-p", tmpDir, "--no-color"}, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeSuccess, code) + } + if !strings.Contains(stdout.String(), "PASSED") { + t.Fatalf("expected PASSED status in output, got: %s", stdout.String()) + } + }) + + t.Run("violation directory check", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, ".env.production"), []byte("PROD=true"), 0644) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"check", "-p", tmpDir, "-f", "json"}, &stdout, &stderr) + + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeFindingsFound, code) + } + + var report reporter.JSONReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("failed to parse json output: %v", err) + } + if report.Summary.Passed { + t.Fatalf("expected summary passed to be false") + } + }) +} diff --git a/internal/cli/exitcodes.go b/internal/cli/exitcodes.go new file mode 100644 index 0000000..589dccf --- /dev/null +++ b/internal/cli/exitcodes.go @@ -0,0 +1,16 @@ +package cli + +// Exit codes returned by the envguard CLI. +const ( + // ExitCodeSuccess indicates successful execution with no blocking findings (0). + ExitCodeSuccess = 0 + + // ExitCodeFindingsFound indicates one or more blocking findings were detected (1). + ExitCodeFindingsFound = 1 + + // ExitCodeUsageError indicates invalid arguments, unknown flags, or invalid subcommands (2). + ExitCodeUsageError = 2 + + // ExitCodeInternalError indicates an internal error occurred during execution (3). + ExitCodeInternalError = 3 +) diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..3e77ffe --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,137 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "strings" + + "github.com/joaooncode/envguard/internal/reporter" + "github.com/joaooncode/envguard/internal/scanner" +) + +// scanConfig holds the parsed options for scan/check commands. +type scanConfig struct { + path string + format string + severity string + noColor bool +} + +func parseSeverity(s string) (scanner.Severity, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "info": + return scanner.SeverityInfo, true + case "warning", "warn": + return scanner.SeverityWarning, true + case "high": + return scanner.SeverityHigh, true + case "critical": + return scanner.SeverityCritical, true + case "all", "": + return "", true + default: + return "", false + } +} + +func severityRank(sev scanner.Severity) int { + switch sev { + case scanner.SeverityInfo: + return 0 + case scanner.SeverityWarning: + return 1 + case scanner.SeverityHigh: + return 2 + case scanner.SeverityCritical: + return 3 + default: + return 0 + } +} + +func runScanCommand(args []string, stdout, stderr io.Writer, scannerInstance *scanner.Scanner) int { + fs := flag.NewFlagSet("scan", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg scanConfig + fs.StringVar(&cfg.path, "path", ".", "Target directory path to scan") + fs.StringVar(&cfg.path, "p", ".", "Target directory path to scan (shorthand)") + fs.StringVar(&cfg.format, "format", "text", "Output format: text|terminal|json") + fs.StringVar(&cfg.format, "f", "text", "Output format (shorthand)") + fs.StringVar(&cfg.severity, "severity", "all", "Minimum severity level to report (info, warning, high, critical)") + fs.StringVar(&cfg.severity, "s", "all", "Minimum severity level (shorthand)") + fs.BoolVar(&cfg.noColor, "no-color", false, "Disable ANSI color escape codes in terminal output") + + if err := fs.Parse(args); err != nil { + return ExitCodeUsageError + } + + // Validate format + var repFormat reporter.Format + switch strings.ToLower(strings.TrimSpace(cfg.format)) { + case "text", "terminal", "": + repFormat = reporter.FormatTerminal + case "json": + repFormat = reporter.FormatJSON + default: + fmt.Fprintf(stderr, "Error: invalid format %q. Supported formats: text, terminal, json\n", cfg.format) + return ExitCodeUsageError + } + + // Validate severity + minSev, ok := parseSeverity(cfg.severity) + if !ok { + fmt.Fprintf(stderr, "Error: invalid severity %q. Supported levels: info, warning, high, critical, all\n", cfg.severity) + return ExitCodeUsageError + } + + if scannerInstance == nil { + scannerInstance = scanner.DefaultScanner + } + + result, err := scannerInstance.Scan(cfg.path) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to scan directory %s: %v\n", cfg.path, err) + return ExitCodeInternalError + } + + // Set version metadata + result.Version = Version + + // Filter by minimum severity if specified + if minSev != "" { + minRank := severityRank(minSev) + filtered := make([]scanner.Finding, 0, len(result.Findings)) + for _, f := range result.Findings { + if severityRank(f.Severity) >= minRank { + filtered = append(filtered, f) + } + } + result.Findings = filtered + result.CalculateSummary() + } + + // Create and render reporter + rep, err := reporter.New( + repFormat, + reporter.WithNoColor(cfg.noColor), + reporter.WithVersion(Version), + reporter.WithPretty(true), + ) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to initialize reporter: %v\n", err) + return ExitCodeInternalError + } + + if err := rep.Render(result, stdout); err != nil { + fmt.Fprintf(stderr, "Error: failed to render scan results: %v\n", err) + return ExitCodeInternalError + } + + if !result.Summary.Passed { + return ExitCodeFindingsFound + } + + return ExitCodeSuccess +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..daea8e5 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,11 @@ +package cli + +import "fmt" + +// Version is the current semantic version of envguard. +const Version = "0.1.0" + +// VersionString returns the formatted version string. +func VersionString() string { + return fmt.Sprintf("envguard v%s", Version) +}