diff --git a/pkg/analyzer/analyzers/twilio/twilio.go b/pkg/analyzer/analyzers/twilio/twilio.go index 0b855a24622e..26b504d61b67 100644 --- a/pkg/analyzer/analyzers/twilio/twilio.go +++ b/pkg/analyzer/analyzers/twilio/twilio.go @@ -34,6 +34,12 @@ func (a *Analyzer) Analyze(ctx context.Context, credentialInfo map[string]string return nil, err } + // List parent and subaccounts + accounts, err := listTwilioAccounts(cfg, key) + if err != nil { + return nil, err + } + var permissions []Permission if info.AccountStatusCode == 200 { permissions = []Permission{ @@ -69,21 +75,37 @@ func (a *Analyzer) Analyze(ctx context.Context, credentialInfo map[string]string } } - // Can we get org information? - resource := analyzers.Resource{ - Name: "Twilio API", - Type: "API", - } - var bindings []analyzers.Binding - for _, perm := range permissions { - permStr, _ := perm.ToString() - bindings = append(bindings, analyzers.Binding{ - Resource: resource, - Permission: analyzers.Permission{ - Value: permStr, - }, - }) + parentAccountSID := info.ServicesRes.Services[0].AccountSID + parentAccountFriendlyName := info.ServicesRes.Services[0].FriendlyName + + for _, account := range accounts { + accountType := "Account" + if account.SID != parentAccountSID { + accountType = "SubAccount" + } + resource := analyzers.Resource{ + Name: account.FriendlyName, + FullyQualifiedName: "twilio.com/account/" + account.SID, + Type: accountType, + } + if account.SID != parentAccountSID { + resource.Parent = &analyzers.Resource{ + Name: parentAccountFriendlyName, + FullyQualifiedName: "twilio.com/account/" + parentAccountSID, + Type: "Account", + } + } + + for _, perm := range permissions { + permStr, _ := perm.ToString() + bindings = append(bindings, analyzers.Binding{ + Resource: resource, + Permission: analyzers.Permission{ + Value: permStr, + }, + }) + } } return &analyzers.AnalyzerResult{ @@ -92,12 +114,12 @@ func (a *Analyzer) Analyze(ctx context.Context, credentialInfo map[string]string }, nil } -type VerifyJSON struct { +type verifyJSON struct { Code int `json:"code"` } -type SecretInfo struct { - VerifyJson VerifyJSON +type secretInfo struct { + ServicesRes serviceResponse AccountStatusCode int } @@ -127,11 +149,6 @@ func getAccountsStatusCode(cfg *config.Config, sid string, secret string) (int, return 0, err } - // add query params - q := req.URL.Query() - q.Add("FriendlyName", "zpoOnD08HdLLZGFnGUMTxbX3qQ1kS") - req.URL.RawQuery = q.Encode() - // add basicAuth req.SetBasicAuth(sid, secret) @@ -144,10 +161,21 @@ func getAccountsStatusCode(cfg *config.Config, sid string, secret string) (int, return resp.StatusCode, nil } +type serviceResponse struct { + Code int `json:"code"` + Services []service `json:"services"` +} + +type service struct { + FriendlyName string `json:"friendly_name"` // friendly name of a service + SID string `json:"sid"` // object id of service + AccountSID string `json:"account_sid"` // account sid +} + // getVerifyServicesStatusCode returns the status code and the JSON response from the Verify Services endpoint // only the code value is captured in the JSON response and this is only shown when the key is invalid or has no permissions -func getVerifyServicesStatusCode(cfg *config.Config, sid string, secret string) (VerifyJSON, error) { - var verifyJSON VerifyJSON +func getVerifyServicesStatusCode(cfg *config.Config, sid string, secret string) (serviceResponse, error) { + var serviceRes serviceResponse // create http client client := analyzers.NewAnalyzeClient(cfg) @@ -155,13 +183,41 @@ func getVerifyServicesStatusCode(cfg *config.Config, sid string, secret string) // create request req, err := http.NewRequest("GET", "https://verify.twilio.com/v2/Services", nil) if err != nil { - return verifyJSON, err + return serviceRes, err } - // add query params - q := req.URL.Query() - q.Add("FriendlyName", "zpoOnD08HdLLZGFnGUMTxbX3qQ1kS") - req.URL.RawQuery = q.Encode() + // add basicAuth + req.SetBasicAuth(sid, secret) + + // send request + resp, err := client.Do(req) + if err != nil { + return serviceRes, err + } + defer resp.Body.Close() + + // read response + if err := json.NewDecoder(resp.Body).Decode(&serviceRes); err != nil { + return serviceRes, err + } + + return serviceRes, nil +} + +func listTwilioAccounts(cfg *config.Config, key string) ([]service, error) { + sid, secret, err := splitKey(key) + if err != nil { + return nil, err + } + + // create http client + client := analyzers.NewAnalyzeClient(cfg) + + // create request + req, err := http.NewRequest("GET", "https://api.twilio.com/2010-04-01/Accounts.json", nil) + if err != nil { + return nil, err + } // add basicAuth req.SetBasicAuth(sid, secret) @@ -169,25 +225,29 @@ func getVerifyServicesStatusCode(cfg *config.Config, sid string, secret string) // send request resp, err := client.Do(req) if err != nil { - return verifyJSON, err + return nil, err } defer resp.Body.Close() + var result struct { + Accounts []service `json:"accounts"` + } + // read response - if err := json.NewDecoder(resp.Body).Decode(&verifyJSON); err != nil { - return verifyJSON, err + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err } - return verifyJSON, nil + return result.Accounts, nil } -func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { +func AnalyzePermissions(cfg *config.Config, key string) (*secretInfo, error) { sid, secret, err := splitKey(key) if err != nil { return nil, err } - verifyJSON, err := getVerifyServicesStatusCode(cfg, sid, secret) + servicesRes, err := getVerifyServicesStatusCode(cfg, sid, secret) if err != nil { return nil, err } @@ -197,8 +257,8 @@ func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { return nil, err } - return &SecretInfo{ - VerifyJson: verifyJSON, + return &secretInfo{ + ServicesRes: servicesRes, AccountStatusCode: statusCode, }, nil } @@ -216,12 +276,12 @@ func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { return } - if info.VerifyJson.Code == INVALID_CREDENTIALS { + if info.ServicesRes.Code == INVALID_CREDENTIALS { color.Red("[x] Invalid Twilio API Key") return } - if info.VerifyJson.Code == AUTHENTICATED_NO_PERMISSION { + if info.ServicesRes.Code == AUTHENTICATED_NO_PERMISSION { printRestrictedKeyMsg() return } diff --git a/pkg/analyzer/analyzers/twilio/twilio_test.go b/pkg/analyzer/analyzers/twilio/twilio_test.go new file mode 100644 index 000000000000..34f8abeebe06 --- /dev/null +++ b/pkg/analyzer/analyzers/twilio/twilio_test.go @@ -0,0 +1,288 @@ +package twilio + +import ( + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors4") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid Twilio key", + key: testSecrets.MustGetField("TWILLIO_ID") + ":" + testSecrets.MustGetField("TWILLIO_API"), + want: ` { + "AnalyzerType": 20, + "Bindings": [ + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "account_management:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "account_management:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "subaccount_configuration:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "subaccount_configuration:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "key_management:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "key_management:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "service_verification:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "service_verification:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "sms:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "sms:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "voice:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "voice:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "messaging:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "messaging:write", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "call_management:read", + "Parent": null + } + }, + { + "Resource": { + "Name": "My first Twilio account", + "FullyQualifiedName": "twilio.com/account/ACa5b6165773490f33f226d71e7ffacff5", + "Type": "Account", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "call_management:write", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": null + }`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{} + got, err := a.Analyze(ctx, map[string]string{"key": tt.key}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = \n%s", gotIndented) + } + }) + } +} diff --git a/pkg/detectors/twilio/twilio.go b/pkg/detectors/twilio/twilio.go index 0530d38360f4..2824a3f3aeca 100644 --- a/pkg/detectors/twilio/twilio.go +++ b/pkg/detectors/twilio/twilio.go @@ -90,6 +90,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result if res.StatusCode >= 200 && res.StatusCode < 300 { s1.Verified = true + s1.AnalysisInfo = map[string]string{"key": sid + ":" + key} var serviceResponse serviceResponse if err := json.NewDecoder(res.Body).Decode(&serviceResponse); err == nil && len(serviceResponse.Services) > 0 { // no error in parsing and have at least one service service := serviceResponse.Services[0] diff --git a/pkg/detectors/twilio/twilio_test.go b/pkg/detectors/twilio/twilio_test.go index 25288e1e75ef..246fd7fcd9fa 100644 --- a/pkg/detectors/twilio/twilio_test.go +++ b/pkg/detectors/twilio/twilio_test.go @@ -55,6 +55,11 @@ func TestTwilio_FromChunk(t *testing.T) { Verified: true, Redacted: id, RawV2: []byte(id + secret), + ExtraData: map[string]string{ + "account_sid": "ACa5b6165773490f33f226d71e7ffacff5", + "friendly_name": "MyServiceName", + "rotation_guide": "https://howtorotate.com/docs/tutorials/twilio/", + }, }, }, wantErr: false, @@ -73,6 +78,9 @@ func TestTwilio_FromChunk(t *testing.T) { Verified: false, Redacted: id, RawV2: []byte(id + secretInactive), + ExtraData: map[string]string{ + "rotation_guide": "https://howtorotate.com/docs/tutorials/twilio/", + }, }, }, wantErr: false, @@ -102,6 +110,9 @@ func TestTwilio_FromChunk(t *testing.T) { Verified: false, Redacted: id, RawV2: []byte(id + secret), + ExtraData: map[string]string{ + "rotation_guide": "https://howtorotate.com/docs/tutorials/twilio/", + }, }, }, wantErr: false, @@ -121,6 +132,9 @@ func TestTwilio_FromChunk(t *testing.T) { Verified: false, Redacted: id, RawV2: []byte(id + secret), + ExtraData: map[string]string{ + "rotation_guide": "https://howtorotate.com/docs/tutorials/twilio/", + }, }, }, wantErr: false, @@ -142,7 +156,7 @@ func TestTwilio_FromChunk(t *testing.T) { t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError()) } } - ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "AnalysisInfo") if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("Twilio.FromData() %s diff: (-got +want)\n%s", tt.name, diff) }