Private/Kinds/GroupPolicy.ps1

# The GroupPolicy Kind: how long Group Policy holds up a boot and a logon, and why.
#
# On a domain-joined machine the desktop does not appear until the user's policy has been
# applied, and the machine's policy runs before anyone can log on at all. A drive mapping
# that waits for a file server that is gone, a printer deployment that installs a driver at
# every logon, a domain controller reachable only through a VPN that is not up yet: each is
# a minute a Customer spends looking at "Willkommen", and each is recorded in the Group
# Policy log with its duration.
#
# Every selection is by event id, and every payload field is read positionally as the
# provider's manifest defines it, for the reason Private/Kinds/Stability.ps1 gives. The
# templates, verified against Microsoft-Windows-GroupPolicy's manifest:
#
# 8000-8007 PolicyElaspedTimeInSeconds, ErrorCode, PrincipalSamName, IsMachine,
# IsConnectivityFailure (8000 boot, 8001 logon)
# 5016/6016/7016 CSEElaspedTimeInMilliSeconds, ErrorCode, CSEExtensionName, CSEExtensionId
# (one extension finished: success / warning / error)
# 5314 BandwidthInkbps, IsSlowLink, ThresholdInkbps, ...
# System log 1030-1129 SupportInfo1, SupportInfo2, ProcessingMode,
# ProcessingTimeInMilliseconds, ErrorCode, ErrorDescription, DCName, ...
# (1085: ... DCName, ExtensionName, ExtensionId)
#
# ("Elasped" is Microsoft's spelling, in the manifest itself.)

# The Operational channel is readable only with admin rights.
$script:GroupPolicyNeedsAdmin = $true

$script:GroupPolicyLog       = 'Microsoft-Windows-GroupPolicy/Operational'
$script:GroupPolicyProvider  = 'Microsoft-Windows-GroupPolicy'

# The Operational log records every extension of every background refresh - every 90
# minutes, a dozen extensions each - so a month of it is thousands of events. The newest
# are the ones that describe the machine as it is.
$script:GroupPolicyMaxExtensionEvents = 2000

# The System-log failures, by what they mean for the Technician.
$script:GroupPolicyConnectivityIds = @(1054, 1129)
$script:GroupPolicyErrorIds        = @(1030, 1053, 1055, 1058, 1096)
$script:GroupPolicyExtensionFailId = 1085

function Get-GroupPolicyData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

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

    $partOfDomain = $null
    try { $partOfDomain = [bool](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { }

    $unreadable = @{}

    $processing = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id (8000..8007) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time                  = $_.TimeCreated
                Id                    = $_.Id
                Seconds               = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode             = Get-EventPayload -LogEntry $_ -Index 1
                Principal             = Get-EventPayload -LogEntry $_ -Index 2
                IsConnectivityFailure = Get-EventPayload -LogEntry $_ -Index 4
            }
        })

    $extensions = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5016, 6016, 7016) -Since $since `
            -Unreadable $unreadable -MaxEvents $script:GroupPolicyMaxExtensionEvents |
        ForEach-Object {
            [pscustomobject]@{
                Time         = $_.TimeCreated
                Id           = $_.Id
                Milliseconds = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode    = Get-EventPayload -LogEntry $_ -Index 1
                Extension    = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    $links = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5314) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time           = $_.TimeCreated
                BandwidthKbps  = Get-EventPayload -LogEntry $_ -Index 0
                IsSlowLink     = Get-EventPayload -LogEntry $_ -Index 1
                ThresholdKbps  = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    $failureIds = @($script:GroupPolicyConnectivityIds) + @($script:GroupPolicyErrorIds) + @($script:GroupPolicyExtensionFailId)
    $failureEvents = @(Get-GroupPolicyEvent -Log 'System' -Provider $script:GroupPolicyProvider -Id $failureIds `
            -Since $since -Unreadable $unreadable)
    $failures = @($failureEvents | ForEach-Object {
        [pscustomobject]@{
            Time             = $_.TimeCreated
            Id               = $_.Id
            ErrorCode        = Get-EventPayload -LogEntry $_ -Index 4
            ErrorDescription = Get-EventPayload -LogEntry $_ -Index 5
            Extension        = $(if ($_.Id -eq $script:GroupPolicyExtensionFailId) { Get-EventPayload -LogEntry $_ -Index 7 } else { $null })
        }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.GroupPolicy'
        Days           = $days
        PartOfDomain   = $partOfDomain
        Processing     = $processing
        Extensions     = $extensions
        Links          = $links
        Failures       = $failures
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        Events         = @($failureEvents | ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Group Policy' })
    }
}

function Get-GroupPolicyEvent {
    <#
    .SYNOPSIS
        Reads events by id out of one log, newest first. Records a log it cannot open.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Log,
        [string]$Provider,
        [Parameter(Mandatory)][int[]]$Id,
        [Parameter(Mandatory)][datetime]$Since,
        [Parameter(Mandatory)][hashtable]$Unreadable,
        [int]$MaxEvents = 0
    )

    if (-not (Test-EventLogReadable -Log $Log -Unreadable $Unreadable)) { return }

    $filter = @{ LogName = $Log; Id = $Id; StartTime = $Since }
    if ($Provider) { $filter.ProviderName = $Provider }

    $arguments = @{ FilterHashtable = $filter; ErrorAction = 'Stop' }
    if ($MaxEvents -gt 0) { $arguments.MaxEvents = $MaxEvents }

    try { Get-WinEvent @arguments }
    catch {
        # "No events matched" is raised as an error as well; only a refusal is a gap.
        if (Test-AccessDenied -ErrorRecord $_) { $Unreadable[$Log] = $true }
    }
}

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

    if ((Get-DataProperty $Data 'PartOfDomain') -eq $false) {
        # A workgroup or Entra-only machine has no domain policy to wait for. Said once,
        # so the Report shows the Check was considered rather than forgotten.
        return New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.GroupPolicy.NotInDomain')
    }

    $unreadable = Get-DataCollection $Data 'UnreadableLogs'
    if ($unreadable -contains $script:GroupPolicyLog) {
        New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.Shared.NeedsAdmin') `
            -Hint (Get-Text 'Hint.GroupPolicy.RerunWithAdmin')
    }
    else {
        New-GroupPolicyDurationFinding -Data $Data -Id 8000 -Check (Get-Text 'Check.GroupPolicy.Boot') `
            -Warn (Get-Parameter $Parameters 'GpBootWarnAboveSeconds' 30) `
            -Fail (Get-Parameter $Parameters 'GpBootFailAboveSeconds' 90)
        New-GroupPolicyDurationFinding -Data $Data -Id 8001 -Check (Get-Text 'Check.GroupPolicy.Logon') `
            -Warn (Get-Parameter $Parameters 'GpLogonWarnAboveSeconds' 15) `
            -Fail (Get-Parameter $Parameters 'GpLogonFailAboveSeconds' 60)
        New-GroupPolicySlowExtensionFinding -Data $Data -Parameters $Parameters
        New-GroupPolicySlowLinkFinding      -Data $Data -Parameters $Parameters
    }

    if ($unreadable -contains 'System') { return }
    New-GroupPolicyConnectivityFinding     -Data $Data -Parameters $Parameters
    New-GroupPolicyErrorFinding            -Data $Data -Parameters $Parameters
    New-GroupPolicyExtensionFailureFinding -Data $Data -Parameters $Parameters
}

