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
154 changes: 154 additions & 0 deletions internal/detector/detector.go
Original file line number Diff line number Diff line change
@@ -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)
}
168 changes: 168 additions & 0 deletions internal/detector/detector_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}