From 5bc8d3a2f67f92a984c255bf1561d9b3397b057a Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:12:07 -0700 Subject: [PATCH] Harden Docker and systemd deployment configs (#581) Pin all base images and ollama to fixed versions + @sha256 digests (no floating :latest), run Docker images as an unprivileged openjarvis user (uid 10001), replace the curl|bash NodeSource install with a digest-pinned multi-stage copy, install from the committed uv.lock via uv export --frozen --no-dev (hash-verified, --no-deps), and add systemd sandboxing (NoNewPrivileges, ProtectSystem=strict, PrivateTmp, kernel/SUID protections). Closes #228, #563, #564, #565, #566, #567. --- deploy/docker/Dockerfile | 37 +++- deploy/docker/Dockerfile.gpu | 30 +++- deploy/docker/Dockerfile.gpu.rocm | 34 +++- deploy/docker/Dockerfile.sandbox | 41 ++++- deploy/docker/docker-compose.gpu.nvidia.yml | 4 +- deploy/docker/docker-compose.yml | 4 +- deploy/systemd/openjarvis.service | 20 +++ tests/deployment/test_docker.py | 184 +++++++++++++++++++- 8 files changed, 323 insertions(+), 31 deletions(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 06cf6950..9ff033ff 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,5 +1,9 @@ +# Base images are pinned to an immutable digest (in addition to a human-readable +# tag) so every build resolves the exact same layers — reproducible builds and +# safe rollbacks (#563). + # Stage 1: Build frontend SPA -FROM node:22-slim AS frontend +FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ @@ -8,10 +12,22 @@ COPY frontend/ . RUN npm run build # Stage 2: Build Python package -FROM python:3.12-slim-bookworm AS builder +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder WORKDIR /app -COPY pyproject.toml README.md ./ + +# Install dependencies from the committed lockfile (#567). `uv export --frozen` +# reads uv.lock as-is (no re-resolution) and emits a fully pinned, hash-verified +# requirements set; `--no-deps` then installs exactly that set. This is a +# separate layer from the source copy so dependency installs stay cached when +# only application code changes. +COPY pyproject.toml uv.lock README.md ./ +RUN pip install --no-cache-dir uv && \ + uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ + uv pip install --system --no-deps -r requirements.txt + +# Copy the source and the non-src force-include paths (see pyproject +# [tool.hatch.build.targets.wheel.force-include]) before building the project. COPY src/ src/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows @@ -19,16 +35,25 @@ COPY deploy/windows deploy/windows # Copy built frontend into the server static directory COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ -RUN pip install --no-cache-dir uv && \ - uv pip install --system ".[server]" +# Install the project itself without re-resolving dependencies. +RUN uv pip install --system --no-deps . # Stage 3: Runtime -FROM python:3.12-slim-bookworm +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf COPY --from=builder /usr/local /usr/local COPY --from=builder /app /app WORKDIR /app +# Run as an unprivileged user — the server needs no root privileges, so dropping +# them limits the blast radius of a compromise (#565). The app writes only to +# $HOME (config/cache/state), which is owned by this user. +RUN groupadd --system --gid 10001 openjarvis && \ + useradd --system --uid 10001 --gid openjarvis \ + --create-home --home-dir /home/openjarvis openjarvis +ENV HOME=/home/openjarvis +USER openjarvis + EXPOSE 8000 ENTRYPOINT ["jarvis"] diff --git a/deploy/docker/Dockerfile.gpu b/deploy/docker/Dockerfile.gpu index ab1a2cba..3e37afd3 100644 --- a/deploy/docker/Dockerfile.gpu +++ b/deploy/docker/Dockerfile.gpu @@ -1,5 +1,9 @@ +# Base images are pinned to an immutable digest (in addition to a human-readable +# tag) so every build resolves the exact same layers — reproducible builds and +# safe rollbacks (#563). + # Stage 1: Build frontend SPA -FROM node:22-slim AS frontend +FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ @@ -8,25 +12,31 @@ COPY frontend/ . RUN npm run build # Stage 2: Build Python package (NVIDIA CUDA 12.4) -FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder +FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \ rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY pyproject.toml README.md ./ + +# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile +# for the rationale behind the frozen export + --no-deps install. +COPY pyproject.toml uv.lock README.md ./ +RUN pip install --no-cache-dir uv && \ + uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ + uv pip install --system --no-deps -r requirements.txt + COPY src/ src/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ -RUN pip install --no-cache-dir uv && \ - uv pip install --system ".[server]" +RUN uv pip install --system --no-deps . # Stage 3: Runtime -FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 +FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip && \ @@ -36,6 +46,14 @@ COPY --from=builder /usr/local /usr/local COPY --from=builder /app /app WORKDIR /app +# Run as an unprivileged user (#565). NVIDIA device nodes (/dev/nvidia*) are +# world-accessible, so GPU workloads do not require root. +RUN groupadd --system --gid 10001 openjarvis && \ + useradd --system --uid 10001 --gid openjarvis \ + --create-home --home-dir /home/openjarvis openjarvis +ENV HOME=/home/openjarvis +USER openjarvis + EXPOSE 8000 ENTRYPOINT ["jarvis"] diff --git a/deploy/docker/Dockerfile.gpu.rocm b/deploy/docker/Dockerfile.gpu.rocm index 52c67247..ddbef492 100644 --- a/deploy/docker/Dockerfile.gpu.rocm +++ b/deploy/docker/Dockerfile.gpu.rocm @@ -1,5 +1,9 @@ +# Base images are pinned to an immutable digest (in addition to a human-readable +# tag) so every build resolves the exact same layers — reproducible builds and +# safe rollbacks (#563). + # Stage 1: Build frontend SPA -FROM node:22-slim AS frontend +FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ @@ -8,25 +12,31 @@ COPY frontend/ . RUN npm run build # Stage 2: Build Python package (AMD ROCm 7.2) -FROM rocm/dev-ubuntu-22.04:7.2 AS builder +FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \ rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY pyproject.toml README.md ./ + +# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile +# for the rationale behind the frozen export + --no-deps install. +COPY pyproject.toml uv.lock README.md ./ +RUN pip install --no-cache-dir uv && \ + uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ + uv pip install --system --no-deps -r requirements.txt + COPY src/ src/ COPY scripts/install scripts/install COPY deploy/windows deploy/windows COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/ -RUN pip install --no-cache-dir uv && \ - uv pip install --system ".[server]" +RUN uv pip install --system --no-deps . # Stage 3: Runtime -FROM rocm/dev-ubuntu-22.04:7.2 +FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 RUN apt-get update && \ apt-get install -y --no-install-recommends python3 python3-pip && \ @@ -36,6 +46,18 @@ COPY --from=builder /usr/local /usr/local COPY --from=builder /app /app WORKDIR /app +# Run as an unprivileged user (#565). ROCm GPU access is gated by the `video` and +# `render` groups (see group_add in docker-compose.gpu.rocm.yml), so the user is +# added to both; root is not required. +RUN groupadd --system --gid 10001 openjarvis && \ + useradd --system --uid 10001 --gid openjarvis \ + --create-home --home-dir /home/openjarvis openjarvis && \ + (getent group video >/dev/null || groupadd --system video) && \ + (getent group render >/dev/null || groupadd --system render) && \ + usermod -aG video,render openjarvis +ENV HOME=/home/openjarvis +USER openjarvis + EXPOSE 8000 ENTRYPOINT ["jarvis"] diff --git a/deploy/docker/Dockerfile.sandbox b/deploy/docker/Dockerfile.sandbox index dc585702..a57f28a7 100644 --- a/deploy/docker/Dockerfile.sandbox +++ b/deploy/docker/Dockerfile.sandbox @@ -1,15 +1,42 @@ -FROM python:3.12-slim +# Base images are pinned to an immutable digest (in addition to a human-readable +# tag) so every build resolves the exact same layers (#563). -# Install Node.js 22 -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl ca-certificates && \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs && \ +# Node.js is sourced from the official, digest-pinned image rather than piping a +# remote setup script into bash (`curl ... | bash -`), which performed no +# checksum or signature verification of the downloaded installer (#566). The +# image digest is the integrity check, and the copy is architecture-agnostic. +FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node + +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf + +# libstdc++6 + ca-certificates are the only runtime requirements of the Node +# binary copied below (the python slim image already provides libc/libgcc). +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \ rm -rf /var/lib/apt/lists/* +# Transplant the Node.js runtime from the official image. Both images are Debian +# bookworm, so the glibc/libstdc++ ABI matches. +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + WORKDIR /app + +# Install dependencies from the committed lockfile (#567): `uv export --frozen` +# reads uv.lock as-is and emits a pinned, hash-verified set installed with +# --no-deps (no re-resolution). Copied first so this layer caches independently +# of application source. +COPY pyproject.toml uv.lock README.md ./ +RUN pip install --no-cache-dir uv && \ + uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \ + uv pip install --system --no-deps -r requirements.txt + COPY . . -RUN pip install --no-cache-dir ".[server]" + +# Install the project itself without re-resolving dependencies. +RUN uv pip install --system --no-deps . LABEL openjarvis-sandbox=true diff --git a/deploy/docker/docker-compose.gpu.nvidia.yml b/deploy/docker/docker-compose.gpu.nvidia.yml index 22dcb450..0fcbad62 100644 --- a/deploy/docker/docker-compose.gpu.nvidia.yml +++ b/deploy/docker/docker-compose.gpu.nvidia.yml @@ -18,7 +18,9 @@ services: capabilities: [gpu] ollama: - image: ollama/ollama:latest + # Pinned to a fixed version + digest for reproducible deployments (#563); + # must match the tag in docker-compose.yml. + image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee environment: - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,utility diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 8edbb9bf..2d5f231b 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -18,7 +18,9 @@ services: restart: unless-stopped ollama: - image: ollama/ollama:latest + # Pinned to a fixed version + digest for reproducible deployments and + # predictable rollbacks (#563). Bump deliberately, not implicitly via :latest. + image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee ports: - "11434:11434" volumes: diff --git a/deploy/systemd/openjarvis.service b/deploy/systemd/openjarvis.service index 3a94d36b..96f8688b 100644 --- a/deploy/systemd/openjarvis.service +++ b/deploy/systemd/openjarvis.service @@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis # OPENJARVIS_API_KEY= (generate one: `jarvis auth generate-key`) # It is not prefixed with "-", so the unit fails to start if the file is # missing — preventing an accidentally unauthenticated public server. +# Keep secrets here (mode 0600, owned by root) rather than inline Environment= +# lines, which leak into `systemctl show` and the journal. EnvironmentFile=/etc/openjarvis/env +# --- Sandboxing / hardening (#564) --- +# Conservative set: tightens the unit without blocking the server's normal I/O +# or local GPU inference. ProtectSystem=strict makes the whole filesystem +# read-only except ReadWritePaths, so $HOME (config/cache/state under +# /opt/openjarvis) stays writable. +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/opt/openjarvis +ProtectHome=true +PrivateTmp=true +ProtectControlGroups=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true + [Install] WantedBy=multi-user.target diff --git a/tests/deployment/test_docker.py b/tests/deployment/test_docker.py index 72396a85..dbd5ef21 100644 --- a/tests/deployment/test_docker.py +++ b/tests/deployment/test_docker.py @@ -1,7 +1,14 @@ -"""Tests for Docker and deployment files.""" +"""Tests for Docker and deployment files. + +These are static file-content checks (no Docker daemon required) so they run in +the default CI lane. They guard the deployment hardening from #228 and its +sub-issues (#563 image pinning, #564 systemd hardening, #565 non-root, #566 +secure Node install, #567 frozen lockfile installs). +""" from __future__ import annotations +import re from pathlib import Path try: @@ -11,6 +18,35 @@ except ModuleNotFoundError: # pragma: no cover - Python < 3.11 ROOT = Path(__file__).resolve().parent.parent.parent DOCKER_DIR = ROOT / "deploy" / "docker" +SYSTEMD_DIR = ROOT / "deploy" / "systemd" + + +def _dockerfiles() -> list[Path]: + return sorted(DOCKER_DIR.glob("Dockerfile*")) + + +def _compose_files() -> list[Path]: + return sorted(DOCKER_DIR.glob("docker-compose*.yml")) + + +def _from_lines(content: str) -> list[str]: + return [ + ln.strip() + for ln in content.splitlines() + if ln.strip().upper().startswith("FROM ") + ] + + +def _image_lines(content: str) -> list[str]: + """`image: ...` lines from a compose file, ignoring comments.""" + out = [] + for ln in content.splitlines(): + stripped = ln.strip() + if stripped.startswith("#"): + continue + if stripped.startswith("image:"): + out.append(stripped) + return out class TestDockerFiles: @@ -37,10 +73,12 @@ class TestDockerFiles: ] non_src_includes = [s for s in force_include if not s.startswith("src/")] - install_marker = 'uv pip install --system ".[server]"' + # The project itself is built by the final `--no-deps .` install (#567); + # the force-includes must be present before that step. + install_marker = "uv pip install --system --no-deps ." wheel_dockerfiles = [ p - for p in sorted(DOCKER_DIR.glob("Dockerfile*")) + for p in _dockerfiles() if install_marker in p.read_text() and "COPY src/ src/" in p.read_text() ] # Sanity: we actually found the wheel-building Dockerfiles to guard. @@ -86,4 +124,142 @@ class TestDockerFiles: assert "ollama:" in content def test_systemd_service_exists(self): - assert (ROOT / "deploy" / "systemd" / "openjarvis.service").is_file() + assert (SYSTEMD_DIR / "openjarvis.service").is_file() + + +class TestImagePinning: + """#563 — base images and ollama pinned to fixed versions + digests.""" + + def test_no_floating_latest_tag_in_image_directives(self): + # `:latest` only acceptable inside comments (rationale text). + for path in _dockerfiles() + _compose_files(): + for ln in path.read_text().splitlines(): + code = ln.split("#", 1)[0] + assert ":latest" not in code, f"{path.name}: floating :latest in {ln!r}" + + def test_every_from_pins_a_digest(self): + for path in _dockerfiles(): + froms = _from_lines(path.read_text()) + assert froms, f"{path.name}: no FROM lines found" + for ln in froms: + assert "@sha256:" in ln, ( + f"{path.name}: FROM is not digest-pinned: {ln!r}" + ) + + def test_ollama_image_pinned_with_version_and_digest(self): + for path in _compose_files(): + for ln in _image_lines(path.read_text()): + if "ollama/ollama" in ln: + assert "@sha256:" in ln, f"{path.name}: ollama not digest-pinned" + # A concrete version tag (digits) must accompany the digest. + assert re.search(r"ollama/ollama:\d", ln), ( + f"{path.name}: ollama missing a version tag" + ) + + +class TestNonRootUser: + """#565 — GPU images (and the base image) drop root.""" + + NON_ROOT = ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"] + + def test_creates_and_switches_to_non_root_user(self): + for name in self.NON_ROOT: + content = (DOCKER_DIR / name).read_text() + assert "useradd" in content, f"{name}: no useradd" + # A USER directive switching away from root must exist and not be root. + user_lines = [ + ln.strip() + for ln in content.splitlines() + if ln.strip().upper().startswith("USER ") + ] + assert user_lines, f"{name}: no USER directive" + assert all("root" not in ln for ln in user_lines), ( + f"{name}: USER directive still root" + ) + + def test_user_directive_is_last_stage(self): + # USER must appear after the final FROM so the runtime stage is non-root. + for name in self.NON_ROOT: + content = (DOCKER_DIR / name).read_text() + last_from = content.rfind("\nFROM ") + user_idx = content.rfind("\nUSER ") + assert user_idx > last_from, f"{name}: USER not in final runtime stage" + + +class TestSandboxNodeSecurity: + """#566 — Node installed without an unverified curl|bash pipe.""" + + def test_no_curl_pipe_bash(self): + content = (DOCKER_DIR / "Dockerfile.sandbox").read_text() + for ln in content.splitlines(): + code = ln.split("#", 1)[0] # ignore the explanatory comment + assert "| bash" not in code and "|bash" not in code, ( + f"unverified pipe-to-bash install remains: {ln!r}" + ) + assert "nodesource.com" not in code, "still using NodeSource setup script" + + def test_node_sourced_from_pinned_official_image(self): + content = (DOCKER_DIR / "Dockerfile.sandbox").read_text() + assert "COPY --from=node" in content, "Node not copied from pinned image stage" + froms = _from_lines(content) + assert any("node:" in ln and "@sha256:" in ln for ln in froms), ( + "no digest-pinned node base stage" + ) + + +class TestFrozenInstall: + """#567 — Docker builds install from the committed, frozen uv.lock.""" + + BUILD_DOCKERFILES = [ + "Dockerfile", + "Dockerfile.gpu", + "Dockerfile.gpu.rocm", + "Dockerfile.sandbox", + ] + + def test_copies_lockfile(self): + for name in self.BUILD_DOCKERFILES: + content = (DOCKER_DIR / name).read_text() + assert "uv.lock" in content, f"{name}: uv.lock not referenced" + + def test_uses_frozen_export(self): + for name in self.BUILD_DOCKERFILES: + content = (DOCKER_DIR / name).read_text() + assert "uv export --frozen" in content, f"{name}: no frozen export" + assert "--no-deps" in content, ( + f"{name}: deps re-resolved (missing --no-deps)" + ) + + def test_no_unpinned_pyproject_resolution(self): + # The old `uv pip install --system ".[server]"` re-resolved deps on every + # build; it must not come back. + for name in self.BUILD_DOCKERFILES: + content = (DOCKER_DIR / name).read_text() + assert '".[server]"' not in content, ( + f"{name}: still re-resolves from pyproject" + ) + + +class TestSystemdHardening: + """#564 — systemd unit ships secrets via EnvironmentFile and is sandboxed.""" + + def _service(self) -> str: + return (SYSTEMD_DIR / "openjarvis.service").read_text() + + def test_environment_file_for_secrets(self): + assert "EnvironmentFile=" in self._service() + + def test_core_hardening_directives_present(self): + content = self._service() + for directive in ( + "NoNewPrivileges=true", + "ProtectSystem=strict", + "PrivateTmp=true", + ): + assert directive in content, f"missing hardening directive: {directive}" + + def test_writable_state_path_declared(self): + # ProtectSystem=strict makes the FS read-only; the working/home dir must + # be re-granted write access or the server cannot persist config/state. + content = self._service() + assert "ReadWritePaths=" in content