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) # # Which run an extension belonged to is the event's ActivityId: every event of one # processing run carries the same, and the 8000..8007 that ends the run says what kind it # was. So an extension that took ten minutes is told apart: at a sign-in somebody waited # for it, in a background refresh nobody did. # 5314 BandwidthInkbps, IsSlowLink, ThresholdInkbps, ... # 5308 DCName, DCIPAddress (the domain controller a processing run used) # 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 manifest is not what a machine writes for 1085. Read off a real one: position 5 # holds the domain controller, \\DC01.contoso.example, and position 6 is empty - the two # the wrong way round, and no description. So of positions 5 and 6 the one that begins # with two backslashes is the controller and the other the description, whichever way # round they stand. The Report had shown "Fehler 183 (\\DC01...)": a server where the # meaning of the number belongs. # Beside the logs, the result itself: which Group Policy objects Windows applied to the # computer and to the User, which it did not and why, and how each component ended. That # is what gpresult writes, and it is asked for as XML - gpresult /scope computer /x - and # read here, so that a Technician need not run it and read three hundred lines for the # five that matter. For the User it is asked with /user: this Kind runs in the Elevated # Part, which may be another account's, and that account's policies are nobody's question. # # Of the result only what says how policy was applied is kept: the objects, their state, # their versions, the components. Not the settings in them, and not the groups an account # is a member of. One thing more: of each item a preference extension has - a drive, a # service, a registry value - its name and the object it comes from, and nothing of what # it sets. A failed extension says in no log which of its items it was; with the items # beside it, the one that has a single item has said it. # # gpresult takes no path of more than 127 characters for its file, and says so in an # error nobody sees: the file goes into the temp folder under a short name. # 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 # How long gpresult may take for one scope. Seen: fifteen seconds on a desktop. $script:GroupPolicyResultTimeoutSeconds = 90 # How many objects a Finding names before it says how many more there are. $script:GroupPolicyResultNamed = 12 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 Activity = "$($_.ActivityId)" 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 Activity = "$($_.ActivityId)" Milliseconds = Get-EventPayload -LogEntry $_ -Index 0 ErrorCode = Get-EventPayload -LogEntry $_ -Index 1 Extension = Get-EventPayload -LogEntry $_ -Index 2 } }) # Which domain controller each processing run used: the run is the event's activity. # The two fields are told apart by what they hold, and not by their place alone - a # failure event of this provider had its two the other way round from the manifest. $controllers = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5308) -Since $since -Unreadable $unreadable ` -MaxEvents $script:GroupPolicyMaxExtensionEvents | ForEach-Object { $used = Split-GroupPolicyControllerText -Text @((Get-EventPayload -LogEntry $_ -Index 0), (Get-EventPayload -LogEntry $_ -Index 1)) if ($used.Name -or $used.Address) { [pscustomobject]@{ Time = $_.TimeCreated; Activity = "$($_.ActivityId)"; Name = $used.Name; Address = $used.Address } } }) $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 { $said = Split-GroupPolicyFailureText -Text @((Get-EventPayload -LogEntry $_ -Index 5), (Get-EventPayload -LogEntry $_ -Index 6)) [pscustomobject]@{ Time = $_.TimeCreated Id = $_.Id ErrorCode = Get-EventPayload -LogEntry $_ -Index 4 ErrorDescription = $said.Description Controller = $said.Controller Extension = $(if ($_.Id -eq $script:GroupPolicyExtensionFailId) { Get-EventPayload -LogEntry $_ -Index 7 } else { $null }) } }) # How far back the log reaches, and since when the machine runs: where the log holds # no start and no sign-in, these say why. The log is of a fixed size and overwrites # its oldest; on a domain machine every background refresh adds to it. $logOldest = $null if (-not $unreadable.ContainsKey($script:GroupPolicyLog)) { try { $logOldest = (Get-WinEvent -LogName $script:GroupPolicyLog -MaxEvents 1 -Oldest -ErrorAction Stop).TimeCreated } catch { } } $lastBoot = $null try { $lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime } catch { } # The result of policy, for the computer and for whoever is at the machine. Not asked # outside a domain: there is none. $result = $null if ($partOfDomain) { $user = "$(Get-InteractiveUser)".Trim() Write-CheckProgress -Status (Get-Text 'Console.Progress.PolicyResult') -Step 0 -Of 2 $computerResult = Invoke-GroupPolicyResult -Scope computer Write-CheckProgress -Status (Get-Text 'Console.Progress.PolicyResult') -Step 1 -Of 2 $userResult = $(if ($user) { Invoke-GroupPolicyResult -Scope user -User $user } else { [pscustomobject]@{ Xml = ''; Problem = 'NoUser' } }) $result = [pscustomobject]@{ UserName = $user Computer = ConvertFrom-GroupPolicyResult -Xml $computerResult.Xml ComputerProblem = "$($computerResult.Problem)" User = ConvertFrom-GroupPolicyResult -Xml $userResult.Xml UserProblem = "$($userResult.Problem)" } } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.GroupPolicy' GatheredAt = Get-Date Result = $result LogOldest = $logOldest LastBoot = $lastBoot Days = $days PartOfDomain = $partOfDomain Processing = $processing Extensions = $extensions Controllers = $controllers Links = $links Failures = $failures UnreadableLogs = @($unreadable.Keys | Sort-Object) Events = @($failureEvents | ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Group Policy' }) } } function Invoke-GroupPolicyResult { <# .SYNOPSIS Asks Windows for the result of policy in one scope, as XML. Never throws. .DESCRIPTION Returns the XML and, where there is none, why: 'Timeout', 'Failed' with the exit code, or 'NotRun'. The file is removed. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][ValidateSet('computer', 'user')][string]$Scope, [string]$User, [int]$TimeoutSeconds = $script:GroupPolicyResultTimeoutSeconds) $name = 'gc-rsop-{0}-{1}.xml' -f $Scope, $PID $file = Join-Path ([IO.Path]::GetTempPath()) $name # See the head of this file. if ($file.Length -gt 120) { $file = Join-Path (Join-Path $env:SystemRoot 'Temp') $name } $arguments = @('/scope', $Scope, '/x', ('"{0}"' -f $file), '/f') if ($Scope -eq 'user' -and $User) { $arguments = @('/user', ('"{0}"' -f $User)) + $arguments } $xml = '' $problem = '' try { $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\gpresult.exe') -ArgumentList $arguments ` -WindowStyle Hidden -PassThru -ErrorAction Stop if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { try { $process.Kill() } catch { } $problem = 'Timeout' } elseif (Test-Path -LiteralPath $file) { $xml = [IO.File]::ReadAllText($file) } else { $problem = 'Failed {0}' -f $process.ExitCode } } catch { $problem = 'NotRun' } finally { try { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } catch { } } [pscustomobject]@{ Xml = $xml; Problem = $problem } } function ConvertFrom-GroupPolicyResult { <# .SYNOPSIS What gpresult's XML says of one scope: the objects with their state as Windows gives it, and the components with how they ended. $null where it is no such XML. Decides nothing, and reaches nothing. .DESCRIPTION Elements are found by their local name: the document has a namespace per kind of setting, and none of them matters here. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Xml) if (-not "$Xml".Trim()) { return $null } $document = [xml]::new() try { $document.LoadXml($Xml) } catch { return $null } $scope = $document.DocumentElement.SelectSingleNode("*[local-name()='ComputerResults' or local-name()='UserResults']") if (-not $scope) { return $null } $text = { param($Node, [string]$Name) $child = $(if ($Node) { $Node.SelectSingleNode(("*[local-name()='{0}']" -f $Name)) }) if ($child) { "$($child.InnerText)".Trim() } else { '' } } [pscustomobject]@{ Domain = & $text $scope 'Domain' Site = & $text $scope 'Site' SlowLink = & $text $scope 'SlowLink' Objects = @($scope.SelectNodes("*[local-name()='GPO']") | ForEach-Object { $link = $_.SelectSingleNode("*[local-name()='Link']") [pscustomobject]@{ Name = & $text $_ 'Name' Id = & $text $_.SelectSingleNode("*[local-name()='Path']") 'Identifier' Link = & $text $link 'SOMPath' LinkEnabled = & $text $link 'Enabled' AppliedOrder = & $text $link 'AppliedOrder' Enabled = & $text $_ 'Enabled' IsValid = & $text $_ 'IsValid' FilterAllowed = & $text $_ 'FilterAllowed' AccessDenied = & $text $_ 'AccessDenied' VersionDirectory = & $text $_ 'VersionDirectory' VersionSysvol = & $text $_ 'VersionSysvol' Extensions = @($_.SelectNodes("*[local-name()='ExtensionName']") | ForEach-Object { "$($_.InnerText)".Trim() } | Where-Object { $_ }) } }) # The items of the preference extensions: each an element that carries what the # extension recorded of it, by the names the extension gives its fields. Items = @($scope.SelectNodes("*[local-name()='ExtensionData']") | ForEach-Object { $extension = & $text $_ 'Name' foreach ($item in @($_.SelectNodes(".//*[*[local-name()='BaseInstanceXml']]"))) { $base = $item.SelectSingleNode("*[local-name()='BaseInstanceXml']") $field = { param([string]$Name) $found = $base.SelectSingleNode(("*[local-name()='PROPERTY'][@NAME='{0}']/*[local-name()='VALUE']" -f $Name)) if ($found) { "$($found.InnerText)".Trim() } else { '' } } $name = & $field 'name' if ($name) { [pscustomobject]@{ Extension = $extension; Name = $name; Policy = (& $field 'polmkrBaseGpoDisplayName') } } } }) Components = @($scope.SelectNodes("*[local-name()='ExtensionStatus']") | ForEach-Object { [pscustomobject]@{ Name = & $text $_ 'Name' Begin = & $text $_ 'BeginTime' End = & $text $_ 'EndTime' Status = & $text $_ 'LoggingStatus' Error = & $text $_ 'Error' } }) } } function Get-GroupPolicyObjectState { <# .SYNOPSIS Whether a Group Policy object was applied, and where not, why: 'Applied', 'AccessDenied', 'NotReadable', 'Disabled', 'LinkDisabled', 'WmiFilter', 'Empty' or 'NotApplied'. Pure. .DESCRIPTION An object the account may not read comes back under its identifier instead of its name: that is the security filter too, seen from the other side. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Object) $order = ConvertTo-Number (Get-DataProperty $Object 'AppliedOrder') if ($null -ne $order -and $order -gt 0) { return 'Applied' } if ("$(Get-DataProperty $Object 'AccessDenied')" -eq 'true') { return 'AccessDenied' } if ("$(Get-DataProperty $Object 'Name')" -match '^\{[0-9A-Fa-f-]{36}\}$') { return 'NotReadable' } if ("$(Get-DataProperty $Object 'Enabled')" -eq 'false') { return 'Disabled' } if ("$(Get-DataProperty $Object 'LinkEnabled')" -eq 'false') { return 'LinkDisabled' } if ("$(Get-DataProperty $Object 'FilterAllowed')" -eq 'false') { return 'WmiFilter' } $directory = ConvertTo-Number (Get-DataProperty $Object 'VersionDirectory') $sysvol = ConvertTo-Number (Get-DataProperty $Object 'VersionSysvol') if ($directory -eq 0 -and $sysvol -eq 0) { return 'Empty' } 'NotApplied' } function Get-GroupPolicyObjectStateText { <# .SYNOPSIS What the Report calls the state of a Group Policy object. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$State) switch ($State) { 'Applied' { return (Get-Text 'Value.GroupPolicy.Object.Applied') } 'AccessDenied' { return (Get-Text 'Value.GroupPolicy.Object.AccessDenied') } 'NotReadable' { return (Get-Text 'Value.GroupPolicy.Object.NotReadable') } 'Disabled' { return (Get-Text 'Value.GroupPolicy.Object.Disabled') } 'LinkDisabled' { return (Get-Text 'Value.GroupPolicy.Object.LinkDisabled') } 'WmiFilter' { return (Get-Text 'Value.GroupPolicy.Object.WmiFilter') } 'Empty' { return (Get-Text 'Value.GroupPolicy.Object.Empty') } } Get-Text 'Value.GroupPolicy.Object.NotApplied' } function New-GroupPolicyResultFinding { <# .SYNOPSIS The Finding about the policy objects of one scope: how many were applied, which were not and why, whether a component ended in an error, and whether an object stands at one version in the directory and at another in SYSVOL. .DESCRIPTION An object that is not applied is no fault: a security filter, a WMI filter and an empty object are how policy is meant to be aimed. A warning is a component that ended in an error, and an applied object whose two versions differ - then the domain controllers do not hold the same, and which policy a machine gets depends on which of them answers. #> [CmdletBinding()] param([AllowNull()]$Data, [Parameter(Mandatory)][ValidateSet('Computer', 'User')][string]$Scope) $result = Get-DataProperty $Data 'Result' if ($null -eq $result) { return } $check = Get-Text 'Check.GroupPolicy.Objects.Computer' if ($Scope -eq 'User') { $check = Get-Text 'Check.GroupPolicy.Objects.User' } $read = Get-DataProperty $result $Scope if ($null -eq $read) { $problem = "$(Get-DataProperty $result ('{0}Problem' -f $Scope))" $why = Get-Text 'Value.GroupPolicy.Result.Failed' if ($problem -eq 'Timeout') { $why = Get-Text 'Value.GroupPolicy.Result.Timeout' } elseif ($problem -eq 'NoUser') { $why = Get-Text 'Value.GroupPolicy.Result.NoUser' } return New-Finding -Category Startup -Check $check -Severity INFO -Value $why ` -Meaning (Get-Text 'Meaning.GroupPolicy.Result.NotRead') -Hint (Get-Text 'Hint.GroupPolicy.Result.NotRead') } $objects = @((Get-DataCollection $read 'Objects') | ForEach-Object { [pscustomobject]@{ Object = $_; State = Get-GroupPolicyObjectState -Object $_; Order = ConvertTo-Number (Get-DataProperty $_ 'AppliedOrder') } }) $applied = @($objects | Where-Object { $_.State -eq 'Applied' } | Sort-Object Order) $others = @($objects | Where-Object { $_.State -ne 'Applied' }) $named = { param($List, $Format) $items = @($List | Select-Object -First $script:GroupPolicyResultNamed | ForEach-Object { & $Format $_ }) if (@($List).Count -gt $script:GroupPolicyResultNamed) { $items += (Get-Text 'Value.NetworkProtocol.More') -f (@($List).Count - $script:GroupPolicyResultNamed) } $items -join '; ' } $value = @((Get-Text 'Value.GroupPolicy.Objects.Count') -f $applied.Count, $others.Count) if ($Scope -eq 'User' -and "$(Get-DataProperty $result 'UserName')") { $value[0] = '{0} ({1})' -f $value[0], "$(Get-DataProperty $result 'UserName')" } if ($applied.Count) { $value += (Get-Text 'Value.GroupPolicy.Objects.Applied') -f (& $named $applied { param($o) "$(Get-DataProperty $o.Object 'Name')" }) } # One the account may not read has no name to show, only an identifier: counted. $nameless = @($others | Where-Object { $_.State -eq 'NotReadable' }) $namedOthers = @($others | Where-Object { $_.State -ne 'NotReadable' }) if ($namedOthers.Count) { $value += (Get-Text 'Value.GroupPolicy.Objects.NotApplied') -f (& $named $namedOthers { param($o) '{0} - {1}' -f "$(Get-DataProperty $o.Object 'Name')", (Get-GroupPolicyObjectStateText -State $o.State) }) } if ($nameless.Count) { $value += (Get-Text 'Value.GroupPolicy.Objects.NotReadable') -f $nameless.Count } $meaning = @() $hint = @() $severity = 'OK' # Applied, and not the same in the directory as in SYSVOL. 65535 is "not read". $apart = @($applied | Where-Object { $directory = ConvertTo-Number (Get-DataProperty $_.Object 'VersionDirectory') $sysvol = ConvertTo-Number (Get-DataProperty $_.Object 'VersionSysvol') $null -ne $directory -and $null -ne $sysvol -and $sysvol -ne 65535 -and $directory -ne $sysvol }) if ($apart.Count) { $severity = 'WARN' $value += (Get-Text 'Value.GroupPolicy.Objects.Apart') -f (& $named $apart { param($o) (Get-Text 'Value.GroupPolicy.Objects.ApartItem') -f "$(Get-DataProperty $o.Object 'Name')", (Get-DataProperty $o.Object 'VersionDirectory'), (Get-DataProperty $o.Object 'VersionSysvol') }) $meaning += Get-Text 'Meaning.GroupPolicy.Objects.Apart' $hint += Get-Text 'Hint.GroupPolicy.Objects.Apart' } $components = @((Get-DataCollection $read 'Components') | ForEach-Object { $_ }) $failed = @($components | Where-Object { $code = ConvertTo-Number (Get-DataProperty $_ 'Error'); $null -ne $code -and $code -ne 0 }) if ($failed.Count) { $severity = 'WARN' $value += (Get-Text 'Value.GroupPolicy.Components.Failed') -f (@($failed | ForEach-Object { '{0} - {1}' -f "$(Get-DataProperty $_ 'Name')", (Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'Error')) }) -join '; ') $meaning += Get-Text 'Meaning.GroupPolicy.Components.Failed' $hint += Get-Text 'Hint.GroupPolicy.Components.Failed' } elseif ($components.Count) { $value += (Get-Text 'Value.GroupPolicy.Components.Fine') -f $components.Count } if ("$(Get-DataProperty $read 'SlowLink')" -eq 'true') { if ($severity -eq 'OK') { $severity = 'INFO' } $value += Get-Text 'Value.GroupPolicy.Result.SlowLink' $meaning += Get-Text 'Meaning.GroupPolicy.Result.SlowLink' $hint += Get-Text 'Hint.GroupPolicy.Result.SlowLink' } New-Finding -Category Startup -Check $check -Severity $severity -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') ` -Reference @((New-Reference -Section 'Title.GroupPolicy.Objects'), (New-Reference -Section 'Title.GroupPolicy.Components')) } function Split-GroupPolicyControllerText { <# .SYNOPSIS Of the two fields that name the domain controller a processing run used, which is its name and which its address: the one that reads as an address is the address. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$Text) $name = '' $address = '' foreach ($one in @($Text | ForEach-Object { "$_".Trim().TrimStart('\') } | Where-Object { $_ })) { if ($one -match '^\d{1,3}(\.\d{1,3}){3}$' -or ($one -match '^[0-9A-Fa-f:]+$' -and $one.Contains(':'))) { if (-not $address) { $address = $one } } elseif (-not $name) { $name = $one } } [pscustomobject]@{ Name = $name; Address = $address } } function Split-GroupPolicyFailureText { <# .SYNOPSIS Of the two fields a failure keeps its description and its domain controller in, which is which: the one that begins with two backslashes is the controller. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$Text) $controller = '' $description = '' foreach ($one in @($Text | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) { if ($one.StartsWith('\\')) { if (-not $controller) { $controller = $one.TrimStart('\') } } elseif (-not $description) { $description = $one } } [pscustomobject]@{ Description = $description; Controller = $controller } } 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 } # Not asked to stop: "no events matched" is an error to Get-WinEvent, and stopping on # it writes a TerminatingError line into the transcript for a log that was merely quiet. $problems = @() $arguments = @{ FilterHashtable = $filter; ErrorAction = 'SilentlyContinue'; ErrorVariable = 'problems' } if ($MaxEvents -gt 0) { $arguments.MaxEvents = $MaxEvents } try { Get-WinEvent @arguments } catch { $problems = @($_) } foreach ($problem in @($problems)) { # "No events matched" is raised as an error as well; only a refusal is a gap. if (Test-AccessDenied -ErrorRecord $problem) { $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 } New-GroupPolicyResultFinding -Data $Data -Scope Computer New-GroupPolicyResultFinding -Data $Data -Scope User 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-GroupPolicyNoneFinding -Data $Data -Id $Id -Check $Check } $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-GroupPolicyNoneFinding { <# .SYNOPSIS The Finding where the log holds no start, or no sign-in, to judge: why there is none, as far as that is known. Pure. .DESCRIPTION "Keine Eintraege in 30 Tagen" under both left a Technician guessing whether that was fine or a gap. There are three reasons and they are told apart: the log no longer reaches back to the last start (the usual one on a domain machine: the log is of a fixed size and every background refresh adds to it); the machine has run for longer than the period, so there was no start to measure; or the log reaches back over the whole period and holds none. Information in each case: nothing was measured, which is no fault of the machine. #> [CmdletBinding()] param([AllowNull()]$Data, [Parameter(Mandatory)][int]$Id, [Parameter(Mandatory)][string]$Check) $days = ConvertTo-Number (Get-DataProperty $Data 'Days') $oldest = ConvertTo-DataTime (Get-DataProperty $Data 'LogOldest') $lastBoot = ConvertTo-DataTime (Get-DataProperty $Data 'LastBoot') $gathered = ConvertTo-DataTime (Get-DataProperty $Data 'GatheredAt') $since = $null if ($null -ne $gathered -and $null -ne $days) { $since = $gathered.AddDays(-$days) } $none = (Get-Text 'Value.GroupPolicy.NoneInPeriod') -f $days # At startup: the machine has run for longer than the period. if ($Id -eq 8000 -and $null -ne $lastBoot -and $null -ne $since -and $lastBoot -lt $since) { return New-Finding -Category Startup -Check $Check -Severity INFO ` -Value ((Get-Text 'Value.GroupPolicy.NoBootInPeriod') -f $days, $lastBoot) ` -Meaning (Get-Text 'Meaning.GroupPolicy.NoBootInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart') } if ($null -eq $oldest -or $null -eq $since) { return New-Finding -Category Startup -Check $Check -Severity INFO -Value $none ` -Meaning (Get-Text 'Meaning.GroupPolicy.NoneReachUnknown') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart') } # The log is shorter than the period: what was looked for may have been in it once. if ($oldest -gt $since) { $value = (Get-Text 'Value.GroupPolicy.LogTooShort') -f $oldest if ($Id -eq 8000 -and $null -ne $lastBoot -and $lastBoot -lt $oldest) { $value = (Get-Text 'Value.GroupPolicy.LogTooShortBoot') -f $lastBoot, $oldest } return New-Finding -Category Startup -Check $Check -Severity INFO -Value $value ` -Meaning (Get-Text 'Meaning.GroupPolicy.LogTooShort') -Hint (Get-Text 'Hint.GroupPolicy.LogTooShort') } New-Finding -Category Startup -Check $Check -Severity INFO -Value $none ` -Meaning (Get-Text 'Meaning.GroupPolicy.NoneInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart') } function Get-GroupPolicyRunPlace { <# .SYNOPSIS Where the runs of the extensions took place: for each, 'Foreground' at a start or a sign-in, 'Background' in any other processing run, 'Unknown' where the events do not say. Returns a block that answers for one run. Pure. #> [CmdletBinding()] [OutputType([hashtable])] param([AllowNull()]$Data) $kind = @{} foreach ($run in (Get-DataCollection $Data 'Processing')) { $activity = "$(Get-DataProperty $run 'Activity')".Trim() if (-not $activity) { continue } $id = ConvertTo-Number (Get-DataProperty $run 'Id') if ($id -in 8000, 8001) { $kind[$activity] = 'Foreground' } elseif (-not $kind.ContainsKey($activity)) { $kind[$activity] = 'Background' } } $kind } function Get-GroupPolicyExtensionTally { <# .SYNOPSIS Per extension: how many runs, how many of them slow, where those took place, the longest and when the last slow one was. Pure. .DESCRIPTION A run whose processing run is not among those gathered took place in the background if it names one at all: the log keeps the newest events, and a sign-in is not what falls out of it between two refreshes. One that names none is of unknown place, and is judged as one somebody waited for. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [double]$SlowSeconds = 10) $kind = Get-GroupPolicyRunPlace -Data $Data (Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension | ForEach-Object { $runs = @($_.Group | ForEach-Object { $ms = ConvertTo-Number (Get-DataProperty $_ 'Milliseconds') if ($null -eq $ms) { return } $activity = "$(Get-DataProperty $_ 'Activity')".Trim() $place = 'Unknown' if ($activity) { $place = $(if ($kind[$activity] -eq 'Foreground') { 'Foreground' } else { 'Background' }) } [pscustomobject]@{ Seconds = [double]$ms / 1000; Place = $place; Time = ConvertTo-DataTime (Get-DataProperty $_ 'Time') } }) if (-not $runs.Count) { return } $slow = @($runs | Where-Object { $_.Seconds -gt $SlowSeconds }) $longest = @($runs | Sort-Object Seconds -Descending)[0] [pscustomobject]@{ Extension = $_.Name Runs = $runs.Count Slow = $slow Waited = @($slow | Where-Object { $_.Place -ne 'Background' }) Background = @($slow | Where-Object { $_.Place -eq 'Background' }) PlaceKnown = -not @($slow | Where-Object { $_.Place -eq 'Unknown' }).Count MaxSeconds = $longest.Seconds Longest = $longest Last = @($slow | ForEach-Object { $_.Time } | Where-Object { $null -ne $_ } | Sort-Object)[-1] # How many runs came after the last slow one, all of them quick: what tells # something that has stopped from something that is going on. After = $( $newest = @($slow | ForEach-Object { $_.Time } | Where-Object { $null -ne $_ } | Sort-Object)[-1] if ($null -eq $newest) { 0 } else { @($runs | Where-Object { $null -ne $_.Time -and $_.Time -gt $newest }).Count }) } } } function New-GroupPolicySlowExtensionFinding { <# .SYNOPSIS The extensions whose runs are slow: how many of how many, the longest, when last, and whether somebody waited. .DESCRIPTION Graded on what a User feels, not on the longest number in the log. A run at a start or a sign-in holds the desktop back: slow is a warning, very slow a failure. A run in the background holds nobody up: one is a note, and only one that keeps coming back is more. Seen on a desktop: a single background run of ten minutes was the failure, and the 88 seconds at every second sign-in stood beside it as a detail. And one that has stopped is a note again. Seen on a machine a week after its domain was repaired: four background runs of a minute, the last five days before, and every run since quick - reported as a failure of today. #> [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warn = [double](Get-Parameter $Parameters 'GpExtensionWarnAboveSeconds' 10) $fail = [double](Get-Parameter $Parameters 'GpExtensionFailAboveSeconds' 60) $repeat = [double](Get-Parameter $Parameters 'GpExtensionRepeat' 3) $quiet = [double](Get-Parameter $Parameters 'GpExtensionQuietRuns' 5) $check = Get-Text 'Check.GroupPolicy.SlowestExtensions' $tally = @(Get-GroupPolicyExtensionTally -Data $Data -SlowSeconds $warn | Sort-Object MaxSeconds -Descending) if (-not $tally.Count) { return New-Finding -Category Startup -Check $check -Severity INFO ` -Value ((Get-Text 'Value.GroupPolicy.NoneInPeriod') -f (Get-DataProperty $Data 'Days')) } $table = @(New-Reference -Section 'Title.GroupPolicy.Extensions') $top = ($tally | Select-Object -First 3 | ForEach-Object { '{0} {1:N0} s' -f $_.Extension, $_.MaxSeconds }) -join ', ' $slow = @($tally | Where-Object { $_.Slow.Count }) if (-not $slow.Count) { return New-Finding -Category Startup -Check $check -Severity OK ` -Value ('{0} | {1}' -f ((Get-Text 'Value.GroupPolicy.SlowExtensions.None') -f $warn), ((Get-Text 'Value.GroupPolicy.SlowestExtensions') -f $top)) -Reference $table } $order = @{ FAIL = 0; WARN = 1; INFO = 2 } $graded = @($slow | ForEach-Object { $waitedMax = @($_.Waited | ForEach-Object { $_.Seconds } | Sort-Object)[-1] $severity = 'INFO' if ($_.Waited.Count -or $_.Background.Count -ge $repeat) { $severity = 'WARN' } if (($null -ne $waitedMax -and $waitedMax -gt $fail) -or @($_.Background | Where-Object { $_.Seconds -gt $fail }).Count -ge $repeat) { $severity = 'FAIL' } # What was only ever in the background and has not come back in the runs since # has stopped. Not said of a run somebody waited for: a sign-in is one run a day, # and quick runs in the background say nothing of the next sign-in. $after = [int](ConvertTo-Number (Get-DataProperty $_ 'After')) $stopped = -not $_.Waited.Count -and $after -ge $quiet if ($stopped) { $severity = 'INFO' } $item = (Get-Text 'Value.GroupPolicy.SlowExtension.Item') -f $_.Extension, $_.Slow.Count, $_.Runs, $warn, $_.MaxSeconds if ($null -ne $_.Last) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Last') -f $_.Last) } if ($stopped) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Since') -f $after) } if ($_.PlaceKnown) { if ($_.Waited.Count) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Waited') -f $_.Waited.Count) } else { $item = '{0}, {1}' -f $item, (Get-Text 'Value.GroupPolicy.SlowExtension.Background') } } [pscustomobject]@{ Tally = $_; Severity = $severity; Item = $item; Stopped = $stopped } } | Sort-Object @{ Expression = { $order[$_.Severity] } }, @{ Expression = { $_.Tally.MaxSeconds }; Descending = $true }) $severity = $graded[0].Severity $value = @((Get-Text 'Value.GroupPolicy.SlowExtensions.Lead') -f $graded.Count, $warn) $value += (Get-Text 'Value.GroupPolicy.SlowExtensions.List') -f (@($graded | Select-Object -First 5 | ForEach-Object { $_.Item }) -join '; ') $meaning = @() $hint = @() $waited = @($graded | Where-Object { $_.Tally.Waited.Count }) $behind = @($graded | Where-Object { $_.Tally.Background.Count }) if ($waited.Count) { $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Waited' $hint += Get-Text 'Hint.GroupPolicy.SlowExtension' if (@($waited | Where-Object { "$($_.Tally.Extension)" -match 'Drive Maps|Laufwerk' }).Count) { $hint += Get-Text 'Hint.GroupPolicy.SlowExtension.DriveMaps' } } if ($behind.Count) { $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Background' $hint += Get-Text 'Hint.GroupPolicy.SlowExtension.Background' } if (@($graded | Where-Object { $_.Stopped }).Count) { $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Stopped' } # The drives among them: what a Lead about drives mapped by the domain's name reads. $signal = @() if (@($graded | Where-Object { "$($_.Tally.Extension)" -match 'Drive Maps|Laufwerk' }).Count) { $signal = @('drive-mapping-slow') } $newest = @($graded | ForEach-Object { $_.Tally.Last } | Where-Object { $null -ne $_ } | Sort-Object)[-1] New-Finding -Category Startup -Check $check -Severity $severity -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $table -Signal $signal -LastSeen $newest } 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') } # Each id with what it means, and behind it the error Windows gave with it: 1058 says # that a policy file could not be read, and the error number says why. $byId = ($errors | Group-Object Id | Sort-Object Count -Descending | ForEach-Object { $id = Format-CodeWithMeaning -Code ('ID {0}' -f $_.Name) -Meaning (Get-GroupPolicyEventIdText -Id $_.Name) Format-GroupPolicyCount -Name $id -Row $_.Group }) -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 Rows = @(@($system) + @($operational) | Where-Object { $_.Extension -eq $name }) } }) $total = ($counted | Measure-Object Count -Sum).Sum $value = @($counted | Sort-Object Count -Descending | ForEach-Object { Format-GroupPolicyCount -Name $_.Extension -Count $_.Count -Row $_.Rows }) # When last, and through which domain controller: once through each of two is no # controller's fault, ten times through one is. $all = @($counted | ForEach-Object { $_.Rows }) $last = @($all | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $null -ne $_ } | Sort-Object)[-1] if ($null -ne $last) { $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Last') -f $last } $via = @($all | ForEach-Object { (("$(Get-DataProperty $_ 'Controller')" -split '\.')[0]).ToUpperInvariant() } | Where-Object { $_ } | Group-Object | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count }) if ($via.Count) { $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Via') -f ($via -join ', ') } # Which items each of them has, from the result of policy: no log says which item # failed, and where an extension has one, that is the one. $hint = @(Get-Text 'Hint.GroupPolicy.ExtensionFailed') $result = Get-DataProperty $Data 'Result' $items = @(@((Get-DataCollection (Get-DataProperty $result 'Computer') 'Items') | ForEach-Object { $_ }) + @((Get-DataCollection (Get-DataProperty $result 'User') 'Items') | ForEach-Object { $_ })) foreach ($one in @($counted | Sort-Object Count -Descending)) { # The log calls it "Group Policy Registry", the result "Windows Registry" or "Registry". $short = "$($one.Extension)" -replace '^Group Policy ', '' $mine = @($items | Where-Object { "$(Get-DataProperty $_ 'Extension')" -in "$($one.Extension)", $short, ('Windows {0}' -f $short) } | ForEach-Object { $policy = "$(Get-DataProperty $_ 'Policy')" if ($policy) { (Get-Text 'Value.GroupPolicy.FailedExtensions.Item') -f "$(Get-DataProperty $_ 'Name')", $policy } else { "$(Get-DataProperty $_ 'Name')" } } | Select-Object -Unique) if (-not $mine.Count) { continue } $shown = @($mine | Select-Object -First 8) if ($mine.Count -gt 8) { $shown += (Get-Text 'Value.NetworkProtocol.More') -f ($mine.Count - 8) } $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Items') -f "$($one.Extension)", ($shown -join '; ') $hint = @($(if ($mine.Count -eq 1) { Get-Text 'Hint.GroupPolicy.ExtensionFailed.OneItem' } else { Get-Text 'Hint.GroupPolicy.ExtensionFailed.Items' })) + $hint | Select-Object -Unique } New-Finding -Category Startup -Check $check -Severity (Get-Severity $total $warn $fail) ` -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.GroupPolicy.ExtensionFailed') ` -Hint (@($hint) -join ' | ') -Reference @(New-Reference -Section 'Title.GroupPolicy.Extensions') } function Get-GroupPolicyEventIdText { <# .SYNOPSIS What a failure event of Group Policy in the System log means. Nothing for one not known here, which is then shown as the number it is. Pure. .DESCRIPTION As Microsoft's own messages for these events say it, shortened: 1006 could not bind to Active Directory, 1030 processing failed and another event says why, 1053 and 1055 the user's or the computer's name could not be resolved, 1054 and 1129 no domain controller, 1058 the policy file (gpt.ini) on SYSVOL could not be read, 1085 an extension failed, 1096 registry.pol could not be applied. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Id) switch ("$Id".Trim()) { '1006' { return (Get-Text 'Value.GroupPolicy.EventId.1006') } '1030' { return (Get-Text 'Value.GroupPolicy.EventId.1030') } '1053' { return (Get-Text 'Value.GroupPolicy.EventId.1053') } '1054' { return (Get-Text 'Value.GroupPolicy.EventId.1054') } '1055' { return (Get-Text 'Value.GroupPolicy.EventId.1055') } '1058' { return (Get-Text 'Value.GroupPolicy.EventId.1058') } '1085' { return (Get-Text 'Value.GroupPolicy.EventId.1085') } '1096' { return (Get-Text 'Value.GroupPolicy.EventId.1096') } '1129' { return (Get-Text 'Value.GroupPolicy.EventId.1129') } } '' } function Get-GroupPolicyProcessingIdText { <# .SYNOPSIS Which run of policy processing an event 8000-8007 is the end of. Nothing for another id. Pure. .DESCRIPTION As Microsoft documents the Operational log: the even ids are the computer's policy and the odd ones the user's; 8000 and 8001 end the run at startup and at logon, 8002 and 8003 the one after a network change, 8004 and 8005 a manual refresh (gpupdate), 8006 and 8007 the periodic one in the background. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Id) switch ("$Id".Trim()) { '8000' { return (Get-Text 'Value.GroupPolicy.Processing.8000') } '8001' { return (Get-Text 'Value.GroupPolicy.Processing.8001') } '8002' { return (Get-Text 'Value.GroupPolicy.Processing.8002') } '8003' { return (Get-Text 'Value.GroupPolicy.Processing.8003') } '8004' { return (Get-Text 'Value.GroupPolicy.Processing.8004') } '8005' { return (Get-Text 'Value.GroupPolicy.Processing.8005') } '8006' { return (Get-Text 'Value.GroupPolicy.Processing.8006') } '8007' { return (Get-Text 'Value.GroupPolicy.Processing.8007') } } '' } function Format-GroupPolicyErrorCode { <# .SYNOPSIS The error number of a Group Policy event with what it means behind it. Pure. .DESCRIPTION The number is a Win32 error. The System-log events carry Windows' own sentence for it (ErrorDescription), which is used where it is there; the Operational log carries the number alone, and Windows is asked. Zero is no error and is said so only where -ShowZero asks for it, for a table that has a cell to fill. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Code, [AllowNull()]$Description, [switch]$ShowZero) $text = "$Code".Trim() if (-not $text) { return '' } if ((ConvertTo-Number $text) -eq 0) { if ($ShowZero) { return (Format-CodeWithMeaning -Code $text -Meaning (Get-Text 'Value.GroupPolicy.NoError')) } return '' } $meaning = "$Description".Trim() if (-not $meaning) { $meaning = Get-Win32ErrorText -Code $text } # Some extensions report an HRESULT where a Win32 error belongs. if (-not $meaning) { $meaning = Get-HResultText -Code $text } # No number stands bare: one nobody has a sentence for is said to be that. if (-not $meaning) { $meaning = Get-Text 'Value.Code.NoText' } Format-CodeWithMeaning -Code $text -Meaning $meaning } function Format-GroupPolicyCount { <# .SYNOPSIS A name, how often it was logged, and the error numbers logged with it, the most frequent first and no more than three. Pure. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$Name, [AllowNull()][AllowEmptyCollection()][object[]]$Row = @(), # Where the rows are two records of the same failures and their number is not the count. [int]$Count = -1 ) $rows = @($Row | Where-Object { $null -ne $_ }) if ($Count -lt 0) { $Count = $rows.Count } $codes = @($rows | ForEach-Object { Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'ErrorCode') -Description (Get-DataProperty $_ 'ErrorDescription') } | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { $_.Name }) if (-not $codes.Count) { return (Get-Text 'Value.GroupPolicy.Count') -f $Name, $Count } (Get-Text 'Value.GroupPolicy.CountWithCode') -f $Name, $Count, ($codes -join ', ') } function ConvertTo-GroupPolicySection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) New-Section -Title (Get-Text 'Title.GroupPolicy.Processing') -Help (Get-Text 'Help.GroupPolicy.Processing') -Row @( (Get-DataCollection $Data 'Processing') | Sort-Object Time -Descending | Select-Object -First 30 | ForEach-Object { # The payload of the boolean field arrives as text; see New-GroupPolicySlowLinkFinding. $failed = $null if ("$($_.IsConnectivityFailure)" -eq 'true') { $failed = $true } elseif ("$($_.IsConnectivityFailure)" -eq 'false') { $failed = $false } $row = [ordered]@{} $row[(Get-Text 'Column.GroupPolicy.Time')] = $_.Time $row[(Get-Text 'Column.GroupPolicy.Event')] = Format-CodeWithMeaning -Code "$($_.Id)" -Meaning (Get-GroupPolicyProcessingIdText -Id $_.Id) $row[(Get-Text 'Column.GroupPolicy.Seconds')] = $_.Seconds $row[(Get-Text 'Column.GroupPolicy.ErrorCode')] = Format-GroupPolicyErrorCode -Code $_.ErrorCode -ShowZero $row[(Get-Text 'Column.GroupPolicy.Principal')] = $_.Principal $row[(Get-Text 'Column.GroupPolicy.ConnectivityFailure')] = Get-TriStateText $failed [pscustomobject]$row } ) $extension = Get-Text 'Column.GroupPolicy.Extension' $runs = Get-Text 'Column.GroupPolicy.Runs' $average = Get-Text 'Column.GroupPolicy.AverageMs' $maximum = Get-Text 'Column.GroupPolicy.MaximumMs' $errors = Get-Text 'Column.GroupPolicy.Errors' $codes = Get-Text 'Column.GroupPolicy.ErrorCodes' $longestAt = Get-Text 'Column.GroupPolicy.LongestAt' $longestIn = Get-Text 'Column.GroupPolicy.LongestIn' $tally = @{} foreach ($one in @(Get-GroupPolicyExtensionTally -Data $Data)) { $tally["$($one.Extension)"] = $one } New-Section -Title (Get-Text 'Title.GroupPolicy.Extensions') -Help (Get-Text 'Help.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 $_ }) $row = [ordered]@{} $row[$extension] = $_.Name $row[$runs] = $_.Count $row[$average] = $(if ($ms.Count) { [math]::Round(($ms | Measure-Object -Average).Average) } else { $null }) $row[$maximum] = $(if ($ms.Count) { ($ms | Measure-Object -Maximum).Maximum } else { $null }) # When the longest run was and whether somebody waited for it: a maximum # without those is a number nobody can place. $longest = $(if ($tally.ContainsKey("$($_.Name)")) { $tally["$($_.Name)"].Longest }) $row[$longestAt] = $(if ($longest) { $longest.Time }) $row[$longestIn] = $(if (-not $longest -or $longest.Place -eq 'Unknown') { '' } elseif ($longest.Place -eq 'Foreground') { Get-Text 'Value.GroupPolicy.Place.Foreground' } else { Get-Text 'Value.GroupPolicy.Place.Background' }) $row[$errors] = @($_.Group | Where-Object { $_.Id -eq 7016 }).Count # What the runs that did not end well ended with (6016 a warning, 7016 an error). $row[$codes] = @($_.Group | ForEach-Object { Format-GroupPolicyErrorCode -Code $_.ErrorCode } | Where-Object { $_ } | Sort-Object -Unique) -join ', ' [pscustomobject]$row } | Sort-Object $maximum -Descending ) # What Windows applied, and what not: the result of policy for each scope. $result = Get-DataProperty $Data 'Result' if ($null -eq $result) { return } $scopes = @( [pscustomobject]@{ Name = (Get-Text 'Value.GroupPolicy.Scope.Computer'); Read = Get-DataProperty $result 'Computer' } [pscustomobject]@{ Name = (Get-Text 'Value.GroupPolicy.Scope.User'); Read = Get-DataProperty $result 'User' }) $scopeColumn = Get-Text 'Column.GroupPolicy.Scope' New-Section -Title (Get-Text 'Title.GroupPolicy.Objects') -Help (Get-Text 'Help.GroupPolicy.Objects') -Row @( foreach ($scope in $scopes) { (Get-DataCollection $scope.Read 'Objects') | ForEach-Object { [pscustomobject]@{ Object = $_; State = Get-GroupPolicyObjectState -Object $_; Order = ConvertTo-Number (Get-DataProperty $_ 'AppliedOrder') } } | Sort-Object @{ Expression = { $_.State -ne 'Applied' } }, Order | ForEach-Object { $row = [ordered]@{} $row[$scopeColumn] = $scope.Name $row[(Get-Text 'Column.GroupPolicy.Object')] = "$(Get-DataProperty $_.Object 'Name')" $row[(Get-Text 'Column.GroupPolicy.State')] = Get-GroupPolicyObjectStateText -State $_.State $row[(Get-Text 'Column.GroupPolicy.Order')] = $(if ($_.State -eq 'Applied') { [int]$_.Order }) $row[(Get-Text 'Column.GroupPolicy.Link')] = "$(Get-DataProperty $_.Object 'Link')" $row[(Get-Text 'Column.GroupPolicy.VersionDirectory')] = "$(Get-DataProperty $_.Object 'VersionDirectory')" $row[(Get-Text 'Column.GroupPolicy.VersionSysvol')] = "$(Get-DataProperty $_.Object 'VersionSysvol')" $row[(Get-Text 'Column.GroupPolicy.Sets')] = @((Get-DataCollection $_.Object 'Extensions') | ForEach-Object { "$_" }) -join ', ' [pscustomobject]$row } } ) New-Section -Title (Get-Text 'Title.GroupPolicy.Components') -Help (Get-Text 'Help.GroupPolicy.Components') -Row @( foreach ($scope in $scopes) { (Get-DataCollection $scope.Read 'Components') | ForEach-Object { $begin = ConvertTo-DataTime (Get-DataProperty $_ 'Begin') $end = ConvertTo-DataTime (Get-DataProperty $_ 'End') $row = [ordered]@{} $row[$scopeColumn] = $scope.Name $row[$extension] = "$(Get-DataProperty $_ 'Name')" $row[(Get-Text 'Column.GroupPolicy.LastRun')] = $end $row[(Get-Text 'Column.GroupPolicy.Seconds')] = $(if ($null -ne $begin -and $null -ne $end) { [int][math]::Round(($end - $begin).TotalSeconds) }) $row[(Get-Text 'Column.GroupPolicy.ErrorCode')] = Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'Error') -ShowZero [pscustomobject]$row } } ) } function ConvertTo-GroupPolicyEvent { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) (Get-DataCollection $Data 'Events') } |