Files
trufflehog/pkg/analyzer/analyzers/datadog/datadog.go
John Elliott 761be8877b Add AnalysisError type and wrap all analyzer error paths (#4779)
* Add AnalysisError type and AnalysisErrorInfo interface

Introduce a shared error type that provides structured metadata
(analyzer type, operation, service, resource) for analysis failures.
This allows the scanner to extract context from errors without
depending on concrete types.

* Wrap errors in simple API analyzers with AnalysisError

Batch A: Airbrake, Anthropic, Asana, DigitalOcean, DockerHub,
ElevenLabs, Fastly, Groq, HuggingFace, Mailchimp, Mailgun, Mux,
Netlify, Ngrok, Notion, OpenAI, Opsgenie, Posthog, Postman,
Sendgrid, Sourcegraph.

Wraps credential validation errors with operation
"validate_credentials" and AnalyzePermissions errors with
operation "analyze_permissions".

* Wrap errors in remaining analyzers with AnalysisError (Batches B-E)

Batch B (OAuth/multi-credential): airtableoauth, airtablepat, datadog,
dropbox, figma, launchdarkly, plaid
Batch C (Complex): bitbucket, databricks, github, gitlab, jira, monday,
planetscale, shopify, slack, square, stripe, twilio
Batch D (Database): mysql, postgres (service: Database)
Batch E (PrivateKey): privatekey (service: crypto)

* Use Type().String() and constants for NewAnalysisError calls

Address PR feedback: replace hardcoded analyzer type strings with
a.Type().String() and replace raw operation/service strings with
package-level constants (OperationValidateCredentials,
OperationAnalyzePermissions, ServiceAPI, ServiceConfig, etc.).

* Omit empty resource parenthetical from AnalysisError messages

Conditionally include "(resource: ...)" only when non-empty,
avoiding cluttered messages like "... (resource: ): ..." that
appear for the majority of analyzers that don't set a resource.

* Wrap no-data error path in GitHub analyzer with AnalysisError
2026-04-14 17:18:48 -07:00

270 lines
8.1 KiB
Go

//go:generate generate_permissions permissions.yaml permissions.go datadog
package datadog
import (
"errors"
"fmt"
"net/url"
"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.AnalyzerTypeDatadog
}
// Analyze performs the analysis of the Datadog API key and returns the analyzer result.
func (a Analyzer) Analyze(ctx context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
apiKey := credInfo["api_key"]
appKey := credInfo["app_key"]
endpoint := credInfo["endpoint"]
info, err := AnalyzePermissions(a.Cfg, apiKey, appKey, endpoint)
if err != nil {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err)
}
return secretInfoToAnalyzerResult(info), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, apiKey, appKey, endpoint string) {
info, err := AnalyzePermissions(cfg, apiKey, appKey, endpoint)
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] No information retrieved")
return
}
color.Green("[i] Valid Datadog API Key\n")
printUser(info.User)
printResources(info.Resources)
printPermissions(info.Permissions)
}
// AnalyzePermissions will collect all the scopes assigned to token along with resource it can access
func AnalyzePermissions(cfg *config.Config, apiKey, appKey, endpoint string) (*SecretInfo, error) {
if apiKey == "" {
return nil, errors.New("api key not found in credentials info")
}
// create the http client
client := analyzers.NewAnalyzeClient(cfg)
var secretInfo = &SecretInfo{}
var baseURL string
var err error
// If endpoint is provided, use it directly; otherwise detect domain
if endpoint != "" {
baseURL, err = url.JoinPath(endpoint, "api")
if err != nil {
return nil, fmt.Errorf("failed to join path: %w", err)
}
} else {
baseURL, err = DetectDomain(client, apiKey, appKey)
if err != nil {
return nil, fmt.Errorf("[x] %v", err)
}
}
if appKey == "" {
// If no application key is provided, we can only validate the API key
if endpoint != "" {
// If endpoint is empty we don't need to validate again because DetectDomain would have already validated the API key against detected domain
// But if endpoint is provided, we should validate the API key against the provided endpoint to ensure it's valid before proceeding
isValidApiKey, err := ValidateApiKey(client, baseURL, apiKey)
if err != nil {
return nil, fmt.Errorf("failed to validate api key: %v", err)
}
if !isValidApiKey {
return nil, errors.New("invalid api key provided")
}
}
if err := CaptureApiKeyPermissions(secretInfo); err != nil {
return nil, fmt.Errorf("failed to fetch permissions: %v", err)
}
return secretInfo, nil
}
// capture user information in secretInfo
// If the application key is scoped, user information cannot be retrieved even if all the permissions are granted
// This is a non-documented Endpoint and can lead to unexpected behavior in future updates
// If user information is not retrieved, we will move ahead with the rest of the analysis and print the error
_ = CaptureUserInformation(client, baseURL, apiKey, appKey, secretInfo)
// capture resources in secretInfo
if err := CaptureResources(client, baseURL, apiKey, appKey, secretInfo); err != nil {
return nil, fmt.Errorf("failed to fetch resources: %v", err)
}
// capture permissions in secretInfo
if err := CapturePermissions(client, baseURL, apiKey, appKey, secretInfo); err != nil {
return nil, fmt.Errorf("failed to fetch permissions: %v", err)
}
// Capture API key permissions
if err := CaptureApiKeyPermissions(secretInfo); err != nil {
return nil, fmt.Errorf("failed to fetch permissions: %v", 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.AnalyzerTypeDatadog,
Metadata: map[string]any{},
Bindings: make([]analyzers.Binding, 0),
}
// Create user resource to use as parent
var userResource *analyzers.Resource
if info.User.Id != "" {
userResource = &analyzers.Resource{
FullyQualifiedName: info.User.Id,
Name: info.User.Name,
Type: "User",
Metadata: map[string]any{
"email": info.User.Email,
},
}
}
permissionBindings := secretInfoPermissionsToAnalyzerPermission(info.Permissions)
if userResource != nil && len(*permissionBindings) > 0 {
result.Bindings = analyzers.BindAllPermissions(*userResource, *permissionBindings...)
}
if userResource == nil && len(*permissionBindings) > 0 {
result.Bindings = analyzers.BindAllPermissions(analyzers.Resource{
FullyQualifiedName: "Unknown User",
Name: "Unknown User",
Type: "User",
Metadata: map[string]any{},
}, *permissionBindings...)
}
// Extract information from resources to create bindings
for _, resource := range info.Resources {
resource := secretInfoResourceToAnalyzerResource(resource)
// Set the user resource as parent if available
if userResource != nil {
resource.Parent = userResource
}
binding := analyzers.Binding{
Resource: *resource,
}
result.Bindings = append(result.Bindings, binding)
}
return &result
}
// secretInfoPermissionsToAnalyzerPermission translate secret info Permission to analyzer resource for binding
func secretInfoPermissionsToAnalyzerPermission(perms []Permission) *[]analyzers.Permission {
permissions := make([]analyzers.Permission, 0, len(perms))
for _, perm := range perms {
permissions = append(permissions, analyzers.Permission{
Value: perm.Title,
})
}
return &permissions
}
// secretInfoResourceToAnalyzerResource translate secret info Resource to analyzer resource for binding
func secretInfoResourceToAnalyzerResource(resource Resource) *analyzers.Resource {
analyzerRes := analyzers.Resource{
FullyQualifiedName: resource.ID,
Name: resource.Name,
Type: resource.Type,
Metadata: map[string]any{},
}
for key, value := range resource.MetaData {
analyzerRes.Metadata[key] = value
}
return &analyzerRes
}
func printUser(user User) {
if user.Id == "" {
color.Red("\n[x] User information not available")
return
}
color.Green("\n[i] User Information:")
userTable := table.NewWriter()
userTable.SetOutputMirror(os.Stdout)
userTable.AppendHeader(table.Row{"User Id", "Name", "Email"})
userTable.AppendRow(table.Row{color.GreenString(user.Id), color.GreenString(user.Name), color.GreenString(user.Email)})
userTable.Render()
}
func printResources(resources []Resource) {
if len(resources) == 0 {
color.Red("[x] No resources found")
return
}
color.Green("\n[i] Resources:")
resourceTable := table.NewWriter()
resourceTable.SetOutputMirror(os.Stdout)
resourceTable.AppendHeader(table.Row{"Name", "Type"})
for _, resource := range resources {
resourceTable.AppendRow(table.Row{
color.GreenString(resource.Name),
color.GreenString(resource.Type),
})
}
resourceTable.Render()
}
func printPermissions(permissions []Permission) {
if len(permissions) == 0 {
color.Red("[x] No permissions found")
return
}
color.Green("\n[i] Permissions:")
permissionTable := table.NewWriter()
permissionTable.SetOutputMirror(os.Stdout)
permissionTable.AppendHeader(table.Row{"Title", "Name", "Description"})
// Set wrapping for long descriptions
permissionTable.SetColumnConfigs([]table.ColumnConfig{
{Number: 3, WidthMax: 50},
})
for _, permission := range permissions {
permissionTable.AppendRow(table.Row{
color.GreenString(permission.Title),
color.GreenString(permission.Name),
color.GreenString(permission.Description),
})
}
permissionTable.Render()
}