From e01c4aacadfcbd963f00185ba696d104a03d2900 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 5 Feb 2026 23:30:30 +0100 Subject: [PATCH 1/6] feat: detect npm publish security downgrade Closes #534 --- app/components/Terminal/Install.vue | 4 +- app/composables/useInstallCommand.ts | 7 +- app/pages/package/[...package].vue | 43 ++++++++ app/utils/publish-security.ts | 104 ++++++++++++++++++ i18n/locales/en.json | 5 + lunaria/files/en-GB.json | 5 + lunaria/files/en-US.json | 5 + .../composables/use-install-command.spec.ts | 19 ++++ test/unit/app/utils/publish-security.spec.ts | 98 +++++++++++++++++ 9 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 app/utils/publish-security.ts create mode 100644 test/unit/app/utils/publish-security.spec.ts diff --git a/app/components/Terminal/Install.vue b/app/components/Terminal/Install.vue index b03a5d6fa..6a8f2fd62 100644 --- a/app/components/Terminal/Install.vue +++ b/app/components/Terminal/Install.vue @@ -5,6 +5,7 @@ import type { PackageManagerId } from '~/utils/install-command' const props = defineProps<{ packageName: string requestedVersion?: string | null + installVersionOverride?: string | null jsrInfo?: JsrPackageInfo | null typesPackageName?: string | null executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null @@ -16,6 +17,7 @@ const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstal () => props.requestedVersion ?? null, () => props.jsrInfo ?? null, () => props.typesPackageName ?? null, + () => props.installVersionOverride ?? null, ) // Generate install command parts for a specific package manager @@ -23,7 +25,7 @@ function getInstallPartsForPM(pmId: PackageManagerId) { return getInstallCommandParts({ packageName: props.packageName, packageManager: pmId, - version: props.requestedVersion, + version: props.installVersionOverride ?? props.requestedVersion, jsrInfo: props.jsrInfo, }) } diff --git a/app/composables/useInstallCommand.ts b/app/composables/useInstallCommand.ts index 23094d457..827ac89f4 100644 --- a/app/composables/useInstallCommand.ts +++ b/app/composables/useInstallCommand.ts @@ -9,6 +9,7 @@ export function useInstallCommand( requestedVersion: MaybeRefOrGetter, jsrInfo: MaybeRefOrGetter, typesPackageName: MaybeRefOrGetter, + installVersionOverride?: MaybeRefOrGetter, ) { const selectedPM = useSelectedPackageManager() const { settings } = useSettings() @@ -21,10 +22,11 @@ export function useInstallCommand( const installCommandParts = computed(() => { const name = toValue(packageName) if (!name) return [] + const version = toValue(installVersionOverride) ?? toValue(requestedVersion) return getInstallCommandParts({ packageName: name, packageManager: selectedPM.value, - version: toValue(requestedVersion), + version, jsrInfo: toValue(jsrInfo), }) }) @@ -32,10 +34,11 @@ export function useInstallCommand( const installCommand = computed(() => { const name = toValue(packageName) if (!name) return '' + const version = toValue(installVersionOverride) ?? toValue(requestedVersion) return getInstallCommand({ packageName: name, packageManager: selectedPM.value, - version: toValue(requestedVersion), + version, jsrInfo: toValue(jsrInfo), }) }) diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index ac08f83a1..3d63dbc56 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -13,6 +13,8 @@ import { areUrlsEquivalent } from '#shared/utils/url' import { isEditableElement } from '~/utils/input' import { formatBytes } from '~/utils/formatters' import { getDependencyCount } from '~/utils/npm/dependency-count' +import { fetchAllPackageVersions } from '~/utils/npm/api' +import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security' import { NuxtLink } from '#components' import { useModal } from '~/composables/useModal' import { useAtproto } from '~/composables/atproto/useAtproto' @@ -124,6 +126,15 @@ const { error: versionError, } = await useResolvedVersion(packageName, requestedVersion) +const { data: allVersionMetadata } = useLazyAsyncData( + () => `package:version-meta:${packageName.value}`, + () => fetchAllPackageVersions(packageName.value), + { + default: () => [], + server: false, + }, +) + if ( versionStatus.value === 'error' && versionError.value?.statusCode && @@ -225,6 +236,17 @@ const deprecationNoticeMessage = useMarkdown(() => ({ text: deprecationNotice.value?.message ?? '', })) +const publishSecurityDowngrade = computed(() => { + const currentVersion = displayVersion.value?.version + if (!currentVersion) return null + return detectPublishSecurityDowngradeForVersion(allVersionMetadata.value ?? [], currentVersion) +}) + +const installVersionOverride = computed(() => { + if (!publishSecurityDowngrade.value) return null + return publishSecurityDowngrade.value?.trustedVersion ?? null +}) + const sizeTooltip = computed(() => { const chunks = [ displayVersion.value && @@ -1088,9 +1110,30 @@ onKeyStroke( :id="`pm-panel-${activePmId}`" :aria-labelledby="`pm-tab-${activePmId}`" > + manual publish). + */ +export function detectPublishSecurityDowngrade( + versions: PackageVersionInfo[], +): PublishSecurityDowngrade | null { + if (versions.length < 2) return null + + const sorted = versions + .map((version, index) => ({ + ...version, + index, + timestamp: toTimestamp(version.time), + })) + .sort(sortByRecency) + + const latest = sorted[0] + if (!latest || latest.hasProvenance) return null + + const latestTrusted = sorted.find(version => version.hasProvenance) + if (!latestTrusted) return null + + return { + downgradedVersion: latest.version, + downgradedPublishedAt: latest.time, + trustedVersion: latestTrusted.version, + trustedPublishedAt: latestTrusted.time, + } +} + +/** + * Detects a security downgrade for a specific viewed version. + * A version is considered downgraded when it has no provenance and + * there exists an older trusted release. + */ +export function detectPublishSecurityDowngradeForVersion( + versions: PackageVersionInfo[], + viewedVersion: string, +): PublishSecurityDowngrade | null { + if (versions.length < 2 || !viewedVersion) return null + + const sorted = versions + .map((version, index) => ({ + ...version, + index, + timestamp: toTimestamp(version.time), + })) + .sort(sortByRecency) + + const currentIndex = sorted.findIndex(version => version.version === viewedVersion) + if (currentIndex === -1) return null + + const current = sorted[currentIndex] + if (!current || current.hasProvenance) return null + + const trustedOlder = sorted.slice(currentIndex + 1).find(version => version.hasProvenance) + if (!trustedOlder) return null + + return { + downgradedVersion: current.version, + downgradedPublishedAt: current.time, + trustedVersion: trustedOlder.version, + trustedPublishedAt: trustedOlder.time, + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 9d0f33ac8..a22696229 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -234,6 +234,11 @@ "view_more_details": "View more details", "error_loading": "Failed to load provenance details" }, + "security_downgrade": { + "title": "Security downgrade detected", + "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.", + "fallback_install": "Install commands below are pinned to trusted version {version} by default." + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 34f763a9c..3eb543a33 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -234,6 +234,11 @@ "view_more_details": "View more details", "error_loading": "Failed to load provenance details" }, + "security_downgrade": { + "title": "Security downgrade detected", + "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.", + "fallback_install": "Install commands below are pinned to trusted version {version} by default." + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 9d0f33ac8..a22696229 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -234,6 +234,11 @@ "view_more_details": "View more details", "error_loading": "Failed to load provenance details" }, + "security_downgrade": { + "title": "Security downgrade detected", + "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.", + "fallback_install": "Install commands below are pinned to trusted version {version} by default." + }, "keywords_title": "Keywords", "compatibility": "Compatibility", "card": { diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index f9307d9b3..579942794 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -260,6 +260,25 @@ describe('useInstallCommand', () => { version.value = '18.2.0' expect(installCommand.value).toBe('npm install react@18.2.0') }) + + it('should prefer installVersionOverride when provided', () => { + const requestedVersion = shallowRef(null) + const installVersionOverride = shallowRef('1.0.0') + + const { installCommand } = useInstallCommand( + 'foo', + requestedVersion, + null, + null, + installVersionOverride, + ) + + expect(installCommand.value).toBe('npm install foo@1.0.0') + + installVersionOverride.value = null + requestedVersion.value = '2.0.0' + expect(installCommand.value).toBe('npm install foo@2.0.0') + }) }) describe('copyInstallCommand', () => { diff --git a/test/unit/app/utils/publish-security.spec.ts b/test/unit/app/utils/publish-security.spec.ts new file mode 100644 index 000000000..f44e25521 --- /dev/null +++ b/test/unit/app/utils/publish-security.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + detectPublishSecurityDowngrade, + detectPublishSecurityDowngradeForVersion, +} from '../../../../app/utils/publish-security' + +describe('detectPublishSecurityDowngrade', () => { + it('detects downgrade when latest publish is untrusted and older publish is trusted', () => { + const result = detectPublishSecurityDowngrade([ + { + version: '1.0.0', + time: '2026-01-01T00:00:00.000Z', + hasProvenance: true, + }, + { + version: '1.0.1', + time: '2026-01-02T00:00:00.000Z', + hasProvenance: false, + }, + ]) + + expect(result).toEqual({ + downgradedVersion: '1.0.1', + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', + trustedVersion: '1.0.0', + trustedPublishedAt: '2026-01-01T00:00:00.000Z', + }) + }) + + it('returns null when latest publish is trusted', () => { + const result = detectPublishSecurityDowngrade([ + { + version: '1.0.0', + time: '2026-01-01T00:00:00.000Z', + hasProvenance: false, + }, + { + version: '1.0.1', + time: '2026-01-02T00:00:00.000Z', + hasProvenance: true, + }, + ]) + + expect(result).toBeNull() + }) + + it('returns null when there is no trusted historical release', () => { + const result = detectPublishSecurityDowngrade([ + { + version: '1.0.0', + time: '2026-01-01T00:00:00.000Z', + hasProvenance: false, + }, + { + version: '1.0.1', + time: '2026-01-02T00:00:00.000Z', + hasProvenance: false, + }, + ]) + + expect(result).toBeNull() + }) +}) + +describe('detectPublishSecurityDowngradeForVersion', () => { + const versions = [ + { + version: '1.0.0', + time: '2026-01-01T00:00:00.000Z', + hasProvenance: true, + }, + { + version: '1.0.1', + time: '2026-01-02T00:00:00.000Z', + hasProvenance: false, + }, + { + version: '1.0.2', + time: '2026-01-03T00:00:00.000Z', + hasProvenance: true, + }, + ] + + it('does not flag trusted viewed version (1.0.2)', () => { + const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.2') + expect(result).toBeNull() + }) + + it('flags downgraded viewed version (1.0.1)', () => { + const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.1') + expect(result).toEqual({ + downgradedVersion: '1.0.1', + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', + trustedVersion: '1.0.0', + trustedPublishedAt: '2026-01-01T00:00:00.000Z', + }) + }) +}) From 9e30e9f97dc1747b23504e98c9d66f66124945a6 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 5 Feb 2026 23:50:18 +0100 Subject: [PATCH 2/6] Apply CR suggestions --- app/pages/package/[...package].vue | 2 +- app/utils/publish-security.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 3d63dbc56..840c5a781 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -244,7 +244,7 @@ const publishSecurityDowngrade = computed(() => { const installVersionOverride = computed(() => { if (!publishSecurityDowngrade.value) return null - return publishSecurityDowngrade.value?.trustedVersion ?? null + return publishSecurityDowngrade.value.trustedVersion }) const sizeTooltip = computed(() => { diff --git a/app/utils/publish-security.ts b/app/utils/publish-security.ts index 651635a3d..471e29388 100644 --- a/app/utils/publish-security.ts +++ b/app/utils/publish-security.ts @@ -53,7 +53,7 @@ export function detectPublishSecurityDowngrade( })) .sort(sortByRecency) - const latest = sorted[0] + const latest = sorted.at(0) if (!latest || latest.hasProvenance) return null const latestTrusted = sorted.find(version => version.hasProvenance) @@ -89,7 +89,7 @@ export function detectPublishSecurityDowngradeForVersion( const currentIndex = sorted.findIndex(version => version.version === viewedVersion) if (currentIndex === -1) return null - const current = sorted[currentIndex] + const current = sorted.at(currentIndex) if (!current || current.hasProvenance) return null const trustedOlder = sorted.slice(currentIndex + 1).find(version => version.hasProvenance) From aa4bf0773361435c4bd411b74875a267d839e1a7 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Fri, 6 Feb 2026 00:03:46 +0100 Subject: [PATCH 3/6] Fix browser tests? --- app/composables/npm/usePackage.ts | 9 +++++++-- app/pages/package/[...package].vue | 23 ++++++++++++----------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/composables/npm/usePackage.ts b/app/composables/npm/usePackage.ts index e0d900f50..7f76a8eab 100644 --- a/app/composables/npm/usePackage.ts +++ b/app/composables/npm/usePackage.ts @@ -12,7 +12,10 @@ const RECENT_VERSIONS_COUNT = 5 * - Including only: 5 most recent versions + one version per dist-tag + requested version * - Stripping unnecessary fields from version objects */ -function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument { +export function transformPackument( + pkg: Packument, + requestedVersion?: string | null, +): SlimPackument { // Get versions pointed to by dist-tags const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) @@ -53,7 +56,9 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S } } filteredVersions[v] = { - ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}), + ...((version?.dist as { attestations?: unknown } | undefined)?.attestations + ? { hasProvenance: true } + : {}), version: version.version, deprecated: version.deprecated, tags: version.tags as string[], diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue index 840c5a781..927ef661c 100644 --- a/app/pages/package/[...package].vue +++ b/app/pages/package/[...package].vue @@ -1,6 +1,7 @@