🔑

GitHub Appsトークン解体新書:GitHub ActionsからPATを駆逐する技術

2023/04/05に公開4

GitHub ActionsではGITHUB_TOKENで権限が足りない場合、PAT(Personal Access Tokens)がよく使われます。しかしPATより優れた選択肢があります。それがGitHub Appsトークンです。本記事ではGitHub Appsトークンの実装方法をゼロから学びます。目標はPATの完全駆逐です。

本記事で学べること

  • PATとGitHub Appsトークンの違い
  • GitHub Appsの作成・インストール方法
  • GitHub ActionsでGitHub Appsトークンを払い出す方法
  • 本番運用で考慮すべきセキュリティとトレードオフ

イントロダクション

GITHUB_TOKENはGitHub Actionsのワークフロー開始時に自動生成され、終了時に自動削除されるトークンです。GITHUB_TOKENで済むなら、これがベストです。何も悩む必要はありません。問題はGITHUB_TOKENで実行不可能な処理を自動化したいときです。このようなケースで使われるのがPATやGitHub Appsトークンです。まずはこの両者の違いをみていきます。

PAT(Personal Access Tokens)

PATには2種類あります。最近よく使われるのはFine-grained PATです。細かいアクセス制御が可能で、有効期限の設定が必須という特徴を備えています。しかしGitHub Actionsでは少し扱いづらいです。なぜなら有効期限内にローテーションをしないと、ある日突然使えなくなるためです。

もう1つは「PAT (classic)」です。こちらはFine-grained PATと比較すると、アクセス制御が十分にできません。現状は使用を控えるべきなので、これ以上は説明しません。

GitHub Appsトークン

GitHub Appsトークンはその名のとおり、GitHub Appsを利用します。GitHub Appsには事前に秘密鍵を登録します。GitHub Appsではこの秘密鍵を使って、一時的に使用可能なトークンを払い出せます。Fine-grained PATと比較した場合、優位性は主に2つです。

突然動かなくなるリスクがない

GitHub Appsに登録した秘密鍵が、勝手に失効することはありません。つまりFine-grained PATのように、突然動かなくなるリスクはありません。もちろん秘密鍵の定期ローテーションは有益ですが、必須ではありません[1]。長期運用する前提であれば、これは大きなアドバンテージです。

トークンの有効期限が短い

GitHub Appsトークンは短命です。そのため万が一漏えいしても、影響は時限的です。「長命な秘密鍵」と「短命なトークン」という組み合わせは強力です。秘密鍵さえ守れば、インシデント発生時の被害はかなり小さくなります。これは長命なPATでは実現できない大きな魅力です。

本番運用においては「秘密鍵を信頼できる相手にのみ渡す」ことがポイントになります。のちほどトークンの払い出し方法について議論しますが、中心的な論点はココになります。

GitHub Appsのセットアップ

GitHub Appsトークンを利用するには、次のような事前準備が必要です。

  1. GitHub Appsを作成する
  2. 署名用の秘密鍵を生成する
  3. GitHub Appsをアカウントへインストールする
  4. SecretsにApp IDと秘密鍵を登録する

本記事では実験しやすいよう、個人アカウントでGitHub Appsを作成します。Organizationsで作成する場合も、手順自体はほぼ同じです[2]

1. GitHub Appsを作成する

GitHub Appsの作成は、個人アカウント向けとOrganizations向けで入り口が異なります。個人アカウントの場合は次のURLへアクセスします[3]

基本情報

