Private/Kinds/Kerberos.ps1

# The Kerberos Kind: whether Windows sign-in to each HTTPS Service the Run reached could work.
#
# A Service that offers Windows sign-in (WWW-Authenticate: Negotiate) signs a User in with
# Kerberos only where the directory knows it: a Service Principal Name HTTP/<host> on one
# account. Without one the client falls back to NTLM, which a server or a policy may
# refuse; with the name on two accounts the domain controller hands out a ticket the
# Service cannot read. Neither leaves anything on the client a Technician would see.
#
# So this Kind reads three things, and changes nothing (ADR-0016):
#
# - the User's tickets, as `klist` lists them. Listing only: never `klist get` or
# `purge`, and nothing else that asks for a ticket, because that writes the User's
# ticket cache. Listed before the directory is asked: the bind to it may add a ticket
# for the domain controller itself, and that one is Gutcheck's and not the User's.
# - for each HTTPS host an earlier Check reached, the accounts that carry HTTP/<host>
# or HOST/<host> - a read-only LDAP query, as the NetworkProtocol Kind asks the
# directory for the machine's password age. HOST stands in for HTTP by the directory's
# own mapping (sPNMappings), so a computer account with HOST/<host> is enough.
# - the Kerberos and NTLM operational logs, where they are on and readable: NTLM's audit
# event 8001 names the Server a client signed in to with NTLM, which, for a Service
# that offers Kerberos, is the fallback this Kind is about.
#
# Which hosts: what the Checks before this one gathered (-Observed), by Kind - the
# HttpsEndpoint Check's endpoints with the WWW-Authenticate they answered, the Server
# Check's Servers that took a connection on 443, and the Nextcloud Check's accounts with
# the schemes their server asked for. Of every Check of those Kinds, read off 'Gathered'
# (Get-ObservedGathered): under a Kind's own key stands only the last of them. The Run
# performs this Check after the applications' Checks (AppCheckReaders).
#
# klist's words are localised ("Startzeit", "Endzeit"), and its layout is not. So a ticket
# is read by position: a block opens with "#<n>>", Client and Server stand on its first two
# lines under names klist does not translate, the three times are the three lines that end
# in a time and a parenthesised word, and the KDC stands three lines below the last of them.
# Older Windows without that line leave the KDC empty. The Client is not kept: it is the
# User, whom the Report already names.
#
# Per-user state is read in the User's Session or not at all: a Run that is elevated has
# its own logon session and its own tickets, and one that runs as another account than
# the one at the machine reads that account's. Then the tickets are not listed, and the
# Finding says that they were not checked. The directory answers the same for any account.
#
# What the Gatherer returns:
# Days, GatheredAt the window of the logs, and the moment of the Run
# IsElevated, RunUser whose tickets klist would list
# Joined whether the machine is in a domain; $null where it could not be told
# TicketsRead whether klist was asked; TicketCount what its header said
# Tickets { Server (the SPN), Realm, Start, End, Kdc }
# TicketsError why klist did not answer: NotFound (no klist.exe), Failed (it ended
# with an error), NoOutput (it printed nothing), or the error's words
# Services { Server, Port, Subject, IsAddress, SchemesKnown, Schemes, From,
# Asked, HttpAccounts, HostAccounts }
# DirectoryError why the directory did not answer
# Logs { Log, State (On, Off, Unreadable, Missing), Count }
# NtlmTargets { Server, Count }: event 8001 for a host of Services, by host

# The logs, by their names in Windows.
$script:KerberosLogKerberos = 'Microsoft-Windows-Kerberos/Operational'
$script:KerberosLogNtlm     = 'Microsoft-Windows-NTLM/Operational'

# NTLM's "outgoing NTLM authentication" audit event: its first field is the target server.
$script:KerberosNtlmOutgoingId = 8001

# The WWW-Authenticate schemes that are Windows sign-in with Kerberos in them.
$script:KerberosNegotiateSchemes = @('Negotiate', 'Kerberos')

# What Get-KerberosData hands its Judge. See Private/DataShape.ps1.
$script:KerberosDataShape = @{
    Days           = 'Answered'
    GatheredAt     = 'Answered'
    IsElevated     = 'Answered'
    RunUser        = 'Value'
    Joined         = 'Value'
    TicketsRead    = 'Answered'
    TicketCount    = 'Value'
    Tickets        = 'List'
    TicketsError   = 'Value'
    Services       = 'List'
    DirectoryError = 'Value'
    Logs           = 'List'
    NtlmTargets    = 'List'
}

