diff --git a/pkg/detectors/twilio/twilio_test.go b/pkg/detectors/twilio/twilio_test.go index a7f30df60..2e772dba3 100644 --- a/pkg/detectors/twilio/twilio_test.go +++ b/pkg/detectors/twilio/twilio_test.go @@ -2,11 +2,19 @@ package twilio import ( "context" + "errors" "fmt" + "io" + "net/http" + "strings" + "sync/atomic" "testing" + "time" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-retryablehttp" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) @@ -81,3 +89,118 @@ func TestTwilio_Pattern(t *testing.T) { }) } } + +type errorTransport struct{ err error } + +func (t errorTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, t.err } + +func TestTwilio_VerificationDeterminacy(t *testing.T) { + data := []byte(fmt.Sprintf("twilio sid %s key %s", validSid, validKey)) + + tests := []struct { + name string + client *http.Client + wantVerified bool + wantVerificationErr bool + }{ + { + name: "authenticated rejection is determinate", + client: common.ConstantResponseHttpClient(http.StatusUnauthorized, ""), + wantVerified: false, + wantVerificationErr: false, + }, + { + name: "success is determinate", + client: common.ConstantResponseHttpClient(http.StatusOK, `{"services":[{"friendly_name":"n","sid":"s","account_sid":"a"}]}`), + wantVerified: true, + wantVerificationErr: false, + }, + { + name: "connection reset is indeterminate", + client: &http.Client{Transport: errorTransport{err: errors.New("read: connection reset by peer")}}, + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "timeout is indeterminate", + client: &http.Client{Transport: errorTransport{err: context.DeadlineExceeded}}, + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "rate limit reaching the switch is indeterminate", + client: common.ConstantResponseHttpClient(http.StatusTooManyRequests, ""), + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "server error is indeterminate", + client: common.ConstantResponseHttpClient(http.StatusInternalServerError, "{}"), + wantVerified: false, + wantVerificationErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, err := Scanner{client: test.client}.FromData(context.Background(), true, data) + if err != nil { + t.Fatalf("FromData() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + got := results[0] + if got.Verified != test.wantVerified { + t.Errorf("Verified = %v, want %v", got.Verified, test.wantVerified) + } + if (got.VerificationError() != nil) != test.wantVerificationErr { + t.Errorf("VerificationError() = %v, want error presence %v", + got.VerificationError(), test.wantVerificationErr) + } + if e := got.VerificationError(); e != nil && strings.Contains(e.Error(), validKey) { + t.Errorf("verification error leaks the credential: %v", e) + } + }) + } +} + +func TestTwilio_RetryExhaustionIsIndeterminate(t *testing.T) { + var attempts int32 + + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 3 + retryClient.Logger = nil + retryClient.RetryWaitMin = time.Millisecond + retryClient.RetryWaitMax = 2 * time.Millisecond + retryClient.HTTPClient.Transport = common.FakeTransport{ + CreateResponse: func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&attempts, 1) + return &http.Response{ + Request: req, + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }, + } + + data := []byte(fmt.Sprintf("twilio sid %s key %s", validSid, validKey)) + results, err := Scanner{client: retryClient.StandardClient()}.FromData(context.Background(), true, data) + if err != nil { + t.Fatalf("FromData() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + if got := atomic.LoadInt32(&attempts); got != 4 { + t.Errorf("expected 4 attempts (initial + RetryMax), got %d", got) + } + if results[0].Verified { + t.Error("Verified = true, want false") + } + if results[0].VerificationError() == nil { + t.Error("VerificationError() = nil; a throttled credential would be recorded as rotated") + } +} diff --git a/pkg/detectors/twilioapikey/twilioapikey_test.go b/pkg/detectors/twilioapikey/twilioapikey_test.go index 4e61f3462..3046ba421 100644 --- a/pkg/detectors/twilioapikey/twilioapikey_test.go +++ b/pkg/detectors/twilioapikey/twilioapikey_test.go @@ -2,11 +2,15 @@ package twilioapikey import ( "context" + "errors" "fmt" + "net/http" + "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) @@ -102,3 +106,79 @@ func TestTwilioAPIKey_SecretRedacted(t *testing.T) { t.Errorf("expected redacted secret to be '%s', got '%s'", validSecret[:5]+"...", results[0].Redacted) } } + +type errorTransport struct{ err error } + +func (t errorTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, t.err } + +func TestTwilioAPIKey_VerificationDeterminacy(t *testing.T) { + data := []byte(fmt.Sprintf("twilio %s %s", validAPIKey, validSecret)) + + tests := []struct { + name string + client *http.Client + wantVerified bool + wantVerificationErr bool + }{ + { + name: "authenticated rejection is determinate", + client: common.ConstantResponseHttpClient(http.StatusUnauthorized, ""), + wantVerified: false, + wantVerificationErr: false, + }, + { + name: "success is determinate", + client: common.ConstantResponseHttpClient(http.StatusOK, `{"services":[{"friendly_name":"n","sid":"s","account_sid":"a"}]}`), + wantVerified: true, + wantVerificationErr: false, + }, + { + name: "rate limit is indeterminate", + client: common.ConstantResponseHttpClient(http.StatusTooManyRequests, ""), + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "connection reset is indeterminate", + client: &http.Client{Transport: errorTransport{err: errors.New("read: connection reset by peer")}}, + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "timeout is indeterminate", + client: &http.Client{Transport: errorTransport{err: context.DeadlineExceeded}}, + wantVerified: false, + wantVerificationErr: true, + }, + { + name: "server error is indeterminate", + client: common.ConstantResponseHttpClient(http.StatusInternalServerError, "{}"), + wantVerified: false, + wantVerificationErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, err := Scanner{client: test.client}.FromData(context.Background(), true, data) + if err != nil { + t.Fatalf("FromData() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + got := results[0] + if got.Verified != test.wantVerified { + t.Errorf("Verified = %v, want %v", got.Verified, test.wantVerified) + } + if (got.VerificationError() != nil) != test.wantVerificationErr { + t.Errorf("VerificationError() = %v, want error presence %v", + got.VerificationError(), test.wantVerificationErr) + } + if e := got.VerificationError(); e != nil && strings.Contains(e.Error(), validSecret) { + t.Errorf("verification error leaks the credential: %v", e) + } + }) + } +}