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 に「GCP で本番環境作って」と言ったら、4箇所にセキュリティ脆弱性があった

1
Last updated at Posted at 2026-03-03

TL;DR

Claude Code に Next.js アプリの GCP 本番環境(Terraform)を書かせた。構文は正確で、リソース定義もほぼ完璧。でもそのまま terraform apply したら本番事故が起きるレベルの問題が4つあった。

AI が書くコードの「何がよくて何がダメか」を、実際の出力を見ながら解説する。

この記事のゴール

  • Claude Code が Terraform をどれくらい書けるか、実感を持つ
  • 「動くけど本番では使えない」コードの見分け方を知る
  • AI に何を任せて、何を自分で判断すべきかの線引きを掴む

前提: 何を作りたかったか

Web アプリの本番環境。構成はこれ:

Next.js (App Router) + Hono (API)
  ↓
Cloud Run (コンテナ)
  ↓ VPC 内通信
Cloud SQL (PostgreSQL 17)
Cloud Tasks (非同期ジョブ)
  ↓
Terraform (IaC)
GitHub Actions (CI/CD)

staging 環境は動いている。production 環境を新しく作りたい。

使用ツール:

  • Terraform ~> 1.11
  • Google Cloud Provider ~> 6.0
  • Claude Code

実際のプロンプト

Claude Code にこう伝えた:

Next.js アプリを GCP にデプロイするための Terraform を書いて。

構成:
- Cloud Run(アプリ)
- Cloud SQL(PostgreSQL 17、Private IP)
- Cloud Tasks(非同期処理)
- Artifact Registry(Docker イメージ)
- VPC + VPC Access Connector
- Workload Identity Federation(GitHub Actions 用)
- IAM(サービスアカウント)

staging と production の2環境を管理したい。

Claude Code の出力

かなり長いので、ポイントを抜粋する。

ディレクトリ構成: ◎ 文句なし

infra/terraform/
├── modules/
│   ├── vpc/
│   ├── cloud-sql/
│   ├── cloud-tasks/
│   ├── iam/
│   ├── wif/
│   └── artifact-registry/
└── environments/
    ├── staging/
    │   ├── main.tf
    │   ├── variables.tf
    │   ├── outputs.tf
    │   ├── versions.tf
    │   └── backend.tf
    └── production/
        ├── main.tf
        ├── variables.tf
        ├── outputs.tf
        ├── versions.tf
        └── backend.tf

modules と environments の分離、各環境のファイル構成、どちらも教科書通り。ここは人間が直す必要がなかった。

Cloud SQL モジュール: ◎ 構文は完璧

# Claude が書いた Cloud SQL モジュール(抜粋)
resource "google_sql_database_instance" "main" {
  name                = var.instance_name
  database_version    = "POSTGRES_17"
  region              = var.region
  deletion_protection = var.deletion_protection

  settings {
    tier = var.tier

    backup_configuration {
      enabled                        = var.backup_enabled
      point_in_time_recovery_enabled = var.backup_enabled
      start_time                     = "18:00"
      transaction_log_retention_days = 7

      backup_retention_settings {
        retained_backups = 7
      }
    }

    ip_configuration {
      ipv4_enabled    = false
      private_network = var.network_id
    }

    maintenance_window {
      day          = 7
      hour         = 4
      update_track = "stable"
    }

    insights_config {
      query_insights_enabled  = true
      query_string_length     = 4500
      record_application_tags = true
      record_client_address   = false
    }

    database_flags {
      name  = "log_min_duration_statement"
      value = "1000"
    }
  }
}

バックアップ設定、PITR、メンテナンスウィンドウ、Query Insights、スロークエリログ…。record_client_address = false(PII 保護)まで設定している。

正直、ここまで書けるのかと感心した。 構文エラーもゼロ。terraform validate を通すと一発で OK。

ただし1点、後から気づいた問題がある。maintenance_windowhour = 4UTCasia-northeast1 にデプロイするので JST に変換すると 13:00(日曜の昼間) にメンテナンスが走る。Claude はリージョンに応じたタイムゾーン変換をしていなかった。JST 早朝にしたいなら hour = 19(19:00 UTC = 翌4:00 JST)が正しい。

構文は完璧でも 値の妥当性 はリージョンやビジネス要件で変わる。これも「動くけど本番では使えない」パターンの一つ。

環境分離: ◎ tfvars の使い方も正しい

# staging.tfvars(Claude が生成)
database_instance_name       = "myapp-db-staging"
database_tier                = "db-f1-micro"
database_backup_enabled      = false
database_deletion_protection = false
enable_custom_domain         = false

# production.tfvars(Claude が生成)
database_instance_name       = "myapp-db-production"
database_tier                = "db-custom-2-8192"
database_backup_enabled      = true
database_deletion_protection = true
enable_custom_domain         = true
domain                       = "myapp.example.com"

main.tf は環境間で同一、差分は tfvars で吸収。設計原則に忠実。

条件付きリソース: ◎ count パターンも正しい

# ドメイン関連リソースは enable_custom_domain = true のときだけ作成
module "cloud_dns" {
  count  = var.enable_custom_domain ? 1 : 0
  source = "../../modules/cloud-dns"
  domain = var.domain
}

