UNPKG

7.18 kBJavaScriptView Raw
1const { fixer } = require('normalize-package-data')
2const npmFetch = require('npm-registry-fetch')
3const npa = require('npm-package-arg')
4const { log } = require('proc-log')
5const semver = require('semver')
6const { URL } = require('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 = 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 = (_manifest, opts) => {
66 const { npmVersion } = opts
67 // we only update top-level fields, so a shallow clone is fine
68 const manifest = { ..._manifest }
69
70 manifest._nodeVersion = process.versions.node
71 if (npmVersion) {
72 manifest._npmVersion = npmVersion
73 }
74
75 fixer.fixNameField(manifest, { strict: true, allowLegacyCase: true })
76 const version = semver.clean(manifest.version)
77 if (!version) {
78 throw Object.assign(
79 new Error('invalid semver: ' + manifest.version),
80 { code: 'EBADSEMVER' }
81 )
82 }
83 manifest.version = version
84 return manifest
85}
86
87const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
88 const { access, defaultTag, algorithms, provenance, provenanceFile } = opts
89 const root = {
90 _id: manifest.name,
91 name: manifest.name,
92 description: manifest.description,
93 'dist-tags': {},
94 versions: {},
95 access,
96 }
97
98 root.versions[manifest.version] = manifest
99 const tag = manifest.tag || defaultTag
100 root['dist-tags'][tag] = manifest.version
101
102 const tarballName = `${manifest.name}-${manifest.version}.tgz`
103 const provenanceBundleName = `${manifest.name}-${manifest.version}.sigstore`
104 const tarballURI = `${manifest.name}/-/${tarballName}`
105 const integrity = ssri.fromData(tarballData, {
106 algorithms: [...new Set(['sha1'].concat(algorithms))],
107 })
108
109 manifest._id = `${manifest.name}@${manifest.version}`
110 manifest.dist = { ...manifest.dist }
111 // Don't bother having sha1 in the actual integrity field
112 manifest.dist.integrity = integrity.sha512[0].toString()
113 // Legacy shasum support
114 manifest.dist.shasum = integrity.sha1[0].hexDigest()
115
116 // NB: the CLI always fetches via HTTPS if the registry is HTTPS,
117 // regardless of what's here. This makes it so that installing
118 // from an HTTP-only mirror doesn't cause problems, though.
119 manifest.dist.tarball = new URL(tarballURI, registry).href
120 .replace(/^https:\/\//, 'http://')
121
122 root._attachments = {}
123 root._attachments[tarballName] = {
124 content_type: 'application/octet-stream',
125 data: tarballData.toString('base64'),
126 length: tarballData.length,
127 }
128
129 // Handle case where --provenance flag was set to true
130 let transparencyLogUrl
131 if (provenance === true || provenanceFile) {
132 let provenanceBundle
133 const subject = {
134 name: npa.toPurl(spec),
135 digest: { sha512: integrity.sha512[0].hexDigest() },
136 }
137
138 if (provenance === true) {
139 await ensureProvenanceGeneration(registry, spec, opts)
140 provenanceBundle = await generateProvenance([subject], opts)
141
142 /* eslint-disable-next-line max-len */
143 log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`)
144
145 const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
146 /* istanbul ignore else */
147 if (tlogEntry) {
148 transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
149 log.notice(
150 'publish',
151 `Provenance statement published to transparency log: ${transparencyLogUrl}`
152 )
153 }
154 } else {
155 provenanceBundle = await verifyProvenance(subject, provenanceFile)
156 }
157
158 const serializedBundle = JSON.stringify(provenanceBundle)
159 root._attachments[provenanceBundleName] = {
160 content_type: provenanceBundle.mediaType,
161 data: serializedBundle,
162 length: serializedBundle.length,
163 }
164 }
165
166 return {
167 metadata: root,
168 transparencyLogUrl,
169 }
170}
171
172// Check that all the prereqs are met for provenance generation
173const ensureProvenanceGeneration = async (registry, spec, opts) => {
174 if (ciInfo.GITHUB_ACTIONS) {
175 // Ensure that the GHA OIDC token is available
176 if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
177 throw Object.assign(
178 /* eslint-disable-next-line max-len */
179 new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
180 { code: 'EUSAGE' }
181 )
182 }
183 } else if (ciInfo.GITLAB) {
184 // Ensure that the Sigstore OIDC token is available
185 if (!process.env.SIGSTORE_ID_TOKEN) {
186 throw Object.assign(
187 /* eslint-disable-next-line max-len */
188 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'),
189 { code: 'EUSAGE' }
190 )
191 }
192 } else {
193 throw Object.assign(
194 new Error('Automatic provenance generation not supported for provider: ' + ciInfo.name),
195 { code: 'EUSAGE' }
196 )
197 }
198
199 // Some registries (e.g. GH packages) require auth to check visibility,
200 // and always return 404 when no auth is supplied. In this case we assume
201 // the package is always private and require `--access public` to publish
202 // with provenance.
203 let visibility = { public: false }
204 if (opts.access !== 'public') {
205 try {
206 const res = await npmFetch
207 .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
208 visibility = res
209 } catch (err) {
210 if (err.code !== 'E404') {
211 throw err
212 }
213 }
214 }
215
216 if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
217 throw Object.assign(
218 /* eslint-disable-next-line max-len */
219 new Error("Can't generate provenance for new or private package, you must set `access` to public."),
220 { code: 'EUSAGE' }
221 )
222 }
223}
224
225module.exports = publish