diff --git a/Dockerfile b/Dockerfile index 37f80524a..8ab75ed66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -157,9 +157,27 @@ COPY --from=builder /app/src/generated ./src/generated # the maintenance scripts already shipped in the standalone tree. The Prisma # config loads dotenv from /app, so expose the isolated copy there before # stripping npm/Corepack and their caches from the runtime surface. +# +# This install belongs to npm, not pnpm, so the `overrides` block in +# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app +# tree still arrives here at whatever version Prisma asks for. That is not +# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves +# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH, +# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit +# whose dependency audit over the app tree was green. Two installs, two +# override mechanisms, one of them set. `overrides` is npm's own mechanism and +# `npm pkg set` writes it into the package.json that `npm init -y` just +# created, before anything resolves. +# +# Measured by rebuilding this exact install both ways: without the field +# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version` +# still reports 7.8.0. Keep the range in step with the entry in +# `pnpm-workspace.yaml` — they are one decision applied to two package +# managers, and a fix that lands in only one of them is the defect above. RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ + npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \ ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ diff --git a/src/__tests__/dependency-override-lockstep-guard.test.ts b/src/__tests__/dependency-override-lockstep-guard.test.ts new file mode 100644 index 000000000..0cd3c7b66 --- /dev/null +++ b/src/__tests__/dependency-override-lockstep-guard.test.ts @@ -0,0 +1,101 @@ +/** + * The two override mechanisms agree, or the build fails here. + * + * The image carries two independent installs. `pnpm install` builds the app + * tree and honours the `overrides` block in `pnpm-workspace.yaml`. A second, + * npm-owned install builds `/opt/prisma-cli` (Dockerfile) and honours nothing + * from that file — it reads its own `overrides` field instead. + * + * On 2026-08-21 only the first one was set. The dependency audit over the app + * tree passed, and the Trivy image scan reported CVE-2026-40345 against + * `deepmerge-ts@7.1.5` under `/opt/prisma-cli`, pulled in by + * `prisma -> @prisma/config`. A pin that covers one of two installs reads + * exactly like a pin that covers both, right up until something scans the + * artefact rather than the lockfile. + * + * So: every package pinned in BOTH places must be pinned to the same range, + * and the Dockerfile's pin must survive as long as the workspace one does. + * This does NOT assert that every workspace override is mirrored — most exist + * for packages `/opt/prisma-cli` never installs, and demanding a mirror there + * would encode noise. It asserts agreement where both files speak, which is + * the failure that actually happened. + * + * Limit, written down so the next reader does not over-trust a green run: this + * compares declared ranges. It cannot see a NEW vulnerable transitive that + * neither file mentions. Only the image scan can, and that job runs on + * main-push, not on pull requests. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8"); +const WORKSPACE = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8"); + +/** `npm pkg set 'overrides.='` occurrences in the Dockerfile. */ +function dockerfileOverrides(): Map { + const found = new Map(); + const pattern = /npm\s+pkg\s+set\s+['"]overrides\.([^='"]+)=([^'"]+)['"]/g; + for (const match of DOCKERFILE.matchAll(pattern)) { + found.set(match[1].trim(), match[2].trim()); + } + return found; +} + +/** + * `"@": ""` entries under the workspace `overrides:` + * block. The key carries a version selector, so `deepmerge-ts@<8.0.0` and a + * bare `deepmerge-ts` both have to reduce to the same package name. + */ +function workspaceOverrides(): Map { + const found = new Map(); + const pattern = /^\s+"([^"@][^"]*?)(?:@[^"]*)?"\s*:\s*"([^"]+)"/gm; + const block = WORKSPACE.split(/^allowBuilds:/m)[0]; + for (const match of block.matchAll(pattern)) { + found.set(match[1].trim(), match[2].trim()); + } + return found; +} + +describe("dependency overrides — pnpm workspace and the npm install agree", () => { + it("finds the Dockerfile's npm-side overrides at all", () => { + const docker = dockerfileOverrides(); + // A zero-match regex would make every assertion below vacuously true. + // This is the check that keeps the guard honest about its own matcher. + expect(docker.size).toBeGreaterThan(0); + expect(docker.has("deepmerge-ts")).toBe(true); + }); + + it("finds the workspace overrides at all", () => { + const workspace = workspaceOverrides(); + expect(workspace.size).toBeGreaterThan(0); + expect(workspace.has("deepmerge-ts")).toBe(true); + }); + + it("pins the same range wherever both files name the same package", () => { + const docker = dockerfileOverrides(); + const workspace = workspaceOverrides(); + + const shared = [...docker.keys()].filter((name) => workspace.has(name)); + expect(shared.length).toBeGreaterThan(0); + + for (const name of shared) { + expect( + docker.get(name), + `Dockerfile pins ${name} to ${docker.get(name)} while ` + + `pnpm-workspace.yaml pins it to ${workspace.get(name)}. The image ` + + `carries both installs, so the looser of the two is what ships.`, + ).toBe(workspace.get(name)); + } + }); + + it("keeps the override ahead of the version Prisma's config asks for", () => { + // `@prisma/config` requests deepmerge-ts 7.x; the advisory is fixed in + // 8.0.0. Anything that lets a 7.x back in re-opens CVE-2026-40345. + const range = dockerfileOverrides().get("deepmerge-ts"); + expect(range).toBeDefined(); + expect(range).toMatch(/\^?8\./); + }); +});