diff --git a/server/Dockerfile b/server/Dockerfile index f008e5ebf..4b048f26f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM python:3.10-slim AS builder +FROM python:3.10-alpine AS builder # Optional: inject the release version when .git is unavailable so hatch-vcs # resolves the real version instead of falling back to 0.1.0.dev0. Passed by @@ -28,9 +28,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl ca-certificates RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:/root/.cargo/bin:${PATH}" @@ -44,7 +42,7 @@ COPY LICENSE README.md ./ # Install the project itself into the venv (deps already synced) RUN uv pip install --no-deps --editable . -FROM python:3.10-slim AS runtime +FROM python:3.10-alpine AS runtime ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONDONTWRITEBYTECODE=1 \ @@ -55,6 +53,15 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ WORKDIR /app +# The service runs entirely from the copied virtualenv. The base image's +# system setuptools is build tooling, not a runtime dependency, and its +# vendored packages needlessly expand the production vulnerability surface. +RUN rm -rf \ + /usr/local/lib/python3.10/site-packages/_distutils_hack \ + /usr/local/lib/python3.10/site-packages/pkg_resources \ + /usr/local/lib/python3.10/site-packages/setuptools \ + /usr/local/lib/python3.10/site-packages/setuptools-*.dist-info + COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/opensandbox_server /app/opensandbox_server COPY --from=builder /app/opensandbox_server/examples/example.config.k8s.toml /etc/opensandbox/config.toml diff --git a/server/opensandbox_server/services/k8s/egress_helper.py b/server/opensandbox_server/services/k8s/egress_helper.py index 6127787bb..b92de579c 100644 --- a/server/opensandbox_server/services/k8s/egress_helper.py +++ b/server/opensandbox_server/services/k8s/egress_helper.py @@ -44,11 +44,19 @@ def prep_execd_init_for_egress(exec_install_script: str) -> tuple[str, Dict[str, security context dict must be applied to the execd init container (typically via ``build_security_context_from_dict`` in ``security_context``). + A pod-level non-root UID otherwise overrides this init container and makes + the sysctl write fail with EPERM. Keep the root exception explicit and + local to the privileged init container. + Returns: - ``(prefixed_shell_script, {"privileged": True})`` + The prefixed shell script and its privileged root security context. """ script = f"set -e; echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6 && {exec_install_script}" - return script, {"privileged": True} + return script, { + "privileged": True, + "runAsNonRoot": False, + "runAsUser": 0, + } def build_security_context_for_sandbox_container( @@ -113,6 +121,11 @@ def apply_egress_to_spec( "env": env, "securityContext": { "capabilities": {"add": ["NET_ADMIN"]}, + # A pod-level non-root UID clears NET_ADMIN from the effective + # capability set. The sidecar must retain it to install the + # nftables/iptables policy, so scope the root exception here. + "runAsNonRoot": False, + "runAsUser": 0, }, "ports": [{"name": "egress-api", "containerPort": 18080}], "readinessProbe": { diff --git a/server/opensandbox_server/services/k8s/security_context.py b/server/opensandbox_server/services/k8s/security_context.py index 8f398d1ea..2d1b17100 100644 --- a/server/opensandbox_server/services/k8s/security_context.py +++ b/server/opensandbox_server/services/k8s/security_context.py @@ -41,13 +41,22 @@ def build_security_context_from_dict( ) privileged = security_context_dict.get("privileged") - - if capabilities is None and privileged is None: + run_as_non_root = security_context_dict.get("runAsNonRoot") + run_as_user = security_context_dict.get("runAsUser") + + if ( + capabilities is None + and privileged is None + and run_as_non_root is None + and run_as_user is None + ): return None return V1SecurityContext( capabilities=capabilities, privileged=privileged, + run_as_non_root=run_as_non_root, + run_as_user=run_as_user, ) @@ -72,6 +81,12 @@ def serialize_security_context_to_dict( if security_context.privileged is not None: result["privileged"] = security_context.privileged + if getattr(security_context, "run_as_non_root", None) is not None: + result["runAsNonRoot"] = security_context.run_as_non_root + + if getattr(security_context, "run_as_user", None) is not None: + result["runAsUser"] = security_context.run_as_user + if getattr(security_context, "seccomp_profile", None) is not None: sp = security_context.seccomp_profile profile_dict: Dict[str, Any] = {"type": sp.type} diff --git a/server/opensandbox_server/services/k8s/template_manager.py b/server/opensandbox_server/services/k8s/template_manager.py index 87bf03059..dd337e44b 100644 --- a/server/opensandbox_server/services/k8s/template_manager.py +++ b/server/opensandbox_server/services/k8s/template_manager.py @@ -102,7 +102,59 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any result[key] = BaseSandboxTemplateManager._deep_merge( result[key], override_value ) + elif BaseSandboxTemplateManager._are_named_object_lists( + result[key], override_value + ): + result[key] = BaseSandboxTemplateManager._merge_named_object_lists( + result[key], override_value + ) else: result[key] = BaseSandboxTemplateManager._deep_copy(override_value) return result + + @staticmethod + def _are_named_object_lists(base: Any, override: Any) -> bool: + """Return whether both values are non-empty lists keyed by unique names. + + Kubernetes uses ``name`` as the merge key for containers, init + containers, environment variables, volumes, and several related pod + fields. Restricting this behavior to unambiguously named object lists + keeps ordinary lists, such as commands and tolerations, replace-only. + """ + if not isinstance(base, list) or not isinstance(override, list): + return False + if not base or not override: + return False + + for items in (base, override): + names = [item.get("name") for item in items if isinstance(item, dict)] + if len(names) != len(items): + return False + if any(not isinstance(name, str) or not name for name in names): + return False + if len(set(names)) != len(names): + return False + + return True + + @staticmethod + def _merge_named_object_lists( + base: list[Dict[str, Any]], override: list[Dict[str, Any]] + ) -> list[Dict[str, Any]]: + """Merge runtime objects into template objects with the same name.""" + result = BaseSandboxTemplateManager._deep_copy(base) + indexes = {item["name"]: index for index, item in enumerate(result)} + + for override_item in override: + name = override_item["name"] + if name in indexes: + index = indexes[name] + result[index] = BaseSandboxTemplateManager._deep_merge( + result[index], override_item + ) + else: + indexes[name] = len(result) + result.append(BaseSandboxTemplateManager._deep_copy(override_item)) + + return result diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index 4d2358503..3b179198d 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -1787,6 +1787,8 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) caps = sidecar.get("securityContext", {}).get("capabilities", {}) assert "NET_ADMIN" in caps.get("add", []) assert sidecar.get("securityContext", {}).get("privileged") is not True + assert sidecar["securityContext"]["runAsNonRoot"] is False + assert sidecar["securityContext"]["runAsUser"] == 0 assert "command" not in sidecar assert sidecar["readinessProbe"]["httpGet"]["path"] == "/healthz" assert sidecar["readinessProbe"]["httpGet"]["port"] == 18080 @@ -1797,6 +1799,8 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) assert execd_init["name"] == "execd-installer" assert execd_init["image"] == "execd:latest" assert execd_init.get("securityContext", {}).get("privileged") is True + assert execd_init["securityContext"]["runAsNonRoot"] is False + assert execd_init["securityContext"]["runAsUser"] == 0 assert "/proc/sys/net/ipv6/conf/all/disable_ipv6" in execd_init["args"][0] main = next(c for c in containers if c["name"] == "sandbox") diff --git a/server/tests/k8s/test_batchsandbox_template.py b/server/tests/k8s/test_batchsandbox_template.py index e06e8731d..4a426f1cf 100644 --- a/server/tests/k8s/test_batchsandbox_template.py +++ b/server/tests/k8s/test_batchsandbox_template.py @@ -112,6 +112,58 @@ def test_deep_merge_replaces_lists_not_merges(self): result = BatchSandboxTemplateManager._deep_merge(base, override) assert result == {"spec": {"tolerations": [{"key": "b"}]}} + + def test_deep_merge_merges_named_kubernetes_objects(self): + base = { + "spec": { + "containers": [ + { + "name": "sandbox", + "securityContext": { + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + }, + "resources": {"limits": {"memory": "6Gi"}}, + }, + {"name": "template-sidecar", "image": "template:latest"}, + ] + } + } + override = { + "spec": { + "containers": [ + { + "name": "sandbox", + "image": "runtime:latest", + "resources": {"requests": {"cpu": "100m"}}, + }, + {"name": "runtime-sidecar", "image": "runtime-sidecar:latest"}, + ] + } + } + + result = BatchSandboxTemplateManager._deep_merge(base, override) + + assert result["spec"]["containers"] == [ + { + "name": "sandbox", + "image": "runtime:latest", + "securityContext": { + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + }, + "resources": { + "limits": {"memory": "6Gi"}, + "requests": {"cpu": "100m"}, + }, + }, + {"name": "template-sidecar", "image": "template:latest"}, + {"name": "runtime-sidecar", "image": "runtime-sidecar:latest"}, + ] + + # The merge must not mutate either input template. + assert "image" not in base["spec"]["containers"][0] + assert "securityContext" not in override["spec"]["containers"][0] def test_deep_merge_none_values_do_not_override(self): base = {"spec": {"expireTime": "2024-12-31"}} diff --git a/server/tests/k8s/test_egress_helper.py b/server/tests/k8s/test_egress_helper.py index d19375fc9..68772c773 100644 --- a/server/tests/k8s/test_egress_helper.py +++ b/server/tests/k8s/test_egress_helper.py @@ -221,7 +221,7 @@ def test_handles_missing_default_action(self): assert "egress" in policy_dict def test_security_context_adds_net_admin_not_privileged(self): - """Egress sidecar uses NET_ADMIN only (IPv6 is disabled in execd init when egress is on).""" + """Egress uses the narrow NET_ADMIN + root exception, not privileged mode.""" egress_image = "opensandbox/egress:v1.1.6" network_policy = NetworkPolicy( default_action="deny", @@ -233,6 +233,8 @@ def test_security_context_adds_net_admin_not_privileged(self): security_context = container["securityContext"] assert security_context.get("privileged") is not True assert "NET_ADMIN" in security_context.get("capabilities", {}).get("add", []) + assert security_context["runAsNonRoot"] is False + assert security_context["runAsUser"] == 0 def test_no_command_uses_image_entrypoint(self): container = _egress_container( @@ -513,7 +515,11 @@ class TestPrepExecdInitForEgress: def test_returns_privileged_security_dict_and_prefixed_script(self): base = "cp ./execd /opt/opensandbox/execd" script, sc = prep_execd_init_for_egress(base) - assert sc == {"privileged": True} + assert sc == { + "privileged": True, + "runAsNonRoot": False, + "runAsUser": 0, + } assert "/proc/sys/net/ipv6/conf/all/disable_ipv6" in script assert script.endswith(base)