1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code が書いた Terraform を本番品質にするまでの全修正記録

1
Last updated at Posted at 2026-03-05

TL;DR

前回の記事で、Claude Code に GCP の本番環境を Terraform で書かせたら4箇所にセキュリティ脆弱性があった。

この記事では、それらを Claude Code と対話しながら修正していく過程 を記録する。AI に「ここが問題だ」と伝えたとき、何が返ってきて、人間が追加で何を判断したか。

前回の4つの脆弱性に加えて、修正の過程で見つかった3つの問題(安全なデフォルト値、cross-variable バリデーション、テスト・セキュリティスキャン)も併せて、計7つの修正を記録する。

結論: AI は「指摘すれば直せる」が、「指摘すべきポイントを知っている」のは人間の役割。


修正の進め方

Claude Code との作業スタイルはこうだった:

1. 問題を特定する(人間)
2. 修正方針を伝える(人間 → Claude Code)
3. コードを生成する(Claude Code)
4. 生成されたコードをレビューする(人間)
5. 追加の修正を指示する(人間 → Claude Code)
6. 繰り返し

「1. 問題を特定する」が最も重要で、最も AI に任せにくい部分だった。


修正1: SA キー → WIF への移行

Claude Code への指示

GitHub Actions の認証を SA キーから Workload Identity Federation に変更して。
deploy-staging.yml と deploy-production.yml の両方。

Claude Code の出力: 80点

WIF モジュールの Terraform は前回の生成時に既にできていたので、GitHub Actions のワークフローだけ修正された:

# Claude が修正したワークフロー
permissions:
  contents: read
  id-token: write

steps:
  - uses: google-github-actions/auth@v3
    with:
      workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
      service_account: ${{ secrets.DEPLOY_SA }}

ここまでは正しい。

人間が追加で判断したこと: attribute_condition

Claude が書いた WIF の Terraform を見直すと:

# Claude が書いた WIF プロバイダー
resource "google_iam_workload_identity_pool_provider" "github" {
  # ...

  attribute_mapping = {
    "google.subject"             = "assertion.sub"
    "attribute.repository"       = "assertion.repository"
    "attribute.job_workflow_ref" = "assertion.job_workflow_ref"
  }

  # ← attribute_condition がない!
}

attribute_condition がない。これがないと、同じ GitHub Organization 内の他リポジトリから WIF を使って GCP にアクセスできる。

攻撃シナリオ:
  Organization に my-org/other-repo がある
  → other-repo の GitHub Actions から WIF 認証
  → myapp-production にアクセス可能

Claude Code に追加指示:

attribute_condition を追加して。
このリポジトリからのリクエストだけを許可するようにしたい。

Claude Code の修正: 100点

attribute_condition = <<-EOT
  assertion.repository_owner == "${var.github_org}" &&
  assertion.repository == "${var.github_org}/${var.github_repo}"
EOT

正しい。ここは指示すれば正確に書ける。

さらに踏み込む: ワークフロー単位の制限

ここは人間が設計判断した部分。リポジトリ制限だけでは不十分で、「どの SA をどのワークフローから使えるか」 まで制限したかった:

SA ごとにワークフロー単位で WIF のバインディングを制限して。
- deploy SA: deploy-staging.yml(main ブランチ)と deploy-production.yml(タグ)からのみ
- terraform SA: terraform.yml からのみ

Claude Code の出力:

# deploy SA: deploy-staging.yml の main ブランチからのみ
resource "google_service_account_iam_member" "deploy_wif_staging" {
  service_account_id = google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member = join("/", [
    "principalSet://iam.googleapis.com/${local.pool_id}",
    "attribute.job_workflow_ref",
    "${var.github_org}/${var.github_repo}/.github/workflows/deploy-staging.yml@refs/heads/main"
  ])
}

# deploy SA: deploy-production.yml のタグからのみ
resource "google_service_account_iam_member" "deploy_wif_production" {
  service_account_id = google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member = join("/", [
    "principalSet://iam.googleapis.com/${local.pool_id}",
    "attribute.job_workflow_ref",
    "${var.github_org}/${var.github_repo}/.github/workflows/deploy-production.yml@refs/tags/*"
  ])
}

これで Terraform ワークフローが deploy SA を使うことも、deploy ワークフローが Terraform SA を使うことも不可能になった。

この修正で学んだこと

AI に任せられた: WIF の HCL 構文、attribute_mapping の書き方
人間が判断した:  attribute_condition の必要性、ワークフロー単位の制限設計

