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:
Brian DeHamer
2026-02-13 11:23:24 -08:00
committed by GitHub
parent a82737a684
commit dc4ad3cc6c
15 changed files with 1297 additions and 61 deletions

View File

@@ -41,6 +41,21 @@ information on artifact attestations.
> Artifact attestations are NOT supported on GitHub Enterprise Server. > Artifact attestations are NOT supported on GitHub Enterprise Server.
<!-- prettier-ignore-end --> <!-- prettier-ignore-end -->
## Attestation Modes
This action supports three attestation modes, automatically detected based on
the inputs you provide:
<!-- markdownlint-disable MD013 -->
| Mode | When Used | Description |
| -------------- | ------------------------------------------------------ | ------------------------------------------------ |
| **Provenance** | No `sbom-path` or predicate inputs | Auto-generates [SLSA build provenance][10] |
| **SBOM** | `sbom-path` is provided | Creates attestation from SPDX or CycloneDX SBOM |
| **Custom** | `predicate-type`/`predicate`/`predicate-path` provided | User-supplied predicate |
<!-- markdownlint-enable MD013 -->
## Usage ## Usage
Within the GitHub Actions workflow which builds some artifact you would like to Within the GitHub Actions workflow which builds some artifact you would like to
@@ -63,24 +78,21 @@ attest:
1. Add the following to your workflow after your artifact has been built: 1. Add the following to your workflow after your artifact has been built:
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
subject-path: '<PATH TO ARTIFACT>' subject-path: '<PATH TO ARTIFACT>'
predicate-type: '<PREDICATE URI>'
predicate-path: '<PATH TO PREDICATE>'
``` ```
The `subject-path` parameter should identify the artifact for which you want By default, this generates a [SLSA build provenance][10] attestation. For
to generate an attestation. The `predicate-type` can be any of the the SBOM or custom attestations, see the [Attestation Modes](#attestation-modes)
[vetted predicate types][3] or a custom value. The `predicate-path` section.
identifies a file containing the JSON-encoded predicate parameters.
### Inputs ### Inputs
See [action.yml](action.yml) See [action.yml](action.yml)
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
# Path to the artifact serving as the subject of the attestation. Must # Path to the artifact serving as the subject of the attestation. Must
# specify exactly one of "subject-path", "subject-digest", or # specify exactly one of "subject-path", "subject-digest", or
@@ -102,17 +114,24 @@ See [action.yml](action.yml)
# or "subject-checksums". # or "subject-checksums".
subject-checksums: subject-checksums:
# URI identifying the type of the predicate. # Path to the JSON-formatted SBOM file (SPDX or CycloneDX) to attest.
# File size cannot exceed 16MB. When provided, creates an SBOM attestation.
# Cannot be used together with "predicate-type", "predicate", or
# "predicate-path".
sbom-path:
# URI identifying the type of the predicate. Required when using "predicate"
# or "predicate-path" for custom attestations.
predicate-type: predicate-type:
# String containing the value for the attestation predicate. String length # String containing the value for the attestation predicate. String length
# cannot exceed 16MB. Must supply exactly one of "predicate-path" or # cannot exceed 16MB. Must supply exactly one of "predicate-path" or
# "predicate". # "predicate" when creating custom attestations.
predicate: predicate:
# Path to the file which contains the content for the attestation predicate. # Path to the file which contains the content for the attestation predicate.
# File size cannot exceed 16MB. Must supply exactly one of "predicate-path" # File size cannot exceed 16MB. Must supply exactly one of "predicate-path"
# or "predicate". # or "predicate" when creating custom attestations.
predicate-path: predicate-path:
# Whether to push the attestation to the image registry. Requires that the # Whether to push the attestation to the image registry. Requires that the
@@ -166,13 +185,13 @@ string cannot exceed 16MB.
## Examples ## Examples
### Identify Subject by Path ### Provenance Attestation (Default)
For the basic use case, simply add the `attest` action to your workflow and The simplest use case - just specify the artifact path and a SLSA build
supply the path to the artifact for which you want to generate attestation. provenance attestation is automatically generated:
```yaml ```yaml
name: build-attest name: build-attest-provenance
on: on:
workflow_dispatch: workflow_dispatch:
@@ -190,11 +209,36 @@ jobs:
- name: Build artifact - name: Build artifact
run: make my-app run: make my-app
- name: Attest - name: Attest
uses: actions/attest@v2 uses: actions/attest@v4
with: with:
subject-path: '${{ github.workspace }}/my-app' subject-path: '${{ github.workspace }}/my-app'
predicate-type: 'https://example.com/predicate/v1' ```
predicate: '{}'
### SBOM Attestation
To create an SBOM attestation, provide the path to an SPDX or CycloneDX JSON
file:
```yaml
- name: Generate SBOM
run: syft . -o spdx-json > sbom.spdx.json
- uses: actions/attest@v4
with:
subject-path: '${{ github.workspace }}/my-app'
sbom-path: '${{ github.workspace }}/sbom.spdx.json'
```
### Custom Attestation
For custom attestations, provide your own predicate type and content:
```yaml
- uses: actions/attest@v4
with:
subject-path: '${{ github.workspace }}/my-app'
predicate-type: 'https://example.com/predicate/v1'
predicate: '{}'
``` ```
### Identify Multiple Subjects ### Identify Multiple Subjects
@@ -203,7 +247,7 @@ If you are generating multiple artifacts, you can attest all of them at the same
time by using a wildcard in the `subject-path` input. time by using a wildcard in the `subject-path` input.
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
subject-path: 'dist/**/my-bin-*' subject-path: 'dist/**/my-bin-*'
predicate-type: 'https://example.com/predicate/v1' predicate-type: 'https://example.com/predicate/v1'
@@ -217,13 +261,13 @@ Alternatively, you can explicitly list multiple subjects with either a comma or
newline delimited list: newline delimited list:
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
subject-path: 'dist/foo, dist/bar' subject-path: 'dist/foo, dist/bar'
``` ```
```yaml ```yaml
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
subject-path: | subject-path: |
dist/foo dist/foo
@@ -245,11 +289,9 @@ attestation.
run: | run: |
shasum -a 256 foo_0.0.1_* > subject.checksums.txt shasum -a 256 foo_0.0.1_* > subject.checksums.txt
- uses: actions/attest@v2 - uses: actions/attest@v4
with: with:
subject-checksums: subject.checksums.txt subject-checksums: subject.checksums.txt
predicate-type: 'https://example.com/predicate/v1'
predicate: '{}'
``` ```
<!-- markdownlint-disable MD038 --> <!-- markdownlint-disable MD038 -->
@@ -322,13 +364,11 @@ jobs:
push: true push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Attest - name: Attest
uses: actions/attest@v2 uses: actions/attest@v4
id: attest id: attest
with: with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
predicate-type: 'https://in-toto.io/attestation/release/v0.1'
predicate: '{"purl":"pkg:oci/..."}'
push-to-registry: true push-to-registry: true
``` ```
@@ -343,3 +383,4 @@ jobs:
[8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns [8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
[9]: [9]:
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
[10]: https://slsa.dev/spec/v1.0/provenance

219
__tests__/attest.test.ts Normal file
View File

@@ -0,0 +1,219 @@
import * as attest from '@actions/attest'
import * as github from '@actions/github'
import * as oci from '@sigstore/oci'
import * as localAttest from '../src/attest'
import { createAttestation, repoOwnerIsOrg } from '../src/attest'
const subjectName = 'ghcr.io/foo/bar'
const subjectDigest =
'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
const predicate = {
type: 'https://in-toto.io/attestation/release/v0.1',
params: {}
}
describe('repoOwnerIsOrg', () => {
const originalContext = { ...github.context }
afterEach(() => {
setGHContext(originalContext)
jest.restoreAllMocks()
})
it('returns true when repo owner is an organization', async () => {
setGHContext({
repo: { owner: 'my-org', repo: 'my-repo' }
})
jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: {
repos: {
get: jest.fn().mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
}
}
} as unknown as ReturnType<typeof github.getOctokit>)
const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(true)
})
it('returns false when repo owner is a user', async () => {
setGHContext({
repo: { owner: 'my-user', repo: 'my-repo' }
})
jest.spyOn(github, 'getOctokit').mockReturnValue({
rest: {
repos: {
get: jest.fn().mockResolvedValue({
data: { owner: { type: 'User' } }
})
}
}
} as unknown as ReturnType<typeof github.getOctokit>)
const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(false)
})
})
describe('createAttestation', () => {
const originalEnv = process.env
const originalContext = { ...github.context }
beforeEach(() => {
jest.clearAllMocks()
setGHContext({
payload: { repository: { visibility: 'private' } },
repo: { owner: 'foo', repo: 'bar' }
})
})
afterEach(() => {
process.env = originalEnv
setGHContext(originalContext)
})
describe('when createStorageRecord is false', () => {
beforeEach(() => {
// Mock the core attest function
jest.spyOn(attest, 'attest').mockResolvedValue({
bundle: {
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json'
},
certificate: 'cert',
tlogID: 'tlog-123',
attestationID: 'att-123'
} as attest.Attestation)
// Mock OCI functions
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user',
password: 'pass'
})
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({
digest: 'sha256:abc123',
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
})
})
it('skips storage record creation', async () => {
const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord')
const subjects = [
{
name: subjectName,
digest: { sha256: subjectDigest.replace('sha256:', '') }
}
]
const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github',
pushToRegistry: true,
createStorageRecord: false,
githubToken: 'gh-token'
})
expect(result.attestationDigest).toBe('sha256:abc123')
expect(createStorageRecordSpy).not.toHaveBeenCalled()
})
})
describe('when storage records are empty', () => {
beforeEach(() => {
jest.spyOn(attest, 'attest').mockResolvedValue({
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
certificate: 'cert',
tlogID: 'tlog-123',
attestationID: 'att-123'
} as attest.Attestation)
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user',
password: 'pass'
})
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({
digest: 'sha256:abc123',
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
})
// Mock repoOwnerIsOrg
jest.spyOn(localAttest, 'repoOwnerIsOrg').mockResolvedValue(true)
// Mock createStorageRecord to return empty array
jest.spyOn(attest, 'createStorageRecord').mockResolvedValue([])
})
it('handles empty storage records gracefully', async () => {
const subjects = [
{
name: subjectName,
digest: { sha256: subjectDigest.replace('sha256:', '') }
}
]
// This exercises the empty records code path for coverage
const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github',
pushToRegistry: true,
createStorageRecord: true,
githubToken: 'gh-token'
})
expect(result.attestationDigest).toBe('sha256:abc123')
})
})
describe('when subject has unsupported protocol', () => {
beforeEach(() => {
jest.spyOn(attest, 'attest').mockResolvedValue({
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
certificate: 'cert',
tlogID: 'tlog-123',
attestationID: 'att-123'
} as attest.Attestation)
jest.spyOn(oci, 'getRegistryCredentials').mockReturnValue({
username: 'user',
password: 'pass'
})
jest.spyOn(oci, 'attachArtifactToImage').mockResolvedValue({
digest: 'sha256:abc123',
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
})
// Mock repoOwnerIsOrg
jest.spyOn(localAttest, 'repoOwnerIsOrg').mockResolvedValue(true)
})
it('handles unsupported protocol gracefully', async () => {
const subjects = [
{
name: 'http://registry.example.com/foo/bar',
digest: { sha256: subjectDigest.replace('sha256:', '') }
}
]
// This exercises the unsupported protocol code path for coverage
const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github',
pushToRegistry: true,
createStorageRecord: true,
githubToken: 'gh-token'
})
// Should complete without throwing (error is caught and logged as warning)
expect(result.attestationDigest).toBe('sha256:abc123')
})
})
})
function setGHContext(context: object): void {
Object.defineProperty(github, 'context', { value: context })
}

