diff --git a/pkg/analyzer/analyzers/shopify/expected_output.json b/pkg/analyzer/analyzers/shopify/expected_output.json new file mode 100644 index 000000000000..5161cad0a7fa --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/expected_output.json @@ -0,0 +1,177 @@ +{ + "AnalyzerType": 15, + "Bindings": [ + { + "Resource": { + "Name": "Analytics", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Analytics", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "read", + "Parent": null + } + }, + { + "Resource": { + "Name": "Applications", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Applications", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "read", + "Parent": null + } + }, + { + "Resource": { + "Name": "Assigned fulfillment orders", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Assigned fulfillment orders", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "Customers", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Customers", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "Discovery", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Discovery", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "Merchant-managed fulfillment orders", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Merchant-managed fulfillment orders", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "Reports", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/Reports", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "cart_transforms", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com/cart_transforms", + "Type": "category", + "Metadata": null, + "Parent": { + "Name": "My Store", + "FullyQualifiedName": "727f01-d6.myshopify.com/detectors@trufflesec.com", + "Type": "shop", + "Metadata": { + "created_at": "2024-08-16T17:16:17+05:00" + }, + "Parent": null + } + }, + "Permission": { + "Value": "full_access", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": { + "status_code": 200 + } + } \ No newline at end of file diff --git a/pkg/analyzer/analyzers/shopify/permissions.go b/pkg/analyzer/analyzers/shopify/permissions.go new file mode 100644 index 000000000000..ce9c7097feee --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/permissions.go @@ -0,0 +1,71 @@ +// Code generated by go generate; DO NOT EDIT. +package shopify + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + Read Permission = iota + Write Permission = iota + FullAccess Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + Read: "read", + Write: "write", + FullAccess: "full_access", + } + + StringToPermission = map[string]Permission{ + "read": Read, + "write": Write, + "full_access": FullAccess, + } + + PermissionIDs = map[Permission]int{ + Read: 1, + Write: 2, + FullAccess: 3, + } + + IdToPermission = map[int]Permission{ + 1: Read, + 2: Write, + 3: FullAccess, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := PermissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := PermissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := StringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := IdToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/shopify/permissions.yaml b/pkg/analyzer/analyzers/shopify/permissions.yaml new file mode 100644 index 000000000000..7a87b27d1632 --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/permissions.yaml @@ -0,0 +1,4 @@ +permissions: + - read + - write + - full_access diff --git a/pkg/analyzer/analyzers/shopify/shopify.go b/pkg/analyzer/analyzers/shopify/shopify.go index f017ea1acdd0..3711baae2bc6 100644 --- a/pkg/analyzer/analyzers/shopify/shopify.go +++ b/pkg/analyzer/analyzers/shopify/shopify.go @@ -1,8 +1,11 @@ +//go:generate generate_permissions permissions.yaml permissions.go shopify + package shopify import ( _ "embed" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -12,8 +15,100 @@ import ( "github.com/jedib0t/go-pretty/table" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +var ( + // order the categories + categoryOrder = []string{"Analytics", "Applications", "Assigned fulfillment orders", "Browsing behavior", "Custom pixels", "Customers", "Discounts", "Discovery", "Draft orders", "Files", "Fulfillment services", "Gift cards", "Inventory", "Legal policies", "Locations", "Marketing events", "Merchant-managed fulfillment orders", "Metaobject definitions", "Metaobject entries", "Online Store navigation", "Online Store pages", "Order editing", "Orders", "Packing slip management", "Payment customizations", "Payment terms", "Pixels", "Price rules", "Product feeds", "Product listings", "Products", "Publications", "Purchase options", "Reports", "Resource feedback", "Returns", "Sales channels", "Script tags", "Shipping", "Shop locales", "Shopify Markets", "Shopify Payments accounts", "Shopify Payments bank accounts", "Shopify Payments disputes", "Shopify Payments payouts", "Store content", "Store credit account transactions", "Store credit accounts", "Themes", "Third-party fulfillment orders", "Translations", "all_cart_transforms", "all_checkout_completion_target_customizations", "cart_transforms", "cash_tracking", "companies", "custom_fulfillment_services", "customer_data_erasure", "customer_merge", "delivery_customizations", "delivery_option_generators", "discounts_allocator_functions", "fulfillment_constraint_rules", "gates", "order_submission_rules", "privacy_settings", "shopify_payments_provider_accounts_sensitive", "validations"} +) + +func (Analyzer) Type() analyzerpb.AnalyzerType { return analyzerpb.AnalyzerType_Shopify } + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, ok := credInfo["key"] + if !ok { + return nil, errors.New("key not found in credentialInfo") + } + + storeUrl, ok := credInfo["store_url"] + if !ok { + return nil, errors.New("store_url not found in credentialInfo") + } + + info, err := AnalyzePermissions(a.Cfg, key, storeUrl) + if err != nil { + return nil, err + } + return secretInfoToAnalyzerResult(info), nil +} + +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzerpb.AnalyzerType_Shopify, + Metadata: map[string]any{ + "status_code": info.StatusCode, + }, + } + + resource := &analyzers.Resource{ + Name: info.ShopInfo.Shop.Name, + FullyQualifiedName: info.ShopInfo.Shop.Domain + "/" + info.ShopInfo.Shop.Email, + Type: "shop", + Metadata: map[string]any{ + "created_at": info.ShopInfo.Shop.CreatedAt, + }, + Parent: nil, + } + result.Bindings = make([]analyzers.Binding, 0) + + for _, category := range categoryOrder { + if val, ok := info.Scopes[category]; ok { + cateogryResource := &analyzers.Resource{ + Name: category, + FullyQualifiedName: resource.FullyQualifiedName + "/" + category, // shop.domain/shop.email/category + Type: "category", + Parent: resource, + } + + if sliceContains(val.Scopes, "Read") && sliceContains(val.Scopes, "Write") { + result.Bindings = append(result.Bindings, analyzers.Binding{ + Resource: *cateogryResource, + Permission: analyzers.Permission{ + Value: PermissionStrings[FullAccess], + }, + }) + continue + } + + for _, scope := range val.Scopes { + lowerScope := strings.ToLower(scope) + if _, ok := StringToPermission[lowerScope]; !ok { // skip unknown scopes/permission + continue + } + result.Bindings = append(result.Bindings, analyzers.Binding{ + Resource: *cateogryResource, + Permission: analyzers.Permission{ + Value: lowerScope, + }, + }) + } + } + } + + return &result +} + //go:embed scopes.json var scopesConfig []byte @@ -90,6 +185,7 @@ func determineScopes(data ScopeDataJSON, input string) map[string]OutputScopes { type ShopInfoJSON struct { Shop struct { + Domain string `json:"domain"` Name string `json:"name"` Email string `json:"email"` CreatedAt string `json:"created_at"` @@ -224,9 +320,6 @@ func printAccessScopes(accessScopes map[string]OutputScopes) { t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Scope", "Description", "Access"}) - // order the categories - categoryOrder := []string{"Analytics", "Applications", "Assigned fulfillment orders", "Browsing behavior", "Custom pixels", "Customers", "Discounts", "Discovery", "Draft orders", "Files", "Fulfillment services", "Gift cards", "Inventory", "Legal policies", "Locations", "Marketing events", "Merchant-managed fulfillment orders", "Metaobject definitions", "Metaobject entries", "Online Store navigation", "Online Store pages", "Order editing", "Orders", "Packing slip management", "Payment customizations", "Payment terms", "Pixels", "Price rules", "Product feeds", "Product listings", "Products", "Publications", "Purchase options", "Reports", "Resource feedback", "Returns", "Sales channels", "Script tags", "Shipping", "Shop locales", "Shopify Markets", "Shopify Payments accounts", "Shopify Payments bank accounts", "Shopify Payments disputes", "Shopify Payments payouts", "Store content", "Store credit account transactions", "Store credit accounts", "Themes", "Third-party fulfillment orders", "Translations", "all_cart_transforms", "all_checkout_completion_target_customizations", "cart_transforms", "cash_tracking", "companies", "custom_fulfillment_services", "customer_data_erasure", "customer_merge", "delivery_customizations", "delivery_option_generators", "discounts_allocator_functions", "fulfillment_constraint_rules", "gates", "order_submission_rules", "privacy_settings", "shopify_payments_provider_accounts_sensitive", "validations"} - for _, category := range categoryOrder { if val, ok := accessScopes[category]; ok { t.AppendRow([]interface{}{color.GreenString(category), color.GreenString(val.Description), color.GreenString(val.PrintScopes())}) diff --git a/pkg/analyzer/analyzers/shopify/shopify_test.go b/pkg/analyzer/analyzers/shopify/shopify_test.go new file mode 100644 index 000000000000..b0f58b9239a8 --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/shopify_test.go @@ -0,0 +1,88 @@ +package shopify + +import ( + _ "embed" + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +//go:embed expected_output.json +var expectedOutput []byte + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors4") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + secret := testSecrets.MustGetField("SHOPIFY_ADMIN_SECRET") + domain := testSecrets.MustGetField("SHOPIFY_DOMAIN") + + tests := []struct { + name string + key string + storeUrl string + want string + wantErr bool + }{ + { + name: "valid Shopify key", + key: secret, + storeUrl: domain, + want: string(expectedOutput), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{Cfg: &config.Config{}} + got, err := a.Analyze(ctx, map[string]string{"key": tt.key, "store_url": tt.storeUrl}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} diff --git a/pkg/detectors/shopify/shopify.go b/pkg/detectors/shopify/shopify.go index 13b11ecf904f..f576fbc1eb7f 100644 --- a/pkg/detectors/shopify/shopify.go +++ b/pkg/detectors/shopify/shopify.go @@ -74,6 +74,10 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.ExtraData = map[string]string{ "access_scopes": strings.Join(handleArray, ","), } + s1.AnalysisInfo = map[string]string{ + "key": key, + "store_url": domainRes, + } } res.Body.Close() } diff --git a/pkg/detectors/shopify/shopify_test.go b/pkg/detectors/shopify/shopify_test.go index 5dfd2ba22f1b..f03987c4296e 100644 --- a/pkg/detectors/shopify/shopify_test.go +++ b/pkg/detectors/shopify/shopify_test.go @@ -99,6 +99,7 @@ func TestShopify_FromChunk(t *testing.T) { t.Fatalf("no raw secret present: \n %+v", got[i]) } got[i].Raw = nil + got[i].AnalysisInfo = nil } if diff := pretty.Compare(got, tt.want); diff != "" { t.Errorf("Shopify.FromData() %s diff: (-got +want)\n%s", tt.name, diff)