function ConvertFrom-KerberosKlistTime {
    <#
    .SYNOPSIS
        One of klist's times as a time, or $null for what is none. Pure.
    .DESCRIPTION
        klist writes month/day/year whatever the language of Windows; a day.month.year is
        read too, should a build ever follow the culture.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    $value = "$Text".Trim()
    if (-not $value) { return $null }
    $parsed = [datetime]::MinValue
    $formats = [string[]]@('M/d/yyyy H:mm:ss', 'd.M.yyyy H:mm:ss', 'yyyy-M-d H:mm:ss')
    if ([datetime]::TryParseExact($value, $formats, [Globalization.CultureInfo]::InvariantCulture,
            [Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$parsed)) { return $parsed }
    $null
}

function ConvertFrom-KerberosKlist {
    <#
    .SYNOPSIS
        The tickets klist listed, out of its lines, in any language. Pure.
    .DESCRIPTION
        Read by position (see the file header). Count is the number the header names in
        parentheses, so that a ticket the parser could not read shows as a difference and
        not as a ticket that was never there; $null where there was no such header.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Line)

    $lines = @($Line | ForEach-Object { "$_" })
    $count = $null
    $blocks = New-Object System.Collections.Generic.List[object]
    $current = $null
    foreach ($text in $lines) {
        if ($text -match '^\s*#\d+>\s*(?<rest>.*)$') {
            $current = New-Object System.Collections.Generic.List[string]
            $current.Add($Matches['rest'].Trim())
            $blocks.Add($current)
            continue
        }
        if ($null -eq $current) {
            # The header: the one line before the first ticket that ends in "(<n>)".
            if ($null -eq $count -and $text -match '\(\s*(?<n>\d+)\s*\)\s*$') { $count = [int]$Matches['n'] }
            continue
        }
        if ($text.Trim()) { $current.Add($text.Trim()) }
    }

    $value = { param([string]$Text) $at = $Text.IndexOf(':'); if ($at -lt 0) { return '' } $Text.Substring($at + 1).Trim() }
    $timePattern = ':\s*(?<time>\d{1,4}[./-]\d{1,2}[./-]\d{1,4}\s+\d{1,2}:\d{2}:\d{2})\s*\([^)]*\)\s*$'

    $tickets = @(foreach ($block in $blocks) {
        if ($block.Count -lt 2) { continue }
        $server = & $value $block[1]
        if (-not $server) { continue }
        $realm = ''
        if ($server -match '^(?<spn>.+?)\s*@\s*(?<realm>\S+)\s*$') { $server = $Matches['spn']; $realm = $Matches['realm'] }

        $times = @(for ($i = 2; $i -lt $block.Count; $i++) { if ($block[$i] -match $timePattern) { [pscustomobject]@{ At = $i; Time = $Matches['time'] } } })
        $kdc = ''
        if ($times.Count -ge 3) {
            $below = $times[2].At + 3
            if ($below -lt $block.Count) {
                $candidate = & $value $block[$below]
                if ($candidate -match '^[A-Za-z0-9][A-Za-z0-9.-]*$') { $kdc = $candidate }
            }
        }
        [pscustomobject]@{
            Server = $server.Trim()
            Realm  = $realm
            Start  = $(if ($times.Count -ge 1) { ConvertFrom-KerberosKlistTime $times[0].Time })
            End    = $(if ($times.Count -ge 2) { ConvertFrom-KerberosKlistTime $times[1].Time })
            Renew  = $(if ($times.Count -ge 3) { ConvertFrom-KerberosKlistTime $times[2].Time })
            Kdc    = $kdc.ToLowerInvariant()
        }
    })

    [pscustomobject]@{ Count = $count; Tickets = $tickets }
}

function Get-KerberosScheme {
    <#
    .SYNOPSIS
        The schemes of WWW-Authenticate values, each once. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Value)

    @(@($Value) | Where-Object { $_ } | ForEach-Object { ConvertFrom-WwwAuthenticate -Value "$_" } |
        ForEach-Object { "$($_.Scheme)" } | Where-Object { $_ } | Sort-Object -Unique)
}

