* 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]>
203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
package boxoauth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
regexp "github.com/wasilibs/go-re2"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
|
|
)
|
|
|
|
type Scanner struct {
|
|
client *http.Client
|
|
}
|
|
|
|
// Ensure the Scanner satisfies the interface at compile time.
|
|
var _ detectors.Detector = (*Scanner)(nil)
|
|
|
|
var (
|
|
defaultClient = common.SaneHttpClient()
|
|
// 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 {
|
|
return []string{"box"}
|
|
}
|
|
|
|
func (s Scanner) Description() string {
|
|
return "Box is a service offering various service for secure collaboration, content management, and workflow. Box Oauth credentials can be used to access and interact with this data."
|
|
}
|
|
|
|
// FromData will find and optionally verify Box secrets in a given set of bytes.
|
|
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
|
|
dataStr := string(data)
|
|
|
|
uniqueIdMatches := make(map[string]struct{})
|
|
for _, match := range clientIdPat.FindAllStringSubmatch(dataStr, -1) {
|
|
uniqueIdMatches[match[1]] = struct{}{}
|
|
}
|
|
|
|
uniqueSecretMatches := make(map[string]struct{})
|
|
for _, match := range clientSecretPat.FindAllStringSubmatch(dataStr, -1) {
|
|
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 {
|
|
if resIdMatch == resSecretMatch {
|
|
continue
|
|
}
|
|
|
|
s1 := detectors.Result{
|
|
DetectorType: s.Type(),
|
|
Raw: []byte(resIdMatch),
|
|
RawV2: []byte(resIdMatch + resSecretMatch),
|
|
SecretParts: map[string]string{
|
|
"client_id": resIdMatch,
|
|
"client_secret": resSecretMatch,
|
|
},
|
|
}
|
|
|
|
if verify {
|
|
isVerified, verificationErr := verifyMatch(ctx, s.getClient(), resIdMatch, resSecretMatch)
|
|
s1.Verified = isVerified
|
|
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)
|
|
|
|
// box client supports only one client id and secret pair
|
|
if s1.Verified {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
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, err
|
|
}
|
|
|
|
req.Header = http.Header{"content-type": []string{"application/x-www-form-urlencoded"}}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() {
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
// We are using malformed request to check if the client id and secret are valid.
|
|
// In this case, the Box OAuth API returns a 400 status code even if the credentials are valid.
|
|
//
|
|
// - If the client ID/secret are valid, the response contains "unauthorized_client"
|
|
// - If the credentials are invalid, the response contains "invalid_client"
|
|
//
|
|
// So we check the response body for one of these keywords.
|
|
switch res.StatusCode {
|
|
case http.StatusBadRequest:
|
|
{
|
|
bodyBytes, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
body := string(bodyBytes)
|
|
if strings.Contains(body, "unauthorized_client") {
|
|
return true, nil
|
|
} else if strings.Contains(body, "invalid_client") {
|
|
return false, nil
|
|
} else {
|
|
return false, fmt.Errorf("response body missing expected keyword")
|
|
}
|
|
}
|
|
default:
|
|
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
|
|
}
|