🐧

pull_request_target で GitHub Actions の改竄を防ぐ

2023/10/22に公開

本記事では GitHub Actions で pull_request event の代わりに pull_request_target を用い、 workflow の改竄を防いでより安全に CI を実行する方法について紹介します。
まずは前置きとして背景や解決したいセキュリティ的な課題について説明した後、 pull_request_target を用いた安全な CI の実行について紹介します。
本記事では OSS 開発とは違い業務で private repository を用いて複数人で開発を行うことを前提にします。

長いので要約

  • GitHub Actions で Workflow の改竄を防ぎたい
  • GitHub の branch protection rule や codeowner, OIDC だけでは不十分なケースもある
  • pull_request event の代わりに pull_request_target を使うことで workflow の改竄を防げる
  • workflow の修正をテストしづらくなるというデメリットもあるので、まずは Terraform の Monorepo など強い権限やクレデンシャルを扱うリポジトリにだけ導入するのが良さそう
  • クレデンシャルは GitHub Secrets ではなく AWS Secrets Manager などで管理し、 OIDC によってアクセス制御するのが良い
  • pull_request_target に移行するには幾つか修正が必要
    • context や環境変数が変わるので workflow や action, reusable workflow の修正が必要(サードパーティの action などに修正が必要ないか要確認)
    • feature branch の action やスクリプトは改竄できてしまうので使うのをやめる
    • merge commit を checkout するために merge_commit_sha を取得するには GitHub API で Pull Request を取得する必要がある
    • OIDC の condition を修正する。 repo, ref, event_name, base_ref などをチェックするようにする
      • AWS の場合、 GitHub API でリポジトリの OIDC の subject claim を修正する必要がある

前置き

GitHub Actions は最も人気のある CI のプラットフォームの一つといって良いでしょう。
CircleCI や AWS CodeBuild など他のプラットフォームから GitHub Actions に移行したという話もよく聞きますし、自分も移行したことがあります。

https://blog.studysapuri.jp/entry/2022/02/04/080000

多くの開発者が最も使いたいプラットフォームと言っても過言ではないでしょう。

