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
41 changes: 39 additions & 2 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,35 @@ import { extractInstallScriptsInfo } from '~/utils/install-scripts'
/** Number of recent versions to include in initial payload */
const RECENT_VERSIONS_COUNT = 5

function hasAttestations(version: Packument['versions'][string]): boolean {
return Boolean(version.dist.attestations)
}

function hasTrustedPublisher(version: Packument['versions'][string]): boolean {
return Boolean(version._npmUser?.trustedPublisher)
}

function hasPublishTrustEvidence(version: Packument['versions'][string]): boolean {
return hasAttestations(version) || hasTrustedPublisher(version)
}

function getTrustLevel(version: Packument['versions'][string]): SlimVersion['trustLevel'] {
if (hasAttestations(version)) return 'provenance'
if (hasTrustedPublisher(version)) return 'trustedPublisher'
return 'none'
}

/**
* Transform a full Packument into a slimmed version for client-side use.
* Reduces payload size by:
* - Removing readme (fetched separately)
* - 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 All @@ -35,6 +56,17 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
includedVersions.add(requestedVersion)
}

const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: trustLevel !== 'none',
trustLevel,
deprecated: metadata.deprecated,
}
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

hasProvenance conflates trusted-publisher evidence with attestation provenance.

On line 64, hasProvenance: trustLevel !== 'none' means a version published via a trusted publisher (OIDC) but without SLSA attestations is marked as hasProvenance: true. This is semantically incorrect — "provenance" specifically refers to verifiable build attestations, not publisher identity trust. The downstream getTrustRank fallback in publish-security.ts (line 25: return version.hasProvenance ? TRUST_RANK.provenance : TRUST_RANK.none) would then incorrectly elevate a trustedPublisher version to provenance rank when trustLevel is absent.

Consider using the attestation-only check for hasProvenance and relying on trustLevel for the broader trust signal:

🐛 Proposed fix
     return {
       version,
       time: pkg.time[version],
-      hasProvenance: trustLevel !== 'none',
+      hasProvenance: hasAttestations(metadata),
       trustLevel,
       deprecated: metadata.deprecated,
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: trustLevel !== 'none',
trustLevel,
deprecated: metadata.deprecated,
}
})
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
const trustLevel = getTrustLevel(metadata)
return {
version,
time: pkg.time[version],
hasProvenance: hasAttestations(metadata),
trustLevel,
deprecated: metadata.deprecated,
}
})


// Build filtered versions object with install scripts info per version
const filteredVersions: Record<string, SlimVersion> = {}
let versionData: SlimPackumentVersion | null = null
Expand All @@ -52,8 +84,12 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
installScripts: installScripts ?? undefined,
}
}
const hasProvenance = hasPublishTrustEvidence(version)
const trustLevel = getTrustLevel(version)

filteredVersions[v] = {
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
hasProvenance,
trustLevel,
Comment on lines +95 to +100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same hasProvenance conflation applies here in the filtered versions.

Lines 90–91 replicate the same trustLevel !== 'none'hasProvenance pattern flagged above. This should use hasAttestations(version) for consistency and correctness.

Proposed fix
-      const trustLevel = getTrustLevel(version)
-      const hasProvenance = trustLevel !== 'none'
+      const trustLevel = getTrustLevel(version)
+      const hasProvenance = hasAttestations(version)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const trustLevel = getTrustLevel(version)
const hasProvenance = trustLevel !== 'none'
filteredVersions[v] = {
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
hasProvenance,
trustLevel,
const trustLevel = getTrustLevel(version)
const hasProvenance = hasAttestations(version)
filteredVersions[v] = {
hasProvenance,
trustLevel,

version: version.version,
deprecated: version.deprecated,
tags: version.tags as string[],
Expand Down Expand Up @@ -91,6 +127,7 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
'bugs': pkg.bugs,
'requestedVersion': versionData,
'versions': filteredVersions,
'securityVersions': securityVersions,
}
}

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
46 changes: 46 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,18 @@ const {
error,
} = usePackage(packageName, resolvedVersion.value ?? requestedVersion.value)
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
if (!pkg.value) return []
if (pkg.value.securityVersions?.length) return pkg.value.securityVersions

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

// Process package description
const pkgDescription = useMarkdown(() => ({
Expand Down Expand Up @@ -225,6 +239,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 +1113,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
130 changes: 130 additions & 0 deletions app/utils/publish-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { PackageVersionInfo, PublishTrustLevel } 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
trustRank: number
}

const TRUST_RANK: Record<PublishTrustLevel, number> = {
none: 0,
trustedPublisher: 1,
provenance: 2,
}

function getTrustRank(version: PackageVersionInfo): number {
if (version.trustLevel) return TRUST_RANK[version.trustLevel]
return version.hasProvenance ? TRUST_RANK.provenance : TRUST_RANK.none
}

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),
trustRank: getTrustRank(version),
}))
.sort(sortByRecency)

const latest = sorted.at(0)
if (!latest) return null

let strongestOlder: VersionWithIndex | null = null
for (const version of sorted.slice(1)) {
if (!strongestOlder || version.trustRank > strongestOlder.trustRank) {
strongestOlder = version
}
}

if (!strongestOlder || strongestOlder.trustRank <= latest.trustRank) return null

return {
downgradedVersion: latest.version,
downgradedPublishedAt: latest.time,
trustedVersion: strongestOlder.version,
trustedPublishedAt: strongestOlder.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),
trustRank: getTrustRank(version),
}))
.sort(sortByRecency)

const currentIndex = sorted.findIndex(version => version.version === viewedVersion)
if (currentIndex === -1) return null

const current = sorted.at(currentIndex)
if (!current) return null

let strongestOlder: VersionWithIndex | null = null
for (const version of sorted.slice(currentIndex + 1)) {
if (!strongestOlder || version.trustRank > strongestOlder.trustRank) {
strongestOlder = version
}
}

if (!strongestOlder || strongestOlder.trustRank <= current.trustRank) return null

return {
downgradedVersion: current.version,
downgradedPublishedAt: current.time,
trustedVersion: strongestOlder.version,
trustedPublishedAt: strongestOlder.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
Loading
Loading