diff --git a/pkg/detectors/basicauth/basicauth.go b/pkg/detectors/basicauth/basicauth.go new file mode 100644 index 000000000000..c8faa73e307d --- /dev/null +++ b/pkg/detectors/basicauth/basicauth.go @@ -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 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})`) +) + +// 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." +} diff --git a/pkg/detectors/basicauth/basicauth_test.go b/pkg/detectors/basicauth/basicauth_test.go new file mode 100644 index 000000000000..76109a5e202f --- /dev/null +++ b/pkg/detectors/basicauth/basicauth_test.go @@ -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) + } + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index f3604c038def..a3bb98f4f9bf 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -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" @@ -969,6 +970,7 @@ func buildDetectorList() []detectors.Detector { &azuresearchquerykey.Scanner{}, &azure_storage.Scanner{}, &azurerepositorykey.Scanner{}, + &basicauth.Scanner{}, &bannerbearv1.Scanner{}, &bannerbearv2.Scanner{}, &baremetrics.Scanner{}, diff --git a/pkg/pb/detector_typepb/detector_type.pb.go b/pkg/pb/detector_typepb/detector_type.pb.go index 11428679df44..ad63871b316e 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -1106,6 +1106,7 @@ const ( DetectorType_GitLabOauth2 DetectorType = 1050 DetectorType_SpectralOps DetectorType = 1051 DetectorType_AWSAppSync DetectorType = 1052 + DetectorType_BasicAuth DetectorType = 1057 ) // Enum value maps for DetectorType. @@ -2160,6 +2161,7 @@ var ( 1050: "GitLabOauth2", 1051: "SpectralOps", 1052: "AWSAppSync", + 1057: "BasicAuth", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3211,6 +3213,7 @@ var ( "GitLabOauth2": 1050, "SpectralOps": 1051, "AWSAppSync": 1052, + "BasicAuth": 1057, } ) diff --git a/proto/detector_type.proto b/proto/detector_type.proto index 6423f9ede368..644d2f6ec1c3 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1054,4 +1054,5 @@ enum DetectorType { GitLabOauth2 = 1050; SpectralOps = 1051; AWSAppSync = 1052; + BasicAuth = 1057; }