Skip to content

Commit 495d79d

Browse files
committed
Support for trusted publisher
1 parent 30b8653 commit 495d79d

6 files changed

Lines changed: 323 additions & 53 deletions

File tree

app/composables/npm/usePackage.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ import { extractInstallScriptsInfo } from '~/utils/install-scripts'
55
/** Number of recent versions to include in initial payload */
66
const RECENT_VERSIONS_COUNT = 5
77

8+
function hasAttestations(version: Packument['versions'][string]): boolean {
9+
return Boolean(version.dist.attestations)
10+
}
11+
12+
function hasTrustedPublisher(version: Packument['versions'][string]): boolean {
13+
return Boolean(version._npmUser?.trustedPublisher)
14+
}
15+
16+
function hasPublishTrustEvidence(version: Packument['versions'][string]): boolean {
17+
return hasAttestations(version) || hasTrustedPublisher(version)
18+
}
19+
20+
function getTrustLevel(version: Packument['versions'][string]): SlimVersion['trustLevel'] {
21+
if (hasAttestations(version)) return 'provenance'
22+
if (hasTrustedPublisher(version)) return 'trustedPublisher'
23+
return 'none'
24+
}
25+
826
/**
927
* Transform a full Packument into a slimmed version for client-side use.
1028
* Reduces payload size by:
@@ -38,6 +56,17 @@ export function transformPackument(
3856
includedVersions.add(requestedVersion)
3957
}
4058

59+
const securityVersions = Object.entries(pkg.versions).map(([version, metadata]) => {
60+
const trustLevel = getTrustLevel(metadata)
61+
return {
62+
version,
63+
time: pkg.time[version],
64+
hasProvenance: trustLevel !== 'none',
65+
trustLevel,
66+
deprecated: metadata.deprecated,
67+
}
68+
})
69+
4170
// Build filtered versions object with install scripts info per version
4271
const filteredVersions: Record<string, SlimVersion> = {}
4372
let versionData: SlimPackumentVersion | null = null
@@ -55,10 +84,12 @@ export function transformPackument(
5584
installScripts: installScripts ?? undefined,
5685
}
5786
}
87+
const hasProvenance = hasPublishTrustEvidence(version)
88+
const trustLevel = getTrustLevel(version)
89+
5890
filteredVersions[v] = {
59-
...((version?.dist as { attestations?: unknown } | undefined)?.attestations
60-
? { hasProvenance: true }
61-
: {}),
91+
hasProvenance,
92+
trustLevel,
6293
version: version.version,
6394
deprecated: version.deprecated,
6495
tags: version.tags as string[],
@@ -96,6 +127,7 @@ export function transformPackument(
96127
'bugs': pkg.bugs,
97128
'requestedVersion': versionData,
98129
'versions': filteredVersions,
130+
'securityVersions': securityVersions,
99131
}
100132
}
101133

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,13 @@ const {
147147
const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)
148148
const versionSecurityMetadata = computed<PackageVersionInfo[]>(() => {
149149
if (!pkg.value) return []
150+
if (pkg.value.securityVersions?.length) return pkg.value.securityVersions
150151
151152
return Object.entries(pkg.value.versions).map(([version, metadata]) => ({
152153
version,
153154
time: pkg.value?.time?.[version],
154155
hasProvenance: !!metadata.hasProvenance,
156+
trustLevel: metadata.trustLevel,
155157
deprecated: metadata.deprecated,
156158
}))
157159
})

app/utils/publish-security.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PackageVersionInfo } from '#shared/types'
1+
import type { PackageVersionInfo, PublishTrustLevel } from '#shared/types'
22
import { compare } from 'semver'
33

44
export interface PublishSecurityDowngrade {
@@ -11,6 +11,18 @@ export interface PublishSecurityDowngrade {
1111
type VersionWithIndex = PackageVersionInfo & {
1212
index: number
1313
timestamp: number
14+
trustRank: number
15+
}
16+
17+
const TRUST_RANK: Record<PublishTrustLevel, number> = {
18+
none: 0,
19+
trustedPublisher: 1,
20+
provenance: 2,
21+
}
22+
23+
function getTrustRank(version: PackageVersionInfo): number {
24+
if (version.trustLevel) return TRUST_RANK[version.trustLevel]
25+
return version.hasProvenance ? TRUST_RANK.provenance : TRUST_RANK.none
1426
}
1527

1628
function toTimestamp(time?: string): number {
@@ -50,20 +62,27 @@ export function detectPublishSecurityDowngrade(
5062
...version,
5163
index,
5264
timestamp: toTimestamp(version.time),
65+
trustRank: getTrustRank(version),
5366
}))
5467
.sort(sortByRecency)
5568

5669
const latest = sorted.at(0)
57-
if (!latest || latest.hasProvenance) return null
70+
if (!latest) return null
5871

59-
const latestTrusted = sorted.find(version => version.hasProvenance)
60-
if (!latestTrusted) return null
72+
let strongestOlder: VersionWithIndex | null = null
73+
for (const version of sorted.slice(1)) {
74+
if (!strongestOlder || version.trustRank > strongestOlder.trustRank) {
75+
strongestOlder = version
76+
}
77+
}
78+
79+
if (!strongestOlder || strongestOlder.trustRank <= latest.trustRank) return null
6180

6281
return {
6382
downgradedVersion: latest.version,
6483
downgradedPublishedAt: latest.time,
65-
trustedVersion: latestTrusted.version,
66-
trustedPublishedAt: latestTrusted.time,
84+
trustedVersion: strongestOlder.version,
85+
trustedPublishedAt: strongestOlder.time,
6786
}
6887
}
6988

@@ -83,22 +102,29 @@ export function detectPublishSecurityDowngradeForVersion(
83102
...version,
84103
index,
85104
timestamp: toTimestamp(version.time),
105+
trustRank: getTrustRank(version),
86106
}))
87107
.sort(sortByRecency)
88108

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

92112
const current = sorted.at(currentIndex)
93-
if (!current || current.hasProvenance) return null
113+
if (!current) return null
114+
115+
let strongestOlder: VersionWithIndex | null = null
116+
for (const version of sorted.slice(currentIndex + 1)) {
117+
if (!strongestOlder || version.trustRank > strongestOlder.trustRank) {
118+
strongestOlder = version
119+
}
120+
}
94121

95-
const trustedOlder = sorted.slice(currentIndex + 1).find(version => version.hasProvenance)
96-
if (!trustedOlder) return null
122+
if (!strongestOlder || strongestOlder.trustRank <= current.trustRank) return null
97123

98124
return {
99125
downgradedVersion: current.version,
100126
downgradedPublishedAt: current.time,
101-
trustedVersion: trustedOlder.version,
102-
trustedPublishedAt: trustedOlder.time,
127+
trustedVersion: strongestOlder.version,
128+
trustedPublishedAt: strongestOlder.time,
103129
}
104130
}

shared/types/npm-registry.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,28 @@
66
* @see https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md
77
*/
88

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'
1014
import type { ReadmeResponse } from './readme'
1115

