Skip to content

Commit 8ef1073

Browse files
authored
feat: detect npm publish security downgrade (#1053)
1 parent d2d893e commit 8ef1073

File tree

13 files changed

+974
-11
lines changed

13 files changed

+974
-11
lines changed

app/components/Terminal/Install.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PackageManagerId } from '~/utils/install-command'
55
const props = defineProps<{
66
packageName: string
77
requestedVersion?: string | null
8+
installVersionOverride?: string | null
89
jsrInfo?: JsrPackageInfo | null
910
typesPackageName?: string | null
1011
executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null
@@ -16,14 +17,15 @@ const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstal
1617
() => props.requestedVersion ?? null,
1718
() => props.jsrInfo ?? null,
1819
() => props.typesPackageName ?? null,
20+
() => props.installVersionOverride ?? null,
1921
)
2022
2123
// Generate install command parts for a specific package manager
2224
function getInstallPartsForPM(pmId: PackageManagerId) {
2325
return getInstallCommandParts({
2426
packageName: props.packageName,
2527
packageManager: pmId,
26-
version: props.requestedVersion,
28+
version: props.installVersionOverride ?? props.requestedVersion,
2729
jsrInfo: props.jsrInfo,
2830
})
2931
}

app/composables/npm/usePackage.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
1-
import type { Packument, SlimPackument, SlimVersion, SlimPackumentVersion } from '#shared/types'
1+
import type {
2+
Packument,
3+
SlimPackument,
4+
SlimVersion,
5+
SlimPackumentVersion,
6+
PackumentVersion,
7+
PublishTrustLevel,
8+
} from '#shared/types'
29
import { extractInstallScriptsInfo } from '~/utils/install-scripts'
310

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

14+
function hasAttestations(version: PackumentVersion): boolean {
15+
return Boolean(version.dist.attestations)
16+
}
17+
18+
function hasTrustedPublisher(version: PackumentVersion): boolean {
19+
return Boolean(version._npmUser?.trustedPublisher)
20+
}
21+
22+
function getTrustLevel(version: PackumentVersion): PublishTrustLevel {
23+
if (hasAttestations(version)) return 'provenance'
24+
if (hasTrustedPublisher(version)) return 'trustedPublisher'
25+
return 'none'
26+
}
27+
728
/**
829
* Transform a full Packument into a slimmed version for client-side use.
930
* Reduces payload size by:
1031
* - Removing readme (fetched separately)
1132
* - Including only: 5 most recent versions + one version per dist-tag + requested version
1233
* - Stripping unnecessary fields from version objects
1334
*/
14-
function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument {
35+
export function transformPackument(
36+
pkg: Packument,
37+
requestedVersion?: string | null,
38+
): SlimPackument {
1539
// Get versions pointed to by dist-tags
1640
const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {}))
1741

@@ -34,6 +58,23 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
3458
includedVersions.add(requestedVersion)
3559
}
3660

61+
// Build security metadata for all versions, but only include in payload
62+
// when the package has mixed trust levels (i.e. a downgrade could exist)
63+
const securityVersionEntries = Object.entries(pkg.versions).map(([version, metadata]) => {
64+
const trustLevel = getTrustLevel(metadata)
65+
return {
66+
version,
67+
time: pkg.time[version],
68+
hasProvenance: trustLevel !== 'none',
69+
trustLevel,
70+
deprecated: metadata.deprecated,
71+
}
72+
})
73+
74+
const trustLevels = new Set(securityVersionEntries.map(v => v.trustLevel))
75+
const hasMixedTrust = trustLevels.size > 1
76+
const securityVersions = hasMixedTrust ? securityVersionEntries : undefined
77+
3778
// Build filtered versions object with install scripts info per version
3879
const filteredVersions: Record<string, SlimVersion> = {}
3980
let versionData: SlimPackumentVersion | null = null
@@ -51,8 +92,12 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
5192
installScripts: installScripts ?? undefined,
5293
}
5394
}
95+
const trustLevel = getTrustLevel(version)
96+
const hasProvenance = trustLevel !== 'none'
97+
5498
filteredVersions[v] = {
55-
...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}),
99+
hasProvenance,
100+
trustLevel,
56101
version: version.version,
57102
deprecated: version.deprecated,
58103
tags: version.tags as string[],
@@ -90,6 +135,7 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
90135
'bugs': pkg.bugs,
91136
'requestedVersion': versionData,
92137
'versions': filteredVersions,
138+
'securityVersions': securityVersions,
93139
}
94140
}
95141