function Select-KerberosService {
    <#
    .SYNOPSIS
        The HTTPS Services the Checks before this one reached, each once, with what they
        asked to be signed in with where that is known. Pure.
    .DESCRIPTION
        From the HttpsEndpoint Check: each endpoint that answered, with the schemes of its
        WWW-Authenticate. From the Server Check: each Server that took a connection on 443;
        what it asks for is not known. From the Nextcloud Check: each account's server, with
        the schemes it asked for where that Check read them. One Service is one host and
        port; what several Checks said of it is put together.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed)

    $found = [ordered]@{}
    $add = {
        param([string]$Server, $Port, [string]$From, $Schemes, [bool]$Known)
        $service = New-PortService -Server (ConvertTo-ServerName -Name $Server) -Port $Port
        if (-not $service -or $null -eq $service.Port) { return }
        $key = $service.Subject
        if (-not $found.Contains($key)) {
            $found[$key] = [pscustomobject]@{
                Server       = $service.Server
                Port         = $service.Port
                Subject      = $service.Subject
                IsAddress    = [bool](Test-NetworkAddress -Text $service.Server)
                SchemesKnown = $false
                Schemes      = @()
                From         = @()
            }
        }
        $entry = $found[$key]
        if ($entry.From -notcontains $From) { $entry.From = @($entry.From) + $From }
        if ($Known) {
            $entry.SchemesKnown = $true
            $entry.Schemes = @(@($entry.Schemes) + @($Schemes | Where-Object { $_ }) | ForEach-Object { "$_" } | Sort-Object -Unique)
        }
    }

    $endpoints = @(Get-ObservedGathered -Observed $Observed -Kind 'HttpsEndpoint' | ForEach-Object { Get-DataCollection $_ 'Endpoints' } | ForEach-Object { $_ })
    foreach ($endpoint in $endpoints) {
        if ($null -eq (Get-DataProperty $endpoint 'StatusCode')) { continue }
        $url = ConvertTo-UrlService -Url "$(Get-DataProperty $endpoint 'Url')"
        if (-not $url -or "$(Get-DataProperty $endpoint 'Url')" -notmatch '^(?i)https://') { continue }
        & $add $url.Server $url.Port 'HttpsEndpoint' @(Get-KerberosScheme -Value (Get-DataCollection $endpoint 'WwwAuthenticate')) $true
    }

    $servers = @(Get-ObservedGathered -Observed $Observed -Kind 'Server' | ForEach-Object { Get-DataCollection $_ 'Servers' } | ForEach-Object { $_ })
    foreach ($server in $servers) {
        $reached = @((Get-DataCollection $server 'PortResults') | Where-Object {
            (ConvertTo-Number (Get-DataProperty $_ 'Port')) -eq 443 -and (ConvertTo-Number (Get-DataProperty $_ 'Successes')) -gt 0 })
        if (-not $reached.Count) { continue }
        & $add "$(Get-DataProperty $server 'HostName')" 443 'Server' @() $false
    }

    $accounts = @(Get-ObservedGathered -Observed $Observed -Kind 'Nextcloud' | ForEach-Object { Get-DataCollection $_ 'Accounts' } | ForEach-Object { $_ })
    foreach ($account in $accounts) {
        $name = "$(Get-DataProperty $account 'Server')"
        $port = Get-DataProperty $account 'Port'
        if (-not $name -or $null -eq (ConvertTo-Number $port)) {
            $url = ConvertTo-UrlService -Url "$(Get-DataProperty $account 'Url')"
            if (-not $url) { continue }
            $name = $url.Server
            $port = $url.Port
        }
        $schemes = Get-DataProperty $account 'Schemes'
        & $add $name $port 'Nextcloud' @($schemes) ($null -ne $schemes)
    }

    @($found.Values)
}

function Test-KerberosNegotiate {
    <#
    .SYNOPSIS
        Whether a Service asked for Windows sign-in. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Service)

    [bool]@((Get-DataCollection $Service 'Schemes') | Where-Object { $script:KerberosNegotiateSchemes -contains "$_" }).Count
}

function Get-KerberosSpnFilter {
    <#
    .SYNOPSIS
        The LDAP filter that finds the accounts of HTTP/<host> and HOST/<host>; nothing for
        a name an LDAP filter could be made to say more with. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Server)

    $name = "$Server".Trim()
    if ($name -notmatch '^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$') { return '' }
    '(|(servicePrincipalName=HTTP/{0})(servicePrincipalName=HOST/{0}))' -f $name
}

