codekingpro/portable-devtools
114k
1const npmFetch = require('npm-registry-fetch')
2const npa = require('npm-package-arg')
3const PackageJson = require('@npmcli/package-json')
4const { log } = require('proc-log')
5const semver = require('semver')
6const { URL } = require('node:url')
7const ssri = require('ssri')
8const ciInfo = require('ci-info')
9
10const { generateProvenance, verifyProvenance } = require('./provenance')
11
12const TLOG_BASE_URL = 'https://search.sigstore.dev/'
13
14const publish = async (manifest, tarballData, opts) => {
15 if (manifest.private) {
16 throw Object.assign(
17 new Error(`This package has been marked as private
18Remove the 'private' field from the package.json to publish it.`),
19 { code: 'EPRIVATE' }
20 )
21 }
22
23 // spec is used to pick the appropriate registry/auth combo
24 const spec = npa.resolve(manifest.name, manifest.version)
25 opts = {
26 access: 'public',
27 algorithms: ['sha512'],
28 defaultTag: 'latest',
29 ...opts,
30 spec,
31 }
32
33 const reg = npmFetch.pickRegistry(spec, opts)
34 const pubManifest = await patchManifest(manifest, opts)
35
36 // registry-frontdoor cares about the access level,
37 // which is only configurable for scoped packages
38 if (!spec.scope && opts.access === 'restricted') {
39 throw Object.assign(
40 new Error("Can't restrict access to unscoped packages."),
41 { code: 'EUNSCOPED' }
42 )
43 }
44
45 const { metadata, transparencyLogUrl } = await buildMetadata(
46 reg,
47 pubManifest,
48 tarballData,
49 spec,
50 opts
51 )
52
53 const res = await npmFetch(spec.escapedName, {
54 ...opts,
55 method: 'PUT',
56 body: metadata,
57 ignoreBody: true,
58 })
59 if (transparencyLogUrl) {
60 res.transparencyLogUrl = transparencyLogUrl
61 }
62 return res
63}
64
65const patchManifest = async (_manifest, opts) => {
66 const { npmVersion } = opts
67 const steps = ['fixName']
68 const manifestInput = { ..._manifest, _nodeVersion: process.versions.node }
69 if (npmVersion != null) {
70 manifestInput._npmVersion = npmVersion
71 }
72 const manifest = await new PackageJson()
73 .fromContent(manifestInput)
74 .normalize({ steps })
75 .then(p => p.content)
76
77 const version = semver.clean(manifest.version)
78 if (!version) {
79 throw Object.assign(
80 new Error('invalid semver: ' + manifest.version),
81 { code: 'EBADSEMVER' }
82 )
83 }
84 manifest.version = version
85 return manifest
86}
87
88const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
89 const { access, defaultTag, algorithms, provenance, provenanceFile } = opts
90 const root = {
91 _id: manifest.name,
92 name: manifest.name,
93 description: manifest.description,
94 'dist-tags': {},
95 versions: {},
96 access,
97 }
98
99 root.versions[manifest.version] = manifest
100 const tag = manifest.tag || defaultTag
101 root['dist-tags'][tag] = manifest.version
102
103 const tarballName = `${manifest.name}-${manifest.version}.tgz`
104 const provenanceBundleName = `${manifest.name}-${manifest.version}.sigstore`
105 const tarballURI = `${manifest.name}/-/${tarballName}`
106 const integrity = ssri.fromData(tarballData, {
107 algorithms: [...new Set(['sha1'].concat(algorithms))],
108 })
109
110 manifest._id = `${manifest.name}@${manifest.version}`
111 manifest.dist = { ...manifest.dist }
112 // Don't bother having sha1 in the actual integrity field
113 manifest.dist.integrity = integrity.sha512[0].toString()
114 // Legacy shasum support
115 manifest.dist.shasum = integrity.sha1[0].hexDigest()
116
117 // NB: the CLI always fetches via HTTPS if the registry is HTTPS,
118 // regardless of what's here. This makes it so that installing
119 // from an HTTP-only mirror doesn't cause problems, though.
120 manifest.dist.tarball = new URL(tarballURI, registry).href
121 .replace(/^https:\/\//, 'http://')
122
123 root._attachments = {}
124 root._attachments[tarballName] = {
125 content_type: 'application/octet-stream',
126 data: tarballData.toString('base64'),
127 length: tarballData.length,
128 }
129
130 // Handle case where --provenance flag was set to true
131 let transparencyLogUrl
132 if (provenance === true || provenanceFile) {
133 let provenanceBundle
134 const subject = {
135 name: npa.toPurl(spec),
136 digest: { sha512: integrity.sha512[0].hexDigest() },
137 }
138
139 if (provenance === true) {
140 await ensureProvenanceGeneration(registry, spec, opts)
141 provenanceBundle = await generateProvenance([subject], opts)
142
143 /* eslint-disable-next-line max-len */
144 log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`)
145
146 const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
147 /* istanbul ignore else */
148 if (tlogEntry) {
149 transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
150 log.notice(
151 'publish',
152 `Provenance statement published to transparency log: ${transparencyLogUrl}`
153 )
154 }
155 } else {
156 provenanceBundle = await verifyProvenance(subject, provenanceFile)
157 }
158
159 const serializedBundle = JSON.stringify(provenanceBundle)
160 root._attachments[provenanceBundleName] = {
161 content_type: provenanceBundle.mediaType,
162 data: serializedBundle,
163 length: serializedBundle.length,
164 }
165 }
166
167 return {
168 metadata: root,
169 transparencyLogUrl,
170 }
171}
172
173// Check that all the prereqs are met for provenance generation
174const ensureProvenanceGeneration = async (registry, spec, opts) => {
175 if (ciInfo.GITHUB_ACTIONS) {
176 // Ensure that the GHA OIDC token is available
177 if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
178 throw Object.assign(
179 /* eslint-disable-next-line max-len */
180 new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
181 { code: 'EUSAGE' }
182 )
183 }
184 } else if (ciInfo.GITLAB) {
185 // Ensure that the Sigstore OIDC token is available
186 if (!process.env.SIGSTORE_ID_TOKEN) {
187 throw Object.assign(
188 /* eslint-disable-next-line max-len */
189 new Error('Provenance generation in GitLab CI requires "SIGSTORE_ID_TOKEN" with "sigstore" audience to be present in "id_tokens". For more info see:\nhttps://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html'),
190 { code: 'EUSAGE' }
191 )
192 }
193 } else {
194 throw Object.assign(
195 new Error('Automatic provenance generation not supported for provider: ' + ciInfo.name),
196 { code: 'EUSAGE' }
197 )
198 }
199
200 // Some registries (e.g. GH packages) require auth to check visibility,
201 // and always return 404 when no auth is supplied. In this case we assume
202 // the package is always private and require `--access public` to publish
203 // with provenance.
204 let visibility = { public: false }
205 if (opts.access !== 'public') {
206 try {
207 const res = await npmFetch
208 .json(`/-/package/${spec.escapedName}/visibility`, { ...opts, registry })
209 visibility = res
210 } catch (err) {
211 if (err.code !== 'E404') {
212 throw err
213 }
214 }
215 }
216
217 if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
218 throw Object.assign(
219 /* eslint-disable-next-line max-len */
220 new Error("Can't generate provenance for new or private package, you must set `access` to public."),
221 { code: 'EUSAGE' }
222 )
223 }
224}
225
226module.exports = publish
227 