Query Details

RULE 15 SP Graph API Enumeration

Query

// Rule    : Workload Identity - SP Graph API Service Principal Enumeration
// Severity: Medium
// Tactics : Discovery
// MITRE   : T1087.004 (Account Discovery: Cloud Account), T1069.003 (Permission Groups: Cloud Groups)
// Freq    : PT1H   Period: PT1H
// Tables  : AuditLogs (Microsoft Graph audit entries)
// Built-in differentiation: No Sentinel built-in detects Graph API SP bulk listing
// by a service principal caller. Microsoft Sentinel "Rare subscription-level operations"
// targets AzureActivity, not Graph API audit events. AzureAD Identity Protection does
// not surface enumeration patterns from non-interactive SP callers.
// Note: Requires Microsoft Entra ID / Azure AD audit log connector. Graph API calls
// to /servicePrincipals appear in AuditLogs with OperationName "List servicePrincipals".
// Tools like AzureHound, ROADtools, and AADInternals generate this pattern at high volume.
//==========================================================================================
// Attackers who compromise an SP use it to enumerate all other SPs in the tenant to
// discover over-privileged apps, legacy credentials, and pivot targets. Listing ≥10 SPs
// via Graph in a single hour from a non-interactive caller is a strong reconnaissance signal.

// ---- 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 SPListingThreshold = 10;    // ≥10 SP list operations = enumeration
let ToolSignatureFilters = dynamic([
    "python-requests", "azurehound", "roadrecon", "aadtools", "pscore",
    "postman", "insomnia", "curl"    // common recon tool UA patterns
]);

// --- Capture Graph API calls to list/read service principals ---
let SPListOps = AuditLogs
    | where TimeGenerated > ago(LookbackWindow)
    | where OperationName in~ (
        "List servicePrincipals",
        "Get servicePrincipal",
        "List servicePrincipal appRoleAssignments",
        "List servicePrincipal memberOf",
        "List servicePrincipal owners",
        "Get servicePrincipal oAuth2PermissionGrants",
        "List application servicePrincipal"
    )
    | where Result =~ "success"
    // Identify if the caller is itself a service principal (non-human)
    | extend CallerType       = case(
        isnotempty(tostring(InitiatedBy.app.servicePrincipalId)), "ServicePrincipal",
        isnotempty(tostring(InitiatedBy.user.id)),                "User",
        "Unknown")
    | extend CallerSPId       = tostring(InitiatedBy.app.servicePrincipalId)
    | extend CallerSPName     = tostring(InitiatedBy.app.displayName)
    | extend CallerUserId     = tostring(InitiatedBy.user.id)
    | extend CallerUpn        = tostring(InitiatedBy.user.userPrincipalName)
    | extend CallerIdentity   = iff(CallerType == "ServicePrincipal", CallerSPName, CallerUpn)
    | extend CallerObjectId   = iff(CallerType == "ServicePrincipal", CallerSPId, CallerUserId)
    | extend TargetSPId       = tostring(TargetResources[0].id)
    | extend TargetSPName     = tostring(TargetResources[0].displayName)
    | extend AdditionalInfo   = tostring(AdditionalDetails);

// --- Aggregate by caller and detect enumeration threshold ---
SPListOps
| summarize
    OpCount           = count(),
    UniqueTargetSPs   = dcount(TargetSPId),
    UniqueOps         = dcount(OperationName),
    TargetSPNames     = make_set(TargetSPName, 30),
    OperationsUsed    = make_set(OperationName, 10),
    FirstOp           = min(TimeGenerated),
    LastOp            = max(TimeGenerated)
    by CallerIdentity, CallerObjectId, CallerType
