Skip to content

Commit c7cec00

Browse files
authored
Merge pull request #2566 from microsoft/July26SU
Jul26SU Release
2 parents 90a4b1c + e758b7e commit c7cec00

8 files changed

Lines changed: 217 additions & 5 deletions

Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerOrganizationInformation.ps1

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,5 +145,67 @@ function Invoke-AnalyzerOrganizationInformation {
145145
Add-AnalyzedResultInformation @params
146146
}
147147

148+
# Legacy Exchange security groups (security best practice cleanup).
149+
# These groups were created by Exchange 2000/2003, are not used by modern Exchange, and still
150+
# carry elevated AD permissions. We surface them here (Organization Information) as a Yellow
151+
# best-practice warning - this is a configuration cleanup recommendation, not a vulnerability.
152+
$legacySecurityGroups = $organizationInformation.LegacySecurityGroups
153+
154+
if ($null -ne $legacySecurityGroups -and $legacySecurityGroups.Count -gt 0) {
155+
Write-Verbose "Found $($legacySecurityGroups.Count) legacy Exchange security group(s) to review"
156+
157+
$params = $baseParams + @{
158+
Name = "Legacy Exchange Security Groups"
159+
Details = $true
160+
DisplayWriteType = "Yellow"
161+
DisplayTestingValue = $true
162+
AddHtmlDetailRow = $false
163+
}
164+
Add-AnalyzedResultInformation @params
165+
166+
$legacyGroupsDisplay = New-Object System.Collections.Generic.List[object]
167+
foreach ($legacyGroup in $legacySecurityGroups) {
168+
$legacyGroupsDisplay.Add([PSCustomObject]@{
169+
DistinguishedName = $legacyGroup.DistinguishedName
170+
Scope = $legacyGroup.Scope
171+
Members = $legacyGroup.MemberCount
172+
})
173+
}
174+
175+
# Legacy groups are always flagged Yellow. The Members cell is only highlighted when the
176+
# group actually has members; an empty Members count (0) is left at the default color.
177+
$legacyGroupsColorizer = {
178+
param ($o, $p)
179+
if ($p -eq "Members") {
180+
if ($o.$p -gt 0) {
181+
"Yellow"
182+
}
183+
} else {
184+
"Yellow"
185+
}
186+
}
187+
188+
$params = $baseParams + @{
189+
OutColumns = ([PSCustomObject]@{
190+
DisplayObject = $legacyGroupsDisplay
191+
ColorizerFunctions = @($legacyGroupsColorizer)
192+
IndentSpaces = 12
193+
})
194+
OutColumnsColorTests = @($legacyGroupsColorizer)
195+
HtmlName = "Legacy Exchange Security Groups"
196+
TestingName = "Legacy Exchange Security Groups Table"
197+
}
198+
Add-AnalyzedResultInformation @params
199+
200+
$params = $baseParams + @{
201+
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"
202+
DisplayWriteType = "Yellow"
203+
DisplayCustomTabNumber = 1
204+
}
205+
Add-AnalyzedResultInformation @params
206+
} else {
207+
Write-Verbose "No legacy Exchange security groups found."
208+
}
209+
148210
Write-Verbose "Completed: $($MyInvocation.MyCommand) and took $($stopWatch.Elapsed.TotalSeconds) seconds"
149211
}

Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveCheck.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ function Invoke-AnalyzerSecurityCveCheck {
145145
"Dec25SU" = (NewCveEntry @("CVE-2025-64666", "CVE-2025-64667") @($ex2016, $ex2019, $exSE))
146146
"Feb26SU" = (NewCveEntry @("CVE-2026-21527") @($ex2016, $ex2019, $exSE))
147147
"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))
148+
"Jul26SU" = (NewCveEntry @("CVE-2026-55005", "CVE-2026-55006", "CVE-2026-55008", "CVE-2026-55009") @($ex2016, $ex2019, $exSE))
148149
}
149150

