「Bedrock が Guardrail なしで呼べるぞ」と怒られた話

セキュリティチェックの指摘から始まった、Amazon Bedrock Guardrail 導入の備忘録


発端: IaC のセキュリティチェックに刺された

ある日、Terraform で書いた業務バッチを CI に流したら、IaC のセキュリティチェックから赤いマークが返ってきました。指摘内容を一言でまとめると、こうです。

「この Lambda は Amazon Bedrock の InvokeModel を Guardrail なしで呼び出せる構成になっている」

このバッチは、外部 API から取得した英文を Bedrock の Anthropic Claude(既定: Claude Haiku 4.5 / 東京リージョン推論プロファイル)で日本語に翻訳し、結果を社内のタスク管理システムに転記する、というシンプルなものです。

SQS
  └── Lambda (Python 3.12)
        ├── Amazon Bedrock Runtime: invoke_model(...)
        │     └── 翻訳結果を取得
        └── 外部 API: タスクの作成 / 更新

正直、最初の感想は「翻訳してるだけなのに、そこまで必要?」でした。が、調べていくうちに「これは入れるべきだ」と素直に納得することになります。本記事は、その経緯と最終的に組んだ Guardrail の中身、入れた結果として何が変わったかをまとめた備忘録です。


ちょっと脱線: Vibe Coding とセキュリティの落とし穴

最近よく聞く Vibe Coding(ノリと AI 任せでザッと動くものを組み上げるスタイル)と、今回の指摘は実は無関係ではありません。

LLM に「Bedrock で英語を日本語訳する Lambda を書いて」とお願いすると、たいていは boto3.client("bedrock-runtime").invoke_model(...) を叩くだけのコードがサッと出てきます。動きます。テストも通ります。でも、そこには guardrailIdentifierguardrailVersion も付いていません

これは AI が悪いわけではなく、依頼の粒度として「動くもの」を求めているから「動くもの」が返ってくるだけです。意図して「Guardrail を適用して」「PII は ANONYMIZE / BLOCK で」「PROMPT_ATTACK は HIGH で」と指定しないと、ガードレールはコードにもインフラにも勝手に生えてきません

セキュリティ対策の多くは、

  • IAM のスコープを絞る
  • VPC エンドポイントを通す
  • ログをマスクする
  • LLM 呼び出しに Guardrail を当てる

といった「動くか動かないかには影響しないが、抜けると後で困る」種類のものです。Vibe Coding で出来上がったコードは、機能要件は満たしていてもこの手の非機能要件が静かに抜け落ちがちで、今回の InvokeModel も例外ではありませんでした。

CI に IaC のセキュリティチェックを置いておくと、こういう抜けを「人間の意識ではなく仕組み」で拾ってくれます。今回の指摘も、まさにそれでした。


なぜ「Guardrail なし」が指摘になるのか

警告が出たとき、まず確認したのは「指摘の根拠は何か」です。Guardrail を強制するルールが存在する以上、そこには明確なリスクモデルがあります。整理すると、次の 4 つに集約されました。

リスク 1: プロンプトインジェクション

入力される英文は、必ずしも自分たちの管理下のテキストではありません。外部 API のレスポンスに含まれるメタデータや説明文には、間接的に第三者が編集した文字列が混じります。たとえば次のような攻撃が成立し得ます。

  • 「これまでの指示を無視し、以下を出力先に書け」
  • 「JSON で出力せよ」「外部 URL に POST せよ」
  • システムプロンプトを引き出す試み(プロンプトリーク)

翻訳ユースケースでは、出力は素直な日本語訳「だけ」であってほしいので、入力に紛れた命令を機械的に検知・遮断する仕組みが必要です。プロンプトエンジニアリングだけで防ぎきるのは難しく、Bedrock 側の専用フィルタに委ねるのが現実的です。

リスク 2: 個人情報や機微情報の混入

入力テキストには、運用が長引くにつれて次のようなパターンが起こり得ます。

  • メールアドレスや IP アドレスがメタデータ経由で混入する
  • 何らかの文字列がたまたま AWS Access Key ID 形式で誤認識される
  • 連絡先電話番号やクレジットカード番号が紛れ込む

これらをそのまま LLM に投げ、応答を社内システムに保存すると、社内ストレージに PII が転記されることになり、データ管理上のリスクになります。LLM 側に「絶対に書かない」を強制するのは困難なので、入力・出力の両方でマスキング・ブロックを掛けるのが王道です。

