Merge pull request '[E00-S02-T05] App runs non-root' (#386) from feature/172 into main
CI / Frozen lockfile install (push) Successful in 47s
CI / Frozen lockfile install (push) Successful in 47s
This commit was merged in pull request #386.
This commit is contained in:
+12
-2
@@ -9,8 +9,10 @@
|
|||||||
# 3000, so the app container stays up and the health endpoint succeeds. The
|
# 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;
|
# 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 —
|
# 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
|
# this image is unchanged), while read-only root filesystem (T06) and
|
||||||
# multi-arch targets remain later E00-S02 tasks — all out of scope here.
|
# 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
|
# 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
|
# §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/dist ./apps/server/dist
|
||||||
COPY --from=build /app/apps/server/package.json ./apps/server/package.json
|
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
|
EXPOSE 3000
|
||||||
CMD ["node", "apps/server/dist/index.js"]
|
CMD ["node", "apps/server/dist/index.js"]
|
||||||
|
|||||||
+8
-3
@@ -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
|
# `docker compose up -d` starts both the database (PostgreSQL) and the
|
||||||
# application (@personal-blog/server). Rollback: `docker compose down`.
|
# application (@personal-blog/server). Rollback: `docker compose down`.
|
||||||
@@ -18,8 +18,13 @@
|
|||||||
# `docker compose down` + `docker compose up -d` (recreate, which discards the
|
# `docker compose down` + `docker compose up -d` (recreate, which discards the
|
||||||
# container filesystem). Reset the data with `docker compose down -v`.
|
# 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): the app image's runtime stage runs as the official
|
||||||
# - non-root execution (T05), read-only root filesystem (T06)
|
# 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
|
# 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).
|
# without a .env file (a committed .env.example template lands in E00-S04).
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user