Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/better-parents-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@pnpm/resolve-dependencies": minor
"@pnpm/core": minor
"@pnpm/config": minor
"pnpm": minor
---

Added a new setting `blockExoticSubdeps` that prevents the resolution of exotic protocols in transitive dependencies.

When set to `true`, direct dependencies (those listed in your root `package.json`) may still use exotic sources, but all transitive dependencies must be resolved from a trusted source. Trusted sources include the configured registry, local file paths, workspace links, trusted GitHub repositories (node, bun, deno), and custom resolvers.

This helps to secure the dependency supply chain. Packages from trusted sources are considered safer, as they are typically subject to more reliable verification and scanning for malware and vulnerabilities.

**Exotic sources** are dependency locations that bypass the usual trusted resolution process. These protocols are specifically targeted and blocked: Git repositories (`git+ssh://...`) and direct URL links to tarballs (`https://.../package.tgz`).

Related PR: [#10265](https://github.com/pnpm/pnpm/pull/10265).
1 change: 1 addition & 0 deletions config/config/src/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export interface Config extends OptionsFromRootManifest {
ignoreWorkspaceCycles?: boolean
disallowWorkspaceCycles?: boolean
packGzipLevel?: number
blockExoticSubdeps?: boolean

registries: Registries
sslConfigs: Record<string, SslConfig>
Expand Down
1 change: 1 addition & 0 deletions config/config/src/configFileKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const pnpmConfigFileKeys = [
'prefer-frozen-lockfile',
'prefer-offline',
'prefer-symlinked-executables',
'block-exotic-subdeps',
'reporter',
'resolution-mode',
'store-dir',
Expand Down
1 change: 1 addition & 0 deletions config/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export async function getConfig (opts: {
'public-hoist-pattern': [],
'recursive-install': true,
registry: npmDefaults.registry,
'block-exotic-subdeps': false,
'resolution-mode': 'highest',
'resolve-peers-from-workspace-root': true,
'save-peer': false,
Expand Down
1 change: 1 addition & 0 deletions config/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const pnpmTypes = {
'public-hoist-pattern': Array,
'publish-branch': String,
'recursive-install': Boolean,
'block-exotic-subdeps': Boolean,
reporter: String,
'resolution-mode': ['highest', 'time-based', 'lowest-direct'],
'resolve-peers-from-workspace-root': Boolean,
Expand Down
2 changes: 2 additions & 0 deletions pkg-manager/core/src/install/extendInstallOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export interface StrictInstallOptions {
minimumReleaseAgeExclude?: string[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
blockExoticSubdeps?: boolean
}

export type InstallOptions =
Expand Down Expand Up @@ -272,6 +273,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
excludeLinksFromLockfile: false,
virtualStoreDirMaxLength: 120,
peersSuffixMaxLength: 1000,
blockExoticSubdeps: false,
} as StrictInstallOptions
}

Expand Down
1 change: 1 addition & 0 deletions pkg-manager/core/src/install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude,
blockExoticSubdeps: opts.blockExoticSubdeps,
}
)
if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) {
Expand Down
57 changes: 57 additions & 0 deletions pkg-manager/core/test/install/blockExoticSubdeps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { prepareEmpty } from '@pnpm/prepare'
import { addDependenciesToPackage } from '@pnpm/core'
import { testDefaults } from '../utils/index.js'

test('blockExoticSubdeps disallows git dependencies in subdependencies', async () => {
prepareEmpty()

await expect(addDependenciesToPackage({},
// @pnpm.e2e/has-aliased-git-dependency has a git-hosted subdependency (say-hi from github:zkochan/hi)
['@pnpm.e2e/has-aliased-git-dependency'],
testDefaults({ blockExoticSubdeps: true, fastUnpack: false })
)).rejects.toThrow('is not allowed in subdependencies when blockExoticSubdeps is enabled')
})

test('blockExoticSubdeps allows git dependencies in direct dependencies', async () => {
const project = prepareEmpty()

// Direct git dependency should be allowed even when blockExoticSubdeps is enabled
const { updatedManifest: manifest } = await addDependenciesToPackage(
{},
['kevva/is-negative#1.0.0'],
testDefaults({ blockExoticSubdeps: true })
)

project.has('is-negative')

expect(manifest.dependencies).toStrictEqual({
'is-negative': 'github:kevva/is-negative#1.0.0',
})
})

test('blockExoticSubdeps allows registry dependencies in subdependencies', async () => {
const project = prepareEmpty()

// A package with only registry subdependencies should work fine
await addDependenciesToPackage(
{},
['[email protected]'],
testDefaults({ blockExoticSubdeps: true })
)

project.has('is-positive')
})

test('blockExoticSubdeps: false (default) allows git dependencies in subdependencies', async () => {
const project = prepareEmpty()

// Without blockExoticSubdeps (or with it set to false), git subdeps should be allowed
await addDependenciesToPackage(
{},
['@pnpm.e2e/has-aliased-git-dependency'],
testDefaults({ blockExoticSubdeps: false, fastUnpack: false })
)

const m = project.requireModule('@pnpm.e2e/has-aliased-git-dependency')
expect(m).toBe('Hi')
})
31 changes: 31 additions & 0 deletions pkg-manager/resolve-dependencies/src/resolveDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export interface ResolutionContext {
publishedByExclude?: PackageVersionPolicy
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
blockExoticSubdeps?: boolean
}

export interface MissingPeerInfo {
Expand Down Expand Up @@ -1385,6 +1386,21 @@ async function resolveDependency (
},
})

// Check if exotic dependencies are disallowed in subdependencies
if (
ctx.blockExoticSubdeps &&
options.currentDepth > 0 &&
!isNonExoticDep(pkgResponse.body.resolvedVia)
) {
const error = new PnpmError(
'EXOTIC_SUBDEP',
`Exotic dependency "${wantedDependency.alias ?? wantedDependency.bareSpecifier}" (resolved via ${pkgResponse.body.resolvedVia}) is not allowed in subdependencies when blockExoticSubdeps is enabled`
)
error.prefix = options.prefix
error.pkgsStack = getPkgsInfoFromIds(options.parentIds, ctx.resolvedPkgsById)
throw error
}

if (ctx.allPreferredVersions && pkgResponse.body.manifest?.version) {
if (!ctx.allPreferredVersions[pkgResponse.body.manifest.name]) {
ctx.allPreferredVersions[pkgResponse.body.manifest.name] = {}
Expand Down Expand Up @@ -1779,3 +1795,18 @@ function getCatalogExistingVersionFromSnapshot (
? existingCatalogResolution.version
: undefined
}

const NON_EXOTIC_RESOLVED_VIA = new Set([
'custom-resolver',
'github.com/denoland/deno',
'github.com/oven-sh/bun',
'jsr-registry',
'local-filesystem',
'nodejs.org',
'npm-registry',
'workspace',
])

function isNonExoticDep (resolvedVia: string | undefined): boolean {
return resolvedVia != null && NON_EXOTIC_RESOLVED_VIA.has(resolvedVia)
}
2 changes: 2 additions & 0 deletions pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export interface ResolveDependenciesOptions {
minimumReleaseAgeExclude?: string[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
blockExoticSubdeps?: boolean
}

export interface ResolveDependencyTreeResult {
Expand Down Expand Up @@ -208,6 +209,7 @@ export async function resolveDependencyTree<T> (
publishedByExclude: opts.minimumReleaseAgeExclude ? createPackageVersionPolicyByExclude(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude') : undefined,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude ? createPackageVersionPolicyByExclude(opts.trustPolicyExclude, 'trustPolicyExclude') : undefined,
blockExoticSubdeps: opts.blockExoticSubdeps,
}

function createPackageVersionPolicyByExclude (patterns: string[], key: string): PackageVersionPolicy {
Expand Down
Loading