Publishing NuGet packages from a Github Action without secrets

This guide will walk you through using a combination of GitHub actions and NuGet publishing policies to push packages to nuget.org from within an action without needing to worry about API keys.

Now you have a GitHub Action configured to sign your code and your nupkgs you can take it a little further and publish to nuget.org without the need to create, rotate and otherwise manage API keys.

NuGet has an implementation of Trusted Publishing, an OpenSSF concept for improving supply change security by removing the need for a long-lived API token to publish to package repositories.

This works by allowing a GitHub action t0 authenticate to nuget.org via OIDC, and to exchange that authentication token for a short-lived API key which can then be used to publish packages.

Like an Azure Managed Identity's federated credentials the NuGet OIDC login is restricted to known GitHub repositories, workflows and, optionally, actions environments, wrapped up as a trusted publishing policy.

If you have already looked at the GitHub actions I use for producing my idunno.Authentication NuGet packages you may have noticed the publish stage in release.yml which authenticates to nuget.org and the publishes the signed packages.

Prerequisites

You should already have

  • A GitHub account and admin rights on the repository you want to configure the actions on.
  • A nuget.org account to which you publish packages.

Configuration values you will need

Configuration Where to find it
GitHub Repository Owner The first path segment in the URI for the repository
you want to assign managed identities to.

e.g. for https://github.com/contoso/WebSite/

the owner is contoso
GitHub Repository Name The second path segment in the URI
for the repository you
want to assign managed identities to.

e.g. for https://github.com/contoso/WebSite/

the Repository Name is WebSite
GitHub Workflow File The name of your workflow file, available in your repository in the .github/workflows directory . Do not include the path.
e.g. for https://github.com/contoso/packages/.github/workflows/publish.yml the filename is publish.yml
(Optionally) GitHub Actions Environment Available in the repository settings on GitHub if you have configured environments for your workflows.
NuGet User Name Available after you log in to https://nuget.org in the right side of the NuGet.org menu.

It is not the email address you log in with.

Defining a NuGet publishing policy

Much like defining a federated credential for an Azure Managed Identity Trusted Publishing NuGet configuration is based off the Repository Owner and Repository Name of the repository you want to authenticate from. NuGet also requires the workflow file, whilst environments are optional (although I'd recommend using actions environments, as their use allows you to tightly control which steps in your build have the ability to publish). All of this is wrapped up in a publishing policy.

To define a publishing policy

  1. Open nuget.org and log in.
  2. Click the username of your account to pull down the account menu and choose Trusted Publishing.
  3. Click + Create.
  4. Fill out the policy definition. Everything except Policy Name is case sensitive.
The NuGet Trusted Publishing policy definition page
  1. Click Create.

Why is my policy pending?

When you create a publishing policy you may see a warning in the Policy Management page, "ℹ️ Use within 7 day(s) to keep it permanently active". Why does NuGet do this?

Until you publish from GitHub NuGet doesn't know the unique repository and owner IDs, only the strings you entered in the policy. Without knowing the underlying IDs if someone deleted a repo, then recreated it with the same name they could still publish, even though the repo contents could have massively changed. This type of exploit is known as a "resurrection attack". The 7-day window narrows the exploitability, so if you want the policy to stick, publish a package from the action within the 7 days.

NuGet Publishing from a GitHub Action

If you have chosen to limit the publish capability to a specific environment first complete the following steps if the environment is an environment that does not, as yet exist.

Configuring the GitHub Action

  1. Log in to GitHub.
  2. Choose the repository you want to add the signing action to by navigating to it.
  3. From the repository view select Settings.
  4. Select Environments from the left-hand menu.
  5. Select New environment, then give it the same name as the environment you entered when you created the Trusted Publishing policy on nuget.org.
  6. Set the protection rules for the environment to whatever policy you require. Personally, I require reviewers for both signing and publishing stages, which means an approval step needs to take place, as well as only allowing signing and publishing to run from the main branch, where I generate new releases from.

Now we need to configure a single secret.

  1. If you are limiting the policy to an environment, choose the environment from the Environments page. Click Add Environment Secret, or
  2. If you are not using environments, bring up the Settings page for your repository, click Secrets and variables in the left-hand menu. Click Actions then click New Repository Secret.
  3. Create a new secret with a secret name of NUGET_USERNAME and enter your NuGet username as the secret value, then click Add Secret.

Adding a publish stage to your action

As you may have read in .NET code & nupkg signing in GitHub Actions I use a staged process in my release and pre-release actions, Build (and test), Sign, and now I will add a third stage Publish. I use stages because it isolates the release of secrets to only the secrets each stage requires, using the environment the stage is configured to use.

The following yaml snippet builds on the code signing action example to add a publish stage, which takes the output of the sign stage, the signed nupkgs, authenticates to NuGet using the publisher policy, and the publishes the packages, all without needing an API key.

  publish:
    name: Publish
    needs: sign
    runs-on: ubuntu-latest
    environment: publish
    if:  ${{ inputs.perform_publish }}
    permissions:
      id-token: write

    - name: 'Setup .NET SDK'
      uses: actions/setup-dotnet

    - name: 'Gather nupkgs from sign output'
      uses: actions/download-artifactv6.0.0
      with:
        name: signed-artifacts
        path : ${{ env.OUTPUT_DIRECTORY }}

    - name: Authenticate to nuget
      uses: NuGet/login
      id: nugetlogin
      with:
        user: ${{secrets.NUGET_USERNAME}}

    - name: Publish NuGet packages
      shell: pwsh
      run: >
        foreach($file in (Get-ChildItem "${env:NUPKG_DIRECTORY}" -Recurse -Filter *.nupkg)) {
          dotnet nuget push $file --api-key "${{ steps.nugetlogin.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json
        }

The short-lived publishing API key is produced in the output of NuGet/login action and is used subsequently in publish as the --api-key parameter value in dotnet nuget push.

As before in .NET code & nupkg signing in GitHub Actions I have omitted the action hashes in the example to make things easier to read, but as you write your own workflow you should pin each action to its version hash to avoid the potential harm that could be caused by an attacker gaining control of an action, injecting malicious code and then retagging the version of the action containing the code to an existing version tag.

In conclusion

Now you have all the tools you need to remove your NuGet publishing API keys from your GitHub actions, increasing security as there are no API keys to leak, and reliability of your publishing as there are no API keys to rotate on expiration.

Subscribe to Ramblings from a .NET Security PM

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe