diff --git a/.planning/phases/06-performance-production-hardening/06-04-SUMMARY.md b/.planning/phases/06-performance-production-hardening/06-04-SUMMARY.md new file mode 100644 index 0000000..ca3f270 --- /dev/null +++ b/.planning/phases/06-performance-production-hardening/06-04-SUMMARY.md @@ -0,0 +1,91 @@ +--- +plan: 06-04 +phase: 06-performance-production-hardening +status: partial +completed_at: 2026-06-04 +commits: + - 3788226 +checkpoint_pending: task-3-human-verify +--- + +# Plan 06-04: Container Hardening + +## What was built (Tasks 1 & 2) + +### backend/Dockerfile — multi-stage with appuser + +Replaced the single-stage root-running Dockerfile with a two-stage build: + +**Stage 1 `AS builder`:** +- `FROM python:3.12-slim AS builder` +- Installs `gcc` (build-only dep, discarded after this stage) with `--no-install-recommends` +- Runs `pip install --no-cache-dir --prefix=/install -r requirements.txt` + +**Stage 2 `AS runtime`:** +- `FROM python:3.12-slim AS runtime` +- Installs runtime apt deps (tesseract-ocr, libgl1, libglib2.0-0) with `--no-install-recommends` +- `COPY --from=builder /install /usr/local` (copies compiled packages, discards builder) +- Creates `appgroup` gid=1000 and `appuser` uid=1000 (no login shell, no home dir) +- `COPY --chown=appuser:appgroup . .` +- `USER appuser` +- `CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]` (no `--reload`) + +### docker-compose.yml — runtime hardening + +Added to **backend** and **celery-worker** services (not celery-beat): + +```yaml +read_only: true +tmpfs: + - "/tmp:mode=1777" +cap_drop: + - ALL +security_opt: + - "no-new-privileges:true" +``` + +Added comment before celery-beat: +```yaml +# celery-beat: NOT hardened — writes celerybeat-schedule to working directory (Phase 6 Pitfall 7). +``` + +Existing bind mounts (`./backend:/app`), env vars, healthchecks, and Loki logging labels preserved. + +## Static verification (Tasks 1 & 2 acceptance criteria) + +| Check | Result | +|-------|--------| +| `FROM python:3.12-slim AS builder` | 1 ✓ | +| `FROM python:3.12-slim AS runtime` | 1 ✓ | +| `USER appuser` | 1 ✓ | +| `useradd --uid 1000` | 1 ✓ | +| `COPY --from=builder` | 1 ✓ | +| `--no-install-recommends` occurrences | 2 ✓ | +| `tesseract-ocr` in runtime only | 1 ✓ | +| `--reload` in Dockerfile | 0 ✓ | +| `docker compose config --quiet` | exit 0 ✓ | +| `read_only: true` in backend block | 1 ✓ | +| `/tmp:mode=1777` in backend block | 1 ✓ | +| `no-new-privileges:true` in backend block | 1 ✓ | +| `read_only: true` in celery-worker block | 1 ✓ | +| hardening keys in celery-beat block | 0 ✓ | +| `./backend:/app` bind mount preserved | 1 ✓ | + +## Task 3: Human smoke test (PENDING) + +Docker daemon was not running at execution time. Task 3 requires: +1. `docker compose up -d --build` (rebuilds with new Dockerfile) +2. `docker compose exec -T backend id` → must show uid=1000(appuser) +3. `docker compose exec -T backend sh -c 'touch /readonly_probe 2>&1 || echo "rootfs is read-only"'` +4. `docker compose exec -T backend sh -c 'touch /tmp/ok && ls -la /tmp/ok'` +5. Same checks on celery-worker +6. Confirm celery-beat is writable (intentionally) +7. End-to-end upload test (proves tempfile.NamedTemporaryFile works under hardened runtime) + +Human responds "approved" to advance to Wave 3 (06-06). + +## Satisfaction of requirements +- D-07 (multi-stage non-root image): ✓ Tasks 1 & 2 complete +- D-08 (read-only rootfs + tmpfs): ✓ Tasks 1 & 2 complete +- D-09 (cap_drop ALL + no-new-privileges): ✓ Tasks 1 & 2 complete +- Runtime verification (Task 3): PENDING human checkpoint