diff --git a/Diagnostics/HealthChecker/Analyzer/Add-AnalyzedResultInformation.ps1 b/Diagnostics/HealthChecker/Analyzer/Add-AnalyzedResultInformation.ps1
index 3ee5916828..a73d82ca26 100644
--- a/Diagnostics/HealthChecker/Analyzer/Add-AnalyzedResultInformation.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Add-AnalyzedResultInformation.ps1
@@ -56,24 +56,46 @@ function Add-AnalyzedResultInformation {
begin {
Write-Verbose "Calling $($MyInvocation.MyCommand): $name"
+ # Extract for Pester Testing - Start
function GetHtmlTextValue {
param(
[string]$OriginalValue
)
- # Test for all the changes, if they do not exist, just return now.
- if ([string]::IsNullOrEmpty($OriginalValue) -or
- ($OriginalValue.Contains(">") -eq $false -and
- $OriginalValue.Contains("<") -eq $false)) {
+ if ([string]::IsNullOrEmpty($OriginalValue)) {
return $OriginalValue
}
- Write-Verbose "Need to make changes for HTML text"
- Write-Verbose "Original Value: $OriginalValue"
- $OriginalValue = $OriginalValue.Replace(">", ">")
- $OriginalValue = $OriginalValue.Replace("<", "<")
- Write-Verbose "New Value: $OriginalValue"
+
+ # HTML encode < and > characters so they are not interpreted as HTML tags.
+ if ($OriginalValue.Contains("<") -or $OriginalValue.Contains(">")) {
+ Write-Verbose "Need to make changes for HTML text"
+ Write-Verbose "Original Value: $OriginalValue"
+ $OriginalValue = $OriginalValue.Replace(">", ">")
+ $OriginalValue = $OriginalValue.Replace("<", "<")
+ # Restore intentional
tags used for line breaks in multi-value HTML cells.
+ $OriginalValue = $OriginalValue.Replace("<br>", "
")
+ Write-Verbose "New Value: $OriginalValue"
+ }
+
+ # Convert URLs to clickable hyperlinks in the HTML report.
+ if ($OriginalValue.Contains("https://") -or $OriginalValue.Contains("http://")) {
+ $OriginalValue = [regex]::Replace($OriginalValue, '(https?://[^\s<>"''`]+)', {
+ param($match)
+ $url = $match.Groups[1].Value
+ # Strip trailing punctuation that is likely sentence-ending, not part of the URL.
+ $trailing = ""
+ while ($url.Length -gt 0 -and $url[-1] -match '[.,;)\]:]') {
+ $trailing = $url[-1] + $trailing
+ $url = $url.Substring(0, $url.Length - 1)
+ }
+ # cspell:ignore noopener noreferrer
+ return "$url$trailing"
+ })
+ }
+
return $OriginalValue
}
+ # Extract for Pester Testing - End
function GetOutColumnsColorObject {
param(
[object[]]$OutColumns,
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityADV24199947.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityADV24199947.ps1
index 586a437940..fa819c093a 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityADV24199947.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityADV24199947.ps1
@@ -35,6 +35,7 @@ function Invoke-AnalyzerSecurityADV24199947 {
DisplayWriteType = "Red"
Details = "{0}"
DisplayTestingValue = "ADV24199947"
+ AddHtmlDetailRow = $false
}
if ($SecurityObject.IsEdgeServer) {
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-21978.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-21978.ps1
index c1ab98d122..3c943ecc82 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-21978.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-21978.ps1
@@ -40,6 +40,7 @@ function Invoke-AnalyzerSecurityCve-2022-21978 {
Details = $null
DisplayWriteType = $null
DisplayTestingValue = "CVE-2022-21978"
+ AddHtmlDetailRow = $false
}
if ($null -ne $cveResults -or
$cveResults.Count -gt 0) {
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-41040.NotPublished.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-41040.NotPublished.ps1
index 540879caef..05e1be4201 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-41040.NotPublished.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCve-2022-41040.NotPublished.ps1
@@ -133,6 +133,7 @@ function Invoke-AnalyzerSecurityCve-2022-41040 {
Details = $details
DisplayWriteType = "Red"
DisplayTestingValue = "CVE-2022-41040"
+ AddHtmlDetailRow = $false
}
Add-AnalyzedResultInformation @params
}
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAddressedBySerializedDataSigning.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAddressedBySerializedDataSigning.ps1
index 4a7be3ff8e..6147199131 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAddressedBySerializedDataSigning.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAddressedBySerializedDataSigning.ps1
@@ -32,6 +32,7 @@ function Invoke-AnalyzerSecurityCveAddressedBySerializedDataSigning {
DisplayGroupingKey = $DisplayGroupingKey
Name = "Security Vulnerability"
DisplayWriteType = "Red"
+ AddHtmlDetailRow = $false
}
$detailsString = "{0}`r`n`t`tSee: https://portal.msrc.microsoft.com/security-guidance/advisory/{0} for more information."
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAndOverrideCheck.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAndOverrideCheck.ps1
index ed7192b29a..75df5bcf23 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAndOverrideCheck.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityCveAndOverrideCheck.ps1
@@ -72,6 +72,7 @@ function Invoke-AnalyzerSecurityCveAndOverrideCheck {
Details = ("{0}$(if($overrideDisabled){" - Disabled By Override"})`r`n`t`tSee: https://portal.msrc.microsoft.com/security-guidance/advisory/{0} for more information." -f $CVEName)
DisplayWriteType = "Red"
DisplayTestingValue = $CVEName
+ AddHtmlDetailRow = $false
}
Add-AnalyzedResultInformation @params
}
diff --git a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityExtendedProtectionConfigState.ps1 b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityExtendedProtectionConfigState.ps1
index 02b2899191..b3a4ddcc8a 100644
--- a/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityExtendedProtectionConfigState.ps1
+++ b/Diagnostics/HealthChecker/Analyzer/Security/Invoke-AnalyzerSecurityExtendedProtectionConfigState.ps1
@@ -67,6 +67,7 @@ function Invoke-AnalyzerSecurityExtendedProtectionConfigState {
TestingName = "Extended Protection Vulnerable"
CustomName = $cveList
DisplayTestingValue = $true
+ AddHtmlDetailRow = $false
}
$epBasicParams = $baseParams + @{
DisplayWriteType = "Red"
@@ -132,6 +133,7 @@ function Invoke-AnalyzerSecurityExtendedProtectionConfigState {
$epFrontEndParams = $baseParams + @{
Name = "Security Vulnerability"
+ AddHtmlDetailRow = $false
OutColumns = ([PSCustomObject]@{
DisplayObject = $epFrontEndOutputObjectDisplayValue
ColorizerFunctions = @($epConfig)
@@ -142,6 +144,7 @@ function Invoke-AnalyzerSecurityExtendedProtectionConfigState {
$epBackEndParams = $baseParams + @{
Name = "Security Vulnerability"
+ AddHtmlDetailRow = $false
OutColumns = ([PSCustomObject]@{
DisplayObject = $epBackEndOutputObjectDisplayValue
ColorizerFunctions = @($epConfig)
@@ -179,6 +182,7 @@ function Invoke-AnalyzerSecurityExtendedProtectionConfigState {
TestingName = "Extended Protection Vulnerable"
CustomName = $cveList
DisplayTestingValue = $true
+ AddHtmlDetailRow = $false
}
Add-AnalyzedResultInformation @params
} else {
diff --git a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1
index 3ce3d7e320..4ad67cffc8 100644
--- a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1
+++ b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1
@@ -13,6 +13,69 @@ Describe "Testing Health Checker by Mock Data Imports" {
. $PSScriptRoot\HealthCheckerTest.CommonMocks.NotPublished.ps1
}
+ Context "GetHtmlTextValue Unit Tests" {
+
+ It "Should return null for null input" {
+ $result = GetHtmlTextValue -OriginalValue $null
+ $result | Should -BeNullOrEmpty
+ }
+
+ It "Should return empty string for empty input" {
+ $result = GetHtmlTextValue -OriginalValue ""
+ $result | Should -Be ""
+ }
+
+ It "Should return plain text unchanged" {
+ $result = GetHtmlTextValue -OriginalValue "Exchange 2019 CU11"
+ $result | Should -Be "Exchange 2019 CU11"
+ }
+
+ It "Should encode angle brackets for certificate SAN values" {
+ $result = GetHtmlTextValue -OriginalValue "CN=mail.contoso.com"
+ $result | Should -Be "<SAN>CN=mail.contoso.com</SAN>"
+ }
+
+ It "Should encode greater-than sign" {
+ $result = GetHtmlTextValue -OriginalValue "Value > 100"
+ $result | Should -Be "Value > 100"
+ }
+
+ It "Should encode less-than sign" {
+ $result = GetHtmlTextValue -OriginalValue "Value < 100"
+ $result | Should -Be "Value < 100"
+ }
+
+ It "Should handle mixed content with angle brackets and normal text" {
+ $result = GetHtmlTextValue -OriginalValue "Status: - Check docs"
+ $result | Should -Be "Status: <Unknown> - Check docs"
+ }
+
+ It "Should preserve intentional br tags after encoding" {
+ $testValue = "CVE-2020-1147
CVE-2023-36434
"
+ $result = GetHtmlTextValue -OriginalValue $testValue
+ $result | Should -Be "CVE-2020-1147
CVE-2023-36434
"
+ }
+
+ It "Should convert URLs to clickable hyperlinks" {
+ $result = GetHtmlTextValue -OriginalValue "More Information: https://aka.ms/HC-ExBuilds"
+ # cspell:ignore noopener noreferrer
+ $result | Should -BeLike '*https://aka.ms/HC-ExBuilds'
+ }
+
+ It "Should convert URLs with trailing sentence punctuation" {
+ $result = GetHtmlTextValue -OriginalValue "See: https://portal.msrc.microsoft.com/security-guidance/advisory/CVE-2020-1147 for more information."
+ $result | Should -BeLike '*https://portal.msrc.microsoft.com/security-guidance/advisory/CVE-2020-1147 for more information.'
+ }
+
+ It "Should handle br tags combined with URLs in security vulnerability summary" {
+ $testValue = "CVE-2020-1147`r`n`t`tSee: https://portal.msrc.microsoft.com/security-guidance/advisory/CVE-2020-1147 for more information.
"
+ $result = GetHtmlTextValue -OriginalValue $testValue
+ $result | Should -BeLike "*
*"
+ $result | Should -Not -BeLike "*<br>*"
+ $result | Should -BeLike "***"
+ }
+ }
+
Context "Basic Exchange 2019 CU11 Testing HyperV" {
BeforeAll {
SetDefaultRunOfHealthChecker "Debug_HyperV_Results.xml"
@@ -192,6 +255,80 @@ Describe "Testing Health Checker by Mock Data Imports" {
$globalRulesWarning = GetObject "Global IIS Rewrite Rules"
$globalRulesWarning | Should -Not -BeNullOrEmpty
}
+
+ It "HTML Report - HtmlServerValues Structure" {
+ $Script:results.HtmlServerValues.ContainsKey("ServerDetails") | Should -Be $true
+ $Script:results.HtmlServerValues.ContainsKey("OverviewValues") | Should -Be $true
+ $Script:results.HtmlServerValues["ServerDetails"].Count | Should -BeGreaterThan 0
+ $Script:results.HtmlServerValues["OverviewValues"].Count | Should -BeGreaterThan 0
+
+ $firstDetail = $Script:results.HtmlServerValues["ServerDetails"][0]
+ $firstDetail.PSObject.Properties.Name | Should -Contain "Name"
+ $firstDetail.PSObject.Properties.Name | Should -Contain "DetailValue"
+ $firstDetail.PSObject.Properties.Name | Should -Contain "TableValue"
+ $firstDetail.PSObject.Properties.Name | Should -Contain "Class"
+
+ $firstOverview = $Script:results.HtmlServerValues["OverviewValues"][0]
+ $firstOverview.PSObject.Properties.Name | Should -Contain "Name"
+ $firstOverview.PSObject.Properties.Name | Should -Contain "DetailValue"
+
+ # ServerDetails captures most entries while OverviewValues is selective
+ $Script:results.HtmlServerValues["ServerDetails"].Count |
+ Should -BeGreaterThan $Script:results.HtmlServerValues["OverviewValues"].Count
+ }
+
+ It "HTML Report - Overview Values" {
+ $serverName = GetHtmlOverviewValue "Server Name"
+ $serverName | Should -Not -BeNullOrEmpty
+ $serverName.DetailValue | Should -Not -BeNullOrEmpty
+
+ $exchangeVersion = GetHtmlOverviewValue "Exchange Version"
+ $exchangeVersion | Should -Not -BeNullOrEmpty
+ $exchangeVersion.DetailValue | Should -Not -BeNullOrEmpty
+
+ $generationTime = GetHtmlOverviewValue "Generation Time"
+ $generationTime | Should -Not -BeNullOrEmpty
+
+ $vulnDetected = GetHtmlOverviewValue "Vulnerability Detected"
+ $vulnDetected | Should -Not -BeNullOrEmpty
+ if ($vulnDetected.DetailValue -ne "None") {
+ $vulnDetected.Class | Should -Be "Red"
+ }
+ }
+
+ It "HTML Report - ServerDetails CSS Class Mapping" {
+ # Grey write type → empty Class
+ $serverName = GetHtmlServerDetail "Server Name"
+ $serverName | Should -Not -BeNullOrEmpty
+ $serverName.Class | Should -BeNullOrEmpty
+
+ # Yellow write type → Yellow Class
+ $edition = GetHtmlServerDetail "Edition"
+ if ($null -ne $edition) {
+ $edition.Class | Should -Be "Yellow"
+ }
+ }
+
+ It "HTML Report - Security Vulnerabilities HTML Rendering" {
+ # Individual "Security Vulnerability" entries should NOT appear in ServerDetails.
+ # They are rolled up into the "Security Vulnerabilities" summary row.
+ # The regular CVE path sets AddHtmlDetailRow = $false, but the override CVE path
+ # in Invoke-AnalyzerSecurityCveAndOverrideCheck.ps1 is missing it.
+ $individualCveEntries = $Script:results.HtmlServerValues["ServerDetails"] |
+ Where-Object { $_.Name -eq "Security Vulnerability" }
+ $individualCveEntries | Should -BeNullOrEmpty
+
+ # The summary row should be present
+ $entry = GetHtmlServerDetail "Security Vulnerabilities"
+ $entry | Should -Not -BeNullOrEmpty
+
+ if ($null -ne $entry -and -not [string]::IsNullOrEmpty($entry.DetailValue)) {
+ $entry.DetailValue | Should -BeLike "*CVE-*"
+ # Validates the fix for the PR #2475 regression:
tags must be preserved
+ $entry.DetailValue | Should -Not -BeLike "*<br>*"
+ $entry.DetailValue | Should -BeLike "*
*"
+ }
+ }
}
Context "Basic Exchange 2019 CU11 Testing Physical" {
diff --git a/Diagnostics/HealthChecker/Tests/HealthCheckerTests.ImportCode.NotPublished.ps1 b/Diagnostics/HealthChecker/Tests/HealthCheckerTests.ImportCode.NotPublished.ps1
index 170b11a5fd..631a4278df 100644
--- a/Diagnostics/HealthChecker/Tests/HealthCheckerTests.ImportCode.NotPublished.ps1
+++ b/Diagnostics/HealthChecker/Tests/HealthCheckerTests.ImportCode.NotPublished.ps1
@@ -91,6 +91,24 @@ function TestObjectMatch {
Should -Be $WriteType
}
+function GetHtmlServerDetail {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true, Position = 1)]
+ [string]$Name
+ )
+ $Script:results.HtmlServerValues["ServerDetails"] | Where-Object { $_.Name -eq $Name }
+}
+
+function GetHtmlOverviewValue {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true, Position = 1)]
+ [string]$Name
+ )
+ $Script:results.HtmlServerValues["OverviewValues"] | Where-Object { $_.Name -eq $Name }
+}
+
function TestOutColumnObjectCompare {
[CmdletBinding()]
param(