function Get-KerberosSpnAccount {
    <#
    .SYNOPSIS
        Which accounts carry HTTP/<host>, and which HOST/<host>, out of the accounts the
        directory returned with their SPNs. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Server, [AllowNull()][AllowEmptyCollection()]$Account)

    $http = @(); $hostAccounts = @()
    foreach ($one in @($Account | Where-Object { $_ })) {
        $name = "$(Get-DataProperty $one 'Name')"
        $spns = @((Get-DataCollection $one 'Spns') | ForEach-Object { "$_".Trim().ToLowerInvariant() })
        if ($spns -contains ('http/' + $Server.ToLowerInvariant())) { $http += $name }
        if ($spns -contains ('host/' + $Server.ToLowerInvariant())) { $hostAccounts += $name }
    }
    [pscustomobject]@{ Http = @($http | Sort-Object -Unique); Host = @($hostAccounts | Sort-Object -Unique) }
}

function ConvertTo-KerberosNtlmTarget {
    <#
    .SYNOPSIS
        The host an NTLM target names: "HTTP/cloud.contoso.example" and
        "cloud.contoso.example" are both cloud.contoso.example. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Target)

    $text = "$Target".Trim()
    if ($text -match '^[A-Za-z]+/(?<host>[^/@\s]+)') { $text = $Matches['host'] }
    ConvertTo-ServerName -Name $text
}

