init attest action
This commit is contained in:
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user