Ken Muse

GitHub Actions Workflow Permissions


GitHub Actions Workflows can provide a great abstraction layer for creating or orchestrating build and release processes. These workflows depend on Actions, which are often provided by third parties that you may not know or trust. GitHub provides a lot of recommendations for security, but one of the most powerful of those is the permissions declaration in your workflow.

Trust? Who needs trust?

While most Actions are open-source (allowing you to verify), some rely on remote executables or Docker images to perform their work. As a result, you can’t always see exactly what you are executing. Even when you can review the code, most teams rely on pinning to a version tag. For example, actions/upload-artifact@v3. Tags are mutable by nature. This means that I can change what version of the code the tag is pointing to at any time.

Most Actions take advantage of this to help you. For example, when v3.1 is released, the v3 tag is updated to point to that instead of v3.0. This enables you to use semantic versioning to update the Action. The challenge is that a malicious developer can change the tag to anything they want. They could change tag to point to a malicious version of the code, completely unrelated to the purpose you had in mind. To combat this, you can pin to the full SHA hash associated with that version of the code. For example, instead of using @v3.1.2, I could use the associated hash. In this case, actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce. Because the hash is immutable, I can guarantee the version of the code.

The other side of the TOKEN

Whether or not a version tag is used, there’s an additional consideration – the GITHUB_TOKEN. This token can start with one of two permissions sets. It can default to read/write or read. This setting automatically configures permissions for multiple scopes, including Actions, Deployments, Pull Requests, Packages, and the repository itself. The Actions in the workflow automatically have access to the token. As a result, that third-party code has elevated permissions within your repository. Do you really want it to have access to so much information?

This is where the permissions key can help.

Permissions are key

GitHub Actions workflows allow you to specify permissions at the workflow level (for all jobs) and at the job level. When you use the permissions key in your workflow, the behavior of the token is changed. Instead of the default permissions, it receives only the permissions declared by your workflow. Any other permissions are set to none. This restricts what the workflow, job, and Actions can access. For example, I can limit my workflow to being able to read/clone the source code and being able to write security events with this snippet:

1permissions:
2  contents: read
3  security-events: write

Any scope that is not included (such as issues, packages, and actions) is removed. This means that instead of the default token, the jobs will receive a token with the restricted permissions. Permissions can also be set at the job level. Instead of receiving the default token or workflow-specified token permissions, the job will receive the specific permissions it requests. For example, having this at the job level:

1permissions:
2  contents: write
3  issues: read

Would remove the security-events: write that we had from the workflow level, upgrade the contents permission to write, and add permissions to read Issues.

This token is then made available to the declared Actions. As a result, any Action’s permissions to access the repository or its features will be limited. No matter how the underlying code is altered, the Actions will not be able to get access beyond what’s provided by the token. In this case, the Actions in this job could push changes to the repository, but they would have no access to read or write pull requests.

Reusable workflows

Reusable workflows also support permissions. You can specify the specific permissions required by the workflow to ensure that callers will have appropriate permissions to perform the required tasks. This can also ensure that a reusable workflow gets just enough permission to execute. This ensures the code is secure while documenting what is required to run.

A reusable workflow is similar to an Action. It can represent code or functionality that may change or perform work that isn’t entirely transparent to the caller. Consequently, when a reusable workflow is called it receives permissions in the same way as an Action. The reusable workflow will receive the permissions that are assigned to the job (explicitly as job permissions, indirectly via workflow permissions, or falling back to the default permissions). For example, if I create a reusable workflow that declares:

1permissions:
2  contents: read

And the calling workflow declares:

1permissions:
2  security-events: write

I will get an error:

1The workflow is not valid.
2.github/workflows/caller.yml (Line: 10, Col: 3): Error calling workflow 'my-org/my-repo/.github/workflows/reusable.yml@main'.
3The workflow 'my-org/my-repo/.github/workflows/reusable.yml@main' is requesting
4'contents: read', but is only allowed 'contents: none'.

My workflow or job permissions will restrict the reusable workflow from being able to execute. If the reusable workflow does not declare any permissions, it will default to the permissions provided by the job or workflow. I won’t get an immediate error, but the workflow will fail the first time an Action attempts to do something that requires more permissions than the workflow received.

Read the docs

You can see a final benefit of using permissions. It explicitly declares what the workflow (or job) requires, making it clear what minimum permissions are required for it to work. In addition to enforcing the principle of least privilege, it provides clear details about those permissions. Lots of benefits from just a few lines of code!

If you’re creating Actions workflows, consider always specifying the permissions. Your security team (and your team) will thank you!