はじめに
GitHub Actionsのワークフローを作成しているときに、actionlintという静的解析ツールを導入しました。
そこで、github.head_refを使用したコードに対して、シェルインジェクションに関する警告が出ました。
最初は疑問でした。
ブランチ名を取得しているだけなのに、なぜセキュリティ上の問題になるの?
調べてみると、GitHub Actionsでは外部から操作できる値をシェルスクリプトに直接埋め込むことで、意図しないコマンドが実行される可能性があると分かりました。
この記事では、以下の内容を整理します。
- actionlintとは
- シェルインジェクションとは
-
github.head_refが危険になる理由 - GitHub Actionsでの攻撃例
-
envを使った対策 - actionlintによる静的解析
1. actionlintとは
actionlintは、GitHub Actionsのワークフローファイルを静的解析するツールです。
GitHub Actionsでは、通常以下のようなYAMLファイルを作成します。
.github/
└── workflows/
├── ci.yml
└── deploy.yml
しかし、YAMLの構文が正しくても、GitHub Actions特有の設定ミスやセキュリティ上の問題が含まれている可能性があります。
actionlintでは、例えば以下の項目をチェックできます。
- GitHub Actionsのワークフロー構文
- 存在しないコンテキストやプロパティの参照
-
needsやstepsなどの参照ミス -
run内のシェルスクリプトの構文(ShellCheck連携) - 信頼できない入力を使ったスクリプトインジェクションの可能性
実行方法
インストール後、リポジトリのルートディレクトリで以下を実行します。
actionlint
.github/workflows/内のワークフローファイルを検査できます。
特定のファイルを検査する場合は、以下のように指定します。
actionlint .github/workflows/ci.yml
なお、ShellCheckをインストールしておくと、シェルスクリプト自体の静的解析も利用できます。
2. シェルインジェクションとは
シェルインジェクションとは、外部から入力された文字列がシェルスクリプトの一部として解釈され、意図しないコマンドが実行される脆弱性です。
例えば、Bashで以下のコードを実行するとします。
echo "Hello"
当然、出力は以下になります。
Hello
しかし、外部入力を適切に扱わず、実行するシェルスクリプトに直接組み込んでしまうと、入力された文字列がコマンドとして解釈される可能性があります。
これはSQLインジェクションと考え方が似ています。
| 比較項目 | SQLインジェクション | シェルインジェクション |
|---|---|---|
| 攻撃対象 | SQL文 | シェルスクリプト |
| 原因 | 外部入力がSQL構文として解釈される | 外部入力がシェル構文として解釈される |
| 影響 | データの不正取得・変更など | 意図しないコマンドの実行など |
| 基本対策 | プレースホルダー等で値を分離 | 外部入力をコードに直接埋め込まない |
共通しているのは、外部から受け取ったデータを、そのまま実行するコードの一部にしてはいけないという点です。
3. github.head_refが危険になる理由
GitHub Actionsでは、以下のコンテキストからPull Requestのブランチ名を取得できます。
${{ github.head_ref }}
github.head_refは、Pull Requestの送信元ブランチ名を表します。
例えば、以下のようなブランチがあるとします。
feature/add-login
この場合、github.head_refから取得できる値は以下のようになります。
feature/add-login
一見すると、ただのブランチ名です。
しかし、ここに問題があります。
Pull Requestの送信元ブランチ名は、外部のPR作成者が指定できる場合があります。
例えば、外部の開発者がリポジトリをForkしてPull Requestを送信するケースです。
外部開発者
↓
リポジトリをFork
↓
ブランチを作成
↓
Pull Requestを送信
↓
GitHub Actionsが実行される
このとき、PR作成者が指定したブランチ名がGitHub Actionsに渡されます。
そのため、github.head_refは信頼できない外部入力として扱う必要があります。
もちろん、すべてのブランチ名が危険という意味ではありません。
外部の人が値を操作できる可能性があるため、悪意のある入力も想定する必要があるということです。
4. GitHub Actionsでのシェルインジェクション例
次のワークフローを考えてみます。
name: Check Branch
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Show branch name
run: |
BRANCH="${{ github.head_ref }}"
echo "$BRANCH"
このコードは、Pull Requestのブランチ名を表示するだけの処理です。
通常のブランチ名なら問題なく動作します。
例えば、
feature/add-login
であれば、GitHub Actionsが式を展開した後のスクリプトは以下のようになります。
BRANCH="feature/add-login"
echo "$BRANCH"
しかし、ブランチ名にシェルの特殊な構文が含まれていた場合はどうなるでしょうか。
悪意のあるブランチ名の例
例えば、次のような文字列を考えます。
feature/$(echo INJECTED)
これは説明用の無害な例です。
Bashでは、$(...)はコマンド置換という構文です。
echo "$(echo Hello)"
このコードを実行すると、内側のecho Helloが実行されます。
GitHub Actionsが悪意のあるブランチ名をそのままスクリプトに展開すると、以下のようになります。
BRANCH="feature/$(echo INJECTED)"
echo "$BRANCH"
Bashでは、ダブルクォート内でもコマンド置換が実行されます。
その結果、変数への代入時にecho INJECTEDが実行されます。
つまり、ブランチ名として渡した文字列の一部が、シェルコマンドとして実行されてしまうわけです。
実際の攻撃では、より危険なコマンドが仕込まれる可能性があります。
※ Gitのブランチ名には使用できない文字もありますが、$()のような構文を含む名前は条件によって作成可能です。
なぜこの問題が発生するのか
重要なのは、GitHub Actionsの式展開とBashの実行順序です。
1. GitHub Actionsがワークフローを処理
↓
2. ${{ github.head_ref }} をブランチ名に展開
↓
3. 展開後のスクリプトをBashで実行
↓
4. 埋め込まれたシェル構文が解釈される
GitHub Actionsの式展開は、シェルスクリプトが実行される前に行われます。
そのため、外部入力をrunに直接埋め込むと、入力内容がスクリプトの構文に影響する可能性があります。
5. envを使って対策する
この問題を回避する方法の一つが、GitHub Actionsのenvを使用することです。
修正前
- name: Show branch name
run: |
BRANCH="${{ github.head_ref }}"
echo "$BRANCH"
この書き方では、github.head_refの値がシェルスクリプトへ直接埋め込まれます。
修正後
- name: Show branch name
env:
BRANCH: ${{ github.head_ref }}
run: |
echo "$BRANCH"
変更点は、github.head_refをenvで環境変数に渡していることです。
この場合、GitHub Actionsが値を環境変数として渡し、Bashは$BRANCHを参照します。
例えば、環境変数に以下の値が入っていたとします。
feature/$(echo INJECTED)
Bashで、
echo "$BRANCH"
を実行しても、変数の中身が再びシェルスクリプトとして解析されることはありません。
出力は以下になります。
feature/$(echo INJECTED)
つまり、$(echo INJECTED)はコマンドとして実行されず、単なる文字列として扱われます。
なぜenvなら安全なのか
違いを整理すると以下のようになります。
直接埋め込む場合
外部入力
↓
GitHub Actionsがrunの内容に直接展開
↓
Bashがスクリプト全体を解析
↓
外部入力がコマンドとして解釈される可能性
envを使用する場合
外部入力
↓
環境変数に格納
↓
Bashが変数を参照
↓
文字列データとして扱う
ただし、envを使用すればあらゆる操作が安全になるわけではありません。
例えば、外部入力をevalで実行したり、別のbash -cにコードとして渡したりすれば、再びインジェクションが発生する可能性があります。
また、変数をコマンド引数として使う場合は、適切なクォートや引数の検証も必要です。
6. actionlintで検出する
ここでactionlintの出番です。
例えば、次のようにgithub.head_refを直接run内で使用するとします。
- name: Show branch
run: |
echo "${{ github.head_ref }}"
actionlintは、このような信頼できないコンテキストを直接使用するパターンを検出し、スクリプトインジェクションの可能性を警告できます。
実行コマンドは以下です。
actionlint
警告では、信頼できない値がスクリプトに直接埋め込まれていることや、環境変数を使用する対策が案内されます。
そこで、以下のように修正します。
- name: Show branch
env:
BRANCH: ${{ github.head_ref }}
run: |
echo "$BRANCH"
修正後、再びactionlintを実行します。
actionlint
該当する警告が解消されていることを確認します。
なお、actionlintはすべての脆弱性を検出できるわけではありません。
静的解析はあくまで問題を発見するための補助ツールであり、GitHub Actionsの権限設定や外部入力の扱いについては別途確認が必要です。
7. GitHub Actionsで特に注意したい外部入力
github.head_ref以外にも、GitHub Actionsでは外部から操作できる可能性がある値が存在します。
例えば、以下のようなものです。
| コンテキスト | 内容 |
|---|---|
github.head_ref |
Pull Requestの送信元ブランチ名 |
github.event.pull_request.title |
Pull Requestのタイトル |
github.event.pull_request.body |
Pull Requestの本文 |
github.event.issue.title |
Issueのタイトル |
github.event.comment.body |
コメント本文 |
これらの値をrunに直接埋め込む場合は注意が必要です。
例えば、Pull Requestのタイトルを表示する場合も同様です。
# 避けたい書き方
- name: Show PR title
run: |
echo "${{ github.event.pull_request.title }}"
代わりに、以下のように環境変数を使用します。
# 推奨する書き方
- name: Show PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
printf '%s\n' "$PR_TITLE"
printfを使用することで、文字列を明示的に出力できます。
ただし、これらの入力を扱う際には、シェルインジェクション以外にも用途に応じた検証が必要です。
8. まとめ
今回、actionlintの警告をきっかけに、GitHub Actionsで発生するシェルインジェクションについて学びました。
重要なポイントは以下のとおりです。
- actionlintはGitHub Actionsのワークフローを静的解析できる
-
github.head_refは外部のPR作成者が操作できる可能性がある -
${{ }}による式展開はシェルスクリプト実行前に行われる - 外部入力を
runに直接埋め込むと、シェル構文として解釈される可能性がある -
envを使用すれば、外部入力をシェルスクリプトのコードから分離できる -
envを使用しても、evalなどで再実行すれば危険性が残る
特に重要なのは、外部入力を実行するコードに直接埋め込まないことです。
SQLインジェクションと同様に、プログラムとして実行する部分と、データとして扱う部分を分離することが基本的な対策になります。
GitHub ActionsはCI/CDを自動化できる便利な仕組みですが、外部からのPull Requestを扱う場合などにはセキュリティにも注意する必要があります。
actionlintを導入することで、こうした問題を実行前に発見しやすくなるため、ワークフロー作成時に活用していきたいと思います。