Skip to content
Merged
4 changes: 3 additions & 1 deletion app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,14 +17,15 @@ 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
function getInstallPartsForPM(pmId: PackageManagerId) {
return getInstallCommandParts({
packageName: props.packageName,
packageManager: pmId,
version: props.requestedVersion,
version: props.installVersionOverride ?? props.requestedVersion,
jsrInfo: props.jsrInfo,
})
}
Expand Down
7 changes: 5 additions & 2 deletions app/composables/useInstallCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function useInstallCommand(
requestedVersion: MaybeRefOrGetter<string | null>,
jsrInfo: MaybeRefOrGetter<JsrPackageInfo | null>,
typesPackageName: MaybeRefOrGetter<string | null>,
installVersionOverride?: MaybeRefOrGetter<string | null>,
) {
const selectedPM = useSelectedPackageManager()
const { settings } = useSettings()
Expand All @@ -21,21 +22,23 @@ 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),
})
})

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),
})
})
Expand Down
43 changes: 43 additions & 0 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
},
)
Comment thread
wojtekmaj marked this conversation as resolved.
Outdated

if (
versionStatus.value === 'error' &&
versionError.value?.statusCode &&
Expand Down Expand Up @@ -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
Comment thread
wojtekmaj marked this conversation as resolved.
Outdated
})

const sizeTooltip = computed(() => {
const chunks = [
displayVersion.value &&
Expand Down Expand Up @@ -1088,9 +1110,30 @@ onKeyStroke(
:id="`pm-panel-${activePmId}`"
:aria-labelledby="`pm-tab-${activePmId}`"
>
<div
v-if="publishSecurityDowngrade"
role="alert"
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"
>
<h3 class="m-0 flex items-center gap-2 font-mono text-sm font-semibold tracking-wide">
<span class="i-carbon-warning-filled w-4 h-4 shrink-0" aria-hidden="true" />
{{ $t('package.security_downgrade.title') }}
</h3>
<p class="mt-2 mb-0 text-sm">
{{ $t('package.security_downgrade.description') }}
</p>
<p class="mt-2 mb-0 text-sm">
{{
$t('package.security_downgrade.fallback_install', {
version: publishSecurityDowngrade.trustedVersion,
})
}}
</p>
</div>
<TerminalInstall
:package-name="pkg.name"
:requested-version="requestedVersion"
:install-version-override="installVersionOverride"
:jsr-info="jsrInfo"
:types-package-name="typesPackageName"
:executable-info="executableInfo"
Expand Down
104 changes: 104 additions & 0 deletions app/utils/publish-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { PackageVersionInfo } from '#shared/types'
import { compare } from 'semver'

export interface PublishSecurityDowngrade {
downgradedVersion: string
downgradedPublishedAt?: string
trustedVersion: string
trustedPublishedAt?: string
}

type VersionWithIndex = PackageVersionInfo & {
index: number
timestamp: number
}

function toTimestamp(time?: string): number {
if (!time) return Number.NaN
return Date.parse(time)
}

function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number {
const aValid = Number.isFinite(a.timestamp)
const bValid = Number.isFinite(b.timestamp)

if (aValid && bValid && a.timestamp !== b.timestamp) {
return b.timestamp - a.timestamp
}

if (aValid !== bValid) {
return aValid ? -1 : 1
}

const semverOrder = compare(b.version, a.version)
if (semverOrder !== 0) return semverOrder

return a.index - b.index
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Detects a security downgrade where the newest publish is not trusted,
* but an older publish was trusted (e.g. OIDC/provenance -> 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]
Comment thread
wojtekmaj marked this conversation as resolved.
Outdated
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]
Comment thread
wojtekmaj marked this conversation as resolved.
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,
}
}
5 changes: 5 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions test/nuxt/composables/use-install-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)
const installVersionOverride = shallowRef<string | null>('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', () => {
Expand Down
98 changes: 98 additions & 0 deletions test/unit/app/utils/publish-security.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
Loading