Entra IDのサインインログをKQLで調べる — 不審ログインの条件をClaudeに書かせて検証する
国外IP・短時間の複数国・失敗後の成功という3つの不審ログイン条件をKQLにし、Claude Codeに書かせて、自分のテストサインインで検出されるかを確かめる手順です。
不審なサインインを探す条件は、日本語にすると簡単です。「普段と違う国から」「短時間に複数の国から」「失敗が続いた直後に成功」。これをKQL(Kusto Query Language)に直す作業をClaude Codeに任せ、出てきたクエリが本当に拾うかを自分のテストサインインで確かめるまでが、この記事の範囲です。
KQLは読み取り専用の言語なので、書かせる相手を選びません。ただし、書けたクエリが正しいかどうかは別問題です。型の取り違えや列名の誤りで、意図と違う条件のクエリや、対象のテーブルを取り違えたクエリが成立してしまうことがあります。そこで、クエリを書かせる前に検証用の足場を作り、書かせた後に必ず実ログで当てる流れにします。
サインインログをKQLで引ける状態にする
Entra IDのサインインログは、管理センターで見るだけならKQLは要りません。KQLで引くには、ログをLog Analyticsワークスペースに送る診断設定が前提です。
前提の整理
Microsoft Entra IDは、内部アプリやリソースを含むテナントへのすべてのサインインを記録します。管理センターの経路は、Entra IDのMonitoring & healthにあるSign-in logsで、閲覧には最低でもReports Reader相当のロールが要ります。
KQLで検索する場合は、次の3点が揃っている必要があります。Log Analyticsでアクティビティログを分析する前提として、Microsoft Entra ID P1またはP2ライセンスも挙げられています。
- Azureサブスクリプションと、その中のLog Analyticsワークスペース
- Entraのテナントで、診断設定を作れるSecurity Administrator以上のロール
- ワークスペースのデータを読む権限(Log Analytics ReaderなどのAzureロール)
診断設定は、Entra IDのMonitoring & healthにあるDiagnostic settingsから作ります。ログカテゴリを選び、送信先にSend to Log Analytics workspaceを指定して保存します。Entraのログを送ると、Microsoft Sentinelのデータコネクターも自動で有効になります。
サインインのカテゴリは種類ごとに分かれています。対話型は SignInLogs、非対話型は NonInteractiveUserSignInLogs、サービスプリンシパルは ServicePrincipalSignInLogs、マネージドIDは ManagedIdentitySignInLogs です。ワークスペースでは、対話型が SigninLogs、非対話型が AADNonInteractiveUserSignInLogs という別のテーブルに入ります。選ばなかったカテゴリのログは、ワークスペースに届きません。
ここで見落としやすいのは、診断設定を作る前の過去分はワークスペースに入らない点です。さかのぼれる期間は、Entra側の保持期間に依存します。
保持期間とライセンス
Entra側のサインインログの保持期間は、ライセンスで変わります。
| ログ | Free | P1 | P2 |
|---|---|---|---|
| サインイン | Free7日 | P130日 | P230日 |
| 監査 | Free7日 | P130日 | P230日 |
7日しか持たないテナントでは、事件が起きてから調べ始めても手遅れになりえます。保持期間を超えて残したいなら、診断設定でワークスペースやストレージアカウントへ流しておくのが前提です。
リスク関連の列にも差があります。RiskLevelDuringSignInやRiskLevelAggregatedは、Microsoft Entra ID P2でない場合、値が hidden で返ります。リスクの有無を条件に使うクエリは、P2のテナントでしか意味を持ちません。次の3条件は、リスク列に頼らず、位置・時刻・結果コードだけで組みます。
Claudeに渡すスキーマと前提を先に固定する
KQLをClaudeに書かせると、列名や型を推測で埋めたクエリが返ってきます。SigninLogsテーブルの列は公式のリファレンスにあるので、それを先に渡して推測の余地を消します。
条件の組み立てに必要な列は、次のとおりです。
| 列 | 型 | 使いどころ |
|---|---|---|
| TimeGenerated | 型datetime | 使いどころ時間窓の集計 |
| UserPrincipalName | 型string | 使いどころユーザー単位の集計 |
| IPAddress | 型string | 使いどころ発信元IPの突き合わせ |
| Location | 型string | 使いどころサインイン元の国コード(2文字) |
| LocationDetails | 型dynamic | 使いどころ市区町村・緯度経度 |
| ResultType | 型string | 使いどころ0が成功、それ以外は失敗コード |
| ResultDescription | 型string | 使いどころ失敗理由のテキスト |
| AppDisplayName | 型string | 使いどころサインイン先のアプリ |
| IsInteractive | 型bool | 使いどころ対話型かどうか |
間違えやすい点が3つあります。ResultTypeは文字列なので、ResultType == 0 ではなく ResultType == "0" と書きます。Locationは国名ではなく2文字の国コードです。KQLは表名・列名・演算子・関数のすべてで大文字小文字を区別します。KQLの概要ページにもこの旨があります。
CLAUDE.mdに置く断片
毎回スキーマを貼り直すより、プロジェクトのCLAUDE.mdに規約として置くほうが続きます。CLAUDE.mdは強制される設定ではなく文脈として読まれるので、検証できる具体的な書き方にします。
## KQL(Entra IDサインインログ)
- 対象テーブルはSigninLogs。時刻列はTimeGenerated。
- ResultTypeは文字列。成功は "0"。数値比較にしない。
- Locationは2文字の国コード(例: "JP")。
- 結果は集計して返す。UserPrincipalNameの生ログ全件を出力しない。
- クエリを出したら、条件ごとに「何を拾い、何を拾わないか」を1行で添える。
- 閾値(回数・時間窓)は let で先頭に宣言し、本文に直書きしない。4行目の「集計して返す」は、個人情報を会話に載せすぎないための一行です。
不審ログインの3条件をKQLにする
以降のクエリは、Claudeに条件を日本語で伝えて書かせる前提で、レビューしやすい形に整えた例です。公式のサンプルをそのまま写したものではなく、SigninLogsの列定義に沿って組んだ一例です。環境に合わせて閾値を調整してください。
条件1: 想定外の国からの成功サインイン
最も単純な条件です。許可する国コードを let で宣言し、それ以外からの成功だけを残します。
let allowedCountries = dynamic(["JP"]);
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| where isnotempty(Location)
| where Location !in (allowedCountries)
| project TimeGenerated, UserPrincipalName, IPAddress, Location, AppDisplayName
| sort by TimeGenerated descisnotempty(Location) を入れているのは、国が解決できなかった行を「国外」と誤認しないためです。Locationは、IPアドレスによっては解決できないことがあります。VPNや社外の出張者が多い組織では、この条件だけだと件数が膨らみます。ユーザー単位の許可リストに広げる段階で、Claudeに相談する価値があります。
条件2: 短時間で複数の国から成功
同じユーザーが1時間の窓に2か国以上から成功しているなら、移動では説明しにくい動きです。
let window = 1h;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| where isnotempty(Location)
| summarize
Countries = make_set(Location),
CountryCount = dcount(Location),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, bin(TimeGenerated, window)
| where CountryCount >= 2
| sort by LastSeen desc窓の区切り方には癖があります。bin は時刻を固定の枠に切るので、59分と61分の2回のサインインは別の枠に入り、見逃します。厳密にやるなら連続するサインイン同士の時間差を取る書き方になります。まず枠方式で全体を見て、検出漏れが気になる段階で書き直すのが現実的です。
条件3: 失敗が続いた後の成功
パスワードスプレーやクレデンシャルスタッフィングの典型的な形です。失敗の集計と成功を同じユーザー・同じIPで結び、成功が最後の失敗から1時間以内かを見ます。
let window = 1h;
let failThreshold = 5;
let failures = SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "50126"
| summarize FailCount = count(), LastFail = max(TimeGenerated)
by UserPrincipalName, IPAddress;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| project SuccessTime = TimeGenerated, UserPrincipalName, IPAddress,
Location, AppDisplayName
| join kind=inner failures on UserPrincipalName, IPAddress
| where FailCount >= failThreshold
| where SuccessTime between (LastFail .. LastFail + window)このクエリには限界があります。失敗は7日間の全体で集計し、判定には最後の失敗時刻だけを使います。「失敗が連続した直後」ではなく「期間内に失敗が累計5回以上あり、最後の失敗から1時間以内に成功した」を拾う形です。連続性まで見たい場合は、失敗を1時間の枠で集計し直します。
失敗コード50126は、ユーザー名またはパスワードが正しくない場合のコードです。エラーコード一覧では、50126は「Error validating credentials due to invalid username or password」です。ほかの失敗コードも含めたいときは、ResultType in ("50126", "50053") のように広げます。50053は、アカウントのロックか、悪意あるIPからのサインインのブロックのどちらかです。この2つは原因が別なので、広げる場合はResultDescriptionも併せて出して区別できるようにします。
Claude Codeに書かせて、レビューする
条件を日本語で渡すときは、期待する結果の例も一緒に渡します。
SigninLogsで、同一ユーザー・同一IPから、失敗(ResultType 50126)が5回以上続いた
あと1時間以内に成功したサインインを抽出するKQLを書いてください。
CLAUDE.mdの規約に従い、閾値は let で宣言してください。返ってきたクエリは、次の観点で読みます。
- ResultTypeを数値で比較していないか
- 集計と結合のキーが、意図した粒度(ユーザー単位かIP単位か)になっているか
- 時間窓の判定が、
betweenやagoの向きまで含めて正しいか - Locationが空の行の扱いが決まっているか
読んで通ったクエリは、実行して結果の行数と中身を見ます。ここからは実行を伴うので、Claudeにどこまで任せるかを決めます。
az CLIで実行させる場合の権限
ワークスペースへのクエリは、Azure CLIの az monitor log-analytics query で投げられます。必須の引数は --analytics-query と、ワークスペースのGUIDを渡す --workspace です。--timespan を付けると期間を絞れます。付けないと利用可能な全期間が対象です。
az monitor log-analytics query \
-w <workspace-customId> \
--analytics-query "SigninLogs | where TimeGenerated > ago(1d) | count" \
-t P1DClaude Codeに実行まで任せるなら、許可ルールを絞ります。ルールはdeny、ask、allowの順に評価され、最初に一致したものが結果になります。allowに入れるのは、クエリ実行だけに絞ったものです。
{
"permissions": {
"allow": [
"Bash(az monitor log-analytics query *)"
],
"deny": [
"Bash(az monitor log-analytics workspace *)",
"Bash(az role *)"
]
}
}ワークスペース自体の変更や、ロールの付与を試みるコマンドは、denyで先に止めます。KQLのクエリはデータを書き換えない読み取り専用です。権限を絞る価値があるのは、クエリの中身ではなく、同じ端末でClaudeが叩けるほかのazコマンドのほうです。
結果をClaudeに読ませるときの注意
サインインログには、ユーザー名・IPアドレス・位置情報が含まれます。生ログを大量にClaudeの会話へ流すと、必要以上の個人情報を会話に載せることになります。CLAUDE.mdで「集計して返す」と決めたのは、このためです。件数・国の集合・最終失敗時刻のように、判断に足りる粒度までKQL側で畳んでから渡すと、会話に出るデータは小さく済みます。
実ログで検証する
クエリが文法として通ることと、狙った動きを拾えることは別です。検証は、自分で用意したテストサインインを使います。
テストサインインで検出を確かめる流れ
- 1
検証用アカウントを決める
本番ユーザーは使いません。専用のテストユーザーを作り、サインイン先のアプリを1つに絞ります。
- 2
失敗から成功までを意図的に起こす
誤ったパスワードで規定回数サインインを失敗させ、続けて正しいパスワードで成功させます。時刻と使ったIPを自分でメモしておきます。
- 3
ログが入るのを待つ
診断設定からワークスペースにログが現れるまでには遅延があります。届くまでの時間は一定ではないので、数分待っても出ないときは、ログカテゴリの選択漏れを疑います。
- 4
クエリを当てて、メモと突き合わせる
自分のメモした時刻・IPと、クエリの出力が一致するかを見ます。検出ゼロなら、クエリの誤りか、ログがまだ届いていないかのどちらかです。
- 5
閾値を動かして偽陽性を見る
失敗回数や時間窓を変え、本物のユーザーの正常なサインインがどれだけ混ざるかを見ます。
国外からのサインインは、検証側の再現が難しい条件です。VPNで別の国を経由するなら、組織のポリシーに反しないか事前に確認してください。難しければ、allowedCountries に自国を入れずに回して、自分の成功が拾われるかを見る方法でも、クエリの動作は確かめられます。
検出ゼロのクエリが静かに成立する落とし穴
検証でよく見る失敗は、検出ゼロや的外れな結果に見えるのに、原因が別のところにある形です。空の結果になりやすいものと、エラーで気づけるものに分けます。
空の結果になりやすいもの:
ResultType == 0と数値で書いた。ResultTypeは文字列なので、意図どおりの比較になりません。必ず"0"と書きますTimeGeneratedの範囲が、Entra側の保持期間より長い。古い範囲は最初からデータがありません- 診断設定でサインインのカテゴリを選んでいない。ワークスペースに届かないので、
SigninLogsは空のままです - 非対話型のサインインを
SigninLogsだけで探している。非対話型は別テーブルに入るため、そこには現れません
エラーで気づけるもの:
- 列名やテーブル名の大文字小文字を間違えた。KQLは区別するので、未定義の名前としてエラーになります
検出ゼロを「異常なし」と読む前に、同じ範囲で SigninLogs | count を投げて、そもそも行があるかを確かめます。この確認で切り分けられるのは、保持期間を超えた範囲と、カテゴリの選択漏れの2つです。型の取り違えと非対話型の別テーブルは、行があっても起きるので、条件を外した形の結果と見比べて見つけます。
非対話型のサインインをどう扱うか
サインインログには種類があります。管理センターのプレビューでは、対話型ユーザー、非対話型ユーザー、サービスプリンシパル、マネージドIDの4つです。従来の画面は対話型ユーザーのみでした。
非対話型は、クライアントアプリやOSが、ユーザーの代わりにトークンを更新するサインインです。ユーザーに認証要素を求めないため、利用者から見ればバックグラウンドで起きます。同じ短時間に大量に生じ、共通点が多いので、管理センターではまとめて表示されます。
SigninLogs の IsInteractive 列は、行ごとに対話型かどうかを示す列です。ただし、非対話型のユーザーサインインは診断設定で別カテゴリ(NonInteractiveUserSignInLogs)として送るもので、ワークスペースでは AADNonInteractiveUserSignInLogs に入ります。サービスプリンシパルとマネージドIDも、それぞれ別のカテゴリです。
条件3のように「ユーザーがパスワードを入力して失敗した」ことを見たいクエリは、SigninLogs だけで足ります。非対話型も含めて調べるなら、2つのテーブルを union します。
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| summarize Count = count() by Type, IsInteractiveType 列でテーブルごとの件数が分かります。Claudeに条件を伝えるときは、「対話型のみ」「非対話型も含める」のどちらかを明示します。指定しないと、返ってくるクエリが暗黙にどちらかを選びます。
検出条件を運用に載せる前に
検証が通ったクエリを、アラートや定期実行に載せる前に確認しておくことがあります。
- 閾値は、検証時の値のまま本番に使わない。偽陽性の量で決める
- 国外・複数国の条件は、VPNや出張で増える。許可リストを持つか、ユーザー単位で例外を持つ
- 条件3は、複数の失敗コードを含めるか、ResultDescriptionで分類するかを決める
- Entra ID Protectionのリスク検知(P2)がある場合は、自前のKQLと重ねて見て、拾えているものと漏れているものを比べる
KQLはMicrosoft SentinelやAzure Monitorで共通に使われる言語です。ここで書いたクエリは、Sentinelの分析ルールやハンティングに持ち込む土台になります。ただし、持ち込む際の列の対応や実行間隔の制約は、Sentinel側のドキュメントで別に確認が要ります。
アカウントの乗っ取りが疑われたときの、Claude側の対応はClaudeの全セッションログアウト手順にまとめています。ClaudeのSSOでEntra IDを使っているなら、Claude Entra ID SSO設定の構成が前提になります。Azureのリソースをまとめて操作させる接続はAzure MCPサーバーの設定が扱い、Entra ID連携の接続エラーはAADSTS9010010の原因で切り分けられます。
まとめ
不審ログインの条件は、日本語で書ければKQLにできます。難しいのはクエリを書く部分より、書かれたクエリが拾うはずのものを拾っているかの確認です。自分のテストサインインで失敗から成功までを起こし、メモした時刻とIPが出力に現れるかを見ると、文法の正しさでは分からない型の取り違えが表に出ます。Claudeに渡すスキーマ、集計して返す規約、実行権限の絞り込みを先に固定しておくと、この検証の繰り返しが安全に回ります。