Public/Test-CwEndpoint.ps1

function Test-CwEndpoint {
    <#
    .SYNOPSIS
        Live TLS probe against LDAPS/HTTPS/RDP endpoints - reads the
        certificate that is actually presented on the wire.
    .DESCRIPTION
        Accepts URIs of the form ldaps://host[:port] (default 636),
        https://host[:port] (default 443), rdp://host[:port] (default 3389) or
        plain host:port (raw TLS). For RDP the required X.224 protocol
        negotiation (PROTOCOL_SSL) is performed before the TLS handshake.
        Certificate validation is intentionally disabled - the point is to
        *see* the presented certificate, including broken ones.
    .EXAMPLE
        Test-CwEndpoint -Uri 'ldaps://dc01:636','https://intranet.contoso.local'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [string[]]$Uri,

        [int]$WarnDays = 30,
        [int]$CritDays = 7,
        [int]$TimeoutMs = 10000
    )

    process {
        foreach ($u in $Uri) {
            $scheme = 'tls'; $targetHost = $u; $port = 0
            if ($u -match '^(?<scheme>[a-z]+)://(?<host>[^:/]+)(:(?<port>\d+))?') {
                $scheme = $Matches.scheme
                $targetHost = $Matches.host
                $port = if ($Matches.port) { [int]$Matches.port } else { 0 }
            }
            elseif ($u -match '^(?<host>[^:/]+):(?<port>\d+)$') {
                $targetHost = $Matches.host
                $port = [int]$Matches.port
            }
            if ($port -eq 0) {
                $port = switch ($scheme) { 'ldaps' { 636 } 'https' { 443 } 'rdp' { 3389 } default { 443 } }
            }

            $result = [ordered]@{
                PSTypeName    = 'CertWatchtower.Endpoint'
                Uri           = $u
                TargetHost    = $targetHost
                Port          = $port
                Reachable     = $false
                Subject       = $null
                Issuer        = $null
                Thumbprint    = $null
                NotAfter      = $null
                DaysRemaining = $null
                SanDnsNames   = @()
                TlsProtocol   = $null
                Severity      = 'UNKNOWN'
                Error         = $null
            }

            $client = $null; $ssl = $null
            try {
                $client = [System.Net.Sockets.TcpClient]::new()
                $connect = $client.ConnectAsync($targetHost, $port)
                if (-not $connect.Wait($TimeoutMs)) { throw "TCP connect to ${targetHost}:${port} timed out." }
                $stream = $client.GetStream()
                $stream.ReadTimeout = $TimeoutMs
                $stream.WriteTimeout = $TimeoutMs

                if ($scheme -eq 'rdp' -or $port -eq 3389) {
                    # X.224 Connection Request with RDP_NEG_REQ requesting PROTOCOL_SSL
                    [byte[]]$negReq = 0x03, 0x00, 0x00, 0x13, 0x0E, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00,
                    0x01, 0x00, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00
                    $stream.Write($negReq, 0, $negReq.Length)
                    $buf = New-Object byte[] 19
                    $read = 0
                    while ($read -lt 19) {
                        $n = $stream.Read($buf, $read, 19 - $read)
                        if ($n -le 0) { break }
                        $read += $n
                    }
                    if ($read -lt 11 -or $buf[11] -ne 0x02) {
                        throw 'RDP negotiation did not grant TLS (server may enforce plain RDP security).'
                    }
                }

                $capturedChain = @{ Cert = $null }
                $callback = [System.Net.Security.RemoteCertificateValidationCallback] {
                    param($senderObj, $certificate, $chain, $errors)
                    $true  # always accept - we only want to inspect
                }
                $ssl = [System.Net.Security.SslStream]::new($stream, $false, $callback)
                $ssl.AuthenticateAsClient($targetHost)
                $capturedChain.Cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ssl.RemoteCertificate)

                $cert = $capturedChain.Cert
                $days = [math]::Round(($cert.NotAfter - (Get-Date)).TotalDays, 2)
                $san = @()
                foreach ($ext in $cert.Extensions) {
                    if ($ext.Oid.Value -eq '2.5.29.17') {
                        $san = @([regex]::Matches($ext.Format($false), 'DNS[ -]?Name\s*=\s*([^,\s]+)') |
                            ForEach-Object { $_.Groups[1].Value })
                    }
                }

                $result.Reachable = $true
                $result.Subject = $cert.Subject
                $result.Issuer = $cert.Issuer
                $result.Thumbprint = $cert.Thumbprint
                $result.NotAfter = $cert.NotAfter
                $result.DaysRemaining = $days
                $result.SanDnsNames = $san
                $result.TlsProtocol = "$($ssl.SslProtocol)"
                $result.Severity = ConvertTo-CwSeverity -DaysRemaining $days -WarnDays $WarnDays -CritDays $CritDays
            }
            catch {
                $result.Error = "$_"
                $result.Severity = 'UNKNOWN'
            }
            finally {
                if ($ssl) { $ssl.Dispose() }
                if ($client) { $client.Dispose() }
            }

            [pscustomobject]$result
        }
    }
}