fix: provenance-file takes precedence over OIDC auto-generated proven… · npm/cli@ddbadfc · GitHub
Skip to content

Commit ddbadfc

Browse files
fix: provenance-file takes precedence over OIDC auto-generated provenance (#9947)
Backport of #9882 to `release/v11`. Signed-off-by: Yunseo Kim <git@yunseo.kim> Co-authored-by: Yunseo Kim <dev@yunseo.kim>
1 parent 64d4b4b commit ddbadfc

8 files changed

Lines changed: 326 additions & 8 deletions

File tree

‎lib/utils/oidc.js‎

Lines changed: 3 additions & 3 deletions

‎tap-snapshots/test/lib/docs.js.test.cjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1631,6 +1631,9 @@ Set to \`false\` to suppress the progress bar.
16311631
When publishing from a supported cloud CI/CD system, the package will be
16321632
publicly linked to where it was built and published from.
16331633
1634+
When the \`provenance-file\` config is set, it takes precedence and automatic
1635+
provenance generation (including via trusted publishing/OIDC) is skipped.
1636+
16341637
This config cannot be used with: \`provenance-file\`
16351638
16361639
#### \`provenance-file\`
@@ -1640,6 +1643,9 @@ This config cannot be used with: \`provenance-file\`
16401643
16411644
When publishing, the provenance bundle at the given path will be used.
16421645
1646+
This takes precedence over automatic provenance generation in trusted
1647+
publishing flows.
1648+
16431649
This config cannot be used with: \`provenance\`
16441650
16451651
#### \`proxy\`

‎test/fixtures/mock-oidc.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const mockOidc = async (t, {
101101
ciInfo.CIRCLE = CIRCLE
102102
})
103103

104-
const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, {
104+
const { npm, registry, joinedOutput, logs, prefix } = await loadNpmWithRegistry(t, {
105105
config: {
106106
loglevel: 'silly',
107107
...config,
@@ -117,11 +117,12 @@ const mockOidc = async (t, {
117117
})
118118

119119
if (mockGithubOidcOptions) {
120-
const { idToken, audience, statusCode = 200 } = mockGithubOidcOptions
120+
const { idToken, audience, statusCode = 200, times = 1 } = mockGithubOidcOptions
121121
const url = new URL(ACTIONS_ID_TOKEN_REQUEST_URL)
122122
nock(url.origin)
123123
.get(url.pathname)
124124
.query({ audience })
125+
.times(times)
125126
.matchHeader('authorization', `Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}`)
126127
.matchHeader('accept', 'application/json')
127128
.reply(statusCode, statusCode !== 500 ? { value: idToken } : { message: 'Internal Server Error' })
@@ -160,7 +161,7 @@ const mockOidc = async (t, {
160161
})
161162
}
162163

163-
return { npm, joinedOutput, logs, ACTIONS_ID_TOKEN_REQUEST_URL }
164+
return { npm, registry, prefix, joinedOutput, logs, ACTIONS_ID_TOKEN_REQUEST_URL }
164165
}
165166

166167
const oidcPublishTest = (opts) => {

‎test/lib/commands/publish.js‎

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const { loadNpmWithRegistry } = require('../../fixtures/mock-npm')
33
const { cleanZlib } = require('../../fixtures/clean-snapshot')
44
const pacote = require('pacote')
55
const Arborist = require('@npmcli/arborist')
6+
const npa = require('npm-package-arg')
7+
const ssri = require('ssri')
68
const path = require('node:path')
79
const fs = require('node:fs')
810
const { circleciIdToken, githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc')
@@ -1495,6 +1497,256 @@ t.test('oidc token exchange - provenance', (t) => {
14951497
},
14961498
}))
14971499

1500+
const provenanceFileSources = [
1501+
{
1502+
name: 'CLI config',
1503+
options: provenanceBundlePath => ({
1504+
config: {
1505+
'provenance-file': provenanceBundlePath,
1506+
},
1507+
}),
1508+
},
1509+
{
1510+
// exercises Publish.#getManifest() and its flatten(filteredPublishConfig, opts)
1511+
// path: publishConfig must reach opts.provenanceFile before oidc() decides
1512+
// whether to enable automatic provenance
1513+
name: 'publishConfig',
1514+
options: provenanceBundlePath => ({
1515+
packageJson: {
1516+
publishConfig: {
1517+
'provenance-file': provenanceBundlePath,
1518+
},
1519+
},
1520+
}),
1521+
},
1522+
]
1523+
1524+
for (const { name, options } of provenanceFileSources) {
1525+
t.test(`${name} provenance-file takes precedence over OIDC auto-provenance`, async t => {
1526+
const bundleDir = t.testdir()
1527+
const provenanceBundlePath = path.join(
1528+
bundleDir,
1529+
'provenance-bundle.json'
1530+
)
1531+
// holder so the libnpmpack mock can return the tarball computed below
1532+
const packMock = { tarballData: null }
1533+
1534+
const sourceOptions = options(provenanceBundlePath)
1535+
1536+
const { npm, registry, prefix, joinedOutput } = await mockOidc(t, {
1537+
oidcOptions: { github: true },
1538+
config: {
1539+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1540+
...sourceOptions.config,
1541+
},
1542+
packageJson: sourceOptions.packageJson,
1543+
mockGithubOidcOptions: {
1544+
audience: 'npm:registry.npmjs.org',
1545+
idToken: githubPublicIdToken,
1546+
},
1547+
mockOidcTokenExchangeOptions: {
1548+
idToken: githubPublicIdToken,
1549+
body: {
1550+
token: 'exchange-token',
1551+
},
1552+
},
1553+
publishOptions: {
1554+
token: 'exchange-token',
1555+
noPut: true,
1556+
},
1557+
load: {
1558+
mocks: {
1559+
libnpmaccess: {
1560+
getVisibility: async () => ({ public: true }),
1561+
},
1562+
// publish a deterministic tarball so the bundle subject digest can match it
1563+
libnpmpack: async () => packMock.tarballData,
1564+
// libnpmpublish must be mocked as a module so its internal require of
1565+
// sigstore is intercepted: a user-supplied bundle is only verified,
1566+
// generation (attest) must never run
1567+
libnpmpublish: t.mock('libnpmpublish', {
1568+
'libnpmpublish/lib/provenance': t.mock('libnpmpublish/lib/provenance', {
1569+
sigstore: {
1570+
verify: async () => {},
1571+
attest: async () => {
1572+
throw new Error('sigstore.attest must not be called when provenance-file is configured')
1573+
},
1574+
},
1575+
}),
1576+
}),
1577+
},
1578+
},
1579+
})
1580+
1581+
// compute the tarball integrity the same way libnpmpublish does so the
1582+
// provenance bundle subject matches the packed tarball
1583+
packMock.tarballData = await pacote.tarball(prefix, { Arborist })
1584+
const integrity = ssri.fromData(packMock.tarballData, { algorithms: ['sha512'] })
1585+
const spec = npa.resolve(pkg, '1.0.0')
1586+
const provenanceBundle = {
1587+
mediaType: 'application/vnd.dev.sigstore.bundle+json;version=0.2',
1588+
verificationMaterial: {
1589+
x509CertificateChain: {
1590+
certificates: [{ rawBytes: 'dGVzdA==' }],
1591+
},
1592+
tlogEntries: [],
1593+
},
1594+
dsseEnvelope: {
1595+
payload: Buffer.from(JSON.stringify({
1596+
_type: 'https://in-toto.io/Statement/v0.1',
1597+
subject: [
1598+
{
1599+
name: npa.toPurl(spec),
1600+
digest: { sha512: integrity.sha512[0].hexDigest() },
1601+
},
1602+
],
1603+
predicateType: 'https://slsa.dev/provenance/v0.2',
1604+
predicate: {},
1605+
})).toString('base64'),
1606+
payloadType: 'application/vnd.in-toto+json',
1607+
signatures: [{
1608+
/* eslint-disable-next-line max-len */
1609+
sig: 'MEUCIQDqHtpkk1d0rMGLmf3qet9jLale3KVn8Pnywpwt7ln+9AIgG9CJvvUmyemhNYHz0DfJ4vMfKk1TMg+m3hR0mISXJos=',
1610+
keyid: '',
1611+
}],
1612+
},
1613+
}
1614+
fs.writeFileSync(provenanceBundlePath, JSON.stringify(provenanceBundle, null, 2))
1615+
1616+
let publishedBody
1617+
registry.nock
1618+
.put(`/${spec.escapedName}`, (body) => {
1619+
publishedBody = body
1620+
return true
1621+
})
1622+
.matchHeader('authorization', 'Bearer exchange-token')
1623+
// optional so a failed publish does not leave a pending mock behind
1624+
.optionally()
1625+
.reply(200, {})
1626+
1627+
// libnpmpublish checks package visibility itself before generating
1628+
// provenance; optional so it is only consumed if generation is attempted
1629+
registry.nock
1630+
.get(`/-/package/${spec.escapedName}/visibility`)
1631+
.optionally()
1632+
.reply(200, { public: true })
1633+
1634+
await npm.exec('publish', [])
1635+
1636+
t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0')
1637+
1638+
const attachment =
1639+
publishedBody?._attachments[`${pkg}-1.0.0.sigstore`]
1640+
1641+
t.ok(attachment, 'published packument includes supplied provenance')
1642+
t.strictSame(
1643+
JSON.parse(attachment.data),
1644+
provenanceBundle,
1645+
'published sigstore bundle is the user-supplied provenance file'
1646+
)
1647+
})
1648+
}
1649+
1650+
t.test('automatic provenance does not leak between workspace publishes', async t => {
1651+
const provenanceBundlePath = path.join(t.testdir(), 'provenance-bundle.json')
1652+
const autoPackage = 'workspace-auto-provenance'
1653+
const filePackage = 'workspace-file-provenance'
1654+
const publishCalls = []
1655+
const prefixDir = {
1656+
'package.json': JSON.stringify({
1657+
name: 'workspace-root',
1658+
version: '1.0.0',
1659+
workspaces: [autoPackage, filePackage],
1660+
}),
1661+
[autoPackage]: {
1662+
'package.json': JSON.stringify({
1663+
name: autoPackage,
1664+
version: '1.0.0',
1665+
}),
1666+
},
1667+
[filePackage]: {
1668+
'package.json': JSON.stringify({
1669+
name: filePackage,
1670+
version: '1.0.0',
1671+
publishConfig: {
1672+
'provenance-file': provenanceBundlePath,
1673+
},
1674+
}),
1675+
},
1676+
}
1677+
1678+
const { npm, registry } = await mockOidc(t, {
1679+
oidcOptions: { github: true },
1680+
packageName: autoPackage,
1681+
config: {
1682+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1683+
workspaces: true,
1684+
},
1685+
mockGithubOidcOptions: {
1686+
audience: 'npm:registry.npmjs.org',
1687+
idToken: githubPublicIdToken,
1688+
times: 2,
1689+
},
1690+
mockOidcTokenExchangeOptions: {
1691+
idToken: githubPublicIdToken,
1692+
body: {
1693+
token: 'exchange-token',
1694+
},
1695+
},
1696+
publishOptions: {
1697+
noPut: true,
1698+
},
1699+
load: {
1700+
prefixDir,
1701+
mocks: {
1702+
libnpmaccess: {
1703+
getVisibility: async () => ({ public: true }),
1704+
},
1705+
// mocked as a plain module so the publish options each workspace
1706+
// receives can be recorded verbatim
1707+
libnpmpublish: {
1708+
publish: async (manifest, _tarballData, opts) => {
1709+
publishCalls.push({
1710+
name: manifest.name,
1711+
provenance: opts.provenance,
1712+
provenanceFile: opts.provenanceFile,
1713+
})
1714+
},
1715+
},
1716+
},
1717+
},
1718+
})
1719+
1720+
registry.mockOidcTokenExchange({
1721+
packageName: filePackage,
1722+
idToken: githubPublicIdToken,
1723+
body: {
1724+
token: 'exchange-token',
1725+
},
1726+
})
1727+
registry.publish(filePackage, { noPut: true })
1728+
1729+
await npm.exec('publish', [])
1730+
1731+
t.strictSame(publishCalls, [
1732+
{
1733+
name: autoPackage,
1734+
provenance: true,
1735+
provenanceFile: null,
1736+
},
1737+
{
1738+
name: filePackage,
1739+
provenance: false,
1740+
provenanceFile: provenanceBundlePath,
1741+
},
1742+
])
1743+
t.equal(
1744+
npm.config.isDefault('provenance'),
1745+
true,
1746+
'automatic provenance does not mutate shared config'
1747+
)
1748+
})
1749+
14981750
const brokenJwts = [
14991751
'x.invalid-jwt.x',
15001752
'x.invalid-jwt.',

‎workspaces/config/lib/definitions/definitions.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,10 @@ const definitions = {
18811881
description: `
18821882
When publishing from a supported cloud CI/CD system, the package will be
18831883
publicly linked to where it was built and published from.
1884+
1885+
When the \`provenance-file\` config is set, it takes precedence and
1886+
automatic provenance generation (including via trusted publishing/OIDC)
1887+
is skipped.
18841888
`,
18851889
flatten,
18861890
}),
@@ -1891,6 +1895,9 @@ const definitions = {
18911895
exclusive: ['provenance'],
18921896
description: `
18931897
When publishing, the provenance bundle at the given path will be used.
1898+
1899+
This takes precedence over automatic provenance generation in trusted
1900+
publishing flows.
18941901
`,
18951902
flatten,
18961903
}),

‎workspaces/libnpmpublish/README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,15 @@ A couple of options of note:
5353

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

5861
* `opts.provenanceFile` - specifies the path to an externally-generated
5962
provenance statement to be published alongside the package. Mutually
60-
exclusive with the `provenance` option. The specified file should be a
63+
exclusive with the `provenance` option; providing both will throw an
64+
`EUSAGE` error. The specified file should be a
6165
[Sigstore Bundle](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto)
6266
containing a [DSSE](https://github.com/secure-systems-lab/dsse)-packaged
6367
provenance statement.

‎workspaces/libnpmpublish/lib/publish.js‎

Lines changed: 6 additions & 0 deletions

0 commit comments

Comments
 (0)