150151
# Need to organize the list so oldest CVEs come out first.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
. $PSScriptRoot\..\..\..\..\Shared\ErrorMonitorFunctions.ps1
5+
. $PSScriptRoot\..\..\..\..\Shared\ActiveDirectoryFunctions\Search-AllActiveDirectoryDomains.ps1
6+
7+
<#
8+
.DESCRIPTION
9+
Searches the forest for legacy Exchange security groups that were created by Exchange 2000/2003
10+
and are no longer used by modern Exchange. These groups still carry elevated Active Directory
11+
permissions, so as a security best practice they should be reviewed and removed if unused.
12+
13+
Search-AllActiveDirectoryDomains binds to a Global Catalog, so a single forest-wide query covers
14+
every domain in one round-trip - group objects of all scopes (Global / Domain Local / Universal)
15+
are published to the GC, and there is no need to iterate each domain individually.
16+
17+
NOTE: The GC replicates the full membership only for Universal groups. For Global and Domain
18+
Local groups the 'member' attribute is not available from the GC, so MemberCount is best-effort
19+
for those scopes and is informational only.
20+
21+
Reference: https://learn.microsoft.com/en-us/previous-versions/office/exchange-server-2010/gg576862(v=exchg.141)
22+
#>
23+
function Get-ExchangeLegacySecurityGroups {
24+
[CmdletBinding()]
25+
param()
26+
begin {
27+
Write-Verbose "Calling: $($MyInvocation.MyCommand)"
28+
$legacyGroups = New-Object 'System.Collections.Generic.List[object]'
29+
30+
# Default sAMAccountName values of the legacy Exchange groups.
31+
# NOTE: Renamed groups cannot be reliably detected by name.
32+
$legacyGroupNames = @(
33+
"Exchange Domain Servers", # Global (one per domain)
34+
"Exchange Enterprise Servers", # Domain local (forest root)
35+
"Exchange Recipient Administrators" # Universal (legacy Exchange 2007 role group)
36+
)
37+
} process {
38+
try {
39+
$filter = "(&(objectClass=group)(|" +
40+
(($legacyGroupNames | ForEach-Object { "(sAMAccountName=$_)" }) -join "") + "))"
41+
$propertiesToLoad = @("distinguishedName", "sAMAccountName", "groupType", "member")
42+
43+
$searchResults = Search-AllActiveDirectoryDomains -Filter $filter -PropertiesToLoad $propertiesToLoad
44+
45+
foreach ($result in $searchResults) {
46+
$distinguishedName = [string]($result.Properties["distinguishedName"][0])
47+
Write-Verbose "Found legacy group: $distinguishedName"
48+
49+
# Decode the group scope from the low bits of the groupType attribute.
50+
$groupTypeValue = [int]($result.Properties["groupType"][0])
51+
$scope = switch ($groupTypeValue -band 0x0000000E) {
52+
2 { "Global" }
53+
4 { "DomainLocal" }
54+
8 { "Universal" }
55+
default { "Unknown" }
56+
}
57+
58+
$legacyGroups.Add([PSCustomObject]@{
59+
Name = [string]($result.Properties["sAMAccountName"][0])
60+
DistinguishedName = $distinguishedName
61+
Scope = $scope
62+
MemberCount = $result.Properties["member"].Count
63+
})
64+
}
65+
} catch {
66+
Write-Verbose "Failed to query Active Directory for legacy Exchange security groups. Inner Exception: $_"
67+
Invoke-CatchActions
68+
}
69+
} end {
70+
return $legacyGroups
71+
}
72+
}

