Query Details

HUNT 10 SP Not Used 90d Stale

Query

// Hunt     : Workload Identity - Service Principals Not Used in 90 Days (Stale/Abandoned SPs)
// Tactics  : Persistence
// MITRE    : T1098.001 (Account Manipulation: Additional Cloud Credentials), T1078.004
// Purpose  : A stale SP with active credentials is a standing attack surface — attackers
//            target them because they have no active owners, rarely rotate credentials,
//            and generate no alerts when used after a long gap. This query surfaces SPs
//            whose last successful sign-in was more than 90 days ago but are still present
//            in the directory. Correlates with AuditLogs to show if credentials were
//            added recently (reactivation attempt), and AzureActivity to check for any
//            recent management plane calls that may not appear in sign-in logs.
//==========================================================================================

// --- SPs that have signed in at any point (to know their last activity) ---
let AllSPActivity = (AADServicePrincipalSignInLogs | invoke ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(180d)
    | where ResultType == "0"
    | summarize
        TotalSignins     = count(),
        LastSignin       = max(TimeGenerated),
        UniqueResources  = dcount(ResourceDisplayName),
        LastResources    = make_set(ResourceDisplayName, 5)
        by ServicePrincipalId, ServicePrincipalName, AppId;

// --- Inactive SPs: last sign-in was >90 days ago ---
let InactiveSPs = AllSPActivity
    | where LastSignin < ago(90d);

// --- Check for recent credential additions to inactive SPs (reactivation signal) ---
let RecentCredChanges = AuditLogs
    | where TimeGenerated > ago(90d)
    | where OperationName has_any (
        "Add service principal credentials",
        "Update application – Certificates and secrets management",
        "Update service principal")
    | where Result =~ "success"
    | extend SPId      = tostring(TargetResources[0].id)
    | extend Initiator = coalesce(
        tostring(InitiatedBy.user.userPrincipalName),
        tostring(InitiatedBy.app.displayName))
    | summarize
        RecentChanges    = count(),
        ChangeTypes      = make_set(OperationName, 5),
        Initiators       = make_set(Initiator, 5),
        LastChange       = max(TimeGenerated)
        by SPId;

// --- Check for recent AzureActivity calls from inactive SPs (headless management access) ---
// isfuzzy=true: AzureActivity may not be connected in all environments
let RecentMgmtActivity = union isfuzzy=true
    AzureActivity,
    (datatable(TimeGenerated:datetime, ActivityStatusValue:string, OperationNameValue:string, Caller:string)[])
    | where TimeGenerated > ago(90d)
    | where ActivityStatusValue =~ "Success"
    | summarize
        MgmtOps          = count(),
        LastMgmtOp       = max(TimeGenerated),
        Operations        = make_set(OperationNameValue, 5)
        by Caller;

InactiveSPs
| join kind=leftouter RecentCredChanges on $left.ServicePrincipalId == $right.SPId
| join kind=leftouter RecentMgmtActivity on $left.ServicePrincipalId == $right.Caller
| extend DaysInactive         = datetime_diff("day", now(), LastSignin)
| extend HasRecentCredChange  = RecentChanges > 0
| extend HasMgmtActivity      = MgmtOps > 0
| extend RiskAssessment = case(
    HasRecentCredChange == true and HasMgmtActivity == true,
        "Critical — Stale SP Reactivated with New Creds + Mgmt Activity",
    HasRecentCredChange == true,
        "High — Stale SP Had Credential Change (Possible Reactivation)",
    HasMgmtActivity == true,
        "High — Stale SP Has Recent Management Plane Activity",
    DaysInactive > 180,
        "Medium — Very Long Dormancy (>180 days), Cleanup Recommended",
        "Low — Stale SP, No Recent Activity or Changes")
| project
    ServicePrincipalName, ServicePrincipalId, AppId,
    LastSignin, DaysInactive, TotalSignins,
    UniqueResources, LastResources,
    HasRecentCredChange, RecentChanges, ChangeTypes, Initiators, LastChange,
    HasMgmtActivity, MgmtOps, LastMgmtOp, Operations,
    RiskAssessment
| order by RiskAssessment asc, DaysInactive desc

Explanation

This query is designed to identify and assess the risk of service principals (SPs) in an Azure environment that have not been used for sign-in activities in over 90 days. Here's a simplified breakdown of what the query does:

  1. Identify Active SPs: It first gathers data on all service principals that have signed in at least once in the last 180 days, capturing details like the total number of sign-ins, the last sign-in date, and the resources accessed.

  2. Find Inactive SPs: From the above data, it filters out SPs whose last sign-in was more than 90 days ago, marking them as inactive.

  3. Check for Recent Credential Changes: It examines audit logs to see if there have been any recent changes to the credentials of these inactive SPs in the last 90 days, which could indicate an attempt to reactivate them.

  4. Look for Recent Management Activity: It checks for any recent management activities performed by these inactive SPs in Azure, which might not be captured in sign-in logs but could indicate unauthorized access or use.

  5. Risk Assessment: The query then assesses the risk level of each inactive SP based on:

    • Recent credential changes and management activities.
    • The duration of inactivity.
    • It categorizes the risk as Critical, High, Medium, or Low, depending on these factors.
  6. Output: Finally, it presents a list of these inactive SPs along with their details, such as the last sign-in date, number of sign-ins, recent changes, and risk assessment, sorted by risk level and inactivity duration.

The purpose of this query is to help identify potentially vulnerable SPs that could be exploited by attackers due to their inactivity and lack of monitoring, allowing for proactive security measures to be taken.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AADServicePrincipalSignInLogsAuditLogsAzureActivity

Keywords

ServicePrincipalAuditLogsAzureActivityResourcesInitiatorOperationNameCallerRiskAssessment

Operators

letinvoke|where>ago==summarize=countmaxdcountmake_setby<has_any=~extendtostringcoalesceunionisfuzzydatatabledatetime_diffcaseprojectorder byascdesc

Actions