Detection rules › Kusto
Microsoft Entra ID Rare UserAgent App Sign-in
This query establishes a baseline of the type of UserAgent (i.e. browser, office application, etc) that is typically used for a particular application by looking back for a number of days. It then searches the current day for any deviations from this pattern, i.e. types of UserAgents not seen before in combination with this application.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Stealth |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| Azure | Success: Successful sign-in |
| Entra ID | SigninLogs sign-in event: Successful sign-in |
Rules detecting the same action
These rules filter on the same operation.
- [Entra ID] Suspicious Continuous OAuth Token Usage (Kusto)
- Anomalous sign-in location by user account and authenticating application (Kusto)
- Anomalous Single Factor Signin (Kusto)
- Authentications of Privileged Accounts Outside of Expected Controls (Kusto)
- Azure Portal sign in from another Azure Tenant (Kusto)
- Azure Service Principal Sign-In Followed by Arc Cluster Credential Access (Elastic)
- Azure SignIn via Legacy Authentication Protocol (Panther)
- Cisco - firewall block but success logon to Microsoft Entra ID (Kusto)
Rule body
id: 87d5cd18-211d-4fd4-9b86-65d23fed87ea
name: Microsoft Entra ID Rare UserAgent App Sign-in
description: |
This query establishes a baseline of the type of UserAgent (i.e. browser, office application, etc) that is typically used for a particular application by looking back for a number of days.
It then searches the current day for any deviations from this pattern, i.e. types of UserAgents not seen before in combination with this application.
severity: Medium
status: Available
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: AzureActiveDirectory
dataTypes:
- AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 0
tactics:
- DefenseEvasion
relevantTechniques:
- T1036
query: |
let minimumAppThreshold = 100;
let timeframe = 1d;
let lookback_timeframe= 7d;
let ExtractBrowserTypeFromUA=(ua:string) {
// Note: these are in a specific order since, for example, Edge contains "Chrome/" and "Edge/" strings.
case(
ua has "Edge/", dynamic({"AgentType": "Browser", "AgentName": "Edge"}),
ua has "Edg/", dynamic({"AgentType": "Browser", "AgentName": "Edge"}),
ua has "Trident/", dynamic({"AgentType": "Browser", "AgentName": "Internet Explorer"}),
ua has "Chrome/" and ua has "Safari/", dynamic({"AgentType": "Browser", "AgentName": "Chrome"}),
ua has "Gecko/" and ua has "Firefox/", dynamic({"AgentType": "Browser", "AgentName": "Firefox"}),
not(ua has "Mobile/") and ua has "Safari/" and ua has "Version/", dynamic({"AgentType": "Browser", "AgentName": "Safari"}),
ua startswith "Dalvik/" and ua has "Android", dynamic({"AgentType": "Browser", "AgentName": "Android Browser"}),
ua startswith "MobileSafari//", dynamic({"AgentType": "Browser", "AgentName": "Mobile Safari"}),
ua has "Mobile/" and ua has "Safari/" and ua has "Version/", dynamic({"AgentType": "Browser", "AgentName": "Mobile Safari"}),
ua has "Mobile/" and ua has "FxiOS/", dynamic({"AgentType": "Browser", "AgentName": "IOS Firefox"}),
ua has "Mobile/" and ua has "CriOS/", dynamic({"AgentType": "Browser", "AgentName": "IOS Chrome"}),
ua has "Mobile/" and ua has "WebKit/", dynamic({"AgentType": "Browser", "AgentName": "Mobile Webkit"}),
//
ua startswith "Excel/", dynamic({"AgentType": "OfficeApp", "AgentName": "Excel"}),
ua startswith "Outlook/", dynamic({"AgentType": "OfficeApp", "AgentName": "Outlook"}),
ua startswith "OneDrive/", dynamic({"AgentType": "OfficeApp", "AgentName": "OneDrive"}),
ua startswith "OneNote/", dynamic({"AgentType": "OfficeApp", "AgentName": "OneNote"}),
ua startswith "Office/", dynamic({"AgentType": "OfficeApp", "AgentName": "Office"}),
ua startswith "PowerPoint/", dynamic({"AgentType": "OfficeApp", "AgentName": "PowerPoint"}),
ua startswith "PowerApps/", dynamic({"AgentType": "OfficeApp", "AgentName": "PowerApps"}),
ua startswith "SharePoint/", dynamic({"AgentType": "OfficeApp", "AgentName": "SharePoint"}),
ua startswith "Word/", dynamic({"AgentType": "OfficeApp", "AgentName": "Word"}),
ua startswith "Visio/", dynamic({"AgentType": "OfficeApp", "AgentName": "Visio"}),
ua startswith "Whiteboard/", dynamic({"AgentType": "OfficeApp", "AgentName": "Whiteboard"}),
ua =~ "Mozilla/5.0 (compatible; MSAL 1.0)", dynamic({"AgentType": "OfficeApp", "AgentName": "Office Telemetry"}),
//
ua has ".NET CLR", dynamic({"AgentType": "Custom", "AgentName": "Dotnet"}),
ua startswith "Java/", dynamic({"AgentType": "Custom", "AgentName": "Java"}),
ua startswith "okhttp/", dynamic({"AgentType": "Custom", "AgentName": "okhttp"}),
ua has "Drupal/", dynamic({"AgentType": "Custom", "AgentName": "Drupal"}),
ua has "PHP/", dynamic({"AgentType": "Custom", "AgentName": "PHP"}),
ua startswith "curl/", dynamic({"AgentType": "Custom", "AgentName": "curl"}),
ua has "python-requests", dynamic({"AgentType": "Custom", "AgentName": "Python"}),
pack("AgentType","Other","AgentName", extract(@"^([^/]*)/",1,ua))
)
};
// Query to obtain 'simplified' user agents in a given timespan.
let QueryUserAgents = (start_time:timespan, end_time:timespan) {
union withsource=tbl_name AADNonInteractiveUserSignInLogs, SigninLogs
| where TimeGenerated >= ago(start_time)
| where TimeGenerated < ago(end_time)
| where ResultType == 0 // Only look at succesful logins
| extend ParsedUserAgent=ExtractBrowserTypeFromUA(UserAgent)
| extend UserAgentType=tostring(ParsedUserAgent.AgentType)
| extend UserAgentName=tostring(ParsedUserAgent.AgentName)
//| extend SimpleUserAgent=strcat(UserAgentType,"_",UserAgentName)
| extend SimpleUserAgent=UserAgentType
| where not(isempty(UserAgent))
| where not(isempty(AppId))
};
// Get baseline usage per application.
let BaselineUserAgents=materialize(
QueryUserAgents(lookback_timeframe+timeframe, timeframe)
| summarize RequestCount=count() by AppId, AppDisplayName, SimpleUserAgent
);
let BaselineSummarizedAgents=(
BaselineUserAgents
| summarize BaselineUAs=make_set(SimpleUserAgent),BaselineRequestCount=sum(RequestCount) by AppId, AppDisplayName
);
QueryUserAgents(timeframe, 0d)
| summarize count() by AppId, AppDisplayName, UserAgent, SimpleUserAgent
| join kind=leftanti BaselineUserAgents on AppId, AppDisplayName, SimpleUserAgent
| join BaselineSummarizedAgents on AppId, AppDisplayName
| where BaselineRequestCount > minimumAppThreshold // Search only for actively used applications.
// Get back full original requests.
| join QueryUserAgents(timeframe, 0d) on AppId, UserAgent
| project-away ParsedUserAgent, UserAgentName
| project-reorder TimeGenerated, AppDisplayName, UserPrincipalName, UserAgent, BaselineUAs
// Begin allow-list.
// End allow-list.
| summarize count() by UserPrincipalName, AppDisplayName, AppId, UserAgentType, SimpleUserAgent, UserAgent
entityMappings:
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: UserPrincipalName
version: 1.0.1
kind: Scheduled
Stages and Predicates
Parameters
let minimumAppThreshold = 100;
let timeframe = 1d;
let lookback_timeframe = 7d;
let QueryUserAgents is inlined into the numbered stages below.
Let binding: ExtractBrowserTypeFromUA
let ExtractBrowserTypeFromUA = (ua:string) {
case(
ua has "Edge/", dynamic({"AgentType": "Browser", "AgentName": "Edge"}),
ua has "Edg/", dynamic({"AgentType": "Browser", "AgentName": "Edge"}),
ua has "Trident/", dynamic({"AgentType": "Browser", "AgentName": "Internet Explorer"}),
ua has "Chrome/" and ua has "Safari/", dynamic({"AgentType": "Browser", "AgentName": "Chrome"}),
ua has "Gecko/" and ua has "Firefox/", dynamic({"AgentType": "Browser", "AgentName": "Firefox"}),
not(ua has "Mobile/") and ua has "Safari/" and ua has "Version/", dynamic({"AgentType": "Browser", "AgentName": "Safari"}),
ua startswith "Dalvik/" and ua has "Android", dynamic({"AgentType": "Browser", "AgentName": "Android Browser"}),
ua startswith "MobileSafari//", dynamic({"AgentType": "Browser", "AgentName": "Mobile Safari"}),
ua has "Mobile/" and ua has "Safari/" and ua has "Version/", dynamic({"AgentType": "Browser", "AgentName": "Mobile Safari"}),
ua has "Mobile/" and ua has "FxiOS/", dynamic({"AgentType": "Browser", "AgentName": "IOS Firefox"}),
ua has "Mobile/" and ua has "CriOS/", dynamic({"AgentType": "Browser", "AgentName": "IOS Chrome"}),
ua has "Mobile/" and ua has "WebKit/", dynamic({"AgentType": "Browser", "AgentName": "Mobile Webkit"}),
ua startswith "Excel/", dynamic({"AgentType": "OfficeApp", "AgentName": "Excel"}),
ua startswith "Outlook/", dynamic({"AgentType": "OfficeApp", "AgentName": "Outlook"}),
ua startswith "OneDrive/", dynamic({"AgentType": "OfficeApp", "AgentName": "OneDrive"}),
ua startswith "OneNote/", dynamic({"AgentType": "OfficeApp", "AgentName": "OneNote"}),
ua startswith "Office/", dynamic({"AgentType": "OfficeApp", "AgentName": "Office"}),
ua startswith "PowerPoint/", dynamic({"AgentType": "OfficeApp", "AgentName": "PowerPoint"}),
ua startswith "PowerApps/", dynamic({"AgentType": "OfficeApp", "AgentName": "PowerApps"}),
ua startswith "SharePoint/", dynamic({"AgentType": "OfficeApp", "AgentName": "SharePoint"}),
ua startswith "Word/", dynamic({"AgentType": "OfficeApp", "AgentName": "Word"}),
ua startswith "Visio/", dynamic({"AgentType": "OfficeApp", "AgentName": "Visio"}),
ua startswith "Whiteboard/", dynamic({"AgentType": "OfficeApp", "AgentName": "Whiteboard"}),
ua =~ "Mozilla/5.0 (compatible; MSAL 1.0)", dynamic({"AgentType": "OfficeApp", "AgentName": "Office Telemetry"}),
ua has ".NET CLR", dynamic({"AgentType": "Custom", "AgentName": "Dotnet"}),
ua startswith "Java/", dynamic({"AgentType": "Custom", "AgentName": "Java"}),
ua startswith "okhttp/", dynamic({"AgentType": "Custom", "AgentName": "okhttp"}),
ua has "Drupal/", dynamic({"AgentType": "Custom", "AgentName": "Drupal"}),
ua has "PHP/", dynamic({"AgentType": "Custom", "AgentName": "PHP"}),
ua startswith "curl/", dynamic({"AgentType": "Custom", "AgentName": "curl"}),
ua has "python-requests", dynamic({"AgentType": "Custom", "AgentName": "Python"}),
pack("AgentType","Other","AgentName", extract(@"^([^/]*)/",1,ua))
)
};
Let binding: BaselineUserAgents
let BaselineUserAgents = materialize(
QueryUserAgents(lookback_timeframe+timeframe, timeframe)
| summarize RequestCount=count() by AppId, AppDisplayName, SimpleUserAgent
);
Let binding: BaselineSummarizedAgents
let BaselineSummarizedAgents = (
BaselineUserAgents
| summarize BaselineUAs=make_set(SimpleUserAgent),BaselineRequestCount=sum(RequestCount) by AppId, AppDisplayName
);
Stages 1 to 9 define let QueryUserAgents (the rule's main pipeline source); stages 10 to 17 run on it.
Stage 1: union
union withsource=tbl_name AADNonInteractiveUserSignInLogs, SigninLogs
Stage 2: source
AADNonInteractiveUserSignInLogs
Stage 3: source
SigninLogs
Stage 4: where
| where TimeGenerated >= ago(start_time)
Stage 5: where
| where TimeGenerated < ago(end_time)
Stage 6: where
| where ResultType == 0
Stage 7: extend (4 consecutive steps)
| extend ParsedUserAgent=ExtractBrowserTypeFromUA(UserAgent)
| extend UserAgentType=tostring(ParsedUserAgent.AgentType)
| extend UserAgentName=tostring(ParsedUserAgent.AgentName)
| extend SimpleUserAgent=UserAgentType
Stage 8: where
| where not(isempty(UserAgent))
Stage 9: where
| where not(isempty(AppId))
Stage 10: summarize
QueryUserAgents
| summarize count() by AppId, AppDisplayName, UserAgent, SimpleUserAgent
Stage 11: join (negated)
| join kind=leftanti BaselineUserAgents on AppId, AppDisplayName, SimpleUserAgent
Stage 12: join
| join BaselineSummarizedAgents on AppId, AppDisplayName
Stage 13: where
| where BaselineRequestCount > minimumAppThreshold
Stage 14: join
| join QueryUserAgents(timeframe, 0d) on AppId, UserAgent
Stage 15: project-away
| project-away ParsedUserAgent, UserAgentName
Stage 16: project-reorder
| project-reorder TimeGenerated, AppDisplayName, UserPrincipalName, UserAgent, BaselineUAs
Stage 17: summarize
| summarize count() by UserPrincipalName, AppDisplayName, AppId, UserAgentType, SimpleUserAgent, UserAgent
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
UserAgent | is_null | excludes:UserAgent | |
AppId | is_null | excludes:AppId |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
BaselineRequestCount | gt |
| field:"BaselineRequestCount" kind:gt value:"100" |
ResultType | eq |
| field:"ResultType" kind:eq value:"0" |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
AppDisplayName | summarize |
AppId | summarize |
SimpleUserAgent | summarize |
UserAgent | summarize |
UserAgentType | summarize |
UserPrincipalName | summarize |