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] 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) } })