Consolidate attestation actions (#346)
* consolidate attestation actions Signed-off-by: Brian DeHamer <bdehamer@github.com> * better errors Signed-off-by: Brian DeHamer <bdehamer@github.com> * Update src/sbom.ts Co-authored-by: Austin Beattie <ajbeattie@github.com> * clarify dedupe comment Signed-off-by: Brian DeHamer <bdehamer@github.com> --------- Signed-off-by: Brian DeHamer <bdehamer@github.com> Co-authored-by: Austin Beattie <ajbeattie@github.com>
This commit is contained in:
co-authored by
Austin Beattie
parent
a82737a684
commit
dc4ad3cc6c
+188
-16
@@ -5,19 +5,20 @@
|
||||
* Specifically, the inputs listed in `action.yml` should be set as environment
|
||||
* variables following the pattern `INPUT_<INPUT_NAME>`.
|
||||
*/
|
||||
import * as attest from '@actions/attest'
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
|
||||
import * as oci from '@sigstore/oci'
|
||||
import * as attest from '@actions/attest'
|
||||
import * as localAttest from '../src/attest'
|
||||
import fs from 'fs/promises'
|
||||
import nock from 'nock'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { MockAgent, setGlobalDispatcher } from 'undici'
|
||||
import * as localAttest from '../src/attest'
|
||||
import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints'
|
||||
import * as main from '../src/main'
|
||||
import * as provenance from '../src/provenance'
|
||||
|
||||
// Mock the GitHub Actions core library
|
||||
const infoMock = jest.spyOn(core, 'info')
|
||||
@@ -43,6 +44,7 @@ const defaultInputs: main.RunInputs = {
|
||||
predicate: '',
|
||||
predicateType: '',
|
||||
predicatePath: '',
|
||||
sbomPath: '',
|
||||
subjectName: '',
|
||||
subjectDigest: '',
|
||||
subjectPath: '',
|
||||
@@ -187,8 +189,9 @@ describe('action', () => {
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).not.toHaveBeenCalledWith()
|
||||
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
expect.stringMatching(
|
||||
`Attestation created for ${subjectName}@${subjectDigest}`
|
||||
)
|
||||
@@ -198,15 +201,15 @@ describe('action', () => {
|
||||
expect.stringMatching('GitHub Sigstore')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
expect.stringMatching('-----BEGIN CERTIFICATE-----')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
4,
|
||||
expect.stringMatching(/attestation uploaded/i)
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
5,
|
||||
expect.stringMatching(attestationID)
|
||||
)
|
||||
expect(setOutputMock).toHaveBeenNthCalledWith(
|
||||
@@ -281,8 +284,9 @@ describe('action', () => {
|
||||
expect(repoOwnerIsOrgSpy).toHaveBeenCalled()
|
||||
expect(createStorageRecordSpy).toHaveBeenCalled()
|
||||
expect(warningMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
expect.stringMatching(
|
||||
`Attestation created for ${subjectName}@${subjectDigest}`
|
||||
)
|
||||
@@ -292,31 +296,31 @@ describe('action', () => {
|
||||
expect.stringMatching('Public Good Sigstore')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
expect.stringMatching('-----BEGIN CERTIFICATE-----')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
4,
|
||||
expect.stringMatching(/signature uploaded/i)
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
5,
|
||||
expect.stringMatching(SEARCH_PUBLIC_GOOD_URL)
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
6,
|
||||
expect.stringMatching(/attestation uploaded/i)
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
7,
|
||||
expect.stringMatching(attestationID)
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
9,
|
||||
10,
|
||||
expect.stringMatching('Storage record created')
|
||||
)
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
10,
|
||||
11,
|
||||
expect.stringMatching('Storage record IDs: 987654321')
|
||||
)
|
||||
expect(setOutputMock).toHaveBeenNthCalledWith(
|
||||
@@ -447,8 +451,9 @@ describe('action', () => {
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
|
||||
expect(infoMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
expect.stringMatching('Attestation created for 5 subjects')
|
||||
)
|
||||
})
|
||||
@@ -496,11 +501,178 @@ describe('action', () => {
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
new Error(
|
||||
'Too many subjects specified. The maximum number of subjects is 1024.'
|
||||
'Too many subjects specified (1025). The maximum number of subjects is 1024.'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attestation type detection', () => {
|
||||
describe('when sbom-path is provided with predicate inputs', () => {
|
||||
it('sets a failed status for conflicting inputs', async () => {
|
||||
const inputs: main.RunInputs = {
|
||||
...defaultInputs,
|
||||
subjectDigest,
|
||||
subjectName,
|
||||
sbomPath: '/path/to/sbom.json',
|
||||
predicateType: 'https://example.com/predicate',
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
|
||||
await main.run(inputs)
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
new Error(
|
||||
'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when predicate is provided without predicate-type', () => {
|
||||
it('sets a failed status for missing predicate-type', async () => {
|
||||
const inputs: main.RunInputs = {
|
||||
...defaultInputs,
|
||||
subjectDigest,
|
||||
subjectName,
|
||||
predicate: '{}',
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
|
||||
await main.run(inputs)
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).toHaveBeenCalledWith(
|
||||
new Error(
|
||||
'predicate-type is required when using predicate or predicate-path'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when custom attestation inputs are provided', () => {
|
||||
const inputs: main.RunInputs = {
|
||||
...defaultInputs,
|
||||
subjectDigest,
|
||||
subjectName,
|
||||
predicateType,
|
||||
predicate,
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setGHContext({
|
||||
payload: { repository: { visibility: 'private' } },
|
||||
repo: { owner: 'foo', repo: 'bar' }
|
||||
})
|
||||
|
||||
await mockFulcio({
|
||||
baseURL: 'https://fulcio.githubapp.com',
|
||||
strict: false
|
||||
})
|
||||
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
|
||||
})
|
||||
|
||||
it('logs the attestation type as Custom', async () => {
|
||||
await main.run(inputs)
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when provenance attestation is detected', () => {
|
||||
const inputs: main.RunInputs = {
|
||||
...defaultInputs,
|
||||
subjectDigest,
|
||||
subjectName,
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
|
||||
const mockProvPredicate = {
|
||||
type: 'https://slsa.dev/provenance/v1',
|
||||
params: { buildDefinition: {}, runDetails: {} }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest
|
||||
.spyOn(provenance, 'generateProvenancePredicate')
|
||||
.mockResolvedValue(mockProvPredicate)
|
||||
|
||||
setGHContext({
|
||||
payload: { repository: { visibility: 'private' } },
|
||||
repo: { owner: 'foo', repo: 'bar' }
|
||||
})
|
||||
|
||||
await mockFulcio({
|
||||
baseURL: 'https://fulcio.githubapp.com',
|
||||
strict: false
|
||||
})
|
||||
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
|
||||
})
|
||||
|
||||
it('logs the attestation type as Build Provenance and generates predicate', async () => {
|
||||
await main.run(inputs)
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenCalledWith(
|
||||
'Attestation type: Build Provenance'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when sbom attestation is detected', () => {
|
||||
let tmpDir: string
|
||||
let sbomFilePath: string
|
||||
|
||||
const spdxSBOM = {
|
||||
spdxVersion: 'SPDX-2.3',
|
||||
SPDXID: 'SPDXRef-DOCUMENT',
|
||||
name: 'test-package',
|
||||
packages: []
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'main-test-'))
|
||||
sbomFilePath = path.join(tmpDir, 'sbom.spdx.json')
|
||||
await fs.writeFile(sbomFilePath, JSON.stringify(spdxSBOM))
|
||||
|
||||
setGHContext({
|
||||
payload: { repository: { visibility: 'private' } },
|
||||
repo: { owner: 'foo', repo: 'bar' }
|
||||
})
|
||||
|
||||
await mockFulcio({
|
||||
baseURL: 'https://fulcio.githubapp.com',
|
||||
strict: false
|
||||
})
|
||||
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true })
|
||||
})
|
||||
|
||||
it('logs the attestation type as SBOM and generates predicate', async () => {
|
||||
const inputs: main.RunInputs = {
|
||||
...defaultInputs,
|
||||
subjectDigest,
|
||||
subjectName,
|
||||
sbomPath: sbomFilePath,
|
||||
githubToken: 'gh-token'
|
||||
}
|
||||
|
||||
await main.run(inputs)
|
||||
|
||||
expect(runMock).toHaveReturned()
|
||||
expect(setFailedMock).not.toHaveBeenCalled()
|
||||
expect(infoMock).toHaveBeenCalledWith('Attestation type: SBOM')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Stubbing the GitHub context is a bit tricky. We need to use
|
||||
|
||||
Reference in New Issue
Block a user