🎉

Conftest: Terraform HCLファイルのポリシーテスト導入手順(1) ~ 基礎編 ~

2024/02/09に公開

インフラをTerraformのHCLファイルで定義する際、StorageやDBの削除保護の設定をうっかり書き忘れないようにしたい場合などに、最近ではRegoでポリシーを実装してチェックを行うのが、そこそこ一般的になり始めている気がしている。

TerraformのHCLファイル含む各種設定ファイルに対して、Regoによるポリシーテストを実行するツールとしてConftestがある。

なお、Conftestを導入しなくても、現在自分のインフラプロジェクトに既に導入しているTFLintTrivyでもRegoによるポリシーテストの実行が可能である。

ではなぜ学習コストを増やしてまでConftestを導入するかということになるが、まずTFLintはTerraformのHCLファイルのチェックのみが関心ごとであるのに対して、ConftestはTerraform(HCL)に限らず、Kubernetes, Dockerfile, JSON, YAML, TOML, ini、SBOM系など、ぼぼすべての一般的な設定ファイルのポリシーチェックの実行に対応している。

(Conftestによってチェック可能なファイルフォーマット一覧はこちらの中段に書かれている)

一方で、TrivyはConftest同様にTerraform以外にもサポートしている設定ファイルがいくつかあるが、Conftestの網羅度に対して大きく及ばない。

そしてドキュメントを全部見たところで、現時点では使い方が直感的に分からないという問題もある。

(一応Trivyが内部的にConftestを利用していないか、Githubリポジトリで検索をかけてみたが、どうやら自分達でConftestと同じような機能を開発している様子)

何よりConftestはRegoの開発元であるOpen Policy Agentのチームが開発したプロダクトであるため、継続的な保守の可能性の観点からも優位にある。

最後の比較検討対象として、余計なツールを一切使わずにRegoのビルトインのランタイムであるopaコマンドで実行すれば一番シンプルになるのではとも考えたが、Conftestはポリシーチェックの実行をより簡単にするためにOPA自身が開発したツールであり、実際に利用してみれば分かるが、設定ファイルをチェックするための余計な手間を大幅にカットしてくれる。

例えば、opaコマンドでTerraformのHCLファイルをポリシーテストしたい場合、以下のようにplan結果をバイナリ出力したものをJSONに変換してから、opaコマンドで実行するといった手順が必要になる。

$ cd /path/to/your-project/terraform/xxx
$ terraform init
$ terraform plan --out /tmp/tfplan.binary
$ terraform show -json /tmp/tfplan.binary > /tmp/tfplan.json
$ opa exec ... /tmp/tfplan.json 

一方で、Conftestを使用した場合、以下のように1行のコマンドだけでポリシーのテストを行うことが出来る。

conftest test .コマンドは再起的にあらゆる設定ファイルを検知してポリシーのテストを行う。

$ cd /path/to/your-project/
$ conftest test .

なお、このコマンドはTerraformの設定だけでなく、ファイルの拡張子を元にあらゆる設定ファイルのデータ構造を自動的に理解して、ポリシーのテストを行なってくれるため、テスト対象の設定ファイルをいちいち自分の手でJSONに変換するなどの手間が一切発生しない。

以上がConftest導入の経緯となる。

次にGCSバケットを作成するTerraformモジュールの設定ファイルに対して、公開アクセス設定(public_access_prevention)が意図通りの値で設定されているかをConftestでテストする例を説明する。

インストール

Macのhomebrewを使用している場合、Conftestは以下のコマンドでインストールできる。

$ brew install conftest

その他のインストール手順はこちら

検証プロジェクト

次に検証用として、GCSバケットを作成するTerraformモジュールを持つプロジェクトを以下のように作成する。

$ mkdir -p /tmp/sample-project/terraform/modules/storage
$ vi /tmp/sample-project/terraform/modules/storage/main.tf
# 設定内容は下記
...
$ cd /tmp/sample-project
$ tree -a
.
└── terraform
    └── modules
        └── storage
            └── main.tf

4 directories, 1 file
/tmp/sample-project/terraform/modules/storage/main.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "5.25.0"
    }
  }
  required_version = "1.8.1"
}

provider "google" {
  project = "your-gcp-project"
  region  = "asia-northeast1"
}

variable "env" {
  type = string
}

