Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions pkg/detectors/basicauth/basicauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package basicauth

import (
"context"
"encoding/base64"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

type Scanner struct{}

var (
// Ensure the Scanner satisfies the interface at compile time.
_ detectors.Detector = (*Scanner)(nil)

// Pattern matches Authorization: Basic <base64> or similar variations
// The base64 part should contain at least one colon when decoded (username:password format)
keyPat = regexp.MustCompile(`(?i)(?:authorization|auth)[\s:=]+basic[\s]+([A-Za-z0-9+/]{20,}={0,2})`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex minimum length rejects many valid credentials

High Severity

The {20,} minimum length in keyPat for the base64 alphabet portion is too high, causing the detector to miss many real-world credentials. Common pairs like admin:admin (15 base64 alphabet chars), user:password (18 chars), and admin:password (19 chars) all fall below the 20-char threshold and won't be detected. Only credentials with a combined username:password length of 15+ bytes produce enough base64 characters to match. The downstream validation (colon check, non-empty parts) already guards against false positives, making this high minimum unnecessary. The test cases for "no colon separator" and "empty password" also silently pass due to this same issue — their base64 strings are too short to match the regex, so the actual validation logic is never exercised.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8b26b24. Configure here.

)

// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"authorization", "basic", "auth"}
}

// FromData will find and optionally verify BasicAuth secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

matches := keyPat.FindAllStringSubmatch(dataStr, -1)

for _, match := range matches {
resMatch := strings.TrimSpace(match[1])

// Decode base64 to verify it contains username:password format
decoded, err := base64.StdEncoding.DecodeString(resMatch)
if err != nil {
// Try URL encoding as fallback
decoded, err = base64.URLEncoding.DecodeString(resMatch)
if err != nil {
continue
}
}

decodedStr := string(decoded)

// Basic auth must contain at least one colon separating username and password
if !strings.Contains(decodedStr, ":") {
continue
}

// Split to get username and password
parts := strings.SplitN(decodedStr, ":", 2)
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
continue
}

s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_BasicAuth,
Raw: []byte(resMatch),
RawV2: []byte(decodedStr),
SecretParts: map[string]string{
"username": parts[0],
"password": parts[1],
"encoded": resMatch,
},
}

// Basic auth tokens cannot be verified without knowing the target URL/endpoint
// So we mark them as unverified by default
s1.Verified = false

results = append(results, s1)
}

return results, nil
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_BasicAuth
}

func (s Scanner) Description() string {
return "HTTP Basic Authentication is a simple authentication scheme built into the HTTP protocol. Basic Auth credentials consist of a username and password encoded in base64 format."
}
156 changes: 156 additions & 0 deletions pkg/detectors/basicauth/basicauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package basicauth

import (
"context"
"encoding/base64"
"fmt"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

func TestBasicAuth_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*1000000000) // 5 seconds
defer cancel()

// Create test credentials
validCreds := base64.StdEncoding.EncodeToString([]byte("admin:password123"))
validCredsComplex := base64.StdEncoding.EncodeToString([]byte("user@example.com:P@ssw0rd!2023"))
invalidNoCreds := base64.StdEncoding.EncodeToString([]byte("justonefield"))
invalidEmptyPassword := base64.StdEncoding.EncodeToString([]byte("username:"))

tests := []struct {
name string
input string
want []detectors.Result
wantErr bool
wantMatches int
}{
{
name: "valid basic auth with Authorization header",
input: fmt.Sprintf("Authorization: Basic %s", validCreds),
wantMatches: 1,
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_BasicAuth,
Verified: false,
Raw: []byte(validCreds),
RawV2: []byte("admin:password123"),
SecretParts: map[string]string{
"username": "admin",
"password": "password123",
"encoded": validCreds,
},
},
},
},
{
name: "valid basic auth with auth header lowercase",
input: fmt.Sprintf("auth: basic %s", validCreds),
wantMatches: 1,
},
{
name: "valid basic auth with complex password",
input: fmt.Sprintf("Authorization: Basic %s", validCredsComplex),
wantMatches: 1,
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_BasicAuth,
Verified: false,
Raw: []byte(validCredsComplex),
RawV2: []byte("user@example.com:P@ssw0rd!2023"),
SecretParts: map[string]string{
"username": "user@example.com",
"password": "P@ssw0rd!2023",
"encoded": validCredsComplex,
},
},
},
},
{
name: "basic auth with equals separator",
input: fmt.Sprintf("Authorization=Basic %s", validCreds),
wantMatches: 1,
},
{
name: "basic auth in curl command",
input: fmt.Sprintf("curl -H 'Authorization: Basic %s' https://api.example.com", validCreds),
wantMatches: 1,
},
{
name: "invalid - no colon separator",
input: fmt.Sprintf("Authorization: Basic %s", invalidNoCreds),
wantMatches: 0,
},
{
name: "invalid - empty password",
input: fmt.Sprintf("Authorization: Basic %s", invalidEmptyPassword),
wantMatches: 0,
},
{
name: "invalid - not base64",
input: "Authorization: Basic not-valid-base64!!!",
wantMatches: 0,
},
{
name: "invalid - too short",
input: "Authorization: Basic YWRt",
wantMatches: 0,
},
{
name: "multiple basic auth tokens",
input: fmt.Sprintf("Authorization: Basic %s\nAuthorization: Basic %s", validCreds, validCredsComplex),
wantMatches: 2,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Scanner{}
got, err := s.FromData(ctx, false, []byte(tt.input))
if (err != nil) != tt.wantErr {
t.Errorf("BasicAuth.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}

if len(got) != tt.wantMatches {
t.Errorf("BasicAuth.FromData() got %d matches, want %d", len(got), tt.wantMatches)
return
}

if tt.want != nil && len(got) > 0 {
// Compare first result
ignoreOpts := cmpopts.IgnoreUnexported(detectors.Result{})

if diff := cmp.Diff(got[0], tt.want[0], ignoreOpts); diff != "" {
t.Errorf("BasicAuth.FromData() mismatch (-got +want):\n%s", diff)
}
}
})
}
}

func TestBasicAuth_Keywords(t *testing.T) {
s := Scanner{}
keywords := s.Keywords()

if len(keywords) == 0 {
t.Error("Keywords() returned empty slice")
}

expectedKeywords := map[string]bool{
"authorization": true,
"basic": true,
"auth": true,
}

for _, kw := range keywords {
if !expectedKeywords[kw] {
t.Errorf("unexpected keyword: %s", kw)
}
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresastoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchadminkey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchquerykey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/basicauth"
bannerbearv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bannerbear/v1"
bannerbearv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bannerbear/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/baremetrics"
Expand Down Expand Up @@ -969,6 +970,7 @@ func buildDetectorList() []detectors.Detector {
&azuresearchquerykey.Scanner{},
&azure_storage.Scanner{},
&azurerepositorykey.Scanner{},
&basicauth.Scanner{},
&bannerbearv1.Scanner{},
&bannerbearv2.Scanner{},
&baremetrics.Scanner{},
Expand Down
3 changes: 3 additions & 0 deletions pkg/pb/detector_typepb/detector_type.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/detector_type.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1054,4 +1054,5 @@ enum DetectorType {
GitLabOauth2 = 1050;
SpectralOps = 1051;
AWSAppSync = 1052;
BasicAuth = 1057;
}