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:
hsiegeln
2026-04-25 20:58:26 +02:00
parent c5b6f2bbad
commit 8e9ad47077
4 changed files with 296 additions and 3 deletions

View File

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