diff --git a/.changes/next-release/feature-AWSSDKforJavav2-45bc696.json b/.changes/next-release/feature-AWSSDKforJavav2-45bc696.json new file mode 100644 index 00000000000..49bb06709b9 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-45bc696.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Implemented the CREDENTIALS_IMDS business metric feature that tracks when Instance Metadata Service (IMDS) credentials are being used in AWS SDK requests." +} diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/ImdsCredentialsBusinessMetricInterceptor.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/ImdsCredentialsBusinessMetricInterceptor.java new file mode 100644 index 00000000000..43ef422ae68 --- /dev/null +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/ImdsCredentialsBusinessMetricInterceptor.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.auth.credentials.internal; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; + +/** + * Interceptor that adds the CREDENTIALS_IMDS business metric when IMDS credentials are being used. + */ +@SdkInternalApi +public final class ImdsCredentialsBusinessMetricInterceptor implements ExecutionInterceptor { + + @Override + public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttributes executionAttributes) { + AwsCredentials credentials = executionAttributes.getAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS); + + if (credentials != null && isImdsCredentials(credentials)) { + executionAttributes.getAttribute(SdkInternalExecutionAttribute.BUSINESS_METRICS) + .addMetric(BusinessMetricFeatureId.CREDENTIALS_IMDS.value()); + } + + return context.request(); + } + + private boolean isImdsCredentials(AwsCredentials credentials) { + return credentials.providerName() + .map(name -> name.contains("InstanceProfileCredentialsProvider")) + .orElse(false); + } +} \ No newline at end of file diff --git a/core/auth/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors b/core/auth/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors new file mode 100644 index 00000000000..166427e1f7a --- /dev/null +++ b/core/auth/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors @@ -0,0 +1 @@ +software.amazon.awssdk.auth.credentials.internal.ImdsCredentialsBusinessMetricInterceptor diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ImdsCredentialsInUserAgentTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ImdsCredentialsInUserAgentTest.java new file mode 100644 index 00000000000..f4421a25a69 --- /dev/null +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ImdsCredentialsInUserAgentTest.java @@ -0,0 +1,113 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.auth.credentials; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.internal.ImdsCredentialsBusinessMetricInterceptor; +import software.amazon.awssdk.auth.signer.AwsSignerExecutionAttribute; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.useragent.BusinessMetricCollection; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; + +public class ImdsCredentialsInUserAgentTest { + + private ImdsCredentialsBusinessMetricInterceptor interceptor; + private ExecutionAttributes executionAttributes; + private BusinessMetricCollection businessMetrics; + private Context.ModifyRequest context; + + @BeforeEach + void setUp() { + interceptor = new ImdsCredentialsBusinessMetricInterceptor(); + executionAttributes = new ExecutionAttributes(); + businessMetrics = new BusinessMetricCollection(); + executionAttributes.putAttribute(SdkInternalExecutionAttribute.BUSINESS_METRICS, businessMetrics); + + context = mock(Context.ModifyRequest.class); + SdkRequest request = mock(SdkRequest.class); + when(context.request()).thenReturn(request); + } + + @Test + public void imdsCredentials_shouldHaveImdsCredentialsBusinessMetric() { + // Create credentials with IMDS provider name + AwsCredentials imdsCredentials = createCredentialsWithProviderName("InstanceProfileCredentialsProvider"); + executionAttributes.putAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS, imdsCredentials); + + interceptor.modifyRequest(context, executionAttributes); + + // Verify that the CREDENTIALS_IMDS metric is added to business metrics + assertThat(businessMetrics.recordedMetrics()) + .contains(BusinessMetricFeatureId.CREDENTIALS_IMDS.value()); + + // Verify that the metric appears in the user agent string + String userAgentString = businessMetrics.asBoundedString(); + assertThat(userAgentString).contains(BusinessMetricFeatureId.CREDENTIALS_IMDS.value()); + } + + @Test + public void containerCredentials_shouldNotHaveImdsCredentialsBusinessMetric() { + // Test with "ContainerCredentialsProvider" provider name - should not be considered IMDS + AwsCredentials containerCredentials = createCredentialsWithProviderName("ContainerCredentialsProvider"); + executionAttributes.putAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS, containerCredentials); + + interceptor.modifyRequest(context, executionAttributes); + + assertThat(businessMetrics.recordedMetrics()) + .doesNotContain(BusinessMetricFeatureId.CREDENTIALS_IMDS.value()); + } + + @Test + public void credentialsWithoutProviderName_shouldNotHaveImdsCredentialsBusinessMetric() { + // Test with credentials that don't have a provider name + AwsCredentials credentialsWithoutProviderName = AwsBasicCredentials.create("accessKey", "secretKey"); + executionAttributes.putAttribute(AwsSignerExecutionAttribute.AWS_CREDENTIALS, credentialsWithoutProviderName); + + interceptor.modifyRequest(context, executionAttributes); + + assertThat(businessMetrics.recordedMetrics()) + .doesNotContain(BusinessMetricFeatureId.CREDENTIALS_IMDS.value()); + } + + private AwsCredentials createCredentialsWithProviderName(String providerName) { + AwsCredentials baseCredentials = AwsBasicCredentials.create("test-access-key", "test-secret-key"); + return new AwsCredentials() { + @Override + public String accessKeyId() { + return baseCredentials.accessKeyId(); + } + + @Override + public String secretAccessKey() { + return baseCredentials.secretAccessKey(); + } + + @Override + public Optional providerName() { + return Optional.of(providerName); + } + }; + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java index 7f1483d5689..43b274a2c80 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java @@ -42,6 +42,7 @@ public enum BusinessMetricFeatureId { RESOLVED_ACCOUNT_ID("T"), DDB_MAPPER("d"), BEARER_SERVICE_ENV_VARS("3"), + CREDENTIALS_IMDS("0"), UNKNOWN("Unknown"); private static final Map VALUE_MAP = diff --git a/test/auth-tests/src/it/java/software/amazon/awssdk/auth/source/ImdsCredentialsUserAgentTest.java b/test/auth-tests/src/it/java/software/amazon/awssdk/auth/source/ImdsCredentialsUserAgentTest.java new file mode 100644 index 00000000000..4e7dd37e04f --- /dev/null +++ b/test/auth-tests/src/it/java/software/amazon/awssdk/auth/source/ImdsCredentialsUserAgentTest.java @@ -0,0 +1,152 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.auth.source; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.io.UnsupportedEncodingException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.services.sts.StsClient; +import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; +import software.amazon.awssdk.utils.DateUtils; +import software.amazon.awssdk.utils.StringInputStream; + +@WireMockTest +class ImdsCredentialsUserAgentTest { + private static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; + private static final String CREDENTIALS_RESOURCE_PATH = "/latest/meta-data/iam/security-credentials/"; + private static final String PROFILE_NAME = "some-profile"; + private static final String TOKEN_STUB = "some-token"; + + private static final String STUB_CREDENTIALS = "{\"AccessKeyId\":\"ACCESS_KEY_ID\",\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))) + + "\"}"; + + private static final AwsCredentials BASIC_IDENTITY = basicCredentialsBuilder().build(); + + @RegisterExtension + static WireMockExtension wireMockServer = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort().dynamicPort()) + .configureStaticDsl(true) + .build(); + + private MockSyncHttpClient mockHttpClient; + + @BeforeEach + public void setup() throws UnsupportedEncodingException { + System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(), + "http://localhost:" + wireMockServer.getPort()); + + stubSecureCredentialsResponse(); + + mockHttpClient = new MockSyncHttpClient(); + mockHttpClient.stubNextResponse(mockResponse()); + } + + @AfterAll + public static void teardown() { + System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property()); + } + + public static HttpExecuteResponse mockResponse() { + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create(new StringInputStream(""))) + .build(); + } + + @ParameterizedTest + @MethodSource("credentialProviders") + void userAgentString_containsCredentialProviderNames_IfPresent(IdentityProvider provider, + String expected) throws Exception { + stsClient(provider, mockHttpClient).getCallerIdentity(); + + SdkHttpRequest lastRequest = mockHttpClient.getLastRequest(); + assertThat(lastRequest).isNotNull(); + + List userAgentHeaders = lastRequest.headers().get("User-Agent"); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + + String userAgent = userAgentHeaders.get(0); + + if ("m/0".equals(expected)) { + assertThat(userAgent).matches(".*m/[^\\s]*0[^\\s]*.*"); + } else { + assertThat(userAgent).contains(expected); + } + } + + private static Stream credentialProviders() { + return Stream.of( + Arguments.of(createRealImdsCredentialsProvider(), "m/0"), + Arguments.of(StaticCredentialsProvider.create(BASIC_IDENTITY), "stat") + ); + } + + /** + * Creates a InstanceProfileCredentialsProvider that uses mocked IMDS endpoints. + */ + private static InstanceProfileCredentialsProvider createRealImdsCredentialsProvider() { + return InstanceProfileCredentialsProvider.builder().build(); + } + + private static void stubSecureCredentialsResponse() { + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody(TOKEN_STUB))); + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH)).willReturn(aResponse().withBody(PROFILE_NAME))); + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + PROFILE_NAME)).willReturn(aResponse().withBody(STUB_CREDENTIALS))); + } + + private static StsClient stsClient(IdentityProvider provider, SdkHttpClient httpClient) { + return StsClient.builder() + .credentialsProvider(provider) + .httpClient(httpClient) + .build(); + } + + private static AwsBasicCredentials.Builder basicCredentialsBuilder() { + return AwsBasicCredentials.builder() + .accessKeyId("akid") + .secretAccessKey("secret"); + } +}