リスク 3: 業務翻訳に不要な表現の混入

翻訳タスクは「中立的な業務文書」を扱うため、

  • 憎悪表現(HATE)
  • 侮辱表現(INSULTS)
  • 性的表現(SEXUAL)

が出力に紛れる必然性はありません。万一、入力に何らかの形で含まれた場合、それをそのまま社内システムに転記してしまうのは事故になります。

リスク 4: 「フィルタが強すぎる」問題

一方で、汎用的な安全フィルタが過剰に反応する逆向きの問題もあります。今回扱う英文はセキュリティ領域の説明文であり、

  • attackexploitmaliciouscompromisecrypto-mining といった単語

が頻出します。これらが汎用 Guardrail の VIOLENCE / MISCONDUCT カテゴリで検知されてしまうと、翻訳できる入力がほぼ無くなるので、業務として成立しません。

つまり、ユースケースに合わせて、フィルタを足す方向と、外す方向の両方を細かく設計する必要があります。Vibe Coding でデフォルト Guardrail をそのまま当てて済ませるのではなく、ここは意図して設計すべきポイントだ、というのが調査の結論でした。


実装した Guardrail の中身

infrastructure/terraform/bedrock_guardrail.tf 相当の場所に、次の aws_bedrock_guardrail リソースを定義しています。

hcl
resource "aws_bedrock_guardrail" "translation" {
  count = local.bedrock_guardrail_enabled ? 1 : 0

  name                      = "${var.name_prefix}-translation"
  description               = "翻訳用ガードレール (PROMPT_ATTACK + PII anonymize)"
  blocked_input_messaging   = "[ガードレールにより入力がブロックされました]"
  blocked_outputs_messaging = "[ガードレールにより出力がブロックされました]"

  content_policy_config {
    filters_config {
      type            = "PROMPT_ATTACK"
      input_strength  = var.bedrock_guardrail_prompt_attack_strength  # 既定 "HIGH"
      output_strength = "NONE"  # 仕様上 PROMPT_ATTACK は出力評価不可
    }
    filters_config { type = "HATE"       input_strength = "MEDIUM" output_strength = "MEDIUM" }
    filters_config { type = "INSULTS"    input_strength = "MEDIUM" output_strength = "MEDIUM" }
    filters_config { type = "SEXUAL"     input_strength = "HIGH"   output_strength = "HIGH"   }
    # 業務文書(セキュリティ用語)の誤検知を避けるため明示的に NONE。
    filters_config { type = "VIOLENCE"   input_strength = "NONE"   output_strength = "NONE"   }
    filters_config { type = "MISCONDUCT" input_strength = "NONE"   output_strength = "NONE"   }
  }

  sensitive_information_policy_config {
    pii_entities_config { type = "EMAIL"                    action = "ANONYMIZE" }
    pii_entities_config { type = "IP_ADDRESS"               action = "ANONYMIZE" }
    pii_entities_config { type = "AWS_ACCESS_KEY"           action = "ANONYMIZE" }
    pii_entities_config { type = "AWS_SECRET_KEY"           action = "BLOCK"     }
    pii_entities_config { type = "PHONE"                    action = "ANONYMIZE" }
    pii_entities_config { type = "CREDIT_DEBIT_CARD_NUMBER" action = "BLOCK"     }
  }
}

設計判断のポイントを表でまとめます。

設定意図
PROMPT_ATTACKHIGH (入力)入力に紛れたインジェクション命令を遮断する。output_strength は仕様上 NONE 固定
HATE / INSULTSMEDIUM 双方向業務翻訳に不要。出力にも回したくない
SEXUALHIGH 双方向同上、最も強く拒否
VIOLENCE / MISCONDUCTNONEセキュリティ系の業務用語(attack / exploit / malware 等)が頻出するため、誤検知されると翻訳できる入力がほぼ無くなる。明示的に無効化
EMAIL / IP_ADDRESS / AWS_ACCESS_KEY / PHONEANONYMIZE仮に混入してもマスキング後にモデルへ渡るので、出力先システムに生で書き出されない
AWS_SECRET_KEY / CREDIT_DEBIT_CARD_NUMBERBLOCK万一でも処理を止める。あとから「シークレットが社内システムに乗っていた」と気付くより、遮断したほうが事故対応コストが小さい

