mirror of https://github.com/actions/toolkit
Merge pull request #1667 from actions/bdehamer/attest
add new @actions/attest packagepull/1672/head
commit
ad1f156c7c
|
@ -2,3 +2,4 @@
|
||||||
|
|
||||||
/packages/artifact/ @actions/artifacts-actions
|
/packages/artifact/ @actions/artifacts-actions
|
||||||
/packages/cache/ @actions/actions-cache
|
/packages/cache/ @actions/actions-cache
|
||||||
|
/package/attest/ @actions/package-security
|
||||||
|
|
|
@ -102,6 +102,15 @@ $ npm install @actions/cache
|
||||||
```
|
```
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
|
:lock_with_ink_pen: [@actions/attest](packages/attest)
|
||||||
|
|
||||||
|
Provides functions to write attestations for workflow artifacts. Read more [here](packages/attest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install @actions/attest
|
||||||
|
```
|
||||||
|
<br/>
|
||||||
|
|
||||||
## Creating an Action with the Toolkit
|
## Creating an Action with the Toolkit
|
||||||
|
|
||||||
:question: [Choosing an action type](docs/action-types.md)
|
:question: [Choosing an action type](docs/action-types.md)
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright 2024 GitHub
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,172 @@
|
||||||
|
# `@actions/attest`
|
||||||
|
|
||||||
|
Functions for generating signed attestations for workflow artifacts.
|
||||||
|
|
||||||
|
Attestations bind some subject (a named artifact along with its digest) to a
|
||||||
|
predicate (some assertion about that subject) using the [in-toto
|
||||||
|
statement](https://github.com/in-toto/attestation/tree/main/spec/v1) format. A
|
||||||
|
signature is generated for the attestation using a
|
||||||
|
[Sigstore](https://www.sigstore.dev/)-issued signing certificate.
|
||||||
|
|
||||||
|
Once the attestation has been created and signed, it will be uploaded to the GH
|
||||||
|
attestations API and associated with the repository from which the workflow was
|
||||||
|
initiated.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### `attest`
|
||||||
|
|
||||||
|
The `attest` function takes the supplied subject/predicate pair and generates a
|
||||||
|
signed attestation.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { attest } = require('@actions/attest');
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// In order to persist attestations to the repo, this should be a token with
|
||||||
|
// repository write permissions.
|
||||||
|
const ghToken = core.getInput('gh-token');
|
||||||
|
|
||||||
|
const attestation = await attest({
|
||||||
|
subjectName: 'my-artifact-name',
|
||||||
|
subjectDigest: { 'sha256': '36ab4667...'},
|
||||||
|
predicateType: 'https://in-toto.io/attestation/release',
|
||||||
|
predicate: { . . . },
|
||||||
|
token: ghToken
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(attestation);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, string>
|
||||||
|
// URI identifying the 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?: 'public-good' | 'github'
|
||||||
|
// Whether to skip writing the attestation to the GH attestations API.
|
||||||
|
skipWrite?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `attestProvenance`
|
||||||
|
|
||||||
|
The `attestProvenance` function accepts the name and digest of some artifact and
|
||||||
|
generates a build provenance attestation over those values.
|
||||||
|
|
||||||
|
The attestation is formed by first generating a [SLSA provenance
|
||||||
|
predicate](https://slsa.dev/spec/v1.0/provenance) populated with
|
||||||
|
[metadata](https://github.com/slsa-framework/github-actions-buildtypes/tree/main/workflow/v1)
|
||||||
|
pulled from the GitHub Actions run.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { attestProvenance } = require('@actions/attest');
|
||||||
|
const core = require('@actions/core');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// In order to persist attestations to the repo, this should be a token with
|
||||||
|
// repository write permissions.
|
||||||
|
const ghToken = core.getInput('gh-token');
|
||||||
|
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName: 'my-artifact-name',
|
||||||
|
subjectDigest: { 'sha256': '36ab4667...'},
|
||||||
|
token: ghToken
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(attestation);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
```
|
||||||
|
|
||||||
|
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<string, string>
|
||||||
|
// GitHub token for writing attestations.
|
||||||
|
token: string
|
||||||
|
// Sigstore instance to use for signing. Must be one of "public-good" or
|
||||||
|
// "github".
|
||||||
|
sigstore?: 'public-good' | 'github'
|
||||||
|
// Whether to skip writing the attestation to the GH attestations API.
|
||||||
|
skipWrite?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Attestation`
|
||||||
|
|
||||||
|
The `Attestation` returned by `attest`/`attestProvenance` has the following
|
||||||
|
fields:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type Attestation = {
|
||||||
|
/*
|
||||||
|
* JSON-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 (if
|
||||||
|
* applicable).
|
||||||
|
*/
|
||||||
|
tlogID?: string
|
||||||
|
/*
|
||||||
|
* ID of the persisted attestation (accessible via the GH API).
|
||||||
|
*/
|
||||||
|
attestationID?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For details about the Sigstore bundle format, see the [Bundle protobuf
|
||||||
|
specification](https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto).
|
||||||
|
|
||||||
|
## Sigstore Instance
|
||||||
|
|
||||||
|
When generating the signed attestation there are two different Sigstore
|
||||||
|
instances which can be used to issue the signing certificate. By default,
|
||||||
|
workflows initiated from public repositories will use the Sigstore public-good
|
||||||
|
instance and persist the attestation signature to the public [Rekor transparency
|
||||||
|
log](https://docs.sigstore.dev/logging/overview/). Workflows initiated from
|
||||||
|
private/internal repositories will use the GitHub-internal Sigstore instance
|
||||||
|
which uses a signed timestamp issued by GitHub's timestamp authority in place of
|
||||||
|
the public transparency log.
|
||||||
|
|
||||||
|
The default Sigstore instance selection can be overridden by passing an explicit
|
||||||
|
value of either "public-good" or "github" for the `sigstore` option when calling
|
||||||
|
either `attest` or `attestProvenance`.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Attestations created by `attest`/`attestProvenance` will be uploaded to the GH
|
||||||
|
attestations API and associated with the appropriate repository. Attestation
|
||||||
|
storage is only supported for public repositories or repositories which belong
|
||||||
|
to a GitHub Enterprise Cloud account.
|
||||||
|
|
||||||
|
In order to generate attestations for private, non-Enterprise repositories, the
|
||||||
|
`skipWrite` option should be set to `true`.
|
|
@ -0,0 +1,5 @@
|
||||||
|
# @actions/attest Releases
|
||||||
|
|
||||||
|
### 1.0.0
|
||||||
|
|
||||||
|
- Initial release
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`buildIntotoStatement returns a provenance hydrated from env vars 1`] = `
|
||||||
|
{
|
||||||
|
"_type": "https://in-toto.io/Statement/v1",
|
||||||
|
"predicate": {
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
"predicateType": "predicatey",
|
||||||
|
"subject": [
|
||||||
|
{
|
||||||
|
"digest": {
|
||||||
|
"sha256": "7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32",
|
||||||
|
},
|
||||||
|
"name": "subjecty",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`buildSLSAProvenancePredicate returns a provenance hydrated from env vars 1`] = `
|
||||||
|
{
|
||||||
|
"params": {
|
||||||
|
"buildDefinition": {
|
||||||
|
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
|
||||||
|
"externalParameters": {
|
||||||
|
"workflow": {
|
||||||
|
"path": ".github/workflows/main.yml",
|
||||||
|
"ref": "main",
|
||||||
|
"repository": "https://github.com/owner/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"internalParameters": {
|
||||||
|
"github": {
|
||||||
|
"event_name": "push",
|
||||||
|
"repository_id": "repo-id",
|
||||||
|
"repository_owner_id": "owner-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"resolvedDependencies": [
|
||||||
|
{
|
||||||
|
"digest": {
|
||||||
|
"gitCommit": "babca52ab0c93ae16539e5923cb0d7403b9a093b",
|
||||||
|
},
|
||||||
|
"uri": "git+https://github.com/owner/repo@refs/heads/main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"runDetails": {
|
||||||
|
"builder": {
|
||||||
|
"id": "https://github.com/actions/runner/github-hosted",
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"invocationId": "https://github.com/owner/repo/actions/runs/run-id/attempts/run-attempt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "https://slsa.dev/provenance/v1",
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,6 @@
|
||||||
|
import {attest, attestProvenance} from '../src'
|
||||||
|
|
||||||
|
it('exports functions', () => {
|
||||||
|
expect(attestProvenance).toBeInstanceOf(Function)
|
||||||
|
expect(attest).toBeInstanceOf(Function)
|
||||||
|
})
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {buildIntotoStatement} from '../src/intoto'
|
||||||
|
import type {Predicate, Subject} from '../src/shared.types'
|
||||||
|
|
||||||
|
describe('buildIntotoStatement', () => {
|
||||||
|
const subject: Subject = {
|
||||||
|
name: 'subjecty',
|
||||||
|
digest: {
|
||||||
|
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const predicate: Predicate = {
|
||||||
|
type: 'predicatey',
|
||||||
|
params: {
|
||||||
|
key: 'value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns a provenance hydrated from env vars', () => {
|
||||||
|
const statement = buildIntotoStatement(subject, predicate)
|
||||||
|
expect(statement).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,213 @@
|
||||||
|
import * as github from '@actions/github'
|
||||||
|
import {mockFulcio, mockRekor, mockTSA} from '@sigstore/mock'
|
||||||
|
import nock from 'nock'
|
||||||
|
import {SIGSTORE_GITHUB, SIGSTORE_PUBLIC_GOOD} from '../src/endpoints'
|
||||||
|
import {attestProvenance, buildSLSAProvenancePredicate} from '../src/provenance'
|
||||||
|
|
||||||
|
// Dummy workflow environment
|
||||||
|
const env = {
|
||||||
|
GITHUB_REPOSITORY: 'owner/repo',
|
||||||
|
GITHUB_REF: 'refs/heads/main',
|
||||||
|
GITHUB_SHA: 'babca52ab0c93ae16539e5923cb0d7403b9a093b',
|
||||||
|
GITHUB_WORKFLOW_REF: 'owner/repo/.github/workflows/main.yml@main',
|
||||||
|
GITHUB_SERVER_URL: 'https://github.com',
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REPOSITORY_ID: 'repo-id',
|
||||||
|
GITHUB_REPOSITORY_OWNER_ID: 'owner-id',
|
||||||
|
GITHUB_RUN_ID: 'run-id',
|
||||||
|
GITHUB_RUN_ATTEMPT: 'run-attempt',
|
||||||
|
RUNNER_ENVIRONMENT: 'github-hosted'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSLSAProvenancePredicate', () => {
|
||||||
|
it('returns a provenance hydrated from env vars', () => {
|
||||||
|
const predicate = buildSLSAProvenancePredicate(env)
|
||||||
|
expect(predicate).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('attestProvenance', () => {
|
||||||
|
// Capture original environment variables so we can restore them after each
|
||||||
|
// test
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
// Subject to attest
|
||||||
|
const subjectName = 'subjective'
|
||||||
|
const subjectDigest = {
|
||||||
|
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake an OIDC token
|
||||||
|
const oidcPayload = {sub: 'foo@bar.com', iss: ''}
|
||||||
|
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
||||||
|
'base64'
|
||||||
|
)}.}`
|
||||||
|
|
||||||
|
const tokenURL = 'https://token.url'
|
||||||
|
const attestationID = '1234567890'
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
nock(tokenURL)
|
||||||
|
.get('/')
|
||||||
|
.query({audience: 'sigstore'})
|
||||||
|
.reply(200, {value: oidcToken})
|
||||||
|
|
||||||
|
// Set-up GHA environment variables
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
...env,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore the original environment
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when using the github Sigstore instance', () => {
|
||||||
|
const {fulcioURL, tsaServerURL} = SIGSTORE_GITHUB
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock Sigstore
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockTSA({baseURL: tsaServerURL})
|
||||||
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, {id: attestationID})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is explicitly set', () => {
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token',
|
||||||
|
sigstore: 'github'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeUndefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
||||||
|
const savedRepository = github.context.payload.repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
github.context.payload.repository = {visibility: 'private'} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
github.context.payload.repository = savedRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeUndefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when using the public-good Sigstore instance', () => {
|
||||||
|
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock Sigstore
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockRekor({baseURL: rekorURL})
|
||||||
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, {id: attestationID})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is explicitly set', () => {
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token',
|
||||||
|
sigstore: 'public-good'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the sigstore instance is inferred from the repo visibility', () => {
|
||||||
|
const savedRepository = github.context.payload.repository
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
github.context.payload.repository = {visibility: 'public'} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
github.context.payload.repository = savedRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when skipWrite is set to true', () => {
|
||||||
|
const {fulcioURL, rekorURL} = SIGSTORE_PUBLIC_GOOD
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock Sigstore
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockRekor({baseURL: rekorURL})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName,
|
||||||
|
subjectDigest,
|
||||||
|
token: 'token',
|
||||||
|
sigstore: 'public-good',
|
||||||
|
skipWrite: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,105 @@
|
||||||
|
import {mockFulcio, mockRekor, mockTSA} from '@sigstore/mock'
|
||||||
|
import nock from 'nock'
|
||||||
|
import {Payload, signPayload} from '../src/sign'
|
||||||
|
|
||||||
|
describe('signProvenance', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
// Fake an OIDC token
|
||||||
|
const subject = 'foo@bar.com'
|
||||||
|
const oidcPayload = {sub: subject, iss: ''}
|
||||||
|
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
||||||
|
'base64'
|
||||||
|
)}.}`
|
||||||
|
|
||||||
|
// Dummy provenance to be signed
|
||||||
|
const provenance = {
|
||||||
|
_type: 'https://in-toto.io/Statement/v1',
|
||||||
|
subject: {
|
||||||
|
name: 'subjective',
|
||||||
|
digest: {
|
||||||
|
sha256:
|
||||||
|
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Payload = {
|
||||||
|
body: Buffer.from(JSON.stringify(provenance)),
|
||||||
|
type: 'application/vnd.in-toto+json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulcioURL = 'https://fulcio.url'
|
||||||
|
const rekorURL = 'https://rekor.url'
|
||||||
|
const tsaServerURL = 'https://tsa.url'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock OIDC token endpoint
|
||||||
|
const tokenURL = 'https://token.url'
|
||||||
|
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token'
|
||||||
|
}
|
||||||
|
|
||||||
|
nock(tokenURL)
|
||||||
|
.get('/')
|
||||||
|
.query({audience: 'sigstore'})
|
||||||
|
.reply(200, {value: oidcToken})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when visibility is public', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockRekor({baseURL: rekorURL})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a bundle', async () => {
|
||||||
|
const att = await signPayload(payload, {fulcioURL, rekorURL})
|
||||||
|
|
||||||
|
expect(att).toBeDefined()
|
||||||
|
expect(att.mediaType).toEqual(
|
||||||
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when visibility is private', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockFulcio({baseURL: fulcioURL, strict: false})
|
||||||
|
await mockTSA({baseURL: tsaServerURL})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a bundle', async () => {
|
||||||
|
const att = await signPayload(payload, {fulcioURL, tsaServerURL})
|
||||||
|
|
||||||
|
expect(att).toBeDefined()
|
||||||
|
expect(att.mediaType).toEqual(
|
||||||
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(0)
|
||||||
|
expect(
|
||||||
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
|
).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,45 @@
|
||||||
|
import nock from 'nock'
|
||||||
|
import {writeAttestation} from '../src/store'
|
||||||
|
|
||||||
|
describe('writeAttestation', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
const attestation = {foo: 'bar '}
|
||||||
|
const token = 'token'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
GITHUB_REPOSITORY: 'foo/bar'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the api call is successful', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.matchHeader('authorization', `token ${token}`)
|
||||||
|
.post('/repos/foo/bar/attestations', {bundle: attestation})
|
||||||
|
.reply(201, {id: '123'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the attestation', async () => {
|
||||||
|
await expect(writeAttestation(attestation, token)).resolves.toEqual('123')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the api call fails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.matchHeader('authorization', `token ${token}`)
|
||||||
|
.post('/repos/foo/bar/attestations', {bundle: attestation})
|
||||||
|
.reply(500, 'oops')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the attestation', async () => {
|
||||||
|
await expect(writeAttestation(attestation, token)).rejects.toThrow(/oops/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "@actions/attest",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Actions attestation lib",
|
||||||
|
"keywords": [
|
||||||
|
"github",
|
||||||
|
"actions",
|
||||||
|
"attestation"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/actions/toolkit/tree/main/packages/attest",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"types": "lib/index.d.ts",
|
||||||
|
"directories": {
|
||||||
|
"lib": "lib",
|
||||||
|
"test": "__tests__"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"provenance": true
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/actions/toolkit.git",
|
||||||
|
"directory": "packages/attest"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||||
|
"tsc": "tsc"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/actions/toolkit/issues"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sigstore/mock": "^0.6.5",
|
||||||
|
"@sigstore/rekor-types": "^2.0.0",
|
||||||
|
"@types/make-fetch-happen": "^10.0.4",
|
||||||
|
"nock": "^13.5.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/github": "^6.0.0",
|
||||||
|
"@sigstore/bundle": "^2.2.0",
|
||||||
|
"@sigstore/sign": "^2.2.3",
|
||||||
|
"make-fetch-happen": "^13.0.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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})
|
||||||
|
}
|
|
@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"declaration": true,
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue