🎉

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

2024/02/11に公開

前回の記事では、TerraformのHCLファイルに定義されているgoogle_storage_bucketリソースにpublic_access_prevention = "enfoced"が定義されていることをチェックするポリシーをRegoで実装して、Conftestでポリシーテストを実行する手順を説明した。

続きとして、今回は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

[GCSバケットを作成するTerraformモジュール]

/tmp/sample-project/terraform/modules/storage/main.tf
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "5.10.0"
    }
  }
  required_version = "1.7.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"
}

[Conftestの設定ファイル]

/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

[public_access_preventionの設定に対するポリシーテスト]

/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])
}

# object内にfiledで指定したkeyが存在するか確認しているだけの関数
# object:{"a":1}, field:"a" の場合、true が返る
# object:{"b":2}, field:"a" の場合、false が返る
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
}

これからgoogle_storage_bucket.regoに対するテストコードを実装していく。

ポリシーコードに対するテストコードは、同じフォルダ内に${policy_file_name}_test.regoという命名規則で作成する。

つまりテストコードのファイル名はgoogle_storage_bucket_test.regoとなる。

次にテストコードを作成するにあたって、まずどのようなテストを実装するのか考える必要がある。

ポリシーファイルでは、google_storage_bucketリソース内にpublic_access_prevention = "enfoced"が定義されていることをチェックしているが、google_storage_bucketリソース内のpublic_access_preventionの設定の状態は以下の4パターンが考えられる。

  • そもそもpublic_access_preventionが定義されていない
  • #public_access_prevention = "enforced"のようにコメントアウトされている
  • public_access_prevention = "inherited"のように意図と違う値が設定されている
  • public_access_prevention = "enforced"が設定されている

つまり、上記4つの状態に対してポリシー(google_storage_bucket.rego)がどのような判定を下すかをテストすることで、網羅的な動作確認を行うことになる。

ちなみに、Conftestでポリシーのテストコードを実装するにあたって、ほぼ必須レベルで利用することになるであろうparse_configという関数がある。

parse_configはテストの際にルール内のinput変数に与える設定ファイルを実際に作成しなくても済むようになるヘルパー関数である。

たとえば、public_access_preventionが定義されていない設定ファイルは、Regoのコード内で以下のように記述することが出来る。

cfg := parse_config("hcl2", `
  resource "google_storage_bucket" "name" {
  }`)

これは以下のようなHCLファイルを読み込んだのと同じ意味を持つ。

resource "google_storage_bucket" "name" {
}

HCLファイル以外にも、Conftestがサポートしているフォーマットであれば、Regoのコード上で設定ファイルをヒアドキュメントのような形で定義することが出来る。

(サポートしているparserの一覧はこちら)

なお、これだけだとHCLファイルをparse_configで定義する有用性がまだよく分からないと思うが、実際にparse_configを使用したテストコードを見れば、テストが簡単に記述できるようになるのがよく分かる。

テストコードは以下のようになる。

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

8 directories, 4 files
/tmp/sample-project/policies/terraform/google/google_storage_bucket_test.rego
package policies

# そもそも`public_access_prevention`が定義されていない場合、ルール違反となることを確認
test_no_public_access_prevention {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
    }`)
  deny["`public_access_prevention` argument isn't defined for google_storage_bucket.name"] with input as cfg
}

# `#public_access_prevention = "enforced"`のようにコメントアウトされている場合、ルール違反となることを確認
test_comment_out_public_access_prevention {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      #public_access_prevention    = "enforced"
    }`)
  deny["`public_access_prevention` argument isn't defined for google_storage_bucket.name"] with input as cfg
}

# `public_access_prevention = "inherited"`のように意図と違う値が設定されている場合、ルール違反となることを確認
test_public_access_prevention_inherited {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      public_access_prevention    = "inherited"
    }`)
  deny["google_storage_bucket.name.public_access_prevention is `inherited` instead of `enforced`"] with input as cfg
}

