diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 37df242b..dfdee639 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -615,6 +615,19 @@ jobs: - name: Charts request images this repository can produce run: node .harness/scripts/ci/58-validate-deployable-images.mjs + # A chart's securityContext OVERRIDES the image's USER, so a chart pinning + # a uid the image does not own files as puts the process on a user that + # can read none of its corpus. The MCP chart shipped 1000 against an image + # built at 1001: `policy.wasm` is mode 600, OPA got EACCES, dispatch + # fail-closed, and every `tools/call` returned FORBIDDEN — 51 tools + # advertised, none executable. Nothing in this repository could have caught + # it: the harness runs the server IN-PROCESS, with no container and no + # securityContext, so only a live deploy showed it. + - name: Charts run as the uid their image owns files as + run: | + node .harness/scripts/ci/61-validate-chart-image-uid.mjs --verbose + node --test .harness/scripts/ci/61-validate-chart-image-uid.test.mjs + # GT-650 / ADR-0125 — the artifact registry is the accepted single declaration, and the gate # corpus is still hand-maintained while the migration lands. For as long as both exist, the # only thing making that intermediate state safe is that they are checked to agree. This diff --git a/.harness/scripts/ci/61-validate-chart-image-uid.mjs b/.harness/scripts/ci/61-validate-chart-image-uid.mjs new file mode 100644 index 00000000..c4d316ed --- /dev/null +++ b/.harness/scripts/ci/61-validate-chart-image-uid.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +/** + * The chart must run the pod as the user its image owns files as. + * + * ## The defect this closes + * + * `product/infra/helm/evolith-mcp/values.yaml` pinned `runAsUser: 1000` while + * `src/packages/mcp-server/Dockerfile` creates `evolith` at uid **1001**, + * `chown -R evolith:evolith /repo /app`, and declares `USER evolith`. A + * `securityContext` overrides the image's USER, so the process landed on the + * base image's `node` user — which owns none of the corpus. + * + * The symptom was three layers from the cause and total: `policy.wasm` ships + * mode 600 owned by 1001, so at uid 1000 the OPA engine got + * `EACCES: permission denied`, and dispatch requires BOTH the native and OPA + * engines to allow — so OPA erroring fail-closed EVERY `tools/call` with + * FORBIDDEN. A deployed MCP server advertising 51 tools and able to execute + * none. Found on 2026-08-04 by running the `core-integration` robot against a + * live cluster; nothing in the repository could have caught it, because the test + * harness runs the server IN-PROCESS, with no container and no securityContext. + * + * The value is correct today because it was corrected by hand. That is the part + * this guard replaces: a hand-corrected value with nothing watching it is one + * edit away from the same outage, and the next person to see it will see it in a + * cluster, not in CI. + * + * ## What it checks + * + * For every chart paired with a Dockerfile below: the uid the Dockerfile creates + * (`adduser -S -u `) must equal EVERY uid/gid the chart pins — + * `podSecurityContext.runAsUser`, `runAsGroup`, `fsGroup`, and + * `containerSecurityContext.runAsUser`. All four, because getting three right + * and one wrong reproduces the same failure through a different door: `fsGroup` + * alone decides who owns mounted volumes. + * + * A chart that pins NO uid is reported, not passed. It inherits the image's USER, + * which happens to be right — but silently, and the next edit that adds a + * securityContext has no anchor to be checked against. + * + * ## Anti-vacuous pass + * + * Zero pairs checked is a hard failure through `assertScanned`: a renamed chart + * directory must not read as "everything agrees". So is a Dockerfile whose + * `adduser` line cannot be parsed — an unreadable uid is not a matching uid. + * + * USAGE + * node .harness/scripts/ci/61-validate-chart-image-uid.mjs + * node .harness/scripts/ci/61-validate-chart-image-uid.mjs --verbose + * + * EXIT CODES + * 0 every chart runs as the uid its image owns files as + * 1 a divergence, an unparseable uid, or a vacuous scan + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +import { findRepoRoot } from '../lib/paths.mjs'; +import { assertScanned } from '../lib/coverage.mjs'; + +const GUARD = '61-validate-chart-image-uid'; + +/** + * The pairs. Hand-written on purpose: deriving "which Dockerfile belongs to + * which chart" from a naming convention would silently skip a chart the day + * someone renames one, and a skipped chart is exactly the shape of the defect. + */ +export const PAIRS = [ + { chart: 'product/infra/helm/evolith-core-api', dockerfile: 'src/apps/core-api/Dockerfile' }, + { chart: 'product/infra/helm/evolith-mcp', dockerfile: 'src/packages/mcp-server/Dockerfile' }, + { chart: 'product/infra/helm/evolith-agent-runtime', dockerfile: 'src/apps/agent-runtime-api/Dockerfile' }, +]; + +/** The uid a Dockerfile creates, or null when it declares no user at all. */ +export function imageUid(dockerfileText) { + // `-u` anywhere on the adduser command, because the USERNAME is POSITIONAL: + // the real line is `adduser -S evolith -u 1001 -G evolith`. The first version + // required every token before `-u` to be a flag, so it matched nothing and + // reported all three images as unreadable — a guard failing for its own reason + // rather than the repository's. + const m = dockerfileText.match(/adduser\b[^\n]*?-u\s+(\d+)/); + if (m) return Number(m[1]); + // A Dockerfile with a USER but no adduser is running as a user the base image + // provides; there is no uid here to compare against and saying so beats + // guessing one. + return /^\s*USER\s+\S+/m.test(dockerfileText) ? undefined : null; +} + +/** Every uid/gid a chart pins, as {field: value}. */ +export function chartUids(valuesText) { + const v = yaml.load(valuesText) || {}; + const out = {}; + const pod = v.podSecurityContext || {}; + const container = v.containerSecurityContext || {}; + if (pod.runAsUser !== undefined) out['podSecurityContext.runAsUser'] = pod.runAsUser; + if (pod.runAsGroup !== undefined) out['podSecurityContext.runAsGroup'] = pod.runAsGroup; + // fsGroup decides who owns mounted volumes. Leaving it behind while fixing the + // other three reproduces the same failure through a different door. + if (pod.fsGroup !== undefined) out['podSecurityContext.fsGroup'] = pod.fsGroup; + if (container.runAsUser !== undefined) out['containerSecurityContext.runAsUser'] = container.runAsUser; + return out; +} + +/** Compare one pair. Returns {problems[], pinned, uid}. */ +export function checkPair(dockerfileText, valuesText) { + const problems = []; + const uid = imageUid(dockerfileText); + + if (uid === null) { + problems.push( + 'the Dockerfile declares no USER and creates no user — it runs as root, which the chart cannot be checked against', + ); + return { problems, pinned: {}, uid }; + } + if (uid === undefined) { + problems.push( + 'the Dockerfile declares a USER but no `adduser -u `, so the uid it runs as cannot be read here. An unreadable uid is not a matching uid — pin it in the Dockerfile or exempt this pair with a reason', + ); + return { problems, pinned: {}, uid }; + } + + const pinned = chartUids(valuesText); + const fields = Object.keys(pinned); + if (fields.length === 0) { + problems.push( + `the chart pins no uid, so the pod inherits the image's USER (${uid}). That is right today and unanchored tomorrow: add podSecurityContext.runAsUser/runAsGroup/fsGroup + containerSecurityContext.runAsUser = ${uid}`, + ); + return { problems, pinned, uid }; + } + + for (const [field, value] of fields.map((f) => [f, pinned[f]])) { + if (value !== uid) { + problems.push( + `${field} = ${value} but the image creates uid ${uid}. A securityContext OVERRIDES the image's USER, so the process lands on a user that owns none of the corpus — files ship mode 600 owned by ${uid}, and the failure surfaces at runtime as EACCES, far from here`, + ); + } + } + return { problems, pinned, uid }; +} + +function main(argv = process.argv.slice(2)) { + const verbose = argv.includes('--verbose'); + const root = findRepoRoot(); + const rows = []; + const violations = []; + + for (const pair of PAIRS) { + const dfPath = path.join(root, pair.dockerfile); + const valuesPath = path.join(root, pair.chart, 'values.yaml'); + if (!fs.existsSync(dfPath) || !fs.existsSync(valuesPath)) { + violations.push( + `${pair.chart}: missing ${!fs.existsSync(dfPath) ? pair.dockerfile : pair.chart + '/values.yaml'}. A moved file must not read as agreement`, + ); + continue; + } + const { problems, pinned, uid } = checkPair( + fs.readFileSync(dfPath, 'utf8'), + fs.readFileSync(valuesPath, 'utf8'), + ); + rows.push({ chart: pair.chart, uid, pinned, ok: problems.length === 0 }); + for (const p of problems) violations.push(`${pair.chart}: ${p}`); + } + + assertScanned(rows.length, { + what: 'chart/image pairs', + where: PAIRS.map((p) => p.chart), + }); + + console.log(`${GUARD} — the chart must run as the uid its image owns files as`); + console.log(` pairs checked ...... ${rows.length}`); + if (verbose) { + for (const r of rows) { + const fields = Object.entries(r.pinned) + .map(([k, v]) => `${k.split('.').pop()}=${v}`) + .join(' '); + console.log(` • ${r.ok ? 'OK ' : 'FAIL'} ${path.basename(r.chart).padEnd(24)} image uid ${r.uid} · ${fields || '(none pinned)'}`); + } + } + + if (violations.length > 0) { + console.error(`\n✗ ${GUARD}: ${violations.length} divergence(s):\n`); + for (const v of violations) console.error(` • ${v}`); + console.error('\n Context: reference/core/control-center/gaps/gap-reference-catalog.md (the MCP chart shipped 1000 against an image built at 1001)'); + process.exit(1); + } + + console.log(`\n✓ ${GUARD}: all ${rows.length} chart(s) run as the uid their image owns files as.`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/.harness/scripts/ci/61-validate-chart-image-uid.test.mjs b/.harness/scripts/ci/61-validate-chart-image-uid.test.mjs new file mode 100644 index 00000000..8c73e0c0 --- /dev/null +++ b/.harness/scripts/ci/61-validate-chart-image-uid.test.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node --test + +/** + * Negative fixtures for `61-validate-chart-image-uid.mjs`. + * + * The guard exists because a hand-corrected value with nothing watching it is + * one edit away from the same outage. A guard nobody has watched fail is the + * same shape of promise, so every rejection below was run against the predicate + * and seen to turn it red. + * + * The green case is not decoration: a predicate that rejected everything would + * pass all the red cases and look thorough. Its first version did exactly the + * opposite — the `adduser` regex required every token before `-u` to be a flag, + * so it read all three real Dockerfiles as unparseable and failed for its own + * reason rather than the repository's. + * + * Run: node --test .harness/scripts/ci/61-validate-chart-image-uid.test.mjs + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { findRepoRoot } from '../lib/paths.mjs'; +import { imageUid, chartUids, checkPair, PAIRS } from './61-validate-chart-image-uid.mjs'; + +// The real shape: the username is POSITIONAL, between the flags. +const DOCKERFILE = `FROM node:20-alpine +RUN addgroup -g 1001 -S evolith && \\ + adduser -S evolith -u 1001 -G evolith && \\ + chown -R evolith:evolith /repo /app +USER evolith +CMD ["node", "dist/main"] +`; + +const VALUES_OK = `podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 +containerSecurityContext: + runAsUser: 1001 +`; + +test('the positional username does not hide the uid — the bug the first regex had', () => { + assert.equal(imageUid(DOCKERFILE), 1001); +}); + +test('the matching pair is accepted, or every rejection below proves nothing', () => { + assert.deepEqual(checkPair(DOCKERFILE, VALUES_OK).problems, []); +}); + +test('runAsUser 1000 against an image built at 1001 is rejected — the exact shape that shipped', () => { + const bad = VALUES_OK.replace(' runAsUser: 1001\n runAsGroup', ' runAsUser: 1000\n runAsGroup'); + const { problems } = checkPair(DOCKERFILE, bad); + assert.equal(problems.length, 1); + assert.match(problems[0], /podSecurityContext\.runAsUser = 1000 but the image creates uid 1001/); +}); + +test('fsGroup alone is rejected — the side door a partial fix leaves open', () => { + // Getting three of four right reproduces the failure through volume ownership. + const bad = VALUES_OK.replace('fsGroup: 1001', 'fsGroup: 1000'); + const { problems } = checkPair(DOCKERFILE, bad); + assert.equal(problems.length, 1); + assert.match(problems[0], /fsGroup = 1000/); +}); + +test('the container-level override is checked too, not just the pod level', () => { + const bad = VALUES_OK.replace( + 'containerSecurityContext:\n runAsUser: 1001', + 'containerSecurityContext:\n runAsUser: 1000', + ); + const { problems } = checkPair(DOCKERFILE, bad); + assert.equal(problems.length, 1); + assert.match(problems[0], /containerSecurityContext\.runAsUser = 1000/); +}); + +test('a chart pinning nothing is REPORTED, not passed', () => { + // It inherits the image's USER and is right today — and unanchored tomorrow. + const { problems } = checkPair(DOCKERFILE, 'replicaCount: 1\n'); + assert.equal(problems.length, 1); + assert.match(problems[0], /pins no uid/); +}); + +test('a Dockerfile whose uid cannot be read is an error, never a pass', () => { + const { problems } = checkPair('FROM node:20-alpine\nUSER node\n', VALUES_OK); + assert.equal(problems.length, 1); + assert.match(problems[0], /cannot be read here/); +}); + +test('a root image is an error too — there is nothing to compare against', () => { + const { problems } = checkPair('FROM node:20-alpine\nCMD ["node"]\n', VALUES_OK); + assert.equal(problems.length, 1); + assert.match(problems[0], /runs as root/); +}); + +test('chartUids reads all four fields', () => { + assert.deepEqual(chartUids(VALUES_OK), { + 'podSecurityContext.runAsUser': 1001, + 'podSecurityContext.runAsGroup': 1001, + 'podSecurityContext.fsGroup': 1001, + 'containerSecurityContext.runAsUser': 1001, + }); +}); + +test('every real pair in the repository agrees', () => { + const root = findRepoRoot(); + for (const pair of PAIRS) { + const { problems } = checkPair( + readFileSync(join(root, pair.dockerfile), 'utf8'), + readFileSync(join(root, pair.chart, 'values.yaml'), 'utf8'), + ); + assert.deepEqual(problems, [], `${pair.chart}: ${problems.join(' · ')}`); + } +});