* 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
355 lines
10 KiB
Go
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"))
|
|
}
|