Query Details

RULE 18 SP Bulk Creation Burst

Query

// Rule    : Workload Identity - Multiple SPs Created by Single Principal in Short Window
// Severity: Medium
// Tactics : Persistence, PrivilegeEscalation
// MITRE   : T1136.003 (Create Account: Cloud Account), T1078.004 (Valid Accounts: Cloud Accounts)
// Freq    : PT30M  Period: PT30M
// Tables  : AuditLogs
// Built-in differentiation: No Sentinel built-in detects bulk SP creation velocity patterns.
// Microsoft Entra ID Protection monitors risky sign-ins but does not detect rapid SP creation
// bursts from a single principal. This is distinct from RULE-09 (SP created never used) which
// is a stale credential hygiene rule — this rule fires on the creation event burst itself,
// well before any dormancy window is reached.
//==========================================================================================
// Attackers who achieve Application Administrator or Cloud App Admin privilege will create
// multiple SPs rapidly to establish persistence (each SP can have independent credentials).
// Automated tools like AzureHound or custom attack payloads generate this burst pattern.
// Legitimate bulk SP creation (e.g., CI/CD pipelines) is typically automated from a known
// service account — alert should be reviewed for unexpected human principals.

// ---- 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 BurstWindow    = 10m;       // sliding window for burst detection
let BurstThreshold = 3;         // >3 SPs created by same principal within window
let LookbackPeriod = 30m;

// --- All SP creation events in lookback ---
let SPCreations = AuditLogs
    | where TimeGenerated > ago(LookbackPeriod)
    | where OperationName =~ "Add service principal"
    | where Result =~ "success"
    | extend InitiatorType    = case(
        isnotempty(tostring(InitiatedBy.app.servicePrincipalId)), "ServicePrincipal",
        isnotempty(tostring(InitiatedBy.user.id)),                "User",
        "Unknown")
    | extend InitiatorId      = coalesce(
        tostring(InitiatedBy.app.servicePrincipalId),
        tostring(InitiatedBy.user.id))
    | extend InitiatorName    = coalesce(
        tostring(InitiatedBy.app.displayName),
        tostring(InitiatedBy.user.userPrincipalName))
    | extend NewSPId          = tostring(TargetResources[0].id)
    | extend NewSPName        = tostring(TargetResources[0].displayName)
    | project TimeGenerated, InitiatorId, InitiatorName, InitiatorType,
        NewSPId, NewSPName;

// --- Post-creation arming events (joined per-SP before summarize to avoid dynamic-key join) ---
let PostCreationData = AuditLogs
    | where TimeGenerated > ago(LookbackPeriod + 10m)
    | where OperationName in (
        "Add service principal credentials",
        "Add app role assignment to service principal",
        "Add delegated permission grant"
    )
    | where Result =~ "success"
    | extend TargetSPId = tostring(TargetResources[0].id)
    | project TargetSPId, OperationName;
// --- Detect burst: ≥BurstThreshold creations within BurstWindow ---
// Join post-creation ops at per-SP (scalar) level before summarize to avoid dynamic-key join (SEM0713)
SPCreations
| join kind=inner (SPCreations | project InitiatorId, WindowEnd = TimeGenerated) on InitiatorId
| where TimeGenerated between (WindowEnd - BurstWindow .. WindowEnd)
| join kind=leftouter PostCreationData on $left.NewSPId == $right.TargetSPId
| summarize
    CreationsInWindow  = dcount(NewSPId),
    SPsCreated         = make_set(NewSPName, 20),
    SPIds              = make_set(NewSPId, 20),
    PostCreationOps    = dcountif(NewSPId, isnotempty(TargetSPId)),
    PostCreationOpList = make_set_if(OperationName, isnotempty(OperationName), 5),
    WindowStart        = min(TimeGenerated),
    WindowEnd          = max(TimeGenerated)
    by InitiatorId, InitiatorName, InitiatorType
| where CreationsInWindow > BurstThreshold
| extend ImmediateArming   = PostCreationOps > 0
| extend BurstDurationMins = datetime_diff("minute", WindowEnd, WindowStart)
// --- Check if initiator is itself an SP (app creating apps = suspicious) ---
| extend InitiatorIsApp    = InitiatorType == "ServicePrincipal"
| extend SeverityLevel = case(
    InitiatorIsApp and ImmediateArming,  "Critical — SP created SPs and immediately armed them",
    InitiatorIsApp,                      "High — Service principal bulk-created other SPs",
    ImmediateArming,                     "High — Bulk SP creation with immediate credential/role arming",
    CreationsInWindow > 5,               "High — Very high SP creation burst (>5)",
    "Medium — Suspicious SP creation burst")
| project
    InitiatorName, InitiatorId, InitiatorType, InitiatorIsApp,
    CreationsInWindow, BurstDurationMins,
    SPsCreated, SPIds,
    ImmediateArming, PostCreationOps, PostCreationOpList,
    SeverityLevel, WindowStart, WindowEnd

Explanation

This query is designed to detect suspicious behavior in a cloud environment, specifically focusing on the rapid creation of service principals (SPs) by a single user or application within a short time frame. Here's a simplified breakdown of what the query does:

  1. Purpose: The query aims to identify potential security threats where a single principal (user or application) creates multiple service principals quickly, which might indicate an attempt to establish persistence or escalate privileges.

  2. Severity and Tactics: The rule is marked with medium severity and is associated with tactics like persistence and privilege escalation. It aligns with specific MITRE ATT&CK techniques related to creating and using cloud accounts.

  3. Detection Logic:

    • The query looks at audit logs for service principal creation events within the last 30 minutes.
    • It identifies cases where more than three service principals are created by the same initiator within a 10-minute window.
    • It checks if these newly created service principals are immediately armed with credentials or roles, which could be a sign of malicious intent.
  4. Exclusions: The query excludes events from trusted IP addresses or ranges, which are specified in a watchlist called 'NetworkAllowlist'.

  5. Output:

    • It summarizes the findings, including the number of service principals created, their names, and any immediate post-creation actions.
    • It categorizes the severity of the detected activity based on factors like whether an application created the service principals or if there was immediate arming.
    • The output includes details about the initiator, the number of creations, the duration of the burst, and the severity level of the activity.
  6. Use Case: This query is useful for security teams to monitor and investigate unusual patterns of service principal creation, which could indicate unauthorized access or potential security breaches. It helps differentiate between legitimate automated processes and potentially malicious activities.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AuditLogs

Keywords

AuditLogsServicePrincipalUserOperationNameTargetResourcesInitiatedByTimeGeneratedIPAddress

Operators

letmaterializeunionisfuzzyprinttakeprojectwhereisnotemptymatchesregexextendiffhasstrcatsummarizemake_listtoscalarnotipv4_is_in_any_rangemv-applytotypeofsplitipv4_comparemaxtointproject-awayagocasecoalescetostringjoinkindbetweendcountmake_setdcountifmake_set_ifminmaxbydatetime_diff==project

Actions