Secure your software supply chain with Sigstore and GitHub Actions
Marco Franssen

Loading...
Marco Franssen

With software supply chain attacks on the rise, securing our software supply chains is becoming increasingly important. Others have already written plenty about these attacks, so I won't repeat that here. I'm assuming you found this article because you want to know how to help prevent them.
In this blog post, I want to show you how to secure a software supply chain by applying some SLSA requirements in a GitHub Actions workflow. We'll use Sigstore to sign and attest Docker images. I'll also bring in a few other tools to generate build provenance and a software bill of materials (SBOM) for our released images, then attach signed attestations for both.
We will cover the following topics:
⚠️ This example only covers releasing a Docker image, but we can apply the same approach to other artifact types using the same toolchain.
Before we get started, I want to point out that you can—and should—limit the permissions a GitHub Actions workflow has by default. The following screenshot shows how.

Make sure you pick the Read repository contents permission option. This disables write permissions on your repositories by default. For more information, see permissions for the GitHub token.
You can find these settings for your own repositories at https://github.com/«your-organization»/«your-repo-goes-here»/settings/actions.
In the rest of this post, I'll show you how to enable specific permissions in your workflows as needed. That way, each workflow has only the permissions it requires, reducing the attack surface.
First, let's look at a simple release workflow that builds our Docker images and publishes them to the GitHub Container Registry.
Loading https://raw.githubusercontent.com/marcofranssen/slsa-workflow-examples/v0.1.0/.github/workflows/release.yaml…
We need the packages: write permission to push the image to ghcr.io. Once you've run this workflow, a new package is created with private visibility. To continue signing with the Rekor transparency log in this example, you'll need to make the Docker package publicly accessible. You can change this in your package settings. In my case, that's github.com/users/marcofranssen/packages/container/slsa-workflow-examples-docker/settings (you won't be able to access this URL unless you're an admin on this GitHub repository).
Adding signatures to our Docker images makes it possible to verify their authenticity with a cryptographically verifiable signature. We'll add a signing job to the workflow, along with configuration to capture the image digest for the next steps. For this, we'll use cosign, Rekor, and Fulcio. In the version of cosign used here, enabling this feature requires the environment variable COSIGN_EXPERIMENTAL=1. We'll set it at the workflow level.
…
env:
COSIGN_EXPERIMENTAL: 1
IMAGE_NAME: ghcr.io/marcofranssen/slsa-workflow-examples-docker
…Here are the other additions to the workflow. Take a moment to digest the changes; I'll explain them below the code block.
docker:
…
outputs:
image-digest: ${{ steps.container_info.outputs.image-digest }}
steps:
…
- name: Get container info
id: container_info
run: |
image_digest="$(docker inspect "${IMAGE_NAME}:latest" --format '{{ index .RepoDigests 0 }}' | awk -F '@' '{ print $2 }')"
echo "::set-output name=image-digest::${image_digest}"
sign:
runs-on: ubuntu-20.04
needs: [docker]
permissions:
packages: write
id-token: write
env:
IMAGE_DIGEST: ${{ needs.docker.outputs.image-digest }}
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v2.1.0
with:
cosign-release: v1.6.0
- name: Login to ghcr.io
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Sign image
run: |
cosign sign "${IMAGE_NAME}@${IMAGE_DIGEST}"
echo "::notice title=Verify signature::COSIGN_EXPERIMENTAL=1 cosign verify ${IMAGE_NAME}@${IMAGE_DIGEST} | jq '.[0]'"
echo "::notice title=Inspect signature bundle::COSIGN_EXPERIMENTAL=1 cosign verify ${IMAGE_NAME}@${IMAGE_DIGEST} | jq '.[0].optional.Bundle.Payload.body |= @base64d | .[0].optional.Bundle.Payload.body | fromjson'"
echo "::notice title=Inspect certificate::COSIGN_EXPERIMENTAL=1 cosign verify ${IMAGE_NAME}@${IMAGE_DIGEST} | jq -r '.[0].optional.Bundle.Payload.body |= @base64d | .[0].optional.Bundle.Payload.body | fromjson | .spec.signature.publicKey.content |= @base64d | .spec.signature.publicKey.content' | openssl x509 -text"At the docker job level, we configured one output to pass information to other jobs. To populate it, we add a step that retrieves the image digest and writes it to the output.
Next, we add a job to sign the Docker image. For this job to authenticate with Fulcio/Rekor, we need to enable the id-token permission with write access (remember, we disabled write permissions by default on our repositories). This experimental feature (at the time of writing) enables keyless code signing through an OIDC integration between GitHub and Sigstore. The Docker login is required because cosign stores the signature in the OCI registry. See the workflow execution notices for instructions on verifying and inspecting our release's signature.
Before you continue, your workflow should look like this.
Adding an SBOM gives consumers of our Docker image an extensive list of its dependencies. Our image might have no known vulnerabilities at release time, but vulnerabilities in those dependencies could be discovered weeks or months later. An SBOM lets consumers check the release for known vulnerabilities in the future. They can also inspect the licenses involved in our release.
sbom:
runs-on: ubuntu-20.04
needs: [docker]
permissions:
packages: write
id-token: write
env:
IMAGE_DIGEST: ${{ needs.docker.outputs.image-digest }}
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v2.1.0
with:
cosign-release: v1.6.0
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0.7.0
- name: Login to ghcr.io
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Attach SBOM to image
run: |
syft "${IMAGE_NAME}@${IMAGE_DIGEST}" -o spdx-json=sbom-spdx.json
cosign attest --predicate sbom-spdx.json --type spdx "${IMAGE_NAME}@${IMAGE_DIGEST}"
echo "::notice title=Verify SBOM attestation::COSIGN_EXPERIMENTAL=1 cosign verify-attestation ${IMAGE_NAME}@${IMAGE_DIGEST} | jq '.payload |= @base64d | .payload | fromjson | select(.predicateType == \"https://spdx.dev/Document\") | .predicate.Data | fromjson'"Same story here. To attest the SBOM for our image, we need the OIDC integration between GitHub and Sigstore, hence the permissions block. We use Syft to generate the SBOM from our Docker image. The notice logged at the end appears in the workflow run overview, showing people how to retrieve and inspect the SBOM for this release. See the workflow execution notices.
Before you continue, check the current state of our workflow.
To add build provenance, we'll use slsa-provenance-action. The SLSA provenance action generates build provenance according to the SLSA provenance specification, which uses in-toto predicates. in-toto is a framework for securing the integrity of software supply chains.
To include all released tags in the build provenance, we need one more output to capture them.
Add the following to the docker job.
docker:
…
outputs:
image-digest: ${{ steps.container_info.outputs.image-digest }}
image-tags: ${{ steps.container_info.outputs.image-tags }}
steps:
…
- name: Get container info
id: container_info
run: |
image_digest="$(docker inspect "${IMAGE_NAME}:latest" --format '{{ index .RepoDigests 0 }}' | awk -F '@' '{ print $2 }')"
image_tags="latest,${GITHUB_REF_NAME},$(git rev-parse "${GITHUB_REF_NAME:-HEAD}")"
echo "::set-output name=image-digest::${image_digest}"
echo "::set-output name=image-tags::${image_tags}"Now add the following job at the end of the workflow to generate build provenance.
provenance:
runs-on: ubuntu-20.04
needs: [docker]
permissions:
packages: write
id-token: write
env:
IMAGE_DIGEST: ${{ needs.docker.outputs.image-digest }}
PROVENANCE_FILE: provenance.att
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v2.1.0
with:
cosign-release: v1.6.0
- name: Generate provenance
uses: philips-labs/slsa-provenance-action@v0.7.2
with:
command: generate
subcommand: container
arguments: --repository "${IMAGE_NAME}" --output-path "${PROVENANCE_FILE}" --digest "${IMAGE_DIGEST}" --tags "${IMAGE_TAGS}"
env:
COSIGN_EXPERIMENTAL: 0
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
IMAGE_TAGS: ${{ needs.docker.outputs.image-tags }}
- name: Login to ghcr.io
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Attach provenance
run: |
jq '.predicate' "${PROVENANCE_FILE}" > provenance-predicate.att
cosign attest --predicate provenance-predicate.att --type slsaprovenance "${IMAGE_NAME}@${IMAGE_DIGEST}"
echo "::notice title=Verify provenance attestation::COSIGN_EXPERIMENTAL=1 cosign verify-attestation ${IMAGE_NAME}@${IMAGE_DIGEST} | jq '.payload |= @base64d | .payload | fromjson | select(.predicateType == \"https://slsa.dev/provenance/v0.2\")'"To attest the provenance for our image, we also need a permissions block for the OIDC integration between GitHub and Sigstore. We use philips-labs/slsa-provenance-action to generate the provenance based on your GITHUB_CONTEXT and RUNNER_CONTEXT, which are environment variables available during the GitHub workflow run. We also capture the Docker tags released in the provenance.
The notice logged at the end appears in the workflow run overview, showing consumers how to retrieve and inspect the build provenance for the release.
You can find the full example here. It includes a small Go app with a single dependency to demonstrate the SBOM. See the end result in action.
With the signature, SBOM, and provenance in place, we can start building policies for our production environments that verify signatures and attestations before allowing a deployment.
What other improvements or practices would you suggest to reach a higher SLSA level? Feel free to fork the repository and contribute to the example to help others.
Using crane, you can query the OCI registry to see how the signature and attestations are stored for your image.
$ crane ls ghcr.io/marcofranssen/slsa-workflow-examples-docker
666a992f55ed7be99945e4019ac9a37d62922543
v0.1.0
8f3a793498eeb0c7eac095d91f87af2846d75a32
v0.1.1
sha256-371450f7c64808fd27f224083c475571e7c0710dc75a536a1755890183234a38.sig
4449252d72ddb46df973fa477309e3ba83a28f07
v0.1.2
sha256-5708dceb1d4e8ed1a7eab0ff7dab6db9742300e713b854334cacd392383be914.sig
sha256-5708dceb1d4e8ed1a7eab0ff7dab6db9742300e713b854334cacd392383be914.att
4d1fcd0b60bf53f4821c8a7ea529e9ea7df70789
latest
v0.1.3
sha256-04b0426d40c824929fd5a75821f39f349db5ffa2eb07df22a55f736d7f40232c.sig
sha256-04b0426d40c824929fd5a75821f39f349db5ffa2eb07df22a55f736d7f40232c.attMarco Franssen
Explore spiffe-vault for secretless Vault authentication, using SPIFFE workload identities and Vault-managed signing keys to sign container images with Cosign.
Marco Franssen
Explore a 2022 approach to storing, signing, and discovering package SBOMs and build provenance in an OCI registry with cosign, fatt, and sget.
Marco Franssen
Automatically choose work or personal Git commit email addresses with conditional includes in your global Git configuration and a folder-based setup.
Marco Franssen
Use Helmsman to manage Helm charts as code, organize Kubernetes environments, preview deployment plans, and apply releases in a GitOps workflow.
name: Release
on:
push:
tags:
- v**
env:
IMAGE_NAME: ghcr.io/marcofranssen/slsa-workflow-examples-docker
jobs:
docker:
runs-on: ubuntu-20.04
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v3.0.0
- name: Login to ghcr.io
uses: docker/login-action@v1.14.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker images
run: |
revision="$(git rev-parse "${GITHUB_REF_NAME:-HEAD}")"
docker build \
-t "${IMAGE_NAME}:latest" \
-t "${IMAGE_NAME}:${GITHUB_REF_NAME}" \
-t "${IMAGE_NAME}:${revision}" \
--label "org.opencontainers.image.source=https://github.com/marcofranssen/slsa-workflow-examples" \
--label "org.opencontainers.image.created=$(date --iso-8601=seconds)" \
--label "org.opencontainers.image.title=slsa-workflow-examples-docker" \
--label "org.opencontainers.image.revision=${revision}" \
--label "org.opencontainers.image.version=${GITHUB_REF_NAME}" \
--label "org.opencontainers.image.licenses=MIT" \
--label "org.opencontainers.image.vendor=Marco Franssen" \
.
- name: Publish Docker images
run: docker push "${IMAGE_NAME}" --all-tags