Is your request related to a problem? Please describe.
As a security best practice, customers should remove legacy Exchange security groups that were
created by Exchange 2000/2003 and are no longer used by modern Exchange. These groups still carry
elevated privileges in Active Directory, so leaving unused groups in place is unnecessary standing
privilege that should be cleaned up to reduce attack surface. There is published guidance that these
groups should be removed once no legacy servers remain:
Health Checker is the natural place to surface this because it already inventories Exchange-related
AD security groups at the organization level.
Describe the request
Add a check to Health Checker that detects the presence of legacy Exchange security groups in AD and
emits a warning recommending that the customer review membership and delete them if unused.
Primary groups to detect:
Exchange Domain Servers — Global security group (one per domain)
Exchange Enterprise Servers — Domain Local security group (forest root domain)
Also include in v1:
Exchange Recipient Administrators — Universal security group (legacy Exchange 2007 role group)
and any other legacy role groups we decide to enumerate by name.
Suggested warning text (wording TBD):
Legacy Exchange security groups were detected in Active Directory. These groups are left over from
Exchange 2000/2003, are not used by modern Exchange, and carry elevated AD permissions. As a
security best practice, review their membership and delete them if they are no longer required.
More information: https://aka.ms/HC-LegacyExchangeGroups (aka.ms link TBD)
Where to implement (code review findings)
Health Checker already collects and analyzes Exchange AD security groups, so this fits cleanly into
the existing Organization Information collection + a new analyzer.
Data collection (Diagnostics/HealthChecker/DataCollection/OrganizationInformation/):
- Existing
Get-ExchangeWellKnownSecurityGroups.ps1 only resolves groups listed in the Exchange
container's otherWellKnownObjects attribute (see
Shared/ActiveDirectoryFunctions/Get-ExchangeOtherWellKnownObjects.ps1). The legacy
Exchange Domain Servers / Exchange Enterprise Servers groups are not in that list, so they
will not be found by the current code. A new dedicated lookup is required.
- Add a new collection function (e.g.
Get-ExchangeLegacySecurityGroups.ps1) that searches the
Global Catalog by group name/sAMAccountName. A single GC query covers every domain in the
forest in one round-trip (group objects of all scopes are published to the GC), so there is no need
to iterate each domain. Capture at minimum: distinguishedName, domain (derived from the DN), group
scope, and member count (informational only).
- Wire it into
Invoke-JobOrganizationInformation.ps1 inside the existing non-Edge block (alongside
Get-ExchangeWellKnownSecurityGroups / Get-ExchangeDomainsAclPermissions, ~lines 110-114) and add
the result to the returned object (e.g. a new LegacySecurityGroups property at ~line 295).
Analysis (Diagnostics/HealthChecker/Analyzer/):
- Surface this in the Organization Information section
(Invoke-AnalyzerOrganizationInformation.ps1) as a Yellow warning. This is the right home because
the finding is an org/AD configuration observation (a cleanup recommendation), not a security
vulnerability. The data is already on $HealthServerObject.OrganizationInformation, so the new
LegacySecurityGroups property is directly available there.
- Deliberately not placed in the "Security Vulnerability" section: that grouping feeds the HTML
overview "Vulnerability Detected" flag (see Invoke-AnalyzerSecurityVulnerability.ps1,
lines 30-31), and labeling a best-practice cleanup as a CVE-style vulnerability would be
misleading.
- Emit a Yellow warning naming each detected legacy group (and which domain it was found in), with a
"More Information" link.
Example implementation (illustrative, not final)
The snippets below show one possible implementation following existing Health Checker patterns. They
are a starting point only — naming, error handling, and edge cases should be finalized during
development.
1. New data collection function
Diagnostics/HealthChecker/DataCollection/OrganizationInformation/Get-ExchangeLegacySecurityGroups.ps1
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
. $PSScriptRoot\..\..\..\..\Shared\ErrorMonitorFunctions.ps1
function Get-ExchangeLegacySecurityGroups {
[CmdletBinding()]
param()
begin {
Write-Verbose "Calling: $($MyInvocation.MyCommand)"
$legacyGroups = New-Object System.Collections.Generic.List[object]
# Default sAMAccountName values of the legacy Exchange groups.
# NOTE: renamed groups cannot be reliably detected by name.
$legacyGroupNames = @(
"Exchange Domain Servers", # Global (one per domain)
"Exchange Enterprise Servers", # Domain local (forest root)
"Exchange Recipient Administrators" # Universal (legacy 2007 role group)
)
} process {
try {
# A single Global Catalog query covers the entire forest in one round-trip,
# regardless of how many domains exist. Group objects of every scope
# (universal / global / domain local) are published to the GC, so this finds
# all instances without iterating each domain.
$globalCatalog = ([System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Forest.FindGlobalCatalog()).Name
$searchRoot = [ADSI]("GC://$globalCatalog")
$filter = "(&(objectClass=group)(|" +
(($legacyGroupNames | ForEach-Object { "(sAMAccountName=$_)" }) -join "") + "))"
$searcher = New-Object System.DirectoryServices.DirectorySearcher($searchRoot, $filter)
$searcher.PageSize = 100
[void]$searcher.PropertiesToLoad.AddRange(@("distinguishedName", "sAMAccountName", "groupType", "member"))
foreach ($result in $searcher.FindAll()) {
$distinguishedName = [string]$result.Properties["distinguishedName"]
# Derive the domain FQDN from the DC= components of the DN.
$domain = ($distinguishedName -split "," |
Where-Object { $_ -like "DC=*" } |
ForEach-Object { $_.Substring(3) }) -join "."
# NOTE: the GC replicates FULL membership only for universal groups. For global /
# domain local groups the 'member' attribute is not in the GC, so MemberCount is
# best-effort. Severity is always Yellow, so the count is informational only.
$legacyGroups.Add([PSCustomObject]@{
Name = [string]$result.Properties["sAMAccountName"]
DistinguishedName = $distinguishedName
Domain = $domain
GroupType = [int]($result.Properties["groupType"][0])
MemberCount = $result.Properties["member"].Count
})
}
} catch {
Write-Verbose "Failed to query the Global Catalog for legacy Exchange groups"
Invoke-CatchActions
}
} end {
return $legacyGroups
}
}
2. Wire into Invoke-JobOrganizationInformation.ps1
# begin{} dot-source block (near the other Get-* includes, ~line 20):
. $PSScriptRoot\Get-ExchangeLegacySecurityGroups.ps1
# begin{} variable initialization (~line 35):
$legacySecurityGroups = $null
# Inside the non-Edge block, alongside the other AD collectors (~lines 110-114):
Get-ExchangeLegacySecurityGroups | Invoke-RemotePipelineHandler -Result ([ref]$legacySecurityGroups)
# Add to the returned object (~line 295):
LegacySecurityGroups = $legacySecurityGroups
3. Analyzer output in Invoke-AnalyzerOrganizationInformation.ps1
# Legacy Exchange security groups (security best practice cleanup)
$legacyGroups = $organizationInformation.LegacySecurityGroups
if ($null -ne $legacyGroups -and $legacyGroups.Count -gt 0) {
foreach ($group in $legacyGroups) {
# Severity is always Yellow; member count is shown for context only.
$params = $baseParams + @{
Name = "Legacy Exchange Security Group"
Details = "$($group.Name) ($($group.Domain)) - Members: $($group.MemberCount)"
DisplayWriteType = "Yellow"
}
Add-AnalyzedResultInformation @params
}
$params = $baseParams + @{
Details = "These legacy Exchange security groups are not used by modern Exchange " +
"and carry elevated AD permissions. As a security best practice, review their membership and " +
"delete them if no longer required. More Information: https://aka.ms/HC-LegacyExchangeGroups"
DisplayCustomTabNumber = 2
DisplayWriteType = "Yellow"
}
Add-AnalyzedResultInformation @params
} else {
Write-Verbose "No legacy Exchange security groups found."
}
Detection approach & caveats
- Global Catalog query: a single GC query returns matching group objects from every domain in the
forest in one round-trip, so we avoid a per-domain fan-out. Group objects of all scopes
(universal/global/domain local) are published to the GC.
- Member count is best-effort: the GC replicates full membership only for universal groups
(e.g. Exchange Recipient Administrators). For global / domain local groups (Exchange Domain
Servers / Exchange Enterprise Servers) the member attribute is not in the GC. Because severity is
always Yellow, member count is informational only; an exact count would require an optional rebind
to each found group's home-domain DC.
- Renamed groups: a group renamed away from its default name cannot be reliably caught by name.
Detection by name is limited; document this as a known gap.
- Duplicates: the same group name can exist in multiple domains (e.g. Exchange Domain Servers is
created per domain); report each occurrence with its domain.
- Permissions: AD reads may fail without sufficient rights; handle the "unable to determine" case
gracefully (the existing CVE-2022-21978 check already models this fallback).
Proposed output / severity
- Surface in the Organization Information section as a Yellow warning ("best practice"),
not a Red vulnerability. Severity is always Yellow regardless of membership.
- Include a "More Information" link (new aka.ms link pointing to the reference doc).
Testing
- Add Pester tests colocated with the data collection / analyzer (per repo
Tests/ convention) using
mocked AD results stored in Tests/Data/*.xml. Cover: group present + empty, group present + has
members, group absent, multi-domain, and the "unable to query AD" fallback.
Decisions
- Severity: always Yellow (best practice), regardless of membership count.
- Scope of v1: include
Exchange Recipient Administrators (and other legacy role groups) in
addition to the two server groups.
- Link: create a new
aka.ms link that redirects to the reference article.
Is your request related to a problem? Please describe.
As a security best practice, customers should remove legacy Exchange security groups that were
created by Exchange 2000/2003 and are no longer used by modern Exchange. These groups still carry
elevated privileges in Active Directory, so leaving unused groups in place is unnecessary standing
privilege that should be cleaned up to reduce attack surface. There is published guidance that these
groups should be removed once no legacy servers remain:
Health Checker is the natural place to surface this because it already inventories Exchange-related
AD security groups at the organization level.
Describe the request
Add a check to Health Checker that detects the presence of legacy Exchange security groups in AD and
emits a warning recommending that the customer review membership and delete them if unused.
Primary groups to detect:
Exchange Domain Servers— Global security group (one per domain)Exchange Enterprise Servers— Domain Local security group (forest root domain)Also include in v1:
Exchange Recipient Administrators— Universal security group (legacy Exchange 2007 role group)and any other legacy role groups we decide to enumerate by name.
Suggested warning text (wording TBD):
Where to implement (code review findings)
Health Checker already collects and analyzes Exchange AD security groups, so this fits cleanly into
the existing Organization Information collection + a new analyzer.
Data collection (
Diagnostics/HealthChecker/DataCollection/OrganizationInformation/):Get-ExchangeWellKnownSecurityGroups.ps1only resolves groups listed in the Exchangecontainer's
otherWellKnownObjectsattribute (seeShared/ActiveDirectoryFunctions/Get-ExchangeOtherWellKnownObjects.ps1). The legacyExchange Domain Servers/Exchange Enterprise Serversgroups are not in that list, so theywill not be found by the current code. A new dedicated lookup is required.
Get-ExchangeLegacySecurityGroups.ps1) that searches theGlobal Catalog by group name/
sAMAccountName. A single GC query covers every domain in theforest in one round-trip (group objects of all scopes are published to the GC), so there is no need
to iterate each domain. Capture at minimum: distinguishedName, domain (derived from the DN), group
scope, and member count (informational only).
Invoke-JobOrganizationInformation.ps1inside the existing non-Edge block (alongsideGet-ExchangeWellKnownSecurityGroups/Get-ExchangeDomainsAclPermissions, ~lines 110-114) and addthe result to the returned object (e.g. a new
LegacySecurityGroupsproperty at ~line 295).Analysis (
Diagnostics/HealthChecker/Analyzer/):(
Invoke-AnalyzerOrganizationInformation.ps1) as a Yellow warning. This is the right home becausethe finding is an org/AD configuration observation (a cleanup recommendation), not a security
vulnerability. The data is already on
$HealthServerObject.OrganizationInformation, so the newLegacySecurityGroupsproperty is directly available there.overview "Vulnerability Detected" flag (see
Invoke-AnalyzerSecurityVulnerability.ps1,lines 30-31), and labeling a best-practice cleanup as a CVE-style vulnerability would be
misleading.
"More Information" link.
Example implementation (illustrative, not final)
The snippets below show one possible implementation following existing Health Checker patterns. They
are a starting point only — naming, error handling, and edge cases should be finalized during
development.
1. New data collection function
Diagnostics/HealthChecker/DataCollection/OrganizationInformation/Get-ExchangeLegacySecurityGroups.ps12. Wire into
Invoke-JobOrganizationInformation.ps13. Analyzer output in
Invoke-AnalyzerOrganizationInformation.ps1Detection approach & caveats
forest in one round-trip, so we avoid a per-domain fan-out. Group objects of all scopes
(universal/global/domain local) are published to the GC.
(e.g. Exchange Recipient Administrators). For global / domain local groups (Exchange Domain
Servers / Exchange Enterprise Servers) the
memberattribute is not in the GC. Because severity isalways Yellow, member count is informational only; an exact count would require an optional rebind
to each found group's home-domain DC.
Detection by name is limited; document this as a known gap.
created per domain); report each occurrence with its domain.
gracefully (the existing CVE-2022-21978 check already models this fallback).
Proposed output / severity
not a Red vulnerability. Severity is always Yellow regardless of membership.
Testing
Tests/convention) usingmocked AD results stored in
Tests/Data/*.xml. Cover: group present + empty, group present + hasmembers, group absent, multi-domain, and the "unable to query AD" fallback.
Decisions
Exchange Recipient Administrators(and other legacy role groups) inaddition to the two server groups.
aka.mslink that redirects to the reference article.