Accessing secrets from forks safely with GitHub Actions
It feels like I see someone asking “how do I let pull requests from forks access my GitHub Actions secrets?” every other day. GitHub prevents PRs from forks from accessing secrets by default so that they can’t exfiltrate secrets and use them for malicious purposes.
There are posts out there that show you how to use pull_request_target
with actions/checkout
to check out the HEAD
ref, but this is insecure by default as it exposes all of your secrets to anyone that can raise a pull request against a repo (which is everyone on a public repo).
So, how do you get the best of both worlds? How can you safely provide secrets to forks without malicious actors stealing them?
GitHub Actions provides a triggering actor field that lets you know who ran (or re-ran) a workflow. This allows maintainers to provide access to secrets on forks once they’ve checked the changes manually and are happy that they don’t pose a security risk by re-running a job.
If you’re working in a private repo and want to enable secrets for forks, you can make them available via repo settings. This post is only relevant for public repos.
Here’s what the workflow would look like:
- Alice opens a pull request from her fork
- GitHub Actions runs and fails the build as she doesn’t have permissions on the repo
- Bob reviews the PR and decides that there’s nothing dangerous
- Bob re-runs the failed job
- GitHub Actions runs and the access check passes as Bob has
write
access to the repo github.event.pull_request.head.sha
is checked out and points to Alice’s changes- The rest of the workflow runs successfully with access to any repository secrets
So in short:
- Check permissions
- Checkout code
- Run tests with secrets
Check Collaborator Permissions
GitHub not trusting pull requests from forks is a proxy for “do we trust this person?”. Rather than asking the question “is the PR from a fork”, why don’t we check what permission the actor triggering the job has?
There are two ways to check permissions - author association or user permissions. Author association tells us if they have any permissions on the repository (they’ll be marked as a collaborator
no matter what permissions they have). User permissions tells us exactly what permissions they have on the repository.
If you want to learn more about author associations vs user permissions see Check permissions in a GitHub Actions workflow
If you’re not sure which to choose, I recommend checking the actor’s permissions rather than relying on author association.
Author Association
The author association is available by default in the pull request event. This means that you can check permissions directly in a workflow without using the GitHub API:
yaml
- name: Check accessif: ${{ github.event.pull_request.author_association != 'COLLABORATOR' && github.event.pull_request.author_association != 'OWNER' }}run: |echo "Event not triggered by a collaborator."exit 1
This step would check if the author of the PR has any access to the repo. If they do not, the workflow will exit with error code 1.
User Permission
Alternatively, you can check if the actor has specific permissions using a GitHub Action. I personally use the following action:
yaml
- name: Get User Permissionid: checkAccessuses: actions-cool/check-user-permission@v2with:require: writeusername: ${{ github.triggering_actor }}env:GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}- name: Check User Permissionif: steps.checkAccess.outputs.require-result == 'false'run: |echo "${{ github.triggering_actor }} does not have permissions on this repo."echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}"echo "Job originally triggered by ${{ github.actor }}"exit 1
The action returns outputs that you can then use to determine what to do next. I check the permission in the second step above and print a debugging message before exiting with an error code of 1 to fail the build.
You might notice that I use github.triggering_actor
rather than github.actor
. This is what allows us to run tests from forks if the job is re-run by someone with the correct permission level.
When checking for permissions, the available levels are none
, read
, write
and admin
. Although triage
and maintain
are permissions in GitHub itself, they are not reflected in the API.
Check out the new code
At this point it’s safe to check out the code as the job has been triggered by someone with write
access.
I mentioned earlier that using actions/checkout
with github.event.pull_request.head.sha
is insecure. However, as the PR has been reviewed and the workflow run has been triggered by someone with write
access we can be a little more trusting.
Here’s how to check out the code from the PR in your workflow by explicitly setting a ref
:
yaml
- name: Checkout codeuses: actions/checkout@v3with:ref: ${{ github.event.pull_request.head.sha }} # This is dangerous without the first access check
Run the tests
Your workflow will be different to mine at this point. If you’re testing if you’ve configured things correctly, you can set a secret named MY_SECRET
to val
and use the following step:
yaml
- name: Testrun: |if [[ "x${{ secrets.MY_SECRET }}" == "xval" ]]; thenecho "Access to secrets"elseecho "No access to secrets"exit 1fi
Putting it all together
Taking all of the above and combining it in to a single workflow gives us the following:
yaml
name: Run tests from forkon:pull_request_target:types: [opened, synchronize]jobs:demo:runs-on: ubuntu-lateststeps:- name: Get User Permissionid: checkAccessuses: actions-cool/check-user-permission@v2with:require: writeusername: ${{ github.triggering_actor }}env:GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}- name: Check User Permissionif: steps.checkAccess.outputs.require-result == 'false'run: |echo "${{ github.triggering_actor }} does not have permissions on this repo."echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}"echo "Job originally triggered by ${{ github.actor }}"exit 1- name: Checkout codeuses: actions/checkout@v3with:ref: ${{ github.event.pull_request.head.sha }} # This is dangerous without the first access check- name: Run testsrun: |if [[ "x${{ secrets.MY_SECRET }}" == "xval" ]]; thenecho "Access to secrets"elseecho "No access to secrets"exit 1fi
So there we have it - a mostly safe way to run workflows with access to secrets when a pull request is raised from a fork.
Why mostly safe rather than 100% safe? It relies on humans to read the pull request diff and make a good decision. We’re all human and mistakes happen. At some point a PR will get run that shouldn’t have been, and you’ll need to rotate your secrets.
I think it’s worth it. Being able to run your full suite of tests with secrets on PRs from forks is key to delivering robust software. If secrets get leaked by mistake, well, it’s always good to test your rotation procedure periodically 😁.