Microsoft Sentinel分析ルールの紐解き（Privileged Accounts - Sign in Failure Spikes）

Microsoft Sentinelにおいてもともと準備されている分析ルールについてクエリの中身や注意点等を紐解いていく。

# 1. ルール概要
|項目|説明|
|--|--|
|重大度|高|
|ルール名|Privileged Accounts - Sign in Failure Spikes|
|ルールの種類|Scheduled|
|ルールの頻度|1日ごとにクエリを実行|
|ルールの期間|過去14日のデータ|
|バージョン|1.0.2|
|説明|特権アカウントからのサインイン失敗が急増していることを特定する。特権アカウントのリストは、IdentityInfo UEBA テーブルまたは内蔵のウォッチリストに基づくことができる。スパイクは、過去の基準値を参照する時系列の異常に基づいて判断される。（by DeepL）|

# 2. 活用するテーブル
|テーブル名|概要|参考URL|
|--|--|--|
|IdentityInfo|UEBA機能によって設定されたユーザー ID 情報|<https://learn.microsoft.com/ja-jp/azure/azure-monitor/reference/tables/identityinfo>|
|SigninLogs|ユーザーからの対話型のAzure ADへのサインインログ|<https://learn.microsoft.com/ja-jp/azure/azure-monitor/reference/tables/signinlogs>|
|AADNonInteractiveUserSignInLogs|ユーザーからの非対話型のAzure ADへのサインインログ|<https://learn.microsoft.com/ja-jp/azure/azure-monitor/reference/tables/aadnoninteractiveusersigninlogs?source=recommendations>|