module "ssl_certificate" {
  count  = var.enable_custom_domain ? 1 : 0
  source = "../../modules/ssl-certificate"
  domain = var.domain
}

staging ではドメインなし、production ではドメインありという切り替えも正しく実装されていた。


ここまでのスコア: 85点

構文、モジュール分離、変数化、条件分岐、全部正しい。terraform planterraform validate もエラーなし。

AI が書いた Terraform としては、かなり高品質。

ただし、残りの15点に本番では致命的な問題が4つあった。

※ 各脆弱性の「深刻度」は CVSS 等の公式スコアではなく、本番運用への影響度を筆者が判断したもの。


脆弱性1: GitHub Actions 認証に SA キーを使っている

Claude が書いたコード

# deploy-production.yml(Claude が生成)
steps:
  - uses: google-github-actions/auth@v3
    with:
      credentials_json: ${{ secrets.GCP_SA_KEY }}

何が問題か

SA キー(サービスアカウントキー)は JSON ファイル。漏洩すると永続的にGCP へアクセス可能。有効期限がないため、取り消すまで使い放題。

SA キーの問題:
  - 漏洩 → 永続アクセス(取り消すまで有効)
  - ローテーション → 手動(忘れる)
  - 利用者 → 区別不可能(誰でも使える)

正しい実装: Workload Identity Federation (WIF)

# 正しい実装
permissions:
  contents: read     # ← リポジトリの読み取り
  id-token: write    # ← OIDC トークン発行に必要

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

WIF は OIDC ベースの一時トークン認証。トークンは1時間で失効する。

WIF の仕組み:
  GitHub Actions → OIDC トークン発行(自動)
       ↓
  GCP WIF Pool → トークン検証 + リポジトリ確認
       ↓
  一時 OAuth2 トークン(1時間で失効)
       ↓
  GCP リソースへのアクセス

興味深い点: プロンプトで「Workload Identity Federation」と明示的に書いたにもかかわらず、Claude は WIF の Terraform モジュールは正しく書いたのに、GitHub Actions ワークフローでは credentials_json を使っていた。モジュールとワークフローの整合性を取れていなかった。

深刻度: 🔴 HIGH

SA キーが GitHub Secrets から漏洩した場合、production の全リソースにアクセスされる。


脆弱性2: IAM が「1つの SA に全権限」

Claude が書いたコード

# Claude が書いた IAM(抜粋)
resource "google_service_account" "app" {
  account_id   = "myapp-app"
  display_name = "Application Service Account"
}

resource "google_project_iam_member" "app_roles" {
  for_each = toset([
    "roles/cloudsql.client",
    "roles/cloudtasks.enqueuer",
    "roles/run.admin",
    "roles/artifactregistry.writer",
    "roles/iam.serviceAccountUser",
  ])
  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.app.email}"
}

1つの SA(myapp-app)に DB 接続、タスク登録、Cloud Run デプロイ、イメージ push、SA なりすましの権限が全部入っている。

何が問題か

この SA が侵害されたら、全部終わり

  • DB のデータを抜ける
  • Cloud Run に悪意のあるイメージをデプロイできる
  • Artifact Registry にバックドア付きイメージを push できる
  • 他の SA になりすませる

正しい設計: 5つの SA に分離

myapp-app      → cloudsql.client + cloudtasks.enqueuer のみ
myapp-deploy   → run.admin + artifactregistry.writer のみ
myapp-migrate  → cloudsql.client のみ
myapp-tasks    → run.invoker のみ
myapp-terraform → インフラ管理(最も強力だが利用シーンが限定的)

侵害されたときの被害範囲が全然違う:

侵害された SA 1つの SA 設計 5分割設計
app 全権限 → 全滅 DB 読み書き + タスク登録のみ
deploy - デプロイのみ(DB アクセス不可)
migrate - DB 接続のみ(デプロイ不可)

Claude に「SA を分けて」と追加で指示したら、正しく分割してくれた。でも最初のプロンプトでは分けてくれなかった。 AI は明示的に指示しないと、最小権限の原則を適用しない傾向がある。

深刻度: 🔴 HIGH

1つの SA の侵害で本番環境の全権限が奪取される。


脆弱性3: GitHub Actions で Script Injection

Claude が書いたコード

Terraform の plan 結果を PR にコメントする部分:

# Claude が書いた PR コメント投稿
- uses: actions/github-script@v8
  with:
    script: |
      const output = `#### Terraform Plan
      \`\`\`
      ${{ steps.plan.outputs.stdout }}
      \`\`\``;

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

何が問題か

${{ steps.plan.outputs.stdout }} は GitHub Actions のテンプレートエンジンが展開する。実行前に文字列置換される。

Terraform の出力にはリソース名やタグ値が含まれる。攻撃者がリソースのタグにこんな値を仕込んだら:

tags = {
  name = "test`; await github.rest.repos.delete({owner:'my-org',repo:'myapp'}); `"
}

テンプレート展開後:

