From d2b3377bf24fa4830f3a7b45ebf48515f08cdf79 Mon Sep 17 00:00:00 2001 From: Nabeel Alam Date: Thu, 17 Apr 2025 20:47:11 +0500 Subject: [PATCH] [Fix] Updated DigitalOceanV2 Detector (#4050) * updated digital ocean v2 detector * added build tags in dov2 detector integration test * updated unexpected error test case name * updated refresh token verification function to return boolean --- .../digitaloceanv2/digitaloceanv2.go | 145 +++++++++++++----- .../digitaloceanv2_integration_test.go | 41 +++-- 2 files changed, 135 insertions(+), 51 deletions(-) diff --git a/pkg/detectors/digitaloceanv2/digitaloceanv2.go b/pkg/detectors/digitaloceanv2/digitaloceanv2.go index c6b670ab2..caf5c7a17 100644 --- a/pkg/detectors/digitaloceanv2/digitaloceanv2.go +++ b/pkg/detectors/digitaloceanv2/digitaloceanv2.go @@ -2,6 +2,7 @@ package digitaloceanv2 import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -14,13 +15,15 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" ) -type Scanner struct{} +type Scanner struct { + client *http.Client +} // Ensure the Scanner satisfies the interface at compile time. var _ detectors.Detector = (*Scanner)(nil) var ( - client = common.SaneHttpClient() + defaultClient = common.SaneHttpClient() // Make sure that your group is surrounded in boundary characters such as below to reduce false positives. keyPat = regexp.MustCompile(`\b((?:dop|doo|dor)_v1_[a-f0-9]{64})\b`) @@ -36,55 +39,42 @@ func (s Scanner) Keywords() []string { func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { dataStr := string(data) - matches := keyPat.FindAllStringSubmatch(dataStr, -1) + var uniqueTokens = make(map[string]struct{}) - for _, match := range matches { - resMatch := strings.TrimSpace(match[1]) + for _, matches := range keyPat.FindAllStringSubmatch(dataStr, -1) { + uniqueTokens[matches[0]] = struct{}{} + } + for token := range uniqueTokens { s1 := detectors.Result{ DetectorType: detectorspb.DetectorType_DigitalOceanV2, - Raw: []byte(resMatch), + Raw: []byte(token), } if verify { + client := s.client + if client == nil { + client = defaultClient + } + + // Check if the token is a refresh token or an access token switch { - case strings.HasPrefix(resMatch, "dor_v1_"): - req, err := http.NewRequestWithContext(ctx, "GET", "https://cloud.digitalocean.com/v1/oauth/token?grant_type=refresh_token&refresh_token="+resMatch, nil) - if err != nil { - continue - } - - res, err := client.Do(req) - if err == nil { - bodyBytes, err := io.ReadAll(res.Body) - - if err != nil { - continue - } - - bodyString := string(bodyBytes) - validResponse := strings.Contains(bodyString, `"access_token"`) - defer res.Body.Close() - - if res.StatusCode >= 200 && res.StatusCode < 300 && validResponse { - s1.Verified = true + case strings.HasPrefix(token, "dor_v1_"): + verified, verificationErr, newAccessToken := verifyRefreshToken(ctx, client, token) + s1.SetVerificationError(verificationErr) + s1.Verified = verified + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "key": newAccessToken, } } - - case strings.HasPrefix(resMatch, "doo_v1_"), strings.HasPrefix(resMatch, "dop_v1_"): - req, err := http.NewRequestWithContext(ctx, "GET", "https://api.digitalocean.com/v2/account", nil) - if err != nil { - continue - } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", resMatch)) - res, err := client.Do(req) - if err == nil { - defer res.Body.Close() - if res.StatusCode >= 200 && res.StatusCode < 300 { - s1.Verified = true - s1.AnalysisInfo = map[string]string{ - "key": resMatch, - } + case strings.HasPrefix(token, "doo_v1_"), strings.HasPrefix(token, "dop_v1_"): + verified, verificationErr := verifyAccessToken(ctx, client, token) + s1.Verified = verified + s1.SetVerificationError(verificationErr) + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "key": token, } } } @@ -96,6 +86,79 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result return results, nil } +// verifyRefreshToken verifies the refresh token by making a request to the DigitalOcean API. +// If the token is valid, it returns the new access token and no error. +// If the token is invalid/expired, it returns an empty string and no error. +// If an error is encountered, it returns an empty string along and the error. +func verifyRefreshToken(ctx context.Context, client *http.Client, token string) (bool, error, string) { + // Ref: https://docs.digitalocean.com/reference/api/oauth/ + + url := "https://cloud.digitalocean.com/v1/oauth/token?grant_type=refresh_token&refresh_token=" + token + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err), "" + } + + res, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("failed to make request: %w", err), "" + } + + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return false, fmt.Errorf("failed to read response body: %w", err), "" + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusOK: + var responseMap map[string]interface{} + if err := json.Unmarshal(bodyBytes, &responseMap); err != nil { + return false, fmt.Errorf("failed to parse response body: %w", err), "" + } + // Extract the access token from the response + accessToken, exists := responseMap["access_token"].(string) + if !exists { + return false, fmt.Errorf("access_token not found in response: %s", string(bodyBytes)), "" + } + return true, nil, accessToken + case http.StatusUnauthorized: + return false, nil, "" + default: + return false, fmt.Errorf("unexpected status code: %d", res.StatusCode), "" + } +} + +// verifyAccessToken verifies the access token by making a request to the DigitalOcean API. +// If the token is valid, it returns true and no error. +// If the token is invalid, it returns false and no error. +// If an error is encountered, it returns false along with the error. +func verifyAccessToken(ctx context.Context, client *http.Client, token string) (bool, error) { + // Ref: https://docs.digitalocean.com/reference/api/digitalocean/#tag/Account + + url := "https://api.digitalocean.com/v2/account" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + res, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("failed to make request: %w", err) + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized: + return false, nil + default: + return false, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } +} + func (s Scanner) Type() detectorspb.DetectorType { return detectorspb.DetectorType_DigitalOceanV2 } diff --git a/pkg/detectors/digitaloceanv2/digitaloceanv2_integration_test.go b/pkg/detectors/digitaloceanv2/digitaloceanv2_integration_test.go index c97b8b7da..a1736df26 100644 --- a/pkg/detectors/digitaloceanv2/digitaloceanv2_integration_test.go +++ b/pkg/detectors/digitaloceanv2/digitaloceanv2_integration_test.go @@ -9,7 +9,8 @@ import ( "testing" "time" - "github.com/kylelemons/godebug/pretty" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" @@ -32,11 +33,12 @@ func TestDigitalOceanV2_FromChunk(t *testing.T) { verify bool } tests := []struct { - name string - s Scanner - args args - want []detectors.Result - wantErr bool + name string + s Scanner + args args + want []detectors.Result + wantErr bool + wantVerificationErr bool }{ { name: "found, verified", @@ -81,11 +83,27 @@ func TestDigitalOceanV2_FromChunk(t *testing.T) { want: nil, wantErr: false, }, + { + name: "found verifiable secret, verification failed due to unexpected API response", + s: Scanner{client: common.ConstantResponseHttpClient(404, "")}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a digitaloceanv2 secret %s within", secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_DigitalOceanV2, + Verified: false, + }, + }, + wantErr: false, + wantVerificationErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := Scanner{} - got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) + got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) if (err != nil) != tt.wantErr { t.Errorf("DigitalOceanV2.FromData() error = %v, wantErr %v", err, tt.wantErr) return @@ -94,9 +112,12 @@ func TestDigitalOceanV2_FromChunk(t *testing.T) { if len(got[i].Raw) == 0 { t.Fatalf("no raw secret present: \n %+v", got[i]) } - got[i].Raw = nil + if (got[i].VerificationError() != nil) != tt.wantVerificationErr { + t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError()) + } } - if diff := pretty.Compare(got, tt.want); diff != "" { + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "AnalysisInfo") + if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("DigitalOceanV2.FromData() %s diff: (-got +want)\n%s", tt.name, diff) } })