Skip to content

Commit f98e6f4

Browse files
committed
feat: add provenancePath option for libnpmpublish
Signed-off-by: Brian DeHamer <[email protected]>
1 parent 3641b3f commit f98e6f4

File tree

3 files changed

+285
-56
lines changed

3 files changed

+285
-56
lines changed

workspaces/libnpmpublish/lib/provenance.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { sigstore } = require('sigstore')
2+
const { readFile } = require('fs/promises')
23

34
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
45
const INTOTO_STATEMENT_TYPE = 'https://in-toto.io/Statement/v0.1'
@@ -66,6 +67,50 @@ const generateProvenance = async (subject, opts) => {
6667
return sigstore.attest(Buffer.from(JSON.stringify(payload)), INTOTO_PAYLOAD_TYPE, opts)
6768
}
6869

70+
const verifyProvenance = async (subject, provenancePath) => {
71+
let provenanceBundle
72+
try {
73+
provenanceBundle = JSON.parse(await readFile(provenancePath))
74+
} catch (err) {
75+
err.message = `Invalid provenance provided: ${err.message}`
76+
throw err
77+
}
78+
79+
const payload = extractProvenance(provenanceBundle)
80+
if (!payload.subject || !payload.subject.length) {
81+
throw new Error('No subject found in sigstore bundle payload')
82+
}
83+
if (payload.subject.length > 1) {
84+
throw new Error('Found more than one subject in the sigstore bundle payload')
85+
}
86+
87+
const bundleSubject = payload.subject[0]
88+
if (subject.name !== bundleSubject.name) {
89+
throw new Error(
90+
`Provenance subject ${bundleSubject.name} does not match the package: ${subject.name}`
91+
)
92+
}
93+
if (subject.digest.sha512 !== bundleSubject.digest.sha512) {
94+
throw new Error('Provenance subject digest does not match the package')
95+
}
96+
97+
await sigstore.verify(provenanceBundle)
98+
return provenanceBundle
99+
}
100+
101+
const extractProvenance = (bundle) => {
102+
if (!bundle?.dsseEnvelope?.payload) {
103+
throw new Error('No dsseEnvelope with payload found in sigstore bundle')
104+
}
105+
try {
106+
return JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8'))
107+
} catch (err) {
108+
err.message = `Failed to parse payload from dsseEnvelope: ${err.message}`
109+
throw err
110+
}
111+
}
112+
69113
module.exports = {
70114
generateProvenance,
115+
verifyProvenance,
71116
}

workspaces/libnpmpublish/lib/publish.js

Lines changed: 66 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { URL } = require('url')
77
const ssri = require('ssri')
88
const ciInfo = require('ci-info')
99

10-
const { generateProvenance } = require('./provenance')
10+
const { generateProvenance, verifyProvenance } = require('./provenance')
1111

1212
const TLOG_BASE_URL = 'https://search.sigstore.dev/'
1313

@@ -111,7 +111,7 @@ const patchManifest = (_manifest, opts) => {
111111
}
112112

