Private/Kinds/HttpsEndpoint.ps1
|
# The HttpsEndpoint Kind: whether an HTTPS service can really be reached from here. # # The Server Kind stops at TCP, and TCP opening says nothing about what Outlook meets on # the way: a proxy that wants a password, a firewall that decrypts and re-signs the # traffic, a block page answering in the service's place, a certificate about to expire. # All of those let the port open. This Kind makes one real request per endpoint and keeps # what came back - status, challenge, certificate, timings, the proxy it went through - # so the Judge can tell them apart. # # Generic on purpose: "reachable, not intercepted, certificate valid, expected challenge" # is the same question for Exchange Online, a Customer's on-prem Exchange and any # vendor's cloud service, so it is answered once and pointed at a URL by a Definition. # # Credentials are never sent, by design and not by default: there is no parameter that # would send them. The Bearer probe is an EMPTY Authorization header, which carries # nothing and is what makes Exchange Online state its sign-in challenge (research, # observed 2026-09-24 and again 2026-09-25). A Definition that asks for the user's # Windows credentials is told so in a Finding rather than obeyed. # # Each entry of the Endpoints parameter is an object: # Url https:// address to request (required) # Method GET (default), POST, HEAD, ...; a body-less POST carries Content-Length: 0 # BearerProbe true to send the empty "Authorization: Bearer" header (default false) # ExpectStatus the status codes that mean "the service answered" (default [200]) # ExpectChallenge the WWW-Authenticate scheme the answer must offer, e.g. "Bearer" or # "Negotiate" (default: none required) # Parameter names that ask for the user's identity to be sent. None of them does anything; # naming them is how the Judge can say so instead of the Definition silently doing less # than its author believed. $script:HttpsEndpointCredentialNames = @('UseDefaultCredentials', 'Credential', 'Credentials') # How long any one step of a probe may take. Not a Check Definition parameter, for the # reason Private/Sampling.ps1 gives about attempts: it bounds how long a Run can hang on # a dead network, it is not a judgement of how fast the service should be. $script:HttpsEndpointTimeoutMs = 10000 function ConvertTo-HttpsEndpointTarget { <# .SYNOPSIS Reads one entry of the Endpoints parameter into what to request and what to expect. Pure, and reaches nothing. .DESCRIPTION An entry arrives as ConvertFrom-Json made it or as a hashtable, and a hand-edited Definition writes true as "true" and a single status as a number rather than a list. Both halves of the Kind read entries through here, so the Gatherer requests exactly what the Judge will hold the answer against. A broken entry is not dropped: it comes back with a Problem, so the Report can name a mistyped Definition instead of reporting a service as unreachable. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Entry) $url = "$(Get-DataProperty $Entry 'Url')".Trim() $method = "$(Get-DataProperty $Entry 'Method')".Trim().ToUpperInvariant() if (-not $method) { $method = 'GET' } $bearer = Get-DataProperty $Entry 'BearerProbe' if ($bearer -isnot [bool]) { $bearer = "$bearer".Trim() -eq 'true' } $expect = @(@(Get-DataProperty $Entry 'ExpectStatus') | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ }) if (-not $expect.Count) { $expect = @(200) } $challenge = "$(Get-DataProperty $Entry 'ExpectChallenge')".Trim() if (-not $challenge) { $challenge = $null } $names = @() if ($Entry -is [hashtable]) { $names = @($Entry.Keys) } elseif ($null -ne $Entry) { $names = @($Entry.PSObject.Properties.Name) } $asks = @($names | Where-Object { $script:HttpsEndpointCredentialNames -contains $_ }).Count -gt 0 $uri = $null $problem = $null if (-not [uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') { $problem = 'NotHttps' $uri = $null } # The method is written onto the wire as it stands, so anything but a token would let # a Definition write its own request line and headers. elseif ($method -notmatch '^[A-Z]+$') { $problem = 'BadMethod' } [pscustomobject]@{ PSTypeName = 'Gutcheck.HttpsEndpointTarget' Url = $url Method = $method Key = '{0} {1}' -f $method, $url HostName = $(if ($uri) { $uri.Host } else { $null }) # Host and path, without the query: what a Technician needs to recognise the # endpoint, without a placeholder mailbox address in every Check name. Label = $(if ($uri) { $uri.Host + $uri.AbsolutePath } else { $url }) BearerProbe = [bool]$bearer ExpectStatus = $expect ExpectChallenge = $challenge AsksForCredentials = $asks Problem = $problem } } function ConvertFrom-HttpResponseHead { <# .SYNOPSIS Reads the status line and headers of an HTTP/1.x response. Pure. .DESCRIPTION Headers come back as a list rather than a map, because WWW-Authenticate is routinely sent more than once and every one of them is evidence. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) if (-not $Text) { return } $lines = $Text -split "`r?`n" if ($lines[0] -notmatch '^HTTP/\d(?:\.\d)?\s+(?<code>\d{3})(?:\s+(?<reason>.*))?$') { return } $status = [int]$Matches['code'] $reason = "$($Matches['reason'])".Trim() $headers = @(foreach ($line in ($lines | Select-Object -Skip 1)) { if (-not $line) { break } $colon = $line.IndexOf(':') if ($colon -lt 1) { continue } [pscustomobject]@{ Name = $line.Substring(0, $colon).Trim(); Value = $line.Substring($colon + 1).Trim() } }) [pscustomobject]@{ PSTypeName = 'Gutcheck.HttpResponseHead' StatusCode = $status Reason = $reason Header = $headers } } function ConvertFrom-SubjectAltName { <# .SYNOPSIS The DNS names in a subjectAltName extension's raw bytes. Pure. .DESCRIPTION Decoded from the DER rather than from the extension's Format() text, because that text is localised by Windows ("DNS-Name=" here, something else on a French machine), and Gutcheck reads by structure, never by a label in one language. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][byte[]]$RawData) if (-not $RawData -or $RawData.Length -lt 2 -or $RawData[0] -ne 0x30) { return } # Returns the content length and moves the position past the length octets. $readLength = { param([ref]$Position) $first = $RawData[$Position.Value] $Position.Value++ if ($first -lt 0x80) { return [int]$first } $count = $first -band 0x7F $length = 0 for ($k = 0; $k -lt $count; $k++) { $length = ($length * 256) + $RawData[$Position.Value] $Position.Value++ } $length } $position = 1 $end = (& $readLength ([ref]$position)) + $position if ($end -gt $RawData.Length) { return } while ($position -lt $end) { $tag = $RawData[$position] $position++ $length = & $readLength ([ref]$position) if ($position + $length -gt $end) { return } # [2] IMPLICIT IA5String: dNSName. Everything else (addresses, URIs, other names) # is skipped by its length. if ($tag -eq 0x82) { [Text.Encoding]::ASCII.GetString($RawData, $position, $length) } $position += $length } } function ConvertFrom-WwwAuthenticate { <# .SYNOPSIS Parses one WWW-Authenticate value into its challenges. Pure, and reaches nothing. .DESCRIPTION One header value can carry several challenges separated by commas, and the parameters of a challenge are separated by commas as well (RFC 9110, 11.6.1). So a comma alone says nothing; what starts a new challenge is a token followed by a space rather than by "=". Quoted values may hold commas and escaped quotes. Each challenge comes back as its Scheme, its Parameters (names compared without regard to case, as the RFC says) and a Token68 when the scheme carried one instead of parameters, as Negotiate does. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Value) if (-not $Value -or -not $Value.Trim()) { return } # Split on commas that are not inside a quoted string. $items = New-Object System.Collections.Generic.List[string] $current = New-Object System.Text.StringBuilder $quoted = $false for ($i = 0; $i -lt $Value.Length; $i++) { $c = $Value[$i] if ($quoted -and $c -eq '\' -and ($i + 1) -lt $Value.Length) { $null = $current.Append($c).Append($Value[$i + 1]) $i++ continue } if ($c -eq '"') { $quoted = -not $quoted } if ($c -eq ',' -and -not $quoted) { $items.Add($current.ToString()) $null = $current.Clear() continue } $null = $current.Append($c) } $items.Add($current.ToString()) $token = '[!#$%&''*+\-.^_`|~0-9A-Za-z]+' $parameter = '^(?<name>' + $token + ')\s*=\s*(?<value>"(?:[^"\\]|\\.)*"|' + $token + ')?$' $scheme = '^(?<scheme>' + $token + ')(?:\s+(?<rest>.+))?$' $challenges = New-Object System.Collections.Generic.List[psobject] $challenge = $null foreach ($item in $items) { $text = $item.Trim() if (-not $text) { continue } if ($text -match $parameter) { # A parameter before any scheme belongs to nothing and is dropped. if ($challenge) { $challenge.Parameters[$Matches['name']] = ConvertFrom-QuotedValue $Matches['value'] } continue } if ($text -notmatch $scheme) { continue } $rest = $Matches['rest'] $challenge = [pscustomobject]@{ PSTypeName = 'Gutcheck.AuthChallenge' Scheme = $Matches['scheme'] Parameters = [ordered]@{} Token68 = $null } $challenges.Add($challenge) if ($rest) { $rest = $rest.Trim() if ($rest -match $parameter) { $challenge.Parameters[$Matches['name']] = ConvertFrom-QuotedValue $Matches['value'] } else { $challenge.Token68 = $rest } } } $challenges } function ConvertFrom-QuotedValue { <# .SYNOPSIS An auth-param value without its quotes and escapes. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Value) if (-not $Value) { return '' } if ($Value.Length -ge 2 -and $Value.StartsWith('"') -and $Value.EndsWith('"')) { return ($Value.Substring(1, $Value.Length - 2) -replace '\\(.)', '$1') } $Value } function Get-HttpsEndpointData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $entries = @(Get-Parameter $Parameters 'Endpoints' @()) $endpoints = @(foreach ($entry in $entries) { $target = ConvertTo-HttpsEndpointTarget -Entry $entry # A broken entry is the Judge's to report; requesting it would only add noise. if ($target.Problem) { continue } Invoke-HttpsEndpointProbe -Target $target }) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.HttpsEndpoint' # What certificate lifetimes are counted from, so the Judge never asks the clock. GatheredAt = Get-Date Endpoints = $endpoints } } function Invoke-HttpsEndpointProbe { <# .SYNOPSIS Makes one request to one endpoint and records everything about how it went. Judges none of it, and never throws. .DESCRIPTION Done by hand over a socket and SslStream rather than with Invoke-WebRequest or HttpWebRequest, because this is the only way that works the same on Windows PowerShell 5.1 and PowerShell 7 and still yields all of: the certificate as it was presented (not as a validation callback on 5.1 leaves it: reset), the negotiated TLS version, no redirect followed, no credential ever offered, and a proxy's own answer to CONNECT kept apart from the service's. The proxy is the one Windows would use for this URL, the same resolution (WinINET settings, PAC) the Technician's browser and Outlook get. The TLS version is left to Windows (SslProtocols None): what SChannel negotiates for Gutcheck is what it negotiates for Outlook, and forcing a version would hide a machine whose TLS 1.2 client has been switched off. Stage names where a failed request stopped: Dns, Connect, Proxy, Tls, Http. Two things only other Kinds ask for, which this Kind's own Definitions cannot reach (ConvertTo-HttpsEndpointTarget admits https:// only): a plain http:// URL, requested without TLS, because Autodiscover's HTTP redirect method is one; and a target property ReadBodyBytes, the most of the body to keep as text in Body, because Exchange Online's Autodiscover V2 answer is in its body. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Target) $uri = [uri]$Target.Url $plain = $uri.Scheme -eq 'http' $bodyLimit = 0 $bodyProperty = $Target.PSObject.Properties['ReadBodyBytes'] if ($bodyProperty -and $bodyProperty.Value) { $bodyLimit = [int]$bodyProperty.Value } $watch = [Diagnostics.Stopwatch]::StartNew() $record = [ordered]@{ Key = $Target.Key Url = $Target.Url Method = $Target.Method BearerProbe = $Target.BearerProbe Proxy = $null StatusCode = $null Reason = $null AnsweredBy = $null WwwAuthenticate = @() ProxyAuthenticate = @() ContentType = $null Server = $null Via = $null Location = $null HeaderNames = @() Body = $null TlsProtocol = $null Certificate = $null DnsMs = $null ConnectMs = $null HandshakeMs = $null ResponseMs = $null ElapsedMs = $null Stage = $null Error = $null } $proxy = $null try { $system = [System.Net.WebRequest]::GetSystemWebProxy() $candidate = $system.GetProxy($uri) # Windows PowerShell answers "direct" with the URL itself, PowerShell 7 with $null. if ($candidate -and -not $system.IsBypassed($uri) -and $candidate.AbsoluteUri -ne $uri.AbsoluteUri) { $proxy = $candidate $record.Proxy = $candidate.AbsoluteUri } } catch { } $client = $null $ssl = $null $stage = 'Dns' try { $connectHost = $uri.Host $connectPort = $uri.Port if ($proxy) { if ($proxy.Scheme -ne 'http') { throw ("Proxy scheme '{0}' is not supported by this probe" -f $proxy.Scheme) } $connectHost = $proxy.Host $connectPort = $proxy.Port } $lookup = [System.Net.Dns]::GetHostAddressesAsync($connectHost) if (-not $lookup.Wait($script:HttpsEndpointTimeoutMs)) { throw ('Name resolution of {0} timed out' -f $connectHost) } # IPv4 first: it is what the Server Kind measures, so the two agree on a number. $addresses = @($lookup.Result | Sort-Object { if ($_.AddressFamily -eq 'InterNetwork') { 0 } else { 1 } }) if (-not $addresses.Count) { throw ('{0} resolved to no address' -f $connectHost) } $record.DnsMs = [math]::Round($watch.Elapsed.TotalMilliseconds, 1) $stage = 'Connect' $started = $watch.Elapsed.TotalMilliseconds $lastError = $null foreach ($address in ($addresses | Select-Object -First 2)) { $candidateClient = New-Object System.Net.Sockets.TcpClient($address.AddressFamily) try { $task = $candidateClient.ConnectAsync($address, $connectPort) if ($task.Wait($script:HttpsEndpointTimeoutMs) -and $candidateClient.Connected) { $client = $candidateClient break } $lastError = 'Connection to {0}:{1} timed out' -f $address, $connectPort } catch { $lastError = $_.Exception.GetBaseException().Message } $candidateClient.Dispose() } if (-not $client) { throw $lastError } $stream = $client.GetStream() $stream.ReadTimeout = $script:HttpsEndpointTimeoutMs $stream.WriteTimeout = $script:HttpsEndpointTimeoutMs # A proxy is asked to tunnel only what is encrypted; plain HTTP is handed to it # whole, with the full URL on the request line. if ($proxy -and -not $plain) { $stage = 'Proxy' $authority = '{0}:{1}' -f $uri.Host, $uri.Port Write-HttpsEndpointText -Stream $stream -Text ("CONNECT {0} HTTP/1.1`r`nHost: {0}`r`nUser-Agent: Gutcheck`r`n`r`n" -f $authority) $head = ConvertFrom-HttpResponseHead -Text (Read-HttpsEndpointHead -Stream $stream) if (-not $head) { throw 'The proxy did not answer CONNECT with HTTP' } if ($head.StatusCode -ne 200) { # The proxy answered in the service's place. That is an answer, not a # failure to get one, and 407 here is the most useful thing this Kind finds. $record.ConnectMs = [math]::Round($watch.Elapsed.TotalMilliseconds - $started, 1) $record.AnsweredBy = 'Proxy' Set-HttpsEndpointHead -Record $record -Head $head $record.ElapsedMs = [math]::Round($watch.Elapsed.TotalMilliseconds, 1) return [pscustomobject]$record } } $record.ConnectMs = [math]::Round($watch.Elapsed.TotalMilliseconds - $started, 1) $channel = $stream if (-not $plain) { $stage = 'Tls' $started = $watch.Elapsed.TotalMilliseconds # Accepts every certificate so the request completes and the certificate can be # judged, rather than failing here with a message that names nothing. What # Windows thought of it is kept alongside. Nothing is sent before the handshake # finishes, and nothing but the probe's own request after it. $captured = @{} $callback = [System.Net.Security.RemoteCertificateValidationCallback] { param($source, $certificate, $chain, $errors) if ($certificate) { # Copied, because Windows PowerShell resets the one it was handed as # soon as the callback returns. $captured['Raw'] = $certificate.GetRawCertData() } $captured['Errors'] = "$errors" if ($chain) { $captured['Chain'] = @($chain.ChainElements | ForEach-Object { $_.Certificate.Subject }) $captured['ChainStatus'] = @($chain.ChainStatus | ForEach-Object { "$($_.Status)" }) } $true } $ssl = New-Object System.Net.Security.SslStream($stream, $false, $callback) $ssl.AuthenticateAsClient($uri.Host, $null, [System.Security.Authentication.SslProtocols]::None, $false) $record.HandshakeMs = [math]::Round($watch.Elapsed.TotalMilliseconds - $started, 1) $record.TlsProtocol = "$($ssl.SslProtocol)" if ($captured['Raw']) { $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (, [byte[]]$captured['Raw']) $san = $certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.17' } | Select-Object -First 1 $record.Certificate = [pscustomobject]@{ Subject = $certificate.Subject Issuer = $certificate.Issuer NotBefore = $certificate.NotBefore NotAfter = $certificate.NotAfter Thumbprint = $certificate.Thumbprint DnsNames = @($(if ($san) { ConvertFrom-SubjectAltName -RawData $san.RawData })) PolicyErrors = $captured['Errors'] Chain = @($captured['Chain']) ChainStatus = @($captured['ChainStatus'] | Where-Object { $_ }) } } $channel = $ssl } $stage = 'Http' $started = $watch.Elapsed.TotalMilliseconds $hostHeader = $uri.Host if (-not $uri.IsDefaultPort) { $hostHeader = '{0}:{1}' -f $uri.Host, $uri.Port } $requestTarget = $uri.PathAndQuery if ($plain -and $proxy) { $requestTarget = $uri.AbsoluteUri } $request = "{0} {1} HTTP/1.1`r`nHost: {2}`r`nUser-Agent: Gutcheck`r`nAccept: */*`r`nConnection: close`r`n" -f $Target.Method, $requestTarget, $hostHeader # The empty Bearer header: no token, no user, nothing. It is what makes Exchange # Online state its challenge, which without it it does not. if ($Target.BearerProbe) { $request += "Authorization: Bearer`r`n" } # A body-less POST without a length is refused with 411 before anything else is # looked at (observed at Exchange Online), which would hide the answer wanted. if ($Target.Method -in 'POST', 'PUT', 'PATCH') { $request += "Content-Length: 0`r`n" } $request += "`r`n" Write-HttpsEndpointText -Stream $channel -Text $request $rest = [ref]$null $head = ConvertFrom-HttpResponseHead -Text (Read-HttpsEndpointHead -Stream $channel -Remainder $rest) if (-not $head) { throw 'The answer was not HTTP' } $record.ResponseMs = [math]::Round($watch.Elapsed.TotalMilliseconds - $started, 1) $record.AnsweredBy = 'Service' Set-HttpsEndpointHead -Record $record -Head $head if ($bodyLimit -gt 0) { # Kept as it arrived, chunk sizes and all: the caller reads it by structure, # and a short JSON answer comes in one piece. A body that fails to arrive # leaves what did, since the head is already the answer. $body = New-Object System.IO.MemoryStream if ($rest.Value) { $body.Write($rest.Value, 0, $rest.Value.Length) } $buffer = New-Object byte[] 4096 try { while ($body.Length -lt $bodyLimit) { $read = $channel.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $body.Write($buffer, 0, $read) } } catch { } $bytes = $body.ToArray() $record.Body = [Text.Encoding]::UTF8.GetString($bytes, 0, [math]::Min($bytes.Length, $bodyLimit)) } } catch { $exception = $_.Exception.GetBaseException() $record.Stage = $stage $record.Error = [pscustomobject]@{ Type = $exception.GetType().FullName; Message = $exception.Message } } finally { if ($ssl) { $ssl.Dispose() } if ($client) { $client.Dispose() } } $record.ElapsedMs = [math]::Round($watch.Elapsed.TotalMilliseconds, 1) [pscustomobject]$record } function ConvertTo-HttpsEndpointFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) $entries = @(Get-Parameter $Parameters 'Endpoints' @() | Where-Object { $null -ne $_ }) if (-not $entries.Count) { return New-Finding -Category Network -Check (Get-Text 'Check.HttpsEndpoint.Endpoints') -Severity INFO ` -Value (Get-Text 'Value.HttpsEndpoint.NoneConfigured') ` -Hint (Get-Text 'Hint.HttpsEndpoint.ThisCheckDefinitionNamesNo') } $targets = @($entries | ForEach-Object { ConvertTo-HttpsEndpointTarget -Entry $_ }) # Refused by design rather than ignored in silence: whoever wrote the Definition # believes the Check signs in, and the Report is where they learn it does not. $asksAtTop = @($Parameters.Keys | Where-Object { $script:HttpsEndpointCredentialNames -contains $_ }).Count -gt 0 if ($asksAtTop -or @($targets | Where-Object { $_.AsksForCredentials }).Count) { New-Finding -Category Network -Check (Get-Text 'Check.HttpsEndpoint.Endpoints') -Severity INFO ` -Value (Get-Text 'Value.HttpsEndpoint.CredentialsRefused') ` -Hint (Get-Text 'Hint.HttpsEndpoint.CredentialsRefused') } $observations = Get-DataCollection $Data 'Endpoints' $gatheredAt = Get-DataProperty $Data 'GatheredAt' foreach ($target in $targets) { $check = (Get-Text 'Check.HttpsEndpoint.Endpoint') -f $target.Label if ($target.Problem) { New-Finding -Category Network -Check $check -Severity INFO ` -Value ((Get-Text 'Value.HttpsEndpoint.InvalidEntry') -f $target.Url, $target.Method) ` -Hint (Get-Text 'Hint.HttpsEndpoint.InvalidEntry') continue } $observation = @($observations | Where-Object { (Get-DataProperty $_ 'Key') -eq $target.Key }) | Select-Object -First 1 if (-not $observation) { New-UnavailableFinding -Category Network -Check $check -Hint (Get-Text 'Hint.HttpsEndpoint.NotRequested') continue } New-HttpsEndpointVerdict -Check $check -Target $target -Observation $observation ` -GatheredAt $gatheredAt -Parameters $Parameters } } function New-HttpsEndpointVerdict { <# .SYNOPSIS The one Finding for one endpoint: the first thing wrong with it, in the order the spec gives, or OK. .DESCRIPTION First wrong thing rather than worst, because each step is only meaningful when the one before it passed. A certificate's lifetime says nothing when a firewall presented it, and a challenge says nothing when a proxy answered instead of the service. The Section keeps everything for second level. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][string]$Check, [Parameter(Mandatory)]$Target, [Parameter(Mandatory)]$Observation, [AllowNull()]$GatheredAt, [hashtable]$Parameters = @{} ) $issuerPattern = Get-Parameter $Parameters 'ExpectIssuerPattern' '' # Microsoft's HealthChecker grades an Exchange certificate green at 60 days or more, # yellow at 30-59 and red under 30 (CSS-Exchange CertificateCheck). $warnDays = Get-Parameter $Parameters 'CertificateWarnDays' 60 $failDays = Get-Parameter $Parameters 'CertificateFailDays' 30 # Unsourced: no Microsoft threshold exists. Borrowed from the Server Kind's TCP connect # threshold (ServerTcpWarnMs) because it is judged against the same measurement. $latencyWarn = Get-Parameter $Parameters 'LatencyWarnMs' 150 $failure = Get-DataProperty $Observation 'Error' if ($failure) { $stage = switch ("$(Get-DataProperty $Observation 'Stage')") { 'Dns' { Get-Text 'Value.HttpsEndpoint.Stage.Dns' } 'Connect' { Get-Text 'Value.HttpsEndpoint.Stage.Connect' } 'Proxy' { Get-Text 'Value.HttpsEndpoint.Stage.Proxy' } 'Tls' { Get-Text 'Value.HttpsEndpoint.Stage.Tls' } default { Get-Text 'Value.HttpsEndpoint.Stage.Http' } } return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.Failed') -f $stage, (Get-DataProperty $failure 'Message')) ` -Hint (Get-Text 'Hint.HttpsEndpoint.Unreachable') } $status = ConvertTo-Number (Get-DataProperty $Observation 'StatusCode') if ($null -eq $status) { return New-UnavailableFinding -Category Network -Check $Check -Hint (Get-Text 'Hint.HttpsEndpoint.NotRequested') } $certificate = Get-DataProperty $Observation 'Certificate' $issuer = "$(Get-DataProperty $certificate 'Issuer')" # Whether the issuer is someone the Definition did not expect. $null when there is # nothing to compare: no pattern, or no certificate because a proxy answered. $unexpectedIssuer = $null if ($issuerPattern -and $certificate) { try { $unexpectedIssuer = -not ($issuer -match $issuerPattern) } catch { return New-Finding -Category Network -Check $Check -Severity INFO ` -Value ((Get-Text 'Value.HttpsEndpoint.InvalidIssuerPattern') -f $issuerPattern) ` -Hint (Get-Text 'Hint.HttpsEndpoint.InvalidIssuerPattern') } } # 407 is a proxy asking for a password, from whichever hop sent it. Microsoft lists # proxy authentication for Microsoft 365 among the known causes of connection problems. if ($status -eq 407) { return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.ProxyAuthentication') -f $status) ` -Hint (Get-Text 'Hint.HttpsEndpoint.ProxyAuthentication') } $expected = @($Target.ExpectStatus) if ($expected -notcontains [int]$status) { # A proxy answered in the service's place when it said so itself (its answer to # CONNECT, a Via or Proxy-Authenticate header), or when an HTML page arrived inside # TLS that somebody other than the expected issuer signed - a block page, which can # only be served from inside an interception. $html = "$(Get-DataProperty $Observation 'ContentType')" -match 'text/html' $byProxy = (Get-DataProperty $Observation 'AnsweredBy') -eq 'Proxy' -or [bool](Get-DataProperty $Observation 'Via') -or (Get-DataCollection $Observation 'ProxyAuthenticate').Count -gt 0 -or ($html -and $unexpectedIssuer) $shown = '{0}' -f $status $location = Get-DataProperty $Observation 'Location' if ($location) { $shown = (Get-Text 'Value.HttpsEndpoint.Redirect') -f $status, $location } if ($byProxy) { return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.ProxyPage') -f $shown, ($expected -join '/')) ` -Hint (Get-Text 'Hint.HttpsEndpoint.ProxyPage') } return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.UnexpectedStatus') -f $shown, ($expected -join '/')) ` -Hint (Get-Text 'Hint.HttpsEndpoint.UnexpectedStatus') } $challenges = @((Get-DataCollection $Observation 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ }) $offered = $null if ($Target.ExpectChallenge) { $offered = @($challenges | Where-Object { $_.Scheme -eq $Target.ExpectChallenge }) | Select-Object -First 1 if (-not $offered) { # Microsoft: an answer without WWW-Authenticate means a device in front of # Exchange is responding, not Exchange. return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.NoChallenge') -f $status, $Target.ExpectChallenge) ` -Hint (Get-Text 'Hint.HttpsEndpoint.NoChallenge') } } if (-not $certificate) { return New-Finding -Category Network -Check $Check -Severity INFO ` -Value (Get-Text 'Value.HttpsEndpoint.NoCertificate') ` -Hint (Get-Text 'Hint.HttpsEndpoint.NotRequested') } $policyErrors = "$(Get-DataProperty $certificate 'PolicyErrors')" $trusted = -not $policyErrors -or $policyErrors -eq 'None' if ($unexpectedIssuer) { # Decrypted and re-signed on the way. Microsoft: "TLS termination or deep packet # inspection of any Microsoft 365 domains" is known to cause connectivity issues. # Trusted, it works but should not be done; untrusted, it does not work at all. $severity = 'WARN' $hint = Get-Text 'Hint.HttpsEndpoint.Intercepted' if (-not $trusted) { $severity = 'FAIL' $hint = Get-Text 'Hint.HttpsEndpoint.InterceptedUntrusted' } return New-Finding -Category Network -Check $Check -Severity $severity ` -Value ((Get-Text 'Value.HttpsEndpoint.Intercepted') -f (Get-HttpsEndpointIssuerName $issuer)) -Hint $hint } $notAfter = Get-DataProperty $certificate 'NotAfter' if (-not $notAfter -or -not $GatheredAt) { return New-Finding -Category Network -Check $Check -Severity INFO ` -Value (Get-Text 'Value.HttpsEndpoint.NoCertificate') ` -Hint (Get-Text 'Hint.HttpsEndpoint.NotRequested') } $days = ([datetime]$notAfter - [datetime]$GatheredAt).TotalDays $lifetime = Get-SeverityBelow $days $warnDays $failDays if ($lifetime -ne 'OK') { $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpires') -f $days, [datetime]$notAfter if ($days -lt 0) { $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpired') -f [datetime]$notAfter } return New-Finding -Category Network -Check $Check -Severity $lifetime -Value $value ` -Hint (Get-Text 'Hint.HttpsEndpoint.CertificateExpiring') } if (-not $trusted) { $reasons = @() if ($policyErrors -match 'RemoteCertificateNameMismatch') { $reasons += Get-Text 'Value.HttpsEndpoint.NameMismatch' } if ($policyErrors -match 'RemoteCertificateChainErrors') { $reasons += (Get-Text 'Value.HttpsEndpoint.ChainErrors') -f ((Get-DataCollection $certificate 'ChainStatus') -join ', ') } if ($policyErrors -match 'RemoteCertificateNotAvailable') { $reasons += Get-Text 'Value.HttpsEndpoint.NoCertificate' } if (-not $reasons.Count) { $reasons += $policyErrors } return New-Finding -Category Network -Check $Check -Severity FAIL ` -Value ((Get-Text 'Value.HttpsEndpoint.Untrusted') -f ($reasons -join '; ')) ` -Hint (Get-Text 'Hint.HttpsEndpoint.Untrusted') } # The connection, not the whole request: Exchange Online takes ~300 ms to think about # an empty Bearer header (measured 2026-09-25), which is Microsoft's time, not the # network's, and would put every healthy machine over any threshold worth having. $connectMs = ConvertTo-Number (Get-DataProperty $Observation 'ConnectMs') $scheme = '' if ($offered) { $scheme = ' ' + $offered.Scheme } if ($null -ne $connectMs -and (Get-Severity $connectMs $latencyWarn ([double]::MaxValue)) -ne 'OK') { return New-Finding -Category Network -Check $Check -Severity WARN ` -Value ((Get-Text 'Value.HttpsEndpoint.Slow') -f $connectMs) ` -Hint (Get-Text 'Hint.HttpsEndpoint.Slow') } New-Finding -Category Network -Check $Check -Severity OK ` -Value ((Get-Text 'Value.HttpsEndpoint.Ok') -f $status, $scheme, (Get-HttpsEndpointIssuerName $issuer), $days, $connectMs) } function Get-HttpsEndpointIssuerName { <# .SYNOPSIS The common name out of an issuer's distinguished name, or the whole name. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Issuer) if ($Issuer -match '(?:^|,\s*)CN=(?<cn>[^,]+)') { return $Matches['cn'].Trim() } $Issuer } function ConvertTo-HttpsEndpointSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $endpoints = Get-DataCollection $Data 'Endpoints' if (-not $endpoints.Count) { return } New-Section -Title (Get-Text 'Title.HttpsEndpoint.Endpoints') -Row @( foreach ($endpoint in $endpoints) { $certificate = Get-DataProperty $endpoint 'Certificate' $failure = Get-DataProperty $endpoint 'Error' $notAfter = Get-DataProperty $certificate 'NotAfter' $proxy = Get-DataProperty $endpoint 'Proxy' [pscustomobject]@{ Url = Get-DataProperty $endpoint 'Url' Method = Get-DataProperty $endpoint 'Method' Proxy = $(if ($proxy) { $proxy } else { 'direct' }) Status = $(if ($failure) { 'failed at {0}' -f (Get-DataProperty $endpoint 'Stage') } else { Get-DataProperty $endpoint 'StatusCode' }) AnsweredBy = Get-DataProperty $endpoint 'AnsweredBy' Challenge = (@((Get-DataCollection $endpoint 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } | ForEach-Object { $_.Scheme }) -join ', ') Tls = Get-DataProperty $endpoint 'TlsProtocol' Issuer = Get-DataProperty $certificate 'Issuer' NotAfter = $(if ($notAfter) { '{0:yyyy-MM-dd}' -f [datetime]$notAfter }) PolicyErrors = Get-DataProperty $certificate 'PolicyErrors' ConnectMs = Get-DataProperty $endpoint 'ConnectMs' HandshakeMs = Get-DataProperty $endpoint 'HandshakeMs' ResponseMs = Get-DataProperty $endpoint 'ResponseMs' Error = Get-DataProperty $failure 'Message' } } ) } function Set-HttpsEndpointHead { <# .SYNOPSIS Copies what a response head said onto a probe record: the headers that tell a service from a proxy, and the names (never the values) of all the rest. .DESCRIPTION Not every header is kept: Exchange Online's Report-To carries the public address the request came from, and a login endpoint sets cookies. Neither belongs in a Report. #> [CmdletBinding()] param([Parameter(Mandatory)]$Record, [Parameter(Mandatory)]$Head) $headers = @($Head.Header) $values = { param($Name) @($headers | Where-Object { $_.Name -eq $Name } | ForEach-Object { $_.Value }) } $Record.StatusCode = $Head.StatusCode $Record.Reason = $Head.Reason $Record.WwwAuthenticate = @(& $values 'WWW-Authenticate') $Record.ProxyAuthenticate = @(& $values 'Proxy-Authenticate') $Record.ContentType = @(& $values 'Content-Type')[0] $Record.Server = @(& $values 'Server')[0] $Record.Via = @(& $values 'Via')[0] $Record.Location = @(& $values 'Location')[0] $Record.HeaderNames = @($headers | ForEach-Object { $_.Name } | Select-Object -Unique) } function Write-HttpsEndpointText { [CmdletBinding()] param([Parameter(Mandatory)]$Stream, [Parameter(Mandatory)][string]$Text) $bytes = [Text.Encoding]::ASCII.GetBytes($Text) $Stream.Write($bytes, 0, $bytes.Length) $Stream.Flush() } function Read-HttpsEndpointHead { <# .SYNOPSIS Reads from a stream until the end of the response head, and returns the head. .DESCRIPTION The body is not read here: status and headers are the whole answer this Kind needs, and a proxy's block page can be large. Whatever of the body arrived with the head is handed back through -Remainder, for a caller that wants it. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Stream, [ref]$Remainder) $buffer = New-Object byte[] 4096 $seen = New-Object System.IO.MemoryStream while ($seen.Length -lt 65536) { $read = $Stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $seen.Write($buffer, 0, $read) $all = $seen.ToArray() $text = [Text.Encoding]::ASCII.GetString($all) $end = $text.IndexOf("`r`n`r`n") if ($end -ge 0) { # ASCII decoding is one character per byte, so the index is a byte offset. if ($Remainder) { $start = $end + 4 $Remainder.Value = [byte[]]@($all | Select-Object -Skip $start) } return $text.Substring(0, $end + 2) } } [Text.Encoding]::ASCII.GetString($seen.ToArray()) } |