Skip to content

Commit e01c4aa

Browse files
committed
feat: detect npm publish security downgrade
Closes #534
1 parent 2273d3b commit e01c4aa

File tree

9 files changed

+287
-3
lines changed

9 files changed

+287
-3
lines changed

app/components/Terminal/Install.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PackageManagerId } from '~/utils/install-command'
55
const props = defineProps<{
66
packageName: string
77
requestedVersion?: string | null
8+
installVersionOverride?: string | null
89
jsrInfo?: JsrPackageInfo | null
910
typesPackageName?: string | null
1011
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
@@ -16,14 +17,15 @@ const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstal
1617
() => props.requestedVersion ?? null,
1718
() => props.jsrInfo ?? null,
1819
() => props.typesPackageName ?? null,
20+
() => props.installVersionOverride ?? null,
1921
)
2022
2123
// Generate install command parts for a specific package manager
2224
function getInstallPartsForPM(pmId: PackageManagerId) {
2325
return getInstallCommandParts({
2426
packageName: props.packageName,
2527
packageManager: pmId,
26-
version: props.requestedVersion,
28+
version: props.installVersionOverride ?? props.requestedVersion,
2729
jsrInfo: props.jsrInfo,
2830
})
2931
}

app/composables/useInstallCommand.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export function useInstallCommand(
99
requestedVersion: MaybeRefOrGetter<string | null>,
1010
jsrInfo: MaybeRefOrGetter<JsrPackageInfo | null>,
1111
typesPackageName: MaybeRefOrGetter<string | null>,
12+
installVersionOverride?: MaybeRefOrGetter<string | null>,
1213
) {
1314
const selectedPM = useSelectedPackageManager()
1415
const { settings } = useSettings()
@@ -21,21 +22,23 @@ export function useInstallCommand(
2122
const installCommandParts = computed(() => {
2223
const name = toValue(packageName)
2324
if (!name) return []
25+
const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
2426
return getInstallCommandParts({
2527
packageName: name,
2628
packageManager: selectedPM.value,
27-
version: toValue(requestedVersion),
29+
version,
2830
jsrInfo: toValue(jsrInfo),
2931
})
3032
})
3133

3234
const installCommand = computed(() => {
3335
const name = toValue(packageName)
3436
if (!name) return ''
37+
const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
3538
return getInstallCommand({
3639
packageName: name,
3740
packageManager: selectedPM.value,
38-
version: toValue(requestedVersion),
41+
version,
3942
jsrInfo: toValue(jsrInfo),
4043
})
4144
})

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { areUrlsEquivalent } from '#shared/utils/url'
1313
import { isEditableElement } from '~/utils/input'
1414
import { formatBytes } from '~/utils/formatters'
1515
import { getDependencyCount } from '~/utils/npm/dependency-count'
16+
import { fetchAllPackageVersions } from '~/utils/npm/api'
17+
import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
1618
import { NuxtLink } from '#components'
1719
import { useModal } from '~/composables/useModal'
1820
import { useAtproto } from '~/composables/atproto/useAtproto'
@@ -124,6 +126,15 @@ const {
124126
error: versionError,
125127
} = await useResolvedVersion(packageName, requestedVersion)
126128
129+
const { data: allVersionMetadata } = useLazyAsyncData(
130+
() => `package:version-meta:${packageName.value}`,
131+
() => fetchAllPackageVersions(packageName.value),
132+
{
133+
default: () => [],
134+
server: false,
135+
},
136+
)
137+
127138
if (
128139
versionStatus.value === 'error' &&
129140
versionError.value?.statusCode &&
@@ -225,6 +236,17 @@ const deprecationNoticeMessage = useMarkdown(() => ({
225236
text: deprecationNotice.value?.message ?? '',
226237
}))
227238
239+
const publishSecurityDowngrade = computed(() => {
240+
const currentVersion = displayVersion.value?.version
241+
if (!currentVersion) return null
242+
return detectPublishSecurityDowngradeForVersion(allVersionMetadata.value ?? [], currentVersion)
243+
})
244+
245+
const installVersionOverride = computed(() => {
246+
if (!publishSecurityDowngrade.value) return null
247+
return publishSecurityDowngrade.value?.trustedVersion ?? null
248+
})
249+
228250
const sizeTooltip = computed(() => {
229251
const chunks = [
230252
displayVersion.value &&
@@ -1088,9 +1110,30 @@ onKeyStroke(
10881110
:id="`pm-panel-${activePmId}`"
10891111
:aria-labelledby="`pm-tab-${activePmId}`"
10901112
>
1113+
<div
1114+
v-if="publishSecurityDowngrade"
1115+
role="alert"
1116+
class="mb-4 rounded-lg border border-red-600/40 bg-red-500/10 px-4 py-3 text-red-800 dark:text-red-300"
1117+
>
1118+
<h3 class="m-0 flex items-center gap-2 font-mono text-sm font-semibold tracking-wide">
1119+
<span class="i-carbon-warning-filled w-4 h-4 shrink-0" aria-hidden="true" />
1120+
{{ $t('package.security_downgrade.title') }}
1121+
</h3>
1122+
<p class="mt-2 mb-0 text-sm">
1123+
{{ $t('package.security_downgrade.description') }}
1124+
</p>
1125+
<p class="mt-2 mb-0 text-sm">
1126+
{{
1127+
$t('package.security_downgrade.fallback_install', {
1128+
version: publishSecurityDowngrade.trustedVersion,
1129+
})
1130+
}}
1131+
</p>
1132+
</div>
10911133
<TerminalInstall
10921134
:package-name="pkg.name"
10931135
:requested-version="requestedVersion"
1136+
:install-version-override="installVersionOverride"
10941137
:jsr-info="jsrInfo"
10951138
:types-package-name="typesPackageName"
10961139
:executable-info="executableInfo"

app/utils/publish-security.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { PackageVersionInfo } from '#shared/types'
2+
import { compare } from 'semver'
3+
4+
export interface PublishSecurityDowngrade {
5+
downgradedVersion: string
6+
downgradedPublishedAt?: string
7+
trustedVersion: string
8+
trustedPublishedAt?: string
9+
}
10+
11+
type VersionWithIndex = PackageVersionInfo & {
12+
index: number
13+
timestamp: number
14+
}
15+
16+
function toTimestamp(time?: string): number {
17+
if (!time) return Number.NaN
18+
return Date.parse(time)
19+
}
20+
21+
function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
22+
const aValid = Number.isFinite(a.timestamp)
23+
const bValid = Number.isFinite(b.timestamp)
24+
25+
if (aValid && bValid && a.timestamp !== b.timestamp) {
26+
return b.timestamp - a.timestamp
27+
}
28+
29+
if (aValid !== bValid) {
30+
return aValid ? -1 : 1
31+
}
32+
33+
const semverOrder = compare(b.version, a.version)
34+
if (semverOrder !== 0) return semverOrder
35+
36+
return a.index - b.index
37+
}
38+
39+
/**
40+
* Detects a security downgrade where the newest publish is not trusted,
41+
* but an older publish was trusted (e.g. OIDC/provenance -> manual publish).
42+
*/
43+
export function detectPublishSecurityDowngrade(
44+
versions: PackageVersionInfo[],
45+
): PublishSecurityDowngrade | null {
46+
if (versions.length < 2) return null
47+
48+
const sorted = versions
49+
.map((version, index) => ({
50+
...version,
51+
index,
52+
timestamp: toTimestamp(version.time),
53+
}))
54+
.sort(sortByRecency)
55+
56+
const latest = sorted[0]
57+
if (!latest || latest.hasProvenance) return null
58+
59+
const latestTrusted = sorted.find(version => version.hasProvenance)
60+
if (!latestTrusted) return null
61+
62+
return {
63+
downgradedVersion: latest.version,
64+
downgradedPublishedAt: latest.time,
65+
trustedVersion: latestTrusted.version,
66+
trustedPublishedAt: latestTrusted.time,
67+
}
68+
}
69+
70+
/**
71+
* Detects a security downgrade for a specific viewed version.
72+
* A version is considered downgraded when it has no provenance and
73+
* there exists an older trusted release.
74+
*/
75+
export function detectPublishSecurityDowngradeForVersion(
76+
versions: PackageVersionInfo[],
77+
viewedVersion: string,
78+
): PublishSecurityDowngrade | null {
79+
if (versions.length < 2 || !viewedVersion) return null
80+
81+
const sorted = versions
82+
.map((version, index) => ({
83+
...version,
84+
index,
85+
timestamp: toTimestamp(version.time),
86+
}))
87+
.sort(sortByRecency)
88+
89+
const currentIndex = sorted.findIndex(version => version.version === viewedVersion)
90+
if (currentIndex === -1) return null
91+
92+
const current = sorted[currentIndex]
93+
if (!current || current.hasProvenance) return null
94+
95+
const trustedOlder = sorted.slice(currentIndex + 1).find(version => version.hasProvenance)
96+
if (!trustedOlder) return null
97+
98+
return {
99+
downgradedVersion: current.version,
100+
downgradedPublishedAt: current.time,
101+
trustedVersion: trustedOlder.version,
102+
trustedPublishedAt: trustedOlder.time,
103+
}
104+
}

i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@
234234
"view_more_details": "View more details",
235235
"error_loading": "Failed to load provenance details"
236236
},
237+
"security_downgrade": {
238+
"title": "Security downgrade detected",
239+
"description": "This package has been released using a stronger, more secure publishing method before. The version you are viewing was published using a weaker or less trusted method. Treat this as a potential supply-chain compromise until verified.",
240+
"fallback_install": "Install commands below are pinned to trusted version {version} by default."
241+
},
237242
"keywords_title": "Keywords",
238243
"compatibility": "Compatibility",
239244
"card": {

lunaria/files/en-GB.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@
234234
"view_more_details": "View more details",
235235
"error_loading": "Failed to load provenance details"
236236
},
237+
"security_downgrade": {
238+
"title": "Security downgrade detected",
239+
"description": "This package has been released using a stronger, more secure publishing method before. The version you are viewing was published using a weaker or less trusted method. Treat this as a potential supply-chain compromise until verified.",
240+
"fallback_install": "Install commands below are pinned to trusted version {version} by default."
241+
},
237242
"keywords_title": "Keywords",
238243
"compatibility": "Compatibility",
239244
"card": {

lunaria/files/en-US.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@
234234
"view_more_details": "View more details",
235235
"error_loading": "Failed to load provenance details"
236236
},
237+
"security_downgrade": {
238+
"title": "Security downgrade detected",
239+
"description": "This package has been released using a stronger, more secure publishing method before. The version you are viewing was published using a weaker or less trusted method. Treat this as a potential supply-chain compromise until verified.",
240+
"fallback_install": "Install commands below are pinned to trusted version {version} by default."
241+
},
237242
"keywords_title": "Keywords",
238243
"compatibility": "Compatibility",
239244
"card": {

test/nuxt/composables/use-install-command.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,25 @@ describe('useInstallCommand', () => {
260260
version.value = '18.2.0'
261261
expect(installCommand.value).toBe('npm install react@18.2.0')
262262
})
263+
264+
it('should prefer installVersionOverride when provided', () => {
265+
const requestedVersion = shallowRef<string | null>(null)
266+
const installVersionOverride = shallowRef<string | null>('1.0.0')
267+
268+
const { installCommand } = useInstallCommand(
269+
'foo',
270+
requestedVersion,
271+
null,
272+
null,
273+
installVersionOverride,
274+
)
275+
276+
expect(installCommand.value).toBe('npm install foo@1.0.0')
277+
278+
installVersionOverride.value = null
279+
requestedVersion.value = '2.0.0'
280+
expect(installCommand.value).toBe('npm install foo@2.0.0')
281+
})
263282
})
264283

265284
describe('copyInstallCommand', () => {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
detectPublishSecurityDowngrade,
4+
detectPublishSecurityDowngradeForVersion,
5+
} from '../../../../app/utils/publish-security'
6+
7+
describe('detectPublishSecurityDowngrade', () => {
8+
it('detects downgrade when latest publish is untrusted and older publish is trusted', () => {
9+
const result = detectPublishSecurityDowngrade([
10+
{
11+
version: '1.0.0',
12+
time: '2026-01-01T00:00:00.000Z',
13+
hasProvenance: true,
14+
},
15+
{
16+
version: '1.0.1',
17+
time: '2026-01-02T00:00:00.000Z',
18+
hasProvenance: false,
19+
},
20+
])
21+
22+
expect(result).toEqual({
23+
downgradedVersion: '1.0.1',
24+
downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
25+
trustedVersion: '1.0.0',
26+
trustedPublishedAt: '2026-01-01T00:00:00.000Z',
27+
})
28+
})
29+
30+
it('returns null when latest publish is trusted', () => {
31+
const result = detectPublishSecurityDowngrade([
32+
{
33+
version: '1.0.0',
34+
time: '2026-01-01T00:00:00.000Z',
35+
hasProvenance: false,
36+
},
37+
{
38+
version: '1.0.1',
39+
time: '2026-01-02T00:00:00.000Z',
40+
hasProvenance: true,
41+
},
42+
])
43+
44+
expect(result).toBeNull()
45+
})
46+
47+
it('returns null when there is no trusted historical release', () => {
48+
const result = detectPublishSecurityDowngrade([
49+
{
50+
version: '1.0.0',
51+
time: '2026-01-01T00:00:00.000Z',
52+
hasProvenance: false,
53+
},
54+
{
55+
version: '1.0.1',
56+
time: '2026-01-02T00:00:00.000Z',
57+
hasProvenance: false,
58+
},
59+
])
60+
61+
expect(result).toBeNull()
62+
})
63+
})
64+
65+
describe('detectPublishSecurityDowngradeForVersion', () => {
66+
const versions = [
67+
{
68+
version: '1.0.0',
69+
time: '2026-01-01T00:00:00.000Z',
70+
hasProvenance: true,
71+
},
72+
{
73+
version: '1.0.1',
74+
time: '2026-01-02T00:00:00.000Z',
75+
hasProvenance: false,
76+
},
77+
{
78+
version: '1.0.2',
79+
time: '2026-01-03T00:00:00.000Z',
80+
hasProvenance: true,
81+
},
82+
]
83+
84+
it('does not flag trusted viewed version (1.0.2)', () => {
85+
const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.2')
86+
expect(result).toBeNull()
87+
})
88+
89+
it('flags downgraded viewed version (1.0.1)', () => {
90+
const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.1')
91+
expect(result).toEqual({
92+
downgradedVersion: '1.0.1',
93+
downgradedPublishedAt: '2026-01-02T00:00:00.000Z',
94+
trustedVersion: '1.0.0',
95+
trustedPublishedAt: '2026-01-01T00:00:00.000Z',
96+
})
97+
})
98+
})

0 commit comments

Comments
 (0)