Diagnostics/HealthChecker/DataCollection/OrganizationInformation/Invoke-JobOrganizationInformation.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function Invoke-JobOrganizationInformation {
1717
# Build Process to add functions.
1818
. $PSScriptRoot\Get-ExchangeAdSchemaInformation.ps1
1919
. $PSScriptRoot\Get-ExchangeDomainsAclPermissions.ps1
20+
. $PSScriptRoot\Get-ExchangeLegacySecurityGroups.ps1
2021
. $PSScriptRoot\Get-ExchangeWellKnownSecurityGroups.ps1
2122
. $PSScriptRoot\Get-SecurityCve-2021-34470.ps1
2223
. $PSScriptRoot\Get-SecurityCve-2022-21978.ps1
@@ -33,6 +34,7 @@ function Invoke-JobOrganizationInformation {
3334
$getOrganizationConfig = $null
3435
$domainsAclPermissions = $null
3536
$wellKnownSecurityGroups = $null
37+
$legacySecurityGroups = $null
3638
$adSchemaInformation = $null
3739
$getHybridConfiguration = $null
3840
$getPartnerApplication = $null
@@ -111,6 +113,7 @@ function Invoke-JobOrganizationInformation {
111113
Get-ExchangeAdSchemaInformation | Invoke-RemotePipelineHandler -Result ([ref]$adSchemaInformation)
112114
Get-ExchangeDomainsAclPermissions | Invoke-RemotePipelineHandler -Result ([ref]$domainsAclPermissions)
113115
Get-ExchangeWellKnownSecurityGroups | Invoke-RemotePipelineHandler -Result ([ref]$wellKnownSecurityGroups)
116+
Get-ExchangeLegacySecurityGroups | Invoke-RemotePipelineHandler -Result ([ref]$legacySecurityGroups)
114117
Get-ExchangeADSplitPermissionsEnabled -CatchActionFunction ${Function:Invoke-CatchActions} | Invoke-RemotePipelineHandler -Result ([ref]$isSplitADPermissions)
115118

116119
# Exchange Cmdlets
@@ -293,6 +296,7 @@ function Invoke-JobOrganizationInformation {
293296
GetOrganizationConfig = $getOrganizationConfig
294297
DomainsAclPermissions = $domainsAclPermissions
295298
WellKnownSecurityGroups = $wellKnownSecurityGroups
299+
LegacySecurityGroups = $legacySecurityGroups
296300
AdSchemaInformation = $adSchemaInformation
297301
GetHybridConfiguration = $getHybridConfiguration
298302
GetPartnerApplication = $getPartnerApplication

Diagnostics/HealthChecker/Tests/HealthChecker.SE.Main.Tests.ps1

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ Describe "Testing Health Checker by Mock Data Imports - Exchange SE" {
197197
SetActiveDisplayGrouping "Security Vulnerability"
198198

199199
$cveTests = GetObject "Security Vulnerability"
200-
$cveTests.Count | Should -Be 19
200+
$cveTests.Count | Should -Be 23
201201

202202
$downloadDomains = GetObject "CVE-2021-1730"
203203
$downloadDomains.DownloadDomainsEnabled | Should -Be "False"
@@ -347,6 +347,7 @@ Describe "Testing Health Checker by Mock Data Imports - Exchange SE" {
347347
Assert-MockCalled Get-IISModules -Exactly 1 -Scope Context
348348
Assert-MockCalled Get-ExchangeDiagnosticInfo -Exactly 2 -Scope Context
349349
Assert-MockCalled Get-ExchangeADSplitPermissionsEnabled -Exactly 1 -Scope Context
350+
Assert-MockCalled Search-AllActiveDirectoryDomains -Exactly 1 -Scope Context
350351
Assert-MockCalled Get-DynamicDistributionGroup -Exactly 1 -Scope Context
351352
Assert-MockCalled Get-ActiveSyncVirtualDirectory -Exactly 1 -Scope Context
352353
Assert-MockCalled Get-AutodiscoverVirtualDirectory -Exactly 1 -Scope Context

Diagnostics/HealthChecker/Tests/HealthChecker.SE.Scenarios.Tests.ps1

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,16 @@ Describe "Exchange SE Scenarios Testing" {
474474
# Reset AuthServer to return both ACS + EvoSTS (S1 sets it to "ACS")
475475
$Script:GetAuthServerMockDataType = "All"
476476

477+
# Legacy Exchange security groups present (security best practice cleanup).
478+
# Mock the underlying AD search so the real Get-ExchangeLegacySecurityGroups logic runs.
479+
Mock Search-AllActiveDirectoryDomains -ParameterFilter { $Filter -like "*Exchange Domain Servers*" } -MockWith {
480+
return @(
481+
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Domain Servers,CN=Users,DC=contoso,DC=com" -SamAccountName "Exchange Domain Servers" -GroupType -2147483646),
482+
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Enterprise Servers,CN=Users,DC=contoso,DC=com" -SamAccountName "Exchange Enterprise Servers" -GroupType -2147483644),
483+
(NewMockAdSearchResult -DistinguishedName "CN=Exchange Recipient Administrators,OU=Microsoft Exchange Security Groups,DC=contoso,DC=com" -SamAccountName "Exchange Recipient Administrators" -GroupType -2147483640 -MemberCount 3)
484+
)
485+
}
486+
477487
SetDefaultRunOfHealthChecker "Debug_SE_Scenario3_Physical_Results.xml"
478488
}
479489

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

495+
It "Legacy Exchange Security Groups Detected" {
496+
SetActiveDisplayGrouping "Organization Information"
497+
TestObjectMatch "Legacy Exchange Security Groups" $true -WriteType "Yellow"
498+
}
499+
500+
It "Legacy Exchange Security Groups Parsed Correctly" {
501+
# Directly exercises the real Get-ExchangeLegacySecurityGroups parsing (scope decode from
502+
# groupType, member count, DN) against the mocked Search-AllActiveDirectoryDomains data.
503+
$legacyGroups = @(Get-ExchangeLegacySecurityGroups | Where-Object { $null -ne $_.DistinguishedName })
504+
$legacyGroups.Count | Should -Be 3
505+
506+
$domainServers = $legacyGroups | Where-Object { $_.Name -eq "Exchange Domain Servers" }
507+
$domainServers.Scope | Should -Be "Global"
508+
$domainServers.MemberCount | Should -Be 0
509+
$domainServers.DistinguishedName | Should -Be "CN=Exchange Domain Servers,CN=Users,DC=contoso,DC=com"
510+
511+
$enterpriseServers = $legacyGroups | Where-Object { $_.Name -eq "Exchange Enterprise Servers" }
512+
$enterpriseServers.Scope | Should -Be "DomainLocal"
513+
$enterpriseServers.MemberCount | Should -Be 0
514+
515+
$recipientAdmins = $legacyGroups | Where-Object { $_.Name -eq "Exchange Recipient Administrators" }
516+
$recipientAdmins.Scope | Should -Be "Universal"
517+
$recipientAdmins.MemberCount | Should -Be 3
518+
}
519+
485520
It "Extended Protection Enabled" {
486521
SetActiveDisplayGrouping "Exchange Information"
487522
TestObjectMatch "Extended Protection Enabled (Any VDir)" $true

Diagnostics/HealthChecker/Tests/HealthCheckerTest.CommonMocks.NotPublished.ps1

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,39 @@ Mock Get-ExchangeWellKnownSecurityGroups {
154154
return Import-Clixml "$Script:MockDataCollectionRoot\Exchange\GetExchangeWellKnownSecurityGroups.xml"
155155
}
156156

157+
# Builds an object shaped like a System.DirectoryServices.SearchResult so the real
158+
# Get-ExchangeLegacySecurityGroups parsing logic (scope decode, member count, DN handling) is tested.
159+
# An absent 'member' (empty array) mimics the GC not replicating membership for Global/Domain Local groups.
160+
function NewMockAdSearchResult {
161+
[CmdletBinding()]
162+
param(
163+
[string]$DistinguishedName,
164+
[string]$SamAccountName,
165+
[int]$GroupType,
166+
[int]$MemberCount = 0
167+
)
168+
169+
$members = @()
170+
if ($MemberCount -gt 0) {
171+
$members = @(1..$MemberCount | ForEach-Object { "CN=Member$_,CN=Users,DC=contoso,DC=com" })
172+
}
173+
174+
return [PSCustomObject]@{
175+
Properties = @{
176+
distinguishedName = @($DistinguishedName)
177+
sAMAccountName = @($SamAccountName)
178+
groupType = @($GroupType)
179+
member = $members
180+
}
181+
}
182+
}
183+
184+
# Default: the legacy Exchange security groups are not present. Specific tests override this with a
185+
# -ParameterFilter on $Filter to return mock search results for the legacy groups query.
186+
Mock Search-AllActiveDirectoryDomains {
187+
return @()
188+
}
189+
157190
Mock Get-HttpProxySetting {
158191
return Import-Clixml "$Script:MockDataCollectionRoot\OS\GetHttpProxySetting.xml"
159192
}

Shared/Get-ExchangeBuildVersionInformation.ps1

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function Get-ExchangeBuildVersionInformation {
131131
$cuReleaseDate = "07/01/2025"
132132
$supportedBuildNumber = $true
133133
}
134-
(GetBuildVersion $exSE "RTM" -SU "Jun26SU") { $latestSUBuild = $true }
134+
(GetBuildVersion $exSE "RTM" -SU "Jul26SU") { $latestSUBuild = $true }
135135
}
136136
} elseif ($exchangeVersion.Major -eq 15 -and $exchangeVersion.Minor -eq 2) {
137137
Write-Verbose "Exchange 2019 is detected"
@@ -150,14 +150,14 @@ function Get-ExchangeBuildVersionInformation {
150150
$cuReleaseDate = "02/10/2025"
151151
$supportedBuildNumber = $true
152152
}
153-
(GetBuildVersion $ex19 "CU15" -SU "Jun26SU") { $latestSUBuild = $true }
153+
(GetBuildVersion $ex19 "CU15" -SU "Jul26SU") { $latestSUBuild = $true }
154154
{ $_ -lt (GetBuildVersion $ex19 "CU15") } {
155155
$cuLevel = "CU14"
156156
$cuReleaseDate = "02/13/2024"
157157
$supportedBuildNumber = $true
158158
$orgValue = 16762
159159
}
160-
(GetBuildVersion $ex19 "CU14" -SU "Jun26SU") { $latestSUBuild = $true }
160+
(GetBuildVersion $ex19 "CU14" -SU "Jul26SU") { $latestSUBuild = $true }
161161
{ $_ -lt (GetBuildVersion $ex19 "CU14") } {
162162
$cuLevel = "CU13"
163163
$cuReleaseDate = "05/03/2023"
@@ -254,7 +254,7 @@ function Get-ExchangeBuildVersionInformation {
254254
$cuReleaseDate = "04/20/2022"
255255
$supportedBuildNumber = $true
256256
}
257-
(GetBuildVersion $ex16 "CU23" -SU "Jun26SU") { $latestSUBuild = $true }
257+
(GetBuildVersion $ex16 "CU23" -SU "Jul26SU") { $latestSUBuild = $true }
258258
{ $_ -lt (GetBuildVersion $ex16 "CU23") } {
259259
$cuLevel = "CU22"
260260
$cuReleaseDate = "09/28/2021"
@@ -750,6 +750,7 @@ function GetExchangeBuildDictionary {
750750
"Dec25SU" = "15.1.2507.63"
751751
"Feb26SU" = "15.1.2507.66"
752752
"Jun26SU" = "15.1.2507.69"
753+
"Jul26SU" = "15.1.2507.71"
753754
})
754755
}
755756
"Exchange2019" = @{
@@ -865,6 +866,7 @@ function GetExchangeBuildDictionary {
865866
"Dec25SU" = "15.2.1544.37"
866867
"Feb26SU" = "15.2.1544.39"
867868
"Jun26SU" = "15.2.1544.41"
869+
"Jul26SU" = "15.2.1544.43"
868870
})
869871
"CU15" = (NewCUAndSUObject "15.2.1748.10" @{
870872
"Apr25HU" = "15.2.1748.24"
@@ -875,6 +877,7 @@ function GetExchangeBuildDictionary {
875877
"Dec25SU" = "15.2.1748.42"
876878
"Feb26SU" = "15.2.1748.43"
877879
"Jun26SU" = "15.2.1748.46"
880+
"Jul26SU" = "15.2.1748.48"
878881
})
879882
}
880883
"ExchangeSE" = @{
@@ -886,6 +889,7 @@ function GetExchangeBuildDictionary {
886889
"Feb26SU" = "15.2.2562.37"
887890
"May26HU" = "15.2.2562.41"
888891
"Jun26SU" = "15.2.2562.43"
892+
"Jul26SU" = "15.2.2562.45"
889893
})
890894
}
891895
}

0 commit comments

Comments
 (0)