mirror of
https://git.mirrors.martin98.com/https://github.com/actions/toolkit
synced 2026-04-28 17:38:05 +08:00
add new @actions/attest package
Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
96
packages/attest/src/attest.ts
Normal file
96
packages/attest/src/attest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
50
packages/attest/src/endpoints.ts
Normal file
50
packages/attest/src/endpoints.ts
Normal 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
|
||||
}
|
||||
}
|
||||
9
packages/attest/src/index.ts
Normal file
9
packages/attest/src/index.ts
Normal 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'
|
||||
32
packages/attest/src/intoto.ts
Normal file
32
packages/attest/src/intoto.ts
Normal 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
|
||||
}
|
||||
}
|
||||
93
packages/attest/src/provenance.ts
Normal file
93
packages/attest/src/provenance.ts
Normal 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
|
||||
})
|
||||
}
|
||||
52
packages/attest/src/shared.types.ts
Normal file
52
packages/attest/src/shared.types.ts
Normal 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
107
packages/attest/src/sign.ts
Normal 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})
|
||||
}
|
||||
31
packages/attest/src/store.ts
Normal file
31
packages/attest/src/store.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user