AI は「WIF の書き方」は知っている。でも「何を制限すべきか」は知らない。


修正2: IAM の5SA分離

Claude Code への指示

IAM を見直したい。現在1つの SA に全権限が集中している。
以下の5つに分離して:

1. myapp-app: Cloud Run アプリ用(cloudsql.client + cloudtasks.enqueuer)
2. myapp-deploy: デプロイ用(run.admin + artifactregistry.writer)
3. myapp-migrate: マイグレーション用(cloudsql.client のみ)
4. myapp-tasks: Cloud Tasks 呼び出し用(run.invoker のみ)
5. myapp-terraform: Terraform 用(インフラ全体管理)

Claude Code の出力: 95点

5つの SA の定義と IAM バインディングが正確に生成された。長いので要点だけ:

# app SA: 最小限の権限
resource "google_service_account" "app" {
  account_id = "${var.service_prefix}-app"
}

resource "google_project_iam_member" "app_cloudsql" {
  project = var.project_id
  role    = "roles/cloudsql.client"
  member  = "serviceAccount:${google_service_account.app.email}"
}

resource "google_project_iam_member" "app_tasks" {
  project = var.project_id
  role    = "roles/cloudtasks.enqueuer"
  member  = "serviceAccount:${google_service_account.app.email}"
}

人間が追加で判断したこと: actAs の制限

Claude が書いた deploy SA:

resource "google_project_iam_member" "deploy_sa_user" {
  project = var.project_id
  role    = "roles/iam.serviceAccountUser"
  member  = "serviceAccount:${google_service_account.deploy.email}"
}

roles/iam.serviceAccountUserプロジェクトレベルで付与している。これだと deploy SA はプロジェクト内の全 SA になりすませる。Terraform SA にもなりすまし可能。

修正指示:

deploy SA の iam.serviceAccountUser は、
app SA と migrate SA に対してのみ付与して。
プロジェクトレベルではなく、SA レベルで。
# 修正後: actAs を app と migrate に限定
resource "google_service_account_iam_member" "deploy_act_as_app" {
  service_account_id = google_service_account.app.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.deploy.email}"
}

resource "google_service_account_iam_member" "deploy_act_as_migrate" {
  service_account_id = google_service_account.migrate.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.deploy.email}"
}

google_project_iam_member(プロジェクトレベル)から google_service_account_iam_member(SA レベル)に変わった。地味だが、これで deploy SA が Terraform SA になりすますことは不可能になった。

この修正で学んだこと

AI に任せられた: 5SA の定義、各 SA への正しいロール付与
人間が判断した:  actAs の制限(プロジェクトレベル vs SA レベル)

AI は「ロールを付ける」のは正確だが、「ロールのスコープ」を最小にする判断はしない。


修正3: Script Injection の修正

Claude Code への指示

terraform.yml の PR コメント投稿部分に Script Injection の脆弱性がある。
${{ steps.plan.outputs.stdout }} をテンプレートリテラルに直接展開している。
環境変数経由で渡すように修正して。

Claude Code の出力: 100点

# 修正後
- uses: actions/github-script@v8
  env:
    PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
    PLAN_EXIT_CODE: ${{ steps.plan.outputs.exitcode }}
  with:
    script: |
      const output = process.env.PLAN_OUTPUT;
      const exitCode = process.env.PLAN_EXIT_CODE;

      const body = `#### Terraform Plan ${exitCode === '0' ? '✅' : '❌'}
      \`\`\`
      ${output}
      \`\`\``;

      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: body
      });

ここは指摘すれば一発で正しく修正できた。

なぜこれで安全なのか: ${{ }} は GitHub Actions のテンプレートエンジンが実行前に文字列置換するため、展開された内容が JavaScript の構文を破壊できる。一方、${output}${process.env.PLAN_OUTPUT} は JavaScript のランタイムで文字列値として評価される。バッククォートを含む値であっても、それはただの文字列データであり、テンプレートリテラルの構文を破壊しない。

他にも同じパターンがないか確認

ワークフロー全体を見直すと、PR タイトルやブランチ名の展開にも同じリスクがあった:

# 他にも危険な箇所
${{ github.event.pull_request.title }}   # PR タイトル
${{ github.head_ref }}                    # ブランチ名
${{ github.event.commits[0].message }}    # コミットメッセージ

原則: ユーザーが入力できる値を ${{ }} で script 内に直接展開しない。

この修正で学んだこと

AI に任せられた: 修正コードの生成(指摘すれば正確)
人間が判断した:  そもそもこれが脆弱性であるという認識、他の箇所の洗い出し

