diff --git a/pkg/detectors/shopifyoauth/shopifyoauth.go b/pkg/detectors/shopifyoauth/shopifyoauth.go new file mode 100644 index 000000000..e1f9d78e9 --- /dev/null +++ b/pkg/detectors/shopifyoauth/shopifyoauth.go @@ -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." +} diff --git a/pkg/detectors/shopifyoauth/shopifyoauth_integration_test.go b/pkg/detectors/shopifyoauth/shopifyoauth_integration_test.go new file mode 100644 index 000000000..1ead43acf --- /dev/null +++ b/pkg/detectors/shopifyoauth/shopifyoauth_integration_test.go @@ -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) + } + } + }) + } +} diff --git a/pkg/detectors/shopifyoauth/shopifyoauth_test.go b/pkg/detectors/shopifyoauth/shopifyoauth_test.go new file mode 100644 index 000000000..20de98daf --- /dev/null +++ b/pkg/detectors/shopifyoauth/shopifyoauth_test.go @@ -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) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index c3e76f3ca..f483433e3 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -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{}, diff --git a/pkg/pb/detectorspb/detectors.pb.go b/pkg/pb/detectorspb/detectors.pb.go index ef73adf3f..3ec2468eb 100644 --- a/pkg/pb/detectorspb/detectors.pb.go +++ b/pkg/pb/detectorspb/detectors.pb.go @@ -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 ( diff --git a/proto/detectors.proto b/proto/detectors.proto index 919d554ec..88829dd17 100644 --- a/proto/detectors.proto +++ b/proto/detectors.proto @@ -1053,6 +1053,7 @@ enum DetectorType { GoogleGeminiAPIKey = 1041; ArtifactoryReferenceToken = 1042; DatadogApikey = 1043; + ShopifyOAuth = 1044; } message Result {