From d6e1627f1620085658ca2e9f9e400a4b2059ee4d Mon Sep 17 00:00:00 2001 From: Abdul Basit Date: Thu, 5 Sep 2024 03:50:19 +0500 Subject: [PATCH] [analyze] Add Analyzer for Opsgenie (#3181) * implement analyzer interface for opsgenie and add unit tests * Add analyzer interface for opsgenie linked detector with analyzers fixed test cases. * generate permissions for opsgenie and change scope names to lowercase for consistency * fixed unboundedresources slice issue. username as fullqualifiedname --------- Co-authored-by: Abdul Basit --- pkg/analyzer/analyzers/opsgenie/opsgenie.go | 75 ++++++++- .../analyzers/opsgenie/opsgenie_test.go | 158 ++++++++++++++++++ .../analyzers/opsgenie/permissions.go | 76 +++++++++ .../analyzers/opsgenie/permissions.yaml | 5 + pkg/analyzer/analyzers/opsgenie/scopes.json | 8 +- pkg/detectors/opsgenie/opsgenie.go | 4 +- pkg/detectors/opsgenie/opsgenie_test.go | 1 + 7 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 pkg/analyzer/analyzers/opsgenie/opsgenie_test.go create mode 100644 pkg/analyzer/analyzers/opsgenie/permissions.go create mode 100644 pkg/analyzer/analyzers/opsgenie/permissions.yaml diff --git a/pkg/analyzer/analyzers/opsgenie/opsgenie.go b/pkg/analyzer/analyzers/opsgenie/opsgenie.go index 64974c731..f308383ef 100644 --- a/pkg/analyzer/analyzers/opsgenie/opsgenie.go +++ b/pkg/analyzer/analyzers/opsgenie/opsgenie.go @@ -1,3 +1,5 @@ +//go:generate generate_permissions permissions.yaml permissions.go opsgenie + package opsgenie import ( @@ -14,8 +16,79 @@ 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 +} + +func (Analyzer) Type() analyzerpb.AnalyzerType { return analyzerpb.AnalyzerType_Opsgenie } + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, ok := credInfo["key"] + if !ok { + return nil, errors.New("missing key in credInfo") + } + info, err := AnalyzePermissions(a.Cfg, key) + 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_Opsgenie, + Metadata: nil, + Bindings: make([]analyzers.Binding, len(info.Permissions)), + UnboundedResources: make([]analyzers.Resource, len(info.Users)), + } + + // Opsgenie has API integrations, so the key does not belong + // to a particular user or account, it itself is a resource + resource := analyzers.Resource{ + Name: "Opsgenie API Integration Key", + FullyQualifiedName: "Opsgenie API Integration Key", + Type: "API Key", + Metadata: map[string]any{ + "expires": "never", + }, + } + + for idx, permission := range info.Permissions { + result.Bindings[idx] = analyzers.Binding{ + Resource: resource, + Permission: analyzers.Permission{ + Value: permission, + }, + } + } + + // We can find list of users in the current account + // if the API key has Configuration Access, so these can be + // unbounded resources + for idx, user := range info.Users { + result.UnboundedResources[idx] = analyzers.Resource{ + Name: user.FullName, + FullyQualifiedName: user.Username, + Type: "user", + Metadata: map[string]any{ + "username": user.Username, + "role": user.Role.Name, + }, + } + } + + return &result +} + //go:embed scopes.json var scopesConfig []byte @@ -195,7 +268,7 @@ func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { info.Permissions = permissions - if contains(permissions, "Configuration Access") { + if contains(permissions, "configuration_access") { users, err := getUserList(cfg, key) if err != nil { return nil, fmt.Errorf("getting user list: %w", err) diff --git a/pkg/analyzer/analyzers/opsgenie/opsgenie_test.go b/pkg/analyzer/analyzers/opsgenie/opsgenie_test.go new file mode 100644 index 000000000..490c030d7 --- /dev/null +++ b/pkg/analyzer/analyzers/opsgenie/opsgenie_test.go @@ -0,0 +1,158 @@ +package opsgenie + +import ( + "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" +) + +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) + } + + key := testSecrets.MustGetField("OPSGENIE") + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid Opsgenie API key", + key: key, + want: `{ + "AnalyzerType": 11, + "Bindings": [ + { + "Resource": { + "Name": "Opsgenie API Integration Key", + "FullyQualifiedName": "Opsgenie API Integration Key", + "Type": "API Key", + "Metadata": { + "expires": "never" + }, + "Parent": null + }, + "Permission": { + "Value": "configuration_access", + "Parent": null + } + }, + { + "Resource": { + "Name": "Opsgenie API Integration Key", + "FullyQualifiedName": "Opsgenie API Integration Key", + "Type": "API Key", + "Metadata": { + "expires": "never" + }, + "Parent": null + }, + "Permission": { + "Value": "read", + "Parent": null + } + }, + { + "Resource": { + "Name": "Opsgenie API Integration Key", + "FullyQualifiedName": "Opsgenie API Integration Key", + "Type": "API Key", + "Metadata": { + "expires": "never" + }, + "Parent": null + }, + "Permission": { + "Value": "delete", + "Parent": null + } + }, + { + "Resource": { + "Name": "Opsgenie API Integration Key", + "FullyQualifiedName": "Opsgenie API Integration Key", + "Type": "API Key", + "Metadata": { + "expires": "never" + }, + "Parent": null + }, + "Permission": { + "Value": "create_and_update", + "Parent": null + } + } + ], + "UnboundedResources": [ + { + "Name": "John Scanner", + "FullyQualifiedName": "secretscanner02@zohomail.com", + "Type": "user", + "Metadata": { + "role": "Owner", + "username": "secretscanner02@zohomail.com" + }, + "Parent": null + } + ], + "Metadata": null + }`, + 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}) + 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/analyzer/analyzers/opsgenie/permissions.go b/pkg/analyzer/analyzers/opsgenie/permissions.go new file mode 100644 index 000000000..11824b2b4 --- /dev/null +++ b/pkg/analyzer/analyzers/opsgenie/permissions.go @@ -0,0 +1,76 @@ +// Code generated by go generate; DO NOT EDIT. +package opsgenie + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + ConfigurationAccess Permission = iota + Read Permission = iota + Delete Permission = iota + CreateAndUpdate Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + ConfigurationAccess: "configuration_access", + Read: "read", + Delete: "delete", + CreateAndUpdate: "create_and_update", + } + + StringToPermission = map[string]Permission{ + "configuration_access": ConfigurationAccess, + "read": Read, + "delete": Delete, + "create_and_update": CreateAndUpdate, + } + + PermissionIDs = map[Permission]int{ + ConfigurationAccess: 1, + Read: 2, + Delete: 3, + CreateAndUpdate: 4, + } + + IdToPermission = map[int]Permission{ + 1: ConfigurationAccess, + 2: Read, + 3: Delete, + 4: CreateAndUpdate, + } +) + +// 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/opsgenie/permissions.yaml b/pkg/analyzer/analyzers/opsgenie/permissions.yaml new file mode 100644 index 000000000..cebe131a7 --- /dev/null +++ b/pkg/analyzer/analyzers/opsgenie/permissions.yaml @@ -0,0 +1,5 @@ +permissions: + - configuration_access + - read + - delete + - create_and_update \ No newline at end of file diff --git a/pkg/analyzer/analyzers/opsgenie/scopes.json b/pkg/analyzer/analyzers/opsgenie/scopes.json index 65a7aad5c..d542fa765 100644 --- a/pkg/analyzer/analyzers/opsgenie/scopes.json +++ b/pkg/analyzer/analyzers/opsgenie/scopes.json @@ -1,6 +1,6 @@ [ { - "name": "Configuration Access", + "name": "configuration_access", "test": { "endpoint": "https://api.opsgenie.com/v2/account", "method": "GET", @@ -9,7 +9,7 @@ } }, { - "name": "Read", + "name": "read", "test": { "endpoint": "https://api.opsgenie.com/v2/alerts", "method": "GET", @@ -18,7 +18,7 @@ } }, { - "name": "Delete", + "name": "delete", "test": { "endpoint": "https://api.opsgenie.com/v2/alerts/`nowaythiscanexist", "method": "DELETE", @@ -27,7 +27,7 @@ } }, { - "name": "Create and Update", + "name": "create_and_update", "test": { "endpoint": "https://api.opsgenie.com/v2/alerts/`nowaycanthisexist/message", "method": "PUT", diff --git a/pkg/detectors/opsgenie/opsgenie.go b/pkg/detectors/opsgenie/opsgenie.go index ed0cf82bd..19ee954ea 100644 --- a/pkg/detectors/opsgenie/opsgenie.go +++ b/pkg/detectors/opsgenie/opsgenie.go @@ -87,7 +87,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result } } else { s1.Verified = false - + } + s1.AnalysisInfo = map[string]string{ + "key": resMatch, } } diff --git a/pkg/detectors/opsgenie/opsgenie_test.go b/pkg/detectors/opsgenie/opsgenie_test.go index 96aae8edf..31d5b834b 100644 --- a/pkg/detectors/opsgenie/opsgenie_test.go +++ b/pkg/detectors/opsgenie/opsgenie_test.go @@ -92,6 +92,7 @@ func TestOpsgenie_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("Opsgenie.FromData() %s diff: (-got +want)\n%s", tt.name, diff)