190
__tests__/detect.test.ts Normal file
View File

@@ -0,0 +1,190 @@
import {
detectAttestationType,
validateAttestationInputs,
DetectionInputs
} from '../src/detect'
describe('detectAttestationType', () => {
const blankInputs: DetectionInputs = {
sbomPath: '',
predicateType: '',
predicate: '',
predicatePath: ''
}
describe('when no inputs are provided', () => {
it('returns provenance', () => {
expect(detectAttestationType(blankInputs)).toBe('provenance')
})
})
describe('when sbom-path is provided', () => {
it('returns sbom', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json'
}
expect(detectAttestationType(inputs)).toBe('sbom')
})
it('returns sbom even when predicate inputs are also provided', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json',
predicateType: 'https://example.com/predicate'
}
expect(detectAttestationType(inputs)).toBe('sbom')
})
})
describe('when predicate-type is provided', () => {
it('returns custom', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate'
}
expect(detectAttestationType(inputs)).toBe('custom')
})
})
describe('when predicate is provided', () => {
it('returns custom', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicate: '{}'
}
expect(detectAttestationType(inputs)).toBe('custom')
})
})
describe('when predicate-path is provided', () => {
it('returns custom', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicatePath: '/path/to/predicate.json'
}
expect(detectAttestationType(inputs)).toBe('custom')
})
})
describe('when predicate-type and predicate are provided', () => {
it('returns custom', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate',
predicate: '{}'
}
expect(detectAttestationType(inputs)).toBe('custom')
})
})
})
describe('validateAttestationInputs', () => {
const blankInputs: DetectionInputs = {
sbomPath: '',
predicateType: '',
predicate: '',
predicatePath: ''
}
describe('when no inputs are provided', () => {
it('does not throw', () => {
expect(() => validateAttestationInputs(blankInputs)).not.toThrow()
})
})
describe('when sbom-path is provided alone', () => {
it('does not throw', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json'
}
expect(() => validateAttestationInputs(inputs)).not.toThrow()
})
})
describe('when sbom-path is combined with predicate-type', () => {
it('throws an error', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json',
predicateType: 'https://example.com/predicate'
}
expect(() => validateAttestationInputs(inputs)).toThrow(
/Cannot specify sbom-path together with/
)
})
})
describe('when sbom-path is combined with predicate', () => {
it('throws an error', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json',
predicate: '{}'
}
expect(() => validateAttestationInputs(inputs)).toThrow(
/Cannot specify sbom-path together with/
)
})
})
describe('when sbom-path is combined with predicate-path', () => {
it('throws an error', () => {
const inputs: DetectionInputs = {
...blankInputs,
sbomPath: '/path/to/sbom.json',
predicatePath: '/path/to/predicate.json'
}
expect(() => validateAttestationInputs(inputs)).toThrow(
/Cannot specify sbom-path together with/
)
})
})
describe('when predicate is provided without predicate-type', () => {
it('throws an error', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicate: '{}'
}
expect(() => validateAttestationInputs(inputs)).toThrow(
/predicate-type is required/
)
})
})
describe('when predicate-path is provided without predicate-type', () => {
it('throws an error', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicatePath: '/path/to/predicate.json'
}
expect(() => validateAttestationInputs(inputs)).toThrow(
/predicate-type is required/
)
})
})
describe('when predicate-type and predicate are provided', () => {
it('does not throw', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate',
predicate: '{}'
}
expect(() => validateAttestationInputs(inputs)).not.toThrow()
})
})
describe('when predicate-type and predicate-path are provided', () => {
it('does not throw', () => {
const inputs: DetectionInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate',
predicatePath: '/path/to/predicate.json'
}
expect(() => validateAttestationInputs(inputs)).not.toThrow()
})
})
})

