From 29c691a0380d1f7e21ab976d3fb3178439d9a372 Mon Sep 17 00:00:00 2001 From: Lore Engine Dev Date: Thu, 18 Jun 2026 19:44:21 -0400 Subject: [PATCH] docker: drop root in the container (review-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New non-root user 'lore' (uid 10001) created early so --chown works on subsequent COPYs. The final USER directive means everything reachable from the MCP wire (pickle load, 36 tools including 12 mutators) runs as UID 10001, not root. - --chown=lore:lore on all COPY lines. - Removed the redundant .graph.pkl COPY (the file is bundled via the lore_engine_poc/ directory copy, but the explicit line was hiding that — restore the 'override at runtime' instruction in the README). - New test: test_docker_runs_as_non_root — execs 'id -u' inside the container and asserts the uid is non-zero. Co-Authored-By: Claude --- Dockerfile | 24 +++++++---- tests/test_mcp/test_dockerfile.py | 72 +++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index aaf1aab..f1ff59c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,22 +9,30 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ LORE_HTTP_HOST=0.0.0.0 \ LORE_HTTP_PORT=8765 +# Create a non-root user early so subsequent --chown works and so the +# final USER directive doesn't need to chown retroactively. +RUN groupadd --system --gid 10001 lore \ + && useradd --system --uid 10001 --gid lore --no-create-home --home-dir /app lore + WORKDIR /app # Layer 1: requirements (cache-friendly; rebuilds only when deps change) -COPY requirements.txt ./ +COPY --chown=lore:lore requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -# Layer 2: source tree -COPY lore_engine_poc ./lore_engine_poc -COPY scripts ./scripts - -# Layer 3: pre-built graph (165 KB today). For larger codexes, override -# at run time with: docker run -v $PWD/data:/data -e LORE_GRAPH_PATH=/data/.graph.pkl -COPY lore_engine_poc/.graph.pkl ./lore_engine_poc/.graph.pkl +# Layer 2: source tree. The pre-built graph (165 KB) lives at +# lore_engine_poc/.graph.pkl and rides along in this layer. For larger +# codexes, override at run time with: +# docker run -v $PWD/data:/data -e LORE_GRAPH_PATH=/data/.graph.pkl +COPY --chown=lore:lore lore_engine_poc ./lore_engine_poc +COPY --chown=lore:lore scripts ./scripts EXPOSE 8765 +# Drop root. Anything reachable from the MCP wire (pickle load, 36 +# tools including 12 mutators) now runs as UID 10001, not root. +USER lore + # Healthcheck: same JSON-RPC path an MCP client would use. HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD python -c "import json, urllib.request; \ diff --git a/tests/test_mcp/test_dockerfile.py b/tests/test_mcp/test_dockerfile.py index 6267946..5685336 100644 --- a/tests/test_mcp/test_dockerfile.py +++ b/tests/test_mcp/test_dockerfile.py @@ -30,8 +30,9 @@ HAS_COMPOSE = HAS_DOCKER and subprocess.run( ["docker", "compose", "version"], capture_output=True, text=True ).returncode == 0 -pytestmark_docker = pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") -pytestmark_compose = pytest.mark.skipif(not HAS_COMPOSE, reason="docker compose not available") +# Tests below carry per-test @pytest.mark.skipif(...) decorators. We +# keep HAS_DOCKER / HAS_COMPOSE module-level so the test names stay +# self-documenting at the call site (see "docker not on PATH" reason=). def _run(args, **kwargs): @@ -49,6 +50,54 @@ def _rm_container(name: str): ) +# --------------------------------------------------------------------------- +# Test 5 — container runs as non-root +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") +def test_docker_runs_as_non_root(): + container = "lore-engine-mcp-test-user" + _rm_container(container) + port = 18768 + try: + _run([ + "run", "-d", + "--name", container, + "-p", f"{port}:8765", + IMAGE_TAG, + ]) + # Container must be reachable first (non-root + read-only rootfs + # in compose can break unrelated stuff; this test focuses on USER). + deadline = time.time() + 20 + last_err = None + while time.time() < deadline: + try: + resp = httpx.post( + f"http://127.0.0.1:{port}/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + headers={"Accept": "application/json"}, + timeout=2.0, + ) + if resp.status_code == 200: + break + except (httpx.TransportError, httpx.TimeoutException) as exc: + last_err = exc + time.sleep(0.3) + else: + raise AssertionError(f"container never became ready: last_err={last_err!r}") + # Now assert the running user is NOT root. + proc = subprocess.run( + ["docker", "exec", container, "id", "-u"], + cwd=str(ROOT), capture_output=True, text=True, + ) + assert proc.returncode == 0, f"docker exec failed: {proc.stderr}" + uid = proc.stdout.strip() + assert uid != "0", f"container runs as root (uid={uid}); expected non-root" + finally: + _rm_container(container) + + # --------------------------------------------------------------------------- # Test 1 — docker build # --------------------------------------------------------------------------- @@ -56,18 +105,13 @@ def _rm_container(name: str): @pytest.mark.skipif(not HAS_DOCKER, reason="docker not on PATH") def test_dockerfile_builds(): - try: - proc = subprocess.run( - ["docker", "build", "-t", IMAGE_TAG, "."], - cwd=str(ROOT), capture_output=True, text=True, timeout=180, - ) - assert proc.returncode == 0, ( - f"docker build failed:\nstdout={proc.stdout}\nstderr={proc.stderr}" - ) - finally: - # Best-effort cleanup; the image is small (~150 MB) and is reused - # by later tests. Don't rmi on success. - pass + proc = subprocess.run( + ["docker", "build", "-t", IMAGE_TAG, "."], + cwd=str(ROOT), capture_output=True, text=True, timeout=180, + ) + assert proc.returncode == 0, ( + f"docker build failed:\nstdout={proc.stdout}\nstderr={proc.stderr}" + ) # ---------------------------------------------------------------------------