Skip to content

feat: oidc provenance by default #8412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 14, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions lib/utils/oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,37 @@ async function oidc ({ packageName, registry, opts, config }) {
return undefined
}

const [headerB64, payloadB64] = idToken.split('.')
let isPublicRepo = false
if (headerB64 && payloadB64) {
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
try {
const payload = JSON.parse(payloadJson)
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
isPublicRepo = true
}
if (ciInfo.GITLAB && payload.project_visibility === 'public') {
isPublicRepo = true
}
} catch (e) {
log.silly('oidc', 'Failed to parse idToken payload as JSON')
}
}

if (isPublicRepo) {
log.silly('oidc', 'Repository is public, setting provenance')
opts.provenance = true
config.set('provenance', true, 'user')
}

log.silly('oidc', `id_token has a length of ${idToken.length} characters`)

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

const exitingToken = config.get(authTokenKey)
if (exitingToken) {
const existingToken = config.get(authTokenKey)
if (existingToken) {
log.silly('oidc', 'Existing token found')
} else {
log.silly('oidc', 'No existing token found')
Expand Down
6 changes: 5 additions & 1 deletion mock-registry/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ class MockRegistry {
// XXX: this is opt-in currently because it breaks some existing CLI
// tests. We should work towards making this the default for all tests.
t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events'))
t.fail(`Unmatched request: ${req.method} ${req.path}`)
const protocol = req?.options?.protocol || 'http:'
const hostname = req?.options?.hostname || req?.hostname || 'localhost'
const p = req?.path || '/'
const url = new URL(p, `${protocol}//${hostname}`).toString()
t.fail(`Unmatched request: ${req.method} ${url}`)
}
}

Expand Down
99 changes: 99 additions & 0 deletions mock-registry/lib/provenance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict'
const t = require('tap')
const mockGlobals = require('@npmcli/mock-globals')
const nock = require('nock')

class MockProvenance {
static sigstoreIdToken () {
return `.${Buffer.from(JSON.stringify({
iss: 'https://oauth2.sigstore.dev/auth',
email: 'foo@bar.com',
})).toString('base64')}.`
}

static successfulNock ({
oidcURL,
requestToken,
workflowPath,
repository,
serverUrl,
ref,
sha,
runID,
runAttempt,
runnerEnv,
}) {
mockGlobals(t, {
'process.env': {
CI: true,
GITHUB_ACTIONS: true,
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
GITHUB_REPOSITORY: repository,
GITHUB_SERVER_URL: serverUrl,
GITHUB_REF: ref,
GITHUB_SHA: sha,
GITHUB_RUN_ID: runID,
GITHUB_RUN_ATTEMPT: runAttempt,
RUNNER_ENVIRONMENT: runnerEnv,
},
})

const idToken = this.sigstoreIdToken()

const url = new URL(oidcURL)
nock(url.origin)
.get(url.pathname)
.query({ audience: 'sigstore' })
.matchHeader('authorization', `Bearer ${requestToken}`)
.matchHeader('accept', 'application/json')
.reply(200, { value: idToken })

const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`

// Mock the Fulcio signing certificate endpoint
nock('https://fulcio.sigstore.dev')
.post('/api/v2/signingCert')
.reply(200, {
signedCertificateEmbeddedSct: {
chain: {
certificates: [
leafCertificate,
`-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`,
],
},
},
})

nock('https://rekor.sigstore.dev')
.post('/api/v1/log/entries')
.reply(201, {
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': {
body: Buffer.from(JSON.stringify({
kind: 'hashedrekord',
apiVersion: '0.0.1',
spec: {
signature: {
content: 'ABC123',
publicKey: { content: Buffer.from(leafCertificate).toString('base64') },
},
},
})).toString(
'base64'
),
integratedTime: 1654015743,
logID:
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
logIndex: 2513258,
verification: {
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
},
},
})
}
}

module.exports = {
MockProvenance,
}
47 changes: 47 additions & 0 deletions test/fixtures/mock-oidc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
const nock = require('nock')
const ciInfo = require('ci-info')

function tnock (t, host, opts) {
nock.disableNetConnect()
const server = nock(host, opts)
t.teardown(function () {
nock.enableNetConnect()
server.done()
})
return server
}

// this is an effort to not add a dependency to the cli just for testing
function makeJwt (payload) {
const header = { alg: 'none', typ: 'JWT' }
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64')
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64')
// empty signature section
return `${headerB64}.${payloadB64}.`
}

function gitlabIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
const now = Math.floor(Date.now() / 1000)
const payload = {
project_visibility: visibility,
iat: now,
exp: now + 3600, // 1 hour expiration
}
return makeJwt(payload)
}

function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
const now = Math.floor(Date.now() / 1000)
const payload = {
repository_visibility: visibility,
iat: now,
exp: now + 3600, // 1 hour expiration
}
return makeJwt(payload)
}

class MockOidc {
constructor (opts) {
const defaultOpts = {
Expand All @@ -17,6 +56,8 @@ class MockOidc {
this.gitlab = options.gitlab
this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL
this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN
this.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_TOKEN

this.NPM_ID_TOKEN = options.NPM_ID_TOKEN
this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN

Expand All @@ -28,6 +69,7 @@ class MockOidc {
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
GITLAB_CI: process.env.GITLAB_CI,
NPM_ID_TOKEN: process.env.NPM_ID_TOKEN,
SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN,
}
this.originalCiInfo = {
GITLAB: ciInfo.GITLAB,
Expand All @@ -53,6 +95,7 @@ class MockOidc {
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
delete process.env.GITLAB_CI
delete process.env.NPM_ID_TOKEN
delete process.env.SIGSTORE_ID_TOKEN

ciInfo.GITHUB_ACTIONS = false
ciInfo.GITLAB = false
Expand All @@ -65,6 +108,7 @@ class MockOidc {

if (this.gitlab) {
process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN
process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN
ciInfo.GITLAB = true
}
}
Expand Down Expand Up @@ -128,4 +172,7 @@ class MockOidc {

module.exports = {
MockOidc,
gitlabIdToken,
githubIdToken,
tnock,
}
125 changes: 125 additions & 0 deletions test/fixtures/mock-provenance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
'use strict'
const t = require('tap')
const MockRegistry = require('./index')
const mockGlobals = require('@npmcli/mock-globals')

class MockProvenance {
static successfulNock ({
oidcURL,
requestToken,
workflowPath,
repository,
serverUrl,
ref,
sha,
runID,
runAttempt,
runnerEnv,
}) {
mockGlobals(t, {
'process.env': {
CI: true,
GITHUB_ACTIONS: true,
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
GITHUB_REPOSITORY: repository,
GITHUB_SERVER_URL: serverUrl,
GITHUB_REF: ref,
GITHUB_SHA: sha,
GITHUB_RUN_ID: runID,
GITHUB_RUN_ATTEMPT: runAttempt,
RUNNER_ENVIRONMENT: runnerEnv,
},
})

// Data for mocking the OIDC token request
const oidcClaims = {
iss: 'https://oauth2.sigstore.dev/auth',
email: 'foo@bar.com',
}
const idToken = `.${Buffer.from(JSON.stringify(oidcClaims)).toString('base64')}.`

// Data for mocking Fulcio certifcate request
const fulcioURL = 'https://mock.fulcio'
const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`
const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`
const certificateResponse = {
signedCertificateEmbeddedSct: {
chain: {
certificates: [leafCertificate, rootCertificate],
},
},
}

// Data for mocking Rekor upload
const rekorURL = 'https://mock.rekor'
const signature = 'ABC123'
const b64Cert = Buffer.from(leafCertificate).toString('base64')
const logIndex = 2513258
const uuid =
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'

const signatureBundle = {
kind: 'hashedrekord',
apiVersion: '0.0.1',
spec: {
signature: {
content: signature,
publicKey: { content: b64Cert },
},
},
}

const rekorEntry = {
[uuid]: {
body: Buffer.from(JSON.stringify(signatureBundle)).toString(
'base64'
),
integratedTime: 1654015743,
logID:
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
logIndex,
verification: {
/* eslint-disable-next-line max-len */
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
},
},
}

const oidcSrv = MockRegistry.tnock(t, oidcURL)
oidcSrv.get('/?audience=sigstore', undefined, {
authorization: `Bearer ${requestToken}`,
}).reply(200, { value: idToken })

const fulcioSrv = MockRegistry.tnock(t, fulcioURL)
fulcioSrv.matchHeader('Content-Type', 'application/json')
.post('/api/v2/signingCert', {
credentials: { oidcIdentityToken: idToken },
publicKeyRequest: {
publicKey: {
algorithm: 'ECDSA',
content: /.+/i,
},
proofOfPossession: /.+/i,
},
})
.reply(200, certificateResponse)

const rekorSrv = MockRegistry.tnock(t, rekorURL)
rekorSrv
.matchHeader('Accept', 'application/json')
.matchHeader('Content-Type', 'application/json')
.post('/api/v1/log/entries')
.reply(201, rekorEntry)

return {
fulcioURL: fulcioURL,
rekorURL: rekorURL,
}
}
}

module.exports = {
MockProvenance,
}
Loading
Loading