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