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
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 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!