diff --git a/pkg/detectors/bitbucketapppassword/bitbucketapppassword.go b/pkg/detectors/bitbucketapppassword/bitbucketapppassword.go new file mode 100644 index 000000000000..61e9d2a5ccd7 --- /dev/null +++ b/pkg/detectors/bitbucketapppassword/bitbucketapppassword.go @@ -0,0 +1,131 @@ +package bitbucketapppassword + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "regexp" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +// Scanner is a stateless struct that implements the detector interface. +type Scanner struct { + client *http.Client +} + +// Ensure the Scanner satisfies the interface at compile time. +var _ detectors.Detector = (*Scanner)(nil) + +// Keywords are used for efficiently pre-filtering chunks. +func (s Scanner) Keywords() []string { + return []string{"bitbucket", "ATBB"} +} + +func (s Scanner) Type() detectorspb.DetectorType { + return detectorspb.DetectorType_BitbucketAppPassword +} + +func (s Scanner) Description() string { + return "Bitbucket is a Git repository hosting service by Atlassian. Bitbucket App Passwords are used to authenticate to the Bitbucket API." +} + +const bitbucketAPIUserURL = "https://api.bitbucket.org/2.0/user" + +var ( + defaultClient = common.SaneHttpClient() +) + +var ( + // credentialPatterns uses named capture groups (?P...) for readability and robustness. + credentialPatterns = []*regexp.Regexp{ + // Explicitly define the boundary as (start of string) or (a non-username character). + regexp.MustCompile(`(?:^|[^A-Za-z0-9-_])(?P[A-Za-z0-9-_]{1,30}):(?PATBB[A-Za-z0-9_=.-]+)\b`), + // Catches 'https://username:password@bitbucket.org' pattern + regexp.MustCompile(`https://(?P[A-Za-z0-9-_]{1,30}):(?PATBB[A-Za-z0-9_=.-]+)@bitbucket\.org`), + // Catches '("username", "password")' pattern, used for HTTP Basic Auth + regexp.MustCompile(`"(?P[A-Za-z0-9-_]{1,30})",\s*"(?PATBB[A-Za-z0-9_=.-]+)"`), + } +) + +// FromData will find and optionally verify Bitbucket App Password secrets in a given set of bytes. +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) ([]detectors.Result, error) { + dataStr := string(data) + + uniqueCredentials := make(map[string]string) + + for _, pattern := range credentialPatterns { + for _, match := range pattern.FindAllStringSubmatch(dataStr, -1) { + // Extract credentials using named capture groups for readability. + namedMatches := make(map[string]string) + for i, name := range pattern.SubexpNames() { + if i != 0 && name != "" { + namedMatches[name] = match[i] + } + } + + username := namedMatches["username"] + password := namedMatches["password"] + + if username != "" && password != "" { + uniqueCredentials[username] = password + } + } + } + + var results []detectors.Result + for username, password := range uniqueCredentials { + result := detectors.Result{ + DetectorType: detectorspb.DetectorType_BitbucketAppPassword, + Raw: fmt.Appendf(nil, "%s:%s", username, password), + } + if verify { + client := s.client + if client == nil { + client = defaultClient + } + var vErr error + result.Verified, vErr = verifyCredential(ctx, client, username, password) + if vErr != nil { + result.SetVerificationError(vErr, username, password) + } + } + results = append(results, result) + } + + return results, nil +} + +// verifyCredential checks if a given username and app password are valid by making a request to the Bitbucket API. +func verifyCredential(ctx context.Context, client *http.Client, username, password string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bitbucketAPIUserURL, nil) + if err != nil { + return false, err + } + req.Header.Add("Accept", "application/json") + auth := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", username, password)) + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth)) + + res, err := client.Do(req) + if err != nil { + return false, err + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch res.StatusCode { + case http.StatusOK, http.StatusForbidden: + // A 403 can indicate a valid credential with insufficient scope, which is still a finding. + return true, nil + case http.StatusUnauthorized: + return false, nil + default: + return false, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } +} diff --git a/pkg/detectors/bitbucketapppassword/bitbucketapppassword_integration_test.go b/pkg/detectors/bitbucketapppassword/bitbucketapppassword_integration_test.go new file mode 100644 index 000000000000..bcc8b0fb637a --- /dev/null +++ b/pkg/detectors/bitbucketapppassword/bitbucketapppassword_integration_test.go @@ -0,0 +1,103 @@ +//go:build detectors +// +build detectors + +package bitbucketapppassword + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +func TestBitbucketAppPassword_FromData_Integration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors2") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + username := testSecrets.MustGetField("USERNAME") + validPassword := testSecrets.MustGetField("BITBUCKETAPPPASSWORD") + invalidPassword := "ATBB123abcDEF456ghiJKL789mnoPQR" // An invalid but correctly formatted password + + tests := []struct { + name string + input string + want []detectors.Result + wantErr bool + }{ + { + name: "valid credential", + input: fmt.Sprintf("https://%s:%s@bitbucket.org", username, validPassword), + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_BitbucketAppPassword, + Verified: true, + Raw: []byte(fmt.Sprintf("%s:%s", username, validPassword)), + }, + }, + }, + { + name: "invalid credential", + input: fmt.Sprintf("https://%s:%s@bitbucket.org", username, invalidPassword), + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_BitbucketAppPassword, + Verified: false, + Raw: []byte(fmt.Sprintf("%s:%s", username, invalidPassword)), + }, + }, + }, + { + name: "no credential found", + input: "this string has no credentials", + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Scanner{} + got, err := s.FromData(ctx, true, []byte(tc.input)) + + if (err != nil) != tc.wantErr { + t.Fatalf("FromData() error = %v, wantErr %v", err, tc.wantErr) + } + // Normalizing results for comparison by removing fields that are not relevant for the test + for i := range got { + if got[i].VerificationError() != nil { + t.Logf("verification error: %s", got[i].VerificationError()) + } + } + + if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(x, y detectors.Result) bool { + return x.Verified == y.Verified && string(x.Raw) == string(y.Raw) && x.DetectorType == y.DetectorType + })); diff != "" { + t.Errorf("FromData() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func BenchmarkFromData(benchmark *testing.B) { + ctx := context.Background() + s := &Scanner{} + for name, data := range detectors.MustGetBenchmarkData() { + benchmark.Run(name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, err := s.FromData(ctx, false, data) + if err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/pkg/detectors/bitbucketapppassword/bitbucketapppassword_test.go b/pkg/detectors/bitbucketapppassword/bitbucketapppassword_test.go new file mode 100644 index 000000000000..abfa51a0f15c --- /dev/null +++ b/pkg/detectors/bitbucketapppassword/bitbucketapppassword_test.go @@ -0,0 +1,71 @@ +package bitbucketapppassword + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBitbucketAppPassword_FromData(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pair", + input: "myuser:ATBB123abcDEF456ghiJKL789mnoPQR", + want: []string{"myuser:ATBB123abcDEF456ghiJKL789mnoPQR"}, + }, + { + name: "valid app password by itself (should not be found)", + input: "ATBB123abcDEF456ghiJKL789mnoPQR", + want: []string{}, + }, + { + name: "pair with invalid username", + input: "my-very-long-username-that-is-over-thirty-characters:ATBB123abcDEF456ghiJKL789mnoPQR", + want: []string{}, + }, + { + name: "url pattern", + input: `https://anotheruser:ATBB123abcDEF456ghiJKL789mnoPQR@bitbucket.org`, + want: []string{"anotheruser:ATBB123abcDEF456ghiJKL789mnoPQR"}, + }, + { + name: "http basic auth pattern", + input: `("basicauthuser", "ATBB123abcDEF456ghiJKL789mnoPQR")`, + want: []string{"basicauthuser:ATBB123abcDEF456ghiJKL789mnoPQR"}, + }, + { + name: "multiple matches", + input: `user1:ATBB123abcDEF456ghiJKL789mnoPQR and then also user2:ATBBzyxwvUT987srqPON654mlkJIH`, + want: []string{"user1:ATBB123abcDEF456ghiJKL789mnoPQR", "user2:ATBBzyxwvUT987srqPON654mlkJIH"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := &Scanner{} + results, err := d.FromData(context.Background(), false, []byte(tc.input)) + if err != nil { + t.Fatalf("FromData() error = %v", err) + } + + got := make(map[string]struct{}) + for _, r := range results { + got[string(r.Raw)] = struct{}{} + } + + wantSet := make(map[string]struct{}) + for _, w := range tc.want { + wantSet[w] = struct{}{} + } + + if diff := cmp.Diff(wantSet, got); diff != "" { + t.Errorf("FromData() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index 8cc460284ad3..c4b2b4ae8063 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -94,6 +94,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/billomat" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bingsubscriptionkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitbar" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitbucketapppassword" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitcoinaverage" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitfinex" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitlyaccesstoken" @@ -1714,6 +1715,7 @@ func buildDetectorList() []detectors.Detector { &zonkafeedback.Scanner{}, &zulipchat.Scanner{}, &stripepaymentintent.Scanner{}, + &bitbucketapppassword.Scanner{}, } } diff --git a/pkg/pb/detectorspb/detectors.pb.go b/pkg/pb/detectorspb/detectors.pb.go index 8fc5af56165d..29dcf158c5a9 100644 --- a/pkg/pb/detectorspb/detectors.pb.go +++ b/pkg/pb/detectorspb/detectors.pb.go @@ -1133,6 +1133,7 @@ const ( DetectorType_DeepSeek DetectorType = 1026 DetectorType_StripePaymentIntent DetectorType = 1027 DetectorType_LangSmith DetectorType = 1028 + DetectorType_BitbucketAppPassword DetectorType = 1029 ) // Enum value maps for DetectorType. @@ -2163,6 +2164,7 @@ var ( 1026: "DeepSeek", 1027: "StripePaymentIntent", 1028: "LangSmith", + 1029: "BitbucketAppPassword", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3190,6 +3192,7 @@ var ( "DeepSeek": 1026, "StripePaymentIntent": 1027, "LangSmith": 1028, + "BitbucketAppPassword": 1029, } ) diff --git a/proto/detectors.proto b/proto/detectors.proto index 4b3bc4e0b6e6..695742621620 100644 --- a/proto/detectors.proto +++ b/proto/detectors.proto @@ -1038,6 +1038,7 @@ enum DetectorType { DeepSeek = 1026; StripePaymentIntent = 1027; LangSmith = 1028; + BitbucketAppPassword = 1029; } message Result {