Fastly Analyzer (#4082)

* initial commit

* added more apis and test cases

* fixed test cases

* added more apis

* fixed analyzer type

* Update launchdarkly_test.go
This commit is contained in:
Kashif Khan
2025-05-08 12:59:16 +05:00
committed by GitHub
parent 425c343b57
commit 08b7e3b4a9
11 changed files with 1645 additions and 9 deletions
+2
View File
@@ -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
}
+185
View File
@@ -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()
}
@@ -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
})
}
+219
View File
@@ -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/<id>/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/<id>/version/<number>/acl API Response
type ACL struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Dictionary is the /service/<id>/version/<number>/dictionary API Response
type Dictionary struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Backend is the /service/<id>/version/<number>/backend API Response
type Backend struct {
Name string `json:"name"`
Address string `json:"address"`
Port string `json:"port"`
}
// Domain is the /service/<id>/version/<number>/domain API Response
type Domain struct {
Name string `json:"name"`
}
// HealthCheck is the /service/<id>/version/<number>/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"`
}
@@ -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")
}
@@ -0,0 +1,5 @@
permissions:
- global
- global:read
- purge_all
- purge_select
+744
View File
@@ -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/<id>/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/<id>/version/<number>/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/<id>/version/<number>/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/<id>/version/<number>/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/<id>/version/<number>/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/<id>/version/<number>/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)
}
}
@@ -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": {}
}
+3
View File
@@ -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"])
}
}
@@ -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)
@@ -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)
}
})