feat: support blockExoticSubdeps option to disallow non-trusted dep sources in subdeps (#10265)

* feat(core): add onlyRegistryDependencies option to disallow non-registry subdependencies

* fix: onlyRegistryDependencies=>registrySubdepsOnly

* fix: allow resolution from custom resolver

* fix: add registry-subdeps-only to types

* docs: update changesets

* refactor: registry-only

* refactor: registrySubdepsOnly=>blockExoticSubdeps

* fix: trust runtime deps

* refactor: remove comment

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Oren
2025-12-10 13:14:16 +02:00
committed by Zoltan Kochan
parent 7d0e7e855e
commit 73cc63504d
9 changed files with 112 additions and 0 deletions

View File

@@ -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).

View File

@@ -190,6 +190,7 @@ export interface Config extends OptionsFromRootManifest {
ignoreWorkspaceCycles?: boolean
disallowWorkspaceCycles?: boolean
packGzipLevel?: number
blockExoticSubdeps?: boolean
registries: Registries
sslConfigs: Record<string, SslConfig>

View File

@@ -181,6 +181,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,

View File

@@ -91,6 +91,7 @@ export const types = Object.assign({
'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,

View File

@@ -170,6 +170,7 @@ export interface StrictInstallOptions {
minimumReleaseAgeExclude?: string[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
blockExoticSubdeps?: boolean
}
export type InstallOptions =
@@ -269,6 +270,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
excludeLinksFromLockfile: false,
virtualStoreDirMaxLength: 120,
peersSuffixMaxLength: 1000,
blockExoticSubdeps: false,
} as StrictInstallOptions
}

View File

@@ -1228,6 +1228,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) {

View File

@@ -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(
{},
['is-positive@1.0.0'],
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')
})

View File

@@ -187,6 +187,7 @@ export interface ResolutionContext {
publishedByExclude?: PackageVersionPolicy
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
blockExoticSubdeps?: boolean
}
export interface MissingPeerInfo {
@@ -1387,6 +1388,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] = {}
@@ -1781,3 +1797,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)
}

View File

@@ -143,6 +143,7 @@ export interface ResolveDependenciesOptions {
minimumReleaseAgeExclude?: string[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
blockExoticSubdeps?: boolean
}
export interface ResolveDependencyTreeResult {
@@ -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 {