From 7185b31072f567ab5e400c8966b69cf4a5cd104b Mon Sep 17 00:00:00 2001 From: Kashif Khan <70996046+kashifkhan0771@users.noreply.github.com> Date: Thu, 13 Feb 2025 02:26:27 +0500 Subject: [PATCH] anthropic api key analyzer (#3878) * initial commit * added anthropic api key analyzer * added secret info type * resolved comments --- pkg/analyzer/analyzers/analyzers.go | 2 + pkg/analyzer/analyzers/anthropic/anthropic.go | 163 ++++++++++++++++++ .../analyzers/anthropic/anthropic_test.go | 85 +++++++++ .../analyzers/anthropic/permissions.go | 61 +++++++ .../analyzers/anthropic/permissions.yaml | 2 + pkg/analyzer/analyzers/anthropic/requests.go | 121 +++++++++++++ .../analyzers/anthropic/result_output.json | 90 ++++++++++ pkg/analyzer/cli.go | 3 + pkg/detectors/anthropic/anthropic.go | 39 +---- .../anthropic/anthropic_integration_test.go | 19 +- pkg/detectors/anthropic/anthropic_test.go | 13 +- 11 files changed, 548 insertions(+), 50 deletions(-) create mode 100644 pkg/analyzer/analyzers/anthropic/anthropic.go create mode 100644 pkg/analyzer/analyzers/anthropic/anthropic_test.go create mode 100644 pkg/analyzer/analyzers/anthropic/permissions.go create mode 100644 pkg/analyzer/analyzers/anthropic/permissions.yaml create mode 100644 pkg/analyzer/analyzers/anthropic/requests.go create mode 100644 pkg/analyzer/analyzers/anthropic/result_output.json diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 565118a2f..58f7bdfef 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -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", diff --git a/pkg/analyzer/analyzers/anthropic/anthropic.go b/pkg/analyzer/analyzers/anthropic/anthropic.go new file mode 100644 index 000000000..163f9a287 --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/anthropic.go @@ -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() +} diff --git a/pkg/analyzer/analyzers/anthropic/anthropic_test.go b/pkg/analyzer/analyzers/anthropic/anthropic_test.go new file mode 100644 index 000000000..19dd2d326 --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/anthropic_test.go @@ -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) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/anthropic/permissions.go b/pkg/analyzer/analyzers/anthropic/permissions.go new file mode 100644 index 000000000..225a54c8c --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/permissions.go @@ -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") +} diff --git a/pkg/analyzer/analyzers/anthropic/permissions.yaml b/pkg/analyzer/analyzers/anthropic/permissions.yaml new file mode 100644 index 000000000..8efa67e19 --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/permissions.yaml @@ -0,0 +1,2 @@ +permissions: + - full_access diff --git a/pkg/analyzer/analyzers/anthropic/requests.go b/pkg/analyzer/analyzers/anthropic/requests.go new file mode 100644 index 000000000..30db3ede4 --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/requests.go @@ -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) + } +} diff --git a/pkg/analyzer/analyzers/anthropic/result_output.json b/pkg/analyzer/analyzers/anthropic/result_output.json new file mode 100644 index 000000000..53f2c24c0 --- /dev/null +++ b/pkg/analyzer/analyzers/anthropic/result_output.json @@ -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 + } +} \ No newline at end of file diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index f7cbebcef..3221a13f5 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -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"]) } } diff --git a/pkg/detectors/anthropic/anthropic.go b/pkg/detectors/anthropic/anthropic.go index cf1d27546..7bbf24418 100644 --- a/pkg/detectors/anthropic/anthropic.go +++ b/pkg/detectors/anthropic/anthropic.go @@ -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: diff --git a/pkg/detectors/anthropic/anthropic_integration_test.go b/pkg/detectors/anthropic/anthropic_integration_test.go index 64713de61..d36c4f533 100644 --- a/pkg/detectors/anthropic/anthropic_integration_test.go +++ b/pkg/detectors/anthropic/anthropic_integration_test.go @@ -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) } diff --git a/pkg/detectors/anthropic/anthropic_test.go b/pkg/detectors/anthropic/anthropic_test.go index ba1f085ac..e05018241 100644 --- a/pkg/detectors/anthropic/anthropic_test.go +++ b/pkg/detectors/anthropic/anthropic_test.go @@ -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",