function New-GroupPolicyDurationFinding {
    <#
    .SYNOPSIS
        Judges how long boot or logon policy processing took across the window.
    .DESCRIPTION
        Judged on the median rather than the slowest: the first boot after a Windows update
        or a new GPO is slow on every machine, and a Customer's complaint is about the
        typical morning. The slowest is still in the Value, for the Technician to see.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [Parameter(Mandatory)][int]$Id,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][double]$Warn,
        [Parameter(Mandatory)][double]$Fail
    )

    $rows    = @((Get-DataCollection $Data 'Processing') | Where-Object { $_.Id -eq $Id })
    $seconds = @($rows | ForEach-Object { ConvertTo-Number $_.Seconds } | Where-Object { $null -ne $_ })

    if (-not $seconds.Count) {
        return New-Finding -Category Startup -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoneInPeriod') -f (Get-DataProperty $Data 'Days'))
    }

    $median  = Get-SamplePercentile -Sample $seconds -Percentile 50
    $maximum = ($seconds | Measure-Object -Maximum).Maximum

    New-Finding -Category Startup -Check $Check -Severity (Get-Severity $median $Warn $Fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Duration') -f $median, $maximum, $seconds.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowProcessing')
}

function New-GroupPolicySlowExtensionFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'GpExtensionWarnAboveSeconds' 10
    $fail = Get-Parameter $Parameters 'GpExtensionFailAboveSeconds' 60

    $check = Get-Text 'Check.GroupPolicy.SlowestExtensions'
    $slowest = @((Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension |
        ForEach-Object {
            $ms = @($_.Group | ForEach-Object { ConvertTo-Number $_.Milliseconds } | Where-Object { $null -ne $_ })
            if ($ms.Count) {
                [pscustomobject]@{ Extension = $_.Name; MaxSeconds = ($ms | Measure-Object -Maximum).Maximum / 1000 }
            }
        } | Sort-Object MaxSeconds -Descending)

    if (-not $slowest.Count) {
        return New-Finding -Category Startup -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoneInPeriod') -f (Get-DataProperty $Data 'Days'))
    }

    $top = ($slowest | Select-Object -First 3 | ForEach-Object { '{0} {1:N0} s' -f $_.Extension, $_.MaxSeconds }) -join ', '

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $slowest[0].MaxSeconds $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.SlowestExtensions') -f $top) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowExtension')
}

function New-GroupPolicySlowLinkFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'GpSlowLinkWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpSlowLinkFailAbove' ([double]::MaxValue)

    $links = Get-DataCollection $Data 'Links'
    if (-not $links.Count) { return }

    # Read as text: the payload of a boolean field arrives as "True" or "False", and as
    # "true" after the Elevated Part's round trip on some editions.
    $slow = @($links | Where-Object { "$($_.IsSlowLink)" -eq 'true' })

    $check = Get-Text 'Check.GroupPolicy.SlowLink'
    if (-not $slow.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK `
            -Value ((Get-Text 'Value.GroupPolicy.SlowLinkNone') -f $links.Count)
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $slow.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.SlowLink') -f $slow.Count, $links.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowLink')
}

function New-GroupPolicyConnectivityFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'GpConnectivityFailureWarnAbove' 5
    $fail = Get-Parameter $Parameters 'GpConnectivityFailureFailAbove' ([double]::MaxValue)

    # Both records of the same fact: the System log's "no domain controller" events, and
    # a processing run that ended flagged as a connectivity failure.
    $system     = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyConnectivityIds })
    $processing = @((Get-DataCollection $Data 'Processing') | Where-Object { "$($_.IsConnectivityFailure)" -eq 'true' })
    $count = [math]::Max($system.Count, $processing.Count)

    $check = Get-Text 'Check.GroupPolicy.DomainController'
    $days  = Get-DataProperty $Data 'Days'
    if (-not $count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value ((Get-Text 'Value.GroupPolicy.AlwaysReached') -f $days)
    }

    $severity = Get-Severity $count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Startup -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.GroupPolicy.NotReached') -f $count, $days) `
        -Hint (Get-Text 'Hint.GroupPolicy.DomainControllerUnreachable')
}

function New-GroupPolicyErrorFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'GpErrorWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpErrorFailAbove' 10

    $errors = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyErrorIds })
    $check  = Get-Text 'Check.GroupPolicy.Errors'
    if (-not $errors.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $byId = ($errors | Group-Object Id | Sort-Object Count -Descending |
        ForEach-Object { 'ID {0} {1}x' -f $_.Name, $_.Count }) -join ', '

    # 1058 and 1096 are policy files that could not be read from SYSVOL; the rest are a
    # user or computer name that could not be resolved, or processing that failed outright.
    $hint = Get-Text 'Hint.GroupPolicy.Errors'
    if (@($errors | Where-Object { $_.Id -in 1058, 1096 }).Count -eq $errors.Count) {
        $hint = Get-Text 'Hint.GroupPolicy.SysvolUnreadable'
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $errors.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Errors') -f $errors.Count, (Get-DataProperty $Data 'Days'), $byId) `
        -Hint $hint
}

function New-GroupPolicyExtensionFailureFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'GpExtensionFailureWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpExtensionFailureFailAbove' ([double]::MaxValue)

    # An extension that failed is recorded twice when both logs were readable: 1085 in the
    # System log and 7016 with an error code in the Operational log. Counted per source
    # and the larger taken, per extension, so one failure is not reported as two.
    $system = @((Get-DataCollection $Data 'Failures') |
        Where-Object { $_.Id -eq $script:GroupPolicyExtensionFailId -and $_.Extension })
    $operational = @((Get-DataCollection $Data 'Extensions') |
        Where-Object { $_.Id -eq 7016 -and $_.Extension })

    $names = @(@($system) + @($operational) | ForEach-Object { $_.Extension } | Sort-Object -Unique)
    $check = Get-Text 'Check.GroupPolicy.FailedExtensions'
    if (-not $names.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $counted = @(foreach ($name in $names) {
        $count = [math]::Max(@($system | Where-Object { $_.Extension -eq $name }).Count,
                             @($operational | Where-Object { $_.Extension -eq $name }).Count)
        [pscustomobject]@{ Extension = $name; Count = $count }
    })
    $total = ($counted | Measure-Object Count -Sum).Sum
    $described = ($counted | Sort-Object Count -Descending | ForEach-Object { '{0} {1}x' -f $_.Extension, $_.Count }) -join ', '

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $total $warn $fail) `
        -Value $described `
        -Hint (Get-Text 'Hint.GroupPolicy.ExtensionFailed')
}

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

    New-Section -Title (Get-Text 'Title.GroupPolicy.Processing') -Row @(
        (Get-DataCollection $Data 'Processing') | Sort-Object Time -Descending | Select-Object -First 30 |
            Select-Object Time, Id, Seconds, ErrorCode, Principal, IsConnectivityFailure
    )

    New-Section -Title (Get-Text 'Title.GroupPolicy.Extensions') -Row @(
        (Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension | ForEach-Object {
            $ms = @($_.Group | ForEach-Object { ConvertTo-Number $_.Milliseconds } | Where-Object { $null -ne $_ })
            [pscustomobject]@{
                Extension = $_.Name
                Runs      = $_.Count
                AverageMs = $(if ($ms.Count) { [math]::Round(($ms | Measure-Object -Average).Average) } else { $null })
                MaximumMs = $(if ($ms.Count) { ($ms | Measure-Object -Maximum).Maximum } else { $null })
                Errors    = @($_.Group | Where-Object { $_.Id -eq 7016 }).Count
            }
        } | Sort-Object MaximumMs -Descending
    )
}

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

    (Get-DataCollection $Data 'Events')
}