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 } } } |