Terraformでインストールできるプロバイダを制限する (.terraformrc編)

この記事は、 [Terraform Advent Calendar 2023](https://qiita.com/advent-calendar/2023/terraform) の25日目の記事です。メリークリスマス :christmas_tree:

# はじめに

先日、「tfprovidercheck - 危険な Terraform Provider の実行を防ぐ」というツールの紹介を読みました。

https://zenn.dev/shunsuke_suzuki/articles/tfprovidercheck-introduction

TerraformのCI/CDについて、apply前に承認フローを挟むなどしている人は多いと思いますが、planフェーズの攻撃まで気にしてる人はまだあんまり多くない印象です。

私は普段TerraformのCI/CDに[Atlantis](https://github.com/runatlantis/atlantis)を使っているので、同じようなことをチェックをするのに、これまでAtlantisのカスタムワークフローにシェルスクリプトを差し込んで、使用するプロバイダの許可リストをチェックするなどしてました。サンプルコードは以下のissueを参照。

https://github.com/runatlantis/atlantis/issues/1893

まぁこれでも十分期待したとおり動くんですが、もしかしてこれぐらいTerraformの設定でできないのかしら？と思い立ち、issueを漁ってたら、 `.terraformrc` の`provider_installation` でも同じようなことできるようという情報を見つけました。

https://github.com/hashicorp/terraform/issues/33698

というわけでやってみた。 

# 環境

試した環境は、本稿執筆時点で最新版のTerraform v1.6.6です。

```
$ terraform -v
Terraform v1.6.6
on darwin_arm64
+ provider registry.terraform.io/hashicorp/external v2.3.2
+ provider registry.terraform.io/hashicorp/null v3.2.2
```

# やってみた

nullプロバイダとexternalプロバイダを使って、適当なサンプルコードを用意しました。

```main.tf
resource "null_resource" "foo" {
  provisioner "local-exec" {
    command = "echo foo"
  }
}

data "external" "example" {
  program = ["echo", "{\"foo\":\"bar\"}"]
}

output "external" {
  value = "${data.external.example.result.foo}"
}
```

意図を補足しておくと、null_resourceもexternal data sourceも任意のコードが実行できますが、null_resourceのlocal-exec provisionerはapplyフェーズで実行されるのに対し、externalのdata sourceのprogramはplanフェーズで実行されるので危険です。
また当たり前ですが、externalプロバイダに限らず、悪意のあるプロバイダ実装は、planフェーズで任意のコードを実行することが可能です。

```
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/null...
- Finding latest version of hashicorp/external...
- Installing hashicorp/null v3.2.2...
- Installed hashicorp/null v3.2.2 (signed by HashiCorp)
- Installing hashicorp/external v2.3.2...
- Installed hashicorp/external v2.3.2 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
```

```
$ terraform plan
data.external.example: Reading...
data.external.example: Read complete after 0s [id=-]

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # null_resource.foo will be created
  + resource "null_resource" "foo" {
      + id = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + external = "bar"

──────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly
these actions if you run "terraform apply" now.
```

まだ何も制限をかけていないので、普通にinitしてplanできてます。

次に、 `~/.terraformrc` に 以下のような `provider_installation` ブロックを追加し、 `registry.terraform.io/hashicorp/*` は `direct` (= オリジンのTerraform Regsitryからインストールする）、 ただし `registry.terraform.io/hashicorp/external` は除くという設定を入れてみましょう。

```.terraformrc
provider_installation {
  direct {
    include = [
      "registry.terraform.io/hashicorp/*",
    ]
    exclude = [
      "registry.terraform.io/hashicorp/external",
    ]
  }
}
```

`provider_installation` ブロックはプロバイダのインストール元として、他に `filesystem_mirror` や `network_mirror` などが指定できますが、何にもマッチしなければ、インストール元が未定義となり、インストールに失敗します。結果的に許可リストとして機能するという算段です。

`.terraformrc` の設定値の意味などは、以下の公式ドキュメントを参考にして下さい。

https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation

予想通りですが、すでにterraform initされた状態で、 `.terraformrc` だけいじっても、terraform planはできてしまいます。これはプロバイダのインストールが発生しないためです。

```
$ terraform plan
data.external.example: Reading...
data.external.example: Read complete after 0s [id=-]

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # null_resource.foo will be created
  + resource "null_resource" "foo" {
      + id = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + external = "bar"

──────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly
these actions if you run "terraform apply" now.
```

terraform initしなおすと、期待したとおりにエラーになりました。

```
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/null from the dependency lock file
- Reusing previous version of hashicorp/external from the dependency lock file
- Using previously-installed hashicorp/null v3.2.2
╷
│ Error: Failed to query available provider packages
│
│ Could not retrieve the list of available versions for provider hashicorp/external: provider
│ registry.terraform.io/hashicorp/external was not found in any of the search locations
│
│
```

CIでチェックする場合には、planの前にinitするので、インストール時点でのチェックで問題ない認識です。一応試したところ、plugin_cache_dirでキャッシュしている場合でも、init時点でエラーになりました。

次に、この状態でmain.tfをいじって、別の3rd-partyのプロバイダをインストールしてみましょう。

```main.tf
resource "null_resource" "foo" {
  provisioner "local-exec" {
    command = "echo foo"
  }
}

terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
    }
  }
}

resource "docker_image" "ubuntu" {
  name = "ubuntu:latest"
}
```

terraform initしてみるとエラーになりました。

```
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding latest version of kreuzwerker/docker...
- Reusing previous version of hashicorp/null from the dependency lock file
- Using previously-installed hashicorp/null v3.2.2
╷
│ Error: Failed to query available provider packages
│
│ Could not retrieve the list of available versions for provider kreuzwerker/docker: provider
│ registry.terraform.io/kreuzwerker/docker was not found in any of the search locations
│
│
╵
```

期待通り、 `hashicorp/*` 以外のプロバイダも未定義として制限されてます。

もしこの方法で制限をかける場合は、環境変数 `TF_CLI_CONFIG_FILE` で設定ファイルのパスを上書きできるので、念のため設定ファイルをいじってないかのチェックもした方がよさそうです。試したところ、 `.terraformrc` はrootモジュールのカレントディレクトリに置いても勝手に読み込まれたりはしませんでした。

# 最後に

`provider_installation` ブロックの本来意図した使い方と若干異なる使い方をしているのが若干トリッキーですが、 試したところ `provider_installation` だけでも最低限の保護は実現できてそうでした。

ちなみに、インストールするモジュールも制限したいところですが、 `module_installation` は今のところ実装されてません。

https://github.com/hashicorp/terraform/issues/31134

一応 terraform initすると `.terraform/modules/modules.json` という `.terraform.lock.hcl` のモジュール版に相当する隠しファイルができてるのは気づいてるんですが、これは公開APIではない実装詳細なので、これに依存するのは壊れそうなんですよね。
今後 `.terraform.lock.hcl` がモジュールに対応するか、 `.terraformrc` が `module_installation` に対応するか、どちらかに期待したいところです。