Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,67 @@ function Invoke-AnalyzerOrganizationInformation {
Add-AnalyzedResultInformation @params
}

# Legacy Exchange security groups (security best practice cleanup).
# These groups were created by Exchange 2000/2003, are not used by modern Exchange, and still
# carry elevated AD permissions. We surface them here (Organization Information) as a Yellow
# best-practice warning - this is a configuration cleanup recommendation, not a vulnerability.
$legacySecurityGroups = $organizationInformation.LegacySecurityGroups

if ($null -ne $legacySecurityGroups -and $legacySecurityGroups.Count -gt 0) {
Write-Verbose "Found $($legacySecurityGroups.Count) legacy Exchange security group(s) to review"

$params = $baseParams + @{
Name = "Legacy Exchange Security Groups"
Details = $true
DisplayWriteType = "Yellow"
DisplayTestingValue = $true
AddHtmlDetailRow = $false
}
Add-AnalyzedResultInformation @params

$legacyGroupsDisplay = New-Object System.Collections.Generic.List[object]
foreach ($legacyGroup in $legacySecurityGroups) {
$legacyGroupsDisplay.Add([PSCustomObject]@{
DistinguishedName = $legacyGroup.DistinguishedName
Scope = $legacyGroup.Scope
Members = $legacyGroup.MemberCount
})
}

# Legacy groups are always flagged Yellow. The Members cell is only highlighted when the
# group actually has members; an empty Members count (0) is left at the default color.
$legacyGroupsColorizer = {
param ($o, $p)
if ($p -eq "Members") {
if ($o.$p -gt 0) {
"Yellow"
}
} else {
"Yellow"
}
}

$params = $baseParams + @{
OutColumns = ([PSCustomObject]@{
DisplayObject = $legacyGroupsDisplay
ColorizerFunctions = @($legacyGroupsColorizer)
IndentSpaces = 12
})
OutColumnsColorTests = @($legacyGroupsColorizer)
HtmlName = "Legacy Exchange Security Groups"
TestingName = "Legacy Exchange Security Groups Table"
}
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 they are no longer required. More Information: https://aka.ms/HC-LegacyExchangeGroups"
DisplayWriteType = "Yellow"
DisplayCustomTabNumber = 1
}
Add-AnalyzedResultInformation @params
} else {
Write-Verbose "No legacy Exchange security groups found."
}

Write-Verbose "Completed: $($MyInvocation.MyCommand) and took $($stopWatch.Elapsed.TotalSeconds) seconds"
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ function Invoke-AnalyzerSecurityCveCheck {
"Dec25SU" = (NewCveEntry @("CVE-2025-64666", "CVE-2025-64667") @($ex2016, $ex2019, $exSE))
"Feb26SU" = (NewCveEntry @("CVE-2026-21527") @($ex2016, $ex2019, $exSE))
"Jun26SU" = (NewCveEntry @("CVE-2026-42897", "CVE-2026-45500", "CVE-2026-45501", "CVE-2026-45502", "CVE-2026-45503", "CVE-2026-45504", "CVE-2026-47631") @($ex2016, $ex2019, $exSE))
"Jul26SU" = (NewCveEntry @("CVE-2026-55005", "CVE-2026-55006", "CVE-2026-55008", "CVE-2026-55009") @($ex2016, $ex2019, $exSE))
}

# Need to organize the list so oldest CVEs come out first.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

. $PSScriptRoot\..\..\..\..\Shared\ErrorMonitorFunctions.ps1
. $PSScriptRoot\..\..\..\..\Shared\ActiveDirectoryFunctions\Search-AllActiveDirectoryDomains.ps1

<#
.DESCRIPTION
Searches the forest for legacy Exchange security groups that were created by Exchange 2000/2003
and are no longer used by modern Exchange. These groups still carry elevated Active Directory
permissions, so as a security best practice they should be reviewed and removed if unused.

Search-AllActiveDirectoryDomains binds to a Global Catalog, so a single forest-wide query covers
every domain in one round-trip - group objects of all scopes (Global / Domain Local / Universal)
are published to the GC, and there is no need to iterate each domain individually.

NOTE: The GC replicates the full membership only for Universal groups. For Global and Domain
Local groups the 'member' attribute is not available from the GC, so MemberCount is best-effort
for those scopes and is informational only.

