Skip to content

HealthTest-CertExpiry: lower severity when a strong replacement certificate candidate exists #139

Description

@ndemou

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions