Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 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;
IdentityInfo
| summarize arg_max(TimeGenerated, *) by AccountObjectId
| where IsAccountEnabled == true
| where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles)
| join kind=leftanti (
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType == "0"
| project AccountObjectId = UserId
) on AccountObjectId
Comment on lines +33 to +43
| extend
AccountName = tostring(split(AccountUpn, "@")[0]),
AccountUPNSuffix = tostring(split(AccountUpn, "@")[1])
| project
AccountObjectId, 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
Comment on lines +52 to +60
- identifier: AadUserId
columnName: AccountObjectId
version: 1.0.1
metadata:
source:
kind: Community
author:
name: d4rk-pri0r
support:
tier: Community
categories:
domains: [ "Security - Threat Protection", "Identity" ]
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
id: 06184800-6d66-46ce-aabd-7e17e9cf3fb0
name: Privileged identities authenticating via legacy protocols
description: |
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
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([
"Exchange ActiveSync",
"IMAP4",
"MAPI over HTTP",
"POP3",
"SMTP Auth",
"Authenticated SMTP",
"Other clients"
]);
Comment on lines +39 to +47
let PrivilegedUsers = IdentityInfo
| where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles)
| summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId;
SigninLogs
| where TimeGenerated > ago(timeframe)
| where ResultType == "0"
| where ClientAppUsed in~ (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),
LastIP = tostring(arg_max(TimeGenerated, IPAddress))
by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName
| project
UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn,
SignInAttempts, FirstSeen, LastSeen, ClientApps, AuthRequirements,
LastIP, 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: LastIP
version: 1.0.1
metadata:
source:
kind: Community
author:
name: d4rk-pri0r
support:
tier: Community
categories:
domains: [ "Security - Threat Protection", "Identity" ]
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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' (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
- 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)
| summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId;
SigninLogs
| where TimeGenerated > ago(timeframe)
| where ResultType == "0"
| extend ConditionalAccessStatus = coalesce(ConditionalAccessStatus, "notEvaluated")
| 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),
LastIP = tostring(arg_max(TimeGenerated, IPAddress))
by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName
| project
UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn,
SignInAttempts, FirstSeen, LastSeen, ConditionalAccessStatuses,
AuthRequirements, LastIP, 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: LastIP
version: 1.0.1
metadata:
source:
kind: Community
author:
name: d4rk-pri0r
support:
tier: Community
categories:
domains: [ "Security - Threat Protection", "Identity" ]