app/composables/useInstallCommand.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export function useInstallCommand(
99
requestedVersion: MaybeRefOrGetter<string | null>,
1010
jsrInfo: MaybeRefOrGetter<JsrPackageInfo | null>,
1111
typesPackageName: MaybeRefOrGetter<string | null>,
12+
installVersionOverride?: MaybeRefOrGetter<string | null>,
1213
) {
1314
const selectedPM = useSelectedPackageManager()
1415
const { settings } = useSettings()
@@ -21,21 +22,23 @@ export function useInstallCommand(
2122
const installCommandParts = computed(() => {
2223
const name = toValue(packageName)
2324
if (!name) return []
25+
const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
2426
return getInstallCommandParts({
2527
packageName: name,
2628
packageManager: selectedPM.value,
27-
version: toValue(requestedVersion),
29+
version,
2830
jsrInfo: toValue(jsrInfo),
2931
})
3032
})
3133

3234
const installCommand = computed(() => {
3335
const name = toValue(packageName)
3436
if (!name) return ''
37+
const version = toValue(installVersionOverride) ?? toValue(requestedVersion)
3538
return getInstallCommand({
3639
packageName: name,
3740
packageManager: selectedPM.value,
38-
version: toValue(requestedVersion),
41+
version,
3942
jsrInfo: toValue(jsrInfo),
4043
})
4144
})

app/pages/package/[[org]]/[name].vue

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type {
33
NpmVersionDist,
4+
PackageVersionInfo,
45
PackumentVersion,
56
ProvenanceDetails,
67
ReadmeResponse,
@@ -12,6 +13,7 @@ import { joinURL } from 'ufo'
1213
import { areUrlsEquivalent } from '#shared/utils/url'
1314
import { isEditableElement } from '~/utils/input'
1415
import { getDependencyCount } from '~/utils/npm/dependency-count'
16+
import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
1517
import { useModal } from '~/composables/useModal'
1618
import { useAtproto } from '~/composables/atproto/useAtproto'
1719
import { togglePackageLike } from '~/utils/atproto/likes'
@@ -143,6 +145,18 @@ const {
143145
error,
144146
} = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value)
145147
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
148+
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
149+
if (!pkg.value) return []
150+
if (pkg.value.securityVersions?.length) return pkg.value.securityVersions
151+
152+
return Object.entries(pkg.value.versions).map(([version, metadata]) => ({
153+
version,
154+
time: pkg.value?.time?.[version],
155+
hasProvenance: !!metadata.hasProvenance,
156+
trustLevel: metadata.trustLevel,
157+
deprecated: metadata.deprecated,
158+
}))
159+
})
146160
147161
// Process package description
148162
const pkgDescription = useMarkdown(() => ({
@@ -225,6 +239,30 @@ const deprecationNoticeMessage = useMarkdown(() => ({
225239
text: deprecationNotice.value?.message ?? '',
226240
}))
227241
242+
const publishSecurityDowngrade = computed(() => {
243+
const currentVersion = displayVersion.value?.version
244+
if (!currentVersion) return null
245+
return detectPublishSecurityDowngradeForVersion(versionSecurityMetadata.value, currentVersion)
246+
})
247+
248+
const installVersionOverride = computed(
249+
() => publishSecurityDowngrade.value?.trustedVersion ?? null,
250+
)
251+
252+
const downgradeFallbackInstallText = computed(() => {
253+
const d = publishSecurityDowngrade.value
254+
if (!d?.trustedVersion) return null
255+
if (d.trustedTrustLevel === 'provenance')
256+
return $t('package.security_downgrade.fallback_install_provenance', {
257+
version: d.trustedVersion,
258+
})
259+
if (d.trustedTrustLevel === 'trustedPublisher')
260+
return $t('package.security_downgrade.fallback_install_trustedPublisher', {
261+
version: d.trustedVersion,
262+
})
263+
return null
264+
})
265+
228266
const sizeTooltip = computed(() => {
229267
const chunks = [
230268
displayVersion.value &&
@@ -1020,9 +1058,96 @@ onKeyStroke(
10201058
:id="`pm-panel-${activePmId}`"
10211059
:aria-labelledby="`pm-tab-${activePmId}`"
10221060
>
1061+
<div
1062+
v-if="publishSecurityDowngrade"
1063+
role="alert"
1064+
class="mb-4 rounded-lg border border-amber-600/40 bg-amber-500/10 px-4 py-3 text-amber-700 dark:text-amber-400"
1065+
>
1066+
<h3 class="m-0 flex items-center gap-2 font-mono text-sm font-medium">
1067+
<span class="i-carbon:warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
1068+
{{ $t('package.security_downgrade.title') }}
1069+
</h3>
1070+
<p class="mt-2 mb-0 text-sm">
1071+
<i18n-t
1072+
v-if="
1073+
publishSecurityDowngrade.downgradedTrustLevel === 'none' &&
1074+
publishSecurityDowngrade.trustedTrustLevel === 'provenance'
1075+
"
1076+
keypath="package.security_downgrade.description_to_none_provenance"
1077+
tag="span"
1078+
scope="global"
1079+
>
1080+
<template #provenance>
1081+
<a
1082+
href="https://docs.npmjs.com/generating-provenance-statements"
1083+
target="_blank"
1084+
rel="noopener noreferrer"
1085+
class="inline-flex items-center gap-1 rounded-sm underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg focus-visible:decoration-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 transition-colors"
1086+
>{{ $t('package.security_downgrade.provenance_link_text')
1087+
}}<span class="i-carbon-launch w-3 h-3" aria-hidden="true"
1088+
/></a>
1089+
</template>
1090+
</i18n-t>
1091+
<i18n-t
1092+
v-else-if="
1093+
publishSecurityDowngrade.downgradedTrustLevel === 'none' &&
1094+
publishSecurityDowngrade.trustedTrustLevel === 'trustedPublisher'
1095+
"
1096+
keypath="package.security_downgrade.description_to_none_trustedPublisher"
1097+
tag="span"
1098+
scope="global"
1099+
>
1100+
<template #trustedPublishing>
1101+
<a
1102+
href="https://docs.npmjs.com/adding-a-trusted-publisher-to-a-package"
1103+
target="_blank"
1104+
rel="noopener noreferrer"
1105+
class="inline-flex items-center gap-1 rounded-sm underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg focus-visible:decoration-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 transition-colors"
1106+
>{{ $t('package.security_downgrade.trusted_publishing_link_text')
1107+
}}<span class="i-carbon-launch w-3 h-3" aria-hidden="true"
1108+
/></a>
1109+
</template>
1110+
</i18n-t>
1111+
<i18n-t
1112+
v-else-if="
1113+
publishSecurityDowngrade.downgradedTrustLevel === 'provenance' &&
1114+
publishSecurityDowngrade.trustedTrustLevel === 'trustedPublisher'
1115+
"
1116+
keypath="package.security_downgrade.description_to_provenance_trustedPublisher"
1117+
tag="span"
1118+
scope="global"
1119+
>
1120+
<template #provenance>
1121+
<a
1122+
href="https://docs.npmjs.com/generating-provenance-statements"
1123+
target="_blank"
1124+
rel="noopener noreferrer"
1125+
class="inline-flex items-center gap-1 rounded-sm underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg focus-visible:decoration-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 transition-colors"
1126+
>{{ $t('package.security_downgrade.provenance_link_text')
1127+
}}<span class="i-carbon-launch w-3 h-3" aria-hidden="true"
1128+
/></a>
1129+
</template>
1130+
<template #trustedPublishing>
1131+
<a
1132+
href="https://docs.npmjs.com/adding-a-trusted-publisher-to-a-package"
1133+
target="_blank"
1134+
rel="noopener noreferrer"
1135+
class="inline-flex items-center gap-1 rounded-sm underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg focus-visible:decoration-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 transition-colors"
1136+
>{{ $t('package.security_downgrade.trusted_publishing_link_text')
1137+
}}<span class="i-carbon-launch w-3 h-3" aria-hidden="true"
1138+
/></a>
1139+
</template>
1140+
</i18n-t>
1141+
{{ ' ' }}
1142+
<template v-if="downgradeFallbackInstallText">
1143+
{{ downgradeFallbackInstallText }}
1144+
</template>
1145+
</p>
1146+
</div>
10231147
<TerminalInstall
10241148
:package-name="pkg.name"
10251149
:requested-version="requestedVersion"
1150+
:install-version-override="installVersionOverride"
10261151
:jsr-info="jsrInfo"
10271152
:types-package-name="typesPackageName"
10281153
:executable-info="executableInfo"

0 commit comments

Comments
 (0)