diff --git a/langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptor.java b/langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptor.java index 066cec11fd..c2f1538d05 100644 --- a/langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptor.java +++ b/langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptor.java @@ -4,13 +4,17 @@ import static dev.langchain4j.internal.Utils.getOrDefault; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; +import dev.langchain4j.Internal; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Optional; - -import dev.langchain4j.Internal; +import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.interceptor.Context; @@ -28,6 +32,11 @@ class AwsLoggingInterceptor implements ExecutionInterceptor { private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(AwsLoggingInterceptor.class); + /** + * Authentication headers whose values must never be written to logs. Compared case-insensitively. + */ + private static final Set SENSITIVE_HEADERS = Set.of("authorization", "x-amz-security-token"); + private final boolean logRequests; private final boolean logResponses; private final Logger logger; @@ -51,7 +60,8 @@ class AwsLoggingInterceptor implements ExecutionInterceptor { if (logRequests) { if (request.method() == SdkHttpMethod.POST && request instanceof SdkHttpFullRequest sdkHttpFullRequest) { try { - ContentStreamProvider csp = sdkHttpFullRequest.contentStreamProvider().orElse(null); + ContentStreamProvider csp = + sdkHttpFullRequest.contentStreamProvider().orElse(null); if (nonNull(csp)) body = IoUtils.toUtf8String(csp.newStream()); } catch (IOException e) { logger.warn("Unable to obtain request body", e); @@ -61,10 +71,9 @@ class AwsLoggingInterceptor implements ExecutionInterceptor { "Request:\n- method: {}\n- url: {}\n- headers: {}\n- query parameters: {}\n- body: {}", request.method(), request.getUri(), - request.headers(), + maskHeaders(request.headers()), request.rawQueryParameters(), - body - ); + body); } } @@ -80,7 +89,7 @@ class AwsLoggingInterceptor implements ExecutionInterceptor { logger.debug( "Response Status: {} \nHeaders: {} \nResponse Body Type: {}", response.statusCode(), - response.headers(), + maskHeaders(response.headers()), context.response().getClass().getSimpleName()); } } @@ -100,4 +109,24 @@ class AwsLoggingInterceptor implements ExecutionInterceptor { } return isNull(content) ? Optional.empty() : Optional.of(new ByteArrayInputStream(content)); } + + /** + * Renders HTTP headers for logging, replacing the values of sensitive authentication headers + * (e.g. {@code Authorization}, {@code X-Amz-Security-Token}) with a placeholder so that + * credentials such as SigV4 signatures and temporary session tokens are not written to logs. + * Header-name matching is case-insensitive; all other headers are rendered unchanged. + */ + static String maskHeaders(Map> headers) { + if (headers == null || headers.isEmpty()) { + return "{}"; + } + return headers.entrySet().stream() + .map(entry -> { + if (SENSITIVE_HEADERS.contains(entry.getKey().toLowerCase(Locale.ROOT))) { + return entry.getKey() + "=[REDACTED]"; + } + return entry.getKey() + "=" + entry.getValue(); + }) + .collect(Collectors.joining(", ", "{", "}")); + } } diff --git a/langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptorTest.java b/langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptorTest.java new file mode 100644 index 0000000000..4e6d53615b --- /dev/null +++ b/langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/AwsLoggingInterceptorTest.java @@ -0,0 +1,82 @@ +package dev.langchain4j.model.bedrock; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AwsLoggingInterceptorTest { + + @Test + void should_redact_authorization_header() { + Map> headers = new LinkedHashMap<>(); + headers.put( + "Authorization", + List.of("AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20240101/us-east-1/bedrock/aws4_request, " + + "SignedHeaders=host;x-amz-date, Signature=deadbeefdeadbeef")); + headers.put("Content-Type", List.of("application/json")); + + String result = AwsLoggingInterceptor.maskHeaders(headers); + + assertThat(result).contains("Authorization=[REDACTED]"); + assertThat(result).doesNotContain("AKIAEXAMPLE"); + assertThat(result).doesNotContain("deadbeefdeadbeef"); + assertThat(result).contains("Content-Type=[application/json]"); + } + + @Test + void should_redact_session_token_case_insensitively() { + Map> headers = new LinkedHashMap<>(); + headers.put("x-amz-security-token", List.of("FwoGZXIvYXdzEXAMPLESESSIONTOKEN")); + + String result = AwsLoggingInterceptor.maskHeaders(headers); + + assertThat(result).contains("x-amz-security-token=[REDACTED]"); + assertThat(result).doesNotContain("EXAMPLESESSIONTOKEN"); + } + + @Test + void should_redact_authorization_regardless_of_case() { + Map> headers = new LinkedHashMap<>(); + headers.put("AUTHORIZATION", List.of("super-secret-value")); + + String result = AwsLoggingInterceptor.maskHeaders(headers); + + assertThat(result).contains("AUTHORIZATION=[REDACTED]"); + assertThat(result).doesNotContain("super-secret-value"); + } + + @Test + void should_preserve_non_sensitive_headers() { + Map> headers = new LinkedHashMap<>(); + headers.put("X-Amz-Date", List.of("20240101T000000Z")); + headers.put("Host", List.of("bedrock-runtime.us-east-1.amazonaws.com")); + + String result = AwsLoggingInterceptor.maskHeaders(headers); + + assertThat(result).contains("X-Amz-Date=[20240101T000000Z]"); + assertThat(result).contains("Host=[bedrock-runtime.us-east-1.amazonaws.com]"); + } + + @Test + void should_preserve_multiple_values_for_non_sensitive_headers() { + Map> headers = new LinkedHashMap<>(); + headers.put("Accept", List.of("application/json", "text/event-stream")); + + String result = AwsLoggingInterceptor.maskHeaders(headers); + + assertThat(result).contains("Accept=[application/json, text/event-stream]"); + } + + @Test + void should_handle_empty_map() { + assertThat(AwsLoggingInterceptor.maskHeaders(Map.of())).isEqualTo("{}"); + } + + @Test + void should_handle_null_map() { + assertThat(AwsLoggingInterceptor.maskHeaders(null)).isEqualTo("{}"); + } +}