[E00-S02-T05] App runs non-root #386

Merged
kpcto merged 2 commits from feature/172 into main 2026-08-29 00:48:29 +00:00
Member

What changed

Implements the [E00-S02-T05] App runs non-root (#172) on top of the [E00-S02-T01..T04] Compose baseline (#59): the application container now runs as a non-root user and does not run with root privileges.

  • apps/server/Dockerfile — the runtime stage now drops root privileges with USER node (the non-root user, uid/gid 1000, that the official node:24.19.0-bookworm-slim image ships with). The server binds port 3000 (≥ 1024, no privileged port needed) and only reads the root-owned application files copied above, so no extra user creation or ownership changes are required. USER is the last instruction before EXPOSE so every COPY lands before the privilege drop. Header comment updated: T05 is in scope; T06 (read-only rootfs) and T07 (multi-arch) remain later tasks.
  • compose.yaml — header doc-accuracy only: T05 is in scope (no Compose-level user: override is needed — the image's USER is inherited by the container); T06/T07 remain out of scope.
  • tests/non-root-user.test.mjs (new) — locks in both acceptance criteria: a static assertion that the Dockerfile runtime stage declares a non-root USER (not root/uid 0, USER node exactly), non-vacuous mutation probes (removing the USER / switching it back to root / a runtime stage with no USER all fail), and a Docker-gated real-stack probe that starts the stack and asserts id -u and id -un inside the running app container report a non-root user, with the health endpoint still answering 200 as a regression guard (the privilege drop must not break startup). The probe cleans up with docker compose down so runs stay isolated.

Explicitly out of scope per the brief, not touched: DB volume persistence (E00-S02-T04 — unchanged behavior), read-only root filesystem (E00-S02-T06), multi-arch build targets (E00-S02-T07).

Criterion → test table

Acceptance criterion Test (fails without the committed state)
app runs as a non-root user tests/non-root-user.test.mjs — "the app image runs as a non-root user (runtime stage declares a non-root USER)" (static: the Dockerfile runtime stage must declare a USER instruction naming a non-root user) and "the runtime USER is the image's built-in non-root node user (uid/gid 1000)" (locks USER node). Mutation probes removing the USER or leaving a runtime stage with no USER both fail
the container does not run with root privileges tests/non-root-user.test.mjs — "the running app container does not run with root privileges (real-stack probe)" (Docker-gated): after docker compose up -d, id -u inside the running app container must not be 0 and id -un must not report root; the health endpoint must still answer HTTP 200 unprivileged (regression guard). Static: the runtime USER must not be root/uid 0 (the "switching the runtime USER back to root" mutation probe fails)

Test plan executed

  • node --test tests/non-root-user.test.mjs → 5/5 pass, 1 skipped (the Docker-gated real-stack probe skips cleanly where no Docker daemon exists; this sandbox has no Docker) ✓
  • node --test tests/*.test.mjs (full suite) → 68 pass / 12 fail / 4 skip, and the identical 12 failures exist on clean main in this sandbox (frozen-install / root-commands / strict-tsconfig / node-engine suites need pnpm install + Node 24, which this environment lacks — no node_modules, Node 22.23.2 here). My branch adds +5 passing tests and +1 Docker-gated skip, zero new failures (baseline: 63 pass / 12 fail / 3 skip) ✓
  • The real-stack probe is exactly the acceptance sequence (docker compose up -d → inspect id inside the container → health check → docker compose down) and is designed to run on CI/dev machines with Docker.

Risks / notes

  • Uses the official Node image's built-in node user (uid/gid 1000) rather than creating a dedicated user — minimal change, no extra packages, canonical per the Node image docs; the committed tests lock USER node explicitly, so a deliberate change of user would update the tests with it.
  • The app only reads its files (server binds port 3000 ≥ 1024, no privileged port, no writes to the root-owned app dirs), so the non-root user needs no ownership changes. T06 (read-only rootfs) may add tmpfs/mount handling later — out of scope here.
  • Docker-gated tests skip without a daemon, so the suite stays green everywhere while giving real container-level validation where Docker exists (same pattern as T01..T04).

Refs #172

## What changed Implements the [E00-S02-T05] App runs non-root (#172) on top of the [E00-S02-T01..T04] Compose baseline (#59): the application container now **runs as a non-root user and does not run with root privileges**. - **`apps/server/Dockerfile`** — the runtime stage now drops root privileges with **`USER node`** (the non-root user, uid/gid 1000, that the official `node:24.19.0-bookworm-slim` image ships with). The server binds port 3000 (≥ 1024, no privileged port needed) and only reads the root-owned application files copied above, so no extra user creation or ownership changes are required. `USER` is the last instruction before `EXPOSE` so every `COPY` lands before the privilege drop. Header comment updated: T05 is in scope; T06 (read-only rootfs) and T07 (multi-arch) remain later tasks. - **`compose.yaml`** — header doc-accuracy only: T05 is in scope (no Compose-level `user:` override is needed — the image's `USER` is inherited by the container); T06/T07 remain out of scope. - **`tests/non-root-user.test.mjs`** (new) — locks in both acceptance criteria: a static assertion that the Dockerfile runtime stage declares a non-root `USER` (not root/uid 0, `USER node` exactly), non-vacuous mutation probes (removing the USER / switching it back to root / a runtime stage with no USER all fail), and a Docker-gated **real-stack probe** that starts the stack and asserts `id -u` and `id -un` inside the running app container report a non-root user, with the health endpoint still answering 200 as a regression guard (the privilege drop must not break startup). The probe cleans up with `docker compose down` so runs stay isolated. Explicitly out of scope per the brief, **not touched**: DB volume persistence (E00-S02-T04 — unchanged behavior), read-only root filesystem (E00-S02-T06), multi-arch build targets (E00-S02-T07). ## Criterion → test table | Acceptance criterion | Test (fails without the committed state) | | --- | --- | | app runs as a non-root user | `tests/non-root-user.test.mjs` — **"the app image runs as a non-root user (runtime stage declares a non-root USER)"** (static: the Dockerfile runtime stage must declare a `USER` instruction naming a non-root user) and **"the runtime USER is the image's built-in non-root node user (uid/gid 1000)"** (locks `USER node`). Mutation probes removing the USER or leaving a runtime stage with no USER both fail | | the container does not run with root privileges | `tests/non-root-user.test.mjs` — **"the running app container does not run with root privileges (real-stack probe)"** (Docker-gated): after `docker compose up -d`, `id -u` inside the running app container must not be `0` and `id -un` must not report `root`; the health endpoint must still answer HTTP 200 unprivileged (regression guard). Static: the runtime USER must not be root/uid 0 (the "switching the runtime USER back to root" mutation probe fails) | ## Test plan executed - `node --test tests/non-root-user.test.mjs` → **5/5 pass, 1 skipped** (the Docker-gated real-stack probe skips cleanly where no Docker daemon exists; this sandbox has no Docker) ✓ - `node --test tests/*.test.mjs` (full suite) → **68 pass / 12 fail / 4 skip**, and the **identical 12 failures exist on clean `main`** in this sandbox (frozen-install / root-commands / strict-tsconfig / node-engine suites need `pnpm install` + Node 24, which this environment lacks — no `node_modules`, Node 22.23.2 here). My branch adds **+5 passing tests and +1 Docker-gated skip, zero new failures** (baseline: 63 pass / 12 fail / 3 skip) ✓ - The real-stack probe is exactly the acceptance sequence (`docker compose up -d` → inspect `id` inside the container → health check → `docker compose down`) and is designed to run on CI/dev machines with Docker. ## Risks / notes - Uses the official Node image's built-in `node` user (uid/gid 1000) rather than creating a dedicated user — minimal change, no extra packages, canonical per the Node image docs; the committed tests lock `USER node` explicitly, so a deliberate change of user would update the tests with it. - The app only reads its files (server binds port 3000 ≥ 1024, no privileged port, no writes to the root-owned app dirs), so the non-root user needs no ownership changes. T06 (read-only rootfs) may add tmpfs/mount handling later — out of scope here. - Docker-gated tests `skip` without a daemon, so the suite stays green everywhere while giving real container-level validation where Docker exists (same pattern as T01..T04). Refs #172
bot-implementer added 2 commits 2026-08-29 00:41:30 +00:00
The runtime stage of apps/server/Dockerfile now drops root privileges with
'USER node' — the non-root user (uid/gid 1000) the official Node image ships
with — so the app container does not run with root privileges. The server
binds port 3000 (>= 1024) and only reads the root-owned files copied above,
so no extra user creation or ownership changes are required. compose.yaml
header updated: T05 is in scope; T06 (read-only rootfs) and T07 (multi-arch)
remain out of scope.
test: lock in non-root execution criteria (E00-S02-T05)
CI / Frozen lockfile install (pull_request) Successful in 51s
6e8ba388a6
tests/non-root-user.test.mjs locks in both acceptance criteria: a static
assertion that the Dockerfile runtime stage declares a non-root USER (not
root/uid 0, 'USER node' exactly), non-vacuous mutation probes, and a
Docker-gated real-stack probe that starts the stack and asserts 'id -u' and
'id -un' inside the running app container report a non-root user, with the
health endpoint still answering as a regression guard.
kpcto merged commit 7cf7994fa8 into main 2026-08-29 00:48:29 +00:00
kpcto deleted branch feature/172 2026-08-29 00:48:29 +00:00
Sign in to join this conversation.