Reference: https://learn.microsoft.com/en-us/previous-versions/office/exchange-server-2010/gg576862(v=exchg.141)
#>
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 Exchange 2007 role group)
)
} process {
try {
$filter = "(&(objectClass=group)(|" +
(($legacyGroupNames | ForEach-Object { "(sAMAccountName=$_)" }) -join "") + "))"
$propertiesToLoad = @("distinguishedName", "sAMAccountName", "groupType", "member")

$searchResults = Search-AllActiveDirectoryDomains -Filter $filter -PropertiesToLoad $propertiesToLoad

foreach ($result in $searchResults) {
$distinguishedName = [string]($result.Properties["distinguishedName"][0])
Write-Verbose "Found legacy group: $distinguishedName"

# Decode the group scope from the low bits of the groupType attribute.
$groupTypeValue = [int]($result.Properties["groupType"][0])
$scope = switch ($groupTypeValue -band 0x0000000E) {
2 { "Global" }
4 { "DomainLocal" }
8 { "Universal" }
default { "Unknown" }
}

$legacyGroups.Add([PSCustomObject]@{
Name = [string]($result.Properties["sAMAccountName"][0])
DistinguishedName = $distinguishedName
Scope = $scope
MemberCount = $result.Properties["member"].Count
})
}
} catch {
Write-Verbose "Failed to query Active Directory for legacy Exchange security groups. Inner Exception: $_"
Invoke-CatchActions
}
Comment thread
lusassl-msft marked this conversation as resolved.
} end {
return $legacyGroups
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function Invoke-JobOrganizationInformation {
# Build Process to add functions.
. $PSScriptRoot\Get-ExchangeAdSchemaInformation.ps1
. $PSScriptRoot\Get-ExchangeDomainsAclPermissions.ps1
. $PSScriptRoot\Get-ExchangeLegacySecurityGroups.ps1
. $PSScriptRoot\Get-ExchangeWellKnownSecurityGroups.ps1
. $PSScriptRoot\Get-SecurityCve-2021-34470.ps1
. $PSScriptRoot\Get-SecurityCve-2022-21978.ps1
Expand All @@ -33,6 +34,7 @@ function Invoke-JobOrganizationInformation {
$getOrganizationConfig = $null
$domainsAclPermissions = $null
$wellKnownSecurityGroups = $null
$legacySecurityGroups = $null
$adSchemaInformation = $null
$getHybridConfiguration = $null
$getPartnerApplication = $null
Expand Down Expand Up @@ -111,6 +113,7 @@ function Invoke-JobOrganizationInformation {
Get-ExchangeAdSchemaInformation | Invoke-RemotePipelineHandler -Result ([ref]$adSchemaInformation)
Get-ExchangeDomainsAclPermissions | Invoke-RemotePipelineHandler -Result ([ref]$domainsAclPermissions)
Get-ExchangeWellKnownSecurityGroups | Invoke-RemotePipelineHandler -Result ([ref]$wellKnownSecurityGroups)
Get-ExchangeLegacySecurityGroups | Invoke-RemotePipelineHandler -Result ([ref]$legacySecurityGroups)
Get-ExchangeADSplitPermissionsEnabled -CatchActionFunction ${Function:Invoke-CatchActions} | Invoke-RemotePipelineHandler -Result ([ref]$isSplitADPermissions)

# Exchange Cmdlets
Expand Down Expand Up @@ -293,6 +296,7 @@ function Invoke-JobOrganizationInformation {
GetOrganizationConfig = $getOrganizationConfig
DomainsAclPermissions = $domainsAclPermissions
WellKnownSecurityGroups = $wellKnownSecurityGroups
LegacySecurityGroups = $legacySecurityGroups
AdSchemaInformation = $adSchemaInformation
GetHybridConfiguration = $getHybridConfiguration
GetPartnerApplication = $getPartnerApplication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Describe "Testing Health Checker by Mock Data Imports - Exchange SE" {
SetActiveDisplayGrouping "Security Vulnerability"

$cveTests = GetObject "Security Vulnerability"
$cveTests.Count | Should -Be 19
$cveTests.Count | Should -Be 23

$downloadDomains = GetObject "CVE-2021-1730"
$downloadDomains.DownloadDomainsEnabled | Should -Be "False"
Expand Down Expand Up @@ -347,6 +347,7 @@ Describe "Testing Health Checker by Mock Data Imports - Exchange SE" {
Assert-MockCalled Get-IISModules -Exactly 1 -Scope Context
Assert-MockCalled Get-ExchangeDiagnosticInfo -Exactly 2 -Scope Context
Assert-MockCalled Get-ExchangeADSplitPermissionsEnabled -Exactly 1 -Scope Context
Assert-MockCalled Search-AllActiveDirectoryDomains -Exactly 1 -Scope Context
Assert-MockCalled Get-DynamicDistributionGroup -Exactly 1 -Scope Context
Assert-MockCalled Get-ActiveSyncVirtualDirectory -Exactly 1 -Scope Context
Assert-MockCalled Get-AutodiscoverVirtualDirectory -Exactly 1 -Scope Context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,16 @@ Describe "Exchange SE Scenarios Testing" {
# Reset AuthServer to return both ACS + EvoSTS (S1 sets it to "ACS")
$Script:GetAuthServerMockDataType = "All"

# Legacy Exchange security groups present (security best practice cleanup).
# Mock the underlying AD search so the real Get-ExchangeLegacySecurityGroups logic runs.
Mock Search-AllActiveDirectoryDomains -ParameterFilter { $Filter -like "*Exchange Domain Servers*" } -MockWith {
return @(
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Domain Servers,CN=Users,DC=contoso,DC=com" -SamAccountName "Exchange Domain Servers" -GroupType -2147483646),
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Enterprise Servers,CN=Users,DC=contoso,DC=com" -SamAccountName "Exchange Enterprise Servers" -GroupType -2147483644),
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Recipient Administrators,OU=Microsoft Exchange Security Groups,DC=contoso,DC=com" -SamAccountName "Exchange Recipient Administrators" -GroupType -2147483640 -MemberCount 3)
Comment thread
lusassl-msft marked this conversation as resolved.
)
}

SetDefaultRunOfHealthChecker "Debug_SE_Scenario3_Physical_Results.xml"
}

Expand All @@ -482,6 +492,31 @@ Describe "Exchange SE Scenarios Testing" {
TestObjectMatch "Dynamic Distribution Group Public Folder Mailboxes Count" 2 -WriteType "Red"
}

It "Legacy Exchange Security Groups Detected" {
SetActiveDisplayGrouping "Organization Information"
TestObjectMatch "Legacy Exchange Security Groups" $true -WriteType "Yellow"
}

It "Legacy Exchange Security Groups Parsed Correctly" {
# Directly exercises the real Get-ExchangeLegacySecurityGroups parsing (scope decode from
# groupType, member count, DN) against the mocked Search-AllActiveDirectoryDomains data.
$legacyGroups = @(Get-ExchangeLegacySecurityGroups | Where-Object { $null -ne $_.DistinguishedName })
$legacyGroups.Count | Should -Be 3

$domainServers = $legacyGroups | Where-Object { $_.Name -eq "Exchange Domain Servers" }
$domainServers.Scope | Should -Be "Global"
$domainServers.MemberCount | Should -Be 0
$domainServers.DistinguishedName | Should -Be "CN=Exchange Domain Servers,CN=Users,DC=contoso,DC=com"

$enterpriseServers = $legacyGroups | Where-Object { $_.Name -eq "Exchange Enterprise Servers" }
$enterpriseServers.Scope | Should -Be "DomainLocal"
$enterpriseServers.MemberCount | Should -Be 0

$recipientAdmins = $legacyGroups | Where-Object { $_.Name -eq "Exchange Recipient Administrators" }
$recipientAdmins.Scope | Should -Be "Universal"
$recipientAdmins.MemberCount | Should -Be 3
}

It "Extended Protection Enabled" {
SetActiveDisplayGrouping "Exchange Information"
TestObjectMatch "Extended Protection Enabled (Any VDir)" $true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,39 @@ Mock Get-ExchangeWellKnownSecurityGroups {
return Import-Clixml "$Script:MockDataCollectionRoot\Exchange\GetExchangeWellKnownSecurityGroups.xml"
}

# Builds an object shaped like a System.DirectoryServices.SearchResult so the real
# Get-ExchangeLegacySecurityGroups parsing logic (scope decode, member count, DN handling) is tested.
# An absent 'member' (empty array) mimics the GC not replicating membership for Global/Domain Local groups.
function NewMockAdSearchResult {
Comment thread
lusassl-msft marked this conversation as resolved.
[CmdletBinding()]
param(
[string]$DistinguishedName,
[string]$SamAccountName,
[int]$GroupType,
[int]$MemberCount = 0
)

$members = @()
if ($MemberCount -gt 0) {
$members = @(1..$MemberCount | ForEach-Object { "CN=Member$_,CN=Users,DC=contoso,DC=com" })
}

return [PSCustomObject]@{
Properties = @{
distinguishedName = @($DistinguishedName)
sAMAccountName = @($SamAccountName)
groupType = @($GroupType)
member = $members
}
}
}

# Default: the legacy Exchange security groups are not present. Specific tests override this with a
# -ParameterFilter on $Filter to return mock search results for the legacy groups query.
Mock Search-AllActiveDirectoryDomains {
return @()
}

Mock Get-HttpProxySetting {
return Import-Clixml "$Script:MockDataCollectionRoot\OS\GetHttpProxySetting.xml"
}
Expand Down
12 changes: 8 additions & 4 deletions Shared/Get-ExchangeBuildVersionInformation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function Get-ExchangeBuildVersionInformation {
$cuReleaseDate = "07/01/2025"
$supportedBuildNumber = $true
}
(GetBuildVersion $exSE "RTM" -SU "Jun26SU") { $latestSUBuild = $true }
(GetBuildVersion $exSE "RTM" -SU "Jul26SU") { $latestSUBuild = $true }
}
} elseif ($exchangeVersion.Major -eq 15 -and $exchangeVersion.Minor -eq 2) {
Write-Verbose "Exchange 2019 is detected"
Expand All @@ -150,14 +150,14 @@ function Get-ExchangeBuildVersionInformation {
$cuReleaseDate = "02/10/2025"
$supportedBuildNumber = $true
}
(GetBuildVersion $ex19 "CU15" -SU "Jun26SU") { $latestSUBuild = $true }
(GetBuildVersion $ex19 "CU15" -SU "Jul26SU") { $latestSUBuild = $true }
{ $_ -lt (GetBuildVersion $ex19 "CU15") } {
$cuLevel = "CU14"
$cuReleaseDate = "02/13/2024"
$supportedBuildNumber = $true
$orgValue = 16762
}
(GetBuildVersion $ex19 "CU14" -SU "Jun26SU") { $latestSUBuild = $true }
(GetBuildVersion $ex19 "CU14" -SU "Jul26SU") { $latestSUBuild = $true }
{ $_ -lt (GetBuildVersion $ex19 "CU14") } {
$cuLevel = "CU13"
$cuReleaseDate = "05/03/2023"
Expand Down Expand Up @@ -254,7 +254,7 @@ function Get-ExchangeBuildVersionInformation {
$cuReleaseDate = "04/20/2022"
$supportedBuildNumber = $true
}
(GetBuildVersion $ex16 "CU23" -SU "Jun26SU") { $latestSUBuild = $true }
(GetBuildVersion $ex16 "CU23" -SU "Jul26SU") { $latestSUBuild = $true }
{ $_ -lt (GetBuildVersion $ex16 "CU23") } {
$cuLevel = "CU22"
$cuReleaseDate = "09/28/2021"
Expand Down Expand Up @@ -750,6 +750,7 @@ function GetExchangeBuildDictionary {
"Dec25SU" = "15.1.2507.63"
"Feb26SU" = "15.1.2507.66"
"Jun26SU" = "15.1.2507.69"
"Jul26SU" = "15.1.2507.71"
})
}
"Exchange2019" = @{
Expand Down Expand Up @@ -865,6 +866,7 @@ function GetExchangeBuildDictionary {
"Dec25SU" = "15.2.1544.37"
"Feb26SU" = "15.2.1544.39"
"Jun26SU" = "15.2.1544.41"
"Jul26SU" = "15.2.1544.43"
})
"CU15" = (NewCUAndSUObject "15.2.1748.10" @{
"Apr25HU" = "15.2.1748.24"
Expand All @@ -875,6 +877,7 @@ function GetExchangeBuildDictionary {
"Dec25SU" = "15.2.1748.42"
"Feb26SU" = "15.2.1748.43"
"Jun26SU" = "15.2.1748.46"
"Jul26SU" = "15.2.1748.48"
})
}
"ExchangeSE" = @{
Expand All @@ -886,6 +889,7 @@ function GetExchangeBuildDictionary {
"Feb26SU" = "15.2.2562.37"
"May26HU" = "15.2.2562.41"
"Jun26SU" = "15.2.2562.43"
"Jul26SU" = "15.2.2562.45"
})
}
}
Expand Down