diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2721111..af7cea1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,6 @@ name: CI on: pull_request: - branches: [main] push: branches: [main] diff --git a/Dockerfile b/Dockerfile index 2afe51e..8fd6f9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,16 @@ # Stage 1 — Install dependencies FROM node:24.21.0-bookworm-slim AS deps -RUN npm install -g pnpm@12.3.2 WORKDIR /app +COPY scripts/install-verified.mjs ./scripts/ +RUN node scripts/install-verified.mjs pnpm@12.3.2 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile # Stage 2 — Build TypeScript and prune dev dependencies FROM node:24.21.0-bookworm-slim AS build -RUN npm install -g pnpm@12.3.2 WORKDIR /app +COPY scripts/install-verified.mjs ./scripts/ +RUN node scripts/install-verified.mjs pnpm@12.3.2 COPY --from=deps /app/node_modules ./node_modules COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY src/ ./src/ diff --git a/renovate.json b/renovate.json index ffd7bb4..9fa1e1b 100644 --- a/renovate.json +++ b/renovate.json @@ -13,7 +13,7 @@ "description": "Track the pnpm version installed in the Dockerfile.", "customType": "regex", "managerFilePatterns": ["/^Dockerfile$/"], - "matchStrings": ["npm install -g (?pnpm)@(?\\S+)"], + "matchStrings": ["install-verified\\.mjs (?pnpm)@(?\\S+)"], "datasourceTemplate": "npm" } ], @@ -22,6 +22,11 @@ "description": "Keep the Node version in .node-version and the Dockerfile in one PR.", "matchPackageNames": ["node"], "groupName": "node" + }, + { + "description": "Keep the pnpm version in package.json and the Dockerfile in one PR.", + "matchPackageNames": ["pnpm"], + "groupName": "pnpm" } ] } diff --git a/scripts/install-verified.mjs b/scripts/install-verified.mjs new file mode 100644 index 0000000..60097bc --- /dev/null +++ b/scripts/install-verified.mjs @@ -0,0 +1,51 @@ +import { createHash, createVerify } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const REGISTRY = 'https://registry.npmjs.org'; + +const spec = process.argv[2]; +if (!spec || !spec.includes('@')) { + console.error('usage: install-verified.mjs @'); + process.exit(1); +} + +const at = spec.lastIndexOf('@'); +const name = spec.slice(0, at); +const version = spec.slice(at + 1); + +async function getJson(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} responded ${response.status}`); + return response.json(); +} + +function toPem(key) { + return `-----BEGIN PUBLIC KEY-----\n${key.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`; +} + +const { dist } = await getJson(`${REGISTRY}/${name}/${version}`); +const signature = dist.signatures?.[0]; +if (!signature) throw new Error(`${spec} has no registry signature`); + +const { keys } = await getJson(`${REGISTRY}/-/npm/v1/keys`); +const signingKey = keys.find((candidate) => candidate.keyid === signature.keyid); +if (!signingKey) throw new Error(`no registry key for ${signature.keyid}`); + +const signed = createVerify('SHA256') + .update(`${name}@${version}:${dist.integrity}`) + .verify(toPem(signingKey.key), signature.sig, 'base64'); +if (!signed) throw new Error(`${spec} failed registry signature verification`); + +const tarball = Buffer.from(await (await fetch(dist.tarball)).arrayBuffer()); +const digest = `sha512-${createHash('sha512').update(tarball).digest('base64')}`; +if (digest !== dist.integrity) + throw new Error(`${spec} tarball does not match the signed integrity`); + +const path = join(tmpdir(), `${name.replace('/', '-')}-${version}.tgz`); +writeFileSync(path, tarball); +execFileSync('npm', ['install', '-g', path], { stdio: 'inherit' }); + +console.log(`verified and installed ${spec}`); diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts