[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: detect npm publish security downgrade (#1053)

authored by

Wojciech Maj and committed by
GitHub
8ef10730 d2d893e6

+974 -11
+3 -1
app/components/Terminal/Install.vue
··· 5 5 const props = defineProps<{ 6 6 packageName: string 7 7 requestedVersion?: string | null 8 + installVersionOverride?: string | null 8 9 jsrInfo?: JsrPackageInfo | null 9 10 typesPackageName?: string | null 10 11 executableInfo?: { hasExecutable: boolean; primaryCommand?: string } | null ··· 16 17 () => props.requestedVersion ?? null, 17 18 () => props.jsrInfo ?? null, 18 19 () => props.typesPackageName ?? null, 20 + () => props.installVersionOverride ?? null, 19 21 ) 20 22 21 23 // Generate install command parts for a specific package manager ··· 23 25 return getInstallCommandParts({ 24 26 packageName: props.packageName, 25 27 packageManager: pmId, 26 - version: props.requestedVersion, 28 + version: props.installVersionOverride ?? props.requestedVersion, 27 29 jsrInfo: props.jsrInfo, 28 30 }) 29 31 }
+49 -3
app/composables/npm/usePackage.ts
··· 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' 2 9 import { extractInstallScriptsInfo } from '~/utils/install-scripts' 3 10 4 11 /** Number of recent versions to include in initial payload */ 5 12 const RECENT_VERSIONS_COUNT = 5 6 13 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 + 7 28 /** 8 29 * Transform a full Packument into a slimmed version for client-side use. 9 30 * Reduces payload size by: ··· 11 32 * - Including only: 5 most recent versions + one version per dist-tag + requested version 12 33 * - Stripping unnecessary fields from version objects 13 34 */ 14 - function transformPackument(pkg: Packument, requestedVersion?: string | null): SlimPackument { 35 + export function transformPackument( 36 + pkg: Packument, 37 + requestedVersion?: string | null, 38 + ): SlimPackument { 15 39 // Get versions pointed to by dist-tags 16 40 const distTagVersions = new Set(Object.values(pkg['dist-tags'] ?? {})) 17 41 ··· 34 58 includedVersions.add(requestedVersion) 35 59 } 36 60 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 + 37 78 // Build filtered versions object with install scripts info per version 38 79 const filteredVersions: Record<string, SlimVersion> = {} 39 80 let versionData: SlimPackumentVersion | null = null ··· 51 92 installScripts: installScripts ?? undefined, 52 93 } 53 94 } 95 + const trustLevel = getTrustLevel(version) 96 + const hasProvenance = trustLevel !== 'none' 97 + 54 98 filteredVersions[v] = { 55 - ...((version?.dist as { attestations?: unknown }) ? { hasProvenance: true } : {}), 99 + hasProvenance, 100 + trustLevel, 56 101 version: version.version, 57 102 deprecated: version.deprecated, 58 103 tags: version.tags as string[], ··· 90 135 'bugs': pkg.bugs, 91 136 'requestedVersion': versionData, 92 137 'versions': filteredVersions, 138 + 'securityVersions': securityVersions, 93 139 } 94 140 } 95 141
+5 -2
app/composables/useInstallCommand.ts
··· 9 9 requestedVersion: MaybeRefOrGetter<string | null>, 10 10 jsrInfo: MaybeRefOrGetter<JsrPackageInfo | null>, 11 11 typesPackageName: MaybeRefOrGetter<string | null>, 12 + installVersionOverride?: MaybeRefOrGetter<string | null>, 12 13 ) { 13 14 const selectedPM = useSelectedPackageManager() 14 15 const { settings } = useSettings() ··· 21 22 const installCommandParts = computed(() => { 22 23 const name = toValue(packageName) 23 24 if (!name) return [] 25 + const version = toValue(installVersionOverride) ?? toValue(requestedVersion) 24 26 return getInstallCommandParts({ 25 27 packageName: name, 26 28 packageManager: selectedPM.value, 27 - version: toValue(requestedVersion), 29 + version, 28 30 jsrInfo: toValue(jsrInfo), 29 31 }) 30 32 }) ··· 32 34 const installCommand = computed(() => { 33 35 const name = toValue(packageName) 34 36 if (!name) return '' 37 + const version = toValue(installVersionOverride) ?? toValue(requestedVersion) 35 38 return getInstallCommand({ 36 39 packageName: name, 37 40 packageManager: selectedPM.value, 38 - version: toValue(requestedVersion), 41 + version, 39 42 jsrInfo: toValue(jsrInfo), 40 43 }) 41 44 })
+125
app/pages/package/[[org]]/[name].vue
··· 1 1 <script setup lang="ts"> 2 2 import type { 3 3 NpmVersionDist, 4 + PackageVersionInfo, 4 5 PackumentVersion, 5 6 ProvenanceDetails, 6 7 ReadmeResponse, ··· 12 13 import { areUrlsEquivalent } from '#shared/utils/url' 13 14 import { isEditableElement } from '~/utils/input' 14 15 import { getDependencyCount } from '~/utils/npm/dependency-count' 16 + import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security' 15 17 import { useModal } from '~/composables/useModal' 16 18 import { useAtproto } from '~/composables/atproto/useAtproto' 17 19 import { togglePackageLike } from '~/utils/atproto/likes' ··· 143 145 error, 144 146 } = usePackage(packageName, () => resolvedVersion.value ?? requestedVersion.value) 145 147 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 + }) 146 160 147 161 // Process package description 148 162 const pkgDescription = useMarkdown(() => ({ ··· 224 238 const deprecationNoticeMessage = useMarkdown(() => ({ 225 239 text: deprecationNotice.value?.message ?? '', 226 240 })) 241 + 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 + }) 227 265 228 266 const sizeTooltip = computed(() => { 229 267 const chunks = [ ··· 1020 1058 :id="`pm-panel-${activePmId}`" 1021 1059 :aria-labelledby="`pm-tab-${activePmId}`" 1022 1060 > 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> 1023 1147 <TerminalInstall 1024 1148 :package-name="pkg.name" 1025 1149 :requested-version="requestedVersion" 1150 + :install-version-override="installVersionOverride" 1026 1151 :jsr-info="jsrInfo" 1027 1152 :types-package-name="typesPackageName" 1028 1153 :executable-info="executableInfo"
+123
app/utils/publish-security.ts
··· 1 + import type { PackageVersionInfo, PublishTrustLevel } from '#shared/types' 2 + import { compare, major } from 'semver' 3 + 4 + export interface PublishSecurityDowngrade { 5 + downgradedVersion: string 6 + downgradedPublishedAt?: string 7 + downgradedTrustLevel: PublishTrustLevel 8 + /** Recommended trusted version within the same major, if one exists */ 9 + trustedVersion?: string 10 + trustedPublishedAt?: string 11 + trustedTrustLevel: PublishTrustLevel 12 + } 13 + 14 + type VersionWithIndex = PackageVersionInfo & { 15 + index: number 16 + timestamp: number 17 + trustRank: number 18 + resolvedTrustLevel: PublishTrustLevel 19 + } 20 + 21 + const TRUST_RANK: Record<PublishTrustLevel, number> = { 22 + none: 0, 23 + provenance: 1, 24 + trustedPublisher: 2, 25 + } 26 + 27 + function resolveTrustLevel(version: PackageVersionInfo): PublishTrustLevel { 28 + if (version.trustLevel) return version.trustLevel 29 + // Fallback for legacy data: hasProvenance only indicates non-'none' trust, 30 + // so map it to provenance (the lower rank) to avoid over-ranking 31 + return version.hasProvenance ? 'provenance' : 'none' 32 + } 33 + 34 + function toTimestamp(time?: string): number { 35 + if (!time) return Number.NaN 36 + return Date.parse(time) 37 + } 38 + 39 + function sortByRecency(a: VersionWithIndex, b: VersionWithIndex): number { 40 + const aValid = !Number.isNaN(a.timestamp) 41 + const bValid = !Number.isNaN(b.timestamp) 42 + 43 + if (!aValid && !bValid) { 44 + // Fall back to semver comparison if no valid timestamps 45 + const semverOrder = compare(b.version, a.version) 46 + if (semverOrder !== 0) return semverOrder 47 + 48 + // If semver is also equal, maintain original order 49 + return a.index - b.index 50 + } 51 + 52 + if (aValid !== bValid) { 53 + return aValid ? -1 : 1 54 + } 55 + 56 + return b.timestamp - a.timestamp 57 + } 58 + 59 + /** 60 + * Detects a security downgrade for a specific viewed version. 61 + * A version is considered downgraded when it has no provenance and 62 + * there exists an older trusted release. 63 + */ 64 + export function detectPublishSecurityDowngradeForVersion( 65 + versions: PackageVersionInfo[], 66 + viewedVersion: string, 67 + ): PublishSecurityDowngrade | null { 68 + if (versions.length < 2 || !viewedVersion) return null 69 + 70 + const sorted = versions 71 + .map((version, index) => { 72 + const resolvedTrustLevel = resolveTrustLevel(version) 73 + return { 74 + ...version, 75 + index, 76 + timestamp: toTimestamp(version.time), 77 + trustRank: TRUST_RANK[resolvedTrustLevel], 78 + resolvedTrustLevel, 79 + } 80 + }) 81 + .sort(sortByRecency) 82 + 83 + const currentIndex = sorted.findIndex(version => version.version === viewedVersion) 84 + if (currentIndex === -1) return null 85 + 86 + const current = sorted[currentIndex] 87 + if (!current) return null 88 + 89 + const currentMajor = major(current.version) 90 + 91 + // Find the strongest older version across all majors (for detection) 92 + // and the strongest within the same major (for recommendation) 93 + let strongestOlderAny: VersionWithIndex | null = null 94 + let strongestOlderSameMajor: VersionWithIndex | null = null 95 + for (const version of sorted.slice(currentIndex + 1)) { 96 + // Skip deprecated versions — recommending a deprecated version is misleading 97 + if (version.deprecated) continue 98 + if (!strongestOlderAny || version.trustRank > strongestOlderAny.trustRank) { 99 + strongestOlderAny = version 100 + } 101 + if (major(version.version) === currentMajor) { 102 + if (!strongestOlderSameMajor || version.trustRank > strongestOlderSameMajor.trustRank) { 103 + strongestOlderSameMajor = version 104 + } 105 + } 106 + } 107 + 108 + // Use same-major for recommendation if available, otherwise any-major for detection only 109 + const strongestOlder = strongestOlderSameMajor ?? strongestOlderAny 110 + if (!strongestOlder || strongestOlder.trustRank <= current.trustRank) return null 111 + 112 + // Only recommend a specific version if it's in the same major 113 + const recommendation = strongestOlderSameMajor 114 + 115 + return { 116 + downgradedVersion: current.version, 117 + downgradedPublishedAt: current.time, 118 + downgradedTrustLevel: current.resolvedTrustLevel, 119 + trustedVersion: recommendation?.version, 120 + trustedPublishedAt: recommendation?.time, 121 + trustedTrustLevel: strongestOlder.resolvedTrustLevel, 122 + } 123 + }
+10
i18n/locales/en.json
··· 256 256 "view_more_details": "View more details", 257 257 "error_loading": "Failed to load provenance details" 258 258 }, 259 + "security_downgrade": { 260 + "title": "Trust downgrade", 261 + "description_to_none_provenance": "This version was published without {provenance}.", 262 + "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.", 263 + "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.", 264 + "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.", 265 + "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.", 266 + "provenance_link_text": "provenance", 267 + "trusted_publishing_link_text": "trusted publishing" 268 + }, 259 269 "keywords_title": "Keywords", 260 270 "compatibility": "Compatibility", 261 271 "card": {
+30
i18n/schema.json
··· 772 772 }, 773 773 "additionalProperties": false 774 774 }, 775 + "security_downgrade": { 776 + "type": "object", 777 + "properties": { 778 + "title": { 779 + "type": "string" 780 + }, 781 + "description_to_none_provenance": { 782 + "type": "string" 783 + }, 784 + "description_to_none_trustedPublisher": { 785 + "type": "string" 786 + }, 787 + "description_to_provenance_trustedPublisher": { 788 + "type": "string" 789 + }, 790 + "fallback_install_provenance": { 791 + "type": "string" 792 + }, 793 + "fallback_install_trustedPublisher": { 794 + "type": "string" 795 + }, 796 + "provenance_link_text": { 797 + "type": "string" 798 + }, 799 + "trusted_publishing_link_text": { 800 + "type": "string" 801 + } 802 + }, 803 + "additionalProperties": false 804 + }, 775 805 "keywords_title": { 776 806 "type": "string" 777 807 },
+10
lunaria/files/en-GB.json
··· 255 255 "view_more_details": "View more details", 256 256 "error_loading": "Failed to load provenance details" 257 257 }, 258 + "security_downgrade": { 259 + "title": "Trust downgrade", 260 + "description_to_none_provenance": "This version was published without {provenance}.", 261 + "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.", 262 + "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.", 263 + "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.", 264 + "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.", 265 + "provenance_link_text": "provenance", 266 + "trusted_publishing_link_text": "trusted publishing" 267 + }, 258 268 "keywords_title": "Keywords", 259 269 "compatibility": "Compatibility", 260 270 "card": {
+10
lunaria/files/en-US.json
··· 255 255 "view_more_details": "View more details", 256 256 "error_loading": "Failed to load provenance details" 257 257 }, 258 + "security_downgrade": { 259 + "title": "Trust downgrade", 260 + "description_to_none_provenance": "This version was published without {provenance}.", 261 + "description_to_none_trustedPublisher": "This version was published without {trustedPublishing}.", 262 + "description_to_provenance_trustedPublisher": "This version uses {provenance} but not {trustedPublishing}.", 263 + "fallback_install_provenance": "Install commands are pinned to {version}, the last version with provenance.", 264 + "fallback_install_trustedPublisher": "Install commands are pinned to {version}, the last version with trusted publishing.", 265 + "provenance_link_text": "provenance", 266 + "trusted_publishing_link_text": "trusted publishing" 267 + }, 258 268 "keywords_title": "Keywords", 259 269 "compatibility": "Compatibility", 260 270 "card": {
+23 -5
shared/types/npm-registry.ts
··· 6 6 * @see https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md 7 7 */ 8 8 9 - import type { Packument as PackumentWithoutLicenseObjects, PackumentVersion } from '@npm/types' 9 + import type { 10 + Packument as PackumentWithoutLicenseObjects, 11 + PackumentVersion as PackumentVersionWithoutAttestations, 12 + Contact, 13 + } from '@npm/types' 10 14 import type { ReadmeResponse } from './readme' 11 15 12 16 // Re-export official npm types for packument/manifest 13 - export type { PackumentVersion, Manifest, ManifestVersion, PackageJSON } from '@npm/types' 17 + export type { Manifest, ManifestVersion, PackageJSON } from '@npm/types' 14 18 15 - // TODO: Remove this type override when @npm/types fixes the license field typing 16 - export type Packument = Omit<PackumentWithoutLicenseObjects, 'license'> & { 19 + type NpmTrustedPublisherEvidence = NpmSearchTrustedPublisher | NpmTrustedPublisher | true 20 + 21 + export interface PackumentVersion extends PackumentVersionWithoutAttestations { 22 + _npmUser?: Contact & { trustedPublisher?: NpmTrustedPublisherEvidence } 23 + dist: PackumentVersionWithoutAttestations['dist'] & { attestations?: NpmVersionAttestations } 24 + } 25 + 26 + export type Packument = Omit<PackumentWithoutLicenseObjects, 'license' | 'versions'> & { 17 27 // Fix for license field being incorrectly typed in @npm/types 28 + // TODO: Remove this type override when @npm/types fixes the license field typing 18 29 license?: string | { type: string; url?: string } 30 + versions: Record<string, PackumentVersion> 19 31 } 20 32 21 33 /** Install scripts info (preinstall, install, postinstall) */ ··· 30 42 installScripts?: InstallScriptsInfo 31 43 } 32 44 45 + export type PublishTrustLevel = 'none' | 'trustedPublisher' | 'provenance' 46 + 33 47 export type SlimVersion = Pick<SlimPackumentVersion, 'version' | 'deprecated' | 'tags'> & { 34 - hasProvenance?: true 48 + hasProvenance?: boolean 49 + trustLevel?: PublishTrustLevel 35 50 } 36 51 37 52 /** ··· 72 87 'requestedVersion': SlimPackumentVersion | null 73 88 /** Only includes dist-tag versions (with installScripts info added per version) */ 74 89 'versions': Record<string, SlimVersion> 90 + /** Lightweight security metadata for all versions */ 91 + 'securityVersions'?: PackageVersionInfo[] 75 92 } 76 93 77 94 /** ··· 81 98 version: string 82 99 time?: string 83 100 hasProvenance: boolean 101 + trustLevel?: PublishTrustLevel 84 102 deprecated?: string 85 103 } 86 104
+19
test/nuxt/composables/use-install-command.spec.ts
··· 260 260 version.value = '18.2.0' 261 261 expect(installCommand.value).toBe('npm install react@18.2.0') 262 262 }) 263 + 264 + it('should prefer installVersionOverride when provided', () => { 265 + const requestedVersion = shallowRef<string | null>(null) 266 + const installVersionOverride = shallowRef<string | null>('1.0.0') 267 + 268 + const { installCommand } = useInstallCommand( 269 + 'foo', 270 + requestedVersion, 271 + null, 272 + null, 273 + installVersionOverride, 274 + ) 275 + 276 + expect(installCommand.value).toBe('npm install foo@1.0.0') 277 + 278 + installVersionOverride.value = null 279 + requestedVersion.value = '2.0.0' 280 + expect(installCommand.value).toBe('npm install foo@2.0.0') 281 + }) 263 282 }) 264 283 265 284 describe('copyInstallCommand', () => {
+265
test/nuxt/composables/use-package-transform.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import type { Packument, PackageVersionInfo } from '#shared/types' 3 + import { transformPackument } from '~/composables/npm/usePackage' 4 + import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security' 5 + 6 + function createVersion(version: string, hasAttestations = false): Packument['versions'][string] { 7 + return { 8 + _id: `foo@${version}`, 9 + _npmVersion: '10.0.0', 10 + name: 'foo', 11 + version, 12 + dist: { 13 + shasum: version, 14 + tarball: `https://registry.npmjs.org/foo/-/foo-${version}.tgz`, 15 + signatures: [], 16 + ...(hasAttestations 17 + ? { 18 + attestations: { 19 + url: `https://example.test/${version}`, 20 + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, 21 + }, 22 + } 23 + : {}), 24 + }, 25 + } 26 + } 27 + 28 + function createTrustedPublisherVersion(version: string) { 29 + return { 30 + ...createVersion(version, false), 31 + _npmUser: { 32 + name: 'github-actions', 33 + email: 'noreply@github.com', 34 + trustedPublisher: { 35 + id: 'github', 36 + }, 37 + }, 38 + } 39 + } 40 + 41 + function createTrustedPublisherWithAttestationsVersion(version: string) { 42 + return { 43 + ...createVersion(version, true), 44 + _npmUser: { 45 + name: 'github-actions', 46 + email: 'noreply@github.com', 47 + trustedPublisher: { 48 + id: 'github', 49 + }, 50 + }, 51 + } 52 + } 53 + 54 + function createPackument( 55 + versions: Packument['versions'], 56 + time: Packument['time'], 57 + latest: string, 58 + ): Packument { 59 + return { 60 + '_id': 'foo', 61 + '_rev': '1', 62 + 'name': 'foo', 63 + 'dist-tags': { latest }, 64 + time, 65 + versions, 66 + } 67 + } 68 + 69 + function toVersionInfos(packument: ReturnType<typeof transformPackument>): PackageVersionInfo[] { 70 + return ( 71 + packument.securityVersions ?? 72 + Object.entries(packument.versions).map(([version, metadata]) => ({ 73 + version, 74 + time: packument.time[version], 75 + hasProvenance: !!metadata.hasProvenance, 76 + trustLevel: metadata.trustLevel, 77 + deprecated: metadata.deprecated, 78 + })) 79 + ) 80 + } 81 + 82 + describe('transformPackument', () => { 83 + it('includes requested old version and preserves provenance on it', () => { 84 + const packument = createPackument( 85 + { 86 + '1.0.0': createVersion('1.0.0', true), 87 + '1.0.1': createVersion('1.0.1'), 88 + '1.0.2': createVersion('1.0.2'), 89 + '1.0.3': createVersion('1.0.3'), 90 + '1.0.4': createVersion('1.0.4'), 91 + '1.0.5': createVersion('1.0.5'), 92 + '1.0.6': createVersion('1.0.6'), 93 + '1.0.7': createVersion('1.0.7'), 94 + }, 95 + { 96 + 'created': '2026-01-01T00:00:00.000Z', 97 + 'modified': '2026-01-08T00:00:00.000Z', 98 + '1.0.0': '2026-01-01T00:00:00.000Z', 99 + '1.0.1': '2026-01-02T00:00:00.000Z', 100 + '1.0.2': '2026-01-03T00:00:00.000Z', 101 + '1.0.3': '2026-01-04T00:00:00.000Z', 102 + '1.0.4': '2026-01-05T00:00:00.000Z', 103 + '1.0.5': '2026-01-06T00:00:00.000Z', 104 + '1.0.6': '2026-01-07T00:00:00.000Z', 105 + '1.0.7': '2026-01-08T00:00:00.000Z', 106 + }, 107 + '1.0.7', 108 + ) 109 + 110 + const transformed = transformPackument(packument, '1.0.0') 111 + 112 + expect(transformed.versions['1.0.0']?.hasProvenance).toBe(true) 113 + expect(transformed.versions['1.0.1']).toBeUndefined() 114 + expect(transformed.versions['1.0.2']).toBeUndefined() 115 + expect(transformed.securityVersions).toHaveLength(8) 116 + }) 117 + 118 + it('omits securityVersions when all versions have the same trust level', () => { 119 + const packument = createPackument( 120 + { 121 + '1.0.0': createVersion('1.0.0'), 122 + '1.0.1': createVersion('1.0.1'), 123 + '1.0.2': createVersion('1.0.2'), 124 + }, 125 + { 126 + 'created': '2026-01-01T00:00:00.000Z', 127 + 'modified': '2026-01-03T00:00:00.000Z', 128 + '1.0.0': '2026-01-01T00:00:00.000Z', 129 + '1.0.1': '2026-01-02T00:00:00.000Z', 130 + '1.0.2': '2026-01-03T00:00:00.000Z', 131 + }, 132 + '1.0.2', 133 + ) 134 + 135 + const transformed = transformPackument(packument, '1.0.2') 136 + 137 + // All versions have trustLevel 'none', so no mixed trust — omit the array 138 + expect(transformed.securityVersions).toBeUndefined() 139 + }) 140 + 141 + it('includes securityVersions when package has mixed trust levels', () => { 142 + const packument = createPackument( 143 + { 144 + '1.0.0': createVersion('1.0.0', true), 145 + '1.0.1': createVersion('1.0.1'), 146 + }, 147 + { 148 + 'created': '2026-01-01T00:00:00.000Z', 149 + 'modified': '2026-01-02T00:00:00.000Z', 150 + '1.0.0': '2026-01-01T00:00:00.000Z', 151 + '1.0.1': '2026-01-02T00:00:00.000Z', 152 + }, 153 + '1.0.1', 154 + ) 155 + 156 + const transformed = transformPackument(packument, '1.0.1') 157 + 158 + expect(transformed.securityVersions).toHaveLength(2) 159 + }) 160 + 161 + it('works with downgrade detection for viewed version', () => { 162 + const packument = createPackument( 163 + { 164 + '1.0.0': createVersion('1.0.0', true), 165 + '1.0.1': createVersion('1.0.1'), 166 + '1.0.2': createVersion('1.0.2', true), 167 + }, 168 + { 169 + 'created': '2026-01-01T00:00:00.000Z', 170 + 'modified': '2026-01-03T00:00:00.000Z', 171 + '1.0.0': '2026-01-01T00:00:00.000Z', 172 + '1.0.1': '2026-01-02T00:00:00.000Z', 173 + '1.0.2': '2026-01-03T00:00:00.000Z', 174 + }, 175 + '1.0.2', 176 + ) 177 + 178 + const transformed = transformPackument(packument, '1.0.1') 179 + const infos = toVersionInfos(transformed) 180 + 181 + expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.2')).toBeNull() 182 + expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')).toEqual({ 183 + downgradedVersion: '1.0.1', 184 + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', 185 + downgradedTrustLevel: 'none', 186 + trustedVersion: '1.0.0', 187 + trustedPublishedAt: '2026-01-01T00:00:00.000Z', 188 + trustedTrustLevel: 'provenance', 189 + }) 190 + }) 191 + 192 + it('treats trustedPublisher as trust evidence for downgrade checks', () => { 193 + const packument = createPackument( 194 + { 195 + '1.0.0': createTrustedPublisherVersion('1.0.0'), 196 + '1.0.1': createVersion('1.0.1'), 197 + '1.0.2': createVersion('1.0.2'), 198 + }, 199 + { 200 + 'created': '2026-01-01T00:00:00.000Z', 201 + 'modified': '2026-01-03T00:00:00.000Z', 202 + '1.0.0': '2026-01-01T00:00:00.000Z', 203 + '1.0.1': '2026-01-02T00:00:00.000Z', 204 + '1.0.2': '2026-01-03T00:00:00.000Z', 205 + }, 206 + '1.0.2', 207 + ) 208 + 209 + const transformed = transformPackument(packument, '1.0.1') 210 + const infos = toVersionInfos(transformed) 211 + 212 + expect(infos.find(v => v.version === '1.0.0')?.hasProvenance).toBe(true) 213 + expect(detectPublishSecurityDowngradeForVersion(infos, '1.0.1')?.trustedVersion).toBe('1.0.0') 214 + }) 215 + 216 + it('prefers provenance trust level when both trustedPublisher and attestations exist', () => { 217 + const packument = createPackument( 218 + { 219 + '1.0.0': createTrustedPublisherWithAttestationsVersion('1.0.0'), 220 + '1.0.1': createTrustedPublisherVersion('1.0.1'), 221 + }, 222 + { 223 + 'created': '2026-01-01T00:00:00.000Z', 224 + 'modified': '2026-01-02T00:00:00.000Z', 225 + '1.0.0': '2026-01-01T00:00:00.000Z', 226 + '1.0.1': '2026-01-02T00:00:00.000Z', 227 + }, 228 + '1.0.1', 229 + ) 230 + 231 + const transformed = transformPackument(packument, '1.0.1') 232 + 233 + expect(transformed.versions['1.0.0']?.trustLevel).toBe('provenance') 234 + }) 235 + 236 + it('flags non-direct downgrade chain until trust is restored', () => { 237 + const packument = createPackument( 238 + { 239 + '2.1.0': createVersion('2.1.0', true), 240 + '2.1.1': createVersion('2.1.1'), 241 + '2.2.0': createVersion('2.2.0'), 242 + '2.3.0': createVersion('2.3.0'), 243 + '2.4.0': createVersion('2.4.0', true), 244 + }, 245 + { 246 + 'created': '2026-01-01T00:00:00.000Z', 247 + 'modified': '2026-01-05T00:00:00.000Z', 248 + '2.1.0': '2026-01-01T00:00:00.000Z', 249 + '2.1.1': '2026-01-02T00:00:00.000Z', 250 + '2.2.0': '2026-01-03T00:00:00.000Z', 251 + '2.3.0': '2026-01-04T00:00:00.000Z', 252 + '2.4.0': '2026-01-05T00:00:00.000Z', 253 + }, 254 + '2.4.0', 255 + ) 256 + 257 + const transformed = transformPackument(packument, '2.3.0') 258 + const infos = toVersionInfos(transformed) 259 + 260 + expect(detectPublishSecurityDowngradeForVersion(infos, '2.1.1')?.trustedVersion).toBe('2.1.0') 261 + expect(detectPublishSecurityDowngradeForVersion(infos, '2.2.0')?.trustedVersion).toBe('2.1.0') 262 + expect(detectPublishSecurityDowngradeForVersion(infos, '2.3.0')?.trustedVersion).toBe('2.1.0') 263 + expect(detectPublishSecurityDowngradeForVersion(infos, '2.4.0')).toBeNull() 264 + }) 265 + })
+302
test/unit/app/utils/publish-security.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { detectPublishSecurityDowngradeForVersion } from '../../../../app/utils/publish-security' 3 + 4 + describe('detectPublishSecurityDowngradeForVersion', () => { 5 + const versions = [ 6 + { 7 + version: '1.0.0', 8 + time: '2026-01-01T00:00:00.000Z', 9 + hasProvenance: true, 10 + }, 11 + { 12 + version: '1.0.1', 13 + time: '2026-01-02T00:00:00.000Z', 14 + hasProvenance: false, 15 + }, 16 + { 17 + version: '1.0.2', 18 + time: '2026-01-03T00:00:00.000Z', 19 + hasProvenance: true, 20 + }, 21 + ] 22 + 23 + it('does not flag trusted viewed version (1.0.2)', () => { 24 + const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.2') 25 + expect(result).toBeNull() 26 + }) 27 + 28 + it('flags downgraded viewed version (1.0.1)', () => { 29 + const result = detectPublishSecurityDowngradeForVersion(versions, '1.0.1') 30 + expect(result).toEqual({ 31 + downgradedVersion: '1.0.1', 32 + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', 33 + downgradedTrustLevel: 'none', 34 + trustedVersion: '1.0.0', 35 + trustedPublishedAt: '2026-01-01T00:00:00.000Z', 36 + trustedTrustLevel: 'provenance', 37 + }) 38 + }) 39 + 40 + it('flags trust downgrade from trustedPublisher to provenance', () => { 41 + const result = detectPublishSecurityDowngradeForVersion( 42 + [ 43 + { 44 + version: '1.0.0', 45 + time: '2026-01-01T00:00:00.000Z', 46 + hasProvenance: true, 47 + trustLevel: 'trustedPublisher', 48 + }, 49 + { 50 + version: '1.0.1', 51 + time: '2026-01-02T00:00:00.000Z', 52 + hasProvenance: true, 53 + trustLevel: 'provenance', 54 + }, 55 + ], 56 + '1.0.1', 57 + ) 58 + 59 + expect(result).toEqual({ 60 + downgradedVersion: '1.0.1', 61 + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', 62 + downgradedTrustLevel: 'provenance', 63 + trustedVersion: '1.0.0', 64 + trustedPublishedAt: '2026-01-01T00:00:00.000Z', 65 + trustedTrustLevel: 'trustedPublisher', 66 + }) 67 + }) 68 + 69 + it('does not flag upgrade from provenance to trustedPublisher', () => { 70 + const result = detectPublishSecurityDowngradeForVersion( 71 + [ 72 + { 73 + version: '1.0.0', 74 + time: '2026-01-01T00:00:00.000Z', 75 + hasProvenance: true, 76 + trustLevel: 'provenance', 77 + }, 78 + { 79 + version: '1.0.1', 80 + time: '2026-01-02T00:00:00.000Z', 81 + hasProvenance: true, 82 + trustLevel: 'trustedPublisher', 83 + }, 84 + ], 85 + '1.0.1', 86 + ) 87 + 88 + expect(result).toBeNull() 89 + }) 90 + 91 + it('flags ongoing downgraded versions until an upgrade happens', () => { 92 + const versions = [ 93 + { 94 + version: '2.1.0', 95 + time: '2026-01-01T00:00:00.000Z', 96 + hasProvenance: true, 97 + trustLevel: 'provenance' as const, 98 + }, 99 + { 100 + version: '2.1.1', 101 + time: '2026-01-02T00:00:00.000Z', 102 + hasProvenance: false, 103 + trustLevel: 'none' as const, 104 + }, 105 + { 106 + version: '2.2.0', 107 + time: '2026-01-03T00:00:00.000Z', 108 + hasProvenance: false, 109 + trustLevel: 'none' as const, 110 + }, 111 + { 112 + version: '2.3.0', 113 + time: '2026-01-04T00:00:00.000Z', 114 + hasProvenance: false, 115 + trustLevel: 'none' as const, 116 + }, 117 + { 118 + version: '2.4.0', 119 + time: '2026-01-05T00:00:00.000Z', 120 + hasProvenance: true, 121 + trustLevel: 'provenance' as const, 122 + }, 123 + ] 124 + 125 + expect(detectPublishSecurityDowngradeForVersion(versions, '2.1.1')?.trustedVersion).toBe( 126 + '2.1.0', 127 + ) 128 + expect(detectPublishSecurityDowngradeForVersion(versions, '2.2.0')?.trustedVersion).toBe( 129 + '2.1.0', 130 + ) 131 + expect(detectPublishSecurityDowngradeForVersion(versions, '2.3.0')?.trustedVersion).toBe( 132 + '2.1.0', 133 + ) 134 + expect(detectPublishSecurityDowngradeForVersion(versions, '2.4.0')).toBeNull() 135 + }) 136 + 137 + it('skips deprecated versions when selecting trustedVersion', () => { 138 + const result = detectPublishSecurityDowngradeForVersion( 139 + [ 140 + { 141 + version: '1.0.0', 142 + time: '2026-01-01T00:00:00.000Z', 143 + hasProvenance: true, 144 + trustLevel: 'provenance', 145 + }, 146 + { 147 + version: '1.0.1', 148 + time: '2026-01-02T00:00:00.000Z', 149 + hasProvenance: true, 150 + trustLevel: 'provenance', 151 + deprecated: 'Use 1.0.2 instead', 152 + }, 153 + { 154 + version: '1.0.2', 155 + time: '2026-01-03T00:00:00.000Z', 156 + hasProvenance: false, 157 + trustLevel: 'none', 158 + }, 159 + ], 160 + '1.0.2', 161 + ) 162 + 163 + // Should recommend 1.0.0 (not 1.0.1 which is deprecated) 164 + expect(result?.trustedVersion).toBe('1.0.0') 165 + }) 166 + 167 + it('returns null when all older trusted versions are deprecated', () => { 168 + const result = detectPublishSecurityDowngradeForVersion( 169 + [ 170 + { 171 + version: '1.0.0', 172 + time: '2026-01-01T00:00:00.000Z', 173 + hasProvenance: true, 174 + trustLevel: 'provenance', 175 + deprecated: 'Deprecated', 176 + }, 177 + { 178 + version: '1.0.1', 179 + time: '2026-01-02T00:00:00.000Z', 180 + hasProvenance: false, 181 + trustLevel: 'none', 182 + }, 183 + ], 184 + '1.0.1', 185 + ) 186 + 187 + expect(result).toBeNull() 188 + }) 189 + 190 + it('detects cross-major downgrade but does not recommend a version', () => { 191 + const result = detectPublishSecurityDowngradeForVersion( 192 + [ 193 + { 194 + version: '1.0.0', 195 + time: '2026-01-01T00:00:00.000Z', 196 + hasProvenance: true, 197 + trustLevel: 'provenance', 198 + }, 199 + { 200 + version: '2.0.0', 201 + time: '2026-01-02T00:00:00.000Z', 202 + hasProvenance: false, 203 + trustLevel: 'none', 204 + }, 205 + ], 206 + '2.0.0', 207 + ) 208 + 209 + // Downgrade is detected (v1.0.0 was trusted, v2.0.0 is not) 210 + expect(result).not.toBeNull() 211 + expect(result?.downgradedVersion).toBe('2.0.0') 212 + // But no trustedVersion recommendation since v1.0.0 is a different major 213 + expect(result?.trustedVersion).toBeUndefined() 214 + }) 215 + 216 + it('recommends same-major trusted version when cross-major exists', () => { 217 + const result = detectPublishSecurityDowngradeForVersion( 218 + [ 219 + { 220 + version: '1.0.0', 221 + time: '2026-01-01T00:00:00.000Z', 222 + hasProvenance: true, 223 + trustLevel: 'provenance', 224 + }, 225 + { 226 + version: '2.0.0', 227 + time: '2026-01-02T00:00:00.000Z', 228 + hasProvenance: true, 229 + trustLevel: 'provenance', 230 + }, 231 + { 232 + version: '2.1.0', 233 + time: '2026-01-03T00:00:00.000Z', 234 + hasProvenance: false, 235 + trustLevel: 'none', 236 + }, 237 + ], 238 + '2.1.0', 239 + ) 240 + 241 + // Should recommend 2.0.0 (same major), not 1.0.0 242 + expect(result?.trustedVersion).toBe('2.0.0') 243 + }) 244 + 245 + it('uses provenance rank (not trustedPublisher) for hasProvenance fallback without trustLevel', () => { 246 + // When trustLevel is absent, hasProvenance: true should map to provenance rank, 247 + // not trustedPublisher rank. This means a version with only hasProvenance: true 248 + // should be considered a downgrade from trustedPublisher. 249 + const result = detectPublishSecurityDowngradeForVersion( 250 + [ 251 + { 252 + version: '1.0.0', 253 + time: '2026-01-01T00:00:00.000Z', 254 + hasProvenance: true, 255 + trustLevel: 'trustedPublisher', 256 + }, 257 + { 258 + version: '1.0.1', 259 + time: '2026-01-02T00:00:00.000Z', 260 + hasProvenance: true, 261 + // no trustLevel — fallback path maps to provenance 262 + }, 263 + ], 264 + '1.0.1', 265 + ) 266 + 267 + // hasProvenance fallback maps to provenance (rank 1), trustedPublisher is rank 2, so this is a downgrade 268 + expect(result).toEqual({ 269 + downgradedVersion: '1.0.1', 270 + downgradedPublishedAt: '2026-01-02T00:00:00.000Z', 271 + downgradedTrustLevel: 'provenance', 272 + trustedVersion: '1.0.0', 273 + trustedPublishedAt: '2026-01-01T00:00:00.000Z', 274 + trustedTrustLevel: 'trustedPublisher', 275 + }) 276 + }) 277 + 278 + it('does not flag hasProvenance fallback against provenance trustLevel', () => { 279 + // When trustLevel is absent, hasProvenance: true maps to provenance rank. 280 + // An explicit provenance trustLevel is the same rank, so no downgrade. 281 + const result = detectPublishSecurityDowngradeForVersion( 282 + [ 283 + { 284 + version: '1.0.0', 285 + time: '2026-01-01T00:00:00.000Z', 286 + hasProvenance: true, 287 + // no trustLevel — fallback path maps to provenance 288 + }, 289 + { 290 + version: '1.0.1', 291 + time: '2026-01-02T00:00:00.000Z', 292 + hasProvenance: true, 293 + trustLevel: 'provenance', 294 + }, 295 + ], 296 + '1.0.1', 297 + ) 298 + 299 + // Both are provenance rank, so no downgrade 300 + expect(result).toBeNull() 301 + }) 302 + })