しかし、セキュリティ意識の高い組織ではセキュリティ的な理由から特定のリポジトリでは GitHub Actions を使えないという場合もあります。
大きな理由の一つは、 Workflow ファイル .github/workflows/*.yaml を改竄して任意のコマンドを CI で実行できてしまうことです。

そのため、例えば Mercari は Terraform Monorepo の CI に Google Cloud Function と Google Cloud Build を使った独自の仕組みを構築しています。
この仕組みの優れているところの一つは CI に関するコードが Terraform Monorepo とは別のリポジトリで管理されており、 CI を改竄できないようになっていることです。

https://engineering.mercari.com/en/blog/entry/20220121-securing-terraform-monorepo-ci/

しかし上述の通り GitHub Actions はとても優れたプラットフォームなので、なんとかして GitHub Actions を使いたいものです。
ここでは CI に関するセキュリティ的な注意点や課題とそれを解決する方法について考察します。

Pull Request の CI のリスクの一つは CI やコードレビューをバイパスして危険な Pull Request がマージされることですが、 GitHub では branch protection rule で Pull Request をマージするには CI がパスすることと codeowner のレビューを必須にすることができます(レビューで見逃す可能性はありますが)。
ただし codeowner のレビューが必須になっていなかったり、自動化のために machine user の GitHub Token を CI で使っていてかつその machine user が codowner で approve 出来てしまうといった場合、 CI を改竄することで悪意のある Pull Request をマージできてしまうリスクがあるので、そこは注意が必要です。

別の問題は workflow を改竄することで feature branch の CI で任意のコードが実行できることです。
この CI で AWS などに対して強い権限を使って任意のコードが実行できてしまうと大変問題です。

逆に言うと、 CI で強い権限を扱わないようなユースケースでは CI が改竄されて任意のコードが実行されてしまっても比較的リスクは低く、安心して GitHub Actions を使うことが出来ます。
つまりセキュリティ意識の高い組織が先述の Mercari のような仕組みを導入する場合であっても、全てのリポジトリで導入する必要はなく、特にリスクの高いリポジトリでのみ使えば良いでしょうということです。

幸い GitHub Actions は OIDC によって AWS や Google Cloud に対して keyless でアクセスすることが可能であり、かつ認証に際して branch などで制限をかけることが出来ます。

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

そのため default branch の CI でのみ強い権限を付与し、 Pull Request の CI では弱い権限しか付与しないということが可能です。
これによって安全に GitHub Actions を使用できるケースも十分あります。

しかしこれでもなお不十分なケースもあります。
AWS や Google Cloud などと違い、一部の SaaS は OIDC や IAM による細かな権限制御に対応しておらず、 CI のために強い権限を持った API Key などを使わなければならない場合があります。
Terraform の CI などでは Pull Request の CI で terraform plan を実行する場合が多く、 Terraform で管理する SaaS の API key などが必要になり、リソースの作成や変更・削除といった権限を持ったクレデンシャルを CI で使わざるを得ないケースがあります。
読み取り権限だけであっても悪用されては困る場合もあるでしょう。
workflow を改竄できればそれらのクレデンシャルを悪用して任意のコマンドが実行できてしまいます。
多くの SaaS は AWS や Google Cloud のような Public Cloud よりは攻撃のリスクが低く、セキュリティと利便性のトレードオフからリスクを許容できるケースもあるかもしれません。
ただ許容できず、結果 GitHub Actions が使えないという場合もあるでしょう。

とまぁ、前置きが長くなりましたが、ここから pull_request_target の話に入ります。

pull_request_target による workflow の改竄の防止

pull_request_target は GitHub Actions の workflow をトリガーする event の一つです。

https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target

pull_request_target については Fork からの Pull Request による CI で GITHUB_TOKEN に write 権限を付与したり secrets を参照したいような場合で語られることが多いですが、本記事では private repository で Fork せずに開発することを前提としているので、話が若干異なります。

https://securitylab.github.com/research/github-actions-preventing-pwn-requests/

pull_request_target のリスクとして語られる write 権限や secrets の悪用・漏洩も関係ありません。
private repository で Fork せずに開発する場合、 pull_request event であっても write 権限や secrets の参照は出来るからです。

むしろ workflow 実行時の context の違いが重要です。
pull_request_target では pull request の base branch の最新コミットで workflow が実行されるため、 Pull Request で workflow を改竄することが出来ません。
逆に言うと workflow の修正を CI でテストしづらいといったデメリットもありますが、それは今回の要件では仕方がありません。

pull_request_target に馴染みがなく workflow を改竄できないというのがどういうことかよくわからない人は、以下の workflow を適当なリポジトリの default branch に追加し、その workflow を適当に書き換えて Pull Request を作成してみてください。

name: test
on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "$EVENT"
        env:
          EVENT: ${{toJSON(github)}}

Pull Request を作成すると改竄前の workflow の定義に基づいて workflow が実行されるはずです。また Pull Request で workflow file を削除しても workflow は実行されます。
これが pull_request event との大きな違いです。

ただし、 workflow の改竄が出来ないと言っても、 feature branch のスクリプトなどを workflow から実行している場合、そのスクリプトを改竄することで任意のコマンドが実行できてしまうケースもありますので、その点には注意が必要です。
スクリプトや action を実行したい場合、別のリポジトリで管理するか、 default branch などから checkout したものを実行する必要があるでしょう。

また pull_request の代わりに pull_request_target を使う場合、 context や環境変数が異なるため pull_request_target を想定していない workflow や action, スクリプトが動かなくなる可能性があることも注意が必要です。
例えば以下の Context 及び環境変数が異なります。

  • event_name, GITHUB_EVENT_NAME
  • ref, GITHUB_REF
  • sha, GITHUB_SHA
  • ref_name, GITHUB_REF_NAME

pull_request_targetactions/checkout を使って Pull Request の merged commit を checkout したい場合は、 ref input を指定して上げる必要があります。これについては後述します。

OIDC で AWS などの Cloud にアクセスする場合、適切に認証を制限する必要があります。これについても後述します。
適切に認証を制限した上で、 AWS Secrets Manager などでクレデンシャルを管理し OIDC 経由でクレデンシャルを取得するようにすることで、クレデンシャルへのアクセスを適切に制限できます。
GitHub Actions の Secrets でも Environment を使って branch level でアクセス制御できますが、 branch level だけでは Pull Request の CI で用いるクレデンシャルに改竄した workflow からアクセスできてしまいます。
OIDC では様々な claim を用いてより柔軟にアクセス制御出来る(Cloud Provider 次第ですが、 AWS と Google Cloud は出来る)ので、 pull_request_target と組み合わせれば Pull Request の CI で用いるクレデンシャルの悪用を防ぐことが出来ます。

merge commit の checkout

pull_request_target では context が base branch の head になるので、 actions/checkoutref を指定しないと base branch が checkout されてしまいます。
そのため、 pull request のコードを checkout するには ref を指定する必要があります。
feature branch の head で良ければ ${{github.event.pull_request.head.sha}} で良いでしょう。
ただし、 pull_request 同様 merge commit を checkout しようと思うと、これが意外と面倒です。

${{github.event.pull_request.merge_commit_sha}} を指定すれば良さそうに見えますが、実はこの値は最新の merge commit の sha ではありません。
細かい説明は省きますが、 pull_request_target で最新の merge commit の sha を取得するには GitHub API で Pull Request を取得する必要があります。
複数の job で actions/checkout を実行する場合、 job ごとに API を叩くのは無駄なので親 job で API を叩いて merge_commit_sha を取得し、後続の job に 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}}

merge_commit_sha を取得するために簡単な action を作りました。

https://github.com/suzuki-shunsuke/get-pr-action

GitHub API を叩いているだけなので actions/github-script でも十分だと思います。
job を増やしたくないのであれば job ごとに API を叩いても良いでしょう。

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

OIDC の設定

具体的な設定は Cloud Provider によって異なりますが、今回は AWS を題材にします。

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

今回はシンプルな GitHub Flow で、 Pull Request の CI でテストを実施し、 merge されたら deploy が実行されるようなケースを考えます。
Pull Request 用の IAM Role と deploy 用の IAM Role を用意し、それぞれ OIDC で Assume するとしましょう。
GitHub Actions は以下のような claim をサポートしています。

例えば AWS の場合、以下のような condition を設定します。

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"
  }
}

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:*"
  }
}

ここでは以下の 4 つの claim を参照しています。

  • repo
  • event_name
  • base_ref
  • ref

これにより、以下のような攻撃を防いでいます。

  • Pull Request から default branch 用の IAM Role を Assume
    • default branch への push でのみ許可しているので無理
  • pull_request event を用いた workflow を追加し、 Pull Request 用の IAM Role を Assume
    • event_name が pull_request_target のみ許可しているので無理
  • 適当な feature branch に pull_request_target event を用いた workflow を追加し、その branch に Pull Request を投げて workflow を実行し、 Pull Request 用の IAM Role を Assume
    • base_ref が main のみ許可しているので無理

ここから更に特定の workflow でのみ認証を許可したい場合、 workflow を追加するということも考えられます。

通常の GitHub Flow ではあまりない話ですが、ある feature branch に別の feature branch から Pull Request を投げるといったことをした場合、 Pull Request 用の IAM Role を Assume 出来ないことに気をつけてください。
これを許してしまうと、 workflow を改竄した上で Assume Role して任意のコードを実行できてしまい、 pull_request_target を使っている意味がなくなってしまいます。
base branch の pattern が決まっているのであれば、その pattern にも default branch 同様の branch protection rule を設定した上で Assume Role を許可することは可能でしょう。

workflow_dispatchschedule など、他の event で実行している workflow から同じ IAM Role を Assume している場合、別の IAM Role を Assume するようにするか OR 条件を追加する必要があります。その場合、 workflow の改竄が出来ないように ref を default branch などに制限します。

"StringLike": {
  "token.actions.githubusercontent.com:sub": [
    "repo:octo-org/octo-repo:event_name:pull_request_target:base_ref:main:*",
    "repo:octo-org/octo-repo:event_name:workflow_dispatch:base_ref::ref:refs/heads/main",
    "repo:octo-org/octo-repo:event_name:schedule:base_ref::ref:refs/heads/main"
  ]
}

上記の Condition では実はこのままでは認証に失敗します。
デフォルトの subject claim には event_name や base_ref が含まれていないからです。
GitHub API を使い、 repository の subject claim をカスタマイズする必要があります。

https://docs.github.com/en/rest/actions/oidc?apiVersion=2022-11-28#set-the-customization-template-for-an-oidc-subject-claim-for-a-repository

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"

OIDC での問題をデバッグする際には github/actions-oidc-debugger が便利です。
subject claim の値を出力してくれます。

OIDC の設定 - Google Cloud の場合

https://cloud.google.com/iam/docs/workload-identity-federation

Google Cloud では Workload Identity Federation の Pool の設定で以下のように属性のマッピングをします。

attribute.repository = assertion.repository
attribute.event_name = assertion.event_name
attribute.base_ref   = assertion.base_ref
attribute.ref        = assertion.ref
attribute.workflow   = assertion.workflow

そして Provider の Condition で制限をかければ良いでしょう。

pull_request_target 用

attribute.repository == "octo-org/octo-repo" && 
  attribute.event_name == "pull_request_target" &&
  attribute.base_ref == "main"

default branch 用

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

workflow の変更のテスト

pull_request_target の欠点は workflow の変更が Pull Request をマージするまで反映されないため、事前にテストをしづらいということです。
特に Renovate で action などの更新を自動マージしている場合、突然 CI が壊れるということが有り得てしまいます。

ただしこれは pull_request_target の欠点というより、 CI を Pull Request で改竄できないようにするアプローチを取った場合全般に言えることであり、 GitHub Actions 以外を使って実現した場合でも同様の問題は起こるはずです。

これに対する対策としては、 workflow が変更されたら強い権限が不要な形で変更後の workflow を実行してテストするということが考えられます。
もっとも、言うのは簡単ですが、実現するのは結構難しいですし、実現不可能なケースもあるでしょう。
pull_reuest_target で実行する job を Reusable Workflow として別の workflow に切り出し、その workflow が更新されたら workflow の input で強い権限を与えない形で実行することが考えられます。
例えば Terraform の Monorepo の場合、強い権限を必要とする provider のリソースを除外し AWS や Google Cloud だけのリソースを含んだテスト用の working directory を用意し、 read only 権限で terraform plan などが実行できるかテストできるはずです。

まぁ実装の難易度はケースバイケースですし、そこまでやらなくても良いという判断も十分あるかと思います。
Renovate で workflow を更新する際は自動マージを無効化するのも一つの手でしょう。

追記: アプリケーションのテストコードなどでの任意のコード実行対策

この記事への SNS での反応を見ていると、アプリケーションのテストコードや Terraform の local-exec provisioner で任意のコードが実行できてしまうので pull_request_target の意味は限定的だという指摘を見かけましたので、補足をしておきたいと思います。

結論を言うと、 pull_request_target を使っても悪意のあるコードが実行出来てしまうケースは当然ありますが、実行環境に渡す権限や secrets を必要最小限に限定できますし、そういった制限を無効化出来ないようにするために workflow を改竄できないようにすることは非常に重要です。

渡す権限や secrets を必要最小限にするのは pull_request_target の使用の有無に関わらない一般的なプラクティスです。
例えば GitHub Actions では test と release で job を分け test job には release に必要な権限を渡さないということも行われます。

また Terraform の local-exec provisioner に関しては、そもそも local-exec は terraform apply 時に実行されるもので Pull Request で任意のコマンドが実行できてしまうものではないですし、 Conftest などで制限することも可能です。

https://engineering.mercari.com/en/blog/entry/20220519-terraform-ci-code-execution-restrictions/

workflow の改竄が出来てしまうと、こういった制約を無効化出来てしまいます。それを防ぐためにも workflow を改竄できないようにすることが非常に重要です。

tfaction を用いた検証

自分が開発している tfaction という OSS を用いて構築している Terraform の workflow があったので、そちらを pull_request から pull_request_target に移行してみました。

以下のような修正が必要でした。

  1. pull_request_target に対応していない action や reusable workflow の修正
  2. feature branch のスクリプトや action を実行している箇所を修正
  3. pull_requestpull_request_target に変更
  4. actions/checkout の ref を指定
  5. github-commenttfcmt 実行時に環境変数 GH_COMMENT_SHA1 及び TFCMT_SHA に merge_commit_sha を設定する
  6. AWS の IAM Role の Trusted entities の修正
  7. GitHub の OIDC の subject claim のカスタマイズ

幾つかの action 及び reusable workflow が pull_request_target を想定していなかったため、修正が必要でした。

また、 feature branch の action を実行している箇所を修正しました。

uses: ./.github/actions/foo # 改竄出来てしまうので修正

こちらの修正は実際には composite action の中身を workflow の steps に展開する形で修正しましたが、次のように default branch の action を実行するのもありかもしれません。

uses: <repo owner>/<repo name>/.github/actions/foo@main

tfcmt や github-comment を使っている場合、 merge commit の sha を環境変数に設定する必要があります。
step 単位で設定するのも面倒なので job 単位で設定してしまっても良いでしょう。

- run: github-comment exec -- github-comment hide
  env:
    GITHUB_TOKEN: ${{github.token}}
    # 環境変数で渡してますが、コマンド引数 -sha で渡しても可
    GH_COMMENT_SHA1: ${{needs.get-pr.outputs.merge_commit_sha}}

OIDC で IAM Role を Assume する condition を変更するために tfaction が提供する Terraform Module を修正しました。

https://github.com/suzuki-shunsuke/terraform-aws-tfaction/pull/52

そして Module の呼び出し側で variable を指定して Condition を修正しました。
tfaction の Drift Detection で workflow を定期実行するために workflow_dispatch と schedule も許可しました。

  assume_role_policy_pr_conditions = [
    {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    },
    {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:${local.repo}:event_name:pull_request_target:base_ref:${local.main_branch}:*",
        "repo:${local.repo}:event_name:workflow_dispatch:base_ref::ref:refs/heads/${local.main_branch}",
        "repo:${local.repo}:event_name:schedule:base_ref::ref:refs/heads/${local.main_branch}",
      ]
    },
  ]

  assume_role_policy_main_conditions = [
    {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    },
    {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${local.repo}:ref:refs/heads/${local.main_branch}"]
    },
  ]

pull_requestpull_request_target に変更する Pull Request をマージする際は変更した workflow が実行されず branch protection rule の条件を満たさなかったので、 admin 権限で強制的にマージしました。

移行する際に CI が動かなくなって修正が必要になったりしたのですが、 pull_request_target だと Pull Request をマージしないと変更が反映されないので、そこは不便だなと思いました(改竄を防ぐための変更なので当然ですが)。

セットアップ完了後、 workflow が正常に動くことに加え、先述の通り意図せぬ Assume Role が成功しないか確認しました。

さいごに

以上、 GitHub Actions で pull_request event の代わりに pull_request_target を用い、 workflow の改竄を防いでより安全に CI を実行する方法について紹介しました。
アイデアとしてはずっと前からあったのですが、長いこと寝かせたままになっていました。
執筆時点ではまだ個人の検証環境で軽く検証したくらいですが、軽く検証した限り問題なく動くので、是非これを活用し現状 GitHub Actions が使えていないようなリポジトリでも GitHub Actions を導入したいと思っています。
tfactionv0.7.1pull_request_target で動くようにしたので、 tfaction をお使いの方は移行を検討してみてください。
ただ前置きの部分でも触れましたが、 pull_request_target を全てのリポジトリに適用する必要は必ずしもないと思っています。
workflow の修正のテストがしにくいといったデメリットもあります。
Terraform の Monorepo のようなより強い権限が必要で高いセキュリティが要求されるようなリポジトリに導入するのが良いでしょう
(ただしいきなり導入してトラブルが起こると業務影響が大きいかもしれないので、まずは適当なリポジトリで検証するのが良いでしょう)。

Discussion