Skip to content

Commit e4ad90c

Browse files
authored
feat: oidc provenance by default (#8412)
This PR adds "auto" or "default" provenance to publishes that use OIDC within github and gitlab. It does this by checking the OIDC id token payload and checking if the current repo's visibility is public or private if it's public we do the equivalent of adding the `--provenance` flag.
1 parent 567f15b commit e4ad90c

File tree

6 files changed

+372
-58
lines changed

6 files changed

+372
-58
lines changed

lib/utils/oidc.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,44 @@ async function oidc ({ packageName, registry, opts, config }) {
116116
return undefined
117117
}
118118

119+
// this checks if the user configured provenance or it's the default unset value
120+
const isDefaultProvenance = config.isDefault('provenance')
121+
const provenanceIntent = config.get('provenance')
122+
const skipProvenance = isDefaultProvenance || provenanceIntent
123+
124+
if (skipProvenance) {
125+
const [headerB64, payloadB64] = idToken.split('.')
126+
let isPublicRepo = false
127+
if (headerB64 && payloadB64) {
128+
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
129+
try {
130+
const payload = JSON.parse(payloadJson)
131+
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
132+
isPublicRepo = true
133+
}
134+
if (ciInfo.GITLAB && payload.project_visibility === 'public') {
135+
isPublicRepo = true
136+
}
137+
} catch (e) {
138+
log.silly('oidc', 'Failed to parse idToken payload as JSON')
139+
}
140+
}
141+
142+
if (isPublicRepo) {
143+
log.silly('oidc', 'Repository is public, setting provenance')
144+
opts.provenance = true
145+
config.set('provenance', true, 'user')
146+
}
147+
}
148+
119149
log.silly('oidc', `id_token has a length of ${idToken.length} characters`)
120150

121151
const parsedRegistry = new URL(registry)
122152
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
123153
const authTokenKey = `${regKey}:_authToken`
124154

