Supply chain security has become an increasingly important topic in software delivery, this is primarily due to the increase in attacks on software supply chains and the need to verify software artifacts.

Supply chain security, in essence, refers to the measures and strategies implemented to safeguard the integrity of the processes involved in the development and distribution of software products.

In the context of supply chain security, software artifacts refer to the various components, such as code, libraries, configurations, and documentation, that make up a software product.

One of the more popular examples of supply chain attacks is the infamous SolarWinds hack, where the attackers were able to create a back door to the SolarWinds software, and this eventually made its way into the hands of consumers who were now running a malicious version of the software and remained undetected for months.

This was a huge wake-up call to organizations who weren’t already thinking about the security of their software delivery pipelines.

In this tutorial, we will take a brief look at supply chain attacks and security, plus how these can partially be mitigated by automatically signing container images using Cosign and GitHub actions.

What are supply chain attacks?

To further illustrate how supply chain attacks can occur, let's take a look at what a typical software delivery pipeline would look like.

Jubril Oyetunji - Typical software delivery pipeline

The illustration above is a simplified example, where a developer pushes code to a source code repository such as GitHub. The code then triggers a build process in a Continuous Integration (CI) service where the code is built and bundled with its dependencies, and produces an artifact. This artifact might be a Python package, binary, or container image that can be deployed in the cloud.

This setup works fine, assuming we can trust every step in the delivery process. But what happens when an attacker gains access to any part of the delivery pipeline?

Jubril Oyetunji - Attacker gains access to software delivery pipeline

In the illustration above, the attacker has gained access to the build server, perhaps through a compromised SSH key or an undisclosed zero-day exploit. They can now modify the build process to include some malicious code producing a malicious artifact.

This leads to two big questions. How can authors sign artifacts such that their authenticity can be verified? How can consumers be sure they are running the legitimate version of the software?

What is the sigstore project and Cosign?

From sigstore.dev

sigstore was started to improve supply chain technology for anyone using open source projects. It's for open source maintainers, by open source maintainers. And it's a direct response to today’s challenges, a work in progress for a future where the integrity of what we build and use is up to standard.

Cosign is part of the suite of tools the Sigstore project built to provide a secure and transparent way of signing and verifying container images and artifacts. It is built with security and usability in mind and can be easily integrated into existing workflows. Cosign also provides key and signature transparency, allowing for easy verification of signatures and ensuring that no one can tamper with signed images without being detected.

Now that we have a good idea of the problem the Sigstore project and Cosign are trying to address, let's dive into the tutorial.

Prerequisites

This tutorial assumes some familiarity with GitHub actions in addition, you would need the following:

Installing Cosign

If you have Go (1.19+) installed, installing Cosign is straightforward. In your terminal, run the the following command.

go install github.com/sigstore/cosign/v2/cmd/cosign@latest

In a few minutes, you should have cosign installed. You can verify this by running

cosign version

Installing Cosign

Automating Container Image Signing

Generating a key pair

In order to sign our container images, we need to generate a public and private key pair. Luckily Cosign ships with a command to do this, and in fact, takes this a step further by being able to write the key pair directly to GitHub secrets. If you haven’t done so early already, clone the repository you created earlier onto your machine, and change into that directory in your terminal.

Before running the command, we need to export some environment variables:

export GITHUB_TOKEN=ghp_yourgithubtoken
export COSIGN_PASSWORD=areallysecurepassW0rd

Cosign will attempt to write the key pair to GitHub secrets, so we provide it with a GitHub token as well as a password for the private key. Be sure to replace COSIGN_PASSWORD with a password of your choosing.

Now you can run the following command, replacing the $REPO_OWNER with your GitHub name, and the $REPO_NAME with the name of your repository:

cosign generate-key-pair github://$REPO_OWNER/$REPO_NAME

Generating a key pair

This will also write the public key and an encrypted version of the private key to the current working directory.

We can verify Cosign has written the key pair by checking the secrets tab in the settings page of the repository on GitHub.

Verify Cosign has written the key pair

While in the secrets tab, create a new secret called GITHUB_TOKEN and set the value to the Github token you generated. This would be required to publish our image to the GitHub container registry.

Configuring GitHub Actions

Next, let’s set up the GitHub workflow that will sign and publish images. Within your repository, create the directories github/ and under it workflows:

mkdir -p .github/workflows 

Next, create a workflow file:

touch .github/workflows/ci.yaml

Create and open up ci.yaml in the editor of your choice and follow along with the code below:

name: build and sign

on:
  push:
    branches:
      - 'main'

jobs:
  build-image:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write
      id-token: write 

    name: build-image
    steps:
      - uses: actions/checkout@v3.5.2
        with:
          fetch-depth: 1

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3.1.1


      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2.1.0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2.5.0

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v2.1.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # publish dockerfile in the repo
      - name: Publish Dockerfile
        uses: docker/build-push-action@v4
        id: build-and-push
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64,linux/arm/v7,linux/arm64
          push: true
          tags: ghcr.io/${{ github.repository }}:latest


      - name: Sign image with a key
        run: |
          cosign sign --yes --key env://COSIGN_PRIVATE_KEY  ghcr.io/${{ github.repository }}:latest
        env:
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}

The important part to look out for here is where we sign the image:

      - name: Sign image with a key
        run: |
          cosign sign --yes --key env://COSIGN_PRIVATE_KEY  ghcr.io/${{ github.repository }}:latest
        env:
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}

This passes the secrets created earlier as an environment variable to the cosign command.

Creating a Dockerfile

With the workflow in place, we need an image to publish. Within the root directory of the repository, create a Dockerfile and add the following instructions:

FROM alpine:latest

RUN apk update && apk upgrade

RUN apk add cowsay --repository <http://dl-3.alpinelinux.org/alpine/edge/testing/> --allow-untrusted

RUN apk add cowsay 

CMD ["cowsay", "Hello, Docker!"]

While this might be a contrived example, the idea here is your project would typically have a Dockerfile that is used to build your application. Commit the files and push them to the repository to trigger a build.

Verifying signatures

As a consumer, it is important that you verify software artifacts before use, this ensures you are only using trusted artifacts. Cosign ships with a command that can be used to verify the integrity of the container image we published.

To verify the signature, run the following command:

cosign verify ghcr.io/$REPO_OWNER/$REPO_NAME --key cosign.pub -o text

If this succeeds, you should see the following output :

Verification for ghcr.io/$REPO_OWNER/$REPO_NAME:latest –

The following checks were performed on each of these signatures:

  • The cosign claims were validated
  • The existence of the claims in the transparency log was verified offline
  • The signatures were verified against the specified public key

Summary

In this tutorial, we demonstrated how to sign container images using Cosign using GitHub actions, we also highlighted the importance of supply chain security.

It's important to note that while signing your artifacts is a critical step, it is just one of the many layers that should be integrated into your software delivery pipeline. As a consumer, it is imperative to ensure that you are utilizing signed or verified software artifacts to enhance overall security and trustworthiness.

Additional Resources

Cosign supports a variety of signing methods that this post doesn’t cover, you can check them out here.

Cosign also allows you to store keys in various secret management solutions such as Hashicorp Vault, this is ideal for production environments.