Query Details

User Risk Event Correlation With Historical Baseline And Account Age

Query

# *User Risk Event Correlation with Historical Baseline and Account Age*

## Query Information

#### MITRE ATT&CK Technique(s)

| Technique ID | Title    | Link    |
| ---  | --- | --- |
| T1078 | Valid Accounts | https://attack.mitre.org/techniques/T1078 |

#### Description

This rule detects potentially compromised accounts by identifying logins from new, previously unseen geographic locations (city or country) or new User-Agent strings. It establishes a 29-day baseline of known behaviors for each user and flags sign-in sessions that deviate from this historical pattern. It considers account age to reduce noise from new user onboarding.


#### Author <Optional>
- **Name: Benjamin Zulliger**
- **Github: https://github.com/benscha/KQLAdvancedHunting**
- **LinkedIn: https://www.linkedin.com/in/benjamin-zulliger/**

#### Possible false positives
- Users traveling to new locations.
- Users updating/changing browsers or operating systems.


## Defender XDR
```KQL
// New UserAgents / Locations per login session – 29-day baseline + account age
// Aggregated per (User, CorrelationId): collapses Risk-Event-Lifecycle & Sub-Events
// Note on Results: Boolean fields (e.g., AnyUANew, HasBaseline, IsNewAccount) 
// will render as 1 for True and 0 for False in the results table.
let lookback	 = 4h;
let historyStart = ago(29d);
let historyEnd   = ago(lookback);
let newAccountWindow = 60d;
let NormalizeUA = (ua:string) {
	tostring(
		replace_regex(
			replace_regex(ua, @'\d+\.\d+\.\d+\.\d+', "x.x.x.x"),
			@';\s*WebView/[\d\.]+', ""
		)
	)
};
let AffectedUsers = materialize(
	AADUserRiskEvents
	| where TimeGenerated >= ago(lookback)
	| where isnotempty(UserPrincipalName)
	| distinct UserPrincipalName
  );
let RiskEvents =
	AADUserRiskEvents
	| where TimeGenerated >= ago(lookback)
    | where RiskState !in ("remediated", "dismissed","confirmedCompromised")
	| extend _loc = parse_json(Location)
	| extend UserAgent = extract(@'"Key"\s*:\s*"userAgent"\s*,\s*"Value"\s*:\s*"([^"]+)"', 1, tostring(AdditionalInfo))
	| extend City    = tolower(trim(@'\s+', tostring(_loc.city))),
			 Country = toupper(tostring(_loc.countryOrRegion)),
			 State   = tostring(_loc.state)
	| extend UANorm  = NormalizeUA(UserAgent),
			 CityKey = strcat(tolower(trim(@'\s+', tostring(_loc.city))), "|", toupper(tostring(_loc.countryOrRegion))),
			 RiskLevelRank = case(RiskLevel =~ "high", 100, RiskLevel =~ "medium", 50, RiskLevel =~ "low", 10, 0)
	| where isnotempty(UserPrincipalName)
	| extend _upn = tolower(UserPrincipalName),
			 SessionId = iff(isnotempty(CorrelationId), CorrelationId, strcat("nocorr:", tostring(bin(TimeGenerated, 10m))))
	| project _upn, SessionId, CorrelationId, TimeGenerated, UserPrincipalName, IpAddress,
			  UserAgent, UANorm, City, Country, State, CityKey,
			  RiskEventType, RiskLevel, RiskLevelRank, RiskState, RiskDetail, Source, RiskEventId = Id;
let Baseline =
	EntraIdSignInEvents
	| where Timestamp between (historyStart .. historyEnd)
	| where AccountUpn in~ (AffectedUsers)
	| where ErrorCode == 0
	| extend UANorm     = NormalizeUA(UserAgent),
			 CityKey    = strcat(tolower(trim(@'\s+', City)), "|", toupper(Country)),
			 CountryKey = toupper(Country)
	| summarize
		KnownUAs        = make_set_if(UANorm, isnotempty(UANorm), 2000),
		KnownCities     = make_set_if(CityKey, isnotempty(City), 1000),
		KnownCountries  = make_set_if(CountryKey, isnotempty(Country), 250),
		BaselineSignins = count()
		by AccountUpn
	| extend _upn = tolower(AccountUpn);
let AccountAge = materialize(
	IdentityInfo
	| where AccountUpn in~ (AffectedUsers)
	| where isnotempty(CreatedDateTime)
	| summarize arg_max(Timestamp, CreatedDateTime) by AccountUpn
	| extend _upn = tolower(AccountUpn)
	| project _upn, AccountCreatedDateTime = CreatedDateTime
  );
RiskEvents
| join kind=leftouter hint.strategy=broadcast (Baseline) on _upn
| join kind=leftouter hint.strategy=broadcast (AccountAge) on _upn
| extend KnownUAs        = coalesce(KnownUAs, dynamic([])),
		 KnownCities     = coalesce(KnownCities, dynamic([])),
		 KnownCountries  = coalesce(KnownCountries, dynamic([])),
		 BaselineSignins = coalesce(BaselineSignins, 0)
| extend HasBaseline    = BaselineSignins > 0
| extend AccountAgeDays = iff(isnotempty(AccountCreatedDateTime), datetime_diff('day', now(), AccountCreatedDateTime), long(null))
| extend IsNewAccount   = iff(isnotempty(AccountCreatedDateTime), AccountCreatedDateTime >= ago(newAccountWindow), bool(null))
| extend UAIsNew        = iff(isempty(UANorm),  bool(null), not(set_has_element(KnownUAs, UANorm)))
| extend CountryIsNew   = iff(isempty(Country), bool(null), not(set_has_element(KnownCountries, Country)))
| extend CityIsNew      = iff(isempty(City),    bool(null), not(set_has_element(KnownCities, CityKey)))
// --- Aggregation pro Session: ein Datensatz statt n State-Snapshots ---
| summarize
	FirstSeen          = min(TimeGenerated),
	arg_max(TimeGenerated, RiskState, RiskDetail),
	DistinctRiskEvents = dcount(RiskEventId),
	RawSnapshots       = count(),
	MaxRiskRank        = max(RiskLevelRank),
	UAs                = make_set(UANorm, 25),
	NewUAs             = make_set_if(UANorm, UAIsNew == true, 25),
	IPs                = make_set(IpAddress, 25),
	Cities             = make_set(City, 25),
	Countries          = make_set(Country, 25),
	NewCities          = make_set_if(CityKey, CityIsNew == true, 25),
	NewCountries       = make_set_if(Country, CountryIsNew == true, 25),
	RiskEventTypes     = make_set(RiskEventType, 15),
	RiskStates         = make_set(RiskState, 15),
	HasBaseline        = take_any(HasBaseline),
	BaselineSignins    = take_any(BaselineSignins),
	IsNewAccount       = take_any(IsNewAccount),
	AccountAgeDays     = take_any(AccountAgeDays),
	AccountCreatedDateTime = take_any(AccountCreatedDateTime),
	KnownCountries     = take_any(KnownCountries)
	by UserPrincipalName, SessionId, CorrelationId
| project-rename LastSeen = TimeGenerated
| extend AnyUANew       = array_length(NewUAs) > 0,
		 AnyCountryNew  = array_length(NewCountries) > 0,
		 AnyCityNew     = array_length(NewCities) > 0
| extend AnyLocationNew = AnyCountryNew or AnyCityNew
| extend MaxRiskLevel   = case(MaxRiskRank >= 100, "high", MaxRiskRank >= 50, "medium", MaxRiskRank >= 10, "low", "none")
| extend Verdict = case(
	not(HasBaseline) and IsNewAccount == true,	"Review - neuer Account (<60d), Baseline-Lücke plausibel",
	not(HasBaseline) and IsNewAccount == false,	"Review - Account >60d ohne Baseline (verdächtig)",
	not(HasBaseline),							"Review - keine Baseline, Account-Alter unbekannt",
	AnyUANew and AnyCountryNew,					"High - neuer UA + neues Land",
	AnyUANew and AnyCityNew,					"High - neuer UA + neue Stadt",
	AnyCountryNew,								"Medium - neues Land",
	AnyUANew,									"Medium - neuer UserAgent",
	AnyCityNew,									"Low - neue Stadt (gleiches Land)",
												"Info - UA & Location bekannt"
  )
| sort by AnyUANew desc, AnyCountryNew desc, MaxRiskRank desc, LastSeen desc
| project FirstSeen, LastSeen, Verdict, UserPrincipalName,
		  AnyUANew, AnyCityNew, AnyCountryNew, AnyLocationNew, HasBaseline,
		  IsNewAccount, AccountAgeDays, AccountCreatedDateTime,
		  MaxRiskLevel, RiskState, RiskDetail, DistinctRiskEvents, RawSnapshots,
		  NewUAs, UAs, NewCountries, NewCities, Countries, Cities, IPs,
		  RiskEventTypes, RiskStates, BaselineSignins, KnownCountries,
		  CorrelationId, SessionId
//| where AnyUANew or AnyLocationNew or not(HasBaseline)
```