| where OpCount >= SPListingThreshold
// --- Corroborate: did the same caller recently have new credentials or role grants? ---
| join kind=leftouter (
    AuditLogs
    | where TimeGenerated > ago(24h)
    | where OperationName in (
        "Add service principal credentials",
        "Add app role assignment to service principal",
        "Add delegated permission grant",
        "Update service principal"
    )
    | where Result =~ "success"
    | extend ActorSPId = tostring(InitiatedBy.app.servicePrincipalId)
    | extend ActorUpn  = tostring(InitiatedBy.user.userPrincipalName)
    | extend Actor     = coalesce(ActorSPId, ActorUpn)
    | summarize RecentPrivChanges = count(), PrivOps = make_set(OperationName, 5)
        by Actor
) on $left.CallerObjectId == $right.Actor
| extend RecentPrivChanges   = coalesce(RecentPrivChanges, 0)
| extend HasPrivilegeContext = RecentPrivChanges > 0
| extend DurationMinutes     = datetime_diff("minute", LastOp, FirstOp)
| extend EnumerationRate     = round(todouble(OpCount) / iff(DurationMinutes < 1, 1.0, todouble(DurationMinutes)), 2)
| extend SeverityLevel = case(
    CallerType == "ServicePrincipal" and HasPrivilegeContext, "High — SP enumerating after privilege change",
    CallerType == "ServicePrincipal" and OpCount >= 50,       "High — Bulk SP enumeration by service principal",
    CallerType == "ServicePrincipal",                         "Medium — SP Graph API reconnaissance",
    HasPrivilegeContext,                                      "Medium — User enumeration after privilege change",
    "Medium — Graph API SP enumeration")
| project
    CallerIdentity, CallerObjectId, CallerType,
    OpCount, UniqueTargetSPs, UniqueOps,
    EnumerationRate, DurationMinutes,
    OperationsUsed, TargetSPNames,
    RecentPrivChanges, PrivOps,
    HasPrivilegeContext, SeverityLevel,
    FirstOp, LastOp

Explanation

This query is designed to detect suspicious activity related to the enumeration of service principals (SPs) in a Microsoft Azure environment using the Microsoft Graph API. Here's a simplified breakdown of what the query does:

  1. Purpose: The query aims to identify potential reconnaissance activities where attackers, after compromising a service principal, enumerate other service principals in the tenant. This can help them discover over-privileged applications, legacy credentials, and potential pivot targets.

  2. Severity and Tactics: The severity of this activity is considered medium, and it falls under the "Discovery" tactic in the MITRE ATT&CK framework, specifically targeting cloud account and group discovery.

  3. Data Source: The query analyzes data from the AuditLogs table, which contains Microsoft Graph audit entries.

  4. Network Allowlist: It excludes trusted IP addresses or ranges from the analysis to reduce false positives.

  5. Detection Logic:

    • The query looks for Graph API calls related to listing or reading service principals.
    • It focuses on operations initiated by service principals (non-human callers) and checks if they successfully listed 10 or more service principals within an hour.
    • It identifies the caller's identity, whether it's a service principal or a user, and gathers details about the operations performed.
  6. Corroboration: The query checks if the same caller recently had new credentials or role grants, which could indicate a privilege escalation.

  7. Output: The query summarizes the findings, including:

    • The number of operations and unique service principals targeted.
    • The rate of enumeration and the duration of the activity.
    • Whether there were recent privilege changes.
    • The severity level of the detected activity, which varies based on the context (e.g., if the caller is a service principal with recent privilege changes, the severity is higher).

Overall, this query helps security teams identify and investigate potential unauthorized enumeration of service principals, which could be an early indicator of a broader attack.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AuditLogs

Keywords

AuditLogsMicrosoftGraphServicePrincipalUserAzureActivityAzureADIdentityProtectionMicrosoftEntraIDAzureADAuditLogConnectorGraphAPIAzureHoundROADtoolsAADInternalsNetworkAllowlistIPCIDRRangeIPAddressToolSignatureFiltersPythonRequestsAzurehoundRoadreconAadtoolsPscorePostmanInsomniaCurlOperationNameInitiatedByAppServicePrincipalIdUserIdUserPrincipalNameTargetResourcesAdditionalDetails

Operators

letmaterializeunionisfuzzyprinttakeprojecttostringwhereisnotemptymatchesregexextendiffstrcatsummarizemake_listtoscalarnotipv4_is_in_any_rangemv-applysplitipv4_comparemaxtointproject-awayagodynamicin~casecoalescedcountmake_setminmaxbyjoinkindleftouterdatetime_diffroundtodoubleproject

Actions