113113
const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
114-
const { access, defaultTag, algorithms, provenance } = opts
114+
const { access, defaultTag, algorithms, provenance, provenancePath } = opts
115115
const root = {
116116
_id: manifest.name,
117117
name: manifest.name,
@@ -154,66 +154,31 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {
154154

155155
// Handle case where --provenance flag was set to true
156156
let transparencyLogUrl
157-
if (provenance === true) {
157+
if (provenance === true || provenancePath) {
158+
let provenanceBundle
158159
const subject = {
159160
name: npa.toPurl(spec),
160161
digest: { sha512: integrity.sha512[0].hexDigest() },
161162
}
162163

163-
// Ensure that we're running in GHA, currently the only supported build environment
164-
if (ciInfo.name !== 'GitHub Actions') {
165-
throw Object.assign(
166-
new Error('Automatic provenance generation not supported outside of GitHub Actions'),
167-
{ code: 'EUSAGE' }
168-
)
169-
}
170-
171-
// Ensure that the GHA OIDC token is available
172-
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
173-
throw Object.assign(
174-
/* eslint-disable-next-line max-len */
175-
new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
176-
{ code: 'EUSAGE' }
177-
)
178-
}
179-
180-
// Some registries (e.g. GH packages) require auth to check visibility,
181-
// and always return 404 when no auth is supplied. In this case we assume
182-
// the package is always private and require `--access public` to publish
183-
// with provenance.
184-
let visibility = { public: false }
185-
if (opts.provenance === true && opts.access !== 'public') {
186-
try {
187-
const res = await npmFetch
188-
.json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
189-
visibility = res
190-
} catch (err) {
191-
if (err.code !== 'E404') {
192-
throw err
193-
}
164+
if (provenance === true) {
165+
await ensureProvenanceGeneration(registry, spec, opts)
166+
provenanceBundle = await generateProvenance([subject], opts)
167+
168+
/* eslint-disable-next-line max-len */
169+
log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions')
170+
171+
const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
172+
/* istanbul ignore else */
173+
if (tlogEntry) {
174+
transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
175+
log.notice(
176+
'publish',
177+
`Provenance statement published to transparency log: ${transparencyLogUrl}`
178+
)
194179
}
195-
}
196-
197-
if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
198-
throw Object.assign(
199-
/* eslint-disable-next-line max-len */
200-
new Error("Can't generate provenance for new or private package, you must set `access` to public."),
201-
{ code: 'EUSAGE' }
202-
)
203-
}
204-
const provenanceBundle = await generateProvenance([subject], opts)
205-
206-
/* eslint-disable-next-line max-len */
207-
log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions')
208-
209-
const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0]
210-
/* istanbul ignore else */
211-
if (tlogEntry) {
212-
transparencyLogUrl = `${TLOG_BASE_URL}?logIndex=${tlogEntry.logIndex}`
213-
log.notice(
214-
'publish',
215-
`Provenance statement published to transparency log: ${transparencyLogUrl}`
216-
)
180+
} else {
181+
provenanceBundle = await verifyProvenance(subject, provenancePath)
217182
}
218183

219184
const serializedBundle = JSON.stringify(provenanceBundle)
@@ -275,4 +240,49 @@ const patchMetadata = (current, newData) => {
275240
return current
276241
}
277242

243+
// Check that all the prereqs are met for provenance generation
244+
const ensureProvenanceGeneration = async (registry, spec, opts) => {
245+
// Ensure that we're running in GHA, currently the only supported build environment
246+
if (ciInfo.name !== 'GitHub Actions') {
247+
throw Object.assign(
248+
new Error('Automatic provenance generation not supported outside of GitHub Actions'),
249+
{ code: 'EUSAGE' }
250+
)
251+
}
252+
253+
// Ensure that the GHA OIDC token is available
254+
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
255+
throw Object.assign(
256+
/* eslint-disable-next-line max-len */
257+
new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'),
258+
{ code: 'EUSAGE' }
259+
)
260+
}
261+
262+
// Some registries (e.g. GH packages) require auth to check visibility,
263+
// and always return 404 when no auth is supplied. In this case we assume
264+
// the package is always private and require `--access public` to publish
265+
// with provenance.
266+
let visibility = { public: false }
267+
if (true && opts.access !== 'public') {
268+
try {
269+
const res = await npmFetch
270+
.json(`${registry}/-/package/${spec.escapedName}/visibility`, opts)
271+
visibility = res
272+
} catch (err) {
273+
if (err.code !== 'E404') {
274+
throw err
275+
}
276+
}
277+
}
278+
279+
if (!visibility.public && opts.provenance === true && opts.access !== 'public') {
280+
throw Object.assign(
281+
/* eslint-disable-next-line max-len */
282+
new Error("Can't generate provenance for new or private package, you must set `access` to public."),
283+
{ code: 'EUSAGE' }
284+
)
285+
}
286+
}
287+
278288
module.exports = publish

workspaces/libnpmpublish/test/publish.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,3 +980,177 @@ t.test('automatic provenance with incorrect permissions', async t => {
980980
}
981981
)
982982
})
983+
984+
t.test('user-supplied provenance - success', async t => {
985+
const { publish } = t.mock('..', {
986+
'../lib/provenance': t.mock('../lib/provenance', {
987+
sigstore: { sigstore: { verify: () => {} } },
988+
}),
989+
})
990+
991+
const registry = new MockRegistry({
992+
tap: t,
993+
registry: opts.registry,
994+
authorization: token,
995+
})
996+
const manifest = {
997+
name: '@npmcli/libnpmpublish-test',
998+
version: '1.0.0',
999+
description: 'test libnpmpublish package',
1000+
}
1001+
const spec = npa(manifest.name)
1002+
const packument = {
1003+
_id: manifest.name,
1004+
name: manifest.name,
1005+
description: manifest.description,
1006+
'dist-tags': {
1007+
latest: '1.0.0',
1008+
},
1009+
versions: {
1010+
'1.0.0': {
1011+
_id: `${manifest.name}@${manifest.version}`,
1012+
_nodeVersion: process.versions.node,
1013+
...manifest,
1014+
dist: {
1015+
shasum,
1016+
integrity: integrity.sha512[0].toString(),
1017+
/* eslint-disable-next-line max-len */
1018+
tarball: 'http://mock.reg/@npmcli/libnpmpublish-test/-/@npmcli/libnpmpublish-test-1.0.0.tgz',
1019+
},
1020+
},
1021+
},
1022+
access: 'public',
1023+
_attachments: {
1024+
'@npmcli/libnpmpublish-test-1.0.0.tgz': {
1025+
content_type: 'application/octet-stream',
1026+
data: tarData.toString('base64'),
1027+
length: tarData.length,
1028+
},
1029+
'@npmcli/libnpmpublish-test-1.0.0.sigstore': {
1030+
content_type: 'application/vnd.dev.sigstore.bundle+json;version=0.1',
1031+
data: /.*/, // Can't match against static value as signature is always different
1032+
length: 7927,
1033+
},
1034+
},
1035+
}
1036+
registry.nock.put(`/${spec.escapedName}`, body => {
1037+
return t.match(body, packument, 'posted packument matches expectations')
1038+
}).reply(201, {})
1039+
const ret = await publish(manifest, tarData, {
1040+
...opts,
1041+
provenancePath: './test/fixtures/valid-bundle.json',
1042+
})
1043+
t.ok(ret, 'publish succeeded')
1044+
})
1045+
1046+
t.test('user-supplied provenance - failure', async t => {
1047+
const { publish } = t.mock('..')
1048+
const manifest = {
1049+
name: '@npmcli/libnpmpublish-test',
1050+
version: '1.0.0',
1051+
description: 'test libnpmpublish package',
1052+
}
1053+
await t.rejects(
1054+
publish(manifest, Buffer.from(''), {
1055+
...opts,
1056+
provenancePath: './test/fixtures/bad-bundle.json',
1057+
}),
1058+
{ message: /Invalid provenance provided/ }
1059+
)
1060+
})
1061+
1062+
t.test('user-supplied provenance - bundle missing DSSE envelope', async t => {
1063+
const { publish } = t.mock('..')
1064+
const manifest = {
1065+
name: '@npmcli/libnpmpublish-test',
1066+
version: '1.0.0',
1067+
description: 'test libnpmpublish package',
1068+
}
1069+
await t.rejects(
1070+
publish(manifest, Buffer.from(''), {
1071+
...opts,
1072+
provenancePath: './test/fixtures/no-provenance-envelope-bundle.json',
1073+
}),
1074+
{ message: /No dsseEnvelope with payload found/ }
1075+
)
1076+
})
1077+
1078+
t.test('user-supplied provenance - bundle with invalid DSSE payload', async t => {
1079+
const { publish } = t.mock('..')
1080+
const manifest = {
1081+
name: '@npmcli/libnpmpublish-test',
1082+
version: '1.0.0',
1083+
description: 'test libnpmpublish package',
1084+
}
1085+
await t.rejects(
1086+
publish(manifest, Buffer.from(''), {
1087+
...opts,
1088+
provenancePath: './test/fixtures/bad-dsse-payload-bundle.json',
1089+
}),
1090+
{ message: /Failed to parse payload/ }
1091+
)
1092+
})
1093+
1094+
t.test('user-supplied provenance - provenance with missing subject', async t => {
1095+
const { publish } = t.mock('..')
1096+
const manifest = {
1097+
name: '@npmcli/libnpmpublish-test',
1098+
version: '1.0.0',
1099+
description: 'test libnpmpublish package',
1100+
}
1101+
await t.rejects(
1102+
publish(manifest, Buffer.from(''), {
1103+
...opts,
1104+
provenancePath: './test/fixtures/no-provenance-subject-bundle.json',
1105+
}),
1106+
{ message: /No subject found/ }
1107+
)
1108+
})
1109+
1110+
t.test('user-supplied provenance - provenance w/ multiple subjects', async t => {
1111+
const { publish } = t.mock('..')
1112+
const manifest = {
1113+
name: '@npmcli/libnpmpublish-test',
1114+
version: '1.0.0',
1115+
description: 'test libnpmpublish package',
1116+
}
1117+
await t.rejects(
1118+
publish(manifest, Buffer.from(''), {
1119+
...opts,
1120+
provenancePath: './test/fixtures/multi-subject-provenance-bundle.json',
1121+
}),
1122+
{ message: /Found more than one subject/ }
1123+
)
1124+
})
1125+
1126+
t.test('user-supplied provenance - provenance w/ mismatched subject name', async t => {
1127+
const { publish } = t.mock('..')
1128+
const manifest = {
1129+
name: '@npmcli/libnpmpublish-fail-test',
1130+
version: '1.0.0',
1131+
description: 'test libnpmpublish package',
1132+
}
1133+
await t.rejects(
1134+
publish(manifest, Buffer.from(''), {
1135+
...opts,
1136+
provenancePath: './test/fixtures/valid-bundle.json',
1137+
}),
1138+
{ message: /Provenance subject/ }
1139+
)
1140+
})
1141+
1142+
t.test('user-supplied provenance - provenance w/ mismatched package digest', async t => {
1143+
const { publish } = t.mock('..')
1144+
const manifest = {
1145+
name: '@npmcli/libnpmpublish-test',
1146+
version: '1.0.0',
1147+
description: 'test libnpmpublish package',
1148+
}
1149+
await t.rejects(
1150+
publish(manifest, Buffer.from(''), {
1151+
...opts,
1152+
provenancePath: './test/fixtures/digest-mismatch-provenance-bundle.json',
1153+
}),
1154+
{ message: /Provenance subject digest does not match/ }
1155+
)
1156+
})

0 commit comments

Comments
 (0)