Skip to content

Commit 907ac64

Browse files
Salesforce Refresh Token Detector (#4295)
* salesforce refresh token init * added pattern tests for salesforce refresh token detector * added integration tests for salesforce refresh token detector * code cleaned --------- Co-authored-by: Kashif Khan <[email protected]>
1 parent 7792f02 commit 907ac64

File tree

6 files changed

+496
-6
lines changed

6 files changed

+496
-6
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package salesforcerefreshtoken
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
12+
regexp "github.com/wasilibs/go-re2"
13+
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
17+
)
18+
19+
type Scanner struct {
20+
client *http.Client
21+
}
22+
23+
var (
24+
// Ensure the Scanner satisfies the interface at compile time.
25+
_ detectors.Detector = (*Scanner)(nil)
26+
defaultClient = common.SaneHttpClient()
27+
28+
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
29+
refreshTokenPat = regexp.MustCompile(`(?i)\b(5AEP861[a-zA-Z0-9._=]{80,})\b`)
30+
consumerKeyPat = regexp.MustCompile(`\b(3MVG9[0-9a-zA-Z._+/=]{80,251})`)
31+
consumerSecretPat = regexp.MustCompile(detectors.PrefixRegex([]string{"salesforce", "consumer", "secret"}) + `\b([A-Za-z0-9+/=.]{64}|[0-9]{19})\b`)
32+
)
33+
34+
// Keywords are used for efficiently pre-filtering chunks.
35+
// Use identifiers in the secret preferably, or the provider name.
36+
func (s Scanner) Keywords() []string {
37+
return []string{"salesforce", "5AEP861", "3MVG9"}
38+
}
39+
40+
func (s Scanner) getClient() *http.Client {
41+
if s.client != nil {
42+
return s.client
43+
}
44+
45+
return defaultClient
46+
}
47+
48+
func (s Scanner) Type() detectorspb.DetectorType {
49+
return detectorspb.DetectorType_SalesforceRefreshToken
50+
}
51+
52+
func (s Scanner) Description() string {
53+
return "Salesforce is a customer relationship management (CRM) platform that provides a suite of applications and APIs. OAuth 2.0 refresh tokens are long-lived credentials that allow applications to obtain new access tokens without requiring user interaction. They enable continuous access to an organization's data and must be handled securely."
54+
}
55+
56+
// FromData will find and optionally verify Salesforceoauth2 refresh token in a given set of bytes.
57+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
58+
dataStr := string(data)
59+
60+
uniqueTokenMatches, uniqueKeyMatches, uniqueSecretMatches := make(map[string]struct{}), make(map[string]struct{}), make(map[string]struct{})
61+
for _, match := range refreshTokenPat.FindAllStringSubmatch(dataStr, -1) {
62+
uniqueTokenMatches[match[1]] = struct{}{}
63+
}
64+
65+
for _, match := range consumerKeyPat.FindAllStringSubmatch(dataStr, -1) {
66+
uniqueKeyMatches[match[1]] = struct{}{}
67+
}
68+
69+
for _, match := range consumerSecretPat.FindAllStringSubmatch(dataStr, -1) {
70+
uniqueSecretMatches[match[1]] = struct{}{}
71+
}
72+
73+
for refreshToken := range uniqueTokenMatches {
74+
for key := range uniqueKeyMatches {
75+
for secret := range uniqueSecretMatches {
76+
s1 := detectors.Result{
77+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
78+
Raw: []byte(refreshToken),
79+
RawV2: fmt.Appendf([]byte{}, "%s:%s:%s", refreshToken, key, secret),
80+
}
81+
82+
if verify {
83+
isVerified, verificationErr := s.verifyMatch(ctx, s.getClient(), refreshToken, key, secret)
84+
s1.Verified = isVerified
85+
if verificationErr != nil {
86+
s1.SetVerificationError(verificationErr, secret)
87+
}
88+
}
89+
90+
results = append(results, s1)
91+
}
92+
}
93+
}
94+
95+
return
96+
}
97+
98+
// verifyMatch attempts to validate a Salesforce Refresh Token.
99+
func (s Scanner) verifyMatch(ctx context.Context, client *http.Client, refreshToken, key, secret string) (bool, error) {
100+
form := url.Values{}
101+
form.Set("token", refreshToken)
102+
form.Set("client_id", key)
103+
form.Set("client_secret", secret)
104+
105+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://login.salesforce.com/services/oauth2/introspect",
106+
strings.NewReader(form.Encode()))
107+
if err != nil {
108+
return false, fmt.Errorf("failed to create request: %w", err)
109+
}
110+
111+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
112+
resp, err := client.Do(req)
113+
if err != nil {
114+
return false, fmt.Errorf("failed to perform request: %w", err)
115+
}
116+
defer func() {
117+
_, _ = io.Copy(io.Discard, resp.Body)
118+
_ = resp.Body.Close()
119+
}()
120+
121+
switch resp.StatusCode {
122+
case http.StatusOK:
123+
bodyBytes, err := io.ReadAll(resp.Body)
124+
if err != nil {
125+
return false, fmt.Errorf("failed to read error response body: %w", err)
126+
}
127+
var apiResp introspectAPIResponse
128+
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
129+
return false, fmt.Errorf("failed to unmarshal error response: %w (body: %s)", err, string(bodyBytes))
130+
}
131+
132+
if !apiResp.Active {
133+
return false, nil
134+
}
135+
return true, nil
136+
137+
case http.StatusBadRequest:
138+
// Salesforce returns a 400 Bad Request if the consumer key/secret are valid, but the refresh token is invalid or missing
139+
return false, nil
140+
141+
case http.StatusUnauthorized:
142+
// Salesforce returns a 401 Unauthorized if the consumer key/secret are invalid.
143+
// This means that a 401 can also occur when the refresh token is valid but the consumer key or secret is incorrect.
144+
return false, fmt.Errorf("unauthorized: invalid client credentials")
145+
146+
default:
147+
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
148+
}
149+
}
150+
151+
type introspectAPIResponse struct {
152+
Active bool `json:"active"`
153+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package salesforcerefreshtoken
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/google/go-cmp/cmp/cmpopts"
14+
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
func TestSalesforcerefreshtoken_FromChunk(t *testing.T) {
21+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
22+
defer cancel()
23+
24+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
25+
if err != nil {
26+
t.Fatalf("could not get test secrets from GCP: %s", err)
27+
}
28+
refreshToken := testSecrets.MustGetField("SALESFORCE_REFRESH_TOKEN")
29+
consumerKey := testSecrets.MustGetField("SALESFORCE_REFRESH_TOKEN_KEY")
30+
consumerSecret := testSecrets.MustGetField("SALESFORCE_REFRESH_TOKEN_SECRET")
31+
inactiveSecret := testSecrets.MustGetField("SALESFORCE_REFRESH_TOKEN_INACTIVE_SECRET")
32+
33+
type args struct {
34+
ctx context.Context
35+
data []byte
36+
verify bool
37+
}
38+
tests := []struct {
39+
name string
40+
s Scanner
41+
args args
42+
want []detectors.Result
43+
wantErr bool
44+
wantVerificationErr bool
45+
}{
46+
{
47+
name: "found one valid trio, verified",
48+
s: Scanner{},
49+
args: args{
50+
ctx: context.Background(),
51+
data: []byte(fmt.Sprintf("refresh_token: %s, key: %s, secret: %s", refreshToken, consumerKey, consumerSecret)),
52+
verify: true,
53+
},
54+
want: []detectors.Result{
55+
{
56+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
57+
Verified: true,
58+
},
59+
},
60+
wantErr: false,
61+
wantVerificationErr: false,
62+
},
63+
{
64+
name: "found one invalid trio, unverified",
65+
s: Scanner{},
66+
args: args{
67+
ctx: context.Background(),
68+
data: []byte(fmt.Sprintf("refresh_token: %s, key: %s, secret: %s", refreshToken, consumerKey, inactiveSecret)),
69+
verify: true,
70+
},
71+
want: []detectors.Result{
72+
{
73+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
74+
Verified: false,
75+
},
76+
},
77+
wantErr: false,
78+
wantVerificationErr: true, // Verification fails because the credentials are invalid and we can't verify the refresh token.
79+
},
80+
{
81+
name: "multiple findings, one verified",
82+
s: Scanner{},
83+
args: args{
84+
ctx: context.Background(),
85+
data: []byte(fmt.Sprintf(`
86+
refresh_token: %s, key: %s, valid_secret: %s,
87+
invalid_refresh_token: 5Aep861eN26Sp9j0R5QPjh0AAAABBBBCCCCjcNqfo5kVBplkpP5tzyWXyVGAivx26AAAABBBBjYE133BBBBAAAA`,
88+
refreshToken, consumerKey, consumerSecret)),
89+
verify: true,
90+
},
91+
want: []detectors.Result{
92+
{
93+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
94+
Verified: true, // The valid refresh token combination
95+
},
96+
{
97+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
98+
Verified: false, // The invalid refresh token combination
99+
},
100+
},
101+
wantErr: false,
102+
wantVerificationErr: false,
103+
},
104+
{
105+
name: "not found (missing a component)",
106+
s: Scanner{},
107+
args: args{
108+
ctx: context.Background(),
109+
data: []byte(fmt.Sprintf("key: %s, secret: %s", consumerKey, consumerSecret)), // No refresh token
110+
verify: true,
111+
},
112+
want: nil,
113+
wantErr: false,
114+
wantVerificationErr: false,
115+
},
116+
{
117+
name: "found, would be verified if not for timeout",
118+
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
119+
args: args{
120+
ctx: context.Background(),
121+
data: []byte(fmt.Sprintf("refresh_token: %s, key: %s, secret: %s", refreshToken, consumerKey, consumerSecret)),
122+
verify: true,
123+
},
124+
want: []detectors.Result{
125+
{
126+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
127+
Verified: false,
128+
},
129+
},
130+
wantErr: false,
131+
wantVerificationErr: true,
132+
},
133+
{
134+
name: "found, unexpected api response",
135+
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
136+
args: args{
137+
ctx: context.Background(),
138+
data: []byte(fmt.Sprintf("refresh_token: %s, key: %s, secret: %s", refreshToken, consumerKey, consumerSecret)),
139+
verify: true,
140+
},
141+
want: []detectors.Result{
142+
{
143+
DetectorType: detectorspb.DetectorType_SalesforceRefreshToken,
144+
Verified: false,
145+
},
146+
},
147+
wantErr: false,
148+
wantVerificationErr: true,
149+
},
150+
}
151+
for _, tt := range tests {
152+
t.Run(tt.name, func(t *testing.T) {
153+
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
154+
if (err != nil) != tt.wantErr {
155+
t.Errorf("FromData() error = %v, wantErr %v", err, tt.wantErr)
156+
return
157+
}
158+
159+
// Since the order of results can vary with maps, we use a more robust comparison.
160+
// This checks that for every `want` result, there is a matching `got` result.
161+
opts := []cmp.Option{
162+
cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "ExtraData", "VerificationFromCache", "primarySecret"),
163+
cmpopts.SortSlices(func(a, b detectors.Result) bool { return a.Verified }),
164+
}
165+
if diff := cmp.Diff(tt.want, got, opts...); diff != "" {
166+
t.Errorf("FromData() results mismatch (-want +got):\n%s", diff)
167+
}
168+
169+
// Also check that verification errors match expectations across all results.
170+
var gotErr bool
171+
for _, r := range got {
172+
if r.VerificationError() != nil {
173+
gotErr = true
174+
break
175+
}
176+
}
177+
if gotErr != tt.wantVerificationErr {
178+
t.Errorf("wantVerificationErr = %v, but got an error state of %v", tt.wantVerificationErr, gotErr)
179+
}
180+
})
181+
}
182+
}
183+
184+
func BenchmarkFromData(benchmark *testing.B) {
185+
ctx := context.Background()
186+
s := Scanner{}
187+
for name, data := range detectors.MustGetBenchmarkData() {
188+
benchmark.Run(name, func(b *testing.B) {
189+
b.ResetTimer()
190+
for n := 0; n < b.N; n++ {
191+
_, err := s.FromData(ctx, false, data)
192+
if err != nil {
193+
b.Fatal(err)
194+
}
195+
}
196+
})
197+
}
198+
}

0 commit comments

Comments
 (0)