mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
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.
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis
|
||||
# OPENJARVIS_API_KEY=<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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user