const output = `#### Terraform Plan
\`\`\`
  ~ tags.name = "old" -> "test`; await github.rest.repos.delete({owner:'my-org',repo:'myapp'}); `"
\`\`\``;

バッククォートが閉じて、任意の JavaScript が実行される。 リポジトリの削除すら可能。

攻撃シナリオ: 外部コントリビューターが PR を送信 → Terraform plan が自動実行 → plan 出力にリソース名やタグ値が含まれる → PR コメントとして展開される際に悪意ある JavaScript が実行される。OSS や社外コントリビューターを受け入れるリポジトリでは、外部からの攻撃が成立する。

正しい実装: 環境変数経由

# 安全な実装
- uses: actions/github-script@v8
  env:
    PLAN_OUTPUT: ${{ steps.plan.outputs.stdout }}
  with:
    script: |
      const output = `#### Terraform Plan
      \`\`\`
      ${process.env.PLAN_OUTPUT}
      \`\`\``;

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

env: で渡すと process.env.PLAN_OUTPUT文字列変数として読み取られる。テンプレートリテラルへの注入にならない。

この脆弱性は AI が書く GitHub Actions のコードでは極めて頻出。 ${{ }} テンプレート展開と process.env の違いを理解していないと見逃す。

深刻度: 🔴 HIGH

GitHub Actions のトークン権限で任意コード実行。リポジトリ削除、シークレット漏洩のリスク。


脆弱性4: Terraform の plan/apply が TOCTOU 脆弱

Claude が書いたコード

# Claude が書いた Terraform ワークフロー
jobs:
  plan:
    steps:
      - run: terraform plan

  apply:
    needs: plan
    steps:
      - run: terraform apply -auto-approve

何が問題か

TOCTOU(Time of Check, Time of Use)。plan した時点と apply する時点で State が変わっている可能性がある。

t0: plan ジョブ → 「Cloud SQL の tier を変更します」
t1: 別の誰かが terraform apply を実行 → State が変わる
t2: apply ジョブ → 再度 plan → t0 とは違う差分が適用される

terraform apply -auto-approve は内部で再度 plan を実行する。レビューした内容と違うものが適用される可能性がある。

正しい実装: plan ファイルを artifact で保存

jobs:
  plan:
    steps:
      - run: terraform plan -out=tfplan    # plan をファイルに保存

      - uses: actions/upload-artifact@v4   # artifact として保存
        with:
          name: tfplan
          path: tfplan
          retention-days: 1

  apply:
    needs: plan
    steps:
      - uses: actions/download-artifact@v4  # artifact をダウンロード
        with:
          name: tfplan

      - run: terraform apply -auto-approve tfplan  # 保存された plan を適用

terraform plan -out=tfplan で plan 結果をバイナリファイルに保存。terraform apply tfplan はこのファイルの内容をそのまま適用する。再度 plan を実行しない。

深刻度: 🟡 MEDIUM

意図しないインフラ変更が適用される。直接的な攻撃よりは運用事故のリスク。


Claude Code が「よくできた」部分まとめ

批判ばかりではフェアではないので、AI が正しく書けていた部分を整理する:

カテゴリ 評価 詳細
HCL 構文 構文エラーゼロ。terraform validate 一発 OK
モジュール分離 modules/environments の分離が教科書通り
Cloud SQL 設計 バックアップ、PITR、メンテナンスウィンドウ、Query Insights
変数化 環境差分を tfvars に閉じ込める設計
条件付きリソース count = var.xxx ? 1 : 0 パターン
VPC 設計 Private IP、VPC Access Connector
outputs 定義 必要な出力を網羅(一部命名の揺れあり)

85点分は本当に優秀。 ゼロから人間が書くより圧倒的に速い。


4つの脆弱性に共通するパターン

脆弱性 共通点
SA キー AI は「最もシンプルな方法」を選ぶ
IAM 1SA AI は明示的に指示しないと分割しない
Script Injection AI はセキュリティコンテキストを考慮しない
TOCTOU AI は「動く」ことを優先し、エッジケースを考慮しない

AI は「構文的に正しいコード」を書くのが得意だが、「設計判断」はしない。

  • 「WIF と SA キー、どちらが安全か」は判断できる(聞けば答える)
  • でも「聞かれなければ SA キーを使う」

これは AI の限界というより、プロンプトの限界。「安全に」「本番品質で」という指示は曖昧すぎて、具体的な設計判断に繋がらない。


この記事のまとめ

Claude Code に Terraform を書かせた結果:

✅ 構文: 完璧
✅ モジュール設計: 完璧
✅ 変数化: 完璧
✅ Cloud SQL 設計: 完璧
❌ 認証方式: SA キー(WIF を使うべき)
❌ IAM 設計: 1SA に全権限(5分割すべき)
❌ CI/CD セキュリティ: Script Injection あり
❌ CI/CD 設計: TOCTOU 脆弱

次の記事では、これら4つの脆弱性を Claude Code と一緒に修正していく過程を記録する。 AI に「ここが問題だ」と指摘したとき、どんなコードが返ってくるか。そして人間が追加で何を判断する必要があるか。

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


シリーズ記事

  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?