Merge pull request #156 from actions/conorsloan/attest-before-publish

Generate provenance attestation before performing upload to ghcr
This commit is contained in:
Conor Sloan
2024-08-07 21:48:38 +01:00
committed by GitHub
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',