blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Creating provenance attestations for NuGet packages in GitHub Actions

Share on:

In this post I discuss software provenance, what attestations say about your software, and how they work. I then show you can use GitHub actions to easily create a signed attestation when creating a NuGet package in GitHub Actions. The same process applies for applications too, but in this post I focus on creating an attestation for a single library. Make sure you read to the end though, as there's a n unfortunate conclusion to the story

What is software provenance?

Software provenance is about providing verifiable information and integrity guarantees about a software artifact, describing where, when, and how it was produced. It is one part of the overall software software-chain that goes into creating secure software that can be proven to have not been tampered with.

Supply-chain Levels for Software Artifacts (SLSA) is a security framework that systematically looks at the various points your software could be compromised, and provides suggested guidelines, controls, and checks, for mitigating these threats.

Threats to the software supply chain. From SLSA

Enabling provenance for your artifacts helps protect you and your customers from several of these attacks. A provenance attestation provides a common format for describing how your artifact was built, the environment it was built in, the build definition that produced it, the build run that produced it, and more.

The attestation is typically a JSON document, which is signed and stored "publicly" (or internally for internal software). When someone consumes the artifact, they can check the artifact's attestation, which provides all the details as to where the artifact was created.

By itself, providing provenance and attestations doesn't automatically enhance the security; for that you must verify the attestations of software you consume or which you deploy. For example, if you're deploying to Kubernetes you could create a policy that enforces that all Docker images deployed to the cluster have attestations. As GitHub say in their announcement post:

“It's important to note that provenance by itself doesn’t make your artifact or your build process secure. What it does do is create a tamper-proof guarantee that the thing you’re executing is definitely the thing that you built, which can stop many attack vectors. It's still vital to maintain strong application security processes like requiring code review for all patches, and applying dependency updates in a timely manner.”

Now that we know why we need provenance, and what attestations are useful for in general, we'll move on to see how they're generated in a specific scenario, in GitHub Actions.

How does GitHub generate attestations?

In May 2024, Github introduced a public beta of artifact attestations, and they became generally available in June 2024. The GitHub model is designed to be simple to use and avoid long-lived credentials. Their model uses a GitHub managed workflow and client to handle the complexity.

The Github attestation method. From Introducing Artifact Attestations–now in public beta

The process starts when you execute GitHub's actions/attest-build-provenance action. This invokes GitHub's Sigstore client, which requests the GitHub Actions OIDC token, which is unique for each job. The Sigstore client then creates a public-private key-pair, and sends the public part of the pair to the Fulcio certificate authority, along with the OIDC token.

