Blog

How to automatically pin GitHub Actions to commit SHAs for security hardening

Malicious actors can exploit third-party GitHub Actions, leaving you vulnerable to attacks. Pinning your actions to commit SHAs is one way to prevent this. With Minder, you can create a rule type for pinning actions to commit SHAs to automatically enforce this practice, and autoremediate to make sure actions are consistently pinned.

/
7 mins read
/
Jan 3, 2024
Minder

If you're using GitHub Actions for CI/CD, you're probably relying on a third-party action from GitHub's Marketplace. Just like with open source packages published on registries like PyPI or npm, third-party actions can be vulnerable to exploitation from malicious actors.

One way to keep your actions secure is to pin your actions to a commit SHA, instead of using a tag. This ensures that you're running the code that the SHA is pointing to, so that even if a malicious actor takes over that third-party actions repo, pushes some malicious code, and then publishes the malicious action using an already-existing tag, you would still be using the non-malicious code that you originally pinned to.

But pinning your actions can be a really tedious task, especially if you're using multiple repos and the repos themselves are using multiple actions.

In December, we introduced a new open source command line utility, Frizbee, that you can use to more easily pin GitHub Actions to a checksum. We've integrated this tool into Minder, to make it easier for you to automatically pin actions to your commit SHAs and scale this practice across all of your GitHub repos. Let's explore how this works.

Using Minder to automatically pin actions to commit SHAs

At Stacklok, we recently wrote about how we use Minder internally to lock down our repositories. We're also using Frizbee in Minder to help us lock down our GitHub Actions!

Minder is an open source platform that helps you apply and automatically enforce security policies and practices across GitHub repos. It allows you to express the desired settings of your supply chain with profiles, and then attempts to reconcile the state of the repositories with those profiles. 

Depending on settings of the profile, Minder can either alert you when an entity such as a repository or an artifact diverges from the settings in the profiles, or even attempt to remediate the finding itself. In the context of this post, Minder can alert you when a repo does not have pinned actions, and use Frizbee to autoremediate that by pinning the actions to the appropriate SHAs. We do this by wrapping Frizbee's API in a Minder rule type that you can add to a profile. When this rule type is in place, Minder will then establish which actions are in use by your repos, and pin the actions, so that they are using commit SHAs instead of tags.

Creating a global inventory of your GitHub Actions

While helpful in automating CI or deployments, each GitHub action represents a security risk. Unvetted actions can expose the repository to potential exploits or breaches. Limiting the actions used in your environment can make the supply chain environment more controlled, predictable, and reliable. Knowing what actions are permitted in your repositories also enables you to vet and scrutinize their code, giving you a defined list of actions you need to look at.

Establishing the allowlist of actions permitted is easier when you start from scratch, as you can keep adding vetted actions to your allowlist as you take them into use. However, that’s rarely the case. Most projects already start their security journey from where they already have existing workflows and deployed actions. Therefore, the first step is to establish an inventory of actions that you actually are using at that moment. We can do that by using Minder’s repo_action_allow_list rule_type in a profile.

Provided that we have a repository enrolled with Minder and the minder-rules-and-profiles repository cloned, we can create the aforementioned rule_type first:

Shell (Bash)
$ minder auth login
$ minder rule_type create -f /path/to/minder-rules-and-profiles/rule-types/github/repo_action_list.yaml  

The way the rule_type works is that it clones the repo, lists all the actions in the workflows by making a call to the Frizbee API, and finally compares that set of actions with the set provided to the profile in the actions parameter. On failure, the rule also prints all the actions that were not present in the actions allowlist. We can take this into use by creating a profile with an empty allow list. Such a profile would always fail, but it would also report all the actions used by the enrolled repositories!

Let’s create the profile, then:

Shell (Bash)
$ cat ~/devel/minder-profiles/list-actions-empty.yaml
---
version: v1
type: profile
name: list-actions-github-profile
context:
  provider: github
alert: "off"
remediate: "off"
repository:
  - type: repo_action_allow_list
    def:
      actions: []

Creating the profile triggers Minder’s reconciliation loop and evaluation of the profile. We can then view the profile status. Note that we’re using the -o json switch to tell Minder that we want JSON; this will come in handy later.

Shell (Bash)
$ minder profile status list -i list-actions-github-profile -d -o json

This command gives an output that is long and looks a bit hairy. We’ve included just a snippet for brevity, which shows the result of running this profile on the Minder repo:

