Secure GitHub Actions by pull_request_target

suzukishunsuke

Shunsuke Suzuki

Posted on October 23, 2023

Secure GitHub Actions by pull_request_target

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)}}
Enter fullscreen mode Exit fullscreen mode

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.

https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect

GitHub supports various OIDC claims.

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

https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services

You create two IAM Roles.

  1. IAM Role for the default branch can create, read, update, and delete resources
  2. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Google Cloud

You create two Service Accounts.

  1. Service Account for the default branch can create, read, update, and delete resources
  2. 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
Enter fullscreen mode Exit fullscreen mode

Attribute conditions

For CI on Pull Request

attribute.repository == "kouzoh/microservices-terraform" && 
  attribute.event_name == "pull_request_target" &&
  attribute.base_ref == "master"
Enter fullscreen mode Exit fullscreen mode

For CI on the default branch

attribute.repository == "octo-org/octo-repo" && 
  attribute.event_name == "push" &&
  attribute.ref == "refs/heads/main"
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
suzukishunsuke
Shunsuke Suzuki

Posted on October 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related