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
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand All @@ -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 <file>`) 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 |

---
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
165 changes: 165 additions & 0 deletions internal/cli/fix.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading