Query Details
// Rule : Workload Identity - SP Credential Modified by Risky User with Concurrent M365 Activity
// Severity: High
// Tactics : Persistence, CredentialAccess, Collection
// MITRE : T1098.001 (Additional Cloud Credentials), T1078.004 (Valid Accounts: Cloud Accounts), T1530
// Freq : PT1H Period: PT1H
// Tables : AADRiskyUsers, AuditLogs, OfficeActivity
// Built-in differentiation: Sentinel built-ins for credential changes do not correlate the
// initiating user's current Identity Protection risk state, nor do they cross-reference
// concurrent M365 data access by the same user. This rule combines all three signals:
// active risk state + SP credential change + concurrent OfficeActivity, confirming that
// the compromised account is simultaneously staging data while installing persistent access.
//==========================================================================================
// A user flagged in Entra Identity Protection modifying SP credentials is the canonical
// lateral movement pivot. Adding OfficeActivity correlation reveals whether the attacker
// is also performing M365 data operations (Exchange mail access, SharePoint downloads,
// Teams channel reads) in the same window — the compound signal separates active
// multi-vector exploitation from isolated audit noise.
// ---- Network Allowlist (exclude trusted IPs / CIDR / ranges) --------------
let _allow = materialize(union isfuzzy=true (print R="" | take 0), (_GetWatchlist('NetworkAllowlist') | project R = tostring(IPOrRange)) | where isnotempty(R));
let _allowCIDR = toscalar(_allow | where not(R matches regex @'^\d+\.\d+\.\d+\.\d+-\d+\.\d+\.\d+\.\d+$') | extend R = iff(R has '/', R, strcat(R, '/32')) | summarize make_list(R));
let _allowRange = toscalar(_allow | where R matches regex @'^\d+\.\d+\.\d+\.\d+-\d+\.\d+\.\d+\.\d+$' | summarize make_list(R));
let _ExcludeAllowlistedIPs = (T:(IPAddress:string)) {
T
| extend IPAddress = tostring(IPAddress)
| where array_length(_allowCIDR) == 0 or isnull(ipv4_is_in_any_range(IPAddress, _allowCIDR)) or not(ipv4_is_in_any_range(IPAddress, _allowCIDR))
| mv-apply _r = _allowRange to typeof(string) on (
extend _lo = tostring(split(_r,'-')[0]), _hi = tostring(split(_r,'-')[1])
| extend _inRange = ipv4_compare(IPAddress, _lo) >= 0 and ipv4_compare(IPAddress, _hi) <= 0
| summarize _anyInRange = max(toint(_inRange)))
| where isnull(_anyInRange) or _anyInRange == 0
| project-away _anyInRange
};
// ---------------------------------------------------------------------------
let LookbackWindow = 1h;
let CredentialOps = dynamic([
"Add service principal credentials",
"Remove service principal credentials",
"Add password to service principal",
"Add key credential to service principal",
"Remove password from service principal",
"Remove key credential from service principal",
"Add owner to service principal",
"Add owner to application",
"Update application \u2013 Certificates and secrets management (by service)"
]);
// Active risky users — risk not yet dismissed or remediated
let RiskyUsers = union isfuzzy=true
AADRiskyUsers,
(datatable(Id:string, UserPrincipalName:string, RiskLevel:string,
RiskState:string, RiskDetail:string,
RiskLastUpdatedDateTime:datetime)[])
| where RiskState in ("atRisk", "confirmedCompromised")
| where RiskLevel in ("high", "medium")
| project UserId = Id, UserPrincipalName, RiskLevel, RiskState, RiskDetail;
// SP credential changes by risky users
let CredChanges = AuditLogs
| where TimeGenerated > ago(LookbackWindow)
| where OperationName has_any (CredentialOps)
| where Result =~ "success"
| where isnotempty(tostring(InitiatedBy.user.id))
| extend InitiatorUserId = tostring(InitiatedBy.user.id)
| extend InitiatorUPN = tostring(InitiatedBy.user.userPrincipalName)
| extend InitiatorIP = tostring(InitiatedBy.user.ipAddress)
| extend TargetId = tostring(TargetResources[0].id)
| extend TargetName = tostring(TargetResources[0].displayName)
| join kind=inner RiskyUsers on $left.InitiatorUserId == $right.UserId
| summarize
EventCount = count(),
Operations = make_set(OperationName, 10),
TargetSPs = make_set(TargetName, 10),
TargetSPIds = make_set(TargetId, 10),
InitiatorIPs = make_set(InitiatorIP, 5),
RiskLevel = any(RiskLevel),
RiskState = any(RiskState),
RiskDetail = any(RiskDetail),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by InitiatorUserId, InitiatorUPN;
// Concurrent OfficeActivity from the same risky user — M365 data plane impact
let M365Activity = OfficeActivity
| where TimeGenerated > ago(LookbackWindow)
| where isnotempty(UserId)
| extend UserIdLower = tolower(UserId)
| summarize
M365OperationCount = count(),
M365Operations = make_set(Operation, 10),
M365Workloads = make_set(OfficeWorkload, 5),
M365ResourceCount = dcount(OfficeObjectId),
M365Resources = make_set(OfficeObjectId, 5),
M365ClientIPs = make_set(ClientIP, 5)
by UserIdLower;
CredChanges
| extend UserIdLower = tolower(InitiatorUPN)
| join kind=leftouter M365Activity on UserIdLower
| extend AlertSeverity = case(
RiskState == "confirmedCompromised" and M365OperationCount > 0, "Critical",
RiskState == "confirmedCompromised", "High",
M365OperationCount > 0, "High",
"Medium")
| project
InitiatorUPN, InitiatorUserId, RiskLevel, RiskState, RiskDetail,
EventCount, Operations, TargetSPs, TargetSPIds, InitiatorIPs,
M365OperationCount, M365Operations, M365Workloads,
M365ResourceCount, M365Resources, M365ClientIPs,
AlertSeverity, FirstSeen, LastSeen
| order by AlertSeverity asc, EventCount desc
This KQL query is designed to detect suspicious activities involving risky users in a Microsoft environment. Here's a simple breakdown of what the query does:
Purpose: The query identifies risky users who have modified service principal (SP) credentials and checks if these users are also performing activities in Microsoft 365 (M365) services like Exchange, SharePoint, or Teams during the same time period. This helps to detect potential multi-vector attacks where a compromised account is used to both change credentials and access data.
Components:
Risky Users: It identifies users flagged as risky by Entra Identity Protection, focusing on those with high or medium risk levels that are not yet resolved.
Credential Changes: It checks the audit logs for successful SP credential changes initiated by these risky users.
M365 Activity: It examines activities in M365 services by the same users to see if they are accessing or manipulating data concurrently.
Alert Severity: The query assigns a severity level to each detected incident based on the risk state and the presence of concurrent M365 activity. The severity can be Critical, High, or Medium.
Output: The query produces a list of incidents with details such as the user's ID, risk level, operations performed, affected SPs, and M365 activities. The results are sorted by alert severity and the number of events.
In summary, this query helps security teams identify and prioritize potential security incidents involving risky users who are modifying credentials and accessing M365 data, indicating possible malicious intent.

David Alonso
Released: April 21, 2026
Tables
Keywords
Operators