diff --git a/.gitignore b/.gitignore index 98743bb..c28bd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ npm-debug.log* website/build/ website/.docusaurus/ website/node_modules/ + +# Local ADR documentation +docs/adr/ diff --git a/CONTEXT.md b/CONTEXT.md index 9c7f138..751a098 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -29,5 +29,13 @@ An explicit configuration rule that replaces the calculated severity level for f _Avoid_: Custom rule, priority tweak **Finding**: -A detected environment file with its assigned severity level, git status, and mitigation suggestions. +The detected environment file with its assigned severity level, git status, and mitigation suggestions. _Avoid_: Vulnerability, issue, report item + +**Initializer**: +The CLI component responsible for bootstrapping repository configuration (`.envguard.yaml`) and safe environment templates (`.env.example`). +_Avoid_: Setup generator, config creator, scaffolder + +**Sanitization**: +The process of stripping secret values from environment definitions while preserving comments, formatting, and key names to produce safe templates. +_Avoid_: Masking, redacting, cleaning diff --git a/README.md b/README.md index b40bc09..5a050b9 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,28 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas envguard check ``` -### 3. Saída Estruturada em JSON +### 3. Inicialização de Configuração e Templates (`init`) + +Gera o arquivo de configuração `.envguard.yaml` documentado e, opcionalmente, cria templates `.env.example` sanitizados a partir de variáveis locais: + +```bash +# Inicializar .envguard.yaml padrão +envguard init + +# Inicializar configuração e gerar template .env.example sanitizado +envguard init --template + +# Inicializar em diretório específico sobrescrevendo arquivos existentes +envguard init --path ./meu-projeto --force +``` + +### 4. Saída Estruturada em JSON ```bash envguard scan --format json ``` -### 4. Verificar Versão +### 5. Verificar Versão ```bash envguard version @@ -118,8 +133,6 @@ envguard version - **Padrões monitorados:** `.env`, `.env.*`, `*.env` - **Exceções seguras permitidas por padrão:** `.env.example`, `.env.sample`, `.env.template` -_(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está no roadmap da v0.2)_ - --- ## Roadmap @@ -130,7 +143,7 @@ _(O suporte a configurações personalizadas via arquivo `.envguard.yaml` está - [x] Relatórios em Terminal e JSON - [x] Códigos de saída para CI/CD - [ ] **v0.2.0:** - - [ ] `envguard init` (criação automática de `.envguard.yaml` e templates) + - [x] `envguard init` (criação automática de `.envguard.yaml` e templates) - [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`) - [ ] Instalação de _Git Precommit Hooks_ - [ ] **v0.3.0:** diff --git a/docs/adr/0001-yaml-configuration-support.md b/docs/adr/0001-yaml-configuration-support.md deleted file mode 100644 index 3ccc320..0000000 --- a/docs/adr/0001-yaml-configuration-support.md +++ /dev/null @@ -1,3 +0,0 @@ -# 0001: YAML Configuration File Support (.envguard.yaml) - -To allow repositories to customize detection rules, allowlists, ignored directories, and severity levels, `envguard` will support project configuration files (`.envguard.yaml` and `.envguard.yml`) and an explicit `--config` CLI flag. Configuration parsing uses strict decoding via `gopkg.in/yaml.v3`, failing fast on invalid syntax or unknown fields to prevent silent security misconfigurations. User-provided allowlists and ignore directories append to the built-in safe defaults by default. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index dc41365..ec811af 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -67,6 +67,9 @@ func (a *App) Run(args []string) int { case "check": return runCheckCommand(args[1:], a.stdout, a.stderr, a.scanner) + case "init": + return runInitCommand(args[1:], a.stdout, a.stderr) + default: fmt.Fprintf(a.stderr, "Error: unknown command or flag %q\n\n", args[0]) a.printHelpTo(a.stderr) @@ -87,6 +90,7 @@ Usage: Available Commands: scan Scan a directory for unprotected environment files check Run verification optimized for CI/CD pipelines + init Initialize configuration file and safe template files version Show current envguard version help Show help for envguard commands @@ -100,11 +104,20 @@ Scan & Check Flags: -s, --severity Minimum severity level: info|warning|high|critical|all (default: "all") --no-color Disable ANSI color escape codes in terminal output +Init Flags: + -p, --path Target directory path to initialize (default: ".") + -f, --force Overwrite existing configuration or template files + -t, --template Generate a safe .env.example template file + --template-from Source .env file to sanitize and create template from + Examples: envguard scan envguard scan --path ./my-project --format json envguard scan --severity warning envguard check --path . --severity high + envguard init + envguard init --template + envguard init --path ./my-project --force envguard version ` fmt.Fprint(w, help) diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..5b5d4db --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,63 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "path/filepath" + + "github.com/joaooncode/envguard/internal/initializer" +) + +type initConfig struct { + path string + force bool + template bool + templateFrom string +} + +func runInitCommand(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg initConfig + fs.StringVar(&cfg.path, "path", ".", "Target directory path to initialize") + fs.StringVar(&cfg.path, "p", ".", "Target directory path to initialize (shorthand)") + fs.BoolVar(&cfg.force, "force", false, "Overwrite existing configuration or template files") + fs.BoolVar(&cfg.force, "f", false, "Overwrite existing files (shorthand)") + fs.BoolVar(&cfg.template, "template", false, "Generate a safe .env.example template file") + fs.BoolVar(&cfg.template, "t", false, "Generate a safe .env.example template file (shorthand)") + fs.StringVar(&cfg.templateFrom, "template-from", "", "Source .env file to sanitize and create .env.example from") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return ExitCodeSuccess + } + return ExitCodeUsageError + } + + if cfg.path == "" { + cfg.path = "." + } + + // 1. Generate configuration file (.envguard.yaml) + if err := initializer.GenerateConfig(cfg.path, cfg.force); err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeInternalError + } + configFilePath := filepath.Join(cfg.path, ".envguard.yaml") + fmt.Fprintf(stdout, "Created configuration file: %s\n", configFilePath) + + // 2. Generate template if requested + if cfg.template || cfg.templateFrom != "" { + if err := initializer.GenerateTemplate(cfg.path, cfg.templateFrom, cfg.force); err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeInternalError + } + templateFilePath := filepath.Join(cfg.path, ".env.example") + fmt.Fprintf(stdout, "Created template file: %s\n", templateFilePath) + } + + return ExitCodeSuccess +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..9d89be1 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,141 @@ +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/cli" +) + +func TestCLIInitHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "--help"}, &stdout, &stderr) + + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d on init --help, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + if !strings.Contains(stderr.String(), "Usage of init:") { + t.Errorf("expected usage output in stderr, got: %s", stderr.String()) + } +} + +func TestCLIInitDefault(t *testing.T) { + tmpDir := t.TempDir() + var stdout, stderr bytes.Buffer + + code := cli.Run([]string{"init", "--path", tmpDir}, &stdout, &stderr) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + configPath := filepath.Join(tmpDir, ".envguard.yaml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatalf("expected .envguard.yaml to be created at %s", configPath) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + if _, err := os.Stat(templatePath); !os.IsNotExist(err) { + t.Fatalf("expected .env.example NOT to be created when --template is not passed") + } + + if !strings.Contains(stdout.String(), "Created configuration file:") { + t.Errorf("expected stdout to report created config, got: %s", stdout.String()) + } +} + +func TestCLIInitWithTemplate(t *testing.T) { + tmpDir := t.TempDir() + + // Create dummy .env + envPath := filepath.Join(tmpDir, ".env") + if err := os.WriteFile(envPath, []byte("API_SECRET=mysecretvalue\nPORT=4000\n"), 0644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "-p", tmpDir, "--template"}, &stdout, &stderr) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + data, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read .env.example: %v", err) + } + + content := string(data) + if strings.Contains(content, "mysecretvalue") { + t.Errorf("expected sensitive value to be stripped, got: %s", content) + } + if !strings.Contains(content, "API_SECRET=") || !strings.Contains(content, "PORT=") { + t.Errorf("expected keys to be preserved, got: %s", content) + } +} + +func TestCLIInitWithTemplateFrom(t *testing.T) { + tmpDir := t.TempDir() + sourceEnv := filepath.Join(tmpDir, ".env.production") + if err := os.WriteFile(sourceEnv, []byte("PROD_DB=supersecret\n"), 0644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "-p", tmpDir, "--template-from", sourceEnv}, &stdout, &stderr) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + data, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read .env.example: %v", err) + } + + if !strings.Contains(string(data), "PROD_DB=") || strings.Contains(string(data), "supersecret") { + t.Errorf("unexpected template content: %s", string(data)) + } +} + +func TestCLIInitCollisionWithoutForce(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".envguard.yaml") + if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "-p", tmpDir}, &stdout, &stderr) + if code != cli.ExitCodeInternalError { + t.Fatalf("expected exit code %d on file collision, got %d", cli.ExitCodeInternalError, code) + } + + if !strings.Contains(stderr.String(), "already exists") { + t.Errorf("expected stderr to mention already exists, got: %s", stderr.String()) + } +} + +func TestCLIInitCollisionWithForce(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".envguard.yaml") + if err := os.WriteFile(configPath, []byte("existing config\n"), 0644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "-p", tmpDir, "--force"}, &stdout, &stderr) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d with --force, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } +} + +func TestCLIInitInvalidFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"init", "--invalid-flag"}, &stdout, &stderr) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d for invalid flag, got %d", cli.ExitCodeUsageError, code) + } +} diff --git a/internal/initializer/initializer.go b/internal/initializer/initializer.go new file mode 100644 index 0000000..97b7737 --- /dev/null +++ b/internal/initializer/initializer.go @@ -0,0 +1,155 @@ +package initializer + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DefaultConfigContent contains the well-commented default .envguard.yaml configuration. +const DefaultConfigContent = `# envguard configuration file +# For more information, visit https://github.com/joaooncode/envguard + +version: "1" + +scanner: + # Directories to ignore during recursive filesystem traversal + ignore_dirs: + - node_modules + - vendor + - .git + +detector: + # Additional regex or glob patterns to detect as environment files + custom_patterns: [] + + # Safe template patterns or example files that should not raise security warnings + allowlist: + - "*.example" + - "*.sample" + - "*.template" + + # Explicit severity overrides for specific patterns + # Supported severities: info, warning, high, critical + severity_overrides: [] +` + +// DefaultTemplateContent contains the boilerplate .env.example template. +const DefaultTemplateContent = `# Environment variables example template +# Copy this file to .env and fill in your actual values + +# Application +PORT=3000 +APP_ENV=development + +# Database +DATABASE_URL= + +# Authentication & Secrets +API_KEY= +JWT_SECRET= +` + +// SanitizeEnv reads an environment file and strips all sensitive values, +// keeping variable names, empty lines, and comments intact. +func SanitizeEnv(r io.Reader) string { + scanner := bufio.NewScanner(r) + var sb strings.Builder + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + sb.WriteString(line) + sb.WriteString("\n") + continue + } + + if idx := strings.Index(line, "="); idx != -1 { + keyPart := strings.TrimRight(line[:idx], " \t") + sb.WriteString(keyPart) + sb.WriteString("=\n") + } else { + sb.WriteString(line) + sb.WriteString("\n") + } + } + + return sb.String() +} + +// GenerateConfig creates the default .envguard.yaml in targetDir. +// If the file already exists and force is false, it returns an error. +func GenerateConfig(targetDir string, force bool) error { + if targetDir == "" { + targetDir = "." + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create target directory %s: %w", targetDir, err) + } + + configPath := filepath.Join(targetDir, ".envguard.yaml") + if !force { + if _, err := os.Stat(configPath); err == nil { + return fmt.Errorf("configuration file already exists at %s (use --force to overwrite)", configPath) + } + } + + if err := os.WriteFile(configPath, []byte(DefaultConfigContent), 0644); err != nil { + return fmt.Errorf("failed to write configuration file %s: %w", configPath, err) + } + + return nil +} + +// GenerateTemplate creates a safe .env.example file in targetDir. +// If sourceEnvPath is provided, it sanitizes that file. If empty, it checks +// for an existing .env in targetDir, or falls back to DefaultTemplateContent. +// If the file already exists and force is false, it returns an error. +func GenerateTemplate(targetDir, sourceEnvPath string, force bool) error { + if targetDir == "" { + targetDir = "." + } + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create target directory %s: %w", targetDir, err) + } + + templatePath := filepath.Join(targetDir, ".env.example") + if !force { + if _, err := os.Stat(templatePath); err == nil { + return fmt.Errorf("template file already exists at %s (use --force to overwrite)", templatePath) + } + } + + var content string + if sourceEnvPath != "" { + file, err := os.Open(sourceEnvPath) + if err != nil { + return fmt.Errorf("failed to open source env file %s: %w", sourceEnvPath, err) + } + defer file.Close() + content = SanitizeEnv(file) + } else { + defaultEnv := filepath.Join(targetDir, ".env") + if info, err := os.Stat(defaultEnv); err == nil && !info.IsDir() { + file, err := os.Open(defaultEnv) + if err != nil { + return fmt.Errorf("failed to open .env file %s: %w", defaultEnv, err) + } + defer file.Close() + content = SanitizeEnv(file) + } else { + content = DefaultTemplateContent + } + } + + if err := os.WriteFile(templatePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write template file %s: %w", templatePath, err) + } + + return nil +} diff --git a/internal/initializer/initializer_test.go b/internal/initializer/initializer_test.go new file mode 100644 index 0000000..1507ef6 --- /dev/null +++ b/internal/initializer/initializer_test.go @@ -0,0 +1,217 @@ +package initializer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/config" +) + +func TestSanitizeEnv(t *testing.T) { + input := `# Project configuration +PORT=8080 +DATABASE_URL="postgres://user:pass@localhost:5432/db" + +# Secret credentials +SECRET_KEY='super-secret-123' +JWT_TOKEN=xyz.abc.123 +export API_ENDPOINT=https://api.example.com +export API_KEY=secret_key + +# Empty variable +EMPTY_VAR= + +# Comments and whitespaces + +ANOTHER_VAR=123 # inline comments +` + + expected := `# Project configuration +PORT= +DATABASE_URL= + +# Secret credentials +SECRET_KEY= +JWT_TOKEN= +export API_ENDPOINT= +export API_KEY= + +# Empty variable +EMPTY_VAR= + +# Comments and whitespaces + +ANOTHER_VAR= +` + + sanitized := SanitizeEnv(strings.NewReader(input)) + if sanitized != expected { + t.Errorf("SanitizeEnv mismatch.\nExpected:\n%s\nGot:\n%s", expected, sanitized) + } +} + +func TestGenerateConfig_Success(t *testing.T) { + tmpDir := t.TempDir() + + err := GenerateConfig(tmpDir, false) + if err != nil { + t.Fatalf("unexpected error generating config: %v", err) + } + + configPath := filepath.Join(tmpDir, ".envguard.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read generated config: %v", err) + } + + // Verify that generated config is valid YAML accepted by our config parser + cfg, err := config.Parse(data) + if err != nil { + t.Fatalf("generated config failed strict YAML validation: %v", err) + } + + if cfg.Version != "1" { + t.Errorf("expected version '1', got %q", cfg.Version) + } + if len(cfg.Scanner.IgnoreDirs) == 0 { + t.Errorf("expected default ignore dirs in generated config") + } + if len(cfg.Detector.Allowlist) == 0 { + t.Errorf("expected default allowlist in generated config") + } +} + +func TestGenerateConfig_CollisionWithoutForce(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".envguard.yaml") + if err := os.WriteFile(configPath, []byte("version: '1'\n"), 0644); err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + err := GenerateConfig(tmpDir, false) + if err == nil { + t.Fatal("expected collision error when generating existing config without force, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected error message to mention 'already exists', got: %v", err) + } +} + +func TestGenerateConfig_CollisionWithForce(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".envguard.yaml") + if err := os.WriteFile(configPath, []byte("dummy content\n"), 0644); err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + err := GenerateConfig(tmpDir, true) + if err != nil { + t.Fatalf("unexpected error overwriting config with force: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read overwritten config: %v", err) + } + + if string(data) == "dummy content\n" { + t.Error("expected config content to be updated with template") + } +} + +func TestGenerateTemplate_FromExistingEnv(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + envContent := "# App\nAPP_NAME=my-app\nSECRET=supersecret\n" + if err := os.WriteFile(envPath, []byte(envContent), 0644); err != nil { + t.Fatalf("failed to write .env: %v", err) + } + + err := GenerateTemplate(tmpDir, "", false) + if err != nil { + t.Fatalf("unexpected error generating template from .env: %v", err) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + data, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read .env.example: %v", err) + } + + expected := "# App\nAPP_NAME=\nSECRET=\n" + if string(data) != expected { + t.Errorf("template content mismatch.\nExpected:\n%s\nGot:\n%s", expected, string(data)) + } +} + +func TestGenerateTemplate_FromExplicitSource(t *testing.T) { + tmpDir := t.TempDir() + customEnvPath := filepath.Join(tmpDir, ".env.staging") + envContent := "DB_HOST=localhost\nDB_PASS=123456\n" + if err := os.WriteFile(customEnvPath, []byte(envContent), 0644); err != nil { + t.Fatalf("failed to write custom env: %v", err) + } + + err := GenerateTemplate(tmpDir, customEnvPath, false) + if err != nil { + t.Fatalf("unexpected error generating template from custom source: %v", err) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + data, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read .env.example: %v", err) + } + + expected := "DB_HOST=\nDB_PASS=\n" + if string(data) != expected { + t.Errorf("template content mismatch.\nExpected:\n%s\nGot:\n%s", expected, string(data)) + } +} + +func TestGenerateTemplate_DefaultBoilerplateWhenNoEnv(t *testing.T) { + tmpDir := t.TempDir() + + err := GenerateTemplate(tmpDir, "", false) + if err != nil { + t.Fatalf("unexpected error generating boilerplate template: %v", err) + } + + templatePath := filepath.Join(tmpDir, ".env.example") + data, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read .env.example: %v", err) + } + + if !strings.Contains(string(data), "PORT=") { + t.Errorf("expected boilerplate to contain PORT=, got: %s", string(data)) + } +} + +func TestGenerateTemplate_NonExistentExplicitSource(t *testing.T) { + tmpDir := t.TempDir() + nonExistent := filepath.Join(tmpDir, "missing.env") + + err := GenerateTemplate(tmpDir, nonExistent, false) + if err == nil { + t.Fatal("expected error when explicit source file does not exist, got nil") + } +} + +func TestGenerateTemplate_CollisionWithoutForce(t *testing.T) { + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, ".env.example") + if err := os.WriteFile(templatePath, []byte("EXISTING=true\n"), 0644); err != nil { + t.Fatalf("failed to write existing template: %v", err) + } + + err := GenerateTemplate(tmpDir, "", false) + if err == nil { + t.Fatal("expected collision error when .env.example already exists, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected error message to mention 'already exists', got: %v", err) + } +}