add new @actions/attest package

Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
Brian DeHamer
2024-02-17 19:14:10 -08:00
parent 415c42d27c
commit 6079dea4c4
24 changed files with 4224 additions and 3 deletions

View File

@@ -0,0 +1,96 @@
import {Bundle, bundleToJSON} from '@sigstore/bundle'
import {X509Certificate} from 'crypto'
import {SigstoreInstance, signingEndpoints} from './endpoints'
import {buildIntotoStatement} from './intoto'
import {Payload, signPayload} from './sign'
import {writeAttestation} from './store'
import type {Attestation, Predicate, Subject} from './shared.types'
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
/**
* Options for attesting a subject / predicate.
*/
export type AttestOptions = {
// The name of the subject to be attested.
subjectName: string
// The digest of the subject to be attested. Should be a map of digest
// algorithms to their hex-encoded values.
subjectDigest: Record<string, string>
// Content type of the predicate being attested.
predicateType: string
// Predicate to be attested.
predicate: object
// GitHub token for writing attestations.
token: string
// Sigstore instance to use for signing. Must be one of "public-good" or
// "github".
sigstore?: SigstoreInstance
// Whether to skip writing the attestation to the GH attestations API.
skipWrite?: boolean
}
/**
* Generates an attestation for the given subject and predicate. The subject and
* predicate are combined into an in-toto statement, which is then signed using
* the identified Sigstore instance and stored as an attestation.
* @param options - The options for attestation.
* @returns A promise that resolves to the attestation.
*/
export async function attest(options: AttestOptions): Promise<Attestation> {
const subject: Subject = {
name: options.subjectName,
digest: options.subjectDigest
}
const predicate: Predicate = {
type: options.predicateType,
params: options.predicate
}
const statement = buildIntotoStatement(subject, predicate)
// Sign the provenance statement
const payload: Payload = {
body: Buffer.from(JSON.stringify(statement)),
type: INTOTO_PAYLOAD_TYPE
}
const endpoints = signingEndpoints(options.sigstore)
const bundle = await signPayload(payload, endpoints)
// Store the attestation
let attestationID: string | undefined
if (options.skipWrite !== true) {
attestationID = await writeAttestation(bundleToJSON(bundle), options.token)
}
return toAttestation(bundle, attestationID)
}
function toAttestation(bundle: Bundle, attestationID?: string): Attestation {
let certBytes: Buffer
switch (bundle.verificationMaterial.content.$case) {
case 'x509CertificateChain':
certBytes =
bundle.verificationMaterial.content.x509CertificateChain.certificates[0]
.rawBytes
break
case 'certificate':
certBytes = bundle.verificationMaterial.content.certificate.rawBytes
break
default:
throw new Error('Bundle must contain an x509 certificate')
}
const signingCert = new X509Certificate(certBytes)
// Collect transparency log ID if available
const tlogEntries = bundle.verificationMaterial.tlogEntries
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined
return {
bundle: bundleToJSON(bundle),
certificate: signingCert.toString(),
tlogID,
attestationID
}
}

View File

@@ -0,0 +1,50 @@
import * as github from '@actions/github'
const PUBLIC_GOOD_ID = 'public-good'
const GITHUB_ID = 'github'
const FULCIO_PUBLIC_GOOD_URL = 'https://fulcio.sigstore.dev'
const REKOR_PUBLIC_GOOD_URL = 'https://rekor.sigstore.dev'
const FULCIO_INTERNAL_URL = 'https://fulcio.githubapp.com'
const TSA_INTERNAL_URL = 'https://timestamp.githubapp.com'
export type SigstoreInstance = typeof PUBLIC_GOOD_ID | typeof GITHUB_ID
export type Endpoints = {
fulcioURL: string
rekorURL?: string
tsaServerURL?: string
}
export const SIGSTORE_PUBLIC_GOOD: Endpoints = {
fulcioURL: FULCIO_PUBLIC_GOOD_URL,
rekorURL: REKOR_PUBLIC_GOOD_URL
}
export const SIGSTORE_GITHUB: Endpoints = {
fulcioURL: FULCIO_INTERNAL_URL,
tsaServerURL: TSA_INTERNAL_URL
}
export const signingEndpoints = (sigstore?: SigstoreInstance): Endpoints => {
let instance: SigstoreInstance
// An explicitly set instance type takes precedence, but if not set, use the
// repository's visibility to determine the instance type.
if (sigstore && [PUBLIC_GOOD_ID, GITHUB_ID].includes(sigstore)) {
instance = sigstore
} else {
instance =
github.context.payload.repository?.visibility === 'public'
? PUBLIC_GOOD_ID
: GITHUB_ID
}
switch (instance) {
case PUBLIC_GOOD_ID:
return SIGSTORE_PUBLIC_GOOD
case GITHUB_ID:
return SIGSTORE_GITHUB
}
}