function Get-KerberosData {
    <#
    .PARAMETER Observed
        What the Checks before this one gathered: the HttpsEndpoint, Server and Nextcloud
        Checks' Services, the Domain Check's membership, and 'RunUser'. Each may be absent.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{})

    $days  = [int](Get-Parameter $Parameters 'Days' 7)
    $since = (Get-Date).AddDays(-$days)

    $runUser = Get-ObservedRunUser -Observed $Observed
    $elevated = (Get-CurrentPrivilege) -eq 'admin'

    $joined = $null
    $domain = Get-ObservedKindData -Observed $Observed -Kind 'Domain'
    if ($null -ne $domain -and $null -ne (Get-DataProperty $domain 'Joined')) { $joined = [bool](Get-DataProperty $domain 'Joined') }
    if ($null -eq $joined) {
        try { $joined = [bool](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { $joined = $null }
    }

    # The tickets first, before the directory is asked (see the file header). Listing
    # only: klist without arguments, and nothing else.
    $ticketsRead = $false
    $ticketCount = $null
    $tickets = @()
    $ticketsError = $null
    if (-not (Get-UserSessionObstacle -RunUser $runUser -IsElevated $elevated)) {
        try {
            $listed = @(& klist.exe 2>$null)
            if ($LASTEXITCODE -ne 0) { $ticketsError = 'Failed' }
            elseif (-not @($listed | Where-Object { "$_".Trim() }).Count) { $ticketsError = 'NoOutput' }
            else {
                $parsed = ConvertFrom-KerberosKlist -Line $listed
                $ticketsRead = $true
                $ticketCount = $parsed.Count
                $tickets = @($parsed.Tickets)
            }
        }
        catch [System.Management.Automation.CommandNotFoundException] { $ticketsError = 'NotFound' }
        catch { $ticketsError = $_.Exception.Message }
    }

    $services = @(Select-KerberosService -Observed $Observed)

    # The directory, once per Service, until it fails to answer: a domain controller out
    # of reach makes every question wait for the timeout.
    $directoryError = $null
    foreach ($service in $services) {
        $service | Add-Member -NotePropertyName Asked -NotePropertyValue $false
        $service | Add-Member -NotePropertyName HttpAccounts -NotePropertyValue @()
        $service | Add-Member -NotePropertyName HostAccounts -NotePropertyValue @()
        if (-not $joined -or $directoryError -or $service.IsAddress) { continue }
        $filter = Get-KerberosSpnFilter -Server $service.Server
        if (-not $filter) { continue }
        $searcher = $null
        $results = $null
        try {
            $searcher = New-Object System.DirectoryServices.DirectorySearcher
            $searcher.Filter = $filter
            $searcher.ClientTimeout = [timespan]::FromSeconds(8)
            $searcher.SizeLimit = 20
            $null = $searcher.PropertiesToLoad.Add('sAMAccountName')
            $null = $searcher.PropertiesToLoad.Add('servicePrincipalName')
            $results = $searcher.FindAll()
            $accounts = @(foreach ($found in $results) {
                [pscustomobject]@{
                    Name = "$(@($found.Properties['samaccountname'])[0])"
                    Spns = @($found.Properties['serviceprincipalname'] | ForEach-Object { "$_" })
                }
            })
            $carriers = Get-KerberosSpnAccount -Server $service.Server -Account $accounts
            $service.Asked = $true
            $service.HttpAccounts = @($carriers.Http)
            $service.HostAccounts = @($carriers.Host)
        }
        catch { $directoryError = $_.Exception.Message }
        finally {
            # Both hold the connection to the directory until they are let go.
            if ($results) { $results.Dispose() }
            if ($searcher) { $searcher.Dispose() }
        }
    }

    # The logs, where they are on and this Run may read them.
    $unreadable = @{}
    $hosts = @($services | ForEach-Object { $_.Server })
    $ntlm = @{}
    $logs = @(foreach ($log in @($script:KerberosLogKerberos, $script:KerberosLogNtlm)) {
        $entry = $null
        try { $entry = Get-WinEvent -ListLog $log -ErrorAction Stop } catch { $entry = $null }
        if (-not $entry) { [pscustomobject]@{ Log = $log; State = 'Missing'; Count = $null }; continue }
        if (-not $entry.IsEnabled) { [pscustomobject]@{ Log = $log; State = 'Off'; Count = $null }; continue }
        $ids = @()
        if ($log -eq $script:KerberosLogNtlm) { $ids = @($script:KerberosNtlmOutgoingId) }
        $events = @(Get-StabilityEvent -Log $log -Id $ids -Since $since -Unreadable $unreadable)
        if ($unreadable.ContainsKey($log)) { [pscustomobject]@{ Log = $log; State = 'Unreadable'; Count = $null }; continue }
        if ($log -eq $script:KerberosLogNtlm) {
            foreach ($logEntry in $events) {
                $target = ConvertTo-KerberosNtlmTarget -Target (Get-EventPayload -LogEntry $logEntry -Index 0)
                # Only the hosts of Services: the rest names Servers this Kind says nothing of.
                if ($hosts -contains $target) { $ntlm[$target] = 1 + [int]$ntlm[$target] }
            }
        }
        [pscustomobject]@{ Log = $log; State = 'On'; Count = $events.Count }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.Kerberos'
        Days           = $days
        GatheredAt     = Get-Date
        IsElevated     = $elevated
        RunUser        = $runUser
        Joined         = $joined
        TicketsRead    = $ticketsRead
        TicketCount    = $ticketCount
        Tickets        = $tickets
        TicketsError   = $ticketsError
        Services       = $services
        DirectoryError = $directoryError
        Logs           = $logs
        NtlmTargets    = @($ntlm.Keys | Sort-Object | ForEach-Object { [pscustomobject]@{ Server = $_; Count = $ntlm[$_] } })
    }
}

function Format-KerberosTime {
    <#
    .SYNOPSIS
        A ticket's time as the Report writes one; empty for none. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Time)

    if ($null -eq $Time -or "$Time" -eq '') { return '' }
    try { ([datetime]$Time).ToString('dd.MM.yyyy HH:mm', [Globalization.CultureInfo]::InvariantCulture) } catch { "$Time" }
}

function Find-KerberosTicket {
    <#
    .SYNOPSIS
        The ticket for HTTP/<host> among the User's, the one that lasts longest; nothing
        where there is none. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$Ticket, [Parameter(Mandatory)][string]$Server)

    $pattern = '^(?i)http/' + [regex]::Escape($Server) + '(:\d+)?$'
    @($Ticket | Where-Object { "$(Get-DataProperty $_ 'Server')" -match $pattern } |
        Sort-Object { Get-DataProperty $_ 'End' } -Descending) | Select-Object -First 1
}

function Get-KerberosKlistProblemText {
    <#
    .SYNOPSIS
        Why klist listed no tickets, in words, out of what the Gatherer recorded; nothing
        where it recorded no reason. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Problem)

    switch ("$Problem") {
        'NotFound' { return (Get-Text 'Value.Kerberos.KlistMissing') }
        'Failed'   { return (Get-Text 'Value.Kerberos.KlistError') }
        ''         { return '' }
        'NoOutput' { return (Get-Text 'Value.Kerberos.KlistEmpty') }
    }
    $Problem
}

