Private/Kinds/SystemFiles.ps1
|
# The SystemFiles Kind: whether the protected Windows system files are as Windows installed them. # # The second half of the integrity scan. ImageIntegrity reads the component store; the # system files are restored from that store, and are a question of their own, which # sfc answers. Opt-in with the scan and after it: it reaches a Run where the scan does, # through New-IntegrityCheckDefinition, and takes as long. # # It verifies and never repairs: sfc /verifyonly. /scannow replaces files, and a Run # changes nothing on the machine it examines - the rule by which the scan is ScanHealth # and not RestoreHealth. The repair is the Hint's, for the Technician to run. # # What sfc prints is in the language of Windows, so its outcome is read from what is # not: its exit code and CBS.log. Recorded 2026-10-18 on Windows 11 (build 26300), see # docs/research/sfc-verifyonly.md: without admin rights it exits 1; with violations it # exits 0 all the same, so the exit code tells a run that was not performed and nothing # else. The files it found damaged it names only in the log, one "Corrupt file:" line # each, right after its last "[SR] Verify complete". $script:SystemFilesNeedsAdmin = $true # The one switch sfc is started with: the one that only verifies. $script:SystemFilesArguments = '/verifyonly' # What Get-SystemFilesData hands its Judge. See Private/DataShape.ps1. $script:SystemFilesDataShape = @{ Elevated = 'Answered' Started = 'Answered' ExitCode = 'Value' Words = 'Value' LogRead = 'Answered' RunFound = 'Answered' Verified = 'Answered' Files = 'List' Message = 'Value' StoreObserved = 'Answered' StoreHealthState = 'Value' } function Get-SystemFilesData { <# .SYNOPSIS Has sfc verify the protected system files, and reads what it wrote into CBS.log for this run. Decides nothing. .PARAMETER Observed What the Checks before it gathered: the component store scan's state, which the Judge needs to say what to repair first. #> [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{}) $store = Get-ObservedKindData -Observed $Observed -Kind 'ImageIntegrity' $record = [ordered]@{ PSTypeName = 'Gutcheck.Data.SystemFiles' Elevated = $false Started = $false ExitCode = $null Words = '' LogRead = $false RunFound = $false Verified = $false Files = @() Message = '' # Whether the component store was scanned before, and what it came to. StoreObserved = [bool](Get-DataProperty $store 'Scanned') StoreHealthState = $(if ([bool](Get-DataProperty $store 'Scanned')) { "$(Get-DataProperty $store 'ImageHealthState')" }) } if ((Get-CurrentPrivilege) -ne 'admin') { return [pscustomobject]$record } $record.Elevated = $true Write-ConsoleNote (Get-Text 'Console.Progress.VerifyOnly') -Color Gray # The log's times are to the second: the run is the one that began from this second on. $now = Get-Date $since = $now.AddTicks(-($now.Ticks % [TimeSpan]::TicksPerSecond)) try { $run = Invoke-SystemFilesVerify $record.Started = $true $record.ExitCode = $run.ExitCode $record.Words = Get-SystemFilesWords -Output $run.Output } catch { $record.Message = $_.Exception.Message return [pscustomobject]$record } $text = $null try { $text = Read-SystemFilesLogText; $record.LogRead = $true } catch { $record.Message = $_.Exception.Message } $log = Read-SystemFilesLog -Text $text -Since $since $record.RunFound = $log.RunFound $record.Verified = $log.Verified $record.Files = @($log.Files) [pscustomobject]$record } function Invoke-SystemFilesVerify { <# .SYNOPSIS Runs sfc with the switch that only verifies, and returns its exit code and what it printed. .DESCRIPTION sfc writes UTF-16 to a pipe, whatever the console's code page: read as anything else, its German comes out as every second byte. #> [CmdletBinding()] [OutputType([psobject])] param() $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = Join-Path $env:SystemRoot 'System32\sfc.exe' $info.Arguments = $script:SystemFilesArguments $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardOutput = $true $info.StandardOutputEncoding = [Text.Encoding]::Unicode $process = [Diagnostics.Process]::Start($info) try { $output = $process.StandardOutput.ReadToEnd() $process.WaitForExit() [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $output } } finally { $process.Dispose() } } function Read-SystemFilesLogText { <# .SYNOPSIS The text of CBS.log, read while Windows may still be writing to it. #> [CmdletBinding()] [OutputType([string])] param() $path = Join-Path $env:SystemRoot 'Logs\CBS\CBS.log' $stream = New-Object System.IO.FileStream -ArgumentList $path, ([IO.FileMode]::Open), ([IO.FileAccess]::Read), ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) try { $reader = New-Object System.IO.StreamReader -ArgumentList $stream $reader.ReadToEnd() } finally { $stream.Dispose() } } function Read-SystemFilesLog { <# .SYNOPSIS What one run of sfc wrote into CBS.log: whether it began, whether it verified, and the files it found damaged. Pure. .DESCRIPTION Other servicing writes into the same file, before the run and after it, so only the run's own lines count: from the last "Client requests SFP repair object" written at or after Since, to its last "[SR] Verify complete" and the lines of that same second after it, where the damaged files are named. Nothing else out of the log is kept. A damaged file is named by a "Corrupt file:" line (observed for a driver). How a violation of another kind is written in a /verifyonly run was not seen. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][datetime]$Since) $none = [pscustomobject]@{ RunFound = $false; Verified = $false; Files = @() } $lines = @("$Text" -split "`r?`n") $at = { param([string]$Line) if ($Line -match '^(?<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),') { [datetime]::ParseExact($Matches['time'], 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) } } $start = -1 for ($i = $lines.Count - 1; $i -ge 0; $i--) { if ($lines[$i] -notmatch 'Client requests SFP repair object') { continue } $time = & $at $lines[$i] if ($null -ne $time -and $time -ge $Since) { $start = $i } break } if ($start -lt 0) { return $none } $last = -1 for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '\[SR\] Verify complete\s*$') { $last = $i } } if ($last -lt 0) { return [pscustomobject]@{ RunFound = $true; Verified = $false; Files = @() } } # The lines of that second, and the "Corrupt file:" lines that follow on from them: a # long list may run into the next second. $second = "$($lines[$last])".Substring(0, 19) $files = @(for ($i = $last + 1; $i -lt $lines.Count; $i++) { $named = $lines[$i] -match 'Corrupt file:\s*(?<file>.+?)\s*$' if (-not $named -and -not "$($lines[$i])".StartsWith($second)) { break } if ($named) { $Matches['file'] } }) [pscustomobject]@{ RunFound = $true; Verified = $true; Files = $files } } function Get-SystemFilesWords { <# .SYNOPSIS What sfc said, as it said it, without its progress: one line. Pure. .DESCRIPTION Shown to the Technician as Windows wrote it, as the time service's words are; read for nothing. Its progress lines are the ones with a percentage. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Output) $kept = @("$Output" -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -notmatch '\d+\s*%' }) # sfc breaks a sentence over lines where its console was narrow. (($kept -join ' ') -replace '\s+', ' ').Trim() } function ConvertTo-SystemFilesFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) $check = Get-Text 'Check.SystemFiles.Verify' if (-not [bool](Get-DataProperty $Data 'Elevated')) { return New-Finding -Category Integrity -Check $check -Severity INFO ` -Value (Get-Text 'Value.SystemFiles.SkippedNeedsAdmin') -Hint (Get-Text 'Hint.SystemFiles.RerunAndAllowTheAdmin') } if (-not [bool](Get-DataProperty $Data 'Started')) { return New-UnavailableFinding -Category Integrity -Check $check ` -Hint ((Get-Text 'Hint.SystemFiles.NotStarted') -f (Get-DataProperty $Data 'Message')).Trim() } # Not performed: sfc ended with a code, or the log holds no verification of this run. # sfc's own words beside it either way, and never the code alone. $words = "$(Get-DataProperty $Data 'Words')".Trim() if (-not $words) { $words = Get-Text 'Value.SystemFiles.SaidNothing' } $code = ConvertTo-Number (Get-DataProperty $Data 'ExitCode') if ($null -ne $code -and $code -ne 0) { return New-Finding -Category Integrity -Check $check -Severity INFO ` -Value ((Get-Text 'Value.SystemFiles.ExitCode') -f [int]$code, $words) -Hint (Get-Text 'Hint.SystemFiles.NotPerformed') } # sfc ran, and what it found could not be read: no answer, and not "nothing in the log". if (-not [bool](Get-DataProperty $Data 'LogRead')) { return New-UnavailableFinding -Category Integrity -Check $check ` -Hint ((Get-Text 'Hint.SystemFiles.LogUnreadable') -f (Get-DataProperty $Data 'Message')) } if (-not [bool](Get-DataProperty $Data 'Verified')) { return New-Finding -Category Integrity -Check $check -Severity INFO ` -Value ((Get-Text 'Value.SystemFiles.NotInLog') -f $words) -Hint (Get-Text 'Hint.SystemFiles.NotPerformed') } $files = @((Get-DataCollection $Data 'Files') | Where-Object { $_ }) if (-not $files.Count) { # Nothing found rests on the log alone, and a damaged file the log names in a form # not seen yet would pass for none: sfc's own words stand beside it, kept on the OK. return New-Finding -Category Integrity -Check $check -Severity OK -Value (Get-Text 'Value.SystemFiles.Clean') ` -Meaning ((Get-Text 'Meaning.SystemFiles.Clean') -f $words) -KeepMeaning } # The files are restored from the component store: where its scan found that damaged, # it is repaired first, and the Hint names that Finding so the two are read together. $state = "$(Get-DataProperty $Data 'StoreHealthState')" $hint = Get-Text 'Hint.SystemFiles.Repair' if ([bool](Get-DataProperty $Data 'StoreObserved') -and $state -and $state -ne 'Healthy') { $hint = (Get-Text 'Hint.SystemFiles.RepairStoreFirst') -f (Get-Text 'Check.ImageIntegrity.ComponentStore') } $value = $(if ($files.Count -eq 1) { (Get-Text 'Value.SystemFiles.Violation') -f (Get-Text 'Title.SystemFiles.Files') } else { (Get-Text 'Value.SystemFiles.Violations') -f $files.Count, (Get-Text 'Title.SystemFiles.Files') }) New-Finding -Category Integrity -Check $check -Severity FAIL -Value $value -Hint $hint } function ConvertTo-SystemFilesSection { <# .SYNOPSIS The files sfc found damaged, one row each. Nothing where it found none. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $files = @((Get-DataCollection $Data 'Files') | Where-Object { $_ }) if (-not $files.Count) { return } New-Section -Title (Get-Text 'Title.SystemFiles.Files') -Help (Get-Text 'Help.SystemFiles.Files') -Privilege admin -Row @( foreach ($file in $files) { $row = [ordered]@{} $row[(Get-Text 'Column.SystemFiles.File')] = "$file" [pscustomobject]$row }) } |