test: lock in non-root execution criteria (E00-S02-T05)
CI / Frozen lockfile install (pull_request) Successful in 51s
CI / Frozen lockfile install (pull_request) Successful in 51s
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.
This commit is contained in:
@@ -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