Query Details

HUNT 11 SP Dormant Reactivation History

Query

// Hunt    : Workload Identity - SP Dormant Reactivation History (90d)
// Purpose : Retroactive 90-day view of service principals that went dormant
//           for 30+ days and then reactivated. Pairs with RULE-13 for triage.
//           Useful for identifying persistent backdoor SPs and reviewing whether
//           reactivations correlated with credential change events.
// Tables  : AADServicePrincipalSignInLogs, AuditLogs
//========================================================================

let LookbackEnd   = now();
let LookbackStart = ago(90d);
let DormancyThreshold = 30d;    // min gap to be considered "dormant"

// --- Step 1: Build full sign-in timeline per SP ---
let AllSignIns = (AADServicePrincipalSignInLogs | invoke ExcludeAllowlistedIPs())
    | where TimeGenerated between (LookbackStart .. LookbackEnd)
    | where ResultType == "0"
    | project TimeGenerated, ServicePrincipalId, ServicePrincipalName,
        AppId, IPAddress, Location, ResourceDisplayName, ClientCredentialType,
        ConditionalAccessStatus;

// --- Step 2: For each SP, find the pairs (last-before-gap, first-after-gap) ---
// Identify dormancy gaps ≥ DormancyThreshold
// Approach: for each SP compute prev_signin using prev() over sorted timeline
let GapEvents = AllSignIns
    | sort by ServicePrincipalId asc, TimeGenerated asc
    | extend PrevSignIn   = prev(TimeGenerated, 1)
    | extend PrevSPId     = prev(ServicePrincipalId, 1)
    | where ServicePrincipalId == PrevSPId          // same SP consecutive row
    | extend GapDays = datetime_diff("day", TimeGenerated, PrevSignIn)
    | where GapDays >= toint(DormancyThreshold / 1d)
    | project
        ServicePrincipalId,
        ServicePrincipalName,
        AppId,
        DormantFrom   = PrevSignIn,
        ReactivatedAt = TimeGenerated,
        GapDays,
        IPAddress,
        Location,
        ResourceDisplayName,
        ClientCredentialType,
        ConditionalAccessStatus;

// --- Step 3: Correlate with credential changes during dormancy (AuditLogs) ---
let CredentialChanges = AuditLogs
    | where TimeGenerated between (LookbackStart .. LookbackEnd)
    | where OperationName in (
        "Update service principal",
        "Add service principal credentials",
        "Remove service principal credentials",
        "Update service principal – Certificates and secrets management (by service)",
        "Update application – Certificates and secrets management"
    )
    | where Result =~ "success"
    | extend SPObjectId   = tostring(TargetResources[0].id)
    | extend SPName       = tostring(TargetResources[0].displayName)
    | project CredChangeTime = TimeGenerated, SPObjectId, SPName,
        CredOperation = OperationName;

// --- Step 4: Correlate with privilege changes during dormancy ---
let RoleChanges = AuditLogs
    | where TimeGenerated between (LookbackStart .. LookbackEnd)
    | where OperationName in (
        "Add member to role",
        "Add app role assignment to service principal",
        "Add delegated permission grant"
    )
    | where Result =~ "success"
    | extend SPObjectId = tostring(TargetResources[0].id)
    | project RoleChangeTime = TimeGenerated, SPObjectId, RoleChangeOp = OperationName;

GapEvents
| join kind=leftouter (CredentialChanges) on $left.ServicePrincipalId == $right.SPObjectId
| where isempty(CredChangeTime)
    or (CredChangeTime >= DormantFrom and CredChangeTime <= ReactivatedAt)   // during dormancy