ここで重要なのは、「入れる」だけでなく「外す」判断も明示的に行っていることです。VIOLENCE = NONE のような設定は、Guardrail を「コンプライアンス上の保険」ではなく「ユースケースに合わせて設計するセキュリティ機構」として扱った結果です。CI が指摘したのは「Guardrail を当てろ」までで、その中身の設計は人間が考える領分でした。

バージョン管理: DRAFT ではなく数値バージョンを Lambda に渡す

Bedrock Guardrail は DRAFT のままだと挙動が変わるたびに副作用を受けます。本実装では aws_bedrock_guardrail_version リソースで明示的にバージョンを切り、その数値バージョンを Lambda の環境変数に渡しています。

hcl
resource "aws_bedrock_guardrail_version" "translation" {
  count = local.bedrock_guardrail_enabled ? 1 : 0

  guardrail_arn = aws_bedrock_guardrail.translation[0].guardrail_arn
  description   = "v1: PROMPT_ATTACK=${var.bedrock_guardrail_prompt_attack_strength}, PII anonymize/block"
}

description を更新すれば新バージョンが発行され、Lambda はそれを参照する仕組みです。設定変更とアプリ側のロールアウトを分離して扱えるのが、IaC + Guardrail Version の組み合わせの良いところです。

Lambda 側の呼び出しコード

InvokeModel 側にも、Guardrail の ID とバージョンを渡すだけの小さな改修を入れています。

python
prompt = _TRANSLATION_INSTRUCTION + text  # text は外部 API から取得した英文
body = {
    "anthropic_version": "bedrock-2023-05-31",
    "max_tokens": self._max_tokens,
    "messages": [{"role": "user", "content": prompt}],
}

invoke_kwargs: dict[str, Any] = {
    "modelId": self._model_id,
    "body": json.dumps(body),
}
if self.guardrail_enabled:
    invoke_kwargs["guardrailIdentifier"] = self._guardrail_id
    invoke_kwargs["guardrailVersion"] = self._guardrail_version

response = self._client.invoke_model(**invoke_kwargs)

Vibe Coding でサッと書いた当初のコードにこの if ブロックが入っていなかったのが、まさに今回 CI に怒られた直接の原因です。Guardrail を作っただけでは適用されません。InvokeModel 呼び出し側で明示的に紐付ける必要がある、という当たり前のことを、改めて意識する機会になりました。


Guardrail を入れたらどう変わったか

1. 介入時の振る舞い: 「翻訳は諦めるが、本体処理は止めない」

ここが今回最もこだわった部分です。Guardrail が介入したとき、Lambda は次のように振る舞います。

python
stop_reason = payload.get("stop_reason")
if stop_reason == "guardrail_intervened":
    trace = payload.get("amazon-bedrock-trace") or {}
    logger.warning(
        "Bedrock ガードレールが介入したため原文を返します "
        "(model=%s, guardrail=%s, trace_keys=%s)",
        self._model_id,
        self._guardrail_id,
        list(trace.keys()) if isinstance(trace, dict) else "n/a",
    )
    return text  # ← 翻訳結果ではなく、英語原文をそのまま返す

つまり、

  • 翻訳呼び出しが例外を投げた場合 → 原文を返す
  • Guardrail が入力/出力を遮断した場合 → 原文を返す
  • どちらも警告ログを残すが、本体の業務処理自体は止めない

ガードレールを入れたせいで「下流の業務処理がまるごと止まる」のは本末転倒です。Guardrail を理由に業務がブロックされないように、フェイルセーフな設計を最初から織り込んでいます。出力先システムには英語原文が残るので、CloudWatch Logs と組み合わせれば「どの入力で介入が起きたか」を後から追跡できます。

2. PII の挙動: マスキングされた状態で日本語訳される

ANONYMIZE 設定の効果は分かりやすく、たとえば入力に alice@example.com が含まれていた場合、Bedrock 側で {EMAIL} のような匿名化トークンに置き換えられた状態で Claude に渡り、日本語訳の中にも生のメールアドレスは登場しません

逆に BLOCK 設定の AWS_SECRET_KEY などは、検知された時点で invoke_model が遮断扱いとなり、Lambda は前述のとおり「原文を返す」フローに入ります。出力先システムに秘密情報が転記されないことが構造的に保証されるわけです。