# `public_access_prevention = "enforced"`が設定されている場合、ルールにすべてパスする
test_public_access_prevention_enforced {
  cfg := parse_config("hcl2", `
    resource "google_storage_bucket" "name" {
      public_access_prevention    = "enforced"
    }`)
  count(deny) == 0 with input as cfg
}

テストの関数名はtest_から始める必要がある。

ポリシーをテストするためのコードは必要な記述量が非常に少なく、読む分にもほとんど直感的に理解できる。

初見の人が分からない点といえばdeny["..."] with input as cfgcount(deny) == 0 with input as cfgの部分くらいだろう。

どちらも [期待する結果] with input as [テスト対象の設定ファイル] という構文に従っていて、これはルールのテストを実行している処理となる。

input as [テスト設定ファイル]はルールに対してテストを実行する際に、input変数に[テスト設定ファイル]の値を代入するという意味になる。

deny["..."]は、input変数の値に対してルールを実行するとルール違反と判定され、"..."で指定されたエラーメッセージ(msg)が返ることを確認している。

一方のcount(deny) == 0は、input変数の値に対してルールを適用した際にルール違反(deny)と判定された件数が0件だった、つまり設定はすべてのルールにパスすることを確認している。

これで正常系と異常系両方のテストの書き方を説明したことになる。

それでは4つの設定ファイルの状態に対して、意図した判定結果が返ることを確認するために、テストコードを実行してみる。

$ conftest verify --show-builtin-errors

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

conftest verifyコマンドはテストコードの実行を行うためのコマンドで、--show-builtin-errorsオプションはparse_config関数実行時にパースできなかった場合に、具体的なエラー原因を出力してくれるオプションである。

