Private/Invoke-InfraPulseTlsCheck.ps1

function Invoke-InfraPulseTlsCheck {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject]$Context,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Settings
    )

    $endpoints = @($Settings.Endpoints)
    if ($endpoints.Count -eq 0) {
        return New-InfraPulseResult -Status 'Skipped' -CheckName 'Tls' -Category 'Security' -ComputerName $Context.ComputerName -Target 'TLS endpoints' -Message 'No TLS endpoints are configured.' -Recommendation 'Add host entries under Checks.Tls.Endpoints to validate TLS handshakes, certificate identity, and expiry.'
    }

    $scriptBlock = {
        param($CheckSettings)

        foreach ($endpoint in @($CheckSettings.Endpoints)) {
            $hostName = [string]$endpoint.Host
            $port = if ($endpoint.Contains('Port')) { [int]$endpoint.Port } else { 443 }
            $sni = if ($endpoint.Contains('Sni') -and -not [string]::IsNullOrWhiteSpace([string]$endpoint.Sni)) { [string]$endpoint.Sni } else { $hostName }
            $displayName = if ($endpoint.Contains('Name') -and -not [string]::IsNullOrWhiteSpace([string]$endpoint.Name)) { [string]$endpoint.Name } else { "$hostName`:$port" }
            $timeout = if ($endpoint.Contains('TimeoutMilliseconds')) { [int]$endpoint.TimeoutMilliseconds } else { [int]$CheckSettings.TimeoutMilliseconds }

            $timer = [System.Diagnostics.Stopwatch]::StartNew()
            $client = New-Object System.Net.Sockets.TcpClient
            $waitHandle = $null
            $sslStream = $null

            # The validation callback runs synchronously on this thread during
            # AuthenticateAsClient; script scope carries its findings out.
            $script:InfraPulseTlsProbe = @{
                PolicyErrors = $null
                ChainStatus  = @()
            }

            try {
                $asyncResult = $client.BeginConnect($hostName, $port, $null, $null)
                $waitHandle = $asyncResult.AsyncWaitHandle
                if (-not $waitHandle.WaitOne($timeout, $false)) {
                    throw "Connection timed out after $timeout ms."
                }
                $client.EndConnect($asyncResult)
                $client.ReceiveTimeout = $timeout
                $client.SendTimeout = $timeout

                $validationCallback = [System.Net.Security.RemoteCertificateValidationCallback]{
                    param($senderObject, $callbackCertificate, $callbackChain, $sslPolicyErrors)
                    $null = $senderObject, $callbackCertificate
                    $script:InfraPulseTlsProbe.PolicyErrors = $sslPolicyErrors
                    if ($null -ne $callbackChain) {
                        $script:InfraPulseTlsProbe.ChainStatus = @(
                            foreach ($chainStatus in @($callbackChain.ChainStatus)) {
                                '{0}: {1}' -f $chainStatus.Status, ([string]$chainStatus.StatusInformation).Trim()
                            }
                        )
                    }
                    return $true
                }

                # .NET Framework negotiates only SSL3/TLS1.0 through the
                # protocol-less overload because Windows PowerShell does not opt
                # into system-default TLS versions, and modern endpoints reject
                # those protocols. Tls13 is deliberately not offered on Desktop:
                # older Framework builds throw on unsupported protocol flags.
                $sslProtocols = if ([string]$PSVersionTable.PSEdition -eq 'Core') {
                    [System.Security.Authentication.SslProtocols]::None
                }
                else {
                    [System.Security.Authentication.SslProtocols](3072 -bor 768 -bor 192)
                }

                $sslStream = New-Object System.Net.Security.SslStream($client.GetStream(), $false, $validationCallback)
                $sslStream.AuthenticateAsClient($sni, $null, $sslProtocols, $false)
                $timer.Stop()

                $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($sslStream.RemoteCertificate)
                $policyErrors = $script:InfraPulseTlsProbe.PolicyErrors
                $nameMatch = $true
                $chainTrusted = $true
                if ($null -ne $policyErrors) {
                    $nameMatch = (([int]$policyErrors) -band [int][System.Net.Security.SslPolicyErrors]::RemoteCertificateNameMismatch) -eq 0
                    $chainTrusted = (([int]$policyErrors) -band [int][System.Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors) -eq 0
                }

                [pscustomobject]@{
                    Name               = $displayName
                    Host               = $hostName
                    Port               = $port
                    Sni                = $sni
                    HandshakeSucceeded = $true
                    Protocol           = $sslStream.SslProtocol.ToString()
                    Subject            = [string]$certificate.Subject
                    Issuer             = [string]$certificate.Issuer
                    Thumbprint         = [string]$certificate.Thumbprint
                    SerialNumber       = [string]$certificate.SerialNumber
                    NotBefore          = [datetime]$certificate.NotBefore
                    NotAfter           = [datetime]$certificate.NotAfter
                    DaysRemaining      = [math]::Round((([datetime]$certificate.NotAfter) - (Get-Date)).TotalDays, 2)
                    NameMatch          = $nameMatch
                    ChainTrusted       = $chainTrusted
                    ChainStatus        = @($script:InfraPulseTlsProbe.ChainStatus)
                    PolicyErrors       = [string]$policyErrors
                    DurationMs         = [math]::Round($timer.Elapsed.TotalMilliseconds, 2)
                    TimeoutMs          = $timeout
                    Error              = $null
                }
            }
            catch {
                $timer.Stop()
                [pscustomobject]@{
                    Name               = $displayName
                    Host               = $hostName
                    Port               = $port
                    Sni                = $sni
                    HandshakeSucceeded = $false
                    Protocol           = $null
                    Subject            = $null
                    Issuer             = $null
                    Thumbprint         = $null
                    SerialNumber       = $null
                    NotBefore          = $null
                    NotAfter           = $null
                    DaysRemaining      = $null
                    NameMatch          = $false
                    ChainTrusted       = $false
                    ChainStatus        = @($script:InfraPulseTlsProbe.ChainStatus)
                    PolicyErrors       = [string]$script:InfraPulseTlsProbe.PolicyErrors
                    DurationMs         = [math]::Round($timer.Elapsed.TotalMilliseconds, 2)
                    TimeoutMs          = $timeout
                    Error              = $_.Exception.Message
                }
            }
            finally {
                if ($null -ne $sslStream) {
                    $sslStream.Dispose()
                }
                if ($null -ne $waitHandle) {
                    $waitHandle.Close()
                }
                $client.Close()
                Remove-Variable -Name InfraPulseTlsProbe -Scope Script -ErrorAction SilentlyContinue
            }
        }
    }

    $raw = @(Invoke-InfraPulseCommand -Context $Context -ScriptBlock $scriptBlock -ArgumentList @($Settings))
    $results = @()

    foreach ($endpoint in $raw) {
        $target = [string]$endpoint.Name
        $endpointLabel = '{0}:{1}' -f [string]$endpoint.Host, [int]$endpoint.Port

        if (-not [bool]$endpoint.HandshakeSucceeded) {
            $results += New-InfraPulseResult -Status 'Critical' -CheckName 'Tls' -Category 'Security' -ComputerName $Context.ComputerName -Target $target -Message "TLS handshake with $endpointLabel failed after $($endpoint.DurationMs) ms." -ObservedValue 'Handshake failed' -WarningThreshold ("Expiry <= {0} days" -f $Settings.WarningDays) -CriticalThreshold 'Handshake, identity, or trust failure' -Recommendation 'Validate listener state, TLS protocol support, firewall policy, and name resolution from the target host.' -Evidence (New-InfraPulseTlsEvidence -Endpoint $endpoint) -DurationMs ([double]$endpoint.DurationMs) -ErrorMessage ([string]$endpoint.Error)
            continue
        }

        $daysRemaining = [double]$endpoint.DaysRemaining
        $criticalReasons = @()
        if ($daysRemaining -lt 0) {
            $criticalReasons += "certificate expired $([math]::Abs([math]::Round($daysRemaining, 0))) day(s) ago on $(([datetime]$endpoint.NotAfter).ToString('yyyy-MM-dd'))"
        }
        if ([bool]$Settings.RequireNameMatch -and -not [bool]$endpoint.NameMatch) {
            $criticalReasons += "certificate identity does not match requested name '$($endpoint.Sni)'"
        }
        if ([bool]$Settings.RequireTrustedChain -and -not [bool]$endpoint.ChainTrusted) {
            $criticalReasons += 'certificate chain is not trusted on the evaluated host'
        }
        if ($daysRemaining -ge 0 -and $daysRemaining -le [double]$Settings.CriticalDays) {
            $criticalReasons += "certificate expires in $([math]::Round($daysRemaining, 0)) day(s) on $(([datetime]$endpoint.NotAfter).ToString('yyyy-MM-dd'))"
        }

        if ($criticalReasons.Count -gt 0) {
            $status = 'Critical'
            $message = 'TLS validation for {0} failed: {1}.' -f $endpointLabel, (($criticalReasons -join '; '))
            $recommendation = 'Renew or redeploy the certificate, correct the served identity and chain, and re-validate every dependent endpoint.'
        }
        elseif ($daysRemaining -le [double]$Settings.WarningDays) {
            $status = 'Warning'
            $message = "Certificate for $endpointLabel expires in $([math]::Round($daysRemaining, 0)) day(s) on $(([datetime]$endpoint.NotAfter).ToString('yyyy-MM-dd'))."
            $recommendation = 'Begin the renewal process and identify every service or endpoint using this certificate.'
        }
        else {
            $status = 'Healthy'
            $message = "TLS handshake with $endpointLabel succeeded using $($endpoint.Protocol); certificate is valid for $([math]::Round($daysRemaining, 0)) more day(s)."
            $recommendation = ''
        }

        $results += New-InfraPulseResult -Status $status -CheckName 'Tls' -Category 'Security' -ComputerName $Context.ComputerName -Target $target -Message $message -ObservedValue ("{0:N2} days" -f $daysRemaining) -WarningThreshold ("Expiry <= {0} days" -f $Settings.WarningDays) -CriticalThreshold ("Expiry <= {0} days, expired, or handshake/identity/trust failure" -f $Settings.CriticalDays) -Recommendation $recommendation -Evidence (New-InfraPulseTlsEvidence -Endpoint $endpoint) -DurationMs ([double]$endpoint.DurationMs) -ErrorMessage ([string]$endpoint.Error)
    }

    return $results
}

