mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 17:52:44 -04:00
* fix(package-bins): reject reserved manifest bin names Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.." passed the bin-name guard because encodeURIComponent leaves them unchanged. When joined to the global bin directory during global remove/update/add operations, "." resolves to the bin directory itself and ".." to its parent, which removeBin then recursively deletes. Reject empty, ".", and ".." bin names after scope stripping. Backport of pnpm/pnpm#12289 to v10. * fix: block untrusted request destination env expansion Makes environment expansion trust-aware for registry/auth config and request destinations: - Stops project and workspace .npmrc files from expanding ${...} placeholders in registry/proxy request destinations, URL-scoped keys, and registry credential values. - Stops repository-controlled pnpm-workspace.yaml from expanding ${...} placeholders in the registry setting. - Preserves env expansion for trusted user/global/CLI/env config so existing token and registry setup flows continue to work. Backport of pnpm/pnpm#12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10. * fix(security): verify npm registry signature before spawning a package-manager binary The packageManager field (and pnpm self-update) makes pnpm download and run a specific pnpm version. The staged install's bytes were trusted based on lockfile integrity alone, which proves nothing when the inputs are repository-controlled. pnpm now verifies the npm registry signature of the engine it is about to spawn, over the installed integrity, against npm's public signing keys embedded in the pnpm CLI (exactly as corepack does): - verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized platform binaries of the staged install before it is linked into the tools directory. - Fails closed: any verification failure, including an unreachable registry, refuses the version switch rather than running an unverified binary. Runs only on a tools-directory cache miss (an actual download). - The embedded keys live in a generated file kept in sync with npm's keys endpoint by scripts/update-npm-signing-keys.mjs; the release workflow runs the check as a gate so a key rotation cannot silently break verification. Backport of pnpm/pnpm#12292 (CAND-PNPM-097) to v10. * fix: harden package-manager bootstrap metadata Resolve package-manager bootstrap traffic through trusted user/CLI registries and trusted network config, defaulting to the public npm registry instead of project/workspace registry settings: - getConfig() now computes packageManagerRegistries and packageManagerNetworkConfig from trusted config sources only (CLI options, env config, user and global .npmrc) — never the repository's project/workspace .npmrc or pnpm-workspace.yaml. - switchCliVersion() applies that bootstrap config when installing and verifying the wanted pnpm version, so repository .npmrc proxy/TLS/registry values cannot steer package-manager bootstrap traffic. Backport of pnpm/pnpm#12296 to v10. The v11 env-lockfile validation parts do not apply: v10 bootstraps the wanted version through a staged child install instead of an env lockfile. * fix(security): verify Node.js runtime SHASUMS OpenPGP signature When a repository requests a Node.js runtime (useNodeVersion or an execution env), pnpm downloads and then executes a Node binary. The download mirror is repository-configurable via node-mirror:<channel> in project .npmrc, and the integrity came from SHASUMS256.txt fetched from that same mirror — a circular check a malicious mirror can satisfy with a tampered binary and matching hashes. pnpm now fetches SHASUMS256.txt.sig and verifies its detached OpenPGP signature against the Node.js release team's public keys, embedded in the pnpm CLI, before trusting the hashes: - @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums / fetchVerifiedNodeShasumsFile verify the signature via openpgp against the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from the canonical nodejs/release-keys list). - @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the release channel; pre-release channels (rc, nightly, ...) are unsigned by Node and remain unverified. - scripts/update-node-release-keys.mjs keeps the keys current (pnpm run check:node-release-keys / update:node-release-keys), and the release workflow runs the check as a gate. Backport of pnpm/pnpm#12295 to v10 (without the pacquet Rust port, which does not exist on this branch). * test(env): sign the SHASUMS fixture for Node.js download tests The Node.js download tests exercise the release channel, whose SHASUMS256.txt is now signature-verified. Sign the fixture with a generated OpenPGP key and trust it through the new trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via @pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep exercising the verification path instead of bypassing it. * fix(self-updater): redact registry credentials from engine identity errors Registry URLs may legally embed basic-auth credentials (https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the packument URL and registry URL into PnpmError messages, and the unreachable-registry path surfaced fetch-layer error messages that embed the request URL — all of which land in terminal output and CI logs. Strip URL credentials from every error message and truncate the non-200 response body. * fix: update vulnerable transitive dependencies Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled in via concurrently) so the audit workflow passes again. The advisory was published after the last release/10 audit run; it is unrelated to the security backports on this branch.
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
import { Response } from 'node-fetch'
|
|
import path from 'path'
|
|
import fs from 'fs'
|
|
import { Readable } from 'stream'
|
|
import tar from 'tar-stream'
|
|
import { globalWarn } from '@pnpm/logger'
|
|
import { jest } from '@jest/globals'
|
|
import { ZipFile } from 'yazl'
|
|
import {
|
|
getNodeDir,
|
|
getNodeBinDir,
|
|
getNodeVersionsBaseDir,
|
|
type NvmNodeCommandOptions,
|
|
prepareExecutionEnv,
|
|
} from '../lib/node.js'
|
|
import { tempDir } from '@pnpm/prepare'
|
|
import * as openpgp from 'openpgp'
|
|
|
|
const SHASUMS_CONTENT = `
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-darwin-arm64.tar.gz
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-linux-arm64.tar.gz
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-linux-x64.tar.gz
|
|
a08f3386090e6511772b949d41970b75a6b71d28abb551dff9854ceb1929dae1 node-v16.4.0-win-x64.zip
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v18.0.0-rc.3-darwin-arm64.tar.gz
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v18.0.0-rc.3-linux-arm64.tar.gz
|
|
5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v18.0.0-rc.3-linux-x64.tar.gz
|
|
07e6121cba611b57f310a489f76c413b6246e79cffe1e9538b2478ffee11c99e node-v18.0.0-rc.3-win-x64.zip
|
|
`
|
|
|
|
// The SHASUMS fixture is signed with a test key generated in beforeAll, and the
|
|
// tests that hit the signed `release` channel trust that key via the
|
|
// trustedNodeReleaseKeys test seam.
|
|
let shasumsSignature!: Uint8Array
|
|
let trustedNodeReleaseKeys!: Array<{ armoredKey: string }>
|
|
|
|
beforeAll(async () => {
|
|
const { privateKey, publicKey } = await openpgp.generateKey({
|
|
userIDs: [{ name: 'Test Node Releaser', email: 'test@nodejs.example' }],
|
|
format: 'armored',
|
|
})
|
|
const message = await openpgp.createMessage({ binary: new TextEncoder().encode(SHASUMS_CONTENT) })
|
|
shasumsSignature = await openpgp.sign({
|
|
message,
|
|
signingKeys: await openpgp.readPrivateKey({ armoredKey: privateKey }),
|
|
detached: true,
|
|
format: 'binary',
|
|
}) as Uint8Array
|
|
trustedNodeReleaseKeys = [{ armoredKey: publicKey }]
|
|
})
|
|
|
|
const fetchMock = jest.fn(async (url: string) => {
|
|
if (url.endsWith('SHASUMS256.txt.sig')) {
|
|
return new Response(Buffer.from(shasumsSignature))
|
|
}
|
|
if (url.endsWith('SHASUMS256.txt')) {
|
|
return new Response(SHASUMS_CONTENT)
|
|
}
|
|
if (url.endsWith('.tar.gz')) {
|
|
const pack = tar.pack()
|
|
pack.finalize()
|
|
return new Response(pack) // pack is a readable stream
|
|
} else if (url.endsWith('.zip')) {
|
|
// The Windows code path for pnpm's node bootstrapping expects a subdir
|
|
// within the .zip file.
|
|
const pkgName = path.basename(url, '.zip')
|
|
const zipfile = new ZipFile()
|
|
|
|
zipfile.addBuffer(Buffer.from('test'), `${pkgName}/dummy-file`, {
|
|
mtime: new Date(0), // fixed timestamp for determinism
|
|
mode: 0o100644, // fixed file permissions
|
|
})
|
|
|
|
zipfile.end()
|
|
return new Response(Readable.from(zipfile.outputStream))
|
|
}
|
|
|
|
return new Response(Readable.from(Buffer.alloc(0)))
|
|
})
|
|
|
|
jest.mock('@pnpm/fetch', () => ({
|
|
createFetchFromRegistry: () => fetchMock,
|
|
}))
|
|
|
|
jest.mock('@pnpm/logger', () => {
|
|
const originalModule = jest.requireActual<object>('@pnpm/logger')
|
|
return {
|
|
...originalModule,
|
|
globalWarn: jest.fn(),
|
|
}
|
|
})
|
|
|
|
beforeEach(() => {
|
|
fetchMock.mockClear()
|
|
jest.mocked(globalWarn).mockClear()
|
|
})
|
|
|
|
test('check API (placeholder test)', async () => {
|
|
expect(typeof getNodeDir).toBe('function')
|
|
})
|
|
|
|
test('install Node uses node-mirror:release option', async () => {
|
|
tempDir()
|
|
const configDir = path.resolve('config')
|
|
|
|
const nodeMirrorRelease = 'https://pnpm-node-mirror-test.localhost/download/release'
|
|
const opts: NvmNodeCommandOptions = {
|
|
bin: process.cwd(),
|
|
configDir,
|
|
global: true,
|
|
pnpmHomeDir: process.cwd(),
|
|
rawConfig: {
|
|
'node-mirror:release': nodeMirrorRelease,
|
|
},
|
|
trustedNodeReleaseKeys,
|
|
useNodeVersion: '16.4.0',
|
|
}
|
|
|
|
await getNodeBinDir(opts)
|
|
|
|
for (const call of fetchMock.mock.calls) {
|
|
expect(call[0]).toMatch(nodeMirrorRelease)
|
|
}
|
|
})
|
|
|
|
test('install an rc version of Node.js', async () => {
|
|
tempDir()
|
|
const configDir = path.resolve('config')
|
|
|
|
const opts: NvmNodeCommandOptions = {
|
|
bin: process.cwd(),
|
|
configDir,
|
|
global: true,
|
|
pnpmHomeDir: process.cwd(),
|
|
rawConfig: {},
|
|
useNodeVersion: 'rc/18.0.0-rc.3',
|
|
}
|
|
|
|
await getNodeBinDir(opts)
|
|
|
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
|
const arch = process.arch
|
|
const extension = process.platform === 'win32' ? 'zip' : 'tar.gz'
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
`https://nodejs.org/download/rc/v18.0.0-rc.3/node-v18.0.0-rc.3-${platform}-${arch}.${extension}`
|
|
)
|
|
})
|
|
|
|
test('get node version base dir', async () => {
|
|
expect(typeof getNodeVersionsBaseDir).toBe('function')
|
|
const versionDir = getNodeVersionsBaseDir(process.cwd())
|
|
expect(versionDir).toBe(path.resolve(process.cwd(), 'nodejs'))
|
|
})
|
|
|
|
test('specified an invalid Node.js via use-node-version should not cause pnpm itself to break', async () => {
|
|
tempDir()
|
|
const configDir = path.resolve('config')
|
|
|
|
const opts: NvmNodeCommandOptions = {
|
|
bin: process.cwd(),
|
|
configDir,
|
|
global: true,
|
|
pnpmHomeDir: process.cwd(),
|
|
rawConfig: {},
|
|
trustedNodeReleaseKeys,
|
|
useNodeVersion: '22.14',
|
|
}
|
|
|
|
fs.mkdirSync('nodejs', { recursive: true })
|
|
fs.writeFileSync('nodejs/versions.json', '{"default":"16.4.0"}', 'utf8')
|
|
|
|
expect(await getNodeBinDir(opts)).toBeTruthy()
|
|
|
|
const calls = jest.mocked(globalWarn).mock.calls
|
|
expect(calls[calls.length - 1][0]).toContain('"22.14" is not a valid Node.js version.')
|
|
})
|
|
|
|
describe('prepareExecutionEnv', () => {
|
|
test('should not proceed to fetch Node.js if the process is already running in wanted node version', async () => {
|
|
fetchMock.mockImplementationOnce(() => {
|
|
throw new Error('prepareExecutionEnv should not proceed to fetch Node.js when wanted version is running')
|
|
})
|
|
|
|
await prepareExecutionEnv({
|
|
bin: '',
|
|
pnpmHomeDir: process.cwd(),
|
|
rawConfig: {},
|
|
}, {
|
|
executionEnv: { nodeVersion: process.versions.node },
|
|
})
|
|
})
|
|
})
|