From 10bc39152e5f501ace7e41c864528cac584a6a28 Mon Sep 17 00:00:00 2001 From: Ryo Matsukawa <76232929+ryo-manba@users.noreply.github.com> Date: Mon, 10 Nov 2025 07:23:58 +0900 Subject: [PATCH] feat: add support for npm package trust evidence check via a new trustPolicy setting (#10103) close #8889 --------- Co-authored-by: Zoltan Kochan --- .changeset/smooth-llamas-pick.md | 16 + .changeset/violet-spiders-write.md | 10 + config/config/src/Config.ts | 2 + config/config/src/types.ts | 2 + .../plugin-commands-script-runners/src/dlx.ts | 8 +- packages/types/src/config.ts | 2 + .../core/src/install/extendInstallOptions.ts | 2 + pkg-manager/core/src/install/index.ts | 1 + .../package-requester/src/packageRequester.ts | 1 + .../plugin-commands-installation/src/add.ts | 1 + .../src/install.ts | 5 + .../src/resolveDependencies.ts | 3 + .../src/resolveDependencyTree.ts | 3 + pnpm/test/install/misc.ts | 22 + registry/types/src/index.ts | 13 + resolving/npm-resolver/src/index.ts | 15 +- resolving/npm-resolver/src/pickPackage.ts | 1 + .../npm-resolver/src/pickPackageFromMeta.ts | 2 +- resolving/npm-resolver/src/trustChecks.ts | 92 ++++ .../npm-resolver/test/trustChecks.test.ts | 407 ++++++++++++++++++ resolving/resolver-base/src/index.ts | 2 + .../src/createNewStoreController.ts | 9 +- store/store-controller-types/src/index.ts | 2 + 23 files changed, 616 insertions(+), 5 deletions(-) create mode 100644 .changeset/smooth-llamas-pick.md create mode 100644 .changeset/violet-spiders-write.md create mode 100644 resolving/npm-resolver/src/trustChecks.ts create mode 100644 resolving/npm-resolver/test/trustChecks.test.ts diff --git a/.changeset/smooth-llamas-pick.md b/.changeset/smooth-llamas-pick.md new file mode 100644 index 0000000000..12f65ec1ef --- /dev/null +++ b/.changeset/smooth-llamas-pick.md @@ -0,0 +1,16 @@ +--- +"@pnpm/plugin-commands-installation": minor +"@pnpm/plugin-commands-script-runners": minor +"@pnpm/resolve-dependencies": minor +"@pnpm/store-connection-manager": minor +"@pnpm/package-requester": minor +"@pnpm/store-controller-types": minor +"@pnpm/resolver-base": minor +"@pnpm/npm-resolver": minor +"@pnpm/core": minor +"@pnpm/types": minor +"@pnpm/registry.types": minor +"@pnpm/config": minor +--- + +Added a new setting: `trustPolicy`. diff --git a/.changeset/violet-spiders-write.md b/.changeset/violet-spiders-write.md new file mode 100644 index 0000000000..cec3b639c6 --- /dev/null +++ b/.changeset/violet-spiders-write.md @@ -0,0 +1,10 @@ +--- +"pnpm": minor +--- + +Added a new setting: `trustPolicy`. + +When set to `no-downgrade`, pnpm will fail installation if a package’s trust level has decreased compared to previous releases — for example, if it was previously published by a trusted publisher but now only has provenance or no trust evidence. +This helps prevent installing potentially compromised versions of a package. + +Related issue: [#8889](https://github.com/pnpm/pnpm/issues/8889). diff --git a/config/config/src/Config.ts b/config/config/src/Config.ts index 4c9316ccfe..f975c283df 100644 --- a/config/config/src/Config.ts +++ b/config/config/src/Config.ts @@ -6,6 +6,7 @@ import { type ProjectsGraph, type Registries, type SslConfig, + type TrustPolicy, } from '@pnpm/types' import type { Hooks } from '@pnpm/pnpmfile' import { type OptionsFromRootManifest } from './getOptionsFromRootManifest.js' @@ -232,6 +233,7 @@ export interface Config extends OptionsFromRootManifest { minimumReleaseAgeExclude?: string[] fetchWarnTimeoutMs?: number fetchMinSpeedKiBps?: number + trustPolicy?: TrustPolicy } export interface ConfigWithDeprecatedSettings extends Config { diff --git a/config/config/src/types.ts b/config/config/src/types.ts index dbf212c608..2d397310d0 100644 --- a/config/config/src/types.ts +++ b/config/config/src/types.ts @@ -1,4 +1,5 @@ import npmTypes from '@pnpm/npm-conf/lib/types.js' +import { type TrustPolicy } from '@pnpm/types' export const types = Object.assign({ 'auto-install-peers': Boolean, @@ -113,6 +114,7 @@ export const types = Object.assign({ 'strict-dep-builds': Boolean, 'strict-store-pkg-content-check': Boolean, 'strict-peer-dependencies': Boolean, + 'trust-policy': ['off', 'no-downgrade'] satisfies TrustPolicy[], 'use-beta-cli': Boolean, 'use-node-version': String, 'use-running-store-server': Boolean, diff --git a/exec/plugin-commands-script-runners/src/dlx.ts b/exec/plugin-commands-script-runners/src/dlx.ts index 58f0ab4094..adc055b874 100644 --- a/exec/plugin-commands-script-runners/src/dlx.ts +++ b/exec/plugin-commands-script-runners/src/dlx.ts @@ -85,7 +85,13 @@ export async function handler ( [command, ...args]: string[] ): Promise<{ exitCode: number }> { const pkgs = opts.package ?? [command] - const fullMetadata = ((opts.resolutionMode === 'time-based' || Boolean(opts.minimumReleaseAge)) && !opts.registrySupportsTimeField) + const fullMetadata = ( + ( + opts.resolutionMode === 'time-based' || + Boolean(opts.minimumReleaseAge) || + opts.trustPolicy === 'no-downgrade' + ) && !opts.registrySupportsTimeField + ) const { resolve } = createResolver({ ...opts, authConfig: opts.rawConfig, diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 0aae1ef85c..c5182bd1a5 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -1,3 +1,5 @@ export type PackageVersionPolicy = (pkgName: string) => boolean | string[] export type AllowBuild = (pkgName: string, pkgVersion: string) => boolean + +export type TrustPolicy = 'no-downgrade' | 'off' diff --git a/pkg-manager/core/src/install/extendInstallOptions.ts b/pkg-manager/core/src/install/extendInstallOptions.ts index a78f4797b2..3e909563dc 100644 --- a/pkg-manager/core/src/install/extendInstallOptions.ts +++ b/pkg-manager/core/src/install/extendInstallOptions.ts @@ -18,6 +18,7 @@ import { type ReadPackageHook, type Registries, type PrepareExecutionEnv, + type TrustPolicy, } from '@pnpm/types' import { parseOverrides, type VersionOverride } from '@pnpm/parse-overrides' import { pnpmPkgJson } from '../pnpmPkgJson.js' @@ -167,6 +168,7 @@ export interface StrictInstallOptions { ci?: boolean minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] + trustPolicy?: TrustPolicy } export type InstallOptions = diff --git a/pkg-manager/core/src/install/index.ts b/pkg-manager/core/src/install/index.ts index d94168a2ff..59746fe174 100644 --- a/pkg-manager/core/src/install/index.ts +++ b/pkg-manager/core/src/install/index.ts @@ -1177,6 +1177,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => { injectWorkspacePackages: opts.injectWorkspacePackages, minimumReleaseAge: opts.minimumReleaseAge, minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude, + trustPolicy: opts.trustPolicy, } ) if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) { diff --git a/pkg-manager/package-requester/src/packageRequester.ts b/pkg-manager/package-requester/src/packageRequester.ts index feaae31450..9284da53d4 100644 --- a/pkg-manager/package-requester/src/packageRequester.ts +++ b/pkg-manager/package-requester/src/packageRequester.ts @@ -209,6 +209,7 @@ async function resolveAndFetch ( const resolveResult = await ctx.requestsQueue.add(async () => ctx.resolve(wantedDependency, { alwaysTryWorkspacePackages: options.alwaysTryWorkspacePackages, defaultTag: options.defaultTag, + trustPolicy: options.trustPolicy, publishedBy: options.publishedBy, publishedByExclude: options.publishedByExclude, pickLowestVersion: options.pickLowestVersion, diff --git a/pkg-manager/plugin-commands-installation/src/add.ts b/pkg-manager/plugin-commands-installation/src/add.ts index feb9265312..9dc7ecaf2c 100644 --- a/pkg-manager/plugin-commands-installation/src/add.ts +++ b/pkg-manager/plugin-commands-installation/src/add.ts @@ -74,6 +74,7 @@ export function rcOptionsTypes (): Record { 'side-effects-cache', 'store-dir', 'strict-peer-dependencies', + 'trust-policy', 'unsafe-perm', 'offline', 'only', diff --git a/pkg-manager/plugin-commands-installation/src/install.ts b/pkg-manager/plugin-commands-installation/src/install.ts index 57719e8a89..e14b1a6638 100644 --- a/pkg-manager/plugin-commands-installation/src/install.ts +++ b/pkg-manager/plugin-commands-installation/src/install.ts @@ -63,6 +63,7 @@ export function rcOptionsTypes (): Record { 'side-effects-cache', 'store-dir', 'strict-peer-dependencies', + 'trust-policy', 'offline', 'only', 'optional', @@ -203,6 +204,10 @@ by any dependencies, so it is an emulation of a flat node_modules', description: 'Fail on missing or invalid peer dependencies', name: '--strict-peer-dependencies', }, + { + description: "Fail when a package's trust level is downgraded (e.g., from a trusted publisher to provenance only or no trust evidence)", + name: '--trust-policy no-downgrade', + }, { description: 'Starts a store server in the background. The store server will keep running after installation is done. To stop the store server, run `pnpm server stop`', name: '--use-store-server', diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts index 0d78f99454..937ac79e27 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencies.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencies.ts @@ -40,6 +40,7 @@ import { type PkgIdWithPatchHash, type PinnedVersion, type PackageVersionPolicy, + type TrustPolicy, } from '@pnpm/types' import * as dp from '@pnpm/dependency-path' import { getPreferredVersionsFromLockfileAndManifests } from '@pnpm/lockfile.preferred-versions' @@ -180,6 +181,7 @@ export interface ResolutionContext { hoistPeers?: boolean maximumPublishedBy?: Date publishedByExclude?: PackageVersionPolicy + trustPolicy?: TrustPolicy } export interface MissingPeerInfo { @@ -1334,6 +1336,7 @@ async function resolveDependency ( ? ctx.lockfileDir : options.parentPkg.rootDir, skipFetch: ctx.dryRun, + trustPolicy: ctx.trustPolicy, update: options.update, workspacePackages: ctx.workspacePackages, supportedArchitectures: options.supportedArchitectures, diff --git a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts index 718da7a8d2..43a6da991c 100644 --- a/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts +++ b/pkg-manager/resolve-dependencies/src/resolveDependencyTree.ts @@ -18,6 +18,7 @@ import { type Registries, type ProjectRootDir, type PackageVersionPolicy, + type TrustPolicy, } from '@pnpm/types' import { partition, zipObj } from 'ramda' import { type WantedDependency } from './getNonDevWantedDependencies.js' @@ -137,6 +138,7 @@ export interface ResolveDependenciesOptions { peersSuffixMaxLength: number minimumReleaseAge?: number minimumReleaseAgeExclude?: string[] + trustPolicy?: TrustPolicy } export interface ResolveDependencyTreeResult { @@ -199,6 +201,7 @@ export async function resolveDependencyTree ( allPeerDepNames: new Set(), maximumPublishedBy: opts.minimumReleaseAge ? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000) : undefined, publishedByExclude: opts.minimumReleaseAgeExclude ? createPublishedByExclude(opts.minimumReleaseAgeExclude) : undefined, + trustPolicy: opts.trustPolicy, } function createPublishedByExclude (patterns: string[]): PackageVersionPolicy { diff --git a/pnpm/test/install/misc.ts b/pnpm/test/install/misc.ts index 742b715121..d3ffa967a8 100644 --- a/pnpm/test/install/misc.ts +++ b/pnpm/test/install/misc.ts @@ -505,3 +505,25 @@ test('install success even though the url\'s hash contains slash', async () => { ]) expect(result.status).toBe(0) }) + +test('install fails when the trust evidence of a package is downgraded', async () => { + const project = prepare() + const result = execPnpmSync([ + 'add', + '@pnpm/e2e.test-provenance@0.0.5', + '--trust-policy=no-downgrade', + ]) + expect(result.status).toBe(1) + project.hasNot('@pnpm/e2e.test-provenance') +}) + +test('install does not fail when the trust evidence of a package is downgraded but trust-policy is turned off', async () => { + const project = prepare() + const result = execPnpmSync([ + 'add', + '@pnpm/e2e.test-provenance@0.0.5', + '--trust-policy=off', + ]) + expect(result.status).toBe(0) + project.has('@pnpm/e2e.test-provenance') +}) diff --git a/registry/types/src/index.ts b/registry/types/src/index.ts index d695bab36e..4170f98c04 100644 --- a/registry/types/src/index.ts +++ b/registry/types/src/index.ts @@ -25,9 +25,22 @@ export type PackageMetaTime = Record & { export interface PackageInRegistry extends PackageManifest { hasInstallScript?: boolean + _npmUser?: { + name?: string + email?: string + trustedPublisher?: { + id: string + oidcConfigId: string + } + } dist: { integrity?: string shasum: string tarball: string + attestations?: { + provenance?: { + predicateType: string + } + } } } diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index 126ffd55e8..4232c62049 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -20,7 +20,13 @@ import { type WorkspacePackages, type WorkspacePackagesByVersion, } from '@pnpm/resolver-base' -import { type DependencyManifest, type Registries, type PinnedVersion, type PackageVersionPolicy } from '@pnpm/types' +import { + type DependencyManifest, + type PackageVersionPolicy, + type PinnedVersion, + type Registries, + type TrustPolicy, +} from '@pnpm/types' import { LRUCache } from 'lru-cache' import normalize from 'normalize-path' import pMemoize from 'p-memoize' @@ -42,7 +48,8 @@ import { import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, RegistryResponseError } from './fetch.js' import { workspacePrefToNpm } from './workspacePrefToNpm.js' import { whichVersionIsPinned } from './whichVersionIsPinned.js' -import { pickVersionByVersionRange } from './pickPackageFromMeta.js' +import { pickVersionByVersionRange, assertMetaHasTime } from './pickPackageFromMeta.js' +import { failIfTrustDowngraded } from './trustChecks.js' export interface NoMatchingVersionErrorOptions { wantedDependency: WantedDependency @@ -180,6 +187,7 @@ export type ResolveFromNpmOptions = { publishedBy?: Date publishedByExclude?: PackageVersionPolicy pickLowestVersion?: boolean + trustPolicy?: TrustPolicy dryRun?: boolean lockfileDir?: string preferredVersions?: PreferredVersions @@ -298,6 +306,9 @@ async function resolveNpm ( } } throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry }) + } else if (opts.trustPolicy === 'no-downgrade') { + assertMetaHasTime(meta) + failIfTrustDowngraded(meta, pickedPackage.version) } const workspacePkgsMatchingName = workspacePackages?.get(pickedPackage.name) diff --git a/resolving/npm-resolver/src/pickPackage.ts b/resolving/npm-resolver/src/pickPackage.ts index 9fc0cf8480..c306e9fd31 100644 --- a/resolving/npm-resolver/src/pickPackage.ts +++ b/resolving/npm-resolver/src/pickPackage.ts @@ -263,6 +263,7 @@ function clearMeta (pkg: PackageMeta): PackageMeta { 'bundleDependencies', 'bundledDependencies', 'hasInstallScript', + '_npmUser', ], info) } diff --git a/resolving/npm-resolver/src/pickPackageFromMeta.ts b/resolving/npm-resolver/src/pickPackageFromMeta.ts index 93a303842a..754bceddb5 100644 --- a/resolving/npm-resolver/src/pickPackageFromMeta.ts +++ b/resolving/npm-resolver/src/pickPackageFromMeta.ts @@ -93,7 +93,7 @@ export function pickPackageFromMeta ( } } -function assertMetaHasTime (meta: PackageMeta): asserts meta is PackageMetaWithTime { +export function assertMetaHasTime (meta: PackageMeta): asserts meta is PackageMetaWithTime { if (meta.time == null) { throw new PnpmError('MISSING_TIME', `The metadata of ${meta.name} is missing the "time" field`) } diff --git a/resolving/npm-resolver/src/trustChecks.ts b/resolving/npm-resolver/src/trustChecks.ts new file mode 100644 index 0000000000..a4df1d1de1 --- /dev/null +++ b/resolving/npm-resolver/src/trustChecks.ts @@ -0,0 +1,92 @@ +import { PnpmError } from '@pnpm/error' +import { type PackageInRegistry, type PackageMetaWithTime } from '@pnpm/registry.types' + +type TrustEvidence = 'provenance' | 'trustedPublisher' + +const TRUST_RANK = { + trustedPublisher: 2, + provenance: 1, +} as const satisfies Record + +export function failIfTrustDowngraded ( + meta: PackageMetaWithTime, + version: string +): void { + const versionPublishedAt = meta.time[version] + if (!versionPublishedAt) { + throw new PnpmError( + 'TRUST_CHECK_FAIL', + `Missing time for version ${version} of ${meta.name} in metadata` + ) + } + + const versionDate = new Date(versionPublishedAt) + const manifest = meta.versions[version] + if (!manifest) { + throw new PnpmError( + 'TRUST_CHECK_FAIL', + `Missing version object for version ${version} of ${meta.name} in metadata` + ) + } + + const strongestEvidencePriorToRequestedVersion = detectStrongestTrustEvidenceBeforeDate(meta, versionDate) + if (strongestEvidencePriorToRequestedVersion == null) { + return + } + + const currentTrustEvidence = getTrustEvidence(manifest) + if (currentTrustEvidence == null || TRUST_RANK[strongestEvidencePriorToRequestedVersion] > TRUST_RANK[currentTrustEvidence]) { + throw new PnpmError( + 'TRUST_DOWNGRADE', + `High-risk trust downgrade for "${meta.name}@${version}" (possible package takeover)`, + { + hint: `Earlier versions had ${prettyPrintTrustEvidence(strongestEvidencePriorToRequestedVersion)}, ` + + `but this version has ${prettyPrintTrustEvidence(currentTrustEvidence)}. ` + + 'A trust downgrade may indicate a supply chain incident.', + } + ) + } +} + +function prettyPrintTrustEvidence (trustEvidence: TrustEvidence | undefined): string { + switch (trustEvidence) { + case 'trustedPublisher': return 'trusted publisher' + case 'provenance': return 'provenance attestation' + default: return 'no trust evidence' + } +} + +function detectStrongestTrustEvidenceBeforeDate ( + meta: PackageMetaWithTime, + beforeDate: Date +): TrustEvidence | undefined { + let best: TrustEvidence | undefined + + for (const [version, manifest] of Object.entries(meta.versions)) { + const ts = meta.time[version] + if (!ts) continue + + const publishedAt = new Date(ts) + if (!(publishedAt < beforeDate)) continue + + const trustEvidence = getTrustEvidence(manifest) + if (!trustEvidence) continue + + if (trustEvidence === 'trustedPublisher') { + return 'trustedPublisher' + } + best ||= 'provenance' + } + + return best +} + +export function getTrustEvidence (manifest: PackageInRegistry): TrustEvidence | undefined { + if (manifest._npmUser?.trustedPublisher) { + return 'trustedPublisher' + } + if (manifest.dist?.attestations?.provenance) { + return 'provenance' + } + return undefined +} diff --git a/resolving/npm-resolver/test/trustChecks.test.ts b/resolving/npm-resolver/test/trustChecks.test.ts new file mode 100644 index 0000000000..2b35ba2aaa --- /dev/null +++ b/resolving/npm-resolver/test/trustChecks.test.ts @@ -0,0 +1,407 @@ +import { type PackageInRegistry, type PackageMetaWithTime } from '@pnpm/registry.types' +import { getTrustEvidence, failIfTrustDowngraded } from '../src/trustChecks.js' + +describe('getTrustEvidence', () => { + test('returns "trustedPublisher" when _npmUser.trustedPublisher exists', () => { + const manifest: PackageInRegistry = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + } + expect(getTrustEvidence(manifest)).toBe('trustedPublisher') + }) + + test('returns "trustedPublisher" even when attestations.provenance exists', () => { + const manifest: PackageInRegistry = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + } + expect(getTrustEvidence(manifest)).toBe('trustedPublisher') + }) + + test('returns true when provenance exists', () => { + const manifest: PackageInRegistry = { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + } + expect(getTrustEvidence(manifest)).toBe('provenance') + }) + + test('returns undefined when provenance and attestations are undefined', () => { + const manifest: PackageInRegistry = { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + } + expect(getTrustEvidence(manifest)).toBeUndefined() + }) + + test('returns undefined when _npmUser exists but trustedPublisher is undefined', () => { + const manifest: PackageInRegistry = { + name: 'foo', + version: '1.0.0', + _npmUser: { + name: 'test-user', + email: 'user@example.com', + }, + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + } + expect(getTrustEvidence(manifest)).toBeUndefined() + }) +}) + +describe('failIfTrustDowngraded', () => { + test('succeeds when no versions have attestation', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '2.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '2.0.0') + }).not.toThrow() + }) + + test('succeeds for version published before first attested version', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '2.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '1.0.0') + }).not.toThrow() + }) + + test('throws an error when downgrading from provenance to none', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + }, + '3.0.0': { + name: 'foo', + version: '3.0.0', + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/foo/-/foo-3.0.0.tgz', + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '3.0.0') + }).toThrow('High-risk trust downgrade') + }) + + test('throws an error when downgrading from trustedPublisher to provenance', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + }, + }, + '3.0.0': { + name: 'foo', + version: '3.0.0', + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/foo/-/foo-3.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '3.0.0') + }).toThrow('High-risk trust downgrade') + }) + + test('throws an error when downgrading from trustedPublisher to none', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + }, + }, + '3.0.0': { + name: 'foo', + version: '3.0.0', + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/foo/-/foo-3.0.0.tgz', + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '3.0.0') + }).toThrow('High-risk trust downgrade') + }) + + test('succeeds when maintaining same trust level', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '3.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + }, + }, + '3.0.0': { + name: 'foo', + version: '3.0.0', + _npmUser: { + name: 'test-publisher', + email: 'publisher@example.com', + trustedPublisher: { + id: 'test-provider', + oidcConfigId: 'oidc:test-config-123', + }, + }, + dist: { + shasum: 'ghi789', + tarball: 'https://registry.example.com/foo/-/foo-3.0.0.tgz', + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + '2.0.0': '2025-02-01T00:00:00.000Z', + '3.0.0': '2025-03-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '3.0.0') + }).not.toThrow() + }) + + test('throws an error when version time is missing', () => { + const meta: PackageMetaWithTime = { + name: 'foo', + 'dist-tags': { latest: '2.0.0' }, + versions: { + '1.0.0': { + name: 'foo', + version: '1.0.0', + dist: { + shasum: 'abc123', + tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz', + attestations: { + provenance: { + predicateType: 'https://slsa.dev/provenance/v1', + }, + }, + }, + }, + '2.0.0': { + name: 'foo', + version: '2.0.0', + dist: { + shasum: 'def456', + tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz', + }, + }, + }, + time: { + '1.0.0': '2025-01-01T00:00:00.000Z', + }, + } + expect(() => { + failIfTrustDowngraded(meta, '2.0.0') + }).toThrow('Missing time') + }) +}) diff --git a/resolving/resolver-base/src/index.ts b/resolving/resolver-base/src/index.ts index f55b59dd1b..aaff8eff28 100644 --- a/resolving/resolver-base/src/index.ts +++ b/resolving/resolver-base/src/index.ts @@ -4,6 +4,7 @@ import { type PkgResolutionId, type PinnedVersion, type PackageVersionPolicy, + type TrustPolicy, } from '@pnpm/types' export { type PkgResolutionId } @@ -107,6 +108,7 @@ export interface PreferredVersions { export interface ResolveOptions { alwaysTryWorkspacePackages?: boolean + trustPolicy?: TrustPolicy defaultTag?: string pickLowestVersion?: boolean publishedBy?: Date diff --git a/store/store-connection-manager/src/createNewStoreController.ts b/store/store-connection-manager/src/createNewStoreController.ts index 9a53a2a4eb..8366776596 100644 --- a/store/store-connection-manager/src/createNewStoreController.ts +++ b/store/store-connection-manager/src/createNewStoreController.ts @@ -43,6 +43,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick { - const fullMetadata = opts.fetchFullMetadata ?? ((opts.resolutionMode === 'time-based' || Boolean(opts.minimumReleaseAge)) && !opts.registrySupportsTimeField) + const fullMetadata = opts.fetchFullMetadata ?? ( + ( + opts.resolutionMode === 'time-based' || + Boolean(opts.minimumReleaseAge) || + opts.trustPolicy === 'no-downgrade' + ) && !opts.registrySupportsTimeField + ) const { resolve, fetchers, clearResolutionCache } = createClient({ customFetchers: opts.hooks?.fetchers, userConfig: opts.userConfig, diff --git a/store/store-controller-types/src/index.ts b/store/store-controller-types/src/index.ts index 789df6eeab..346d06ca57 100644 --- a/store/store-controller-types/src/index.ts +++ b/store/store-controller-types/src/index.ts @@ -19,6 +19,7 @@ import { type PackageManifest, type PinnedVersion, type PackageVersionPolicy, + type TrustPolicy, } from '@pnpm/types' export type { PackageFileInfo, PackageFilesResponse, ImportPackageFunction, ImportPackageFunctionAsync } @@ -136,6 +137,7 @@ export interface RequestPackageOptions { injectWorkspacePackages?: boolean calcSpecifier?: boolean pinnedVersion?: PinnedVersion + trustPolicy?: TrustPolicy } export type BundledManifestFunction = () => Promise