GitHub Apps作成ページを開いたら、基本情報を入力します。

  • GitHub App name: グローバルで一意にする必要がある
  • Description: 空欄でも構わないが、長期運用するならちゃんと入れる
  • Homepage URL: 適当なダミー値を入れる(たとえばhttps://example.comなど)

基本情報の登録

名前をグローバルで一意にするため、命名規則があると便利です。たとえば「アカウント名をプレフィックスとして付与する」などと決めておきます。これだけで一意になる確率が高まります。

Webhook

下へスクロールします。Webhookは不要なので、「Active」のチェックを外しましょう。

Webhookの設定

Permissions

下へスクロールし、パーミッションを変更します。何を実行するかによって、必要なパーミッションは当然異なります。本記事では例として次のように設定します[4]

  • Repository permissions > Contents > Read and write
  • Repository permissions > Pull requests > Read and write

Permissionsの設定

Where can this GitHub App be installed?

最下部へスクロールします。プライベートで運用するため、「Only on this account」へチェックが入っているか確認します。最後に「Create GitHub App」をクリックすれば完了です。

Where can this GitHub App be installed?

なお「Only on this account」へチェックを入れても、GitHub Appsの名前は公開されます。公開先URLの構造は「https://github.com/apps/<app-slug>」です[5]。このURLへアクセスすると、次のようなページが表示されます。変な名前をつけるとバレます。

GitHub Appsの公開ページ

2. 署名用の秘密鍵を生成する

GitHub Appsが作成されると、管理ページが表示されます。ここで表示されている「App ID」は、のちほど必要になります。どこかにコピーしておきましょう。

GitHub Apps管理ページ

それではトークンの払い出しに使用する、署名用の秘密鍵を生成します。下へスクロールし、Private keysの「Generate a private key」をクリックします。

秘密鍵の生成

すると秘密鍵が生成され、<App名>.<作成日>.private-keyというファイル名でダウンロードを促されます。あとで使うため、デスクトップにでも置いておきましょう。

3. GitHub Appsをアカウントへインストールする

GitHub Appsは作成しただけでは使えません。インストールが必要です。管理ページの左メニューから「Install App」を選択し、インストール先アカウント[6]の「Install」をクリックします。

GitHub Appsのインストール

次にどのリポジトリへアクセスを許可するか設定します。

  • All repositories(全リポジトリを許可): 運用は楽だがセキュリティ的にイマイチ
  • Only select repositories(特定リポジトリのみ許可): セキュアだが運用は少し煩雑

設定したら「Install」をクリックしてインストールします。

GitHub Appsがアクセス可能なリポジトリの選択

なおリポジトリのアクセス許可設定では、できれば「Only select repositories」を選択しましょう。ちゃんと運用するのはメンドウですが、圧倒的に安心感は大きくなります。

4. SecretsにApp IDと秘密鍵を登録する

App IDと秘密鍵をGitHub Actionsから参照できるよう、Secretsへ登録します。SecretsはリポジトリレベルやOrganizationsレベルなど、いくつかの選択肢があります。どのレベルで登録すべきかは、ユースケース次第です。本記事ではリポジトリレベルで登録します。

まず登録対象のリポジトリを開き、「Settings → Secrets and variables → Actions → New repository secret」の順にクリックします。そして次のようにシークレットを登録します。

  • APP_ID: GitHub Apps管理ページでメモしておいたApp ID
  • PRIVATE_KEY: ダウンロードした署名用の秘密鍵

Secretsの登録

Secretsへ登録したら、秘密鍵を速やかにローカルから削除しましょう。ゴミ箱に残らないよう注意してください。平穏に過ごすため、秘密鍵はさっさと手放すに限ります。

サードパーティアクションによるGitHub Appsトークンの生成

GitHub Appsトークンの生成には、tibdex/github-app-tokenアクションがよく使われます。動作確認に便利なので、本記事でも最初だけこのサードパーティアクションを使用します。

ここで実装するのは、プルリクエストを作成するワークフローです。実はGITHUB_TOKENでも動きますが、動作確認のためにあえてGitHub Appsトークンを使用します。

name: Create Pull Request
on: workflow_dispatch
permissions:
  contents: write
jobs:
  create:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    env:
      BRANCH: test-${{ github.run_id }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Push
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git switch -c "${BRANCH}"
          git commit -m "Add empty commit" --allow-empty
          git push origin "${BRANCH}"

      - name: Generate GitHub Apps token
        id: generate
        uses: tibdex/github-app-token@v1
        with:
          app_id: ${{ secrets.APP_ID }}
          private_key: ${{ secrets.PRIVATE_KEY }}

      - name: Create PR
        env:
          GITHUB_TOKEN: ${{ steps.generate.outputs.token }}
        run: |
          gh pr create --base "main" --title "Test" --body "Test"

トークン生成部分を抜粋します。見てのとおりApp IDと秘密鍵をアクションへ渡すだけです。

tibdex/github-app-tokenによるトークン生成
      - name: Generate GitHub Apps token
        id: generate
        uses: tibdex/github-app-token@v1
        with:
          app_id: ${{ secrets.APP_ID }}
          private_key: ${{ secrets.PRIVATE_KEY }}

ちょっと動かしてみる程度であれば、これで終了してもよいでしょう。しかし個人アカウントではなく、Organizationsで運用する場合はもう一歩踏み込みたいところです。

サードパーティアクションの問題点

サードパーティアクションは便利ですが、攻撃対象になりやすいという特性があります。たとえばBash Uploaderが改ざんされたインシデントでは、世界中の企業が影響を受けました[7]。そんなサードパーティアクションですが、セキュリティ向上手法がいくつか知られています。

これらのアプローチにより、リスクは下がります。ただ決定打には欠けます。サードパーティアクションの利用は、「自リポジトリのセキュリティレベルが外部の第三者に依存する」ことを意味するからです。サードパーティアクションのセキュアな運用は難易度が高いです。

サードパーティアクションのトレードオフ

インシデント発生時の影響を考慮すると、サードパーティアクションには不安が残ります。トークン生成のようなクリティカルな仕事はなおさらです。万が一サードパーティアクションが侵害されると、GitHub Appsの権限がまるごと奪われかねません。GitHub Appsは複数リポジトリに関与する可能性が高く、ほぼ確実にGITHUB_TOKENの漏えいよりも被害が大きくなります。

結論として筆者個人は、「サードパーティアクションでGitHub Appsトークンを生成すべきではない」と考えています。サードパーティアクションはあまりにもアンコントローラブルであり、秘密鍵のように長命なクレデンシャルを渡す相手としては不適切です。

自前実装によるGitHub Appsトークンの生成

GitHub Appsトークンの生成は、サードパーティアクションに任せられません。そうなると我々に残された道は1つです。管理下にあるリポジトリへ自分で実装します

GitHub Appsトークンの払い出しは、難しくありません。実はシェル芸で十分です。詳細はこれからすべて説明しますが、本節の最後にコピー可能なスクリプトが置いてあります。結論だけ知りたい人はそちらを使ってください。では始めましょう。

処理フロー

GitHub Appsトークンの生成は、次のような流れで行います。

  1. App IDと秘密鍵を使って、APIアクセスに必要なJWTを生成する
  2. Installation APIを実行し、Installation IDを取得する
  3. Access Tokens APIを実行し、トークンを取得する

まず押さえるべきは、GitHub APIへのアクセスにはJWT(JSON Web Token)が必要な点です。そしてJWTを使って、2つのGitHub APIを実行します。これだけ理解できれば先に進めます。

Requirements

実装するスクリプトでは、「openssl」と「jq」を使います。Ubuntuランナーには最初から入っています。また次の環境変数も必要です。GitHub ActionsではSecrets経由で設定します。

  • APP_ID: GitHub AppsのID
  • PRIVATE_KEY: GitHub Appsに登録した署名用の秘密鍵

このスクリプトは一度実装すると、長期運用される可能性が高いです。そのため依存は極力小さくしてあります。目標はOSバージョンアップなどが行われても、そのまま動くコードです。

JWT生成用関数

まずはJWT生成に使う関数を2つ定義します。JWTはJSON形式のデータをコンパクトにやりとりする技術です。次のように「ヘッダー」「ペイロード」「署名」をピリオドで連結します。ただし各要素はBase64URLエンコードします。

  • <header>.<payload>.<signature>

詳細には立ち入りませんが、JWTの仕様はRFC7519を参照してください。また正確な理解にはRFC7515JWS(JSON Web Signature)も必要です。興味があれば読んでみましょう。

Base64URLエンコード関数

この関数では最初に、opensslコマンドで入力値をBase64に変換します。次にtrコマンドの文字列置換で、Base64URLに変換します。最後にパディング文字(=)を削除します。この変換アルゴリズムはRFC7515のAppendix Cに記載されたコードを、Bashで書き直したものです。

base64url() {
  openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

署名関数

署名する関数を定義します。秘密鍵はGitHub Appsで事前生成したものを使います。またメッセージダイジェスト関数として使えるのはSHA-256のみです。

sign() {
  openssl dgst -binary -sha256 -sign <(printf '%s' "${PRIVATE_KEY}")
}

JWTの生成

では定義した関数を利用して、JWTを生成していきます。ヘッダーやペイロードの仕様は、公式ドキュメント「Generating a JSON Web Token (JWT) for a GitHub App」に従います。

ヘッダー

ヘッダーのJSONは次のような固定値です。

JWTのヘッダー
{
  "alg": "RS256",
  "typ": "JWT"
}

これをBase64URLエンコードするので、スクリプトは次のようになります。

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"

ペイロード

ペイロードのJSONは次のような構造になります。

JWTのペイロード
{
  "iss": "<GitHub AppのID>",
  "iat": "<JWT作成時刻>",
  "exp": "<JWT有効期限>"
}

このJSONをBase64URLエンコードします。スクリプトは次のとおりです。

now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${APP_ID}" "${iat}" "${exp}" | base64url)"

ここで少し興味深いことを行っています。「iat」は作成時刻といいながら、実際には現時刻の60秒前に設定しています。これはスクリプトの実行環境とGitHub APIサーバの時刻ズレ対策です。公式ドキュメントにも、次のように記載されています。

To protect against clock drift, we recommend that you set this 60 seconds in the past

また有効期限「exp」については、10分以内にすべしと記載があります。本記事ではさらに短く、3分にしました。トークン生成後にJWTはお払い箱となるため、短くても問題ありません。

The time must be no more than 10 minutes into the future

署名

ヘッダーとペイロードをピリオドで連結し、その値に対して署名します。署名したら忘れずに、Base64URLエンコードします。

signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"

JWT

最後にここまで作成してきた各要素を、ピリオドで連結してJWTにします。これで完成です。

jwt="${header}.${payload}.${signature}"

JWTの作成といえば難しそうに聞こえますが、実装は思ったより簡単です。あとは生成したJWTを使って、GitHub APIを実行していくだけです。

Installation APIの実行

GitHubのREST APIを使って、GitHub AppsのInstallation IDを取得します。Installation IDはApp IDとは別物なので注意しましょう。GitHub Appsはインストール先ごとに異なるIDを割り当てます。それがInstallation IDです。トークンを生成するAPIでは、こちらが必要になります。

スクリプトの実装は次のとおりです。curlコマンドでInstallation APIを実行し、jqコマンドでidを取り出します。HTTPヘッダーのAuthorizationには、先ほど作成したJWTを渡します。

installation_id="$(curl --location --silent --request GET \
  --url "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/installation" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.id'
)"

スクリプトに出てくるGITHUB_API_URLGITHUB_REPOSITORYは、GitHub Actionsのデフォルト環境変数です。それぞれ次のような値がセットされています。

  • GITHUB_REPOSITORY:<org>/<repo>形式のリポジトリ名
  • GITHUB_API_URL:GitHub APIのURL(https://api.github.com

参考までにInstallation APIのレスポンスJSONを、折りたたんで置いておきます。

Installation APIのレスポンス
{
  "id": 12345678,
  "account": {
    "login": "tmknom",
    ......
  },
  "repository_selection": "selected",
  "access_tokens_url": "https://api.github.com/app/installations/12345678/access_tokens",
  "repositories_url": "https://api.github.com/installation/repositories",
  "html_url": "https://github.com/settings/installations/12345678",
  "app_id": 123456,
  "app_slug": "test-github-apps-for-tmknom",
  "target_id": 9876543,
  "target_type": "User",
  "permissions": {
    "contents": "write",
    "metadata": "read",
    "pull_requests": "write"
  },
  "events": [],
  "created_at": "2023-03-21T09:31:45.000Z",
  "updated_at": "2023-03-22T08:50:30.000Z",
  "single_file_name": null,
  "has_multiple_single_files": false,
  "single_file_paths": [],
  "suspended_by": null,
  "suspended_at": null
}

Access Tokens APIの実行

ようやくココまでたどり着きました。先ほど取得したInstallation IDを指定して、トークンを生成します。JWTでアクセスするのもInstallation APIと一緒です。スクリプトは次のとおりです。

token="$(curl --location --silent --request POST \
  --url "${GITHUB_API_URL}/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.token'
)"
echo "${token}"

参考までにAccess Tokens APIのレスポンスJSONを、折りたたんで置いておきます。

Access Tokens APIのレスポンス
{
  "token": "ghs_XXXXXXXXXXXXX",
  "expires_at": "2023-03-25T09:52:30Z",
  "permissions": {
    "contents": "write",
    "metadata": "read",
    "pull_requests": "write"
  },
  "repository_selection": "selected"
}

これでスクリプトは完成です。なお本スクリプトで生成するトークンは、正確には『Installationアクセストークン』と呼びます。本記事ではずっとGitHub Appsトークンと呼んでいましたが、以降は正式名称のInstallationアクセストークンという呼称を使います[8]

スクリプトの全体像

ここまでの内容をまとめましょう。実装したスクリプトは次のとおりです。

Installationアクセストークン生成スクリプト
#!/usr/bin/env bash

base64url() {
  openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

sign() {
  openssl dgst -binary -sha256 -sign <(printf '%s' "${PRIVATE_KEY}")
}

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${APP_ID}" "${iat}" "${exp}" | base64url)"
signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"
jwt="${header}.${payload}.${signature}"

installation_id="$(curl --location --silent --request GET \
  --url "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/installation" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.id'
)"

token="$(curl --location --silent --request POST \
  --url "${GITHUB_API_URL}/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.token'
)"
echo "${token}"

このスクリプトをscript.shファイルへ保存した場合、GitHub Actionsからは次のように利用できます。なお本記事ではスクリプトを直接実行していますが、実運用ではアクションとして切り出したほうが使いやすいでしょう。

Installationアクセストークン生成スクリプトの利用
- name: Create PR
  env:
    APP_ID: ${{ secrets.APP_ID }}
    PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
  run: |
    export GITHUB_TOKEN="$(./script.sh)"
    gh pr create --base "main" --title "Test" --body "Test"

Installationアクセストークンのセキュリティ強化

すでにこの時点でかなりセキュアですが、さらなるセキュリティ強化が可能です。使い勝手を損なわずに導入できるため、どうせなら徹底的にやりましょう。

Installationアクセストークンの実行時制約

GitHub Appsは複数のリポジトリへアクセス可能です。しかしGitHub Actionsでは特定のリポジトリのみアクセスできればよく、それ以外は不要なケースも多いです。そこでトークン生成時にリポジトリを制限しましょう。API実行時にPOSTのBodyとして、次のようなJSONを渡します。

リポジトリの制約
{
  "repositories": ["<リポジトリ名>", ...]
}

curlコマンドへ次のオプションを渡せばOKです。なおrepo_nameには、アカウント名を含めません。つまりuser-name/example-repoではなく、example-repoのような値を渡します。

curlコマンドのオプション
--data "$(printf '{"repositories":["%s"]}' "${repo_name}")"

たとえばGitHub Actionsを実行するリポジトリにのみ、アクセスを制限したいとしましょう。このケースではAccess Tokens APIの実行部分を次のように変更します。

Access Tokens APIの実行処理(変更後)
repo_name="$(echo "${GITHUB_REPOSITORY}" | cut -d '/' -f 2)"
token="$(curl --location --silent --request POST \
  --url "${GITHUB_API_URL}/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  --data "$(printf '{"repositories":["%s"]}' "${repo_name}")" \
  | jq -r '.token'
)"

