diff --git a/internal/scanner/finding.go b/internal/scanner/finding.go new file mode 100644 index 0000000..a148aa4 --- /dev/null +++ b/internal/scanner/finding.go @@ -0,0 +1,77 @@ +package scanner + +import ( + "time" + + "github.com/joaooncode/envguard/internal/git" +) + +// Severity represents the severity level of a detected finding. +type Severity string + +const ( + // SeverityCritical indicates an environment file tracked in Git history. + SeverityCritical Severity = "critical" + // SeverityHigh indicates an environment file staged in Git index. + SeverityHigh Severity = "high" + // SeverityWarning indicates an unprotected local environment file not covered by .gitignore. + SeverityWarning Severity = "warning" + // SeverityInfo indicates an allowed template/example file or properly ignored file. + SeverityInfo Severity = "info" +) + +// Finding represents a detected environment file and its Git security posture. +type Finding struct { + Path string `json:"path"` + Severity Severity `json:"severity"` + Message string `json:"message"` + Suggestions []string `json:"suggestions,omitempty"` + GitStatus git.FileStatus `json:"git_status"` + IsAllowed bool `json:"is_allowed"` +} + +// Summary aggregates finding counts categorized by severity. +type Summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Warning int `json:"warning"` + Info int `json:"info"` + Passed bool `json:"passed"` +} + +// Result contains the complete scan output including findings and summary metrics. +type Result struct { + Version string `json:"version,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` + ScannedDir string `json:"scanned_dir"` + Findings []Finding `json:"findings"` + Summary Summary `json:"summary"` +} + +// CalculateSummary computes metrics for a slice of findings. +func CalculateSummary(findings []Finding) Summary { + var s Summary + for _, f := range findings { + switch f.Severity { + case SeverityCritical: + s.Critical++ + case SeverityHigh: + s.High++ + case SeverityWarning: + s.Warning++ + case SeverityInfo: + s.Info++ + } + } + s.Total = len(findings) + s.Passed = (s.Critical == 0 && s.High == 0 && s.Warning == 0) + return s +} + +// CalculateSummary updates and returns the Result's summary. +func (r *Result) CalculateSummary() Summary { + r.Summary = CalculateSummary(r.Findings) + return r.Summary +} + diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..2bf9651 --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,173 @@ +package scanner + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/joaooncode/envguard/internal/detector" + "github.com/joaooncode/envguard/internal/git" +) + +// IgnoredDirectories contains the directory names skipped during recursive scanning. +var IgnoredDirectories = map[string]bool{ + ".git": true, + "node_modules": true, + "vendor": true, + "dist": true, + "build": true, + ".idea": true, + ".vscode": true, +} + +// Scanner coordinates filesystem traversal, environment detection, and Git status inspection. +type Scanner struct { + gitClient git.Client + detector *detector.Detector +} + +// New creates a new Scanner instance with the provided git client and detector. +func New(gitClient git.Client, det *detector.Detector) *Scanner { + if gitClient == nil { + gitClient = git.NewClient() + } + if det == nil { + det = detector.New() + } + return &Scanner{ + gitClient: gitClient, + detector: det, + } +} + +// NewDefault creates a Scanner configured with default Git and Detector implementations. +func NewDefault() *Scanner { + return New(git.NewClient(), detector.New()) +} + +// Scan recursively walks the directory and classifies any detected environment files. +// It never opens or reads file contents. +func (s *Scanner) Scan(dir string) (*Result, error) { + if dir == "" { + dir = "." + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve directory path: %w", err) + } + + info, err := os.Stat(absDir) + if err != nil { + return nil, fmt.Errorf("failed to stat directory %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("path is not a directory: %s", dir) + } + + result := &Result{ + ScannedDir: dir, + Findings: make([]Finding, 0), + } + + err = filepath.WalkDir(absDir, func(currentPath string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + if currentPath != absDir && IgnoredDirectories[d.Name()] { + return filepath.SkipDir + } + return nil + } + + // Evaluate path against detector rules without opening or reading the file + isEnv, isAllowed := s.detector.Detect(d.Name()) + if !isEnv { + return nil + } + + relPath, err := filepath.Rel(absDir, currentPath) + if err != nil { + relPath = currentPath + } + relPath = filepath.ToSlash(relPath) + + status, err := s.gitClient.GetFileStatus(absDir, relPath) + if err != nil { + status = git.FileStatus{ + IsRepo: false, + IsTracked: false, + IsStaged: false, + IsIgnored: false, + } + } + + finding := s.classifyFinding(relPath, status, isAllowed) + result.Findings = append(result.Findings, finding) + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error walking directory %s: %w", dir, err) + } + + result.CalculateSummary() + return result, nil +} + +// classifyFinding evaluates file metadata and git status to assign a severity level, message, and recommendations. +func (s *Scanner) classifyFinding(relPath string, status git.FileStatus, isAllowed bool) Finding { + var severity Severity + var message string + var suggestions []string + + if isAllowed { + severity = SeverityInfo + message = "Safe environment template/example file allowed." + suggestions = []string{} + } else if status.IsStaged { + severity = SeverityHigh + message = "Environment file is staged for commit in Git index." + suggestions = []string{ + fmt.Sprintf("Unstage file: git restore --staged %s", relPath), + "Add to .gitignore", + } + } else if status.IsTracked { + severity = SeverityCritical + message = "Environment file is tracked by Git (committed in repository history)." + suggestions = []string{ + fmt.Sprintf("Remove file from git tracking: git rm --cached %s", relPath), + "Add to .gitignore", + "Rotate any leaked credentials", + } + } else if status.IsIgnored { + severity = SeverityInfo + message = "Environment file is properly ignored by .gitignore." + suggestions = []string{} + } else { + severity = SeverityWarning + message = "Environment file exists locally and is not ignored by .gitignore." + suggestions = []string{ + "Add to .gitignore", + } + } + + return Finding{ + Path: relPath, + Severity: severity, + Message: message, + Suggestions: suggestions, + GitStatus: status, + IsAllowed: isAllowed, + } +} + +// DefaultScanner is the package-level default scanner instance. +var DefaultScanner = NewDefault() + +// Scan walks the specified directory using the default scanner. +func Scan(dir string) (*Result, error) { + return DefaultScanner.Scan(dir) +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..0175cff --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -0,0 +1,412 @@ +package scanner + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/joaooncode/envguard/internal/detector" + "github.com/joaooncode/envguard/internal/git" +) + +// setupGitRepo creates a temporary initialized git repository. +func setupGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + runGit(t, dir, "init") + runGit(t, dir, "config", "user.email", "test@envguard.dev") + runGit(t, dir, "config", "user.name", "Envguard Test") + runGit(t, dir, "config", "commit.gpgsign", "false") + + return dir +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out)) + } + return string(out) +} + +func TestCalculateSummary(t *testing.T) { + findings := []Finding{ + {Severity: SeverityCritical}, + {Severity: SeverityCritical}, + {Severity: SeverityHigh}, + {Severity: SeverityWarning}, + {Severity: SeverityWarning}, + {Severity: SeverityWarning}, + {Severity: SeverityInfo}, + } + + summary := CalculateSummary(findings) + + if summary.Critical != 2 { + t.Errorf("expected Critical = 2, got %d", summary.Critical) + } + if summary.High != 1 { + t.Errorf("expected High = 1, got %d", summary.High) + } + if summary.Warning != 3 { + t.Errorf("expected Warning = 3, got %d", summary.Warning) + } + if summary.Info != 1 { + t.Errorf("expected Info = 1, got %d", summary.Info) + } + if summary.Total != 7 { + t.Errorf("expected Total = 7, got %d", summary.Total) + } + + // Test Result method CalculateSummary + res := &Result{Findings: findings} + resSummary := res.CalculateSummary() + if resSummary.Total != 7 || res.Summary.Total != 7 { + t.Errorf("expected Result.CalculateSummary to update Summary, got %d", res.Summary.Total) + } +} + +func TestClassifyFinding(t *testing.T) { + s := NewDefault() + + tests := []struct { + name string + path string + status git.FileStatus + isAllowed bool + expectedSeverity Severity + expectedMessage string + expectedSuggestions []string + }{ + { + name: "Tracked env file (CRITICAL)", + path: ".env", + status: git.FileStatus{ + IsRepo: true, + IsTracked: true, + }, + isAllowed: false, + expectedSeverity: SeverityCritical, + expectedMessage: "Environment file is tracked by Git (committed in repository history).", + expectedSuggestions: []string{ + "Remove file from git tracking: git rm --cached .env", + "Add to .gitignore", + "Rotate any leaked credentials", + }, + }, + { + name: "Staged env file (HIGH)", + path: ".env.staging", + status: git.FileStatus{ + IsRepo: true, + IsTracked: false, + IsStaged: true, + }, + isAllowed: false, + expectedSeverity: SeverityHigh, + expectedMessage: "Environment file is staged for commit in Git index.", + expectedSuggestions: []string{ + "Unstage file: git restore --staged .env.staging", + "Add to .gitignore", + }, + }, + { + name: "Untracked, unignored env file (WARNING)", + path: ".env.local", + status: git.FileStatus{ + IsRepo: true, + IsTracked: false, + IsStaged: false, + IsIgnored: false, + }, + isAllowed: false, + expectedSeverity: SeverityWarning, + expectedMessage: "Environment file exists locally and is not ignored by .gitignore.", + expectedSuggestions: []string{ + "Add to .gitignore", + }, + }, + { + name: "Properly ignored env file (INFO)", + path: ".env.local", + status: git.FileStatus{ + IsRepo: true, + IsTracked: false, + IsStaged: false, + IsIgnored: true, + }, + isAllowed: false, + expectedSeverity: SeverityInfo, + expectedMessage: "Environment file is properly ignored by .gitignore.", + expectedSuggestions: []string{}, + }, + { + name: "Allowed template file (INFO)", + path: ".env.example", + status: git.FileStatus{ + IsRepo: true, + IsTracked: true, + }, + isAllowed: true, + expectedSeverity: SeverityInfo, + expectedMessage: "Safe environment template/example file allowed.", + expectedSuggestions: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + finding := s.classifyFinding(tt.path, tt.status, tt.isAllowed) + + if finding.Severity != tt.expectedSeverity { + t.Errorf("Severity = %s, want %s", finding.Severity, tt.expectedSeverity) + } + if finding.Message != tt.expectedMessage { + t.Errorf("Message = %q, want %q", finding.Message, tt.expectedMessage) + } + if len(finding.Suggestions) != len(tt.expectedSuggestions) { + t.Fatalf("Suggestions length = %d, want %d", len(finding.Suggestions), len(tt.expectedSuggestions)) + } + for i, sug := range tt.expectedSuggestions { + if finding.Suggestions[i] != sug { + t.Errorf("Suggestion[%d] = %q, want %q", i, finding.Suggestions[i], sug) + } + } + }) + } +} + +func TestScanIgnoredDirectories(t *testing.T) { + tempDir := t.TempDir() + + // Create root .env + if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte("ROOT=1"), 0644); err != nil { + t.Fatal(err) + } + + // Create ignored directories with .env files + ignoredDirs := []string{ + ".git", + "node_modules", + "vendor", + "dist", + "build", + ".idea", + ".vscode", + filepath.Join("nested", "node_modules"), + } + + for _, d := range ignoredDirs { + dirPath := filepath.Join(tempDir, d) + if err := os.MkdirAll(dirPath, 0755); err != nil { + t.Fatal(err) + } + envPath := filepath.Join(dirPath, ".env") + if err := os.WriteFile(envPath, []byte("SECRET=1"), 0644); err != nil { + t.Fatal(err) + } + } + + s := NewDefault() + result, err := s.Scan(tempDir) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + + // Only root .env should be found + if len(result.Findings) != 1 { + t.Fatalf("expected exactly 1 finding (root .env), got %d findings: %+v", len(result.Findings), result.Findings) + } + + if result.Findings[0].Path != ".env" { + t.Errorf("expected finding path to be '.env', got %s", result.Findings[0].Path) + } +} + +func TestScanGitRepositoryIntegration(t *testing.T) { + repoDir := setupGitRepo(t) + + // 1. CRITICAL: committed .env + trackedFile := filepath.Join(repoDir, ".env.production") + if err := os.WriteFile(trackedFile, []byte("PROD=secret"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "add", ".env.production") + runGit(t, repoDir, "commit", "-m", "chore: add prod env") + + // 2. HIGH: staged .env + stagedFile := filepath.Join(repoDir, ".env.staging") + if err := os.WriteFile(stagedFile, []byte("STAGE=secret"), 0644); err != nil { + t.Fatal(err) + } + runGit(t, repoDir, "add", ".env.staging") + + // 3. INFO: .gitignore + ignored .env + gitignoreFile := filepath.Join(repoDir, ".gitignore") + if err := os.WriteFile(gitignoreFile, []byte(".env.local\n"), 0644); err != nil { + t.Fatal(err) + } + ignoredFile := filepath.Join(repoDir, ".env.local") + if err := os.WriteFile(ignoredFile, []byte("LOCAL=1"), 0644); err != nil { + t.Fatal(err) + } + + // 4. WARNING: untracked .env not in .gitignore + warningFile := filepath.Join(repoDir, ".env.test") + if err := os.WriteFile(warningFile, []byte("TEST=1"), 0644); err != nil { + t.Fatal(err) + } + + // 5. INFO: allowed template + allowedFile := filepath.Join(repoDir, ".env.example") + if err := os.WriteFile(allowedFile, []byte("EXAMPLE=1"), 0644); err != nil { + t.Fatal(err) + } + + s := NewDefault() + res, err := s.Scan(repoDir) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + + if res.Summary.Total != 5 { + t.Fatalf("expected 5 total findings, got %d (findings: %+v)", res.Summary.Total, res.Findings) + } + if res.Summary.Critical != 1 { + t.Errorf("expected 1 Critical finding, got %d", res.Summary.Critical) + } + if res.Summary.High != 1 { + t.Errorf("expected 1 High finding, got %d", res.Summary.High) + } + if res.Summary.Warning != 1 { + t.Errorf("expected 1 Warning finding, got %d", res.Summary.Warning) + } + if res.Summary.Info != 2 { + t.Errorf("expected 2 Info findings, got %d", res.Summary.Info) + } + + // Validate finding details + findingsMap := make(map[string]Finding) + for _, f := range res.Findings { + findingsMap[f.Path] = f + } + + if f, ok := findingsMap[".env.production"]; !ok || f.Severity != SeverityCritical { + t.Errorf("expected .env.production to be CRITICAL, got %+v", f) + } + if f, ok := findingsMap[".env.staging"]; !ok || f.Severity != SeverityHigh { + t.Errorf("expected .env.staging to be HIGH, got %+v", f) + } + if f, ok := findingsMap[".env.test"]; !ok || f.Severity != SeverityWarning { + t.Errorf("expected .env.test to be WARNING, got %+v", f) + } + if f, ok := findingsMap[".env.local"]; !ok || f.Severity != SeverityInfo { + t.Errorf("expected .env.local to be INFO, got %+v", f) + } + if f, ok := findingsMap[".env.example"]; !ok || f.Severity != SeverityInfo { + t.Errorf("expected .env.example to be INFO, got %+v", f) + } +} + +func TestScanNonGitDirectory(t *testing.T) { + tempDir := t.TempDir() + + envFile := filepath.Join(tempDir, ".env") + if err := os.WriteFile(envFile, []byte("FOO=bar"), 0644); err != nil { + t.Fatal(err) + } + exampleFile := filepath.Join(tempDir, ".env.example") + if err := os.WriteFile(exampleFile, []byte("FOO=example"), 0644); err != nil { + t.Fatal(err) + } + + res, err := Scan(tempDir) + if err != nil { + t.Fatalf("Scan on non-git dir failed: %v", err) + } + + if res.Summary.Total != 2 { + t.Errorf("expected 2 findings, got %d", res.Summary.Total) + } + if res.Summary.Warning != 1 { + t.Errorf("expected 1 Warning finding for .env, got %d", res.Summary.Warning) + } + if res.Summary.Info != 1 { + t.Errorf("expected 1 Info finding for .env.example, got %d", res.Summary.Info) + } +} + +func TestScanInvalidDirectory(t *testing.T) { + s := NewDefault() + + // Non-existent directory + _, err := s.Scan("/path/that/does/not/exist/9999") + if err == nil { + t.Errorf("expected error scanning non-existent directory") + } + + // File instead of directory + tempFile := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(tempFile, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + _, err = s.Scan(tempFile) + if err == nil { + t.Errorf("expected error scanning regular file instead of directory") + } +} + +// mockGitClient simulates custom git client behaviors. +type mockGitClient struct { + statusFn func(dir, filePath string) (git.FileStatus, error) +} + +func (m *mockGitClient) IsAvailable() bool { return true } +func (m *mockGitClient) IsGitRepo(dir string) bool { return true } +func (m *mockGitClient) GetRepoRoot(dir string) (string, error) { return dir, nil } +func (m *mockGitClient) IsTracked(dir, filePath string) (bool, error) { return false, nil } +func (m *mockGitClient) IsStaged(dir, filePath string) (bool, error) { return false, nil } +func (m *mockGitClient) IsIgnored(dir, filePath string) (bool, error) { return false, nil } +func (m *mockGitClient) GetFileStatus(dir, filePath string) (git.FileStatus, error) { + if m.statusFn != nil { + return m.statusFn(dir, filePath) + } + return git.FileStatus{}, nil +} + +func TestScannerCustomComponents(t *testing.T) { + mock := &mockGitClient{ + statusFn: func(dir, filePath string) (git.FileStatus, error) { + if strings.Contains(filePath, "tracked") { + return git.FileStatus{IsRepo: true, IsTracked: true}, nil + } + return git.FileStatus{IsRepo: true}, nil + }, + } + + customDetector := detector.NewWithAllowlist([]string{".env.custom.allowed"}) + s := New(mock, customDetector) + + tempDir := t.TempDir() + _ = os.WriteFile(filepath.Join(tempDir, ".env.tracked"), []byte(""), 0644) + _ = os.WriteFile(filepath.Join(tempDir, ".env.custom.allowed"), []byte(""), 0644) + + res, err := s.Scan(tempDir) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + + if res.Summary.Critical != 1 { + t.Errorf("expected 1 Critical finding, got %d", res.Summary.Critical) + } + if res.Summary.Info != 1 { + t.Errorf("expected 1 Info finding for custom allowed file, got %d", res.Summary.Info) + } +}