Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
50e15f9
feat(auth): add provider token detectors
StephenJarso Jun 15, 2026
36911ea
test(scanner): add binary detection benchmarks
StephenJarso Jun 15, 2026
3d6153f
test(auth): add provider token coverage
StephenJarso Jun 15, 2026
fa74758
refactor(auth): centralize provider token checks
StephenJarso Jun 15, 2026
8e32146
test(auth): assert provider finding metadata
StephenJarso Jun 15, 2026
7a1c89a
refactor(auth): narrow intercom token matching
StephenJarso Jun 15, 2026
5d61e31
test(auth): add intercom false-positive guard
StephenJarso Jun 15, 2026
5a125d1
refactor(auth): deduplicate provider findings per line
StephenJarso Jun 15, 2026
c9c766d
test(auth): cover provider findings per line
StephenJarso Jun 15, 2026
be3559f
docs(detectors): document provider token auth rules
StephenJarso Jun 15, 2026
f222833
docs: update supported detector table
StephenJarso Jun 15, 2026
cee93b4
docs: update changelog for provider token detectors
StephenJarso Jun 15, 2026
50d3cdc
refactor(scanner): reuse file info helper in ScanFile
StephenJarso Jun 15, 2026
9a79625
test(scanner): add auth detector integration coverage
StephenJarso Jun 15, 2026
bffe2d7
test(scanner): cover hidden directory walk behavior
StephenJarso Jun 15, 2026
d7b613f
test(scanner): cover relative path error handling
StephenJarso Jun 15, 2026
55f211a
test(scanner): cover missing path directory checks
StephenJarso Jun 15, 2026
fea0cae
refactor(scanner): use io.EOF for binary read handling
StephenJarso Jun 15, 2026
d13f297
test(scanner): cover IsTextFile binary behavior
StephenJarso Jun 15, 2026
ee94822
test(scanner): add text classification benchmark
StephenJarso Jun 15, 2026
4a35fde
docs: add detector test guidance
StephenJarso Jun 15, 2026
714a199
test(scanner): cover ignored ScanFile paths
StephenJarso Jun 15, 2026
e387a1c
test(scanner): cover disabled auth detector
StephenJarso Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
36 changes: 36 additions & 0 deletions internal/detectors/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
134 changes: 132 additions & 2 deletions internal/detectors/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
119 changes: 119 additions & 0 deletions internal/scanner/auth_integration_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 3 additions & 1 deletion internal/scanner/file.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package scanner

import (
"errors"
"io"
"os"
"path/filepath"
"sync"
Expand All @@ -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
}

Expand Down
Loading
Loading