Skip to content

Commit

Permalink
chore: address testcontainers vulnerability by replacing with docker-…
Browse files Browse the repository at this point in the history
…java
  • Loading branch information
ianbotsf committed May 2, 2024
1 parent 152ded4 commit af4778a
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 47 deletions.
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") { }
}

// 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"
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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 {
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)

try {
delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort).apply {
attachLogger(::println)
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:[email protected]:$proxyPort")
ProxyConfig.Http("http://testuser:[email protected]:$hostPort")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* 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.util.OsFamily
import aws.smithy.kotlin.runtime.util.PlatformProvider
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 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()

// Default Docker host locations according to Docker reference guide:
// https://docs.docker.com/reference/cli/dockerd/#bind-docker-to-another-hostport-or-a-unix-socket
val dockerHostString = when (PlatformProvider.System.osInfo().family) {
OsFamily.Windows -> "tcp://localhost:2376"
else -> "unix:///var/run/docker.sock"
}

val httpClient = ZerodepDockerHttpClient.Builder()
.dockerHost(URI.create(dockerHostString))
.build()

DockerClientImpl.getInstance(config, httpClient)
}

fun createContainer(imageName: String, cmd: List<String>, bind: Bind, exposedPort: ExposedPort): Container {
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

return Container(id, exposedPort)
}

inner class Container(val id: String, val exposedPort: ExposedPort) : Closeable {
private val poller = Poller(MAX_POLL_TIME, POLL_INTERVAL)

fun attachLogger(handler: (String) -> Unit) {
val logger = object : ResultCallback.Adapter<Frame>() {
override fun onNext(frame: Frame?) {
frame?.payload?.decodeToString()?.let(handler)
}
}

client
.attachContainerCmd(id)
.withFollowStream(true)
.withStdOut(true)
.withStdErr(true)
.withLogs(true)
.execAndMeasure(logger) { "Attached logger to container $id" }
.awaitStarted()
}

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 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

0 comments on commit af4778a

Please sign in to comment.