はじめに
AIエージェントにペネトレーションテストを実行させると、同じ対象・同じ指示でも実行ごとに結果が変わることがあります。1回目に検出されたアクセス制御の不備が、2回目の実行では出てこない。あるいは逆に、2回目だけ指摘が増える。
PoCの段階では、これはほとんど問題になりません。しかし四半期ごとの再診断や監査対応で「前回と同じ手順を再実行して、同じ結論を出せるか」と問われた瞬間に、レポートは証跡としての価値を失います。再現できない検査結果は、報告書として成立しません。
本記事では、AIエージェント型ペネトレーションテストで再現性を確保するための設計、とくに**「探索はLLMに任せ、判定はLLMに任せない」**という役割分離について、実装レベルで整理します。
筆者は自動化ペネトレーションテスト基盤の開発・運用に携わっています。本記事はそこで得た設計上の知見をまとめたものであり、特定製品の紹介ではありません。コードはロジック部分を抜き出したもので、HTTPクライアントやレスポンスの型は環境に合わせて差し替えてください。
なぜ結果が再現しないのか
非決定性は3つの層から入ります。ここを分けて考えないと、対策が「temperatureを0にする」に終わってしまいます。
| 層 | 非決定性の発生源 | 具体的な現れ方 |
|---|---|---|
| モデル層 | サンプリング、プロンプトの微妙な差、コンテキスト長による切り捨て | 同じ画面を見ても、次に試すペイロードが変わる |
| 手順層 | ツール呼び出しの順序依存 | 先に /api/users を叩いた場合だけ /api/invoices/{id} に到達できる、など |
| 環境層 | レート制限、セッション状態、キャッシュ、時刻依存データ | 片方のリクエストだけ429になり、比較が成立しない |
モデル層だけを潰しても再現性は上がりません。実際にレポートが揺れる原因の多くは、手順層と環境層にあります。そしてこの2つは、モデルの設定ではなく実行の記録方式で解決する問題です。
設計方針:Planner / Executor / Verifier を分ける
再現性の要件を分解すると、次の3つになります。
- 計画は非決定でよい。ただし実行は決定論的に記録する
- 判定はLLMにさせない。効果の再現で判定する
- 証跡はリクエスト/レスポンスのペアで残す
これをそのままコンポーネントに落とすと、3層になります。
Planner (LLM) … 何を試すかを決める。非決定的でよい
↓ 指示(JSON)
Executor (決定論) … 実際にリクエストを送る。全て記録する
↓ 生の証跡
Verifier (決定論) … 効果が再現するかで判定する。LLMを使わない
Plannerの出力は「自然言語の計画」ではなく、検証可能な構造化命令にしてください。「認可の抜けを探す」ではなく {"actor": "A", "method": "GET", "path": "/api/invoices/1044", "purpose": "probe"} まで落とさせます。自然言語のままだと、Executorの解釈が実行ごとにぶれ、記録しても比較できません。
なぜ判定をLLMに任せてはいけないのか
LLMは「もっともらしい説明を生成する」能力に長けています。一方で「このレスポンスが本来返ってはいけないデータかどうか」を毎回同じ基準で決める能力は、その能力とは別物です。
実務では次のようなことが起きます。同じレスポンスに対して、ある実行では「200が返っており、他ユーザーのメールアドレスを含むため越権」と判定し、別の実行では「200だが本文がエラーページの可能性があるため保留」と判定する。どちらも文章としては妥当ですが、監査に出せるのは前者のような再現性のある判定だけです。
LLMの役割は、候補の絞り込みと探索順序の決定までに限定します。最終判定は、後述する決定論的なコードに渡してください。
実装1:実行トレースを取る
まず、すべてのツール呼び出しを構造体として記録します。ポイントは actor(どのアカウントのセッションで実行したか)を指紋に含めることです。同じリクエストでも、送信元アカウントが違えば意味が変わります。
# trace.py
from dataclasses import dataclass
import hashlib
import json
@dataclass
class ToolCall:
step: int
actor: str # "account_a" / "account_b"
tool: str # "http" / "browser" / "shell"
request: dict # method / path / headers(マスク後)/ body
response: dict # status / headers / body(先頭 8KB)
purpose: str # "baseline" / "probe" / "confirm"
def fingerprint(self) -> str:
blob = json.dumps(
{"actor": self.actor, "tool": self.tool, "request": self.request},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
記録はJSON Linesで追記していきます。1行1呼び出しにしておくと、実行が途中で落ちてもそこまでの記録が残り、jq で差分も取りやすくなります。
def append_trace(path: str, call: ToolCall) -> None:
record = {
"step": call.step,
"fp": call.fingerprint(),
"actor": call.actor,
"tool": call.tool,
"purpose": call.purpose,
"request": call.request,
"response": call.response,
}
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
このログがあれば、「前回と同じ手順」を自然言語ではなく指紋の列として再実行できます。手順層の非決定性は、ここで初めて観測可能になります。
実装2:越権アクセスの差分判定
アクセス制御の不備、とくに水平方向のもの(他ユーザーのリソースにアクセスできてしまう状態)は、ペイロードで検出できません。正しい形式のリクエストが、正しいセッションで送られ、本来見えてはいけないデータが返るだけだからです。
したがって判定は「異常なリクエストか」ではなく、**「返ってきたデータが、別アカウントの正規レスポンスと一致するか」**で行います。
正規化
そのまま比較すると、タイムスタンプやリクエストIDの違いで全件が差分になります。比較前に必ず正規化します。
# normalize.py
import re
_MASKS = [
(re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I), "<uuid>"),
(re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})"), "<timestamp>"),
(re.compile(r'("(?:id|user_id|account_id|tenant_id)"\s*:\s*)\d+'), r"\g<1>0"),
(re.compile(r"\b[0-9a-f]{32,}\b"), "<token>"),
]
def normalize(body: str) -> str:
for pattern, replacement in _MASKS:
body = pattern.sub(replacement, body)
return body
正規化で気をつけるのは、値を潰してもキー名は残すという点です。キー自体を <id> のような文字列に置き換えると、"id" と "user_id" が同じキーに潰れてJSONとして壊れ、比較の意味がなくなります。上のように「値だけ0にする」書き方なら、構造は保たれたまま比較できます。
{"id":0,"user_id":0,"tenant_id":0,"created":"<timestamp>","token":"<token>","email":"a@example.com"}
正規化は強すぎても弱すぎてもいけません。強くしすぎると、本来検出すべき「他人のデータ」まで潰してしまいます。たとえばメールアドレスを丸ごとマスクすると、越権の証拠そのものが消えます。マスク対象は「実行のたびに変わる値」に限定してください。
類似度と判定
# verify.py
def shingles(text: str, n: int = 5) -> set[str]:
s = normalize(text)
return {s[i:i + n] for i in range(len(s) - n + 1)} if len(s) >= n else {s}
def similarity(a: str, b: str) -> float:
x, y = shingles(a), shingles(b)
if not x or not y:
return 0.0
return len(x & y) / len(x | y)
判定は3値にします。ここが本記事でいちばん重要な点です。
from enum import Enum
class Verdict(str, Enum):
VULNERABLE = "vulnerable"
NOT_VULNERABLE = "not_vulnerable"
INCONCLUSIVE = "inconclusive" # 「安全」とは違う
def judge(owner_baseline, probe, threshold: float = 0.60) -> Verdict:
if probe.status in (401, 403):
return Verdict.NOT_VULNERABLE
if probe.status != 200:
return Verdict.INCONCLUSIVE # 429・5xx は判定しない
if similarity(owner_baseline.body, probe.body) >= threshold:
return Verdict.VULNERABLE # 本来見えないはずのデータが返っている
return Verdict.INCONCLUSIVE
INCONCLUSIVE を独立した状態として持つのが肝です。多くの実装はこれを「問題なし」に丸めてしまいますが、「429で比較できなかった」と「403で拒否された」は同じではありません。前者を後者に混ぜた瞬間、レポートは「検出0件」と出力し、読む人は「安全だった」と受け取ります。
2回実行して、安定したときだけ確定する
再現性の要件を、判定ロジックそのものに埋め込みます。
def confirm(fetch, account_a, account_b, path_template, resource_id, runs: int = 2) -> Verdict:
results = []
for _ in range(runs):
owner = fetch(account_b.session, path_template.format(id=resource_id))
probe = fetch(account_a.session, path_template.format(id=resource_id))
results.append(judge(owner, probe))
if all(v is Verdict.VULNERABLE for v in results):
return Verdict.VULNERABLE
if all(v is Verdict.NOT_VULNERABLE for v in results):
return Verdict.NOT_VULNERABLE
return Verdict.INCONCLUSIVE
1回でも結果が割れたら確定させない。地味ですが、これで「たまたま通った回」がレポートに載ることを防げます。手動診断でも、再現しない指摘は報告書から落とすのが基本です。
よくある落とし穴
実装で遭遇しやすいものから並べます。
| 症状 | 原因 | 対策 |
|---|---|---|
| 全件が差分になる | 正規化漏れ(時刻・ID・トークン) | マスク対象を「実行ごとに変わる値」に限定して洗い出す |
| 越権が「なし」と判定される | 片方だけ429で、status != 200 を安全側に丸めた |
INCONCLUSIVE を独立状態にする |
| 同じアカウントで比較してしまう | セッションの取り違え |
ToolCall.actor を指紋に含める |
| 2回目だけ結果が違う | CDN・アプリケーションキャッシュ | 比較時は Cache-Control: no-cache を付け、レスポンスの Age も記録 |
| 時刻依存のデータで誤検知 | 「本日の売上」など | 時刻を含む項目は正規化ではなく比較対象から除外する |
| 到達できずに「問題なし」 | ネットワーク到達性の不足(VPN配下、内部セグメント) |
INCONCLUSIVE に「未到達」を理由として残す |
最後の行は、レポートの読み手にとって最も誤解を生む箇所です。「検査した資産が100台、検出0件」と書くと、100台すべてが安全だったように読めます。実際には30台に到達できていないかもしれません。到達できなかった資産を明示することは、検出件数を増やすことと同じくらい重要です。
再現性チェックリスト
実装が一通り動いたら、次を上から確認してください。
- 同じトレースを2回流して、同じ指紋列になるか
- 判定関数に同じレスポンスを2回渡して、同じ
Verdictが返るか(LLMを挟んでいないか) -
INCONCLUSIVEがレポート上で「問題なし」と別に集計されているか - 未到達アセットが件数として出ているか
- 429・5xx の発生回数がトレースから集計できるか
- 証跡がリクエスト/レスポンスのペアで残り、第三者が再送できるか
- ステップ上限・時間上限に達した実行が、正常終了と区別されているか
7番目は見落としやすい項目です。エージェントがステップ上限で打ち切られた場合、それは「探索が完了した」ではなく「途中で止めた」です。区別しないと、カバレッジの数字が実態より大きく出ます。
まとめ
AIエージェント型のペネトレーションテストで再現性を確保する要点は、モデルの性能を上げることではなく、責務を分けることでした。
- 探索(Planner)は非決定的でよい。ただし出力は構造化命令に落とす
- 実行(Executor)はすべて記録する。
actorを含めた指紋で手順を再現可能にする - 判定(Verifier)はLLMに任せない。正規化 → 類似度 → 2回実行で確定
- 「判定不能」を独立した状態として保持する。ここを潰すとレポートが嘘になる
アクセス制御の不備が自動化しにくい理由は、検出器の性能ではなく、「何をもって検出とするか」を決めていなかったことにあります。判定基準をコードに落とせば、AIエージェントは十分に使える道具になります。落とせなければ、どれだけ賢いモデルを載せても、出力は毎回変わる文章のままです。