diff --git a/pkg/detectors/anthropic/anthropic.go b/pkg/detectors/anthropic/anthropic.go index 20de25165..166c5d65e 100644 --- a/pkg/detectors/anthropic/anthropic.go +++ b/pkg/detectors/anthropic/anthropic.go @@ -2,8 +2,9 @@ package anthropic import ( "context" - "errors" + "encoding/json" "fmt" + "io" "net/http" "strings" @@ -59,22 +60,21 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result client = defaultClient } - isAdminKey := isAdminKey(keyMatch) - var isVerified bool - var err error + var ( + isVerified bool + verificationErr error + ) - if isAdminKey { - isVerified, err = verifyAnthropicKey(ctx, client, adminKeyEndpoint, keyMatch) + if isAdminKey(keyMatch) { s1.ExtraData["Type"] = "Admin Key" - } else if !isAdminKey { - isVerified, err = verifyAnthropicKey(ctx, client, apiKeyEndpoint, keyMatch) - s1.ExtraData["Type"] = "API Key" + isVerified, verificationErr = verifyAnthropicKey(ctx, client, adminKeyEndpoint, keyMatch) } else { - return nil, errors.New("unknown key type detected for anthropic") + s1.ExtraData["Type"] = "API Key" + isVerified, verificationErr = verifyAnthropicKey(ctx, client, apiKeyEndpoint, keyMatch) } s1.Verified = isVerified - s1.SetVerificationError(err, keyMatch) + s1.SetVerificationError(verificationErr, keyMatch) } results = append(results, s1) @@ -95,7 +95,7 @@ Endpoints: func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key string) (bool, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { - return false, nil + return false, err } req.Header.Set("x-api-key", key) @@ -106,7 +106,10 @@ func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key if err != nil { return false, err } - defer func() { _ = res.Body.Close() }() + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() switch res.StatusCode { case http.StatusOK: @@ -117,10 +120,39 @@ func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key return false, nil default: - return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + // A 400 is invalid_request_error, never a bad-key signal (that's always 401), + // so it must stay indeterminate rather than count as not-live. + return false, apiError(res) } } +// anthropicErrorResponse is the Anthropic API's error envelope for non-2xx responses. +// See https://platform.claude.com/docs/en/api/errors. +type anthropicErrorResponse struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +const maxErrorBodySize = 4 << 10 // cap how much of the error body we bother parsing + +// apiError enriches an unexpected status with the API's own error type/message when present. +func apiError(res *http.Response) error { + body, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBodySize)) + if err != nil { + return fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + } + + var apiErr anthropicErrorResponse + if err := json.Unmarshal(body, &apiErr); err != nil || apiErr.Error.Type == "" { + return fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + } + + return fmt.Errorf("unexpected HTTP response status %d (%s: %s)", + res.StatusCode, apiErr.Error.Type, apiErr.Error.Message) +} + func (s Scanner) Type() detector_typepb.DetectorType { return detector_typepb.DetectorType_Anthropic } diff --git a/pkg/detectors/anthropic/anthropic_test.go b/pkg/detectors/anthropic/anthropic_test.go index b4ea6730e..2883527af 100644 --- a/pkg/detectors/anthropic/anthropic_test.go +++ b/pkg/detectors/anthropic/anthropic_test.go @@ -2,15 +2,101 @@ package anthropic import ( "context" + "net/http" "testing" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) +func TestAnthropic_VerifyMatch(t *testing.T) { + const key = "sk-ant-api03-Dtjm9IZ_rYhS_ihHLZmPXhjJ6PN8UPp7vNO7qO3735RRDpf8xbWGinsch0McONXznUm-4KWoA7WU2otvvwHBR5QRjiLakAA" + + tests := []struct { + name string + statusCode int + body string + wantVerified bool + wantErr bool + // wantErrContains, when set, must appear in the verification error. It pins that + // the API's own explanation survives into the error rather than being discarded. + wantErrContains string + }{ + { + name: "200 is verified", + statusCode: http.StatusOK, + body: `{"data":[]}`, + wantVerified: true, + }, + { + name: "401 is determinate not-live", + statusCode: http.StatusUnauthorized, + body: `{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."}}`, + }, + { + name: "404 is determinate not-live", + statusCode: http.StatusNotFound, + body: `{"type":"error","error":{"type":"not_found_error","message":"Not found"}}`, + }, + { + // 400 is invalid_request_error: the API returns it for a malformed request, + // never for the state of the key (bad keys return 401). It must stay + // indeterminate, otherwise a request mangled in transit silently marks a live + // key as not-verified. + name: "400 stays indeterminate and keeps the API message", + statusCode: http.StatusBadRequest, + body: `{"type":"error","error":{"type":"invalid_request_error","message":"anthropic-version: header is required"}}`, + wantErr: true, + wantErrContains: "anthropic-version: header is required", + }, + { + name: "429 stays indeterminate", + statusCode: http.StatusTooManyRequests, + body: `{"type":"error","error":{"type":"rate_limit_error","message":"Number of requests has exceeded your rate limit"}}`, + wantErr: true, + wantErrContains: "rate_limit_error", + }, + { + name: "500 stays indeterminate", + statusCode: http.StatusInternalServerError, + body: `{"type":"error","error":{"type":"api_error","message":"Internal server error"}}`, + wantErr: true, + }, + { + // A gateway that returns a non-JSON body must still degrade to a plain + // status-code error rather than panicking or losing the error entirely. + name: "unparseable body still yields an error", + statusCode: http.StatusBadRequest, + body: `502 Bad Gateway`, + wantErr: true, + wantErrContains: "unexpected HTTP response status 400", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := common.ConstantResponseHttpClient(test.statusCode, test.body) + + verified, err := verifyAnthropicKey(context.Background(), client, apiKeyEndpoint, key) + + if test.wantErr { + require.Error(t, err) + if test.wantErrContains != "" { + assert.Contains(t, err.Error(), test.wantErrContains) + } + } else { + require.NoError(t, err) + } + assert.Equal(t, test.wantVerified, verified) + }) + } +} + func TestAnthropic_Pattern(t *testing.T) { d := Scanner{} ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})