Agent Studio が自動生成する /api-proxy バックエンドに SSRF の欠陥があり、2026年7月1日より前に作成した Web アプリは再生成して再デプロイすることが推奨されている、という告知を読んだとき、私は手を止めました。
該当するかどうかを判断する材料が、自分の側に何もなかったからです。
2014年から個人開発を続けているうちに、手元の小さなサービスは少しずつ増えました。どれが「外から渡された URL をそのまま取りに行く」経路を持っているのか、記憶では答えられません。grep で requests.get を数えると数十件出てきて、そのうち何件が本当に危ないのかは、結局一件ずつ読むまで分かりませんでした。
Gemini 3.5 Flash Cyber のように、脆弱性の発見・検証・修正パッチ作成へ用途を絞った軽量モデルが出てきたことで、この作業を機械に寄せる余地は確実に広がりました。ただ、私が最初に組んだ「リポジトリを丸ごと渡して危ないところを教えてもらう」構成は、二週間で運用が止まりました。止まった理由と、そこから組み直した三層構成、そして一番手こずった「指摘の指紋」の設計を残しておきます。
数値はすべて手元のフィクスチャと実在のコードツリーで測ったものです。再現手順もそのまま載せています。
丸ごと渡す構成が二週間で止まった理由
最初の実装は素朴でした。変更のあったファイルをまとめてモデルへ送り、「SSRF に該当する箇所を挙げてください」と頼み、返ってきた指摘を Issue にする。動きはしました。動きはしたのですが、三つの理由で続きませんでした。
一つ目は、同じ指摘が何度も戻ってくることです。コメントを一行足しただけの差分でも、モデルは同じ関数を再び「危険」として挙げます。台帳を作って重複を弾こうとしたところ、その台帳の鍵の作り方そのものが本題になりました(後半で詳述します)。
二つ目は、確信度を判断材料に使えないことです。「high / medium / low」を返してもらう形にしていましたが、同じ入力を三回投げると high と medium が入れ替わります。閾値を引いた瞬間、閾値が意味を失いました。
三つ目は費用です。差分が小さい日でも、周辺の文脈を添えると入力トークンは膨らみます。指摘の大半が既知の再提出なのに課金だけは毎回発生する、という構図になりました。この手の「呼び出し単位の会計が合わない」問題については、Gemini API の usageMetadata で本番アプリのコストを記録する で扱った記録の取り方が土台になります。
止まった後で気づいたのは、私がモデルに「探索」と「判定」を同時に任せていたことでした。探索は機械の得意分野ですが、判定を任せると、判定の根拠が毎回揺れます。
三層に分ける — 抽出・仮説・検証
組み直した構成は次の三層です。
| 層 | 担当 | 出力 | 揺れるか |
| 抽出 | 自前の AST スキャナ | 候補シンクの一覧と指紋 | 揺れません(決定的) |
| 仮説 | モデル | 再現手順と修正パッチの案 | 揺れます(許容) |
| 検証 | 自前のハーネス | 採用 / 却下の二値 | 揺れません(決定的) |
肝は、揺れる層を真ん中だけに閉じ込めることです。モデルには「ここが危ないか」を尋ねず、「これが危ないとしたら、どう再現し、どう直すか」を出してもらいます。危ないかどうかの最終判断は、その再現手順が実際に走るかどうかだけで決めます。
モデルの自己申告を採用条件から外すと、モデルを差し替えても運用が壊れません。私は仮説層をインターフェース越しに呼ぶ形にして、汎用の Flash 系と用途特化のモデルを同じ台帳で比べられるようにしています。
抽出層をコードで持つ
抽出層は自分で書くことをお勧めします。ここを外注すると、費用と再現性の両方を失うからです。
Python の ast で、外向きリクエストのシンクのうち、リクエスト由来の入力が第一引数に届いているものだけを拾います。
# scan_sinks.py — 外向きリクエストのシンクを抽出して指紋を付ける
import ast, hashlib, json, sys
from pathlib import Path
SINKS = {
("requests", "get"), ("requests", "post"),
("httpx", "get"), ("httpx", "post"),
("urllib.request", "urlopen"),
}
TAINT_ROOTS = ("request",)
def dotted(node):
if isinstance(node, ast.Attribute):
left = dotted(node.value)
return f"{left}.{node.attr}" if left else node.attr
if isinstance(node, ast.Name):
return node.id
return ""
def taint_key(node):
"""request.args.get("src") / request.json["url"] を正規化キーに変換する。"""
if isinstance(node, ast.Call):
base = dotted(node.func)
if base.startswith(TAINT_ROOTS) and node.args and isinstance(node.args[0], ast.Constant):
return f"{base}({node.args[0].value})"
return None
if isinstance(node, ast.Subscript):
base = dotted(node.value)
if base.startswith(TAINT_ROOTS) and isinstance(node.slice, ast.Constant):
return f"{base}[{node.slice.value}]"
return None
if isinstance(node, ast.Attribute):
base = dotted(node)
return base if base.startswith(TAINT_ROOTS) else None
return None
class FnScanner(ast.NodeVisitor):
def __init__(self, relpath):
self.relpath = relpath
self.findings = []
def visit_FunctionDef(self, fn):
env = {} # ローカル変数名 -> 汚染元キー
for node in ast.walk(fn):
if isinstance(node, ast.Assign):
key = taint_key(node.value)
if key:
for t in node.targets:
if isinstance(t, ast.Name):
env[t.id] = key
for node in ast.walk(fn):
if not isinstance(node, ast.Call):
continue
fq = dotted(node.func)
if "." not in fq or not node.args:
continue
mod, _, fname = fq.rpartition(".")
if (mod, fname) not in SINKS:
continue
arg = node.args[0]
local = arg.id if isinstance(arg, ast.Name) else ""
src = env.get(local) if local else taint_key(arg)
if src is None:
continue
self.findings.append({
"file": self.relpath, "line": node.lineno,
"sink": f"{mod}.{fname}", "source": src,
"local": local, "fn": fn.name,
})
self.generic_visit(fn)
def h(*parts):
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:12]
def scan(root: Path):
out = []
for p in sorted(root.rglob("*.py")):
rel = str(p.relative_to(root))
s = FnScanner(rel)
s.visit(ast.parse(p.read_text()))
for f in s.findings:
f["fp_line"] = h(f["file"], str(f["line"]), f["sink"]) # 方式A
f["fp_v1"] = h(f["file"], f["fn"], f["sink"], f["local"]) # 方式B
f["fp_v2"] = h(f["sink"], f["source"], f["fn"]) # 方式C
f["fp_v3"] = h(f["sink"], f["source"]) # 方式D
out.append(f)
return out
if __name__ == "__main__":
print(json.dumps(scan(Path(sys.argv[1])), ensure_ascii=False, indent=2))
汚染の追跡を一段しか行っていないのは、手抜きではなく意図です。payload = request.json を経由して target = payload["callback_url"] と二段で受け渡す書き方は、この実装では拾えません。検証用に用意した 4 ファイル・9 個のシンクのフィクスチャでは、8 個を検出し、この二段経由の 1 個を取りこぼしました。再現率としては 88.9% です。
取りこぼしを減らそうと汚染追跡を深くすると、今度は誤検出が増えて仮説層の入力が膨らみます。抽出層は「多少漏らしても静かに速い」ほうが運用が続きました。漏れの対策は追跡を深くすることではなく、シンクの定義を足していくことに寄せています。
速度も測っておきました。実在の Python ツリーに対して走らせた結果です。
| 対象 | .py ファイル数 | 所要時間 | 1ファイルあたり |
| /usr/lib/python3.10 | 651 | 6.0 秒 | 9.2 ms |
| /usr/lib/python3/dist-packages | 4,016 | 28.2 秒 | 7.0 ms |
4,000 ファイルを 30 秒弱で走り切るなら、CI のたびに回しても負担になりません。モデル呼び出しの前にここを通すだけで、仮説層へ渡す対象は数十件まで落ちます。
行番号で指紋を作った初日に台帳が壊れた
抽出した指摘には識別子が要ります。同じ箇所を毎回「新規」として扱うと、台帳は一日で意味を失うからです。
最初は素直に ファイルパス + 行番号 + シンク名 にしました。方式Aです。翌日、ファイル冒頭に docstring と TODO コメントを足し、ロギング用のヘルパー関数を一つ追加した時点で、台帳は全滅しました。
そこで ファイルパス + 関数名 + シンク名 + ローカル変数名 に変えました。方式Bです。行番号への依存は消えたので大丈夫だと考えていたのですが、src を image_url に、hook を endpoint にリネームした瞬間、こちらも全滅しました。変数名は意味を持たないのに指紋に混ぜていた、という単純な設計ミスでした。
最終的に落ち着いたのが シンク名 + 汚染元キー + 囲んでいる関数名 の三つ組です。方式Cでは、ローカル変数がどう名付けられていても request.args.get(src) という汚染元は変わりません。
フィクスチャに対して、コメント追加・ヘルパー関数追加・ローカル変数リネームを加えて再スキャンした結果です。
| 指紋方式 | 構成要素 | 追跡できた件数 | 新規として再提出 |
| 方式A | パス + 行番号 + シンク | 0 / 8 | 8 件(100%) |
| 方式B | パス + 関数 + シンク + 変数名 | 0 / 8 | 8 件(100%) |
| 方式C | シンク + 汚染元 + 関数 | 8 / 8 | 0 件(0%) |
方式Bが方式Aと同じ全滅になったのは、私にとって一番痛い結果でした。行番号さえ外せば安定するという思い込みが、変数名という別の揺れる要素を見落とさせていたからです。指紋に入れてよいのは「意味が変われば変わる要素」だけで、「書き手の気分で変わる要素」を入れてはいけない、というだけの話でした。
粗くすると安定する、が安全な実装が脆弱な実装を吸収する
ここからが、事前の予想と逆だった部分です。
方式Cにも弱点があります。関数名を含んでいるので、リファクタリングで関数をリネームすると指紋が変わります。フィクスチャのファイル名を api/proxy.py から api/gateway.py に変え、api_proxy を proxy_get にリネームしたところ、8 件中 6 件は追跡できたものの 2 件は新規扱いになりました。
ならば関数名も外して シンク + 汚染元 の二つ組にすればよい、と考えるのは自然です。方式Dです。実際に測ると、追跡率は 7/7 で完璧でした。
問題は、なぜ 8 件が 7 件に減っているかです。
衝突: 97d2524cdca2 -> ['services/thumbnail.py:8 fetch_remote_thumbnail',
'services/thumbnail.py:16 fetch_allowlisted_thumbnail']
同じファイルの中に、requests.get を request.args.get("src") で呼ぶ関数が二つありました。片方は URL をそのまま取りに行く実装、もう片方は許可リストでホストを検査してから取りに行く実装です。方式Dでは、この二つが同じ指紋に潰れます。
つまり、安全なほうを先にレビューして台帳に「対処済み」と書いた瞬間、危険なほうは二度と浮かんできません。追跡率は上がったのに、消えたのは本物の指摘のほうでした。
| 指紋方式 | リファクタ後の追跡 | フィクスチャ内の一意性 | 判定 |
| 方式C(シンク + 汚染元 + 関数) | 6 / 8 | 8 / 8(衝突 0) | 採用 |
| 方式D(シンク + 汚染元) | 7 / 7 | 7 / 8(衝突 1) | 不採用 |
指紋の設計では「安定性」を上げる方向にばかり目が行きますが、安定性と識別性はトレードオフです。私は識別性を優先して方式Cを採り、関数リネームによる再提出は、別の仕組みで吸収することにしました。
具体的には、台帳に aliases の配列を持たせ、関数をリネームした際に旧指紋を新指紋のエイリアスとして手で一行足します。年に数回の作業です。自動で名寄せしようとして誤って別の指摘を統合するより、この一行のほうがずっと安全でした。
採用は再現の可否だけで決める
仮説層が返すのは「危険度」ではなく、次の二つに限定しています。
- 失敗を再現する最小のテストコード
- そのテストが通るようになる修正パッチ
採用判定は、このテストが「修正前は落ちて、修正後は通る」ことだけで行います。モデルの言葉づかいは一切見ません。
# verify_gate.py — 再現テストの落ち→通りだけで採用を決める
import subprocess, tempfile, pathlib
def run(cmd, cwd):
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True).returncode
def verify(repo: pathlib.Path, repro_test: str, patch: str) -> dict:
"""戻り値の accepted が True のときだけ台帳へ書き込む。"""
test_path = repo / "tests" / "test_repro_generated.py"
test_path.parent.mkdir(exist_ok=True)
test_path.write_text(repro_test)
before = run(["python", "-m", "pytest", "-q", str(test_path)], repo)
if before == 0:
# 修正前に通ってしまう再現テストは、再現できていない
test_path.unlink()
return {"accepted": False, "reason": "repro_did_not_fail"}
with tempfile.NamedTemporaryFile("w", suffix=".patch", delete=False) as fp:
fp.write(patch)
patch_file = fp.name
if run(["git", "apply", "--check", patch_file], repo) != 0:
test_path.unlink()
return {"accepted": False, "reason": "patch_does_not_apply"}
run(["git", "apply", patch_file], repo)
after = run(["python", "-m", "pytest", "-q", str(test_path)], repo)
suite = run(["python", "-m", "pytest", "-q"], repo)
run(["git", "checkout", "--", "."], repo) # 必ず元に戻す
test_path.unlink(missing_ok=True)
return {
"accepted": after == 0 and suite == 0,
"reason": "ok" if after == 0 and suite == 0 else f"after={after} suite={suite}",
}
before == 0 の枝が、実務では一番よく効きました。修正前から通ってしまう再現テストは、再現できていないという証拠です。モデルが自信ありげに説明を書いていても、ここで機械的に落ちます。
git checkout -- . を必ず通す構造にしておく点も、地味ですが外せません。検証の途中で例外が出たときにパッチが残ると、次の検証が汚れた状態から始まります。私は最初これを try の外に書いていて、一度だけ作業ツリーを汚しました。本番リポジトリではなく検証用のクローンで回す運用に切り替えたのは、その後です。この「壊れた状態を次の実行へ持ち越さない」考え方は、ブラウザ自動化の文脈で書いた Gemini Computer Use の自己修復アーキテクチャ とも共通しています。
台帳に何を書き、何を書かないか
台帳は JSON Lines 一本にしています。列は次のとおりです。
| 列 | 内容 | 置く理由 |
| fingerprint | 方式Cの三つ組ハッシュ | 再提出の判定に使う唯一の鍵 |
| aliases | 旧指紋の配列 | 関数リネームを手で引き継ぐ |
| state | open / accepted / wontfix | 再スキャン時の抑止判断 |
| verified_at | 検証ゲートを通った時刻 | 採用の根拠が機械判定だと示す |
| evidence | 再現テストのパス | 後から人が追試できる |
モデルの説明文と確信度は、あえて台帳に入れていません。入れると、次に読む人がそれを根拠に判断してしまうからです。判断の根拠は evidence に残したテストだけに寄せています。
wontfix には必ず一行の理由を人が書く運用にしました。ここを空欄で通せるようにすると、抑止リストが「読まずに閉じたもの」の置き場になります。
移植するときの順番
同じ構成を別の言語やフレームワークへ移すときは、次の順番をお勧めします。
- シンクの定義を 3〜5 個だけ書いて抽出層を動かす。この時点ではモデルを呼ばない
- 指紋を方式Cで付け、二週間ほど再スキャンだけを回して、再提出がゼロに収束するか見る
- 収束を確認してから、はじめて仮説層のモデル呼び出しを繋ぐ
- 検証ゲートを通ったものだけを台帳へ書き、
wontfix の理由欄を必須にする
順番を入れ替えて先にモデルを繋ぐと、台帳が固まる前に指摘が溜まり、私が最初にやったように二週間で運用が止まります。抽出と指紋が安定していれば、モデルの側は後からいくらでも差し替えられます。用途特化のモデルが出るたびに全体を組み直さずに済むのは、この順番のおかげです。
Agent Studio の告知に戻ると、いま自分の手元でまずやるべきなのは、該当時期に作ったアプリを列挙することではなく、/api-proxy に相当する経路が今どこに何本あるかを機械が言える状態を作ることでした。抽出層さえ動いていれば、次に同種の告知が出たときの返答は十数秒で用意できます。
まずは自分のリポジトリで scan_sinks.py を一度走らせて、出てきた件数を眺めてみてください。私自身、想像していたより二倍以上の経路が残っていて、静かに驚きました。
拙い試行錯誤の記録ですが、どこかでお役に立てば嬉しく思います。