AI は「Script Injection とは何か」を知っている。でも自分が書いたコードにそれが含まれていることに自発的には気づかない


修正4: TOCTOU 対策

Claude Code への指示

terraform.yml で plan と apply を分離しているが、
plan の結果を apply で使っていない(TOCTOU 脆弱)。
plan ファイルを artifact で保存して、apply で使うようにして。
staging と production の両方。

Claude Code の出力: 90点

# staging の plan
- name: Terraform Plan
  run: terraform plan -out=tfplan -var-file=staging.tfvars

- name: Upload plan artifact
  uses: actions/upload-artifact@v4
  with:
    name: tfplan-staging
    path: infra/terraform/environments/staging/tfplan

# staging の apply
- name: Download plan artifact
  uses: actions/download-artifact@v4
  with:
    name: tfplan-staging

- name: Terraform Apply
  run: terraform apply -auto-approve tfplan

基本構造は正しい。

人間が追加で判断したこと: retention-days とセキュリティ

Claude は retention-days を指定していなかった(デフォルト90日)。今回は Cloud SQL のパスワードに Terraform 1.11+ の write_only 引数(password_wo)を使っているため State にも plan にもパスワードは含まれない。それでも plan ファイルにはリソース構成やネットワーク情報が含まれるため、90日も保持するのはリスク。retention-days: 1 に修正:

- uses: actions/upload-artifact@v4
  with:
    name: tfplan-staging
    path: infra/terraform/environments/staging/tfplan
    retention-days: 1    # ← 追加: デフォルト90日 → 1日に短縮

この修正で学んだこと

AI に任せられた: artifact の upload/download の構文、plan ファイルの生成
人間が判断した:  retention-days の適切な値、plan ファイルの機密性の判断

修正5(追加): 安全なデフォルト値

前回の記事では触れなかったが、レビュー中にもう一つ気になった点。

問題

staging と production の variables.tf が同一で、デフォルト値も同じ:

# 両環境共通
variable "database_tier" {
  type    = string
  default = "db-f1-micro"    # ← production でこのデフォルトは危険
}

variable "database_backup_enabled" {
  type    = bool
  default = false             # ← production でバックアップ無効がデフォルトは危険
}

variable "database_deletion_protection" {
  type    = bool
  default = false             # ← production で削除保護なしがデフォルトは危険
}

tfvars で上書きするから問題ない…と思いきや、tfvars を指定し忘れた場合にフォールバックする。production で db-f1-micro + バックアップなし + 削除保護なし、は事故直結。

Claude Code への指示

production の variables.tf を修正して:
- database_tier はデフォルト値を削除(tfvars で明示的に指定しないとエラーにする)
- database_backup_enabled のデフォルトを true にする
- database_deletion_protection のデフォルトを true にする

Claude Code の出力: 100点

# production/variables.tf
variable "database_tier" {
  description = "Cloud SQL マシンタイプ"
  type        = string
  # デフォルトなし → tfvars で明示的に指定しないとエラー
}

variable "database_backup_enabled" {
  description = "Cloud SQL の自動バックアップを有効にするか"
  type        = bool
  default     = true     # production はバックアップ ON がデフォルト
}

variable "database_deletion_protection" {
  description = "Cloud SQL の削除保護を有効にするか"
  type        = bool
  default     = true     # production は削除保護 ON がデフォルト
}

設計上のトレードオフ: 記事1で「main.tf は環境間で同一、差分は tfvars で吸収」と評価した原則を、この修正で崩している。production の variables.tf を staging と別にするということは、環境ごとにコードが分岐するということ。代替案としては variables.tf を共通のまま維持しつつ、CI で「production では特定の変数の明示指定が必須」というチェックを入れる方法もある。今回は production の安全性を優先して、環境ごとの variables.tf 分離を選択した。


修正6(追加): cross-variable バリデーション

問題

enable_custom_domain = true なのに domain = "" のまま apply できてしまう。DNS・SSL・LB が空ドメインで作成され、意味不明なリソースが生まれる。

Claude Code への指示

enable_custom_domain = true のときに domain が空だったらエラーにしたい。
変数間のバリデーションを追加して。

Claude Code の出力(1回目): check ブロック

check "domain_required_when_custom_domain_enabled" {
  assert {
    condition     = !var.enable_custom_domain || length(var.domain) > 0
    error_message = "enable_custom_domain = true の場合、domain を指定する必要があります。"
  }
}

