diff --git a/core/build.gradle b/core/build.gradle index d7a54a26afe..501fd89066e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,3 +1,5 @@ +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator + apply plugin: 'com.gradleup.shadow' description = "Testcontainers Core" @@ -26,9 +28,43 @@ shadowJar { 'META-INF/proguard/', 'META-INF/versions/*/module-info.class', 'META-INF/services/java.security.Provider', + 'META-INF/DEPENDENCIES', + 'META-INF/AL2.0', + 'META-INF/LGPL2.1', ].each { exclude(it) } } +afterEvaluate { + // gradle/shading.gradle replaces shadowJar.relocators within its own afterEvaluate + // block, so these must be registered afterwards. The auto-generated relocations + // only cover class packages; the httpclient5/httpcore5 resources (version.properties) + // would otherwise stay behind at their original paths. + tasks.shadowJar.relocate('org.apache.hc.client5', 'org.testcontainers.shaded.org.apache.hc.client5') { + exclude 'org.apache.hc.client5.http.wire' + exclude 'org.apache.hc.client5.http.headers' + } + tasks.shadowJar.relocate('org.apache.hc.core5', 'org.testcontainers.shaded.org.apache.hc.core5') + + // Keep the Apache HttpClient wire/headers logger names out of the `org.testcontainers` + // hierarchy. The logger names are plain string constants inside the shaded classes + // ("org.apache.hc.client5.http.wire" / "org.apache.hc.client5.http.headers") and shadow + // relocates them along with the code. If they landed under + // `org.testcontainers.shaded...`, they would inherit the level of the `org.testcontainers` + // logger: raising it to DEBUG (the documented troubleshooting switch) would silently turn + // on full wire/header logging of the Docker API traffic, including registry credentials. + // With the original logger names preserved, wire logging stays off by default and only + // activates when the user explicitly enables that exact logger (see + // docs/supported_docker_environment/logging_config.md). The excludes above are not enough + // on their own: the auto-generated per-package relocator for `org.apache.hc.client5.http` + // (gradle/shading.gradle) would still match the strings, so it must skip them as well. + tasks.shadowJar.relocators + .findAll { it instanceof SimpleRelocator && it.pattern == 'org.apache.hc.client5.http' } + .each { relocator -> + relocator.exclude('org.apache.hc.client5.http.wire') + relocator.exclude('org.apache.hc.client5.http.headers') + } +} + task jarFileTest(type: Test) { useJUnitPlatform() testClassesDirs = sourceSets.jarFileTest.output.classesDirs @@ -84,12 +120,28 @@ dependencies { shaded platform('com.github.docker-java:docker-java-bom:3.7.1') api "com.github.docker-java:docker-java-api" + api 'com.github.docker-java:docker-java-transport' shaded('com.github.docker-java:docker-java-core') { exclude group: 'com.google.guava', module: 'guava' } - api 'com.github.docker-java:docker-java-transport-zerodep' + // Embed the HTTP transport ourselves instead of depending on + // com.github.docker-java:docker-java-transport-zerodep, whose jar bundles + // httpclient5 5.5.1 / httpcore5(-h2) 5.3.6, affected by CVE-2026-54428 + // (see https://github.com/testcontainers/testcontainers-java/issues/11973). + // httpclient5 5.6.4 brings httpcore5 5.4.3, the first fixed version. + shaded 'com.github.docker-java:docker-java-transport-httpclient5' + shaded 'org.apache.httpcomponents.client5:httpclient5:5.6.4' + + // docker-java-transport-httpclient5 depends on JNA, which used to reach the api + // configuration transitively via docker-java-transport-zerodep. Declared as an api + // dependency here, JNA is excluded from the shaded jar and left unrelocated: shaded + // (relocated) JNA classes cannot bind the native jnidispatch library, whose JNI + // symbols and embedded resource path stay at com/sun/jna/... - relocating them + // breaks DockerClientProviderStrategy resolution with an UnsatisfiedLinkError in + // RootlessDockerClientProviderStrategy (ci/circleci: minimal_core failure). + api 'net.java.dev.jna:jna:5.18.1' shaded 'com.google.guava:guava:33.3.1-jre' shaded "org.yaml:snakeyaml:2.6" @@ -129,6 +181,10 @@ dependencies { tasks.generatePomFileForMavenJavaPublication.finalizedBy( tasks.register('checkPOMdependencies', org.testcontainers.build.ComparePOMWithLatestReleasedTask) { ignore = [ + // previously pulled in transitively by docker-java-transport-zerodep (#11973) + 'com.github.docker-java:docker-java-transport', + // same for JNA, previously pulled in transitively by docker-java-transport-zerodep (#11973) + 'net.java.dev.jna:jna', ] } ) diff --git a/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java b/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java index 8b6f380f6eb..e6dcbdcac28 100644 --- a/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java +++ b/core/src/jarFileTest/java/org/testcontainers/JarFileShadingTest.java @@ -4,8 +4,12 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -15,7 +19,9 @@ class JarFileShadingTest extends AbstractJarFileTest { void testPackages() throws Exception { assertThatFileList(root).containsOnly("org", "META-INF"); - assertThatFileList(root.resolve("org")).containsOnly("testcontainers"); + // "publicsuffix" is the data-only resource directory of the shaded httpclient5 + // (effective_tld_names.dat), not relocated, just like in docker-java's zerodep jar + assertThatFileList(root.resolve("org")).containsOnly("testcontainers", "publicsuffix"); } @Test @@ -38,6 +44,51 @@ void testMetaInfServices() throws Exception { .allMatch(it -> it.startsWith("org.testcontainers.")); } + @Test + void testHttpClientWireLoggersAreNotRelocated() throws Exception { + // The shaded Apache HttpClient must send wire/header output to the plain + // "org.apache.hc.client5.http.wire" / "org.apache.hc.client5.http.headers" loggers. + // The logger names are plain string constants, and if they were relocated along with + // the code (to "org.testcontainers.shaded..."), the loggers would inherit the level of + // the `org.testcontainers` logger: raising it to DEBUG (the documented troubleshooting + // switch in docs/supported_docker_environment/logging_config.md) would then print the + // full Docker API traffic, including registry credentials. Wire logging must stay + // opt-in on the exact, unrelocated logger names. + Path shadedHttpClientClasses = root + .resolve("org/testcontainers/shaded/org/apache/hc") + .toAbsolutePath() + .normalize(); + + try (Stream entries = Files.walk(shadedHttpClientClasses)) { + List shadedClasses = entries + .filter(path -> path.toString().endsWith(".class")) + .collect(Collectors.toList()); + + assertThat(shadedClasses).isNotEmpty(); + + for (Path shadedClass : shadedClasses) { + String constantPool = new String(Files.readAllBytes(shadedClass), StandardCharsets.ISO_8859_1); + assertThat(constantPool) + .as("constant pool of %s", shadedClass) + .doesNotContain("org.testcontainers.shaded.org.apache.hc.client5.http.wire") + .doesNotContain("org.testcontainers.shaded.org.apache.hc.client5.http.headers"); + } + } + + // the wire/header logger names must keep their original, opt-in values + String connectionClass = new String( + Files.readAllBytes( + root.resolve( + "org/testcontainers/shaded/org/apache/hc/client5/http/impl/io/DefaultManagedHttpClientConnection.class" + ) + ), + StandardCharsets.ISO_8859_1 + ); + assertThat(connectionClass) + .contains("org.apache.hc.client5.http.wire") + .contains("org.apache.hc.client5.http.headers"); + } + private ListAssert assertThatFileList(Path path) throws IOException { return (ListAssert) assertThat(Files.list(path)) .extracting(Path::getFileName) diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java index 7b0aaafc169..2df08ad3d6e 100644 --- a/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java +++ b/core/src/main/java/org/testcontainers/dockerclient/DockerClientProviderStrategy.java @@ -6,11 +6,11 @@ import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; import com.github.dockerjava.core.RemoteApiVersion; +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import com.github.dockerjava.transport.DockerHttpClient; import com.github.dockerjava.transport.NamedPipeSocket; import com.github.dockerjava.transport.SSLConfig; import com.github.dockerjava.transport.UnixSocket; -import com.github.dockerjava.zerodep.ZerodepDockerHttpClient; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Throwables; import lombok.Getter; @@ -392,7 +392,7 @@ public static DockerClient getClientForConfig(TransportConfig transportConfig) { switch (transportType) { case "httpclient5": dockerHttpClient = - new ZerodepDockerHttpClient.Builder() + new ApacheDockerHttpClient.Builder() .dockerHost(transportConfig.getDockerHost()) .sslConfig(transportConfig.getSslConfig()) .build(); diff --git a/core/src/test/resources/logback-test.xml b/core/src/test/resources/logback-test.xml index 7d4d9e8f4b4..d8c72ff3d5f 100644 --- a/core/src/test/resources/logback-test.xml +++ b/core/src/test/resources/logback-test.xml @@ -19,4 +19,8 @@ + + + + diff --git a/docs/supported_docker_environment/logging_config.md b/docs/supported_docker_environment/logging_config.md index 6bbe0584434..c0a43b3dfac 100644 --- a/docs/supported_docker_environment/logging_config.md +++ b/docs/supported_docker_environment/logging_config.md @@ -20,7 +20,14 @@ should be included in your classpath to show a reasonable level of log output: - + + + ``` @@ -30,4 +37,22 @@ In order to troubleshoot issues with Testcontainers, increase the logging level ``` -Avoid changing the root logger's level to `DEBUG`, because this turns on debug logging for every package whose level isn't explicitly configured here, resulting in a large amount of log data. +Raising `org.testcontainers` (or `com.github.dockerjava`) to `DEBUG` does not turn on HTTP wire logging for the +communication with the Docker daemon. That logging is off by default and opt-in only, because the wire logs include +complete HTTP request and response headers and bodies, including registry credentials (e.g. the Base64-encoded +`X-Registry-Auth` header used for image pulls). To enable it temporarily for troubleshooting, explicitly set the exact +logger names to `DEBUG`: + +```xml + + + +``` + +The same logger names apply whether Testcontainers is using its shaded copy of Apache HttpClient (from the published +jar) or a plain Apache HttpClient 5.x (e.g. when running from a source classpath), and they also control wire logging +for other Apache HttpClient 5.x usage in your application. + +Avoid changing the root logger's level to `DEBUG`, because this turns on debug logging for every package whose level +isn't explicitly configured here — resulting in a large amount of log data and, in particular, in wire logging of the +Docker API communication (see above), which includes credentials.