Compare commits

...

28 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 0554dfbc8c Build package with updated changes
Co-authored-by: bdehamer <398027+bdehamer@users.noreply.github.com>
2026-02-17 16:02:50 +00:00
copilot-swe-agent[bot] 4734ea3b9b Fix parseSBOMFromPath to check file size before reading
Co-authored-by: bdehamer <398027+bdehamer@users.noreply.github.com>
2026-02-17 16:01:11 +00:00
copilot-swe-agent[bot] d2ffba3269 Initial plan 2026-02-17 15:58:33 +00:00
Brian DeHamer 4578f5b3b1 remove stray istanbul ignore
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-17 07:55:08 -08:00
Brian DeHamer 628abdee2b use experimental flag for jest in ci
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:07 -08:00
Brian DeHamer 36d44dca36 rebuild package-lock.json
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:07 -08:00
Brian DeHamer 9fd6a44d89 update @actions/attest
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:07 -08:00
Brian DeHamer a9a0a2c826 update @actions/github
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:07 -08:00
Brian DeHamer 0415dcc0d0 async all file functions
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:06 -08:00
Brian DeHamer 02c1da5b1d glob updated
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:06 -08:00
Brian DeHamer 8cd8ec4ddf debug mock
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:06 -08:00
Brian DeHamer 2a9eeeb3e1 lint issues
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:08:06 -08:00
Brian DeHamer e1605dcab6 esm'ify jest tests
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:07:58 -08:00
Brian DeHamer 9645b20a6a initial esm conversion
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2026-02-13 16:07:30 -08:00
Brian DeHamer dc4ad3cc6c 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>
2026-02-13 11:23:24 -08:00
dependabot[bot] a82737a684 Bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 (#342)
* Bump @isaacs/brace-expansion from 5.0.0 to 5.0.1

Bumps @isaacs/brace-expansion from 5.0.0 to 5.0.1.

---
updated-dependencies:
- dependency-name: "@isaacs/brace-expansion"
  dependency-version: 5.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate package-lock

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Meredith Lancaster <malancas@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Meredith Lancaster <malancas@github.com>
Co-authored-by: Meredith Lancaster <malancas@users.noreply.github.com>
2026-02-05 10:03:29 -08:00
dependabot[bot] 9a85e4f48a Bump the npm-development group with 2 updates (#338)
Bumps the npm-development group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [undici](https://github.com/nodejs/undici).


Updates `@types/node` from 25.1.0 to 25.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `undici` from 7.19.2 to 7.20.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.19.2...v7.20.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: undici
  dependency-version: 7.20.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 14:50:42 -08:00
dependabot[bot] 615da641f0 Bump tar from 7.4.3 to 7.5.7 (#337)
* Bump tar from 7.4.3 to 7.5.7

Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.7.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.7)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Rebuild dist after dependency updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-29 15:03:36 -08:00
dependabot[bot] 411f73e40b Bump @actions/attest from 2.1.0 to 2.2.0 (#325)
* Bump @actions/attest from 2.1.0 to 2.2.0

Bumps [@actions/attest](https://github.com/actions/toolkit/tree/HEAD/packages/attest) from 2.1.0 to 2.2.0.
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/attest/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/attest)

---
updated-dependencies:
- dependency-name: "@actions/attest"
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: update dist/ after build

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-29 15:01:54 -08:00
dependabot[bot] 95674aef8a Bump @actions/github from 6.0.1 to 7.0.0 (#324)
Bumps [@actions/github](https://github.com/actions/toolkit/tree/HEAD/packages/github) from 6.0.1 to 7.0.0.
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/github/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/github)

---
updated-dependencies:
- dependency-name: "@actions/github"
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-28 15:29:26 -08:00
dependabot[bot] 775709ffff Bump the npm-development group across 1 directory with 5 updates (#336)
* Bump the npm-development group across 1 directory with 5 updates

Bumps the npm-development group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.0.3` | `25.0.10` |
| [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) | `29.9.0` | `29.12.1` |
| [prettier](https://github.com/prettier/prettier) | `3.7.4` | `3.8.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.50.1` | `8.54.0` |
| [undici](https://github.com/nodejs/undici) | `7.18.2` | `7.19.1` |



Updates `@types/node` from 25.0.3 to 25.0.10
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-plugin-jest` from 29.9.0 to 29.12.1
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v29.9.0...v29.12.1)

Updates `prettier` from 3.7.4 to 3.8.1
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.1)

Updates `typescript-eslint` from 8.50.1 to 8.54.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.54.0/packages/typescript-eslint)

Updates `undici` from 7.18.2 to 7.19.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.18.2...v7.19.1)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-development
- dependency-name: eslint-plugin-jest
  dependency-version: 29.12.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: prettier
  dependency-version: 3.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: typescript-eslint
  dependency-version: 8.54.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: undici
  dependency-version: 7.19.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: update dist/ after build

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-27 18:41:15 -08:00
dependabot[bot] 6d9cc6edb5 Bump tar from 7.4.3 to 7.5.6 (#333)
* Bump tar from 7.4.3 to 7.5.6

Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.6.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.6)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: update dist/ after build

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-27 18:40:58 -08:00
dependabot[bot] 792c62d14a Bump @actions/core from 2.0.1 to 2.0.2 in the npm-production group (#323)
Bumps the npm-production group with 1 update: [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core).


Updates `@actions/core` from 2.0.1 to 2.0.2
- [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md)
- [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core)

---
updated-dependencies:
- dependency-name: "@actions/core"
  dependency-version: 2.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tingting Wang <tingx2wang@github.com>
2026-01-27 18:40:13 -08:00
dependabot[bot] 65786c7512 Bump the actions-minor group across 1 directory with 2 updates (#335)
Bumps the actions-minor group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/checkout` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6.0.1...v6.0.2)

Updates `actions/setup-node` from 6.1.0 to 6.2.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6.1.0...v6.2.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor
- dependency-name: actions/setup-node
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 15:27:59 -08:00
Meredith Lancaster e59cbc1ad1 Update version to 3.2.0 (#334)
* update version to 3.2.0

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate package-lock

Signed-off-by: Meredith Lancaster <malancas@github.com>

---------

Signed-off-by: Meredith Lancaster <malancas@github.com>
2026-01-26 08:59:13 -08:00
Meredith Lancaster 20eb46ce7a Validate repository org-ownership before storage record creation (#328)
* check if the repository is owned by org before attempting storage record creation

Signed-off-by: Meredith Lancaster <malancas@github.com>

* linter

Signed-off-by: Meredith Lancaster <malancas@github.com>

* generate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add fixtures for repoOwnerIsOrg function

Signed-off-by: Meredith Lancaster <malancas@github.com>

* formatter

Signed-off-by: Meredith Lancaster <malancas@github.com>

* clean up fixtures

Signed-off-by: Meredith Lancaster <malancas@github.com>

* more clean up

Signed-off-by: Meredith Lancaster <malancas@github.com>

* fix function declaration

Signed-off-by: Meredith Lancaster <malancas@github.com>

* clean up fixtures

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add test when repo is not owned by org

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add more expect statements, clean up mock calls

Signed-off-by: Meredith Lancaster <malancas@github.com>

* formatter

Signed-off-by: Meredith Lancaster <malancas@github.com>

* add more spy expect statements

Signed-off-by: Meredith Lancaster <malancas@github.com>

---------

Signed-off-by: Meredith Lancaster <malancas@github.com>
2026-01-26 08:31:21 -08:00
Meredith Lancaster 7433fa7e7a Update undici development dependency to the latest version (#332)
* update undici dep to the latest version

Signed-off-by: Meredith Lancaster <malancas@github.com>

* regenerate dist

Signed-off-by: Meredith Lancaster <malancas@github.com>

* update to v7.18.2

Signed-off-by: Meredith Lancaster <malancas@github.com>

---------

Signed-off-by: Meredith Lancaster <malancas@github.com>
2026-01-20 16:21:19 -08:00
dependabot[bot] c03bf4160d Bump the npm-development group with 3 updates (#320)
Bumps the npm-development group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@types/node` from 25.0.2 to 25.0.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-plugin-jest` from 29.5.0 to 29.9.0
- [Release notes](https://github.com/jest-community/eslint-plugin-jest/releases)
- [Changelog](https://github.com/jest-community/eslint-plugin-jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jest-community/eslint-plugin-jest/compare/v29.5.0...v29.9.0)

Updates `typescript-eslint` from 8.50.0 to 8.50.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-development
- dependency-name: eslint-plugin-jest
  dependency-version: 29.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-development
- dependency-name: typescript-eslint
  dependency-version: 8.50.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 05:03:48 -08:00
28 changed files with 82462 additions and 68809 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v6.0.1
uses: actions/checkout@v6.0.2
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.2.0
with:
node-version-file: .node-version
cache: npm
+3 -3
View File
@@ -21,11 +21,11 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.1
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v5.0.1
- name: Setup Node.js
id: setup-node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: .node-version
cache: npm
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.1
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v5.0.1
- name: Calculate subject digest
id: subject
env:
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v6.0.1
uses: actions/checkout@v6.0.2
- name: Initialize CodeQL
id: initialize
+68 -27
View File
@@ -41,6 +41,21 @@ information on artifact attestations.
> Artifact attestations are NOT supported on GitHub Enterprise Server.
<!-- 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
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: '<PATH TO ARTIFACT>'
predicate-type: '<PREDICATE URI>'
predicate-path: '<PATH TO PREDICATE>'
```
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: '{}'
```
<!-- markdownlint-disable MD038 -->
@@ -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
+195
View File
@@ -0,0 +1,195 @@
import { jest } from '@jest/globals'
import type { Descriptor } from '@sigstore/oci'
// Mock functions
const mockGetOctokit = jest.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockAttest = jest.fn<() => Promise<any>>()
const mockCreateStorageRecord = jest.fn<() => Promise<number[]>>()
const mockGetRegistryCredentials = jest.fn()
const mockAttachArtifactToImage = jest.fn<() => Promise<Descriptor>>()
// Mock @actions/github
jest.unstable_mockModule('@actions/github', () => ({
getOctokit: mockGetOctokit,
context: {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: mockAttest,
createStorageRecord: mockCreateStorageRecord
}))
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: mockGetRegistryCredentials,
attachArtifactToImage: mockAttachArtifactToImage
}))
// Dynamic imports after mocking
const { createAttestation, repoOwnerIsOrg } = await import('../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', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('returns true when repo owner is an organization', async () => {
mockGetOctokit.mockReturnValue({
rest: {
repos: {
get: jest
.fn<() => Promise<{ data: { owner: { type: string } } }>>()
.mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
}
}
})
const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(true)
})
it('returns false when repo owner is a user', async () => {
mockGetOctokit.mockReturnValue({
rest: {
repos: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: jest.fn<() => Promise<any>>().mockResolvedValue({
data: { owner: { type: 'User' } }
})
}
}
})
const result = await repoOwnerIsOrg('gh-token')
expect(result).toBe(false)
})
})
describe('createAttestation', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default mock implementations
mockAttest.mockResolvedValue({
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
certificate: 'cert',
tlogID: 'tlog-123',
attestationID: 'att-123'
})
mockGetRegistryCredentials.mockReturnValue({
username: 'user',
password: 'pass'
})
mockAttachArtifactToImage.mockResolvedValue({
digest: 'sha256:abc123',
mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json',
size: 100
})
})
describe('when createStorageRecord is false', () => {
it('skips storage record creation', async () => {
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(mockCreateStorageRecord).not.toHaveBeenCalled()
})
})
describe('when storage records are empty', () => {
beforeEach(() => {
mockGetOctokit.mockReturnValue({
rest: {
repos: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: jest.fn<() => Promise<any>>().mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
}
}
})
mockCreateStorageRecord.mockResolvedValue([])
})
it('handles empty storage records gracefully', async () => {
const subjects = [
{
name: subjectName,
digest: { sha256: subjectDigest.replace('sha256:', '') }
}
]
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(() => {
mockGetOctokit.mockReturnValue({
rest: {
repos: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: jest.fn<() => Promise<any>>().mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
}
}
})
mockCreateStorageRecord.mockResolvedValue([123])
})
it('handles unsupported protocol gracefully', async () => {
const subjects = [
{
name: 'http://registry.example.com/foo/bar',
digest: { sha256: subjectDigest.replace('sha256:', '') }
}
]
const result = await createAttestation(subjects, predicate, {
sigstoreInstance: 'github',
pushToRegistry: true,
createStorageRecord: true,
githubToken: 'gh-token'
})
expect(result.attestationDigest).toBe('sha256:abc123')
})
})
})
+190
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()
})
})
})
+23 -10
View File
@@ -1,22 +1,35 @@
/**
* Unit tests for the action's entrypoint, src/index.ts
*/
import { jest } from '@jest/globals'
import * as core from '@actions/core'
import * as main from '../src/main'
// Mock functions
const mockRun = jest.fn()
const mockGetInput = jest.fn()
const mockGetBooleanInput = jest.fn()
// Mock the action's entrypoint
const runMock = jest.spyOn(main, 'run').mockImplementation()
const getBooleanInputMock = jest.spyOn(core, 'getBooleanInput')
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
getInput: mockGetInput,
getBooleanInput: mockGetBooleanInput
}))
// Mock ../src/main
jest.unstable_mockModule('../src/main', () => ({
run: mockRun
}))
describe('index', () => {
beforeEach(() => {
getBooleanInputMock.mockImplementation(() => false)
jest.clearAllMocks()
mockGetBooleanInput.mockReturnValue(false)
mockGetInput.mockReturnValue('')
})
it('calls run when imported', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../src/index')
expect(runMock).toHaveBeenCalled()
it('calls run when imported', async () => {
// Dynamic import after mocking
await import('../src/index')
expect(mockRun).toHaveBeenCalled()
})
})
+377 -168
View File
@@ -5,43 +5,122 @@
* Specifically, the inputs listed in `action.yml` should be set as environment
* variables following the pattern `INPUT_<INPUT_NAME>`.
*/
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 fs from 'fs/promises'
import nock from 'nock'
import os from 'os'
import path from 'path'
import { MockAgent, setGlobalDispatcher } from 'undici'
import { SEARCH_PUBLIC_GOOD_URL } from '../src/endpoints'
import * as main from '../src/main'
import type { Predicate } from '@actions/attest'
import { jest } from '@jest/globals'
import type { RunInputs } from '../src/main'
// Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info')
const warningMock = jest.spyOn(core, 'warning')
const startGroupMock = jest.spyOn(core, 'startGroup')
const setOutputMock = jest.spyOn(core, 'setOutput')
const setFailedMock = jest.spyOn(core, 'setFailed')
// Create mock functions before mocking modules
const infoMock = jest.fn()
const warningMock = jest.fn()
const startGroupMock = jest.fn()
const endGroupMock = jest.fn()
const setOutputMock = jest.fn()
const setFailedMock = jest.fn()
const debugMock = jest.fn()
// Ensure that setFailed doesn't set an exit code during tests
setFailedMock.mockImplementation(() => {})
// OCI mocks
const getRegCredsMock = jest.fn()
const attachArtifactMock = jest.fn()
const summaryWriteMock = jest.spyOn(core.summary, 'write')
summaryWriteMock.mockImplementation(async () => Promise.resolve(core.summary))
// Attest mocks
const attestMock = jest.fn()
const createStorageRecordMock = jest.fn()
// Mock the action's main function
const runMock = jest.spyOn(main, 'run')
// Local attest mocks
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAttestationMock = jest.fn<() => Promise<any>>()
const repoOwnerIsOrgMock = jest.fn()
// Provenance mock
const generateProvenancePredicateMock = jest.fn<() => Promise<Predicate>>()
// GitHub context mock
const mockContext = {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
const mockGetOctokit = jest.fn()
// Summary mock with chainable methods
const summaryMock = {
write: jest.fn().mockReturnThis(),
addRaw: jest.fn().mockReturnThis(),
addHeading: jest.fn().mockReturnThis(),
addLink: jest.fn().mockReturnThis(),
addTable: jest.fn().mockReturnThis(),
addBreak: jest.fn().mockReturnThis(),
addSeparator: jest.fn().mockReturnThis(),
addQuote: jest.fn().mockReturnThis(),
addCodeBlock: jest.fn().mockReturnThis(),
addList: jest.fn().mockReturnThis(),
addImage: jest.fn().mockReturnThis(),
addDetails: jest.fn().mockReturnThis(),
addEOL: jest.fn().mockReturnThis(),
emptyBuffer: jest.fn().mockReturnThis(),
stringify: jest.fn().mockReturnValue(''),
isEmptyBuffer: jest.fn().mockReturnValue(true),
clear: jest.fn().mockReturnThis()
}
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
info: infoMock,
warning: warningMock,
startGroup: startGroupMock,
endGroup: endGroupMock,
setOutput: setOutputMock,
setFailed: setFailedMock,
debug: debugMock,
summary: summaryMock
}))
// Mock @actions/github
jest.unstable_mockModule('@actions/github', () => ({
context: mockContext,
getOctokit: mockGetOctokit
}))
// Mock @sigstore/oci
jest.unstable_mockModule('@sigstore/oci', () => ({
getRegistryCredentials: getRegCredsMock,
attachArtifactToImage: attachArtifactMock
}))
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
attest: attestMock,
createStorageRecord: createStorageRecordMock
}))
// Mock ../src/attest
jest.unstable_mockModule('../src/attest', () => ({
createAttestation: createAttestationMock,
repoOwnerIsOrg: repoOwnerIsOrgMock
}))
// Mock ../src/provenance
jest.unstable_mockModule('../src/provenance', () => ({
generateProvenancePredicate: generateProvenancePredicateMock
}))
// Dynamic imports after mocking
const { mockFulcio, mockRekor, mockTSA } = await import('@sigstore/mock')
const fs = (await import('fs/promises')).default
const nock = (await import('nock')).default
const os = (await import('os')).default
const path = (await import('path')).default
const { MockAgent, setGlobalDispatcher } = await import('undici')
const { run } = await import('../src/main')
// MockAgent for mocking @actions/github
const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)
const defaultInputs: main.RunInputs = {
const defaultInputs: RunInputs = {
predicate: '',
predicateType: '',
predicatePath: '',
sbomPath: '',
subjectName: '',
subjectDigest: '',
subjectPath: '',
@@ -54,10 +133,12 @@ const defaultInputs: main.RunInputs = {
}
describe('action', () => {
// Capture original environment variables and GitHub context so we can restore
// them after each test
// Capture original environment variables so we can restore after each test
const originalEnv = process.env
const originalContext = { ...github.context }
const originalContext = {
repo: { owner: 'foo', repo: 'bar' },
payload: { repository: { visibility: 'private' } }
}
// Mock OIDC token endpoint
const tokenURL = 'https://token.url'
@@ -118,7 +199,7 @@ describe('action', () => {
})
describe('when ACTIONS_ID_TOKEN_REQUEST_URL is not set', () => {
const inputs: main.RunInputs = {
const inputs: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -133,9 +214,8 @@ describe('action', () => {
})
it('sets a failed status', async () => {
await main.run(inputs)
await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
@@ -146,9 +226,8 @@ describe('action', () => {
describe('when no inputs are provided', () => {
it('sets a failed status', async () => {
await main.run(defaultInputs)
await run(defaultInputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'One of subject-path, subject-digest, or subject-checksums must be provided'
@@ -158,7 +237,7 @@ describe('action', () => {
})
describe('when the repository is private', () => {
const inputs: main.RunInputs = {
const inputs: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -174,6 +253,16 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
// Mock createAttestation to return expected values
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
await mockFulcio({
baseURL: 'https://fulcio.githubapp.com',
strict: false
@@ -182,56 +271,21 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
await main.run(inputs)
await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalledWith()
expect(infoMock).toHaveBeenNthCalledWith(
1,
expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
expect(startGroupMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('GitHub Sigstore')
)
expect(infoMock).toHaveBeenNthCalledWith(
2,
expect.stringMatching('-----BEGIN CERTIFICATE-----')
)
expect(infoMock).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/attestation uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
4,
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
)
expect(setFailedMock).not.toHaveBeenCalled()
expect(createAttestationMock).toHaveBeenCalled()
})
})
describe('when the repository is public', () => {
const getRegCredsSpy = jest.spyOn(oci, 'getRegistryCredentials')
const attachArtifactSpy = jest.spyOn(oci, 'attachArtifactToImage')
const inputs: main.RunInputs = {
const inputs: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
@@ -248,108 +302,86 @@ describe('action', () => {
repo: { owner: 'foo', repo: 'bar' }
})
// Setup createAttestation mock
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' },
storageRecordIds: [storageRecordID]
})
await mockFulcio({
baseURL: 'https://fulcio.sigstore.dev',
strict: false
})
await mockRekor({ baseURL: 'https://rekor.sigstore.dev' })
getRegCredsSpy.mockImplementation(() => ({
username: 'username',
password: 'password'
}))
attachArtifactSpy.mockImplementation(async () =>
Promise.resolve({
digest: 'sha256:123456',
mediaType: 'application/vnd.cncf.notary.v2',
size: 123456
})
)
mockGetOctokit.mockReturnValue({
rest: {
repos: {
get: jest
.fn<() => Promise<{ data: { owner: { type: string } } }>>()
.mockResolvedValue({
data: { owner: { type: 'Organization' } }
})
}
}
})
})
it('invokes the action w/o error', async () => {
await main.run(inputs)
await run(inputs)
expect(runMock).toHaveReturned()
expect(setFailedMock).not.toHaveBeenCalled()
expect(getRegCredsSpy).toHaveBeenCalledWith(subjectName)
expect(attachArtifactSpy).toHaveBeenCalled()
expect(warningMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenNthCalledWith(
1,
expect(createAttestationMock).toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
expect(startGroupMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Public Good Sigstore')
)
expect(infoMock).toHaveBeenNthCalledWith(
2,
expect.stringMatching('-----BEGIN CERTIFICATE-----')
)
expect(infoMock).toHaveBeenNthCalledWith(
3,
expect.stringMatching(/signature uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
4,
expect.stringMatching(SEARCH_PUBLIC_GOOD_URL)
)
expect(infoMock).toHaveBeenNthCalledWith(
5,
expect.stringMatching(/attestation uploaded/i)
)
expect(infoMock).toHaveBeenNthCalledWith(
6,
expect.stringMatching(attestationID)
)
expect(infoMock).toHaveBeenNthCalledWith(
9,
expect.stringMatching('Storage record created')
)
expect(infoMock).toHaveBeenNthCalledWith(
10,
expect.stringMatching('Storage record IDs: 987654321')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
1,
'bundle-path',
expect.stringMatching('attestation.json')
)
expect(setOutputMock).toHaveBeenNthCalledWith(
2,
'attestation-id',
expect.stringMatching(attestationID)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
3,
'attestation-url',
expect.stringContaining(`foo/bar/attestations/${attestationID}`)
)
expect(setOutputMock).toHaveBeenNthCalledWith(
4,
'storage-record-ids',
expect.stringMatching(storageRecordID.toString())
)
expect(setFailedMock).not.toHaveBeenCalled()
})
it('catches error when storage record creation fails and continues', async () => {
// Mock the createStorageRecord function and throw an error
const createStorageRecordSpy = jest.spyOn(attest, 'createStorageRecord')
createStorageRecordSpy.mockRejectedValueOnce(
new Error('Failed to persist storage record: Not Found')
)
// Mock createAttestation to simulate storage record failure (but still succeed overall)
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
// No storageRecordIDs - simulates empty/failed storage record
})
await main.run(inputs)
await run(inputs)
expect(runMock).toHaveReturned()
expect(createAttestationMock).toHaveBeenCalled()
expect(setFailedMock).not.toHaveBeenCalled()
expect(warningMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching('Failed to create storage record')
})
it('does not create a storage record when the repo is owned by a user', async () => {
// Mock createAttestation to not return storage record IDs
createAttestationMock.mockResolvedValue({
attestationID,
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(createAttestationMock).toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith(
expect.stringMatching(
`Attestation created for ${subjectName}@${subjectDigest}`
)
)
})
})
@@ -393,19 +425,19 @@ describe('action', () => {
})
it('invokes the action w/o error', async () => {
const inputs: main.RunInputs = {
const inputs: RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
}
await main.run(inputs)
await run(inputs)
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')
)
})
@@ -441,27 +473,204 @@ describe('action', () => {
})
it('sets a failed status', async () => {
const inputs: main.RunInputs = {
const inputs: RunInputs = {
...defaultInputs,
subjectPath: path.join(dir, `${filename}-*`),
predicateType,
predicate,
githubToken: 'gh-token'
}
await main.run(inputs)
await run(inputs)
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: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
sbomPath: '/path/to/sbom.json',
predicateType: 'https://example.com/predicate',
githubToken: 'gh-token'
}
await run(inputs)
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: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
predicate: '{}',
githubToken: 'gh-token'
}
await run(inputs)
expect(setFailedMock).toHaveBeenCalledWith(
new Error(
'predicate-type is required when using predicate or predicate-path'
)
)
})
})
describe('when custom attestation inputs are provided', () => {
const inputs: 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 run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: Custom')
})
})
describe('when provenance attestation is detected', () => {
const inputs: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
githubToken: 'gh-token'
}
const mockProvPredicate = {
type: 'https://slsa.dev/provenance/v1',
params: { buildDefinition: {}, runDetails: {} }
}
beforeEach(async () => {
// Configure mock for provenance predicate
generateProvenancePredicateMock.mockResolvedValue(mockProvPredicate)
// Configure mock for createAttestation
createAttestationMock.mockResolvedValue({
attestationID: '1234567890',
certificate:
'-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
tlogID: 'tlog-123',
attestationDigest: 'sha256:123456',
bundle: { mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json' }
})
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 run(inputs)
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: RunInputs = {
...defaultInputs,
subjectDigest,
subjectName,
sbomPath: sbomFilePath,
githubToken: 'gh-token'
}
await run(inputs)
expect(setFailedMock).not.toHaveBeenCalled()
expect(infoMock).toHaveBeenCalledWith('Attestation type: SBOM')
})
})
})
})
// Stubbing the GitHub context is a bit tricky. We need to use
// `Object.defineProperty` because `github.context` is read-only.
function setGHContext(context: object): void {
Object.defineProperty(github, 'context', { value: context })
// Helper to update the mock context
function setGHContext(context: {
repo?: { owner: string; repo: string }
payload?: { repository?: { visibility: string } }
}): void {
if (context.repo) {
mockContext.repo = context.repo
}
if (context.payload) {
mockContext.payload = context.payload as typeof mockContext.payload
}
}
+18 -14
View File
@@ -11,33 +11,35 @@ describe('subjectFromInputs', () => {
}
describe('when no inputs are provided', () => {
it('throws an error', () => {
expect(() => predicateFromInputs(blankInputs)).toThrow(/predicate-type/i)
it('throws an error', async () => {
await expect(predicateFromInputs(blankInputs)).rejects.toThrow(
/predicate-type/i
)
})
})
describe('when neither predicate path nor predicate are provided', () => {
it('throws an error', () => {
it('throws an error', async () => {
const inputs: PredicateInputs = {
...blankInputs,
predicateType: 'https://example.com/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
await expect(predicateFromInputs(inputs)).rejects.toThrow(
/one of predicate-path or predicate must be provided/i
)
})
})
describe('when both predicate path and predicate are provided', () => {
it('throws an error', () => {
it('throws an error', async () => {
const inputs: PredicateInputs = {
predicateType: 'https://example.com/predicate',
predicate: '{}',
predicatePath: 'path/to/predicate'
}
expect(() => predicateFromInputs(inputs)).toThrow(
await expect(predicateFromInputs(inputs)).rejects.toThrow(
/only one of predicate-path or predicate may be provided/i
)
})
@@ -65,13 +67,13 @@ describe('subjectFromInputs', () => {
await fs.rm(path.parse(predicatePath).dir, { recursive: true })
})
it('returns the predicate', () => {
it('returns the predicate', async () => {
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicatePath
}
expect(predicateFromInputs(inputs)).toEqual({
await expect(predicateFromInputs(inputs)).resolves.toEqual({
type: predicateType,
params: JSON.parse(content)
})
@@ -82,13 +84,15 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate'
const predicatePath = 'foo'
it('returns the predicate', () => {
it('returns the predicate', async () => {
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicatePath
}
expect(() => predicateFromInputs(inputs)).toThrow(/file not found/)
await expect(predicateFromInputs(inputs)).rejects.toThrow(
/file not found/
)
})
})
@@ -96,14 +100,14 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate'
const content = '{}'
it('returns the predicate', () => {
it('returns the predicate', async () => {
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicate: content
}
expect(predicateFromInputs(inputs)).toEqual({
await expect(predicateFromInputs(inputs)).resolves.toEqual({
type: predicateType,
params: JSON.parse(content)
})
@@ -114,14 +118,14 @@ describe('subjectFromInputs', () => {
const predicateType = 'https://example.com/predicate'
const content = JSON.stringify({ a: 'a'.repeat(16 * 1024 * 1024) })
it('throws an error', () => {
it('throws an error', async () => {
const inputs: PredicateInputs = {
...blankInputs,
predicateType,
predicate: content
}
expect(() => predicateFromInputs(inputs)).toThrow(
await expect(predicateFromInputs(inputs)).rejects.toThrow(
/predicate string exceeds maximum/
)
})
+48
View File
@@ -0,0 +1,48 @@
import type { Predicate } from '@actions/attest'
import { jest } from '@jest/globals'
// Mock function
const mockBuildSLSAProvenancePredicate = jest.fn<() => Promise<Predicate>>()
// Mock @actions/attest
jest.unstable_mockModule('@actions/attest', () => ({
buildSLSAProvenancePredicate: mockBuildSLSAProvenancePredicate
}))
// Dynamic import after mocking
const { generateProvenancePredicate } = await import('../src/provenance')
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()
mockBuildSLSAProvenancePredicate.mockResolvedValue(mockPredicate)
})
it('returns the SLSA provenance predicate', async () => {
const result = await generateProvenancePredicate()
expect(mockBuildSLSAProvenancePredicate).toHaveBeenCalledTimes(1)
expect(result).toEqual(mockPredicate)
})
it('propagates errors from buildSLSAProvenancePredicate', async () => {
const error = new Error('Failed to build provenance')
mockBuildSLSAProvenancePredicate.mockRejectedValue(error)
await expect(generateProvenancePredicate()).rejects.toThrow(
'Failed to build provenance'
)
})
})
+161
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/
)
})
})
})
+33
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', () => {
const checksums = `f861e demo_0.0.1_linux_386`
+12 -4
View File
@@ -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: >
Generated Vendored
+26 -14
View File
@@ -1,7 +1,6 @@
"use strict";
exports.id = 606;
exports.ids = [606];
exports.modules = {
export const id = 606;
export const ids = [606];
export const modules = {
/***/ 606:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
@@ -19,7 +18,7 @@ async function pMap(
signal,
} = {},
) {
return new Promise((resolve, reject_) => {
return new Promise((resolve_, reject_) => {
if (iterable[Symbol.iterator] === undefined && iterable[Symbol.asyncIterator] === undefined) {
throw new TypeError(`Expected \`input\` to be either an \`Iterable\` or \`AsyncIterable\`, got (${typeof iterable})`);
}
@@ -42,10 +41,24 @@ async function pMap(
let currentIndex = 0;
const iterator = iterable[Symbol.iterator] === undefined ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator]();
const signalListener = () => {
reject(signal.reason);
};
const cleanup = () => {
signal?.removeEventListener('abort', signalListener);
};
const resolve = value => {
resolve_(value);
cleanup();
};
const reject = reason => {
isRejected = true;
isResolved = true;
reject_(reason);
cleanup();
};
if (signal) {
@@ -53,9 +66,7 @@ async function pMap(
reject(signal.reason);
}
signal.addEventListener('abort', () => {
reject(signal.reason);
});
signal.addEventListener('abort', signalListener, {once: true});
}
const next = async () => {
@@ -203,31 +214,32 @@ function pMapIterable(
const iterator = iterable[Symbol.asyncIterator] === undefined ? iterable[Symbol.iterator]() : iterable[Symbol.asyncIterator]();
const promises = [];
let runningMappersCount = 0;
let pendingPromisesCount = 0;
let isDone = false;
let index = 0;
function trySpawn() {
if (isDone || !(runningMappersCount < concurrency && promises.length < backpressure)) {
if (isDone || !(pendingPromisesCount < concurrency && promises.length < backpressure)) {
return;
}
pendingPromisesCount++;
const promise = (async () => {
const {done, value} = await iterator.next();
if (done) {
pendingPromisesCount--;
return {done: true};
}
runningMappersCount++;
// Spawn if still below concurrency and backpressure limit
trySpawn();
try {
const returnValue = await mapper(await value, index++);
runningMappersCount--;
pendingPromisesCount--;
if (returnValue === pMapSkip) {
const index = promises.indexOf(promise);
@@ -242,6 +254,7 @@ function pMapIterable(
return {done: false, value: returnValue};
} catch (error) {
pendingPromisesCount--;
isDone = true;
return {error};
}
@@ -284,4 +297,3 @@ const pMapSkip = Symbol('skip');
/***/ })
};
;
Generated Vendored
+78928 -60690
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+2
View File
@@ -1 +1,3 @@
import { jest } from '@jest/globals'
process.stdout.write = jest.fn()
+2072 -7829
View File
File diff suppressed because it is too large Load Diff
+24 -14
View File
@@ -1,9 +1,10 @@
{
"name": "actions/attest",
"description": "Generate signed attestations for workflow artifacts",
"version": "3.1.0",
"version": "3.2.0",
"author": "",
"private": true,
"type": "module",
"homepage": "https://github.com/actions/attest",
"repository": {
"type": "git",
@@ -24,7 +25,7 @@
},
"scripts": {
"bundle": "npm run format:write && npm run package",
"ci-test": "jest",
"ci-test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"format:write": "prettier --write **/*.ts",
"format:check": "prettier --check **/*.ts",
"lint:eslint": "npx eslint",
@@ -32,12 +33,15 @@
"lint": "npm run lint:eslint && npm run lint:markdown",
"package": "ncc build src/index.ts --license licenses.txt",
"package:watch": "npm run package -- --watch",
"test": "jest",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"all": "npm run format:write && npm run lint && npm run test && npm run package"
},
"license": "MIT",
"jest": {
"preset": "ts-jest",
"preset": "ts-jest/presets/default-esm",
"extensionsToTreatAsEsm": [
".ts"
],
"setupFilesAfterEnv": [
"./jest.setup.js"
],
@@ -56,7 +60,12 @@
"/dist/"
],
"transform": {
"^.+\\.ts$": "ts-jest"
"^.+\\.ts$": [
"ts-jest",
{
"useESM": true
}
]
},
"coverageReporters": [
"json-summary",
@@ -69,31 +78,32 @@
]
},
"dependencies": {
"@actions/attest": "^2.1.0",
"@actions/core": "^2.0.1",
"@actions/github": "^6.0.1",
"@actions/glob": "^0.5.0",
"@actions/attest": "^3.0.0",
"@actions/core": "^3.0.0",
"@actions/github": "^9.0.0",
"@actions/glob": "^0.6.1",
"@sigstore/oci": "^0.6.0",
"csv-parse": "^5.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@jest/globals": "^30.2.0",
"@sigstore/mock": "^0.11.0",
"@types/jest": "^30.0.0",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^25.0.2",
"@types/node": "^25.2.0",
"@vercel/ncc": "^0.38.4",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.5.0",
"eslint-plugin-jest": "^29.12.1",
"jest": "^30.2.0",
"js-yaml": "^4.1.1",
"markdownlint-cli": "^0.47.0",
"nock": "^13.5.6",
"prettier": "^3.7.4",
"prettier": "^3.8.1",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0",
"undici": "^5.29.0"
"typescript-eslint": "^8.54.0",
"undici": "^7.20.0"
}
}
+24 -2
View File
@@ -5,9 +5,10 @@ import {
attest,
createStorageRecord
} from '@actions/attest'
import * as core from '@actions/core'
import * as github from '@actions/github'
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
import { formatSubjectDigest } from './subject'
import * as core from '@actions/core'
const OCI_TIMEOUT = 30000
const OCI_RETRY = 3
@@ -64,6 +65,15 @@ export const createAttestation = async (
// attestation process if the token does not have the correct permissions.
if (opts.createStorageRecord) {
try {
const token = opts.githubToken
const isOrg = await repoOwnerIsOrg(token)
if (!isOrg) {
// The Artifact Metadata Storage Record API is only available to
// organizations. So if the repo owner is not an organization,
// storage record creation should not be attempted.
return result
}
const registryUrl = getRegistryURL(subject.name)
const artifactOpts = {
name: subject.name,
@@ -75,7 +85,7 @@ export const createAttestation = async (
const records = await createStorageRecord(
artifactOpts,
packageRegistryOpts,
opts.githubToken
token
)
if (!records || records.length === 0) {
@@ -95,6 +105,18 @@ export const createAttestation = async (
return result
}
// Call the GET /repos/{owner}/{repo} endpoint to determine if the repo
// owner is an organization. This is used to determine if storage
// record creation should be attempted.
export const repoOwnerIsOrg = async (githubToken: string): Promise<boolean> => {
const octokit = github.getOctokit(githubToken)
const { data: repo } = await octokit.rest.repos.get({
owner: github.context.repo.owner,
repo: github.context.repo.repo
})
return repo.owner?.type === 'Organization'
}
function getRegistryURL(subjectName: string): string {
let url: URL
+45
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'
)
}
}
+1
View File
@@ -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'),
+63 -9
View File
@@ -1,11 +1,19 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import fs from 'fs'
import fs from 'fs/promises'
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 { generateSBOMPredicate, parseSBOMFromPath } 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,13 +71,26 @@ 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({
...inputs,
downcaseName: inputs.pushToRegistry
})
const predicate = predicateFromInputs(inputs)
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
// Generate predicate based on attestation type
const predicate = await getPredicateForType(attestationType, inputs)
const outputPath = path.join(await tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath)
const att = await createAttestation(subjects, predicate, {
@@ -77,7 +103,7 @@ export async function run(inputs: RunInputs): Promise<void> {
logAttestation(subjects, att, sigstoreInstance)
// Write attestation bundle to output file
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
await fs.writeFile(outputPath, JSON.stringify(att.bundle) + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
@@ -87,7 +113,7 @@ export async function run(inputs: RunInputs): Promise<void> {
if (baseDir) {
const outputSummaryPath = path.join(baseDir, ATTESTATION_PATHS_FILE_NAME)
// Append the output path to the attestations paths file
fs.appendFileSync(outputSummaryPath, outputPath + os.EOL, {
await fs.appendFile(outputSummaryPath, outputPath + os.EOL, {
encoding: 'utf-8',
flag: 'a'
})
@@ -102,6 +128,7 @@ export async function run(inputs: RunInputs): Promise<void> {
core.setOutput('attestation-id', att.attestationID)
core.setOutput('attestation-url', attestationURL(att.attestationID))
}
if (att.storageRecordIds) {
core.setOutput('storage-record-ids', att.storageRecordIds.join(','))
}
@@ -194,7 +221,7 @@ const logSummary = async (attestation: AttestResult): Promise<void> => {
}
}
const tempDir = (): string => {
const tempDir = async (): Promise<string> => {
const basePath = process.env['RUNNER_TEMP']
/* istanbul ignore if */
@@ -202,8 +229,35 @@ const tempDir = (): string => {
throw new Error('Missing RUNNER_TEMP environment variable')
}
return fs.mkdtempSync(path.join(basePath, path.sep))
return fs.mkdtemp(path.join(basePath, path.sep))
}
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<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)
}
}
+11 -5
View File
@@ -1,4 +1,4 @@
import fs from 'fs'
import fs from 'fs/promises'
import type { Predicate } from '@actions/attest'
@@ -12,7 +12,9 @@ const MAX_PREDICATE_SIZE_BYTES = 16 * 1024 * 1024
// Returns the predicate specified by the action's inputs. The predicate value
// may be specified as a path to a file or as a string.
export const predicateFromInputs = (inputs: PredicateInputs): Predicate => {
export const predicateFromInputs = async (
inputs: PredicateInputs
): Promise<Predicate> => {
const { predicateType, predicate, predicatePath } = inputs
if (!predicateType) {
@@ -30,18 +32,22 @@ export const predicateFromInputs = (inputs: PredicateInputs): Predicate => {
let params: string = predicate
if (predicatePath) {
if (!fs.existsSync(predicatePath)) {
try {
await fs.access(predicatePath)
} catch {
throw new Error(`predicate file not found: ${predicatePath}`)
}
const stat = await fs.stat(predicatePath)
/* istanbul ignore next */
if (fs.statSync(predicatePath).size > MAX_PREDICATE_SIZE_BYTES) {
if (stat.size > MAX_PREDICATE_SIZE_BYTES) {
throw new Error(
`predicate file exceeds maximum allowed size: ${MAX_PREDICATE_SIZE_BYTES} bytes`
)
}
params = fs.readFileSync(predicatePath, 'utf-8')
params = await fs.readFile(predicatePath, 'utf-8')
} else {
if (predicate.length > MAX_PREDICATE_SIZE_BYTES) {
throw new Error(
+7
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()
}
+96
View File
@@ -0,0 +1,96 @@
import fs from 'fs/promises'
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> => {
let stats
try {
stats = await fs.stat(filePath)
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
throw new Error('SBOM file not found')
}
throw error
}
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.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
}
}
+27 -15
View File
@@ -2,7 +2,8 @@ import * as glob from '@actions/glob'
import assert from 'assert'
import crypto from 'crypto'
import { parse } from 'csv-parse/sync'
import fs from 'fs'
import { createReadStream } from 'fs'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'
@@ -64,7 +65,7 @@ export const subjectFromInputs = async (
case !!subjectDigest:
return [getSubjectFromDigest(subjectDigest, name)]
case !!subjectChecksums:
return getSubjectFromChecksums(subjectChecksums)
return await getSubjectFromChecksums(subjectChecksums)
/* istanbul ignore next */
default:
// This should be unreachable, but TS requires a default case
@@ -94,11 +95,12 @@ const getSubjectFromPath = async (
const paths = await glob.create(subjectPaths).then(async g => g.glob())
// Filter path list to just the files (not directories)
const files = paths.filter(p => fs.statSync(p).isFile())
const stats = await Promise.all(paths.map(async p => fs.stat(p)))
const files = paths.filter((_, i) => stats[i].isFile())
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}.`
)
}
@@ -142,16 +144,21 @@ const getSubjectFromDigest = (
}
}
const getSubjectFromChecksums = (subjectChecksums: string): Subject[] => {
if (fs.existsSync(subjectChecksums)) {
const getSubjectFromChecksums = async (
subjectChecksums: string
): Promise<Subject[]> => {
try {
await fs.access(subjectChecksums)
return getSubjectFromChecksumsFile(subjectChecksums)
} else {
} catch {
return getSubjectFromChecksumsString(subjectChecksums)
}
}
const getSubjectFromChecksumsFile = (checksumsPath: string): Subject[] => {
const stats = fs.statSync(checksumsPath)
const getSubjectFromChecksumsFile = async (
checksumsPath: string
): Promise<Subject[]> => {
const stats = await fs.stat(checksumsPath)
if (!stats.isFile()) {
throw new Error(`subject checksums file not found: ${checksumsPath}`)
}
@@ -163,7 +170,7 @@ const getSubjectFromChecksumsFile = (checksumsPath: string): Subject[] => {
)
}
const checksums = fs.readFileSync(checksumsPath, 'utf-8')
const checksums = await fs.readFile(checksumsPath, 'utf-8')
return getSubjectFromChecksumsString(checksums)
}
@@ -195,10 +202,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
@@ -213,7 +225,7 @@ const digestFile = async (
): Promise<string> => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(algorithm).setEncoding('hex')
fs.createReadStream(filePath)
createReadStream(filePath)
.once('error', reject)
.pipe(hash)
.once('finish', () => resolve(hash.read()))
+2 -2
View File
@@ -2,9 +2,9 @@
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"module": "ESNext",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"baseUrl": "./",
"sourceMap": true,