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:
Conor Sloan
2024-08-07 17:13:39 +01:00
parent 8215ec2f64
commit c1f237b012
7 changed files with 274 additions and 70 deletions
+150 -46
View File
@@ -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'
}
})
+39 -1
View File
@@ -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()
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 96.81%"><title>Coverage: 96.81%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">96.81%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">96.81%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="116" height="20" role="img" aria-label="Coverage: 96.95%"><title>Coverage: 96.95%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">96.95%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">96.95%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Generated Vendored
+47 -7
View File
@@ -104732,7 +104732,7 @@ async function publishOCIArtifact(token, registry, repository, semver, zipFile,
const digest = await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token);
return {
packageURL: new URL(`${repository}:${semver}`, registry),
manifestDigest: digest
publishedDigest: digest
};
}
exports.publishOCIArtifact = publishOCIArtifact;
@@ -104884,10 +104884,7 @@ async function run() {
const archiveDir = fsHelper.createTempDir(options.runnerTempDir, 'archives');
const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir);
const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date());
const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
core.setOutput('package-url', packageURL.toString());
core.setOutput('package-manifest', JSON.stringify(manifest));
core.setOutput('package-manifest-sha', manifestDigest);
const manifestDigest = ociContainer.sha256Digest(manifest);
// Attestations are not currently supported in GHES.
if (!options.isEnterprise) {
const attestation = await generateAttestation(manifestDigest, semverTag.raw, options);
@@ -104895,6 +104892,13 @@ async function run() {
core.setOutput('attestation-id', attestation.attestationID);
}
}
const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
if (manifestDigest !== publishedDigest) {
throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`);
}
core.setOutput('package-url', packageURL.toString());
core.setOutput('package-manifest', JSON.stringify(manifest));
core.setOutput('package-manifest-sha', publishedDigest);
}
catch (error) {
// Fail the workflow run if an error occurs
@@ -104923,6 +104927,7 @@ function parseSemverTagFromRef(opts) {
async function generateAttestation(manifestDigest, semverTag, options) {
const subjectName = `${options.nameWithOwner}@${semverTag}`;
const subjectDigest = removePrefix(manifestDigest, 'sha256:');
core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`);
return await attest.attestProvenance({
subjectName,
subjectDigest: { sha256: subjectDigest },
@@ -104946,12 +104951,36 @@ function removePrefix(str, prefix) {
/***/ }),
/***/ 33207:
/***/ ((__unused_webpack_module, exports) => {
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.createActionPackageManifest = void 0;
exports.sha256Digest = exports.createActionPackageManifest = void 0;
const crypto = __importStar(__nccwpck_require__(6113));
// Given a name and archive metadata, creates a manifest in the format expected by GHCR for an Actions Package.
function createActionPackageManifest(tarFile, zipFile, repository, repoId, ownerId, sourceCommit, version, created) {
const configLayer = createConfigLayer();
@@ -104978,6 +105007,17 @@ function createActionPackageManifest(tarFile, zipFile, repository, repoId, owner
return manifest;
}
exports.createActionPackageManifest = createActionPackageManifest;
// Calculate the SHA256 digest of a given manifest.
// This should match the digest which the GitHub container registry calculates for this manifest.
function sha256Digest(manifest) {
const data = JSON.stringify(manifest);
const buffer = Buffer.from(data, 'utf8');
const hash = crypto.createHash('sha256');
hash.update(buffer);
const hexHash = hash.digest('hex');
return `sha256:${hexHash}`;
}
exports.sha256Digest = sha256Digest;
function createConfigLayer() {
const configLayer = {
mediaType: 'application/vnd.oci.empty.v1+json',
+2 -2
View File
@@ -12,7 +12,7 @@ export async function publishOCIArtifact(
zipFile: FileMetadata,
tarFile: FileMetadata,
manifest: ociContainer.Manifest
): Promise<{ packageURL: URL; manifestDigest: string }> {
): Promise<{ packageURL: URL; publishedDigest: string }> {
const b64Token = Buffer.from(token).toString('base64')
const checkBlobEndpoint = new URL(
@@ -76,7 +76,7 @@ export async function publishOCIArtifact(
return {
packageURL: new URL(`${repository}:${semver}`, registry),
manifestDigest: digest
publishedDigest: digest
}
}
+23 -13
View File
@@ -50,19 +50,7 @@ export async function run(): Promise<void> {
new Date()
)
const { packageURL, manifestDigest } = await ghcr.publishOCIArtifact(
options.token,
options.containerRegistryUrl,
options.nameWithOwner,
semverTag.raw,
archives.zipFile,
archives.tarFile,
manifest
)
core.setOutput('package-url', packageURL.toString())
core.setOutput('package-manifest', JSON.stringify(manifest))
core.setOutput('package-manifest-sha', manifestDigest)
const manifestDigest = ociContainer.sha256Digest(manifest)
// Attestations are not currently supported in GHES.
if (!options.isEnterprise) {
@@ -75,6 +63,26 @@ export async function run(): Promise<void> {
core.setOutput('attestation-id', attestation.attestationID)
}
}
const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(
options.token,
options.containerRegistryUrl,
options.nameWithOwner,
semverTag.raw,
archives.zipFile,
archives.tarFile,
manifest
)
if (manifestDigest !== publishedDigest) {
throw new Error(
`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`
)
}
core.setOutput('package-url', packageURL.toString())
core.setOutput('package-manifest', JSON.stringify(manifest))
core.setOutput('package-manifest-sha', publishedDigest)
} catch (error) {
// Fail the workflow run if an error occurs
if (error instanceof Error) core.setFailed(error.message)
@@ -112,6 +120,8 @@ async function generateAttestation(
const subjectName = `${options.nameWithOwner}@${semverTag}`
const subjectDigest = removePrefix(manifestDigest, 'sha256:')
core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`)
return await attest.attestProvenance({
subjectName,
subjectDigest: { sha256: subjectDigest },
+12
View File
@@ -1,4 +1,5 @@
import { FileMetadata } from './fs-helper'
import * as crypto from 'crypto'
export interface Manifest {
schemaVersion: number
@@ -53,6 +54,17 @@ export function createActionPackageManifest(
return manifest
}
// Calculate the SHA256 digest of a given manifest.
// This should match the digest which the GitHub container registry calculates for this manifest.
export function sha256Digest(manifest: Manifest): string {
const data = JSON.stringify(manifest)
const buffer = Buffer.from(data, 'utf8')
const hash = crypto.createHash('sha256')
hash.update(buffer)
const hexHash = hash.digest('hex')
return `sha256:${hexHash}`
}
function createConfigLayer(): Layer {
const configLayer: Layer = {
mediaType: 'application/vnd.oci.empty.v1+json',