diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 7a399726d..7995cc21f 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -2,14 +2,20 @@ name: Auto-merge Dependabot on: pull_request +# Nothing outside the one job needs more than a checkout, so the default for +# the workflow is read-only and the write scopes sit on the job that spends +# them: `gh pr merge --auto` writes to the repository's merge queue state, and +# both the merge and the review comment write to the pull request. permissions: - contents: write - pull-requests: write + contents: read jobs: auto-merge: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' + permissions: + contents: write + pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 978eae4e6..4303ae830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,31 @@ base URL that is not a URL at all behaves as it did. The operator card now says which providers the field accepts. Found in a fork by @sweidinger. +### Security + +- **The published image no longer ships four flagged dependency versions.** + Three floors in the override block had gone stale: the sanitiser behind the + doctor-report PDF sat below the fix for GHSA-55q2-fjhq-7xh7, the CSS + processor the build runs on sat below the fix for CVE-2026-69153, and a + schema library that arrives through two unrelated parents had no floor at + all (CVE-2026-59952). All three are raised and re-resolved, each inside its + existing major, so nothing changes shape for anyone. +- **The Prisma command line inside the image gets the same pins as the app.** + The image carries two separate installs, and only one of them reads the + project's override block. The other is an npm install of the Prisma CLI, and + it was quietly resolving an HTTP server (GHSA-frvp-7c67-39w9, + CVE-2026-39406) and a schema library (CVE-2026-59952) at the versions the + image scan flagged, while the same packages were pinned and clean in the + application tree. Both are now pinned on the npm side too, verified by + rebuilding that install both ways. This is the second time the same gap has + produced a finding, so the guard that compares the two files was widened: it + had been skipping every scoped package name, which is exactly the case that + went unnoticed here. +- The Dependabot auto-merge workflow no longer hands a write token to the + whole run. The workflow default is read-only and the write scopes sit on the + one job that spends them. Nothing about which updates merge automatically + changes. + ## [1.38.3] — 2026-09-02 An account with two-factor can be deleted from the phone, and every data diff --git a/Dockerfile b/Dockerfile index f62b66d3b..1860926f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -191,11 +191,21 @@ COPY --from=builder /app/src/generated ./src/generated # 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. +# +# The same class recurred on 2026-09-02: the image scan reported +# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and +# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through +# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the +# npm install never reads. Rebuilding this install both ways confirms it: +# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it +# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0. RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \ npm pkg set 'overrides.mysql2=^3.22.0' && \ + npm pkg set 'overrides.@hono/node-server=^1.19.15' && \ + npm pkg set 'overrides.valibot=^1.4.2' && \ npm install --omit=dev prisma@7.8.0 @prisma/engines@7.8.0 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/pnpm-lock.yaml b/pnpm-lock.yaml index 3423fa752..0993c6ce9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - dompurify@<=3.4.10: ^3.4.11 - postcss@<8.5.10: ^8.5.10 + dompurify@<3.4.13: ^3.4.13 + postcss@<8.5.23: ^8.5.23 nanoid@<3.3.18: ^3.3.18 - '@hono/node-server@<1.19.13': ^1.19.13 + '@hono/node-server@<1.19.15': ^1.19.15 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 body-parser@<2.3.0: ^2.3.0 @@ -21,6 +21,7 @@ overrides: sharp@<0.35.0: ^0.35.3 pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108 deepmerge-ts@<8.0.0: ^8.0.0 + valibot@<1.4.2: ^1.4.2 importers: @@ -40,7 +41,7 @@ importers: version: 3.2.2(react@19.2.7) '@hookform/resolvers': specifier: ^5.9.1 - version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.2.0(typescript@6.0.3))(zod@4.4.3) + version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: ^1.30.0 version: 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3) @@ -864,7 +865,7 @@ packages: react-hook-form: ^7.55.0 superstruct: '>=0.12.0' typanion: ^3.3.2 - valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + valibot: ^1.4.2 vest: '>=6.0.0' yup: ^1.0.0 zod: ^3.25.0 || ^4.0.0 @@ -4204,8 +4205,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dompurify@3.4.12: - resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} @@ -5961,10 +5962,6 @@ packages: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} - postcss@8.5.20: - resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -6923,8 +6920,8 @@ packages: utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -7923,7 +7920,7 @@ snapshots: dependencies: hono: 4.13.3 - '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.2.0(typescript@6.0.3))(zod@4.4.3)': + '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.83.0(react@19.2.7) @@ -7932,7 +7929,7 @@ snapshots: ajv: 8.20.0 ajv-formats: 2.1.1(ajv@8.20.0) effect: 3.20.0 - valibot: 1.2.0(typescript@6.0.3) + valibot: 1.4.2(typescript@6.0.3) zod: 4.4.3 '@humanfs/core@0.19.1': {} @@ -8894,7 +8891,7 @@ snapshots: proper-lockfile: 4.1.2 remeda: 2.33.4 std-env: 3.10.0 - valibot: 1.2.0(typescript@6.0.3) + valibot: 1.4.2(typescript@6.0.3) zeptomatch: 2.1.0 transitivePeerDependencies: - typescript @@ -11068,7 +11065,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.4.12: + dompurify@3.4.14: optionalDependencies: '@types/trusted-types': 2.0.7 optional: true @@ -12253,7 +12250,7 @@ snapshots: optionalDependencies: canvg: 3.0.11 core-js: 3.49.0 - dompurify: 3.4.12 + dompurify: 3.4.14 html2canvas: 1.4.1 jsx-ast-utils@3.3.5: @@ -12603,7 +12600,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.43 caniuse-lite: 1.0.30001805 - postcss: 8.5.20 + postcss: 8.5.26 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.7) @@ -12990,12 +12987,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.20: - dependencies: - nanoid: 3.3.18 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.25: dependencies: nanoid: 3.3.18 @@ -14169,7 +14160,7 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true - valibot@1.2.0(typescript@6.0.3): + valibot@1.4.2(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c90da0443..5970d1ce6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,20 @@ overrides: - "dompurify@<=3.4.10": "^3.4.11" - "postcss@<8.5.10": "^8.5.10" + # GHSA-55q2-fjhq-7xh7: a crafted document gets past the sanitiser and its + # markup survives into the output. The copy here is jspdf's optional HTML + # renderer, which the doctor-report PDF path reaches. + "dompurify@<3.4.13": "^3.4.13" + # CVE-2026-69153. Next declares an exact `postcss@8.4.31`, so nothing except + # this bound moves it. The build has run on the 8.5 line since the first + # bump; the fix landed in 8.5.23. + "postcss@<8.5.23": "^8.5.23" "nanoid@<3.3.18": "^3.3.18" - "@hono/node-server@<1.19.13": "^1.19.13" + # GHSA-frvp-7c67-39w9 raised the floor from 1.19.13 to 1.19.15. Mirrored for + # /opt/prisma-cli in the Dockerfile, which is where the flagged 1.19.11 + # actually landed: prisma pulls it through @prisma/dev, and that install + # reads npm `overrides`, not this file. The bound stays on the 1.x line + # because the advisory is fixed there too and 2.x is a major nothing asked + # for. + "@hono/node-server@<1.19.15": "^1.19.15" "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" "body-parser@<2.3.0": "^2.3.0" @@ -39,6 +51,12 @@ overrides: # override is the only way through. It runs on the config-loading and CLI # path, not under a request, but a red audit blocks the whole release train. "deepmerge-ts@<8.0.0": "^8.0.0" + # CVE-2026-59952. Two copies arrive, neither of them ours to call: an + # optional peer of `@hookform/resolvers` (this app validates with zod) and + # prisma's local dev server. Mirrored for /opt/prisma-cli in the Dockerfile, + # where the flagged 1.2.0 landed. 1.4.2 is the same major, so no consumer is + # dragged onto a new one. + "valibot@<1.4.2": "^1.4.2" allowBuilds: "@prisma/engines": true diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 34bac3b29..5d6714b18 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -150,17 +150,86 @@ describe("container dependency installation", () => { }); }); +/** + * `>= 0` comparison over a dotted version, enough for the floor checks below. + * Pre-release suffixes are not expected in an override replacement and are + * ignored rather than guessed at. + */ +function atLeast(version: string, floor: string): boolean { + const parts = (value: string) => + value + .replace(/^[\^~>=<\s]+/, "") + .split("-")[0] + .split(".") + .map((piece) => Number.parseInt(piece, 10) || 0); + const [a, b] = [parts(version), parts(floor)]; + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const left = a[i] ?? 0; + const right = b[i] ?? 0; + if (left !== right) return left > right; + } + return true; +} + describe("production dependency advisory floors", () => { - it("pins vulnerable transitive ranges to compatible patched versions", () => { + /** + * The floor an advisory demands, by package. Raising an override past one of + * these must not break this test: an earlier version of it asserted the exact + * key and replacement string, so every legitimate floor raise failed it while + * a floor that had gone STALE against a newer advisory passed. It now asserts + * the property that matters, which is that the pin is at or above the fixed + * version. Update a floor here when a new advisory raises it. + */ + const floors: Record = { + // GHSA-55q2-fjhq-7xh7, reached through jspdf's optional HTML renderer. + dompurify: "3.4.13", + // CVE-2026-69153. + postcss: "8.5.23", + // GHSA-frvp-7c67-39w9 and CVE-2026-39406. Mirrored in the Dockerfile. + "@hono/node-server": "1.19.15", + hono: "4.12.25", + // CVE-2026-59952. Mirrored in the Dockerfile. + valibot: "1.4.2", + }; + + it("pins vulnerable transitive ranges at or above the fixed version", () => { const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as { overrides?: Record; }; + const overrides = workspace.overrides ?? {}; - expect(workspace.overrides).toMatchObject({ - "dompurify@<=3.4.10": "^3.4.11", - "postcss@<8.5.10": "^8.5.10", - "@hono/node-server@<1.19.13": "^1.19.13", - "hono@<4.12.25": "^4.12.25", - }); + const byName = new Map(); + for (const [key, replacement] of Object.entries(overrides)) { + const selectorAt = key.startsWith("@") + ? key.indexOf("@", 1) + : key.indexOf("@"); + byName.set( + selectorAt === -1 ? key : key.slice(0, selectorAt), + replacement, + ); + } + expect(byName.size).toBeGreaterThan(0); + + for (const [name, floor] of Object.entries(floors)) { + const replacement = byName.get(name); + expect( + replacement, + `${name} has no override in pnpm-workspace.yaml`, + ).toBeDefined(); + expect( + atLeast(replacement as string, floor), + `${name} is pinned to ${replacement}, below the advisory floor ${floor}.`, + ).toBe(true); + } + }); + + it("compares versions rather than always agreeing", () => { + // The comparison is the whole test above; a helper that never returns + // false would make it vacuous. + expect(atLeast("^3.4.13", "3.4.13")).toBe(true); + expect(atLeast("^3.4.14", "3.4.13")).toBe(true); + expect(atLeast("^3.4.12", "3.4.13")).toBe(false); + expect(atLeast("^1.19.9", "1.19.15")).toBe(false); + expect(atLeast("^2.0.5", "1.19.15")).toBe(true); }); }); diff --git a/src/__tests__/dependency-override-lockstep-guard.test.ts b/src/__tests__/dependency-override-lockstep-guard.test.ts index 0cd3c7b66..029e10fd6 100644 --- a/src/__tests__/dependency-override-lockstep-guard.test.ts +++ b/src/__tests__/dependency-override-lockstep-guard.test.ts @@ -20,6 +20,12 @@ * would encode noise. It asserts agreement where both files speak, which is * the failure that actually happened. * + * The matcher had a blind spot of its own until 2026-09-02: it refused any + * workspace key beginning with `@`, so every scoped package was invisible to + * it. `@hono/node-server` was pinned on one side and unpinned on the other and + * the guard stayed green, which is the same shape of green the guard exists to + * prevent. Scoped keys parse now, and a test below pins that they do. + * * 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 @@ -44,17 +50,30 @@ function dockerfileOverrides(): Map { return found; } +/** + * Reduces an overrides key to the bare package name. The key carries a version + * selector, so `deepmerge-ts@<8.0.0` and a bare `deepmerge-ts` both land on the + * same name. A scoped name opens with its own `@`, so the selector starts at + * the SECOND one, not the first. + */ +function packageNameOf(key: string): string { + const trimmed = key.trim(); + const selectorAt = trimmed.startsWith("@") + ? trimmed.indexOf("@", 1) + : trimmed.indexOf("@"); + return (selectorAt === -1 ? trimmed : trimmed.slice(0, selectorAt)).trim(); +} + /** * `"@": ""` 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. + * block. */ function workspaceOverrides(): Map { const found = new Map(); - const pattern = /^\s+"([^"@][^"]*?)(?:@[^"]*)?"\s*:\s*"([^"]+)"/gm; + 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()); + found.set(packageNameOf(match[1]), match[2].trim()); } return found; } @@ -74,6 +93,23 @@ describe("dependency overrides — pnpm workspace and the npm install agree", () expect(workspace.has("deepmerge-ts")).toBe(true); }); + it("reads scoped workspace keys rather than skipping them", () => { + // The old matcher started the name at `[^"@]`, so `@hono/node-server` was + // never in the shared set and could disagree across the two files without + // failing anything. Both halves are asserted: the reduction itself, and + // that a real scoped entry survives it. + expect(packageNameOf("@hono/node-server@<1.19.15")).toBe( + "@hono/node-server", + ); + expect(packageNameOf("dompurify@<3.4.13")).toBe("dompurify"); + expect(packageNameOf("js-yaml@>=4.0.0 <4.3.1")).toBe("js-yaml"); + expect(packageNameOf("deepmerge-ts")).toBe("deepmerge-ts"); + + const workspace = workspaceOverrides(); + const scoped = [...workspace.keys()].filter((name) => name.startsWith("@")); + expect(scoped.length).toBeGreaterThan(0); + }); + it("pins the same range wherever both files name the same package", () => { const docker = dockerfileOverrides(); const workspace = workspaceOverrides(); @@ -91,6 +127,21 @@ describe("dependency overrides — pnpm workspace and the npm install agree", () } }); + it("mirrors every pin the /opt/prisma-cli tree was scanned on", () => { + // These three were each reported by the image scan under + // `/opt/prisma-cli`, reached through `prisma`. Losing a mirror is not a + // style regression, it is the vulnerable version coming back. + const docker = dockerfileOverrides(); + for (const name of ["deepmerge-ts", "@hono/node-server", "valibot"]) { + expect( + docker.has(name), + `${name} was flagged under /opt/prisma-cli and needs an ` + + `\`npm pkg set 'overrides.${name}=...'\` line in the Dockerfile; ` + + `pnpm-workspace.yaml does not reach that install.`, + ).toBe(true); + } + }); + 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.