From afebaaa34123695f0168bf3ebafed1d8c5e16851 Mon Sep 17 00:00:00 2001 From: Courier Date: Sat, 3 Oct 2026 20:02:50 +0000 Subject: [PATCH 1/5] fix(mcp): build -mcp image from production-only deps plus a tsx layer (#1173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mcp stage copied node_modules from the dev-inclusive deps stage because its entrypoint runs tsx, shipping the whole lint/test toolchain — including the accepted dev-only advisory chain eslint-config-next -> @next/eslint-plugin-next -> fast-glob -> micromatch -> braces (GHSA-vfj7-8cjw-p6xm) — in the published image. Add an mcp-deps stage (npm ci --omit=dev + a package.json rewrite that makes tsx a production dep of the layer copy only, then npm install --no-save) and copy the image tree and shipped manifest from it. Assert absence of the dev toolchain in the docker-mcp job after the handshake validation. --- .github/workflows/image.yaml | 39 ++++++++++++++++++++++++++++++++++++ Dockerfile | 36 ++++++++++++++++++++++++++++++--- SECURITY-ACCEPTED-RISKS.md | 3 ++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 8fd3597e..6e596e07 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -190,3 +190,42 @@ jobs: | docker run --rm -i --env DISPATCH_URL=http://localhost --env DISPATCH_AGENT_TOKEN=ci "$IMAGE" \ | tee /dev/stderr \ | grep -q '"serverInfo"' + + # #1173 regression guard: the MCP image must carry only the production + # closure plus tsx. The dev toolchain and its accepted dev-only advisory + # chain (eslint-config-next -> @next/eslint-plugin-next -> fast-glob -> + # micromatch -> braces, GHSA-vfj7-8cjw-p6xm) must never ship in it again. + - name: Assert MCP image ships no dev toolchain + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + IMAGE="$(printf '%s\n' "$TAGS" | head -n1)" + if [ "${{ github.event_name }}" != "pull_request" ]; then + docker pull "$IMAGE" + fi + echo "Asserting no dev toolchain in image: $IMAGE" + docker run --rm --entrypoint sh "$IMAGE" -c ' + rc=0 + # Advisory chain from SECURITY-ACCEPTED-RISKS (#1166): + # eslint-config-next -> @next/eslint-plugin-next -> fast-glob -> + # micromatch -> braces. Scan one nesting level too -- a duplicate + # can hide under a host package. + for pkg in eslint eslint-config-next @next/eslint-plugin-next fast-glob micromatch braces; do + for dir in "node_modules/$pkg" node_modules/*/node_modules/"$pkg"; do + if [ -e "$dir" ]; then + echo "dev dependency present in MCP image: $dir" + rc=1 + fi + done + done + # Broader dev-tool smoke list, hoisted top level only (nested + # duplicates of these can be legitimate transitive prod content). + for pkg in vitest typescript ts-node; do + if [ -e "node_modules/$pkg" ]; then + echo "dev dependency present in MCP image: node_modules/$pkg" + rc=1 + fi + done + exit "$rc" + ' diff --git a/Dockerfile b/Dockerfile index d49d483e..ddeb3404 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,29 @@ WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci --omit=dev +# Production-only dependency tree for the MCP image: the app's full production +# closure (@modelcontextprotocol/sdk, zod, next, prisma + transitive), plus tsx +# as a separate layer -- tsx is the image entrypoint but must stay a +# devDependency so the main runner image never ships it. package.json and +# package-lock.json are unchanged in git; only this layer's copy is rewritten +# (see RUN notes below, #1173). +FROM base AS mcp-deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev +# Both direct npm install shapes are unusable here: installing "tsx@range" +# without --omit=dev reifies the whole tree and reinstalls every devDependency, +# and with --omit=dev npm skips an explicit package that package.json lists as +# dev-only. Recipe: rewrite THIS layer's copy of package.json (tsx -> +# dependencies, drop devDependencies; the range is still read from +# devDependencies so Renovate stays the source of truth) and let a plain +# --no-save install add only tsx + esbuild. The install pass also prunes +# dev-flagged stragglers this lock leaks into --omit=dev (typescript et al.); +# the "Assert MCP image ships no dev toolchain" step in .github/workflows/ +# image.yaml is the guard if npm's behavior drifts. +RUN node -e 'const f="./package.json",p=require(f);p.dependencies.tsx=p.devDependencies.tsx;delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2))' \ + && npm install --no-save --no-audit --no-fund + FROM base AS builder WORKDIR /app ARG DATABASE_URL=postgresql://localhost:5432/dispatch @@ -67,14 +90,21 @@ ENTRYPOINT ["/docker-entrypoint.sh"] # MCP server image (stdio transport) for the in-cluster toolhive gateway. It # talks to the dispatch API over HTTP, so it needs neither prisma nor the Next -# build -- only tsx and the client code. +# build -- only tsx and the client code. Its node_modules comes from mcp-deps +# (npm ci --omit=dev + a tsx layer), NOT from deps: the published image must +# ship the runtime closure only, never the dev toolchain with its accepted +# dev-only advisory chain (#1173, GHSA-vfj7-8cjw-p6xm). FROM base AS mcp WORKDIR /app ENV NODE_ENV=production -COPY --from=deps /app/node_modules ./node_modules -COPY package.json tsconfig.json ./ +COPY --from=mcp-deps /app/node_modules ./node_modules +COPY tsconfig.json ./ +# Ship the mcp-deps layer's rewritten manifest (devDependencies removed) rather +# than the repo one: trivy-style manifest scanners must not see dev deps that +# are not in this image. tsx does not read package.json at runtime. +COPY --from=mcp-deps /app/package.json ./package.json # Only the server's import closure -- copying all of src/lib would put 111 # unrelated files, tests included, into a published image. COPY src/mcp/server.ts ./src/mcp/ diff --git a/SECURITY-ACCEPTED-RISKS.md b/SECURITY-ACCEPTED-RISKS.md index d198710c..fa9ed9d8 100644 --- a/SECURITY-ACCEPTED-RISKS.md +++ b/SECURITY-ACCEPTED-RISKS.md @@ -1,6 +1,6 @@ # Accepted Security Risks -**Last updated: 2026-08-15** +**Last updated: 2026-10-03** There are currently no accepted npm runtime advisories. @@ -60,3 +60,4 @@ The following previously accepted risks have been retired: |---|---|---| | Trivy action pinned to SHA | ✅ Resolved | `aquasecurity/trivy-action@ed142fd` (v0.36.0). The SHA pin is intentional: trivy is the release gate, so a floating tag must not reach a release build. Renovate's `github-tags` datasource cannot resolve a bare SHA pin (it only produced a `no-result` lookup failure on the dashboard), so the action is excluded from Renovate in `renovate.json` (`matchPackageNames: ["aquasecurity/trivy-action"]`, `enabled: false`) and is bumped manually, with the version comment, after reviewing an upstream release. | | `.npmrc` invalid omit config | ✅ Resolved | Fixed `omit=` → `omit=dev` | +| Dev-only advisory chain `eslint-config-next` -> `@next/eslint-plugin-next` -> `fast-glob` -> `micromatch` -> `braces` (GHSA-vfj7-8cjw-p6xm) | ✅ Resolved (image scope) | #1173: the `-mcp` image is now built from a production-only install plus a tsx layer, so the chain ships in no published image. It remains in dev installs only; `.github/workflows/image.yaml` asserts its absence from the `-mcp` image on every build. | From be22380ba15588a1d82ddabfc0dc270448dafc0f Mon Sep 17 00:00:00 2001 From: Courier Date: Sat, 3 Oct 2026 21:44:58 +0000 Subject: [PATCH 2/5] fix(mcp): harden mcp-deps rewrite and dev-toolchain assert per review - fail the build (not just the handshake) if tsx disappears from devDependencies; emit a trailing newline in the layer-rewritten package.json - warn against copying .npmrc into mcp-deps (include=dev overrides --omit=dev) - assert step: also scan advisory-chain packages nested under scoped host packages (node_modules/@scope/host/node_modules/*) - Dockerfile.test.ts: pin the mcp stage to COPY --from=mcp-deps and keep mcp-deps a --omit=dev install (guards the #1173 wiring in-unit) --- .github/workflows/image.yaml | 4 ++-- Dockerfile | 5 ++++- Dockerfile.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 6e596e07..c8d4e6f2 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -210,9 +210,9 @@ jobs: # Advisory chain from SECURITY-ACCEPTED-RISKS (#1166): # eslint-config-next -> @next/eslint-plugin-next -> fast-glob -> # micromatch -> braces. Scan one nesting level too -- a duplicate - # can hide under a host package. + # can hide under a host package, scoped hosts included. for pkg in eslint eslint-config-next @next/eslint-plugin-next fast-glob micromatch braces; do - for dir in "node_modules/$pkg" node_modules/*/node_modules/"$pkg"; do + for dir in "node_modules/$pkg" node_modules/*/node_modules/"$pkg" node_modules/@*/*/node_modules/"$pkg"; do if [ -e "$dir" ]; then echo "dev dependency present in MCP image: $dir" rc=1 diff --git a/Dockerfile b/Dockerfile index ddeb3404..53bad37b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ RUN npm ci --omit=dev FROM base AS mcp-deps WORKDIR /app COPY package.json package-lock.json* ./ +# Do not COPY .npmrc into this stage: the repo .npmrc sets include=dev, +# which overrides --omit=dev and would re-admit the dev tree (#1166 proved +# the override; the CI assert would catch it, but fail at build here). RUN npm ci --omit=dev # Both direct npm install shapes are unusable here: installing "tsx@range" # without --omit=dev reifies the whole tree and reinstalls every devDependency, @@ -30,7 +33,7 @@ RUN npm ci --omit=dev # dev-flagged stragglers this lock leaks into --omit=dev (typescript et al.); # the "Assert MCP image ships no dev toolchain" step in .github/workflows/ # image.yaml is the guard if npm's behavior drifts. -RUN node -e 'const f="./package.json",p=require(f);p.dependencies.tsx=p.devDependencies.tsx;delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2))' \ +RUN node -e 'const f="./package.json",p=require(f);const range=p.devDependencies&&p.devDependencies.tsx;if(!range)throw new Error("tsx missing from devDependencies (#1173)");p.dependencies.tsx=range;delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")' \ && npm install --no-save --no-audit --no-fund FROM base AS builder diff --git a/Dockerfile.test.ts b/Dockerfile.test.ts index 3e9a7e91..84481581 100644 --- a/Dockerfile.test.ts +++ b/Dockerfile.test.ts @@ -81,3 +81,33 @@ describe("Dockerfile builder stage DATABASE_URL", () => { } }); }); + +/** + * Regression tests for issue #1173: the MCP image must be assembled from the + * production-only mcp-deps tree, never from the dev-inclusive `deps` stage, + * and mcp-deps itself must stay a production-only install. + */ +describe("Dockerfile MCP image wiring", () => { + it("ships the MCP image from the production-only mcp-deps tree", () => { + const stages = splitIntoStages(readDockerfile()); + const mcp = stages.get("mcp"); + expect(mcp, "expected a `mcp` stage in the Dockerfile").toBeDefined(); + expect(mcp).toContain("COPY --from=mcp-deps /app/node_modules ./node_modules"); + expect(mcp).toContain("COPY --from=mcp-deps /app/package.json ./package.json"); + expect( + mcp, + "mcp stage must not copy from the dev-inclusive deps stage", + ).not.toContain("COPY --from=deps "); + }); + + it("installs mcp-deps without dev dependencies", () => { + const stages = splitIntoStages(readDockerfile()); + const mcpDeps = stages.get("mcp-deps"); + expect(mcpDeps, "expected an `mcp-deps` stage in the Dockerfile").toBeDefined(); + expect(mcpDeps).toContain("npm ci --omit=dev"); + expect( + mcpDeps, + "mcp-deps stage must not set ENV DATABASE_URL (belt for issue #533)", + ).not.toMatch(/^ENV\s+DATABASE_URL\b/m); + }); +}); From 6fdf72fa0c948567bb6473c044baa8a53368e412 Mon Sep 17 00:00:00 2001 From: Courier Date: Sat, 3 Oct 2026 22:00:44 +0000 Subject: [PATCH 3/5] ci: re-run checks after transient runner network failure (smoke: ENOTFOUND nodejs.org at Install Node step) From 20e4c8399a4c131dfa443cb684e37c94c6e1248b Mon Sep 17 00:00:00 2001 From: Courier Date: Sat, 3 Oct 2026 22:08:16 +0000 Subject: [PATCH 4/5] ci: re-run checks (second transient infra failure: GHCR 'No server is currently available' at Extract metadata on identical tree) From f47f6a5f4a32bc1f3a9457e4b78d420240209290 Mon Sep 17 00:00:00 2001 From: Courier Date: Sun, 4 Oct 2026 15:56:17 +0000 Subject: [PATCH 5/5] fix(mcp): harden mcp-deps rewrite guard and pin tsx-layer recipe in tests Independent review pass (post-merge): - rewrite now tolerates a manifest without a dependencies field (p.dependencies = p.dependencies || {}) instead of a cryptic TypeError; - fix inverted comment: typescript leaks into --omit=dev because it is dev:false in the lock via optional-peer refs, not dev-flagged; - Dockerfile.test.ts pins the tsx layer itself (rewrite reads the range from devDependencies + npm install --no-save), so deleting the rewrite fails the unit test, not only the CI handshake. --- Dockerfile | 5 +++-- Dockerfile.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 53bad37b..d6364a1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,11 @@ RUN npm ci --omit=dev # dependencies, drop devDependencies; the range is still read from # devDependencies so Renovate stays the source of truth) and let a plain # --no-save install add only tsx + esbuild. The install pass also prunes -# dev-flagged stragglers this lock leaks into --omit=dev (typescript et al.); +# stragglers this lock reifies even under --omit=dev (typescript et al.: +# dev:false in the lock via optional-peer refs); # the "Assert MCP image ships no dev toolchain" step in .github/workflows/ # image.yaml is the guard if npm's behavior drifts. -RUN node -e 'const f="./package.json",p=require(f);const range=p.devDependencies&&p.devDependencies.tsx;if(!range)throw new Error("tsx missing from devDependencies (#1173)");p.dependencies.tsx=range;delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")' \ +RUN node -e 'const f="./package.json",p=require(f);const range=p.devDependencies&&p.devDependencies.tsx;if(!range)throw new Error("tsx missing from devDependencies (#1173)");p.dependencies=p.dependencies||{};p.dependencies.tsx=range;delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")' \ && npm install --no-save --no-audit --no-fund FROM base AS builder diff --git a/Dockerfile.test.ts b/Dockerfile.test.ts index 84481581..2f7f0f58 100644 --- a/Dockerfile.test.ts +++ b/Dockerfile.test.ts @@ -105,6 +105,14 @@ describe("Dockerfile MCP image wiring", () => { const mcpDeps = stages.get("mcp-deps"); expect(mcpDeps, "expected an `mcp-deps` stage in the Dockerfile").toBeDefined(); expect(mcpDeps).toContain("npm ci --omit=dev"); + expect( + mcpDeps, + "mcp-deps must add tsx via the layer-local manifest rewrite (reads the range from devDependencies)", + ).toContain("devDependencies.tsx"); + expect( + mcpDeps, + "mcp-deps must install the tsx layer with --no-save (no manifest/lock writes)", + ).toContain("npm install --no-save"); expect( mcpDeps, "mcp-deps stage must not set ENV DATABASE_URL (belt for issue #533)",