In this post, I describe how to build secure GitHub Actions workflows by pull_request_target
event instead of pull_request
event.
This post is based on my post written in Japanese. pull_request_target で GitHub Actions の改竄を防ぐ
GitHub Actions is one of the most popular CI platform.
GitHub Actions is powerful, but has a security concern that workflow files .github/workflows/*.yaml
can be tampered and malicious codes can be executed with secrets and permissions in CI.
To solve the issue, I propose using GitHub Actions' pull_request_target event instead of pull_request
event.
Note that in this post I talk about the enterprise software development on private repositories rather than OSS activities on public repositories, and I assume pull requests aren't sent from Fork repositories.
Before using pull_request_target
Before using pull_request_target
, you should utilize GitHub features such as Branch protection rules, code owners, and OIDC, and so on for security.
In this post I assume you are utilizing them properly already. Using pull_request_target
is a more advanced topic.
What and Why pull_request_target?
pull_request_target is one of the events triggering GitHub Actions workflows.
One of the differences between pull_request_target and pull_request is that pull_request_target triggers workflows based on the latest commit of the pull request's base branch.
Even if workflow files are modified or deleted on feature branches, workflows on the default branch aren't affected so you can prevent malicious code from being executed in CI without code review.
Example of pull_request_target
If you aren't familiar with pull_request_target and you can't understand how it prevents tampering, please add the following workflow to your repository's default branch.
name: test
on:
pull_request_target: # Use pull_request_target
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "$EVENT"
env:
EVENT: ${{toJSON(github)}}
Then please modify the workflow file and create a pull request to the default branch.
The workflow would be run based on the workflow file of the base branch and your modification wouldn't affect to the workflow run.
And even if you remove the workflow from the feature branch, the workflow would be run.
So malicious codes can't be run in CI unless they are merged into the default branch.
This is one of the diffrences between pull_request and pull_request_target.
Don't execute actions and scripts of feature branches
You shouldn't execute actions and scripts of feature branches because they can be tampered.
If you want to execute them, you should get them from safe other repositories or branches such as the default branch.
Secure OIDC Settings
To access Cloud Providers such as AWS and Google Cloud, you should use OIDC rather than secrets in terms of security.
GitHub supports various OIDC claims.
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#understanding-the-oidc-token
- https://token.actions.githubusercontent.com/.well-known/openid-configuration
You can prevent malicious authentication to OIDC with the following claims.
- repo
- event_name
- base_ref
- ref
If you want to allow the authentication only on the specific workflows, you can use the claim workflow
too.
I describe OIDC settings on AWS and Google Cloud.
AWS
You create two IAM Roles.
- IAM Role for the default branch can create, read, update, and delete resources
- IAM Role for pull requests can read resources
You can restrict the authentication to those IAM Roles by the following IAM Role's trust policy.
For the default branch
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:event_name:push:base_ref::ref:refs/heads/main"
}
}
For pull_request_target
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:event_name:pull_request_target:base_ref:main:*"
}
}
Then you can prevent the following attacks.
- Assume the IAM Role for the default branch on pull request
- This is impossible because only push event to the default branch is allowed
- Assume the IAM Role for pull requests by running malicious workflows with pull_request event
- This is impossible because only pull_request_target event is allowed
- Assume the IAM Role for pull requests by adding malicious workflows to any feature branches and sending pull requests with pull_request_target event to the branches
- This is impossible because base_ref must be main
In case of AWS, you need to set the customization template for an OIDC subject claim for the GitHub repository.
Otherwise, the authentication would fail.
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/$REPO/actions/oidc/customization/sub" \
-F use_default=false \
-f "include_claim_keys[]=repo" \
-f "include_claim_keys[]=event_name" \
-f "include_claim_keys[]=base_ref" \
-f "include_claim_keys[]=ref"
Google Cloud
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform
- https://cloud.google.com/iam/docs/workload-identity-federation
You create two Service Accounts.
- Service Account for the default branch can create, read, update, and delete resources
- Service Account for pull requests can read resources
You can restrict the authentication to those Service Accounts by the following Attribute mappings and Attribute conditions.
Attribute mapping
attribute.repository = assertion.repository
attribute.event_name = assertion.event_name
attribute.base_ref = assertion.base_ref
attribute.ref = assertion.ref
attribute.workflow = assertion.workflow
Attribute conditions
For CI on Pull Request
attribute.repository == "kouzoh/microservices-terraform" &&
attribute.event_name == "pull_request_target" &&
attribute.base_ref == "master"
For CI on the default branch
attribute.repository == "octo-org/octo-repo" &&
attribute.event_name == "push" &&
attribute.ref == "refs/heads/main"
Then you can prevent attacks same with AWS.
Unlike AWS, you don't have to set the customization template for an OIDC subject claim for the repository.
Secret Management
To access secrets securely in CI, you should manage them in secrets management services such as AWS Secrets Manager and Google Secret Manager and access them via OIDC so that you can restrict access to them with OIDC claims.
GitHub's Environment Secrets can also restrict the access but it supports only the restriction based on branches, so malicious workflows can access secrets for pull request CI.
As I described in the previous section, OIDC supports more flexible restrictions, so they are better than GitHub Secrets in terms of security.
Modify workflows for pull_request_target
The GitHub Actions built in environment variables and Context of pull_request_target event are different from those of pull_request event.
For example, the following environment variables and context are different.
- event_name, GITHUB_EVENT_NAME
- ref, GITHUB_REF
- sha, GITHUB_SHA
- ref_name, GITHUB_REF_NAME
You may need to fix scripts and actions so that they work well on pull_request_target events.
For example, if you use tfcmt and github-comment, which are my OSS, you need to set the merge commit hash to the environment variables TFCMT_SHA
and GH_COMMENT_SHA1
.
You also need to check if third party actions support the pull_request_target event.
Checkout merge commits
To checkout the merged commit with actions/checkout on pull_request_target event, you need to get the pull request by GitHub API and set the merge commit hash to actions/checkout
input ref
.
- uses: actions/github-script@v6
id: pr
with:
script: |
const { data: pullRequest } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
});
return pullRequest
- uses: actions/checkout@v4
with:
ref: ${{fromJSON(steps.pr.outputs.result).merge_commit_sha}}
I created a small action for this.
- uses: suzuki-shunsuke/get-pr-action@v0.1.0
id: pr
- uses: actions/checkout@v4
with:
ref: ${{steps.get-pr.outputs.merge_commit_sha}}
It is useless to call the GitHub API to get the merge commit hash everytime you run actions/checkout
, so it's good to get the merge commit hash in one job and pass the merge commit hash by the job's output.
jobs:
get-pr:
outputs:
merge_commit_sha: ${{steps.prs.outputs.merge_commit_sha}}
runs-on: ubuntu-latest
steps:
- uses: suzuki-shunsuke/get-pr-action@v0.1.0
id: pr
foo:
runs-on: ubuntu-latest
needs:
- get-pr
steps:
- uses: actions/checkout@v4
with:
ref: ${{needs.get-pr.outputs.merge_commit_sha}}
Note that the context value ${{github.event.pull_request.merge_commit_sha}}
isn't the latest merge commit hash.
Test of workflow changes
One of the drawbacks of pull_request_target is that it's difficult to test changes of GitHub Actions workflows in CI because changes aren't reflected until they are merged to the default branch.
Especially, if pull requests by Renovate are merged automatically, workflows may be broken suddenly.
To solve the issue, maybe you can run workflows with test files when workflow files are modified.
By separating workflows as reusable workflows, maybe you can test workflow changes with test inputs.
About Renovate, disabling auto-merge of actions updates is also one of options.
Conclusion
In this post, I described how to build secure GitHub Actions workflows by pull_request_target
event instead of pull_request
event.
Using pull_request_target
, you can prevent malicious codes from being executed in CI.
And by managing secrets in secrets management services such as AWS Secrets Manager and Google Secret Manager and access them via OIDC, you can restrict the access to secrets securely.
To migrate pull_request
to pull_request_target
, several modifications are needed.
And pull_request_target
has a drawback that it's difficult to test changes of workflows, so it's good to introduce pull_request_target
to repositories that require strong permissions in CI.
For example, a Terraform Monorepo tends to require strong permissions for CI, so it's good to introduce pull_request_target
to it.
Top comments (0)