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

355 lines
10 KiB
Go

//go:generate generate_permissions permissions.yaml permissions.go dropbox
package dropbox
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/v6/table"
_ "embed"
"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)
//go:embed scopes.json
var scopeConfigJson []byte
type Analyzer struct {
Cfg *config.Config
}
type PermissionStatus string
const (
StatusGranted PermissionStatus = "Granted"
StatusDenied PermissionStatus = "Denied"
StatusUnverified PermissionStatus = "Unverified"
)
func (a Analyzer) Type() analyzers.AnalyzerType {
return analyzers.AnalyzerTypeDropbox
}
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
token, exist := credInfo["token"]
if !exist {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("token not found in credentials info"))
}
info, err := AnalyzePermissions(a.Cfg, token)
if err != nil {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err)
}
return secretInfoToAnalyzerResult(info), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
info, err := AnalyzePermissions(cfg, token)
if err != nil {
color.Red("[x] Invalid Dropbox Token\n")
color.Red("[x] Error : %s", err.Error())
return
}
if info == nil {
color.Red("[x] Error : %s", "No information found")
return
}
color.Green("[i] Valid Dropbox OAuth2 Credentials\n")
printAccountAndPermissions(info)
}
func AnalyzePermissions(cfg *config.Config, token string) (*secretInfo, error) {
// Dropbox API uses POST requests for all requests, so we need to use an unrestricted client
client := analyzers.NewAnalyzeClientUnrestricted(cfg)
scopeConfigMap, err := getScopeConfigMap()
if err != nil {
return nil, err
}
secretInfo := &secretInfo{}
accountInfoPermission := PermissionStrings[AccountInfoRead]
for _, perm := range PermissionStrings {
scopeDetails := scopeConfigMap.Scopes[perm]
status := StatusUnverified
if perm == accountInfoPermission {
// Account Info Read permission is always enabled
status = StatusGranted
}
secretInfo.Permissions = append(secretInfo.Permissions, accountPermission{
Name: perm,
Status: status,
Actions: scopeDetails.Actions,
})
}
if err := populateAccountInfo(client, secretInfo, token); err != nil {
return nil, err
}
if err := testAllPermissions(client, secretInfo, scopeConfigMap, token); err != nil {
return nil, err
}
return secretInfo, nil
}
func populateAccountInfo(client *http.Client, info *secretInfo, token string) error {
endpoint := "/2/users/get_current_account"
body, statusCode, err := callDropboxAPIEndpoint(client, endpoint, token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
if err := json.Unmarshal([]byte(body), &info.Account); err != nil {
return fmt.Errorf("failed to unmarshal account info: %w", err)
}
return nil
default:
return fmt.Errorf("failed to validate scope. Status %d: %s", statusCode, body)
}
}
func testAllPermissions(client *http.Client, info *secretInfo, scopeConfigMap *scopeConfig, token string) error {
permissionStatuses := make(map[string]PermissionStatus)
for _, perm := range PermissionStrings {
scopeDetails := scopeConfigMap.Scopes[perm]
if _, ok := permissionStatuses[perm]; ok || scopeDetails.TestEndpoint == "" {
// Skip if the scope has already been determined or has no test endpoint
continue
}
if perm == PermissionStrings[Openid] {
// The OpenID permission can be validated using the "/2/users/get_current_account" endpoint
// If the response contains the "email" key, that implies that the "email" permission is also granted
// Similar case for the "given_name" key and the "profile" permission
body, statusCode, err := callDropboxAPIEndpoint(client, scopeDetails.TestEndpoint, token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK, http.StatusConflict:
// The endpoint responds with 409 Conflict if the openid scope
// is granted but the email and profile scopes are not granted
permissionStatuses[perm] = StatusGranted
// Check for the "email" key in the response body
if strings.Contains(body, "\"email\":") {
permissionStatuses[PermissionStrings[Email]] = StatusGranted
} else {
permissionStatuses[PermissionStrings[Email]] = StatusDenied
}
// Check for the "given_name" key in the response body
if strings.Contains(body, "\"given_name\":") {
permissionStatuses[PermissionStrings[Profile]] = StatusGranted
} else {
permissionStatuses[PermissionStrings[Profile]] = StatusDenied
}
case http.StatusUnauthorized:
permissionStatuses[perm] = StatusDenied
permissionStatuses[PermissionStrings[Email]] = StatusDenied
permissionStatuses[PermissionStrings[Profile]] = StatusDenied
}
continue
}
isGranted, err := testPermission(client, scopeDetails.TestEndpoint, token)
if err != nil {
return err
}
if !isGranted {
permissionStatuses[perm] = StatusDenied
continue
}
permissionStatuses[perm] = StatusGranted
for _, impliedScope := range scopeDetails.ImpliedScopes {
permissionStatuses[impliedScope] = StatusGranted
}
}
for idx, permission := range info.Permissions {
permission.Status = permissionStatuses[permission.Name]
info.Permissions[idx] = permission
}
return nil
}
func testPermission(client *http.Client, testEndpoint string, token string) (bool, error) {
body, statusCode, err := callDropboxAPIEndpoint(client, testEndpoint, token)
if err != nil {
return false, err
}
switch statusCode {
case http.StatusUnauthorized:
return false, nil
case http.StatusBadRequest:
if strings.Contains(body, "does not have the required scope") {
return false, nil
}
if strings.Contains(body, "your request body is empty") {
return true, nil
}
}
return false, fmt.Errorf("failed to validate scope. Status %d: %s", statusCode, body)
}
func callDropboxAPIEndpoint(client *http.Client, endpoint string, token string) (string, int, error) {
baseURL := "https://api.dropboxapi.com"
req, err := http.NewRequest(http.MethodPost, baseURL+endpoint, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
res, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return "", 0, fmt.Errorf("failed to read response body: %w", err)
}
return string(bodyBytes), res.StatusCode, nil
}
func getScopeConfigMap() (*scopeConfig, error) {
var scopeConfigMap scopeConfig
if err := json.Unmarshal(scopeConfigJson, &scopeConfigMap); err != nil {
return nil, errors.New("failed to unmarshal scopes.json: " + err.Error())
}
return &scopeConfigMap, nil
}
func secretInfoToAnalyzerResult(info *secretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
account := info.Account
accountID := account.AccountID
allPermissions := getValidatedPermissions(info)
resource := analyzers.Resource{
Name: fmt.Sprintf("%s %s", account.Name.GivenName, account.Name.Surname),
FullyQualifiedName: accountID,
Type: "account",
Metadata: map[string]any{
"email": account.Email,
"emailVerified": account.EmailVerified,
"disabled": account.Disabled,
"country": account.Country,
"accountType": account.AccountType.Tag,
},
}
analyzers.BindAllPermissions(resource, allPermissions...)
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeDropbox,
Metadata: nil,
Bindings: analyzers.BindAllPermissions(resource, allPermissions...),
}
return &result
}
func getValidatedPermissions(info *secretInfo) []analyzers.Permission {
permissions := []analyzers.Permission{}
for _, permission := range info.Permissions {
if permission.Status != StatusGranted {
continue
}
permissions = append(permissions, analyzers.Permission{
Value: permission.Name,
})
}
return permissions
}
func printAccountAndPermissions(info *secretInfo) {
color.Yellow("\n[i] Accounts Info:")
t1 := table.NewWriter()
t1.SetOutputMirror(os.Stdout)
t1.AppendHeader(table.Row{"ID", "Name", "Email", "Email Verified", "Disabled", "Country", "Account Type"})
emailVerified := "No"
disabled := "No"
if info.Account.EmailVerified {
emailVerified = "Yes"
}
if info.Account.Disabled {
disabled = "Yes"
}
t1.AppendRow(table.Row{
color.GreenString(info.Account.AccountID),
color.GreenString(info.Account.Name.GivenName + " " + info.Account.Name.Surname),
color.GreenString(info.Account.Email),
color.GreenString(emailVerified),
color.GreenString(disabled),
color.GreenString(info.Account.Country),
color.GreenString(info.Account.AccountType.Tag),
})
t1.SetOutputMirror(os.Stdout)
t1.Render()
color.Yellow("\n[i] Permissions:")
t2 := table.NewWriter()
t2.AppendHeader(table.Row{"Permission", "Access", "Actions"})
permissions := info.Permissions
for _, permission := range permissions {
access := "Denied"
permissionStatus := permission.Status
if permissionStatus == StatusGranted {
access = "Granted"
}
if permissionStatus == StatusUnverified {
access = "Unverified"
}
for idx, action := range permission.Actions {
permissionCell := ""
accessCell := ""
if idx == 0 {
permissionCell = color.GreenString(permission.Name)
accessCell = color.GreenString(access)
}
t2.AppendRow(table.Row{
permissionCell,
accessCell,
action,
})
}
t2.AppendSeparator()
}
t2.SetOutputMirror(os.Stdout)
t2.Render()
fmt.Printf("%s: https://www.dropbox.com/developers/documentation\n\n", color.GreenString("Ref"))
}