build provenance stmt from OIDC claims

Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
Brian DeHamer
2024-03-21 19:25:36 -07:00
parent ef77c9d60b
commit a0e6af1e53
12 changed files with 1031 additions and 212 deletions

102
packages/attest/src/oidc.ts Normal file
View File

@@ -0,0 +1,102 @@
import {getIDToken} from '@actions/core'
import {HttpClient} from '@actions/http-client'
import * as jwt from 'jsonwebtoken'
import jwks from 'jwks-rsa'
const OIDC_AUDIENCE = 'nobody'
const REQUIRED_CLAIMS = [
'iss',
'ref',
'sha',
'repository',
'event_name',
'workflow_ref',
'repository_id',
'repository_owner_id',
'runner_environment',
'run_id',
'run_attempt'
] as const
export type ClaimSet = {[K in (typeof REQUIRED_CLAIMS)[number]]: string}
type OIDCConfig = {
jwks_uri: string
}
export const getIDTokenClaims = async (issuer: string): Promise<ClaimSet> => {
try {
const token = await getIDToken(OIDC_AUDIENCE)
const claims = await decodeOIDCToken(token, issuer)
assertClaimSet(claims)
return claims
} catch (error) {
throw new Error(`Failed to get ID token: ${error.message}`)
}
}
const decodeOIDCToken = async (
token: string,
issuer: string
): Promise<jwt.JwtPayload> => {
// Verify and decode token
return new Promise((resolve, reject) => {
jwt.verify(
token,
getPublicKey(issuer),
{audience: OIDC_AUDIENCE, issuer},
(err, decoded) => {
if (err) {
reject(err)
} else if (!decoded || typeof decoded === 'string') {
reject(new Error('No decoded token'))
} else {
resolve(decoded)
}
}
)
})
}
// Returns a callback to locate the public key for the given JWT header. This
// involves two calls:
// 1. Fetch the OpenID configuration to get the JWKS URI.
// 2. Fetch the public key from the JWKS URI.
const getPublicKey =
(issuer: string): jwt.GetPublicKeyOrSecret =>
(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => {
// Look up the JWKS URI from the issuer's OpenID configuration
new HttpClient('actions/attest')
.getJson<OIDCConfig>(`${issuer}/.well-known/openid-configuration`)
.then(data => {
if (!data.result) {
callback(new Error('No OpenID configuration found'))
} else {
// Fetch the public key from the JWKS URI
jwks({jwksUri: data.result.jwks_uri}).getSigningKey(
header.kid,
(err, key) => {
callback(err, key?.getPublicKey())
}
)
}
})
.catch(err => {
callback(err)
})
}
function assertClaimSet(claims: jwt.JwtPayload): asserts claims is ClaimSet {
const missingClaims: string[] = []
for (const claim of REQUIRED_CLAIMS) {
if (!(claim in claims)) {
missingClaims.push(claim)
}
}
if (missingClaims.length > 0) {
throw new Error(`Missing claims: ${missingClaims.join(', ')}`)
}
}

View File

@@ -1,4 +1,5 @@
import {attest, AttestOptions} from './attest'
import {getIDTokenClaims} from './oidc'
import type {Attestation, Predicate} from './shared.types'
const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
@@ -7,30 +8,35 @@ const GITHUB_BUILDER_ID_PREFIX = 'https://github.com/actions/runner'
const GITHUB_BUILD_TYPE =
'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
const DEFAULT_ISSUER = 'https://token.actions.githubusercontent.com'
export type AttestProvenanceOptions = Omit<
AttestOptions,
'predicate' | 'predicateType'
>
> & {
issuer?: string
}
/**
* 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`.
* @param issuer - URL for the OIDC issuer. Defaults to the GitHub Actions token
* issuer.
* @returns The SLSA provenance predicate.
*/
export const buildSLSAProvenancePredicate = (
env: NodeJS.ProcessEnv = process.env
): Predicate => {
const workflow = env.GITHUB_WORKFLOW_REF || ''
export const buildSLSAProvenancePredicate = async (
issuer: string = DEFAULT_ISSUER
): Promise<Predicate> => {
const serverURL = process.env.GITHUB_SERVER_URL
const claims = await getIDTokenClaims(issuer)
// 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}/`, '')
const [workflowPath, workflowRef] = claims.workflow_ref
.replace(`${claims.repository}/`, '')
.split('@')
return {
@@ -41,32 +47,32 @@ export const buildSLSAProvenancePredicate = (
externalParameters: {
workflow: {
ref: workflowRef,
repository: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`,
repository: `${serverURL}/${claims.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
event_name: claims.event_name,
repository_id: claims.repository_id,
repository_owner_id: claims.repository_owner_id
}
},
resolvedDependencies: [
{
uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`,
uri: `git+${serverURL}/${claims.repository}@${claims.ref}`,
digest: {
gitCommit: env.GITHUB_SHA
gitCommit: claims.sha
}
}
]
},
runDetails: {
builder: {
id: `${GITHUB_BUILDER_ID_PREFIX}/${env.RUNNER_ENVIRONMENT}`
id: `${GITHUB_BUILDER_ID_PREFIX}/${claims.runner_environment}`
},
metadata: {
invocationId: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`
invocationId: `${serverURL}/${claims.repository}/actions/runs/${claims.run_id}/attempts/${claims.run_attempt}`
}
}
}
@@ -84,7 +90,7 @@ export const buildSLSAProvenancePredicate = (
export async function attestProvenance(
options: AttestProvenanceOptions
): Promise<Attestation> {
const predicate = buildSLSAProvenancePredicate(process.env)
const predicate = await buildSLSAProvenancePredicate(options.issuer)
return attest({
...options,
predicateType: predicate.type,