mirror of
https://github.com/pnpm/pnpm.git
synced 2026-01-15 02:18:31 -05:00
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:
16
.changeset/better-parents-tell.md
Normal file
16
.changeset/better-parents-tell.md
Normal 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).
|
||||
@@ -190,6 +190,7 @@ export interface Config extends OptionsFromRootManifest {
|
||||
ignoreWorkspaceCycles?: boolean
|
||||
disallowWorkspaceCycles?: boolean
|
||||
packGzipLevel?: number
|
||||
blockExoticSubdeps?: boolean
|
||||
|
||||
registries: Registries
|
||||
sslConfigs: Record<string, SslConfig>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
pkg-manager/core/test/install/blockExoticSubdeps.ts
Normal file
57
pkg-manager/core/test/install/blockExoticSubdeps.ts
Normal 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')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user