Skip to content

Commit 50b0a39

Browse files
x-stpbrandonjyanamanfcpkashifkhan0771
authored
Feat: bitbucket app (#4214)
* add bitbucket app password scanner * clean up regex and username pattern logic * feat: re-intro bitbucket in engine. * feat: add BitbucketAppPassword detector type to proto files * Update pkg/detectors/bitbucketapppassword/bitbucketapppassword.go Co-authored-by: Amaan Ullah <[email protected]> * refactor(bitbucket): tests (+patterns) ; code cleanup - add switch{} block + credentialPattern[] for readability - + tidy nested loops - reflect codebase conventions in verification scope - hard drain io.Discard, body.Close on res call in verify func - intro the patterns test as it was not introoed - standardize the integration test based off others living in repo * Update pkg/detectors/bitbucketapppassword/bitbucketapppassword.go Co-authored-by: Amaan Ullah <[email protected]> --------- Co-authored-by: Brandon Yan <[email protected]> Co-authored-by: x-stp <[email protected]> Co-authored-by: Amaan Ullah <[email protected]> Co-authored-by: Kashif Khan <[email protected]> Co-authored-by: Amaan Ullah <[email protected]>
1 parent 60dd91e commit 50b0a39

File tree

6 files changed

+311
-0
lines changed

6 files changed

+311
-0
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package bitbucketapppassword
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"regexp"
10+
11+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
14+
)
15+
16+
// Scanner is a stateless struct that implements the detector interface.
17+
type Scanner struct {
18+
client *http.Client
19+
}
20+
21+
// Ensure the Scanner satisfies the interface at compile time.
22+
var _ detectors.Detector = (*Scanner)(nil)
23+
24+
// Keywords are used for efficiently pre-filtering chunks.
25+
func (s Scanner) Keywords() []string {
26+
return []string{"bitbucket", "ATBB"}
27+
}
28+
29+
func (s Scanner) Type() detectorspb.DetectorType {
30+
return detectorspb.DetectorType_BitbucketAppPassword
31+
}
32+
33+
func (s Scanner) Description() string {
34+
return "Bitbucket is a Git repository hosting service by Atlassian. Bitbucket App Passwords are used to authenticate to the Bitbucket API."
35+
}
36+
37+
const bitbucketAPIUserURL = "https://api.bitbucket.org/2.0/user"
38+
39+
var (
40+
defaultClient = common.SaneHttpClient()
41+
)
42+
43+
var (
44+
// credentialPatterns uses named capture groups (?P<name>...) for readability and robustness.
45+
credentialPatterns = []*regexp.Regexp{
46+
// Explicitly define the boundary as (start of string) or (a non-username character).
47+
regexp.MustCompile(`(?:^|[^A-Za-z0-9-_])(?P<username>[A-Za-z0-9-_]{1,30}):(?P<password>ATBB[A-Za-z0-9_=.-]+)\b`),
48+
// Catches 'https://username:[email protected]' pattern
49+
regexp.MustCompile(`https://(?P<username>[A-Za-z0-9-_]{1,30}):(?P<password>ATBB[A-Za-z0-9_=.-]+)@bitbucket\.org`),
50+
// Catches '("username", "password")' pattern, used for HTTP Basic Auth
51+
regexp.MustCompile(`"(?P<username>[A-Za-z0-9-_]{1,30})",\s*"(?P<password>ATBB[A-Za-z0-9_=.-]+)"`),
52+
}
53+
)
54+
55+
// FromData will find and optionally verify Bitbucket App Password secrets in a given set of bytes.
56+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) ([]detectors.Result, error) {
57+
dataStr := string(data)
58+
59+
uniqueCredentials := make(map[string]string)
60+
61+
for _, pattern := range credentialPatterns {
62+
for _, match := range pattern.FindAllStringSubmatch(dataStr, -1) {
63+
// Extract credentials using named capture groups for readability.
64+
namedMatches := make(map[string]string)
65+
for i, name := range pattern.SubexpNames() {
66+
if i != 0 && name != "" {
67+
namedMatches[name] = match[i]
68+
}
69+
}
70+
71+
username := namedMatches["username"]
72+
password := namedMatches["password"]
73+
74+
if username != "" && password != "" {
75+
uniqueCredentials[username] = password
76+
}
77+
}
78+
}
79+
80+
var results []detectors.Result
81+
for username, password := range uniqueCredentials {
82+
result := detectors.Result{
83+
DetectorType: detectorspb.DetectorType_BitbucketAppPassword,
84+
Raw: fmt.Appendf(nil, "%s:%s", username, password),
85+
}
86+
if verify {
87+
client := s.client
88+
if client == nil {
89+
client = defaultClient
90+
}
91+
var vErr error
92+
result.Verified, vErr = verifyCredential(ctx, client, username, password)
93+
if vErr != nil {
94+
result.SetVerificationError(vErr, username, password)
95+
}
96+
}
97+
results = append(results, result)
98+
}
99+
100+
return results, nil
101+
}
102+
103+
// verifyCredential checks if a given username and app password are valid by making a request to the Bitbucket API.
104+
func verifyCredential(ctx context.Context, client *http.Client, username, password string) (bool, error) {
105+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bitbucketAPIUserURL, nil)
106+
if err != nil {
107+
return false, err
108+
}
109+
req.Header.Add("Accept", "application/json")
110+
auth := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", username, password))
111+
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth))
112+
113+
res, err := client.Do(req)
114+
if err != nil {
115+
return false, err
116+
}
117+
defer func() {
118+
_, _ = io.Copy(io.Discard, res.Body)
119+
_ = res.Body.Close()
120+
}()
121+
122+
switch res.StatusCode {
123+
case http.StatusOK, http.StatusForbidden:
124+
// A 403 can indicate a valid credential with insufficient scope, which is still a finding.
125+
return true, nil
126+
case http.StatusUnauthorized:
127+
return false, nil
128+
default:
129+
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
130+
}
131+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package bitbucketapppassword
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
16+
)
17+
18+
func TestBitbucketAppPassword_FromData_Integration(t *testing.T) {
19+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
20+
defer cancel()
21+
22+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors2")
23+
if err != nil {
24+
t.Fatalf("could not get test secrets from GCP: %s", err)
25+
}
26+
27+
username := testSecrets.MustGetField("USERNAME")
28+
validPassword := testSecrets.MustGetField("BITBUCKETAPPPASSWORD")
29+
invalidPassword := "ATBB123abcDEF456ghiJKL789mnoPQR" // An invalid but correctly formatted password
30+
31+
tests := []struct {
32+
name string
33+
input string
34+
want []detectors.Result
35+
wantErr bool
36+
}{
37+
{
38+
name: "valid credential",
39+
input: fmt.Sprintf("https://%s:%[email protected]", username, validPassword),
40+
want: []detectors.Result{
41+
{
42+
DetectorType: detectorspb.DetectorType_BitbucketAppPassword,
43+
Verified: true,
44+
Raw: []byte(fmt.Sprintf("%s:%s", username, validPassword)),
45+
},
46+
},
47+
},
48+
{
49+
name: "invalid credential",
50+
input: fmt.Sprintf("https://%s:%[email protected]", username, invalidPassword),
51+
want: []detectors.Result{
52+
{
53+
DetectorType: detectorspb.DetectorType_BitbucketAppPassword,
54+
Verified: false,
55+
Raw: []byte(fmt.Sprintf("%s:%s", username, invalidPassword)),
56+
},
57+
},
58+
},
59+
{
60+
name: "no credential found",
61+
input: "this string has no credentials",
62+
want: nil,
63+
},
64+
}
65+
66+
for _, tc := range tests {
67+
t.Run(tc.name, func(t *testing.T) {
68+
s := &Scanner{}
69+
got, err := s.FromData(ctx, true, []byte(tc.input))
70+
71+
if (err != nil) != tc.wantErr {
72+
t.Fatalf("FromData() error = %v, wantErr %v", err, tc.wantErr)
73+
}
74+
// Normalizing results for comparison by removing fields that are not relevant for the test
75+
for i := range got {
76+
if got[i].VerificationError() != nil {
77+
t.Logf("verification error: %s", got[i].VerificationError())
78+
}
79+
}
80+
81+
if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(x, y detectors.Result) bool {
82+
return x.Verified == y.Verified && string(x.Raw) == string(y.Raw) && x.DetectorType == y.DetectorType
83+
})); diff != "" {
84+
t.Errorf("FromData() mismatch (-want +got):\n%s", diff)
85+
}
86+
})
87+
}
88+
}
89+
90+
func BenchmarkFromData(benchmark *testing.B) {
91+
ctx := context.Background()
92+
s := &Scanner{}
93+
for name, data := range detectors.MustGetBenchmarkData() {
94+
benchmark.Run(name, func(b *testing.B) {
95+
for n := 0; n < b.N; n++ {
96+
_, err := s.FromData(ctx, false, data)
97+
if err != nil {
98+
b.Fatal(err)
99+
}
100+
}
101+
})
102+
}
103+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package bitbucketapppassword
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
)
9+
10+
func TestBitbucketAppPassword_FromData(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
want []string
15+
}{
16+
{
17+
name: "valid pair",
18+
input: "myuser:ATBB123abcDEF456ghiJKL789mnoPQR",
19+
want: []string{"myuser:ATBB123abcDEF456ghiJKL789mnoPQR"},
20+
},
21+
{
22+
name: "valid app password by itself (should not be found)",
23+
input: "ATBB123abcDEF456ghiJKL789mnoPQR",
24+
want: []string{},
25+
},
26+
{
27+
name: "pair with invalid username",
28+
input: "my-very-long-username-that-is-over-thirty-characters:ATBB123abcDEF456ghiJKL789mnoPQR",
29+
want: []string{},
30+
},
31+
{
32+
name: "url pattern",
33+
input: `https://anotheruser:[email protected]`,
34+
want: []string{"anotheruser:ATBB123abcDEF456ghiJKL789mnoPQR"},
35+
},
36+
{
37+
name: "http basic auth pattern",
38+
input: `("basicauthuser", "ATBB123abcDEF456ghiJKL789mnoPQR")`,
39+
want: []string{"basicauthuser:ATBB123abcDEF456ghiJKL789mnoPQR"},
40+
},
41+
{
42+
name: "multiple matches",
43+
input: `user1:ATBB123abcDEF456ghiJKL789mnoPQR and then also user2:ATBBzyxwvUT987srqPON654mlkJIH`,
44+
want: []string{"user1:ATBB123abcDEF456ghiJKL789mnoPQR", "user2:ATBBzyxwvUT987srqPON654mlkJIH"},
45+
},
46+
}
47+
48+
for _, tc := range tests {
49+
t.Run(tc.name, func(t *testing.T) {
50+
d := &Scanner{}
51+
results, err := d.FromData(context.Background(), false, []byte(tc.input))
52+
if err != nil {
53+
t.Fatalf("FromData() error = %v", err)
54+
}
55+
56+
got := make(map[string]struct{})
57+
for _, r := range results {
58+
got[string(r.Raw)] = struct{}{}
59+
}
60+
61+
wantSet := make(map[string]struct{})
62+
for _, w := range tc.want {
63+
wantSet[w] = struct{}{}
64+
}
65+
66+
if diff := cmp.Diff(wantSet, got); diff != "" {
67+
t.Errorf("FromData() mismatch (-want +got):\n%s", diff)
68+
}
69+
})
70+
}
71+
}

pkg/engine/defaults/defaults.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import (
9494
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/billomat"
9595
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bingsubscriptionkey"
9696
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitbar"
97+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitbucketapppassword"
9798
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitcoinaverage"
9899
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitfinex"
99100
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bitlyaccesstoken"
@@ -1714,6 +1715,7 @@ func buildDetectorList() []detectors.Detector {
17141715
&zonkafeedback.Scanner{},
17151716
&zulipchat.Scanner{},
17161717
&stripepaymentintent.Scanner{},
1718+
&bitbucketapppassword.Scanner{},
17171719
}
17181720
}
17191721

pkg/pb/detectorspb/detectors.pb.go

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/detectors.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,7 @@ enum DetectorType {
10381038
DeepSeek = 1026;
10391039
StripePaymentIntent = 1027;
10401040
LangSmith = 1028;
1041+
BitbucketAppPassword = 1029;
10411042
}
10421043

10431044
message Result {

0 commit comments

Comments
 (0)