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
30 changes: 13 additions & 17 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getTrustLevel, getTrustStatus } from 'packumeta'
import { getTrustStatus } from 'packumeta'
import { normalizeLicense } from '#shared/utils/npm'
import { detectSecurityDowngradeForVersion } from '~/utils/publish-security'

/** Number of recent versions to include in initial payload */
const RECENT_VERSIONS_COUNT = 5
Expand Down Expand Up @@ -37,21 +38,16 @@ export function transformPackument(
includedVersions.add(requestedVersion)
}

// Build security metadata for all versions, but only include in payload
// when the package has mixed trust levels (i.e. a downgrade could exist)
Comment on lines -40 to -41

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was "only" when a package has mixed trust, but in practice nearly every popular package will meet this condition.

const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustStatus = getTrustStatus(metadata)
return {
version,
time: pkg.time[version],
trustStatus,
deprecated: metadata.deprecated,
}
})

const trustLevels = new Set(securityVersionEntries.map(v => getTrustLevel(v.trustStatus)))
const hasMixedTrust = trustLevels.size > 1
const securityVersions = hasMixedTrust ? securityVersionEntries : undefined
// Computed from the full version history so only the small result is serialized.
const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => ({
version,
time: pkg.time[version],
trustStatus: getTrustStatus(metadata),
deprecated: metadata.deprecated,
}))
const publishSecurityDowngrade = requestedVersion
? detectSecurityDowngradeForVersion(securityVersionEntries, requestedVersion)
: null

// Build filtered versions object with install scripts info per version
const filteredVersions: Record<string, SlimVersion> = {}
Expand Down Expand Up @@ -122,7 +118,7 @@ export function transformPackument(
...(storybook && { storybook }),
'requestedVersion': versionData,
'versions': filteredVersions,
'securityVersions': securityVersions,
'publishSecurityDowngrade': publishSecurityDowngrade,
}
}

Expand Down
17 changes: 1 addition & 16 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -246,17 +246,6 @@ if (isSpaFallback.value || isHydratingWithServerContent.value) {
}

const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
if (!pkg.value) return []
if (pkg.value.securityVersions?.length) return pkg.value.securityVersions

return Object.entries(pkg.value.versions).map(([version, metadata]) => ({
version,
time: pkg.value?.time?.[version],
trustStatus: metadata.trustStatus,
deprecated: metadata.deprecated,
}))
})

