Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 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 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
4 changes: 2 additions & 2 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 Down Expand Up @@ -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) => {
Expand Down
119 changes: 119 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')
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,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.',
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 `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.
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 are mutually exclusive'),
{ code: 'EPROVENANCECONFLICT' }
)
}
if (provenance === true || provenanceFile) {
let provenanceBundle
const subject = {
Expand Down
42 changes: 42 additions & 0 deletions workspaces/libnpmpublish/test/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down