resource "google_storage_bucket" "example" {
  name          = "${var.env}-example-bucket"
  location      = "ASIA"
  storage_class = "MULTI_REGIONAL"
  versioning {
    enabled = true
  }
  uniform_bucket_level_access = true
  # 意図的にコメントアウト
  #public_access_prevention = "enforced"
}

上記設定ではpublic_access_prevention = "enforced"の設定をポリシーテストするためにコメントアウトしている。

この設定は、GCPプロジェクトが所属するオーガナイゼーションの設定に関係なく、バケットに対する公開アクセスの防止が適用されるという意味の設定となっている。

(この設定に関する詳細が知りたい場合はこちら)

ちなみにTrivyのmisconfigスキャン機能を利用して、このTerraformモジュールの設定ファイルに対してポリシーテストを行なった場合、versioningの有効化とIAMベース認証の有効化(uniform_bucket_level_access)についてはチェックを行なってくれるが、public_access_preventionについてはチェックされない。

そこで、public_access_prevention = "enforced"が設定されているかチェックするポリシーを、Regoで自作してConftestで実行するところまで試してみる。

Conftest 設定ファイル

まずConftestを使うにあたって、Conftestの設定ファイルのドキュメントを確認して、運用上有用そうな設定を洗い出す。

自分の場合、設定ファイルは以下のように定義した。

/tmp/sample-project/conftest.toml
# Conftestの設定ファイル作成
$ vi /tmp/sample-project/conftest.toml

# ポリシーファイル(Rego)がまとめられているディレクトリパス(ルートから相対パスで指定)
# デフォルト値は"policy"
policy = "policies"
# Regoのプログラム内で、必要に応じて使うデータ(JSON、YAML等)がまとめられているディレクトリのパス
data = "policies/data"
# ポリシーの公開/配布等を考えないなら、とりあえずRegoのコードのpackage名とあわせておけばいい
namespace = "policies"
# ポリシーのテスト対象から除外するファイル、ディレクトリ
# .gitや.terraformに生成されるファイルは自分で作ったファイルではないのでチェックから除外
ignore = ".git/|.terraform/"
# warn発生時のExitCodeを1にする(デフォルトは0)
fail-on-warn = true

上記設定はすべてconftestコマンドを実行する際にオプションとして渡すことも出来るが、長いオプションをコマンド実行のたびに書かなくても済むようになるので、最初に作成しておいた方がよい。

また設定ファイル名はconftest.tomlとすることで、conftestコマンド実行時に-cオプションで設定ファイル名を指定しなくても良くなる。

1~2行目のpolicy = "policies"data = "policies/data"の設定は、ポリシーテスト用のファイルを格納するディレクトリ構成に関する設定なので、これらのディレクトリを作成しておく必要がある。

$ mkdir -p /tmp/sample-project/policies/data
$ cd /tmp/sample-project
$ tree -a
.
├── conftest.toml
├── policies
│   └── data
└── terraform
    └── modules
        └── storage
            └── main.tf

6 directories, 2 files

これでポリシーを開発する準備は完了となる。

ポリシー実装準備

ポリシーを実装するにあたって、まずプログラム言語Regoの知識が必要になる。

この検証で実装するRegoのコードは出来る限り理解しやすくなるよう、1行ごとの説明を書いたので、そのまま読み進めてもらって問題ないが、もしRegoを今後導入することに興味がある人はRego入門のZenn本Conftestのドキュメント(特にExamplesのコード群)を読むだけでも大分読み書きできるようになるので、一度目を通すことをおすすめしたい。

(Zenn本は約2時間、Conftestのドキュメントは約3時間くらいでメモ取りながら全部読み切れるので、日帰り日程レベルで知識ゼロからRegoのコードを実装出来るようになると考えれば、これはもうやるしかない)

もちろん一番いいのはOPAの公式でRegoのPolicy Languageのドキュメントを読むことだが、こちらは割と時間がかかる上にConftest専用のポリシーの書き方ではなく、より汎用的なRegoの実装について書かれたドキュメントなので、手っ取り早くConftestを触り始めたい人は上記組み合わせの方が良いと個人的には思う。

話が少しそれたが、ポリシーを実装していく前に普通の手続き型言語の開発と同様に、ポリシーファイルのフォルダ構造を決める必要がある。

自分の場合、クラウドプロバイダ名を一階層置いた上で、その中にTerraformリソース1つ(google_storage_bucket, google_sql_database_instance, .etc)に対して、1つのポリシーファイルといった関係で作成することにした。

