From cf2282d77025c3c98b3abbc48ad9b1280d6c0467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20R=2E=20da=20Silva?= Date: Sat, 29 Aug 2026 16:13:35 -0300 Subject: [PATCH] =?UTF-8?q?feat(detector):=20implementar=20detec=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20arquivos=20.env=20e=20allowlist=20de=20exce?= =?UTF-8?q?=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/detector/detector.go | 154 ++++++++++++++++++++++++++ internal/detector/detector_test.go | 168 +++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 internal/detector/detector.go create mode 100644 internal/detector/detector_test.go diff --git a/internal/detector/detector.go b/internal/detector/detector.go new file mode 100644 index 0000000..12c1f59 --- /dev/null +++ b/internal/detector/detector.go @@ -0,0 +1,154 @@ +package detector + +import ( + "path/filepath" + "strings" +) + +// DefaultAllowlist contains standard safe template and example file patterns. +var DefaultAllowlist = []string{ + ".env.example", + ".env.sample", + ".env.template", + ".env.*.example", + ".env.*.sample", + ".env.*.template", + "*.env.example", + "*.env.sample", + "*.env.template", +} + +// Detector evaluates file paths against environment patterns and allowlist rules. +type Detector struct { + allowlist []string +} + +// New creates a Detector initialized with the default allowlist. +func New() *Detector { + return &Detector{ + allowlist: DefaultAllowlist, + } +} + +// NewWithAllowlist creates a Detector with a custom allowlist. +func NewWithAllowlist(allowlist []string) *Detector { + return &Detector{ + allowlist: allowlist, + } +} + +// extractBaseName extracts the filename from a given path across Unix and Windows formats. +func extractBaseName(path string) string { + cleanPath := strings.ReplaceAll(path, "\\", "/") + cleanPath = strings.TrimRight(cleanPath, "/") + if cleanPath == "" || cleanPath == "." { + return "" + } + idx := strings.LastIndex(cleanPath, "/") + if idx >= 0 { + return cleanPath[idx+1:] + } + return cleanPath +} + +// IsEnvFile reports whether path corresponds to an environment file name (.env, .env.*, *.env, etc.). +// Operates exclusively on file path strings without inspecting disk or file contents. +func (d *Detector) IsEnvFile(path string) bool { + base := extractBaseName(path) + if base == "" { + return false + } + + lower := strings.ToLower(base) + + // Direct match: .env + if lower == ".env" { + return true + } + + // Prefix match: .env.* (e.g., .env.local, .env.production, .env.example) + if strings.HasPrefix(lower, ".env.") { + return true + } + + // Suffix match: *.env (e.g., backend.env, prod.env) + if strings.HasSuffix(lower, ".env") { + return true + } + + // Segment match: *.env.* (e.g., app.env.local, backend.env.example) + if strings.Contains(lower, ".env.") { + return true + } + + return false +} + +// IsAllowed reports whether path is a recognized safe template/example file according to the allowlist. +func (d *Detector) IsAllowed(path string) bool { + base := extractBaseName(path) + if base == "" { + return false + } + + lower := strings.ToLower(base) + + // Check configured allowlist patterns + for _, pattern := range d.allowlist { + patternLower := strings.ToLower(pattern) + if matchAllowlistPattern(patternLower, lower) { + return true + } + } + + // General safe suffixes for env files (*.example, *.sample, *.template) + if d.IsEnvFile(path) { + if strings.HasSuffix(lower, ".example") || + strings.HasSuffix(lower, ".sample") || + strings.HasSuffix(lower, ".template") { + return true + } + } + + return false +} + +// matchAllowlistPattern matches a pattern against a filename. +func matchAllowlistPattern(pattern, name string) bool { + if pattern == name { + return true + } + matched, err := filepath.Match(pattern, name) + if err == nil && matched { + return true + } + return false +} + +// Detect analyzes a path and returns whether it is an environment file and whether it is allowed. +func (d *Detector) Detect(path string) (isEnv bool, isAllowed bool) { + isEnv = d.IsEnvFile(path) + if !isEnv { + return false, false + } + isAllowed = d.IsAllowed(path) + return isEnv, isAllowed +} + +// DefaultDetector is a package-level instance using standard configurations. +var DefaultDetector = New() + +// IsEnvFile reports whether the given path represents an environment file using default patterns. +func IsEnvFile(path string) bool { + return DefaultDetector.IsEnvFile(path) +} + +// IsAllowed reports whether the given path represents a safe exception using default allowlist. +func IsAllowed(path string) bool { + return DefaultDetector.IsAllowed(path) +} + +// Detect analyzes a path using the default detector instance. +func Detect(path string) (isEnv bool, isAllowed bool) { + return DefaultDetector.Detect(path) +} diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go new file mode 100644 index 0000000..44f1d63 --- /dev/null +++ b/internal/detector/detector_test.go @@ -0,0 +1,168 @@ +package detector + +import ( + "testing" +) + +func TestIsEnvFile(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + // Standard .env files + {"exact .env", ".env", true}, + {"exact .ENV (uppercase)", ".ENV", true}, + {"exact .Env (mixed case)", ".Env", true}, + + // Prefix .env.* + {".env.local", ".env.local", true}, + {".env.production", ".env.production", true}, + {".env.staging", ".env.staging", true}, + {".env.development", ".env.development", true}, + {".env.test", ".env.test", true}, + {".env.dev.local", ".env.dev.local", true}, + {".ENV.LOCAL uppercase", ".ENV.LOCAL", true}, + + // Suffix *.env + {"backend.env", "backend.env", true}, + {"prod.env", "prod.env", true}, + {"docker.env", "docker.env", true}, + {"app.service.env", "app.service.env", true}, + {"PROD.ENV uppercase", "PROD.ENV", true}, + + // Segment *.env.* + {"app.env.local", "app.env.local", true}, + {"docker.env.production", "docker.env.production", true}, + + // Allowlist candidates (they are env files, but safe templates) + {".env.example", ".env.example", true}, + {".env.sample", ".env.sample", true}, + {".env.template", ".env.template", true}, + {".env.local.example", ".env.local.example", true}, + {"backend.env.template", "backend.env.template", true}, + + // Nested paths (Unix and Windows) + {"nested unix path", "configs/sub/.env", true}, + {"nested windows path", `C:\Users\dev\repo\.env.production`, true}, + {"relative current dir", "./.env", true}, + {"nested unix with prefix", "src/backend/.env.staging", true}, + {"nested windows with suffix", `services\auth\api.env`, true}, + + // Negative cases (non-env files) + {"empty string", "", false}, + {"dot only", ".", false}, + {"main.go", "main.go", false}, + {"environment.js", "environment.js", false}, + {"envelope.py", "envelope.py", false}, + {"env.go", "env.go", false}, + {"dotenv.rs", "dotenv.rs", false}, + {"env_vars.json", "env_vars.json", false}, + {"package.json", "package.json", false}, + {"README.md", "README.md", false}, + {"config.yaml", "config.yaml", false}, + {"app.environment", "app.environment", false}, + {"environment_test.go", "environment_test.go", false}, + {"some-env-file.txt", "some-env-file.txt", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsEnvFile(tt.path) + if got != tt.expected { + t.Errorf("IsEnvFile(%q) = %v; want %v", tt.path, got, tt.expected) + } + }) + } +} + +func TestIsAllowed(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + // Standard default allowlist + {".env.example", ".env.example", true}, + {".env.sample", ".env.sample", true}, + {".env.template", ".env.template", true}, + {".ENV.EXAMPLE (uppercase)", ".ENV.EXAMPLE", true}, + {".env.SAMPLE (mixed case)", ".env.SAMPLE", true}, + + // Variants with suffixes + {".env.local.example", ".env.local.example", true}, + {".env.production.sample", ".env.production.sample", true}, + {"backend.env.template", "backend.env.template", true}, + {"api.env.example", "api.env.example", true}, + + // Paths with directories + {"nested unix example", "config/docker/.env.example", true}, + {"nested windows template", `C:\Projects\app\.env.local.template`, true}, + + // Env files that are NOT allowed (real secret files) + {".env", ".env", false}, + {".env.local", ".env.local", false}, + {".env.production", ".env.production", false}, + {"backend.env", "backend.env", false}, + {"docker.env", "docker.env", false}, + + // Non-env files (should not be in allowlist) + {"main.go", "main.go", false}, + {"readme.md", "readme.md", false}, + {"example.go", "example.go", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsAllowed(tt.path) + if got != tt.expected { + t.Errorf("IsAllowed(%q) = %v; want %v", tt.path, got, tt.expected) + } + }) + } +} + +func TestDetect(t *testing.T) { + tests := []struct { + name string + path string + expectedIsEnv bool + expectedAllowed bool + }{ + {"sensitive .env", ".env", true, false}, + {"sensitive .env.production", ".env.production", true, false}, + {"sensitive backend.env", "services/backend.env", true, false}, + {"safe .env.example", ".env.example", true, true}, + {"safe .env.local.template", "config/.env.local.template", true, true}, + {"regular code file", "pkg/detector/detector.go", false, false}, + {"regular config", "configs/settings.json", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isEnv, isAllowed := Detect(tt.path) + if isEnv != tt.expectedIsEnv || isAllowed != tt.expectedAllowed { + t.Errorf("Detect(%q) = (%v, %v); want (%v, %v)", + tt.path, isEnv, isAllowed, tt.expectedIsEnv, tt.expectedAllowed) + } + }) + } +} + +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") + } + if customDetector.IsAllowed("app.env.defaults") != true { + t.Errorf("expected app.env.defaults to be allowed in custom detector") + } + if customDetector.IsAllowed(".env.production") != false { + t.Errorf("expected .env.production to not be allowed") + } +}