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 を出すだけで plan も apply も止めない。 空ドメインのまま 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 のコードは validate と fmt はパスした。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_checkpoints と log_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 に「GCP で本番環境作って」と言ったら、4箇所にセキュリティ脆弱性があった
- Claude Code が書いた Terraform を本番品質にするまでの全修正記録(この記事)
- Claude Code と一緒に本番デプロイして学んだ、AI に任せていいこと・ダメなこと