(この構造であれば、他のプロジェクトでのポリシーの再利用が簡単な気がしている)

$ mkdir -p /tmp/sample-project/policies/terraform/google
$ vi /tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
# 設定内容は下記
$ cd /tmp/sample-project/
$ tree -a
.
├── conftest.toml
├── policies
│   ├── data
│   └── terraform
│       └── google
│           └── google_storage_bucket.rego
└── terraform
    └── modules
        └── storage
            └── main.tf

8 directories, 3 files

ポリシーテスト

Regoで実装したポリシーは以下のようなコードになる。

/tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
package policies

# google_storage_bucketリソースにpublic_access_preventionを定義していない場合、ルール違反
deny[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
  not has_field(bucket, "public_access_prevention")
  msg := sprintf("`public_access_prevention` argument isn't defined for google_storage_bucket.%v", [name])
}

# google_storage_bucketリソースのpublic_access_preventionに"enforced"以外の値を指定した場合、ルール違反
deny[msg] {
  some name
  public_access_prevention := input.resource.google_storage_bucket[name].public_access_prevention
  public_access_prevention != "enforced"
  msg := sprintf("google_storage_bucket.%v.public_access_prevention is `%v` instead of `enforced`", [name, public_access_prevention])
}

# 以下のコードは本当は先頭に書く方が一般的だが、1行目にあると初心者は面食らうので下に書いている
# 以下のように、ノードにkeyが存在するか確認しているだけの関数
# object:{"a":1}, field:"a" の場合、true が返る
# object:{"b":2}, field:"a" の場合、false が返る
#
# ConftestのExamplesからコピー拝借してきた(割とExamples内でみんな使ってる関数)
# https://github.com/open-policy-agent/conftest/blob/master/examples/serverless/policy/util.rego
has_field(object, field) {
  object[field]
}

has_field(object, field) {
  object[field] == false
}

has_field(object, field) := false {
  not object[field]
  not object[field] == false
}

Regoを読んだことがない人でも、文面からやっていることがなんとなく想像できるとは思うが、一番上のルールについて1行づつ解説していく。

(とはいえ、自分もまだ数十時間程度しかRego触っていないので、若干疑わしいのは容赦)

package policies
...

まずpackage名の宣言だが、RegoのパッケージはGoのパッケージとアクセススコープが似ていて、たとえば別のフォルダに格納されているポリシーファイルでも、同じパッケージ名であれば、お互いの関数にアクセス可能となっている。

意味が分からなければ、とりあえず全部のポリシーファイルで以上の宣言を1行目に書いておけば問題ない。