check ブロックを使った実装が返ってきた。構文は正しいが、check ブロックは Warning を出すだけで planapply も止めない。 空ドメインのまま apply が通ってしまう。

追加指示 → Claude Code の出力(2回目): 100点

check ブロックだと apply が止まらない。
variable の validation で他の変数を参照して、plan を止めたい。
variable "domain" {
  description = "カスタムドメイン"
  type        = string
  default     = ""

  validation {
    condition     = !var.enable_custom_domain || length(var.domain) > 0
    error_message = "enable_custom_domain = true の場合、domain を指定する必要があります。"
  }
}

Terraform 1.9+ の cross-variable validation。variable ブロック内の validation で他の変数(var.enable_custom_domain)を参照できる。こちらは plan を停止するため、誤った設定のまま apply されることがない。

Claude Code は check ブロックも cross-variable validation も両方知っていた。 ただし最初に提案してきたのは check ブロックだった。「apply を止めたい」と明示したら正しい方法に切り替えてくれた。ここでも 「何を求めているか」を人間が明確にする必要がある というパターンが出た。


修正7(追加): Claude Code のコードをどう検証するか

前回の記事で「terraform validate 一発 OK」と書いた。確かに構文エラーはゼロだった。でも validate は構文しか見ない。 設定ミスやセキュリティ問題は検出できない。

AI が書いたインフラコードこそ、テスト戦略が必要。

テストピラミッド

        /\
       /  \          End-to-End Tests(高コスト)
      /____\         - 全環境デプロイ + 動作確認
     /      \
    /________\       Integration Tests(中コスト)
   /          \      - terraform test でモジュール単体検証
  /____________\
 /              \    Static Analysis(コスト: ゼロ)
/________________\   - validate, fmt, tflint, trivy, checkov

下から順に導入する。コストゼロの静的解析は言い訳なしに入れるべき。

静的解析(コスト: ゼロ)

# 構文チェック
terraform validate

# フォーマットチェック(CI で差分があったら fail にする)
terraform fmt -check -recursive

# Terraform 固有の lint(非推奨パターン、型ミスなど)
tflint --recursive

Claude Code のコードは validatefmt はパスした。tflint では変数の description 欠落など軽微な指摘が出た。

ネイティブテスト(Terraform 1.6+)

モジュール単位で terraform test を書くと、plan レベルで設計意図を検証できる。テストファイルは各モジュールの tests/ ディレクトリに配置する:

# modules/vpc/tests/vpc_test.tftest.hcl
run "vpc_creates_private_network" {
  command = plan

  assert {
    condition     = google_compute_network.this.auto_create_subnetworks == false
    error_message = "VPC should not auto-create subnetworks"
  }
}
# modules/cloud-sql/tests/cloud_sql_test.tftest.hcl
run "cloud_sql_has_private_ip_only" {
  command = plan

  assert {
    condition     = google_sql_database_instance.this.settings[0].ip_configuration[0].ipv4_enabled == false
    error_message = "Cloud SQL should not have public IP"
  }
}

テストファイルをモジュールディレクトリ内に配置すると、そのモジュール内のリソースを直接参照できる。モジュールをまたぐリソースを1つのテストファイルで参照することはできないので、モジュールごとにテストを分ける。

command = plan なら実リソースを作らないのでコストゼロ。「Private IP のみ」「バックアップ有効」「削除保護有効」といった設計意図をコードで表現できる。

セキュリティスキャン(コスト: ゼロ)── Claude Code のコードに trivy をかけてみた

$ trivy config infra/terraform/

MEDIUM: google_sql_database_instance.main
  → No database flags for 'log_checkpoints'
  → No database flags for 'log_connections'

LOW: google_compute_network.main
  → VPC flow logs not enabled

Claude は log_min_duration_statement(スロークエリログ)を設定していたが、trivy が推奨する log_checkpointslog_connections が抜けていた。構文は完璧でも、セキュリティベースラインを満たしていない。

# trivy の指摘を受けて追加した database_flags
database_flags {
  name  = "log_checkpoints"
  value = "on"
}

database_flags {
  name  = "log_connections"
  value = "on"
}
# コンプライアンスチェック
$ checkov -d infra/terraform/

Passed checks: 42
Failed checks: 3
  MEDIUM: CKV_GCP_51 - Ensure PostgreSQL database 'log_checkpoints' flag is set to 'on'
  MEDIUM: CKV_GCP_52 - Ensure PostgreSQL database 'log_connections' flag is set to 'on'
  LOW:    CKV_GCP_79 - Ensure VPC flow logs are enabled for every subnet

