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:
24
Dockerfile
24
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; \
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user