diff --git a/CONTEXT.md b/CONTEXT.md index 751a098..9997ed5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,3 +39,7 @@ _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 + +**Fixer**: +The remediation component responsible for automatically generating and applying non-destructive `.gitignore` rules for unignored environment files. +_Avoid_: Patcher, autofixer, corrector, remediator diff --git a/README.md b/README.md index 5a050b9..8019828 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,22 @@ Ideal para pipelines e automações. Retorna código de erro (`exit code 1`) cas envguard check ``` -### 3. Inicialização de Configuração e Templates (`init`) +### 3. Remediação Automática (`fix`) + +Adiciona automaticamente padrões correspondentes para arquivos desprotegidos (`WARNING`) no `.gitignore` da raiz, preservando comentários e formatação existente: + +```bash +# Aplicar correções no .gitignore +envguard fix + +# Simular alterações propostas sem modificar arquivos +envguard fix --dry-run + +# Executar em diretório específico +envguard fix --path ./meu-projeto +``` + +### 4. 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: @@ -103,13 +118,13 @@ envguard init --template envguard init --path ./meu-projeto --force ``` -### 4. Saída Estruturada em JSON +### 5. Saída Estruturada em JSON ```bash envguard scan --format json ``` -### 5. Verificar Versão +### 6. Verificar Versão ```bash envguard version @@ -123,7 +138,7 @@ envguard version | :------------------ | :---------------------------------------------------------------------- | :--------------------------------------------------------------------- | | **`CRITICAL`** | Arquivo de ambiente rastreado (_tracked_) no histórico Git | Remover do rastreamento (`git rm --cached`) e rotacionar credenciais | | **`HIGH`** | Arquivo de ambiente adicionado para commit (_staged_) | Retirar da stage (`git reset HEAD `) e adicionar ao `.gitignore` | -| **`WARNING`** | Arquivo existe localmente mas **não está** no `.gitignore` | Adicionar padrão correspondente ao `.gitignore` | +| **`WARNING`** | Arquivo existe localmente mas **não está** no `.gitignore` | Executar `envguard fix` ou adicionar padrão ao `.gitignore` | | **`INFO` / `SAFE`** | Arquivo protegido ou template permitido (`.env.example`, `.env.sample`) | Nenhuma ação necessária | --- @@ -144,7 +159,7 @@ envguard version - [x] Códigos de saída para CI/CD - [ ] **v0.2.0:** - [x] `envguard init` (criação automática de `.envguard.yaml` e templates) - - [ ] `envguard fix` (auxílio na adição automática ao `.gitignore`) + - [x] `envguard fix` (auxílio na adição automática ao `.gitignore`) - [ ] Instalação de _Git Precommit Hooks_ - [ ] **v0.3.0:** - [ ] Secret scanning básico por conteúdo & cálculo de entropia diff --git a/internal/cli/cli.go b/internal/cli/cli.go index ec811af..efe5436 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -70,6 +70,9 @@ func (a *App) Run(args []string) int { case "init": return runInitCommand(args[1:], a.stdout, a.stderr) + case "fix": + return runFixCommand(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) @@ -90,6 +93,7 @@ Usage: 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 init Initialize configuration file and safe template files version Show current envguard version help Show help for envguard commands @@ -104,6 +108,12 @@ 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 +Fix Flags: + -p, --path Target directory path to scan and fix (default: ".") + -d, --dry-run Preview proposed .gitignore additions without modifying files + -c, --config Path to custom configuration file + --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 @@ -115,6 +125,9 @@ Examples: envguard scan --path ./my-project --format json envguard scan --severity warning envguard check --path . --severity high + envguard fix + envguard fix --dry-run + envguard fix --path ./my-project envguard init envguard init --template envguard init --path ./my-project --force diff --git a/internal/cli/fix.go b/internal/cli/fix.go new file mode 100644 index 0000000..2fb3bdd --- /dev/null +++ b/internal/cli/fix.go @@ -0,0 +1,165 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/joaooncode/envguard/internal/config" + "github.com/joaooncode/envguard/internal/fixer" + "github.com/joaooncode/envguard/internal/scanner" +) + +type fixConfig struct { + path string + configPath string + dryRun bool + noColor bool +} + +func runFixCommand(args []string, stdout, stderr io.Writer, scannerInstance *scanner.Scanner) int { + fs := flag.NewFlagSet("fix", flag.ContinueOnError) + fs.SetOutput(stderr) + + var cfg fixConfig + fs.StringVar(&cfg.path, "path", ".", "Target directory path to scan and fix") + fs.StringVar(&cfg.path, "p", ".", "Target 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.dryRun, "dry-run", false, "Preview proposed .gitignore changes without writing to disk") + fs.BoolVar(&cfg.dryRun, "d", false, "Preview proposed changes (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 + } + + // Load configuration + appConfig, _, err := config.DiscoverAndLoad(cfg.path, cfg.configPath) + if err != nil { + fmt.Fprintf(stderr, "Error: %v\n", err) + return ExitCodeUsageError + } + + if scannerInstance == nil { + scannerInstance = scanner.NewWithConfig(nil, nil, appConfig) + } + + scanResult, err := scannerInstance.Scan(cfg.path) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to scan directory %s: %v\n", cfg.path, err) + return ExitCodeInternalError + } + + f := fixer.New() + fixRes, err := f.Apply(fixer.Options{ + TargetDir: cfg.path, + Findings: scanResult.Findings, + DryRun: cfg.dryRun, + }) + if err != nil { + fmt.Fprintf(stderr, "Error: failed to apply remediation: %v\n", err) + return ExitCodeInternalError + } + + renderFixOutput(stdout, fixRes, cfg.noColor) + + if len(fixRes.CriticalFindings) > 0 { + return ExitCodeFindingsFound + } + + return ExitCodeSuccess +} + +func renderFixOutput(w io.Writer, res *fixer.Result, noColor bool) { + bold := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[1m" + s + "\033[0m" + } + green := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[32m" + s + "\033[0m" + } + yellow := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[33m" + s + "\033[0m" + } + red := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[31m" + s + "\033[0m" + } + dim := func(s string) string { + if noColor || s == "" { + return s + } + return "\033[90m" + s + "\033[0m" + } + + var sb strings.Builder + + if res.DryRun { + sb.WriteString(bold("🔍 Dry run mode: changes will not be written to disk\n\n")) + if len(res.AddedRules) > 0 { + sb.WriteString(bold("Proposed .gitignore additions:\n")) + for _, r := range res.AddedRules { + sb.WriteString(fmt.Sprintf(" %s %s\n", green("+"), r)) + } + sb.WriteString("\n") + } else { + sb.WriteString(green("No unprotected environment files found to remediate.\n\n")) + } + } else { + if len(res.AddedRules) > 0 { + sb.WriteString(fmt.Sprintf("%s Successfully updated %s with %d rule(s):\n", + green("✓"), + bold(".gitignore"), + len(res.AddedRules), + )) + for _, r := range res.AddedRules { + sb.WriteString(fmt.Sprintf(" %s %s\n", green("+"), r)) + } + if len(res.SkippedRules) > 0 { + sb.WriteString(dim(fmt.Sprintf(" (Skipped %d rule(s) already present in .gitignore)\n", len(res.SkippedRules)))) + } + sb.WriteString("\n") + } else if len(res.SkippedRules) > 0 { + sb.WriteString(fmt.Sprintf("%s All detected environment files are already present in %s.\n\n", + green("✓"), + bold(".gitignore"), + )) + } else { + sb.WriteString(fmt.Sprintf("%s No unprotected environment files found to remediate.\n\n", + green("✓"), + )) + } + } + + if len(res.CriticalFindings) > 0 { + sb.WriteString(yellow(fmt.Sprintf("⚠️ Warning: %d tracked environment file(s) detected (CRITICAL severity)\n", len(res.CriticalFindings)))) + sb.WriteString(dim("Tracked files in Git cannot be ignored via .gitignore alone:\n")) + for _, f := range res.CriticalFindings { + sb.WriteString(fmt.Sprintf(" %s %s\n", red("✗"), f.Path)) + } + sb.WriteString("\n" + bold("Remediation steps:") + "\n") + sb.WriteString(" 1. Remove file from Git index without deleting local copy:\n") + for _, f := range res.CriticalFindings { + sb.WriteString(fmt.Sprintf(" git rm --cached %s\n", f.Path)) + } + sb.WriteString(" 2. Commit the removal and rotate any exposed credentials immediately.\n\n") + } + + fmt.Fprint(w, sb.String()) +} diff --git a/internal/cli/fix_test.go b/internal/cli/fix_test.go new file mode 100644 index 0000000..6f7570d --- /dev/null +++ b/internal/cli/fix_test.go @@ -0,0 +1,128 @@ +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/cli" +) + +func TestFixCommand_BasicRemediation(t *testing.T) { + tempDir := t.TempDir() + + // Create an unprotected .env file + envPath := filepath.Join(tempDir, ".env") + if err := os.WriteFile(envPath, []byte("SECRET=123\n"), 0644); err != nil { + t.Fatalf("failed to create env file: %v", err) + } + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"fix", "--path", tempDir, "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + gitignorePath := filepath.Join(tempDir, ".gitignore") + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + if !strings.Contains(string(content), ".env") { + t.Errorf("expected .gitignore to contain .env, got:\n%s", string(content)) + } + + outStr := stdout.String() + if !strings.Contains(outStr, ".env") { + t.Errorf("expected stdout to mention .env, got:\n%s", outStr) + } +} + +func TestFixCommand_DryRun(t *testing.T) { + tempDir := t.TempDir() + + envPath := filepath.Join(tempDir, ".env") + if err := os.WriteFile(envPath, []byte("SECRET=123\n"), 0644); err != nil { + t.Fatalf("failed to create env file: %v", err) + } + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"fix", "--path", tempDir, "--dry-run", "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + gitignorePath := filepath.Join(tempDir, ".gitignore") + if _, err := os.Stat(gitignorePath); !os.IsNotExist(err) { + t.Errorf(".gitignore should not exist in dry-run mode") + } + + outStr := stdout.String() + if !strings.Contains(outStr, "dry run") && !strings.Contains(outStr, "Dry run") { + t.Errorf("expected stdout to mention dry run, got:\n%s", outStr) + } + if !strings.Contains(outStr, ".env") { + t.Errorf("expected stdout to mention proposed rule .env, got:\n%s", outStr) + } +} + +func TestFixCommand_NoFindings(t *testing.T) { + tempDir := t.TempDir() + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"fix", "--path", tempDir, "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + + outStr := stdout.String() + if !strings.Contains(outStr, "No unprotected environment files found") { + t.Errorf("expected stdout to indicate no unprotected files, got:\n%s", outStr) + } +} + +func TestFixCommand_InvalidFlags(t *testing.T) { + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"fix", "--unknown-flag"}) + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d for invalid flags, got %d", cli.ExitCodeUsageError, code) + } +} + +func TestFixCommand_SkippedExistingRules(t *testing.T) { + tempDir := t.TempDir() + + envPath := filepath.Join(tempDir, ".env") + if err := os.WriteFile(envPath, []byte("SECRET=123\n"), 0644); err != nil { + t.Fatalf("failed to create env file: %v", err) + } + + gitignorePath := filepath.Join(tempDir, ".gitignore") + if err := os.WriteFile(gitignorePath, []byte("# Added by envguard\n.env\n"), 0644); err != nil { + t.Fatalf("failed to create gitignore: %v", err) + } + + var stdout, stderr bytes.Buffer + app := cli.New(&stdout, &stderr) + + code := app.Run([]string{"fix", "--path", tempDir, "--no-color"}) + if code != cli.ExitCodeSuccess { + t.Fatalf("expected exit code %d, got %d", cli.ExitCodeSuccess, code) + } + + outStr := stdout.String() + if !strings.Contains(outStr, "already present") { + t.Errorf("expected stdout to mention already present rules, got:\n%s", outStr) + } +} diff --git a/internal/fixer/fixer.go b/internal/fixer/fixer.go new file mode 100644 index 0000000..2bb15e0 --- /dev/null +++ b/internal/fixer/fixer.go @@ -0,0 +1,227 @@ +package fixer + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joaooncode/envguard/internal/scanner" +) + +const SectionHeader = "# Added by envguard" + +// Fixer orchestrates automated remediation of unprotected environment files. +type Fixer struct{} + +// New creates a new Fixer instance. +func New() *Fixer { + return &Fixer{} +} + +// Options contains parameters for a remediation operation. +type Options struct { + TargetDir string + Findings []scanner.Finding + DryRun bool +} + +// Result summarizes the outcomes of a remediation execution. +type Result struct { + TargetDir string `json:"target_dir"` + GitignorePath string `json:"gitignore_path"` + AddedRules []string `json:"added_rules"` + SkippedRules []string `json:"skipped_rules"` + CriticalFindings []scanner.Finding `json:"critical_findings"` + GitignoreUpdated bool `json:"gitignore_updated"` + DryRun bool `json:"dry_run"` + NewContent string `json:"new_content,omitempty"` +} + +// Apply analyzes scan findings and updates the root .gitignore accordingly. +func (f *Fixer) Apply(opts Options) (*Result, error) { + targetDir := opts.TargetDir + if targetDir == "" { + targetDir = "." + } + + absTargetDir, err := filepath.Abs(targetDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve target directory %s: %w", targetDir, err) + } + + gitignorePath := filepath.Join(absTargetDir, ".gitignore") + + result := &Result{ + TargetDir: absTargetDir, + GitignorePath: gitignorePath, + DryRun: opts.DryRun, + } + + var warningFindings []scanner.Finding + for _, finding := range opts.Findings { + switch finding.Severity { + case scanner.SeverityCritical: + result.CriticalFindings = append(result.CriticalFindings, finding) + case scanner.SeverityWarning: + warningFindings = append(warningFindings, finding) + } + } + + if len(warningFindings) == 0 { + return result, nil + } + + var existingContent string + if data, err := os.ReadFile(gitignorePath); err == nil { + existingContent = string(data) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read .gitignore at %s: %w", gitignorePath, err) + } + + rulesToAdd, skippedRules := f.computeRules(absTargetDir, warningFindings, existingContent) + result.SkippedRules = skippedRules + result.AddedRules = rulesToAdd + + if len(rulesToAdd) == 0 { + return result, nil + } + + newContent := f.mergeGitignore(existingContent, rulesToAdd) + result.NewContent = newContent + result.GitignoreUpdated = true + + if !opts.DryRun { + if err := os.WriteFile(gitignorePath, []byte(newContent), 0644); err != nil { + return nil, fmt.Errorf("failed to write %s: %w", gitignorePath, err) + } + } + + return result, nil +} + +func (f *Fixer) computeRules(baseDir string, findings []scanner.Finding, currentContent string) ([]string, []string) { + existingRules := make(map[string]bool) + scanner := bufio.NewScanner(strings.NewReader(currentContent)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + existingRules[line] = true + } + } + + var toAdd []string + var skipped []string + seenInBatch := make(map[string]bool) + + for _, finding := range findings { + rule := f.CalculateRule(baseDir, finding.Path) + if rule == "" { + continue + } + + if existingRules[rule] || seenInBatch[rule] { + if !seenInBatch[rule] { + skipped = append(skipped, rule) + seenInBatch[rule] = true + } + continue + } + + toAdd = append(toAdd, rule) + seenInBatch[rule] = true + } + + return toAdd, skipped +} + +// CalculateRule returns the appropriate .gitignore pattern for a file path relative to baseDir. +func (f *Fixer) CalculateRule(baseDir, filePath string) string { + absBase, err := filepath.Abs(baseDir) + if err != nil { + absBase = baseDir + } + + absPath := filePath + if !filepath.IsAbs(filePath) { + absPath = filepath.Join(absBase, filePath) + } + absPath = filepath.Clean(absPath) + + rel, err := filepath.Rel(absBase, absPath) + if err != nil { + rel = filepath.Base(filePath) + } + + slashRel := filepath.ToSlash(rel) + if slashRel == "." || slashRel == "" { + return "" + } + + if !strings.Contains(slashRel, "/") { + return slashRel + } + + return "/" + slashRel +} + +func (f *Fixer) mergeGitignore(existingContent string, newRules []string) string { + if len(newRules) == 0 { + return existingContent + } + + rulesText := strings.Join(newRules, "\n") + "\n" + + if existingContent == "" { + return SectionHeader + "\n" + rulesText + } + + lines := strings.Split(existingContent, "\n") + headerIdx := -1 + for i, line := range lines { + if strings.TrimSpace(line) == SectionHeader { + headerIdx = i + break + } + } + + if headerIdx != -1 { + // Insert directly under existing SectionHeader + insertIdx := headerIdx + 1 + for insertIdx < len(lines) { + trimmed := strings.TrimSpace(lines[insertIdx]) + if strings.HasPrefix(trimmed, "#") && trimmed != "" { + // Reached next section + break + } + if trimmed == "" && insertIdx+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[insertIdx+1]), "#") { + // Reached blank line before next section + break + } + insertIdx++ + } + + var updatedLines []string + updatedLines = append(updatedLines, lines[:insertIdx]...) + for _, rule := range newRules { + updatedLines = append(updatedLines, rule) + } + updatedLines = append(updatedLines, lines[insertIdx:]...) + return strings.Join(updatedLines, "\n") + } + + // Section header does not exist, append at the end + var sb strings.Builder + sb.WriteString(existingContent) + if !strings.HasSuffix(existingContent, "\n") { + sb.WriteString("\n") + } + if strings.TrimSpace(existingContent) != "" { + sb.WriteString("\n") + } + sb.WriteString(SectionHeader + "\n") + sb.WriteString(rulesText) + + return sb.String() +} diff --git a/internal/fixer/fixer_test.go b/internal/fixer/fixer_test.go new file mode 100644 index 0000000..d65a0cb --- /dev/null +++ b/internal/fixer/fixer_test.go @@ -0,0 +1,274 @@ +package fixer_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/fixer" + "github.com/joaooncode/envguard/internal/git" + "github.com/joaooncode/envguard/internal/scanner" +) + +func TestFixer_Apply_NewGitignore(t *testing.T) { + tempDir := t.TempDir() + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), + Severity: scanner.SeverityWarning, + Message: "unprotected local file", + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + { + Path: filepath.Join(tempDir, "backend", ".env.local"), + Severity: scanner.SeverityWarning, + Message: "unprotected local file", + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !res.GitignoreUpdated { + t.Fatalf("expected GitignoreUpdated to be true") + } + + if len(res.AddedRules) != 2 { + t.Fatalf("expected 2 added rules, got %d", len(res.AddedRules)) + } + + gitignorePath := filepath.Join(tempDir, ".gitignore") + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read created .gitignore: %v", err) + } + + expectedSubstrings := []string{ + "# Added by envguard", + ".env", + "/backend/.env.local", + } + + for _, sub := range expectedSubstrings { + if !strings.Contains(string(content), sub) { + t.Errorf("expected .gitignore to contain %q, got:\n%s", sub, string(content)) + } + } +} + +func TestFixer_Apply_ExistingGitignore_AppendSection(t *testing.T) { + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + initialContent := "node_modules/\ndist/\n" + if err := os.WriteFile(gitignorePath, []byte(initialContent), 0644); err != nil { + t.Fatalf("failed to write initial .gitignore: %v", err) + } + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !res.GitignoreUpdated { + t.Fatalf("expected GitignoreUpdated to be true") + } + + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + strContent := string(content) + if !strings.HasPrefix(strContent, "node_modules/\ndist/\n") { + t.Errorf("expected original content to be preserved at start, got:\n%s", strContent) + } + if !strings.Contains(strContent, "# Added by envguard\n.env") { + t.Errorf("expected envguard section appended, got:\n%s", strContent) + } +} + +func TestFixer_Apply_ExistingEnvguardSection(t *testing.T) { + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + initialContent := "# Existing rules\nnode_modules/\n\n# Added by envguard\n.env\n" + if err := os.WriteFile(gitignorePath, []byte(initialContent), 0644); err != nil { + t.Fatalf("failed to write initial .gitignore: %v", err) + } + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), // duplicate + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + { + Path: filepath.Join(tempDir, ".env.local"), // new + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(res.AddedRules) != 1 || res.AddedRules[0] != ".env.local" { + t.Fatalf("expected 1 added rule (.env.local), got: %v", res.AddedRules) + } + if len(res.SkippedRules) != 1 || res.SkippedRules[0] != ".env" { + t.Fatalf("expected 1 skipped rule (.env), got: %v", res.SkippedRules) + } + + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + strContent := string(content) + // Should not duplicate the section header + if strings.Count(strContent, "# Added by envguard") != 1 { + t.Errorf("expected exactly 1 '# Added by envguard' header, got: %d", strings.Count(strContent, "# Added by envguard")) + } + if !strings.Contains(strContent, ".env.local") { + t.Errorf("expected .env.local in .gitignore, got:\n%s", strContent) + } +} + +func TestFixer_Apply_DryRun(t *testing.T) { + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !res.DryRun { + t.Fatalf("expected DryRun to be true") + } + if len(res.AddedRules) != 1 { + t.Fatalf("expected 1 added rule in dry-run result, got %d", len(res.AddedRules)) + } + + // File should NOT exist in dry-run mode + if _, err := os.Stat(gitignorePath); !os.IsNotExist(err) { + t.Errorf(".gitignore should not have been created during dry-run") + } +} + +func TestFixer_Apply_CriticalFindings(t *testing.T) { + tempDir := t.TempDir() + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), + Severity: scanner.SeverityCritical, + GitStatus: git.FileStatus{IsTracked: true}, + }, + { + Path: filepath.Join(tempDir, "config", ".env.local"), + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(res.CriticalFindings) != 1 { + t.Fatalf("expected 1 critical finding, got %d", len(res.CriticalFindings)) + } + if len(res.AddedRules) != 1 { + t.Fatalf("expected 1 added rule for warning, got %d", len(res.AddedRules)) + } +} + +func TestFixer_Apply_NoTrailingNewlineHandling(t *testing.T) { + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + initialContent := "node_modules" // no trailing newline + if err := os.WriteFile(gitignorePath, []byte(initialContent), 0644); err != nil { + t.Fatalf("failed to write initial .gitignore: %v", err) + } + + findings := []scanner.Finding{ + { + Path: filepath.Join(tempDir, ".env"), + Severity: scanner.SeverityWarning, + GitStatus: git.FileStatus{IsTracked: false, IsIgnored: false}, + }, + } + + f := fixer.New() + res, err := f.Apply(fixer.Options{ + TargetDir: tempDir, + Findings: findings, + DryRun: false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !res.GitignoreUpdated { + t.Fatalf("expected GitignoreUpdated to be true") + } + + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + strContent := string(content) + if !strings.HasPrefix(strContent, "node_modules\n\n# Added by envguard\n.env\n") { + t.Errorf("unexpected content structure:\n%s", strContent) + } +}