1216
// 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'
1418

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'> & {
1727
// Fix for license field being incorrectly typed in @npm/types
28+
// TODO: Remove this type override when @npm/types fixes the license field typing
1829
license?: string | { type: string; url?: string }
30+
versions: Record<string, PackumentVersion>
1931
}
2032

2133
/** Install scripts info (preinstall, install, postinstall) */
@@ -30,8 +42,11 @@ export type SlimPackumentVersion = PackumentVersion & {
3042
installScripts?: InstallScriptsInfo
3143
}
3244

45+
export type PublishTrustLevel = 'none' | 'trustedPublisher' | 'provenance'
46+
3347
export type SlimVersion = Pick<SlimPackumentVersion, 'version' | 'deprecated' | 'tags'> & {
34-
hasProvenance?: true
48+
hasProvenance?: boolean
49+
trustLevel?: PublishTrustLevel
3550
}
3651

3752
/**
@@ -72,6 +87,8 @@ export interface SlimPackument {
7287
'requestedVersion': SlimPackumentVersion | null
7388
/** Only includes dist-tag versions (with installScripts info added per version) */
7489
'versions': Record<string, SlimVersion>
90+
/** Lightweight security metadata for all versions */
91+
'securityVersions'?: PackageVersionInfo[]
7592
}
7693

7794
/**
@@ -81,6 +98,7 @@ export interface PackageVersionInfo {
8198
version: string
8299
time?: string
83100
hasProvenance: boolean
101+
trustLevel?: PublishTrustLevel
84102
deprecated?: string
85103
}
86104

0 commit comments

Comments
 (0)