Generate provenance attestation before performing upload to ghcr
This allows us to check in the backend that a valid attestation exists for a package version before we allow the upload to succeed. In doing this, we can perform an integrity check that the attestation is valid and all action packages have valid attestations.
This commit is contained in:
+150
-46
@@ -12,6 +12,7 @@ import * as main from '../src/main'
|
||||
import * as cfg from '../src/config'
|
||||
import * as fsHelper from '../src/fs-helper'
|
||||
import * as ghcr from '../src/ghcr-client'
|
||||
import * as ociContainer from '../src/oci-container'
|
||||
|
||||
const ghcrUrl = new URL('https://ghcr.io')
|
||||
|
||||
@@ -19,11 +20,16 @@ const ghcrUrl = new URL('https://ghcr.io')
|
||||
let setFailedMock: jest.SpyInstance
|
||||
let setOutputMock: jest.SpyInstance
|
||||
|
||||
// Mock the IA Toolkit
|
||||
// Mock the FS Helper
|
||||
let createTempDirMock: jest.SpyInstance
|
||||
let createArchivesMock: jest.SpyInstance
|
||||
let stageActionFilesMock: jest.SpyInstance
|
||||
let ensureCorrectShaCheckedOutMock: jest.SpyInstance
|
||||
|
||||
// Mock OCI container lib
|
||||
let calculateManifestDigestMock: jest.SpyInstance
|
||||
|
||||
// Mock GHCR client
|
||||
let publishOCIArtifactMock: jest.SpyInstance
|
||||
|
||||
// Mock the config resolution
|
||||
@@ -54,6 +60,11 @@ describe('run', () => {
|
||||
.spyOn(fsHelper, 'ensureTagAndRefCheckedOut')
|
||||
.mockImplementation()
|
||||
|
||||
// OCI Container mocks
|
||||
calculateManifestDigestMock = jest
|
||||
.spyOn(ociContainer, 'sha256Digest')
|
||||
.mockImplementation()
|
||||
|
||||
// GHCR Client mocks
|
||||
publishOCIArtifactMock = jest
|
||||
.spyOn(ghcr, 'publishOCIArtifact')
|
||||
@@ -189,43 +200,6 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if publishing OCI artifact fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if creating attestation fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
@@ -252,11 +226,8 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async () => {
|
||||
@@ -270,6 +241,127 @@ describe('run', () => {
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if publishing OCI artifact fails', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
throw new Error('Something went wrong')
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith('Something went wrong')
|
||||
})
|
||||
|
||||
it('fails if unexpected digest returned from GHCR', async () => {
|
||||
resolvePublishActionOptionsMock.mockReturnValue(baseOptions())
|
||||
|
||||
ensureCorrectShaCheckedOutMock.mockImplementation(() => {})
|
||||
|
||||
createTempDirMock.mockImplementation(() => {
|
||||
return 'stagingOrArchivesDir'
|
||||
})
|
||||
|
||||
stageActionFilesMock.mockImplementation(() => {})
|
||||
|
||||
createArchivesMock.mockImplementation(() => {
|
||||
return {
|
||||
zipFile: {
|
||||
path: 'test',
|
||||
size: 5,
|
||||
sha256: '123'
|
||||
},
|
||||
tarFile: {
|
||||
path: 'test2',
|
||||
size: 52,
|
||||
sha256: '1234'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
generateAttestationMock.mockImplementation(async options => {
|
||||
expect(options).toHaveProperty('skipWrite', false)
|
||||
|
||||
return {
|
||||
attestationID: 'test-attestation-id',
|
||||
certificate: 'test',
|
||||
bundle: {
|
||||
mediaType: 'application/vnd.cncf.notary.v2+jwt',
|
||||
verificationMaterial: {
|
||||
publicKey: {
|
||||
hint: 'test-hint'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
publishedDigest: 'sha256:some-other-digest'
|
||||
}
|
||||
})
|
||||
|
||||
// Run the action
|
||||
await main.run()
|
||||
|
||||
// Check the results
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
'Unexpected digest returned for manifest. Expected sha256:my-test-digest, got sha256:some-other-digest'
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => {
|
||||
const options = baseOptions()
|
||||
options.isEnterprise = true
|
||||
@@ -298,10 +390,14 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -356,10 +452,14 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -439,10 +539,14 @@ describe('run', () => {
|
||||
}
|
||||
})
|
||||
|
||||
calculateManifestDigestMock.mockImplementation(() => {
|
||||
return 'sha256:my-test-digest'
|
||||
})
|
||||
|
||||
publishOCIArtifactMock.mockImplementation(() => {
|
||||
return {
|
||||
packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3',
|
||||
manifestDigest: 'sha256:my-test-digest'
|
||||
publishedDigest: 'sha256:my-test-digest'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import { createActionPackageManifest } from '../src/oci-container'
|
||||
import { createActionPackageManifest, sha256Digest } from '../src/oci-container'
|
||||
import { FileMetadata } from '../src/fs-helper'
|
||||
|
||||
describe('sha256Digest', () => {
|
||||
it('calculates the SHA256 digest of the provided manifest', () => {
|
||||
const date = new Date('2021-01-01T00:00:00Z')
|
||||
const repo = 'test-org/test-repo'
|
||||
const version = '1.2.3'
|
||||
const repoId = '123'
|
||||
const ownerId = '456'
|
||||
const sourceCommit = 'abc'
|
||||
const tarFile: FileMetadata = {
|
||||
path: '/test/test/test.tar.gz',
|
||||
sha256: 'tarSha',
|
||||
size: 123
|
||||
}
|
||||
const zipFile: FileMetadata = {
|
||||
path: '/test/test/test.zip',
|
||||
sha256: 'zipSha',
|
||||
size: 456
|
||||
}
|
||||
|
||||
const manifest = createActionPackageManifest(
|
||||
tarFile,
|
||||
zipFile,
|
||||
repo,
|
||||
repoId,
|
||||
ownerId,
|
||||
sourceCommit,
|
||||
version,
|
||||
date
|
||||
)
|
||||
|
||||
const digest = sha256Digest(manifest)
|
||||
const expectedDigest =
|
||||
'sha256:dd8537ef913cf87e25064a074973ed2c62699f1dbd74d0dd78e85d394a5758b5'
|
||||
|
||||
expect(digest).toEqual(expectedDigest)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createActionPackageManifest', () => {
|
||||
it('creates a manifest containing the provided information', () => {
|
||||
const date = new Date()
|
||||
|
||||
Reference in New Issue
Block a user