diff --git a/CHANGELOG.md b/CHANGELOG.md index 101a889..442c152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Telegram bot token detector - Azure Key Vault detector - Personal access token detector +- Provider access token detectors for Figma, Notion, Linear, Auth0, and Intercom +- Scanner binary detection benchmarks - Hardcoded password pattern detection - Connection string pattern detection - API key header pattern detection diff --git a/README.md b/README.md index cb5ed3a..eb70db0 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ allowlist: |---------------------------|-----------|---------------------| | AWS Keys | CRITICAL | Low | | GitHub Tokens | CRITICAL | Low | +| Provider Access Tokens | HIGH | Low | | `.env` Files | CRITICAL | None | | Generic API Keys | HIGH | Medium | | Private SSH Keys | CRITICAL | Low | diff --git a/docs/detectors.md b/docs/detectors.md index 031aac8..9f6852d 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -38,6 +38,11 @@ The `AUTH_CREDENTIALS` detector identifies various authentication tokens and cre - **Telegram Bot Tokens**: `1234567890:...` - **Azure Key Vault**: `-----BEGIN AZURE KEY VAULT-----` - **Personal Access Tokens**: Various formats +- **Figma Tokens**: `figd_...` +- **Notion Tokens**: `secret_...` +- **Linear API Tokens**: `lin_api_...` +- **Auth0 Tokens**: JWT-like tokens with long segments +- **Intercom Tokens**: 40-character tokens assigned to Intercom access token variables - **SSH/PGP Keys**: Private key headers - **JWT Tokens**: JSON Web Tokens - **Bearer Tokens**: Authorization headers diff --git a/docs/development.md b/docs/development.md index 5f92601..9dca5ae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,6 +56,8 @@ make lint 4. Register the detector in `internal/scanner/scanner.go` 5. Update README.md with the new detector +Detector tests should include positive matches, negative matches, line number assertions, and false-positive guards for broad patterns. + ## Adding a New Reporter 1. Create a new file in `internal/reporters/` diff --git a/internal/detectors/auth.go b/internal/detectors/auth.go index 9a79960..03aa5a3 100644 --- a/internal/detectors/auth.go +++ b/internal/detectors/auth.go @@ -37,8 +37,27 @@ var ( telegramBotTokenPattern = regexp.MustCompile(`[0-9]{8,10}:[A-Za-z0-9\-_]{35,}`) azureKeyPattern = regexp.MustCompile(`-----BEGIN AZURE KEY VAULT-----`) personalAccessTokenPattern = regexp.MustCompile(`[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{60,}`) + figmaTokenPattern = regexp.MustCompile(`figd_[A-Za-z0-9]{60,}`) + notionTokenPattern = regexp.MustCompile(`secret_[A-Za-z0-9]{43}`) + linearTokenPattern = regexp.MustCompile(`lin_api_[A-Za-z0-9]{64}`) + intercomTokenPattern = regexp.MustCompile(`(?i)(intercom(?:_|[-[:space:]])?(?:access[_-]?)?token|access[_-]?token)\s*[:=]\s*['"]?[A-Za-z0-9]{40,}['"]?`) + auth0TokenPattern = regexp.MustCompile(`[A-Za-z0-9\-_]{80,}\.[A-Za-z0-9\-_]{32,}`) ) +type authRule struct { + pattern *regexp.Regexp + message string + severity Severity +} + +var providerAuthRules = []authRule{ + {pattern: figmaTokenPattern, message: "Figma token found", severity: High}, + {pattern: notionTokenPattern, message: "Notion token found", severity: High}, + {pattern: linearTokenPattern, message: "Linear API token found", severity: High}, + {pattern: auth0TokenPattern, message: "Auth0 token found", severity: High}, + {pattern: intercomTokenPattern, message: "Intercom token found", severity: High}, +} + func (d *AuthDetector) Detect(content string, filename string) ([]Finding, error) { var findings []Finding lines := strings.Split(content, "\n") @@ -258,6 +277,23 @@ func (d *AuthDetector) Detect(content string, filename string) ([]Finding, error Message: "Personal access token found", }) } + + seenProviderFindings := make(map[string]struct{}, len(providerAuthRules)) + for _, rule := range providerAuthRules { + if rule.pattern.MatchString(line) { + if _, seen := seenProviderFindings[rule.message]; seen { + continue + } + findings = append(findings, Finding{ + Rule: d.Name(), + Severity: rule.severity, + File: filename, + Line: lineNum + 1, + Message: rule.message, + }) + seenProviderFindings[rule.message] = struct{}{} + } + } } return findings, nil diff --git a/internal/detectors/auth_test.go b/internal/detectors/auth_test.go index ac09e6d..69f5126 100644 --- a/internal/detectors/auth_test.go +++ b/internal/detectors/auth_test.go @@ -130,7 +130,7 @@ func TestAuthDetector_Detect(t *testing.T) { { name: "slack token", filename: "config.go", - content: "slack_token = 'xoxb-1234567890-123456789012-ABCDEFGHIJKLMNO'", + content: "slack_token = 'xoxb-test0000000-test00000000000-TESTTESTTEST'", wantMin: 1, }, { @@ -160,9 +160,39 @@ func TestAuthDetector_Detect(t *testing.T) { { name: "multiple slack tokens", filename: "config.go", - content: "token1 = 'xoxb-1234567890-123456789012-ABCDEFGHIJKLMNO'\ntoken2 = 'xoxa-1234567890-123456789012-ABCDEFGHIJKLMNO'", + content: "token1 = 'xoxb-test0000000-test00000000000-TESTTESTTEST'\ntoken2 = 'xoxa-test0000000-test00000000000-TESTTESTTEST'", wantMin: 2, }, + { + name: "figma token", + filename: "config.go", + content: "figma_token = 'figd_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH'", + wantMin: 1, + }, + { + name: "notion token", + filename: "config.go", + content: "notion_token = 'secret_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrs'", + wantMin: 1, + }, + { + name: "linear api token", + filename: "config.go", + content: "linear_api_token = 'lin_api_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH'", + wantMin: 1, + }, + { + name: "auth0 token", + filename: "config.go", + content: "auth0_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJ.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl'", + wantMin: 1, + }, + { + name: "intercom token", + filename: "config.go", + content: "intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", + wantMin: 1, + }, } for _, tt := range tests { @@ -197,3 +227,103 @@ func TestAuthDetector_Detect_NoError(t *testing.T) { t.Fatalf("Detect() error = %v, want nil", err) } } + +func TestAuthDetector_DetectProviderMessages(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "figma", + content: "figma_token = 'figd_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH'", + want: "Figma token found", + }, + { + name: "notion", + content: "notion_token = 'secret_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrs'", + want: "Notion token found", + }, + { + name: "linear", + content: "linear_api_token = 'lin_api_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH'", + want: "Linear API token found", + }, + { + name: "auth0", + content: "auth0_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJ.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl'", + want: "Auth0 token found", + }, + { + name: "intercom", + content: "intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", + want: "Intercom token found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect(tt.content, "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("Detect() returned %d findings, want 1", len(got)) + } + if got[0].Message != tt.want { + t.Errorf("Message = %q, want %q", got[0].Message, tt.want) + } + }) + } +} + +func TestAuthDetector_DetectProviderLineNumbers(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect("line1\nintercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("Detect() returned %d findings, want 1", len(got)) + } + if got[0].Line != 2 { + t.Errorf("Line = %d, want 2", got[0].Line) + } +} + +func TestAuthDetector_DetectProviderWhitespaceComments(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect(" # intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 0 { + t.Fatalf("Detect() returned %d findings, want 0", len(got)) + } +} + +func TestAuthDetector_DetectIntercomTokenFalsePositiveGuard(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect("random_string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 0 { + t.Fatalf("Detect() returned %d findings, want 0", len(got)) + } +} + +func TestAuthDetector_DetectProviderOneFindingPerLine(t *testing.T) { + d := &AuthDetector{} + got, err := d.Detect("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\nintercom_access_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'", "config.go") + if err != nil { + t.Fatalf("Detect() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("Detect() returned %d findings, want 2", len(got)) + } + if got[0].Line != 1 || got[1].Line != 2 { + t.Errorf("Lines = [%d, %d], want [1, 2]", got[0].Line, got[1].Line) + } +} diff --git a/internal/scanner/auth_integration_test.go b/internal/scanner/auth_integration_test.go new file mode 100644 index 0000000..f69660a --- /dev/null +++ b/internal/scanner/auth_integration_test.go @@ -0,0 +1,119 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "secure-push/internal/config" + "secure-push/internal/detectors" + "secure-push/internal/logger" +) + +func TestIntegrationScanWithAuthDetector(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.go") + err := os.WriteFile(configFile, []byte("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n"), 0o644) + if err != nil { + t.Fatal(err) + } + + scanner := New([]detectors.Detector{&detectors.AuthDetector{}}, config.DefaultConfig(), logger.New(logger.Debug)) + findings, err := scanner.Scan(tmpDir) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if len(findings) != 1 { + t.Fatalf("Scan() returned %d findings, want 1", len(findings)) + } + if findings[0].Message != "Intercom token found" { + t.Errorf("Message = %q, want Intercom token found", findings[0].Message) + } +} + +func TestIntegrationScanFileWithAuthDetector(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "config-*.go") + if err != nil { + t.Fatal(err) + } + if _, err := tmpFile.WriteString("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n"); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + scanner := New([]detectors.Detector{&detectors.AuthDetector{}}, config.DefaultConfig(), logger.New(logger.Debug)) + findings, err := scanner.ScanFile(tmpFile.Name()) + if err != nil { + t.Fatalf("ScanFile() error = %v", err) + } + if len(findings) != 1 { + t.Fatalf("ScanFile() returned %d findings, want 1", len(findings)) + } + if findings[0].Line != 1 { + t.Errorf("Line = %d, want 1", findings[0].Line) + } +} + +func TestIntegrationScanFileSkipsBinaryFile(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "binary-*.bin") + if err != nil { + t.Fatal(err) + } + if _, err := tmpFile.Write([]byte{0x00, 0x01, 0x02}); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + scanner := New([]detectors.Detector{&detectors.AuthDetector{}}, config.DefaultConfig(), logger.New(logger.Debug)) + _, err = scanner.ScanFile(tmpFile.Name()) + if err == nil { + t.Fatal("ScanFile() error = nil, want binary file error") + } +} + +func TestIntegrationScanFileRejectsIgnoredFile(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "config-*.go") + if err != nil { + t.Fatal(err) + } + if _, err := tmpFile.WriteString("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n"); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + IgnorePaths: []string{tmpFile.Name()}, + } + scanner := New([]detectors.Detector{&detectors.AuthDetector{}}, cfg, logger.New(logger.Debug)) + _, err = scanner.ScanFile(tmpFile.Name()) + if err == nil { + t.Fatal("ScanFile() error = nil, want ignored file error") + } +} + +func TestIntegrationScanWithDisabledAuthDetector(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.go") + err := os.WriteFile(configFile, []byte("intercom_token = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'\n"), 0o644) + if err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + DisableDetectors: []string{"AUTH_CREDENTIALS"}, + } + scanner := New([]detectors.Detector{&detectors.AuthDetector{}}, cfg, logger.New(logger.Debug)) + findings, err := scanner.Scan(tmpDir) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if len(findings) != 0 { + t.Fatalf("Scan() returned %d findings, want 0", len(findings)) + } +} diff --git a/internal/scanner/file.go b/internal/scanner/file.go index 6406102..9de9662 100644 --- a/internal/scanner/file.go +++ b/internal/scanner/file.go @@ -1,6 +1,8 @@ package scanner import ( + "errors" + "io" "os" "path/filepath" "sync" @@ -26,7 +28,7 @@ func IsBinaryFile(path string) (bool, error) { buffer := *bufPtr n, err := file.Read(buffer) - if err != nil && err.Error() != "EOF" { + if err != nil && !errors.Is(err, io.EOF) { return false, err } diff --git a/internal/scanner/file_test.go b/internal/scanner/file_test.go index 61cb483..b2cea1d 100644 --- a/internal/scanner/file_test.go +++ b/internal/scanner/file_test.go @@ -128,6 +128,27 @@ func TestIsTextFile(t *testing.T) { }) } +func TestIsTextFileWithBinaryFile(t *testing.T) { + tmpFile, err := os.CreateTemp("", "binary-*.bin") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write([]byte{0x00, 0x01, 0x02}); err != nil { + t.Fatal(err) + } + tmpFile.Close() + + isText, err := IsTextFile(tmpFile.Name()) + if err != nil { + t.Fatalf("IsTextFile() error = %v", err) + } + if isText { + t.Error("IsTextFile() = true, want false for binary file") + } +} + func TestIsBinaryFileWithSmallFile(t *testing.T) { // Test that files smaller than 512 bytes are handled correctly tmpFile, err := os.CreateTemp("", "small-*.txt") @@ -207,3 +228,50 @@ func TestIsBinaryFileInSubdir(t *testing.T) { t.Error("IsBinaryFile() = true, want false for text file in subdir") } } + +func BenchmarkIsBinary(b *testing.B) { + textData := make([]byte, 512) + for i := range textData { + textData[i] = 'a' + } + + binaryData := make([]byte, 512) + for i := range binaryData { + binaryData[i] = byte(i % 256) + } + binaryData[100] = 0 // Add null byte + + b.Run("text data", func(b *testing.B) { + for i := 0; i < b.N; i++ { + IsBinary(textData) + } + }) + + b.Run("binary data", func(b *testing.B) { + for i := 0; i < b.N; i++ { + IsBinary(binaryData) + } + }) +} + +func BenchmarkBufferPool(b *testing.B) { + b.Run("get and put", func(b *testing.B) { + for i := 0; i < b.N; i++ { + bufPtr := bufferPool.Get().(*[]byte) + _ = (*bufPtr)[0] + bufferPool.Put(bufPtr) + } + }) +} + +func BenchmarkTextClassification(b *testing.B) { + textData := make([]byte, 512) + for i := range textData { + textData[i] = 'a' + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = IsBinary(textData) + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index f354439..a11c475 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -144,7 +144,7 @@ func (s *Scanner) ScanFile(path string) ([]detectors.Finding, error) { return nil, fmt.Errorf("file is ignored: %s", path) } - fileInfo, err := os.Stat(path) + fileInfo, err := GetFileInfo(path) if err != nil { return nil, err } diff --git a/internal/scanner/walk_test.go b/internal/scanner/walk_test.go index 968fa74..8c79aa7 100644 --- a/internal/scanner/walk_test.go +++ b/internal/scanner/walk_test.go @@ -35,6 +35,30 @@ func TestWalkDir(t *testing.T) { } } +func TestWalkDirSkipsHiddenDirectory(t *testing.T) { + tmpDir := t.TempDir() + hiddenDir := filepath.Join(tmpDir, ".hidden") + if err := os.MkdirAll(hiddenDir, 0o755); err != nil { + t.Fatal(err) + } + hiddenFile := filepath.Join(hiddenDir, "secret.txt") + if err := os.WriteFile(hiddenFile, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + + var visited []string + err := WalkDir(tmpDir, func(path string, info os.FileInfo) error { + visited = append(visited, filepath.Base(path)) + return nil + }) + if err != nil { + t.Fatalf("WalkDir() error = %v", err) + } + if len(visited) != 0 { + t.Errorf("WalkDir() visited %d files, want 0", len(visited)) + } +} + func TestGetFileSize(t *testing.T) { tmpFile, err := os.CreateTemp("", "test-*.txt") if err != nil { @@ -94,6 +118,12 @@ func TestIsDir(t *testing.T) { } } +func TestIsDirWithMissingPath(t *testing.T) { + if IsDir("/nonexistent/path") { + t.Error("IsDir() = true, want false for missing path") + } +} + func TestGetRelativePath(t *testing.T) { base := "/home/user/project" path := "/home/user/project/src/main.go" @@ -108,6 +138,13 @@ func TestGetRelativePath(t *testing.T) { } } +func TestGetRelativePathError(t *testing.T) { + _, err := GetRelativePath("/tmp/secure-push-a", "/tmp/secure-push-b") + if err == nil { + t.Fatal("GetRelativePath() error = nil, want error") + } +} + func TestValidatePath(t *testing.T) { t.Run("empty path", func(t *testing.T) { err := ValidatePath("")