Box Detector: Extract Subject ID for Analyzer Integration (#4761)
* extract subject id in boxoauth detector * update the detector design for subject id handling * verify subject ID via CCG auth before populating AnalysisInfo Instead of blindly pairing every found subject ID with verified credentials, use the Box CCG token endpoint to verify which subject ID actually works. Emit one result per credential pair with only the verified subject ID in AnalysisInfo. Add gock-based tests for all AnalysisInfo scenarios. * replace analysisinfo with secretparts * [INS-379] Populate SecretParts unconditionally in BoxOauth detector Always set client_id and client_secret in SecretParts regardless of verification status. subject_id is added only when a valid triplet is confirmed via verifySubjectID. Previously SecretParts was only populated for a fully verified triple, leaving unverified results with no credential data stored. * address bugbot comment; Non-deterministic map iteration for subject ID selection --------- Co-authored-by: Charlie Gunyon <[email protected]>
This commit is contained in:
co-authored by
Charlie Gunyon
parent
ef9a56c33b
commit
ada12e0cf2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user