mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-29 11:11:43 -04:00
fix: fail by default when a tarball does not match the locked integrity (#11985)
Backport of #11968 to release/10. Treats tarball-integrity mismatches against the lockfile as a hard failure by default; `--update-checksums` is the only opt-in. `--force` and `pnpm update` deliberately do not bypass the integrity check. 🤖 Written by an agent (Claude Code, claude-opus-4-7).
This commit is contained in:
14
.changeset/integrity-mismatch-fails-by-default.md
Normal file
14
.changeset/integrity-mismatch-fails-by-default.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"@pnpm/core": minor
|
||||
"@pnpm/plugin-commands-installation": minor
|
||||
"@pnpm/worker": patch
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Treat tarball-integrity mismatches against the lockfile as a hard failure by default. Previously, `pnpm install` (non-frozen) would log `ERR_PNPM_TARBALL_INTEGRITY`, silently re-resolve from the registry, and overwrite the locked integrity — which meant a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.
|
||||
|
||||
`pnpm install` now exits with `ERR_PNPM_TARBALL_INTEGRITY` and a hint pointing at the new opt-in flag.
|
||||
|
||||
The only opt-in is **`pnpm install --update-checksums`** — narrowly scoped to refreshing the locked integrity values from what the registry currently serves. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.
|
||||
|
||||
`--force` and `pnpm update` deliberately do **not** bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. `--frozen-lockfile` behavior is unchanged. `--fix-lockfile` keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.
|
||||
@@ -47,6 +47,7 @@ export interface StrictInstallOptions {
|
||||
lockfileOnly: boolean
|
||||
forceFullResolution: boolean
|
||||
fixLockfile: boolean
|
||||
updateChecksums: boolean
|
||||
dedupe: boolean
|
||||
ignoreCompatibilityDb: boolean
|
||||
ignoreDepScripts: boolean
|
||||
@@ -226,6 +227,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
|
||||
},
|
||||
lockfileDir: opts.lockfileDir ?? opts.dir ?? process.cwd(),
|
||||
lockfileOnly: false,
|
||||
updateChecksums: false,
|
||||
nodeVersion: opts.nodeVersion,
|
||||
nodeLinker: 'isolated',
|
||||
overrides: {},
|
||||
|
||||
@@ -449,6 +449,7 @@ export async function mutateModules (
|
||||
const upToDateLockfileMajorVersion = ctx.wantedLockfile.lockfileVersion.toString().startsWith(`${LOCKFILE_MAJOR_VERSION}.`)
|
||||
let needsFullResolution = outdatedLockfileSettings ||
|
||||
opts.fixLockfile ||
|
||||
opts.updateChecksums ||
|
||||
!upToDateLockfileMajorVersion ||
|
||||
opts.forceFullResolution
|
||||
if (needsFullResolution) {
|
||||
@@ -844,21 +845,16 @@ Note that in CI environments, this setting is enabled by default.`,
|
||||
ignoredBuilds,
|
||||
}
|
||||
} catch (error: any) { // eslint-disable-line
|
||||
const isIntegrityError = BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)
|
||||
if (
|
||||
frozenLockfile ||
|
||||
(
|
||||
error.code !== 'ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY' &&
|
||||
!BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)
|
||||
!isIntegrityError
|
||||
) ||
|
||||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile)
|
||||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile) ||
|
||||
(isIntegrityError && !opts.updateChecksums)
|
||||
) throw error
|
||||
if (BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code)) {
|
||||
needsFullResolution = true
|
||||
// Ideally, we would not update but currently there is no other way to redownload the integrity of the package
|
||||
for (const project of projects) {
|
||||
(project as InstallMutationOptions).update = true
|
||||
}
|
||||
}
|
||||
// A broken lockfile may be caused by a badly resolved Git conflict
|
||||
logger.warn({
|
||||
error,
|
||||
@@ -1201,6 +1197,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
|
||||
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
|
||||
force: opts.force,
|
||||
forceFullResolution,
|
||||
updateChecksums: opts.updateChecksums,
|
||||
ignoreScripts: opts.ignoreScripts,
|
||||
hooks: {
|
||||
readPackage: opts.readPackageHook,
|
||||
@@ -1684,19 +1681,16 @@ const installInContext: InstallFunction = async (projects, ctx, opts) => {
|
||||
} catch (error: any) { // eslint-disable-line
|
||||
if (
|
||||
!BROKEN_LOCKFILE_INTEGRITY_ERRORS.has(error.code) ||
|
||||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile)
|
||||
(!ctx.existsNonEmptyWantedLockfile && !ctx.existsCurrentLockfile) ||
|
||||
!opts.updateChecksums
|
||||
) throw error
|
||||
opts.needsFullResolution = true
|
||||
// Ideally, we would not update but currently there is no other way to redownload the integrity of the package
|
||||
for (const project of projects) {
|
||||
(project as InstallMutationOptions).update = true
|
||||
}
|
||||
logger.warn({
|
||||
error,
|
||||
message: error.message,
|
||||
prefix: ctx.lockfileDir,
|
||||
})
|
||||
logger.error(new PnpmError(error.code, 'The lockfile is broken! A full installation will be performed in an attempt to fix it.'))
|
||||
logger.error(new PnpmError(error.code, 'Refreshing the locked integrity from the registry as requested by --update-checksums. A full installation will be performed.'))
|
||||
return _installInContext(projects, ctx, opts)
|
||||
} finally {
|
||||
await opts.storeController.close()
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { sync as writeYamlFile } from 'write-yaml-file'
|
||||
import { testDefaults } from './utils/index.js'
|
||||
|
||||
test('installation breaks if the lockfile contains the wrong checksum', async () => {
|
||||
test('installation fails by default if the lockfile contains a wrong checksum, but --update-checksums recovers', async () => {
|
||||
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' })
|
||||
const project = prepareEmpty()
|
||||
|
||||
@@ -36,11 +36,17 @@ test('installation breaks if the lockfile contains the wrong checksum', async ()
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({ frozenLockfile: true }, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
|
||||
|
||||
await expect(mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({}, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
|
||||
|
||||
await mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({}, { retry: { retries: 0 } }))
|
||||
}, testDefaults({ updateChecksums: true }, { retry: { retries: 0 } }))
|
||||
|
||||
expect(project.readLockfile()).toStrictEqual(correctLockfile)
|
||||
|
||||
@@ -49,16 +55,15 @@ test('installation breaks if the lockfile contains the wrong checksum', async ()
|
||||
|
||||
rimraf('node_modules')
|
||||
|
||||
await mutateModulesInSingleProject({
|
||||
// --force is NOT an opt-in: it should still fail.
|
||||
await expect(mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({ preferFrozenLockfile: false }, { retry: { retries: 0 } }))
|
||||
|
||||
expect(project.readLockfile()).toStrictEqual(correctLockfile)
|
||||
}, testDefaults({ force: true }, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum for/)
|
||||
})
|
||||
|
||||
test('installation breaks if the lockfile contains the wrong checksum and the store is clean', async () => {
|
||||
test('installation fails by default if the lockfile contains the wrong checksum and the store is clean', async () => {
|
||||
await addDistTag({ package: '@pnpm.e2e/dep-of-pkg-with-1-dep', version: '100.0.0', distTag: 'latest' })
|
||||
const project = prepareEmpty()
|
||||
|
||||
@@ -83,37 +88,20 @@ test('installation breaks if the lockfile contains the wrong checksum and the st
|
||||
}, testDefaults({ frozenLockfile: true }, { retry: { retries: 0 } }))
|
||||
).rejects.toThrowError(/Got unexpected checksum/)
|
||||
|
||||
await expect(mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({}, { retry: { retries: 0 } }))).rejects.toThrowError(/Got unexpected checksum/)
|
||||
|
||||
await mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({}, { retry: { retries: 0 } }))
|
||||
}, testDefaults({ updateChecksums: true }, { retry: { retries: 0 } }))
|
||||
|
||||
{
|
||||
const lockfile = project.readLockfile()
|
||||
expect((lockfile.packages['@pnpm.e2e/pkg-with-1-dep@100.0.0'].resolution as TarballResolution).integrity).toBe(correctIntegrity)
|
||||
}
|
||||
|
||||
// Breaking the lockfile again
|
||||
writeYamlFile(WANTED_LOCKFILE, corruptedLockfile, { lineWidth: 1000 })
|
||||
|
||||
rimraf('node_modules')
|
||||
|
||||
const reporter = jest.fn()
|
||||
await mutateModulesInSingleProject({
|
||||
manifest,
|
||||
mutation: 'install',
|
||||
rootDir: process.cwd() as ProjectRootDir,
|
||||
}, testDefaults({ preferFrozenLockfile: false, reporter }, { retry: { retries: 0 } }))
|
||||
|
||||
expect(reporter).toHaveBeenCalledWith(expect.objectContaining({
|
||||
level: 'warn',
|
||||
name: 'pnpm',
|
||||
prefix: process.cwd(),
|
||||
message: expect.stringMatching(/Got unexpected checksum/),
|
||||
}))
|
||||
{
|
||||
const lockfile = project.readLockfile()
|
||||
expect((lockfile.packages['@pnpm.e2e/pkg-with-1-dep@100.0.0'].resolution as TarballResolution).integrity).toBe(correctIntegrity)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,7 +186,7 @@ async function resolveAndFetch (
|
||||
let alias: string | undefined
|
||||
let resolution = options.currentPkg?.resolution as Resolution
|
||||
let pkgId = options.currentPkg?.id
|
||||
const skipResolution = resolution && !options.update
|
||||
const skipResolution = resolution && !options.update && !options.updateChecksums
|
||||
let forceFetch = false
|
||||
let updated = false
|
||||
let resolvedVia: string | undefined
|
||||
@@ -225,6 +225,7 @@ async function resolveAndFetch (
|
||||
projectDir: options.projectDir,
|
||||
workspacePackages: options.workspacePackages,
|
||||
update: options.update,
|
||||
updateChecksums: options.updateChecksums,
|
||||
injectWorkspacePackages: options.injectWorkspacePackages,
|
||||
calcSpecifier: options.calcSpecifier,
|
||||
pinnedVersion: options.pinnedVersion,
|
||||
|
||||
@@ -81,6 +81,7 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
|
||||
...rcOptionsTypes(),
|
||||
...pick(['force'], allTypes),
|
||||
'fix-lockfile': Boolean,
|
||||
'update-checksums': Boolean,
|
||||
'resolution-only': Boolean,
|
||||
recursive: Boolean,
|
||||
})
|
||||
@@ -154,6 +155,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
|
||||
description: 'Fix broken lockfile entries automatically',
|
||||
name: '--fix-lockfile',
|
||||
},
|
||||
{
|
||||
description: 'Refresh integrity checksums recorded in the lockfile from the registry',
|
||||
name: '--update-checksums',
|
||||
},
|
||||
{
|
||||
description: 'Merge lockfiles were generated on git branch',
|
||||
name: '--merge-git-branch-lockfiles',
|
||||
@@ -344,6 +349,7 @@ export type InstallCommandOptions = Pick<Config,
|
||||
original: string[]
|
||||
}
|
||||
fixLockfile?: boolean
|
||||
updateChecksums?: boolean
|
||||
frozenLockfileIfExists?: boolean
|
||||
useBetaCli?: boolean
|
||||
pruneDirectDependencies?: boolean
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface ResolutionContext {
|
||||
defaultTag: string
|
||||
dryRun: boolean
|
||||
forceFullResolution: boolean
|
||||
updateChecksums?: boolean
|
||||
ignoreScripts?: boolean
|
||||
resolvedPkgsById: ResolvedPkgsById
|
||||
resolvePeersFromWorkspaceRoot?: boolean
|
||||
@@ -866,6 +867,7 @@ async function resolveDependenciesOfDependency (
|
||||
proceed: extendedWantedDep.proceed || updateShouldContinue || ctx.updatedSet.size > 0,
|
||||
publishedBy: options.publishedBy,
|
||||
update: update ? options.updateToLatest ? 'latest' : 'compatible' : false,
|
||||
updateChecksums: ctx.updateChecksums,
|
||||
updateDepth,
|
||||
updateRequested,
|
||||
supportedArchitectures: options.supportedArchitectures,
|
||||
@@ -1254,6 +1256,7 @@ interface ResolveDependencyOptions {
|
||||
publishedBy?: Date
|
||||
pickLowestVersion?: boolean
|
||||
update: false | 'compatible' | 'latest'
|
||||
updateChecksums?: boolean
|
||||
updateDepth: number
|
||||
/**
|
||||
* Whether or not an update is requested based on filter conditions (such as
|
||||
@@ -1354,6 +1357,7 @@ async function resolveDependency (
|
||||
trustPolicyExclude: ctx.trustPolicyExclude,
|
||||
trustPolicyIgnoreAfter: ctx.trustPolicyIgnoreAfter,
|
||||
update: options.update,
|
||||
updateChecksums: options.updateChecksums,
|
||||
workspacePackages: ctx.workspacePackages,
|
||||
supportedArchitectures: options.supportedArchitectures,
|
||||
onFetchError: (err: any) => { // eslint-disable-line
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface ResolveDependenciesOptions {
|
||||
engineStrict: boolean
|
||||
force: boolean
|
||||
forceFullResolution: boolean
|
||||
updateChecksums?: boolean
|
||||
ignoreScripts?: boolean
|
||||
hooks: {
|
||||
readPackage?: ReadPackageHook
|
||||
@@ -180,6 +181,7 @@ export async function resolveDependencyTree<T> (
|
||||
engineStrict: opts.engineStrict,
|
||||
force: opts.force,
|
||||
forceFullResolution: opts.forceFullResolution,
|
||||
updateChecksums: opts.updateChecksums,
|
||||
ignoreScripts: opts.ignoreScripts,
|
||||
injectWorkspacePackages: opts.injectWorkspacePackages,
|
||||
linkWorkspacePackagesDepth: opts.linkWorkspacePackagesDepth ?? -1,
|
||||
|
||||
@@ -219,6 +219,7 @@ export type ResolveFromNpmOptions = {
|
||||
preferredVersions?: PreferredVersions
|
||||
preferWorkspacePackages?: boolean
|
||||
update?: false | 'compatible' | 'latest'
|
||||
updateChecksums?: boolean
|
||||
injectWorkspacePackages?: boolean
|
||||
calcSpecifier?: boolean
|
||||
pinnedVersion?: PinnedVersion
|
||||
@@ -275,6 +276,7 @@ async function resolveNpm (
|
||||
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
|
||||
registry,
|
||||
updateToLatest: opts.update === 'latest',
|
||||
updateChecksums: opts.updateChecksums,
|
||||
optional: wantedDependency.optional,
|
||||
})
|
||||
} catch (err: any) { // eslint-disable-line
|
||||
@@ -417,6 +419,7 @@ async function resolveJsr (
|
||||
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
|
||||
registry,
|
||||
updateToLatest: opts.update === 'latest',
|
||||
updateChecksums: opts.updateChecksums,
|
||||
})
|
||||
|
||||
if (pickedPackage == null) {
|
||||
|
||||
@@ -69,6 +69,15 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
|
||||
dryRun: boolean
|
||||
updateToLatest?: boolean
|
||||
optional?: boolean
|
||||
/**
|
||||
* When true, skip the on-disk exact-version cache fast path so a
|
||||
* stale on-disk packument can't satisfy the call without a
|
||||
* conditional registry request. The in-memory cache is left alone:
|
||||
* its entries can only be populated by this install's own fresh
|
||||
* network fetches, so they're authoritative for second-and-onward
|
||||
* lookups within the same install.
|
||||
*/
|
||||
updateChecksums?: boolean
|
||||
}
|
||||
|
||||
const pickPackageFromMetaUsingTimeStrict = pickPackageFromMeta.bind(null, pickVersionByVersionRange)
|
||||
@@ -189,7 +198,7 @@ export async function pickPackage (
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.updateToLatest && spec.type === 'version') {
|
||||
if (!opts.updateToLatest && !opts.updateChecksums && spec.type === 'version') {
|
||||
metaCachedInStore = metaCachedInStore ?? await limit(async () => loadMeta(pkgMirror))
|
||||
// use the cached meta only if it has the required package version
|
||||
// otherwise it is probably out of date
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface ResolveOptions {
|
||||
preferWorkspacePackages?: boolean
|
||||
workspacePackages?: WorkspacePackages
|
||||
update?: false | 'compatible' | 'latest'
|
||||
updateChecksums?: boolean
|
||||
injectWorkspacePackages?: boolean
|
||||
calcSpecifier?: boolean
|
||||
pinnedVersion?: PinnedVersion
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface RequestPackageOptions {
|
||||
sideEffectsCache?: boolean
|
||||
skipFetch?: boolean
|
||||
update?: false | 'compatible' | 'latest'
|
||||
updateChecksums?: boolean
|
||||
workspacePackages?: WorkspacePackages
|
||||
forceResolve?: boolean
|
||||
supportedArchitectures?: SupportedArchitectures
|
||||
|
||||
@@ -122,11 +122,14 @@ export class TarballIntegrityError extends PnpmError {
|
||||
`Got unexpected checksum for "${opts.url}". Wanted "${opts.expected}". Got "${opts.found}".`,
|
||||
{
|
||||
attempts: opts.attempts,
|
||||
hint: `This error may happen when a package is republished to the registry with the same version.
|
||||
In this case, the metadata in the local pnpm cache will contain the old integrity checksum.
|
||||
hint: `The downloaded tarball does not match the integrity recorded in the lockfile. pnpm will not silently overwrite the locked integrity — that would defeat the lockfile's protection if a registry or proxy is serving tampered content.
|
||||
|
||||
If you think that this is the case, then run "pnpm store prune" and rerun the command that failed.
|
||||
"pnpm store prune" will remove your local metadata cache.`,
|
||||
If you trust the new content (legitimate republish, or stale local metadata cache):
|
||||
|
||||
- Run "pnpm store prune" and retry, in case only the metadata cache is out of date.
|
||||
- Run "pnpm install --update-checksums" to refresh the locked integrity from the registry.
|
||||
|
||||
If you did not expect this package to change, treat it as a potential supply-chain issue and verify the new content before re-running with --update-checksums.`,
|
||||
}
|
||||
)
|
||||
this.found = opts.found
|
||||
|
||||
Reference in New Issue
Block a user