-
Notifications
You must be signed in to change notification settings - Fork 2k
Feat: bitbucket app #4214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: bitbucket app #4214
Changes from 14 commits
d46d629
0d55489
6eb8739
575b8ba
bf9d7fe
39b30b0
ee5b6a3
f31ad18
340b046
8635c6e
4458fad
3f8f4db
136bd87
223602e
ef35faa
12a658a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" | ||
|
||
var ( | ||
defaultClient = common.SaneHttpClient() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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{ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we please use the standard |
||
DetectorType: detectorspb.DetectorType_BitbucketAppPassword, | ||
Raw: fmt.Appendf(nil, "%s:%s", username, password), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Its better we to stick to the format, why are we declaring |
||
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))) | ||
x-stp marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
} | ||
}) | ||
} | ||
} |
x-stp marked this conversation as resolved.
Show resolved
Hide resolved
|
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) | ||
} | ||
}) | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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