Problem
HealthTest-CertExpiry.ps1 currently derives severity primarily from certificate expiry state / days remaining. This can make an expired or imminently-expiring certificate look like a service-impacting failure even when a newer certificate is already installed and is a very likely renewal.
The health test should still report the old certificate because an operator must verify that all service/application bindings have moved. However, when a sufficiently strong replacement candidate exists, the severity can be reduced to reflect the lower operational risk.
Example using dummy certificate names:
Expired certificate:
Subject: CN=timesheet.example.com
NotBefore: 2025-08-05
NotAfter: 2026-08-06
Candidate:
Subject: CN=timesheet.example.com
NotBefore: 2026-07-31
NotAfter: 2027-02-15
HasPrivateKey: True
Same/covering SANs: True
Compatible EKUs: True
The candidate's NotBefore falls inside the final 20% of the old certificate's validity period, which is strong evidence that it is a renewal. This is still only evidence of supersession, not proof that application/service bindings have moved.
Proposed behavior
Keep the existing date-based severity calculation, then apply a replacement-candidate severity cap.
Suggested policy:
| Existing severity |
Replacement |
Effective severity |
| FAILURE |
none / Possible |
FAILURE |
| FAILURE |
Probable + ready |
WARNING |
| FAILURE |
Strong + ready |
NOTICE |
| WARNING |
none / Possible |
WARNING |
| WARNING |
Probable + ready |
WARNING |
| WARNING |
Strong + ready |
NOTICE |
| NOTICE |
any |
NOTICE |
The replacement candidate must have ReadyForMitigation = $true before it can reduce severity.
This is important for future-dated certificates:
- If the old certificate is already expired, the replacement must be valid now.
- If the old certificate has not expired yet, a future-dated replacement is acceptable only if it becomes valid no later than the old certificate's
NotAfter and remains valid beyond it.
- A candidate that leaves a validity gap must not reduce severity.
The warning text should continue to make clear that the operator is responsible for checking bindings. Suggested information to include:
Replacement candidate confidence: Strong
Replacement candidate thumbprint: <thumbprint>
Replacement validity: <NotBefore> through <NotAfter>
Ready for mitigation: Yes
Binding check: Not performed / or binding-specific result if available
Do not say that the old certificate "has been replaced" based only on certificate-store metadata. Prefer wording such as "Strong replacement candidate found".
Replacement detection
The following function is intended for Windows PowerShell 5.x. It separates candidate confidence from operational readiness.
NotBefore is used as the practical issuance-time proxy because X.509 certificates do not expose a separate generally useful issuance-date property.
function Find-CertificateReplacement {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Certificate,
[string]$StorePath = 'Cert:\LocalMachine\My',
[datetime]$AtTime = (Get-Date),
[ValidateRange(1,100)]
[int]$RenewalWindowPercent = 20,
[ValidateRange(0,365)]
[int]$PostExpiryGraceDays = 30
)
begin {
function Get-CertificateDnsNames {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Cert
)
$names = @()
if ($Cert.PSObject.Properties.Name -contains 'DnsNameList') {
foreach ($entry in @($Cert.DnsNameList)) {
if ($entry.PSObject.Properties.Name -contains 'Unicode') {
$names += $entry.Unicode
}
else {
$names += [string]$entry
}
}
}
if (-not $names) {
$san = $Cert.Extensions |
Where-Object { $_.Oid.Value -eq '2.5.29.17' }
if ($san) {
$formatted = $san.Format($true)
foreach ($match in [regex]::Matches(
$formatted,
'DNS Name=([^\r\n,]+)',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)) {
$names += $match.Groups[1].Value.Trim()
}
}
}
@(
$names |
Where-Object { $_ } |
ForEach-Object { $_.Trim().ToLowerInvariant() } |
Sort-Object -Unique
)
}
function Get-CertificateEkuOids {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Cert
)
$extension = $Cert.Extensions |
Where-Object { $_.Oid.Value -eq '2.5.29.37' }
if (-not $extension) {
return @()
}
try {
$eku = New-Object `
System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension(
$extension,
$extension.Critical
)
@(
$eku.EnhancedKeyUsages |
ForEach-Object { $_.Value } |
Sort-Object -Unique
)
}
catch {
@()
}
}
function Test-CertificateIsCa {
param(
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Cert
)
$extension = $Cert.Extensions |
Where-Object { $_.Oid.Value -eq '2.5.29.19' }
if (-not $extension) {
return $false
}
try {
$basicConstraints = New-Object `
System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(
$extension,
$extension.Critical
)
return $basicConstraints.CertificateAuthority
}
catch {
return $false
}
}
function Test-DnsNameCovered {
param(
[Parameter(Mandatory)]
[string]$RequiredName,
[Parameter(Mandatory)]
[string]$CandidateName
)
$required = $RequiredName.Trim().ToLowerInvariant()
$candidateNameNormalized = $CandidateName.Trim().ToLowerInvariant()
if ($required -eq $candidateNameNormalized) {
return $true
}
if (
$candidateNameNormalized.StartsWith('*.') -and
-not $required.StartsWith('*.')
) {
$suffix = $candidateNameNormalized.Substring(1)
if (-not $required.EndsWith($suffix)) {
return $false
}
$prefixLength = $required.Length - $suffix.Length
if ($prefixLength -le 0) {
return $false
}
$prefix = $required.Substring(0, $prefixLength)
return $prefix.IndexOf('.') -lt 0
}
return $false
}
function Test-DnsCoverage {
param(
[string[]]$RequiredNames,
[string[]]$CandidateNames
)
if ($RequiredNames.Count -eq 0) {
return $null
}
foreach ($requiredName in $RequiredNames) {
$covered = $false
foreach ($candidateName in $CandidateNames) {
if (Test-DnsNameCovered `
-RequiredName $requiredName `
-CandidateName $candidateName) {
$covered = $true
break
}
}
if (-not $covered) {
return $false
}
}
return $true
}
}
process {
$old = $Certificate
$oldDns = @(Get-CertificateDnsNames $old)
$oldEkus = @(Get-CertificateEkuOids $old)
$oldIsCa = Test-CertificateIsCa $old
$oldExpired = $old.NotAfter -le $AtTime
$validityDuration = $old.NotAfter - $old.NotBefore
$windowStartFraction = 1 - ($RenewalWindowPercent / 100.0)
$renewalWindowStart = $old.NotBefore.AddTicks(
[long]($validityDuration.Ticks * $windowStartFraction)
)
$renewalWindowEnd = $old.NotAfter.AddDays($PostExpiryGraceDays)
foreach ($candidate in Get-ChildItem $StorePath) {
if ($candidate.Thumbprint -eq $old.Thumbprint) {
continue
}
if ($candidate.NotAfter -le $old.NotAfter) {
continue
}
if ($candidate.NotAfter -le $AtTime) {
continue
}
$candidateDns = @(Get-CertificateDnsNames $candidate)
$candidateEkus = @(Get-CertificateEkuOids $candidate)
$candidateIsCa = Test-CertificateIsCa $candidate
$subjectStringMatch = $candidate.Subject -ieq $old.Subject
$subjectBinaryMatch = (
[Convert]::ToBase64String($candidate.SubjectName.RawData) -eq
[Convert]::ToBase64String($old.SubjectName.RawData)
)
$subjectMatch = $subjectStringMatch -or $subjectBinaryMatch
$dnsCoverage = Test-DnsCoverage `
-RequiredNames $oldDns `
-CandidateNames $candidateDns
if ($oldDns.Count -gt 0) {
$identityMatch = $dnsCoverage -eq $true
}
else {
$identityMatch = $subjectMatch
}
if (-not $identityMatch -and -not $subjectMatch) {
continue
}
if ($oldEkus.Count -eq 0) {
$ekuCoverage = $true
$missingEkus = @()
}
elseif ($candidateEkus.Count -eq 0) {
$ekuCoverage = $true
$missingEkus = @()
}
else {
$missingEkus = @(
$oldEkus |
Where-Object { $_ -notin $candidateEkus }
)
$ekuCoverage = $missingEkus.Count -eq 0
}
$privateKeyCompatible = (
-not $old.HasPrivateKey -or
$candidate.HasPrivateKey
)
$certificateRoleMatch = $oldIsCa -eq $candidateIsCa
$candidateValidNow = (
$candidate.NotBefore -le $AtTime -and
$candidate.NotAfter -gt $AtTime
)
$candidateUsableByOldExpiry = (
$candidate.NotBefore -le $old.NotAfter -and
$candidate.NotAfter -gt $old.NotAfter
)
if ($oldExpired) {
$readyForMitigation = $candidateValidNow
}
else {
$readyForMitigation = $candidateUsableByOldExpiry
}
$issuedInLastRenewalWindow = (
$candidate.NotBefore -ge $renewalWindowStart -and
$candidate.NotBefore -le $old.NotAfter
)
$issuedAroundRenewal = (
$candidate.NotBefore -ge $renewalWindowStart -and
$candidate.NotBefore -le $renewalWindowEnd
)
$missingDns = @()
if ($oldDns.Count -gt 0) {
foreach ($requiredName in $oldDns) {
$covered = $false
foreach ($candidateName in $candidateDns) {
if (Test-DnsNameCovered `
-RequiredName $requiredName `
-CandidateName $candidateName) {
$covered = $true
break
}
}
if (-not $covered) {
$missingDns += $requiredName
}
}
}
$replacementCompatible = (
$identityMatch -and
$ekuCoverage -and
$privateKeyCompatible -and
$certificateRoleMatch
)
if (
$replacementCompatible -and
$readyForMitigation -and
$issuedAroundRenewal
) {
$confidence = 'Strong'
}
elseif (
$replacementCompatible -and
$readyForMitigation
) {
$confidence = 'Probable'
}
else {
$confidence = 'Possible'
}
$score = 0
if ($identityMatch) { $score += 35 }
if ($subjectMatch) { $score += 5 }
if ($ekuCoverage) { $score += 15 }
if ($privateKeyCompatible) { $score += 15 }
if ($certificateRoleMatch) { $score += 10 }
if ($issuedAroundRenewal) { $score += 10 }
if ($readyForMitigation) { $score += 10 }
[pscustomobject]@{
Confidence = $confidence
Score = $score
ReadyForMitigation = $readyForMitigation
ReplacementCompatible = $replacementCompatible
OldExpired = $oldExpired
OldSubject = $old.Subject
OldIssuer = $old.Issuer
OldThumbprint = $old.Thumbprint
OldSerialNumber = $old.SerialNumber
OldNotBefore = $old.NotBefore
OldNotAfter = $old.NotAfter
OldHasPrivateKey = $old.HasPrivateKey
RenewalWindowPercent = $RenewalWindowPercent
RenewalWindowStart = $renewalWindowStart
RenewalWindowEnd = $renewalWindowEnd
PostExpiryGraceDays = $PostExpiryGraceDays
CandidateSubject = $candidate.Subject
CandidateIssuer = $candidate.Issuer
CandidateThumbprint = $candidate.Thumbprint
CandidateSerialNumber = $candidate.SerialNumber
CandidateNotBefore = $candidate.NotBefore
CandidateNotAfter = $candidate.NotAfter
CandidateHasPrivateKey = $candidate.HasPrivateKey
CandidateValidNow = $candidateValidNow
CandidateUsableByOldExpiry = $candidateUsableByOldExpiry
SubjectMatch = $subjectMatch
DnsCoverage = $dnsCoverage
EkuCoverage = $ekuCoverage
PrivateKeyCompatible = $privateKeyCompatible
CertificateRoleMatch = $certificateRoleMatch
IssuedInLastRenewalWindow = $issuedInLastRenewalWindow
IssuedAroundRenewal = $issuedAroundRenewal
IssuerMatch = $candidate.Issuer -ieq $old.Issuer
OldDnsNames = $oldDns -join '; '
CandidateDnsNames = $candidateDns -join '; '
MissingDnsNames = $missingDns -join '; '
OldEkuOids = $oldEkus -join '; '
CandidateEkuOids = $candidateEkus -join '; '
MissingEkuOids = $missingEkus -join '; '
}
}
}
}
function Get-BestCertificateReplacement {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[System.Security.Cryptography.X509Certificates.X509Certificate2]
$Certificate,
[string]$StorePath = 'Cert:\LocalMachine\My',
[datetime]$AtTime = (Get-Date),
[ValidateRange(1,100)]
[int]$RenewalWindowPercent = 20,
[ValidateRange(0,365)]
[int]$PostExpiryGraceDays = 30
)
process {
@(
Find-CertificateReplacement `
-Certificate $Certificate `
-StorePath $StorePath `
-AtTime $AtTime `
-RenewalWindowPercent $RenewalWindowPercent `
-PostExpiryGraceDays $PostExpiryGraceDays
) |
Sort-Object `
@{ Expression = 'Score'; Descending = $true },
@{ Expression = 'CandidateNotAfter'; Descending = $true } |
Select-Object -First 1
}
}
Suggested integration
Conceptually:
$replacement = Get-BestCertificateReplacement -Certificate $cert
if ($replacement -and $replacement.ReadyForMitigation) {
switch ($replacement.Confidence) {
'Strong' {
# Cap effective severity at NOTICE
}
'Probable' {
# Cap effective severity at WARNING
}
}
}
Candidate confidence must not suppress the certificate from output. It only informs severity. The operator remains responsible for checking IIS, HTTP.sys, RDP, WinRM, SQL Server, Azure Backup, or any other application/service-specific certificate reference before treating the candidate as an actual replacement or removing the old certificate.
Optional future enhancement
Where practical, the health test could add service-specific evidence such as IIS/HTTP.sys binding checks. That evidence should be separate from the certificate metadata confidence so the generic replacement detector remains reusable for non-IIS certificates.
Problem
HealthTest-CertExpiry.ps1currently derives severity primarily from certificate expiry state / days remaining. This can make an expired or imminently-expiring certificate look like a service-impacting failure even when a newer certificate is already installed and is a very likely renewal.The health test should still report the old certificate because an operator must verify that all service/application bindings have moved. However, when a sufficiently strong replacement candidate exists, the severity can be reduced to reflect the lower operational risk.
Example using dummy certificate names:
The candidate's
NotBeforefalls inside the final 20% of the old certificate's validity period, which is strong evidence that it is a renewal. This is still only evidence of supersession, not proof that application/service bindings have moved.Proposed behavior
Keep the existing date-based severity calculation, then apply a replacement-candidate severity cap.
Suggested policy:
The replacement candidate must have
ReadyForMitigation = $truebefore it can reduce severity.This is important for future-dated certificates:
NotAfterand remains valid beyond it.The warning text should continue to make clear that the operator is responsible for checking bindings. Suggested information to include:
Do not say that the old certificate "has been replaced" based only on certificate-store metadata. Prefer wording such as "Strong replacement candidate found".
Replacement detection
The following function is intended for Windows PowerShell 5.x. It separates candidate confidence from operational readiness.
NotBeforeis used as the practical issuance-time proxy because X.509 certificates do not expose a separate generally useful issuance-date property.Suggested integration
Conceptually:
Candidate confidence must not suppress the certificate from output. It only informs severity. The operator remains responsible for checking IIS, HTTP.sys, RDP, WinRM, SQL Server, Azure Backup, or any other application/service-specific certificate reference before treating the candidate as an actual replacement or removing the old certificate.
Optional future enhancement
Where practical, the health test could add service-specific evidence such as IIS/HTTP.sys binding checks. That evidence should be separate from the certificate metadata confidence so the generic replacement detector remains reusable for non-IIS certificates.