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
9 changes: 7 additions & 2 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? {}))

Expand Down Expand Up @@ -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[],
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
44 changes: 44 additions & 0 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type {
NpmVersionDist,
PackageVersionInfo,
PackumentVersion,
ProvenanceDetails,
ReadmeResponse,
Expand All @@ -13,6 +14,7 @@ import { areUrlsEquivalent } from '#shared/utils/url'
import { isEditableElement } from '~/utils/input'
import { formatBytes } from '~/utils/formatters'
import { getDependencyCount } from '~/utils/npm/dependency-count'
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 @@ -143,6 +145,16 @@ const {
error,
} = usePackage(packageName, resolvedVersion.value ?? requestedVersion.value)
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
if (!pkg.value) return []

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

// Process package description
const pkgDescription = useMarkdown(() => ({
Expand Down Expand Up @@ -225,6 +237,17 @@ 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 installVersionOverride = computed(() => {
if (!publishSecurityDowngrade.value) return null
return publishSecurityDowngrade.value.trustedVersion
})

const sizeTooltip = computed(() => {
const chunks = [
displayVersion.value &&
Expand Down Expand Up @@ -1088,9 +1111,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
}

/**
* 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.at(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.at(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,
}
}
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
Loading
Loading