From 39efd83a6a8ae2f7dac7cf3608520afa7455de05 Mon Sep 17 00:00:00 2001 From: Tianyi Wang Date: Wed, 10 Jan 2024 15:59:11 -0500 Subject: [PATCH 1/3] Add accountID into identity resolving --- aws/credentials.go | 3 ++ config/env_config.go | 3 ++ config/env_config_test.go | 11 +++++ config/shared_config.go | 3 ++ config/shared_config_test.go | 13 ++++++ config/testdata/shared_config | 5 +++ .../endpointcreds/internal/client/client.go | 1 + credentials/endpointcreds/provider.go | 1 + credentials/endpointcreds/provider_test.go | 6 ++- credentials/processcreds/provider.go | 4 ++ credentials/processcreds/provider_test.go | 18 ++++++++ .../processcreds/testdata/accountid.json | 6 +++ .../ssocreds/sso_credentials_provider.go | 1 + .../ssocreds/sso_credentials_provider_test.go | 2 + credentials/stscreds/assume_role_provider.go | 7 +++ .../stscreds/assume_role_provider_test.go | 6 +++ credentials/stscreds/web_identity_provider.go | 15 +++++++ .../stscreds/web_identity_provider_test.go | 44 +++++++++++++++++++ 18 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 credentials/processcreds/testdata/accountid.json diff --git a/aws/credentials.go b/aws/credentials.go index 714d4ad85cb..a8d58ff460e 100644 --- a/aws/credentials.go +++ b/aws/credentials.go @@ -90,6 +90,9 @@ type Credentials struct { // The time the credentials will expire at. Should be ignored if CanExpire // is false. Expires time.Time + + // AWS Account ID resolved from identity and used for optional endpoint2.0 routing + AccountID string } // Expired returns if the credentials have expired. diff --git a/config/env_config.go b/config/env_config.go index 88550198cce..d160cb34576 100644 --- a/config/env_config.go +++ b/config/env_config.go @@ -80,6 +80,8 @@ const ( awsRequestMinCompressionSizeBytes = "AWS_REQUEST_MIN_COMPRESSION_SIZE_BYTES" awsS3DisableExpressSessionAuthEnv = "AWS_S3_DISABLE_EXPRESS_SESSION_AUTH" + + awsAccountID = "AWS_ACCOUNT_ID" ) var ( @@ -309,6 +311,7 @@ func NewEnvConfig() (EnvConfig, error) { setStringFromEnvVal(&creds.AccessKeyID, credAccessEnvKeys) setStringFromEnvVal(&creds.SecretAccessKey, credSecretEnvKeys) if creds.HasKeys() { + creds.AccountID = os.Getenv(awsAccountID) creds.SessionToken = os.Getenv(awsSessionTokenEnvVar) cfg.Credentials = creds } diff --git a/config/env_config_test.go b/config/env_config_test.go index bf874552b4b..37f2c8d21d9 100644 --- a/config/env_config_test.go +++ b/config/env_config_test.go @@ -81,6 +81,17 @@ func TestNewEnvConfig_Creds(t *testing.T) { Source: CredentialsSourceName, }, }, + { + Env: map[string]string{ + "AWS_ACCESS_KEY_ID": "AKID", + "AWS_SECRET_ACCESS_KEY": "SECRET", + "AWS_ACCOUNT_ID": "012345678901", + }, + Val: aws.Credentials{ + AccessKeyID: "AKID", SecretAccessKey: "SECRET", AccountID: "012345678901", + Source: CredentialsSourceName, + }, + }, } for i, c := range cases { diff --git a/config/shared_config.go b/config/shared_config.go index c546cb7d0f5..0d4f570b721 100644 --- a/config/shared_config.go +++ b/config/shared_config.go @@ -115,6 +115,8 @@ const ( requestMinCompressionSizeBytes = "request_min_compression_size_bytes" s3DisableExpressSessionAuthKey = "s3_disable_express_session_auth" + + accountID = "aws_account_id" ) // defaultSharedConfigProfile allows for swapping the default profile for testing @@ -1130,6 +1132,7 @@ func (c *SharedConfig) setFromIniSection(profile string, section ini.Section) er SecretAccessKey: section.String(secretAccessKey), SessionToken: section.String(sessionTokenKey), Source: fmt.Sprintf("SharedConfigCredentials: %s", section.SourceFile[accessKeyIDKey]), + AccountID: section.String(accountID), } if creds.HasKeys() { diff --git a/config/shared_config_test.go b/config/shared_config_test.go index 4bf531b0f85..07881bf7c73 100644 --- a/config/shared_config_test.go +++ b/config/shared_config_test.go @@ -730,6 +730,19 @@ func TestNewSharedConfig(t *testing.T) { }, }, }, + "profile with aws account ID": { + ConfigFilenames: []string{testConfigFilename}, + Profile: "account_id", + Expected: SharedConfig{ + Profile: "account_id", + Credentials: aws.Credentials{ + AccessKeyID: "account_id_akid", + SecretAccessKey: "account_id_secret", + Source: fmt.Sprintf("SharedConfigCredentials: %s", testConfigFilename), + AccountID: "012345678901", + }, + }, + }, } for name, c := range cases { diff --git a/config/testdata/shared_config b/config/testdata/shared_config index ba5f80644e5..6d765890514 100644 --- a/config/testdata/shared_config +++ b/config/testdata/shared_config @@ -317,3 +317,8 @@ s3 = other = foo ec2 = endpoint_url = http://127.0.0.1:81 + +[profile account_id] +aws_access_key_id = account_id_akid +aws_secret_access_key = account_id_secret +aws_account_id = 012345678901 diff --git a/credentials/endpointcreds/internal/client/client.go b/credentials/endpointcreds/internal/client/client.go index 9a869f89547..dc291c97cd7 100644 --- a/credentials/endpointcreds/internal/client/client.go +++ b/credentials/endpointcreds/internal/client/client.go @@ -128,6 +128,7 @@ type GetCredentialsOutput struct { AccessKeyID string SecretAccessKey string Token string + AccountID string } // EndpointError is an error returned from the endpoint service diff --git a/credentials/endpointcreds/provider.go b/credentials/endpointcreds/provider.go index 0c3c4d68266..2386153a9ec 100644 --- a/credentials/endpointcreds/provider.go +++ b/credentials/endpointcreds/provider.go @@ -152,6 +152,7 @@ func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) { SecretAccessKey: resp.SecretAccessKey, SessionToken: resp.Token, Source: ProviderName, + AccountID: resp.AccountID, } if resp.Expiration != nil { diff --git a/credentials/endpointcreds/provider_test.go b/credentials/endpointcreds/provider_test.go index 43a3451cc93..0bc4cc6a306 100644 --- a/credentials/endpointcreds/provider_test.go +++ b/credentials/endpointcreds/provider_test.go @@ -79,7 +79,8 @@ func TestRetrieveStaticCredentials(t *testing.T) { StatusCode: 200, Body: ioutil.NopCloser(bytes.NewReader([]byte(`{ "AccessKeyID": "AKID", - "SecretAccessKey": "SECRET" + "SecretAccessKey": "SECRET", + "AccountID": "012345678901" }`))), }, nil }) @@ -96,6 +97,9 @@ func TestRetrieveStaticCredentials(t *testing.T) { if e, a := "SECRET", creds.SecretAccessKey; e != a { t.Errorf("expect %v, got %v", e, a) } + if e, a := "012345678901", creds.AccountID; e != a { + t.Errorf("expect account ID to be %v, got %v", e, a) + } if v := creds.SessionToken; len(v) != 0 { t.Errorf("expect empty, got %v", v) } diff --git a/credentials/processcreds/provider.go b/credentials/processcreds/provider.go index fe9345e287c..6d758720d39 100644 --- a/credentials/processcreds/provider.go +++ b/credentials/processcreds/provider.go @@ -167,6 +167,9 @@ type CredentialProcessResponse struct { // The date on which the current credentials expire. Expiration *time.Time + + // The aws account ID for this op and could be used for endpoint2.0 routing + AccountID string `json:"AccountId"` } // Retrieve executes the credential process command and returns the @@ -208,6 +211,7 @@ func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) { AccessKeyID: resp.AccessKeyID, SecretAccessKey: resp.SecretAccessKey, SessionToken: resp.SessionToken, + AccountID: resp.AccountID, } // Handle expiration diff --git a/credentials/processcreds/provider_test.go b/credentials/processcreds/provider_test.go index 5678af0dde8..f3277c02f02 100644 --- a/credentials/processcreds/provider_test.go +++ b/credentials/processcreds/provider_test.go @@ -142,6 +142,7 @@ type credentialTest struct { AccessKeyID string `json:"AccessKeyId"` SecretAccessKey string Expiration string + AccountID string `json:"AccountId"` } func TestProviderStatic(t *testing.T) { @@ -330,6 +331,23 @@ func BenchmarkProcessProvider(b *testing.B) { } } +func TestProviderWithAccountID(t *testing.T) { + provider := NewProvider( + fmt.Sprintf( + "%s %s", + getOSCat(), + filepath.Join("testdata", "accountid.json"), + )) + v, err := provider.Retrieve(context.Background()) + if err != nil { + t.Errorf("expected %v, got %v", "no error", err) + } + + if e, a := "012345678901", v.AccountID; e != a { + t.Errorf("expect retrieved accountID to be %v, got %v", e, a) + } +} + func getOSCat() string { if runtime.GOOS == "windows" { return "type" diff --git a/credentials/processcreds/testdata/accountid.json b/credentials/processcreds/testdata/accountid.json new file mode 100644 index 00000000000..46cbfc8ea38 --- /dev/null +++ b/credentials/processcreds/testdata/accountid.json @@ -0,0 +1,6 @@ +{ + "Version":1, + "AccessKeyId":"accesskey", + "SecretAccessKey":"secretkey", + "AccountId": "012345678901" +} diff --git a/credentials/ssocreds/sso_credentials_provider.go b/credentials/ssocreds/sso_credentials_provider.go index b3cf7853e76..8c230be8eb8 100644 --- a/credentials/ssocreds/sso_credentials_provider.go +++ b/credentials/ssocreds/sso_credentials_provider.go @@ -129,6 +129,7 @@ func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) { CanExpire: true, Expires: time.Unix(0, output.RoleCredentials.Expiration*int64(time.Millisecond)).UTC(), Source: ProviderName, + AccountID: p.options.AccountID, }, nil } diff --git a/credentials/ssocreds/sso_credentials_provider_test.go b/credentials/ssocreds/sso_credentials_provider_test.go index c9006ea63c4..3cb4b9d08f9 100644 --- a/credentials/ssocreds/sso_credentials_provider_test.go +++ b/credentials/ssocreds/sso_credentials_provider_test.go @@ -110,6 +110,7 @@ func TestProvider(t *testing.T) { CanExpire: true, Expires: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), Source: ProviderName, + AccountID: "012345678901", }, }, "custom cached token file": { @@ -144,6 +145,7 @@ func TestProvider(t *testing.T) { CanExpire: true, Expires: time.Date(2021, 01, 20, 21, 22, 23, 0.123e9, time.UTC), Source: ProviderName, + AccountID: "012345678901", }, }, "expired access token": { diff --git a/credentials/stscreds/assume_role_provider.go b/credentials/stscreds/assume_role_provider.go index 289707b6de4..703eb1cf818 100644 --- a/credentials/stscreds/assume_role_provider.go +++ b/credentials/stscreds/assume_role_provider.go @@ -308,6 +308,12 @@ func (p *AssumeRoleProvider) Retrieve(ctx context.Context) (aws.Credentials, err return aws.Credentials{Source: ProviderName}, err } + // extract accountID from arn with format "arn:partition:service:region:account-id:[resource-section]" + var accountID string + if resp.AssumedRoleUser != nil { + accountID = getAccountID(resp.AssumedRoleUser) + } + return aws.Credentials{ AccessKeyID: *resp.Credentials.AccessKeyId, SecretAccessKey: *resp.Credentials.SecretAccessKey, @@ -316,5 +322,6 @@ func (p *AssumeRoleProvider) Retrieve(ctx context.Context) (aws.Credentials, err CanExpire: true, Expires: *resp.Credentials.Expiration, + AccountID: accountID, }, nil } diff --git a/credentials/stscreds/assume_role_provider_test.go b/credentials/stscreds/assume_role_provider_test.go index 142a4cfb8fd..83c465ea1ac 100644 --- a/credentials/stscreds/assume_role_provider_test.go +++ b/credentials/stscreds/assume_role_provider_test.go @@ -23,6 +23,9 @@ func (s *mockAssumeRole) AssumeRole(ctx context.Context, params *sts.AssumeRoleI expiry := time.Now().Add(60 * time.Minute) return &sts.AssumeRoleOutput{ + AssumedRoleUser: &types.AssumedRoleUser{ + Arn: aws.String("arn:aws:sts::131990247566:assumed-role/assume-role-integration-test-role/Name"), + }, Credentials: &types.Credentials{ // Just reflect the role arn to the provider. AccessKeyId: params.RoleArn, @@ -54,6 +57,9 @@ func TestAssumeRoleProvider(t *testing.T) { if e, a := "assumedSessionToken", creds.SessionToken; e != a { t.Errorf("Expect session token to match") } + if e, a := "131990247566", creds.AccountID; e != a { + t.Errorf("Expect account id to match") + } } func TestAssumeRoleProvider_WithTokenProvider(t *testing.T) { diff --git a/credentials/stscreds/web_identity_provider.go b/credentials/stscreds/web_identity_provider.go index ddaf6df6ce1..d0eb3158468 100644 --- a/credentials/stscreds/web_identity_provider.go +++ b/credentials/stscreds/web_identity_provider.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "strconv" + "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -135,6 +136,12 @@ func (p *WebIdentityRoleProvider) Retrieve(ctx context.Context) (aws.Credentials return aws.Credentials{}, fmt.Errorf("failed to retrieve credentials, %w", err) } + // extract accountID from arn with format "arn:partition:service:region:account-id:[resource-section]" + var accountID string + if resp.AssumedRoleUser != nil { + accountID = getAccountID(resp.AssumedRoleUser) + } + // InvalidIdentityToken error is a temporary error that can occur // when assuming an Role with a JWT web identity token. @@ -145,6 +152,14 @@ func (p *WebIdentityRoleProvider) Retrieve(ctx context.Context) (aws.Credentials Source: WebIdentityProviderName, CanExpire: true, Expires: *resp.Credentials.Expiration, + AccountID: accountID, } return value, nil } + +func getAccountID(assumedRoleUser *types.AssumedRoleUser) string { + if arn := assumedRoleUser.Arn; arn != nil && len(*arn) > 0 { + return strings.Split(*arn, ":")[4] + } + return "" +} diff --git a/credentials/stscreds/web_identity_provider_test.go b/credentials/stscreds/web_identity_provider_test.go index 5d83b95b4b2..1f3a8700a1f 100644 --- a/credentials/stscreds/web_identity_provider_test.go +++ b/credentials/stscreds/web_identity_provider_test.go @@ -161,6 +161,50 @@ func TestWebIdentityProviderRetrieve(t *testing.T) { Expires: sdk.NowTime(), }, }, + "success with accountID": { + roleARN: "arn01234567890123456789", + tokenFilepath: "testdata/token.jwt", + options: func(o *stscreds.WebIdentityRoleOptions) { + o.RoleSessionName = "foo" + }, + mockClient: func( + ctx context.Context, params *sts.AssumeRoleWithWebIdentityInput, optFns ...func(*sts.Options), + ) ( + *sts.AssumeRoleWithWebIdentityOutput, error, + ) { + if e, a := "foo", *params.RoleSessionName; e != a { + return nil, fmt.Errorf("expected %v, but received %v", e, a) + } + if params.DurationSeconds != nil { + return nil, fmt.Errorf("expect no duration seconds, got %v", + *params.DurationSeconds) + } + if params.Policy != nil { + return nil, fmt.Errorf("expect no policy, got %v", + *params.Policy) + } + return &sts.AssumeRoleWithWebIdentityOutput{ + AssumedRoleUser: &types.AssumedRoleUser{ + Arn: aws.String("arn:aws:sts::131990247566:assumed-role/assume-role-integration-test-role/Name"), + }, + Credentials: &types.Credentials{ + Expiration: aws.Time(sdk.NowTime()), + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + expectedCredValue: aws.Credentials{ + AccessKeyID: "access-key-id", + SecretAccessKey: "secret-access-key", + SessionToken: "session-token", + Source: stscreds.WebIdentityProviderName, + CanExpire: true, + Expires: sdk.NowTime(), + AccountID: "131990247566", + }, + }, } for name, c := range cases { From a45a31108f3e7f7d83457a30a6f739eba6c830f2 Mon Sep 17 00:00:00 2001 From: Tianyi Wang Date: Wed, 10 Jan 2024 16:02:28 -0500 Subject: [PATCH 2/3] Add changelog --- .changelog/9166aec5123a472ab3d80afbcae6de9b.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changelog/9166aec5123a472ab3d80afbcae6de9b.json diff --git a/.changelog/9166aec5123a472ab3d80afbcae6de9b.json b/.changelog/9166aec5123a472ab3d80afbcae6de9b.json new file mode 100644 index 00000000000..269ce7c1e66 --- /dev/null +++ b/.changelog/9166aec5123a472ab3d80afbcae6de9b.json @@ -0,0 +1,11 @@ +{ + "id": "9166aec5-123a-472a-b3d8-0afbcae6de9b", + "type": "feature", + "collapse": true, + "description": "Allow identity resolvers to resolve aws account id", + "modules": [ + ".", + "config", + "credentials" + ] +} \ No newline at end of file From f2a0627636b0e4624fd505087f12efab98d58ede Mon Sep 17 00:00:00 2001 From: Tianyi Wang Date: Thu, 11 Jan 2024 14:13:10 -0500 Subject: [PATCH 3/3] optimize some accountID resolving code --- .changelog/9166aec5123a472ab3d80afbcae6de9b.json | 11 ----------- aws/credentials.go | 2 +- config/env_config.go | 4 ++-- config/shared_config.go | 4 ++-- credentials/processcreds/provider.go | 2 +- credentials/stscreds/assume_role_provider.go | 1 - credentials/stscreds/assume_role_provider_test.go | 2 +- credentials/stscreds/web_identity_provider.go | 14 +++++++++----- 8 files changed, 16 insertions(+), 24 deletions(-) delete mode 100644 .changelog/9166aec5123a472ab3d80afbcae6de9b.json diff --git a/.changelog/9166aec5123a472ab3d80afbcae6de9b.json b/.changelog/9166aec5123a472ab3d80afbcae6de9b.json deleted file mode 100644 index 269ce7c1e66..00000000000 --- a/.changelog/9166aec5123a472ab3d80afbcae6de9b.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "9166aec5-123a-472a-b3d8-0afbcae6de9b", - "type": "feature", - "collapse": true, - "description": "Allow identity resolvers to resolve aws account id", - "modules": [ - ".", - "config", - "credentials" - ] -} \ No newline at end of file diff --git a/aws/credentials.go b/aws/credentials.go index a8d58ff460e..98ba7705642 100644 --- a/aws/credentials.go +++ b/aws/credentials.go @@ -91,7 +91,7 @@ type Credentials struct { // is false. Expires time.Time - // AWS Account ID resolved from identity and used for optional endpoint2.0 routing + // The ID of the account for the credentials. AccountID string } diff --git a/config/env_config.go b/config/env_config.go index d160cb34576..197147ce699 100644 --- a/config/env_config.go +++ b/config/env_config.go @@ -81,7 +81,7 @@ const ( awsS3DisableExpressSessionAuthEnv = "AWS_S3_DISABLE_EXPRESS_SESSION_AUTH" - awsAccountID = "AWS_ACCOUNT_ID" + awsAccountIDEnv = "AWS_ACCOUNT_ID" ) var ( @@ -311,7 +311,7 @@ func NewEnvConfig() (EnvConfig, error) { setStringFromEnvVal(&creds.AccessKeyID, credAccessEnvKeys) setStringFromEnvVal(&creds.SecretAccessKey, credSecretEnvKeys) if creds.HasKeys() { - creds.AccountID = os.Getenv(awsAccountID) + creds.AccountID = os.Getenv(awsAccountIDEnv) creds.SessionToken = os.Getenv(awsSessionTokenEnvVar) cfg.Credentials = creds } diff --git a/config/shared_config.go b/config/shared_config.go index 0d4f570b721..3d8d1775e07 100644 --- a/config/shared_config.go +++ b/config/shared_config.go @@ -116,7 +116,7 @@ const ( s3DisableExpressSessionAuthKey = "s3_disable_express_session_auth" - accountID = "aws_account_id" + accountIDKey = "aws_account_id" ) // defaultSharedConfigProfile allows for swapping the default profile for testing @@ -1132,7 +1132,7 @@ func (c *SharedConfig) setFromIniSection(profile string, section ini.Section) er SecretAccessKey: section.String(secretAccessKey), SessionToken: section.String(sessionTokenKey), Source: fmt.Sprintf("SharedConfigCredentials: %s", section.SourceFile[accessKeyIDKey]), - AccountID: section.String(accountID), + AccountID: section.String(accountIDKey), } if creds.HasKeys() { diff --git a/credentials/processcreds/provider.go b/credentials/processcreds/provider.go index 6d758720d39..911fcc32729 100644 --- a/credentials/processcreds/provider.go +++ b/credentials/processcreds/provider.go @@ -168,7 +168,7 @@ type CredentialProcessResponse struct { // The date on which the current credentials expire. Expiration *time.Time - // The aws account ID for this op and could be used for endpoint2.0 routing + // The ID of the account for credentials AccountID string `json:"AccountId"` } diff --git a/credentials/stscreds/assume_role_provider.go b/credentials/stscreds/assume_role_provider.go index 703eb1cf818..4c7f7993f54 100644 --- a/credentials/stscreds/assume_role_provider.go +++ b/credentials/stscreds/assume_role_provider.go @@ -308,7 +308,6 @@ func (p *AssumeRoleProvider) Retrieve(ctx context.Context) (aws.Credentials, err return aws.Credentials{Source: ProviderName}, err } - // extract accountID from arn with format "arn:partition:service:region:account-id:[resource-section]" var accountID string if resp.AssumedRoleUser != nil { accountID = getAccountID(resp.AssumedRoleUser) diff --git a/credentials/stscreds/assume_role_provider_test.go b/credentials/stscreds/assume_role_provider_test.go index 83c465ea1ac..99cb6fde939 100644 --- a/credentials/stscreds/assume_role_provider_test.go +++ b/credentials/stscreds/assume_role_provider_test.go @@ -58,7 +58,7 @@ func TestAssumeRoleProvider(t *testing.T) { t.Errorf("Expect session token to match") } if e, a := "131990247566", creds.AccountID; e != a { - t.Errorf("Expect account id to match") + t.Error("Expect account id to match") } } diff --git a/credentials/stscreds/web_identity_provider.go b/credentials/stscreds/web_identity_provider.go index d0eb3158468..b4b71970862 100644 --- a/credentials/stscreds/web_identity_provider.go +++ b/credentials/stscreds/web_identity_provider.go @@ -136,7 +136,6 @@ func (p *WebIdentityRoleProvider) Retrieve(ctx context.Context) (aws.Credentials return aws.Credentials{}, fmt.Errorf("failed to retrieve credentials, %w", err) } - // extract accountID from arn with format "arn:partition:service:region:account-id:[resource-section]" var accountID string if resp.AssumedRoleUser != nil { accountID = getAccountID(resp.AssumedRoleUser) @@ -157,9 +156,14 @@ func (p *WebIdentityRoleProvider) Retrieve(ctx context.Context) (aws.Credentials return value, nil } -func getAccountID(assumedRoleUser *types.AssumedRoleUser) string { - if arn := assumedRoleUser.Arn; arn != nil && len(*arn) > 0 { - return strings.Split(*arn, ":")[4] +// extract accountID from arn with format "arn:partition:service:region:account-id:[resource-section]" +func getAccountID(u *types.AssumedRoleUser) string { + if u.Arn == nil { + return "" } - return "" + parts := strings.Split(*u.Arn, ":") + if len(parts) < 5 { + return "" + } + return parts[4] }