anthropic api key analyzer (#3878)

* initial commit

* added anthropic api key analyzer

* added secret info type

* resolved comments
This commit is contained in:
Kashif Khan
2025-02-12 15:26:27 -06:00
committed by GitHub
parent ec42f4437c
commit 7185b31072
11 changed files with 548 additions and 50 deletions
+2
View File
@@ -61,6 +61,7 @@ const (
const (
AnalyzerTypeInvalid AnalyzerType = iota
AnalyzerTypeAirbrake
AnalyzerAnthropic
AnalyzerTypeAsana
AnalyzerTypeBitbucket
AnalyzerTypeDockerHub
@@ -90,6 +91,7 @@ const (
var analyzerTypeStrings = map[AnalyzerType]string{
AnalyzerTypeInvalid: "Invalid",
AnalyzerTypeAirbrake: "Airbrake",
AnalyzerAnthropic: "Anthropic",
AnalyzerTypeAsana: "Asana",
AnalyzerTypeBitbucket: "Bitbucket",
AnalyzerTypeDockerHub: "DockerHub",
@@ -0,0 +1,163 @@
package anthropic
import (
"errors"
"os"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/table"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
var _ analyzers.Analyzer = (*Analyzer)(nil)
const (
// Key Types
APIKey = "API-Key"
)
type Analyzer struct {
Cfg *config.Config
}
// SecretInfo hold the information about the anthropic key
type SecretInfo struct {
Valid bool
Type string // key type - TODO: Handle Anthropic Admin Keys
Reference string
AnthropicResources []AnthropicResource
Permissions string // always full_access
Misc map[string]string
}
// AnthropicResource is any resource that can be accessed with anthropic key
type AnthropicResource struct {
ID string
Name string
Type string
Metadata map[string]string
}
func (a Analyzer) Type() analyzers.AnalyzerType {
return analyzers.AnalyzerAnthropic
}
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
key, exist := credInfo["key"]
if !exist {
return nil, errors.New("key not found in credentials info")
}
secretInfo, err := AnalyzePermissions(a.Cfg, key)
if err != nil {
return nil, err
}
return secretInfoToAnalyzerResult(secretInfo), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
info, err := AnalyzePermissions(cfg, key)
if err != nil {
// just print the error in cli and continue as a partial success
color.Red("[x] Error : %s", err.Error())
}
if info == nil {
color.Red("[x] Error : %s", "No information found")
return
}
if info.Valid {
color.Green("[!] Valid Anthropic API key\n\n")
// no user information
// print full access permission
printPermission(info.Permissions)
// print resources
printAnthropicResources(info.AnthropicResources)
color.Yellow("\n[i] Expires: Never")
}
}
func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
// create a HTTP client
client := analyzers.NewAnalyzeClient(cfg)
var secretInfo = &SecretInfo{
Type: APIKey, // TODO: implement Admin-Key type as well
}
if err := listModels(client, key, secretInfo); err != nil {
return nil, err
}
if err := listMessageBatches(client, key, secretInfo); err != nil {
return nil, err
}
// anthropic key has full access only
secretInfo.Permissions = PermissionStrings[FullAccess]
secretInfo.Valid = true
return secretInfo, nil
}
// secretInfoToAnalyzerResult translate secret info to Analyzer Result
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerAnthropic,
Metadata: map[string]any{"Valid_Key": info.Valid},
Bindings: make([]analyzers.Binding, len(info.AnthropicResources)),
}
// extract information to create bindings and append to result bindings
for _, Anthropicresource := range info.AnthropicResources {
binding := analyzers.Binding{
Resource: analyzers.Resource{
Name: Anthropicresource.Name,
FullyQualifiedName: Anthropicresource.ID,
Type: Anthropicresource.Type,
Metadata: map[string]any{},
},
Permission: analyzers.Permission{
Value: info.Permissions,
},
}
for key, value := range Anthropicresource.Metadata {
binding.Resource.Metadata[key] = value
}
result.Bindings = append(result.Bindings, binding)
}
return &result
}
func printPermission(permission string) {
color.Yellow("[i] Permissions:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"Permission"})
t.AppendRow(table.Row{color.GreenString(permission)})
t.Render()
}
func printAnthropicResources(resources []AnthropicResource) {
color.Green("\n[i] Resources:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"Resource Type", "Resource ID", "Resource Name"})
for _, resource := range resources {
t.AppendRow(table.Row{color.GreenString(resource.Type), color.GreenString(resource.ID), color.GreenString(resource.Name)})
}
t.Render()
}
@@ -0,0 +1,85 @@
package anthropic
import (
_ "embed"
"encoding/json"
"testing"
"time"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
//go:embed result_output.json
var expectedOutput []byte
func TestAnalyzer_Analyze(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secret := testSecrets.MustGetField("ANTHROPIC")
tests := []struct {
name string
secret string
want []byte // JSON string
wantErr bool
}{
{
name: "valid anthropic key",
secret: secret,
want: expectedOutput,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Analyzer{Cfg: &config.Config{}}
got, err := a.Analyze(ctx, map[string]string{"key": tt.secret})
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, wantIndented []byte
gotIndented, err = json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("could not marshal got to indented JSON: %s", err)
}
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
if err != nil {
t.Fatalf("could not marshal want to indented JSON: %s", err)
}
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
}
})
}
}
@@ -0,0 +1,61 @@
// Code generated by go generate; DO NOT EDIT.
package anthropic
import "errors"
type Permission int
const (
Invalid Permission = iota
FullAccess Permission = iota
)
var (
PermissionStrings = map[Permission]string{
FullAccess: "full_access",
}
StringToPermission = map[string]Permission{
"full_access": FullAccess,
}
PermissionIDs = map[Permission]int{
FullAccess: 1,
}
IdToPermission = map[int]Permission{
1: FullAccess,
}
)
// ToString converts a Permission enum to its string representation
func (p Permission) ToString() (string, error) {
if str, ok := PermissionStrings[p]; ok {
return str, nil
}
return "", errors.New("invalid permission")
}
// ToID converts a Permission enum to its ID
func (p Permission) ToID() (int, error) {
if id, ok := PermissionIDs[p]; ok {
return id, nil
}
return 0, errors.New("invalid permission")
}
// PermissionFromString converts a string representation to its Permission enum
func PermissionFromString(s string) (Permission, error) {
if p, ok := StringToPermission[s]; ok {
return p, nil
}
return 0, errors.New("invalid permission string")
}
// PermissionFromID converts an ID to its Permission enum
func PermissionFromID(id int) (Permission, error) {
if p, ok := IdToPermission[id]; ok {
return p, nil
}
return 0, errors.New("invalid permission ID")
}
@@ -0,0 +1,2 @@
permissions:
- full_access
@@ -0,0 +1,121 @@
package anthropic
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type ModelsResponse struct {
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
} `json:"data"`
}
type MessageResponse struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
ProcessingStatus string `json:"processing_status"`
ExpiresAt string `json:"expires_at"`
ResultsURL string `json:"results_url"`
} `json:"data"`
}
// makeAnthropicRequest send the API request to passed url with passed key as API Key and return response body and status code
func makeAnthropicRequest(client *http.Client, url, key string) ([]byte, int, error) {
// create request
req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
if err != nil {
return nil, 0, err
}
// add required keys in the header
req.Header.Set("x-api-key", key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
responseBodyByte, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, err
}
return responseBodyByte, resp.StatusCode, nil
}
func listModels(client *http.Client, key string, secretInfo *SecretInfo) error {
response, statusCode, err := makeAnthropicRequest(client, "https://api.anthropic.com/v1/models", key)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var models ModelsResponse
if err := json.Unmarshal(response, &models); err != nil {
return err
}
for _, model := range models.Data {
secretInfo.AnthropicResources = append(secretInfo.AnthropicResources, AnthropicResource{
ID: model.ID,
Name: model.DisplayName,
Type: model.Type,
})
}
return nil
case http.StatusNotFound, http.StatusUnauthorized:
return fmt.Errorf("invalid/revoked api-key")
default:
return fmt.Errorf("unexpected status code: %d while fetching models", statusCode)
}
}
func listMessageBatches(client *http.Client, key string, secretInfo *SecretInfo) error {
response, statusCode, err := makeAnthropicRequest(client, "https://api.anthropic.com/v1/messages/batches", key)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var messageBatches MessageResponse
if err := json.Unmarshal(response, &messageBatches); err != nil {
return err
}
for _, messageBatch := range messageBatches.Data {
secretInfo.AnthropicResources = append(secretInfo.AnthropicResources, AnthropicResource{
ID: messageBatch.ID,
Name: "", // no name
Type: messageBatch.Type,
Metadata: map[string]string{
"expires_at": messageBatch.ExpiresAt,
"results_url": messageBatch.ResultsURL,
},
})
}
return nil
case http.StatusNotFound, http.StatusUnauthorized:
return fmt.Errorf("invalid/revoked api-key")
default:
return fmt.Errorf("unexpected status code: %d while fetching models", statusCode)
}
}
@@ -0,0 +1,90 @@
{
"AnalyzerType": 2,
"Bindings": [
{
"Resource": {
"Name": "Claude 3.5 Sonnet (New)",
"FullyQualifiedName": "claude-3-5-sonnet-20241022",
"Type": "model",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Claude 3.5 Haiku",
"FullyQualifiedName": "claude-3-5-haiku-20241022",
"Type": "model",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Claude 3.5 Sonnet (Old)",
"FullyQualifiedName": "claude-3-5-sonnet-20240620",
"Type": "model",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Claude 3 Haiku",
"FullyQualifiedName": "claude-3-haiku-20240307",
"Type": "model",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Claude 3 Opus",
"FullyQualifiedName": "claude-3-opus-20240229",
"Type": "model",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "",
"FullyQualifiedName": "msgbatch_015FDqbx29LDeVvbwwyCe314",
"Type": "message_batch",
"Metadata": {
"expires_at": "2025-02-05T07:36:34.761695+00:00",
"results_url": "https://api.anthropic.com/v1/messages/batches/msgbatch_015FDqbx29LDeVvbwwyCe314/results"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
}
],
"UnboundedResources": null,
"Metadata": {
"Valid_Key": true
}
}
+3
View File
@@ -6,6 +6,7 @@ import (
"github.com/alecthomas/kingpin/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airbrake"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/anthropic"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/asana"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/bitbucket"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/dockerhub"
@@ -91,5 +92,7 @@ func Run(keyType string, secretInfo SecretInfo) {
notion.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "dockerhub":
dockerhub.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["username"], secretInfo.Parts["pat"])
case "anthropic":
anthropic.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
}
}
+9 -30
View File
@@ -1,9 +1,7 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -56,6 +54,11 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
isVerified, err := verifyToken(ctx, client, resMatch)
s1.Verified = isVerified
s1.SetVerificationError(err, resMatch)
if s1.Verified {
s1.AnalysisInfo = map[string]string{
"key": resMatch,
}
}
}
results = append(results, s1)
@@ -64,31 +67,13 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
return results, nil
}
type response struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
func verifyToken(ctx context.Context, client *http.Client, apiKey string) (bool, error) {
body := map[string]any{
"model": "claude-3-opus-20240229",
"max_tokens": 1024,
"messages": []map[string]string{
{"role": "user", "content": "Hello, world"},
},
}
bodyBytes, err := json.Marshal(body)
// https://docs.anthropic.com/en/api/models-list
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/v1/models", http.NoBody)
if err != nil {
return false, nil
}
// https://docs.anthropic.com/claude/reference/messages_post
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(bodyBytes))
if err != nil {
return false, nil
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
@@ -103,14 +88,8 @@ func verifyToken(ctx context.Context, client *http.Client, apiKey string) (bool,
case http.StatusOK:
return true, nil
case http.StatusBadRequest:
var resp response
if err = json.NewDecoder(res.Body).Decode(&resp); err != nil {
return false, fmt.Errorf("unexpected HTTP response body: %w", err)
}
return true, nil
case http.StatusUnauthorized:
case http.StatusNotFound, http.StatusUnauthorized:
// 404 is returned if api key is disabled or not found
return false, nil
default:
@@ -104,23 +104,6 @@ func TestAnthropic_FromChunk(t *testing.T) {
wantErr: false,
wantVerificationErr: true,
},
{
name: "found, verified but unexpected api surface",
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a anthropic secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Anthropic,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -137,7 +120,7 @@ func TestAnthropic_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("Anthropic.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
+11 -2
View File
@@ -11,7 +11,16 @@ import (
)
var (
validPattern = "sk-ant-api03-abc123xyz-456def789ghij-klmnopqrstuvwx-3456yza789bcde-1234fghijklmnopby56aaaogaopaaaabc123xyzAA"
validPattern = `
System Log - Authentication Token Issued
Date: 2025-02-04 14:32:10 UTC
Server: api-secure-03.internal
Service: Anthropic API Gateway
API Key: sk-ant-api03-abc123xyz-456def789ghij-klmnopqrstuvwx-3456yza789bcde-1234fghijklmnopby56aaaogaopaaaabc123xyzAA
Log Entry:
A new API key has been generated for service authentication. Please ensure that this key remains confidential and is not exposed in any public repositories or logs.
`
invalidPattern = "sk-ant-api03-abc123xyz-456de-klMnopqrstuvwx-3456yza789bcde-1234fghijklmnopAA"
)
@@ -27,7 +36,7 @@ func TestAnthropic_Pattern(t *testing.T) {
{
name: "valid pattern",
input: validPattern,
want: []string{validPattern},
want: []string{"sk-ant-api03-abc123xyz-456def789ghij-klmnopqrstuvwx-3456yza789bcde-1234fghijklmnopby56aaaogaopaaaabc123xyzAA"},
},
{
name: "invalid pattern",