AI アプリのセキュリティ、入口ばかり見ていませんか
プロンプトインジェクションの記事は山ほどあります。一方で、モデルが返してきた文字列をアプリがどう扱っているかを論じた記事はほとんど見かけません。
OWASP GenAI Security Project の LLM Top 10 でいう LLM05: Improper Output Handling の領域です。入口(プロンプト)ではなく出口(出力の取り扱い)。
そして、この出口の話は実は新しくありません。「外部から来た文字列をエスケープせずに画面に出す」という、ただのクロスサイトスクリプティング(XSS)の話です。違うのは「外部」が今回は LLM だという点だけ。
だったら静的解析(SAST)で検出できるはずです。実際できるようになっていたので、手元で確認しました。
なお前回は「どのモデルを使っているか」を数える話を書きました。今回はその続きで、「そのモデルの出力をどう扱っているか」の話です。
検出メッセージが主張そのもの
先に結論の証拠を出します。以下は実際の snyk code test --json の出力です。
"message": {
"text": "Unsanitized input from an LLM flows into flask.render_template_string,
where it is used to render an HTML page returned to the user.
This may result in a Cross-Site Scripting attack (XSS)."
}
注目してほしいのは冒頭の Unsanitized input from an LLM の部分です。
この文は固定文ではなく、テンプレートになっています。同じ JSON の中に、埋め込み前の形が入っています。
"markdown": "Unsanitized input from {0} {1} into {2}, where it is used to render ...",
"arguments": [
"[an LLM](0)", // ← {0} = source が何か
"[flows](1),(2),(3),(4),(5),(6)", // ← {1} = データフローの経路
"[flask.render_template_string](7)" // ← {2} = sink
]
{0} は「汚染された値がどこから来たか」を入れるスロットです。ここに何が入るかで、Snyk がその値を何と見なしたかが分かります。
実際、同じスキャンの別の検出では、このスロットの中身が変わります。
Unsanitized input from an HTTP parameter flows into os.system, ...
Unsanitized input from an environment variable flows into flask.render_template_string, ...
Unsanitized input from an LLM flows into flask.render_template_string, ...
HTTP パラメータ、環境変数、そして LLM。つまり LLM の応答は、リクエストパラメータや環境変数と同じ「外部から来た信頼できない値」のカテゴリとして、独立に扱われているということです。
ルール定義側にもタグが立っています。
"id": "python/XSS",
"properties": {
"tags": ["python", "XSS", "Security",
"SourceNonServer", "SourceResourceAccess", "SourceLLM", "Taint"]
}
SourceLLM というタグが付いています。LLM が独立した source 種別として扱われていることが、ルール定義からも読み取れます。
検証したコード
最小構成です。Flask のルートで LLM を呼び、応答をそのまま HTML として返します。
from flask import Flask
from langchain_community.chat_models import ChatLiteLLM
from langchain_core.messages import HumanMessage
app = Flask(__name__)
@app.route("/ask")
def ask():
answer = ChatLiteLLM(model="gpt-4o-mini").invoke(
[HumanMessage(content="hello")]
).content
return f"<div>{answer}</div>" # ← ここが sink
これで XSS として報告されます。
LLM を呼び出した10行目が起点、出力している13行目が終点として追跡されている
「そんなコード書かないでしょ」と思うかもしれませんが、LLM に「見やすく HTML で整形して」と指示しているアプリでは、むしろ自然にこうなります。タグがエスケープされると表示が壊れるので、開発者は「表示を直そうとして」エスケープを外す方向に動きます。うっかりではなく、必然としてそうなる。
sink の書き方は問わない
出口の形を3通り試しましたが、いずれも XSS として報告されました。
# 1. 文字列をそのまま返す
return "<div>" + answer + "</div>"
# 2. f-string
return f"<div>{answer}</div>"
# 3. make_response 経由
resp = make_response("<div>" + answer + "</div>")
resp.headers["Content-Type"] = "text/html"
return resp
render_template_string() に渡した場合は、XSS に加えて SSTI(Server-Side Template Injection)としても報告されます。
動作を確認したクライアント
以下の8つで、LLM の応答が untrusted source として扱われることを確認しました。
| クライアント | LLM source 認識 |
|---|---|
langchain_community.chat_models.ChatLiteLLM |
✅ |
langchain_community.chat_models.ChatOpenAI |
✅ |
langchain_community.chat_models.ChatAnthropic |
✅ |
langchain_openai.ChatOpenAI |
✅ |
langchain_anthropic.ChatAnthropic |
✅ |
openai |
✅ |
google.generativeai |
✅ |
huggingface_hub |
✅ |
LangChain のラッパー経由でも、ベンダー SDK を直接叩いても検出されます。
注意
ルールはライブラリごとに定義されているため、使っているクライアントによって挙動が異なる場合があります。また Snyk Code のルールは8月・9月と継続的に更新されているため、上記は 2026年9月・Snyk Code engine 1.1307.4 時点の結果です。この検出を CI のゲートとして当てにするなら、自分の環境で一度確認してください。確認方法は次のセクションの通りです。
使っているクライアントを確かめる方法
検証用のコードを書くときの注意点がひとつあります。
確認したいクライアントごとに、sink を別々の行に置いてください。
def check_openai(q):
a = openai_client(q)
return render_template_string("<div>" + a + "</div>") # 独立した sink
def check_anthropic(q):
a = anthropic_client(q)
return render_template_string("<div>" + a + "</div>") # 独立した sink
sink を共通の関数にまとめてはいけません。同じ行に複数のデータフローが集まると、報告が1件にまとまります。どのクライアントが認識されたのか判別できなくなります。
そして すでに動くと分かっているクライアントを、対照として必ず1つ混ぜてください。その対照が出てこなければ、その回の結果は信用できません。
結果は --json で取ると、codeFlows の起点行からどのクライアントが引っかかったかを逆算できます。
snyk code test --json > result.json
SAST の守備範囲
ここは誤解されやすいので明示しておきます。SAST という手法の性質の話で、特定の製品の話ではありません。
プロンプトインジェクションそのものは検出されません。 SAST が見るのはコードです。プロンプトは自然言語であってコードではないので、解析の対象になりません。
システムプロンプト内の設計不備も同様です。 たとえば「渡された ID のユーザー情報を返す」とだけ書かれていて、その ID が本人のものか確かめていない、といった権限の抜け。これはプロンプトの文面の問題なので、コードをいくら読んでも出てきません。
つまり SAST でカバーできるのは「LLM が返した文字列を、アプリがどう扱っているか」という一点です。
狭く見えますが、この一点は既存の AppSec パイプラインでそのまま扱えるという強みがあります。CI に SAST が入っているなら追加コストはほぼゼロで、出口の事故を止められる。
プロンプト側は別のレイヤーの仕事です。入口と出口を別々の道具で押さえる、という整理が現実的だと思います。
では、他のレイヤーは何で見るのか
整理するとこうなります。
| 見たいもの | いつ見るか | 手法 |
|---|---|---|
| LLM 出力の取り扱い(この記事) | コードを書いた時点 | SAST |
| どのモデル・エージェント・MCP を使っているか | コードを書いた時点 | AI-BOM / AI-SPM |
| 実際に攻撃が通るか | 動かした状態 | DAST |
| ビジネスロジックや脆弱性の連鎖 | 動かした状態 | AI ペンテスト |
このうち AI-BOM は、この記事で使ったのと同じリポジトリにそのまま撃てます。
AI 資産の棚卸しは前回の記事で
snyk aibom による棚卸しは、前回の記事に詳しく書きました。
モデルを1つ呼んだだけでデータセットが21件ぶら下がってくる話と、ビューアが表示しないリスクスコアの話です。コマンド自体は1行です。
snyk aibom
前回と今回を並べると、こうなります。
| 見ているもの | |
|---|---|
| 前回(AI-BOM / AI-SPM) | 何を使っているか |
| 今回(SAST) | その出力をどう扱っているか |
同じリポジトリを見ていますが、軸が違います。棚卸しでモデルの一覧が出ても、そのモデルの応答をアプリがエスケープせずに画面へ出しているかどうかは分かりません。逆もまた同じです。
動いているアプリ側
コードを静的に見るだけでは、実際に攻撃が通るかは分かりません。そこは動的検査(DAST)の領域で、Snyk では Snyk API & Web が担当します。Web アプリと API の両方が対象です。使い方は別の記事に書きました。
さらに攻撃者視点で継続的に検証する Continuous Offensive Security(COS) があります。自律エージェントがビジネスロジックを推論し、脆弱性を連鎖させて、PoC コード付きの検証済み findings を出すというもの。プロンプトインジェクションのような「コードを読んでも分からない」問題は、最終的にこちら側で拾うことになります。
結局どう組むか
出口はコードで止める。入口は動かして試す。
この記事で見たのは前半だけです。LLM の出力を untrusted source として扱う SAST は、既存の CI にそのまま乗ります。そこに AI-BOM で「何を使っているか」の可視化を足し、DAST や AI ペンテストで「実際に通るか」を確かめる。
どれか一つで全部解決する、という話ではありません。
まとめ
「AI アプリのセキュリティ = プロンプト対策」で止まらないでください。
出口の処理は、従来の XSS やインジェクションの問題にそのまま還元できます。そして LLM の応答を untrusted source として扱う SAST が既に動いています。
LLM の出力は、ユーザー入力と同じく「外から来た文字列」です。そう扱えば、やるべきことは変わりません。