これで指定したリポジトリ以外にはアクセスできなくなります。少しメンドウですが、不要なアクセス権限を落とせるので積極的に活用しましょう[9]

Installationアクセストークンの失効

Installationアクセストークンの有効期限は一時間です。PATと比べれば、はるかに短い時間です。しかしGitHub Actionsでの利用を前提にすると、これでも少し長い印象があります。たいていのワークフローは数分以内に終わるためです。

そこでInstallationアクセストークンが不要になったら失効させましょう。スクリプトは次のとおりです。HTTPヘッダーのAuthorizationには、Installationアクセストークンを渡します。

Installationアクセストークンの失効
curl --location --silent --request DELETE \
  --url "${GITHUB_API_URL}/installation/token" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${GITHUB_TOKEN}"

これでInstallationアクセストークンは一時間を待たず、即時に失効します。仮にInstallationアクセストークンが奪取されたとしても、失効させておけば影響をゼロに抑え込めます。

認証情報のログマスク

Installationアクセストークンは秘密にすべき値です。有効期限が比較的短いとはいえ、漏えいしないに越したことはありません。特にGitHub Actionsでは、意図せずログ出力してしまう事故が起きやすいです。そこで誤ってログ出力した場合に、マスクされるようにしておきましょう。

GitHub ActionsにはWorkflow commandsという仕組みがあり、この中でログマスクの機能が提供されています。実装は簡単で、マスク対象の値を特定の書式でechoするだけです。スクリプトの最終行「echo "${token}"」を次のように変更します。これでマスクされます。

