init attest action

This commit is contained in:
ejahnGithub
2024-02-22 07:53:51 -08:00
parent d9dd4e3410
commit e3c685d193
48 changed files with 85876 additions and 1630 deletions
+6
View File
@@ -0,0 +1,6 @@
export const FULCIO_PUBLIC_GOOD_URL = 'https://fulcio.sigstore.dev'
export const REKOR_PUBLIC_GOOD_URL = 'https://rekor.sigstore.dev'
export const SEARCH_PUBLIC_GOOD_URL = 'https://search.sigstore.dev'
export const FULCIO_INTERNAL_URL = 'https://fulcio.githubapp.com'
export const TSA_INTERNAL_URL = 'https://timestamp.githubapp.com'
+38
View File
@@ -0,0 +1,38 @@
import * as core from '@actions/core'
import fs from 'fs'
import * as path from 'path'
import type { Predicate } from '@actions/attest'
// Returns the predicate specified by the action's inputs. The predicate value
// may be specified as a path to a file or as a string.
export const predicateFromInputs = (): Predicate => {
const predicateType = core.getInput('predicate-type', { required: true })
const predicateStr = core.getInput('predicate', { required: false })
const predicatePath = core.getInput('predicate-path', { required: false })
if (!predicatePath && !predicateStr) {
throw new Error('One of predicate-path or predicate must be provided')
}
const params = predicatePath
? fs.readFileSync(predicatePath, 'utf-8')
: predicateStr
return { type: predicateType, params: JSON.parse(params) }
}
export const storePredicate = (predicate: Predicate): string => {
// random tempfile
const basePath = process.env['RUNNER_TEMP']
if (!basePath) {
throw new Error('Missing RUNNER_TEMP environment variable')
}
const tmpDir = fs.mkdtempSync(path.join(basePath, path.sep))
const tempFile = path.join(tmpDir, 'predicate.json')
// write predicate to file
fs.writeFileSync(tempFile, JSON.stringify(predicate.params))
return tempFile
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'fs'
import { SBOM } from '@actions/attest'
export async function parseSBOMFromPath(path: string): Promise<SBOM> {
// Read the file content
const fileContent = await fs.promises.readFile(path, 'utf8')
const sbom = JSON.parse(fileContent)
if (checkIsSPDX(sbom)) {
return { type: 'spdx', object: sbom }
} else if (checkIsCycloneDX(sbom)) {
return { type: 'cyclonedx', object: sbom }
}
throw new Error('Unsupported SBOM format')
}
function checkIsSPDX(sbomObject: {
spdxVersion?: string
SPDXID?: string
}): boolean {
if (sbomObject?.spdxVersion && sbomObject?.SPDXID) {
return true
} else {
return false
}
}
function checkIsCycloneDX(sbomObject: {
bomFormat?: string
serialNumber?: string
specVersion?: string
}): boolean {
if (
sbomObject?.bomFormat &&
sbomObject?.serialNumber &&
sbomObject?.specVersion
) {
return true
} else {
return false
}
}
+96
View File
@@ -0,0 +1,96 @@
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import crypto from 'crypto'
import fs from 'fs'
import path from 'path'
import type { Subject } from '@actions/attest'
const DIGEST_ALGORITHM = 'sha256'
// Returns the subject specified by the action's inputs. The subject may be
// specified as a path to a file or as a digest. If a path is provided, the
// file's digest is calculated and returned along with the subject's name. If a
// digest is provided, the name must also be provided.
export const subjectFromInputs = async (): Promise<Subject[]> => {
const subjectPath = core.getInput('subject-path', { required: false })
const subjectDigest = core.getInput('subject-digest', { required: false })
const subjectName = core.getInput('subject-name', { required: false })
if (!subjectPath && !subjectDigest) {
throw new Error('One of subject-path or subject-digest must be provided')
}
if (subjectPath && subjectDigest) {
throw new Error(
'Only one of subject-path or subject-digest may be provided'
)
}
if (subjectDigest && !subjectName) {
throw new Error('subject-name must be provided when using subject-digest')
}
if (subjectPath) {
return await getSubjectFromPath(subjectPath, subjectName)
} else {
return [getSubjectFromDigest(subjectDigest, subjectName)]
}
}
// Returns the subject specified by the path to a file. The file's digest is
// calculated and returned along with the subject's name.
const getSubjectFromPath = async (
subjectPath: string,
subjectName?: string
): Promise<Subject[]> => {
/* eslint-disable-next-line github/no-then */
const files = await glob.create(subjectPath).then(async g => g.glob())
const subjects = files.map(async file => {
const name = subjectName || path.parse(file).base
const digest = await digestFile(DIGEST_ALGORITHM, file)
return { name, digest: { [DIGEST_ALGORITHM]: digest } }
})
if (subjects.length === 0) {
throw new Error(`Could not find subject at path ${subjectPath}`)
}
return Promise.all(subjects)
}
// Returns the subject specified by the digest of a file. The digest is returned
// along with the subject's name.
const getSubjectFromDigest = (
subjectDigest: string,
subjectName: string
): Subject => {
if (!subjectDigest.match(/^sha256:[A-Za-z0-9]{64}$/)) {
throw new Error(
'subject-digest must be in the format "sha256:<hex-digest>"'
)
}
const [alg, digest] = subjectDigest.split(':')
return {
name: subjectName,
digest: { [alg]: digest }
}
}
// Calculates the digest of a file using the specified algorithm. The file is
// streamed into the digest function to avoid loading the entire file into
// memory. The returned digest is a hex string.
const digestFile = async (
algorithm: string,
filePath: string
): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(algorithm).setEncoding('hex')
fs.createReadStream(filePath)
.once('error', reject)
.pipe(hash)
.once('finish', () => resolve(hash.read()))
})
}
+157 -12
View File
@@ -1,26 +1,171 @@
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
import * as core from '@actions/core'
import { wait } from './wait'
import * as github from '@actions/github'
import { BUNDLE_V02_MEDIA_TYPE } from '@sigstore/bundle'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import fs from 'fs'
import os from 'os'
import path from 'path'
import {
FULCIO_INTERNAL_URL,
FULCIO_PUBLIC_GOOD_URL,
REKOR_PUBLIC_GOOD_URL,
SEARCH_PUBLIC_GOOD_URL,
TSA_INTERNAL_URL
} from './helper/endpoints'
import { predicateFromInputs } from './helper/predicate'
import { subjectFromInputs } from './helper/subject'
type Endpoints = {
fulcioURL: string
rekorURL?: string
tsaServerURL?: string
}
const COLOR_CYAN = '\x1B[36m'
const COLOR_DEFAULT = '\x1B[39m'
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
const SIGSTORE_PUBLIC_GOOD_ENDPOINTS: Endpoints = {
fulcioURL: FULCIO_PUBLIC_GOOD_URL,
rekorURL: REKOR_PUBLIC_GOOD_URL
}
const SIGSTORE_INTERNAL_ENDPOINTS: Endpoints = {
fulcioURL: FULCIO_INTERNAL_URL,
tsaServerURL: TSA_INTERNAL_URL
}
/**
* The main function for the action.
* @returns {Promise<void>} Resolves when the action is complete.
*/
export async function run(): Promise<void> {
// Provenance visibility will be public ONLY if we can confirm that the
// repository is public AND the undocumented "private-signing" arg is NOT set.
// Otherwise, it will be private.
const endpoints =
github.context.payload.repository?.visibility === 'public' &&
core.getInput('private-signing') !== 'true'
? SIGSTORE_PUBLIC_GOOD_ENDPOINTS
: SIGSTORE_INTERNAL_ENDPOINTS
try {
const ms: string = core.getInput('milliseconds')
// Calculate subject from inputs and generate provenance
const subjects = await subjectFromInputs()
const predicate = predicateFromInputs()
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
// Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true
core.debug(`Waiting ${ms} milliseconds ...`)
// Generate attestations for each subject serially
for (const subject of subjects) {
const att = await createAttestation(subject, predicate, endpoints)
// Log the current timestamp, wait, then log the new timestamp
core.debug(new Date().toTimeString())
await wait(parseInt(ms, 10))
core.debug(new Date().toTimeString())
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
// Set outputs for other workflow steps to use
core.setOutput('time', new Date().toTimeString())
} catch (error) {
if (att.attestationID) {
core.summary.addLink(
`${subject.name}@${subjectDigest(subject)}`,
attestationURL(att.attestationID)
)
}
}
if (!core.summary.isEmptyBuffer()) {
core.summary.addHeading('Attestation(s) Created', 3)
core.summary.write()
}
core.setOutput('bundle-path', outputPath)
} catch (err) {
// Fail the workflow run if an error occurs
if (error instanceof Error) core.setFailed(error.message)
core.setFailed(
err instanceof Error ? err.message : /* istanbul ignore next */ `${err}`
)
/* istanbul ignore if */
if (err instanceof Error && 'cause' in err) {
const innerErr = err.cause
core.debug(innerErr instanceof Error ? innerErr.message : `${innerErr}}`)
}
}
}
const createAttestation = async (
subject: Subject,
predicate: Predicate,
endpoints: Endpoints
): Promise<Attestation> => {
// Sign provenance w/ Sigstore
const attestation = await attest({
...endpoints,
subjectName: subject.name,
subjectDigest: subject.digest,
predicateType: predicate.type,
predicate: predicate.params,
token: core.getInput('github-token')
})
core.startGroup(
highlight(
`Attestation signed using ephemeral certificate from ${endpoints.fulcioURL}`
)
)
core.info(attestation.certificate)
core.endGroup()
if (attestation.tlogID) {
core.info(
highlight('Attestation signature uploaded to Rekor transparency log')
)
core.info(`${SEARCH_PUBLIC_GOOD_URL}?logIndex=${attestation.tlogID}`)
}
if (attestation.attestationID) {
core.info(highlight('Attestation uploaded to repository'))
core.info(attestationURL(attestation.attestationID))
}
if (core.getBooleanInput('push-to-registry', { required: false })) {
const credentials = getRegistryCredentials(subject.name)
const artifact = await attachArtifactToImage({
credentials,
imageName: subject.name,
imageDigest: subjectDigest(subject),
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
mediaType: BUNDLE_V02_MEDIA_TYPE,
annotations: {
'dev.sigstore.bundle/predicateType': core.getInput('predicate-type')
}
})
core.info(highlight('Attestation uploaded to registry'))
core.info(`${subject.name}@${artifact.digest}`)
}
return attestation
}
const highlight = (str: string): string => `${COLOR_CYAN}${str}${COLOR_DEFAULT}`
const tempDir = (): string => {
const basePath = process.env['RUNNER_TEMP']
if (!basePath) {
throw new Error('Missing RUNNER_TEMP environment variable')
}
return fs.mkdtempSync(path.join(basePath, path.sep))
}
// Returns the subject's digest as a formatted string of the form
// "<algorithm>:<digest>".
const subjectDigest = (subject: Subject): string => {
const alg = Object.keys(subject.digest).sort()[0]
return `${alg}:${subject.digest[alg]}`
}
const attestationURL = (id: string): string =>
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
-14
View File
@@ -1,14 +0,0 @@
/**
* Wait for a number of milliseconds.
* @param milliseconds The number of milliseconds to wait.
* @returns {Promise<string>} Resolves with 'done!' after the wait is over.
*/
export async function wait(milliseconds: number): Promise<string> {
return new Promise(resolve => {
if (isNaN(milliseconds)) {
throw new Error('milliseconds not a number')
}
setTimeout(() => resolve('done!'), milliseconds)
})
}