Detection rules › Kusto

Anomalous sign-in location by user account and authenticating application

Status
available
Severity
medium
Time window
7d
Group by
AppDisplayName, AppId, CountOfLocations, TimeGenerated, UserId, UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

'This query over Microsoft Entra ID sign-in considers all user sign-ins for each Microsoft Entra ID application and picks out the most anomalous change in location profile for a user within an individual application.

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1078 Valid Accounts

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: 7cb8f77d-c52f-4e46-b82f-3cf2e106224a
name: Anomalous sign-in location by user account and authenticating application
description: |
  'This query over Microsoft Entra ID sign-in considers all user sign-ins for each Microsoft Entra ID application and picks out the most anomalous change in location profile for a user within an individual application.
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - InitialAccess
relevantTechniques:
  - T1078
query: |
  // Adjust this figure to adjust how sensitive this detection is
  let sensitivity = 2.5;
  // Adjust this figure to set the value that defines the requested estimation accuracy. The default value is 1. Possible values are 0, 1, 2, 3, 4.
  let dcountAccuracy = 1;
  let AuthEvents = materialize(
  union isfuzzy=True SigninLogs, AADNonInteractiveUserSignInLogs
  | where TimeGenerated between (ago(7d) .. now())
  | where ResultType == 0
  | extend LocationDetails = LocationDetails_dynamic
  | extend Location = strcat(LocationDetails.countryOrRegion, "-", LocationDetails.state,"-", LocationDetails.city)
  | where Location != "--");
  AuthEvents
  | summarize dcount(Location, dcountAccuracy) by AppDisplayName, AppId, UserPrincipalName, UserId, bin(startofday(TimeGenerated), 1d)
  | where dcount_Location > 2
  | make-series CountOfLocations = sum(dcount_Location) on TimeGenerated  step 1d by AppId, UserId
  | extend (Anomalies, Score, Baseline) = series_decompose_anomalies(CountOfLocations, sensitivity, -1, 'linefit')
  | mv-expand CountOfLocations to typeof(double), TimeGenerated to typeof(datetime), Anomalies to typeof(double), Score to typeof(double), Baseline to typeof(long)
  | where Anomalies > 0 and Baseline > 0
  | join kind=inner( AuthEvents | extend TimeStamp = startofday(TimeGenerated)) on UserId, AppId
  | extend SignInDetails = bag_pack("TimeGenerated", TimeGenerated1, "Location", Location, "Source", IPAddress, "Device", DeviceDetail_dynamic)
  | summarize SignInDetailsSet=make_set(SignInDetails, 1000) by UserId, UserPrincipalName, CountOfLocations, TimeGenerated, AppId, AppDisplayName
  | extend Name = split(UserPrincipalName, "@")[0], UPNSuffix = split(UserPrincipalName, "@")[1]
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: UserId   
eventGroupingSettings:
  aggregationKind: SingleAlert
customDetails:
  Application: AppDisplayName
alertDetailsOverride:
  alertDisplayNameFormat: Anomalous sign-in location by {{UserPrincipalName}} to {{AppDisplayName}}
  alertDescriptionFormat: |
    This query over Microsoft Entra ID sign-in considers all user sign-ins for each Microsoft Entra ID application and picks out the most anomalous change in location profile for a user within an
    individual application. This has detected {{UserPrincipalName}} signing into {{AppDisplayName}} from {{CountOfLocations}} 
    different locations.
version: 2.0.5
kind: Scheduled

Stages and Predicates

Parameters

let sensitivity = 2.5;
let dcountAccuracy = 1;

union isfuzzy=True (2 sources)

Each leg below queries one source; the rule matches if any leg does. Sources: SigninLogs, AADNonInteractiveUserSignInLogs

Leg 1: SigninLogs

Leg 2: AADNonInteractiveUserSignInLogs

Applied to the combined result

| where TimeGenerated between (ago(7d) .. now()) | where ResultType == 0 | extend LocationDetails = LocationDetails_dynamic | extend Location = strcat(LocationDetails.countryOrRegion, "-", LocationDetails.state,"-", LocationDetails.city) | where Location != "--" | summarize dcount(Location, dcountAccuracy) by AppDisplayName, AppId, UserPrincipalName, UserId, bin(startofday(TimeGenerated), 1d) | where dcount_Location > 2 | make-series CountOfLocations = sum(dcount_Location) on TimeGenerated  step 1d by AppId, UserId | extend (Anomalies, Score, Baseline) = series_decompose_anomalies(CountOfLocations, sensitivity, -1, 'linefit') | mv-expand CountOfLocations to typeof(double), TimeGenerated to typeof(datetime), Anomalies to typeof(double), Score to typeof(double), Baseline to typeof(long) | where Anomalies > 0 and Baseline > 0 | join kind=inner( AuthEvents | extend TimeStamp = startofday(TimeGenerated)) on UserId, AppId | extend SignInDetails = bag_pack("TimeGenerated", TimeGenerated1, "Location", Location, "Source", IPAddress, "Device", DeviceDetail_dynamic) | summarize SignInDetailsSet=make_set(SignInDetails, 1000) by UserId, UserPrincipalName, CountOfLocations, TimeGenerated, AppId, AppDisplayName | extend Name = split(UserPrincipalName, "@")[0], UPNSuffix = split(UserPrincipalName, "@")[1]

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Anomaliesgt
  • 0 transforms: cased
Baselinegt
  • 0 transforms: cased
Locationne
  • -- transforms: cased
ResultTypeeq
  • 0 transforms: cased
dcount_Locationgt
  • 2 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AppDisplayNamesummarize
AppIdsummarize
CountOfLocationssummarize
SignInDetailsSetsummarize
TimeGeneratedsummarize
UserIdsummarize
UserPrincipalNamesummarize
Nameextend
UPNSuffixextend