Yaml
    {
      "profileId": "69ccef4d-65cc-4c31-8a2e-701cd13ea285",
      "ruleId": "3e9f9ce7-895d-4036-86df-e14d9b9b117e",
      "ruleName": "repo_action_allow_list",
      "entity": "repository",
      "status": "failure",
      "lastUpdated": "2023-12-13T12:03:13.531513Z",
      "entityInfo": {
        "provider": "github",
        "repo_name": "minder",
        "repo_owner": "stacklok",
        "repository_id": "efc2596d-7a53-44bf-9e52-b9e469d3f5d1"
      },
      "details": "[{\"actions_not_allowed\":[\"GoTestTools/gotestfmt-action\",\"actions/checkout\",\"actions/setup-go\",\"actions/setup-node\",\"actions/stale\",\"anchore/sbom-action/download-syft\",\"aquasecurity/trivy-action\",\"azure/setup-helm\",\"bufbuild/buf-breaking-action\",\"bufbuild/buf-lint-action\",\"bufbuild/buf-setup-action\",\"docker/build-push-action\",\"docker/setup-buildx-action\",\"github/codeql-action/analyze\",\"github/codeql-action/autobuild\",\"github/codeql-action/init\",\"golangci/golangci-lint-action\",\"goreleaser/goreleaser-action\",\"ko-build/setup-ko\",\"peaceiris/actions-gh-pages\",\"peter-evans/create-pull-request\",\"sigstore/cosign-installer\",\"slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml\",\"slsa-framework/slsa-verifier/actions/installer\"]}]",
      "guidance": "Having an overview over which actions and reusable workflows are allowed in a repository is important and allows for a better overall security posture.\n\nFor more information, see\nhttps://docs.github.com/en/rest/actions/permissions#set-allowed-actions-and-reusable-workflows-for-a-repository\n",
      "remediationStatus": "skipped",
      "remediationLastUpdated": "2023-12-13T12:03:13.534023Z"
    }

But the reason we want this output is that it’s machine readable. Note that the details field contains the list of actions that were not allowed by our profile and because we didn’t allow any, it is the list of all the rules that our repository currently uses. With a little bit of JQ, we can turn this into a much nicer list that gives us an overview of all the repositories that we have enrolled in a single call:

Shell (Bash)
$ minder profile status list --provider=github -i list-actions-github-profile -d -ojson | jq '.ruleEvaluationStatus | map(select(.ruleName == "repo_action_allow_list" and .status == "failure")) | map(.details | fromjson | .[].actions_not_allowed) | add | unique'
No config file present, using default values.
[
  "1password/load-secrets-action",
  "GoTestTools/gotestfmt-action",
  "actions/checkout",
  "actions/download-artifact",
  "actions/setup-go",
  "actions/setup-node",
  "actions/stale",
  "actions/upload-artifact",
  "anchore/sbom-action/download-syft",
  "aquasecurity/trivy-action",
  "arduino/setup-task",
  "azure/setup-helm",
  "bearer/bearer-action",
  "bufbuild/buf-breaking-action",
  "bufbuild/buf-lint-action",
  "bufbuild/buf-setup-action",
  "docker/build-push-action",
  "docker/login-action",
  "docker/metadata-action",
  "docker/setup-buildx-action",
  "github/codeql-action/analyze",
  "github/codeql-action/autobuild",
  "github/codeql-action/init",
  "golangci/golangci-lint-action",
  "goreleaser/goreleaser-action",
  "ko-build/setup-ko",
  "peaceiris/actions-gh-pages",
  "peter-evans/create-pull-request",
  "sigstore/cosign-installer",
"slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml",
  "slsa-framework/slsa-verifier/actions/installer"
]

This is already actionable! Now we have a single list of all the actions across Stacklok’s repositories and either use that list to inspect the actions we use, or use the resulting list as an input for another rule using the allowed_selected_actions rule_type, which restricts the actions usable for repositories.

Autoremediation for Actions pinning

Similarly to the Minder rule mentioned above, we also took advantage of the Frizbee functionality in a remediation for a rule that ensures that all actions across workflows in registered repositories are referred to by a commit checksum.

To try this rule and its remediation out, first, make sure that you have the appropriate rule_type created:

Shell (Bash)
$ minder rule_type create -f /path/to/minder-rules-and-profiles/rule-types/github/actions_check_pinned_tags.yaml 

Then we’re going to create a profile that uses this rule_type:

Yaml
---
version: v1
type: profile
name: actions-resolve-to-tags
context:
  provider: github
alert: "on"
remediate: "on"
repository:
  - type: actions_check_pinned_tags
    def: {}

Once this profile is created, as long as any workflows in your enrolled repositories are referring to an action by a tag, the profile will fail—that is expected. However, you should also see a pull request being opened against each enrolled repository, as well as a GHSA.

The pull requests would include a patch, created by Frizbee and delivered to your repository’s doorstep by Minder that changes those tags to the corresponding SHA. This change ensures that the actions consistently reference the same code, safeguarding against the scenario where an attacker compromises an action’s repository and injects malicious code under an existing tag.

We recently deployed this rule in our Stacklok profile that we use to secure our own repositories. Upon profile creation, Minder opened pull requests against all our enrolled repositories, including one pull request against the Minder repository itself.

You can see an example of that pull request here:

Minder screenshot of PR for unpinned actions

Inspecting the patches in this pull request indeed shows that the actions were replaced by their respective SHA hashes - all automatically and done across all repositories enrolled with Minder. As an example, here’s a pull request opened against a different repository enrolled with the same Minder instance as a result of reconciling the same profile.

Screenshot: Minder reconciliation loop

Merging those PRs would trigger Minder’s reconcile loop which would then in turn mark the profile as succeeding, close the GHSAs, and most importantly, make your repository more secure.

Try it out!

Using Minder to keep an inventory of your GitHub Actions and ensure that actions are always pinned to commit SHAs can help you keep your actions and code more secure.

To test this out, you can use our Minder quickstart to enroll your repos and create your first profile in less than a minute. If you have questions or ideas on this, let us know by joining our new Discord server! We'd love to hear your feedback on this feature and help you get it up and running.

You can also check out this demo to see how this feature works in action:

Thanks for reading!