Private/Kinds/Change.ps1
|
# The Change Kind: what Windows logged as altered on this machine, as a timeline. # # "What changed?" is the first thing a second-level Technician asks, and until now it was # asked by telephone. This Check answers it from what Windows wrote down itself: programs # installed, updated or removed, Windows updates, drivers, services added, and the starts # of Windows with the shutdowns that were not planned. # # A Change is a fact Windows logged, with its time and, where Windows logged one, the # account it happened under. It is no judgement about anybody (ADR-0006), and nothing here # reads what a User did: no document, no program that was merely used. # # Get-ChangeData reads the machine and decides nothing. Every event is reduced to a Change # by a pure function of plain rows, and ConvertTo-ChangeFinding and ConvertTo-ChangeSection # are pure. # # The Changes in the data are what a later Check is handed through -Observed. Each is: # # Time when Windows logged it. For a Change known by its date only, midnight. # Kind one of $script:ChangeKinds, a stable word that is never reworded # What the name: the program, the update, the device, the service, 'Windows' # Version where Windows logged one, otherwise '' # Account the account as a name, otherwise '' # AccountState 'named', 'not-logged' (Windows logged none) or 'unresolved' (it logged # one that no longer translates to a name) # DateOnly whether only the day is known # Before for an update, the version that was there before, otherwise '' # Detail what else the event names: manufacturer, driver package, process, and # for a service the name of the file it runs - never its folder or its # arguments, see Get-ChangeFileName # How far back the timeline goes, in days. The spec's (#59): "the last 30 days". $script:ChangeDefaultWindowDays = 30 # How many rows the table shows. No limit of Windows: a table of thousands of rows is not # read, and 200 is more than the busiest month seen while this was written (about 60). $script:ChangeDefaultMaxRows = 200 # How many seconds a removal and an installation of the same program may lie apart and # still be one update. Windows Installer removes the old version inside the installation # of the new one: six seconds apart on the machine this was written on. Five minutes # leaves room for a slow machine and a large program. $script:ChangeDefaultUpdatePairSeconds = 300 # The kinds of Change, in the order the Finding counts them. $script:ChangeKinds = @( 'program-installed', 'program-updated', 'program-removed', 'program-listed', 'windows-update', 'driver-installed', 'service-added', 'restart', 'unexpected-shutdown' ) # The kinds of Change that recur by themselves, and stand in the table once with how often: # the virus definitions Windows Update installs every day, a service a program adds again # at every start. An installation of a program or a start of Windows is never one of many. $script:ChangeCollapsedKinds = @('windows-update', 'driver-installed', 'service-added') # What is read, by provider name and numeric event id and never by message text: a German # Windows returns German messages and invariant ids. Where each id's meaning comes from: # # MsiInstaller 1033, 1034, 1036 (Application). A classic provider without a manifest, # so (Get-WinEvent -ListProvider MsiInstaller).Events is empty. Read off real events # on Windows 11: 1033 "product installed", 1034 "product removed", 1036 "update # installed". Fields: name, version, language, then the status (1036: the name of # the update, then the status) and the manufacturer. Status 0 is success. # Not read: 11707 and 11724 say the same as 1033 and 1034 a second time, without the # version and as one sentence; 1035 "product reconfigured" is written for every # product whenever something lists the installed products, 2,400 times in a month # on that machine, and nothing changed. # Microsoft-Windows-WindowsUpdateClient 19 (System). The provider's manifest: # "Installation successful", fields updateTitle, updateGuid, updateRevisionNumber. # The Updates Kind reads the update history for its Findings about the last and the # failed updates; this is the list of what was installed, which it does not show. # Microsoft-Windows-Kernel-PnP 400 (Microsoft-Windows-Kernel-PnP/Configuration). The # provider's manifest: "device configured", fields DeviceInstanceId, DriverName, # ClassGuid, DriverDate, DriverVersion, DriverProvider, DriverInbox, DriverSection, # DriverRank, MatchingDeviceId, OutrankedDrivers, DeviceUpdated, Status. Readable # without admin rights on Windows 11. Only DeviceUpdated = true is a Change: the # event is also written each time a device is plugged in again. Not read: UserPnp # 20001 ("driver installed", in the manifest) was not written on that machine in a # month in which four drivers were replaced. # Service Control Manager 7045 (System). The provider's manifest: "a service was # installed in the system", fields ServiceName, ImagePath, ServiceType, StartType, # AccountName. The event's user is who added it. # Microsoft-Windows-Kernel-General 12 (System). The provider's manifest: "the operating # system started", fields MajorVersion, MinorVersion, BuildVersion, QfeVersion. # One per start of Windows. Not read: Kernel-General 13 and EventLog 6005, 6006 and # 6009 say the same start and the shutdown before it a second time. # User32 1074 (System). The provider's manifest: a process asked for a shutdown or a # restart on behalf of a user. Fields: the process with the computer's name behind # it, the computer, the reason, a code, the kind of shutdown as a localised word, # a comment, the user. Read only for the account and the process behind a start. # Microsoft-Windows-Kernel-Power 41 (System). The provider's manifest: the system # restarted without shutting down cleanly first. The one the Stability Kind counts, # so that both say the same number. Not read: EventLog 6008 says it a second time. $script:ChangeScans = @( @{ Source = 'Program'; Log = 'Application' Provider = @('MsiInstaller'); Id = @(1033, 1034, 1036) } @{ Source = 'Update'; Log = 'System' Provider = @('Microsoft-Windows-WindowsUpdateClient'); Id = @(19) } @{ Source = 'Driver'; Log = 'Microsoft-Windows-Kernel-PnP/Configuration' Provider = @('Microsoft-Windows-Kernel-PnP'); Id = @(400) } @{ Source = 'Service'; Log = 'System' Provider = @('Service Control Manager'); Id = @(7045) } @{ Source = 'Restart'; Log = 'System' Provider = @('Microsoft-Windows-Kernel-General', 'Microsoft-Windows-Kernel-Power', 'User32'); Id = @(12, 41, 1074) } ) function Get-ChangeData { <# .SYNOPSIS What Windows logged as changed in the window, as Changes. Decides nothing. .DESCRIPTION Reads the events of $script:ChangeScans and the list of installed programs, and hands them as plain rows to the pure functions that make Changes of them. A log that could not be read, or that this machine does not have, is recorded by name: a month nobody could read is not a quiet month. #> [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $days = [int](Get-Parameter $Parameters 'WindowDays' 30) $max = [int](Get-Parameter $Parameters 'MaxRows' 200) $pair = [int](Get-Parameter $Parameters 'UpdatePairSeconds' 300) if ($days -lt 1) { $days = $script:ChangeDefaultWindowDays } if ($max -lt 1) { $max = $script:ChangeDefaultMaxRows } if ($pair -lt 0) { $pair = $script:ChangeDefaultUpdatePairSeconds } $now = Get-Date $since = $now.AddDays(-$days) $refused = @{} $bySource = @{} $logs = @() foreach ($log in @($script:ChangeScans | ForEach-Object { $_.Log } | Select-Object -Unique)) { $scans = @($script:ChangeScans | Where-Object { $_.Log -eq $log }) # Asked for by name first: a filtered query for a log that does not exist, or is # switched off, answers "no events" like an empty one. $found = $false try { $entry = Get-WinEvent -ListLog $log -ErrorAction SilentlyContinue $found = [bool]($entry -and $entry.IsEnabled) } catch { } $readable = $false $oldest = $null if ($found) { $readable = Test-EventLogReadable -Log $log -Unreadable $refused if ($readable) { # How far back the log reaches: one that was overwritten after ten days # says nothing about the twenty before. try { $first = Get-WinEvent -LogName $log -Oldest -MaxEvents 1 -ErrorAction SilentlyContinue if ($first) { $oldest = $first.TimeCreated } } catch { } foreach ($scan in $scans) { $bySource[$scan.Source] = @(Get-StabilityEvent -Log $log -Provider $scan.Provider -Id $scan.Id ` -Since $since -Unreadable $refused | ForEach-Object { ConvertTo-ChangeLogRow -LogEntry $_ }) } # A query may be refused where reading the log by name was not. if ($refused.ContainsKey($log)) { $readable = $false } } } $logs += [pscustomobject]@{ Log = $log Sources = @($scans | ForEach-Object { $_.Source }) Found = $found Readable = $readable Oldest = $oldest } } # The name Windows shows for a device. The event names the device by its instance id. $devices = @{} if (@($bySource['Driver']).Count) { try { Get-CimInstance Win32_PnPEntity -Property PNPDeviceID, Name -ErrorAction SilentlyContinue | ForEach-Object { if ($_.PNPDeviceID -and $_.Name) { $devices["$($_.PNPDeviceID)"] = "$($_.Name)" } } } catch { } foreach ($row in @($bySource['Driver'])) { $id = "$(@($row.Payload)[0])" if ($id -and $devices.ContainsKey($id)) { $row.Device = $devices[$id] } } } # The list of installed programs, for what Windows Installer did not log: a program # with an installer of its own leaves its date there and no event. $listed = @(Get-InstalledProgram | ForEach-Object { [pscustomobject]@{ Name = "$($_.DisplayName)" Version = "$($_.DisplayVersion)" Publisher = "$($_.Publisher)" InstallDate = "$($_.InstallDate)" SystemComponent = "$($_.SystemComponent)" } }) $changes = @( ConvertTo-ChangeOfProgram -LogRow @($bySource['Program']) -Listed $listed -Since $since -Until $now -PairSeconds $pair ConvertTo-ChangeOfUpdate -LogRow @($bySource['Update']) ConvertTo-ChangeOfDriver -LogRow @($bySource['Driver']) ConvertTo-ChangeOfService -LogRow @($bySource['Service']) ConvertTo-ChangeOfRestart -LogRow @($bySource['Restart']) ) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Change' GatheredAt = $now WindowDays = $days Since = $since # Carried for the Section, which is built from data alone. MaxRows = $max Logs = $logs Changes = @(Select-ChangeInWindow -Change $changes -Since $since -Until $now) } } function ConvertTo-ChangeLogRow { <# .SYNOPSIS One event as a plain row: when, from whom, under which account, and its fields. .DESCRIPTION The account is translated here, where the machine can be asked: a row carries the name, and an empty name beside a Sid says that Windows could not translate it. Asked of Windows once for each account in a Run, by the one function that asks: see Resolve-AccountName. So this reads the machine, and is no part of the pure half: New-ChangeRow says from the row alone whether an account was named. Fields are kept by position, because their names are localised and their positions are not. A field that is binary is left empty. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$LogEntry) $sid = "$($LogEntry.UserId)" $account = '' if ($sid) { $account = (Resolve-AccountName -Sid $sid).Name } [pscustomobject]@{ Time = $LogEntry.TimeCreated Provider = "$($LogEntry.ProviderName)" Id = [int]$LogEntry.Id Sid = $sid Account = $account Payload = @(@($LogEntry.Properties) | ForEach-Object { if ($_.Value -is [byte[]]) { '' } else { "$($_.Value)" } }) # Filled in for a driver event, where the device is still there to be asked. Device = '' } } function Get-ChangeLogField { <# .SYNOPSIS One field of a row by its position, or '' when the event did not carry it. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$LogRow, [Parameter(Mandatory)][int]$Index) # Not through Get-DataCollection: it takes the empty fields out, and with them the # positions of the fields behind them. $payload = @(Get-DataProperty $LogRow 'Payload') if ($Index -lt 0 -or $Index -ge $payload.Count) { return '' } $value = "$($payload[$Index])".Trim() # What Windows Installer writes where it has nothing to say. if ($value -eq '(NULL)') { return '' } $value } function Select-ChangeLogRow { <# .SYNOPSIS The rows of one provider and one event id that have a time. Pure. .DESCRIPTION A time is read the one way a Judge reads one, and never by a free parse: see ConvertTo-DataTime. A row whose time is none is no row here, so that whatever comes after may take the time of a row for granted. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()]$LogRow, [Parameter(Mandatory)][string]$Provider, [Parameter(Mandatory)][int]$Id) @($LogRow) | Where-Object { $null -ne $_ -and "$(Get-DataProperty $_ 'Provider')" -eq $Provider -and (ConvertTo-Number (Get-DataProperty $_ 'Id')) -eq $Id -and $null -ne (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) } } function New-ChangeRow { <# .SYNOPSIS One Change. Pure. .DESCRIPTION The account is the name Windows gave for the event's user. Where it logged none, or one that no longer translates to a name, the state says which: an empty account must not read as nobody. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][datetime]$Time, [Parameter(Mandatory)][string]$Kind, [AllowEmptyString()][string]$What = '', [AllowEmptyString()][string]$Version = '', # The row whose account this Change happened under. None: Windows logged no account. [AllowNull()]$AccountOf, [AllowEmptyString()][string]$Before = '', [AllowEmptyString()][string]$Detail = '', [switch]$DateOnly ) $sid = "$(Get-DataProperty $AccountOf 'Sid')".Trim() $account = "$(Get-DataProperty $AccountOf 'Account')".Trim() $state = 'not-logged' if ($account) { $state = 'named' } elseif ($sid) { $state = 'unresolved' } [pscustomobject]@{ Time = $Time Kind = $Kind What = $What.Trim() Version = $Version.Trim() Account = $account AccountState = $state DateOnly = [bool]$DateOnly Before = $Before.Trim() Detail = $Detail.Trim() } } function Get-ChangeProgramKey { <# .SYNOPSIS A program's name without its numbers, to know two versions of it as one. Pure. .DESCRIPTION Many programs carry their version in their name: "Contoso Office 4.2" is removed and "Contoso Office 4.3" installed, and that is an update of Contoso Office. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Name) (("$Name" -replace '\d+', '') -replace '[\s.]+', ' ').Trim().ToLowerInvariant() } function ConvertTo-ChangeOfProgram { <# .SYNOPSIS Programs installed, updated and removed, from what Windows Installer logged and from the list of installed programs. Pure. .DESCRIPTION An installation that failed changed nothing and is no Change: only status 0. A removal and an installation of the same program by the same manufacturer, no more than PairSeconds apart and of two versions, are one update: that is how Windows Installer replaces a version. A program in the list of installed programs with a date in the window, which Windows Installer logged nothing about on that day or the days beside it, is a Change known by its date only. The list says neither the time nor the account, and not whether the program was installed or updated on that day. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$LogRow, [AllowNull()][AllowEmptyCollection()]$Listed, [AllowNull()]$Since, [AllowNull()]$Until, [int]$PairSeconds = $script:ChangeDefaultUpdatePairSeconds ) $read = { param($Row, [int]$StatusIndex, [int]$ManufacturerIndex) $status = Get-ChangeLogField $Row $StatusIndex if ($status -and $status -ne '0') { return } $name = Get-ChangeLogField $Row 0 if (-not $name) { return } [pscustomobject]@{ Row = $Row; Time = (ConvertTo-DataTime (Get-DataProperty $Row 'Time')); Name = $name Version = Get-ChangeLogField $Row 1; Manufacturer = Get-ChangeLogField $Row $ManufacturerIndex; Used = $false } } $installed = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1033 | ForEach-Object { & $read $_ 3 4 }) $removed = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1034 | ForEach-Object { & $read $_ 3 4 }) $patched = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1036 | ForEach-Object { & $read $_ 4 5 }) $changes = @( foreach ($new in $installed) { $old = $null $gap = [double]::MaxValue foreach ($candidate in $removed) { if ($candidate.Used) { continue } if ($candidate.Manufacturer.ToLowerInvariant() -ne $new.Manufacturer.ToLowerInvariant()) { continue } if ((Get-ChangeProgramKey $candidate.Name) -ne (Get-ChangeProgramKey $new.Name)) { continue } if ($candidate.Version -eq $new.Version) { continue } $apart = [math]::Abs(($new.Time - $candidate.Time).TotalSeconds) if ($apart -le $PairSeconds -and $apart -lt $gap) { $old = $candidate; $gap = $apart } } if ($old) { $old.Used = $true New-ChangeRow -Time $new.Time -Kind 'program-updated' -What $new.Name -Version $new.Version ` -AccountOf $new.Row -Before $old.Version -Detail $new.Manufacturer } else { New-ChangeRow -Time $new.Time -Kind 'program-installed' -What $new.Name -Version $new.Version ` -AccountOf $new.Row -Detail $new.Manufacturer } } foreach ($old in @($removed | Where-Object { -not $_.Used })) { New-ChangeRow -Time $old.Time -Kind 'program-removed' -What $old.Name -Version $old.Version ` -AccountOf $old.Row -Detail $old.Manufacturer } foreach ($patch in $patched) { New-ChangeRow -Time $patch.Time -Kind 'program-updated' -What $patch.Name -Version $patch.Version ` -AccountOf $patch.Row -Detail $patch.Manufacturer } ) $changes $seen = @{} foreach ($program in @($Listed | Where-Object { $null -ne $_ })) { $name = "$(Get-DataProperty $program 'Name')".Trim() if (-not $name) { continue } # What Windows does not show among the installed programs either: the parts a # program brings along. if ("$(Get-DataProperty $program 'SystemComponent')".Trim() -eq '1') { continue } # The date as installers write it: eight digits, year first. Anything else is not # guessed at. $date = [datetime]::MinValue if (-not [datetime]::TryParseExact("$(Get-DataProperty $program 'InstallDate')".Trim(), 'yyyyMMdd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date)) { continue } if ($Since -and $date -lt (ConvertTo-DataTime $Since).Date) { continue } if ($Until -and $date -gt (ConvertTo-DataTime $Until)) { continue } $version = "$(Get-DataProperty $program 'Version')".Trim() $key = ('{0}|{1}|{2:yyyyMMdd}' -f $name, $version, $date).ToLowerInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $logged = @($changes | Where-Object { $_.What.ToLowerInvariant() -eq $name.ToLowerInvariant() -and [math]::Abs(($_.Time.Date - $date).TotalDays) -le 1 }) if ($logged.Count) { continue } New-ChangeRow -Time $date -Kind 'program-listed' -What $name -Version $version ` -Detail "$(Get-DataProperty $program 'Publisher')" -DateOnly } } function ConvertTo-ChangeOfUpdate { <# .SYNOPSIS The updates Windows Update installed. Pure. .DESCRIPTION The title is the update's name as Windows Update gives it, with the number of its article in it where it has one. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$LogRow) Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-WindowsUpdateClient' -Id 19 | ForEach-Object { $title = Get-ChangeLogField $_ 0 if (-not $title) { return } New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'windows-update' -What $title -AccountOf $_ } } function ConvertTo-ChangeOfDriver { <# .SYNOPSIS The devices that were given another driver. Pure. .DESCRIPTION Windows writes "device configured" each time a device appears, a headset plugged in again as much as a new graphics driver. Only an event that says the device was updated is a Change, and only one that succeeded. The device is named as Windows shows it where it is still there. Where it is not - unplugged since, or removed - Windows has no name for it any more, and the event carries its device instance id only. That id is no name and does not stand bare: the Change says in words that the device has no name any more, with the id beside them, which is what the device is found by in the event log. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$LogRow) Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-PnP' -Id 400 | ForEach-Object { if ((Get-ChangeLogField $_ 11) -ne 'True') { return } $status = Get-ChangeLogField $_ 12 if ($status -and $status -ne '0') { return } $device = "$(Get-DataProperty $_ 'Device')".Trim() if (-not $device) { $instance = Get-ChangeLogField $_ 0 if (-not $instance) { return } $device = (Get-Text 'Value.Change.Device.Unnamed') -f $instance } # The driver package: who it is from and the file Windows keeps it under. $package = @((Get-ChangeLogField $_ 5), (Get-ChangeLogField $_ 1) | Where-Object { $_ }) -join ', ' New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'driver-installed' -What $device ` -Version (Get-ChangeLogField $_ 4) -AccountOf $_ -Detail $package } } function Get-ChangeFileName { <# .SYNOPSIS The name of the file a command line starts, without its folder and without its arguments. Empty where there is none. Pure. .DESCRIPTION What Windows logs for a service that was added is its whole command line, and that is more than a Change: the folder may lie in a User Profile and name the User, and the arguments may hold a password. The name of the file says which program the service belongs to, and is all that is kept or shown (ADR-0006). In quotation marks where the path has a blank in it; what follows is arguments. Without them, up to the first .exe, .sys or .dll. Where neither is there, up to the first blank: rather half a name than an argument taken for one. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Path) $text = "$Path".Trim() if (-not $text) { return '' } if ($text -match '^"([^"]+)"') { $text = $Matches[1] } elseif ($text -match '^(.*?\.(?:exe|sys|dll))(?:\s|$)') { $text = $Matches[1] } else { $text = @($text -split '\s+')[0] } "$(@($text -split '[\\/]')[-1])".Trim() } function Get-ChangeServiceFile { <# .SYNOPSIS The name of the file a service that was added runs, in lower case, to compare by. Empty where the Change names none. Pure. .DESCRIPTION Read off what the Change keeps of it, which is the name already; a Change from before that was so, with the whole command line, comes out the same. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Change) (Get-ChangeFileName -Path "$(Get-DataProperty $Change 'Detail')").ToLowerInvariant() } function ConvertTo-ChangeOfService { <# .SYNOPSIS The services added to Windows, each with the name of the file it runs. Pure. .DESCRIPTION The name of the file and nothing else of the command line Windows logged: see Get-ChangeFileName. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$LogRow) Select-ChangeLogRow -LogRow $LogRow -Provider 'Service Control Manager' -Id 7045 | ForEach-Object { $name = Get-ChangeLogField $_ 0 if (-not $name) { return } New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'service-added' -What $name ` -AccountOf $_ -Detail (Get-ChangeFileName -Path (Get-ChangeLogField $_ 1)) } } function ConvertTo-ChangeOfRestart { <# .SYNOPSIS The starts of Windows, and the shutdowns that were not planned. Pure. .DESCRIPTION A start is logged by the kernel under no account that says anything. Where a shutdown or a restart was asked for since the start before it, the account and the process that asked are that start's: the last one asked for is the one that happened. A start nothing asked for has no account, and the cell says so. The version is that of Windows as it started: a start after a feature update shows it. An unexpected shutdown is logged at the start after it, and bears that time. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$LogRow) $starts = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-General' -Id 12 | Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) } }) $requests = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'User32' -Id 1074 | Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) } }) $previous = [datetime]::MinValue foreach ($start in $starts) { $time = (ConvertTo-DataTime (Get-DataProperty $start 'Time')) $request = $null foreach ($candidate in $requests) { $asked = (ConvertTo-DataTime (Get-DataProperty $candidate 'Time')) if ($asked -gt $previous -and $asked -lt $time) { $request = $candidate } } $previous = $time $parts = @(0..3 | ForEach-Object { Get-ChangeLogField $start $_ }) $version = '' if (@($parts | Where-Object { $_ -match '^\d+$' }).Count -eq 4) { $version = $parts -join '.' } # The process as the event names it: its path, and the computer's name behind it # in brackets. The file's name is what tells an update from a person. $process = '' if ($request) { $process = (Get-ChangeLogField $request 0) -replace '\s*\([^()]*\)\s*$', '' $process = @($process -split '[\\/]')[-1] } New-ChangeRow -Time $time -Kind 'restart' -What 'Windows' -Version $version -AccountOf $request -Detail $process } Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-Power' -Id 41 | ForEach-Object { New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'unexpected-shutdown' -What 'Windows' } } function Select-ChangeInWindow { <# .SYNOPSIS The Changes of the window, newest first. Pure. .DESCRIPTION Sorted on every column that tells two Changes apart: a sort does not keep the order of equals on Windows PowerShell 5.1, and four drivers are installed in one second. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Change, [AllowNull()]$Since, [AllowNull()]$Until) @($Change | Where-Object { $null -ne $_ -and $null -ne (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -and # A Change known by its day only belongs to the window if its day does. (-not $Since -or (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -ge $(if (Get-DataProperty $_ 'DateOnly') { (ConvertTo-DataTime $Since).Date } else { (ConvertTo-DataTime $Since) })) -and (-not $Until -or (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -le (ConvertTo-DataTime $Until)) } | Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) }; Descending = $true }, @{ Expression = { "$(Get-DataProperty $_ 'Kind')" }; Descending = $false }, @{ Expression = { "$(Get-DataProperty $_ 'What')" }; Descending = $false }, @{ Expression = { "$(Get-DataProperty $_ 'Version')" }; Descending = $false }, @{ Expression = { "$(Get-DataProperty $_ 'Account')" }; Descending = $false }) } function Group-ChangeRow { <# .SYNOPSIS The Changes as the table shows and the Finding counts them: what recurs by itself once, with how often and since when. Pure. .DESCRIPTION Only the kinds of $script:ChangeCollapsedKinds, and only what is the same thing under the same account: an update with the same article number - the virus definitions, installed every day under one - or of the same title, the same service with the same file, the same device with the same driver version. The row is the newest of them. Count is how many there were and First the oldest. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $order = @() $groups = @{} $number = 0 foreach ($change in (Select-ChangeInWindow -Change @((Get-DataCollection $Data 'Changes') | ForEach-Object { $_ }))) { $kind = "$(Get-DataProperty $change 'Kind')" $what = "$(Get-DataProperty $change 'What')" $number++ $key = "single|$number" if ($script:ChangeCollapsedKinds -contains $kind) { $name = '{0}|{1}|{2}' -f $what, (Get-DataProperty $change 'Version'), (Get-DataProperty $change 'Detail') if ($kind -eq 'windows-update' -and $what -match 'KB\d{6,}') { $name = $Matches[0] } $key = ('{0}|{1}|{2}|{3}' -f $kind, $name, (Get-DataProperty $change 'Account'), (Get-DataProperty $change 'AccountState')).ToLowerInvariant() } if (-not $groups.ContainsKey($key)) { $order += $key $groups[$key] = [pscustomobject]@{ Time = (ConvertTo-DataTime (Get-DataProperty $change 'Time')) Kind = $kind What = $what Version = "$(Get-DataProperty $change 'Version')" Account = "$(Get-DataProperty $change 'Account')" AccountState = "$(Get-DataProperty $change 'AccountState')" DateOnly = [bool](Get-DataProperty $change 'DateOnly') Before = "$(Get-DataProperty $change 'Before')" Detail = "$(Get-DataProperty $change 'Detail')" Count = 1 First = (ConvertTo-DataTime (Get-DataProperty $change 'Time')) } continue } # Newest first: what comes later in the list is older. $groups[$key].Count++ $groups[$key].First = (ConvertTo-DataTime (Get-DataProperty $change 'Time')) } foreach ($key in $order) { $groups[$key] } } function Get-ChangeKindText { <# .SYNOPSIS A kind of Change in a Technician's words; one this module does not know, as it is. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Kind) switch ("$Kind") { 'program-installed' { return (Get-Text 'Value.Change.Kind.ProgramInstalled') } 'program-updated' { return (Get-Text 'Value.Change.Kind.ProgramUpdated') } 'program-removed' { return (Get-Text 'Value.Change.Kind.ProgramRemoved') } 'program-listed' { return (Get-Text 'Value.Change.Kind.ProgramListed') } 'windows-update' { return (Get-Text 'Value.Change.Kind.WindowsUpdate') } 'driver-installed' { return (Get-Text 'Value.Change.Kind.DriverInstalled') } 'service-added' { return (Get-Text 'Value.Change.Kind.ServiceAdded') } 'restart' { return (Get-Text 'Value.Change.Kind.Restart') } 'unexpected-shutdown' { return (Get-Text 'Value.Change.Kind.UnexpectedShutdown') } } "$Kind" } function Get-ChangeSourceText { <# .SYNOPSIS What one of the sources of $script:ChangeScans tells, in a Technician's words. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Source) switch ("$Source") { 'Program' { return (Get-Text 'Value.Change.Source.Program') } 'Update' { return (Get-Text 'Value.Change.Source.Update') } 'Driver' { return (Get-Text 'Value.Change.Source.Driver') } 'Service' { return (Get-Text 'Value.Change.Source.Service') } 'Restart' { return (Get-Text 'Value.Change.Source.Restart') } } "$Source" } function Get-ChangeGap { <# .SYNOPSIS What of the window was not read, as sentences: a log that was refused, one this machine does not have, one that does not reach back as far as the window. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Data) # Not read in this Run, whatever the reason: the one wording for that (see Get-UnreadText). $notRead = Get-Text 'Value.Cell.NotRead' $since = Get-DataProperty $Data 'Since' foreach ($log in (Get-DataCollection $Data 'Logs')) { $name = "$(Get-DataProperty $log 'Log')" $sources = @((Get-DataCollection $log 'Sources') | ForEach-Object { Get-ChangeSourceText -Source "$_" }) -join ', ' $oldest = Get-DataProperty $log 'Oldest' if (-not [bool](Get-DataProperty $log 'Found')) { (Get-Text 'Value.Change.LogMissing') -f $notRead, $sources, $name } elseif (-not [bool](Get-DataProperty $log 'Readable')) { (Get-Text 'Value.Change.LogUnread') -f $notRead, $sources, $name } elseif ($since -and $oldest -and (ConvertTo-DataTime $oldest) -gt (ConvertTo-DataTime $since)) { (Get-Text 'Value.Change.LogShort') -f $sources, $name, (ConvertTo-DataTime $oldest) } } } function ConvertTo-ChangeFinding { <# .SYNOPSIS One Finding: how many Changes of each kind Windows logged in the window. Pure. .DESCRIPTION Information, and never more: a Change is a fact and no fault, and whether it has to do with the trouble a Technician was called for is theirs to conclude. A month without a Change is OK - but only one that was read. What could not be read is said in the same Finding, which is then information whatever was counted. Counted as the table shows them: what recurs by itself counts once. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [hashtable]$Parameters = @{}) $check = Get-Text 'Check.Change.Summary' if ($null -eq $Data -or -not (Get-DataCollection $Data 'Logs').Count) { return New-UnavailableFinding -Category Stability -Check $check -Hint (Get-Text 'Hint.Change.NothingRead') } $days = ConvertTo-Number (Get-DataProperty $Data 'WindowDays') if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays } $max = ConvertTo-Number (Get-DataProperty $Data 'MaxRows') if ($null -eq $max -or $max -lt 1) { $max = $script:ChangeDefaultMaxRows } $rows = @(Group-ChangeRow -Data $Data) $gaps = @(Get-ChangeGap -Data $Data) $value = @() if ($rows.Count) { $value += (Get-Text 'Value.Change.Total') -f $rows.Count, $days foreach ($kind in $script:ChangeKinds) { $count = @($rows | Where-Object { $_.Kind -eq $kind }).Count if ($count) { $value += (Get-Text 'Value.Change.Count') -f (Get-ChangeKindText -Kind $kind), $count } } if ($rows.Count -gt $max) { $value += (Get-Text 'Value.Change.Cut') -f $max, $rows.Count } } else { $value += (Get-Text 'Value.Change.None') -f $days } $value += $gaps if (-not $rows.Count -and -not $gaps.Count) { return New-Finding -Category Stability -Check $check -Severity OK -Value ($value -join ' | ') } $meaning = @() $hint = @() if ($rows.Count) { $meaning += Get-Text 'Meaning.Change.Summary' $hint += Get-Text 'Hint.Change.Summary' } if ($gaps.Count) { $meaning += Get-Text 'Meaning.Change.Gap' $hint += Get-Text 'Hint.Change.Gap' } New-Finding -Category Stability -Check $check -Severity INFO -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') ` -Reference @(New-Reference -Section 'Title.Change.Timeline' -Argument $days) } function ConvertTo-ChangeSection { <# .SYNOPSIS The Changes of the window as one table, newest first. Pure. .DESCRIPTION Every cell in words. The account is the name Windows logged; where it logged none, or one that no longer has a name, the cell says which, and never shows a Sid. The newest MaxRows only: the Finding says when the table was cut. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $days = ConvertTo-Number (Get-DataProperty $Data 'WindowDays') if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays } $max = ConvertTo-Number (Get-DataProperty $Data 'MaxRows') if ($null -eq $max -or $max -lt 1) { $max = $script:ChangeDefaultMaxRows } New-Section -Title ((Get-Text 'Title.Change.Timeline') -f $days) -Row @( Group-ChangeRow -Data $Data | Select-Object -First ([int]$max) | ForEach-Object { $account = $_.Account if ($_.AccountState -eq 'unresolved') { $account = Get-Text 'Value.Change.Account.Unresolved' } elseif (-not $account) { $account = Get-Text 'Value.Change.Account.NotLogged' } $detail = @() if ($_.Before) { $detail += (Get-Text 'Value.Change.Detail.Before') -f $_.Before } if ($_.Detail) { # Named: inside a switch, $_ is what is switched on. $named = $_.Detail switch ($_.Kind) { 'driver-installed' { $detail += (Get-Text 'Value.Change.Detail.Driver') -f $named } 'service-added' { $detail += (Get-Text 'Value.Change.Detail.Service') -f (Get-ChangeFileName -Path $named) } 'restart' { $detail += (Get-Text 'Value.Change.Detail.Restart') -f $named } default { $detail += (Get-Text 'Value.Change.Detail.Manufacturer') -f $named } } } if ($_.Kind -eq 'unexpected-shutdown') { $detail += Get-Text 'Value.Change.Detail.UnexpectedShutdown' } if ($_.DateOnly) { $detail += Get-Text 'Value.Change.Detail.DateOnly' } if ($_.Count -gt 1) { $detail += (Get-Text 'Value.Change.Detail.Repeated') -f $_.Count, $_.First } $row = [ordered]@{} $row[(Get-Text 'Column.Change.Time')] = $(if ($_.DateOnly) { (Get-Text 'Value.Change.Date') -f $_.Time } else { (Get-Text 'Value.Change.Time') -f $_.Time }) $row[(Get-Text 'Column.Change.Kind')] = Get-ChangeKindText -Kind $_.Kind $row[(Get-Text 'Column.Change.What')] = $_.What $row[(Get-Text 'Column.Change.Version')] = $_.Version $row[(Get-Text 'Column.Change.Account')] = $account $row[(Get-Text 'Column.Change.Detail')] = $detail -join '; ' [pscustomobject]$row } ) } |