function ConvertTo-KerberosFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $category = 'Domain'
    $days     = [int](ConvertTo-Number (Get-DataProperty $Data 'Days'))
    $joined   = Get-DataProperty $Data 'Joined'
    $session  = Get-UserSessionObstacle -RunUser (Get-DataProperty $Data 'RunUser') -IsElevated (Get-DataProperty $Data 'IsElevated')
    $read     = [bool](Get-DataProperty $Data 'TicketsRead')
    $tickets  = Get-DataCollection $Data 'Tickets'

    if ($null -ne $joined -and -not [bool]$joined) {
        New-Finding -Category $category -Check (Get-Text 'Check.Kerberos.Tickets') -Severity INFO `
            -Value (Get-Text 'Value.Kerberos.NotJoined')
        return
    }

    # The User's tickets.
    $ticketCheck = Get-Text 'Check.Kerberos.Tickets'
    if ($session -eq 'Elevated') {
        New-Finding -Category $category -Check $ticketCheck -Severity INFO `
            -Value (Get-Text 'Value.Kerberos.Elevated') -Hint (Get-Text 'Hint.Kerberos.UserSession')
    }
    elseif ($session -eq 'OtherUser') {
        New-Finding -Category $category -Check $ticketCheck -Severity INFO `
            -Value (Get-Text 'Value.Kerberos.OtherUser') -Hint (Get-Text 'Hint.Kerberos.UserSession')
    }
    elseif (-not $read) {
        $value = Get-Text 'Value.Kerberos.KlistFailed'
        $why = Get-KerberosKlistProblemText -Problem (Get-DataProperty $Data 'TicketsError')
        if ($why) { $value = '{0} | {1}' -f $value, $why }
        New-Finding -Category $category -Check $ticketCheck -Severity INFO -Value $value
    }
    else {
        $tgt = @($tickets | Where-Object { "$(Get-DataProperty $_ 'Server')" -match '^(?i)krbtgt/' } |
            Sort-Object { Get-DataProperty $_ 'End' } -Descending) | Select-Object -First 1
        $listed = ConvertTo-Number (Get-DataProperty $Data 'TicketCount')
        $value = (Get-Text 'Value.Kerberos.Tickets') -f $tickets.Count
        if ($null -ne $listed -and $listed -ne $tickets.Count) {
            $value = (Get-Text 'Value.Kerberos.TicketsPartly') -f $tickets.Count, [int]$listed
        }
        if ($tgt) {
            $value = '{0} | {1}' -f $value, ((Get-Text 'Value.Kerberos.Tgt') -f (Format-KerberosTime (Get-DataProperty $tgt 'End')), "$(Get-DataProperty $tgt 'Kdc')")
            New-Finding -Category $category -Check $ticketCheck -Severity OK -Value $value
        }
        else {
            New-Finding -Category $category -Check $ticketCheck -Severity INFO `
                -Value ('{0} | {1}' -f $value, (Get-Text 'Value.Kerberos.NoTgt')) `
                -Meaning (Get-Text 'Meaning.Kerberos.NoTgt') -Hint (Get-Text 'Hint.Kerberos.NoTgt')
        }
    }
    $ticketsKnown = $read -and -not $session

    # Each Service.
    $services = Get-DataCollection $Data 'Services'
    $directoryError = "$(Get-DataProperty $Data 'DirectoryError')"
    $ntlm = @{}
    foreach ($row in (Get-DataCollection $Data 'NtlmTargets')) { $ntlm["$(Get-DataProperty $row 'Server')"] = [int](ConvertTo-Number (Get-DataProperty $row 'Count')) }
    $quiet = 0
    foreach ($service in $services) {
        $server    = "$(Get-DataProperty $service 'Server')"
        $subject   = "$(Get-DataProperty $service 'Subject')"
        $negotiate = Test-KerberosNegotiate -Service $service
        $asked     = [bool](Get-DataProperty $service 'Asked')
        $http      = Get-DataCollection $service 'HttpAccounts'
        $hostOnes  = Get-DataCollection $service 'HostAccounts'
        $spn       = 'HTTP/' + $server
        $check     = (Get-Text 'Check.Kerberos.Service') -f $subject
        $ntlmText  = ''
        if ($ntlm.ContainsKey($server) -and $ntlm[$server] -gt 0) { $ntlmText = ' | ' + ((Get-Text 'Value.Kerberos.NtlmUsed') -f $ntlm[$server], $days) }

        if ([bool](Get-DataProperty $service 'IsAddress')) {
            if ($negotiate) {
                New-Finding -Category $category -Check $check -Subject $subject -Severity INFO `
                    -Value ((Get-Text 'Value.Kerberos.Address') + $ntlmText) -Hint (Get-Text 'Hint.Kerberos.Address')
            }
            else { $quiet++ }
            continue
        }
        if (-not $asked) {
            if ($negotiate) {
                $why = Get-Text 'Value.Kerberos.SpnNotAsked'
                if ($directoryError) { $why = (Get-Text 'Value.Kerberos.DirectoryFailed') -f $directoryError }
                New-Finding -Category $category -Check $check -Subject $subject -Severity INFO `
                    -Value (((Get-Text 'Value.Kerberos.SpnUnknown') -f $spn, $why) + $ntlmText) -Unobserved @('spn-missing')
            }
            else { $quiet++ }
            continue
        }
        if ($http.Count -ge 2 -or ($http.Count -eq 0 -and $hostOnes.Count -ge 2)) {
            $name = $spn; $carriers = $http
            if ($http.Count -eq 0) { $name = 'HOST/' + $server; $carriers = $hostOnes }
            New-Finding -Category $category -Check $check -Subject $subject -Severity WARN -Signal @('spn-duplicate') `
                -Value (((Get-Text 'Value.Kerberos.SpnDuplicate') -f $name, $carriers.Count, ($carriers -join ', ')) + $ntlmText) `
                -Meaning (Get-Text 'Meaning.Kerberos.SpnDuplicate') -Hint ((Get-Text 'Hint.Kerberos.SpnDuplicate') -f $name)
            continue
        }
        if ($http.Count -eq 0 -and $hostOnes.Count -eq 0) {
            if ($negotiate) {
                New-Finding -Category $category -Check $check -Subject $subject -Severity WARN -Signal @('spn-missing') `
                    -Value (((Get-Text 'Value.Kerberos.SpnMissing') -f $spn) + $ntlmText) `
                    -Meaning (Get-Text 'Meaning.Kerberos.SpnMissing') -Hint ((Get-Text 'Hint.Kerberos.SpnMissing') -f $spn)
            }
            else { $quiet++ }
            continue
        }

        $carrier = (Get-Text 'Value.Kerberos.SpnOn') -f $spn, "$(@($http)[0])"
        if ($http.Count -eq 0) { $carrier = (Get-Text 'Value.Kerberos.SpnOnHost') -f $spn, ('HOST/' + $server), "$(@($hostOnes)[0])" }
        $cleared = @(if ($negotiate) { 'spn-missing' }; 'spn-duplicate')
        if (-not $ticketsKnown) {
            New-Finding -Category $category -Check $check -Subject $subject -Severity INFO -Cleared $cleared `
                -Value ('{0} | {1}{2}' -f $carrier, (Get-Text 'Value.Kerberos.TicketNotChecked'), $ntlmText)
            continue
        }
        $ticket = Find-KerberosTicket -Ticket $tickets -Server $server
        if ($ticket) {
            New-Finding -Category $category -Check $check -Subject $subject -Severity OK -Cleared $cleared `
                -Value ('{0} | {1}{2}' -f $carrier, ((Get-Text 'Value.Kerberos.TicketHeld') -f (Format-KerberosTime (Get-DataProperty $ticket 'End'))), $ntlmText)
        }
        else {
            New-Finding -Category $category -Check $check -Subject $subject -Severity INFO -Cleared $cleared `
                -Value ('{0} | {1}{2}' -f $carrier, (Get-Text 'Value.Kerberos.NoTicket'), $ntlmText) `
                -Meaning (Get-Text 'Meaning.Kerberos.NoTicket')
        }
    }

    # What the Services were, in one line: never a silent "nothing". Without any - a Scope
    # without the applications - nothing was looked up, and that is no all-clear. Nor is it
    # a look that failed: with no Service there was nothing to look at, so it declares
    # nothing Unobserved, or every refused sign-in in the Report, of any Service, would read
    # "SPN not checked" beside it. Where the directory did not answer, or a Service was not
    # asked, its own Finding says so of that Service.
    $summaryCheck = Get-Text 'Check.Kerberos.Services'
    if (-not $services.Count) {
        New-Finding -Category $category -Check $summaryCheck -Severity INFO -Value (Get-Text 'Value.Kerberos.NoServices') `
            -Hint (Get-Text 'Hint.Kerberos.NoServices')
    }
    elseif ($quiet) {
        New-Finding -Category $category -Check $summaryCheck -Severity OK -Value ((Get-Text 'Value.Kerberos.Quiet') -f $services.Count, $quiet)
    }

    # The logs: what they could say, and why they say nothing where they do.
    $parts = @(foreach ($log in (Get-DataCollection $Data 'Logs')) {
        $name = "$(Get-DataProperty $log 'Log')"
        $state = switch ("$(Get-DataProperty $log 'State')") {
            'On'         { (Get-Text 'Value.Kerberos.Log.On') -f (Format-EventCount (ConvertTo-Number (Get-DataProperty $log 'Count'))), $days }
            'Off'        { Get-Text 'Value.Kerberos.Log.Off' }
            'Unreadable' { Get-Text 'Value.Kerberos.Log.Unreadable' }
            default      { Get-Text 'Value.Kerberos.Log.Missing' }
        }
        '{0}: {1}' -f $name, $state
    })
    if ($parts.Count) {
        New-Finding -Category $category -Check (Get-Text 'Check.Kerberos.Logs') -Severity INFO -Value ($parts -join ' | ') `
            -Hint (Get-Text 'Hint.Kerberos.Logs')
    }
}

function ConvertTo-KerberosSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $tickets = Get-DataCollection $Data 'Tickets'
    if ($tickets.Count) {
        New-Section -Title (Get-Text 'Title.Kerberos.Tickets') -Help (Get-Text 'Help.Kerberos.Tickets') -Row @(
            foreach ($ticket in $tickets) {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Kerberos.Spn')]   = "$(Get-DataProperty $ticket 'Server')"
                $row[(Get-Text 'Column.Kerberos.Realm')] = "$(Get-DataProperty $ticket 'Realm')"
                $row[(Get-Text 'Column.Kerberos.Start')] = Format-KerberosTime (Get-DataProperty $ticket 'Start')
                $row[(Get-Text 'Column.Kerberos.End')]   = Format-KerberosTime (Get-DataProperty $ticket 'End')
                $row[(Get-Text 'Column.Kerberos.Kdc')]   = "$(Get-DataProperty $ticket 'Kdc')"
                [pscustomobject]$row
            })
    }

    $services = Get-DataCollection $Data 'Services'
    if ($services.Count) {
        New-Section -Title (Get-Text 'Title.Kerberos.Services') -Help (Get-Text 'Help.Kerberos.Services') -Row @(
            foreach ($service in $services) {
                $schemes = Get-Text 'Value.Kerberos.SchemesUnknown'
                if ([bool](Get-DataProperty $service 'SchemesKnown')) {
                    $schemes = (Get-DataCollection $service 'Schemes') -join ', '
                    if (-not $schemes) { $schemes = Get-Text 'Value.Kerberos.SchemesNone' }
                }
                $accounts = Get-Text 'Value.Kerberos.SpnNotAskedShort'
                if ([bool](Get-DataProperty $service 'Asked')) {
                    $accounts = @(@((Get-DataCollection $service 'HttpAccounts') | ForEach-Object { 'HTTP: ' + $_ }) +
                                  @((Get-DataCollection $service 'HostAccounts') | ForEach-Object { 'HOST: ' + $_ })) -join ', '
                    if (-not $accounts) { $accounts = Get-Text 'Value.Kerberos.SpnNone' }
                }
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Kerberos.Service')]  = "$(Get-DataProperty $service 'Subject')"
                $row[(Get-Text 'Column.Kerberos.From')]     = (Get-DataCollection $service 'From') -join ', '
                $row[(Get-Text 'Column.Kerberos.Schemes')]  = $schemes
                $row[(Get-Text 'Column.Kerberos.Accounts')] = $accounts
                [pscustomobject]$row
            })
    }
}