function New-InfraPulseTlsEvidence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject]$Endpoint
    )

    [ordered]@{
        Name               = [string]$Endpoint.Name
        Host               = [string]$Endpoint.Host
        Port               = [int]$Endpoint.Port
        Sni                = [string]$Endpoint.Sni
        HandshakeSucceeded = [bool]$Endpoint.HandshakeSucceeded
        Protocol           = [string]$Endpoint.Protocol
        Subject            = [string]$Endpoint.Subject
        Issuer             = [string]$Endpoint.Issuer
        Thumbprint         = [string]$Endpoint.Thumbprint
        SerialNumber       = [string]$Endpoint.SerialNumber
        NotBefore          = $Endpoint.NotBefore
        NotAfter           = $Endpoint.NotAfter
        DaysRemaining      = $Endpoint.DaysRemaining
        NameMatch          = [bool]$Endpoint.NameMatch
        ChainTrusted       = [bool]$Endpoint.ChainTrusted
        ChainStatus        = @($Endpoint.ChainStatus)
        PolicyErrors       = [string]$Endpoint.PolicyErrors
        HandshakeMs        = [double]$Endpoint.DurationMs
        TimeoutMs          = [int]$Endpoint.TimeoutMs
        Error              = [string]$Endpoint.Error
    }
}