@@ -3,6 +3,8 @@ const { loadNpmWithRegistry } = require('../../fixtures/mock-npm')
33const { cleanZlib } = require ( '../../fixtures/clean-snapshot' )
44const pacote = require ( 'pacote' )
55const Arborist = require ( '@npmcli/arborist' )
6+ const npa = require ( 'npm-package-arg' )
7+ const ssri = require ( 'ssri' )
68const path = require ( 'node:path' )
79const fs = require ( 'node:fs' )
810const { 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.' ,
0 commit comments