Query Details

RULE 13 SP Stale Credential Activation

Query

// Rule    : Workload Identity - SP Stale/Dormant Credential Suddenly Activated
// Severity: High
// Tactics : Persistence, InitialAccess
// MITRE   : T1078.004 (Valid Accounts: Cloud Accounts), T1098.001
// Freq    : PT1H   Period: P90D
// NOT a Sentinel built-in: built-in rules track active SPs; this specifically detects
// a sign-in gap of 30+ days followed by sudden reactivation — the attacker dormant-access pattern.
//==========================================================================================
// Stale SPs with active credentials are a standing attack surface. Attackers obtain creds
// for an ownerless SP, wait for the initial suspicious-activity window to pass, then
// reactivate it. This rule fires at the moment of reactivation after a ≥30d dormancy.

// --- Last sign-in per SP in the prior 30d–90d window (dormancy baseline) ---
// ---- 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 LastKnownSignin = (AADServicePrincipalSignInLogs | invoke _ExcludeAllowlistedIPs())
    | where TimeGenerated between (ago(90d) .. ago(30d))
    | where ResultType == "0"
    | summarize LastSignin = max(TimeGenerated) by ServicePrincipalId, ServicePrincipalName, AppId;

// --- SPs that have NOT signed in during 0–30d (confirming dormancy) ---
let ActiveInLast30d = (AADServicePrincipalSignInLogs | invoke _ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(30d)
    | where ResultType == "0"
    | distinct ServicePrincipalId;

// --- SPs that were active 30–90d ago but dormant for the last 30d, NOW signing in this hour ---
let NowReactivated = (AADServicePrincipalSignInLogs | invoke _ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(1h)
    | where ResultType == "0"
    | where isnotempty(IPAddress)
    | extend GeoInfo    = geo_info_from_ip_address(IPAddress)
    | extend Country    = tostring(GeoInfo.country_iso_code)
    | extend City       = tostring(GeoInfo.city)
    | summarize
        ReactivationSignins  = count(),
        UniqueIPs            = dcount(IPAddress),
        IPList               = make_set(IPAddress, 5),
        Countries            = make_set(Country, 5),
        Resources            = make_set(ResourceDisplayName, 10),
        CredTypes            = make_set(ClientCredentialType, 5),
        FirstReactivation    = min(TimeGenerated)
        by ServicePrincipalId, ServicePrincipalName, AppId;

// --- Combine: was dormant, now active ---
NowReactivated
| join kind=inner LastKnownSignin on ServicePrincipalId
| join kind=leftanti ActiveInLast30d on ServicePrincipalId
| extend DaysDormant = datetime_diff("day", FirstReactivation, LastSignin)
// --- Correlate: were credentials added during dormancy? (staged backdoor) ---
| join kind=leftouter (
    AuditLogs
    | where TimeGenerated > ago(90d)
    | where OperationName has_any (
        "Add service principal credentials",
        "Update application – Certificates and secrets management")
    | where Result =~ "success"
    | extend SPId      = tostring(TargetResources[0].id)
    | extend Initiator = coalesce(tostring(InitiatedBy.user.userPrincipalName),
        tostring(InitiatedBy.app.displayName))
    | summarize
        CredChanges     = count(),
        CredInitiators  = make_set(Initiator, 5),
        LastCredChange  = max(TimeGenerated)
        by SPId
) on $left.ServicePrincipalId == $right.SPId
| extend CredAddedDuringDormancy = isnotempty(LastCredChange)
    and LastCredChange > LastSignin
    and LastCredChange < FirstReactivation
| extend RiskLevel = case(
    CredAddedDuringDormancy == true and DaysDormant > 60, "Critical",
    CredAddedDuringDormancy == true,                       "High",
    DaysDormant > 60,                                      "High",
    "Medium")
| project
    ServicePrincipalName, ServicePrincipalId, AppId,
    LastSignin, FirstReactivation, DaysDormant,
    ReactivationSignins, UniqueIPs, IPList, Countries, Resources, CredTypes,
    CredAddedDuringDormancy, CredChanges, CredInitiators, LastCredChange, RiskLevel

Explanation

This query is designed to detect suspicious activity involving service principals (SPs) in a cloud environment, specifically focusing on dormant or stale credentials that suddenly become active. Here's a simplified breakdown of what the query does:

  1. Purpose: The query identifies service principals that have been inactive for 30 or more days and then suddenly become active. This pattern can indicate potential unauthorized access or an attack.

  2. Severity and Tactics: The rule is marked with high severity and is associated with tactics like Persistence and Initial Access, as per the MITRE ATT&CK framework.

  3. Network Allowlist: The query excludes trusted IP addresses from the analysis to focus on potentially suspicious activity.

  4. Dormancy Baseline: It first identifies service principals that last signed in between 30 and 90 days ago, establishing a baseline for dormancy.

  5. Recent Activity Check: It then checks for service principals that have not signed in during the last 30 days, confirming their dormancy.

  6. Reactivation Detection: The query looks for service principals that have become active in the last hour, indicating a sudden reactivation.

  7. Combining Results: It combines the results to find service principals that were dormant and are now active, calculating the number of days they were dormant.

  8. Credential Changes: The query checks if any credentials were added or updated during the dormancy period, which could indicate a staged backdoor.

  9. Risk Assessment: It assigns a risk level to each detected event based on the dormancy duration and whether credentials were changed during dormancy.

  10. Output: The final output includes details like the service principal name, ID, last sign-in, first reactivation, days dormant, number of reactivation sign-ins, unique IPs, countries, resources accessed, credential types, and risk level.

Overall, this query helps identify potentially compromised service principals by detecting unusual patterns of inactivity followed by sudden activity, which could indicate an attacker's attempt to exploit dormant credentials.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AADServicePrincipalSignInLogsAuditLogs

Keywords

ServicePrincipalSignInLogsIPAddressGeoInfoCountryCityResourcesAuditLogsOperationNameCertificatesSecretsManagementUserAppDisplayNameRiskLevel

Operators

letmaterializeunionisfuzzyprinttakeprojecttostringwhereisnotemptymatchesregextoscalarextendiffstrcatsummarizemake_listnothas'/'array_lengthisnullipv4_is_in_any_rangemv-applytotypeofonsplit'-'ipv4_compare>=<=maxtointproject-awaybetweenago..==summarizebydistinct>invokeisnotemptygeo_info_from_ip_addresscountdcountmake_setminjoinkindinnerleftantidatetime_diff"day"leftouterhas_any=~coalescesummarizeon$left==$rightandcase==>project

Actions