forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { PackageVersionInfo, PublishTrustLevel } from '#shared/types'
2import { compare, major } from 'semver'
3
4export 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
14type VersionWithIndex = PackageVersionInfo & {
15 index: number
16 timestamp: number
17 trustRank: number
18 resolvedTrustLevel: PublishTrustLevel
19}
20
21const TRUST_RANK: Record<PublishTrustLevel, number> = {
22 none: 0,
23 provenance: 1,
24 trustedPublisher: 2,
25}
26
27function 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
34function toTimestamp(time?: string): number {
35 if (!time) return Number.NaN
36 return Date.parse(time)
37}
38
39function 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 */
64export 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}