Skip to content
6 changes: 3 additions & 3 deletions lib/utils/oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ async function oidc ({ packageName, registry, opts, config }) {

try {
const isDefaultProvenance = config.isDefault('provenance')
// CircleCI doesn't support provenance yet, so skip the auto-enable logic
if (isDefaultProvenance && !ciInfo.CIRCLE) {
// CircleCI doesn't support provenance yet, so skip the auto-enable logic.
// An explicitly provided provenance file always takes precedence over auto-generated provenance
if (isDefaultProvenance && !ciInfo.CIRCLE && !opts.provenanceFile) {
const [headerB64, payloadB64] = idToken.split('.')
if (headerB64 && payloadB64) {
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
Expand All @@ -158,7 +159,6 @@ async function oidc ({ packageName, registry, opts, config }) {
if (visibility?.public) {
log.verbose('oidc', `Enabling provenance`)
opts.provenance = true
config.set('provenance', true, 'user')
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions tap-snapshots/test/lib/docs.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1750,6 +1750,9 @@ Set to \`false\` to suppress the progress bar.
When publishing from a supported cloud CI/CD system, the package will be
publicly linked to where it was built and published from.

When the \`provenance-file\` config is set, it takes precedence and automatic
provenance generation (including via trusted publishing/OIDC) is skipped.

This config cannot be used with: \`provenance-file\`

#### \`provenance-file\`
Expand All @@ -1759,6 +1762,9 @@ This config cannot be used with: \`provenance-file\`

When publishing, the provenance bundle at the given path will be used.

This takes precedence over automatic provenance generation in trusted
publishing flows.

This config cannot be used with: \`provenance\`

#### \`proxy\`
Expand Down
7 changes: 4 additions & 3 deletions test/fixtures/mock-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const mockOidc = async (t, {
ciInfo.CIRCLE = CIRCLE
})

const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, {
const { npm, registry, joinedOutput, logs, prefix } = await loadNpmWithRegistry(t, {
config: {
loglevel: 'silly',
...config,
Expand All @@ -117,11 +117,12 @@ const mockOidc = async (t, {
})

if (mockGithubOidcOptions) {
const { idToken, audience, statusCode = 200 } = mockGithubOidcOptions
const { idToken, audience, statusCode = 200, times = 1 } = mockGithubOidcOptions
const url = new URL(ACTIONS_ID_TOKEN_REQUEST_URL)
nock(url.origin)
.get(url.pathname)
.query({ audience })
.times(times)
.matchHeader('authorization', `Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}`)
.matchHeader('accept', 'application/json')
.reply(statusCode, statusCode !== 500 ? { value: idToken } : { message: 'Internal Server Error' })
Expand Down Expand Up @@ -160,7 +161,7 @@ const mockOidc = async (t, {
})
}

return { npm, joinedOutput, logs, ACTIONS_ID_TOKEN_REQUEST_URL }
return { npm, registry, prefix, joinedOutput, logs, ACTIONS_ID_TOKEN_REQUEST_URL }
}

const oidcPublishTest = (opts) => {
Expand Down
252 changes: 252 additions & 0 deletions test/lib/commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const { loadNpmWithRegistry } = require('../../fixtures/mock-npm')
const { cleanZlib } = require('../../fixtures/clean-snapshot')
Comment thread
yunseo-kim marked this conversation as resolved.
const pacote = require('pacote')
const Arborist = require('@npmcli/arborist')
const npa = require('npm-package-arg')
const ssri = require('ssri')
const path = require('node:path')
const fs = require('node:fs')
const { circleciIdToken, githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc')
Expand Down Expand Up @@ -1533,6 +1535,256 @@ t.test('oidc token exchange - provenance', (t) => {
},
}))

const provenanceFileSources = [
{
name: 'CLI config',
options: provenanceBundlePath => ({
config: {
'provenance-file': provenanceBundlePath,
},
}),
},
{
// exercises Publish.#getManifest() and its flatten(filteredPublishConfig, opts)
// path: publishConfig must reach opts.provenanceFile before oidc() decides
// whether to enable automatic provenance
name: 'publishConfig',
options: provenanceBundlePath => ({
packageJson: {
publishConfig: {
'provenance-file': provenanceBundlePath,
},
},
}),
},
]

for (const { name, options } of provenanceFileSources) {
t.test(`${name} provenance-file takes precedence over OIDC auto-provenance`, async t => {
const bundleDir = t.testdir()
const provenanceBundlePath = path.join(
bundleDir,
'provenance-bundle.json'
)
// holder so the libnpmpack mock can return the tarball computed below
const packMock = { tarballData: null }

const sourceOptions = options(provenanceBundlePath)

const { npm, registry, prefix, joinedOutput } = await mockOidc(t, {
oidcOptions: { github: true },
config: {
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
...sourceOptions.config,
},
packageJson: sourceOptions.packageJson,
mockGithubOidcOptions: {
audience: 'npm:registry.npmjs.org',
idToken: githubPublicIdToken,
},
mockOidcTokenExchangeOptions: {
idToken: githubPublicIdToken,
body: {
token: 'exchange-token',
},
},
publishOptions: {
token: 'exchange-token',
noPut: true,
},
load: {
mocks: {
libnpmaccess: {
getVisibility: async () => ({ public: true }),
},
// publish a deterministic tarball so the bundle subject digest can match it
libnpmpack: async () => packMock.tarballData,
// libnpmpublish must be mocked as a module so its internal require of
// sigstore is intercepted: a user-supplied bundle is only verified,
// generation (attest) must never run
libnpmpublish: t.mock('libnpmpublish', {
'libnpmpublish/lib/provenance': t.mock('libnpmpublish/lib/provenance', {
sigstore: {
verify: async () => {},
attest: async () => {
throw new Error('sigstore.attest must not be called when provenance-file is configured')
},
},
}),
}),
},
},
})

// compute the tarball integrity the same way libnpmpublish does so the
// provenance bundle subject matches the packed tarball
packMock.tarballData = await pacote.tarball(prefix, { Arborist })
const integrity = ssri.fromData(packMock.tarballData, { algorithms: ['sha512'] })
const spec = npa.resolve(pkg, '1.0.0')
const provenanceBundle = {
mediaType: 'application/vnd.dev.sigstore.bundle+json;version=0.2',
verificationMaterial: {
x509CertificateChain: {
certificates: [{ rawBytes: 'dGVzdA==' }],
},
tlogEntries: [],
},
dsseEnvelope: {
payload: Buffer.from(JSON.stringify({
_type: 'https://in-toto.io/Statement/v0.1',
subject: [
{
name: npa.toPurl(spec),
digest: { sha512: integrity.sha512[0].hexDigest() },
},
],
predicateType: 'https://slsa.dev/provenance/v0.2',
predicate: {},
})).toString('base64'),
payloadType: 'application/vnd.in-toto+json',
signatures: [{
/* eslint-disable-next-line max-len */
sig: 'MEUCIQDqHtpkk1d0rMGLmf3qet9jLale3KVn8Pnywpwt7ln+9AIgG9CJvvUmyemhNYHz0DfJ4vMfKk1TMg+m3hR0mISXJos=',
keyid: '',
}],
},
}
fs.writeFileSync(provenanceBundlePath, JSON.stringify(provenanceBundle, null, 2))

let publishedBody
registry.nock
.put(`/${spec.escapedName}`, (body) => {
publishedBody = body
return true
})
.matchHeader('authorization', 'Bearer exchange-token')
// optional so a failed publish does not leave a pending mock behind
.optionally()
.reply(200, {})

// libnpmpublish checks package visibility itself before generating
// provenance; optional so it is only consumed if generation is attempted
registry.nock
.get(`/-/package/${spec.escapedName}/visibility`)
.optionally()
.reply(200, { public: true })

await npm.exec('publish', [])

t.match(joinedOutput(), '+ @npmcli/[email protected]')

const attachment =
publishedBody?._attachments[`${pkg}-1.0.0.sigstore`]

t.ok(attachment, 'published packument includes supplied provenance')
t.strictSame(
JSON.parse(attachment.data),
provenanceBundle,
'published sigstore bundle is the user-supplied provenance file'
)
})
}

t.test('automatic provenance does not leak between workspace publishes', async t => {
const provenanceBundlePath = path.join(t.testdir(), 'provenance-bundle.json')
const autoPackage = 'workspace-auto-provenance'
const filePackage = 'workspace-file-provenance'
const publishCalls = []
const prefixDir = {
'package.json': JSON.stringify({
name: 'workspace-root',
version: '1.0.0',
workspaces: [autoPackage, filePackage],
}),
[autoPackage]: {
'package.json': JSON.stringify({
name: autoPackage,
version: '1.0.0',
}),
},
[filePackage]: {
'package.json': JSON.stringify({
name: filePackage,
version: '1.0.0',
publishConfig: {
'provenance-file': provenanceBundlePath,
},
}),
},
}

const { npm, registry } = await mockOidc(t, {
oidcOptions: { github: true },
packageName: autoPackage,
config: {
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
workspaces: true,
},
mockGithubOidcOptions: {
audience: 'npm:registry.npmjs.org',
idToken: githubPublicIdToken,
times: 2,
},
mockOidcTokenExchangeOptions: {
idToken: githubPublicIdToken,
body: {
token: 'exchange-token',
},
},
publishOptions: {
noPut: true,
},
load: {
prefixDir,
mocks: {
libnpmaccess: {
getVisibility: async () => ({ public: true }),
},
// mocked as a plain module so the publish options each workspace
// receives can be recorded verbatim
libnpmpublish: {
publish: async (manifest, _tarballData, opts) => {
publishCalls.push({
name: manifest.name,
provenance: opts.provenance,
provenanceFile: opts.provenanceFile,
})
},
},
},
},
})

registry.mockOidcTokenExchange({
packageName: filePackage,
idToken: githubPublicIdToken,
body: {
token: 'exchange-token',
},
})
registry.publish(filePackage, { noPut: true })

await npm.exec('publish', [])

t.strictSame(publishCalls, [
{
name: autoPackage,
provenance: true,
provenanceFile: null,
},
{
name: filePackage,
provenance: false,
provenanceFile: provenanceBundlePath,
},
])
t.equal(
npm.config.isDefault('provenance'),
true,
'automatic provenance does not mutate shared config'
)
})

const brokenJwts = [
'x.invalid-jwt.x',
'x.invalid-jwt.',
Expand Down
7 changes: 7 additions & 0 deletions workspaces/config/lib/definitions/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2006,6 +2006,10 @@ const definitions = {
description: `
When publishing from a supported cloud CI/CD system, the package will be
publicly linked to where it was built and published from.

When the \`provenance-file\` config is set, it takes precedence and
automatic provenance generation (including via trusted publishing/OIDC)
is skipped.
`,
flatten,
}),
Expand All @@ -2016,6 +2020,9 @@ const definitions = {
exclusive: ['provenance'],
description: `
When publishing, the provenance bundle at the given path will be used.

This takes precedence over automatic provenance generation in trusted
publishing flows.
`,
flatten,
}),
Expand Down
8 changes: 6 additions & 2 deletions workspaces/libnpmpublish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ A couple of options of note:

* `opts.provenance` - when running in a supported CI environment, will trigger
the generation of a signed provenance statement to be published alongside
the package. Mutually exclusive with the `provenanceFile` option.
the package. Mutually exclusive with the `provenanceFile` option; providing
both will throw an `EUSAGE` error. In the npm CLI's trusted
publishing flows, automatic provenance generation is skipped when
`provenanceFile` is supplied.

* `opts.provenanceFile` - specifies the path to an externally-generated
provenance statement to be published alongside the package. Mutually
exclusive with the `provenance` option. The specified file should be a
exclusive with the `provenance` option; providing both will throw an
`EUSAGE` error. The specified file should be a
[Sigstore Bundle](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto)
containing a [DSSE](https://github.com/secure-systems-lab/dsse)-packaged
provenance statement.
Expand Down
6 changes: 6 additions & 0 deletions workspaces/libnpmpublish/lib/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => {

// Handle case where --provenance flag was set to true
let transparencyLogUrl
if (provenance === true && provenanceFile) {
throw Object.assign(
new Error('provenance and provenanceFile cannot be used together'),
{ code: 'EUSAGE' }
)
}
if (provenance === true || provenanceFile) {
let provenanceBundle
const subject = {
Expand Down
Loading
Loading