Workflow commandsによるログのマスク
echo "::add-mask::${token}"

ただしこの実装だと、利用側ワークフローが壊れます。そこでGITHUB_OUTPUT経由で参照できるよう、さらに次の一行を追加します。これはGitHub Actionsで値の受け渡しに使う記法です。

GITHUB_OUTPUT経由で値の受け渡し
echo "token=${token}" >>"${GITHUB_OUTPUT}"

スクリプトを変更したら、最後に利用側ワークフローを修正します。

Installationアクセストークン生成スクリプトの利用(変更後)
- name: Generate GitHub Apps token
  id: generate
  env:
    APP_ID: ${{ secrets.APP_ID }}
    PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
  run: |
    ./script.sh

- name: Create PR
  env:
    GITHUB_TOKEN: ${{ steps.generate.outputs.token }}
  run: |
    gh pr create --base "main" --title "Test" --body "Test"

これで万が一ログへトークンを書き出してしまっても、***としか出力されなくなります。もちろんメモリ上では平文で値を保持しているため、100%の安全性があるわけではありません。しかしうっかり防止には役立ちます。多層防御の一環として、組み込むことをオススメします。

なお万全を期すなら、スクリプトの途中で出てくるシェル変数をマスクしてもよいでしょう。jwt変数などをマスクすれば、実装ミスによるログ漏えいの確率がさらに下がります。

