Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
131 changes: 131 additions & 0 deletions pkg/detectors/bitbucketapppassword/bitbucketapppassword.go
Original file line number Diff line number Diff line change
@@ -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"
Copy link

Choose a reason for hiding this comment

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

Why is this declared here and not used directly like the rest of detectors


var (
defaultClient = common.SaneHttpClient()
Copy link

Choose a reason for hiding this comment

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

Why is this in a different block?

)

var (
// credentialPatterns uses named capture groups (?P<name>...) 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<username>[A-Za-z0-9-_]{1,30}):(?P<password>ATBB[A-Za-z0-9_=.-]+)\b`),
// Catches 'https://username:[email protected]' pattern
regexp.MustCompile(`https://(?P<username>[A-Za-z0-9-_]{1,30}):(?P<password>ATBB[A-Za-z0-9_=.-]+)@bitbucket\.org`),
// Catches '("username", "password")' pattern, used for HTTP Basic Auth
regexp.MustCompile(`"(?P<username>[A-Za-z0-9-_]{1,30})",\s*"(?P<password>ATBB[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
Copy link

Choose a reason for hiding this comment

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

Its better we stick to the format for this as well

for username, password := range uniqueCredentials {
result := detectors.Result{
Copy link

Choose a reason for hiding this comment

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

Can we please use the standard s1 variable name like the rest of detectors

DetectorType: detectorspb.DetectorType_BitbucketAppPassword,
Raw: fmt.Appendf(nil, "%s:%s", username, password),
Copy link

Choose a reason for hiding this comment

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

Again please use the standard format

}
if verify {
client := s.client
if client == nil {
client = defaultClient
}
var vErr error
result.Verified, vErr = verifyCredential(ctx, client, username, password)
Copy link

Choose a reason for hiding this comment

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

Its better we to stick to the format, why are we declaring vErr here and they way verified is assigned

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([]byte(fmt.Sprintf("%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.
Copy link

Choose a reason for hiding this comment

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

Does it return any distinct message in response that we can check for?

return true, nil
case http.StatusUnauthorized:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
}
Original file line number Diff line number Diff line change
@@ -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:%[email protected]", 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:%[email protected]", 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)
}
}
})
}
}
71 changes: 71 additions & 0 deletions pkg/detectors/bitbucketapppassword/bitbucketapppassword_test.go
Original file line number Diff line number Diff line change
@@ -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:[email protected]`,
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)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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"
Expand Down Expand Up @@ -1712,6 +1713,7 @@ func buildDetectorList() []detectors.Detector {
&zonkafeedback.Scanner{},
&zulipchat.Scanner{},
&stripepaymentintent.Scanner{},
&bitbucketapppassword.Scanner{},
}
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/pb/detectorspb/detectors.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/detectors.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,7 @@ enum DetectorType {
DeepSeek = 1026;
StripePaymentIntent = 1027;
LangSmith = 1028;
BitbucketAppPassword = 1029;
}

message Result {
Expand Down