diff --git a/README.md b/README.md index fae03c5..45b4115 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ information on artifact attestations. > Artifact attestations are NOT supported on GitHub Enterprise Server. +## Attestation Modes + +This action supports three attestation modes, automatically detected based on +the inputs you provide: + + + +| 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 | + + + ## Usage 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: ```yaml - - uses: actions/attest@v2 + - uses: actions/attest@v4 with: subject-path: '' - predicate-type: '' - predicate-path: '' ``` - The `subject-path` parameter should identify the artifact for which you want - to generate an attestation. The `predicate-type` can be any of the the - [vetted predicate types][3] or a custom value. The `predicate-path` - identifies a file containing the JSON-encoded predicate parameters. + By default, this generates a [SLSA build provenance][10] attestation. For + SBOM or custom attestations, see the [Attestation Modes](#attestation-modes) + section. ### Inputs See [action.yml](action.yml) ```yaml -- uses: actions/attest@v2 +- uses: actions/attest@v4 with: # Path to the artifact serving as the subject of the attestation. Must # specify exactly one of "subject-path", "subject-digest", or @@ -102,17 +114,24 @@ See [action.yml](action.yml) # or "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: # String containing the value for the attestation predicate. String length # cannot exceed 16MB. Must supply exactly one of "predicate-path" or - # "predicate". + # "predicate" when creating custom attestations. 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" - # or "predicate". + # or "predicate" when creating custom attestations. predicate-path: # Whether to push the attestation to the image registry. Requires that the @@ -166,13 +185,13 @@ string cannot exceed 16MB. ## Examples -### Identify Subject by Path +### Provenance Attestation (Default) -For the basic use case, simply add the `attest` action to your workflow and -supply the path to the artifact for which you want to generate attestation. +The simplest use case - just specify the artifact path and a SLSA build +provenance attestation is automatically generated: ```yaml -name: build-attest +name: build-attest-provenance on: workflow_dispatch: @@ -190,11 +209,36 @@ jobs: - name: Build artifact run: make my-app - name: Attest - uses: actions/attest@v2 + uses: actions/attest@v4 with: 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 @@ -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. ```yaml -- uses: actions/attest@v2 +- uses: actions/attest@v4 with: subject-path: 'dist/**/my-bin-*' 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: ```yaml -- uses: actions/attest@v2 +- uses: actions/attest@v4 with: subject-path: 'dist/foo, dist/bar' ``` ```yaml -- uses: actions/attest@v2 +- uses: actions/attest@v4 with: subject-path: | dist/foo @@ -245,11 +289,9 @@ attestation. run: | shasum -a 256 foo_0.0.1_* > subject.checksums.txt -- uses: actions/attest@v2 +- uses: actions/attest@v4 with: subject-checksums: subject.checksums.txt - predicate-type: 'https://example.com/predicate/v1' - predicate: '{}' ``` @@ -322,13 +364,11 @@ jobs: push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - name: Attest - uses: actions/attest@v2 + uses: actions/attest@v4 id: attest with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} - predicate-type: 'https://in-toto.io/attestation/release/v0.1' - predicate: '{"purl":"pkg:oci/..."}' push-to-registry: true ``` @@ -343,3 +383,4 @@ jobs: [8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns [9]: https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds +[10]: https://slsa.dev/spec/v1.0/provenance diff --git a/__tests__/attest.test.ts b/__tests__/attest.test.ts new file mode 100644 index 0000000..eba97f0 --- /dev/null +++ b/__tests__/attest.test.ts @@ -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) + + 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) + + 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 }) +} diff --git a/__tests__/detect.test.ts b/__tests__/detect.test.ts new file mode 100644 index 0000000..36f959c --- /dev/null +++ b/__tests__/detect.test.ts @@ -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() + }) + }) +}) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index bd9ca34..b91c60c 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -5,19 +5,20 @@ * Specifically, the inputs listed in `action.yml` should be set as environment * variables following the pattern `INPUT_`. */ +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 diff --git a/__tests__/provenance.test.ts b/__tests__/provenance.test.ts new file mode 100644 index 0000000..ff001b1 --- /dev/null +++ b/__tests__/provenance.test.ts @@ -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' + ) + }) +}) diff --git a/__tests__/sbom.test.ts b/__tests__/sbom.test.ts new file mode 100644 index 0000000..17b72d5 --- /dev/null +++ b/__tests__/sbom.test.ts @@ -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/ + ) + }) + }) +}) diff --git a/__tests__/subject.test.ts b/__tests__/subject.test.ts index a89b867..7064a00 100644 --- a/__tests__/subject.test.ts +++ b/__tests__/subject.test.ts @@ -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', () => { const checksums = `f861e demo_0.0.1_linux_386` diff --git a/action.yml b/action.yml index 66fe071..18bce0d 100644 --- a/action.yml +++ b/action.yml @@ -30,21 +30,29 @@ inputs: attestation. Must specify exactly one of "subject-path", "subject-digest", or "subject-checksums". 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: description: > - URI identifying the type of the predicate. - required: true + URI identifying the type of the predicate. Required when using "predicate" + or "predicate-path" for custom attestations. + required: false predicate: description: > String containing the value for the attestation predicate. String length cannot exceed 16MB. Must supply exactly one of "predicate-path" or - "predicate". + "predicate" when creating custom attestations. required: false predicate-path: description: > Path to the file which contains the content for the attestation predicate. File size cannot exceed 16MB. Must supply exactly one of "predicate-path" - or "predicate". + or "predicate" when creating custom attestations. required: false push-to-registry: description: > diff --git a/dist/index.js b/dist/index.js index 5e87a61..faffca6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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: @@ -107079,6 +107116,7 @@ const inputs = { subjectName: core.getInput('subject-name'), subjectDigest: core.getInput('subject-digest'), subjectChecksums: core.getInput('subject-checksums'), + sbomPath: core.getInput('sbom-path'), predicateType: core.getInput('predicate-type'), predicate: core.getInput('predicate'), predicatePath: core.getInput('predicate-path'), @@ -107144,8 +107182,11 @@ const fs_1 = __importDefault(__nccwpck_require__(79896)); const os_1 = __importDefault(__nccwpck_require__(70857)); const path_1 = __importDefault(__nccwpck_require__(16928)); const attest_1 = __nccwpck_require__(93738); +const detect_1 = __nccwpck_require__(41052); const endpoints_1 = __nccwpck_require__(7437); 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 subject_1 = __nccwpck_require__(36303); const ATTESTATION_FILE_NAME = 'attestation.json'; @@ -107174,11 +107215,22 @@ async function run(inputs) { if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { 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)({ ...inputs, 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); core.setOutput('bundle-path', outputPath); 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)); }; 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; +/***/ }), + +/***/ 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: @@ -107461,7 +107625,7 @@ const getSubjectFromPath = async (subjectPath, subjectName) => { // Filter path list to just the files (not directories) const files = paths.filter(p => fs_1.default.statSync(p).isFile()); 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) { const name = subjectName || path_1.default.parse(file).base; @@ -107528,10 +107692,14 @@ const getSubjectFromChecksumsString = (checksums) => { if (!HEX_STRING_RE.test(digest)) { throw new Error(`Invalid digest: ${digest}`); } - subjects.push({ - name, - digest: { [digestAlgorithm(digest)]: digest } - }); + const alg = digestAlgorithm(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; }; diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..2e12de2 --- /dev/null +++ b/src/detect.ts @@ -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' + ) + } +} diff --git a/src/index.ts b/src/index.ts index 890f747..3759673 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ const inputs: RunInputs = { subjectName: core.getInput('subject-name'), subjectDigest: core.getInput('subject-digest'), subjectChecksums: core.getInput('subject-checksums'), + sbomPath: core.getInput('sbom-path'), predicateType: core.getInput('predicate-type'), predicate: core.getInput('predicate'), predicatePath: core.getInput('predicate-path'), diff --git a/src/main.ts b/src/main.ts index fc4db78..8c0c388 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,8 +4,16 @@ import fs from 'fs' import os from 'os' import path from 'path' import { AttestResult, SigstoreInstance, createAttestation } from './attest' +import { + AttestationType, + DetectionInputs, + detectAttestationType, + validateAttestationInputs +} from './detect' import { SEARCH_PUBLIC_GOOD_URL } from './endpoints' import { PredicateInputs, predicateFromInputs } from './predicate' +import { generateProvenancePredicate } from './provenance' +import { parseSBOMFromPath, generateSBOMPredicate } from './sbom' import * as style from './style' import { SubjectInputs, @@ -13,13 +21,18 @@ import { subjectFromInputs } from './subject' -import type { Subject } from '@actions/attest' +import type { Predicate, Subject } from '@actions/attest' const ATTESTATION_FILE_NAME = 'attestation.json' const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt' +export type SBOMInputs = { + sbomPath: string +} + export type RunInputs = SubjectInputs & - PredicateInputs & { + PredicateInputs & + SBOMInputs & { pushToRegistry: boolean createStorageRecord: boolean githubToken: string @@ -58,11 +71,24 @@ export async function run(inputs: RunInputs): Promise { ) } + // 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({ ...inputs, 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) core.setOutput('bundle-path', outputPath) @@ -207,3 +233,30 @@ const tempDir = (): string => { const attestationURL = (id: string): string => `${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 = { + 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 => { + switch (type) { + case 'provenance': + return generateProvenancePredicate() + case 'sbom': { + const sbom = await parseSBOMFromPath(inputs.sbomPath) + return generateSBOMPredicate(sbom) + } + case 'custom': + return predicateFromInputs(inputs) + } +} diff --git a/src/provenance.ts b/src/provenance.ts new file mode 100644 index 0000000..fb40e03 --- /dev/null +++ b/src/provenance.ts @@ -0,0 +1,7 @@ +import { buildSLSAProvenancePredicate } from '@actions/attest' + +import type { Predicate } from '@actions/attest' + +export const generateProvenancePredicate = async (): Promise => { + return buildSLSAProvenancePredicate() +} diff --git a/src/sbom.ts b/src/sbom.ts new file mode 100644 index 0000000..41d604e --- /dev/null +++ b/src/sbom.ts @@ -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 => { + 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 + } +} diff --git a/src/subject.ts b/src/subject.ts index b6a7a57..bb380a5 100644 --- a/src/subject.ts +++ b/src/subject.ts @@ -98,7 +98,7 @@ const getSubjectFromPath = async ( if (files.length > MAX_SUBJECT_COUNT) { 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}`) } - subjects.push({ - name, - digest: { [digestAlgorithm(digest)]: digest } - }) + const alg = digestAlgorithm(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