125-
const exitingToken = config.get(authTokenKey)
126-
if (exitingToken) {
155+
const existingToken = config.get(authTokenKey)
156+
if (existingToken) {
127157
log.silly('oidc', 'Existing token found')
128158
} else {
129159
log.silly('oidc', 'No existing token found')

mock-registry/lib/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ class MockRegistry {
8080
// XXX: this is opt-in currently because it breaks some existing CLI
8181
// tests. We should work towards making this the default for all tests.
8282
t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events'))
83-
t.fail(`Unmatched request: ${req.method} ${req.path}`)
83+
const protocol = req?.options?.protocol || 'http:'
84+
const hostname = req?.options?.hostname || req?.hostname || 'localhost'
85+
const p = req?.path || '/'
86+
const url = new URL(p, `${protocol}//${hostname}`).toString()
87+
t.fail(`Unmatched request: ${req.method} ${url}`)
8488
}
8589
}
8690

mock-registry/lib/provenance.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use strict'
2+
const t = require('tap')
3+
const mockGlobals = require('@npmcli/mock-globals')
4+
const nock = require('nock')
5+
6+
class MockProvenance {
7+
static sigstoreIdToken () {
8+
return `.${Buffer.from(JSON.stringify({
9+
iss: 'https://oauth2.sigstore.dev/auth',
10+
email: 'foo@bar.com',
11+
})).toString('base64')}.`
12+
}
13+
14+
static successfulNock ({
15+
oidcURL,
16+
requestToken,
17+
workflowPath,
18+
repository,
19+
serverUrl,
20+
ref,
21+
sha,
22+
runID,
23+
runAttempt,
24+
runnerEnv,
25+
}) {
26+
mockGlobals(t, {
27+
'process.env': {
28+
CI: true,
29+
GITHUB_ACTIONS: true,
30+
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
31+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
32+
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
33+
GITHUB_REPOSITORY: repository,
34+
GITHUB_SERVER_URL: serverUrl,
35+
GITHUB_REF: ref,
36+
GITHUB_SHA: sha,
37+
GITHUB_RUN_ID: runID,
38+
GITHUB_RUN_ATTEMPT: runAttempt,
39+
RUNNER_ENVIRONMENT: runnerEnv,
40+
},
41+
})
42+
43+
const idToken = this.sigstoreIdToken()
44+
45+
const url = new URL(oidcURL)
46+
nock(url.origin)
47+
.get(url.pathname)
48+
.query({ audience: 'sigstore' })
49+
.matchHeader('authorization', `Bearer ${requestToken}`)
50+
.matchHeader('accept', 'application/json')
51+
.reply(200, { value: idToken })
52+
53+
const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`
54+
55+
// Mock the Fulcio signing certificate endpoint
56+
nock('https://fulcio.sigstore.dev')
57+
.post('/api/v2/signingCert')
58+
.reply(200, {
59+
signedCertificateEmbeddedSct: {
60+
chain: {
61+
certificates: [
62+
leafCertificate,
63+
`-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`,
64+
],
65+
},
66+
},
67+
})
68+
69+
nock('https://rekor.sigstore.dev')
70+
.post('/api/v1/log/entries')
71+
.reply(201, {
72+
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': {
73+
body: Buffer.from(JSON.stringify({
74+
kind: 'hashedrekord',
75+
apiVersion: '0.0.1',
76+
spec: {
77+
signature: {
78+
content: 'ABC123',
79+
publicKey: { content: Buffer.from(leafCertificate).toString('base64') },
80+
},
81+
},
82+
})).toString(
83+
'base64'
84+
),
85+
integratedTime: 1654015743,
86+
logID:
87+
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
88+
logIndex: 2513258,
89+
verification: {
90+
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
91+
},
92+
},
93+
})
94+
}
95+
}
96+
97+
module.exports = {
98+
MockProvenance,
99+
}

test/fixtures/mock-oidc.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
const nock = require('nock')
22
const ciInfo = require('ci-info')
33

4+
// this is an effort to not add a dependency to the cli just for testing
5+
function makeJwt (payload) {
6+
const header = { alg: 'none', typ: 'JWT' }
7+
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64')
8+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64')
9+
// empty signature section
10+
return `${headerB64}.${payloadB64}.`
11+
}
12+
13+
function gitlabIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
14+
const now = Math.floor(Date.now() / 1000)
15+
const payload = {
16+
project_visibility: visibility,
17+
iat: now,
18+
exp: now + 3600, // 1 hour expiration
19+
}
20+
return makeJwt(payload)
21+
}
22+
23+
function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
24+
const now = Math.floor(Date.now() / 1000)
25+
const payload = {
26+
repository_visibility: visibility,
27+
iat: now,
28+
exp: now + 3600, // 1 hour expiration
29+
}
30+
return makeJwt(payload)
31+
}
32+
433
class MockOidc {
534
constructor (opts) {
635
const defaultOpts = {
@@ -17,6 +46,8 @@ class MockOidc {
1746
this.gitlab = options.gitlab
1847
this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL
1948
this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN
49+
this.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_TOKEN
50+
2051
this.NPM_ID_TOKEN = options.NPM_ID_TOKEN
2152
this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN
2253

@@ -28,6 +59,7 @@ class MockOidc {
2859
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
2960
GITLAB_CI: process.env.GITLAB_CI,
3061
NPM_ID_TOKEN: process.env.NPM_ID_TOKEN,
62+
SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN,
3163
}
3264
this.originalCiInfo = {
3365
GITLAB: ciInfo.GITLAB,
@@ -53,6 +85,7 @@ class MockOidc {
5385
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
5486
delete process.env.GITLAB_CI
5587
delete process.env.NPM_ID_TOKEN
88+
delete process.env.SIGSTORE_ID_TOKEN
5689

5790
ciInfo.GITHUB_ACTIONS = false
5891
ciInfo.GITLAB = false
@@ -65,6 +98,7 @@ class MockOidc {
6598

6699
if (this.gitlab) {
67100
process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN
101+
process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN
68102
ciInfo.GITLAB = true
69103
}
70104
}
@@ -128,4 +162,6 @@ class MockOidc {
128162

129163
module.exports = {
130164
MockOidc,
165+
gitlabIdToken,
166+
githubIdToken,
131167
}

0 commit comments

Comments
 (0)