diff --git a/README.md b/README.md index 8f00439..e6761ea 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,7 @@ async function run() { const ghToken = core.getInput('gh-token'); const attestation = await attest({ - subjectName: 'my-artifact-name', - subjectDigest: { 'sha256': '36ab4667...'}, + subjects: [{name: 'my-artifact-name', digest: { 'sha256': '36ab4667...'}}], predicateType: 'https://in-toto.io/attestation/release', predicate: { . . . }, token: ghToken @@ -49,11 +48,12 @@ The `attest` function supports the following options: ```typescript 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 + // Deprecated. Use 'subjects' instead. + subjectName?: string + // Deprecated. Use 'subjects' instead. + subjectDigest?: Record + // Collection of subjects to be attested + subjects?: Subject[] // URI identifying the content type of the predicate being attested. predicateType: string // Predicate to be attested. @@ -68,6 +68,13 @@ export type AttestOptions = { // Whether to skip writing the attestation to the GH attestations API. skipWrite?: boolean } + +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 +} ``` ### `attestProvenance` @@ -105,12 +112,13 @@ The `attestProvenance` function supports the following options: ```typescript export type AttestProvenanceOptions = { - // 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 - // GitHub token for writing attestations. + // Deprecated. Use 'subjects' instead. + subjectName?: string + // Deprecated. Use 'subjects' instead. + subjectDigest?: Record + // Collection of subjects to be attested + subjects?: Subject[] + // URI identifying the content type of the predicate being attested. token: string // Sigstore instance to use for signing. Must be one of "public-good" or // "github". diff --git a/RELEASES.md b/RELEASES.md index f6d2519..da623b9 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -5,6 +5,8 @@ - Bump @actions/core from 1.10.1 to 1.11.1 [#1847](https://github.com/actions/toolkit/pull/1847) - Bump @sigstore/bundle from 2.3.2 to 3.0.0 [#1846](https://github.com/actions/toolkit/pull/1846) - Bump @sigstore/sign from 2.3.2 to 3.0.0 [#1846](https://github.com/actions/toolkit/pull/1846) +- Support for generating multi-subject attestations [#1864](https://github.com/actions/toolkit/pull/1865) +- Fix bug in `buildSLSAProvenancePredicate` related to `workflow_ref` OIDC token claims containing the "@" symbol in the tag name [#1863](https://github.com/actions/toolkit/pull/1863) ### 1.4.2 diff --git a/__tests__/__snapshots__/provenance.test.ts.snap b/__tests__/__snapshots__/provenance.test.ts.snap index 4c199da..82daca9 100644 --- a/__tests__/__snapshots__/provenance.test.ts.snap +++ b/__tests__/__snapshots__/provenance.test.ts.snap @@ -1,5 +1,47 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`provenance functions buildSLSAProvenancePredicate handle tags including "@" character 1`] = ` +{ + "params": { + "buildDefinition": { + "buildType": "https://actions.github.io/buildtypes/workflow/v1", + "externalParameters": { + "workflow": { + "path": ".github/workflows/main.yml", + "ref": "foo@1.0.0", + "repository": "https://foo.ghe.com/owner/repo", + }, + }, + "internalParameters": { + "github": { + "event_name": "push", + "repository_id": "repo-id", + "repository_owner_id": "owner-id", + "runner_environment": "github-hosted", + }, + }, + "resolvedDependencies": [ + { + "digest": { + "gitCommit": "babca52ab0c93ae16539e5923cb0d7403b9a093b", + }, + "uri": "git+https://foo.ghe.com/owner/repo@refs/heads/main", + }, + ], + }, + "runDetails": { + "builder": { + "id": "https://foo.ghe.com/owner/workflows/.github/workflows/publish.yml@main", + }, + "metadata": { + "invocationId": "https://foo.ghe.com/owner/repo/actions/runs/run-id/attempts/run-attempt", + }, + }, + }, + "type": "https://slsa.dev/provenance/v1", +} +`; + exports[`provenance functions buildSLSAProvenancePredicate returns a provenance hydrated from an OIDC token 1`] = ` { "params": { diff --git a/__tests__/attest.test.ts b/__tests__/attest.test.ts new file mode 100644 index 0000000..d8b0716 --- /dev/null +++ b/__tests__/attest.test.ts @@ -0,0 +1,16 @@ +import {attest} from '../src/attest' + +describe('attest', () => { + describe('when no subject information is provided', () => { + it('throws an error', async () => { + const options = { + predicateType: 'foo', + predicate: {bar: 'baz'}, + token: 'token' + } + expect(attest(options)).rejects.toThrowError( + 'Must provide either subjectName and subjectDigest or subjects' + ) + }) + }) +}) diff --git a/__tests__/intoto.test.ts b/__tests__/intoto.test.ts index dd6a1a9..c69f7d8 100644 --- a/__tests__/intoto.test.ts +++ b/__tests__/intoto.test.ts @@ -17,7 +17,7 @@ describe('buildIntotoStatement', () => { } it('returns an intoto statement', () => { - const statement = buildIntotoStatement(subject, predicate) + const statement = buildIntotoStatement([subject], predicate) expect(statement).toMatchSnapshot() }) }) diff --git a/__tests__/provenance.test.ts b/__tests__/provenance.test.ts index 4dbfef5..ca7941e 100644 --- a/__tests__/provenance.test.ts +++ b/__tests__/provenance.test.ts @@ -33,15 +33,7 @@ describe('provenance functions', () => { runner_environment: 'github-hosted' } - beforeEach(async () => { - process.env = { - ...originalEnv, - ACTIONS_ID_TOKEN_REQUEST_URL: `${issuer}${tokenPath}?`, - ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', - GITHUB_SERVER_URL: 'https://foo.ghe.com', - GITHUB_REPOSITORY: claims.repository - } - + const mockIssuer = async (claims: jose.JWTPayload): Promise => { // Generate JWT signing key const key = await jose.generateKeyPair('PS256') @@ -60,6 +52,18 @@ describe('provenance functions', () => { // Mock OIDC token endpoint for populating the provenance nock(issuer).get(tokenPath).query({audience}).reply(200, {value: jwt}) + } + + beforeEach(async () => { + process.env = { + ...originalEnv, + ACTIONS_ID_TOKEN_REQUEST_URL: `${issuer}${tokenPath}?`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token', + GITHUB_SERVER_URL: 'https://foo.ghe.com', + GITHUB_REPOSITORY: claims.repository + } + + await mockIssuer(claims) }) afterEach(() => { @@ -71,6 +75,16 @@ describe('provenance functions', () => { const predicate = await buildSLSAProvenancePredicate() expect(predicate).toMatchSnapshot() }) + + it('handle tags including "@" character', async () => { + nock.cleanAll() + await mockIssuer({ + ...claims, + workflow_ref: 'owner/repo/.github/workflows/main.yml@foo@1.0.0' + }) + const predicate = await buildSLSAProvenancePredicate() + expect(predicate).toMatchSnapshot() + }) }) describe('attestProvenance', () => { @@ -115,8 +129,7 @@ describe('provenance functions', () => { describe('when the sigstore instance is explicitly set', () => { it('attests provenance', async () => { const attestation = await attestProvenance({ - subjectName, - subjectDigest, + subjects: [{name: subjectName, digest: subjectDigest}], token: 'token', sigstore: 'github' }) @@ -143,8 +156,7 @@ describe('provenance functions', () => { it('attests provenance', async () => { const attestation = await attestProvenance({ - subjectName, - subjectDigest, + subjects: [{name: subjectName, digest: subjectDigest}], token: 'token' }) @@ -178,8 +190,7 @@ describe('provenance functions', () => { describe('when the sigstore instance is explicitly set', () => { it('attests provenance', async () => { const attestation = await attestProvenance({ - subjectName, - subjectDigest, + subjects: [{name: subjectName, digest: subjectDigest}], token: 'token', sigstore: 'public-good' }) @@ -206,8 +217,7 @@ describe('provenance functions', () => { it('attests provenance', async () => { const attestation = await attestProvenance({ - subjectName, - subjectDigest, + subjects: [{name: subjectName, digest: subjectDigest}], token: 'token' }) diff --git a/src/attest.ts b/src/attest.ts index 85c6301..807a8e5 100644 --- a/src/attest.ts +++ b/src/attest.ts @@ -14,11 +14,16 @@ 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 + /** + * @deprecated Use `subjects` instead. + **/ + subjectName?: string + /** + * @deprecated Use `subjects` instead. + **/ + subjectDigest?: Record + // Subjects to be attested. + subjects?: Subject[] // Content type of the predicate being attested. predicateType: string // Predicate to be attested. @@ -42,15 +47,24 @@ export type AttestOptions = { * @returns A promise that resolves to the attestation. */ export async function attest(options: AttestOptions): Promise { - const subject: Subject = { - name: options.subjectName, - digest: options.subjectDigest + let subjects: Subject[] + + if (options.subjects) { + subjects = options.subjects + } else if (options.subjectName && options.subjectDigest) { + subjects = [{name: options.subjectName, digest: options.subjectDigest}] + } else { + throw new Error( + 'Must provide either subjectName and subjectDigest or subjects' + ) } + const predicate: Predicate = { type: options.predicateType, params: options.predicate } - const statement = buildIntotoStatement(subject, predicate) + + const statement = buildIntotoStatement(subjects, predicate) // Sign the provenance statement const payload: Payload = { diff --git a/src/intoto.ts b/src/intoto.ts index 9d6a2d0..5a2dcc9 100644 --- a/src/intoto.ts +++ b/src/intoto.ts @@ -20,12 +20,12 @@ export type InTotoStatement = { * @returns The constructed in-toto statement. */ export const buildIntotoStatement = ( - subject: Subject, + subjects: Subject[], predicate: Predicate ): InTotoStatement => { return { _type: INTOTO_STATEMENT_V1_TYPE, - subject: [subject], + subject: subjects, predicateType: predicate.type, predicate: predicate.params } diff --git a/src/provenance.ts b/src/provenance.ts index 09aa64f..faba08f 100644 --- a/src/provenance.ts +++ b/src/provenance.ts @@ -30,9 +30,11 @@ export const buildSLSAProvenancePredicate = async ( // 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] = claims.workflow_ref + const [workflowPath, ...workflowRefChunks] = claims.workflow_ref .replace(`${claims.repository}/`, '') .split('@') + // Handle case where tag contains `@` (e.g: when using changesets in a monorepo context), + const workflowRef = workflowRefChunks.join('@') return { type: SLSA_PREDICATE_V1_TYPE,