docker: drop root in the container (review-2)

- 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 <noreply@anthropic.com>
This commit is contained in:
Lore Engine Dev
2026-06-18 19:44:21 -04:00
parent d1285eea60
commit 29c691a038
2 changed files with 74 additions and 22 deletions

View File

@@ -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; \

View File

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