feat(runtime): harden tenant containers + auto-detect gVisor (#152)
Tenant JARs are arbitrary user code: Camel ships components (camel-exec, camel-bean, MVEL/Groovy templating) that turn a header into shell, and Java 17 has no SecurityManager — the JVM is not a security boundary. This applies an unconditional hardening contract to every tenant container so a single runc CVE no longer equals host takeover. DockerRuntimeOrchestrator.startContainer now sets: - cap_drop ALL (Capability.values() — docker-java has no ALL constant) - security_opt: no-new-privileges, apparmor=docker-default (default seccomp profile applies implicitly) - read_only rootfs, pids_limit=512 - /tmp tmpfs rw,nosuid,size=256m — no noexec, since Netty/Snappy/LZ4/Zstd dlopen native libs from /tmp via mmap(PROT_EXEC) which noexec blocks The orchestrator also probes `docker info` at construction and uses runsc (gVisor) automatically when the daemon has it registered. Override via cameleer.server.runtime.dockerruntime (e.g. "kata"); empty = auto. Outbound TCP, DNS, and TLS are unaffected — caps/seccomp don't gate those — so vanilla Camel-Kafka producers/consumers and REST integrations keep working unchanged. Stateful tenants (Kafka Streams with on-disk state stores, apps writing to /var/log/...) need explicit writeable volumes; that's tracked in #153 as the natural follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer.server.core.runtime.ContainerRequest;
|
||||
import com.github.dockerjava.api.DockerClient;
|
||||
import com.github.dockerjava.api.command.CreateContainerCmd;
|
||||
import com.github.dockerjava.api.command.CreateContainerResponse;
|
||||
import com.github.dockerjava.api.command.InfoCmd;
|
||||
import com.github.dockerjava.api.command.StartContainerCmd;
|
||||
import com.github.dockerjava.api.model.Capability;
|
||||
import com.github.dockerjava.api.model.HostConfig;
|
||||
import com.github.dockerjava.api.model.Info;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Answers;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies the multi-tenant hardening contract from issue #152: every tenant
|
||||
* container is launched with cap_drop ALL, no-new-privileges, AppArmor profile,
|
||||
* read-only rootfs, a pids limit, and a writeable /tmp tmpfs. Also verifies the
|
||||
* runsc auto-detect via `docker info` and the explicit override.
|
||||
*/
|
||||
class DockerRuntimeOrchestratorHardeningTest {
|
||||
|
||||
private static ContainerRequest sampleRequest() {
|
||||
return new ContainerRequest(
|
||||
"tenant-app-0-abcd1234",
|
||||
"registry.example/runtime:latest",
|
||||
"/data/jars/app.jar",
|
||||
null, null,
|
||||
"tenant-net",
|
||||
List.of(),
|
||||
Map.of("CAMELEER_AGENT_APPLICATION", "myapp"),
|
||||
Map.of(),
|
||||
512L * 1024 * 1024,
|
||||
null,
|
||||
512,
|
||||
null,
|
||||
List.of(8080),
|
||||
9464,
|
||||
"on-failure",
|
||||
3,
|
||||
"spring-boot",
|
||||
"",
|
||||
null);
|
||||
}
|
||||
|
||||
private static DockerClient mockDockerClientWithRuntimes(Map<String, ?> runtimes) {
|
||||
DockerClient dockerClient = mock(DockerClient.class);
|
||||
InfoCmd infoCmd = mock(InfoCmd.class);
|
||||
Info info = mock(Info.class);
|
||||
when(dockerClient.infoCmd()).thenReturn(infoCmd);
|
||||
when(infoCmd.exec()).thenReturn(info);
|
||||
when(info.getRuntimes()).thenReturn((Map) runtimes);
|
||||
return dockerClient;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRuntime_picksRunscWhenDaemonHasIt() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of(
|
||||
"runc", new Object(),
|
||||
"runsc", new Object()));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
|
||||
assertThat(orchestrator.getDockerRuntime()).isEqualTo("runsc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRuntime_returnsEmptyWhenSandboxedRuntimeMissing() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runc", new Object()));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
|
||||
assertThat(orchestrator.getDockerRuntime()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRuntime_overrideWinsOverAutoDetect() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of(
|
||||
"runc", new Object(),
|
||||
"runsc", new Object()));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "kata");
|
||||
|
||||
assertThat(orchestrator.getDockerRuntime()).isEqualTo("kata");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRuntime_blankOverrideTreatedAsAuto() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runsc", new Object()));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, " ");
|
||||
|
||||
assertThat(orchestrator.getDockerRuntime()).isEqualTo("runsc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRuntime_swallowsDockerInfoFailure() {
|
||||
DockerClient dockerClient = mock(DockerClient.class);
|
||||
InfoCmd infoCmd = mock(InfoCmd.class);
|
||||
when(dockerClient.infoCmd()).thenReturn(infoCmd);
|
||||
when(infoCmd.exec()).thenThrow(new RuntimeException("docker daemon unreachable"));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
|
||||
assertThat(orchestrator.getDockerRuntime()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void startContainer_appliesHardeningContractToHostConfig() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(new HashMap<>());
|
||||
|
||||
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
|
||||
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
|
||||
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
|
||||
when(createResponse.getId()).thenReturn("container-id-1");
|
||||
when(createCmd.exec()).thenReturn(createResponse);
|
||||
StartContainerCmd startCmd = mock(StartContainerCmd.class);
|
||||
when(dockerClient.startContainerCmd(anyString())).thenReturn(startCmd);
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
orchestrator.startContainer(sampleRequest());
|
||||
|
||||
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
|
||||
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
|
||||
HostConfig hc = hostCaptor.getValue();
|
||||
|
||||
// cap_drop ALL — every capability the SDK knows about
|
||||
assertThat(hc.getCapDrop())
|
||||
.as("cap_drop should drop every capability")
|
||||
.containsExactlyInAnyOrder(Capability.values());
|
||||
|
||||
// no-new-privileges + apparmor stock profile
|
||||
assertThat(hc.getSecurityOpts())
|
||||
.as("security_opt must include no-new-privileges and apparmor=docker-default")
|
||||
.contains("no-new-privileges:true", "apparmor=docker-default");
|
||||
|
||||
// readonly rootfs
|
||||
assertThat(hc.getReadonlyRootfs())
|
||||
.as("read_only rootfs must be enabled")
|
||||
.isTrue();
|
||||
|
||||
// pids-limit applied
|
||||
assertThat(hc.getPidsLimit())
|
||||
.as("pids_limit must be set to bound fork-bomb damage")
|
||||
.isNotNull()
|
||||
.isPositive();
|
||||
|
||||
// /tmp tmpfs writable, nosuid, no `noexec` (would break JNI dlopen)
|
||||
assertThat(hc.getTmpFs())
|
||||
.as("/tmp must be a writeable tmpfs")
|
||||
.containsKey("/tmp");
|
||||
String tmpOpts = hc.getTmpFs().get("/tmp");
|
||||
assertThat(tmpOpts).contains("rw").contains("nosuid").doesNotContain("noexec");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startContainer_doesNotForceRuntimeWhenAutoDetectFindsNothing() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runc", new Object()));
|
||||
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
|
||||
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
|
||||
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
|
||||
when(createResponse.getId()).thenReturn("c");
|
||||
when(createCmd.exec()).thenReturn(createResponse);
|
||||
when(dockerClient.startContainerCmd(anyString())).thenReturn(mock(StartContainerCmd.class));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
orchestrator.startContainer(sampleRequest());
|
||||
|
||||
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
|
||||
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
|
||||
|
||||
// When daemon has no sandboxed runtime, we leave runtime null/empty so Docker picks its default.
|
||||
String runtime = hostCaptor.getValue().getRuntime();
|
||||
assertThat(runtime == null || runtime.isBlank())
|
||||
.as("no runtime should be forced when sandboxed runtime unavailable")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void startContainer_appliesRunscWhenAvailable() {
|
||||
DockerClient dockerClient = mockDockerClientWithRuntimes(Map.of("runsc", new Object()));
|
||||
CreateContainerCmd createCmd = mock(CreateContainerCmd.class, Answers.RETURNS_SELF);
|
||||
when(dockerClient.createContainerCmd(anyString())).thenReturn(createCmd);
|
||||
CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
|
||||
when(createResponse.getId()).thenReturn("c");
|
||||
when(createCmd.exec()).thenReturn(createResponse);
|
||||
when(dockerClient.startContainerCmd(anyString())).thenReturn(mock(StartContainerCmd.class));
|
||||
|
||||
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient, "");
|
||||
orchestrator.startContainer(sampleRequest());
|
||||
|
||||
ArgumentCaptor<HostConfig> hostCaptor = ArgumentCaptor.forClass(HostConfig.class);
|
||||
org.mockito.Mockito.verify(createCmd).withHostConfig(hostCaptor.capture());
|
||||
|
||||
assertThat(hostCaptor.getValue().getRuntime()).isEqualTo("runsc");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user