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