1 | const { fixer } = require('normalize-package-data')
|
2 | const npmFetch = require('npm-registry-fetch')
|
3 | const npa = require('npm-package-arg')
|
4 | const { log } = require('proc-log')
|
5 | const semver = require('semver')
|
6 | const { URL } = require('url')
|
7 | const ssri = require('ssri')
|
8 | const ciInfo = require('ci-info')
|
9 |
|
10 | const { generateProvenance, verifyProvenance } = require('./provenance')
|
11 |
|
12 | const TLOG_BASE_URL = 'https://search.sigstore.dev/'
|
13 |
|
14 | const publish = async (manifest, tarballData, opts) => {
|
15 | if (manifest.private) {
|
16 | throw Object.assign(
|
17 | new Error(`This package has been marked as private
|
18 | Remove the 'private' field from the package.json to publish it.`),
|
19 | { code: 'EPRIVATE' }
|
20 | )
|
21 | }
|
22 |
|
23 |
|
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 |
|
37 |
|
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 |
|
65 | const patchManifest = (_manifest, opts) => {
|
66 | const { npmVersion } = opts
|
67 |
|
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 |
|
87 | const 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 |
|
112 | manifest.dist.integrity = integrity.sha512[0].toString()
|
113 |
|
114 | manifest.dist.shasum = integrity.sha1[0].hexDigest()
|
115 |
|
116 |
|
117 |
|
118 |
|
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 |
|
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 |
|
143 | log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`)
|
144 |
|
145 | const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
|
146 |
|
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 |
|
173 | const ensureProvenanceGeneration = async (registry, spec, opts) => {
|
174 | if (ciInfo.GITHUB_ACTIONS) {
|
175 |
|
176 | if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
177 | throw Object.assign(
|
178 |
|
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 |
|
185 | if (!process.env.SIGSTORE_ID_TOKEN) {
|
186 | throw Object.assign(
|
187 |
|
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 |
|
200 |
|
201 |
|
202 |
|
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 |
|
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 |
|
225 | module.exports = publish
|