Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: address testcontainers vulnerability by replacing with docker-java #1088

Merged
merged 9 commits into from
May 3, 2024
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ allprojects {
)
}
}

// Enables running `./gradlew allDeps` to get a comprehensive list of dependencies for every subproject
tasks.register<DependencyReportTask>("allDeps") { }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this different from ./gradlew dependencies?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, ./gradlew dependencies only shows the dependencies of the target project (root if otherwise unspecified). I initially ran ./gradlew dependencies to grep for where we transitively consumed the Apache Commons Compress library but couldn't find it because it only showed me the root project dependencies.

Running ./gradlew :runtime:protocol:http-client-engines:test-suite:dependencies shows our use of Apache Commons Compress but then you have to know that :runtime:protocol:http-client-engines:test-suite is where to look. This new task registers an effective alias of dependencies for every subproject in such a way that it can be invoked at the root project and yield the dependencies for root and every subproject.

}

// configure the root multimodule docs
Expand Down
6 changes: 3 additions & 3 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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-client-version = "3.3.6"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: docker-java-version might be more accurate

ktor-version = "2.3.6"
kaml-version = "0.55.0"
jsoup-version = "1.16.2"
Expand Down Expand Up @@ -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-client-version" }
docker-transport-zerodep = { module = "com.github.docker-java:docker-java-transport-zerodep", version.ref = "docker-client-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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ kotlin {

jvmTest {
dependencies {
implementation(libs.testcontainers)
implementation(libs.testcontainers.junit.jupiter)
implementation(libs.docker.core)
implementation(libs.docker.transport.zerodep)
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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)

class MitmContainer(vararg options: String) : Closeable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing KDocs

private val delegate: Docker.Container

init {
val cmd = listOf(
"mitmdump",
"--flow-detail",
"2",
"-s",
"$CONTAINER_MOUNT_POINT/fakeupstream.py",
*options,
).also { println("Initializing container with command: $it") }

// Make proxy scripts from host available to container
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
}
}

val hostPort: Int
get() = delegate.hostPort

override fun close() = delegate.close()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
}

Expand All @@ -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")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
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<Frame>(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<PullResponseItem>(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<I>(
val handler: (String) -> Unit,
val converter: (I) -> String?,
) : ResultCallback.Adapter<I>() {
override fun onNext(value: I?) {
value?.let(converter)?.let(handler)
}
}

private fun <T> SyncDockerCmd<T>.execAndMeasure(msg: (T) -> String): T {
val (value, duration) = measureTimedValue {
exec()
}
println("${msg(value)} in $duration")
return value
}

private fun <C : AsyncDockerCmd<C, T>?, T, I : ResultCallback<T>> AsyncDockerCmd<C, T>.execAndMeasure(
input: I,
msg: (I) -> String,
): I {
val (value, duration) = measureTimedValue {
exec(input)
}
println("${msg(value)} in $duration")
return value
}
Loading
Loading