From 513d4ac0c8c48ec355a783707ecf0db37acab2b2 Mon Sep 17 00:00:00 2001 From: Martin Locklear Date: Wed, 7 May 2025 15:08:03 -0400 Subject: [PATCH 1/5] postman_client.IDNameUUID becomes IdNameUid (#4123) This is misleadingly named. This field actually represents a thing called `uid`, which is a field sometimes provided by the Postman API, and is usually a concatenation of a user id and an object ID. The object ID usually looks like a UUID, but this field is strictly never a UUID. So we rename the field and the struct to (which is named based on it's fields) --- pkg/sources/postman/postman.go | 14 +++++++------- pkg/sources/postman/postman_client.go | 26 +++++++++++++------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pkg/sources/postman/postman.go b/pkg/sources/postman/postman.go index 2151f4904..a499b12c9 100644 --- a/pkg/sources/postman/postman.go +++ b/pkg/sources/postman/postman.go @@ -266,18 +266,18 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu // gather and scan environment variables for _, envID := range workspace.Environments { - envVars, err := s.client.GetEnvironmentVariables(ctx, envID.UUID) + envVars, err := s.client.GetEnvironmentVariables(ctx, envID.Uid) if err != nil { - ctx.Logger().Error(err, "could not get env variables", "environment_uuid", envID.UUID) + ctx.Logger().Error(err, "could not get env variables", "environment_uuid", envID.Uid) continue } - if shouldSkip(envID.UUID, s.conn.IncludeEnvironments, s.conn.ExcludeEnvironments) { + if shouldSkip(envID.Uid, s.conn.IncludeEnvironments, s.conn.ExcludeEnvironments) { continue } metadata.Type = ENVIRONMENT_TYPE - metadata.Link = LINK_BASE_URL + "environments/" + envID.UUID + metadata.Link = LINK_BASE_URL + "environments/" + envID.Uid metadata.FullID = envVars.ID - metadata.EnvironmentID = envID.UUID + metadata.EnvironmentID = envID.Uid metadata.EnvironmentName = envVars.Name ctx.Logger().V(2).Info("scanning environment vars", "environment_uuid", metadata.FullID) @@ -300,10 +300,10 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu // at this point we have all the possible // substitutions from Environment variables for _, collectionID := range workspace.Collections { - if shouldSkip(collectionID.UUID, s.conn.IncludeCollections, s.conn.ExcludeCollections) { + if shouldSkip(collectionID.Uid, s.conn.IncludeCollections, s.conn.ExcludeCollections) { continue } - collection, err := s.client.GetCollection(ctx, collectionID.UUID) + collection, err := s.client.GetCollection(ctx, collectionID.Uid) if err != nil { // Log and move on, because sometimes the Postman API seems to give us collection IDs // that we don't have access to, so we don't want to kill the scan because of it. diff --git a/pkg/sources/postman/postman_client.go b/pkg/sources/postman/postman_client.go index b82655136..87f26f9f8 100644 --- a/pkg/sources/postman/postman_client.go +++ b/pkg/sources/postman/postman_client.go @@ -22,25 +22,25 @@ const ( ) type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Description string `json:"description"` - Visibility string `json:"visibility"` - CreatedBy string `json:"createdBy"` - UpdatedBy string `json:"updatedBy"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Collections []IDNameUUID `json:"collections"` - Environments []IDNameUUID `json:"environments"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Visibility string `json:"visibility"` + CreatedBy string `json:"createdBy"` + UpdatedBy string `json:"updatedBy"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Collections []IdNameUid `json:"collections"` + Environments []IdNameUid `json:"environments"` CollectionsRaw []Collection EnvironmentsRaw []VariableData } -type IDNameUUID struct { +type IdNameUid struct { ID string `json:"id"` Name string `json:"name"` - UUID string `json:"uid"` + Uid string `json:"uid"` } type KeyValue struct { From c4764085c5f2b5e59c6a4e36c8804cdb425ddfa2 Mon Sep 17 00:00:00 2001 From: Martin Locklear Date: Wed, 7 May 2025 15:08:27 -0400 Subject: [PATCH 2/5] Normalize UID to Uid in Postman Code (#4125) Following the convention of casing acronyms as words. --- pkg/sources/postman/postman.go | 28 +++++++++++++-------------- pkg/sources/postman/postman_client.go | 6 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/sources/postman/postman.go b/pkg/sources/postman/postman.go index a499b12c9..8de762754 100644 --- a/pkg/sources/postman/postman.go +++ b/pkg/sources/postman/postman.go @@ -318,13 +318,13 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu // scanCollection scans a collection and all its items, folders, and requests. // locally scoped Metadata is updated as we drill down into the collection. func (s *Source) scanCollection(ctx context.Context, chunksChan chan *sources.Chunk, metadata Metadata, collection Collection) { - ctx.Logger().V(2).Info("starting to scan collection", "collection_name", collection.Info.Name, "collection_uuid", collection.Info.UID) + ctx.Logger().V(2).Info("starting to scan collection", "collection_name", collection.Info.Name, "collection_uuid", collection.Info.Uid) metadata.CollectionInfo = collection.Info metadata.Type = COLLECTION_TYPE s.attemptToAddKeyword(collection.Info.Name) if !metadata.fromLocal { - metadata.FullID = metadata.CollectionInfo.UID + metadata.FullID = metadata.CollectionInfo.Uid metadata.Link = LINK_BASE_URL + COLLECTION_TYPE + "/" + metadata.FullID } @@ -361,20 +361,20 @@ func (s *Source) scanItem(ctx context.Context, chunksChan chan *sources.Chunk, c metadata.FolderName = item.Name } - if item.UID != "" { - metadata.FullID = item.UID + if item.Uid != "" { + metadata.FullID = item.Uid metadata.Link = LINK_BASE_URL + FOLDER_TYPE + "/" + metadata.FullID } // recurse through the folders for _, subItem := range item.Items { - s.scanItem(ctx, chunksChan, collection, metadata, subItem, item.UID) + s.scanItem(ctx, chunksChan, collection, metadata, subItem, item.Uid) } // The assignment of the folder ID to be the current item UID is due to wanting to assume that your current item is a folder unless you have request data inside of your item. // If your current item is a folder, you will want the folder ID to match the UID of the current item. // If your current item is a request, you will want the folder ID to match the UID of the parent folder. // If the request is at the root of a collection and has no parent folder, the folder ID will be empty. - metadata.FolderID = item.UID + metadata.FolderID = item.Uid // check if there are any requests in the folder if item.Request.Method != "" { metadata.FolderName = strings.Replace(metadata.FolderName, (" > " + item.Name), "", -1) @@ -382,13 +382,13 @@ func (s *Source) scanItem(ctx context.Context, chunksChan chan *sources.Chunk, c if metadata.FolderID == "" { metadata.FolderName = "" } - metadata.RequestID = item.UID + metadata.RequestID = item.Uid metadata.RequestName = item.Name metadata.Type = REQUEST_TYPE - if item.UID != "" { + if item.Uid != "" { // Route to API endpoint - metadata.FullID = item.UID - metadata.Link = LINK_BASE_URL + REQUEST_TYPE + "/" + item.UID + metadata.FullID = item.Uid + metadata.Link = LINK_BASE_URL + REQUEST_TYPE + "/" + item.Uid } else { // Route to collection.json metadata.FullID = item.ID @@ -624,9 +624,9 @@ func (s *Source) scanRequestBody(ctx context.Context, chunksChan chan *sources.C } func (s *Source) scanHTTPResponse(ctx context.Context, chunksChan chan *sources.Chunk, m Metadata, response Response) { - if response.UID != "" { - m.Link = LINK_BASE_URL + "example/" + response.UID - m.FullID = response.UID + if response.Uid != "" { + m.Link = LINK_BASE_URL + "example/" + response.Uid + m.FullID = response.Uid } originalType := m.Type @@ -720,7 +720,7 @@ func (s *Source) scanData(ctx context.Context, chunksChan chan *sources.Chunk, d Link: metadata.Link, WorkspaceUuid: metadata.WorkspaceUUID, WorkspaceName: metadata.WorkspaceName, - CollectionId: metadata.CollectionInfo.UID, + CollectionId: metadata.CollectionInfo.Uid, CollectionName: metadata.CollectionInfo.Name, EnvironmentId: metadata.EnvironmentID, EnvironmentName: metadata.EnvironmentName, diff --git a/pkg/sources/postman/postman_client.go b/pkg/sources/postman/postman_client.go index 87f26f9f8..440329fb1 100644 --- a/pkg/sources/postman/postman_client.go +++ b/pkg/sources/postman/postman_client.go @@ -99,7 +99,7 @@ type Info struct { Description string `json:"description"` Schema string `json:"schema"` UpdatedAt time.Time `json:"updatedAt"` - UID string `json:"uid"` //Need to use this to get the collection via API + Uid string `json:"uid"` //Need to use this to get the collection via API } type Item struct { @@ -112,7 +112,7 @@ type Item struct { Request Request `json:"request,omitempty"` Response []Response `json:"response,omitempty"` Description string `json:"description,omitempty"` - UID string `json:"uid,omitempty"` //Need to use this to get the collection via API. The UID is a concatenation of the ID and the user ID of whoever created the item. + Uid string `json:"uid,omitempty"` //Need to use this to get the collection via API. The UID is a concatenation of the ID and the user ID of whoever created the item. } type Auth struct { @@ -180,7 +180,7 @@ type Response struct { HeaderKeyValue []KeyValue HeaderString []string Body string `json:"body,omitempty"` - UID string `json:"uid,omitempty"` + Uid string `json:"uid,omitempty"` } // A Client manages communication with the Postman API. From 425c343b57dcc21b3761e3b2db80f8e3a47622eb Mon Sep 17 00:00:00 2001 From: Martin Locklear Date: Wed, 7 May 2025 15:24:08 -0400 Subject: [PATCH 3/5] Postman Code Uses Consistent Casing for Id Var Names (#4124) We were going back and forth on this. This normalizes it all - but doesn't include places where it's used as _part_ of a variable name. That change is incoming. --- pkg/sources/postman/postman.go | 18 +++++++++--------- pkg/sources/postman/postman_client.go | 14 +++++++------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/sources/postman/postman.go b/pkg/sources/postman/postman.go index 8de762754..4db10620f 100644 --- a/pkg/sources/postman/postman.go +++ b/pkg/sources/postman/postman.go @@ -146,7 +146,7 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ . if err = json.Unmarshal(contents, &env); err != nil { return err } - s.scanVariableData(ctx, chunksChan, Metadata{EnvironmentID: env.ID, EnvironmentName: env.Name, fromLocal: true, Link: envPath, LocationType: source_metadatapb.PostmanLocationType_ENVIRONMENT_VARIABLE}, env) + s.scanVariableData(ctx, chunksChan, Metadata{EnvironmentID: env.Id, EnvironmentName: env.Name, fromLocal: true, Link: envPath, LocationType: source_metadatapb.PostmanLocationType_ENVIRONMENT_VARIABLE}, env) } // Scan local collections @@ -174,7 +174,7 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ . } } basename := path.Base(workspacePath) - workspace.ID = strings.TrimSuffix(basename, filepath.Ext(basename)) + workspace.Id = strings.TrimSuffix(basename, filepath.Ext(basename)) s.scanLocalWorkspace(ctx, chunksChan, workspace, workspacePath) } @@ -219,9 +219,9 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ . } ctx.Logger().V(2).Info("enumerated workspaces", "workspaces", workspaces) for _, workspace := range workspaces { - s.SetProgressOngoing(fmt.Sprintf("Scanning workspace %s", workspace.ID), "") + s.SetProgressOngoing(fmt.Sprintf("Scanning workspace %s", workspace.Id), "") if err = s.scanWorkspace(ctx, chunksChan, workspace); err != nil { - return fmt.Errorf("error scanning workspace %s: %w", workspace.ID, err) + return fmt.Errorf("error scanning workspace %s: %w", workspace.Id, err) } } } @@ -235,12 +235,12 @@ func (s *Source) scanLocalWorkspace(ctx context.Context, chunksChan chan *source s.resetKeywords() metadata := Metadata{ - WorkspaceUUID: workspace.ID, + WorkspaceUUID: workspace.Id, fromLocal: true, } for _, environment := range workspace.EnvironmentsRaw { - metadata.Link = strings.TrimSuffix(path.Base(filePath), path.Ext(filePath)) + "/environments/" + environment.ID + ".json" + metadata.Link = strings.TrimSuffix(path.Base(filePath), path.Ext(filePath)) + "/environments/" + environment.Id + ".json" metadata.LocationType = source_metadatapb.PostmanLocationType_ENVIRONMENT_VARIABLE s.scanVariableData(ctx, chunksChan, metadata, environment) metadata.LocationType = source_metadatapb.PostmanLocationType_UNKNOWN_POSTMAN @@ -258,7 +258,7 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu // initiate metadata to track the tree structure of postman data metadata := Metadata{ - WorkspaceUUID: workspace.ID, + WorkspaceUUID: workspace.Id, WorkspaceName: workspace.Name, CreatedBy: workspace.CreatedBy, Type: "workspace", @@ -276,7 +276,7 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu } metadata.Type = ENVIRONMENT_TYPE metadata.Link = LINK_BASE_URL + "environments/" + envID.Uid - metadata.FullID = envVars.ID + metadata.FullID = envVars.Id metadata.EnvironmentID = envID.Uid metadata.EnvironmentName = envVars.Name @@ -391,7 +391,7 @@ func (s *Source) scanItem(ctx context.Context, chunksChan chan *sources.Chunk, c metadata.Link = LINK_BASE_URL + REQUEST_TYPE + "/" + item.Uid } else { // Route to collection.json - metadata.FullID = item.ID + metadata.FullID = item.Id } s.scanHTTPRequest(ctx, chunksChan, metadata, item.Request) } diff --git a/pkg/sources/postman/postman_client.go b/pkg/sources/postman/postman_client.go index 440329fb1..e934c931d 100644 --- a/pkg/sources/postman/postman_client.go +++ b/pkg/sources/postman/postman_client.go @@ -22,7 +22,7 @@ const ( ) type Workspace struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` Description string `json:"description"` @@ -38,7 +38,7 @@ type Workspace struct { } type IdNameUid struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name"` Uid string `json:"uid"` } @@ -53,7 +53,7 @@ type KeyValue struct { } type VariableData struct { - ID string `json:"id"` // For globals and envs, this is just the UUID, not the full ID. + Id string `json:"id"` // For globals and envs, this is just the UUID, not the full ID. Name string `json:"name"` KeyValues []KeyValue `json:"values"` Owner string `json:"owner"` @@ -105,7 +105,7 @@ type Info struct { type Item struct { Name string `json:"name"` Items []Item `json:"item,omitempty"` - ID string `json:"id,omitempty"` + Id string `json:"id,omitempty"` Auth Auth `json:"auth,omitempty"` Events []Event `json:"event,omitempty"` Variable []KeyValue `json:"variable,omitempty"` @@ -173,7 +173,7 @@ type URL struct { } type Response struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name,omitempty"` OriginalRequest Request `json:"originalRequest,omitempty"` HeaderRaw json.RawMessage `json:"header,omitempty"` @@ -292,11 +292,11 @@ func (c *Client) EnumerateWorkspaces(ctx context.Context) ([]Workspace, error) { } for i, workspace := range workspacesObj.Workspaces { - tempWorkspace, err := c.GetWorkspace(ctx, workspace.ID) + tempWorkspace, err := c.GetWorkspace(ctx, workspace.Id) if err != nil { // Log and move on, because sometimes the Postman API seems to give us workspace IDs // that we don't have access to, so we don't want to kill the scan because of it. - ctx.Logger().Error(err, "could not get workspace %q (%s) during enumeration", workspace.Name, workspace.ID) + ctx.Logger().Error(err, "could not get workspace %q (%s) during enumeration", workspace.Name, workspace.Id) continue } workspacesObj.Workspaces[i] = tempWorkspace From 08b7e3b4a958acc0b3e55b85c25d4e2c83da02ef Mon Sep 17 00:00:00 2001 From: Kashif Khan <70996046+kashifkhan0771@users.noreply.github.com> Date: Thu, 8 May 2025 12:59:16 +0500 Subject: [PATCH 4/5] Fastly Analyzer (#4082) * initial commit * added more apis and test cases * fixed test cases * added more apis * fixed analyzer type * Update launchdarkly_test.go --- pkg/analyzer/analyzers/analyzers.go | 2 + pkg/analyzer/analyzers/fastly/fastly.go | 185 +++++ pkg/analyzer/analyzers/fastly/fastly_test.go | 102 +++ pkg/analyzer/analyzers/fastly/models.go | 219 ++++++ pkg/analyzer/analyzers/fastly/permissions.go | 76 ++ .../analyzers/fastly/permissions.yaml | 5 + pkg/analyzer/analyzers/fastly/requests.go | 744 ++++++++++++++++++ .../analyzers/fastly/result_output.json | 294 +++++++ pkg/analyzer/cli.go | 3 + .../fastlypersonaltoken.go | 6 + .../fastlypersonaltoken_integration_test.go | 18 +- 11 files changed, 1645 insertions(+), 9 deletions(-) create mode 100644 pkg/analyzer/analyzers/fastly/fastly.go create mode 100644 pkg/analyzer/analyzers/fastly/fastly_test.go create mode 100644 pkg/analyzer/analyzers/fastly/models.go create mode 100644 pkg/analyzer/analyzers/fastly/permissions.go create mode 100644 pkg/analyzer/analyzers/fastly/permissions.yaml create mode 100644 pkg/analyzer/analyzers/fastly/requests.go create mode 100644 pkg/analyzer/analyzers/fastly/result_output.json diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 9a5f542f6..968113719 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -95,6 +95,7 @@ const ( AnalyzerTypeFigma AnalyzerTypePlaid AnalyzerTypeNetlify + AnalyzerTypeFastly // Add new items here with AnalyzerType prefix ) @@ -135,6 +136,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{ AnalyzerTypeFigma: "Figma", AnalyzerTypePlaid: "Plaid", AnalyzerTypeNetlify: "Netlify", + AnalyzerTypeFastly: "Fastly", // Add new mappings here } diff --git a/pkg/analyzer/analyzers/fastly/fastly.go b/pkg/analyzer/analyzers/fastly/fastly.go new file mode 100644 index 000000000..40dfe94dc --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/fastly.go @@ -0,0 +1,185 @@ +//go:generate generate_permissions permissions.yaml permissions.go fastly +package fastly + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +func (a Analyzer) Type() analyzers.AnalyzerType { + return analyzers.AnalyzerTypeFastly +} + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, exist := credInfo["key"] + if !exist { + return nil, fmt.Errorf("key not found in credential info") + } + + // analyze permissions + info, err := AnalyzePermissions(a.Cfg, key) + if err != nil { + return nil, err + } + + // secret info to analyzer + return secretInfoToAnalyzerResult(info), nil +} + +func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { + info, err := AnalyzePermissions(cfg, key) + if err != nil { + // just print the error in cli and continue as a partial success + color.Red("[x] Error : %s", err.Error()) + } + + if info == nil { + color.Red("[x] Error : %s", "No information found") + return + } + + color.Green("[!] Valid Fastly API key\n\n") + + if info.TokenInfo.hasGlobalScope() { + printUserInfo(info.UserInfo) + } + + printScopes(info.TokenInfo.Scopes) + + if len(info.Resources) > 0 { + printResources(info.Resources) + } + + color.Yellow("\n[i] Expires: %s", info.TokenInfo.ExpiresAt) +} + +func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { + // create http client + client := analyzers.NewAnalyzeClient(cfg) + + var secretInfo = &SecretInfo{} + + // capture the token details + if err := captureTokenInfo(client, key, secretInfo); err != nil { + return nil, err + } + + /* + Fastly defines four types of permissions. Two of these are related specifically to purging: + + - If a token has either `purge_select` or `purge_all` access, it is limited to calling purge-related APIs only. + - If a token has `global` or `global:read` access, it can call APIs that retrieve resource and user information. + */ + + if !secretInfo.TokenInfo.hasGlobalScope() { + return secretInfo, nil + } + + // capture the user information + if err := captureUserInfo(client, key, secretInfo); err != nil { + return nil, err + } + + // capture the resources + if err := captureResources(client, key, secretInfo); err != nil { + // return secretInfo as well in case of error for partial success + return secretInfo, err + } + + return secretInfo, nil +} + +// secretInfoToAnalyzerResult translate secret info to Analyzer Result +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzers.AnalyzerTypeFastly, + Metadata: map[string]any{}, + Bindings: make([]analyzers.Binding, 0), + } + + // extract information from resource to create bindings and append to result bindings + for _, resource := range info.Resources { + binding := analyzers.Binding{ + Resource: *secretInfoResourceToAnalyzerResource(resource), + Permission: analyzers.Permission{ + Value: info.TokenInfo.Scope, + }, + } + + if resource.Parent != nil { + binding.Resource.Parent = secretInfoResourceToAnalyzerResource(*resource.Parent) + } + + result.Bindings = append(result.Bindings, binding) + + } + + return &result +} + +// secretInfoResourceToAnalyzerResource translate secret info resource to analyzer resource for binding +func secretInfoResourceToAnalyzerResource(resource FastlyResource) *analyzers.Resource { + analyzerRes := analyzers.Resource{ + // make fully qualified name unique + FullyQualifiedName: resource.Type + "/" + resource.ID, + Name: resource.Name, + Type: resource.Type, + Metadata: map[string]any{}, + } + + for key, value := range resource.Metadata { + analyzerRes.Metadata[key] = value + } + + return &analyzerRes +} + +// cli print functions +func printUserInfo(user User) { + color.Yellow("[i] User Information:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"ID", "Name", "Login", "Role", "Last Active At"}) + t.AppendRow(table.Row{color.GreenString(user.ID), color.GreenString(user.Name), color.GreenString(user.Login), color.GreenString(user.Role), color.GreenString(user.LastActiveAt)}) + + t.Render() +} + +func printScopes(scopes []string) { + color.Yellow("[i] Scopes:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scopes"}) + for _, scope := range scopes { + t.AppendRow(table.Row{color.GreenString(scope)}) + } + t.Render() +} + +func printResources(resources []FastlyResource) { + color.Yellow("[i] Resources:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Name", "Type"}) + for _, resource := range resources { + t.AppendRow(table.Row{color.GreenString(resource.Name), color.GreenString(resource.Type)}) + } + + t.Render() +} diff --git a/pkg/analyzer/analyzers/fastly/fastly_test.go b/pkg/analyzer/analyzers/fastly/fastly_test.go new file mode 100644 index 000000000..022f23a61 --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/fastly_test.go @@ -0,0 +1,102 @@ +package fastly + +import ( + _ "embed" + "encoding/json" + "sort" + "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 result_output.json +var expectedOutput []byte + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + key := testSecrets.MustGetField("FASTLYPERSONALTOKEN_TOKEN") + + tests := []struct { + name string + key string + want []byte // JSON string + wantErr bool + }{ + { + name: "valid fastly token", + key: key, + want: 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}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Bindings need to be in the same order to be comparable + sortBindings(got.Bindings) + + // 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) + } + + // Bindings need to be in the same order to be comparable + sortBindings(wantObj.Bindings) + + // 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) + } + }) + } +} + +// Helper function to sort bindings +func sortBindings(bindings []analyzers.Binding) { + sort.SliceStable(bindings, func(i, j int) bool { + if bindings[i].Resource.FullyQualifiedName == bindings[j].Resource.FullyQualifiedName { + return bindings[i].Permission.Value < bindings[j].Permission.Value + } + return bindings[i].Resource.FullyQualifiedName < bindings[j].Resource.FullyQualifiedName + }) +} diff --git a/pkg/analyzer/analyzers/fastly/models.go b/pkg/analyzer/analyzers/fastly/models.go new file mode 100644 index 000000000..8432295d8 --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/models.go @@ -0,0 +1,219 @@ +package fastly + +import "sync" + +const ( + // types + TypeUserToken string = "User Token" + TypeAutomationToken string = "Automation Token" + TypeService string = "Service" + TypeSvcVersion string = "Service Version" + TypeSvcVersionACL string = "Service Version ACL" + TypeSvcVersionDict string = "Service Version Dictionary" + TypeSvcVersionBackend string = "Service Version Backend" + TypeSvcVersionDomain string = "Service Version Domain" + TypeSvcVersionHealthCheck string = "Service Version Health Check" + TypeConfigStore string = "Config Store" + TypeSecretStore string = "Secret Store" + TypeTLSPrivateKey string = "TLS Private Key" + TypeTLSCertificate string = "TLS Certificates" + TypeTLSDomain string = "TLS Domain" + TypeInvoice string = "Invoice" +) + +type SecretInfo struct { + mu sync.RWMutex + + UserInfo User + TokenInfo SelfToken + Resources []FastlyResource +} + +type FastlyResource struct { + ID string + Name string + Type string + Metadata map[string]string + Parent *FastlyResource +} + +// AppendResource append resource to secret info resource list +func (s *SecretInfo) appendResource(resource FastlyResource) { + s.mu.Lock() + defer s.mu.Unlock() + + s.Resources = append(s.Resources, resource) +} + +// listResourceByType returns a list of resources matching the given type. +func (s *SecretInfo) listResourceByType(resourceType string) []FastlyResource { + s.mu.RLock() + defer s.mu.RUnlock() + + resources := make([]FastlyResource, 0, len(s.Resources)) + for _, resource := range s.Resources { + if resource.Type == resourceType { + resources = append(resources, resource) + } + } + + return resources +} + +// API Response models + +// User is /current_user API Response +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Role string `json:"role"` + LastActiveAt string `json:"last_active_at"` +} + +// SelfToken is /tokens/self API Response +type SelfToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + LastUsedAt string `json:"last_used_at"` + ExpiresAt string `json:"expires_at"` + Scope string `json:"scope"` + Scopes []string `json:"scopes"` + Services []string `json:"services"` +} + +// hasGlobalScope returns true if any global scope is assigned to the token +func (t SelfToken) hasGlobalScope() bool { + for _, scope := range t.Scopes { + if scope == PermissionStrings[Global] || scope == PermissionStrings[GlobalRead] { + return true + } + } + + return false +} + +// TokenData is /automation-tokens API Response +type TokenData struct { + Data []Token `json:"data"` +} + +// Token is /tokens API Response +type Token struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` +} + +// Service is /service API Response +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +// Version is /service//version API Response +type Version struct { + Number int `json:"number"` + Active bool `json:"active"` + Deployed bool `json:"deployed"` + ServiceID string `json:"service_id"` +} + +// ACL is /service//version//acl API Response +type ACL struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Dictionary is the /service//version//dictionary API Response +type Dictionary struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Backend is the /service//version//backend API Response +type Backend struct { + Name string `json:"name"` + Address string `json:"address"` + Port string `json:"port"` +} + +// Domain is the /service//version//domain API Response +type Domain struct { + Name string `json:"name"` +} + +// HealthCheck is the /service//version//healthcheck API Response +type HealthCheck struct { + Name string `json:"name"` + Host string `json:"host"` + Path string `json:"path"` + Method string `json:"method"` +} + +// ConfigStore is the /resources/stores/config API Response +type ConfigStore struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// SecretStoreData is the /resources/stores/secret API Response +type SecretStoreData struct { + Data []SecretStore `json:"data"` +} + +// SecretStore is a single store in SecretStoreData +type SecretStore struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// TLSPrivateKeyData is the /tls/private_keys API Response +type TLSPrivateKeyData struct { + Data []TLSPrivateKey `json:"data"` +} + +// TLSPrivateKey is the single TLS private key in TLSPrivateKeyData +type TLSPrivateKey struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// TLSCertificatesData is the /tls/certificates API Response +type TLSCertificatesData struct { + Data []TLSCertificate `json:"data"` +} + +// TLSCertificate is the single TLS certificate in TLSCertificatesData +type TLSCertificate struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// TLSDomainsData is the /tls/domains API Response +type TLSDomainsData struct { + Data []TLSDomain `json:"data"` +} + +// TLSDomain is the single TLS Domain in TLSDomainsData +type TLSDomain struct { + ID string `json:"id"` +} + +// InvoicesData is the /billing/v3/invoices API Response +type InvoicesData struct { + Data []Invoice `json:"data"` +} + +// Invoice is the single invoice in InvoicesData +type Invoice struct { + ID string `json:"invoice_id"` + CustomerID string `json:"customer_id"` + Region string `json:"region"` + StatementNo string `json:"statement_number"` + InvoicePostedOn string `json:"invoice_posted_on"` +} diff --git a/pkg/analyzer/analyzers/fastly/permissions.go b/pkg/analyzer/analyzers/fastly/permissions.go new file mode 100644 index 000000000..58cbe7cdf --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/permissions.go @@ -0,0 +1,76 @@ +// Code generated by go generate; DO NOT EDIT. +package fastly + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + Global Permission = iota + GlobalRead Permission = iota + PurgeAll Permission = iota + PurgeSelect Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + Global: "global", + GlobalRead: "global:read", + PurgeAll: "purge_all", + PurgeSelect: "purge_select", + } + + StringToPermission = map[string]Permission{ + "global": Global, + "global:read": GlobalRead, + "purge_all": PurgeAll, + "purge_select": PurgeSelect, + } + + PermissionIDs = map[Permission]int{ + Global: 1, + GlobalRead: 2, + PurgeAll: 3, + PurgeSelect: 4, + } + + IdToPermission = map[int]Permission{ + 1: Global, + 2: GlobalRead, + 3: PurgeAll, + 4: PurgeSelect, + } +) + +// 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/fastly/permissions.yaml b/pkg/analyzer/analyzers/fastly/permissions.yaml new file mode 100644 index 000000000..749de94bb --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/permissions.yaml @@ -0,0 +1,5 @@ +permissions: + - global + - global:read + - purge_all + - purge_select diff --git a/pkg/analyzer/analyzers/fastly/requests.go b/pkg/analyzer/analyzers/fastly/requests.go new file mode 100644 index 000000000..f7f29f1b8 --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/requests.go @@ -0,0 +1,744 @@ +package fastly + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "sync" +) + +type endpoint int + +const ( + // list of endpoints + selfToken endpoint = iota + currentUser + userTokens + automationTokens + service + serviceVersions + serviceVersionACLs + serviceVersionDictionaries + serviceVersionBackends + serviceVersionDomains + serviceVersionHealthChecks + configStores + secretStores + tlsPrivateKeys + tlsCertificates + tlsDomains + invoices +) + +var ( + baseURL = "https://api.fastly.com" + + // endpoints contain Fastly API endpoints + endpoints = map[endpoint]string{ + selfToken: "/tokens/self", + currentUser: "/current_user", + userTokens: "/tokens", + automationTokens: "/automation-tokens", + service: "/service", + serviceVersions: "/service/%s/version", // require service id + serviceVersionACLs: "/service/%s/version/%s/acl", // require service id and version number + serviceVersionDictionaries: "/service/%s/version/%s/dictionary", // require service id and version number + serviceVersionBackends: "/service/%s/version/%s/backend", // require service id and version number + serviceVersionDomains: "/service/%s/version/%s/domain", // require service id and version number + serviceVersionHealthChecks: "/service/%s/version/%s/healthcheck", // require service id and version number + configStores: "/resources/stores/config", + secretStores: "/resources/stores/secret", + tlsPrivateKeys: "/tls/private_keys", + tlsCertificates: "/tls/certificates", + tlsDomains: "/tls/domains", + invoices: "/billing/v3/invoices", + + /* + API: + - /service/service_id/version/version_id/package (The use of this API is discouraged as per documentation due to limited availability release) + - /tls/bulk/certificates (The use of this API is discouraged as per documentation due to limited availability release) + - /security/workspaces (This Fastly Security API is only available to customers with access to the Next-Gen WAF product ) + - /events (This API just returns the account events like user logged in or user logged out etc) + + Utilities API Docs: + Some of these APIs are deprecated while others return same response for everyone with a global access key. + - https://www.fastly.com/documentation/reference/api/utils/ + */ + } +) + +// makeFastlyRequest send the API request to passed url with passed key as API Key and return response body and status code +func makeFastlyRequest(client *http.Client, endpoint, key string) ([]byte, int, error) { + // create request + req, err := http.NewRequest(http.MethodGet, baseURL+endpoint, http.NoBody) + if err != nil { + return nil, 0, err + } + + // add key in the header + req.Header.Add("Fastly-Key", key) + + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + responseBodyByte, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, err + } + + return responseBodyByte, resp.StatusCode, nil +} + +// captureResources try to capture all the resource that the key can access +func captureResources(client *http.Client, key string, secretInfo *SecretInfo) error { + var ( + wg sync.WaitGroup + errAggWg sync.WaitGroup + aggregatedErrs = make([]error, 0) + errChan = make(chan error, 1) + ) + + errAggWg.Add(1) + go func() { + defer errAggWg.Done() + for err := range errChan { + aggregatedErrs = append(aggregatedErrs, err) + } + }() + + // helper to launch tasks concurrently. + launchTask := func(task func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := task(); err != nil { + errChan <- err + } + }() + } + + launchTask(func() error { return captureAutomationTokens(client, key, secretInfo) }) + launchTask(func() error { return captureUserTokens(client, key, secretInfo) }) + + // capture services and their sub resources + launchTask(func() error { + if err := captureServices(client, key, secretInfo); err != nil { + return err + } + + services := secretInfo.listResourceByType(TypeService) + for _, service := range services { + if err := captureSvcVersions(client, key, service, secretInfo); err != nil { + return err + } + } + + // capture each version sub resources + versions := secretInfo.listResourceByType(TypeSvcVersion) + for _, version := range versions { + launchTask(func() error { return captureSvcVersionACLs(client, key, version, secretInfo) }) + launchTask(func() error { return captureSvcVersionDicts(client, key, version, secretInfo) }) + launchTask(func() error { return captureSvcVersionBackends(client, key, version, secretInfo) }) + launchTask(func() error { return captureSvcVersionDomains(client, key, version, secretInfo) }) + launchTask(func() error { return captureSvcVersionHealthChecks(client, key, version, secretInfo) }) + } + + return nil + }) + + launchTask(func() error { return captureConfigStores(client, key, secretInfo) }) + launchTask(func() error { return captureSecretStores(client, key, secretInfo) }) + launchTask(func() error { return capturePrivateKeys(client, key, secretInfo) }) + launchTask(func() error { return captureCertificates(client, key, secretInfo) }) + launchTask(func() error { return captureTLSDomains(client, key, secretInfo) }) + launchTask(func() error { return captureInvoices(client, key, secretInfo) }) + + wg.Wait() + close(errChan) + errAggWg.Wait() + + if len(aggregatedErrs) > 0 { + return errors.Join(aggregatedErrs...) + } + + return nil +} + +// captureTokenInfo calls `/tokens/self` API and capture the token information in secretInfo +func captureTokenInfo(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[selfToken], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var token SelfToken + + if err := json.Unmarshal(respBody, &token); err != nil { + return err + } + + if token.ExpiresAt == "" { + token.ExpiresAt = "never" + } + + secretInfo.TokenInfo = token + + return nil + case http.StatusUnauthorized: + return fmt.Errorf("invalid/expired api key") + default: + return fmt.Errorf("unexpected status code: %d for API: %s", statusCode, endpoints[selfToken]) + } +} + +// captureUserInfo calls `/current_user` API and capture the current user information in secretInfo +func captureUserInfo(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[currentUser], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var user User + + if err := json.Unmarshal(respBody, &user); err != nil { + return err + } + + secretInfo.UserInfo = user + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d for API: %s", statusCode, endpoints[currentUser]) + } +} + +// captureUserTokens calls `/tokens` API +func captureUserTokens(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[userTokens], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var tokens []Token + + if err := json.Unmarshal(respBody, &tokens); err != nil { + return err + } + + for _, token := range tokens { + resource := FastlyResource{ + ID: token.ID, + Name: token.Name, + Type: TypeUserToken, + Metadata: map[string]string{ + "Scope": token.Scope, + "Role": token.Role, + "Expires At": token.ExpiresAt, + }, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureAutomationTokens calls `/automation-tokens` API +func captureAutomationTokens(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[automationTokens], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var tokens TokenData + + if err := json.Unmarshal(respBody, &tokens); err != nil { + return err + } + + for _, token := range tokens.Data { + resource := FastlyResource{ + ID: token.ID, + Name: token.Name, + Type: TypeAutomationToken, + Metadata: map[string]string{ + "Scope": token.Scope, + "Role": token.Role, + "Expires At": token.ExpiresAt, + }, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureServices calls `/service` API +func captureServices(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[service], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var services []Service + + if err := json.Unmarshal(respBody, &services); err != nil { + return err + } + + for _, service := range services { + resource := FastlyResource{ + ID: service.ID, + Name: service.Name, + Type: TypeService, + Metadata: map[string]string{ + "Service Type": service.Type, + }, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d for API: %s", statusCode, endpoints[service]) + } +} + +// captureSvcVersions calls `/service//version` API +func captureSvcVersions(client *http.Client, key string, parentService FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersions], parentService.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var versions []Version + + if err := json.Unmarshal(respBody, &versions); err != nil { + return err + } + + for _, version := range versions { + resource := FastlyResource{ + ID: strconv.Itoa(version.Number), + Name: parentService.ID + "/version/" + strconv.Itoa(version.Number), // versions has no specific name + Type: TypeSvcVersion, + Metadata: map[string]string{"service_id": version.ServiceID}, + Parent: &parentService, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSvcVersionACLs calls `/service//version//acl` API +func captureSvcVersionACLs(client *http.Client, key string, parentVersion FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersionACLs], parentVersion.Metadata["service_id"], parentVersion.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var acls []ACL + + if err := json.Unmarshal(respBody, &acls); err != nil { + return err + } + + for _, acl := range acls { + resource := FastlyResource{ + ID: acl.ID, + Name: acl.Name, + Type: TypeSvcVersionACL, + Parent: &parentVersion, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSvcVersionDicts calls `/service//version//dictionaries` API +func captureSvcVersionDicts(client *http.Client, key string, parentVersion FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersionDictionaries], parentVersion.Metadata["service_id"], parentVersion.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var dicts []Dictionary + + if err := json.Unmarshal(respBody, &dicts); err != nil { + return err + } + + for _, dict := range dicts { + resource := FastlyResource{ + ID: dict.ID, + Name: dict.Name, + Type: TypeSvcVersionDict, + Parent: &parentVersion, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSvcVersionBackends calls `/service//version//backend` API +func captureSvcVersionBackends(client *http.Client, key string, parentVersion FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersionBackends], parentVersion.Metadata["service_id"], parentVersion.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var backends []Backend + + if err := json.Unmarshal(respBody, &backends); err != nil { + return err + } + + for _, backend := range backends { + resource := FastlyResource{ + ID: parentVersion.Metadata["service_id"] + "/version/" + parentVersion.ID + "/backend/" + backend.Name, // no specific ID + Name: backend.Name, + Type: TypeSvcVersionBackend, + Parent: &parentVersion, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSvcVersionDomains calls `/service//version//domain` API +func captureSvcVersionDomains(client *http.Client, key string, parentVersion FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersionDomains], parentVersion.Metadata["service_id"], parentVersion.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var domains []Domain + + if err := json.Unmarshal(respBody, &domains); err != nil { + return err + } + + for _, domain := range domains { + resource := FastlyResource{ + ID: parentVersion.Metadata["service_id"] + "/version/" + parentVersion.ID + "/domain/" + domain.Name, // no specific ID + Name: domain.Name, + Type: TypeSvcVersionDomain, + Parent: &parentVersion, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSvcVersionHealthChecks calls `/service//version//healthcheck` API +func captureSvcVersionHealthChecks(client *http.Client, key string, parentVersion FastlyResource, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, fmt.Sprintf(endpoints[serviceVersionHealthChecks], parentVersion.Metadata["service_id"], parentVersion.ID), key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var healthChecks []HealthCheck + + if err := json.Unmarshal(respBody, &healthChecks); err != nil { + return err + } + + for _, healthCheck := range healthChecks { + resource := FastlyResource{ + ID: parentVersion.Metadata["service_id"] + "/version/" + parentVersion.ID + "/healthcheck/" + healthCheck.Name, // no specific ID + Name: healthCheck.Name, + Type: TypeSvcVersionHealthCheck, + Parent: &parentVersion, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureConfigStores calls `/resources/stores/config` API +func captureConfigStores(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[configStores], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var configs []ConfigStore + + if err := json.Unmarshal(respBody, &configs); err != nil { + return err + } + + for _, config := range configs { + resource := FastlyResource{ + ID: config.ID, + Name: config.Name, + Type: TypeConfigStore, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureSecretStores calls `/resources/stores/secret` API +func captureSecretStores(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[secretStores], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var secretStores SecretStoreData + + if err := json.Unmarshal(respBody, &secretStores); err != nil { + return err + } + + for _, secret := range secretStores.Data { + resource := FastlyResource{ + ID: secret.ID, + Name: secret.Name, + Type: TypeSecretStore, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// capturePrivateKeys calls `/tls/private_keys` API +func capturePrivateKeys(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[tlsPrivateKeys], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var privateKeys TLSPrivateKeyData + + if err := json.Unmarshal(respBody, &privateKeys); err != nil { + return err + } + + for _, privateKey := range privateKeys.Data { + resource := FastlyResource{ + ID: privateKey.ID, + Name: privateKey.Name, + Type: TypeTLSPrivateKey, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureCertificates calls `/tls/certificates` API +func captureCertificates(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[tlsCertificates], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var certData TLSCertificatesData + + if err := json.Unmarshal(respBody, &certData); err != nil { + return err + } + + for _, cert := range certData.Data { + resource := FastlyResource{ + ID: cert.ID, + Name: cert.Name, + Type: TypeTLSCertificate, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureTLSDomains calls `/tls/domains` API +func captureTLSDomains(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[tlsDomains], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var domainData TLSDomainsData + + if err := json.Unmarshal(respBody, &domainData); err != nil { + return err + } + + for _, domain := range domainData.Data { + resource := FastlyResource{ + ID: domain.ID, + Name: domain.ID, + Type: TypeTLSDomain, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} + +// captureInvoices calls `/billing/v3/invoices` API +func captureInvoices(client *http.Client, key string, secretInfo *SecretInfo) error { + respBody, statusCode, err := makeFastlyRequest(client, endpoints[invoices], key) + if err != nil { + return err + } + + switch statusCode { + case http.StatusOK: + var invoices InvoicesData + + if err := json.Unmarshal(respBody, &invoices); err != nil { + return err + } + + for _, invoice := range invoices.Data { + resource := FastlyResource{ + ID: invoice.CustomerID + "/region/" + invoice.Region + "/statement/" + invoice.StatementNo + "/invoice/" + invoice.ID, + Name: invoice.ID, // no specific name + Type: TypeInvoice, + } + + secretInfo.appendResource(resource) + } + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil + default: + return fmt.Errorf("unexpected status code: %d", statusCode) + } +} diff --git a/pkg/analyzer/analyzers/fastly/result_output.json b/pkg/analyzer/analyzers/fastly/result_output.json new file mode 100644 index 000000000..21c84a839 --- /dev/null +++ b/pkg/analyzer/analyzers/fastly/result_output.json @@ -0,0 +1,294 @@ +{ + "AnalyzerType": 34, + "Bindings": [ + { + "Resource": { + "Name": "test", + "FullyQualifiedName": "Config Store/Q9uDqi7ODnLUrhMFifFVT4", + "Type": "Config Store", + "Metadata": {}, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "centrally-decent-lynx.edgecompute.app", + "FullyQualifiedName": "Service Version Domain/vInh5jJ0qnGdhiCO04INR7/version/1/domain/centrally-decent-lynx.edgecompute.app", + "Type": "Service Version Domain", + "Metadata": {}, + "Parent": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/1", + "FullyQualifiedName": "Service Version/1", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "centrally-decent-lynx.edgecompute.app", + "FullyQualifiedName": "Service Version Domain/vInh5jJ0qnGdhiCO04INR7/version/2/domain/centrally-decent-lynx.edgecompute.app", + "Type": "Service Version Domain", + "Metadata": {}, + "Parent": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/2", + "FullyQualifiedName": "Service Version/2", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "centrally-decent-lynx.edgecompute.app", + "FullyQualifiedName": "Service Version Domain/vInh5jJ0qnGdhiCO04INR7/version/3/domain/centrally-decent-lynx.edgecompute.app", + "Type": "Service Version Domain", + "Metadata": {}, + "Parent": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/3", + "FullyQualifiedName": "Service Version/3", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "Detectors", + "FullyQualifiedName": "Service Version Health Check/vInh5jJ0qnGdhiCO04INR7/version/3/healthcheck/Detectors", + "Type": "Service Version Health Check", + "Metadata": {}, + "Parent": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/3", + "FullyQualifiedName": "Service Version/3", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "yja0K1GNPRDNTA6vizIFK4/version/1", + "FullyQualifiedName": "Service Version/1", + "Type": "Service Version", + "Metadata": { + "service_id": "yja0K1GNPRDNTA6vizIFK4" + }, + "Parent": { + "Name": "Truffle Security's website", + "FullyQualifiedName": "Service/yja0K1GNPRDNTA6vizIFK4", + "Type": "Service", + "Metadata": { + "Service Type": "vcl" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/1", + "FullyQualifiedName": "Service Version/1", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": { + "Name": "this is a test service", + "FullyQualifiedName": "Service/vInh5jJ0qnGdhiCO04INR7", + "Type": "Service", + "Metadata": { + "Service Type": "wasm" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/2", + "FullyQualifiedName": "Service Version/2", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": { + "Name": "this is a test service", + "FullyQualifiedName": "Service/vInh5jJ0qnGdhiCO04INR7", + "Type": "Service", + "Metadata": { + "Service Type": "wasm" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "vInh5jJ0qnGdhiCO04INR7/version/3", + "FullyQualifiedName": "Service Version/3", + "Type": "Service Version", + "Metadata": { + "service_id": "vInh5jJ0qnGdhiCO04INR7" + }, + "Parent": { + "Name": "this is a test service", + "FullyQualifiedName": "Service/vInh5jJ0qnGdhiCO04INR7", + "Type": "Service", + "Metadata": { + "Service Type": "wasm" + }, + "Parent": null + } + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "this is a test service", + "FullyQualifiedName": "Service/vInh5jJ0qnGdhiCO04INR7", + "Type": "Service", + "Metadata": { + "Service Type": "wasm" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "Truffle Security's website", + "FullyQualifiedName": "Service/yja0K1GNPRDNTA6vizIFK4", + "Type": "Service", + "Metadata": { + "Service Type": "vcl" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "test-user-global", + "FullyQualifiedName": "User Token/24K13teXo9GhmaUGhwBS2V", + "Type": "User Token", + "Metadata": { + "Expires At": "2025-12-31T19:00:00Z", + "Role": "", + "Scope": "global" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "test-user-purge-select", + "FullyQualifiedName": "User Token/2782vHUyFqralr1GKmWmVF", + "Type": "User Token", + "Metadata": { + "Expires At": "", + "Role": "", + "Scope": "purge_select" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "test", + "FullyQualifiedName": "User Token/278C9jIudzPv9NC6BvZT4z", + "Type": "User Token", + "Metadata": { + "Expires At": "2025-07-22T19:00:00Z", + "Role": "", + "Scope": "global:read global" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + }, + { + "Resource": { + "Name": "integration-test", + "FullyQualifiedName": "User Token/2ICO7ArmhY8OMiiOyNpXfc", + "Type": "User Token", + "Metadata": { + "Expires At": "", + "Role": "", + "Scope": "global:read global" + }, + "Parent": null + }, + "Permission": { + "Value": "global:read global", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": {} +} \ No newline at end of file diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index 643b28fe7..cb3f72039 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -14,6 +14,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/digitalocean" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/dockerhub" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/elevenlabs" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/fastly" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/figma" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/gitlab" @@ -124,5 +125,7 @@ func Run(keyType string, secretInfo SecretInfo) { plaid.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["secret"], secretInfo.Parts["id"], secretInfo.Parts["token"]) case "netlify": netlify.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "fastly": + fastly.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) } } diff --git a/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken.go b/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken.go index 9b2866195..9ab6559c0 100644 --- a/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken.go +++ b/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken.go @@ -59,6 +59,12 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.Verified = verified s1.ExtraData = extraData s1.SetVerificationError(verificationErr, match) + + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "key": match, + } + } } results = append(results, s1) diff --git a/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken_integration_test.go b/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken_integration_test.go index 3bc602541..3bbeee3ac 100644 --- a/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken_integration_test.go +++ b/pkg/detectors/fastlypersonaltoken/fastlypersonaltoken_integration_test.go @@ -9,7 +9,8 @@ import ( "testing" "time" - "github.com/kylelemons/godebug/pretty" + "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" @@ -51,10 +52,10 @@ func TestFastlyPersonalToken_FromChunk(t *testing.T) { DetectorType: detectorspb.DetectorType_FastlyPersonalToken, Verified: true, ExtraData: map[string]string{ - "token_id": "2GUTBVFzHG2zVOMGtEpi9q", - "user_id": "2j1UhHmRhefRMNNrlxcyf5", + "token_id": "2ICO7ArmhY8OMiiOyNpXfc", + "user_id": "7anDA1ct17E8pkFAE0tJkk", "token_expires_at": "never", - "token_scope": "global:read", + "token_scope": "global:read global", }, }, }, @@ -72,7 +73,7 @@ func TestFastlyPersonalToken_FromChunk(t *testing.T) { { DetectorType: detectorspb.DetectorType_FastlyPersonalToken, Verified: false, - ExtraData: map[string]string{}, + ExtraData: nil, }, }, wantErr: false, @@ -91,8 +92,7 @@ func TestFastlyPersonalToken_FromChunk(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := Scanner{} - got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) + got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) if (err != nil) != tt.wantErr { t.Errorf("FastlyPersonalToken.FromData() error = %v, wantErr %v", err, tt.wantErr) return @@ -101,9 +101,9 @@ func TestFastlyPersonalToken_FromChunk(t *testing.T) { if len(got[i].Raw) == 0 { t.Fatalf("no raw secret present: \n %+v", got[i]) } - got[i].Raw = nil } - if diff := pretty.Compare(got, tt.want); diff != "" { + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "AnalysisInfo") + if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("FastlyPersonalToken.FromData() %s diff: (-got +want)\n%s", tt.name, diff) } }) From 18c009f4f25009ea463db36d22bb86518e360099 Mon Sep 17 00:00:00 2001 From: Abdul Basit Date: Thu, 8 May 2025 15:00:29 +0500 Subject: [PATCH 5/5] [Feat] Detector implementation for Azure API Management Direct Management Key (#3938) * azuredirectmanagementapikey detector implementation and tests * add reference url * use sanehttpclient * better regex * add prefix regex for key pattern * some refactoring * cleanup some unnecessary logic for expiry formatting. code refactoring, remove unnecessary code. updated the description of detector. reduce the expiry to 5 seconds. * improvise the regex to increase confidence level. refactor variable name for better understanding. * remove \b from regex * implemented CustomFalsePositiveChecker interface. implemented host caching if host is not reachable to avoid unnecessary extra verification calls. * add delimiter to raw v2 * make protos --- .../azuredirectmanagementkey.go | 163 ++++++++++++++++++ ...uredirectmanagementkey_integration_test.go | 129 ++++++++++++++ .../azuredirectmanagementkey_test.go | 86 +++++++++ pkg/engine/defaults/defaults.go | 2 + pkg/pb/detectorspb/detectors.pb.go | 17 +- proto/detectors.proto | 1 + 6 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey.go create mode 100644 pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_integration_test.go create mode 100644 pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_test.go diff --git a/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey.go b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey.go new file mode 100644 index 000000000..e7feda428 --- /dev/null +++ b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey.go @@ -0,0 +1,163 @@ +package azuredirectmanagementkey + +import ( + "context" + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + "time" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +const RFC3339WithoutMicroseconds = "2006-01-02T15:04:05" + +type Scanner struct { + client *http.Client + detectors.DefaultMultiPartCredentialProvider +} + +// Ensure the Scanner satisfies the interface at compile time. +var _ detectors.Detector = (*Scanner)(nil) +var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil) + +var ( + defaultClient = common.SaneHttpClient() + urlPat = regexp.MustCompile(`https://([a-z0-9][a-z0-9-]{0,48}[a-z0-9])\.management\.azure-api\.net`) // https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.APIM.Name/ + keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"azure", ".management.azure-api.net"}) + `([a-zA-Z0-9+\/]{83,85}[a-zA-Z0-9]==)`) // pattern for both Primary and secondary key + + invalidHosts = simple.NewCache[struct{}]() + noSuchHostErr = errors.New("no such host") +) + +// 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{".management.azure-api.net"} +} + +// FromData will find and optionally verify Azure Management API keys in a given set of bytes. +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { + logger := logContext.AddLogger(ctx).Logger().WithName("azuredirectmanagementkey") + dataStr := string(data) + + urlMatchesUnique := make(map[string]string) + for _, urlMatch := range urlPat.FindAllStringSubmatch(dataStr, -1) { + urlMatchesUnique[urlMatch[0]] = urlMatch[1] // urlMatch[0] is the full url, urlMatch[1] is the service name + } + keyMatchesUnique := make(map[string]struct{}) + for _, keyMatch := range keyPat.FindAllStringSubmatch(dataStr, -1) { + keyMatchesUnique[strings.TrimSpace(keyMatch[1])] = struct{}{} + } + +EndpointLoop: + for baseUrl, serviceName := range urlMatchesUnique { + for key := range keyMatchesUnique { + s1 := detectors.Result{ + DetectorType: detectorspb.DetectorType_AzureDirectManagementKey, + Raw: []byte(baseUrl), + RawV2: []byte(baseUrl + ":" + key), + } + + if verify { + if invalidHosts.Exists(baseUrl) { + logger.V(3).Info("Skipping invalid registry", "baseUrl", baseUrl) + continue EndpointLoop + } + + client := s.client + if client == nil { + client = defaultClient + } + + isVerified, verificationErr := s.verifyMatch(ctx, client, baseUrl, serviceName, key) + s1.Verified = isVerified + if verificationErr != nil { + if errors.Is(verificationErr, noSuchHostErr) { + invalidHosts.Set(baseUrl, struct{}{}) + continue EndpointLoop + } + s1.SetVerificationError(verificationErr, baseUrl) + } + } + + results = append(results, s1) + } + } + + return results, nil +} + +func (s Scanner) Type() detectorspb.DetectorType { + return detectorspb.DetectorType_AzureDirectManagementKey +} + +func (s Scanner) Description() string { + return "Azure API Management provides a direct management REST API for performing operations on selected entities, such as users, groups, products, and subscriptions." +} + +func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) { + return false, "" +} + +func (s Scanner) verifyMatch(ctx context.Context, client *http.Client, baseUrl, serviceName, key string) (bool, error) { + url := fmt.Sprintf( + "%s/subscriptions/default/resourceGroups/default/providers/Microsoft.ApiManagement/service/%s/apis?api-version=2024-05-01", + baseUrl, serviceName, + ) + accessToken, err := generateAccessToken(key) + if err != nil { + return false, err + } + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("SharedAccessSignature %s", accessToken)) + resp, err := client.Do(req) + if err != nil { + return false, nil + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized: + return false, nil + default: + return false, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode) + } +} + +// https://learn.microsoft.com/en-us/rest/api/apimanagement/apimanagementrest/azure-api-management-rest-api-authentication +func generateAccessToken(key string) (string, error) { + expiry := time.Now().UTC().Add(5 * time.Second).Format(RFC3339WithoutMicroseconds) // expire in 5 seconds + expiry = expiry + ".0000000Z" // 7 decimals microsecond's precision is must for access token + + // Construct the string-to-sign + stringToSign := fmt.Sprintf("integration\n%s", expiry) + + // Generate HMAC-SHA512 signature + h := hmac.New(sha512.New, []byte(key)) + h.Write([]byte(stringToSign)) + signature := h.Sum(nil) + + // Base64 encode the signature + encodedSignature := base64.StdEncoding.EncodeToString(signature) + + // Create the access token + accessToken := fmt.Sprintf("uid=integration&ex=%s&sn=%s", expiry, encodedSignature) + return accessToken, nil +} diff --git a/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_integration_test.go b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_integration_test.go new file mode 100644 index 000000000..06afd70bb --- /dev/null +++ b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_integration_test.go @@ -0,0 +1,129 @@ +//go:build detectors +// +build detectors + +package azuredirectmanagementkey + +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 TestAzureDirectManagementAPIKey_FromChunk(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + url := testSecrets.MustGetField("AZUREDIRECTMANAGEMENTAPI_URL") + secret := testSecrets.MustGetField("AZUREDIRECTMANAGEMENTAPI_KEY") + inactiveSecret := testSecrets.MustGetField("AZUREDIRECTMANAGEMENTAPI_KEY_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: ctx, + data: []byte(fmt.Sprintf("You can find a azure management api url %s and key %s within", url, secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_AzureDirectManagementKey, + Verified: true, + }, + }, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "found, unverified", + s: Scanner{}, + args: args{ + ctx: ctx, + data: []byte(fmt.Sprintf("You can find a azure management api url %s and key %s within but not valid", url, inactiveSecret)), // the secret would satisfy the regex but not pass validation + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_AzureDirectManagementKey, + Verified: false, + }, + }, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "not found", + s: Scanner{}, + args: args{ + ctx: ctx, + data: []byte("You cannot find the secret within"), + verify: true, + }, + want: nil, + wantErr: false, + wantVerificationErr: false, + }, + } + 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("AzureDirectManagementAPIKey.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", "Redacted", "verificationError") + if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { + t.Errorf("AzureDirectManagementAPIKey.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/azuredirectmanagementkey/azuredirectmanagementkey_test.go b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_test.go new file mode 100644 index 000000000..4185c7ecf --- /dev/null +++ b/pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey_test.go @@ -0,0 +1,86 @@ +package azuredirectmanagementkey + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" +) + +var ( + validPattern = ` + AZURE_MANGEMENT_API_KEY=UJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKpBqPeppTHCharbaMeKqKs/H4gA/go1w== + AZURE_MANAGEMENT_API_URL=https://trufflesecuritytest.management.azure-api.net + ` + invalidPattern = ` + AZURE_MANGEMENT_API_KEY=UJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKp + AZURE_MANAGEMENT_API_URL=https://trufflesecuritytest.management.azure-api.net + ` +) + +func TestAzureDirectManagementAPIKey_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pattern", + input: validPattern, + want: []string{"https://trufflesecuritytest.management.azure-api.net:UJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKpBqPeppTHCharbaMeKqKs/H4gA/go1w=="}, + }, + { + name: "invalid pattern", + input: invalidPattern, + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + if len(matchedDetectors) == 0 { + t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input) + return + } + + results, err := d.FromData(context.Background(), false, []byte(test.input)) + if err != nil { + t.Errorf("error = %v", err) + return + } + + if len(results) != len(test.want) { + if len(results) == 0 { + t.Errorf("did not receive result") + } else { + t.Errorf("expected %d results, only received %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 741816903..b15e52c31 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -77,6 +77,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azureapimanagementsubscriptionkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azurecontainerregistry" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredevopspersonalaccesstoken" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredirectmanagementkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresastoken" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchadminkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchquerykey" @@ -916,6 +917,7 @@ func buildDetectorList() []detectors.Detector { &azure_cosmosdb.Scanner{}, &azurecontainerregistry.Scanner{}, &azuredevopspersonalaccesstoken.Scanner{}, + &azuredirectmanagementkey.Scanner{}, // &azurefunctionkey.Scanner{}, // detector is throwing some FPs &azure_openai.Scanner{}, &azuresastoken.Scanner{}, diff --git a/pkg/pb/detectorspb/detectors.pb.go b/pkg/pb/detectorspb/detectors.pb.go index 4e4610330..cb6d1f46b 100644 --- a/pkg/pb/detectorspb/detectors.pb.go +++ b/pkg/pb/detectorspb/detectors.pb.go @@ -1128,6 +1128,7 @@ const ( DetectorType_Langfuse DetectorType = 1021 DetectorType_BingSubscriptionKey DetectorType = 1022 DetectorType_XAI DetectorType = 1023 + DetectorType_AzureDirectManagementKey DetectorType = 1024 ) // Enum value maps for DetectorType. @@ -2153,6 +2154,7 @@ var ( 1021: "Langfuse", 1022: "BingSubscriptionKey", 1023: "XAI", + 1024: "AzureDirectManagementKey", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3175,6 +3177,7 @@ var ( "Langfuse": 1021, "BingSubscriptionKey": 1022, "XAI": 1023, + "AzureDirectManagementKey": 1024, } ) @@ -3628,7 +3631,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, 0xdc, 0x83, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x10, 0x04, 0x2a, 0xfb, 0x83, 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, @@ -4682,11 +4685,13 @@ var file_detectors_proto_rawDesc = []byte{ 0x0d, 0x0a, 0x08, 0x4c, 0x61, 0x6e, 0x67, 0x66, 0x75, 0x73, 0x65, 0x10, 0xfd, 0x07, 0x12, 0x18, 0x0a, 0x13, 0x42, 0x69, 0x6e, 0x67, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x10, 0xfe, 0x07, 0x12, 0x08, 0x0a, 0x03, 0x58, 0x41, 0x49, 0x10, - 0xff, 0x07, 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, + 0xff, 0x07, 0x12, 0x1d, 0x0a, 0x18, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x10, 0x80, + 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 7247bdd7f..ecf815a61 100644 --- a/proto/detectors.proto +++ b/proto/detectors.proto @@ -1033,6 +1033,7 @@ enum DetectorType { Langfuse = 1021; BingSubscriptionKey = 1022; XAI = 1023; + AzureDirectManagementKey = 1024; } message Result {