diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95df251 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent Guidelines - envguard + +## Agent skills + +### Issue tracker + +GitHub Issues via `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Canonical 5 triage roles (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout (`CONTEXT.md` + `docs/adr/`). See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9c7f138 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,33 @@ +# envguard + +Security-focused CLI tool to detect and prevent committed or exposed environment files in Git repositories. + +## Language + +**Scanner**: +The recursive filesystem traversal and Git status coordinator that produces scan findings. +_Avoid_: Crawler, inspector, walker + +**Detector**: +The rule evaluator that determines if a file path is an environment file and whether it matches safe allowlist templates. +_Avoid_: Matcher, filter, classifier + +**Configuration**: +The project-level settings loaded from `.envguard.yaml`, `.envguard.yml`, or via `--config` to customize scanning and detection rules. +_Avoid_: Options, settings, preferences + +**Allowlist**: +The collection of glob patterns for safe environment templates or sample files (e.g., `.env.example`) that should not raise security warnings. +_Avoid_: Whitelist, safe-list, permitted files + +**Ignore Directory**: +A directory name skipped during recursive filesystem traversal (e.g., `node_modules`, `.git`). +_Avoid_: Excluded path, blacklisted folder + +**Severity Override**: +An explicit configuration rule that replaces the calculated severity level for files matching a specific pattern. +_Avoid_: Custom rule, priority tweak + +**Finding**: +A detected environment file with its assigned severity level, git status, and mitigation suggestions. +_Avoid_: Vulnerability, issue, report item diff --git a/docs/adr/0001-yaml-configuration-support.md b/docs/adr/0001-yaml-configuration-support.md new file mode 100644 index 0000000..3ccc320 --- /dev/null +++ b/docs/adr/0001-yaml-configuration-support.md @@ -0,0 +1,3 @@ +# 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/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..3524904 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..b258aeb --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/go.mod b/go.mod index a9b0f8c..b332dfc 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/joaooncode/envguard go 1.22.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/check.go b/internal/cli/check.go index 56b183b..293c0f8 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -6,6 +6,7 @@ import ( "io" "strings" + "github.com/joaooncode/envguard/internal/config" "github.com/joaooncode/envguard/internal/reporter" "github.com/joaooncode/envguard/internal/scanner" ) @@ -17,6 +18,8 @@ func runCheckCommand(args []string, stdout, stderr io.Writer, scannerInstance *s 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.configPath, "config", "", "Path to custom configuration file") + fs.StringVar(&cfg.configPath, "c", "", "Path to custom configuration file (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)") @@ -46,8 +49,15 @@ func runCheckCommand(args []string, stdout, stderr io.Writer, scannerInstance *s 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.DefaultScanner + scannerInstance = scanner.NewWithConfig(nil, nil, appConfig) } result, err := scannerInstance.Scan(cfg.path) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e2c1930..2e9e146 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -292,3 +292,78 @@ func TestCLICheckCommand(t *testing.T) { } }) } + +func TestCLIConfigFileIntegration(t *testing.T) { + t.Run("auto-discovered .envguard.yaml with allowlist", func(t *testing.T) { + tmpDir := t.TempDir() + + // Write .envguard.yaml allowing .env.custom + configContent := ` +detector: + allowlist: + - ".env.custom" +` + if err := os.WriteFile(filepath.Join(tmpDir, ".envguard.yaml"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + // Write .env.custom (which would normally fail without config) + if err := os.WriteFile(filepath.Join(tmpDir, ".env.custom"), []byte("CUSTOM=1"), 0644); err != nil { + t.Fatal(err) + } + + 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 (PASSED due to allowlist in .envguard.yaml), got %d. stderr: %s", cli.ExitCodeSuccess, code, stderr.String()) + } + if !strings.Contains(stdout.String(), "PASSED") { + t.Fatalf("expected PASSED in output, got: %s", stdout.String()) + } + }) + + t.Run("explicit --config flag", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "custom-rules.yaml") + + configContent := ` +detector: + custom_patterns: + - "*.env.vault" +` + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(tmpDir, "app.env.vault"), []byte("SECRET=1"), 0644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"check", "--path", tmpDir, "--config", configFile, "--no-color"}, &stdout, &stderr) + + if code != cli.ExitCodeFindingsFound { + t.Fatalf("expected exit code %d (FINDINGS due to custom pattern), got %d", cli.ExitCodeFindingsFound, code) + } + if !strings.Contains(stdout.String(), "app.env.vault") { + t.Fatalf("expected app.env.vault in output, got: %s", stdout.String()) + } + }) + + t.Run("invalid config file returns UsageError", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "bad.yaml") + _ = os.WriteFile(configFile, []byte("scanner: [invalid"), 0644) + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"scan", "--path", tmpDir, "--config", configFile}, &stdout, &stderr) + + if code != cli.ExitCodeUsageError { + t.Fatalf("expected exit code %d for invalid config file, got %d", cli.ExitCodeUsageError, code) + } + if !strings.Contains(stderr.String(), "Error:") { + t.Fatalf("expected error message in stderr, got: %s", stderr.String()) + } + }) +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index d40060f..9018035 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -6,16 +6,18 @@ import ( "io" "strings" + "github.com/joaooncode/envguard/internal/config" "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 + path string + configPath string + format string + severity string + noColor bool } func parseSeverity(s string) (scanner.Severity, bool) { @@ -57,6 +59,8 @@ func runScanCommand(args []string, stdout, stderr io.Writer, scannerInstance *sc 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.configPath, "config", "", "Path to custom configuration file") + fs.StringVar(&cfg.configPath, "c", "", "Path to custom configuration file (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)") @@ -86,8 +90,15 @@ func runScanCommand(args []string, stdout, stderr io.Writer, scannerInstance *sc 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.DefaultScanner + scannerInstance = scanner.NewWithConfig(nil, nil, appConfig) } result, err := scannerInstance.Scan(cfg.path) diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0ae18eb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,134 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// DefaultConfigFilenames lists the standard config filenames checked in target directories. +var DefaultConfigFilenames = []string{ + ".envguard.yaml", + ".envguard.yml", +} + +// Config represents the complete configuration structure for envguard. +type Config struct { + Version string `yaml:"version,omitempty"` + Scanner ScannerConfig `yaml:"scanner,omitempty"` + Detector DetectorConfig `yaml:"detector,omitempty"` +} + +// ScannerConfig holds scanner-specific options. +type ScannerConfig struct { + IgnoreDirs []string `yaml:"ignore_dirs,omitempty"` +} + +// DetectorConfig holds detector-specific options. +type DetectorConfig struct { + CustomPatterns []string `yaml:"custom_patterns,omitempty"` + Allowlist []string `yaml:"allowlist,omitempty"` + SeverityOverrides []SeverityOverride `yaml:"severity_overrides,omitempty"` +} + +// SeverityOverride overrides the calculated severity for files matching a pattern. +type SeverityOverride struct { + Pattern string `yaml:"pattern"` + Severity string `yaml:"severity"` +} + +// NewDefault returns a new Config with empty/default settings. +func NewDefault() *Config { + return &Config{ + Version: "1", + Scanner: ScannerConfig{ + IgnoreDirs: make([]string, 0), + }, + Detector: DetectorConfig{ + CustomPatterns: make([]string, 0), + Allowlist: make([]string, 0), + SeverityOverrides: make([]SeverityOverride, 0), + }, + } +} + +// Parse decodes raw YAML bytes into a Config struct using strict field validation. +func Parse(data []byte) (*Config, error) { + cfg := NewDefault() + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + + if err := decoder.Decode(cfg); err != nil { + return nil, fmt.Errorf("invalid configuration syntax: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +// Load reads and parses a YAML configuration file from the specified path. +func Load(filePath string) (*Config, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", filePath, err) + } + + cfg, err := Parse(data) + if err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", filePath, err) + } + + return cfg, nil +} + +// DiscoverAndLoad resolves configuration either from an explicit path or standard locations in dir. +func DiscoverAndLoad(dir string, explicitPath string) (*Config, bool, error) { + if explicitPath != "" { + cfg, err := Load(explicitPath) + if err != nil { + return nil, false, err + } + return cfg, true, nil + } + + if dir == "" { + dir = "." + } + + for _, filename := range DefaultConfigFilenames { + candidate := filepath.Join(dir, filename) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + cfg, err := Load(candidate) + if err != nil { + return nil, false, err + } + return cfg, true, nil + } + } + + return NewDefault(), false, nil +} + +// Validate ensures all configuration fields have valid and well-formed values. +func (c *Config) Validate() error { + for i, override := range c.Detector.SeverityOverrides { + if strings.TrimSpace(override.Pattern) == "" { + return fmt.Errorf("severity_overrides[%d]: pattern cannot be empty", i) + } + sev := strings.ToLower(strings.TrimSpace(override.Severity)) + switch sev { + case "info", "warning", "warn", "high", "critical": + // valid + default: + return fmt.Errorf("severity_overrides[%d]: invalid severity %q (supported: info, warning, high, critical)", i, override.Severity) + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..095c59b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := NewDefault() + if cfg == nil { + t.Fatal("expected non-nil default config") + } + if len(cfg.Scanner.IgnoreDirs) != 0 { + t.Errorf("expected 0 default custom ignore dirs, got %d", len(cfg.Scanner.IgnoreDirs)) + } + if len(cfg.Detector.Allowlist) != 0 { + t.Errorf("expected 0 default custom allowlist patterns, got %d", len(cfg.Detector.Allowlist)) + } +} + +func TestParseYAML_Valid(t *testing.T) { + yamlContent := ` +version: "1" +scanner: + ignore_dirs: + - .custom_vendor + - build_artifacts +detector: + custom_patterns: + - "*.env.vault" + - ".env.secret" + allowlist: + - ".env.custom.example" + severity_overrides: + - pattern: ".env.production" + severity: "critical" + - pattern: ".env.local" + severity: "info" +` + cfg, err := Parse([]byte(yamlContent)) + if err != nil { + t.Fatalf("unexpected error parsing valid yaml: %v", err) + } + + if cfg.Version != "1" { + t.Errorf("expected version '1', got %q", cfg.Version) + } + if len(cfg.Scanner.IgnoreDirs) != 2 { + t.Errorf("expected 2 ignore dirs, got %d", len(cfg.Scanner.IgnoreDirs)) + } + if len(cfg.Detector.CustomPatterns) != 2 { + t.Errorf("expected 2 custom patterns, got %d", len(cfg.Detector.CustomPatterns)) + } + if len(cfg.Detector.Allowlist) != 1 { + t.Errorf("expected 1 allowlist pattern, got %d", len(cfg.Detector.Allowlist)) + } + if len(cfg.Detector.SeverityOverrides) != 2 { + t.Errorf("expected 2 severity overrides, got %d", len(cfg.Detector.SeverityOverrides)) + } +} + +func TestParseYAML_InvalidSyntax(t *testing.T) { + invalidYAML := ` +scanner: + ignore_dirs: [ +` + _, err := Parse([]byte(invalidYAML)) + if err == nil { + t.Fatal("expected error parsing invalid YAML syntax, got nil") + } +} + +func TestParseYAML_UnknownFields(t *testing.T) { + unknownFieldYAML := ` +scanner: + unknown_key: "value" +` + _, err := Parse([]byte(unknownFieldYAML)) + if err == nil { + t.Fatal("expected error on unknown fields with strict decoding, got nil") + } +} + +func TestParseYAML_InvalidSeverity(t *testing.T) { + invalidSevYAML := ` +detector: + severity_overrides: + - pattern: ".env.production" + severity: "super-critical" +` + _, err := Parse([]byte(invalidSevYAML)) + if err == nil { + t.Fatal("expected error on invalid severity value, got nil") + } +} + +func TestDiscoverAndLoad(t *testing.T) { + tmpDir := t.TempDir() + + // 1. Without any config file, returns default and found=false, no error + cfg, found, err := DiscoverAndLoad(tmpDir, "") + if err != nil { + t.Fatalf("unexpected error when no config exists: %v", err) + } + if found { + t.Errorf("expected found=false when no config file exists") + } + if cfg == nil { + t.Fatal("expected non-nil default config") + } + + // 2. Discover .envguard.yaml + yamlPath := filepath.Join(tmpDir, ".envguard.yaml") + content := ` +scanner: + ignore_dirs: + - test_dir +` + if err := os.WriteFile(yamlPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test .envguard.yaml: %v", err) + } + + cfg, found, err = DiscoverAndLoad(tmpDir, "") + if err != nil { + t.Fatalf("unexpected error discovering .envguard.yaml: %v", err) + } + if !found { + t.Fatal("expected found=true when .envguard.yaml exists") + } + if len(cfg.Scanner.IgnoreDirs) != 1 || cfg.Scanner.IgnoreDirs[0] != "test_dir" { + t.Errorf("expected ignore_dirs to contain 'test_dir', got %v", cfg.Scanner.IgnoreDirs) + } + + // 3. Explicit config path override + customPath := filepath.Join(tmpDir, "custom.yaml") + customContent := ` +detector: + custom_patterns: + - "*.env.custom" +` + if err := os.WriteFile(customPath, []byte(customContent), 0644); err != nil { + t.Fatalf("failed to write custom.yaml: %v", err) + } + + cfg, found, err = DiscoverAndLoad(tmpDir, customPath) + if err != nil { + t.Fatalf("unexpected error loading explicit config: %v", err) + } + if !found { + t.Fatal("expected found=true for explicit config") + } + if len(cfg.Detector.CustomPatterns) != 1 || cfg.Detector.CustomPatterns[0] != "*.env.custom" { + t.Errorf("expected custom_patterns to contain '*.env.custom', got %v", cfg.Detector.CustomPatterns) + } + + // 4. Non-existent explicit config path returns error + _, _, err = DiscoverAndLoad(tmpDir, filepath.Join(tmpDir, "non_existent.yaml")) + if err == nil { + t.Fatal("expected error when explicit config does not exist, got nil") + } +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 12c1f59..196b659 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -20,20 +20,32 @@ var DefaultAllowlist = []string{ // Detector evaluates file paths against environment patterns and allowlist rules. type Detector struct { - allowlist []string + customPatterns []string + allowlist []string } // New creates a Detector initialized with the default allowlist. func New() *Detector { return &Detector{ - allowlist: DefaultAllowlist, + customPatterns: make([]string, 0), + allowlist: DefaultAllowlist, } } -// NewWithAllowlist creates a Detector with a custom allowlist. +// NewWithAllowlist creates a Detector with a custom allowlist appended to defaults. func NewWithAllowlist(allowlist []string) *Detector { + return NewWithPatterns(nil, allowlist) +} + +// NewWithPatterns creates a Detector with custom environment patterns and allowlist appended to defaults. +func NewWithPatterns(customPatterns []string, customAllowlist []string) *Detector { + combinedAllowlist := make([]string, 0, len(DefaultAllowlist)+len(customAllowlist)) + combinedAllowlist = append(combinedAllowlist, DefaultAllowlist...) + combinedAllowlist = append(combinedAllowlist, customAllowlist...) + return &Detector{ - allowlist: allowlist, + customPatterns: customPatterns, + allowlist: combinedAllowlist, } } @@ -61,6 +73,14 @@ func (d *Detector) IsEnvFile(path string) bool { lower := strings.ToLower(base) + // Check custom patterns first if configured + for _, pattern := range d.customPatterns { + patternLower := strings.ToLower(pattern) + if matchAllowlistPattern(patternLower, lower) { + return true + } + } + // Direct match: .env if lower == ".env" { return true diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 44f1d63..b8d28ff 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -149,20 +149,33 @@ func TestDetect(t *testing.T) { } } -func TestCustomAllowlist(t *testing.T) { - customDetector := NewWithAllowlist([]string{ - ".env.dist", - "*.env.defaults", - }) - - // Default allowlist items shouldn't match in this custom instance unless defined - if customDetector.IsAllowed(".env.dist") != true { - t.Errorf("expected .env.dist to be allowed in custom detector") +func TestCustomPatternsAndAllowlist(t *testing.T) { + d := NewWithPatterns( + []string{"*.env.vault", ".env.secret"}, + []string{".env.dist", "*.env.custom-example"}, + ) + + // Built-in defaults should still work + if !d.IsEnvFile(".env") { + t.Errorf("expected default .env to be recognized") } - if customDetector.IsAllowed("app.env.defaults") != true { - t.Errorf("expected app.env.defaults to be allowed in custom detector") + if !d.IsAllowed(".env.example") { + t.Errorf("expected default .env.example to be allowed") } - if customDetector.IsAllowed(".env.production") != false { - t.Errorf("expected .env.production to not be allowed") + + // Custom patterns + if !d.IsEnvFile("backend.env.vault") { + t.Errorf("expected custom pattern backend.env.vault to be recognized") + } + if !d.IsEnvFile(".env.secret") { + t.Errorf("expected custom pattern .env.secret to be recognized") + } + + // Custom allowlist + if !d.IsAllowed(".env.dist") { + t.Errorf("expected custom allowlist .env.dist to be allowed") + } + if !d.IsAllowed("app.env.custom-example") { + t.Errorf("expected custom allowlist app.env.custom-example to be allowed") } } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 2bf9651..ed090da 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "strings" + "github.com/joaooncode/envguard/internal/config" "github.com/joaooncode/envguard/internal/detector" "github.com/joaooncode/envguard/internal/git" ) @@ -22,27 +24,48 @@ var IgnoredDirectories = map[string]bool{ // Scanner coordinates filesystem traversal, environment detection, and Git status inspection. type Scanner struct { - gitClient git.Client - detector *detector.Detector + gitClient git.Client + detector *detector.Detector + cfg *config.Config + ignoreDirs map[string]bool } // New creates a new Scanner instance with the provided git client and detector. func New(gitClient git.Client, det *detector.Detector) *Scanner { + return NewWithConfig(gitClient, det, nil) +} + +// NewWithConfig creates a Scanner initialized with configuration settings. +func NewWithConfig(gitClient git.Client, det *detector.Detector, cfg *config.Config) *Scanner { if gitClient == nil { gitClient = git.NewClient() } + if cfg == nil { + cfg = config.NewDefault() + } if det == nil { - det = detector.New() + det = detector.NewWithPatterns(cfg.Detector.CustomPatterns, cfg.Detector.Allowlist) } + + ignoreMap := make(map[string]bool) + for k, v := range IgnoredDirectories { + ignoreMap[k] = v + } + for _, dir := range cfg.Scanner.IgnoreDirs { + ignoreMap[dir] = true + } + return &Scanner{ - gitClient: gitClient, - detector: det, + gitClient: gitClient, + detector: det, + cfg: cfg, + ignoreDirs: ignoreMap, } } // NewDefault creates a Scanner configured with default Git and Detector implementations. func NewDefault() *Scanner { - return New(git.NewClient(), detector.New()) + return NewWithConfig(nil, nil, nil) } // Scan recursively walks the directory and classifies any detected environment files. @@ -76,7 +99,7 @@ func (s *Scanner) Scan(dir string) (*Result, error) { } if d.IsDir() { - if currentPath != absDir && IgnoredDirectories[d.Name()] { + if currentPath != absDir && s.ignoreDirs[d.Name()] { return filepath.SkipDir } return nil @@ -154,6 +177,37 @@ func (s *Scanner) classifyFinding(relPath string, status git.FileStatus, isAllow } } + // Check if any severity override matches this file path or base name + baseName := filepath.Base(relPath) + for _, override := range s.cfg.Detector.SeverityOverrides { + patternLower := strings.ToLower(override.Pattern) + baseLower := strings.ToLower(baseName) + relLower := strings.ToLower(relPath) + + matched := false + if patternLower == baseLower || patternLower == relLower { + matched = true + } else if m, err := filepath.Match(patternLower, baseLower); err == nil && m { + matched = true + } else if m, err := filepath.Match(patternLower, relLower); err == nil && m { + matched = true + } + + if matched { + switch strings.ToLower(override.Severity) { + case "info": + severity = SeverityInfo + case "warning", "warn": + severity = SeverityWarning + case "high": + severity = SeverityHigh + case "critical": + severity = SeverityCritical + } + break + } + } + return Finding{ Path: relPath, Severity: severity, diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index adeda8e..b4d711a 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/joaooncode/envguard/internal/config" "github.com/joaooncode/envguard/internal/detector" "github.com/joaooncode/envguard/internal/git" ) @@ -410,3 +411,68 @@ func TestScannerCustomComponents(t *testing.T) { t.Errorf("expected 1 Info finding for custom allowed file, got %d", res.Summary.Info) } } + +func TestScannerWithConfig(t *testing.T) { + tempDir := t.TempDir() + + // 1. Setup files: + // a. Custom ignored dir + customIgnoredDir := filepath.Join(tempDir, "custom_vendor") + if err := os.MkdirAll(customIgnoredDir, 0755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(customIgnoredDir, ".env"), []byte("IGNORED=1"), 0644) + + // b. Custom env pattern + _ = os.WriteFile(filepath.Join(tempDir, "app.env.vault"), []byte("SECRET=1"), 0644) + + // c. Custom allowlist + _ = os.WriteFile(filepath.Join(tempDir, ".env.dist"), []byte("DIST=1"), 0644) + + // d. Severity override (.env.test normally Warning in non-git, override to Critical) + _ = os.WriteFile(filepath.Join(tempDir, ".env.test"), []byte("TEST=1"), 0644) + + cfg := &config.Config{ + Scanner: config.ScannerConfig{ + IgnoreDirs: []string{"custom_vendor"}, + }, + Detector: config.DetectorConfig{ + CustomPatterns: []string{"*.env.vault"}, + Allowlist: []string{".env.dist"}, + SeverityOverrides: []config.SeverityOverride{ + {Pattern: ".env.test", Severity: "critical"}, + }, + }, + } + + s := NewWithConfig(nil, nil, cfg) + res, err := s.Scan(tempDir) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + + findingsMap := make(map[string]Finding) + for _, f := range res.Findings { + findingsMap[f.Path] = f + } + + // custom_vendor/.env should NOT be found + if _, ok := findingsMap["custom_vendor/.env"]; ok { + t.Errorf("expected custom_vendor/.env to be ignored, but was found") + } + + // app.env.vault should be detected + if _, ok := findingsMap["app.env.vault"]; !ok { + t.Errorf("expected app.env.vault to be detected as env file") + } + + // .env.dist should be allowed (Info) + if f, ok := findingsMap[".env.dist"]; !ok || f.Severity != SeverityInfo { + t.Errorf("expected .env.dist to be allowed (Info), got %+v", f) + } + + // .env.test should have overridden severity Critical + if f, ok := findingsMap[".env.test"]; !ok || f.Severity != SeverityCritical { + t.Errorf("expected .env.test to be overridden to Critical, got %+v", f) + } +}