Files
pnpm/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts
mehmet turac 0721d64188 fix: require provenance for trusted publisher evidence (#11911)
* fix: require provenance for trusted publisher evidence

* test: align provenance fixtures with registry types

* chore: include pnpm CLI in changeset

The repo guideline requires every changeset that touches a published
package to list the pnpm CLI explicitly so the fix appears in the CLI's
release notes.

* fix(resolving-npm-resolver): require provenance for trusted publisher evidence

Ports pnpm's fea5fd41da: `get_trust_evidence` now only returns
`TrustedPublisher` when the version carries both
`_npmUser.trustedPublisher` *and* `dist.attestations.provenance`.
Without the attestation, the publisher flag is metadata a staged
publish could mint, so it can't be ranked above plain provenance.

Refs #11887.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-05-25 12:52:35 +02:00

246 lines
8.9 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from '@jest/globals'
import { createFetchFromRegistry } from '@pnpm/network.fetch'
import { createNpmResolutionVerifier } from '@pnpm/resolving.npm-resolver'
import type { Resolution } from '@pnpm/resolving.resolver-base'
import type { Registries } from '@pnpm/types'
import { temporaryDirectory } from 'tempy'
import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js'
const registries: Registries = {
default: 'https://registry.npmjs.org/',
}
const fetchFromRegistry = createFetchFromRegistry({})
const getAuthHeaderValueByURI = (): undefined => undefined
function makeVerifierOpts (overrides: Partial<Parameters<typeof createNpmResolutionVerifier>[0]> = {}): Parameters<typeof createNpmResolutionVerifier>[0] {
return {
registries,
fetchOpts: {
fetch: fetchFromRegistry,
retry: { retries: 0 },
timeout: 60_000,
fetchWarnTimeoutMs: 10_000,
},
getAuthHeaderValueByURI,
cacheDir: temporaryDirectory(),
now: Date.UTC(2026, 0, 1),
...overrides,
}
}
function makeTarballResolution (name: string, version: string): Resolution {
return {
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
tarball: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`,
} as unknown as Resolution
}
afterEach(async () => {
await teardownMockAgent()
})
beforeEach(async () => {
await setupMockAgent()
})
test('createNpmResolutionVerifier() returns undefined when no policy is active', () => {
expect(createNpmResolutionVerifier(makeVerifierOpts())).toBeUndefined()
})
test('createNpmResolutionVerifier() flags a trustedPublisher → provenance downgrade', async () => {
// 0.0.1 was published by a trustedPublisher with provenance → rank 2.
// 0.0.2 is provenance-only (rank 1, weaker) → downgrade vs 0.0.1.
// This is exactly the case the resolver-time trustChecks unit tests
// cover, but routed through the lockfile verifier. The verifier must
// not pass simply because 0.0.2 has *some* attestation.
const meta = {
name: 'demo',
'dist-tags': { latest: '0.0.2' },
versions: {
'0.0.1': {
name: 'demo',
version: '0.0.1',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz',
shasum: 'aa',
attestations: { provenance: { predicateType: 'https://example.org/p' } },
},
_npmUser: { trustedPublisher: { id: 'gha', oidcConfigId: 'cfg' } },
},
'0.0.2': {
name: 'demo',
version: '0.0.2',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
shasum: 'bb',
attestations: { provenance: { predicateType: 'https://example.org/p' } },
},
},
},
time: {
'0.0.1': '2025-01-01T00:00:00.000Z',
'0.0.2': '2025-06-01T00:00:00.000Z',
},
modified: '2025-06-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
}))!
expect(verifier).toBeDefined()
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
expect(result).toMatchObject({
ok: false,
code: 'TRUST_DOWNGRADE',
})
})
test('createNpmResolutionVerifier() passes a same-evidence-level version', async () => {
// 0.0.1 had provenance, 0.0.2 still has provenance → no downgrade.
// Verifies the trust check isn't over-aggressive for stable evidence.
const meta = {
name: 'demo',
'dist-tags': { latest: '0.0.2' },
versions: {
'0.0.1': {
name: 'demo',
version: '0.0.1',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz',
shasum: 'aa',
attestations: { provenance: { predicateType: 'https://example.org/p1' } },
},
},
'0.0.2': {
name: 'demo',
version: '0.0.2',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
shasum: 'bb',
attestations: { provenance: { predicateType: 'https://example.org/p2' } },
},
},
},
time: {
'0.0.1': '2025-01-01T00:00:00.000Z',
'0.0.2': '2025-06-01T00:00:00.000Z',
},
modified: '2025-06-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
}))!
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
expect(result).toEqual({ ok: true })
})
test('createNpmResolutionVerifier() abbreviated shortcut requires the pinned version to be in metadata', async () => {
// Package's `modified` is well before the cutoff (default 1-day window
// means modified=2010 is fine), but `0.0.2` was unpublished and is no
// longer in `versions`. The shortcut must NOT return the package-level
// `modified` for that version — that would be a fail-open on a
// missing pin. The verifier should fall through to the deeper layers
// and end up reporting a violation (no source could surface the time).
const abbreviatedMeta = {
name: 'unpublished-pkg',
'dist-tags': {},
versions: {
'0.0.1': {
name: 'unpublished-pkg',
version: '0.0.1',
dist: { tarball: 'https://registry.npmjs.org/unpublished-pkg/-/unpublished-pkg-0.0.1.tgz', shasum: 'aa' },
},
},
modified: '2010-01-01T00:00:00.000Z',
}
const fullMeta = {
...abbreviatedMeta,
time: { '0.0.1': '2010-01-01T00:00:00.000Z' },
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/unpublished-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
pool.intercept({ path: '/-/npm/v1/attestations/unpublished-pkg@0.0.2', method: 'GET' }).reply(404, {}).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
minimumReleaseAge: 1440, // 1 day
}))!
const result = await verifier.verify(
makeTarballResolution('unpublished-pkg', '0.0.2'),
{ name: 'unpublished-pkg', version: '0.0.2' }
)
expect(result).toMatchObject({
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
// Sanity check: the unrelated full meta isn't used here because the
// abbreviated shortcut won't fire (version missing), and the deeper
// layers also have no entry for 0.0.2. Keep `fullMeta` in scope so
// future test additions can wire it in without redefining.
expect(fullMeta.versions['0.0.1'].version).toBe('0.0.1')
})
test('createNpmResolutionVerifier() ignoreMissingTimeField passes the entry when no source surfaces a timestamp', async () => {
// Mirrors the resolver-side `pickMatchingVersionFinal` warn-and-skip
// behavior: when the registry strips the per-version `time` field and
// the user has opted into `minimumReleaseAgeIgnoreMissingTime`, the
// verifier shouldn't be stricter than fresh resolution.
const abbreviatedMeta = {
name: 'time-free-pkg',
'dist-tags': {},
versions: {
'1.0.0': {
name: 'time-free-pkg',
version: '1.0.0',
dist: { tarball: 'https://registry.npmjs.org/time-free-pkg/-/time-free-pkg-1.0.0.tgz', shasum: 'aa' },
},
},
modified: '2010-01-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
// Full meta also lacks `time`, so no layer surfaces a publish timestamp.
pool.intercept({ path: '/time-free-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
pool.intercept({ path: '/-/npm/v1/attestations/time-free-pkg@1.0.0', method: 'GET' }).reply(404, {}).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
minimumReleaseAge: 1440,
ignoreMissingTimeField: true,
}))!
const result = await verifier.verify(
makeTarballResolution('time-free-pkg', '1.0.0'),
{ name: 'time-free-pkg', version: '1.0.0' }
)
expect(result).toEqual({ ok: true })
})
test('createNpmResolutionVerifier() canTrustPastCheck rejects when the trust-exclude list shrinks', () => {
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo'],
}))!
// Same policy → trust.
expect(verifier.canTrustPastCheck({
minimumReleaseAge: 0,
minimumReleaseAgeExclude: [],
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo'],
trustPolicyIgnoreAfter: null,
})).toBe(true)
// Cached run had a wider exclude list (today's is stricter) → invalidate.
expect(verifier.canTrustPastCheck({
minimumReleaseAge: 0,
minimumReleaseAgeExclude: [],
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo', 'bar'],
trustPolicyIgnoreAfter: null,
})).toBe(false)
})