From 7d2012af6077ee1619b6ff66f73edba53081f3e0 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Fri, 3 May 2024 09:55:44 -0700 Subject: [PATCH] chore: address testcontainers vulnerability by replacing with docker-java (#1088) --- build.gradle.kts | 3 + gradle/libs.versions.toml | 6 +- .../test-suite/build.gradle.kts | 10 +- .../kotlin/runtime/http/test/MitmContainer.kt | 63 +++++++ .../kotlin/runtime/http/test/ProxyTest.kt | 73 ++++---- .../kotlin/runtime/http/test/util/Docker.kt | 173 ++++++++++++++++++ .../kotlin/runtime/http/test/util/Poller.kt | 52 ++++++ runtime/runtime-core/api/runtime-core.api | 1 + .../aws/smithy/kotlin/runtime/time/Instant.kt | 6 + .../smithy/kotlin/runtime/time/InstantJVM.kt | 5 + .../kotlin/runtime/time/InstantNative.kt | 4 + 11 files changed, 348 insertions(+), 48 deletions(-) create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt diff --git a/build.gradle.kts b/build.gradle.kts index 5ff5b26b5..da9ffca7c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -83,6 +83,9 @@ allprojects { ) } } + + // Enables running `./gradlew allDeps` to get a comprehensive list of dependencies for every subproject + tasks.register("allDeps") { } } // configure the root multimodule docs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d75fd27c7..59ca097fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ kotest-version = "5.8.0" kotlin-compile-testing-version = "1.5.0" kotlinx-benchmark-version = "0.4.9" kotlinx-serialization-version = "1.6.0" -testcontainers-version = "1.19.1" +docker-java-version = "3.3.6" ktor-version = "2.3.6" kaml-version = "0.55.0" jsoup-version = "1.16.2" @@ -81,8 +81,8 @@ kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version. kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest-version" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" } -testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers-version" } -testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers-version" } +docker-core = { module = "com.github.docker-java:docker-java-core", version.ref = "docker-java-version" } +docker-transport-zerodep = { module = "com.github.docker-java:docker-java-transport-zerodep", version.ref = "docker-java-version" } ktor-http-cio = { module = "io.ktor:ktor-http-cio", version.ref = "ktor-version" } ktor-utils = { module = "io.ktor:ktor-utils", version.ref = "ktor-version" } diff --git a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts index 7fbc7afa5..06d011297 100644 --- a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts +++ b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts @@ -42,8 +42,8 @@ kotlin { jvmTest { dependencies { - implementation(libs.testcontainers) - implementation(libs.testcontainers.junit.jupiter) + implementation(libs.docker.core) + implementation(libs.docker.transport.zerodep) } } @@ -114,9 +114,13 @@ tasks.jvmTest { // set test environment for proxy tests systemProperty("MITM_PROXY_SCRIPTS_ROOT", projectDir.resolve("proxy-scripts").absolutePath) systemProperty("SSL_CONFIG_PATH", startTestServers.sslConfigPath) + val enableProxyTestsProp = "aws.test.http.enableProxyTests" val runningInCodeBuild = System.getenv().containsKey("CODEBUILD_BUILD_ID") - systemProperty(enableProxyTestsProp, System.getProperties().getOrDefault(enableProxyTestsProp, !runningInCodeBuild)) + val runningInLinux = System.getProperty("os.name").contains("Linux", ignoreCase = true) + val shouldRunProxyTests = !runningInCodeBuild && runningInLinux + + systemProperty(enableProxyTestsProp, System.getProperties().getOrDefault(enableProxyTestsProp, shouldRunProxyTests)) } gradle.buildFinished { diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt new file mode 100644 index 000000000..9561fffac --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt @@ -0,0 +1,63 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test + +import aws.smithy.kotlin.runtime.http.test.util.Docker +import com.github.dockerjava.api.model.AccessMode +import com.github.dockerjava.api.model.Bind +import com.github.dockerjava.api.model.ExposedPort +import com.github.dockerjava.api.model.Volume +import java.io.Closeable + +private const val CONTAINER_MOUNT_POINT = "/home/mitmproxy/scripts" +private const val CONTAINER_PORT = 8080 +private const val IMAGE_NAME = "mitmproxy/mitmproxy:8.1.0" +private val PROXY_SCRIPT_ROOT = System.getProperty("MITM_PROXY_SCRIPTS_ROOT") // defined by gradle script + +// Port used for communication with container +private val exposedPort = ExposedPort.tcp(CONTAINER_PORT) + +/** + * A Docker container which runs the **mitmproxy** image. Upon instantiating this class, a docker container will be + * created and ran with a logger attached echoing logs out to **STDOUT**. The container will be stopped and removed when + * [close] is called. + */ +class MitmContainer(vararg options: String) : Closeable { + private val delegate: Docker.Container + + init { + val cmd = listOf( + "mitmdump", // https://docs.mitmproxy.org/stable/#mitmdump + "--flow-detail", + "2", + "-s", + "$CONTAINER_MOUNT_POINT/fakeupstream.py", + *options, + ).also { println("Initializing container with command: $it") } + + // Make proxy scripts from host filesystem available in container's filesystem + val binding = Bind(PROXY_SCRIPT_ROOT, Volume(CONTAINER_MOUNT_POINT), AccessMode.ro) + + delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort) + + try { + delegate.apply { + start() + waitUntilReady() + } + } catch (e: Throwable) { + close() + throw e + } + } + + /** + * Gets the host port that can be used to communicate to the MITM proxy + */ + val hostPort: Int + get() = delegate.hostPort + + override fun close() = delegate.close() +} diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt index 3ed4a6575..e5d55a4c3 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt @@ -2,7 +2,6 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ - package aws.smithy.kotlin.runtime.http.test import aws.smithy.kotlin.runtime.http.HttpStatusCode @@ -18,49 +17,34 @@ import aws.smithy.kotlin.runtime.http.test.util.AbstractEngineTest import aws.smithy.kotlin.runtime.http.test.util.engineConfig import aws.smithy.kotlin.runtime.http.test.util.test import aws.smithy.kotlin.runtime.net.url.Url +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import org.testcontainers.containers.BindMode -import org.testcontainers.containers.GenericContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.utility.DockerImageName import kotlin.test.assertEquals -// defined by gradle script -private val PROXY_SCRIPT_ROOT = System.getProperty("MITM_PROXY_SCRIPTS_ROOT") -private fun mitmProxyContainer( - vararg options: String, -) = GenericContainer(DockerImageName.parse("mitmproxy/mitmproxy:8.1.0")) - .withExposedPorts(8080) - .withFileSystemBind(PROXY_SCRIPT_ROOT, "/home/mitmproxy/scripts", BindMode.READ_ONLY) - .withLogConsumer { - print(it.utf8String) - }.apply { - val command = buildString { - // load the custom addon which by default does nothing without setting additional options - append("mitmdump --flow-detail 2 -s /home/mitmproxy/scripts/fakeupstream.py") - append(options.joinToString(separator = " ", prefix = " ")) - } - withCommand(command) - } - -@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // enables non-static @BeforeAll/@AfterAll methods @EnabledIfSystemProperty(named = "aws.test.http.enableProxyTests", matches = "true") class ProxyTest : AbstractEngineTest() { + private lateinit var mitmProxy: MitmContainer + + @BeforeAll + fun setUp() { + mitmProxy = MitmContainer("--set", "fakeupstream=aws.amazon.com") + } - @Container - val mitmProxy = mitmProxyContainer("--set fakeupstream=aws.amazon.com") + @AfterAll + fun cleanUp() { + mitmProxy.close() + } @Test - fun testHttpProxy() = testEngines( - // we would expect a customer to configure proxy support on the underlying engine - skipEngines = setOf("KtorEngine"), - ) { + fun testHttpProxy() = testEngines { engineConfig { - val proxyPort = mitmProxy.getMappedPort(8080) + val hostPort = mitmProxy.hostPort proxySelector = ProxySelector { - ProxyConfig.Http("http://127.0.0.1:$proxyPort") + ProxyConfig.Http("http://127.0.0.1:$hostPort") } } @@ -70,22 +54,27 @@ class ProxyTest : AbstractEngineTest() { } } -@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // enables non-static @BeforeAll/@AfterAll methods @EnabledIfSystemProperty(named = "aws.test.http.enableProxyTests", matches = "true") class ProxyAuthTest : AbstractEngineTest() { + private lateinit var mitmProxy: MitmContainer - @Container - val mitmProxy = mitmProxyContainer("--proxyauth testuser:testpass --set fakeupstream=aws.amazon.com") + @BeforeAll + fun setUp() { + mitmProxy = MitmContainer("--proxyauth", "testuser:testpass", "--set", "fakeupstream=aws.amazon.com") + } + + @AfterAll + fun cleanUp() { + mitmProxy.close() + } @Test - fun testHttpProxyAuth() = testEngines( - // we would expect a customer to configure proxy support on the underlying engine - skipEngines = setOf("KtorEngine"), - ) { + fun testHttpProxyAuth() = testEngines { engineConfig { - val proxyPort = mitmProxy.getMappedPort(8080) + val hostPort = mitmProxy.hostPort proxySelector = ProxySelector { - ProxyConfig.Http("http://testuser:testpass@127.0.0.1:$proxyPort") + ProxyConfig.Http("http://testuser:testpass@127.0.0.1:$hostPort") } } diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt new file mode 100644 index 000000000..510a5c5b5 --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -0,0 +1,173 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test.util + +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.command.AsyncDockerCmd +import com.github.dockerjava.api.command.SyncDockerCmd +import com.github.dockerjava.api.model.* +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientImpl +import com.github.dockerjava.zerodep.ZerodepDockerHttpClient +import java.io.Closeable +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.measureTimedValue + +private val DOCKER_HOST = URI.create("unix:///var/run/docker.sock") +private val MAX_POLL_TIME = 10.seconds +private const val POLL_CONNECT_TIMEOUT_MS = 100 +private val POLL_INTERVAL = 250.milliseconds + +/** + * Wrapper class for the Docker client + */ +class Docker { + companion object { + val Instance by lazy { Docker() } + } + + private val client = run { + val config = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + + val httpClient = ZerodepDockerHttpClient.Builder() + .dockerHost(DOCKER_HOST) + .build() + + DockerClientImpl.getInstance(config, httpClient) + } + + fun createContainer( + imageName: String, + cmd: List, + bind: Bind, + exposedPort: ExposedPort, + loggingHandler: (String) -> Unit = ::println, + ): Container { + ensureImageExists(imageName, loggingHandler) + + val portBinding = PortBinding(Ports.Binding.empty(), exposedPort) + + val hostConfig = HostConfig + .newHostConfig() + .withBinds(bind) + .withPortBindings(portBinding) + + val id = client + .createContainerCmd(imageName) + .withHostConfig(hostConfig) + .withExposedPorts(exposedPort) + .withCmd(cmd) + .execAndMeasure { "Created container ${it.id}" } + .id + .substring(0..<12) // Short container IDs are 12 chars vs full container IDs at 64 chars + + val loggerAdapter = LoggerAdapter(loggingHandler) { it.payload.decodeToString() } + + client + .attachContainerCmd(id) + .withFollowStream(true) + .withStdOut(true) + .withStdErr(true) + .withLogs(true) + .execAndMeasure(loggerAdapter) { "Attached logger to container $id" } + .awaitStarted() + + return Container(id, exposedPort) + } + + private fun ensureImageExists(imageName: String, loggingHandler: (String) -> Unit) { + val exists = client + .listImagesCmd() + .withReferenceFilter(imageName) + .execAndMeasure { "Checking for $imageName locally (exists = ${it.any()})" } + .any() + + if (!exists) { + val loggerAdapter = LoggerAdapter(loggingHandler) { it.status } + + client + .pullImageCmd(imageName) + .execAndMeasure(loggerAdapter) { "Started image pull for $imageName" } + .awaitCompletion() + } + } + + inner class Container(val id: String, val exposedPort: ExposedPort) : Closeable { + private val poller = Poller(MAX_POLL_TIME, POLL_INTERVAL) + + override fun close() { + client + .removeContainerCmd(id) + .withForce(true) + .exec() + .also { println("Container $id removed") } + } + + val hostPort: Int by lazy { + poller.pollNotNull("Port $exposedPort in container $id") { + client + .inspectContainerCmd(id) + .exec() + .networkSettings + .ports + .bindings[exposedPort] + ?.first() + ?.hostPortSpec + ?.toInt() + } + } + + private fun isReady() = + Socket().use { socket -> + val endpoint = InetSocketAddress(InetAddress.getLocalHost(), hostPort) + try { + socket.connect(endpoint, POLL_CONNECT_TIMEOUT_MS) + true + } catch (e: IOException) { + false + } + } + + fun start() { + client.startContainerCmd(id).execAndMeasure { "Container $id running" } + } + + fun waitUntilReady() = poller.pollTrue("Socket localHost:$hostPort → $exposedPort on container $id", ::isReady) + } +} + +private class LoggerAdapter( + val handler: (String) -> Unit, + val converter: (I) -> String?, +) : ResultCallback.Adapter() { + override fun onNext(value: I?) { + value?.let(converter)?.let(handler) + } +} + +private fun SyncDockerCmd.execAndMeasure(msg: (T) -> String): T { + val (value, duration) = measureTimedValue { + exec() + } + println("${msg(value)} in $duration") + return value +} + +private fun ?, T, I : ResultCallback> AsyncDockerCmd.execAndMeasure( + input: I, + msg: (I) -> String, +): I { + val (value, duration) = measureTimedValue { + exec(input) + } + println("${msg(value)} in $duration") + return value +} diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt new file mode 100644 index 000000000..6d15bd92c --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test.util + +import aws.smithy.kotlin.runtime.time.Instant +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.time.Duration + +/** + * A utility class for polling a resource until some specific condition is met + * @param maxWait The maximum amount of time the poller will wait for a condition to be met + * @param interval The amount of time to delay between polls + */ +class Poller(val maxWait: Duration, val interval: Duration) { + /** + * Poll [resource] by executing [action] until it returns true + */ + fun pollTrue(resource: String, action: () -> Boolean) { + poll(resource, action) { it } + } + + /** + * Poll [resource] by executing [action] until it returns non-null + */ + fun pollNotNull(resource: String, action: () -> T?): T = poll(resource, action) { it != null }!! + + /** + * Poll [resource] by executing [action] until [condition] is met on the result + */ + fun poll(resource: String, action: () -> T, condition: (T) -> Boolean): T = runBlocking { + val startTime = Instant.now() + val stopTime = startTime + maxWait + + var result = action() + var ready = condition(result) + while (!ready && (Instant.now() + interval < stopTime)) { + delay(interval) + result = action() + ready = condition(result) + } + + check(ready) { "$resource not ready within $maxWait" } + + val elapsed = Instant.now() - startTime + println("$resource is ready after $elapsed") + + result + } +} diff --git a/runtime/runtime-core/api/runtime-core.api b/runtime/runtime-core/api/runtime-core.api index 01b1c46db..a41a4aa47 100644 --- a/runtime/runtime-core/api/runtime-core.api +++ b/runtime/runtime-core/api/runtime-core.api @@ -2086,6 +2086,7 @@ public final class aws/smithy/kotlin/runtime/time/Instant : java/lang/Comparable public final fun getEpochSeconds ()J public final fun getNanosecondsOfSecond ()I public fun hashCode ()I + public final fun minus-5sfh64U (Laws/smithy/kotlin/runtime/time/Instant;)J public final fun minus-LRDsOJo (J)Laws/smithy/kotlin/runtime/time/Instant; public final fun plus-LRDsOJo (J)Laws/smithy/kotlin/runtime/time/Instant; public fun toString ()Ljava/lang/String; diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt index f9ffd6395..bd13ab6b7 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt @@ -44,6 +44,12 @@ public expect class Instant : Comparable { */ public operator fun minus(duration: Duration): Instant + /** + * Returns the duration between [other] and this instant. NOTE: The duration will be negative if [other] occurred + * after this instant. + */ + public operator fun minus(other: Instant): Duration + public companion object { /** * Parse an ISO-8601 formatted string into an [Instant] diff --git a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt index 54f32fc30..7ed814ae3 100644 --- a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt +++ b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt @@ -22,6 +22,8 @@ import java.time.format.SignStyle import java.time.temporal.ChronoField import java.time.temporal.ChronoUnit import kotlin.time.Duration +import kotlin.time.toKotlinDuration +import java.time.Duration as jtDuration import java.time.Instant as jtInstant public actual class Instant(internal val value: jtInstant) : Comparable { @@ -57,6 +59,9 @@ public actual class Instant(internal val value: jtInstant) : Comparable */ public actual operator fun minus(duration: Duration): Instant = plus(-duration) + public actual operator fun minus(other: Instant): Duration = + jtDuration.between(other.value, value).toKotlinDuration() + /** * Encode the [Instant] as a string into the format specified by [TimestampFormat] */ diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt index 671cae6e3..00bc92f2f 100644 --- a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt @@ -40,6 +40,10 @@ public actual class Instant : Comparable { TODO("Not yet implemented") } + public actual operator fun minus(other: Instant): Duration { + TODO("Not yet implemented") + } + public actual companion object { /** * Parse an ISO-8601 formatted string into an [Instant]