Anthropic detector: surface API error detail on non-2xx verification (#5180)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: `<html>502 Bad Gateway</html>`,
|
||||
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})
|
||||
|
||||
Reference in New Issue
Block a user