例えばcfg := parse_config("hcl2",の箇所を誤って、parserにhclと指定してしまった場合に、 --show-builtin-errorsオプションのあり/なしでテスト実行結果は以下のように変化する。

# --show-builtin-errors あり
$ conftest verify --show-builtin-errors
Error: running verification: run test: policies/terraform/google/google_storage_bucket_test.rego:31: eval_builtin_error: parse_config: create config parser: unknown parser: hcl

# --show-builtin-errors なし
$ conftest verify
FAIL - policies/terraform/google/google_storage_bucket_test.rego -  - data.policies.test_public_access_prevention_enforced

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

--show-builtin-errorsオプションを付けた場合、エラー発生の原因が一目瞭然だが、オプションを付けなかった場合は原因がよく分からない結果が返ってくる。

これでポリシーの挙動がすべて意図通りであることが確認できて、自信を持ってポリシーを利用できるようになった。

ポリシーの実装/運用ナリッジ

これまでの説明だけでもConftestを用いたポリシーテストの運用を開始出来るはずである。

しかしポリシーの実装/運用を実際に行っていくと、まだまだ理解しなければいけないことは沢山ある。

そこで、現時点で自分が知っている実践/運用に役に立つナリッジについていくつか紹介したい。

terraform plan&opaコマンドとconftestコマンドの違い

基礎編で、terraform plan&opaコマンドによるポリシーテストは自分でinput用のJSONを生成しなければいけないために煩雑と説明したが、Conftestを使用したポリシーテストに比べて有利な点が1つある。

それはterraform planから生成したJSONはvariableを適用した後の属性値をチェックできるという点である。

たとえば以下のようなコマンドでTerraformのHCLファイルに対してポリシーテストを実行したとする。

$ cd /path/to/your-project/terraform/xxx
$ terraform init
# variableに`env=test`を指定した結果を出力している
$ TF_VAR_env=test terraform plan --out /tmp/tfplan.binary
$ terraform show -json /tmp/tfplan.binary > /tmp/tfplan.json
$ opa exec ... /tmp/tfplan.json 

/tmp/tfplan.json内ではGCSバケット名がtest-example-bucketに確定した状態となる。

一方、Conftestでは、GCSバケット名が"${var.env}-example-bucket"と名前が未確定のままテストすることになる。

ポリシーテストの要件次第では、terraform planコマンドも選択肢となりうる可能性があることを、頭の片隅に置いておきたい.

ポリシープロジェクトの開発ツール

ポリシーのコード群もアプリケーションプロジェクトとして考えるなら、一般的なアプリケーションプロジェクト同様に、パッケージ管理、テストコード、リンタ、フォーマッタの導入が可能か検討する必要がある。

まずパッケージ管理ツールだが、Regoのコードはそこまで複雑な実装を必要としないため、ライブラリのようなものを自分のプロジェクトに取り込むケースはそこまで無いような気がしている。

よって、JavaのMaven&Gradle、JavaScriptのnpm&Yarn、Pythonのpip&Poetry等のようなパッケージ管理は必要無い気がしていて、簡単に調べてはみたがしっくり来るものは現時点では見つけられなかった。

次にテストコードだが、これは上記で説明したようにConftestを利用することで導入済みである。

リンタについては、opaコマンドcheckinspectを使うことを検討したものの、parse_config関数がConftest固有の関数であるために、*_test.regoでパースエラーを起こしてしまうため、導入できなかった。

なお、conftest verifyコマンドでは構文チェックもある程度行ってくれているようなので、このコマンドをテストコードの実行だけでなく、リンタとしても扱うことになる。

最後にフォーマッタについては、opa fmtコマンドがparse_config関数が含まれていても、正常に動作したため、これを使用していくことにする。

$ cd /tmp/sample-project
$ opa fmt -w policies/

inputの値の調査

基礎編の記事でconftest.tomlとmain.tfの設定ファイルがinput変数に代入される際に、具体的にどのような値になるか説明したが、これはprint関数でinputの値を出力することで調べられる。

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

# 以下のルールを追加
deny[msg] {
  print(input)
  1 == 2
  msg := "this line is dead code"
}

...

以上のように、inputを出力するだけのルールを作成した後、conftest testコマンドでポリシーテストを実行すると、すべてのinputの値が標準出力に表示される。

$ conftest test .
PRNT   policies/terraform/google/google_storage_bucket.rego:4: {"data": "policies/data", ...

PRNT   policies/terraform/google/google_storage_bucket.rego:4: {"provider": {"google": {"project": ...

6 tests, 6 passed, 0 warnings, 0 failures, 0 exceptions

# 上記出力結果をコピーしてjqコマンドで整形
$ echo '{"data": "policies/data", ...' | jq .

なおconftest verifyコマンドではprint関数が実行されない点に注意。

あと設定ファイル数が多くなると、目当ての設定ファイルの出力結果を見つけるのにも一苦労な点はご愛嬌。

warn, violation

ルールはdeny[msg]の他に、warn[msg]violation[msg]と宣言することもできる。

warnはルールに違反した場合に、"対応を検討する必要はあるが、CIなどをエラー終了させるほどではない"といった場合に使用する。

試しにgoogle_storage_bucket.regogoogle_storage_bucket_test.regoをdenyからwarnに変更してポリシーテストを実行すると以下のような結果になる。

$ conftest test .
WARN - terraform/modules/storage/main.tf - policies - `public_access_prevention` argument isn't defined for google_storage_bucket.example

4 tests, 3 passed, 1 warning, 0 failures, 0 exceptions

$ echo $?
1

ExitCodeが1になっているのは、conftest.tomlにfail-on-warn = trueが定義されているためで、この行を消せばExitCodeが0になり、エラー終了しなくなる。

violationは、denyと同じようにルール違反した場合にExitCodeが1になるが、denyとは異なりviolation[{"msg": msg, "details":{}}]のような構造化データを含めることができる。

GatekeeperというOpen Policy Agentが提供するツールで、このような指定が必要になるとIssueで報告されているが、自分はまだ必要なケースに遭遇していないので説明が難しい。

denyの細分化

基礎編でルールの先頭にはとりあえずdeny[msg]と書いておけばいいと説明したものの、ポリシーのコードが増えていくにつれて面倒な問題が発生する。

テストコードにて、正常系のテストの実行をcount(deny) == 0 with input as cfgのように記述したが、これだとpublic_access_preventionのテスト以外でdenyが発生した場合にも、エラーが発生してしまう。

例として、google_storage_bucketリソースにはforce_destroyを指定しなければいけないといったルールを新たに追加したとする。

/tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
$ vi /tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
...
# 以下のルールを末尾に追加
deny[msg] {
  some name
  bucket := input.resource.google_storage_bucket[name]
  not has_field(bucket, "force_destroy")
  msg := sprintf("`force_destroy` argument isn't defined for google_storage_bucket.%v", [name])
}

この状態でテストコードを実行すると、public_access_prevention関連の変更を一切行っていないにも関わらず、force_destroyが設定されていないために結果がdenyとなるルールが検知され、count(deny) == 0 ...のテストが失敗する。

$ conftest verify --show-builtin-errors
FAIL - policies/terraform/google/google_storage_bucket_test.rego -  - data.policies.test_public_access_prevention_enforced

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

この問題を回避するためには、google_storage_bucket.regoのpublic_access_preventionのルールをdeny[msg]ではなくdeny_public_access_prevention[msg] のようにして、google_storage_bucket_test.rego側ではdeny_public_access_prevention["..."]count(deny_public_access_prevention) を指定すればよい。

exception

exceptionを利用することで、ルールの適用に対して、例外を設けることが出来る。

例として、public_access_preventionのルールが一時的に望ましくないルールとなった場合などに、exceptionを定義することで一時的に無効化することができる。

/tmp/sample-project/policies/terraform/google/google_storage_bucket.rego
...
# 以下のルールを末尾に追加
exception[rules] {
  # 無効化の際に条件を指定したい場合はルールと同様に条件を記述できる
  rules = ["public_access_prevention"]
}

rules = ["public_access_prevention"]と記述することで、deny_public_access_preventionルールとviolation_public_access_preventionルールがスキップされるようになる。

この状態でconftest testコマンドを実行すると、以下のようにpublic_access_prevention関連のチェックがスキップされるようになる。

$ conftest test .
EXCP - conftest.toml - policies - data.policies.exception[_][_] == "public_access_prevention"
EXCP - terraform/modules/storage/main.tf - policies - data.policies.exception[_][_] == "public_access_prevention"

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

data

例えば、何かしらのdenylistをチェックするルールを記述する場合、Regoのコード内にdenylistを直接定義するのではなく、外部ファイルから取得したいケースが考えられる。

そのような場合に、conftestコマンドのdataオプションに指定したディレクトリにJSONやYAMLで定義したデータファイルを置くことで、Regoのコードからデータ定義を分離することが出来る。

/tmp/sample-projectでdataフォルダ配下に作成されたデータを取り込む方法を、以下のように試してみる。

$ cd /tmp/sample-project

# 1. データファイルを作成
$ vi policies/data/denylist.yaml
# 以下の設定を記述
denylist:
  deny_users:
    - user1
    - user2

# 2. データファイルの値を参照する処理を実装
$ vi policies/data_example.rego
# 以下の設定を記述
package policies

import data.denylist

deny_users := denylist.deny_users

deny[msg] {
  print(deny_users)
  1 == 2
  msg := "this line is dead code"
}

# 3. 現在のプロジェクトの状態
$ tree -a
.
├── conftest.toml
├── policies
│   ├── data
│   │   └── denylist.yaml
│   ├── data_example.rego
│   └── terraform
│       └── google
│           ├── google_storage_bucket.rego
│           └── google_storage_bucket_test.rego
└── terraform
    └── modules
        └── storage
            └── main.tf

8 directories, 6 files

# 4. dataの取り込みの動作確認
# 以下のコマンドを実行すると、当然他のルールチェックに影響を及ぼすためエラー終了となるが、とりあえずdataの挙動を手っ取り早く確認するためにこうしている
$ conftest test .
PRNT   policies/data_example.rego:10: ["user1", "user2"]
...

# 5. テストが完了したら検証に使用したファイルを論理削除
$ mv policies/data/denylist.yaml /tmp/
$ mv policies/data_example.rego /tmp/

なお、conftest.tomlにすでにdata = "policies/data"を指定しているため、特にdataオプションを指定しなくても、このディレクト配下であればimport data.xxxでデータを読み込むことができる。

なお、denylist.yamlをpolicies/data/foo/bar/denylist.yamlに置いても、変わらず以下のコードで読み込むことができる点は特殊。

import data.denylist

deny_users := denylist.deny_users

他にも、denylist.yamlのデータのルートはファイル名と同じdenylistにしないとうまくデータが読み込めないようで、割とクセが強い気がしている。

ポリシーの共有

詳細は公式ドキュメントを確認したほうがいいが、第三者が開発したGithubプロジェクトからポリシーを丸ごとローカルプロジェクトにダウンロードすることも出来る。

試しにConftestのExamplesのhcl2のポリシーをダウンロードしてみる。

ダウンロードコマンドは以下のようになる。

$ cd /tmp/sample-project
$ conftest pull git::https://github.com/open-policy-agent/conftest.git//examples/hcl2/policy -p external-policies
$ tree -a
.
├── conftest.toml
├── external-policies
│   ├── deny.rego
│   ├── deny_test.rego
│   └── unencrypted_azure_disk.tf
├── policies
│   ├── data
│   └── terraform
│       └── google
│           ├── google_storage_bucket.rego
│           └── google_storage_bucket_test.rego
└── terraform
    └── modules
        └── storage
            └── main.tf

9 directories, 7 files

この状態で試しにExamplesのテスト対象であるterraform.tfをローカルに作成して、conftest testコマンドを実行すると、しっかりエラーを検知出来ていることが確認できる。

terraform/hcl2_example.tf
$ vi terraform/hcl2_example.tf
# 以下の設定を定義
resource "aws_security_group_rule" "my-rule" {
  type        = "ingress"
  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_alb_listener" "my-alb-listener" {
  port     = "80"
  protocol = "HTTP"
}

resource "aws_db_security_group" "my-group" {

}

resource "azurerm_managed_disk" "source" {
  encryption_settings {
    enabled = false
  }
}

テスト実行コマンドは以下のようになる。

# ポリシーのディレクトリやRegoのpackage名が異なるためポリシーテストは個別に実行する必要がある
$ conftest test -p policies -n policies .

8 tests, 8 passed, 0 warnings, 0 failures, 0 exceptions

# 外部から取得したポリシーがちゃんとチェックできていることが確認できる
$ conftest test -p external-policies -n main .

FAIL - external-policies/unencrypted_azure_disk.tf - main - Azure disk `sample` is not encrypted
FAIL - terraform/hcl2_example.tf - main - ALB `my-alb-listener` is using HTTP rather than HTTPS
FAIL - terraform/hcl2_example.tf - main - ASG `my-rule` defines a fully open ingress
FAIL - terraform/hcl2_example.tf - main - Azure disk `source` is not encrypted

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

この機能は、組織のポリシーをまとめたGithubリポジトリからポリシーをダウンロードして、CI実行時などに追加チェックを行うといった応用が可能になる。

余談だが、前の記事でgoogle_storage_bucketリソースのversioningやuniform_bucket_level_accessといった値はTrivyでテスト出来ると書いたが、組織のポリシーとしてgoogle_storage_bucketリソースのテストを配布するなら、Trivyを使っていないプロジェクトを想定する必要があるため、すべて自前で実装しなければいけない。

おわり

Conftestの利用方法については以上の内容で、ドキュメントに書いてあることの九割方については触れたと思う。

しかし自分もConftestをまだ使い始めたばかりで、上記説明には間違いが含まれている可能性が十分考えられる。

よって、もし間違いなどを発見した場合には、コメントなどで報告してもらえればありがたい。

追記

denyの細分化の解説の日本語がいろいろおかしかったので修正。

Discussion