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 226