diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 00f32c642621c..dd41e96c92739 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') 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..a9581765ff2c4 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, @@ -160,7 +160,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..c56557bee4053 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,123 @@ t.test('oidc token exchange - provenance', (t) => { }, })) + t.test('provenance-file config should take 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 { npm, registry, prefix, joinedOutput } = await mockOidc(t, { + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + 'provenance-file': provenanceBundlePath, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + noPut: true, + }, + load: { + mocks: { + // the registry visibility check passes for this public package so the + // OIDC flow would auto-enable provenance unless provenance-file wins + 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 sigstoreAttachment = publishedBody?._attachments[`${pkg}-1.0.0.sigstore`] + t.ok(sigstoreAttachment, 'published packument includes a sigstore bundle attachment') + t.strictSame( + JSON.parse(sigstoreAttachment.data), + provenanceBundle, + 'published sigstore bundle is the user-supplied provenance file' + ) + }) + 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..a21140ead0031 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 `EPROVENANCECONFLICT` 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 + `EPROVENANCECONFLICT` 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..33a46d56c26d5 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 are mutually exclusive'), + { code: 'EPROVENANCECONFLICT' } + ) + } if (provenance === true || provenanceFile) { let provenanceBundle const subject = { diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 3b41656bef17a..6d5dd0ed569e0 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: 'EPROVENANCECONFLICT', + message: /mutually exclusive/, + } + ) +}) + t.test('user-supplied provenance - failure', async t => { const { publish } = t.mock('..') const manifest = {