おわりに

最後にセキュリティ強化バージョンのコードを、折りたたんで置いておきます。

Installationアクセストークン生成スクリプト(最終形)
script.sh
#!/usr/bin/env bash

base64url() {
  openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

sign() {
  openssl dgst -binary -sha256 -sign <(printf '%s' "${PRIVATE_KEY}")
}

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${APP_ID}" "${iat}" "${exp}" | base64url)"
echo "::add-mask::${payload}"
signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"
echo "::add-mask::${signature}"
jwt="${header}.${payload}.${signature}"
echo "::add-mask::${jwt}"

installation_id="$(curl --location --silent --request GET \
  --url "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/installation" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.id'
)"

repo_name="$(echo "${GITHUB_REPOSITORY}" | cut -d '/' -f 2)"
token="$(curl --location --silent --request POST \
  --url "${GITHUB_API_URL}/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  --data "$(printf '{"repositories":["%s"]}' "${repo_name}")" \
  | jq -r '.token'
)"
echo "::add-mask::${token}"
echo "token=${token}" >>"${GITHUB_OUTPUT}"
Installationアクセストークン生成スクリプト利用側ワークフロー(最終形)
create-pr.yml
name: Create Pull Request
on: workflow_dispatch
permissions:
  contents: write
jobs:
  create:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    env:
      BRANCH: test-${{ github.run_id }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Push
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git switch -c "${BRANCH}"
          git commit -m "Add empty commit" --allow-empty
          git push origin "${BRANCH}"

      - name: Generate GitHub Apps token
        id: generate
        env:
          APP_ID: ${{ secrets.APP_ID }}
          PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
        run: |
          ./script.sh

      - name: Create PR
        env:
          GITHUB_TOKEN: ${{ steps.generate.outputs.token }}
        run: |
          gh pr create --base "main" --title "Test" --body "Test"

      - name: Revoke GitHub Apps token
        env:
          GITHUB_TOKEN: ${{ steps.generate.outputs.token }}
        run: |
          curl --location --silent --request DELETE \
            --url "${GITHUB_API_URL}/installation/token" \
            --header "Accept: application/vnd.github+json" \
            --header "X-GitHub-Api-Version: 2022-11-28" \
            --header "Authorization: Bearer ${GITHUB_TOKEN}"

本記事ではGitHub Appsトークンについて、現時点で筆者が知るすべてを記しました。本記事の内容を実践すれば、PATレス社会の実現も夢ではありません。ぜひご活用ください。

参考リンク

一度理解してしまえば、GitHub Appsトークンは難しくありません。ただそこに至るのは大変で、どのドキュメントを読めばいいか分からず苦労しました。特に公式ドキュメントは理解している人向けに書かれており、初見だとさっぱり分かりません。

しかし本記事を読了したなら、十分読みこなせるはずです。リンクを貼っておくので、ぜひ一度眺めておきましょう。また本記事ではJWTなど、普段はあまり直接扱わない技術が出てきました。こちらもRFCを読んでおくと、OAuthやOpenID Connectを学ぶ時に役立つのでオススメです。

脚注
  1. 必須ではありませんが、推奨はされています。ただ本文にも記載したとおり、タイミングをこちらで完全にコントールできるメリットは大きいです。 ↩︎

  2. ただしOrganizationsでGitHub Appsを作成できるのは、Ownerか特定のロールに属している人だけです。詳細は「Adding GitHub App managers in your organization」を参照しましょう。 ↩︎

  3. Organizationsの場合、https://github.com/organizations/<org-name>/settings/apps/newです。 ↩︎

  4. 余談ですがRepository permissionsにいずれかに権限を付与すると、自動的に「Meta/Read」というパーミッションも付与されます。 ↩︎

  5. <app-slug>は、GitHub App nameを「小文字化+特殊文字をハイフンへ変換」した値です。 ↩︎

  6. 個人アカウントの場合は、もちろん自分のアカウントになります。 ↩︎

  7. 詳細は「CodecovのBash Uploader Security Update」などを参照しましょう。 ↩︎

  8. GitHub Appsで生成できるのは、Installationアクセストークンだけではありません。そのためGitHub Appsトークンだと、実は意味が広いです。しかしGitHub Actionsで使用するのは、ほぼInstallationアクセストークンになります。なんともややこしい話です。本題から外れるのでこれ以上は説明しませんが、興味があれば「About authentication with a GitHub App」などを参照しましょう。 ↩︎

  9. 本記事ではリポジトリ制限のみ紹介しましたが、実はパーミッションも制限できます。 ↩︎

Discussion

hankei6kmhankei6km

利用が完了したトークンを失効させるなどの対策、参考になりました。
少し前に作成したワークフローへ取り入れたり、自分の記事で引用させていただきました。ありがとうございます。

そして、そのときに少し気が付いた点がありました。

コマンドライン引数でトークンを環境変数経由で渡している箇所がいくつかありますが(curl--header での利用など)、この方法はできれば避けるようドキュメントに記述があります。

暗号化されたシークレットのワークフロー内での利用

可能であれば、コマンドラインからプロセス間でシークレットを渡すのは避けてください。 コマンドライン プロセスは、他のユーザーに表示される (ps コマンドを使用)、またはセキュリティ監査イベントによってキャプチャされる可能性もあります。 シークレットの保護のために、環境変数、STDIN、またはターゲットのプロセスがサポートしている他のメカニズムの利用を検討してください。

セルフホストランナーを強化する

これは、セルフホストランナーが1つのジョブだけを実行するという保証がないためです。一部のジョブでは、コマンド ライン引数としてシークレットが使われ、同じランナーで実行している別のジョブで見ることができます (ps x -w など)。 これにより、シークレットが漏えいする可能性があります。

すでにご存じであれば申し訳ないのですが、対策強化の一環になればと思いコメントいたしました。

tmknomtmknom

コメントありがとうございます。

記事執筆中はGitHub-Hosted Runnersを念頭に置いていたので
あまり気にしてませんでしたが、たしかにSelf-Hosted Runnersまで考慮すると
ご指摘いただいたように、コマンドライン引数の扱いは慎重にしたほうがいいですね。

例示いただいたコードのようにenvsubstをはさんで、
コマンドライン引数から除外するのは面白いアイデアだと感じました。
psコマンドなどでカジュアルに覗き見られる可能性は、これだけでだいぶ低減しそうです。

一方でランナー自体が侵害される可能性を考慮しだすと
実行中プロセスのメモリダンプなどでシークレットが抜かれるパターンもあるため、
単純にワークフロー側の対策だけでは限界もありそうに感じました。

Self-Hosted Runnersは運用経験がないので、机上レベルでしか設計できないですが
ドキュメントを読む限り、ランナーをephemeralに実行するというのも
ランナー側のセキュリティ対策として有効そうに見えました。

GitHub recommends implementing autoscaling with ephemeral self-hosted runners; autoscaling with persistent self-hosted runners is not recommended. In certain cases, GitHub cannot guarantee that jobs are not assigned to persistent runners while they are shut down. With ephemeral runners, this can be guaranteed because GitHub only assigns one job to a runner.
https://docs.github.com/en/actions/hosting-your-own-runners/autoscaling-with-self-hosted-runners#using-ephemeral-runners-for-autoscaling

hankei6kmhankei6km

返信ありがとうございます。

一方でランナー自体が侵害される可能性を考慮しだすと
実行中プロセスのメモリダンプなどでシークレットが抜かれるパターンもあるため、
単純にワークフロー側の対策だけでは限界もありそうに感じました。

おっしゃる通りランナー側が適切に設定されているという前提は必要で、その上で引数の対策をする必要があるかと思います。

Self-Hosted Runnersは運用経験がないので、机上レベルでしか設計できないですが
ドキュメントを読む限り、ランナーをephemeralに実行するというのも
ランナー側のセキュリティ対策として有効そうに見えました。

引用していただいたドキュメントを読み直してみたところ、ephemeral の構成にするとランナーとジョブが 1 対 1 の関係にできることに改めて気が付きました。

With ephemeral runners, this can be guaranteed because GitHub only assigns one job to a runner.

(最初のコメントで引用したドキュメントにあるように)ランナー内でジョブが同時に実行されるとお互いの /proc/PID などを参照しやすくなることが気になっていたので勉強になりました。
ありがとうございます。

なお、私も今回少し試しただけなのですが、ephemeral の構成にすると各ジョブ毎にランナーの登録処理が必要になるため PAT(あるいはプライベートキー)が不用意に表示される可能性は増えるのかもしれないと感じています。

試してみた限りでは config.sh でランナーを登録するには registration token が必要になります。ephemeral ではその特性上、各ジョブで利用するランナー登録のためにどこかの時点で registration token を API で取得config.sh で利用することになります。
(ephemeral でない場合でも登録を自動化するなら同じような処理は必要です)

この処理は次のように 1 つのコンテナへまとめてることがあるようなのですが、これは $PRIVATE_KEY がジョブから見えることになります。(すべてのジョブ作成者が信頼できる場合でも)ワークフローのログなどにプライベートキーが表示される危険性があります。

export PRIVATE_KEY="<private key>" # 環境変数としてプライベートキーが挿入されている想定
TOKEN="$(/path/to/get_registration_token.sh)" # $PRIVATE_KEY を使って registration token を取得
./config.sh --token "${TOKEN}" --ephemeral # $TOKEN を使って新しいランナーを登録(実際には他にも引数が必要)
./run.sh # ランナー開始
      # ジョブで環境変数を表示
      - name: print env
        run: |
          env | grep -e "^PRIVATE_KEY"

GitHub のウェブ UI でステップの実行結果に $PRIVATE_KEY の内容が表示されているスクリーンショット
実行結果の表示

少し検索してみた限りではこれに対する定番的な対応はなさそうなので、実際に使うならばある程度の試行錯誤が必要なるのかと予想しています。

記事の本来の主旨から外れた返信を長々としてしまってすみません。

ephemeral について最後に 1 点だけ。
引数にシークレットを使わないほうが良いとコメントをした手前、config.sh などでトークンを引数で扱うのも気になるといえば気になるところです(ジョブ間で見えてしまうのとは状況が違うので、そにまで気にすることもないかとは思いますが念のため言及させていただきました)。

tmknomtmknom

Self-Hosted Runnersを利用すると色々考慮事項が増えるんですね。
大変勉強になりました。ありがとうございます。