* 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
325 lines
8.8 KiB
Go
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")
|
|
}
|