From 20ab5e304fdafdc24fb0152e346ba1b82868ecac Mon Sep 17 00:00:00 2001 From: d4rk-pri0r Date: Mon, 3 Aug 2026 20:00:00 -0400 Subject: [PATCH 1/2] Add privileged-identity utilization hunting pack (3 queries) New SigninLogs hunting queries that surface licensed-but-minimally- configured identity gaps in Microsoft Entra ID: - DormantPrivilegedIdentities: enabled identities with directory or PIM roles that have no successful sign-in in 90 days - PrivilegedSigninsWithoutConditionalAccessSuccess: privileged users whose successful logons are not gated by an applied Conditional Access policy - PrivilegedAccountsUsingLegacyAuthentication: privileged users authenticating via legacy protocols that bypass modern CA/MFA All three join IdentityInfo (Microsoft 365 Defender) with SigninLogs and map Account/IP entities. --- .../DormantPrivilegedIdentities.yaml | 70 ++++++++++++++ ...egedAccountsUsingLegacyAuthentication.yaml | 91 +++++++++++++++++++ ...igninsWithoutConditionalAccessSuccess.yaml | 86 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml create mode 100644 Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml create mode 100644 Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml diff --git a/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml new file mode 100644 index 00000000000..16cb1837554 --- /dev/null +++ b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml @@ -0,0 +1,70 @@ +id: 0a70fd03-6f58-4df3-ab39-f9d123d538cd +name: Dormant privileged identities with no recent sign-ins +description: | + Finds enabled identity objects that hold directory roles or Entra PIM + roles but have not performed a successful interactive sign-in in the + last 90 days. These accounts remain licensed and enabled yet unmanaged, + a common 'licensed but minimally configured' gap: an attacker who + obtains or reuses these credentials inherits the group's privilege + without triggering new role-assignment audit events. +description-detailed: | + Leverages the Microsoft 365 Defender IdentityInfo table, which exposes + assigned and PIM-eligible roles, BlastRadius and account enablement + state, correlated against SigninLogs to surface identities with zero + successful logons within the lookback. Review whether each account is + still required, rotate credentials for any that remain, and consider + removing unneeded role assignments or triggering just-in-time access. + References: + - https://learn.microsoft.com/entra/identity/role-based-access-control/groups-concept + - https://attack.mitre.org/techniques/T1078/004/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - Persistence + - PrivilegeEscalation +relevantTechniques: + - T1078.004 +query: | + let lookback = 90d; + let noSigninSince = 1d; + IdentityInfo + | where IsAccountEnabled == true + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | join kind=leftanti ( + SigninLogs + | where TimeGenerated > ago(noSigninSince) + | where ResultType == "0" + | project AccountObjectId = UserId + ) on AccountObjectId + | extend + AccountName = tostring(split(AccountUpn, "@")[0]), + AccountUPNSuffix = tostring(split(AccountUpn, "@")[1]) + | project + AccountUpn, AccountName, AccountUPNSuffix, AccountDisplayName, + BlastRadius, AssignedRoles, PrivilegedEntraPimRoles, + Department, JobTitle + | sort by BlastRadius desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: AccountUpn + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix +version: 1.0.0 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ] diff --git a/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml new file mode 100644 index 00000000000..afed40dc77a --- /dev/null +++ b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml @@ -0,0 +1,91 @@ +id: 06184800-6d66-46ce-aabd-7e17e9cf3fb0 +name: Privileged identities authenticating via legacy protocols +description: | + Identifies successful legacy-protocol sign-ins (IMAP, POP, SMTP, + ActiveSync, Exchange Web Services) by accounts that hold directory or + PIM roles. Legacy authentication bypasses modern Conditional Access + controls and MFA in most configurations, so a tenant may be licensed + for Entra ID P2 and Defender yet leave its highest-privilege accounts + reachable through unmanaged clients. +description-detailed: | + Joins privileged accounts from IdentityInfo (AssignedRoles / + PrivilegedEntraPimRoles) against SigninLogs where ClientAppUsed matches + a legacy protocol and the sign-in succeeded. Review each hit against + Conditional Access 'legacy authentication' blocking policies and + block-legacy-auth settings in Exchange Online; disable the legacy + protocol for any account that does not require it. Protocol names here + follow the SigninLogs ClientAppUsed values used by existing repo + legacy-auth detection patterns. + References: + - https://learn.microsoft.com/entra/identity/conditional-access/block-legacy-authentication + - https://attack.mitre.org/techniques/T1078/004/ + - https://attack.mitre.org/techniques/T1110/003/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - InitialAccess + - CredentialAccess +relevantTechniques: + - T1078.004 + - T1110.003 +query: | + let timeframe = 30d; + let LegacyProtocols = dynamic([ + "IMAP", "POP", "SMTP", "ActiveSync", "ExchangeWebServices", + "Autodiscover", "OWA" + ]); + let PrivilegedUsers = IdentityInfo + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | project AccountObjectId, AccountUpn; + SigninLogs + | where TimeGenerated > ago(timeframe) + | where ResultType == "0" + | where ClientAppUsed has_any (LegacyProtocols) + | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId + | extend + AccountName = tostring(split(UserPrincipalName, "@")[0]), + AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1]) + | summarize + SignInAttempts = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + ClientApps = make_set(ClientAppUsed), + AuthRequirements = make_set(AuthenticationRequirement), + IPAddresses = make_set(IPAddress) + by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName + | extend FirstIP = tostring(IPAddresses[0]) + | project + UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, + SignInAttempts, FirstSeen, LastSeen, ClientApps, AuthRequirements, + FirstIP, AppDisplayName + | sort by SignInAttempts desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: UserPrincipalName + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix + - identifier: AadUserId + columnName: UserId + - entityType: IP + fieldMappings: + - identifier: Address + columnName: FirstIP +version: 1.0.0 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ] diff --git a/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml new file mode 100644 index 00000000000..bcd8d50c156 --- /dev/null +++ b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml @@ -0,0 +1,86 @@ +id: 87245d60-eefb-42dd-9748-cd1949c83a5e +name: Privileged identities whose sign-ins are not protected by Conditional Access +description: | + Identifies privileged identity objects (directory or PIM roles assigned) + whose successful sign-ins do not have a Conditional Access policy applied. + Entra ID P2 licenses the CA engine, but if no policy gates these accounts + they remain a 'licensed but minimally configured' gap: MFA and device + controls that the tenant already pays for are never enforced. +description-detailed: | + Joins privileged accounts from the Microsoft 365 Defender IdentityInfo + table (AssignedRoles / PrivilegedEntraPimRoles) against SigninLogs, + keeping only successful logons whose ConditionalAccessStatus is not + 'success'. Status values such as 'notApplied', 'notEnabled' or 'failure' + indicate the sign-in was not gated by an applied CA policy. Review + whether a policy intended to cover privileged roles is misconfigured, + out of scope, or disabled (compare with the ConditionalAccessPolicyDisabled + family of hunting queries in Hunting Queries/AuditLogs), and prioritize + accounts that also authenticate without MFA (AuthenticationRequirement = + singleFactorAuthentication). + References: + - https://learn.microsoft.com/entra/identity/conditional-access/overview + - https://attack.mitre.org/techniques/T1078/004/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - InitialAccess +relevantTechniques: + - T1078.004 +query: | + let timeframe = 30d; + let PrivilegedUsers = IdentityInfo + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | project AccountObjectId, AccountUpn; + SigninLogs + | where TimeGenerated > ago(timeframe) + | where ResultType == "0" + | where isnotempty(ConditionalAccessStatus) + | where ConditionalAccessStatus !~ "success" + | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId + | extend + AccountName = tostring(split(UserPrincipalName, "@")[0]), + AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1]) + | summarize + SignInAttempts = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + ConditionalAccessStatuses = make_set(ConditionalAccessStatus), + AuthRequirements = make_set(AuthenticationRequirement), + IPAddresses = make_set(IPAddress) + by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName + | extend FirstIP = tostring(IPAddresses[0]) + | project + UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, + SignInAttempts, FirstSeen, LastSeen, ConditionalAccessStatuses, + AuthRequirements, FirstIP, AppDisplayName + | sort by SignInAttempts desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: UserPrincipalName + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix + - identifier: AadUserId + columnName: UserId + - entityType: IP + fieldMappings: + - identifier: Address + columnName: FirstIP +version: 1.0.0 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ] From ac85ed09833880e3f0b04dadaf12a52645398459 Mon Sep 17 00:00:00 2001 From: d4rk-pri0r Date: Wed, 5 Aug 2026 20:00:00 -0400 Subject: [PATCH 2/2] Address Copilot review on privileged-identity hunting pack Fix dormant lookback (use 90d), dedupe IdentityInfo, add AadUserId, include null CA status, align legacy ClientAppUsed values with repo patterns, and pick LastIP via arg_max for stable entity mapping. --- .../DormantPrivilegedIdentities.yaml | 20 ++++++----- ...egedAccountsUsingLegacyAuthentication.yaml | 35 +++++++++++-------- ...igninsWithoutConditionalAccessSuccess.yaml | 25 +++++++------ 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml index 16cb1837554..2b9aa5e46c5 100644 --- a/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml +++ b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml @@ -2,11 +2,11 @@ id: 0a70fd03-6f58-4df3-ab39-f9d123d538cd name: Dormant privileged identities with no recent sign-ins description: | Finds enabled identity objects that hold directory roles or Entra PIM - roles but have not performed a successful interactive sign-in in the - last 90 days. These accounts remain licensed and enabled yet unmanaged, - a common 'licensed but minimally configured' gap: an attacker who - obtains or reuses these credentials inherits the group's privilege - without triggering new role-assignment audit events. + roles but have not performed a successful sign-in in the last 90 days. + These accounts remain licensed and enabled yet unmanaged, a common + 'licensed but minimally configured' gap: an attacker who obtains or + reuses these credentials inherits the group's privilege without + triggering new role-assignment audit events. description-detailed: | Leverages the Microsoft 365 Defender IdentityInfo table, which exposes assigned and PIM-eligible roles, BlastRadius and account enablement @@ -31,13 +31,13 @@ relevantTechniques: - T1078.004 query: | let lookback = 90d; - let noSigninSince = 1d; IdentityInfo + | summarize arg_max(TimeGenerated, *) by AccountObjectId | where IsAccountEnabled == true | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) | join kind=leftanti ( SigninLogs - | where TimeGenerated > ago(noSigninSince) + | where TimeGenerated > ago(lookback) | where ResultType == "0" | project AccountObjectId = UserId ) on AccountObjectId @@ -45,7 +45,7 @@ query: | AccountName = tostring(split(AccountUpn, "@")[0]), AccountUPNSuffix = tostring(split(AccountUpn, "@")[1]) | project - AccountUpn, AccountName, AccountUPNSuffix, AccountDisplayName, + AccountObjectId, AccountUpn, AccountName, AccountUPNSuffix, AccountDisplayName, BlastRadius, AssignedRoles, PrivilegedEntraPimRoles, Department, JobTitle | sort by BlastRadius desc @@ -58,7 +58,9 @@ entityMappings: columnName: AccountName - identifier: UPNSuffix columnName: AccountUPNSuffix -version: 1.0.0 + - identifier: AadUserId + columnName: AccountObjectId +version: 1.0.1 metadata: source: kind: Community diff --git a/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml index afed40dc77a..25be01d8244 100644 --- a/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml +++ b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml @@ -1,12 +1,13 @@ id: 06184800-6d66-46ce-aabd-7e17e9cf3fb0 name: Privileged identities authenticating via legacy protocols description: | - Identifies successful legacy-protocol sign-ins (IMAP, POP, SMTP, - ActiveSync, Exchange Web Services) by accounts that hold directory or - PIM roles. Legacy authentication bypasses modern Conditional Access - controls and MFA in most configurations, so a tenant may be licensed - for Entra ID P2 and Defender yet leave its highest-privilege accounts - reachable through unmanaged clients. + Identifies successful legacy-protocol sign-ins (Exchange ActiveSync, + IMAP4, POP3, SMTP Auth, MAPI over HTTP, and other legacy clients) by + accounts that hold directory or PIM roles. Legacy authentication + bypasses modern Conditional Access controls and MFA in most + configurations, so a tenant may be licensed for Entra ID P2 and + Defender yet leave its highest-privilege accounts reachable through + unmanaged clients. description-detailed: | Joins privileged accounts from IdentityInfo (AssignedRoles / PrivilegedEntraPimRoles) against SigninLogs where ClientAppUsed matches @@ -36,16 +37,21 @@ relevantTechniques: query: | let timeframe = 30d; let LegacyProtocols = dynamic([ - "IMAP", "POP", "SMTP", "ActiveSync", "ExchangeWebServices", - "Autodiscover", "OWA" + "Exchange ActiveSync", + "IMAP4", + "MAPI over HTTP", + "POP3", + "SMTP Auth", + "Authenticated SMTP", + "Other clients" ]); let PrivilegedUsers = IdentityInfo | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) - | project AccountObjectId, AccountUpn; + | summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId; SigninLogs | where TimeGenerated > ago(timeframe) | where ResultType == "0" - | where ClientAppUsed has_any (LegacyProtocols) + | where ClientAppUsed in~ (LegacyProtocols) | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId | extend AccountName = tostring(split(UserPrincipalName, "@")[0]), @@ -56,13 +62,12 @@ query: | LastSeen = max(TimeGenerated), ClientApps = make_set(ClientAppUsed), AuthRequirements = make_set(AuthenticationRequirement), - IPAddresses = make_set(IPAddress) + LastIP = tostring(arg_max(TimeGenerated, IPAddress)) by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName - | extend FirstIP = tostring(IPAddresses[0]) | project UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, SignInAttempts, FirstSeen, LastSeen, ClientApps, AuthRequirements, - FirstIP, AppDisplayName + LastIP, AppDisplayName | sort by SignInAttempts desc entityMappings: - entityType: Account @@ -78,8 +83,8 @@ entityMappings: - entityType: IP fieldMappings: - identifier: Address - columnName: FirstIP -version: 1.0.0 + columnName: LastIP +version: 1.0.1 metadata: source: kind: Community diff --git a/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml index bcd8d50c156..c406bb3b790 100644 --- a/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml +++ b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml @@ -10,12 +10,12 @@ description-detailed: | Joins privileged accounts from the Microsoft 365 Defender IdentityInfo table (AssignedRoles / PrivilegedEntraPimRoles) against SigninLogs, keeping only successful logons whose ConditionalAccessStatus is not - 'success'. Status values such as 'notApplied', 'notEnabled' or 'failure' - indicate the sign-in was not gated by an applied CA policy. Review - whether a policy intended to cover privileged roles is misconfigured, - out of scope, or disabled (compare with the ConditionalAccessPolicyDisabled - family of hunting queries in Hunting Queries/AuditLogs), and prioritize - accounts that also authenticate without MFA (AuthenticationRequirement = + 'success' (including empty/null, notApplied, notEnabled, or failure). + Review whether a policy intended to cover privileged roles is + misconfigured, out of scope, or disabled (compare with the + ConditionalAccessPolicyDisabled family of hunting queries in + Hunting Queries/AuditLogs), and prioritize accounts that also + authenticate without MFA (AuthenticationRequirement = singleFactorAuthentication). References: - https://learn.microsoft.com/entra/identity/conditional-access/overview @@ -35,11 +35,11 @@ query: | let timeframe = 30d; let PrivilegedUsers = IdentityInfo | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) - | project AccountObjectId, AccountUpn; + | summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId; SigninLogs | where TimeGenerated > ago(timeframe) | where ResultType == "0" - | where isnotempty(ConditionalAccessStatus) + | extend ConditionalAccessStatus = coalesce(ConditionalAccessStatus, "notEvaluated") | where ConditionalAccessStatus !~ "success" | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId | extend @@ -51,13 +51,12 @@ query: | LastSeen = max(TimeGenerated), ConditionalAccessStatuses = make_set(ConditionalAccessStatus), AuthRequirements = make_set(AuthenticationRequirement), - IPAddresses = make_set(IPAddress) + LastIP = tostring(arg_max(TimeGenerated, IPAddress)) by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName - | extend FirstIP = tostring(IPAddresses[0]) | project UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, SignInAttempts, FirstSeen, LastSeen, ConditionalAccessStatuses, - AuthRequirements, FirstIP, AppDisplayName + AuthRequirements, LastIP, AppDisplayName | sort by SignInAttempts desc entityMappings: - entityType: Account @@ -73,8 +72,8 @@ entityMappings: - entityType: IP fieldMappings: - identifier: Address - columnName: FirstIP -version: 1.0.0 + columnName: LastIP +version: 1.0.1 metadata: source: kind: Community