Secure GitHub Actions by pull_request_target
Tags: security, githubactions, cicd, devops
Published: 2023-10-23 01:34:11

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 の改竄を防ぐ](https://zenn.dev/shunsuke_suzuki/articles/secure-github-actions-by-pull-request-target)

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](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#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](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule), [code owners](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners), and [OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), 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](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#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.

```yaml
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.

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

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

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
1. 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

```json
"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

```json
"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](https://docs.github.com/en/rest/actions/oidc?apiVersion=2022-11-28#set-the-customization-template-for-an-oidc-subject-claim-for-a-repository).
Otherwise, the authentication would fail.

```sh
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.

1. Service Account for the default branch can create, read, update, and delete resources
1. 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](https://docs.github.com/en/rest/actions/oidc?apiVersion=2022-11-28#set-the-customization-template-for-an-oidc-subject-claim-for-a-repository).

## Secret Management

To access secrets securely in CI, you should manage them in secrets management services such as [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) and [Google Secret Manager](https://cloud.google.com/secret-manager) and access them via OIDC so that you can restrict access to them with OIDC claims.
[GitHub's Environment Secrets](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#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](https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables) and [Context](https://docs.github.com/en/actions/learn-github-actions/contexts) 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](https://github.com/suzuki-shunsuke/tfcmt) and [github-comment](https://github.com/suzuki-shunsuke/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](https://github.com/actions/checkout) on pull_request_target event, you need to [get the pull request by GitHub API](https://docs.github.com/en/free-pro-team@latest/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request) and set the merge commit hash to `actions/checkout` input `ref`.

```yaml
- 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](https://github.com/suzuki-shunsuke/get-pr-action) for this.

```yaml
- 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.

```yaml
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](https://aws.amazon.com/secrets-manager/) and [Google Secret Manager](https://cloud.google.com/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.