Streaming Azure AD risk events to Azure Sentinel

Microsoft recently added the ability to stream risk events from Azure AD Identity Protection into Azure Sentinel, check out the guidance here. You can add the data in the Azure AD -> Diagnostic Settings page, and once enabled you will see data stream into two new tables

  • AADUserRiskEvents – this is the data that you would see in Azure AD Identity Protection if you went and viewed the risk detections, or risky sign-in reports
  • AADRiskyUsers – this is the data from the Risky Users blade in Azure AD Identity Protection but streamed as log data, so will include when users are remediated.

This is a really welcome addition because there has always been an overlap with where detections are found, Azure AD Identity Protection will find some stuff, Microsoft Cloud App Security will find its own things, there is some crossover, and you may not be licensed for everything. Also having the data in Sentinel means you can query it against other log sources more unique to your environment. If you want to visualize the type of risk events in your environment you can do so. Keep in mind this data will only start populating once you enable it, any risk events prior to that won’t be resent to Azure Sentinel.

AADUserRiskEvents
| where isnotempty( RiskEventType)
| summarize count()by RiskEventType
| render piechart 

You can see here some of the overlap, you get unlikelyTravel and mcasImpossibleTravel, you can also have a look at where the data is coming from.

AADUserRiskEvents
| where isnotempty( RiskEventType)
| summarize count()by RiskEventType, Source

If you look at an AADUserRiskEvents event in detail, you see a column for DetectionTimingType – which tells us whether the detection is realtime (on sign in) or offline.

AADUserRiskEvents
| where isnotempty( DetectionTimingType) 
| summarize count()by DetectionTimingType, RiskEventType, Source

So we get some realtime alerts and some offline alerts from a number of sources. At the end of the day, more data is always useful, even if users will trigger multiple alerts if you are licensed for both systems. For anyone that has spent time looking at Azure AD sign in data, you would also know that there are risk items in those logs too, so how to we match up the data from a sign in to the data in our new AADUserRiskEvents? Thankfully when a sign in occurs that flags a risk event, it registers the same correlation id on both tables. So we can join between them and extract some really great data from both tables. Sign in data has all the information about what the user was accessing, conditional access rules, what client etc and then we can also get the data from our risk events.

let signin=
SigninLogs
| where TimeGenerated > ago(24h)
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| where TimeGenerated > ago(24h)
| join kind=inner signin on CorrelationId

When a user sign-ins with no risk unfortunately the RiskEventTypes_V2 table is actually not actually empty, it is just [], so we exclude those, then join on the correlation id to our risk events and you will get the data from both. We can even extend the columns and calculate the time delta between the sign in event and the risk event, for real time that is obviously going to be quick, but for offline you can find out how long it took for the risk to be flagged.

let signin=
SigninLogs
| where TimeGenerated > ago(24h)
| extend SigninTime = TimeGenerated
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| where TimeGenerated > ago(24h)
| extend RiskTime = TimeGenerated
| join kind=inner signin on CorrelationId
| extend TimeDelta = abs(SigninTime - RiskTime)
| project UserPrincipalName, AppDisplayName, DetectionTimingType, SigninTime, RiskTime, TimeDelta, RiskLevelDuringSignIn, Source, RiskEventType

When looking at these risk events, you may notice a column called RiskDetail, and occasionally you will see aiConfirmedSigninSafe. This is basically Microsoft flagging the risk event as safe based on some kind of signals they are seeing. They won’t tell you what is in the secret sauce to confirm it is safe but we can guess it is a combination of properties they have seen before for that user – maybe an IP address, location or user agent known seen previously. So we can probably exclude those from things we are worried about. Maybe you also only care about realtime detections considered medium or high, so we filter out offline detections and low risk events.

let signin=
SigninLogs
| where TimeGenerated > ago(24h)
| where RiskLevelDuringSignIn in ('high','medium')
| extend SigninTime = TimeGenerated
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| where TimeGenerated > ago(24h)
| extend RiskTime = TimeGenerated
| where DetectionTimingType == "realtime"
| where RiskDetail !has "aiConfirmedSigninSafe"
| join kind=inner signin on CorrelationId
| extend TimeDelta = abs(SigninTime - RiskTime)
| project UserPrincipalName, AppDisplayName, DetectionTimingType, SigninTime, RiskTime, TimeDelta, RiskLevelDuringSignIn, Source, RiskEventType, RiskDetail

You can visualize these events per day if you wanted to have an idea if you are seeing increases at all. Keep in mind this table is relatively new so you won’t have a lot of historical data to work with, and again the data won’t appear at all until you enable the diagnostic setting. But over time it will help you create a baseline of what is normal in your environment.

let signin=
SigninLogs
| where RiskLevelDuringSignIn in ('high','medium')
| extend SigninTime = TimeGenerated
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| extend RiskTime = TimeGenerated
| where DetectionTimingType == "realtime"
| where RiskDetail !has "aiConfirmedSigninSafe"
| join kind=inner signin on CorrelationId
| extend TimeDelta = abs(SigninTime - RiskTime)
| summarize count(RiskEventType) by bin(TimeGenerated, 1d), RiskEventType
| render columnchart  

If you have Azure Sentinel UEBA enabled, you can even enrich your queries with that data, which includes things like City, Country, Assigned Azure AD roles, group membership etc.

