From a371f56fdbc3a159a495dd515a6d1e16c4992e5c Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:13:15 +0000 Subject: [PATCH] fix(demo): serve the static demo from a slim base, not the builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dev`'s build has failed on four consecutive pushes — 2d0e3d5, d2c73de, c1fc614, ba6baf1 — always the same job, always Trivy, and never because anything in the tree changed. CVE-2026-56408 was published against `libexpat1`/`libexpat1-dev` 2.5.0-1+deb12u2 with a fix in deb12u3, and the gate is `--ignore-unfixed`, so a fixed CVE is exactly what it refuses. Neither package is used by the demo. They are there because `demo-runtime` reused `NODE_IMAGE`, the *builder* base: building the PWA needs a toolchain, serving the built bytes does not. `node:22-bookworm` derives from `buildpack-deps` and carries 413 packages of compilers and `-dev` headers behind a server whose entire dependency set is four `node:` builtins (`node:fs`, `node:fs/promises`, `node:http`, `node:path`). So the fix is not to chase the patch. Upgrading libexpat would leave the next advisory against the other 400 packages to break `dev` again, the same way npm's transitive deps did in #454 — the `rm -rf npm` above this line is that scar. The runtime now has a base of its own at `node:22-bookworm-slim`: 88 packages, no libexpat, no compilers. Measured with two images identical but for the base, scanned with the workflow's exact Trivy invocation: fat (node:22-bookworm) exit 1, 2 HIGH (libexpat1, libexpat1-dev) slim (node:22-bookworm-slim) exit 0, 0 findings and the slim image serves correctly under the same hardened flags CI runs (`--read-only --cap-drop ALL --security-opt no-new-privileges`): healthz 200, root document carries `id="root"`, manifest keeps `full-estate-v1`, and `POST /api/sessions` still refuses with 404 and "no server-side API". No new plumbing: the mirror workflow discovers bases by reading `ARG *IMAGE=` lines, and Renovate's `registryAliases` maps the whole `vogt-base` prefix, so the new base is mirrored and tracked upstream without touching either config. The test that pinned `FROM ${NODE_IMAGE} AS demo-runtime` now pins the property that actually matters — a runtime base separate from the builder, and a slim one — with both halves verified to fail on the old shape. --- engine/Dockerfile | 16 ++++++++++++++-- tests/test_demo_delivery.py | 22 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/engine/Dockerfile b/engine/Dockerfile index d64cec9..da0c98a 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -69,6 +69,17 @@ # these build args back to their Docker Hub names still works and is the # escape hatch for a builder that cannot reach GHCR. ARG NODE_IMAGE=ghcr.io/thedancingdeveloper-org/vogt-base/node:22-bookworm +# The static demo's *runtime* base, deliberately not `NODE_IMAGE`. Building the +# PWA needs a toolchain; serving the built bytes does not — `demo-server.mjs` +# imports only `node:` builtins. `node:22-bookworm` derives from +# `buildpack-deps` and carries 413 packages including compilers and `-dev` +# headers, every one of which is attack surface the demo never uses and a +# fatal-gated Trivy finding waiting to be published against it. The `rm -rf npm` +# in the demo stage is the scar from the last round of that (#454); +# `libexpat1`/`libexpat1-dev` (CVE-2026-56408) was the next, and it failed +# `dev`'s build on four consecutive pushes while nothing in the tree had +# changed. The slim base carries 88 packages and neither of those. +ARG DEMO_RUNTIME_IMAGE=ghcr.io/thedancingdeveloper-org/vogt-base/node:22-bookworm-slim ARG RUST_IMAGE=ghcr.io/thedancingdeveloper-org/vogt-base/rust:1-bookworm # uv builds the core's virtualenv from the committed lockfile, so the image # contains exactly what CI tested (NFR-Q5). Same pin as the root Dockerfile; @@ -157,7 +168,7 @@ RUN set -eu; \ VOGT_PRODUCT_VERSION="$product_version"; \ pnpm demo:augment -FROM ${NODE_IMAGE} AS demo-runtime +FROM ${DEMO_RUNTIME_IMAGE} AS demo-runtime WORKDIR /app COPY --from=demo-web /app/web/dist /app/dist COPY engine/deploy/demo-server.mjs /app/demo-server.mjs @@ -168,7 +179,8 @@ COPY engine/deploy/demo-server.mjs /app/demo-server.mjs # npm CLI the base image bundles is dead weight — and its own bundled # dependencies (tar, brace-expansion, pacote, sigstore, …) are what the fatal # Trivy gate flags as CRITICAL/HIGH. Removing npm/npx clears every one of those -# findings without touching anything the demo runtime uses. +# findings without touching anything the demo runtime uses. The slim base still +# ships npm, so this stays load-bearing. RUN chmod 0444 /app/demo-server.mjs \ && rm -rf /usr/local/lib/node_modules/npm \ /usr/local/bin/npm \ diff --git a/tests/test_demo_delivery.py b/tests/test_demo_delivery.py index e7f36e2..bc07acc 100644 --- a/tests/test_demo_delivery.py +++ b/tests/test_demo_delivery.py @@ -70,10 +70,28 @@ def test_demo_image_branches_from_the_normal_web_build() -> None: assert "FROM web-build AS demo-web" in text assert "pnpm demo:augment" in text demo = text.split("FROM web-build AS demo-web", 1)[1].split("# ─── Stage 2:", 1)[0] - assert "FROM ${NODE_IMAGE} AS demo-runtime" in demo + # Not `NODE_IMAGE`, which is the *builder*. Building the PWA needs a + # toolchain; serving the built bytes does not, and `node:22-bookworm` + # derives from `buildpack-deps` — 413 packages of compilers and `-dev` + # headers the demo never executes, each one a fatal-gated Trivy finding + # waiting to be published against it. That is not hypothetical twice over: + # npm's transitive deps forced the removal below (#454), and then + # `libexpat1`/`libexpat1-dev` (CVE-2026-56408) failed `dev`'s build on four + # consecutive pushes while nothing in the tree had changed. The slim base + # carries 88 packages and neither. + assert "FROM ${DEMO_RUNTIME_IMAGE} AS demo-runtime" in demo + assert "FROM ${NODE_IMAGE} AS demo-runtime" not in demo, ( + "the demo runtime must not reuse the builder base; that is what put " + "compilers and -dev headers in a static file server" + ) + assert re.search(r"^ARG DEMO_RUNTIME_IMAGE=\S+-slim$", text, re.MULTILINE), ( + "the demo runtime base must be a slim variant: the property is a small " + "surface, not merely a separate build arg pointing at the same image" + ) assert "chmod 0444 /app/demo-server.mjs" in demo # The demo server uses only node builtins, so the bundled npm CLI is removed - # — its transitive deps are what the fatal Trivy gate flags (#454). + # — its transitive deps are what the fatal Trivy gate flags (#454). The slim + # base still ships npm, so this stays load-bearing. assert "rm -rf /usr/local/lib/node_modules/npm" in demo assert "COPY --from=server-build" not in demo assert "COPY --from=core" not in demo