Query Details

RULE 07 SP First Time Critical Resource

Query

// Rule    : Workload Identity - SP First-Time Access to Critical Resource Correlated with AuditLogs
// Severity: High
// Tactics : CredentialAccess, Discovery
// MITRE   : T1552.001, T1087.004
// Freq    : PT6H   Period: P14D
//==========================================================================================

// ---- 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 CriticalResources = dynamic([
    "Microsoft Graph", "Azure Key Vault", "Azure Active Directory",
    "Windows Azure Active Directory", "Azure Resource Manager",
    "Office 365 Exchange Online", "Office 365 SharePoint Online"
]);
let PrivateRanges = dynamic(["10.", "192.168.", "172.16.", "172.17.", "172.18.",
    "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
    "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
    "127.", "169.254.", "168.63."]);

// --- Baseline: which SPs have historically accessed each critical resource ---
let HistoricalAccess = (AADServicePrincipalSignInLogs | invoke _ExcludeAllowlistedIPs())
    | where TimeGenerated between (ago(14d) .. ago(6h))
    | where ResultType == "0"
    | where ResourceDisplayName in (CriticalResources)
    | distinct ServicePrincipalId, ResourceDisplayName;

// --- New accesses in current window not seen historically ---
let NewAccesses = (AADServicePrincipalSignInLogs | invoke _ExcludeAllowlistedIPs())
    | where TimeGenerated > ago(6h)
    | where ResultType == "0"
    | where ResourceDisplayName in (CriticalResources)
    | where isnotempty(IPAddress)
    | where not(IPAddress has_any (PrivateRanges))
    | extend GeoInfo = geo_info_from_ip_address(IPAddress)
    | extend Country = tostring(GeoInfo.country_iso_code)
    | join kind=leftanti HistoricalAccess on ServicePrincipalId, ResourceDisplayName
    | summarize
        AccessCount      = count(),
        UniqueIPs        = dcount(IPAddress),
        IPList           = make_set(IPAddress, 5),
        Countries        = make_set(Country, 5),
        CredTypes        = make_set(ClientCredentialType, 5),
        FirstAccess      = min(TimeGenerated),
        LastAccess       = max(TimeGenerated)
        by ServicePrincipalName, ServicePrincipalId, AppId, ResourceDisplayName;

// --- Corroborate: was this SP recently created or had credentials added? ---
let RecentSPChanges = AuditLogs
    | where TimeGenerated > ago(14d)
    | where OperationName has_any (
        "Add service principal", "Add service principal credentials",
        "Update application – Certificates and secrets management",
        "Add application")
    | where Result =~ "success"
    | extend SPName    = tostring(TargetResources[0].displayName)
    | extend SPId      = tostring(TargetResources[0].id)
    | extend Initiator = coalesce(tostring(InitiatedBy.user.userPrincipalName),
        tostring(InitiatedBy.app.displayName))
    | project ChangeTime = TimeGenerated, SPName, SPId, ChangeType = OperationName, Initiator;

NewAccesses
| join kind=leftouter RecentSPChanges on $left.ServicePrincipalId == $right.SPId
| extend IsRecentlyModified = isnotempty(ChangeTime)
| project-away SPName, SPId

Explanation

This query is designed to detect suspicious activity involving service principals (SPs) accessing critical resources for the first time. Here's a simplified summary of what the query does:

  1. Define Trusted Networks: It starts by defining a list of trusted IP addresses or ranges (allowlist) that should be excluded from further analysis.

  2. Identify Critical Resources: It specifies a list of critical resources such as Microsoft Graph, Azure Key Vault, and others that are of interest.

  3. Historical Access Baseline: It creates a baseline of which service principals have accessed these critical resources in the past 14 days, excluding the last 6 hours. This helps identify normal access patterns.

  4. Detect New Accesses: It looks for new access attempts to these critical resources in the last 6 hours that are not part of the historical baseline. It excludes access from private IP ranges and trusted networks, and gathers information about the geographic location of the IP addresses involved.

  5. Check for Recent Changes: It checks if the service principals involved in these new access attempts were recently created or had their credentials modified in the last 14 days. This is done by examining audit logs for relevant operations like adding service principals or updating credentials.

  6. Combine Results: Finally, it combines the new access data with the recent changes data to identify if any of the service principals involved in the new accesses were recently modified. This helps in assessing the risk level of the access attempt.

Overall, the query aims to identify potentially unauthorized or suspicious first-time access to critical resources by service principals, especially if those service principals have been recently created or modified.

Details

David Alonso profile picture

David Alonso

Released: April 21, 2026

Tables

AADServicePrincipalSignInLogsAuditLogs

Keywords

WorkloadIdentityServicePrincipalAuditLogsMicrosoftGraphAzureKeyVaultAzureActiveDirectoryWindowsAzureActiveDirectoryAzureResourceManagerOfficeExchangeOnlineOfficeSharePointOnlineIPAddressGeoInfoCountryClientCredentialType

Operators

letmaterializeunionisfuzzyprinttakeprojecttostringwhereisnotemptymatchesregextoscalarextendiffstrcatsummarizemake_listarray_lengthisnullipv4_is_in_any_rangemv-applytotypeofsplitipv4_comparemaxtointproject-awaydynamicbetweenagodistinctinhas_anygeo_info_from_ip_addressjoinkindleftanticountdcountmake_setminmaxbyhas=~coalesceprojectleftouter$left$right==isnotemptyproject-away.

Actions