Add Shopify OAuth Detector (#4738)

* add shopify oauth detector

* embed multipart credential provider

* fix shopifyoauth SSRF: use DetectorHttpClientWithNoLocalAddresses

* remove unnecessary Analysis info
This commit is contained in:
Amaan Ullah
2026-04-01 17:53:42 +05:00
committed by GitHub
parent 03acc788f8
commit e81c0fc099
6 changed files with 487 additions and 7 deletions
+142
View File
@@ -0,0 +1,142 @@
package shopifyoauth
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)
type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
}
// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var (
defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses
// Client secret has a distinctive prefix: shpss_ followed by 32 hex characters
clientSecretPat = regexp.MustCompile(`\b(shpss_[a-fA-F0-9]{32})\b`)
// Client ID is a generic 32-character alphanumeric string, requiring context
clientIdPat = regexp.MustCompile(detectors.PrefixRegex([]string{"shopify", "client", "id"}) + `\b([a-zA-Z0-9]{32})\b`)
// Domain pattern for Shopify stores
domainPat = regexp.MustCompile(`\b([a-zA-Z0-9][-a-zA-Z0-9]*\.myshopify\.com)\b`)
)
// 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{"shpss_", "myshopify.com"}
}
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
// FromData will find and optionally verify ShopifyOAuth 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)
// Extract all three components into unique maps
uniqueSecrets := make(map[string]struct{})
for _, match := range clientSecretPat.FindAllStringSubmatch(dataStr, -1) {
uniqueSecrets[match[1]] = struct{}{}
}
uniqueClientIds := make(map[string]struct{})
for _, match := range clientIdPat.FindAllStringSubmatch(dataStr, -1) {
uniqueClientIds[match[1]] = struct{}{}
}
uniqueDomains := make(map[string]struct{})
for _, match := range domainPat.FindAllStringSubmatch(dataStr, -1) {
uniqueDomains[match[1]] = struct{}{}
}
// If we are missing any of the three components, we cannot form a valid credential.
if len(uniqueSecrets) == 0 || len(uniqueClientIds) == 0 || len(uniqueDomains) == 0 {
return nil, nil
}
for domain := range uniqueDomains {
for clientId := range uniqueClientIds {
for secret := range uniqueSecrets {
s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_ShopifyOAuth,
Raw: []byte(secret),
RawV2: fmt.Appendf(nil, "%s:%s:%s", domain, clientId, secret),
}
if verify {
isVerified, verificationErr := s.verifyMatch(ctx, s.getClient(), domain, clientId, secret)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, secret)
}
results = append(results, s1)
}
}
}
return results, nil
}
// verifyMatch attempts to validate Shopify OAuth credentials using the client_credentials grant.
func (s Scanner) verifyMatch(ctx context.Context, client *http.Client, domain, clientId, secret string) (bool, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", clientId)
form.Set("client_secret", secret)
authURL := url.URL{
Scheme: "https",
Host: domain,
Path: "/admin/oauth/access_token",
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL.String(), strings.NewReader(form.Encode()))
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("failed to perform request: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusBadRequest, http.StatusNotFound:
// 400 Bad Request: invalid credentials
// 404 Not Found: store doesn't exist
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}
func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_ShopifyOAuth
}
func (s Scanner) Description() string {
return "Shopify OAuth credentials (client ID and client secret) are used to authenticate applications with Shopify stores. These credentials can be used to access store data and perform operations on behalf of the application."
}
@@ -0,0 +1,179 @@
//go:build detectors
// +build detectors
package shopifyoauth
import (
"context"
"fmt"
"testing"
"time"
"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"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)
func TestShopifyOAuth_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
domain := testSecrets.MustGetField("SHOPIFY_OAUTH_DOMAIN")
clientId := testSecrets.MustGetField("SHOPIFY_OAUTH_CLIENT_ID")
clientSecret := testSecrets.MustGetField("SHOPIFY_OAUTH_CLIENT_SECRET")
inactiveSecret := testSecrets.MustGetField("SHOPIFY_OAUTH_CLIENT_SECRET_INACTIVE")
type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
wantVerificationErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf(`
shopify_client_id=%s
client_secret=%s
store=%s
`, clientId, clientSecret, domain)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_ShopifyOAuth,
Verified: true,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf(`
shopify_client_id=%s
client_secret=%s
store=%s
`, clientId, inactiveSecret, domain)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_ShopifyOAuth,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "not found (missing domain)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf(`
shopify_client_id=%s
client_secret=%s
`, clientId, clientSecret)),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: false,
},
{
name: "not found (missing client id context)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf(`
random_key=%s
secret=%s
store=%s
`, clientId, clientSecret, domain)),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, verification error due to timeout",
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf(`
shopify_client_id=%s
client_secret=%s
store=%s
`, clientId, clientSecret, domain)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_ShopifyOAuth,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("ShopifyOAuth.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
}
}
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "AnalysisInfo", "primarySecret")
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf("ShopifyOAuth.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}
func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
benchmark.Run(name, func(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
@@ -0,0 +1,152 @@
package shopifyoauth
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)
func TestShopifyOAuth_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - all three components",
input: `
SHOPIFY_CLIENT_ID=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
SHOPIFY_CLIENT_SECRET=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
SHOPIFY_STORE=my-test-store.myshopify.com
`,
want: []string{"my-test-store.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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"},
},
{
name: "valid pattern - different context keywords",
input: `
client_id: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
client_secret: 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
store_url: example-shop.myshopify.com
`,
want: []string{"example-shop.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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"},
},
{
name: "valid pattern - multiple domains produce multiple results",
input: `
shopify_client_id=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
client_secret=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
store1: store-one.myshopify.com
store2: store-two.myshopify.com
`,
want: []string{
"store-one.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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",
"store-two.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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",
},
},
{
name: "missing client secret - no results",
input: `
shopify_client_id=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
store: test-store.myshopify.com
`,
want: []string{},
},
{
name: "missing client id - no results",
input: `
client_secret=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
store: test-store.myshopify.com
`,
want: []string{},
},
{
name: "missing domain - no results",
input: `
shopify_client_id=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
client_secret=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
`,
want: []string{},
},
{
name: "invalid secret prefix - no results",
input: `
shopify_client_id=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
client_secret=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
store: test-store.myshopify.com
`,
want: []string{},
},
{
name: "client id without context keywords - no results",
input: `
random_key=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
secret=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
store: test-store.myshopify.com
`,
want: []string{},
},
{
name: "valid pattern - JSON config",
input: `{
"shopify": {
"client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"client_secret": "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",
"store_domain": "my-store.myshopify.com"
}
}`,
want: []string{"my-store.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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"},
},
{
name: "valid pattern - uppercase hex in secret",
input: `
shopify_client_id=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
secret=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
url: test.myshopify.com
`,
want: []string{"test.myshopify.com:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6: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"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(test.want) > 0 && len(matchedDetectors) == 0 {
t.Errorf("test %q failed: expected keywords %v to be found in the input", test.name, d.Keywords())
return
}
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
}
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)
}
})
}
}
+2
View File
@@ -670,6 +670,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shipday"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shodankey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shopify"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shopifyoauth"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shortcut"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shotstack"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/shutterstock"
@@ -1556,6 +1557,7 @@ func buildDetectorList() []detectors.Detector {
&shipday.Scanner{},
&shodankey.Scanner{},
&shopify.Scanner{},
&shopifyoauth.Scanner{},
&shortcut.Scanner{},
&shotstack.Scanner{},
&shutterstock.Scanner{},
+11 -7
View File
@@ -1151,6 +1151,7 @@ const (
DetectorType_GoogleGeminiAPIKey DetectorType = 1041
DetectorType_ArtifactoryReferenceToken DetectorType = 1042
DetectorType_DatadogApikey DetectorType = 1043
DetectorType_ShopifyOAuth DetectorType = 1044
)
// Enum value maps for DetectorType.
@@ -2196,6 +2197,7 @@ var (
1041: "GoogleGeminiAPIKey",
1042: "ArtifactoryReferenceToken",
1043: "DatadogApikey",
1044: "ShopifyOAuth",
}
DetectorType_value = map[string]int32{
"Alibaba": 0,
@@ -3238,6 +3240,7 @@ var (
"GoogleGeminiAPIKey": 1041,
"ArtifactoryReferenceToken": 1042,
"DatadogApikey": 1043,
"ShopifyOAuth": 1044,
}
)
@@ -3691,7 +3694,7 @@ var file_detectors_proto_rawDesc = []byte{
0x4c, 0x41, 0x49, 0x4e, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x41, 0x53, 0x45, 0x36, 0x34,
0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x55, 0x54, 0x46, 0x31, 0x36, 0x10, 0x03, 0x12, 0x13, 0x0a,
0x0f, 0x45, 0x53, 0x43, 0x41, 0x50, 0x45, 0x44, 0x5f, 0x55, 0x4e, 0x49, 0x43, 0x4f, 0x44, 0x45,
0x10, 0x04, 0x2a, 0xa8, 0x87, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72,
0x10, 0x04, 0x2a, 0xbb, 0x87, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72,
0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62, 0x61, 0x10,
0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x41,
0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10, 0x03, 0x12,
@@ -4773,12 +4776,13 @@ var file_detectors_proto_rawDesc = []byte{
0x69, 0x41, 0x50, 0x49, 0x4b, 0x65, 0x79, 0x10, 0x91, 0x08, 0x12, 0x1e, 0x0a, 0x19, 0x41, 0x72,
0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
0x63, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0x92, 0x08, 0x12, 0x12, 0x0a, 0x0d, 0x44, 0x61,
0x74, 0x61, 0x64, 0x6f, 0x67, 0x41, 0x70, 0x69, 0x6b, 0x65, 0x79, 0x10, 0x93, 0x08, 0x42, 0x3d,
0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75,
0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75,
0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70,
0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x70, 0x62, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
0x74, 0x61, 0x64, 0x6f, 0x67, 0x41, 0x70, 0x69, 0x6b, 0x65, 0x79, 0x10, 0x93, 0x08, 0x12, 0x11,
0x0a, 0x0c, 0x53, 0x68, 0x6f, 0x70, 0x69, 0x66, 0x79, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x10, 0x94,
0x08, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b,
0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x70, 0x62,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
+1
View File
@@ -1053,6 +1053,7 @@ enum DetectorType {
GoogleGeminiAPIKey = 1041;
ArtifactoryReferenceToken = 1042;
DatadogApikey = 1043;
ShopifyOAuth = 1044;
}
message Result {