Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ COPY --chown=app:app src/ src/
USER app

# Put the venv on PATH so `uvicorn` resolves without `uv run` indirection.
ENV PATH="/app/.venv/bin:${PATH}"
# PYTHONDONTWRITEBYTECODE=1 stops Python from attempting `.pyc` writes under
# `__pycache__/` on cold start — they would EROFS-fail under the read-only
# root FS configured in docker-compose.yml. PYTHONUNBUFFERED=1 keeps
# uvicorn's stdout from being held behind line-buffering when running under
# non-TTY container stdio.
ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

EXPOSE 8000

Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ services:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
- OTEL_SERVICE_NAME=harness-python-react
# Container hardening. The root FS is read-only at the kernel level so a
# post-exploit shell can't modify /app, persist binaries, or fill disk
# under the `app` user's ownership. /tmp is the only writable path —
# tmpfs-mounted with a 64 MB ceiling so it can't be abused as unbounded
# storage. Verified: `touch /app/foo` → EROFS; `touch /tmp/foo` succeeds;
# healthcheck reports healthy. See docs/SECURITY.md "Container Security".
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777

frontend:
build: ./frontend
Expand Down
8 changes: 8 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,16 @@ Tag v*.*.* ──► release.yml: build image, push to ghcr.io, generate
- **Builder** — runs `uv sync --frozen --no-dev`. Has uv, pip cache, build tools.
- **Runtime** — `python:3.14-slim`, copies only `.venv` + `src/` from the builder, runs as non-root user `app`. No uv, no pip cache, no build tools, no dev deps.

Runtime stage env: `PYTHONDONTWRITEBYTECODE=1` (no `.pyc` writes — would EROFS-fail under the read-only root FS) and `PYTHONUNBUFFERED=1` (uvicorn stdout flushed immediately).

`docker-compose.yml`'s `app` service runs with `read_only: true` and a `tmpfs: /tmp:size=64m,mode=1777` mount. The kernel rejects writes to every path except the 64 MB tmpfs, so a post-exploit shell under the `app` user cannot modify `/app`, persist binaries, or fill the host's disk under `app`'s ownership. Verified locally: `touch /app/foo` → `Read-only file system`; `touch /tmp/foo` succeeds; healthcheck reports `healthy`.

Healthcheck uses stdlib `urllib.request` so curl isn't in the image.

### Distroless evaluation — deferred

`gcr.io/distroless/python3-debian12` ships Python at `/usr/bin/python3` while the current builder stage materialises a venv whose `pyvenv.cfg` and interpreter symlinks reference `/usr/local/bin/python3.14` (Dockerfile comment makes this constraint explicit). Migrating requires either matching Python paths between stages (no distroless variant matches slim's `/usr/local`) or rebuilding the venv inside the runtime stage (distroless has no `pip` / `uv`). Either route adds engineering risk and operational friction (no `docker exec ... sh`) that outweighs the marginal attack-surface reduction now that read-only-FS + non-root + no-build-tools + trivy-scanning are all in place. Revisit when distroless ships a `/usr/local` variant or when the venv-in-runtime cost shrinks.

## What's intentionally out of scope (scaffold)

- **WAF / DDoS** — deployment-environment concerns, not template concerns.
Expand Down
Loading