diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 00f32c642621c..203aaf3143a77 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -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') @@ -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') } } } diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index dbfc88ab63abc..9c656e35b245a 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -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\` @@ -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\` diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index d15d52c1b819f..e2e11b27224b6 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -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, @@ -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' }) @@ -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) => { diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index fe286ff46b748..4d4b090c8320c 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -3,6 +3,8 @@ const { loadNpmWithRegistry } = require('../../fixtures/mock-npm') const { cleanZlib } = require('../../fixtures/clean-snapshot') 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') @@ -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/test-package@1.0.0') + + 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.', diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index f932d8f48103c..8ada20fcad6ee 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -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, }), @@ -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, }), diff --git a/workspaces/libnpmpublish/README.md b/workspaces/libnpmpublish/README.md index 69b747d92dcfc..897c61b7bfe82 100644 --- a/workspaces/libnpmpublish/README.md +++ b/workspaces/libnpmpublish/README.md @@ -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. diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 796cef6aa392a..d9afc066eb4ba 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -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 = { diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 3b41656bef17a..f1e6b28c6ae19 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -862,6 +862,48 @@ t.test('user-supplied provenance - success', async t => { t.ok(ret, 'publish succeeded') }) +t.test('provenance and provenanceFile together throws', async t => { + mockGlobals(t, { + 'process.env': { + CI: true, + GITHUB_ACTIONS: true, + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://mock.oidc', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'decafbad', + }, + }) + + const { publish } = t.mock('..', { + 'ci-info': { GITHUB_ACTIONS: true, name: 'GitHub Actions' }, + '../lib/provenance': { + generateProvenance: () => { + throw new Error('generateProvenance should not be called') + }, + verifyProvenance: () => { + throw new Error('verifyProvenance should not be called') + }, + }, + }) + + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + + await t.rejects( + publish(manifest, tarData, { + ...opts, + access: 'public', + provenance: true, + provenanceFile: './test/fixtures/valid-bundle.json', + }), + { + code: 'EUSAGE', + message: /cannot be used together/, + } + ) +}) + t.test('user-supplied provenance - failure', async t => { const { publish } = t.mock('..') const manifest = {