Explanation

This query is designed to detect potentially compromised user accounts by analyzing login patterns and comparing them to historical behavior. Here's a simplified breakdown of what the query does:

  1. Objective: The query aims to identify unusual login activities that might indicate a compromised account. It does this by checking for logins from new geographic locations or using new User-Agent strings that haven't been seen in the past 29 days.

  2. Baseline Establishment: For each user, the query establishes a 29-day baseline of known login behaviors, including familiar User-Agent strings and geographic locations.

  3. Account Age Consideration: The query takes into account the age of the account to reduce false positives. New accounts (less than 60 days old) might naturally have less historical data.

  4. Risk Event Analysis: It analyzes recent risk events, excluding those that have been remediated or dismissed, and checks if the login details (like location and User-Agent) deviate from the established baseline.

  5. Data Aggregation: The query aggregates data per user and session, summarizing the number of distinct risk events, the maximum risk level, and whether new User-Agent strings or locations were detected.

  6. Verdict Assignment: Based on the analysis, the query assigns a verdict to each session, categorizing it as high, medium, low, or informational risk. For example, a session with a new User-Agent and a new country is flagged as high risk.

  7. Sorting and Output: The results are sorted by the presence of new User-Agent strings, new locations, and risk level, providing a prioritized list of sessions that may require further investigation.

In summary, this query helps security teams identify potentially compromised accounts by flagging unusual login activities that deviate from a user's typical behavior, while also considering the account's age to minimize false alarms.

Details

Benjamin Zulliger profile picture

Benjamin Zulliger

Released: June 25, 2026

Tables

AADUserRiskEventsEntraIdSignInEventsIdentityInfo

Keywords

UserRiskEventAccountAgeLocationUserAgentSessionRiskLevelCountryCityIPAddressBaselineSignInAccountCreatedDateTimeRiskStateRiskDetailCorrelationIdSessionId

Operators

letagotostringreplace_regexmaterializewhereisnotemptydistinctparse_jsonextracttolowertrimtouppercaseiffstrcatbinprojectbetweenin~summarizemake_set_ifcountarg_maxcoalescedatetime_diffboolisemptynotset_has_elementmindcountmake_settake_anybyproject-renamearray_lengthorsortdesc

Actions