Private/Kinds/StabilityChange.ps1
|
# The tie between a Change and crashes: a part of the Stability Kind, and no Kind. # # An order dependency, and it exists by position only: the Check of the Change Kind # ("Recent changes" in the Local Definitions) has to stand BEFORE the Stability Check in # the Check Definitions, because a Gatherer is handed only what the Checks before it # gathered. Where it stands after it, or was skipped, or is not in the Definitions at all, # nothing breaks and nothing is said: the crash Findings are then exactly as they were # before there was a Change Kind, with no line about a Change and no Finding saying that # none was looked for (#59: "the crash Finding is then as today"). A test holds the Local # Definitions to the order: tests/Definition.Tests.ps1, "stand in the order the Checks that # read from each other need". A Check Definition document cannot carry a comment, so that # test and this are where the dependency is written down. # How many days before the first crash of one kind a Change may lie and still be named # beside it. The spec's (#59): "where crashes of that kind began within 3 days after a # Change". A rule of thumb and no limit of Windows: a driver that crashes a machine # usually does so within days of being installed, and after a week half the programs of # a machine have updated themselves. $script:StabilityChangeWindowDays = 3 # How many days before the first crash of one kind have to have been read, and without a # crash of that kind, for the crashes to be said to have BEGUN there. A rule of thumb and # no limit of Windows: two weeks, because what crashes every week has crashed in them, and # so has what crashes within days of each monthly update at the one before. With the # window alone - three readable days before the first crash in the data - both were said # to have begun after a Change. See Get-CrashChangeTie. $script:StabilityChangeQuietDays = 14 # On how many different days of the Changes' window the same thing has to have been # changed to be routine, which no crash is tied to. Two: what Windows logged on two days # of one month comes back by itself. A rule of thumb. See Select-ChangeWorthTying. $script:StabilityChangeRoutineDays = 2 # The updates no crash is tied to, by the number of their article, which is the same in # every language. KB2267602 is the security intelligence update of Microsoft Defender # Antivirus: the virus definitions, installed once a day or more, under that one number. $script:StabilityChangeIgnoredUpdates = @('KB2267602') # What the title of an update looks like that is an app from the Microsoft Store updating # itself: the Store's id of the app, twelve characters beginning with 9, a hyphen, and the # name of the package ("9WZDNCRFJBH4-Microsoft.Windows.Photos"). Read off real events on # Windows 11, where Windows Update logs a dozen of these a month, at night, each app under # a title of its own - so that none of them recurs, and one of them is near every crash. $script:StabilityChangeStoreAppPattern = '^9[A-Z0-9]{11}-\S' # The kinds of Change a crash can be tied to. Not a start of Windows and not an # unexpected shutdown: those are what happened to the machine, logged at the start after, # and nothing that was done to it. A kind this list does not know is not tied. $script:StabilityChangeTiedKinds = @( 'driver-installed', 'program-installed', 'program-updated', 'program-removed', 'program-listed', 'service-added', 'windows-update' ) function Copy-ObservedChange { <# .SYNOPSIS The Changes the Change Check gathered, as this Kind's own data. Pure, and decides nothing. .DESCRIPTION Every Change, the starts of Windows and the daily virus definitions with the rest: what a crash is tied to is decided by the Judge, which a Check Definition can then tell otherwise without anything being gathered again. Gathered is false when that Check was not performed. With them go how far back the Changes were read and how far each log reaches: a crash is only said to have begun after a Change where the days before it were read. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][hashtable]$Observed) $source = Get-ObservedKindData -Observed $Observed -Kind 'Change' [pscustomobject]@{ Gathered = $null -ne $source GatheredAt = Get-DataProperty $source 'GatheredAt' WindowDays = Get-DataProperty $source 'WindowDays' Since = Get-DataProperty $source 'Since' Logs = @(foreach ($row in (Get-DataCollection $source 'Logs')) { [pscustomobject]@{ Log = "$(Get-DataProperty $row 'Log')" Readable = [bool](Get-DataProperty $row 'Readable') Oldest = Get-DataProperty $row 'Oldest' } }) Changes = @(foreach ($row in (Get-DataCollection $source 'Changes')) { [pscustomobject]@{ Time = Get-DataProperty $row 'Time' Kind = "$(Get-DataProperty $row 'Kind')" What = "$(Get-DataProperty $row 'What')" Version = "$(Get-DataProperty $row 'Version')" Account = "$(Get-DataProperty $row 'Account')" AccountState = "$(Get-DataProperty $row 'AccountState')" DateOnly = [bool](Get-DataProperty $row 'DateOnly') Before = "$(Get-DataProperty $row 'Before')" Detail = "$(Get-DataProperty $row 'Detail')" } }) } } function Get-ChangeTieSetting { <# .SYNOPSIS How a crash is tied to a Change: how many days before the first crash a Change may lie, how many days before it have to have been quiet, from how many days on a recurring Change is routine, and which updates are never tied. Pure. .DESCRIPTION Check Definition parameters: ChangeWindowDays (3), ChangeQuietDays (14), ChangeRoutineDays and ChangeIgnoredUpdates. What is no number, or not above zero, is the Kind's own: a slip in a Definition must neither throw nor switch the question off. Both numbers of days are rules of thumb and neither is a limit of Windows or one Microsoft documents: see where their defaults are declared. The quiet time is never shorter than the window: a Change is only looked for in days that were read. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][hashtable]$Parameters = @{}) $window = Get-NumberParameter $Parameters 'ChangeWindowDays' $script:StabilityChangeWindowDays if ($window -le 0) { $window = $script:StabilityChangeWindowDays } $quiet = Get-NumberParameter $Parameters 'ChangeQuietDays' $script:StabilityChangeQuietDays if ($quiet -le 0) { $quiet = $script:StabilityChangeQuietDays } if ($quiet -lt $window) { $quiet = $window } $routine = Get-NumberParameter $Parameters 'ChangeRoutineDays' $script:StabilityChangeRoutineDays if ($routine -lt 2) { $routine = $script:StabilityChangeRoutineDays } [pscustomobject]@{ WindowDays = $window QuietDays = $quiet RoutineDays = $routine IgnoredUpdates = @(Get-ListParameter $Parameters 'ChangeIgnoredUpdates' $script:StabilityChangeIgnoredUpdates) } } function Get-ChangeRoutineKey { <# .SYNOPSIS What a Change is a Change of, to know the same thing changed again. Pure. .DESCRIPTION A program by its name without its numbers, whether installed, updated or removed: many carry their version in their name. An update by the number of its article, which every edition of the virus definitions shares. A driver by its device and its version: the same version given to a device again is the device plugged in again, and another version is news. A service by the file it runs, which stays where a program adds its service under a new name and in a new folder each time: see Get-ChangeServiceFile, which is the Change Kind's. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Change) $kind = "$(Get-DataProperty $Change 'Kind')" $what = "$(Get-DataProperty $Change 'What')" switch -Wildcard ($kind) { 'program-*' { return 'program|{0}' -f (Get-ChangeProgramKey $what) } 'windows-update' { if ($what -match 'KB\d{6,}') { return ('update|{0}' -f $Matches[0]).ToLowerInvariant() } return 'update|{0}' -f (Get-ChangeProgramKey $what) } 'driver-installed' { return ('driver|{0}|{1}' -f $what, (Get-DataProperty $Change 'Version')).ToLowerInvariant() } 'service-added' { $file = Get-ChangeServiceFile -Change $Change if ($file) { return 'service|{0}' -f $file } return 'service|{0}' -f (Get-ChangeProgramKey $what) } } ('{0}|{1}' -f $kind, $what).ToLowerInvariant() } function Test-ChangeNamesProgram { <# .SYNOPSIS Whether a Change is plausibly to the program that crashes: a program of its name, or a service that runs its file. Pure. .DESCRIPTION By the letters of the two names and nothing else: "labelwin.exe" is in "Acme Labelwin 7.1". A name of fewer than four letters is in too many others to say anything. A driver and an update name a device and an article, never a program. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$Change, [AllowNull()][AllowEmptyString()][string]$App) $file = "$App".Trim().ToLowerInvariant() if (-not $file) { return $false } $kind = "$(Get-DataProperty $Change 'Kind')" if ($kind -eq 'service-added' -and (Get-ChangeServiceFile -Change $Change) -eq $file) { return $true } if ($kind -ne 'service-added' -and $kind -notlike 'program-*') { return $false } $program = (($file -replace '\.exe$', '') -replace '[^\p{L}]', '') $named = ("$(Get-DataProperty $Change 'What')".ToLowerInvariant() -replace '[^\p{L}]', '') if ($program.Length -lt 4 -or $named.Length -lt 4) { return $false } [bool]($named.Contains($program) -or $program.Contains($named)) } function Select-ChangeWorthTying { <# .SYNOPSIS The Changes a crash can be tied to, each with its time and whether it names the crashing program. Pure. .DESCRIPTION A tie that stands in every Report is worse than none: a Technician who has read "began after a Change" on three healthy machines does not read it on the fourth. So what is tied is what was done to the machine and does not happen by itself: - a driver installed, a program installed, updated or removed by Windows Installer, a service added, an update Windows Update installed; - never a start of Windows or an unexpected shutdown, which are no Changes a crash comes from, see $script:StabilityChangeTiedKinds; - never the virus definitions, see $script:StabilityChangeIgnoredUpdates, and never an app from the Microsoft Store updating itself, see $script:StabilityChangeStoreAppPattern; - never what was changed on RoutineDays or more different days of the Changes' window, see Get-ChangeRoutineKey: a browser that updates itself every week, a service a program adds again at every start. Twice on one day is not that: an installation and its patch; - never a program known only from the list of installed programs, by its day: the list keeps one date for each program, the last, so it cannot show that a program updates itself every week, and those that do are the ones in it. What such a program brings that can stop Windows - a driver, a service - is logged as a Change of its own. What names the crashing program is tied whatever the last two say: that the program that crashes was itself changed is the first thing to know, routine or not. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Change, [AllowNull()][AllowEmptyString()][string]$App = '', [AllowNull()]$Setting ) if ($null -eq $Setting) { $Setting = Get-ChangeTieSetting } $rows = @(foreach ($row in (Get-DataCollection $Change 'Changes')) { $time = ConvertTo-DataTime (Get-DataProperty $row 'Time') if ($null -eq $time) { continue } [pscustomobject]@{ Row = $row; Time = $time; Kind = "$(Get-DataProperty $row 'Kind')"; Key = Get-ChangeRoutineKey -Change $row } }) # On how many different days each thing was changed. $days = @{} foreach ($entry in $rows) { if (-not $days.ContainsKey($entry.Key)) { $days[$entry.Key] = @{} } $days[$entry.Key][$entry.Time.ToString('yyyyMMdd')] = $true } foreach ($entry in $rows) { if ($script:StabilityChangeTiedKinds -notcontains $entry.Kind) { continue } # Not asked whether it names the program: an update names an article or a package. if ($entry.Kind -eq 'windows-update' -and "$(Get-DataProperty $entry.Row 'What')".Trim() -cmatch $script:StabilityChangeStoreAppPattern) { continue } $related = Test-ChangeNamesProgram -Change $entry.Row -App $App if (-not $related) { if ($entry.Kind -eq 'program-listed') { continue } if ($entry.Kind -eq 'windows-update') { $title = "$(Get-DataProperty $entry.Row 'What')" if (@($Setting.IgnoredUpdates | Where-Object { $_ -and $title -match ('(?i)\b{0}\b' -f [regex]::Escape($_)) }).Count) { continue } } if ($days[$entry.Key].Count -ge $Setting.RoutineDays) { continue } } [pscustomobject]@{ Change = $entry.Row Time = $entry.Time DateOnly = [bool](Get-DataProperty $entry.Row 'DateOnly') Related = $related } } } function Get-CrashChangeTie { <# .SYNOPSIS The Change the crashes of one kind began shortly after, or nothing. Pure. .DESCRIPTION The crashes of one kind are the blue screens with one stop code, or the crashes of one program. They began with the earliest of them in the data. Where a Change worth tying (Select-ChangeWorthTying) lies no more than ChangeWindowDays before that first crash, the crashes are tied to it: to the nearest before the crash, and Others says how many more lie in the same days. For a program, a Change that names it comes before a nearer one that does not, and is not required: a driver installed before a program's first crash is exactly what a Technician would not have looked for. A Change known by its day only is compared by day, and only a day strictly before that of the first crash counts. On the day of the crash itself the order of the two is not known, and "began after a Change" is not said of an order nobody knows. Nothing where the Changes were not read: the Check was not performed, or none of its logs could be read. Where it was performed and one of its logs could not be read, or does not reach back over the window before the first crash, a Change from another log is still tied - and Partial says that not every source of Changes was read for that time, so that "the nearest" is the nearest of what was read and is not taken for more. Nothing where "began" cannot be known. The first crash in the data is the first crash only if a quiet time before it was read: ChangeQuietDays (14) without a crash of that kind. The first crash in the data has none before it in the data, so what has to hold is that the data reaches back that far before it - by this Check (Days back from when the Changes were gathered, which is minutes before), by the Change Check (Since), and in the log the crashes are in, where the Change Check says how far back that log reaches. A first crash nearer than that to any of those may have had one before it that nobody saw: a program that crashes every week, or with every monthly update, has its "first" crash in the data within days of whatever was changed last, and was said to have begun there as long as three readable days were enough. Where the data does not reach back that far, nothing is said. .PARAMETER Time When each crash of the kind happened. .PARAMETER Log The event log these crashes are in: System for blue screens, Application for programs. .PARAMETER Days How many days this Check looked back. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Time, [AllowNull()]$Change, [AllowNull()][AllowEmptyString()][string]$App = '', [AllowNull()][AllowEmptyString()][string]$Log = '', [AllowNull()]$Days, [AllowNull()][hashtable]$Parameters = @{} ) if (-not [bool](Get-DataProperty $Change 'Gathered')) { return } $logs = Get-DataCollection $Change 'Logs' if (-not @($logs | Where-Object { [bool](Get-DataProperty $_ 'Readable') }).Count) { return } $times = @(@($Time) | ForEach-Object { ConvertTo-DataTime $_ } | Where-Object { $null -ne $_ } | Sort-Object) if (-not $times.Count) { return } $first = [datetime]$times[0] $setting = Get-ChangeTieSetting -Parameters $Parameters $from = $first.AddDays(-$setting.WindowDays) # From where on the days before the first crash have to have been read. $quiet = $first.AddDays(-$setting.QuietDays) # How far back everything was read. See the description. $reach = ConvertTo-DataTime (Get-DataProperty $Change 'Since') if ($null -eq $reach) { return } $gathered = ConvertTo-DataTime (Get-DataProperty $Change 'GatheredAt') $looked = ConvertTo-Number $Days if ($null -ne $gathered -and $null -ne $looked -and $gathered.AddDays(-$looked) -gt $reach) { $reach = $gathered.AddDays(-$looked) } foreach ($entry in @($logs | Where-Object { "$(Get-DataProperty $_ 'Log')" -eq $Log })) { $oldest = ConvertTo-DataTime (Get-DataProperty $entry 'Oldest') if ($null -ne $oldest -and $oldest -gt $reach) { $reach = $oldest } } if ($quiet -lt $reach) { return } $near = @(Select-ChangeWorthTying -Change $Change -App $App -Setting $setting | ForEach-Object { if ($_.DateOnly) { # A day strictly before: on the day of the crash the order is not known. $apart = ($first.Date - $_.Time.Date).TotalDays if ($apart -lt 1 -or $apart -gt $setting.WindowDays) { return } } elseif ($_.Time -gt $first -or $_.Time -lt $from) { return } $_ }) if (-not $near.Count) { return } $named = @($near | Where-Object { $_.Related }) if (-not $named.Count) { $named = $near } # On every column that tells two apart: a sort does not keep the order of equals on # Windows PowerShell 5.1. $nearest = @($named | Sort-Object @{ Expression = { $_.Time }; Descending = $true }, @{ Expression = { "$(Get-DataProperty $_.Change 'Kind')" } }, @{ Expression = { "$(Get-DataProperty $_.Change 'What')" } })[0] # Whether every log a Change comes from was read over the window before the crash. $partial = [bool]@($logs | Where-Object { if (-not [bool](Get-DataProperty $_ 'Readable')) { return $true } $oldest = ConvertTo-DataTime (Get-DataProperty $_ 'Oldest') $null -ne $oldest -and $oldest -gt $from }).Count [pscustomobject]@{ First = $first Change = $nearest.Change Time = $nearest.Time Partial = $partial DateOnly = $nearest.DateOnly # For a Change known by its day only: how many days before the day of the crash. Days = [int]($first.Date - $nearest.Time.Date).TotalDays Related = $nearest.Related Others = $near.Count - 1 # The title of the timeline takes the days it covers. Timeline = Get-DataProperty $Change 'WindowDays' } } function Get-ChangeTieText { <# .SYNOPSIS How long after which Change the first crash was, as the end of a sentence: "2 Tage nach einer Aenderung: Treiber installiert, ..., am ... (Konto ...)". Pure. .DESCRIPTION The Change by its kind in words, its name, its version and the one before where Windows logged them, when, and the account where Windows logged one. Where it logged none, none is named: the timeline says which of the two it was. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Tie) $change = $Tie.Change $time = [datetime]$Tie.Time if ($Tie.DateOnly) { # At least one: a Change of the day of the crash itself is not tied. $after = Get-Text 'Value.Stability.Change.After.Days' $Tie.Days if ($Tie.Days -le 1) { $after = Get-Text 'Value.Stability.Change.After.Day' } } else { $apart = [datetime]$Tie.First - $time $hours = [int][math]::Floor($apart.TotalHours) $days = [int][math]::Round($apart.TotalDays, [MidpointRounding]::AwayFromZero) if ($hours -lt 1) { $after = Get-Text 'Value.Stability.Change.After.Minutes' } elseif ($hours -eq 1) { $after = Get-Text 'Value.Stability.Change.After.Hour' } elseif ($hours -lt 24) { $after = Get-Text 'Value.Stability.Change.After.Hours' $hours } elseif ($days -le 1) { $after = Get-Text 'Value.Stability.Change.After.Day' } else { $after = Get-Text 'Value.Stability.Change.After.Days' $days } } $parts = @((Get-ChangeKindText -Kind "$(Get-DataProperty $change 'Kind')"), "$(Get-DataProperty $change 'What')") $version = "$(Get-DataProperty $change 'Version')" $before = "$(Get-DataProperty $change 'Before')" if ($version) { $parts += Get-Text 'Value.Stability.Change.Version' $version } if ($before) { $parts += Get-Text 'Value.Stability.Change.VersionBefore' $before } if ($Tie.DateOnly) { $parts += Get-Text 'Value.Stability.Change.On' $time } else { $parts += Get-Text 'Value.Stability.Change.At' $time } $named = @($parts | Where-Object { $_ }) -join ', ' $account = "$(Get-DataProperty $change 'Account')" if ($account) { $named = Get-Text 'Value.Stability.Change.Account' $named $account } if ($Tie.Others -eq 1) { $named = Get-Text 'Value.Stability.Change.OtherOne' $named } elseif ($Tie.Others -gt 1) { $named = Get-Text 'Value.Stability.Change.Others' $named $Tie.Others } $text = Get-Text 'Value.Stability.Change.After' $after $named # One short statement, so that "the nearest" is not read as more than it is. if ([bool](Get-DataProperty $Tie 'Partial')) { $text = '{0}; {1}' -f $text, (Get-Text 'Value.Stability.Change.SourcesPartial') } $text } function Get-ChangeTieMeaning { <# .SYNOPSIS What it means that crashes began after a Change, said once for a Finding however many of its crashes were tied: a coincidence in time, and no proven cause. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Tie) $ties = @($Tie | Where-Object { $null -ne $_ }) if (-not $ties.Count) { return } Get-Text 'Meaning.Stability.Change.Coincidence' if (@($ties | Where-Object { $_.Related }).Count) { Get-Text 'Meaning.Stability.Change.SameProgram' } } function Get-ChangeTieHint { <# .SYNOPSIS What to do about crashes that began after a Change: take that kind of Change back, or bring it up to date, as a test. Each said once. Pure. .DESCRIPTION Not so for an update of Windows: a coincidence in time is no reason for first level to uninstall a security update. First whether problems with it are known and whether other machines with it show the same; uninstalling comes after, as a test and with second level. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Tie) $hints = @(foreach ($one in @($Tie | Where-Object { $null -ne $_ })) { switch ("$(Get-DataProperty $one.Change 'Kind')") { 'driver-installed' { Get-Text 'Hint.Stability.Change.Driver' } 'windows-update' { Get-Text 'Hint.Stability.Change.Update' } 'service-added' { Get-Text 'Hint.Stability.Change.Service' } 'program-removed' { Get-Text 'Hint.Stability.Change.ProgramRemoved' } default { Get-Text 'Hint.Stability.Change.Program' } } }) $hints | Select-Object -Unique } function Get-ChangeTieReference { <# .SYNOPSIS The Reference to the timeline of the Changes, for a Finding with a crash tied to one; nothing for a Finding without. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Tie) $ties = @($Tie | Where-Object { $null -ne $_ }) if (-not $ties.Count) { return } # As the Change Kind names its Section: by the days its window has. $days = ConvertTo-Number $ties[0].Timeline if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays } New-Reference -Section 'Title.Change.Timeline' -Argument $days } |