View File

@@ -5,19 +5,20 @@
* Specifically, the inputs listed in `action.yml` should be set as environment * Specifically, the inputs listed in `action.yml` should be set as environment
* variables following the pattern `INPUT_<INPUT_NAME>`. * variables following the pattern `INPUT_<INPUT_NAME>`.
*/ */
import * as attest from '@actions/attest'
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock' import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
import * as oci from '@sigstore/oci' import * as oci from '@sigstore/oci'
import * as attest from '@actions/attest'
import * as localAttest from '../src/attest'
import fs from 'fs/promises' import fs from 'fs/promises'
import nock from 'nock' import nock from 'nock'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { MockAgent, setGlobalDispatcher } from 'undici' import { MockAgent, setGlobalDispatcher } from 'undici'
import * as localAttest from '../src/attest'
import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints' import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints'
import * as main from '../src/main' import * as main from '../src/main'
import * as provenance from '../src/provenance'
// Mock the GitHub Actions core library // Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info') const infoMock = jest.spyOn(core, 'info')
@@ -43,6 +44,7 @@ const defaultInputs: main.RunInputs = {
predicate: '', predicate: '',
predicateType: '', predicateType: '',
predicatePath: '', predicatePath: '',
sbomPath: '',
subjectName: '', subjectName: '',
subjectDigest: '', subjectDigest: '',
subjectPath: '', subjectPath: '',
@@ -187,8 +189,9 @@ describe('action', () => {
expect(runMock).toHaveReturned() expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalledWith() expect(setFailedMock).not.toHaveBeenCalledWith()
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
1, 2,
expect.stringMatching( expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}` `Attestation created for ${subjectName}@${subjectDigest}`
) )
@@ -198,15 +201,15 @@ describe('action', () => {
expect.stringMatching('GitHub Sigstore') expect.stringMatching('GitHub Sigstore')
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
2, 3,
expect.stringMatching('-----BEGIN CERTIFICATE-----') expect.stringMatching('-----BEGIN CERTIFICATE-----')
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
3, 4,
expect.stringMatching(/attestation uploaded/i) expect.stringMatching(/attestation uploaded/i)
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
4, 5,
expect.stringMatching(attestationID) expect.stringMatching(attestationID)
) )
expect(setOutputMock).toHaveBeenNthCalledWith( expect(setOutputMock).toHaveBeenNthCalledWith(
@@ -281,8 +284,9 @@ describe('action', () => {
expect(repoOwnerIsOrgSpy).toHaveBeenCalled() expect(repoOwnerIsOrgSpy).toHaveBeenCalled()
expect(createStorageRecordSpy).toHaveBeenCalled() expect(createStorageRecordSpy).toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled() expect(warningMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
1, 2,
expect.stringMatching( expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}` `Attestation created for ${subjectName}@${subjectDigest}`
) )
@@ -292,31 +296,31 @@ describe('action', () => {
expect.stringMatching('Public Good Sigstore') expect.stringMatching('Public Good Sigstore')
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
2, 3,
expect.stringMatching('-----BEGIN CERTIFICATE-----') expect.stringMatching('-----BEGIN CERTIFICATE-----')
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
3, 4,
expect.stringMatching(/signature uploaded/i) expect.stringMatching(/signature uploaded/i)
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
4, 5,
expect.stringMatching(SEARCH_PUBLIC_GOOD_URL) expect.stringMatching(SEARCH_PUBLIC_GOOD_URL)
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
5, 6,
expect.stringMatching(/attestation uploaded/i) expect.stringMatching(/attestation uploaded/i)
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
6, 7,
expect.stringMatching(attestationID) expect.stringMatching(attestationID)
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
9, 10,
expect.stringMatching('Storage record created') expect.stringMatching('Storage record created')
) )
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
10, 11,
expect.stringMatching('Storage record IDs: 987654321') expect.stringMatching('Storage record IDs: 987654321')
) )
expect(setOutputMock).toHaveBeenNthCalledWith( expect(setOutputMock).toHaveBeenNthCalledWith(
@@ -447,8 +451,9 @@ describe('action', () => {
expect(runMock).toHaveReturned() expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled() expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(1, 'Attestation type: Custom')
expect(infoMock).toHaveBeenNthCalledWith( expect(infoMock).toHaveBeenNthCalledWith(
1, 2,
expect.stringMatching('Attestation created for 5 subjects') expect.stringMatching('Attestation created for 5 subjects')
) )
}) })
@@ -496,11 +501,178 @@ describe('action', () => {
expect(runMock).toHaveReturned() expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith( expect(setFailedMock).toHaveBeenCalledWith(
new Error( 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 // Stubbing the GitHub context is a bit tricky. We need to use

View File

@@ -0,0 +1,43 @@
import { generateProvenancePredicate } from '../src/provenance'
import { buildSLSAProvenancePredicate } from '@actions/attest'
jest.mock('@actions/attest', () => ({
buildSLSAProvenancePredicate: jest.fn()
}))
describe('generateProvenancePredicate', () => {
const mockPredicate = {
type: 'https://slsa.dev/provenance/v1',
params: {
buildDefinition: {
buildType: 'https://actions.github.io/buildtypes/workflow/v1'
},
runDetails: {
builder: { id: 'https://github.com/actions/runner' }
}
}
}
beforeEach(() => {
jest.clearAllMocks()
;(buildSLSAProvenancePredicate as jest.Mock).mockResolvedValue(
mockPredicate
)
})
it('returns the SLSA provenance predicate', async () => {
const result = await generateProvenancePredicate()
expect(buildSLSAProvenancePredicate).toHaveBeenCalledTimes(1)
expect(result).toEqual(mockPredicate)
})
it('propagates errors from buildSLSAProvenancePredicate', async () => {
const error = new Error('Failed to build provenance')
;(buildSLSAProvenancePredicate as jest.Mock).mockRejectedValue(error)
await expect(generateProvenancePredicate()).rejects.toThrow(
'Failed to build provenance'
)
})
})

161
__tests__/sbom.test.ts Normal file
View File

@@ -0,0 +1,161 @@
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import { parseSBOMFromPath, generateSBOMPredicate, SBOM } from '../src/sbom'
describe('parseSBOMFromPath', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sbom-test-'))
})
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true })
})
describe('when file does not exist', () => {
it('throws an error', async () => {
await expect(parseSBOMFromPath('/nonexistent/file.json')).rejects.toThrow(
/SBOM file not found/
)
})
})
describe('when file contains valid SPDX SBOM', () => {
const spdxSBOM = {
spdxVersion: 'SPDX-2.3',
SPDXID: 'SPDXRef-DOCUMENT',
name: 'test-package',
packages: []
}
it('returns SBOM with type spdx', async () => {
const filePath = path.join(tmpDir, 'sbom.spdx.json')
await fs.writeFile(filePath, JSON.stringify(spdxSBOM))
const result = await parseSBOMFromPath(filePath)
expect(result.type).toBe('spdx')
expect(result.object).toEqual(spdxSBOM)
})
})
describe('when file contains valid CycloneDX SBOM', () => {
const cyclonedxSBOM = {
bomFormat: 'CycloneDX',
specVersion: '1.4',
serialNumber: 'urn:uuid:12345',
components: []
}
it('returns SBOM with type cyclonedx', async () => {
const filePath = path.join(tmpDir, 'sbom.cdx.json')
await fs.writeFile(filePath, JSON.stringify(cyclonedxSBOM))
const result = await parseSBOMFromPath(filePath)
expect(result.type).toBe('cyclonedx')
expect(result.object).toEqual(cyclonedxSBOM)
})
})
describe('when file contains invalid SBOM format', () => {
it('throws an error', async () => {
const filePath = path.join(tmpDir, 'invalid.json')
await fs.writeFile(filePath, JSON.stringify({ random: 'data' }))
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
/Unsupported SBOM format/
)
})
})
describe('when file contains invalid JSON', () => {
it('throws an error', async () => {
const filePath = path.join(tmpDir, 'invalid.json')
await fs.writeFile(filePath, 'not valid json')
await expect(parseSBOMFromPath(filePath)).rejects.toThrow()
})
})
describe('when file exceeds maximum size', () => {
it('throws an error', async () => {
const filePath = path.join(tmpDir, 'large.json')
// Create a file larger than 16MB
const largeContent = 'x'.repeat(17 * 1024 * 1024)
await fs.writeFile(filePath, largeContent)
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
/SBOM file exceeds maximum allowed size/
)
})
})
})
describe('generateSBOMPredicate', () => {
describe('for SPDX SBOM', () => {
const spdxSBOM: SBOM = {
type: 'spdx',
object: {
spdxVersion: 'SPDX-2.3',
SPDXID: 'SPDXRef-DOCUMENT',
name: 'test-package'
}
}
it('returns predicate with correct SPDX type', () => {
const predicate = generateSBOMPredicate(spdxSBOM)
expect(predicate.type).toBe('https://spdx.dev/Document/v2.3')
expect(predicate.params).toEqual(spdxSBOM.object)
})
})
describe('for CycloneDX SBOM', () => {
const cyclonedxSBOM: SBOM = {
type: 'cyclonedx',
object: {
bomFormat: 'CycloneDX',
specVersion: '1.4',
serialNumber: 'urn:uuid:12345'
}
}
it('returns predicate with correct CycloneDX type', () => {
const predicate = generateSBOMPredicate(cyclonedxSBOM)
expect(predicate.type).toBe('https://cyclonedx.org/bom')
expect(predicate.params).toEqual(cyclonedxSBOM.object)
})
})
describe('for SPDX without version', () => {
const invalidSBOM: SBOM = {
type: 'spdx',
object: {
SPDXID: 'SPDXRef-DOCUMENT'
}
}
it('throws an error', () => {
expect(() => generateSBOMPredicate(invalidSBOM)).toThrow(
/Cannot find spdxVersion/
)
})
})
describe('for unsupported SBOM type', () => {
const unsupportedSBOM = {
type: 'unknown' as SBOM['type'],
object: { foo: 'bar' }
}
it('throws an error', () => {
expect(() => generateSBOMPredicate(unsupportedSBOM)).toThrow(
/Unsupported SBOM format/
)
})
})
})

