Skip to content

Commit 114d1aa

Browse files
Merge pull request #1394 from maester365/tnh-PrivilegedUserLinkedIdentitiy
Add tests for privileged users linked to identities
2 parents cf4c486 + 17cfbba commit 114d1aa

8 files changed

+258
-10
lines changed

powershell/Maester.psd1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@
169169
'Test-MtXspmAppRegWithPrivilegedUnusedPermissions',
170170
'Test-MtXspmExposedCredentialsForPrivilegedUsers',
171171
'Test-MtXspmHybridUsersWithAssignedEntraIdRoles',
172+
'Test-MtXspmEnabledPrivilegedUsersLinkedToDisabledIdentity',
173+
'Test-MtXspmPrivilegedUsersLinkedToIdentity',
172174
'Test-MtXspmPendingApprovalCriticalAssetManagement',
173175
'Test-MtOperationApprovalPolicies',
174176
'Test-MtDeviceRegistrationLocalAdminsGlobalAdmin',

powershell/internal/xspm/Get-MtXspmUnifiedIdentityInfo.ps1

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,14 @@ function Get-MtXspmUnifiedIdentityInfo {
112112
| where tolower(AccountDisplayName) contains tolower(ServicePrincipalName) and AccountObjectId contains tostring(ServicePrincipalObjectId)
113113
| where Timestamp >ago(14d)
114114
| summarize arg_max(Timestamp, *) by AccountObjectId
115-
| project ServicePrincipalName = AccountDisplayName, ServicePrincipalId = AccountObjectId, CriticalityLevel
115+
| extend AccountStatus = iff(IsAccountEnabled == true, 'Enabled', 'Disabled')
116+
| project ServicePrincipalName = AccountDisplayName, ServicePrincipalId = AccountObjectId, CriticalityLevel, AccountStatus
116117
// Lookup for OAuth application details
117118
| lookup (
118119
OAuthAppInfo
119120
| where Timestamp >ago(30d)
120121
| where tolower(AppName) contains tolower(ServicePrincipalName) and ServicePrincipalId contains tostring(ServicePrincipalObjectId)
121-
| extend OAuthAppInfoAppDisplayName = AppName
122-
| summarize arg_max(Timestamp, *) by ServicePrincipalId, OAuthAppInfoAppDisplayName
122+
| summarize arg_max(Timestamp, *) by ServicePrincipalId
123123
) on ServicePrincipalId
124124
// Lookup for Graph API Classification
125125
| lookup (
@@ -270,10 +270,8 @@ function Get-MtXspmUnifiedIdentityInfo {
270270
) on XspmGraphOAuthAppNodeId
271271
| extend CriticalityLevel = toint(parse_json(XspmCriticalAssetDetails)['criticalityLevel'])
272272
| project-away XspmGraphNodeId, XspmGraphNodeId1, ServicePrincipalId1, ServicePrincipalId2, XspmGraphNodeId1, XspmGraphNodeId2, TargetNodeId, XspmGraphOAuthAppNodeId, XspmGraphOAuthAppNodeId1
273-
| extend ServicePrincipalName = coalesce(ServicePrincipalName, OAuthAppInfoAppDisplayName)
274-
| extend ServicePrincipalName = coalesce(ServicePrincipalName, ServicePrincipalId)
275273
| sort by ServicePrincipalName asc
276-
| project Timestamp, TimeGenerated, ServicePrincipalName, ServicePrincipalId, OAuthAppId, CriticalityLevel, AddedOnTime, LastModifiedTime, AppStatus, VerifiedPublisher, IsAdminConsented, AppOrigin, AppOwnerTenantId, ApiPermissions, AssignedAzureRoles, AssignedEntraRoles, AuthenticatedBy, OwnedBy
274+
| project Timestamp, TimeGenerated, ServicePrincipalName, ServicePrincipalId, OAuthAppId, CriticalityLevel, AddedOnTime, LastModifiedTime, AppStatus, VerifiedPublisher, IsAdminConsented, AppOrigin, AppOwnerTenantId, ApiPermissions, AssignedAzureRoles, AssignedEntraRoles, AuthenticatedBy, OwnedBy, AccountStatus
277275
| extend Classification = case(
278276
AssignedEntraRoles has 'ControlPlane' or ApiPermissions has 'ControlPlane', 'ControlPlane',
279277
AssignedEntraRoles has 'ManagementPlane' or ApiPermissions has 'ManagementPlane', 'ManagementPlane',
@@ -289,7 +287,27 @@ function Get-MtXspmUnifiedIdentityInfo {
289287
| where Type == 'User'
290288
| where tolower(AccountDisplayName) contains tolower(ObjectName) and AccountObjectId contains tostring(ObjectId)
291289
| extend OnPremSynchronized = iff(isnotempty(OnPremObjectId), 'true', 'false')
292-
| extend IsDeleted = iff(isnotempty(DeletedDateTime), 'true', 'false');
290+
| extend IsDeleted = iff(isnotempty(DeletedDateTime), 'true', 'false')
291+
| extend AccountStatus = iff(IsAccountEnabled == true, 'Enabled', 'Disabled')
292+
// Enrichment to primary work account
293+
| join kind=leftouter (
294+
IdentityAccountInfo
295+
| where SourceProvider == @'AzureActiveDirectory'
296+
| where tolower(DisplayName) contains tolower(ObjectName) and SourceProviderAccountId contains tostring(ObjectId)
297+
| summarize arg_max(TimeGenerated, *) by AccountId
298+
| where IsPrimary == false
299+
| project TimeGenerated, DisplayName, SourceProviderAccountId, IdentityId, IdentityLinkBy, IdentityLinkType, IsPrimary, AccountId
300+
| join kind = leftouter (
301+
IdentityAccountInfo
302+
| where SourceProvider == @'AzureActiveDirectory'
303+
| summarize arg_max(TimeGenerated, *) by AccountId
304+
| where IsPrimary == true
305+
| project IdentityId, AccountObjectId = SourceProviderAccountId, AccountUpn, AccountStatus
306+
) on IdentityId
307+
| extend AssociatedPrimaryAccount = bag_pack_columns(AccountObjectId, AccountUpn, AccountStatus, IdentityLinkType, IdentityId)
308+
| project AccountObjectId = SourceProviderAccountId, AssociatedPrimaryAccount, PrimaryAccountObjectId = AccountObjectId
309+
) on AccountObjectId
310+
| project-away AccountObjectId1;
293311
let AllWorkloads = Int_WorkloadIdentityInfoXdr(ServicePrincipalName=tolower(ObjectName),ServicePrincipalObjectId=tostring(ObjectId))
294312
| extend Type = 'Workload'
295313
| project-rename AccountObjectId = ServicePrincipalId, AccountDisplayName = ServicePrincipalName;
@@ -300,8 +318,28 @@ function Get-MtXspmUnifiedIdentityInfo {
300318
| summarize arg_max(TimeGenerated, *) by AccountObjectId
301319
| where Type == 'User'
302320
| where tolower(AccountDisplayName) contains tolower(ObjectName) and AccountObjectId contains tostring(ObjectId)
321+
| extend AccountStatus = iff(IsAccountEnabled == true, 'Enabled', 'Disabled')
303322
| summarize arg_max(TimeGenerated, *) by AccountObjectId
304323
| join kind=anti (PrivilegedUsers | where TimeGenerated > ago(14d)) on AccountObjectId
324+
// Enrichment to primary work account
325+
| join kind=leftouter (
326+
IdentityAccountInfo
327+
| where SourceProvider == @'AzureActiveDirectory'
328+
| where tolower(DisplayName) contains tolower(ObjectName) and SourceProviderAccountId contains tostring(ObjectId)
329+
| summarize arg_max(TimeGenerated, *) by AccountId
330+
| where IsPrimary == false
331+
| project TimeGenerated, DisplayName, SourceProviderAccountId, IdentityId, IdentityLinkBy, IdentityLinkType, IsPrimary, AccountId
332+
| join kind = leftouter (
333+
IdentityAccountInfo
334+
| where SourceProvider == @'AzureActiveDirectory'
335+
| summarize arg_max(TimeGenerated, *) by AccountId
336+
| where IsPrimary == true
337+
| project IdentityId, AccountObjectId = SourceProviderAccountId, AccountUpn, AccountStatus
338+
) on IdentityId
339+
| extend AssociatedPrimaryAccount = bag_pack_columns(AccountObjectId, AccountUpn, IdentityLinkType, IdentityId, AccountStatus)
340+
| project AccountObjectId = SourceProviderAccountId, AssociatedPrimaryAccount, PrimaryAccountObjectId = AccountObjectId
341+
) on AccountObjectId
342+
| project-away AccountObjectId1
305343
| extend OnPremSynchronized = iff(isnotempty(OnPremObjectId), 'true', 'false')
306344
| extend IsDeleted = iff(isnotempty(DeletedDateTime), 'true', 'false')
307345
| project-away ReportId, AssignedRoles, PrivilegedEntraPimRoles;
@@ -322,9 +360,9 @@ function Get-MtXspmUnifiedIdentityInfo {
322360
| extend RuleName = tostring(CriticalityData)
323361
| extend ObjectId = iff(EntityType['type'] == 'AadObjectId', tolower(tostring(extract('objectid=([\\w-]+)', 1, tostring(parse_json(EntityIds)['id'])))), tolower(tostring(EntityType['id'])))
324362
| extend CriticalAssetDetail = bag_pack_columns(CriticalityLevel, RuleName)
325-
| summarize CriticalAssetDetails = make_set_if(CriticalAssetDetail, tostring(CriticalAssetDetail) !contains '') by AccountObjectId = ObjectId
363+
| summarize CriticalAssetDetails = make_set_if(CriticalAssetDetail, isnotempty(CriticalAssetDetail)) by AccountObjectId = ObjectId
326364
) on AccountObjectId
327-
| project-reorder AccountObjectId, AccountDisplayName, Type, CriticalityLevel, CriticalAssetDetails, Classification, AssignedAzureRoles, AssignedEntraRoles, ApiPermissions;
365+
| project-reorder AccountObjectId, AccountDisplayName, AccountStatus, Type, CriticalityLevel, CriticalAssetDetails, Classification, AssignedAzureRoles, AssignedEntraRoles, ApiPermissions, AssociatedPrimaryAccount;
328366
};
329367
// Lookback feature is limited to user identities only
330368
UnifiedIdentityInfoXdr(ObjectName='',ObjectId='',LookbackTimestamp=datetime(now))
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Reviewing the enabled status of a privileged account when the linked user identity has been disabled is critical to prevent orphaned high‑risk access. If a normal work account is deactivated (for example, because the user left the organization) but the related privileged account remains enabled, an attacker or former employee could still use that privileged identity to access sensitive systems, change security settings, or exfiltrate data unnoticed. Regularly checking and aligning the status of privileged accounts with their primary identities helps enforce least privilege, reduces the attack surface, and ensures that privileges are revoked promptly when a user’s employment or role ends.
2+
3+
### How to fix
4+
Review the results from this check and verify whether it is legitimate for the privileged user account to remain enabled when the associated primary work account has been disabled.
5+
6+
<!--- Results --->
7+
%TestResult%
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<#
2+
.SYNOPSIS
3+
Tests if enabled privileged users with assigned high privileged Entra ID roles or criticality level (<= 1) are linked to a disabled identity in Microsoft Defender XDR.
4+
5+
.DESCRIPTION
6+
This function checks if any enabled privileged users with assigned high privileged Entra ID roles or criticality level (<= 1) are linked to a disabled identity in Microsoft Defender XDR. Having enabled privileged users linked to disabled identities can pose a security risk, as it may indicate orphaned privileged accounts that could be exploited by attackers.
7+
8+
.OUTPUTS
9+
[bool] - Returns $true if no enabled privileged users are linked to disabled identities, otherwise returns $false.
10+
11+
.EXAMPLE
12+
Test-MtXspmEnabledPrivilegedUsersLinkedToDisabledIdentity
13+
14+
.LINK
15+
https://maester.dev/docs/commands/Test-MtXspmEnabledPrivilegedUsersLinkedToDisabledIdentity
16+
#>
17+
18+
function Test-MtXspmEnabledPrivilegedUsersLinkedToDisabledIdentity {
19+
[CmdletBinding()]
20+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'This test checks multiple users and roles.')]
21+
[OutputType([bool])]
22+
param()
23+
24+
$UnifiedIdentityInfoExecutable = Get-MtXspmUnifiedIdentityInfo -ValidateRequiredTablesOnly
25+
if ( $UnifiedIdentityInfoExecutable -eq $false) {
26+
Add-MtTestResultDetail -SkippedBecause 'Custom' -SkippedCustomReason 'This test requires availability of MDA App Governance and MDI to get data for Defender XDR Advanced Hunting tables. Check https://maester.dev/docs/tests/MT.1081/#Prerequisites for more information.'
27+
return $null
28+
}
29+
30+
try {
31+
Write-Verbose "Get details from UnifiedIdentityInfo ..."
32+
$UnifiedIdentityInfo = Get-MtXspmUnifiedIdentityInfo
33+
} catch {
34+
Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_
35+
return $null
36+
}
37+
38+
$EnabledPrivUsersToDisabledAccounts = $UnifiedIdentityInfo `
39+
| Where-Object {
40+
$_.Type -eq "User" `
41+
-and $_.AccountStatus -eq "Enabled" `
42+
-and (($_.Classification -eq "ControlPlane" -or $_.Classification -eq "ManagementPlane") -or $_.CriticalityLevel -le 1) `
43+
-and $_.AssociatedPrimaryAccount.AccountStatus -eq "Disabled" `
44+
} `
45+
| Sort-Object Classification, AccountDisplayName
46+
47+
$Severity = "Medium"
48+
49+
if ([string]::IsNullOrEmpty($EnabledPrivUsersToDisabledAccounts)) {
50+
$testResultMarkdown = "Well done. No enabled privileged or critical users linked to disabled identity."
51+
} else {
52+
$testResultMarkdown = "At least one enabled critical or privileged user is linked to a disabled identity.`n`n%TestResult%"
53+
54+
$result = "| AccountName | Classification | CriticalityLevel | Linked Identity |`n"
55+
$result += "| --- | --- | --- | --- |`n"
56+
57+
Write-Verbose "Found $($EnabledPrivUsersToDisabledAccounts.Count) enabled and privileged users linked to disabled identities in total."
58+
59+
foreach ($EnabledPrivUsersToDisabledAccount in $EnabledPrivUsersToDisabledAccounts) {
60+
$filteredDirectoryRoles = $EnabledPrivUsersToDisabledAccount.AssignedEntraRoles | Where-Object { $_.Classification -eq "ControlPlane" -or $_.RoleIsPrivileged -eq $True} | Select-Object RoleDefinitionName, Classification
61+
$UserSensitiveDirectoryRoles = $filteredDirectoryRoles | foreach-object { (Get-MtXspmPrivilegedClassificationIcon -AdminTierLevelName $_.Classification) + " " + $_.RoleDefinitionName }
62+
$UserSensitiveDirectoryRolesResult = @()
63+
$UserSensitiveDirectoryRoles | ForEach-Object {
64+
$UserSensitiveDirectoryRolesResult += '`' + $_ + '`'
65+
}
66+
$AdminTierLevelIcon = Get-MtXspmPrivilegedClassificationIcon -AdminTierLevelName $EnabledPrivUsersToDisabledAccount.Classification
67+
if ($EnabledPrivUsersToDisabledAccount.Classification -eq "ControlPlane") {
68+
$Severity = "High"
69+
}
70+
71+
$PrivilegedUserLink = "[$($EnabledPrivUsersToDisabledAccount.AccountDisplayName)](https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($EnabledPrivUsersToDisabledAccount.AccountObjectId))"
72+
$PrimaryIdentityLink = "[$($EnabledPrivUsersToDisabledAccount.AssociatedPrimaryAccount.AccountUpn)](https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($EnabledPrivUsersToDisabledAccount.AssociatedPrimaryAccount.AccountObjectId))"
73+
$result += "| $($AdminTierLevelIcon) $($PrivilegedUserLink) | $($EnabledPrivUsersToDisabledAccount.Classification) | $($EnabledPrivUsersToDisabledAccount.CriticalityLevel) | $($PrimaryIdentityLink) |`n"
74+
}
75+
$testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
76+
}
77+
Add-MtTestResultDetail -Result $testResultMarkdown -Severity $Severity
78+
79+
$result = [string]::IsNullOrEmpty($EnabledPrivUsersToDisabledAccounts)
80+
return $result
81+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Linking a privileged user account to the primary work account in Microsoft Defender XDR makes it easier to detect, prioritize, and contain attacks that target highly sensitive identities. It also improves incident response because all relevant activity and risk signals are correlated to the real person behind both identities, reducing blind spots and investigation time.
2+
3+
This use case is explicitly described in the Defender XDR documentation:
4+
A user might have two accounts, one for everyday work and another with elevated permissions for administrative tasks.
5+
Example
6+
7+
john.smith@company.com (regular account)
8+
john.smith.admin@company.com (privileged account)
9+
10+
### How to fix
11+
Review the accounts in the Identity inventory of Microsoft Defender portal and add a [manual link](https://learn.microsoft.com/en-us/defender-for-identity/link-unlink-account-to-identity) from the identity page of the (primary) user account to the privileged account.
12+
13+
<!--- Results --->
14+
%TestResult%

0 commit comments

Comments
 (0)