| summarize
    DormantFrom           = min(DormantFrom),
    ReactivatedAt         = min(ReactivatedAt),
    GapDays               = max(GapDays),
    ReactivationIPs       = make_set(IPAddress, 5),
    ReactivationLocations = make_set(Location, 5),
    ResourcesAccessed     = make_set(ResourceDisplayName, 10),
    CredChangesCount      = countif(isnotempty(CredChangeTime)),
    CredOps               = make_set_if(CredOperation, isnotempty(CredOperation), 10)
    by ServicePrincipalId, ServicePrincipalName, AppId
| join kind=leftouter (RoleChanges) on $left.ServicePrincipalId == $right.SPObjectId
| summarize
    DormantFrom           = min(DormantFrom),
    ReactivatedAt         = min(ReactivatedAt),
    GapDays               = max(GapDays),
    ReactivationIPs       = take_any(ReactivationIPs),
    ReactivationLocations = take_any(ReactivationLocations),
    ResourcesAccessed     = take_any(ResourcesAccessed),
    CredChangesCount      = max(CredChangesCount),
    CredOps               = take_any(CredOps),
    PrivChangesCount      = countif(isnotempty(RoleChangeOp)),
    PrivOps               = make_set_if(RoleChangeOp, isnotempty(RoleChangeOp), 10)
    by ServicePrincipalId, ServicePrincipalName, AppId
| extend RiskSignals = bag_pack(
    "CredChangedDuringDormancy", CredChangesCount > 0,
    "PrivEscDuringDormancy",     PrivChangesCount > 0,
    "DormancyDays",              GapDays,
    "ReactivationIPs",           ReactivationIPs,
    "ReactivationLocations",     ReactivationLocations
)
| extend RiskLevel = case(
    CredChangesCount > 0 and PrivChangesCount > 0, "Critical",
    CredChangesCount > 0 or GapDays > 60,          "High",
    GapDays > 30,                                  "Medium",
                                                   "Low")
| sort by RiskLevel asc, GapDays desc

Explanation

This query is designed to identify and analyze service principals (SPs) in Azure Active Directory that have been inactive (dormant) for 30 or more days and then reactivated within a 90-day period. It aims to detect potential security risks, such as backdoor SPs, by examining their reactivation patterns and any associated credential or privilege changes. Here's a simplified breakdown of the query:

  1. Define Timeframe: The query looks back over the last 90 days to find SPs that were inactive for at least 30 days.

  2. Collect Sign-In Data: It gathers all successful sign-in events for SPs within this period, excluding any from allowlisted IPs.

  3. Identify Dormancy Gaps: For each SP, it identifies periods of inactivity (gaps) that meet or exceed the 30-day threshold.

  4. Check Credential Changes: It checks if there were any credential changes (like updates or additions) to the SPs during their dormant periods.

  5. Check Privilege Changes: It also checks for any privilege changes (like role assignments) during the dormancy.

  6. Summarize Findings: The query summarizes the data to show:

    • When each SP went dormant and when it reactivated.
    • The number of days it was inactive.
    • The IPs and locations from which reactivations occurred.
    • Any resources accessed upon reactivation.
    • Counts and types of credential and privilege changes during dormancy.
  7. Assess Risk: It evaluates the risk level of each SP based on the presence of credential or privilege changes during dormancy and the length of inactivity. The risk levels are categorized as Critical, High, Medium, or Low.

  8. Sort Results: Finally, it sorts the results by risk level and the number of dormancy days to prioritize potential security threats.

This query helps security teams monitor and investigate suspicious SP activities that could indicate security breaches or unauthorized access.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AADServicePrincipalSignInLogsAuditLogs

Keywords

WorkloadIdentityServicePrincipalsAuditLogsIPAddressLocationResourceDisplayNameClientCredentialTypeConditionalAccessStatusOperationNameTargetResourcesRoleChangeTimeRiskSignalsRiskLevel

Operators

letnow()ago()invokebetweenprojectsortextendprev()datetime_diff()toint()in=~tostring()joinkindisempty()countif()isnotempty()make_set()make_set_if()summarizemin()max()take_any()bag_pack()case()ascdesc

Actions