deny[msg] {
...

次の行では、意味不明な定義が書かれているが、これはConftestでポリシー(=ルール)を定義する場合、必ずこう書くものだと深く考えずにまず書けばいい。

(他にもviolation[msg]warn[msg]などがあるが、deny[msg]だけで大半の要件を満たせるので、とりあえずは気にしない)

deny[msg]内にはルール違反の条件がつらつらと列挙されているが、読み方としては1行ごとにルール的に一致するとアウトな条件が羅列されていて、1つでも一致しない条件があったらセーフでチェック終了、全部一致して最後の行まで到達した場合はアウト=ルール違反であると理解すればいい。

ちなみに変数宣言や代入は条件一致と同じ扱いで、次の行に進んでいくことになる。

つまり、ルール内の1行目は変数宣言で2行目は代入であるため、ルール違反状態は継続ということになる。

deny[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
...

ここでinputという重要なキーワードが出てくるが、これは設定ファイルのデータそのものをあらわす変数である。

一つ思い出して欲しいのだが、conftestコマンドは再起的に設定ファイルを読み込んでいくという動作仕様がある。

仮に/tmp/sample-projectプロジェクト直下でconftest test .コマンドでポリシーテストを実行すると、Conftestは以下のようにconftest.tomlmain.tfをポリシーテストの対象として認識する。

$ tree .
.
├── conftest.toml <-- Conftestが知ってる形式
├── policies
│   ├── data
│   └── terraform
│       └── google
│           └── google_storage_bucket.rego <-- Conftestが多分無視してる形式
└── terraform
    └── modules
        └── storage
            └── main.tf <-- Conftestが知ってる形式

8 directories, 3 files

そしてポリシーテストの対象として認識されたすべてのファイルは、必ず全ルール(deny[msg] { ... })のチェックを受けることになる。

conftest.tomlmain.tfは各ルールチェック時に以下のような値に変換されて、変数inputに代入される。

conftest.toml
{
  "data": "policies/data",
  "fail-on-warn": true,
  "ignore": ".git/|.terraform/",
  "namespace": "policies",
  "policy": "policies"
}
terraform/modules/storage/main.tf
{
  "provider": {
    "google": {
      "project": "your-gcp-project",
      "region": "asia-northeast1"
    }
  },
  "resource": {
    "google_storage_bucket": {
      "example": {
        "location": "ASIA",
        "name": "${var.env}-example-bucket",
        "storage_class": "MULTI_REGIONAL",
        "uniform_bucket_level_access": true,
        "versioning": {
          "enabled": true
        }
      }
    }
  },
  "terraform": {
    "required_providers": {
      "google": {
        "source": "hashicorp/google",
        "version": "5.10.0"
      }
    },
    "required_version": "1.7.1"
  },
  "variable": {
    "env": {
      "type": "${string}"
    }
  }
}

これで以下のコードの意味がある程度分かったと思われる。

...
  some name
  bucket := input.resource.google_storage_bucket[name]
...

まずconftest.tomlresourceという値がルートに存在しないため、代入できずルールチェック断念(扱いはセーフ)となる。

次のmain.tfresource.google_storage_bucketという値を持っているために、変数bucketに代入が成功して次の行に進むという寸法である。

ちなみに[name]と書くと、some nameにTerraformのresource名が代入されるのは何故かと聞かれても今の自分では具体的には説明できない。

Conftestが1 -> 1 -> 2 -> 3 -> ? -> 8の場合、? = 5みたいなことをやって、値を抜き出して変数に入れてるくらいのイメージでいいと思う。

説明が長くなってきたが、次の行ではこれぞルールチェックの本体ともいえる条件を定義している。

deny[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
  not has_field(bucket, "public_access_prevention")
...
}

ソースのコメントにも書いたように、has_field関数は上記input(JSON)の構造をチェックして、google_storage_bucketプロパティ配下にpublic_access_preventionプロパティが存在するかをチェックしている。

先頭のnotも踏まえてこのコードを読むと、google_storage_bucketリソース内にpublic_access_preventionが定義されていなかったら、次に進む」 という意味になる。

なおmain.tfではpublic_access_preventionの設定をコメントアウトしている状態なので、この行の評価は真となり、次に進むことになる。

そしてついに最後の行だが、deny[msg]がテンプレなのと同様に、最後の行はエラーメッセージ(msg := ...)を定義するのもまたテンプレと考えれば良い。

deny[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
  not has_field(bucket, "public_access_prevention")
  msg := sprintf("`public_access_prevention` argument isn't defined for google_storage_bucket.%v", [name])
}

つまりこの行の前の式を通過してきた時点で詰み状態である。

以上が、Regoによるポリシーの実装とその読み方の説明となる。

(もう1つのルールについては、もし興味があれば頑張って自力で読んでみてほしい)

これでポリシーテストの実行の準備が完了したので、conftestコマンドを早速実行してみる。

$ cd /tmp/sample-project
$ conftest test .
FAIL - terraform/modules/storage/main.tf - policies - `public_access_prevention` argument isn't defined for google_storage_bucket.example

4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptions

public_access_preventionを付け忘れているHCLファイルを、きちんと通知してくれているのが分かる。

ではコメントを外してから、再度ポリシーテストを実行してみる。

# コメントを外す
$ sed -i '' 's/#public_access_prevention/public_access_prevention/' terraform/modules/storage/main.tf
$ conftest test .

4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions

public_access_preventionを記述することでポリシーテストがパスするようになった。

おわり

Conftestの基本的な使い方とRegoの基本の解説は以上となる。

本当はここからさらに、Regoのテストコードの書き方の説明や、情報の補足を行いたいところなのだが、長くなりすぎて書き手も読み手もしんどい文章になりそうな気がしたので、以下の記事に続く。

https://zenn.dev/erueru_tech/articles/a18d15de17a751

追記

当初以下のコマンドで、public_access_preventionのコメントを消していたが、この方法だとmain.tf-eという意図しないバックアップが作成されてしまうので修正した。

$ sed -i -e s/#public_access_prevention/public_access_prevention/g terraform/modules/storage/main.tf

Discussion