From 7b2ab6d13c022b9bfec882df499d57597a38d691 Mon Sep 17 00:00:00 2001 From: Yunseo Kim Date: Sun, 16 Aug 2026 13:24:18 +0900 Subject: [PATCH 1/3] fix: provenance-file takes precedence over OIDC auto-generated provenance When publishing with an externally generated provenance bundle under OIDC trusted publishing, the auto-enable logic in the OIDC flow unconditionally set `provenance: true`, which caused libnpmpublish to silently discard the supplied `--provenance-file` bundle and publish its own generated provenance instead. Skip auto-enabling provenance when a provenance file is configured, so the explicitly supplied bundle is verified and published as documented. Fixes: https://github.com/npm/cli/issues/9879 Signed-off-by: Yunseo Kim --- lib/utils/oidc.js | 5 +- test/fixtures/mock-oidc.js | 4 +- test/lib/commands/publish.js | 119 +++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) 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/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.', From 66710dcf8f693bb70e04834017734b2ecd674e24 Mon Sep 17 00:00:00 2001 From: Yunseo Kim Date: Sun, 16 Aug 2026 13:24:27 +0900 Subject: [PATCH 2/3] fix(libnpmpublish): throw when provenance and provenanceFile are both set The two options are documented as mutually exclusive, but buildMetadata previously preferred generation when both were set, silently discarding the supplied bundle. Throw an EPROVENANCECONFLICT usage error instead of silently discarding a caller-supplied cryptographically meaningful artifact. Signed-off-by: Yunseo Kim --- workspaces/libnpmpublish/README.md | 8 +++-- workspaces/libnpmpublish/lib/publish.js | 6 ++++ workspaces/libnpmpublish/test/publish.js | 42 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) 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 = { From df4e3001e5cab941764e714c70f7fcaccc5e28b8 Mon Sep 17 00:00:00 2001 From: Yunseo Kim Date: Sun, 16 Aug 2026 13:24:34 +0900 Subject: [PATCH 3/3] docs: document provenance-file precedence over automatic provenance Signed-off-by: Yunseo Kim --- tap-snapshots/test/lib/docs.js.test.cjs | 6 ++++++ workspaces/config/lib/definitions/definitions.js | 7 +++++++ 2 files changed, 13 insertions(+) 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/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, }),