let id=
IdentityInfo
| summarize arg_max(TimeGenerated, *) by AccountUPN;
let signin=
SigninLogs
| where TimeGenerated > ago (14d)
| where RiskLevelDuringSignIn in ('high','medium')
| join kind=inner id on $left.UserPrincipalName == $right.AccountUPN
| extend SigninTime = TimeGenerated
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| where TimeGenerated > ago (14d)
| extend RiskTime = TimeGenerated
| where DetectionTimingType == "realtime"
| where RiskDetail !has "aiConfirmedSigninSafe"
| join kind=inner signin on CorrelationId
| extend TimeDelta = abs(SigninTime - RiskTime)
| project SigninTime, UserPrincipalName, RiskTime, TimeDelta, RiskEventTypes, RiskLevelDuringSignIn, City, Country, EmployeeId, AssignedRoles

If you were then to filter on only alerts where the users have an assigned Azure AD role.

let id=
IdentityInfo
| summarize arg_max(TimeGenerated, *) by AccountUPN;
let signin=
SigninLogs
| where TimeGenerated > ago (14d)
| where RiskLevelDuringSignIn in ('high','medium')
| join kind=inner id on $left.UserPrincipalName == $right.AccountUPN
| extend SigninTime = TimeGenerated
| where RiskEventTypes_V2 != "[]";
AADUserRiskEvents
| where TimeGenerated > ago (14d)
| extend RiskTime = TimeGenerated
| where DetectionTimingType == "realtime"
| where RiskDetail !has "aiConfirmedSigninSafe"
| join kind=inner signin on CorrelationId
| where AssignedRoles != "[]"
| extend TimeDelta = abs(SigninTime - RiskTime)
| project SigninTime, UserPrincipalName, RiskTime, TimeDelta, RiskEventTypes, RiskLevelDuringSignIn, City, Country, EmployeeId, AssignedRoles

This kind of combination of attributes – realtime risk which is either medium or high, which Microsoft has not confirmed as safe and the user has an Azure AD role assigned may warrant a faster response from you or your team.

4 thoughts on “Streaming Azure AD risk events to Azure Sentinel

  1. Thankyou for your great Blog! Is it possible to add the following things in the query:

    – Confirm sign-in (safe)
    – Dismiss user risk

    So if we fixed the Risky sign-in or the risk status, that it will be visible at the dashboard of Sentinel?

    let signin=
    SigninLogs
    | where TimeGenerated > ago(24h)
    | extend SigninTime = TimeGenerated
    | where RiskEventTypes_V2 != “[]”;
    AADUserRiskEvents
    | where TimeGenerated > ago(24h)
    | extend RiskTime = TimeGenerated
    | join kind=inner signin on CorrelationId
    | extend TimeDelta = abs(SigninTime – RiskTime)
    | project UserPrincipalName, AppDisplayName, DetectionTimingType, SigninTime, RiskTime, TimeDelta, RiskLevelDuringSignIn, Source, RiskEventType

    Like

    1. Thanks for reading! So for SigninLogs and AADUserRiskEvents, they are just firing each time something happens, so we just get a log for each sign in and each risky sign in. You can definitely bring back risk state in the above query.

      let signin=
      SigninLogs
      | where TimeGenerated > ago(24h)
      | extend SigninTime = TimeGenerated
      | where RiskEventTypes_V2 != ‘[]’;
      AADUserRiskEvents
      | where TimeGenerated > ago(24h)
      | extend RiskTime = TimeGenerated
      | join kind=inner signin on CorrelationId
      | extend TimeDelta = abs(SigninTime – RiskTime)
      | project UserPrincipalName, AppDisplayName, DetectionTimingType, SigninTime, RiskTime, TimeDelta, RiskLevelDuringSignIn, Source, RiskEventType, RiskState

      But given you are looking up a table of risky events, it is always going to say atRisk, just the nature of that table. You also have access to the AADRiskyUsers table which shows the state of a user and how their risk has changed (maybe it was low, then medium, then remediated), so you can combine that table with the above query to bring back the users ‘current state’. Use arg_max to get the most recent record from that table for each user, and join it to the query above, something like this

      let riskyevent=
      SigninLogs
      | where TimeGenerated > ago(24h)
      | extend SigninTime = TimeGenerated
      | where RiskEventTypes_V2 != ‘[]’
      | join kind=inner (
      AADUserRiskEvents
      | where TimeGenerated > ago(24h)
      | extend RiskTime = TimeGenerated
      ) on CorrelationId
      | project UserPrincipalName, DetectionTimingType, RiskLevelDuringSignIn;
      AADRiskyUsers
      | summarize arg_max(TimeGenerated, *) by UserPrincipalName
      | join kind=inner riskyevent on UserPrincipalName
      | extend CurrentRiskState = RiskState
      | project UserPrincipalName, DetectionTimingType, RiskLevelDuringSignIn, CurrentRiskState

      Hope that makes sense!

      Like

  2. Great, thanks a lot for your update. We can use it for our monitoring.
    Did you have any idea how to add a location of the RiskySignIn? We have now added already the ip-adress but we are stucked at the location. (You can see that in the RiskySignIn under Identity Protection)

    Like

    1. Yep you can grab the location & IP from the sign in logs, try this

      let riskyevent=
      SigninLogs
      | where TimeGenerated > ago(24h)
      | extend SigninTime = TimeGenerated
      | where RiskEventTypes_V2 != “[]”
      | join kind=inner (
      AADUserRiskEvents
      | where TimeGenerated > ago(24h)
      | extend RiskTime = TimeGenerated
      ) on CorrelationId
      | project UserPrincipalName, DetectionTimingType, RiskLevelDuringSignIn, Location, IPAddress;
      AADRiskyUsers
      | summarize arg_max(TimeGenerated, *) by UserPrincipalName
      | join kind=inner riskyevent on UserPrincipalName
      | extend CurrentRiskState = RiskState
      | project UserPrincipalName, DetectionTimingType, RiskLevelDuringSignIn, CurrentRiskState, Location, IPAddress

      Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s