This post is the third and final in a series of posts about GitHub Actions security. Part 1, Part 2

In previous blog posts, we discussed possible mistakes and abuse patterns that could lead to the compromise of your GitHub repository. This time, I’ll discuss sometimes less obvious — whose code GitHub Actions are running.

In a sense, actions are like serverless cloud functions. There is no computer constantly running the application, but actions are triggered by special events like pull requests or issue comments, and they run for an allocated time. The result (or state) of every run is loaded from and saved to the repository itself in the form of, for example, pull request comments, artifacts, or source code itself.

Actions allow you to quickly create automatic workflows from convenient building blocks: actions published by other developers. GitHub Marketplace has thousands of free actions available for consumption. By referencing an action with the uses: directive, you’re running third-party code and giving it access to:

Malicious actors could benefit from the computing time provided by a malicious or compromised action. That doesn’t just cost GitHub money, it also impacts you as the repository owner because GitHub limits the number of parallel actions run per repository. So a compromised or malicious action could potentially disrupt automatic workflows of your repository.

Read access to secrets, such as deployment keys, could also be used by malicious actors for lateral movement (in other words, for compromising other resources). Although only the secrets referenced or used in the workflow job are potentially accessible to the action, the repository token is different. Even if the GITHUB_TOKEN is not explicitly used in a workflow, it’s still available for all referenced actions. Attackers that control the YAML definition of the action may add, for example, a new input field and set the default value to the repository token:

inputs:
  random_name:
    default: ${{ github.token }}

It’s fair to assume that anyone who controls the YAML action definition has access to the temporary repository token in a context of the running workflow that consumes the action. This means you should carefully review the permissions you supply to the workflows you’re running.

Following the principle of least privilege

The principle of least privilege states that software should run with the minimal set of permissions needed to accomplish the task. This applies both to the privileges of secrets available for your workflows and the automatically supplied temporary repository token, which is based on the workflow trigger type.

For example, if a secret grants access to upload files to cloud storage, make sure it doesn’t give access to other storage containers. Restrict it to write-only and deny read and delete permissions. If you have different usage scenarios, it’s better to have different tokens for specific tasks rather than one universal token.

The permissions of the automatically supplied repository token GITHUB_TOKEN are restricted in the case of a pull_request from a fork. However they are quite permissive in other cases, like when the workflow is triggered by a new issue or a comment, so GitHub’s recommended security practice is to reduce all GITHUB_TOKEN permissions that your workflow doesn’t need. It’s even safer to change the default “read and write” permissions for your organization or repository to read-only:

workflow permissions settings

You can grant additional permissions to specific workflows on a case-by-case basis if needed:

jobs:
  job_name:
  ...
    permissions:
      issues: write

If access to any scope is specified, all unspecified scopes like contents, pull-requests or actions are set to none.

These workflow permissions map directly to the GitHub App permissions in terms of which API actions they allow.

Referencing actions

As you can see, adding a new action to a workflow requires careful consideration of security impact. Some actions have a “Verified creator” badge that can help you decide the level of trust you place in the action creator. However, the best approach is to audit the code behind the action, just like you would for open source libraries, to assess whether it’s reasonably secure and doesn’t do anything suspicious like sending secrets to third-party hosts. Thankfully, many actions are designed for a single purpose and are relatively easy to read.

Once you’ve verified the action’s code, there are multiple ways of referencing it in your workflow:

As you can see, all options are a tradeoff between guaranteed supply chain integrity and auto-patching of vulnerabilities in dependencies. In all cases except the last, it’s possible to configure Dependabot to create a pull request when the action is updated. In order to protect repository secrets, such pull requests are treated as if they come from external forks. You can set up actions to automatically accept and merge these pull requests from Dependabot. However, accepting changes without reviewing them isn’t the most secure approach. Every time the referenced action is updated, I recommend that you verify what has changed in the action source code.

Conclusion

GitHub Actions are a great way to rapidly build a functional CI/CD pipeline for your GitHub projects. From an attacker’s perspective, they are part of a much broader CI/CD attack surface that also includes any third-party artifact integrations and API interactions. Beyond the usual code review for untrusted input handling, CI/CD supply chain integrity requires careful vetting of your dependencies and any changes that occur in those dependencies. By applying the concepts of least privilege, change attestation, and tracking as well as ensuring that third parties don’t have mutable control over your CI/CD supply chain, you can actively start to take charge of your CI/CD supply chain security.