概要
Pythonプロジェクトのセキュリティ診断において、「BanditをCIへ導入したが、テストコードや型キャストまで警告されて通知が埋もれてしまう」といった経験はないでしょうか。また、「LLMにコードレビューをさせたら、安全なコードまで過剰警戒してオオカミ少年になってしまった」という声も多く聞かれます。
本記事では、まず静的解析(Bandit / Semgrep)が騙されやすい典型的な誤検知パターンを整理します。その上で、Google Mantisの設計思想に基づく「Python標準ASTによる要約 + 隔離PoC検証」により、偽陽性を劇的に排除する自動監査パイプラインの実装手順を解説します。
前提環境
- Python:
3.10/3.11/3.12 - Bandit:
1.7.5以上 - Semgrep:
1.40.0以上 - pytest:
7.4.0以上 - 検証用公開リポジトリ: ai-nexus-jp/code-security-benchmark-suite
1. 静的スキャナが誤検知(偽陽性)を起こす典型コード
静的解析ツールが誤検知を起こす最大の原因は、「局所的な構文に反応し、ファイル境界や事前バリデーションを認識できないこと」です。
以下のコードは、現場で頻発する誤検知の代表例です。
誤検知パターン例:別ファイルでEnum検証済みの動的 ORDER BY
# queries.py
from sqlalchemy import text
from app.utils import validators
def get_sorted_products(sort_column: str, db_session):
# 【防御壁】別ファイルで定義されたEnumホワイトリストで厳格に検証されている
# 許可された文字列('price', 'created_at'等)以外はValueErrorで弾かれる
validated_col = validators.validate_order_by(sort_column)
# 構文上は f-string による生SQL構築に見えるため、Semgrepが SQL Injection (CWE-89) と誤警告
query = f"SELECT id, name, price FROM products ORDER BY {validated_col}"
return db_session.execute(text(query)).fetchall()
- Bandit の判定: 関数名や文字列置換だけでは拾えずスルー(FN)。
-
Semgrep の判定:
ORDER BY {validated_col}の文字列補間を見て「SQL Injection」と誤判定(FP)。 -
素のLLM の判定:
validators.pyの実装を同時に読ませないと、前後の安全性を無視して「SQLiの危険性あり」と警告(FP)。
このような誤検知をゼロにするには、「怪しい箇所を検出した後に、実際にエクスプロイトが成立するか動的テスト(PoC)で確かめる」というアプローチが不可欠です。
2. 実践ステップ①:Python標準 ast でトークンを80%圧縮する構造要約器
大規模なリポジトリ全体をLLMにプロンプト投入すると、コンテキスト上限に達しコストも跳ね上がります。
Python標準ライブラリの ast モジュールを使い、関数シグネチャ、型アノテーション、デコレータ、コール関係のみを抽出する軽量要約スクリプトを作成します。
# summarizer.py
import ast
from typing import Dict, Any, List
class SecurityContextVisitor(ast.NodeVisitor):
def __init__(self, filename: str):
self.filename = filename
self.functions: List[Dict[str, Any]] = []
def visit_FunctionDef(self, node: ast.FunctionDef):
# デコレータ(@require_auth 等の認可ガード)を抽出
decorators = [
d.id if isinstance(d, ast.Name) else ast.unparse(d)
for d in node.decorator_list
]
# 引数名と型アノテーションを抽出
args = [
f"{arg.arg}: {ast.unparse(arg.annotation)}" if arg.annotation else arg.arg
for arg in node.args.args
]
# 関数内部で呼び出している関数(コールグラフ)を収集
calls = []
for child in ast.walk(node):
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
calls.append(child.func.id)
self.functions.append({
"name": node.name,
"args": args,
"decorators": decorators,
"internal_calls": list(set(calls)),
"docstring": ast.get_docstring(node) or "",
"line_no": node.lineno,
})
self.generic_visit(node)
def generate_ast_summary(source_code: str, filename: str) -> List[Dict[str, Any]]:
tree = ast.parse(source_code, filename=filename)
visitor = SecurityContextVisitor(filename)
visitor.visit(tree)
return visitor.functions
# 動作確認
if __name__ == "__main__":
sample_code = '''
@require_ownership
def update_profile(user_id: int, payload: dict):
"""ユーザープロフィールを更新する"""
validate_payload(payload)
db.save(user_id, payload)
'''
summary = generate_ast_summary(sample_code, "profile.py")
import json
print(json.dumps(summary, indent=2, ensure_ascii=False))
この要約器を通すことで、リポジトリの全コード量を 約80%圧縮 し、認可デコレータや検証関数の存在をLLMへ漏れなく伝えることができます。
3. 実践ステップ②:LLMによる脆弱性仮説とPoCテストコードの抽出
要約ツリーを入力とし、LLMに「疑わしい入力経路」と「再現検証用テストコード(PoC)」を出力させます。
プロンプト設計の要点
- 「脆弱性があるか」ではなく、「攻撃が成立することを証明する pytest 単体テストコードを出力せよ」と指示します。
- 期待するアサーション(例: 認可されていない別ユーザーのデータが返ること、
/etc/passwdの内容が読み出せること)を明記させます。
[プロンプト例]
以下のAST要約ツリーから、セキュリティ上のリスクが疑われる関数を特定してください。
その上で、その脆弱性が実在することを証明するための最小限の pytest テストコード(PoC)を作成してください。
もし途中に Enum 検証や認可デコレータが存在し、攻撃リクエストが例外等で遮断される可能性が高い場合は、
その防御壁を突破できる具体的なペイロード(二重デコード、絶対パス等)を含めてください。
4. 実践ステップ③:動的PoC検証ランナーの実装(偽陽性を即座に棄却)
生成されたPoCスクリプトを隔離環境で実行し、アサーションの成否によって真陽性(TP)と誤検知(FP)を機械的にトリアージします。
# poc_verifier.py
import subprocess
import tempfile
import sys
from pathlib import Path
def run_poc_verification(test_code: str, timeout_sec: int = 10) -> dict:
"""
生成されたPoCスクリプトを一時ファイルへ保存し、pytest経由で隔離実行する。
"""
with tempfile.TemporaryDirectory() as temp_dir:
test_file = Path(temp_dir) / "test_poc.py"
test_file.write_text(test_code, encoding="utf-8")
# セキュリティのため、ネットワーク接続や環境変数を制限して実行
cmd = [
sys.executable, "-m", "pytest",
str(test_file),
"-q", "--tb=short"
]
try:
res = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_sec,
cwd=temp_dir
)
# pytest の終了コードが 0(テスト成功)= エクスプロイトが成立したことを意味する
if res.returncode == 0:
return {
"status": "VALID_VULNERABILITY",
"verdict": "TP (真陽性)",
"message": "PoCの実行に成功しました。攻撃が成立することが実証されました。",
"output": res.stdout
}
else:
# バリデータによる拒否や認可エラーでアサーションが失敗した場合
return {
"status": "REJECTED_FALSE_POSITIVE",
"verdict": "FP (誤検知として棄却)",
"message": "攻撃コードの実行が遮断されました(安全な設計)。",
"output": res.stderr or res.stdout
}
except subprocess.TimeoutExpired:
return {
"status": "TIMEOUT",
"verdict": "UNKNOWN",
"message": "PoC実行がタイムアウトしました。"
}
# 動作検証例
if __name__ == "__main__":
# 例: 実際に攻撃が成立する脆弱なコードに対するPoCテスト
exploit_test = '''
def test_path_traversal():
import os
BASE_DIR = "/tmp/safe_dir"
os.makedirs(BASE_DIR, exist_ok=True)
# 脆弱な関数の再現(os.path.joinの絶対パストラップ)
user_input = "/etc/passwd"
resolved = os.path.join(BASE_DIR, user_input)
# BASE_DIR を脱出できているかアサーション
assert resolved == "/etc/passwd"
'''
result = run_poc_verification(exploit_test)
print(f"結果: {result['verdict']} - {result['message']}")
5. 運用時の注意点とトラブルシューティング
(1) PoC実行プロセスのサンドボックス化(安全対策)
PoCスクリプトは意図的に危険な入力(コマンドインジェクションやパストラバーサル)を発火させるため、開発端末上で直接実行してはいけません。
実運用では、必ず以下の制限を施してください。
- Dockerコンテナ(Read-Onlyルート、
--network none)での実行 - Linuxの
seccompやgVisor、一時ユーザー(UID 65534 / nobody)による権限剥奪 - ファイル書き込み先の
/tmp(tmpfs)制限
(2) 外部DBや認証プロバイダへの依存
単体テスト形式のPoCでは、実データベースの接続やAWS/外部SaaS APIの認証を伴う処理でエラーになりがちです。
この場合は、unittest.mock や pytest-mock を活用し、「疑わしい関数の直前・直後」に絞った最小モックをLLMに併せて生成させる設計が有効です。
まとめ
- Bandit や Semgrep の誤検知: 構文のみに依存するため、別ファイルでのEnum検証や認可デコレータを追跡できず、偽陽性が多発する。
- ASTによるコンテキスト要約: 関数シグネチャ・デコレータ・コールグラフのみを構造化することで、トークン消費を約80%圧縮しつつ文脈を維持できる。
- 動的PoC検証によるトリアージ: LLMにPoCテストコードを生成させ、サンドボックスで実際に攻撃が通るかを検証することで、誤検知(偽陽性)を物理的にゼロへと排除 できる。
関連記事・お問い合わせ
-
開発背景・アーキテクチャの解説:
本ツールの設計思想や、4世代セキュリティスキャナの実測結果は Zennの解説記事 にまとめています。 -
業務導入・受託開発のご相談:
AI Nexusでは、業務効率化ツールの受託開発や、CI/CDパイプラインへのセキュアなAIエージェント組み込みを支援しています。詳細は AI Nexus 公式サイト をご覧ください。