View File

@@ -524,6 +524,39 @@ f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e demo_0.0.1_lin
}) })
}) })
describe('when specifying a subject checksums string with duplicates', () => {
const checksums = `
f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e demo_0.0.1_linux_386
f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e demo_0.0.1_linux_386
187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d *demo_0.0.1_linux_amd64`
it('returns de-duplicated subjects', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectChecksums: checksums
}
const subjects = await subjectFromInputs(inputs)
expect(subjects).toBeDefined()
expect(subjects).toHaveLength(2)
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_386',
digest: {
sha256:
'f861e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e'
}
})
expect(subjects).toContainEqual({
name: 'demo_0.0.1_linux_amd64',
digest: {
sha256:
'187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d'
}
})
})
})
describe('when specifying a subject checksums string with an unrecognized digest', () => { describe('when specifying a subject checksums string with an unrecognized digest', () => {
const checksums = `f861e demo_0.0.1_linux_386` const checksums = `f861e demo_0.0.1_linux_386`

View File

@@ -30,21 +30,29 @@ inputs:
attestation. Must specify exactly one of "subject-path", "subject-digest", attestation. Must specify exactly one of "subject-path", "subject-digest",
or "subject-checksums". or "subject-checksums".
required: false required: false
sbom-path:
description: >
Path to the JSON-formatted SBOM file (SPDX or CycloneDX) to attest.
File size cannot exceed 16MB. When provided, creates an SBOM attestation.
Cannot be used together with "predicate-type", "predicate", or
"predicate-path".
required: false
predicate-type: predicate-type:
description: > description: >
URI identifying the type of the predicate. URI identifying the type of the predicate. Required when using "predicate"
required: true or "predicate-path" for custom attestations.
required: false
predicate: predicate:
description: > description: >
String containing the value for the attestation predicate. String length String containing the value for the attestation predicate. String length
cannot exceed 16MB. Must supply exactly one of "predicate-path" or cannot exceed 16MB. Must supply exactly one of "predicate-path" or
"predicate". "predicate" when creating custom attestations.
required: false required: false
predicate-path: predicate-path:
description: > description: >
Path to the file which contains the content for the attestation predicate. Path to the file which contains the content for the attestation predicate.
File size cannot exceed 16MB. Must supply exactly one of "predicate-path" File size cannot exceed 16MB. Must supply exactly one of "predicate-path"
or "predicate". or "predicate" when creating custom attestations.
required: false required: false
push-to-registry: push-to-registry:
description: > description: >

180
dist/index.js generated vendored
View File

@@ -107016,6 +107016,43 @@ function getRegistryURL(subjectName) {
} }
/***/ }),
/***/ 41052:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.validateAttestationInputs = exports.detectAttestationType = void 0;
const detectAttestationType = (inputs) => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs;
// SBOM mode takes priority
if (sbomPath) {
return 'sbom';
}
// Custom mode when any predicate inputs are provided
if (predicateType || predicate || predicatePath) {
return 'custom';
}
// Default to provenance mode
return 'provenance';
};
exports.detectAttestationType = detectAttestationType;
const validateAttestationInputs = (inputs) => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs;
// Cannot combine sbom-path with predicate inputs
if (sbomPath && (predicateType || predicate || predicatePath)) {
throw new Error('Cannot specify sbom-path together with predicate-type, predicate, or predicate-path');
}
// Custom mode requires predicate-type
if ((predicate || predicatePath) && !predicateType) {
throw new Error('predicate-type is required when using predicate or predicate-path');
}
};
exports.validateAttestationInputs = validateAttestationInputs;
/***/ }), /***/ }),
/***/ 7437: /***/ 7437:
@@ -107079,6 +107116,7 @@ const inputs = {
subjectName: core.getInput('subject-name'), subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'), subjectDigest: core.getInput('subject-digest'),
subjectChecksums: core.getInput('subject-checksums'), subjectChecksums: core.getInput('subject-checksums'),
sbomPath: core.getInput('sbom-path'),
predicateType: core.getInput('predicate-type'), predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'), predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'), predicatePath: core.getInput('predicate-path'),
@@ -107144,8 +107182,11 @@ const fs_1 = __importDefault(__nccwpck_require__(79896));
const os_1 = __importDefault(__nccwpck_require__(70857)); const os_1 = __importDefault(__nccwpck_require__(70857));
const path_1 = __importDefault(__nccwpck_require__(16928)); const path_1 = __importDefault(__nccwpck_require__(16928));
const attest_1 = __nccwpck_require__(93738); const attest_1 = __nccwpck_require__(93738);
const detect_1 = __nccwpck_require__(41052);
const endpoints_1 = __nccwpck_require__(7437); const endpoints_1 = __nccwpck_require__(7437);
const predicate_1 = __nccwpck_require__(84982); const predicate_1 = __nccwpck_require__(84982);
const provenance_1 = __nccwpck_require__(83628);
const sbom_1 = __nccwpck_require__(20594);
const style = __importStar(__nccwpck_require__(64542)); const style = __importStar(__nccwpck_require__(64542));
const subject_1 = __nccwpck_require__(36303); const subject_1 = __nccwpck_require__(36303);
const ATTESTATION_FILE_NAME = 'attestation.json'; const ATTESTATION_FILE_NAME = 'attestation.json';
@@ -107174,11 +107215,22 @@ async function run(inputs) {
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'); throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
} }
// Detect attestation type and validate inputs
const detectionInputs = {
sbomPath: inputs.sbomPath,
predicateType: inputs.predicateType,
predicate: inputs.predicate,
predicatePath: inputs.predicatePath
};
(0, detect_1.validateAttestationInputs)(detectionInputs);
const attestationType = (0, detect_1.detectAttestationType)(detectionInputs);
logAttestationType(attestationType);
const subjects = await (0, subject_1.subjectFromInputs)({ const subjects = await (0, subject_1.subjectFromInputs)({
...inputs, ...inputs,
downcaseName: inputs.pushToRegistry downcaseName: inputs.pushToRegistry
}); });
const predicate = (0, predicate_1.predicateFromInputs)(inputs); // Generate predicate based on attestation type
const predicate = await getPredicateForType(attestationType, inputs);
const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME); const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME);
core.setOutput('bundle-path', outputPath); core.setOutput('bundle-path', outputPath);
const att = await (0, attest_1.createAttestation)(subjects, predicate, { const att = await (0, attest_1.createAttestation)(subjects, predicate, {
@@ -107283,6 +107335,28 @@ const tempDir = () => {
return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep)); return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep));
}; };
const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`; const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`;
// Log the detected attestation type
const logAttestationType = (type) => {
const typeLabels = {
provenance: 'Build Provenance',
sbom: 'SBOM',
custom: 'Custom'
};
core.info(`Attestation type: ${typeLabels[type]}`);
};
// Generate predicate based on attestation type
const getPredicateForType = async (type, inputs) => {
switch (type) {
case 'provenance':
return (0, provenance_1.generateProvenancePredicate)();
case 'sbom': {
const sbom = await (0, sbom_1.parseSBOMFromPath)(inputs.sbomPath);
return (0, sbom_1.generateSBOMPredicate)(sbom);
}
case 'custom':
return (0, predicate_1.predicateFromInputs)(inputs);
}
};
/***/ }), /***/ }),
@@ -107334,6 +107408,96 @@ const predicateFromInputs = (inputs) => {
exports.predicateFromInputs = predicateFromInputs; exports.predicateFromInputs = predicateFromInputs;
/***/ }),
/***/ 83628:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.generateProvenancePredicate = void 0;
const attest_1 = __nccwpck_require__(11485);
const generateProvenancePredicate = async () => {
return (0, attest_1.buildSLSAProvenancePredicate)();
};
exports.generateProvenancePredicate = generateProvenancePredicate;
/***/ }),
/***/ 20594:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.generateSBOMPredicate = exports.parseSBOMFromPath = void 0;
const fs_1 = __importDefault(__nccwpck_require__(79896));
// SBOMs cannot exceed 16MB.
const MAX_SBOM_SIZE_BYTES = 16 * 1024 * 1024;
const parseSBOMFromPath = async (filePath) => {
if (!fs_1.default.existsSync(filePath)) {
throw new Error(`SBOM file not found: ${filePath}`);
}
const stats = fs_1.default.statSync(filePath);
if (stats.size > MAX_SBOM_SIZE_BYTES) {
throw new Error(`SBOM file exceeds maximum allowed size: ${MAX_SBOM_SIZE_BYTES} bytes`);
}
const fileContent = await fs_1.default.promises.readFile(filePath, 'utf8');
const sbom = JSON.parse(fileContent);
if (checkIsSPDX(sbom)) {
return { type: 'spdx', object: sbom };
}
else if (checkIsCycloneDX(sbom)) {
return { type: 'cyclonedx', object: sbom };
}
throw new Error('Unsupported SBOM format. Must be valid SPDX or CycloneDX JSON.');
};
exports.parseSBOMFromPath = parseSBOMFromPath;
const checkIsSPDX = (sbomObject) => {
return !!(sbomObject?.spdxVersion && sbomObject?.SPDXID);
};
const checkIsCycloneDX = (sbomObject) => {
return !!(sbomObject?.bomFormat &&
sbomObject?.serialNumber &&
sbomObject?.specVersion);
};
const generateSBOMPredicate = (sbom) => {
switch (sbom.type) {
case 'spdx':
return generateSPDXPredicate(sbom.object);
case 'cyclonedx':
return generateCycloneDXPredicate(sbom.object);
default:
throw new Error('Unsupported SBOM format');
}
};
exports.generateSBOMPredicate = generateSBOMPredicate;
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/spdx.md
const generateSPDXPredicate = (sbom) => {
const spdxVersion = sbom?.['spdxVersion'];
if (!spdxVersion) {
throw new Error('Cannot find spdxVersion in the SBOM');
}
const version = spdxVersion.split('-')[1];
return {
type: `https://spdx.dev/Document/v${version}`,
params: sbom
};
};
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/cyclonedx.md
const generateCycloneDXPredicate = (sbom) => {
return {
type: 'https://cyclonedx.org/bom',
params: sbom
};
};
/***/ }), /***/ }),
/***/ 64542: /***/ 64542:
@@ -107461,7 +107625,7 @@ const getSubjectFromPath = async (subjectPath, subjectName) => {
// Filter path list to just the files (not directories) // Filter path list to just the files (not directories)
const files = paths.filter(p => fs_1.default.statSync(p).isFile()); const files = paths.filter(p => fs_1.default.statSync(p).isFile());
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`); throw new Error(`Too many subjects specified (${files.length}). The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`);
} }
for (const file of files) { for (const file of files) {
const name = subjectName || path_1.default.parse(file).base; const name = subjectName || path_1.default.parse(file).base;
@@ -107528,10 +107692,14 @@ const getSubjectFromChecksumsString = (checksums) => {
if (!HEX_STRING_RE.test(digest)) { if (!HEX_STRING_RE.test(digest)) {
throw new Error(`Invalid digest: ${digest}`); throw new Error(`Invalid digest: ${digest}`);
} }
subjects.push({ const alg = digestAlgorithm(digest);
name, // Only add the subject if it is not already in the list (deduplicate by name & digest)
digest: { [digestAlgorithm(digest)]: digest } if (!subjects.some(s => s.name === name && s.digest[alg] === digest)) {
}); subjects.push({
name,
digest: { [alg]: digest }
});
}
} }
return subjects; return subjects;
}; };

45
src/detect.ts Normal file
View File

@@ -0,0 +1,45 @@
export type AttestationType = 'provenance' | 'sbom' | 'custom'
export type DetectionInputs = {
sbomPath: string
predicateType: string
predicate: string
predicatePath: string
}
export const detectAttestationType = (
inputs: DetectionInputs
): AttestationType => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs
// SBOM mode takes priority
if (sbomPath) {
return 'sbom'
}
// Custom mode when any predicate inputs are provided
if (predicateType || predicate || predicatePath) {
return 'custom'
}
// Default to provenance mode
return 'provenance'
}
export const validateAttestationInputs = (inputs: DetectionInputs): void => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs
// Cannot combine sbom-path with predicate inputs
if (sbomPath && (predicateType || predicate || predicatePath)) {
throw new Error(
'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path'
)
}
// Custom mode requires predicate-type
if ((predicate || predicatePath) && !predicateType) {
throw new Error(
'predicate-type is required when using predicate or predicate-path'
)
}
}

View File

@@ -9,6 +9,7 @@ const inputs: RunInputs = {
subjectName: core.getInput('subject-name'), subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'), subjectDigest: core.getInput('subject-digest'),
subjectChecksums: core.getInput('subject-checksums'), subjectChecksums: core.getInput('subject-checksums'),
sbomPath: core.getInput('sbom-path'),
predicateType: core.getInput('predicate-type'), predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'), predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'), predicatePath: core.getInput('predicate-path'),

View File

@@ -4,8 +4,16 @@ import fs from 'fs'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { AttestResult, SigstoreInstance, createAttestation } from './attest' import { AttestResult, SigstoreInstance, createAttestation } from './attest'
import {
AttestationType,
DetectionInputs,
detectAttestationType,
validateAttestationInputs
} from './detect'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints' import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate' import { PredicateInputs, predicateFromInputs } from './predicate'
import { generateProvenancePredicate } from './provenance'
import { parseSBOMFromPath, generateSBOMPredicate } from './sbom'
import * as style from './style' import * as style from './style'
import { import {
SubjectInputs, SubjectInputs,
@@ -13,13 +21,18 @@ import {
subjectFromInputs subjectFromInputs
} from './subject' } from './subject'
import type { Subject } from '@actions/attest' import type { Predicate, Subject } from '@actions/attest'
const ATTESTATION_FILE_NAME = 'attestation.json' const ATTESTATION_FILE_NAME = 'attestation.json'
const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt' const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt'
export type SBOMInputs = {
sbomPath: string
}
export type RunInputs = SubjectInputs & export type RunInputs = SubjectInputs &
PredicateInputs & { PredicateInputs &
SBOMInputs & {
pushToRegistry: boolean pushToRegistry: boolean
createStorageRecord: boolean createStorageRecord: boolean
githubToken: string githubToken: string
@@ -58,11 +71,24 @@ export async function run(inputs: RunInputs): Promise<void> {
) )
} }
// Detect attestation type and validate inputs
const detectionInputs: DetectionInputs = {
sbomPath: inputs.sbomPath,
predicateType: inputs.predicateType,
predicate: inputs.predicate,
predicatePath: inputs.predicatePath
}
validateAttestationInputs(detectionInputs)
const attestationType = detectAttestationType(detectionInputs)
logAttestationType(attestationType)
const subjects = await subjectFromInputs({ const subjects = await subjectFromInputs({
...inputs, ...inputs,
downcaseName: inputs.pushToRegistry downcaseName: inputs.pushToRegistry
}) })
const predicate = predicateFromInputs(inputs)
// Generate predicate based on attestation type
const predicate = await getPredicateForType(attestationType, inputs)
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME) const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath) core.setOutput('bundle-path', outputPath)
@@ -207,3 +233,30 @@ const tempDir = (): string => {
const attestationURL = (id: string): string => const attestationURL = (id: string): string =>
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}` `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
// Log the detected attestation type
const logAttestationType = (type: AttestationType): void => {
const typeLabels: Record<AttestationType, string> = {
provenance: 'Build Provenance',
sbom: 'SBOM',
custom: 'Custom'
}
core.info(`Attestation type: ${typeLabels[type]}`)
}
// Generate predicate based on attestation type
const getPredicateForType = async (
type: AttestationType,
inputs: RunInputs
): Promise<Predicate> => {
switch (type) {
case 'provenance':
return generateProvenancePredicate()
case 'sbom': {
const sbom = await parseSBOMFromPath(inputs.sbomPath)
return generateSBOMPredicate(sbom)
}
case 'custom':
return predicateFromInputs(inputs)
}
}

7
src/provenance.ts Normal file
View File

@@ -0,0 +1,7 @@
import { buildSLSAProvenancePredicate } from '@actions/attest'
import type { Predicate } from '@actions/attest'
export const generateProvenancePredicate = async (): Promise<Predicate> => {
return buildSLSAProvenancePredicate()
}

90
src/sbom.ts Normal file
View File

@@ -0,0 +1,90 @@
import fs from 'fs'
import type { Predicate } from '@actions/attest'
export type SBOM = {
type: 'spdx' | 'cyclonedx'
object: object
}
// SBOMs cannot exceed 16MB.
const MAX_SBOM_SIZE_BYTES = 16 * 1024 * 1024
export const parseSBOMFromPath = async (filePath: string): Promise<SBOM> => {
if (!fs.existsSync(filePath)) {
throw new Error(`SBOM file not found: ${filePath}`)
}
const stats = fs.statSync(filePath)
if (stats.size > MAX_SBOM_SIZE_BYTES) {
throw new Error(
`SBOM file exceeds maximum allowed size: ${MAX_SBOM_SIZE_BYTES} bytes`
)
}
const fileContent = await fs.promises.readFile(filePath, 'utf8')
const sbom = JSON.parse(fileContent) as object
if (checkIsSPDX(sbom)) {
return { type: 'spdx', object: sbom }
} else if (checkIsCycloneDX(sbom)) {
return { type: 'cyclonedx', object: sbom }
}
throw new Error(
'Unsupported SBOM format. Must be valid SPDX or CycloneDX JSON.'
)
}
const checkIsSPDX = (sbomObject: {
spdxVersion?: string
SPDXID?: string
}): boolean => {
return !!(sbomObject?.spdxVersion && sbomObject?.SPDXID)
}
const checkIsCycloneDX = (sbomObject: {
bomFormat?: string
serialNumber?: string
specVersion?: string
}): boolean => {
return !!(
sbomObject?.bomFormat &&
sbomObject?.serialNumber &&
sbomObject?.specVersion
)
}
export const generateSBOMPredicate = (sbom: SBOM): Predicate => {
switch (sbom.type) {
case 'spdx':
return generateSPDXPredicate(sbom.object)
case 'cyclonedx':
return generateCycloneDXPredicate(sbom.object)
default:
throw new Error('Unsupported SBOM format')
}
}
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/spdx.md
const generateSPDXPredicate = (sbom: object): Predicate => {
const spdxVersion = (sbom as { spdxVersion?: string })?.['spdxVersion']
if (!spdxVersion) {
throw new Error('Cannot find spdxVersion in the SBOM')
}
const version = spdxVersion.split('-')[1]
return {
type: `https://spdx.dev/Document/v${version}`,
params: sbom
}
}
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/cyclonedx.md
const generateCycloneDXPredicate = (sbom: object): Predicate => {
return {
type: 'https://cyclonedx.org/bom',
params: sbom
}
}

View File

@@ -98,7 +98,7 @@ const getSubjectFromPath = async (
if (files.length > MAX_SUBJECT_COUNT) { if (files.length > MAX_SUBJECT_COUNT) {
throw new Error( throw new Error(
`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.` `Too many subjects specified (${files.length}). The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`
) )
} }
@@ -195,10 +195,15 @@ const getSubjectFromChecksumsString = (checksums: string): Subject[] => {
throw new Error(`Invalid digest: ${digest}`) throw new Error(`Invalid digest: ${digest}`)
} }
subjects.push({ const alg = digestAlgorithm(digest)
name,
digest: { [digestAlgorithm(digest)]: digest } // Only add the subject if it is not already in the list (deduplicate by name & digest)
}) if (!subjects.some(s => s.name === name && s.digest[alg] === digest)) {
subjects.push({
name,
digest: { [alg]: digest }
})
}
} }
return subjects return subjects