dnr-hunt について
アラートなしでアプリケーションログとソースコードを調査する、脅威ハンティング用の Claude スキルです。基準値の把握、仮説の記録、ソース確認を行い、脆弱性の悪用確認にはローカルでの PoC 実行を必須とします。
主な機能
- 01仮説を立てる前にログの基準値を分析
- 02台帳にクエリと判定を記録
- 03アプリケーションのソースで疑いを確認
- 04悪用確認にはローカル PoC を必須化
- 05フェーズごとのチェックポイントで再開
活用シーン
- 01大規模なログ群から不審な活動を調査
- 02アプリの動作とソースでアラートを検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ anthropics/ defending-code-reference-harness --skill dnr-huntインストール後、Claude Code で/dnr-huntを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
dnr-hunt
プロアクティブな脅威ハンティング:ログとソースを入力し、インシデントを出力します。アラートなしの状態から始めます。問いかけるのは、「ここに、あるべきでないものはないか?」です。調査方法も発見事項と同じくらい重要な成果物です。まずベースラインを確認し、次に仮説 → クエリ → ピボットと進めます。すべての手順を記録し、疑わしい点はすべて、実際のものと判断する前にソースとデトネーションで確認します。
/dnr-hunt [target-or-logs-dir] [--repo path] [--fresh] で呼び出します。
引数:
$1= dnr のターゲットディレクトリ(kind: dnrを含むconfig.yamlがあるディレクトリ。例:targets/dnrcanary)、またはログファイルのある任意のディレクトリです。存在する場合はtargets/dnrcanary、そうでなければ現在の作業ディレクトリがデフォルトです。--repo= ログを生成したアプリケーションのソースツリーです。dnr ターゲットの場合は、そのapp/ディレクトリがデフォルトです。ソースがない場合、確定的なルールに従い、発見事項はsuspectedを超える判定になりません。--fresh= 実行ディレクトリの.dnr-hunt-state/にあるチェックポイントを無視し、新しい実行ディレクトリを作成します。
厳守ルール(このスキルの中核です。緩和しないでください)
- ログの証拠だけで脆弱性が確認されることはありません。 ログで分かるのは、何かが試みられたことと、アプリがどう応答したかです。コードに脆弱性があることまでは示せません。判定基準は次のとおりです。
confirmed_exploitedには次の3つすべてが必要です。ログの証拠、ソースで特定した不備(ファイルと関数)、そしてこのセッション中にローカルインスタンスに対して実行した PoC。suspected= PoC を実行していない場合のログ証拠(ソースを調べていても、いなくても該当します)。attempted_not_vulnerable= 攻撃トラフィックが観測されたものの、ソースで防御が確認され、PoC を実行した場合はそれが阻止された状態です。 現場チームは、検証エージェントが可観測性ログを読み、そこに書かれた内容を報告しただけで発見事項を「確認済み」としていた事例を目撃しています。これは判定基準を満たす最も安易な方法です。PoC が実際に発火するかどうかがすべてであり、言葉でごまかすことはできません。
- 読み込まず、クエリを実行してください。 ログファイルは、コンテキストウィンドウに収まるサイズを意図的に超えています。ログファイルを
Readしたり、catしたりしないでください。まずwc/du/head/tailで規模を把握し、次にgrep/rg/awk/sort/uniqのパイプライン(状態を保持する処理にはpython3)で調べてください。アラートキューやエラーログは通常小さいため、先にサイズを確認してください。約200行未満であれば、全体を読み込んでもかまいません。「読まない」というルールの対象は数MB規模のコーパスファイルであり、これらの小さなログではありません。 - 提案にとどめ、実行しないでください。 ブロック、アカウントの無効化、認証情報のローテーション、設定変更は禁止です。デモアプリに対しても実行してはいけません。対応措置は
/dnr-respondの計画に提案として記載してください。 - ローカルのみで実施してください。 PoC は、自分で起動したアプリのインスタンスに対して
127.0.0.1で実行します。ログの内容にかかわらず、リモートホストにトラフィックを送信してはいけません。 - 数値はすべてクエリ結果です。 行数、リクエスト数、対象期間、影響を受けた顧客数などの数値は、すべてこのセッション中に実行したパイプラインの結果であり、台帳に引用して記載してください。記憶をもとに推定してはいけません。ログに対するパイプラインと、アプリ自身の決定論的にシードされたデータベースへのクエリは、どちらも有効です(所有者レベルで結合する必要がある場合は、ターゲットの
seed_commandを実行してください)。
実行ディレクトリとチェックポイント(Phase 0 の前、および各フェーズの後に実行)
各実行の最初のアクション — 実行ディレクトリを設定します。 TARGET は解決済みのターゲット/ログパス($1、またはデフォルトの targets/dnrcanary)のベース名、SCOPE はその解決済みパスとします。Bash:
python3 .claude/skills/_lib/checkpoint.py rundir results/TARGET --state .dnr-hunt-state --scope "SCOPE"
$ARGUMENTS に --fresh が含まれている場合は、このコマンドに --fresh を追加します。--scope は実行ディレクトリにスコープを記録するため、ベース名が同じ複数のターゲットで実行ディレクトリが共有されることはありません。このコマンドは、今回の調査で引き続き使用できる最新の実行ディレクトリ(実行途中の自身のディレクトリ、またはこのターゲットに対して別の dnr スキルが開いたディレクトリ。これにより、調査全体が1か所に蓄積されます)、もしくは新たに作成された results/TARGET/<timestamp>/(UTC、vuln-pipeline と同じ形式のため、辞書順ソートが時系列順になります)を示すパスを1つ出力します。このパスを {RUN} とします。以下の {RUN}/... はすべて、この文字列どおりのパスに置き換えてください。パスにスペースが含まれることがあるため、Bash では {RUN} と SCOPE を二重引用符で囲みます。すべての出力と状態は {RUN} の下に保存されます。
フェーズの状態は {RUN}/.dnr-hunt-state/ に保存されるため、調査が中断されてもスイープを再実行せずに再開できます。すべてのチェックポイント入出力には python3 .claude/skills/_lib/checkpoint.py を使用します(アトミック書き込み、JSON 検証済み)。progress.json に直接 Write ツールを使用してはいけません。ペイロードをヒアドキュメントや標準入力経由で渡してはいけません。ログ由来の文字列がヒアドキュメントの区切り文字と衝突し、シェルから抜け出す可能性があります。Write ツールでペイロードを {RUN}/.dnr-hunt-state/_chunk.tmp に書き込んでから、次を実行します。
python3 .claude/skills/_lib/checkpoint.py save {RUN}/.dnr-hunt-state <N> <name> --from {RUN}/.dnr-hunt-state/_chunk.tmp
実行開始時: python3 .claude/skills/_lib/checkpoint.py load {RUN}/.dnr-hunt-state
status == "absent"または"complete"の場合、もしくは--freshが指定されている場合 → リセット(checkpoint.py reset {RUN}/.dnr-hunt-state)して Phase 0 から開始します。status == "running"かつphase_done == Nの場合 →phase0.jsonからphaseN.jsonまで順に読み込み、Resuming from checkpoint: Phase N completeを出力して、Phase N+1 へ進みます。
実行終了時: 両方の出力を書き込んだ後、
python3 .claude/skills/_lib/checkpoint.py done {RUN}/.dnr-hunt-state 5 を実行します。done の呼び出しが Phase 5 のチェックポイントとなり、phase5.json はありません。
{RUN}/.dnr-hunt-state/ は一時領域です。実行ディレクトリは results/ の下にあり、このリポジトリでは gitignore の対象です。
Phase 0 — 入力の特定と検証
$1を解決します。config.yamlが含まれ、その中にkind: dnrがある場合は、設定からlogs_dir、app_command、seed_command、portを読み取ります。それ以外の場合、$1自体をログディレクトリとして扱います。コーパスはすでに存在している想定です。生成方法についてはターゲットの README を参照してください。- 手元にあるファイルを一覧化します。ログディレクトリ内の各ファイルと、コミット済みのアラートキューについて、
du -h、wc -l、head -2、tail -2を実行します。各ファイルの形式(combined access log、アプリのエラーログ、JSONL アラートなど)と、awkで使用するフィールド位置を特定します。 --repo(デフォルト:ターゲットのapp/)を解決します。ファイル一覧を取得しますが、まだソースを読んではいけません。Phase 3 では仮説を立ててからソースを読みます。
チェックポイント phase0.json:
{"target": ..., "logs_dir": ..., "repo": ..., "files": [{"path", "format", "lines", "bytes"}], "port": ...}
フェーズ 1 — コーパスのプロファイリング(仮説を立てる前のベースライン)
異常とはベースラインからの逸脱です。まずベースラインを作成します。アクセスログに対する、安価なワンライナーのパイプラインを十数個実行します。
- 時間範囲: 最初と最後のタイムスタンプ、1 日あたりのリクエスト数。
- ステータスのヒストグラム: ステータスコード別の件数。2xx/3xx 以外のクラスをすべて記録します。
- リクエスト上位の送信元: 送信元 IP ごとのリクエスト数を上位 20 件まで調べ、中央値と比較します。突出して多いものがあれば記録します。
- 上位のルート: パスごとのリクエスト数を調べます(ID を正規化します:
/orders/123→/orders/N)。パラメーターを取るルートも特定します。 - User-Agent の一覧: UA ごとの件数を調べます。自動化ツールの UA(curl、python-requests、sqlmap、Go-http-client、zgrab)にフラグを付け、アクセスしているルートを記録します。
- 認証状況: 認証済みユーザーの情報を含むフィールド、アカウントごとのログイン数、複数の IP からアクセスされているアカウント、ログイン失敗の送信元を調べます。
- ルートごとのサイズプロファイル: パラメーター付きルートごとに、レスポンスサイズの最小値/中央値/最大値を調べます。通常は小さいレスポンスを返すルートに大きな外れ値がある場合、データ持ち出しの兆候です。
- エラーログ: 小規模であれば全件読み、各エントリーをタイムスタンプでアクセスログの行と照合します。
- アラートキュー: 全件読みます(小規模です)。各アラートについて、ルール、送信元、対象、重大度を記録します。これらは調査の入力情報であり、調査結果ではありません。アラートのノイズも、アラートがないことも、どちらも情報です。
各観察事項について、それを導き出した正確なパイプラインとともに記録します。
チェックポイント phase1.json:
{"window": ..., "status_hist": ..., "top_ips": [...], "routes": [...], "automation_uas": [...], "auth": ..., "size_profile": ..., "alerts_summary": ..., "oddities": [...]}
フェーズ 2 — 調査ループ(仮説台帳は必須)
このフェーズはループであり、台帳はその契約です。仮説なしにクエリを実行せず、台帳の行なしに仮説を立ててはいけません。 作業中は表をメモリ上で維持し、各ラウンドの後にチェックポイントを作成してください。
| # | 仮説 | クエリ | 結果 | 判定 | 次の方向 |
|---|
判定:supported/refuted/needs-pivot。反証された仮説も台帳に残します。可能性を排除することも作業の半分であり、出力の ruled_out に反映されるためです。
初期スイープ — フェーズ 1 のベースラインから導き出した以下の項目をすべてラウンド 1 で実行し、ヒットから後続の仮説を立てます。
- サーバーエラー。 どのルートで、どのようなペイロードを使って、誰が 5xx を引き起こしているか? エラーログと照合してください。攻撃者の入力を引用するアプリケーションエラーは、プローブの兆候です。
- すべてのアラートを判定する。 アラートキューの各項目について、検知対象となったアクティビティは成功したか? 無害なトラフィックに対して発生したアラートは、証拠とともに記録する
ruled_out項目です。 - 上位トーカーの外れ値。 ベースラインを大きく上回る通信量の IP ごとに、何をしているかを調べます。スキャナーのノイズ、クローラー、それとも意図的な活動でしょうか?
- クエリ文字列内のインジェクション構文。 エンコードされた引用符(
%27)、UNION/SELECT、コメントマーカー(--、%23)、真偽値のペア(AND 1=1/AND 1=2)を調べます。見つかったものをデコードし、送信元 IP ごとに時系列順で一連の動きを再構成してください。 - 列挙パターン。 1 つのセッションまたは IP が、機械的な間隔でオブジェクト ID に連続してアクセスしているパターン(
/orders/1、/orders/2、…)。正規のセッションが同じルートにアクセスする方法と比較してください。 - 認証の異常。 アカウントでこれまで確認されていない IP からのログイン、失敗後に成功する一連の試行、または 1 つの IP が 1 つのアカウントとして認証した後に通常より多くのデータを読み取っているケースを調べます。
- サイズの異常。 パラメーター付きルートへのリクエストのうち、レスポンスサイズがそのルートの中央値の倍数になっているものを調べます。それらのレスポンスには何が含まれていたでしょうか?
- 静かな時間帯。 最もトラフィックの少ない時間帯のアクティビティを重点的に調べます。低速で長時間にわたるキャンペーンは、トラフィック量に紛れ込みますが、午前 3 時には際立ちます。
方向転換の規律: スイープでヒットが見つかったら、次の仮説はそのヒットに含まれるエンティティから導き出します。その IP がほかに何をしたか、そのセッションが何に触れたか、同じルートに同じ方法でアクセスした別の IP はあるか、移動したデータに何が起きたかを調べます。キャンペーンは一連のつながりです。リンクが 1 つ確認されたら、両方向を追跡します(どうやってここにたどり着いたのか? 次にどこへ行ったのか?)。
停止条件: 新しいエンティティが見つからないラウンドが 2 回連続した場合(supported になっても無害な動作を確認しただけの仮説は、新しいエンティティなしとして扱い、記録して ruled_out に振り分けます)。台帳に停止したことを記録してください。
各ラウンドの後に phase2.json のチェックポイントを作成します。
{"ledger": [...], "suspects": [{"name", "entities": {"ips", "accounts", "routes", "sessions"}, "summary", "evidence": [...]}], "round": N}
フェーズ 3 — ソースの確認
ルートが関与している疑いがある場合は、--repo 内のハンドラーを読みます。
- そのルートのハンドラー関数を見つけます。不審な入力を、リクエストからシンク(クエリ、ファイルシステム、認証判断)まで追跡します。
- 脆弱な場合:問題を正確に記述します。ファイル、関数、該当するコードパターン(例:ユーザー入力を SQL に連結している、認証と取得処理の間に所有権チェックがない)を示します。
- 脆弱でない場合:同じ精度で防御策(パラメーター化クエリ、スコープ付き検索)を示します。これにより、攻撃トラフィックは誤検知ではなく
attempted_not_vulnerableの判定になります。これを書き残すことは必須であり、任意ではありません。 - ファイルを確認している間に、同じ種類の問題がないか、隣接するルートも調べます。ログが示すのは攻撃者がアクセスした場所であり、バグが存在するすべての場所ではありません。
phase3.json のチェックポイントを作成します。
{"confirmations": [{"route", "file", "function", "vulnerable": bool, "mechanism", "siblings_checked": [...]}]}
フェーズ 4 — PoC の実行(confirmed_exploited の判定条件)
ソースで確認された各脆弱性について:
-
対象の設定に従ってアプリをローカルで起動します。
seed_commandとapp_commandは対象相対パスなので、対象ディレクトリを cwd として実行します (dnrcanary のアプリも独自のデータパスを解決するため、リポジトリルート形式でも たまたま動作しますが、他の対象で同様に動作すると想定しないでください)。まず シードを実行してから、PID を出力する単一のコマンドでアプリをバックグラウンドで 起動します。例:python3 -c "import os,subprocess; p=subprocess.Popen(['python3','app/app.py'],cwd='targets/dnrcanary',env={**os.environ,'DNRCANARY_PORT':'5252'}); print(p.pid)"curl -s http://127.0.0.1:<port>/healthzが応答するまでポーリングします。対象の デフォルトポートが使用中の場合は、ドキュメントに記載されている上書き方法 (dnrcanary の場合は前述のDNRCANARY_PORT)で別のポートを選び、使用した ポートを記録します。 -
最小限の PoC を
curlで実行します。欠陥を実証するための最小のリクエスト (データの 1 行、外部オブジェクト 1 つ)にとどめ、攻撃者のキャンペーン全体を 再実行しないでください。 -
正確なコマンドと、応答の該当箇所を記録します。
-
該当する場合は、否定 PoC も実行します。脆弱性なしと判定したルートに対して 同じ手法を試し、拒否されることを示します。
-
取得した PID を使ってアプリを停止します(
kill <pid>。ポートやパターンで プロセスを終了してはいけません)。
この環境でアプリを実行できない場合は、その旨を明記します。該当する判定は
疑いあり のままとし、レポートにはどこで何を実行すべきかを記載します。
チェックポイント phase4.json:
{"detonations": [{"vuln", "command", "status", "response_excerpt", "verdict"}]}
フェーズ 5 — 出力
{RUN}/INCIDENTS.json を書き込みます。
{
"target": "<target name or logs dir>",
"incidents": [
{
"id": "INC-1",
"title": "<one line: what happened>",
"verdict": "confirmed_exploited | suspected",
"attacker_ips": ["..."],
"accounts_involved": ["..."],
"vuln": {"type": "...", "route": "...", "file": "...", "function": "..."},
"timeline": [{"ts": "...", "event": "..."}],
"impact": "<what data / how much / whose — every number from a query>",
"evidence": ["<log file>: <pipeline> → <result>", "..."],
"poc": {"command": "...", "verified": true}
}
],
"ruled_out": [
{"subject": "...", "verdict": "attempted_not_vulnerable | benign",
"reason": "<the defense, with file:function, or the benign explanation>"}
]
}
振り分けルール:incidents には、発生が確認されたもの(confirmed_exploited)または
発生した可能性があるもの(suspected)を格納します。attempted_not_vulnerable または
benign と判定されたものはすべて ruled_out に格納し、incidents には含めません。
{RUN}/INCIDENT_REPORT.md を書き込みます。内容は、エグゼクティブサマリー(最大 3 文)、
すべてのインシデントを通じた統合キャンペーンのタイムライン、インシデントごとの
セクション(証拠、ソースでの確認、PoC の記録、影響)、「除外した項目」セクション、
そして仮説台帳全体を付録として含めます。台帳は、調査をレビュー可能にする監査証跡です。
完了としてマークし(checkpoint.py done {RUN}/.dnr-hunt-state 5)、ユーザーに次の手順を
伝えます(文字どおりの {RUN} パスに置き換えてください)。
/dnr-respond INC-<n>— 特定のインシデントの範囲、影響範囲、対応計画案を提示します。 同じ実行ディレクトリを自動的に引き継ぎます。- dnr の対象では、
python <target>/grade.py {RUN}/INCIDENTS.jsonで自己採点できます (これはユーザーが実行します)。 {RUN}内から、/triage INCIDENTS.json --repo <repo>に続けて/patch TRIAGE.json --repo <repo>を実行します。どちらのスキルも呼び出し元の ディレクトリに書き込むため、実行ディレクトリ内で実行すれば、TRIAGE.jsonとPATCHES/がインシデントと同じ場所に保たれます。また、patch は生のインシデント ではなく、triage による真陽性の判定を使用します。脆弱性エントリにはfile、function、typeが含まれており、これらが各スキルの入力に使われます。
デモの範囲を超えてカスタマイズする
- 独自のログ:
$1にアクセスログ、アプリログ、認証ログがある任意のディレクトリを 指定します。フェーズ 0 では見つかった内容に合わせて対応します。手法 (ベースライン → 台帳 → ソース → 実行)は形式に依存しません。Nginx/Apache の combined ログ、JSON Lines 形式のアプリログ、CDN/WAF のエクスポート、認証ログを すべて扱えます。フェーズ 0 でフィールドの位置を特定し、awk のパイプラインを 調整してください。 - EDR/エンドポイントテレメトリ: 脅威イベントを CSV/JSON にエクスポートし、 同じ方法で調査します。エンティティは IP/セッション/ルートではなく プロセス/ホスト/ユーザーになり、「ソースでの確認」は設定またはバイナリの レビューになります。判定の段階は維持してください。テレメトリだけでは決して 確認済みになりません。
- 規模への対応: grep による処理が遅すぎるコーパスは、DuckDB
(
python3 -c "import duckdb; ...")に読み込み、同じ検索を SQL として実行します。 - 検知エンジニアリングへの展開: 確認されたすべてのインシデントは、
/dnr-respondの計画内で検知ルールの提案につなげる必要があります。 アラートキューの改善につながらない調査は、いつまでも手作業で繰り返すことになります。