// Process package description
const pkgDescription = useMarkdown(() => ({
Expand Down Expand Up @@ -329,11 +318,7 @@ const deprecationNoticeMessage = useMarkdown(() => ({
text: deprecationNotice.value?.message ?? '',
}))

const publishSecurityDowngrade = computed(() => {
const currentVersion = displayVersion.value?.version
if (!currentVersion) return null
return detectPublishSecurityDowngradeForVersion(versionSecurityMetadata.value, currentVersion)
})
const publishSecurityDowngrade = computed(() => pkg.value?.publishSecurityDowngrade ?? null)

const installVersionOverride = computed(
() => publishSecurityDowngrade.value?.trustedVersion ?? null,
Expand Down
13 changes: 3 additions & 10 deletions app/utils/publish-security.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import { getTrustLevel, getTrustLevelName, type TrustLevelName } from 'packumeta'
import { compare, getMajor } from 'verkit'
import type { PublishSecurityDowngrade } from '#shared/types/npm-registry'

export interface PublishSecurityDowngrade {
downgradedVersion: string
downgradedPublishedAt?: string
downgradedTrustLevel: TrustLevelName
/** Recommended trusted version within the same major, if one exists */
trustedVersion?: string
trustedPublishedAt?: string
trustedTrustLevel: TrustLevelName
}
export type { PublishSecurityDowngrade }

type VersionWithIndex = PackageVersionInfo & {
index: number
Expand Down Expand Up @@ -48,7 +41,7 @@ function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
* A version is considered downgraded when it has no provenance and
* there exists an older trusted release.
*/
export function detectPublishSecurityDowngradeForVersion(
export function detectSecurityDowngradeForVersion(
versions: PackageVersionInfo[],
viewedVersion: string,
): PublishSecurityDowngrade | null {
Expand Down
22 changes: 19 additions & 3 deletions shared/types/npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
Contact,
} from '@npm/types'
import type { ReadmeResponse } from './readme'
import type { TrustStatus } from 'packumeta'
import type { TrustLevelName, TrustStatus } from 'packumeta'

// Re-export official npm types for packument/manifest
export type { Manifest, ManifestVersion, PackageJSON } from '@npm/types'
Expand Down Expand Up @@ -91,8 +91,24 @@ export interface SlimPackument {
'requestedVersion': SlimPackumentVersion | null
/** Only includes dist-tag versions (with installScripts info added per version) */
'versions': Record<string, SlimVersion>
/** Lightweight security metadata for all versions */
'securityVersions'?: PackageVersionInfo[]
/**
* Publish security downgrade for the requested version, or `null`.
* Computed server-side so the payload only carries the small result, not the full version history.
*/
'publishSecurityDowngrade'?: PublishSecurityDowngrade | null
}

/**
* A publish-security downgrade detected for a specific viewed version.
*/
export interface PublishSecurityDowngrade {
downgradedVersion: string
downgradedPublishedAt?: string
downgradedTrustLevel: TrustLevelName
/** Recommended trusted version within the same major, if one exists */
trustedVersion?: string
trustedPublishedAt?: string
trustedTrustLevel: TrustLevelName
}

/**
Expand Down
70 changes: 32 additions & 38 deletions test/nuxt/composables/use-package-transform.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,6 @@ function createPackument(
}
}

function toVersionInfos(packument: ReturnType<typeof transformPackument>): PackageVersionInfo[] {
return (
packument.securityVersions ??
Object.entries(packument.versions).map(([version, metadata]) => ({
version,
time: packument.time[version],
trustStatus: metadata.trustStatus,
deprecated: metadata.deprecated,
}))
)
}

describe('transformPackument', () => {
it('includes requested old version and preserves provenance on it', () => {
const packument = createPackument(
Expand Down Expand Up @@ -116,10 +104,11 @@ describe('transformPackument', () => {
})
expect(transformed.versions['1.0.1']).toBeUndefined()
expect(transformed.versions['1.0.2']).toBeUndefined()
expect(transformed.securityVersions).toHaveLength(8)
// 1.0.0 is the oldest and trusted, so the viewed version is not a downgrade
expect(transformed.publishSecurityDowngrade).toBeNull()
})

it('omits securityVersions when all versions have the same trust level', () => {
it('computes no downgrade when all versions have the same trust level', () => {
const packument = createPackument(
{
'1.0.0': createVersion('1.0.0'),
Expand All @@ -138,11 +127,11 @@ describe('transformPackument', () => {

const transformed = transformPackument(packument, '1.0.2')

// All versions have trustLevel 'none', so no mixed trust β€” omit the array
expect(transformed.securityVersions).toBeUndefined()
// All versions have trustLevel 'none', so nothing to detect
expect(transformed.publishSecurityDowngrade).toBeNull()
})

it('includes securityVersions when package has mixed trust levels', () => {
it('detects a downgrade when the package has mixed trust levels', () => {
const packument = createPackument(
{
'1.0.0': createVersion('1.0.0', true),
Expand All @@ -159,10 +148,17 @@ describe('transformPackument', () => {

const transformed = transformPackument(packument, '1.0.1')

expect(transformed.securityVersions).toHaveLength(2)
expect(transformed.publishSecurityDowngrade).toEqual({
downgradedVersion: '1.0.1',
downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
downgradedTrustLevel: 'none',
trustedVersion: '1.0.0',
trustedPublishedAt: '2026-01-01T00:00:00.000Z',
trustedTrustLevel: 'provenance',
})
})

it('works with downgrade detection for viewed version', () => {
it('computes the downgrade for the requested version only', () => {
const packument = createPackument(
{
'1.0.0': createVersion('1.0.0', true),
Expand All @@ -179,11 +175,8 @@ describe('transformPackument', () => {
'1.0.2',
)

const transformed = transformPackument(packument, '1.0.1')
const infos = toVersionInfos(transformed)

expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.2')).toBeNull()
expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')).toEqual({
expect(transformPackument(packument, '1.0.2').publishSecurityDowngrade).toBeNull()
expect(transformPackument(packument, '1.0.1').publishSecurityDowngrade).toEqual({
downgradedVersion: '1.0.1',
downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
downgradedTrustLevel: 'none',
Expand Down Expand Up @@ -211,14 +204,13 @@ describe('transformPackument', () => {
)

const transformed = transformPackument(packument, '1.0.1')
const infos = toVersionInfos(transformed)

expect(infos.find(v => v.version === '1.0.0')?.trustStatus).toEqual({
expect(transformed.versions['1.0.0']?.trustStatus).toEqual({
provenance: true,
trustedPublisher: true,
stagedPublish: false,
})
expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')?.trustedVersion).toBe('1.0.0')
expect(transformed.publishSecurityDowngrade?.trustedVersion).toBe('1.0.0')
})

it('prefers trustedPublisher trust level when both trustedPublisher and attestations exist', () => {
Expand Down Expand Up @@ -264,20 +256,19 @@ describe('transformPackument', () => {
)

const transformed = transformPackument(packument, '7.0.1')
const infos = toVersionInfos(transformed)

// Both versions should be trustedPublisher β€” no downgrade
expect(infos.find(v => v.version === '7.0.0')?.trustStatus).toEqual({
expect(transformed.versions['7.0.0']?.trustStatus).toEqual({
provenance: true,
trustedPublisher: true,
stagedPublish: false,
})
expect(infos.find(v => v.version === '7.0.1')?.trustStatus).toEqual({
expect(transformed.versions['7.0.1']?.trustStatus).toEqual({
provenance: true,
trustedPublisher: true,
stagedPublish: false,
})
expect(detectPublishSecurityDowngradeForVersion(infos, '7.0.1')).toBeNull()
expect(transformed.publishSecurityDowngrade).toBeNull()
})

it('flags non-direct downgrade chain until trust is restored', () => {
Expand All @@ -301,12 +292,15 @@ describe('transformPackument', () => {
'2.4.0',
)

const transformed = transformPackument(packument, '2.3.0')
const infos = toVersionInfos(transformed)

expect(detectPublishSecurityDowngradeForVersion(infos, '2.1.1')?.trustedVersion).toBe('2.1.0')
expect(detectPublishSecurityDowngradeForVersion(infos, '2.2.0')?.trustedVersion).toBe('2.1.0')
expect(detectPublishSecurityDowngradeForVersion(infos, '2.3.0')?.trustedVersion).toBe('2.1.0')
expect(detectPublishSecurityDowngradeForVersion(infos, '2.4.0')).toBeNull()
expect(transformPackument(packument, '2.1.1').publishSecurityDowngrade?.trustedVersion).toBe(
'2.1.0',
)
expect(transformPackument(packument, '2.2.0').publishSecurityDowngrade?.trustedVersion).toBe(
'2.1.0',
)
expect(transformPackument(packument, '2.3.0').publishSecurityDowngrade?.trustedVersion).toBe(
'2.1.0',
)
expect(transformPackument(packument, '2.4.0').publishSecurityDowngrade).toBeNull()
})
})
40 changes: 20 additions & 20 deletions test/unit/app/utils/publish-security.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
import { detectSecurityDowngradeForVersion } from '~/utils/publish-security'

describe('detectPublishSecurityDowngradeForVersion', () => {
describe('detectSecurityDowngradeForVersion', () => {
const versions = [
{
version: '1.0.0',
Expand All @@ -21,12 +21,12 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
]

it('does not flag trusted viewed version (1.0.2)', () => {
const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.2')
const result = detectSecurityDowngradeForVersion(versions, '1.0.2')
expect(result).toBeNull()
})

it('flags downgraded viewed version (1.0.1)', () => {
const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.1')
const result = detectSecurityDowngradeForVersion(versions, '1.0.1')
expect(result).toEqual({
downgradedVersion: '1.0.1',
downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
Expand All @@ -38,7 +38,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
})

it('flags trust downgrade from trustedPublisher to provenance', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand All @@ -65,7 +65,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
})

it('does not flag upgrade from provenance to trustedPublisher', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand Down Expand Up @@ -113,20 +113,20 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
},
]

expect(
detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.1.1')?.trustedVersion,
).toBe('2.1.0')
expect(
detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.2.0')?.trustedVersion,
).toBe('2.1.0')
expect(
detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.3.0')?.trustedVersion,
).toBe('2.1.0')
expect(detectPublishSecurityDowngradeForVersion(downgradedVersions, '2.4.0')).toBeNull()
expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.1.1')?.trustedVersion).toBe(
'2.1.0',
)
expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.2.0')?.trustedVersion).toBe(
'2.1.0',
)
expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.3.0')?.trustedVersion).toBe(
'2.1.0',
)
expect(detectSecurityDowngradeForVersion(downgradedVersions, '2.4.0')).toBeNull()
})

it('skips deprecated versions when selecting trustedVersion', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand All @@ -153,7 +153,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
})

it('returns null when all older trusted versions are deprecated', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand All @@ -174,7 +174,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
})

it('detects cross-major downgrade but does not recommend a version', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand All @@ -198,7 +198,7 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
})

it('recommends same-major trusted version when cross-major exists', () => {
const result = detectPublishSecurityDowngradeForVersion(
const result = detectSecurityDowngradeForVersion(
[
{
version: '1.0.0',
Expand Down
Loading