View File

@@ -0,0 +1,9 @@
export {AttestOptions, attest} from './attest'
export {
AttestProvenanceOptions,
attestProvenance,
buildSLSAProvenancePredicate
} from './provenance'
export type {SerializedBundle} from '@sigstore/bundle'
export type {Attestation, Predicate, Subject} from './shared.types'

View File

@@ -0,0 +1,32 @@
import {Predicate, Subject} from './shared.types'
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1'
/**
* An in-toto statement.
* https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md
*/
export type InTotoStatement = {
_type: string
subject: Subject[]
predicateType: string
predicate: object
}
/**
* Assembles the given subject and predicate into an in-toto statement.
* @param subject - The subject of the statement.
* @param predicate - The predicate of the statement.
* @returns The constructed in-toto statement.
*/
export const buildIntotoStatement = (
subject: Subject,
predicate: Predicate
): InTotoStatement => {
return {
_type: INTOTO_STATEMENT_V1_TYPE,
subject: [subject],
predicateType: predicate.type,
predicate: predicate.params
}
}

View File

@@ -0,0 +1,93 @@
import {attest, AttestOptions} from './attest'
import type {Attestation, Predicate} from './shared.types'
const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
const GITHUB_BUILDER_ID_PREFIX = 'https://github.com/actions/runner'
const GITHUB_BUILD_TYPE =
'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
export type AttestProvenanceOptions = Omit<
AttestOptions,
'predicate' | 'predicateType'
>
/**
* Builds an SLSA (Supply Chain Levels for Software Artifacts) provenance
* predicate using the GitHub Actions Workflow build type.
* https://slsa.dev/spec/v1.0/provenance
* https://github.com/slsa-framework/github-actions-buildtypes/tree/main/workflow/v1
* @param env - The Node.js process environment variables. Defaults to
* `process.env`.
* @returns The SLSA provenance predicate.
*/
export const buildSLSAProvenancePredicate = (
env: NodeJS.ProcessEnv = process.env
): Predicate => {
const workflow = env.GITHUB_WORKFLOW_REF || ''
// Split just the path and ref from the workflow string.
// owner/repo/.github/workflows/main.yml@main =>
// .github/workflows/main.yml, main
const [workflowPath, workflowRef] = workflow
.replace(`${env.GITHUB_REPOSITORY}/`, '')
.split('@')
return {
type: SLSA_PREDICATE_V1_TYPE,
params: {
buildDefinition: {
buildType: GITHUB_BUILD_TYPE,
externalParameters: {
workflow: {
ref: workflowRef,
repository: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`,
path: workflowPath
}
},
internalParameters: {
github: {
event_name: env.GITHUB_EVENT_NAME,
repository_id: env.GITHUB_REPOSITORY_ID,
repository_owner_id: env.GITHUB_REPOSITORY_OWNER_ID
}
},
resolvedDependencies: [
{
uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`,
digest: {
gitCommit: env.GITHUB_SHA
}
}
]
},
runDetails: {
builder: {
id: `${GITHUB_BUILDER_ID_PREFIX}/${env.RUNNER_ENVIRONMENT}`
},
metadata: {
invocationId: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`
}
}
}
}
}
/**
* Attests the build provenance of the provided subject. Generates the SLSA
* build provenance predicate, assembles it into an in-toto statement, and
* attests it.
*
* @param options - The options for attesting the provenance.
* @returns A promise that resolves to the attestation.
*/
export async function attestProvenance(
options: AttestProvenanceOptions
): Promise<Attestation> {
const predicate = buildSLSAProvenancePredicate(process.env)
return attest({
...options,
predicateType: predicate.type,
predicate: predicate.params
})
}

View File

@@ -0,0 +1,52 @@
import type {SerializedBundle} from '@sigstore/bundle'
/*
* The subject of an attestation.
*/
export type Subject = {
/*
* Name of the subject.
*/
name: string
/*
* Digests of the subject. Should be a map of digest algorithms to their hex-encoded values.
*/
digest: Record<string, string>
}
/*
* The predicate of an attestation.
*/
export type Predicate = {
/*
* URI identifying the content type of the predicate.
*/
type: string
/*
* Predicate parameters.
*/
params: object
}
/*
* Artifact attestation.
*/
export type Attestation = {
/*
* Serialized Sigstore bundle containing the provenance attestation,
* signature, signing certificate and witnessed timestamp.
*/
bundle: SerializedBundle
/*
* PEM-encoded signing certificate used to sign the attestation.
*/
certificate: string
/*
* ID of Rekor transparency log entry created for the attestation.
*/
tlogID?: string
/*
* ID of the persisted attestation (accessible via the GH API).
*/
attestationID?: string
}

107
packages/attest/src/sign.ts Normal file
View File

@@ -0,0 +1,107 @@
import {Bundle} from '@sigstore/bundle'
import {
BundleBuilder,
CIContextProvider,
DSSEBundleBuilder,
FulcioSigner,
RekorWitness,
TSAWitness,
Witness
} from '@sigstore/sign'
const OIDC_AUDIENCE = 'sigstore'
const DEFAULT_TIMEOUT = 10000
const DEFAULT_RETRIES = 3
/**
* The payload to be signed (body) and its media type (type).
*/
export type Payload = {
body: Buffer
type: string
}
/**
* Options for signing a document.
*/
export type SignOptions = {
/**
* The URL of the Fulcio service.
*/
fulcioURL: string
/**
* The URL of the Rekor service.
*/
rekorURL?: string
/**
* The URL of the TSA (Time Stamping Authority) server.
*/
tsaServerURL?: string
/**
* The timeout duration in milliseconds when communicating with Sigstore
* services.
*/
timeout?: number
/**
* The number of retry attempts.
*/
retry?: number
}
/**
* Signs the provided payload with a Sigstore-issued certificate and returns the
* signature bundle.
* @param payload Payload to be signed.
* @param options Signing options.
* @returns A promise that resolves to the Sigstore signature bundle.
*/
export const signPayload = async (
payload: Payload,
options: SignOptions
): Promise<Bundle> => {
const artifact = {
data: payload.body,
type: payload.type
}
// Sign the artifact and build the bundle
return initBundleBuilder(options).create(artifact)
}
// Assembles the Sigstore bundle builder with the appropriate options
const initBundleBuilder = (opts: SignOptions): BundleBuilder => {
const identityProvider = new CIContextProvider(OIDC_AUDIENCE)
const timeout = opts.timeout || DEFAULT_TIMEOUT
const retry = opts.retry || DEFAULT_RETRIES
const witnesses: Witness[] = []
const signer = new FulcioSigner({
identityProvider,
fulcioBaseURL: opts.fulcioURL,
timeout,
retry
})
if (opts.rekorURL) {
witnesses.push(
new RekorWitness({
rekorBaseURL: opts.rekorURL,
entryType: 'dsse',
timeout,
retry
})
)
}
if (opts.tsaServerURL) {
witnesses.push(
new TSAWitness({
tsaBaseURL: opts.tsaServerURL,
timeout,
retry
})
)
}
return new DSSEBundleBuilder({signer, witnesses})
}

View File

@@ -0,0 +1,31 @@
import * as github from '@actions/github'
import fetch from 'make-fetch-happen'
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
/**
* Writes an attestation to the repository's attestations endpoint.
* @param attestation - The attestation to write.
* @param token - The GitHub token for authentication.
* @returns The ID of the attestation.
* @throws Error if the attestation fails to persist.
*/
export const writeAttestation = async (
attestation: unknown,
token: string
): Promise<string> => {
const octokit = github.getOctokit(token, {request: {fetch}})
try {
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
data: {bundle: attestation}
})
return response.data?.id
} catch (err) {
const message = err instanceof Error ? err.message : err
throw new Error(`Failed to persist attestation: ${message}`)
}
}