Skip to content

Commit 50e11ee

Browse files
committed
fix: handle cross-major downgrades, minimise payload, add tests
1 parent e3424a7 commit 50e11ee

5 files changed

Lines changed: 214 additions & 11 deletions

File tree

app/composables/npm/usePackage.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ export function transformPackument(
5959
includedVersions.add(requestedVersion)
6060
}
6161

62-
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
62+
// Build security metadata for all versions, but only include in payload
63+
// when the package has mixed trust levels (i.e. a downgrade could exist)
64+
const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => {
6365
const trustLevel = getTrustLevel(metadata)
6466
return {
6567
version,
@@ -70,6 +72,10 @@ export function transformPackument(
7072
}
7173
})
7274

75+
const trustLevels = new Set(securityVersionEntries.map(v => v.trustLevel))
76+
const hasMixedTrust = trustLevels.size > 1
77+
const securityVersions = hasMixedTrust ? securityVersionEntries : undefined
78+
7379
// Build filtered versions object with install scripts info per version
7480
const filteredVersions: Record<string, SlimVersion> = {}
7581
let versionData: SlimPackumentVersion | null = null

app/pages/package/[...package].vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ onKeyStroke(
11241124
<p class="mt-2 mb-0 text-sm">
11251125
{{ $t('package.security_downgrade.description') }}
11261126
</p>
1127-
<p class="mt-2 mb-0 text-sm">
1127+
<p v-if="publishSecurityDowngrade.trustedVersion" class="mt-2 mb-0 text-sm">
11281128
{{
11291129
$t('package.security_downgrade.fallback_install', {
11301130
version: publishSecurityDowngrade.trustedVersion,

app/utils/publish-security.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { PackageVersionInfo, PublishTrustLevel } from '#shared/types'
2-
import { compare } from 'semver'
2+
import { compare, major } from 'semver'
33

44
export interface PublishSecurityDowngrade {
55
downgradedVersion: string
66
downgradedPublishedAt?: string
7-
trustedVersion: string
7+
/** Recommended trusted version within the same major, if one exists */
8+
trustedVersion?: string
89
trustedPublishedAt?: string
910
}
1011

@@ -22,7 +23,9 @@ const TRUST_RANK: Record<PublishTrustLevel, number> = {
2223

2324
function getTrustRank(version: PackageVersionInfo): number {
2425
if (version.trustLevel) return TRUST_RANK[version.trustLevel]
25-
return version.hasProvenance ? TRUST_RANK.provenance : TRUST_RANK.none
26+
// Fallback for legacy data: hasProvenance only indicates non-'none' trust,
27+
// so map it to trustedPublisher (the lower rank) to avoid over-ranking
28+
return version.hasProvenance ? TRUST_RANK.trustedPublisher : TRUST_RANK.none
2629
}
2730

2831
function toTimestamp(time?: string): number {
@@ -73,22 +76,39 @@ export function detectPublishSecurityDowngradeForVersion(
7376
const currentIndex = sorted.findIndex(version => version.version === viewedVersion)
7477
if (currentIndex === -1) return null
7578

76-
const current = sorted.at(currentIndex)
79+
const current = sorted[currentIndex]
7780
if (!current) return null
7881

79-
let strongestOlder: VersionWithIndex | null = null
82+
const currentMajor = major(current.version)
83+
84+
// Find the strongest older version across all majors (for detection)
85+
// and the strongest within the same major (for recommendation)
86+
let strongestOlderAny: VersionWithIndex | null = null
87+
let strongestOlderSameMajor: VersionWithIndex | null = null
8088
for (const version of sorted.slice(currentIndex + 1)) {
81-
if (!strongestOlder || version.trustRank > strongestOlder.trustRank) {
82-
strongestOlder = version
89+
// Skip deprecated versions — recommending a deprecated version is misleading
90+
if (version.deprecated) continue
91+
if (!strongestOlderAny || version.trustRank > strongestOlderAny.trustRank) {
92+
strongestOlderAny = version
93+
}
94+
if (major(version.version) === currentMajor) {
95+
if (!strongestOlderSameMajor || version.trustRank > strongestOlderSameMajor.trustRank) {
96+
strongestOlderSameMajor = version
97+
}
8398
}
8499
}
85100

101+
// Use same-major for recommendation if available, otherwise any-major for detection only
102+
const strongestOlder = strongestOlderSameMajor ?? strongestOlderAny
86103
if (!strongestOlder || strongestOlder.trustRank <= current.trustRank) return null
87104

105+
// Only recommend a specific version if it's in the same major
106+
const recommendation = strongestOlderSameMajor
107+
88108
return {
89109
downgradedVersion: current.version,
90110
downgradedPublishedAt: current.time,
91-
trustedVersion: strongestOlder.version,
92-
trustedPublishedAt: strongestOlder.time,
111+
trustedVersion: recommendation?.version,
112+
trustedPublishedAt: recommendation?.time,
93113
}
94114
}

test/nuxt/composables/use-package-transform.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,49 @@ describe('transformPackument', () => {
115115
expect(transformed.securityVersions).toHaveLength(8)
116116
})
117117

118+
it('omits securityVersions when all versions have the same trust level', () => {
119+
const packument = createPackument(
120+
{
121+
'1.0.0': createVersion('1.0.0'),
122+
'1.0.1': createVersion('1.0.1'),
123+
'1.0.2': createVersion('1.0.2'),
124+
},
125+
{
126+
'created': '2026-01-01T00:00:00.000Z',
127+
'modified': '2026-01-03T00:00:00.000Z',
128+
'1.0.0': '2026-01-01T00:00:00.000Z',
129+
'1.0.1': '2026-01-02T00:00:00.000Z',
130+
'1.0.2': '2026-01-03T00:00:00.000Z',
131+
},
132+
'1.0.2',
133+
)
134+
135+
const transformed = transformPackument(packument, '1.0.2')
136+
137+
// All versions have trustLevel 'none', so no mixed trust — omit the array
138+
expect(transformed.securityVersions).toBeUndefined()
139+
})
140+
141+
it('includes securityVersions when package has mixed trust levels', () => {
142+
const packument = createPackument(
143+
{
144+
'1.0.0': createVersion('1.0.0', true),
145+
'1.0.1': createVersion('1.0.1'),
146+
},
147+
{
148+
'created': '2026-01-01T00:00:00.000Z',
149+
'modified': '2026-01-02T00:00:00.000Z',
150+
'1.0.0': '2026-01-01T00:00:00.000Z',
151+
'1.0.1': '2026-01-02T00:00:00.000Z',
152+
},
153+
'1.0.1',
154+
)
155+
156+
const transformed = transformPackument(packument, '1.0.1')
157+
158+
expect(transformed.securityVersions).toHaveLength(2)
159+
})
160+
118161
it('works with downgrade detection for viewed version', () => {
119162
const packument = createPackument(
120163
{

test/unit/app/utils/publish-security.spec.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,138 @@ describe('detectPublishSecurityDowngradeForVersion', () => {
107107
)
108108
expect(detectPublishSecurityDowngradeForVersion(versions, '2.4.0')).toBeNull()
109109
})
110+
111+
it('skips deprecated versions when selecting trustedVersion', () => {
112+
const result = detectPublishSecurityDowngradeForVersion(
113+
[
114+
{
115+
version: '1.0.0',
116+
time: '2026-01-01T00:00:00.000Z',
117+
hasProvenance: true,
118+
trustLevel: 'provenance',
119+
},
120+
{
121+
version: '1.0.1',
122+
time: '2026-01-02T00:00:00.000Z',
123+
hasProvenance: true,
124+
trustLevel: 'provenance',
125+
deprecated: 'Use 1.0.2 instead',
126+
},
127+
{
128+
version: '1.0.2',
129+
time: '2026-01-03T00:00:00.000Z',
130+
hasProvenance: false,
131+
trustLevel: 'none',
132+
},
133+
],
134+
'1.0.2',
135+
)
136+
137+
// Should recommend 1.0.0 (not 1.0.1 which is deprecated)
138+
expect(result?.trustedVersion).toBe('1.0.0')
139+
})
140+
141+
it('returns null when all older trusted versions are deprecated', () => {
142+
const result = detectPublishSecurityDowngradeForVersion(
143+
[
144+
{
145+
version: '1.0.0',
146+
time: '2026-01-01T00:00:00.000Z',
147+
hasProvenance: true,
148+
trustLevel: 'provenance',
149+
deprecated: 'Deprecated',
150+
},
151+
{
152+
version: '1.0.1',
153+
time: '2026-01-02T00:00:00.000Z',
154+
hasProvenance: false,
155+
trustLevel: 'none',
156+
},
157+
],
158+
'1.0.1',
159+
)
160+
161+
expect(result).toBeNull()
162+
})
163+
164+
it('detects cross-major downgrade but does not recommend a version', () => {
165+
const result = detectPublishSecurityDowngradeForVersion(
166+
[
167+
{
168+
version: '1.0.0',
169+
time: '2026-01-01T00:00:00.000Z',
170+
hasProvenance: true,
171+
trustLevel: 'provenance',
172+
},
173+
{
174+
version: '2.0.0',
175+
time: '2026-01-02T00:00:00.000Z',
176+
hasProvenance: false,
177+
trustLevel: 'none',
178+
},
179+
],
180+
'2.0.0',
181+
)
182+
183+
// Downgrade is detected (v1.0.0 was trusted, v2.0.0 is not)
184+
expect(result).not.toBeNull()
185+
expect(result?.downgradedVersion).toBe('2.0.0')
186+
// But no trustedVersion recommendation since v1.0.0 is a different major
187+
expect(result?.trustedVersion).toBeUndefined()
188+
})
189+
190+
it('recommends same-major trusted version when cross-major exists', () => {
191+
const result = detectPublishSecurityDowngradeForVersion(
192+
[
193+
{
194+
version: '1.0.0',
195+
time: '2026-01-01T00:00:00.000Z',
196+
hasProvenance: true,
197+
trustLevel: 'provenance',
198+
},
199+
{
200+
version: '2.0.0',
201+
time: '2026-01-02T00:00:00.000Z',
202+
hasProvenance: true,
203+
trustLevel: 'provenance',
204+
},
205+
{
206+
version: '2.1.0',
207+
time: '2026-01-03T00:00:00.000Z',
208+
hasProvenance: false,
209+
trustLevel: 'none',
210+
},
211+
],
212+
'2.1.0',
213+
)
214+
215+
// Should recommend 2.0.0 (same major), not 1.0.0
216+
expect(result?.trustedVersion).toBe('2.0.0')
217+
})
218+
219+
it('uses trustedPublisher rank (not provenance) for hasProvenance fallback without trustLevel', () => {
220+
// When trustLevel is absent, hasProvenance: true should map to trustedPublisher rank,
221+
// not provenance rank. This means a version with only hasProvenance: true should NOT
222+
// be considered a downgrade from trustedPublisher.
223+
const result = detectPublishSecurityDowngradeForVersion(
224+
[
225+
{
226+
version: '1.0.0',
227+
time: '2026-01-01T00:00:00.000Z',
228+
hasProvenance: true,
229+
// no trustLevel — fallback path
230+
},
231+
{
232+
version: '1.0.1',
233+
time: '2026-01-02T00:00:00.000Z',
234+
hasProvenance: true,
235+
trustLevel: 'trustedPublisher',
236+
},
237+
],
238+
'1.0.1',
239+
)
240+
241+
// Both should be treated as trustedPublisher rank, so no downgrade
242+
expect(result).toBeNull()
243+
})
110244
})

0 commit comments

Comments
 (0)