trivy と checkov は CI に組み込むべき。 AI が書いたコードのセキュリティベースラインを自動で検証できる:

# CI に追加
- name: Security scan
  run: |
    trivy config --exit-code 1 --severity HIGH,CRITICAL infra/terraform/
    checkov -d infra/terraform/ --soft-fail

この修正で学んだこと

AI に任せられた: validate が通るコード、lint 指摘の修正
人間が判断した:  テスト戦略の設計、どのツールを CI に組み込むか
人間が見落とし、ツールが検出した: database_flags の不足

AI のコードレビューは「人間 + ツール」の二段構え。どちらか片方だけでは不十分。


全修正の振り返り

修正 AI の貢献度 人間の判断が必要だった部分
WIF 移行 ★★★★☆ attribute_condition の必要性、ワークフロー単位制限
IAM 5分割 ★★★★☆ actAs のスコープ(プロジェクト vs SA レベル)
Script Injection ★★★★★ そもそもこれが脆弱性だという認識
TOCTOU 対策 ★★★★☆ retention-days、plan ファイルの機密性
安全なデフォルト ★★★★★ デフォルト値の設計方針
cross-variable ★★★★☆ check vs validation の使い分け(apply を止めるか否か)
テスト・セキュリティスキャン ★★★☆☆ テスト戦略の設計、trivy/checkov の CI 組み込み判断

パターン

AI が得意なこと:

  • 「こう直して」と言われれば、正確なコードを書ける
  • 構文を間違えない
  • 知らない API やブロック構文を教えてくれる(check ブロック)

AI が苦手なこと:

  • 自分が書いたコードの脆弱性に自発的に気づく
  • 「最小権限」「最小スコープ」を明示的に指示されないと適用しない
  • セキュリティのコンテキスト(攻撃シナリオ)を考慮しない

.gitignore の落とし穴

修正の過程でもう一つ気づいたこと。Claude Code が生成した .gitignore:

# Terraform
*.tfvars

これだと staging.tfvars も production.tfvars も除外されてしまう。非機密の環境設定ファイルも git に入らない。

修正:

# Terraform
*.tfvars
!infra/terraform/environments/staging/staging.tfvars
!infra/terraform/environments/production/production.tfvars

ワイルドカードで除外してから、明示的なパスで許可。これで secret.auto.tfvars のような意図しないファイルの混入を防ぎつつ、非機密の環境設定はバージョン管理できる。


まとめ: AI レビューの4ステップ

この修正過程を通じて、AI が書いたインフラコードのレビュー方法が見えてきた:

ステップ0: ツールに検証させる

□ terraform validate が通るか?
□ terraform fmt -check で差分がないか?
□ tflint で lint 警告がないか?
□ trivy config で HIGH/CRITICAL の指摘がないか?
□ checkov で Failed checks がないか?

まず機械にできることは機械にやらせる。 人間のレビューはその後。

ステップ1: 「誰がアクセスできるか」を確認する

□ 認証方式は SA キーではなく WIF か?
□ WIF に attribute_condition があるか?
□ SA ごとにワークフロー制限があるか?
□ IAM のロールは最小限か?
□ actAs のスコープは SA レベルか?

ステップ2: 「ユーザー入力が注入される箇所」を確認する

□ ${{ }} テンプレート展開に外部入力が含まれていないか?
□ PR タイトル、ブランチ名、コミットメッセージが注入されていないか?
□ 環境変数経由で渡しているか?

ステップ3: 「時間差で壊れる箇所」を確認する

□ plan と apply の間に State が変わる可能性はないか?
□ plan ファイルを artifact で保存しているか?
□ artifact の retention-days は適切か?
□ デフォルト値は安全側に倒れているか?

このチェックリストは、AI が書いたコードに限らず、人間が書いた Terraform / GitHub Actions にも使える。


次の記事

修正が完了した。次はいよいよ本番環境にデプロイする。

Claude Code にはできない「人間の判断」が必要な場面が3つあった:

  • ブートストラップ問題(WIF の鶏と卵)
  • DNS 伝播の待ち時間
  • GitHub Secrets の対応表管理

次の記事: Claude Code と一緒に本番デプロイして学んだ、AI に任せていいこと・ダメなこと


シリーズ記事

  1. Claude Code に「GCP で本番環境作って」と言ったら、4箇所にセキュリティ脆弱性があった
  2. Claude Code が書いた Terraform を本番品質にするまでの全修正記録(この記事)
  3. Claude Code と一緒に本番デプロイして学んだ、AI に任せていいこと・ダメなこと
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?