On receiving the OIDC token and the public part of the key pair, Flucio creates a new short-lived X.509 certificate, associated with the OIDC token, and returns it back to the Sigstore client. The Sigstore client then:

  • Calculates the SHA-256 digest of the artifact for which provenance is being attested.
  • Writes the provenance statement as an in-toto JSON blob, including additional data (termed a predicate) from the OIDC token.
  • The provenance statement is signed using the private part of the key value pair.
  • The private part of the key value pair is now thrown away, and can not be recovered.
  • The statement is counter-signed by a Time Stamp Authority (TSA) that proves the signing process completed in the required 10-minutes (the Fulcio certificate's validity)
  • All the data is pushed as a Sigstore bundle, persisted to GitHub’s attestation store and to the Sigstore Public Good Instance (for public repos)

And with that, the attestation is complete. In the next section, I'll show how you can easily create attestations for your own projects.

Create an attestation for a NuGet package in GitHub Actions

In this section I show how to create an attestation for a NuGet package created in a GitHub action. To provide some context, this is the (simplified) workflow that we start from. It simply installs .NET, builds and packs the solution, and then pushes the NuGet packages to nuget.org.

name: BuildAndPack

on:
  push:
    branches: ["main" ]
    tags: ['*']
  pull_request:
    branches: ['*']

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
        with:
          dotnet-version: |
            9.0.x
            8.0.x
            6.0.x
            3.1.x

      - name: Build and pack
        run: dotnet pack -c Release

      - name: Push to NuGet
        run: dotnet nuget push artifacts/packages/*.nupkg
        env:
          NuGetToken: ${{ secrets.NUGET_TOKEN }}

      - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
        with:
          name: packages
          path: artifacts/packages

A real build would have additional steps like a test stage, but this is good enough as an example—it shows the NuGet packages being published to the artifacts/packages folder, and pushed to NuGet.

Note that we're pinning to a specific commit hash for each action. This is important to avoid supply-chain attacks, such as the recent attack on the tj-actions/changed-files action.

We're now going to add an attestation to this. The simplest way to do this is simply add an additional step to the workflow:

- name: Generate artifact attestation
  uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
  with:
    subject-path: 'artifacts/packages/*.nupkg'

You'll also need to make sure the workflow has sufficient permissions to create the OIDC token and attestations:

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

Putting that together,

name: BuildAndPack

on:
  push:
    branches: ["main" ]
    tags: ['*']
  pull_request:
    branches: ['*']

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    # 👇 Add the permissions here
    permissions:
      id-token: write
      contents: read
      attestations: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
        with:
          dotnet-version: |
            9.0.x
            8.0.x
            6.0.x
            3.1.x

      - name: Build and publish
        run: dotnet publish -c Release

      - name: Build and publish
        run: dotnet nuget push artifacts/packages/*.nupkg
        env:
          NuGetToken: ${{ secrets.NUGET_TOKEN }}

        # 👇 Generate the attestation
      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
        with:
          subject-path: 'artifacts/packages/*.nupkg'

      - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
        with:
          name: packages
          path: artifacts/packages

And that's all you need to do—GitHub does the rest! The job summary includes a link to the public GitHub attestation where you can view and download the attestation directly if you wish:

The public attestation

Note that the method above achieves SLSA v1.0 Build Level 2. After verifying the provenance of an artifact you still need to follow the links and evaluate the build instructions used to create the artifact.

The next level up, SLSA v1.0 Build Level 3, requires defining the build instructions outside of your repository, in a reusable workflow that is shared across all your organisation's repositories, for example. Requiring that builds make use of known, vetted build instructions provides that additional protection required for level 3.

You can read more about how to achieve Build Level 3 using GitHub here, or consider the generators provided by the SLSA project.

Verifying an attestation via the Github CLI

As already discussed, the act of creating an attestation doesn't improve security on its own. It's only beneficial if you verify that the artifacts you're using and deploying have valid attestations. If you're deploying images to Kubernetes, you might want to consider using an admission controller to enforce provenance.

If you're not deploying in this way, you might need to do manual verification. There are several ways to do this, for example you can follow these instructions to do offline verification.

The easiest way to verify the attestation is to use the GitHub CLI using a command like the following:

gh attestation verify <path/to/artifact/to/verify> -R <org/repo>

So for example, to verify an attestation for the repo andrewlock/NetEscapades.AspNetCore.SecurityHeaders, you might use a command like the following, which provides the following output:

> gh attestation verify --repo andrewlock/NetEscapades.AspNetCore.SecurityHeaders "./NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.3.nupkg"
Loaded digest sha256:dd57dea438848532551aac6ba585d27b91e198fbc567ae576362212c5f9581e1 for file://C:\NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.3.nupkg
✓ Verification succeeded!

sha256:dd57dea438848532551aac6ba585d27b91e198fbc567ae576362212c5f9581e1 was attested by:
REPO                                                PREDICATE_TYPE                  WORKFLOW                                              
andrewlock/NetEscapades.AspNetCore.SecurityHeaders  https://slsa.dev/provenance/v1  .github/workflows/BuildAndPack.yml@refs/pull/225/merge

As long as a provenance exists for the artifact, then verification will succeed, and you can be sure that the artifact was generated using the linked workflow. If verification fails, then you can't be sure where or how the artifact was created.

And now for the bad news…

After going through all this, one thing I haven't been able to figure out is how to efficiently verify the provenance of NuGet packages as part of a .NET build. My gut feeling is that it's simply not feasible right now. There's an issue on the NuGet repo which seems to be suggesting providing support for this sort of thing, but there doesn't look to be any progress, so I wouldn't hold your breath. In the meantime, if it's the sort of thing you really care about, you're likely stuck with manual verification approaches.

But that's where things really fall apart.

Unfortunately, the NuGet package you upload using dotnet nuget push is not the same package as the one that you download from http://nuget.org. NuGet modify the package when they receive it (to add a signature file, ironically), so the package available on nuget.org has a different SHA value to the artifact created in GitHub. That means the attestation generated for the build is effectively useless, as it does not apply to the package that you actually download from NuGet!

I was really hoping I was wrong about that and I had messed something up, but it seems to be by design based on this issue, and from empiricial testing. For example, this is the GitHub workflow in which I created and uploaded version 1.0.0-preview.4 of NetEscapades.AspNetCore.SecurityHeaders to NuGet. And yet, if you compare the packages stored in that artifact run with the packages you can download from NuGet then their clearly different files.

Given all that, it really seems like you can only treat the GitHub attestations as an academic exercise for NuGet packages for now. The signature file that nuget.org embeds in the NuGet package attempts to perform a similar role, and is explicitly supported by NuGet clients, so that's probably not a big deal.

It seems to me (definitely not a security expert) that there's an additional potential avenue for attack with the signature file. If you could perform an attacker-in-the-middle attack on the upload to nuget.org, then you could replace the contents of the package with malicious content, and nuget.org would happily sign it I believe. This is obviously a niche, mitigated issue, given HTTPS and Microsoft's security scanning, but seems possible.

Overall, the conclusion to this journey was disappointing. I think it might be feasible to perform an attestation of the content of the NuGet package prior to upload, as described in this comment, as that is unaffected by the nuget.org signing process. But retrieving that "content hash" from a NuGet package isn't necessarily simple for clients to do, which makes verifying any attestations similarly difficult. For now, I'm just going to declare that provenance attestations are basically not possible for NuGet packages 😢.

Update: the redemption arc

After a bit of experimentation, and after reading through this issue in more detail, I established that you can essentially "revert" the modification nuget.org makes using the Linux zip utility, something like this:

# Delete the .signature.p7s file that nuget.org adds to the package
zip -d NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.4.nupkg .signature.p7s

# Run attestation verification for the package
gh attestation verify --owner andrewlock "C:\Users\sock\Downloads\NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.4.nupkg"

As it turns out, the zip utility essentially directly inverts the modification NuGet makes, deleting the .signature.p7s file, and thereby restoring the SHA for the package to the version prior to upload. And that means that the attestations are once again valid:

Loaded digest sha256:bf809ff0ed6a8a31131df4391b169e35ded44d4dfd97cc797123441683a95c9f for file://./NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.4.nupkg
Loaded 2 attestations from GitHub API

The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/andrewlock
- Subject Alternative Name must match regex: (?i)^https://github.com/andrewlock/
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com

✓ Verification succeeded!

The following 1 attestation matched the policy criteria

- Attestation #1
  - Build repo:..... andrewlock/NetEscapades.AspNetCore.SecurityHeaders
  - Build workflow:. .github/workflows/BuildAndPack.yml@refs/tags/v1.0.0-preview.4
  - Signer repo:.... andrewlock/NetEscapades.AspNetCore.SecurityHeaders

If you're not on Linux, you can achieve a similar reversion using .NET, or using a PowerShell script that invokes .NET, something like the following:

# The file to update
$zipfile =  "C:\Users\sock\Downloads\NetEscapades.AspNetCore.SecurityHeaders.1.0.0-preview.4.nupkg"

# Load the System.IO.Compression Compression library
[Reflection.Assembly]::LoadWithPartialName('System.IO.Compression')

# Load the ZipArchive
$stream = New-Object IO.FileStream($zipfile, [IO.FileMode]::Open)
$zip    = New-Object IO.Compression.ZipArchive($stream, [IO.Compression.ZipArchiveMode]::Update)

# Delete the signature file and cleanup
$zip.Entries | ? { $_.Name -eq ".signature.p7s" } | % { $_.Delete() }
$zip.Dispose();

So there we go, a little bit of hope after the disappointment!😀

Summary

In this post I described how artifact attestation works and why provenance is an important part of securing the software supply chain. In particular I describe the approach to attestation provided by GitHub with their publicly available artifact attestations. Finally I show how you can use the GitHub action in your own flows, using an example of a repo that produces a NuGet package, to generate the build provenance for the package. Finally, I described how nuget.org modifies the packages during upload, rendering the provenance attestation invalid for the files present on nuget.org.

  • Buy Me A Coffee
  • Donate with PayPal
Andrew Lock | .Net Escapades
Want an email when
there's new posts?