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(