Anthropic detector: surface API error detail on non-2xx verification (#5180)
This commit is contained in:
@@ -2,8 +2,9 @@ package anthropic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -59,22 +60,21 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
|
|||||||
client = defaultClient
|
client = defaultClient
|
||||||
}
|
}
|
||||||
|
|
||||||
isAdminKey := isAdminKey(keyMatch)
|
var (
|
||||||
var isVerified bool
|
isVerified bool
|
||||||
var err error
|
verificationErr error
|
||||||
|
)
|
||||||
|
|
||||||
if isAdminKey {
|
if isAdminKey(keyMatch) {
|
||||||
isVerified, err = verifyAnthropicKey(ctx, client, adminKeyEndpoint, keyMatch)
|
|
||||||
s1.ExtraData["Type"] = "Admin Key"
|
s1.ExtraData["Type"] = "Admin Key"
|
||||||
} else if !isAdminKey {
|
isVerified, verificationErr = verifyAnthropicKey(ctx, client, adminKeyEndpoint, keyMatch)
|
||||||
isVerified, err = verifyAnthropicKey(ctx, client, apiKeyEndpoint, keyMatch)
|
|
||||||
s1.ExtraData["Type"] = "API Key"
|
|
||||||
} else {
|
} 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.Verified = isVerified
|
||||||
s1.SetVerificationError(err, keyMatch)
|
s1.SetVerificationError(verificationErr, keyMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, s1)
|
results = append(results, s1)
|
||||||
@@ -95,7 +95,7 @@ Endpoints:
|
|||||||
func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key string) (bool, error) {
|
func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key string) (bool, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("x-api-key", key)
|
req.Header.Set("x-api-key", key)
|
||||||
@@ -106,7 +106,10 @@ func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() {
|
||||||
|
_, _ = io.Copy(io.Discard, res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
switch res.StatusCode {
|
switch res.StatusCode {
|
||||||
case http.StatusOK:
|
case http.StatusOK:
|
||||||
@@ -117,10 +120,39 @@ func verifyAnthropicKey(ctx context.Context, client *http.Client, endpoint, key
|
|||||||
return false, nil
|
return false, nil
|
||||||
|
|
||||||
default:
|
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 {
|
func (s Scanner) Type() detector_typepb.DetectorType {
|
||||||
return detector_typepb.DetectorType_Anthropic
|
return detector_typepb.DetectorType_Anthropic
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,101 @@ package anthropic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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/detectors"
|
||||||
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
|
"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) {
|
func TestAnthropic_Pattern(t *testing.T) {
|
||||||
d := Scanner{}
|
d := Scanner{}
|
||||||
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
|
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
|
||||||
|
|||||||
Reference in New Issue
Block a user