Skip to content

Commit 6ecce5f

Browse files
committed
refactor(bitbucket): rename test filename and introduce naming conv
- .._test -> .._integration_test.go refactor(bitbucket): code cleanup + tests - 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 _test.go (patterns) - chore integration tests based off others living in repo
1 parent f31ad18 commit 6ecce5f

File tree

3 files changed

+227
-143
lines changed

3 files changed

+227
-143
lines changed

pkg/detectors/bitbucketapppassword/bitbucketapppassword.go

Lines changed: 89 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,99 +4,128 @@ import (
44
"context"
55
"encoding/base64"
66
"fmt"
7+
"io"
78
"net/http"
89
"regexp"
9-
"strings"
1010

1111
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
1212
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
1313
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
1414
)
1515

16-
type Scanner struct{}
16+
// Scanner is a stateless struct that implements the detector interface.
17+
type Scanner struct {
18+
client *http.Client
19+
}
1720

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

21-
var (
22-
client = common.SaneHttpClient()
24+
// Keywords are used for efficiently pre-filtering chunks.
25+
func (s Scanner) Keywords() []string {
26+
return []string{"bitbucket", "ATBB"}
27+
}
2328

24-
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
25-
// The following patterns cover the methods of authentication found here:
26-
// https://support.atlassian.com/bitbucket-cloud/docs/using-app-passwords/, as well as for other general cases.
29+
func (s Scanner) Type() detectorspb.DetectorType {
30+
return detectorspb.DetectorType_BitbucketAppPassword
31+
}
2732

28-
// Covers 'username:appPassword' pattern
29-
credentialPairPattern = regexp.MustCompile(`\b([A-Za-z0-9-_]{1,30}):ATBB[A-Za-z0-9_=.-]+[A-Z0-9]{8}\b`)
30-
// Covers assignment of username to variable
31-
usernameAssignmentPattern = regexp.MustCompile(`(?im)(?:user|usr)\S{0,40}?[:=\s]{1,3}[ '"=]?([a-zA-Z0-9-_]{1,30})\b`)
32-
// Covers 'https://[email protected]' pattern
33-
usernameUrlPattern = regexp.MustCompile(`https://([a-zA-Z0-9-_]{1,30})@bitbucket.org`)
34-
// Covers '("username", "password")' pattern, used for HTTP Basic Auth
35-
httpBasicAuthPattern = regexp.MustCompile(`"([a-zA-Z0-9-_]{1,30})",(?: )?"ATBB[A-Za-z0-9_=.-]+[A-Z0-9]{8}"`)
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+
}
3636

37-
usernamePatterns = []*regexp.Regexp{usernamePat1, usernamePat2, usernamePat3, usernamePat4}
37+
const bitbucketAPIUserURL = "https://api.bitbucket.org/2.0/user"
3838

39-
appPasswordPat = regexp.MustCompile(`\bATBB[A-Za-z0-9_=.-]+[A-Z0-9]{8}\b`)
39+
var (
40+
defaultClient = common.SaneHttpClient()
4041
)
4142

42-
// Keywords are used for efficiently pre-filtering chunks.
43-
// Use identifiers in the secret preferably, or the provider name.
44-
func (s Scanner) Keywords() []string {
45-
return []string{"bitbucketapppassword", "ATBB"}
46-
}
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+
)
4754

4855
// FromData will find and optionally verify Bitbucket App Password secrets in a given set of bytes.
49-
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
56+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) ([]detectors.Result, error) {
5057
dataStr := string(data)
5158

52-
var usernameMatches [][]string
53-
for _, pattern := range usernamePatterns {
54-
usernameMatches = append(usernameMatches, pattern.FindAllStringSubmatch(dataStr, -1)...)
55-
}
56-
appPasswordMatches := appPasswordPat.FindAllString(dataStr, -1)
59+
uniqueCredentials := make(map[string]string)
5760

58-
for _, usernameMatch := range usernameMatches {
59-
if len(usernameMatch) != 2 {
60-
continue
61-
}
62-
resUsernameMatch := strings.TrimSpace(usernameMatch[1])
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+
}
6370

64-
for _, resAppPasswordMatch := range appPasswordMatches {
71+
username := namedMatches["username"]
72+
password := namedMatches["password"]
6573

66-
s1 := detectors.Result{
67-
DetectorType: detectorspb.DetectorType_BitbucketAppPassword,
68-
Raw: []byte(fmt.Sprintf(`%s: %s`, resUsernameMatch, resAppPasswordMatch)),
74+
if username != "" && password != "" {
75+
uniqueCredentials[username] = password
6976
}
77+
}
78+
}
7079

71-
if verify {
72-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.bitbucket.org/2.0/user", nil)
73-
if err != nil {
74-
continue
75-
}
76-
req.Header.Add("Accept", "application/json")
77-
data := fmt.Sprintf("%s:%s", resUsernameMatch, resAppPasswordMatch)
78-
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(data))))
79-
res, err := client.Do(req)
80-
if err == nil {
81-
defer res.Body.Close()
82-
// Status 403 FORBIDDEN indicates a valid secret without valid scope
83-
if res.StatusCode >= 200 && res.StatusCode < 300 || res.StatusCode == 403 {
84-
s1.Verified = true
85-
}
86-
}
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)
8795
}
88-
89-
results = append(results, s1)
9096
}
97+
results = append(results, result)
9198
}
9299

93100
return results, nil
94101
}
95102

96-
func (s Scanner) Type() detectorspb.DetectorType {
97-
return detectorspb.DetectorType_BitbucketAppPassword
98-
}
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([]byte(fmt.Sprintf("%s:%s", username, password)))
111+
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", auth))
99112

100-
func (s Scanner) Description() string {
101-
return "Bitbucket is a Git repository hosting service by Atlassian. Bitbucket App Passwords are used to authenticate to the Bitbucket API."
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+
}
102131
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
}

0 commit comments

Comments
 (0)