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