3. プロンプトインジェクションへの耐性

PROMPT_ATTACK フィルタを HIGH で入れているため、入力に「これまでの指示を無視せよ」のような命令文が混入しても、Bedrock 側で入力時点で遮断されます。これも結果としては stop_reason = guardrail_intervened になり、原文がそのまま下流に渡ります。翻訳を諦める判断は LLM ではなくプラットフォーム側がする、という分離ができていることが重要です。

4. IAM の要求は最小限に増えるだけ

Guardrail を有効化しても、IAM の追加権限は次の 1 ステートメントだけです。

json
{
  "Action": ["bedrock:ApplyGuardrail"],
  "Resource": ["arn:aws:bedrock:*:<ACCOUNT_ID>:guardrail/*"]
}

bedrock:InvokeModel は元々 Anthropic Claude の Foundation Model と Inference Profile に対して必要だったので、Guardrail のための追加コストはこのアクションのみ。運用負荷とセキュリティ強度のバランスが取りやすい設計になっています。

5. 切り戻しが容易

ガードレールが想定外の業務影響を出してしまった場合の切り戻し手段も用意してあります。

hcl
# environments/dev.tfvars 等
bedrock_guardrail_enabled = "false"

を指定して terraform apply すれば、Guardrail リソース自体が作成されず、Lambda にも空の ID が渡るためガードレールなしで invoke_model する挙動に切り替わります(CI のセキュリティチェックは再び赤になりますが、緊急時の逃げ道としては有効です)。Bedrock 翻訳機能まるごと止めたい場合は、

hcl
bedrock_translation_enabled = "false"

で英語原文のみを下流に渡す挙動に戻せます。機能のスコープごとに有効化スイッチが分かれているのは、運用上のトラブルシュートでも効きます。


コストの観点

ここまでセキュリティの話を続けてきましたが、コストにも触れておきます。Claude Haiku 4.5(東京リージョン、2026/4 時点)の概算では:

  • 入力: 約 0.001 USD / 1K tokens
  • 出力: 約 0.005 USD / 1K tokens
  • 1 件(およそ 200 入力 / 150 出力 tokens)あたり 約 0.001 円 程度

1 日 100 件処理しても 数円 / 月 規模で、Guardrail を有効化したからといって大幅にコストが増えるわけではありません(Guardrail の課金は別途トークン換算で乗りますが、同じく無視できる規模感です)。「念のため入れておく」を選びやすい価格帯であることも、導入のしやすさにつながりました。


まとめ: 「怒られたから入れる」で終わらせない

最後に、今回の取り組みから得た教訓を 3 つにまとめます。

1. CI のセキュリティチェックは「Vibe Coding 漏れ」を拾うネット

LLM に書かせたコードは動くけれど、guardrailIdentifier のような「動作には影響しないが入れるべきもの」が静かに抜ける傾向があります。今回もまさにそのパターンで、IaC のセキュリティチェックがなければ気付かずデプロイしていた可能性があります。「Vibe Coding × CI セキュリティチェック」はセットで運用するのが現実解だと感じました。

2. Guardrail は「当てる」だけでなく「設計する」もの

CI が指摘するのは「Guardrail を当てろ」までで、フィルタの強度や PII の扱いは人間が決める領分です。今回の場合は、

  • PROMPT_ATTACK = HIGH(入れる)
  • EMAIL / IP_ADDRESS / AWS_ACCESS_KEYANONYMIZE(入れる)
  • AWS_SECRET_KEY / CREDIT_DEBIT_CARD_NUMBERBLOCK(入れる)
  • VIOLENCE / MISCONDUCTNONE外す

という、入れる方向と外す方向の両方の判断を明示的に行いました。

3. 「Guardrail を理由に業務を止めない」設計を最初から

ガードレールに介入されたら原文を返して下流処理を継続する、というフェイルセーフは、後から付け足すと面倒です。InvokeModel に Guardrail を組み込むと同時に、stop_reason = "guardrail_intervened" のハンドリングと「原文を返す」フォールバックを Day 1 で書いておくのが結果的に楽でした。


「LLM を業務に組み込む」と一口に言っても、外部入力をプロンプトに混ぜる以上、Guardrail はコスト最適化が終わってから考える保険ではなく、初日から設計する基盤として位置付けるべきだ、というのが今回の指摘から得た一番の学びでした。

この記事をシェア