Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
46 changes: 43 additions & 3 deletions app/composables/npm/usePackage.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
import type { Packument, SlimPackument, SlimVersion, SlimPackumentVersion } from '#shared/types'
import type {
Packument,
SlimPackument,
SlimVersion,
SlimPackumentVersion,
PackumentVersion,
PublishTrustLevel,
} from '#shared/types'
import { NPM_REGISTRY } from '~/utils/npm/common'
import { extractInstallScriptsInfo } from '~/utils/install-scripts'

/** Number of recent versions to include in initial payload */
const RECENT_VERSIONS_COUNT = 5

function hasAttestations(version: PackumentVersion): boolean {
return Boolean(version.dist.attestations)
}

function hasTrustedPublisher(version: PackumentVersion): boolean {
return Boolean(version._npmUser?.trustedPublisher)
}

function getTrustLevel(version: PackumentVersion): PublishTrustLevel {
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 +59,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,
}
})
Comment on lines +62 to +71
Copy link
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 +87,12 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
installScripts: installScripts ?? undefined,
}
}
const trustLevel = getTrustLevel(version)
const hasProvenance = trustLevel !== 'none'

filteredVersions[v] = {
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
hasProvenance,
trustLevel,
Comment on lines +90 to +95
Copy link
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 +130,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
45 changes: 45 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,16 @@ 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(
() => publishSecurityDowngrade.value?.trustedVersion ?? null,
)

const sizeTooltip = computed(() => {
const chunks = [
displayVersion.value &&
Expand Down Expand Up @@ -1088,9 +1112,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
94 changes: 94 additions & 0 deletions app/utils/publish-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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.isNaN(a.timestamp)
const bValid = !Number.isNaN(b.timestamp)

if (!aValid && !bValid) {
// Fall back to semver comparison if no valid timestamps
const semverOrder = compare(b.version, a.version)
if (semverOrder !== 0) return semverOrder

// If semver is also equal, maintain original order
return a.index - b.index
}

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

return b.timestamp - a.timestamp
}

/**
* 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