# 3. クエリ解説
```Kusto:Privileged Accounts - Sign in Failure Spikes / version1.0.2
// 分析対象期間の開始日
let starttime = 14d;
// 
let timeframe = 1d;
// 異常検出のための系列分解における閾値（規定値は1.5）
let scorethreshold = 3;
// 系列の予測値に対する異常と判断するための閾値
let baselinethreshold = 5;
// 管理者権限が付与されたユーザーにおいて、過去14日以内に認証失敗したログを洗い出す
let aadFunc = (tableName:string){
    // ユーザーIDに関するテーブル
    IdentityInfo
    // ログ生成日時が過去14日以内のものに絞る
    | where TimeGenerated > ago(starttime)
    // AccountUPNを主キーとして最後に生成されたログのみ表示する
    | summarize arg_max(TimeGenerated, *) by AccountUPN
    // AssignedRolesの配列を展開する
    | mv-expand AssignedRoles
    // AssignedRolesカラムのデータに「Admin」という文字が含まれているレコードに絞り込む
    | where AssignedRoles matches regex 'Admin'
    // AccountUPN（小文字化）を主キーとしてRolesフィールドにAdminという名称が含まれる、付与された権限一覧のリストを作成
    | summarize Roles = make_list(AssignedRoles) by AccountUPN = tolower(AccountUPN)
    // サブサーチを行い、メインサーチとサブサーチで合致するログのみ結合して残す
    | join kind=inner (
        // 引数で渡されたテーブル名を代入
        table(tableName)
        // ログ生成日時フィールドが14日前の00:00:00から今日の00:00:00の期間に含まれるものに絞り込む
        | where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
        // 認証成功以外のログに絞り込む
        | where ResultType != 0
        // UPNの値をすべて小文字化する
        | extend UserPrincipalName = tolower(UserPrincipalName)
    // メインサーチの主キー AccountUPNとサブサーチの主キーUPNが合致する場合、サブサーチの全カラムをメインサーチの結果にマージする
    // メインサーチにはユーザー毎に1行ログが存在するが、サブサーチで複数失敗ログがある場合（1:N）、N行分のログが生成される
    ) on $left.AccountUPN == $right.UserPrincipalName
    // ログ生成日時（サブサーチの値）フィールド、UPN、付与された管理者権限一覧（リスト）のみ表示する
    | extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName, Roles = tostring(Roles)
};
// SigninLogsテーブル（AAD対話型のログイン）を対象に管理者権限が付与されたユーザーの過去14日以内に認証失敗したログを洗い出す
let aadSignin = aadFunc("SigninLogs");
// AADNonInteractiveUserSignInLogsテーブル（AAD非対話型のログイン）を対象に管理者権限が付与されたユーザーの過去14日以内に認証失敗したログを洗い出す
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
// 上記2つのログ抽出結果を1つの変数に（テーブルにデータが存在しなくてもクエリ実行を継続する）
let allSignins = union isfuzzy=true aadSignin, aadNonInt;
// 系列分解により異常傾向ありと認められる認証失敗のログを抽出する
let TimeSeriesAlerts = 
    allSignins
    // ユーザー名と権限名の組み合わせにおいて、過去14日間の1時間あたりの失敗回数をカウントする
    | make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step 1h by UserPrincipalName, Roles
    // 異常性の傾向、異常スコア、予測値を各変数に格納
    // 系列分解により線形回帰を使用して傾向コンポーネントを抽出
    // https://learn.microsoft.com/ja-jp/azure/data-explorer/kusto/query/series-decompose-anomaliesfunction
    | extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')
    // 各ログにスコアリングを付与する
    | mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double), score to typeof(double), baseline to typeof(long)
    // Filtering low count events per baselinethreshold
    // 傾向が増えており、異常の予測値の閾値を超えているログにのみ絞る
    | where anomalies > 0 and baseline > baselinethreshold
    // 異常傾向がある時間を新しいフィールドに代入
    | extend AnomalyHour = TimeGenerated
    // 表示するフィールドを絞り込む
    | project UserPrincipalName, Roles, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score;
// Filter the alerts for specified timeframe
TimeSeriesAlerts
// 過去1日以内に異常と判断した認証失敗ログに絞り込む
| where TimeGenerated > startofday(ago(timeframe))
| join kind=inner ( 
    allSignins
    // 過去1日間の認証失敗したログに絞り込む
    | where TimeGenerated > startofday(ago(timeframe))
    // create a new column and round to hour
    | extend DateHour = bin(TimeGenerated, 1h)
    // 1時間あたりの失敗件数と最後の認証失敗時間やその他情報にサマライズする
    | summarize PartialFailedSignins = count(), LatestAnomalyTime = arg_max(TimeGenerated, *) by bin(TimeGenerated, 1h), OperationName, Category, ResultType, ResultDescription, UserPrincipalName, Roles, UserDisplayName, AppDisplayName, ClientAppUsed, IPAddress, ResourceDisplayName
// 異常傾向がある時間を主キーにメイン／サブサーチのデータを結合する（結合されたメインサーチ結果のみに絞る）
) on UserPrincipalName, $left.AnomalyHour == $right.DateHour
// フィールドを指定したフィールドのみに絞り込む
| project LatestAnomalyTime, OperationName, Category, UserPrincipalName, Roles = todynamic(Roles), UserDisplayName, ResultType, ResultDescription, AppDisplayName, ClientAppUsed, UserAgent, IPAddress, Location, AuthenticationRequirement, ConditionalAccessStatus, ResourceDisplayName, PartialFailedSignins, TotalFailedSignins = HourlyCount, baseline, anomalies, score
// エンティティにマッピングする
| extend timestamp = LatestAnomalyTime, IPCustomEntity = IPAddress, AccountCustomEntity = UserPrincipalName
```

# X. おまけ
mv-expandの挙動の説明。簡単に言うと1つの行に特定のフィールドが複数のバリューを持っている場合、それを展開して1行1バリューの形にする。

以下のような出力結果に対して
|Num|values|
|--|--|
|1|[10,20]|
|2|["a","b"]|

mv-expandを使うと以下のような状態になる

|Num|values|
|--|--|
|1|10|
|1|20|
|2|"a"|
|2|"b"|