diff --git a/pkg/detectors/boxoauth/boxoauth.go b/pkg/detectors/boxoauth/boxoauth.go index 75e7e51d3..5363e19e9 100644 --- a/pkg/detectors/boxoauth/boxoauth.go +++ b/pkg/detectors/boxoauth/boxoauth.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" regexp "github.com/wasilibs/go-re2" @@ -26,8 +27,19 @@ var ( // Make sure that your group is surrounded in boundary characters such as below to reduce false positives. clientIdPat = regexp.MustCompile(detectors.PrefixRegex([]string{"id"}) + `\b([a-zA-Z0-9]{32})\b`) clientSecretPat = regexp.MustCompile(detectors.PrefixRegex([]string{"secret"}) + `\b([a-zA-Z0-9]{32})\b`) + // Box enterprise and user IDs are numeric strings. + subjectIdPat = regexp.MustCompile(detectors.PrefixRegex([]string{ + "enterprise", "enterprise_id", "user", "user_id", "subject", "box_subject", + }) + `\b([0-9]{6,20})\b`) ) +func (s Scanner) getClient() *http.Client { + if s.client != nil { + return s.client + } + return defaultClient +} + // Keywords are used for efficiently pre-filtering chunks. // Use identifiers in the secret preferably, or the provider name. func (s Scanner) Keywords() []string { @@ -52,34 +64,45 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result uniqueSecretMatches[match[1]] = struct{}{} } + var subjectIds []string + uniqueSubjectIdMatches := make(map[string]struct{}) + for _, match := range subjectIdPat.FindAllStringSubmatch(dataStr, -1) { + if _, seen := uniqueSubjectIdMatches[match[1]]; !seen { + uniqueSubjectIdMatches[match[1]] = struct{}{} + subjectIds = append(subjectIds, match[1]) + } + } + slices.Sort(subjectIds) + for resIdMatch := range uniqueIdMatches { for resSecretMatch := range uniqueSecretMatches { - - // ignore if the id and secret are the same if resIdMatch == resSecretMatch { continue } s1 := detectors.Result{ - DetectorType: detector_typepb.DetectorType_BoxOauth, + DetectorType: s.Type(), Raw: []byte(resIdMatch), + RawV2: []byte(resIdMatch + resSecretMatch), SecretParts: map[string]string{ - "id": resIdMatch, - "secret": resSecretMatch, + "client_id": resIdMatch, + "client_secret": resSecretMatch, }, - RawV2: []byte(resIdMatch + resSecretMatch), } if verify { - client := s.client - if client == nil { - client = defaultClient - } - - isVerified, extraData, verificationErr := verifyMatch(ctx, client, resIdMatch, resSecretMatch) + isVerified, verificationErr := verifyMatch(ctx, s.getClient(), resIdMatch, resSecretMatch) s1.Verified = isVerified - s1.ExtraData = extraData s1.SetVerificationError(verificationErr, resIdMatch) + + if isVerified { + for _, subjectId := range subjectIds { + if verifySubjectID(ctx, s.getClient(), resIdMatch, resSecretMatch, subjectId) { + s1.SecretParts["subject_id"] = subjectId + break + } + } + } } results = append(results, s1) @@ -94,19 +117,19 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result return } -func verifyMatch(ctx context.Context, client *http.Client, id string, secret string) (bool, map[string]string, error) { +func verifyMatch(ctx context.Context, client *http.Client, id string, secret string) (bool, error) { url := "https://api.box.com/oauth2/token" payload := strings.NewReader("grant_type=client_credentials&client_id=" + id + "&client_secret=" + secret) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, payload) if err != nil { - return false, nil, err + return false, err } req.Header = http.Header{"content-type": []string{"application/x-www-form-urlencoded"}} res, err := client.Do(req) if err != nil { - return false, nil, err + return false, err } defer func() { _, _ = io.Copy(io.Discard, res.Body) @@ -125,22 +148,55 @@ func verifyMatch(ctx context.Context, client *http.Client, id string, secret str { bodyBytes, err := io.ReadAll(res.Body) if err != nil { - return false, nil, err + return false, err } body := string(bodyBytes) if strings.Contains(body, "unauthorized_client") { - return true, nil, nil + return true, nil } else if strings.Contains(body, "invalid_client") { - return false, nil, nil + return false, nil } else { - return false, nil, fmt.Errorf("response body missing expected keyword") + return false, fmt.Errorf("response body missing expected keyword") } } default: - return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) } } +// verifySubjectID checks whether a subject ID is valid for the given client +// credentials by attempting the Box CCG (Client Credentials Grant) auth flow. +// It mirrors the analyzer's Authenticate logic: try enterprise subject type +// first, then fall back to user subject type. Returns true on the first +// successful authentication (HTTP 200). +func verifySubjectID(ctx context.Context, client *http.Client, clientID, clientSecret, subjectID string) bool { + for _, subjectType := range []string{"enterprise", "user"} { + payload := fmt.Sprintf( + "grant_type=client_credentials&client_id=%s&client_secret=%s&box_subject_type=%s&box_subject_id=%s", + clientID, clientSecret, subjectType, subjectID, + ) + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, "https://api.box.com/oauth2/token", strings.NewReader(payload)) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := client.Do(req) + if err != nil { + continue + } + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + + if res.StatusCode == http.StatusOK { + return true + } + } + return false +} + func (s Scanner) Type() detector_typepb.DetectorType { return detector_typepb.DetectorType_BoxOauth } diff --git a/pkg/detectors/boxoauth/boxoauth_integration_test.go b/pkg/detectors/boxoauth/boxoauth_integration_test.go index 61a88b8e1..a140d3408 100644 --- a/pkg/detectors/boxoauth/boxoauth_integration_test.go +++ b/pkg/detectors/boxoauth/boxoauth_integration_test.go @@ -54,6 +54,10 @@ func TestBoxOauth_FromChunk(t *testing.T) { Verified: true, Raw: []byte(id), RawV2: []byte(id + secret), + SecretParts: map[string]string{ + "client_id": id, + "client_secret": secret, + }, }, }, wantErr: false, @@ -72,6 +76,10 @@ func TestBoxOauth_FromChunk(t *testing.T) { Verified: false, Raw: []byte(id), RawV2: []byte(id + invalidSecret), + SecretParts: map[string]string{ + "client_id": id, + "client_secret": invalidSecret, + }, }, }, wantErr: false, diff --git a/pkg/detectors/boxoauth/boxoauth_test.go b/pkg/detectors/boxoauth/boxoauth_test.go index 473c609e3..9573f98c3 100644 --- a/pkg/detectors/boxoauth/boxoauth_test.go +++ b/pkg/detectors/boxoauth/boxoauth_test.go @@ -3,10 +3,12 @@ package boxoauth import ( "context" "fmt" + "net/http" "testing" - "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/h2non/gock.v1" "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" @@ -17,6 +19,8 @@ var ( clientId = common.GenerateRandomPassword(true, true, true, false, 32) clientSecret = common.GenerateRandomPassword(true, true, true, false, 32) invalidClientSecret = common.GenerateRandomPassword(true, true, true, true, 32) + subjectId = "1234567890" + subjectId2 = "9876543210" ) func TestBoxOauth_Pattern(t *testing.T) { @@ -24,30 +28,58 @@ func TestBoxOauth_Pattern(t *testing.T) { ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) tests := []struct { - name string - input string - want []string + name string + input string + wantCount int + wantRawV2 string + wantMatched bool }{ { - name: "valid pattern", - input: fmt.Sprintf("box id = '%s' box secret = '%s'", clientId, clientSecret), - want: []string{clientId + clientSecret}, + name: "valid pattern - no subject id", + input: fmt.Sprintf("box id = '%s' box secret = '%s'", clientId, clientSecret), + wantCount: 1, + wantRawV2: clientId + clientSecret, + wantMatched: true, }, { - name: "invalid pattern", - input: fmt.Sprintf("box id = '%s' box secret = '%s'", clientId, invalidClientSecret), - want: nil, + name: "valid pattern - with one subject id", + input: fmt.Sprintf("box id = '%s' box secret = '%s' enterprise = '%s'", clientId, clientSecret, subjectId), + wantCount: 1, + wantRawV2: clientId + clientSecret, + wantMatched: true, }, { - name: "invalid pattern", - input: fmt.Sprintf("box = '%s|%s'", clientId, invalidClientSecret), - want: nil, + name: "valid pattern - with multiple subject ids", + input: fmt.Sprintf("box id = '%s' box secret = '%s' enterprise = '%s' subject = '%s'", clientId, clientSecret, subjectId, subjectId2), + wantCount: 1, + wantRawV2: clientId + clientSecret, + wantMatched: true, + }, + { + name: "invalid pattern - secret contains special characters", + input: fmt.Sprintf("box id = '%s' box secret = '%s'", clientId, invalidClientSecret), + wantCount: 0, + wantMatched: false, + }, + { + name: "invalid pattern - no keyword separation", + input: fmt.Sprintf("box = '%s|%s'", clientId, invalidClientSecret), + wantCount: 0, + wantMatched: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + + if !test.wantMatched { + results, err := d.FromData(context.Background(), false, []byte(test.input)) + require.NoError(t, err) + assert.Empty(t, results) + return + } + if len(matchedDetectors) == 0 { t.Errorf("test %q failed: expected keywords %v to be found in the input", test.name, d.Keywords()) return @@ -56,28 +88,143 @@ func TestBoxOauth_Pattern(t *testing.T) { results, err := d.FromData(context.Background(), false, []byte(test.input)) require.NoError(t, err) - if len(results) != len(test.want) { - t.Errorf("mismatch in result count: expected %d, got %d", len(test.want), len(results)) - return - } + require.Lenf(t, results, test.wantCount, + "expected %d results, got %d", test.wantCount, len(results)) - actual := make(map[string]struct{}, len(results)) - for _, r := range results { - if len(r.RawV2) > 0 { - actual[string(r.RawV2)] = struct{}{} - } else { - actual[string(r.Raw)] = struct{}{} - } - } - - expected := make(map[string]struct{}, len(test.want)) - for _, v := range test.want { - expected[v] = struct{}{} - } - - if diff := cmp.Diff(expected, actual); diff != "" { - t.Errorf("%s diff: (-want +got)\n%s", test.name, diff) + for i, r := range results { + assert.Equalf(t, test.wantRawV2, string(r.RawV2), + "result[%d] RawV2 mismatch", i) } }) } } + +func TestBoxOauth_AnalysisInfo_VerifiedWithValidSubjectId(t *testing.T) { + client := common.SaneHttpClient() + d := Scanner{client: client} + + defer gock.Off() + defer gock.RestoreClient(client) + gock.InterceptClient(client) + + // Mock 1: verifyMatch → valid pair (400 with "unauthorized_client") + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"unauthorized_client","error_description":"The grant type is unauthorized for this client_id"}`) + + // Mock 2: verifySubjectID enterprise attempt → success (200) + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusOK). + BodyString(`{"access_token":"test_token","expires_in":4169,"token_type":"bearer"}`) + + input := fmt.Sprintf("box id = '%s' box secret = '%s' enterprise = '%s'", clientId, clientSecret, subjectId) + + results, err := d.FromData(context.Background(), true, []byte(input)) + require.NoError(t, err) + require.Len(t, results, 1) + + r := results[0] + assert.True(t, r.Verified) + require.NotNil(t, r.SecretParts) + assert.Equal(t, clientId, r.SecretParts["client_id"]) + assert.Equal(t, clientSecret, r.SecretParts["client_secret"]) + assert.Equal(t, subjectId, r.SecretParts["subject_id"]) +} + +func TestBoxOauth_AnalysisInfo_VerifiedNoSubjectId(t *testing.T) { + client := common.SaneHttpClient() + d := Scanner{client: client} + + defer gock.Off() + defer gock.RestoreClient(client) + gock.InterceptClient(client) + + // Mock: verifyMatch → valid pair + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"unauthorized_client","error_description":"The grant type is unauthorized for this client_id"}`) + + input := fmt.Sprintf("box id = '%s' box secret = '%s'", clientId, clientSecret) + + results, err := d.FromData(context.Background(), true, []byte(input)) + require.NoError(t, err) + require.Len(t, results, 1) + + r := results[0] + assert.True(t, r.Verified) + require.NotNil(t, r.SecretParts) + assert.Equal(t, clientId, r.SecretParts["client_id"]) + assert.Equal(t, clientSecret, r.SecretParts["client_secret"]) + assert.Empty(t, r.SecretParts["subject_id"]) +} + +func TestBoxOauth_AnalysisInfo_VerifiedInvalidSubjectId(t *testing.T) { + client := common.SaneHttpClient() + d := Scanner{client: client} + + defer gock.Off() + defer gock.RestoreClient(client) + gock.InterceptClient(client) + + // Mock 1: verifyMatch → valid pair + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"unauthorized_client","error_description":"The grant type is unauthorized for this client_id"}`) + + // Mock 2: verifySubjectID enterprise attempt → fail (400) + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"invalid_grant","error_description":"Cannot obtain token based on the enterprise configuration for your app"}`) + + // Mock 3: verifySubjectID user attempt → fail (400) + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"invalid_grant","error_description":"Cannot obtain token based on the enterprise configuration for your app"}`) + + input := fmt.Sprintf("box id = '%s' box secret = '%s' enterprise = '%s'", clientId, clientSecret, subjectId) + + results, err := d.FromData(context.Background(), true, []byte(input)) + require.NoError(t, err) + require.Len(t, results, 1) + + r := results[0] + assert.True(t, r.Verified) + require.NotNil(t, r.SecretParts) + assert.Equal(t, clientId, r.SecretParts["client_id"]) + assert.Equal(t, clientSecret, r.SecretParts["client_secret"]) + assert.Empty(t, r.SecretParts["subject_id"]) +} + +func TestBoxOauth_AnalysisInfo_UnverifiedWithSubjectId(t *testing.T) { + client := common.SaneHttpClient() + d := Scanner{client: client} + + defer gock.Off() + defer gock.RestoreClient(client) + gock.InterceptClient(client) + + // Mock: verifyMatch → invalid pair (400 with "invalid_client") + gock.New("https://api.box.com"). + Post("/oauth2/token"). + Reply(http.StatusBadRequest). + BodyString(`{"error":"invalid_client","error_description":"The client_id is invalid"}`) + + input := fmt.Sprintf("box id = '%s' box secret = '%s' enterprise = '%s'", clientId, clientSecret, subjectId) + + results, err := d.FromData(context.Background(), true, []byte(input)) + require.NoError(t, err) + require.Len(t, results, 1) + + r := results[0] + assert.False(t, r.Verified) + require.NotNil(t, r.SecretParts) + assert.Equal(t, clientId, r.SecretParts["client_id"]) + assert.Equal(t, clientSecret, r.SecretParts["client_secret"]) + assert.Empty(t, r.SecretParts["subject_id"]) +}