From a4cf365098b9dd8e4c3f82239de24d02c299359e Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:41:10 +0000 Subject: [PATCH 1/2] feat: run the app image as a non-root user (E00-S02-T05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/Dockerfile | 14 ++++++++++++-- compose.yaml | 11 ++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 4cc2d96..a42ccb8 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -9,8 +9,10 @@ # 3000, so the app container stays up and the health endpoint succeeds. The # Fastify 5 application shell (and the real HTTP API) lands in a later story; # DB volume persistence (T04) is a Compose-level concern (see compose.yaml — -# this image is unchanged), while non-root/read-only hardening (T05/T06) and -# multi-arch targets remain later E00-S02 tasks — all out of scope here. +# this image is unchanged), while read-only root filesystem (T06) and +# multi-arch build targets (T07) remain later E00-S02 tasks — all out of scope +# here. Since T05 the runtime stage drops root privileges (runs as the +# image's non-root `node` user). # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module @@ -50,5 +52,13 @@ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/apps/server/dist ./apps/server/dist COPY --from=build /app/apps/server/package.json ./apps/server/package.json +# T05: run as the image's non-root `node` user (uid/gid 1000, shipped with the +# official Node image) so the app container does not run with root privileges. +# 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 above lands before the privilege drop. +USER node + EXPOSE 3000 CMD ["node", "apps/server/dist/index.js"] diff --git a/compose.yaml b/compose.yaml index ebcdf89..4cb33c8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01/T02/T03/T04] +# EPPP Docker Compose baseline — [E00-S02-T01..T05] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -18,8 +18,13 @@ # `docker compose down` + `docker compose up -d` (recreate, which discards the # container filesystem). Reset the data with `docker compose down -v`. # -# Explicitly out of scope for T01..T04 (land in later E00-S02 tasks): -# - non-root execution (T05), read-only root filesystem (T06) +# Non-root execution (T05): the app image's runtime stage runs as the official +# Node image's non-root `node` user (see apps/server/Dockerfile — `USER node`), +# so the app container does not run with root privileges. No Compose-level +# `user:` override is needed: the image's USER is inherited by the container. +# +# Explicitly out of scope for T01..T05 (land in later E00-S02 tasks): +# - read-only root filesystem (T06), multi-arch build targets (T07) # # All values have defaults so `docker compose up -d` works from a clean clone # without a .env file (a committed .env.example template lands in E00-S04). -- 2.54.0 From 6e8ba388a6d12293ac3d31cb8f0a996d6ce3c337 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:41:16 +0000 Subject: [PATCH 2/2] test: lock in non-root execution criteria (E00-S02-T05) 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. --- tests/non-root-user.test.mjs | 237 +++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tests/non-root-user.test.mjs diff --git a/tests/non-root-user.test.mjs b/tests/non-root-user.test.mjs new file mode 100644 index 0000000..a4966ef --- /dev/null +++ b/tests/non-root-user.test.mjs @@ -0,0 +1,237 @@ +/** + * App non-root execution test — locks in the [E00-S02-T05] non-root runtime + * user for the workspace server application image. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "app runs as a non-root user" → the committed + * `apps/server/Dockerfile` runtime stage declares a `USER` instruction + * naming a non-root user (the official Node image's built-in `node` user, + * uid/gid 1000). A static assertion requires the runtime stage to drop + * root privileges, and the mutation probes below prove the assertion is + * non-vacuous (removing the USER, or switching it back to root, breaks + * the criterion). + * - "the container does not run with root privileges" → the runtime USER + * must not be root / uid 0 (static), and when a Docker daemon + Compose + * plugin are available (CI/dev machines), the real-stack probe starts the + * stack and inspects the running app container: `id -u` inside the + * container reports a non-zero uid and `id -un` does not report `root`, + * while the health endpoint still answers (the unprivileged app keeps + * serving). + * + * Run: `node --test tests/non-root-user.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed app image definition under test. */ +const DOCKERFILE_PATH = 'apps/server/Dockerfile'; + +// --------------------------------------------------------------------------- +// Dockerfile structure helpers +// --------------------------------------------------------------------------- + +/** + * Extracts the runtime stage of the committed Dockerfile (from its + * `FROM node:24.19.0-bookworm-slim AS runtime` line to the end of file — the + * runtime stage is last). The USER must live in the runtime stage: the build + * stage may run as root, only the container the app runs in must drop + * privileges. + */ +function runtimeStageOf(dockerfile) { + const from = dockerfile.indexOf('FROM node:24.19.0-bookworm-slim AS runtime'); + assert.notEqual( + from, + -1, + 'the Dockerfile must declare the runtime stage (FROM node:24.19.0-bookworm-slim AS runtime)', + ); + const tail = dockerfile.slice(from); + const nextFrom = tail.indexOf('\nFROM ', 1); + return nextFrom === -1 ? tail : tail.slice(0, nextFrom); +} + +/** + * Asserts the committed Dockerfile's runtime stage declares a `USER` + * instruction naming a non-root user — the app runs as a non-root user and + * the container does not run with root privileges. Fails fast on a missing or + * root USER; the mutation probes below prove the assertions are non-vacuous. + */ +function assertNonRootUser(dockerfile) { + const runtime = runtimeStageOf(dockerfile); + assert.match( + runtime, + /^USER\s+\S+/m, + 'the runtime stage must declare a USER instruction naming a non-root user ' + + '(e.g. "USER node") so the app runs as a non-root user', + ); + assert.doesNotMatch( + runtime, + /^USER\s+(root|0)(\s|$)/m, + 'the runtime USER must not be root or uid 0 — the container must not run with root privileges', + ); +} + +// --------------------------------------------------------------------------- +// Docker probe helpers (integration tests skip cleanly without Docker) +// --------------------------------------------------------------------------- + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { + encoding: 'utf8', + timeout: 600_000, + ...opts, + }); +} + +/** True when the `docker` CLI with the Compose plugin is on PATH. */ +function dockerComposeAvailable() { + try { + return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +/** True when a reachable Docker daemon exists. */ +function dockerDaemonAvailable() { + try { + return run('docker', ['info'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +const DOCKER_COMPOSE = dockerComposeAvailable(); +const DOCKER_DAEMON = dockerDaemonAvailable(); + +/** + * Polls `docker compose exec app id -u` until the app container answers (the + * app starts only after the db health gate, so `docker compose up -d` may + * return before the container is exec-able). Returns the reported uid. + */ +function waitForAppUid(deadlineMs = 60_000) { + const deadline = Date.now() + deadlineMs; + let last = ''; + while (Date.now() < deadline) { + const probe = run('docker', ['compose', 'exec', '-T', 'app', 'id', '-u'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + last = `${probe.status}: ${probe.stdout?.trim()} ${probe.stderr?.trim()}`; + if (probe.status === 0) return probe.stdout.trim(); + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry + } + throw new Error(`the app container did not answer "id -u" within ${deadlineMs}ms (last: "${last.trim()}")`); +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the app image runs as a non-root user (runtime stage declares a non-root USER)', () => { + assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); + assertNonRootUser(read(DOCKERFILE_PATH)); +}); + +test('the runtime USER is the image\'s built-in non-root node user (uid/gid 1000)', () => { + const runtime = runtimeStageOf(read(DOCKERFILE_PATH)); + assert.match( + runtime, + /^USER\s+node\s*$/m, + 'the runtime stage must run as the official Node image\'s non-root "node" user (USER node)', + ); +}); + +test('the running app container does not run with root privileges (real-stack probe)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { + const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT }); + assert.equal( + up.status, + 0, + `"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), + ); + try { + const uid = waitForAppUid(); + assert.notEqual( + uid, + '0', + `the app container must run as a non-root user ("id -u" inside the container must not be 0; got "${uid}")`, + ); + + const name = run('docker', ['compose', 'exec', '-T', 'app', 'id', '-un'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + assert.equal( + name.status, + 0, + `"id -un" inside the app container must succeed:\n${(name.stdout || '')}\n${(name.stderr || '')}`.trim(), + ); + assert.notEqual( + name.stdout.trim(), + 'root', + `the app container must not run as root ("id -un" must not report "root"; got "${name.stdout.trim()}")`, + ); + + // Regression guard: the unprivileged app still serves the health endpoint + // (the T05 privilege drop must not break startup). Poll with timeout — no + // flaky sleeps. + let healthOutput = ''; + let healthOk = false; + for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) { + const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', ` + fetch('http://127.0.0.1:3000/health') + .then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); }) + .catch(() => process.exit(2)); + `], { cwd: REPO_ROOT, timeout: 15_000 }); + const output = String(probe.stdout ?? '') + String(probe.stderr ?? ''); + if (probe.status === 0) { + healthOk = true; + healthOutput = output; + } else if (probe.status === 1) { + healthOutput = output; // answered but not 2xx — fail fast + break; + } else { + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry + } + } + assert.ok( + healthOk, + `GET /health must answer 2xx inside the app container while running unprivileged (last probe: "${healthOutput.trim()}")`, + ); + assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); + } finally { + run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('removing the USER instruction makes the non-root criterion fail (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const withoutUser = dockerfile.replace(/^USER node\s*$/m, ''); + assert.notEqual(withoutUser, dockerfile, 'the mutation must actually remove the USER instruction'); + assert.throws(() => assertNonRootUser(withoutUser), /USER instruction/); +}); + +test('switching the runtime USER back to root makes the non-root criterion fail (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const asRoot = dockerfile.replace(/^USER node\s*$/m, 'USER root'); + assert.notEqual(asRoot, dockerfile, 'the mutation must actually switch the USER back to root'); + assert.throws(() => assertNonRootUser(asRoot), /root/); +}); + +test('a runtime stage without any USER fails the non-root criterion (mutation probe)', () => { + const runtime = runtimeStageOf(read(DOCKERFILE_PATH)).replace(/^USER node\s*$/m, ''); + assert.throws(() => assertNonRootUser(runtime), /USER instruction/); +}); -- 2.54.0