Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions pkg/detectors/shopifyoauth/shopifyoauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package shopifyoauth

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"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/detectorspb"
)

type Scanner struct {
client *http.Client
}

// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)

var (
defaultClient = common.SaneHttpClient()

// 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)

if isVerified {
s1.AnalysisInfo = map[string]string{
"domain": domain,
"client_id": clientId,
"client_secret": 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."
}
179 changes: 179 additions & 0 deletions pkg/detectors/shopifyoauth/shopifyoauth_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading
Loading