From 24b64d9c6ab8b04087502301ae8aef9d1a3bbc73 Mon Sep 17 00:00:00 2001 From: Alberto Arroyo Raygada Date: Tue, 4 Aug 2026 12:18:21 -0500 Subject: [PATCH] feat(ci): a chart must run as the uid its image owns files as (#427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP chart pinned `runAsUser: 1000` against an image that creates `evolith` at 1001 and chowns the corpus to it. A securityContext overrides the image's USER, so the process landed on the base image's `node` user: `policy.wasm` is mode 600 owned by 1001, OPA got EACCES, dispatch requires both engines to allow, and every `tools/call` returned FORBIDDEN — 51 tools advertised, none executable. That value is correct today because it was corrected by hand two days ago, and nothing was watching it. This is the part that replaces the hand: a hand-corrected value with no guard is one edit from the same outage, and the next person to meet it meets it in a cluster. Nothing here could have caught it before. The test harness runs the MCP server IN-PROCESS — no container, no securityContext — so the defect was unreachable from every suite in the repository and only a live deploy showed it. All four pinned fields are compared, not just `runAsUser`: `runAsGroup` and `fsGroup` too, because getting three right and one wrong reproduces the same failure through a different door — `fsGroup` alone decides who owns mounted volumes. A chart pinning NOTHING is reported rather than passed: it inherits the image's USER, which is right today and unanchored tomorrow. Watched failing, not declared able to: - reintroducing the exact 1000/1001 shape → 2 divergences, exit 1; - moving ONLY fsGroup → 1 divergence, exit 1; - 10 unit tests over the predicate, including a green case so a reject-everything predicate cannot masquerade as thorough. The first version failed for its own reason and not the repository's: its `adduser` regex required every token before `-u` to be a flag, but the username is POSITIONAL (`adduser -S evolith -u 1001 -G evolith`), so it read all three real Dockerfiles as unparseable. Caught by running it; the shape is pinned by a test. Guards: 42 classifies it (74 total, none unprotected), 43 sees it turn red on the empty fixture (50/50), governance suite 17/17, gitleaks clean. Co-authored-by: Claude Opus 5 --- .github/workflows/ci-cd.yml | 13 ++ .../ci/61-validate-chart-image-uid.mjs | 194 ++++++++++++++++++ .../ci/61-validate-chart-image-uid.test.mjs | 116 +++++++++++ 3 files changed, 323 insertions(+) create mode 100644 .harness/scripts/ci/61-validate-chart-image-uid.mjs create mode 100644 .harness/scripts/ci/61-validate-chart-image-uid.test.mjs 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(' · ')}`); + } +});