Files
trufflehog/pkg/analyzer/analyzers/sendgrid/sendgrid.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

325 lines
8.8 KiB
Go

//go:generate generate_permissions permissions.yaml permissions.go sendgrid
package sendgrid
import (
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/v6/table"
sg "github.com/sendgrid/sendgrid-go"
"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
}
type ScopesJSON struct {
Scopes []string `json:"scopes"`
}
type Profile struct {
ID int `json:"userid"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Company string `json:"company"`
Website string `json:"website"`
Country string `json:"country"`
}
type SecretInfo struct {
User Profile
RawScopes []string
Scopes []SendgridScope
}
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeSendgrid }
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
key, ok := credInfo["key"]
if !ok {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", fmt.Errorf("missing key in credInfo"),
)
}
info, err := AnalyzePermissions(a.Cfg, key)
if err != nil {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err,
)
}
return secretInfoToAnalyzerResult(info), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
info, err := AnalyzePermissions(cfg, key)
if err != nil {
color.Red("[!] Error: %v", err)
return
}
color.Green("[!] Valid Sendgrid API Key\n\n")
if slices.Contains(info.RawScopes, "user.email.read") {
color.Green("[*] Sendgrid Key Type: Full Access Key")
} else if slices.Contains(info.RawScopes, "billing.read") {
color.Yellow("[*] Sendgrid Key Type: Billing Access Key")
} else {
color.Yellow("[*] Sendgrid Key Type: Restricted Access Key")
}
if slices.Contains(info.RawScopes, "2fa_required") {
color.Yellow("[i] 2FA Required for this account")
}
if info.User.FirstName != "" {
printProfile(info.User)
}
printPermissions(info, cfg.ShowAll)
}
func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
// Setup custom HTTP client so we can log requests.
sg.DefaultClient.HTTPClient = analyzers.NewAnalyzeClient(cfg)
// get scopes
rawScopes, err := getScopes(key)
if err != nil {
return nil, err
}
categoryScope := processPermissions(rawScopes)
var secretInfo = &SecretInfo{
RawScopes: rawScopes,
Scopes: categoryScope,
}
if slices.Contains(rawScopes, "user.email.read") {
profile, err := getProfile(key)
if err != nil {
// if get profile fails return secretInfo with scopes for partial success
return secretInfo, nil
}
secretInfo.User = *profile
}
return secretInfo, nil
}
func getScopes(key string) ([]string, error) {
req := sg.GetRequest(key, "/v3/scopes", "https://api.sendgrid.com")
req.Method = "GET"
resp, err := sg.API(req)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, fmt.Errorf("invalid api key")
} else if resp.StatusCode != 200 {
return nil, fmt.Errorf("%v", resp.StatusCode)
}
if err != nil {
return nil, err
}
// Unmarshal the JSON response into a struct
var jsonScopes ScopesJSON
if err := json.Unmarshal([]byte(resp.Body), &jsonScopes); err != nil {
return nil, err
}
return jsonScopes.Scopes, nil
}
func getProfile(key string) (*Profile, error) {
req := sg.GetRequest(key, "/v3/user/profile", "https://api.sendgrid.com")
req.Method = "GET"
resp, err := sg.API(req)
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, fmt.Errorf("invalid api key")
} else if resp.StatusCode != 200 {
return nil, fmt.Errorf("%v", resp.StatusCode)
}
if err != nil {
return nil, err
}
// Unmarshal the JSON response into a struct
var profile Profile
if err := json.Unmarshal([]byte(resp.Body), &profile); err != nil {
return nil, err
}
return &profile, nil
}
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
var keyType string
if slices.Contains(info.RawScopes, "user.email.read") {
keyType = "full access"
} else if slices.Contains(info.RawScopes, "billing.read") {
keyType = "billing access"
} else {
keyType = "restricted access"
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeSendgrid,
Metadata: map[string]any{
"key_type": keyType,
"2fa_required": slices.Contains(info.RawScopes, "2fa_required"),
},
Bindings: []analyzers.Binding{},
UnboundedResources: []analyzers.Resource{},
}
// add profile information to analyzer result
if info.User.ID != 0 && info.User.FirstName != "" {
result.Bindings = append(result.Bindings, analyzers.Binding{
Resource: analyzers.Resource{
Name: info.User.FirstName + " " + info.User.LastName,
FullyQualifiedName: fmt.Sprintf("%d", info.User.ID),
Type: "User",
},
Permission: analyzers.Permission{
Value: "full_access", // if token has all permissions than we can get user information
},
})
}
for _, scope := range info.Scopes {
resource := getCategoryResource(scope)
if len(scope.Permissions) == 0 {
result.UnboundedResources = append(result.UnboundedResources, *resource)
continue
}
for _, permission := range scope.Permissions {
result.Bindings = append(result.Bindings, analyzers.Binding{
Resource: *resource,
Permission: analyzers.Permission{
Value: permission,
},
})
}
}
return &result
}
func getCategoryResource(scope SendgridScope) *analyzers.Resource {
categoryResource := &analyzers.Resource{
Name: scope.Category,
FullyQualifiedName: scope.Category,
Type: "category",
Metadata: nil,
}
if scope.SubCategory != "" {
return &analyzers.Resource{
Name: scope.SubCategory,
FullyQualifiedName: fmt.Sprintf("%s/%s", scope.Category, scope.SubCategory),
Type: "category",
Metadata: nil,
Parent: categoryResource,
}
}
return categoryResource
}
// getCategoryFromScope returns the category for a given scope.
// It will return the most specific category possible.
// For example, if the scope is "mail.send.read", it will return "Mail Send", not just "Mail"
// since it's searching "mail.send.read" -> "mail.send" -> "mail"
func getScopeIndex(categories []SendgridScope, scope string) int {
splitScope := strings.Split(scope, ".")
for i := len(splitScope); i > 0; i-- {
searchScope := strings.Join(splitScope[:i], ".")
for i, s := range categories {
for _, prefix := range s.Prefixes {
if strings.HasPrefix(searchScope, prefix) {
return i
}
}
}
}
return -1
}
func processPermissions(rawScopes []string) []SendgridScope {
categoryPermissions := make([]SendgridScope, len(SCOPES))
// copy all scope categories to the categoryPermissions slice
copy(categoryPermissions, SCOPES)
for _, scope := range rawScopes {
// Skip these scopes since they are not useful for this analysis
if scope == "2fa_required" || scope == "sender_verification_eligible" {
continue
}
// must be part of generated permissions
if _, ok := StringToPermission[scope]; !ok {
continue
}
ind := getScopeIndex(categoryPermissions, scope)
if ind == -1 {
//color.Red("[!] Scope not found: %v", scope)
continue
}
s := &categoryPermissions[ind]
s.AddPermission(scope)
}
// Run tests to determine the permission type
for i := range categoryPermissions {
categoryPermissions[i].RunTests()
}
return categoryPermissions
}
func printProfile(profile Profile) {
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"UserID", "Name", "Company", "Website", "Country"})
t.AppendRow(table.Row{profile.ID, profile.FirstName + " " + profile.LastName, profile.Company, profile.Website, profile.Country})
t.Render()
}
func printPermissions(info *SecretInfo, show_all bool) {
fmt.Print("\n\n")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
if show_all {
t.AppendHeader(table.Row{"Scope", "Sub-Scope", "Access", "Permissions"})
} else {
t.AppendHeader(table.Row{"Scope", "Sub-Scope", "Access"})
}
// Print the scopes
for _, s := range info.Scopes {
writer := analyzers.GetWriterFromStatus(s.PermissionType)
if show_all {
t.AppendRow([]interface{}{writer(s.Category), writer(s.SubCategory), writer(s.PermissionType), writer(strings.Join(s.Permissions, "\n"))})
} else if s.PermissionType != analyzers.NONE {
t.AppendRow([]interface{}{writer(s.Category), writer(s.SubCategory), writer(s.PermissionType)})
}
}
t.Render()
fmt.Print("\n\n")
}