mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 17:52:44 -04:00
* fix(security): harden build-approval artifact identities on v10
Port of pnpm/pnpm#12294 (commit bf1b731ee6) to release/10.
Package-name entries in onlyBuiltDependencies (and allowBuilds) no longer
approve lifecycle scripts for artifacts whose identity a name cannot pin:
git, git-hosted tarball, direct tarball, and local directory dependencies.
To approve such an artifact explicitly, use its peer-suffix-free lockfile
depPath as the key — the GIT_DEP_PREPARE_NOT_ALLOWED hint, pnpm
ignored-builds, and pnpm approve-builds print exactly that key.
- AllowBuild policy functions identify packages by DepPath instead of
caller-supplied name/version. Identity trust is derived from the depPath
shape: a registry-style depPath (name@semver) is a trusted identity.
- The trust is sound because lockfile entries are structurally checked
wherever they are materialized into fetchable resolutions
(pkgSnapshotToResolution) and before rebuild runs scripts: a
registry-style key backed by a git, directory, or git-hosted tarball
resolution is rejected with ERR_PNPM_RESOLUTION_SHAPE_MISMATCH.
- preparePackage requires a pkgResolutionId and gates on the synthesized
name@<resolution id> depPath; scp-style git URLs are normalized to
ssh:// form in resolution ids and the git fetcher reuses
createGitHostedPkgId from the resolver.
- isGitHostedTarballUrl matches case-insensitively and is shared from
@pnpm/lockfile.utils; new removePeersSuffix() in @pnpm/dependency-path
and allowBuildKeyFromIgnoredBuild() in @pnpm/builder.policy.
- pnpm rebuild and approve-builds accept depPath specs for selecting and
approving artifact builds; installs rebuild ignored builds approved by
depPath keys.
- shell-quote is overridden to 1.8.4 (GHSA-w7jw-789q-3m8p /
CVE-2026-9277).
Differences from main: v10 has no lockfile resolution verifier, so the
structural pass lives in pkgSnapshotToResolution and the rebuild loop; the
AllowBuild policy keeps v10's boolean return; the revoked-approval
detection and global-virtual-store hash changes have no v10 counterpart.
* test: serve the direct-tarball fixture from an off-registry origin
The registry mock binds only localhost, so a hard-coded 127.0.0.1 URL is
refused in CI, and a tarball URL on the registry's own origin resolves as
a registry package, which defeats the direct-tarball identity the test
needs. An in-test HTTP server redirecting to the mock provides a separate
origin whose binding the test controls.
* test: approve the git-protocol licenses fixture by its depPath
A git-hosted artifact has an untrusted package identity, so the
ajv-keywords build has to be approved by its depPath. Pin the fixture to
the commit the depPath names.
146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
import { assertRegistryShapedResolution, isGitHostedTarballUrl } from '@pnpm/lockfile.utils'
|
|
import { type PackageSnapshot } from '@pnpm/lockfile.types'
|
|
|
|
function snapshot (resolution: unknown): PackageSnapshot {
|
|
return { resolution } as PackageSnapshot
|
|
}
|
|
|
|
const MISMATCH = expect.objectContaining({ code: 'ERR_PNPM_RESOLUTION_SHAPE_MISMATCH' })
|
|
|
|
test('rejects a registry-style depPath backed by a git resolution', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
type: 'git', repo: 'https://example.com/foo.git', commit: 'abc123',
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('rejects a registry-style depPath backed by a git-hosted tarball resolution', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://codeload.github.com/org/foo/tar.gz/abc123', gitHosted: true,
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('rejects a registry-style depPath backed by a directory resolution', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
type: 'directory', directory: '../foo',
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('accepts registry-style depPaths with registry and all-registry variations resolutions', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-a',
|
|
}))
|
|
}).not.toThrow()
|
|
expect(() => {
|
|
assertRegistryShapedResolution('bar@1.0.0', snapshot({
|
|
type: 'variations',
|
|
variants: [
|
|
{ targets: [{ os: 'darwin' }], resolution: { integrity: 'sha512-a' } },
|
|
{ targets: [{ os: 'linux' }], resolution: { integrity: 'sha512-b' } },
|
|
],
|
|
}))
|
|
}).not.toThrow()
|
|
})
|
|
|
|
test('rejects a registry-style depPath whose variations resolution hides a git variant', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('bar@1.0.0', snapshot({
|
|
type: 'variations',
|
|
variants: [
|
|
{ targets: [{ os: 'darwin' }], resolution: { integrity: 'sha512-a' } },
|
|
{ targets: [{ os: 'linux' }], resolution: { type: 'git', repo: 'https://example.com/bar.git', commit: 'abc123' } },
|
|
],
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('does not flag artifact depPaths with non-registry resolutions', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@git+https://example.com/foo.git#abc123', snapshot({
|
|
type: 'git', repo: 'https://example.com/foo.git', commit: 'abc123',
|
|
}))
|
|
}).not.toThrow()
|
|
expect(() => {
|
|
assertRegistryShapedResolution('bar@https://example.com/bar.tgz', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://example.com/bar.tgz',
|
|
}))
|
|
}).not.toThrow()
|
|
})
|
|
|
|
test('rejects a registry-style depPath whose git-host tarball clears the gitHosted flag', () => {
|
|
// A tampered lockfile sets a non-truthy gitHosted on a codeload URL to
|
|
// dodge a flag-only check. The URL itself must still flag it.
|
|
for (const gitHosted of [false, 'true', 'false', 0, 1]) {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://codeload.github.com/org/foo/tar.gz/abc123', gitHosted,
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
}
|
|
})
|
|
|
|
test('rejects a registry-style depPath with a non-boolean gitHosted flag', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz', gitHosted: 'true',
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('rejects a registry-style depPath backed by a non-http(s) tarball URL', () => {
|
|
for (const tarball of ['file:///tmp/evil.tgz', 'ftp://example.com/evil.tgz']) {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball,
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
}
|
|
})
|
|
|
|
test('rejects a registry-style depPath whose tarball escapes the registry host without a scheme', () => {
|
|
for (const tarball of ['//evil.example.com/foo.tgz', '/\\evil.example.com/foo.tgz', '\\\\evil.example.com\\foo.tgz']) {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball,
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
}
|
|
})
|
|
|
|
test('accepts a registry-style depPath whose tarball is an http(s) or registry-relative URL', () => {
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://registry.npmjs.org/foo/-/foo-1.0.0.tgz',
|
|
}))
|
|
}).not.toThrow()
|
|
// Lockfiles written by older pnpm versions store registry-relative tarball
|
|
// paths, which resolve against the configured registry.
|
|
expect(() => {
|
|
assertRegistryShapedResolution('@mycompany/mypackage@2.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: '@mycompany/mypackage/-/@mycompany/mypackage-2.0.0.tgz',
|
|
}))
|
|
}).not.toThrow()
|
|
})
|
|
|
|
test('rejects a registry-style depPath whose git-host tarball varies the host casing', () => {
|
|
// Hostnames are case-insensitive; an upper-case codeload host paired with
|
|
// gitHosted: false must not pass as registry-shaped.
|
|
expect(() => {
|
|
assertRegistryShapedResolution('foo@1.0.0', snapshot({
|
|
integrity: 'sha512-deadbeef', tarball: 'https://CODELOAD.GITHUB.COM/org/foo/tar.gz/abc123', gitHosted: false,
|
|
}))
|
|
}).toThrow(MISMATCH)
|
|
})
|
|
|
|
test('isGitHostedTarballUrl() matches the known git hosts regardless of casing', () => {
|
|
expect(isGitHostedTarballUrl('https://codeload.github.com/org/foo/tar.gz/abc123')).toBe(true)
|
|
expect(isGitHostedTarballUrl('https://CODELOAD.GITHUB.COM/org/foo/TAR.GZ/abc123')).toBe(true)
|
|
expect(isGitHostedTarballUrl('https://registry.npmjs.org/foo/-/foo-1.0.0.tgz')).toBe(false)
|
|
})
|