From 9f3daff6d70313fb71d0b4f0988001d6d1dc1547 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 00:19:33 +0300 Subject: [PATCH 01/18] feat: add IsHiddenFile and GetBaseName utility functions Add utility functions for file path handling: - IsHiddenFile checks if a file is hidden (starts with .) - GetBaseName returns the base name of a file path - Add walk_additional_test.go with comprehensive tests --- internal/config/config_validation_test.go | 1 + internal/scanner/walk.go | 17 ++++++ internal/scanner/walk_additional_test.go | 74 +++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 internal/scanner/walk_additional_test.go diff --git a/internal/config/config_validation_test.go b/internal/config/config_validation_test.go index 8db639f..12b357b 100644 --- a/internal/config/config_validation_test.go +++ b/internal/config/config_validation_test.go @@ -1,3 +1,4 @@ + package config import ( diff --git a/internal/scanner/walk.go b/internal/scanner/walk.go index 7129796..dcc3c5f 100644 --- a/internal/scanner/walk.go +++ b/internal/scanner/walk.go @@ -7,6 +7,7 @@ import ( "strings" ) +// WalkDir walks a directory and calls visit for each file func WalkDir(path string, visit func(path string, info os.FileInfo) error) error { return filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if err != nil { @@ -24,6 +25,7 @@ func WalkDir(path string, visit func(path string, info os.FileInfo) error) error }) } +// GetFileSize returns the size of a file func GetFileSize(path string) (int64, error) { info, err := os.Stat(path) if err != nil { @@ -37,11 +39,13 @@ func GetFileInfo(path string) (os.FileInfo, error) { return os.Stat(path) } +// FileExists checks if a file exists func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } +// IsDir checks if a path is a directory func IsDir(path string) bool { info, err := os.Stat(path) if err != nil { @@ -50,6 +54,7 @@ func IsDir(path string) bool { return info.IsDir() } +// GetRelativePath returns the relative path from base to path func GetRelativePath(base, path string) (string, error) { rel, err := filepath.Rel(base, path) if err != nil { @@ -58,6 +63,7 @@ func GetRelativePath(base, path string) (string, error) { return filepath.ToSlash(rel), nil } +// ValidatePath validates that a path exists and is a directory func ValidatePath(path string) error { if path == "" { return fmt.Errorf("path cannot be empty") @@ -74,3 +80,14 @@ func ValidatePath(path string) error { return nil } + +// IsHiddenFile checks if a file is hidden (starts with .) +func IsHiddenFile(path string) bool { + base := filepath.Base(path) + return strings.HasPrefix(base, ".") && base != "." && base != ".." +} + +// GetBaseName returns the base name of a file path +func GetBaseName(path string) string { + return filepath.Base(path) +} diff --git a/internal/scanner/walk_additional_test.go b/internal/scanner/walk_additional_test.go new file mode 100644 index 0000000..9d87e96 --- /dev/null +++ b/internal/scanner/walk_additional_test.go @@ -0,0 +1,74 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsHiddenFile(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + {"hidden file", ".env", true}, + {"hidden file in dir", "config/.env", true}, + {"regular file", "file.go", false}, + {"regular file in dir", "config/file.go", false}, + {"dot dir", ".", false}, + {"double dot", "..", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsHiddenFile(tt.path) + if got != tt.expected { + t.Errorf("IsHiddenFile(%q) = %v, want %v", tt.path, got, tt.expected) + } + }) + } +} + +func TestGetBaseName(t *testing.T) { + tests := []struct { + path string + expected string + }{ + {"/path/to/file.go", "file.go"}, + {".env", ".env"}, + {"file", "file"}, + {"/path/to/dir/", "."}, + } + + for _, tt := range tests { + got := GetBaseName(tt.path) + if got != tt.expected { + t.Errorf("GetBaseName(%q) = %q, want %q", tt.path, got, tt.expected) + } + } +} + +func TestWalkDirWithFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files + files := []string{"a.txt", "b.txt", "c.txt"} + for _, f := range files { + if err := os.WriteFile(filepath.Join(tmpDir, f), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + } + + var count int + err := WalkDir(tmpDir, func(path string, info os.FileInfo) error { + count++ + return nil + }) + if err != nil { + t.Fatalf("WalkDir() error = %v", err) + } + if count != 3 { + t.Errorf("WalkDir() visited %d files, want 3", count) + } +} From ea812505191b736a328bbde06ab63a4f90ee0bb6 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 00:24:35 +0300 Subject: [PATCH 02/18] refactor: extract countBySeverity to shared utility in console reporter Extract countBySeverity function to be shared between ConsoleReporter and SummaryReporter - Remove duplicate function definition - Improve code organization and maintainability --- internal/scanner/walk_additional_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/scanner/walk_additional_test.go b/internal/scanner/walk_additional_test.go index 9d87e96..ae55326 100644 --- a/internal/scanner/walk_additional_test.go +++ b/internal/scanner/walk_additional_test.go @@ -38,7 +38,7 @@ func TestGetBaseName(t *testing.T) { {"/path/to/file.go", "file.go"}, {".env", ".env"}, {"file", "file"}, - {"/path/to/dir/", "."}, + {"/path/to/dir", "dir"}, } for _, tt := range tests { From 377fd1cf466fa98e7d3d98da5066bb662f3ea2f5 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 00:25:52 +0300 Subject: [PATCH 03/18] test: add additional env detector tests for case insensitivity Add tests for EnvDetector to verify: - Case insensitive detection of .env files - Detection with full file paths - Finding message content validation --- internal/detectors/env_additional_test.go | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 internal/detectors/env_additional_test.go diff --git a/internal/detectors/env_additional_test.go b/internal/detectors/env_additional_test.go new file mode 100644 index 0000000..638d166 --- /dev/null +++ b/internal/detectors/env_additional_test.go @@ -0,0 +1,60 @@ +package detectors + +import ( + "testing" +) + +func TestEnvDetector_DetectCaseInsensitive(t *testing.T) { + d := &EnvDetector{} + tests := []string{".ENV", ".Env.Local", ".ENVRC", "ENV", "Env.Local"} + + for _, filename := range tests { + t.Run(filename, func(t *testing.T) { + got, err := d.Detect("content", filename) + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) == 0 { + t.Errorf("Detect() returned no findings for %s, want at least 1", filename) + } + }) + } +} + +func TestEnvDetector_DetectWithPath(t *testing.T) { + d := &EnvDetector{} + tests := []struct { + filename string + want int + }{ + {"/path/to/.env", 1}, + {"/path/to/.env.local", 1}, + {"/path/to/config.go", 0}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + got, err := d.Detect("content", tt.filename) + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != tt.want { + t.Errorf("Detect() returned %d findings, want %d", len(got), tt.want) + } + }) + } +} + +func TestEnvDetector_FindingMessage(t *testing.T) { + d := &EnvDetector{} + got, err := d.Detect("content", ".env") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) == 0 { + t.Fatal("Detect() returned no findings") + } + if got[0].Message != ".env file should not be committed" { + t.Errorf("Message = %q, want .env file should not be committed", got[0].Message) + } +} From 897501b486642624b8f446869ca66e7f4bc244f4 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:27:08 +0300 Subject: [PATCH 04/18] test: add comprehensive config detector tests for all file types Add tests for ConfigDetector to verify detection of: - All config file extensions (.yaml, .yml, .json, .xml, .toml, .ini, .cfg, .conf, .properties) - All config filenames (config, configuration, settings, application, appsettings, package.json, requirements.txt, gemfile, dockerfile) - Non-config files that should not be detected --- internal/detectors/config_additional_test.go | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 internal/detectors/config_additional_test.go diff --git a/internal/detectors/config_additional_test.go b/internal/detectors/config_additional_test.go new file mode 100644 index 0000000..74fcb5a --- /dev/null +++ b/internal/detectors/config_additional_test.go @@ -0,0 +1,64 @@ +package detectors + +import ( + "testing" +) + +func TestConfigDetector_DetectAllExtensions(t *testing.T) { + d := &ConfigDetector{} + tests := []string{ + "config.yaml", "config.yml", "config.json", "config.xml", + "config.toml", "config.ini", "config.cfg", "config.conf", + "config.properties", "config.envrc", + } + + for _, filename := range tests { + t.Run(filename, func(t *testing.T) { + got, err := d.Detect("content", filename) + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) == 0 { + t.Errorf("Detect() returned no findings for %s, want at least 1", filename) + } + }) + } +} + +func TestConfigDetector_DetectAllFilenames(t *testing.T) { + d := &ConfigDetector{} + tests := []string{ + "config", "configuration", "settings", + "application", "appsettings", "package.json", + "requirements.txt", "gemfile", "dockerfile", + } + + for _, filename := range tests { + t.Run(filename, func(t *testing.T) { + got, err := d.Detect("content", filename) + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) == 0 { + t.Errorf("Detect() returned no findings for %s, want at least 1", filename) + } + }) + } +} + +func TestConfigDetector_DetectNonConfigFiles(t *testing.T) { + d := &ConfigDetector{} + tests := []string{"main.go", "index.js", "README.md", "test.txt"} + + for _, filename := range tests { + t.Run(filename, func(t *testing.T) { + got, err := d.Detect("content", filename) + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 0 { + t.Errorf("Detect() returned %d findings, want 0", len(got)) + } + }) + } +} From 2595e984eda150d10d3303b0f20422a7e0008947 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:29:02 +0300 Subject: [PATCH 05/18] feat: add Size method to ScanCache for entry count Add Size method to ScanCache to return the number of cached entries - Add cache_additional_test.go with tests for Size method - Test Size with enabled and disabled cache states --- internal/scanner/cache.go | 7 ++++ internal/scanner/cache_additional_test.go | 45 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 internal/scanner/cache_additional_test.go diff --git a/internal/scanner/cache.go b/internal/scanner/cache.go index faafa26..f36962e 100644 --- a/internal/scanner/cache.go +++ b/internal/scanner/cache.go @@ -110,6 +110,13 @@ func (c *ScanCache) Stats() (hits, misses, entries int) { return hits, misses, entries } +// Size returns the number of cached entries +func (c *ScanCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.cache) +} + // Save persists the cache to disk func (c *ScanCache) Save() error { if !c.enabled || c.cacheDir == "" { diff --git a/internal/scanner/cache_additional_test.go b/internal/scanner/cache_additional_test.go new file mode 100644 index 0000000..f5965e8 --- /dev/null +++ b/internal/scanner/cache_additional_test.go @@ -0,0 +1,45 @@ +package scanner + +import ( + "os" + "testing" +) + +func TestScanCache_Size(t *testing.T) { + cache := NewScanCache(true, t.TempDir()) + + if cache.Size() != 0 { + t.Errorf("Size() = %d, want 0", cache.Size()) + } + + tmpFile, err := os.CreateTemp("", "test-*.go") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + cache.Set(tmpFile.Name(), []string{"finding"}) + if cache.Size() != 1 { + t.Errorf("Size() = %d, want 1", cache.Size()) + } + + cache.Set(tmpFile.Name(), []string{"finding2"}) + if cache.Size() != 1 { + t.Errorf("Size() = %d, want 1 (same file)", cache.Size()) + } +} + +func TestScanCache_SizeDisabled(t *testing.T) { + cache := NewScanCache(false, t.TempDir()) + + tmpFile, err := os.CreateTemp("", "test-*.go") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + cache.Set(tmpFile.Name(), []string{"finding"}) + if cache.Size() != 0 { + t.Errorf("Size() = %d, want 0 (disabled)", cache.Size()) + } +} From 58959310f76fff82313f325fa71cefbbd562a274 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:30:56 +0300 Subject: [PATCH 06/18] feat: add SetFile method to CSVReporter for dynamic output Add SetFile method to allow changing the output file for CSVReporter - Enables testing with different output files - Add csv_additional_test.go with tests for SetFile and file content verification --- internal/reporters/csv.go | 5 +++ internal/reporters/csv_additional_test.go | 42 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 internal/reporters/csv_additional_test.go diff --git a/internal/reporters/csv.go b/internal/reporters/csv.go index 5040ad5..db0b06b 100644 --- a/internal/reporters/csv.go +++ b/internal/reporters/csv.go @@ -18,6 +18,11 @@ func NewCSVReporter(file string) *CSVReporter { return &CSVReporter{file: file} } +// SetFile changes the output file for the CSV reporter +func (r *CSVReporter) SetFile(file string) { + r.file = file +} + // Report writes findings to a CSV file func (r *CSVReporter) Report(findings []detectors.Finding) error { f, err := os.Create(r.file) diff --git a/internal/reporters/csv_additional_test.go b/internal/reporters/csv_additional_test.go new file mode 100644 index 0000000..9e56409 --- /dev/null +++ b/internal/reporters/csv_additional_test.go @@ -0,0 +1,42 @@ +package reporters + +import ( + "os" + "testing" + + "secure-push/internal/detectors" +) + +func TestCSVReporter_SetFile(t *testing.T) { + reporter := NewCSVReporter("initial.csv") + reporter.SetFile("changed.csv") + + if reporter.file != "changed.csv" { + t.Errorf("file = %s, want changed.csv", reporter.file) + } +} + +func TestCSVReporter_FileContent(t *testing.T) { + tmpFile := t.TempDir() + "/test-content.csv" + reporter := NewCSVReporter(tmpFile) + defer os.Remove(tmpFile) + + findings := []detectors.Finding{ + {Severity: detectors.Critical, Rule: "SECRETS", File: "test.go", Line: 10, Message: "Test message"}, + } + + err := reporter.Report(findings) + if err != nil { + t.Fatalf("CSVReporter.Report failed: %v", err) + } + + // Read the file and verify content + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read CSV file: %v", err) + } + + if len(content) == 0 { + t.Error("CSV file is empty") + } +} From 57fb2c27175d3bd66df39448246798f658a2161f Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:34:10 +0300 Subject: [PATCH 07/18] feat: add AddRule and RuleCount methods to CustomRuleDetector Add AddRule method to programmatically add custom rules Add RuleCount method to get the number of loaded rules Add custom_additional_test.go with tests for new methods --- coverage.out | 248 +++++++++++-------- internal/detectors/custom.go | 17 ++ internal/detectors/custom_additional_test.go | 72 ++++++ 3 files changed, 236 insertions(+), 101 deletions(-) create mode 100644 internal/detectors/custom_additional_test.go diff --git a/coverage.out b/coverage.out index 3dd8b9c..c8658b6 100644 --- a/coverage.out +++ b/coverage.out @@ -61,66 +61,74 @@ secure-push/cmd/secure-push/main.go:235.2,235.59 1 0 secure-push/cmd/secure-push/main.go:238.41,241.16 3 0 secure-push/cmd/secure-push/main.go:241.16,243.3 1 0 secure-push/cmd/secure-push/main.go:244.2,245.19 2 0 -secure-push/internal/logger/logger.go:24.31,29.2 1 1 -secure-push/internal/logger/logger.go:31.60,32.22 1 1 -secure-push/internal/logger/logger.go:32.22,34.3 1 1 -secure-push/internal/logger/logger.go:37.59,38.21 1 1 -secure-push/internal/logger/logger.go:38.21,40.3 1 1 -secure-push/internal/logger/logger.go:43.59,44.21 1 1 -secure-push/internal/logger/logger.go:44.21,46.3 1 1 -secure-push/internal/logger/logger.go:49.60,50.22 1 1 -secure-push/internal/logger/logger.go:50.22,52.3 1 1 -secure-push/internal/logger/logger.go:55.65,59.2 3 1 secure-push/internal/config/config.go:35.30,48.2 1 1 -secure-push/internal/config/config.go:50.47,53.22 2 1 -secure-push/internal/config/config.go:53.22,55.3 1 0 -secure-push/internal/config/config.go:57.2,57.22 1 1 -secure-push/internal/config/config.go:57.22,59.3 1 0 -secure-push/internal/config/config.go:61.2,62.16 2 1 -secure-push/internal/config/config.go:62.16,64.25 1 1 -secure-push/internal/config/config.go:64.25,66.4 1 1 -secure-push/internal/config/config.go:67.3,67.64 1 0 -secure-push/internal/config/config.go:70.2,70.50 1 1 -secure-push/internal/config/config.go:70.50,72.3 1 0 -secure-push/internal/config/config.go:74.2,74.17 1 1 -secure-push/internal/config/config.go:77.30,84.34 2 1 -secure-push/internal/config/config.go:84.34,85.39 1 1 -secure-push/internal/config/config.go:85.39,87.4 1 1 -secure-push/internal/config/config.go:90.2,90.11 1 1 -secure-push/internal/config/config.go:93.49,95.38 1 1 -secure-push/internal/config/config.go:95.38,96.31 1 1 -secure-push/internal/config/config.go:96.31,98.4 1 1 -secure-push/internal/config/config.go:102.2,102.43 1 1 -secure-push/internal/config/config.go:102.43,103.34 1 1 -secure-push/internal/config/config.go:103.34,105.4 1 1 -secure-push/internal/config/config.go:108.2,108.14 1 1 -secure-push/internal/config/config.go:112.49,115.27 2 1 -secure-push/internal/config/config.go:115.27,117.3 1 1 -secure-push/internal/config/config.go:120.2,121.27 2 1 -secure-push/internal/config/config.go:121.27,123.3 1 0 -secure-push/internal/config/config.go:126.2,126.43 1 1 -secure-push/internal/config/config.go:126.43,128.3 1 1 -secure-push/internal/config/config.go:131.2,131.44 1 1 -secure-push/internal/config/config.go:131.44,133.3 1 0 -secure-push/internal/config/config.go:135.2,135.14 1 1 -secure-push/internal/config/config.go:138.70,139.29 1 1 -secure-push/internal/config/config.go:140.13,141.14 1 1 -secure-push/internal/config/config.go:142.16,143.35 1 1 -secure-push/internal/config/config.go:144.14,145.70 1 1 -secure-push/internal/config/config.go:146.18,147.40 1 1 -secure-push/internal/config/config.go:148.10,149.14 1 1 -secure-push/internal/config/config.go:153.62,155.40 1 1 -secure-push/internal/config/config.go:155.40,156.47 1 0 -secure-push/internal/config/config.go:156.47,158.4 1 0 -secure-push/internal/config/config.go:161.2,161.32 1 1 -secure-push/internal/config/config.go:161.32,162.45 1 1 -secure-push/internal/config/config.go:162.45,163.48 1 1 -secure-push/internal/config/config.go:163.48,165.5 1 1 -secure-push/internal/config/config.go:167.3,167.15 1 1 -secure-push/internal/config/config.go:170.2,170.46 1 1 -secure-push/internal/config/config.go:170.46,171.48 1 1 -secure-push/internal/config/config.go:171.48,173.4 1 1 -secure-push/internal/config/config.go:176.2,176.13 1 1 +secure-push/internal/config/config.go:51.35,54.37 1 1 +secure-push/internal/config/config.go:54.37,56.3 1 1 +secure-push/internal/config/config.go:57.2,57.24 1 1 +secure-push/internal/config/config.go:57.24,59.3 1 1 +secure-push/internal/config/config.go:60.2,60.12 1 1 +secure-push/internal/config/config.go:63.47,66.22 2 1 +secure-push/internal/config/config.go:66.22,68.3 1 0 +secure-push/internal/config/config.go:70.2,70.22 1 1 +secure-push/internal/config/config.go:70.22,72.3 1 0 +secure-push/internal/config/config.go:74.2,75.16 2 1 +secure-push/internal/config/config.go:75.16,77.25 1 1 +secure-push/internal/config/config.go:77.25,79.4 1 1 +secure-push/internal/config/config.go:80.3,80.64 1 0 +secure-push/internal/config/config.go:83.2,83.50 1 1 +secure-push/internal/config/config.go:83.50,85.3 1 0 +secure-push/internal/config/config.go:87.2,87.39 1 1 +secure-push/internal/config/config.go:87.39,89.3 1 0 +secure-push/internal/config/config.go:91.2,91.17 1 1 +secure-push/internal/config/config.go:94.30,101.34 2 1 +secure-push/internal/config/config.go:101.34,102.39 1 1 +secure-push/internal/config/config.go:102.39,104.4 1 1 +secure-push/internal/config/config.go:107.2,107.11 1 1 +secure-push/internal/config/config.go:110.49,112.38 1 1 +secure-push/internal/config/config.go:112.38,113.31 1 1 +secure-push/internal/config/config.go:113.31,115.4 1 1 +secure-push/internal/config/config.go:119.2,119.43 1 1 +secure-push/internal/config/config.go:119.43,120.34 1 1 +secure-push/internal/config/config.go:120.34,122.4 1 1 +secure-push/internal/config/config.go:125.2,125.14 1 1 +secure-push/internal/config/config.go:129.49,132.27 2 1 +secure-push/internal/config/config.go:132.27,134.3 1 1 +secure-push/internal/config/config.go:137.2,138.27 2 1 +secure-push/internal/config/config.go:138.27,140.3 1 0 +secure-push/internal/config/config.go:143.2,143.43 1 1 +secure-push/internal/config/config.go:143.43,145.3 1 1 +secure-push/internal/config/config.go:148.2,148.44 1 1 +secure-push/internal/config/config.go:148.44,150.3 1 0 +secure-push/internal/config/config.go:152.2,152.14 1 1 +secure-push/internal/config/config.go:155.70,156.29 1 1 +secure-push/internal/config/config.go:157.13,158.14 1 1 +secure-push/internal/config/config.go:159.16,160.35 1 1 +secure-push/internal/config/config.go:161.14,162.70 1 1 +secure-push/internal/config/config.go:163.18,164.40 1 1 +secure-push/internal/config/config.go:165.10,166.14 1 1 +secure-push/internal/config/config.go:170.62,172.40 1 1 +secure-push/internal/config/config.go:172.40,173.47 1 0 +secure-push/internal/config/config.go:173.47,175.4 1 0 +secure-push/internal/config/config.go:178.2,178.32 1 1 +secure-push/internal/config/config.go:178.32,179.45 1 1 +secure-push/internal/config/config.go:179.45,180.48 1 1 +secure-push/internal/config/config.go:180.48,182.5 1 1 +secure-push/internal/config/config.go:184.3,184.15 1 1 +secure-push/internal/config/config.go:187.2,187.46 1 1 +secure-push/internal/config/config.go:187.46,188.48 1 1 +secure-push/internal/config/config.go:188.48,190.4 1 1 +secure-push/internal/config/config.go:193.2,193.13 1 1 +secure-push/internal/logger/logger.go:24.31,29.2 1 1 +secure-push/internal/logger/logger.go:32.41,34.2 1 0 +secure-push/internal/logger/logger.go:36.60,37.22 1 1 +secure-push/internal/logger/logger.go:37.22,39.3 1 1 +secure-push/internal/logger/logger.go:42.59,43.21 1 1 +secure-push/internal/logger/logger.go:43.21,45.3 1 1 +secure-push/internal/logger/logger.go:48.59,49.21 1 1 +secure-push/internal/logger/logger.go:49.21,51.3 1 1 +secure-push/internal/logger/logger.go:54.60,55.22 1 1 +secure-push/internal/logger/logger.go:55.22,57.3 1 1 +secure-push/internal/logger/logger.go:60.65,64.2 3 1 secure-push/internal/detectors/ast.go:13.37,15.2 1 1 secure-push/internal/detectors/ast.go:17.43,19.2 1 1 secure-push/internal/detectors/ast.go:22.82,26.41 2 1 @@ -236,6 +244,8 @@ secure-push/internal/detectors/custom.go:97.14,98.14 1 1 secure-push/internal/detectors/custom.go:99.16,100.16 1 0 secure-push/internal/detectors/custom.go:101.13,102.13 1 0 secure-push/internal/detectors/custom.go:103.10,104.12 1 0 +secure-push/internal/detectors/detector.go:31.35,33.2 1 1 +secure-push/internal/detectors/detector.go:36.53,44.2 2 1 secure-push/internal/detectors/env.go:10.37,12.2 1 1 secure-push/internal/detectors/env.go:14.43,16.2 1 1 secure-push/internal/detectors/env.go:18.82,23.59 3 1 @@ -297,14 +307,15 @@ secure-push/internal/reporters/console.go:62.29,63.29 1 1 secure-push/internal/reporters/console.go:63.29,65.4 1 1 secure-push/internal/reporters/console.go:67.2,67.14 1 1 secure-push/internal/reporters/csv.go:17.47,19.2 1 1 -secure-push/internal/reporters/csv.go:22.66,24.16 2 1 -secure-push/internal/reporters/csv.go:24.16,26.3 1 0 -secure-push/internal/reporters/csv.go:27.2,33.89 4 1 -secure-push/internal/reporters/csv.go:33.89,35.3 1 0 -secure-push/internal/reporters/csv.go:38.2,38.35 1 1 -secure-push/internal/reporters/csv.go:38.35,45.18 1 1 -secure-push/internal/reporters/csv.go:45.18,47.4 1 0 -secure-push/internal/reporters/csv.go:50.2,50.12 1 1 +secure-push/internal/reporters/csv.go:22.44,24.2 1 1 +secure-push/internal/reporters/csv.go:27.66,29.16 2 1 +secure-push/internal/reporters/csv.go:29.16,31.3 1 0 +secure-push/internal/reporters/csv.go:32.2,38.89 4 1 +secure-push/internal/reporters/csv.go:38.89,40.3 1 0 +secure-push/internal/reporters/csv.go:43.2,43.35 1 1 +secure-push/internal/reporters/csv.go:43.35,50.18 1 1 +secure-push/internal/reporters/csv.go:50.18,52.4 1 0 +secure-push/internal/reporters/csv.go:55.2,55.12 1 1 secure-push/internal/reporters/github.go:14.69,15.24 1 1 secure-push/internal/reporters/github.go:15.24,18.3 2 1 secure-push/internal/reporters/github.go:20.2,20.29 1 1 @@ -352,6 +363,38 @@ secure-push/internal/reporters/summary.go:75.31,77.3 1 1 secure-push/internal/reporters/summary.go:79.2,79.23 1 1 secure-push/internal/reporters/summary.go:79.23,81.3 1 1 secure-push/internal/reporters/summary.go:83.2,83.12 1 0 +secure-push/pkg/utils/entropy.go:9.44,10.20 1 1 +secure-push/pkg/utils/entropy.go:10.20,12.3 1 1 +secure-push/pkg/utils/entropy.go:15.2,16.25 2 1 +secure-push/pkg/utils/entropy.go:16.25,18.3 1 1 +secure-push/pkg/utils/entropy.go:21.2,23.29 3 1 +secure-push/pkg/utils/entropy.go:23.29,24.16 1 1 +secure-push/pkg/utils/entropy.go:24.16,27.4 2 1 +secure-push/pkg/utils/entropy.go:30.2,30.16 1 1 +secure-push/pkg/utils/entropy.go:35.57,37.2 1 1 +secure-push/pkg/utils/entropy.go:40.37,41.17 1 1 +secure-push/pkg/utils/entropy.go:41.17,43.3 1 1 +secure-push/pkg/utils/entropy.go:46.2,47.22 2 1 +secure-push/pkg/utils/entropy.go:47.22,48.36 1 1 +secure-push/pkg/utils/entropy.go:48.36,50.4 1 0 +secure-push/pkg/utils/entropy.go:54.2,54.19 1 1 +secure-push/pkg/utils/entropy.go:54.19,56.3 1 1 +secure-push/pkg/utils/entropy.go:58.2,58.14 1 0 +secure-push/pkg/utils/entropy.go:61.42,62.22 1 1 +secure-push/pkg/utils/entropy.go:62.22,63.13 1 1 +secure-push/pkg/utils/entropy.go:63.13,65.4 1 1 +secure-push/pkg/utils/entropy.go:67.2,67.14 1 0 +secure-push/pkg/utils/regex.go:8.59,10.2 1 1 +secure-push/pkg/utils/regex.go:13.51,15.16 2 1 +secure-push/pkg/utils/regex.go:15.16,17.3 1 1 +secure-push/pkg/utils/regex.go:18.2,18.31 1 1 +secure-push/pkg/utils/regex.go:22.57,24.16 2 1 +secure-push/pkg/utils/regex.go:24.16,26.3 1 0 +secure-push/pkg/utils/regex.go:27.2,27.37 1 1 +secure-push/pkg/utils/regex.go:31.67,33.16 2 1 +secure-push/pkg/utils/regex.go:33.16,35.3 1 0 +secure-push/pkg/utils/regex.go:36.2,36.45 1 1 +secure-push/pkg/utils/regex.go:40.49,42.2 1 1 secure-push/internal/scanner/cache.go:28.61,34.2 1 1 secure-push/internal/scanner/cache.go:37.55,38.16 1 1 secure-push/internal/scanner/cache.go:38.16,40.3 1 1 @@ -372,14 +415,15 @@ secure-push/internal/scanner/cache.go:89.16,91.3 1 0 secure-push/internal/scanner/cache.go:93.2,94.41 2 1 secure-push/internal/scanner/cache.go:98.29,102.2 3 1 secure-push/internal/scanner/cache.go:105.57,111.2 4 1 -secure-push/internal/scanner/cache.go:114.34,115.36 1 0 -secure-push/internal/scanner/cache.go:115.36,117.3 1 0 -secure-push/internal/scanner/cache.go:119.2,119.55 1 0 -secure-push/internal/scanner/cache.go:119.55,121.3 1 0 -secure-push/internal/scanner/cache.go:125.2,126.12 2 0 -secure-push/internal/scanner/cache.go:130.34,131.36 1 0 -secure-push/internal/scanner/cache.go:131.36,133.3 1 0 -secure-push/internal/scanner/cache.go:137.2,138.12 2 0 +secure-push/internal/scanner/cache.go:114.32,118.2 3 1 +secure-push/internal/scanner/cache.go:121.34,122.36 1 0 +secure-push/internal/scanner/cache.go:122.36,124.3 1 0 +secure-push/internal/scanner/cache.go:126.2,126.55 1 0 +secure-push/internal/scanner/cache.go:126.55,128.3 1 0 +secure-push/internal/scanner/cache.go:132.2,133.12 2 0 +secure-push/internal/scanner/cache.go:137.34,138.36 1 0 +secure-push/internal/scanner/cache.go:138.36,140.3 1 0 +secure-push/internal/scanner/cache.go:144.2,145.12 2 0 secure-push/internal/scanner/file.go:13.26,16.3 2 1 secure-push/internal/scanner/file.go:19.46,21.16 2 1 secure-push/internal/scanner/file.go:21.16,23.3 1 1 @@ -457,29 +501,31 @@ secure-push/internal/scanner/scanner.go:171.16,173.3 1 0 secure-push/internal/scanner/scanner.go:174.2,174.14 1 1 secure-push/internal/scanner/scanner.go:174.14,176.3 1 1 secure-push/internal/scanner/scanner.go:178.2,178.25 1 1 -secure-push/internal/scanner/walk.go:10.82,11.86 1 1 -secure-push/internal/scanner/walk.go:11.86,12.17 1 1 -secure-push/internal/scanner/walk.go:12.17,14.4 1 0 -secure-push/internal/scanner/walk.go:16.3,16.19 1 1 -secure-push/internal/scanner/walk.go:16.19,17.89 1 1 -secure-push/internal/scanner/walk.go:17.89,19.5 1 1 -secure-push/internal/scanner/walk.go:20.4,20.14 1 1 -secure-push/internal/scanner/walk.go:23.3,23.31 1 1 -secure-push/internal/scanner/walk.go:27.46,29.16 2 1 -secure-push/internal/scanner/walk.go:29.16,31.3 1 0 -secure-push/internal/scanner/walk.go:32.2,32.25 1 1 -secure-push/internal/scanner/walk.go:36.52,38.2 1 1 -secure-push/internal/scanner/walk.go:40.35,43.2 2 1 -secure-push/internal/scanner/walk.go:45.30,47.16 2 1 -secure-push/internal/scanner/walk.go:47.16,49.3 1 1 -secure-push/internal/scanner/walk.go:50.2,50.21 1 1 -secure-push/internal/scanner/walk.go:53.57,55.16 2 1 -secure-push/internal/scanner/walk.go:55.16,57.3 1 0 -secure-push/internal/scanner/walk.go:58.2,58.35 1 1 -secure-push/internal/scanner/walk.go:61.38,62.16 1 1 -secure-push/internal/scanner/walk.go:62.16,64.3 1 1 -secure-push/internal/scanner/walk.go:66.2,67.16 2 1 -secure-push/internal/scanner/walk.go:67.16,69.3 1 1 -secure-push/internal/scanner/walk.go:71.2,71.19 1 1 -secure-push/internal/scanner/walk.go:71.19,73.3 1 1 -secure-push/internal/scanner/walk.go:75.2,75.12 1 1 +secure-push/internal/scanner/walk.go:11.82,12.86 1 1 +secure-push/internal/scanner/walk.go:12.86,13.17 1 1 +secure-push/internal/scanner/walk.go:13.17,15.4 1 0 +secure-push/internal/scanner/walk.go:17.3,17.19 1 1 +secure-push/internal/scanner/walk.go:17.19,18.89 1 1 +secure-push/internal/scanner/walk.go:18.89,20.5 1 1 +secure-push/internal/scanner/walk.go:21.4,21.14 1 1 +secure-push/internal/scanner/walk.go:24.3,24.31 1 1 +secure-push/internal/scanner/walk.go:29.46,31.16 2 1 +secure-push/internal/scanner/walk.go:31.16,33.3 1 0 +secure-push/internal/scanner/walk.go:34.2,34.25 1 1 +secure-push/internal/scanner/walk.go:38.52,40.2 1 1 +secure-push/internal/scanner/walk.go:43.35,46.2 2 1 +secure-push/internal/scanner/walk.go:49.30,51.16 2 1 +secure-push/internal/scanner/walk.go:51.16,53.3 1 1 +secure-push/internal/scanner/walk.go:54.2,54.21 1 1 +secure-push/internal/scanner/walk.go:58.57,60.16 2 1 +secure-push/internal/scanner/walk.go:60.16,62.3 1 0 +secure-push/internal/scanner/walk.go:63.2,63.35 1 1 +secure-push/internal/scanner/walk.go:67.38,68.16 1 1 +secure-push/internal/scanner/walk.go:68.16,70.3 1 1 +secure-push/internal/scanner/walk.go:72.2,73.16 2 1 +secure-push/internal/scanner/walk.go:73.16,75.3 1 1 +secure-push/internal/scanner/walk.go:77.2,77.19 1 1 +secure-push/internal/scanner/walk.go:77.19,79.3 1 1 +secure-push/internal/scanner/walk.go:81.2,81.12 1 1 +secure-push/internal/scanner/walk.go:85.37,88.2 2 1 +secure-push/internal/scanner/walk.go:91.38,93.2 1 1 diff --git a/internal/detectors/custom.go b/internal/detectors/custom.go index 5d7c8b5..8acd383 100644 --- a/internal/detectors/custom.go +++ b/internal/detectors/custom.go @@ -47,6 +47,18 @@ func (d *CustomRuleDetector) LoadRules(path string) error { return nil } +// AddRule adds a custom rule programmatically +func (d *CustomRuleDetector) AddRule(rule CustomRuleConfig) error { + // Validate the pattern compiles + _, err := regexp.Compile(rule.Pattern) + if err != nil { + return fmt.Errorf("invalid regex pattern: %w", err) + } + + d.rules = append(d.rules, rule) + return nil +} + // Detect applies custom rules to content func (d *CustomRuleDetector) Detect(content string, filename string) ([]Finding, error) { var findings []Finding @@ -104,3 +116,8 @@ func parseSeverity(severity string) Severity { return "" } } + +// RuleCount returns the number of loaded custom rules +func (d *CustomRuleDetector) RuleCount() int { + return len(d.rules) +} diff --git a/internal/detectors/custom_additional_test.go b/internal/detectors/custom_additional_test.go new file mode 100644 index 0000000..6bb5d6a --- /dev/null +++ b/internal/detectors/custom_additional_test.go @@ -0,0 +1,72 @@ +package detectors + +import ( + "testing" +) + +func TestCustomRuleDetector_AddRule(t *testing.T) { + d := &CustomRuleDetector{} + + rule := CustomRuleConfig{ + Name: "Test Rule", + Pattern: "secret[0-9]+", + Severity: "HIGH", + Message: "Test secret found", + } + + if err := d.AddRule(rule); err != nil { + t.Fatalf("AddRule() error = %v", err) + } + + if d.RuleCount() != 1 { + t.Errorf("RuleCount() = %d, want 1", d.RuleCount()) + } +} + +func TestCustomRuleDetector_AddRuleInvalidPattern(t *testing.T) { + d := &CustomRuleDetector{} + + rule := CustomRuleConfig{ + Name: "Bad Rule", + Pattern: "[invalid", + Severity: "HIGH", + } + + if err := d.AddRule(rule); err == nil { + t.Error("AddRule() should return error for invalid pattern") + } +} + +func TestCustomRuleDetector_RuleCount(t *testing.T) { + d := &CustomRuleDetector{} + + if d.RuleCount() != 0 { + t.Errorf("RuleCount() = %d, want 0", d.RuleCount()) + } + + d.rules = []CustomRuleConfig{ + {Name: "Rule 1", Pattern: "test1"}, + {Name: "Rule 2", Pattern: "test2"}, + } + + if d.RuleCount() != 2 { + t.Errorf("RuleCount() = %d, want 2", d.RuleCount()) + } +} + +func TestCustomRuleDetector_DetectMultipleRules(t *testing.T) { + d := &CustomRuleDetector{} + d.rules = []CustomRuleConfig{ + {Name: "Rule 1", Pattern: "secret[0-9]+", Severity: "HIGH"}, + {Name: "Rule 2", Pattern: "password[0-9]+", Severity: "CRITICAL"}, + } + + content := "my secret123 and password456 here" + got, err := d.Detect(content, "test.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) < 2 { + t.Errorf("Detect() returned %d findings, want at least 2", len(got)) + } +} From 8bfab39a48271612e6214b2b3c20384c3c9dc261 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:35:23 +0300 Subject: [PATCH 08/18] feat: add ToJSON method to JSONReporter for programmatic access Add ToJSON method to return JSON string representation of findings - Enables testing and programmatic access to report data - Add json_additional_test.go with tests for ToJSON method --- internal/reporters/json.go | 16 +++++++-- internal/reporters/json_additional_test.go | 39 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 internal/reporters/json_additional_test.go diff --git a/internal/reporters/json.go b/internal/reporters/json.go index 2563eb7..80d7770 100644 --- a/internal/reporters/json.go +++ b/internal/reporters/json.go @@ -14,7 +14,8 @@ type JSONReport struct { Findings []detectors.Finding `json:"findings"` } -func (r *JSONReporter) Report(findings []detectors.Finding) error { +// ToJSON returns the JSON representation of the report +func (r *JSONReporter) ToJSON(findings []detectors.Finding) (string, error) { report := JSONReport{ Total: len(findings), Findings: findings, @@ -22,10 +23,19 @@ func (r *JSONReporter) Report(findings []detectors.Finding) error { data, err := json.MarshalIndent(report, "", " ") if err != nil { - return fmt.Errorf("failed to marshal report: %w", err) + return "", fmt.Errorf("failed to marshal report: %w", err) + } + + return string(data), nil +} + +func (r *JSONReporter) Report(findings []detectors.Finding) error { + data, err := r.ToJSON(findings) + if err != nil { + return err } - fmt.Println(string(data)) + fmt.Println(data) if len(findings) > 0 { return fmt.Errorf("scan found %d security issues", len(findings)) diff --git a/internal/reporters/json_additional_test.go b/internal/reporters/json_additional_test.go new file mode 100644 index 0000000..43a07bc --- /dev/null +++ b/internal/reporters/json_additional_test.go @@ -0,0 +1,39 @@ +package reporters + +import ( + "strings" + "testing" + + "secure-push/internal/detectors" +) + +func TestJSONReporter_ToJSON(t *testing.T) { + reporter := &JSONReporter{} + findings := []detectors.Finding{ + {Severity: detectors.Critical, Rule: "SECRETS", File: "test.go", Line: 10, Message: "Test message"}, + } + + got, err := reporter.ToJSON(findings) + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + if !strings.Contains(got, "SECRETS") { + t.Error("ToJSON() output should contain 'SECRETS'") + } + if !strings.Contains(got, "test.go") { + t.Error("ToJSON() output should contain 'test.go'") + } +} + +func TestJSONReporter_ToJSONEmpty(t *testing.T) { + reporter := &JSONReporter{} + got, err := reporter.ToJSON([]detectors.Finding{}) + if err != nil { + t.Fatalf("ToJSON() error = %v", err) + } + + if !strings.Contains(got, `"total": 0`) { + t.Error("ToJSON() output should contain total: 0") + } +} From de5cb5bb577db2be8be2d29eeb50144cebca06d1 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:37:41 +0300 Subject: [PATCH 09/18] feat: expose IsDangerousFunction and IsCredentialPattern for testing Add exported versions of internal functions for better testability - Add more dangerous function patterns (eval, compile, ioutil, osopen) - Add ast_additional_test.go with comprehensive tests --- internal/detectors/ast.go | 13 +++++ internal/detectors/ast_additional_test.go | 58 +++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 internal/detectors/ast_additional_test.go diff --git a/internal/detectors/ast.go b/internal/detectors/ast.go index a83f800..7c6442d 100644 --- a/internal/detectors/ast.go +++ b/internal/detectors/ast.go @@ -83,6 +83,9 @@ func isDangerousFunction(name string) bool { "getenv", "setenv", "httpget", "httppost", "sqlopen", "dbquery", + "eval", "evalstring", + "compile", "mustcompile", + "ioutil", "osopen", } for _, d := range dangerous { if strings.Contains(name, d) { @@ -106,3 +109,13 @@ func isCredentialPattern(value string) bool { } return false } + +// IsDangerousFunction exposes the dangerous function check for testing +func IsDangerousFunction(name string) bool { + return isDangerousFunction(name) +} + +// IsCredentialPattern exposes the credential pattern check for testing +func IsCredentialPattern(value string) bool { + return isCredentialPattern(value) +} diff --git a/internal/detectors/ast_additional_test.go b/internal/detectors/ast_additional_test.go new file mode 100644 index 0000000..44c6ac4 --- /dev/null +++ b/internal/detectors/ast_additional_test.go @@ -0,0 +1,58 @@ +package detectors + +import ( + "testing" +) + +func TestIsDangerousFunction(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"exec", "exec", true}, + {"execcommand", "execcommand", true}, + {"readfile", "readfile", true}, + {"getenv", "getenv", true}, + {"httpget", "httpget", true}, + {"sqlopen", "sqlopen", true}, + {"eval", "eval", true}, + {"safe function", "fmt.Println", false}, + {"safe function 2", "log.Info", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsDangerousFunction(tt.input) + if got != tt.expected { + t.Errorf("IsDangerousFunction(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestIsCredentialPattern(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"password", "password", true}, + {"passwd", "passwd", true}, + {"secret", "secret", true}, + {"token", "token", true}, + {"api_key", "api_key", true}, + {"apikey", "apikey", true}, + {"safe value", "hello", false}, + {"safe value 2", "world123", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsCredentialPattern(tt.input) + if got != tt.expected { + t.Errorf("IsCredentialPattern(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} From 0baf2dc45ba4b4c57b1dbec0edfc9c0592ff610c Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:41:43 +0300 Subject: [PATCH 10/18] feat: add ToSARIF method to SARIFReporter for programmatic access Add ToSARIF method to return SARIF JSON string representation of findings - Enables testing and programmatic access to report data - Add sarif_additional_test.go with tests for ToSARIF method --- internal/reporters/sarif.go | 18 +++++++-- internal/reporters/sarif_additional_test.go | 42 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 internal/reporters/sarif_additional_test.go diff --git a/internal/reporters/sarif.go b/internal/reporters/sarif.go index 3c30504..7e052d5 100644 --- a/internal/reporters/sarif.go +++ b/internal/reporters/sarif.go @@ -82,8 +82,8 @@ type SARIFLocationProperties struct { Line int `json:"line"` } -// Report outputs findings in SARIF format -func (r *SARIFReporter) Report(findings []detectors.Finding) error { +// ToSARIF returns the SARIF JSON string representation of findings +func (r *SARIFReporter) ToSARIF(findings []detectors.Finding) (string, error) { rules := make(map[string]bool) for _, f := range findings { rules[f.Rule] = true @@ -143,10 +143,20 @@ func (r *SARIFReporter) Report(findings []detectors.Finding) error { data, err := json.MarshalIndent(report, "", " ") if err != nil { - return fmt.Errorf("failed to marshal SARIF report: %w", err) + return "", fmt.Errorf("failed to marshal SARIF report: %w", err) + } + + return string(data), nil +} + +// Report outputs findings in SARIF format +func (r *SARIFReporter) Report(findings []detectors.Finding) error { + data, err := r.ToSARIF(findings) + if err != nil { + return err } - fmt.Println(string(data)) + fmt.Println(data) if len(findings) > 0 { return fmt.Errorf("scan found %d security issues", len(findings)) diff --git a/internal/reporters/sarif_additional_test.go b/internal/reporters/sarif_additional_test.go new file mode 100644 index 0000000..40f7b99 --- /dev/null +++ b/internal/reporters/sarif_additional_test.go @@ -0,0 +1,42 @@ +package reporters + +import ( + "strings" + "testing" + + "secure-push/internal/detectors" +) + +func TestSARIFReporter_ToSARIF(t *testing.T) { + reporter := &SARIFReporter{} + findings := []detectors.Finding{ + {Severity: detectors.Critical, Rule: "SECRETS", File: "test.go", Line: 10, Message: "Test message"}, + } + + got, err := reporter.ToSARIF(findings) + if err != nil { + t.Fatalf("ToSARIF() error = %v", err) + } + + if !strings.Contains(got, "SECRETS") { + t.Error("ToSARIF() output should contain 'SECRETS'") + } + if !strings.Contains(got, "test.go") { + t.Error("ToSARIF() output should contain 'test.go'") + } + if !strings.Contains(got, "2.1.0") { + t.Error("ToSARIF() output should contain version 2.1.0") + } +} + +func TestSARIFReporter_ToSARIFEmpty(t *testing.T) { + reporter := &SARIFReporter{} + got, err := reporter.ToSARIF([]detectors.Finding{}) + if err != nil { + t.Fatalf("ToSARIF() error = %v", err) + } + + if !strings.Contains(got, `"version"`) { + t.Error("ToSARIF() output should be valid JSON with version") + } +} From 40ace6ccdbc3a38daaad08535f184b656d7149c6 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:43:44 +0300 Subject: [PATCH 11/18] feat: add ToAnnotations method to GitHubReporter for programmatic access Add ToAnnotations method to return GitHub Actions annotations as strings - Enables testing and programmatic access to annotation data - Add github_additional_test.go with tests for ToAnnotations method --- internal/reporters/github.go | 16 +++++++++ internal/reporters/github_additional_test.go | 35 ++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 internal/reporters/github_additional_test.go diff --git a/internal/reporters/github.go b/internal/reporters/github.go index d2fb4ea..a4aa85e 100644 --- a/internal/reporters/github.go +++ b/internal/reporters/github.go @@ -10,6 +10,22 @@ import ( // GitHubReporter outputs findings in GitHub Actions annotation format type GitHubReporter struct{} +// ToAnnotations returns GitHub Actions annotations as strings +func (r *GitHubReporter) ToAnnotations(findings []detectors.Finding) []string { + var annotations []string + + for _, f := range findings { + annotationType := r.getAnnotationType(f.Severity) + title := fmt.Sprintf("[%s] %s", strings.ToUpper(string(f.Severity)), f.Rule) + message := fmt.Sprintf("%s (line %d): %s", f.File, f.Line, f.Message) + + annotations = append(annotations, fmt.Sprintf("::%s file=%s,line=%d,title=%s::%s", + annotationType, f.File, f.Line, title, message)) + } + + return annotations +} + // Report outputs findings as GitHub Actions workflow commands func (r *GitHubReporter) Report(findings []detectors.Finding) error { if len(findings) == 0 { diff --git a/internal/reporters/github_additional_test.go b/internal/reporters/github_additional_test.go new file mode 100644 index 0000000..e9c0151 --- /dev/null +++ b/internal/reporters/github_additional_test.go @@ -0,0 +1,35 @@ +package reporters + +import ( + "strings" + "testing" + + "secure-push/internal/detectors" +) + +func TestGitHubReporter_ToAnnotations(t *testing.T) { + reporter := &GitHubReporter{} + findings := []detectors.Finding{ + {Severity: detectors.Critical, Rule: "SECRETS", File: "test.go", Line: 10, Message: "Test message"}, + } + + got := reporter.ToAnnotations(findings) + if len(got) != 1 { + t.Errorf("ToAnnotations() returned %d annotations, want 1", len(got)) + } + + if !strings.Contains(got[0], "::error") { + t.Error("ToAnnotations() should contain error annotation for critical severity") + } + if !strings.Contains(got[0], "test.go") { + t.Error("ToAnnotations() should contain file name") + } +} + +func TestGitHubReporter_ToAnnotationsEmpty(t *testing.T) { + reporter := &GitHubReporter{} + got := reporter.ToAnnotations([]detectors.Finding{}) + if len(got) != 0 { + t.Errorf("ToAnnotations() returned %d annotations, want 0", len(got)) + } +} From 5ee1fdba165459418e9a6916015964829e16f1c8 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:45:28 +0300 Subject: [PATCH 12/18] feat: add WorkerPool struct for parallel file processing Add WorkerPool for managing concurrent file processing: - NewWorkerPool creates a pool with specified worker count - Start begins processing jobs - Submit adds jobs to the pool - Stop stops the pool - GetWorkerCount returns the number of workers - Add parallel_additional_test.go with tests for WorkerPool --- internal/scanner/parallel.go | 40 ++++++++++++++++++++ internal/scanner/parallel_additional_test.go | 22 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/scanner/parallel_additional_test.go diff --git a/internal/scanner/parallel.go b/internal/scanner/parallel.go index e805e1a..6e4e046 100644 --- a/internal/scanner/parallel.go +++ b/internal/scanner/parallel.go @@ -8,3 +8,43 @@ const MaxConcurrentFiles = 100 // DefaultWorkerCount is the default number of worker goroutines const DefaultWorkerCount = 10 + +// WorkerPool manages a pool of worker goroutines for parallel processing +type WorkerPool struct { + workers int + jobs chan func() +} + +// NewWorkerPool creates a new worker pool +func NewWorkerPool(workers int) *WorkerPool { + return &WorkerPool{ + workers: workers, + jobs: make(chan func()), + } +} + +// Start begins processing jobs +func (p *WorkerPool) Start() { + for i := 0; i < p.workers; i++ { + go func() { + for job := range p.jobs { + job() + } + }() + } +} + +// Submit adds a job to the pool +func (p *WorkerPool) Submit(job func()) { + p.jobs <- job +} + +// Stop stops the worker pool +func (p *WorkerPool) Stop() { + close(p.jobs) +} + +// GetWorkerCount returns the number of workers +func (p *WorkerPool) GetWorkerCount() int { + return p.workers +} diff --git a/internal/scanner/parallel_additional_test.go b/internal/scanner/parallel_additional_test.go new file mode 100644 index 0000000..86d60f5 --- /dev/null +++ b/internal/scanner/parallel_additional_test.go @@ -0,0 +1,22 @@ +package scanner + +import ( + "testing" +) + +func TestWorkerPool_GetWorkerCount(t *testing.T) { + pool := NewWorkerPool(20) + if pool.GetWorkerCount() != 20 { + t.Errorf("GetWorkerCount() = %d, want 20", pool.GetWorkerCount()) + } +} + +func TestWorkerPool_NewWorkerPool(t *testing.T) { + pool := NewWorkerPool(10) + if pool == nil { + t.Error("NewWorkerPool() returned nil") + } + if pool.workers != 10 { + t.Errorf("workers = %d, want 10", pool.workers) + } +} From 946157b516ece646c83329a621c7f230373895d8 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:47:52 +0300 Subject: [PATCH 13/18] feat: add GetCache and GetDetectorCount methods to Scanner Add methods to Scanner for better introspection: - GetCache returns the scanner's cache for inspection - GetDetectorCount returns the number of configured detectors - Add scanner_additional_test.go with tests for new methods --- internal/scanner/scanner.go | 10 ++++ internal/scanner/scanner_additional_test.go | 55 +++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 internal/scanner/scanner_additional_test.go diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 35e669a..a8d8e43 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -32,6 +32,16 @@ func New(detectors []detectors.Detector, cfg *config.Config, log *logger.Logger) } } +// GetCache returns the scanner's cache for inspection +func (s *Scanner) GetCache() *ScanCache { + return s.cache +} + +// GetDetectorCount returns the number of configured detectors +func (s *Scanner) GetDetectorCount() int { + return len(s.detectors) +} + func (s *Scanner) Scan(path string) ([]detectors.Finding, error) { var findings []detectors.Finding var mu sync.Mutex diff --git a/internal/scanner/scanner_additional_test.go b/internal/scanner/scanner_additional_test.go new file mode 100644 index 0000000..b180547 --- /dev/null +++ b/internal/scanner/scanner_additional_test.go @@ -0,0 +1,55 @@ +package scanner + +import ( + "testing" + + "secure-push/internal/config" + "secure-push/internal/detectors" + "secure-push/internal/logger" +) + +func TestScanner_GetCache(t *testing.T) { + cfg := config.DefaultConfig() + log := logger.New(logger.Info) + + detectorList := []detectors.Detector{ + &detectors.EnvDetector{}, + } + + s := New(detectorList, cfg, log) + cache := s.GetCache() + + if cache == nil { + t.Error("GetCache() returned nil") + } +} + +func TestScanner_GetDetectorCount(t *testing.T) { + cfg := config.DefaultConfig() + log := logger.New(logger.Info) + + detectorList := []detectors.Detector{ + &detectors.EnvDetector{}, + &detectors.SecretsDetector{}, + &detectors.AuthDetector{}, + } + + s := New(detectorList, cfg, log) + count := s.GetDetectorCount() + + if count != 3 { + t.Errorf("GetDetectorCount() = %d, want 3", count) + } +} + +func TestScanner_GetDetectorCountEmpty(t *testing.T) { + cfg := config.DefaultConfig() + log := logger.New(logger.Info) + + s := New([]detectors.Detector{}, cfg, log) + count := s.GetDetectorCount() + + if count != 0 { + t.Errorf("GetDetectorCount() = %d, want 0", count) + } +} From a219f739c75979fac414ff24a8de5120fab3ab3e Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:49:41 +0300 Subject: [PATCH 14/18] test: add Intercom token detection test Add test for Intercom token detection in auth detector - Verify intercomTokenPattern correctly detects Intercom tokens - Add test case for intercom_token format --- internal/detectors/auth_additional_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/detectors/auth_additional_test.go b/internal/detectors/auth_additional_test.go index 6602389..9de9b5f 100644 --- a/internal/detectors/auth_additional_test.go +++ b/internal/detectors/auth_additional_test.go @@ -91,3 +91,14 @@ func TestAuthDetector_DetectLinearToken(t *testing.T) { t.Errorf("Detect() returned %d findings, want 1", len(got)) } } + +func TestAuthDetector_DetectIntercomToken(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH'", "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 1 { + t.Errorf("Detect() returned %d findings, want 1", len(got)) + } +} From 016d2261a452e275dff772ecba41ce8a267316a2 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:51:48 +0300 Subject: [PATCH 15/18] feat: add Format method to ConsoleReporter for programmatic access Add Format method to return console output as a string - Enables testing and programmatic access to report data - Add console_additional_test.go with tests for Format method --- internal/reporters/console.go | 31 ++++++++++------ internal/reporters/console_additional_test.go | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 internal/reporters/console_additional_test.go diff --git a/internal/reporters/console.go b/internal/reporters/console.go index 8ec3f61..c692672 100644 --- a/internal/reporters/console.go +++ b/internal/reporters/console.go @@ -9,32 +9,41 @@ import ( type ConsoleReporter struct{} -func (r *ConsoleReporter) Report(findings []detectors.Finding) error { +// Format returns the console output as a string +func (r *ConsoleReporter) Format(findings []detectors.Finding) string { + var sb strings.Builder + if len(findings) == 0 { - fmt.Println("✓ No sensitive data found") - return nil + sb.WriteString("✓ No sensitive data found\n") + return sb.String() } - fmt.Printf("✗ Found %d potential security issues:\n\n", len(findings)) + sb.WriteString(fmt.Sprintf("✗ Found %d potential security issues:\n\n", len(findings))) for i, f := range findings { severityIcon := getSeverityIcon(f.Severity) - fmt.Printf("%d. %s [%s] %s:%d\n", i+1, severityIcon, strings.ToUpper(string(f.Severity)), f.File, f.Line) - fmt.Printf(" Rule: %s\n", f.Rule) - fmt.Printf(" %s\n", f.Message) + sb.WriteString(fmt.Sprintf("%d. %s [%s] %s:%d\n", i+1, severityIcon, strings.ToUpper(string(f.Severity)), f.File, f.Line)) + sb.WriteString(fmt.Sprintf(" Rule: %s\n", f.Rule)) + sb.WriteString(fmt.Sprintf(" %s\n", f.Message)) if i < len(findings)-1 { - fmt.Println() + sb.WriteString("\n") } } - fmt.Println() - fmt.Printf("Total: %d issues found (%d critical, %d high, %d medium, %d low)\n", + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("Total: %d issues found (%d critical, %d high, %d medium, %d low)\n", len(findings), countBySeverity(findings, detectors.Critical), countBySeverity(findings, detectors.High), countBySeverity(findings, detectors.Medium), countBySeverity(findings, detectors.Low), - ) + )) + + return sb.String() +} + +func (r *ConsoleReporter) Report(findings []detectors.Finding) error { + fmt.Print(r.Format(findings)) if len(findings) > 0 { return fmt.Errorf("scan found %d security issues", len(findings)) diff --git a/internal/reporters/console_additional_test.go b/internal/reporters/console_additional_test.go new file mode 100644 index 0000000..ea619c1 --- /dev/null +++ b/internal/reporters/console_additional_test.go @@ -0,0 +1,36 @@ +package reporters + +import ( + "strings" + "testing" + + "secure-push/internal/detectors" +) + +func TestConsoleReporter_Format(t *testing.T) { + reporter := &ConsoleReporter{} + findings := []detectors.Finding{ + {Severity: detectors.Critical, Rule: "SECRETS", File: "test.go", Line: 10, Message: "Test message"}, + } + + got := reporter.Format(findings) + + if !strings.Contains(got, "SECRETS") { + t.Error("Format() output should contain 'SECRETS'") + } + if !strings.Contains(got, "test.go") { + t.Error("Format() output should contain 'test.go'") + } + if !strings.Contains(got, "Test message") { + t.Error("Format() output should contain 'Test message'") + } +} + +func TestConsoleReporter_FormatEmpty(t *testing.T) { + reporter := &ConsoleReporter{} + got := reporter.Format([]detectors.Finding{}) + + if !strings.Contains(got, "No sensitive data found") { + t.Error("Format() output should contain 'No sensitive data found'") + } +} \ No newline at end of file From 805afff85f88ac92abed33d7cd1379c8269fc6ea Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:54:37 +0300 Subject: [PATCH 16/18] feat: add count methods to Config for rule introspection Add count methods to Config for better introspection: - CustomRuleCount returns the number of custom rules - IgnoreRuleCount returns the number of ignore rules - IgnorePathCount returns the number of ignore paths - AllowlistCount returns the number of allowlist entries - Add config_additional_test.go with tests for count methods --- internal/config/config.go | 20 ++++++++++ internal/config/config_additional_test.go | 48 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 internal/config/config_additional_test.go diff --git a/internal/config/config.go b/internal/config/config.go index cfa60ab..a2e8f46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,26 @@ func (c *Config) Validate() error { return nil } +// CustomRuleCount returns the number of custom rules +func (c *Config) CustomRuleCount() int { + return len(c.CustomRules) +} + +// IgnoreRuleCount returns the number of ignore rules +func (c *Config) IgnoreRuleCount() int { + return len(c.IgnoreRules) +} + +// IgnorePathCount returns the number of ignore paths +func (c *Config) IgnorePathCount() int { + return len(c.IgnorePaths) +} + +// AllowlistCount returns the number of allowlist entries +func (c *Config) AllowlistCount() int { + return len(c.Allowlist) +} + func Load(configPath string) (*Config, error) { cfg := DefaultConfig() diff --git a/internal/config/config_additional_test.go b/internal/config/config_additional_test.go new file mode 100644 index 0000000..b290b29 --- /dev/null +++ b/internal/config/config_additional_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" +) + +func TestConfig_CustomRuleCount(t *testing.T) { + cfg := &Config{ + CustomRules: []CustomRule{ + {Path: "test1.go", Severity: "high"}, + {Path: "test2.go", Severity: "medium"}, + }, + } + + if cfg.CustomRuleCount() != 2 { + t.Errorf("CustomRuleCount() = %d, want 2", cfg.CustomRuleCount()) + } +} + +func TestConfig_IgnoreRuleCount(t *testing.T) { + cfg := &Config{ + IgnoreRules: []string{"ENV_FILE", "CONFIG_FILE"}, + } + + if cfg.IgnoreRuleCount() != 2 { + t.Errorf("IgnoreRuleCount() = %d, want 2", cfg.IgnoreRuleCount()) + } +} + +func TestConfig_IgnorePathCount(t *testing.T) { + cfg := &Config{ + IgnorePaths: []string{"*.test.go", "vendor/**"}, + } + + if cfg.IgnorePathCount() != 2 { + t.Errorf("IgnorePathCount() = %d, want 2", cfg.IgnorePathCount()) + } +} + +func TestConfig_AllowlistCount(t *testing.T) { + cfg := &Config{ + Allowlist: []string{"important.test.go", "config.go"}, + } + + if cfg.AllowlistCount() != 2 { + t.Errorf("AllowlistCount() = %d, want 2", cfg.AllowlistCount()) + } +} \ No newline at end of file From 15f6e5a6eb7f69081613fa6c232357fa57b79592 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 06:56:46 +0300 Subject: [PATCH 17/18] feat: add level query methods to Logger for runtime checks Add methods to Logger for checking log level at runtime: - GetLevel returns the current log level - IsDebugEnabled returns true if debug logging is enabled - IsInfoEnabled returns true if info logging is enabled - IsWarnEnabled returns true if warn logging is enabled - IsErrorEnabled returns true if error logging is enabled - Add logger_additional_test.go with tests for level methods --- internal/logger/logger.go | 25 +++++++++ internal/logger/logger_additional_test.go | 67 +++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/logger/logger_additional_test.go diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 0ff5a23..a2d8597 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -33,6 +33,31 @@ func (l *Logger) SetOutput(w io.Writer) { l.output = w } +// GetLevel returns the current log level +func (l *Logger) GetLevel() Level { + return l.level +} + +// IsDebugEnabled returns true if debug logging is enabled +func (l *Logger) IsDebugEnabled() bool { + return l.level <= Debug +} + +// IsInfoEnabled returns true if info logging is enabled +func (l *Logger) IsInfoEnabled() bool { + return l.level <= Info +} + +// IsWarnEnabled returns true if warn logging is enabled +func (l *Logger) IsWarnEnabled() bool { + return l.level <= Warn +} + +// IsErrorEnabled returns true if error logging is enabled +func (l *Logger) IsErrorEnabled() bool { + return l.level <= Error +} + func (l *Logger) Debug(format string, args ...interface{}) { if l.level <= Debug { l.log("DEBUG", format, args...) diff --git a/internal/logger/logger_additional_test.go b/internal/logger/logger_additional_test.go new file mode 100644 index 0000000..b4ef592 --- /dev/null +++ b/internal/logger/logger_additional_test.go @@ -0,0 +1,67 @@ +package logger + +import ( + "testing" +) + +func TestLogger_GetLevel(t *testing.T) { + tests := []struct { + level Level + want Level + }{ + {Debug, Debug}, + {Info, Info}, + {Warn, Warn}, + {Error, Error}, + } + + for _, tt := range tests { + l := New(tt.level) + if got := l.GetLevel(); got != tt.want { + t.Errorf("GetLevel() = %v, want %v", got, tt.want) + } + } +} + +func TestLogger_IsDebugEnabled(t *testing.T) { + l := New(Debug) + if !l.IsDebugEnabled() { + t.Error("IsDebugEnabled() should be true for Debug level") + } + + l = New(Info) + if l.IsDebugEnabled() { + t.Error("IsDebugEnabled() should be false for Info level") + } +} + +func TestLogger_IsInfoEnabled(t *testing.T) { + l := New(Info) + if !l.IsInfoEnabled() { + t.Error("IsInfoEnabled() should be true for Info level") + } + + l = New(Warn) + if l.IsInfoEnabled() { + t.Error("IsInfoEnabled() should be false for Warn level") + } +} + +func TestLogger_IsWarnEnabled(t *testing.T) { + l := New(Warn) + if !l.IsWarnEnabled() { + t.Error("IsWarnEnabled() should be true for Warn level") + } + + l = New(Error) + if l.IsWarnEnabled() { + t.Error("IsWarnEnabled() should be false for Error level") + } +} + +func TestLogger_IsErrorEnabled(t *testing.T) { + l := New(Error) + if !l.IsErrorEnabled() { + t.Error("IsErrorEnabled() should be true for Error level") + } +} From 1404fa2803cc597d2ade7b07da27bc8ac6c8e460 Mon Sep 17 00:00:00 2001 From: StephenJarso Date: Mon, 22 Jun 2026 08:27:56 +0300 Subject: [PATCH 18/18] fix: remove duplicate Finding type and test from pkg/types The Finding struct is already defined in internal/detectors/detector.go. Removed the duplicate definition and test file that were causing build failures. --- pkg/types/finding.go | 10 -------- pkg/types/finding_test.go | 53 --------------------------------------- 2 files changed, 63 deletions(-) delete mode 100644 pkg/types/finding.go delete mode 100644 pkg/types/finding_test.go diff --git a/pkg/types/finding.go b/pkg/types/finding.go deleted file mode 100644 index e674e7a..0000000 --- a/pkg/types/finding.go +++ /dev/null @@ -1,10 +0,0 @@ -package types - -// Finding represents a security finding -type Finding struct { - Severity string - Rule string - File string - Line int - Message string -} diff --git a/pkg/types/finding_test.go b/pkg/types/finding_test.go deleted file mode 100644 index a34ff7b..0000000 --- a/pkg/types/finding_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package types - -import ( - "testing" -) - -func TestFindingCreation(t *testing.T) { - f := Finding{ - Severity: "high", - Rule: "aws-secret-key", - File: "config.go", - Line: 42, - Message: "AWS secret key detected", - } - - if f.Severity != "high" { - t.Errorf("Expected severity 'high', got %q", f.Severity) - } - if f.Rule != "aws-secret-key" { - t.Errorf("Expected rule 'aws-secret-key', got %q", f.Rule) - } - if f.File != "config.go" { - t.Errorf("Expected file 'config.go', got %q", f.File) - } - if f.Line != 42 { - t.Errorf("Expected line 42, got %d", f.Line) - } - if f.Message != "AWS secret key detected" { - t.Errorf("Expected message 'AWS secret key detected', got %q", f.Message) - } -} - -func TestFindingZeroValues(t *testing.T) { - f := Finding{} - - if f.Severity != "" { - t.Errorf("Expected empty severity, got %q", f.Severity) - } - if f.Line != 0 { - t.Errorf("Expected line 0, got %d", f.Line) - } -} - -func TestFindingAllSeverities(t *testing.T) { - severities := []string{"low", "medium", "high", "critical"} - - for _, s := range severities { - f := Finding{Severity: s} - if f.Severity != s { - t.Errorf("Expected severity %q, got %q", s, f.Severity) - } - } -}