Public/Invoke-Gutcheck.ps1
|
function Invoke-Gutcheck { <# .SYNOPSIS Gutcheck - an honest look inside a slow or unstable Windows PC. .DESCRIPTION Performs a Run: resolves the Check Definitions, performs each Check against this Target Machine, and writes a Report saying what it found and where its Checks came from. A Run spans two Parts. The Main Part runs in the Technician's own session, where their profile, mapped drives and processes are the real ones. The Elevated Part runs behind a UAC prompt, where an admin colleague can enter their own credentials, and performs only the Checks whose Kinds cannot be performed without admin rights. A Main Part that was started elevated does both itself. .PARAMETER OutputPath Output folder. Default: Desktop\Gutcheck_<computer>_<timestamp> - on the Desktop all users share when the Run is not in the name of the user who is logged on. .PARAMETER Apps Applications to examine: names, All, Auto, None, or proc:<processname>. Omit for the selection list on an interactive Run, or Auto on one with nobody to ask. .PARAMETER Skip Checks this Run should not perform, named by Check Name or by Kind. A skipped Check appears in the Report saying so, because a Check that did not run and a Check that found nothing must never look the same to a Technician. .PARAMETER NoUpdate Do not check the Gallery for a newer Gutcheck. Use it to reproduce a Report on a known version. .PARAMETER Updated Internal. Set on the Run that an update started, so it does not check again. .PARAMETER PublishedDefinitions Where the Published Definitions live, as <host>:<site path>:<file path> - for example contoso.sharepoint.com:/sites/Gutcheck:/checks.json. Defaults to MERLIN's; name another to point a Run at a different document. .PARAMETER TenantId The Entra ID tenant the device flow authenticates against. Defaults to MERLIN's. .PARAMETER ClientId The Entra app registration the device flow authenticates against. Defaults to MERLIN's, which ships in the package - see docs/adr/0003-the-client-id-ships-in-the-package.md. .PARAMETER NoFetch Do not fetch Check Definitions at all: use the Local Definitions shipped inside the module. For a machine that cannot reach the internet, or when the Technician does not want to stop and authenticate. .PARAMETER KeepSignIn Answers, in advance, the question a Run asks before signing in: will you come back to this machine for follow-up Runs? -KeepSignIn is yes; -KeepSignIn:$false is no, without being asked. Left out, a Run asks - and with nobody there to answer, as in a scheduled task, keeps nothing. Yes keeps the sign-in on this machine for seven days, so later Runs here neither sign in nor ask again. It can read the Gutcheck site and nothing else - not your OneDrive, not any other site - and only read it. The file is encrypted to this Windows user on this machine: a copy is useless elsewhere, but that Windows user can use it, and at a Customer Site that is usually the Customer's employee. That is why the question defaults to no. -ForgetSignIn removes it early. The seven days are enforced by Gutcheck, on the disk: every Run removes a sign-in whose week is up. Entra would honour the refresh token for longer, so a copy taken within the week outlives it unless the tenant sets a Conditional Access sign-in frequency for the Gutcheck app. See ADR-0004. -KeepSignIn:$false answers the question with no. A sign-in already kept on this machine is still used; -ForgetSignIn is what removes it. Either way, a sign-in is reused for the rest of the PowerShell session it was made in. That leaves nothing on the machine, so it is not asked about. -CacheToken is the name this had before the question existed, and still works. .PARAMETER ForgetSignIn Remove the kept sign-in from this machine, then carry on with the Run. .PARAMETER RunIntegrityCheck Also scan the Windows component store. Opt-in because it takes minutes rather than seconds, which on a Customer's machine is a real cost, and it answers a question worth asking only once the other explanations are exhausted. .PARAMETER NoElevation Do not ask for admin rights. The Checks that need them are performed anyway, with whatever rights this Run has, and say what they could not read. .PARAMETER NoUserSwitch Stay in the window this Run was started in. Without it a Run started "as administrator" starts itself again in the logged-on user's session and without the rights: started in another account it would examine the wrong profile, and started in the user's own it would not see the user's network drives. It asks for the rights itself. -NoElevation also keeps a Run that has admin rights where it is. .PARAMETER Switched Internal. Marks the Run that was started by such a switch, so it does not switch again. .PARAMETER Part Internal. Elevated is how the launcher re-enters the module in the second process. .PARAMETER TransferPath Internal. The directory the Elevated Part reads its Check Definitions from. .PARAMETER NoShow Do not open the Report when the Run finishes. .EXAMPLE Invoke-Gutcheck .EXAMPLE Invoke-Gutcheck -Skip 'Disk write test', 'Network' .EXAMPLE Invoke-Gutcheck -Apps Outlook, proc:acmeerp #> [CmdletBinding()] [OutputType([psobject])] param( [string]$OutputPath, [string[]]$Apps, [string[]]$Skip, [switch]$NoUpdate, [switch]$Updated, [string]$PublishedDefinitions, [string]$TenantId, [string]$ClientId, [switch]$NoFetch, [Alias('CacheToken')][switch]$KeepSignIn, [switch]$ForgetSignIn, [switch]$RunIntegrityCheck, [switch]$NoElevation, [switch]$NoUserSwitch, [switch]$Switched, [ValidateSet('Main', 'Elevated')][string]$Part = 'Main', [string]$TransferPath, [switch]$NoShow ) if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Gutcheck diagnoses Windows machines and only runs on Windows.' } if ($Part -eq 'Elevated' -and -not $TransferPath) { throw 'The Elevated Part needs -TransferPath: it reads its Check Definitions from there.' } $asOf = Get-Date $moduleVersion = $MyInvocation.MyCommand.Module.Version $computerName = $env:COMPUTERNAME $privilege = Get-CurrentPrivilege # The Elevated Part produces Findings and hands them back; it writes no Report of its # own, because a Run produces one Report and the Main Part is what assembles it. if ($Part -eq 'Elevated') { # Its window is a classic console, where one click freezes whatever is writing to # it until somebody presses Enter. Not put back: the window closes with the Part. $null = Disable-ConsoleQuickEdit return Invoke-ElevatedCheck -TransferPath $TransferPath -ModuleVersion $moduleVersion ` -Privilege $privilege -Skip $Skip } # Before anything else a Run does, and before the Definitions are fetched: a newer # module may implement Kinds the fetched Definitions name, so updating first is what # makes the two orderings agree. Nothing has been created yet, so a Run that is # replaced here leaves no half-written output folder behind. $update = Invoke-ModuleUpdate -Installed $moduleVersion -Disabled ([bool]$NoUpdate) ` -AlreadyUpdated ([bool]$Updated) -BoundParameter $PSBoundParameters if ($update.Relaunched) { # The replacement wrote the Report. So that the next Run in this window is not # replaced again, the window is moved to the version just used - as the last # thing, because it unloads the module this line belongs to. Switch-LoadedGutcheck -Version $update.Decision.Version return } # Before anything is examined: a Run in another account than the one logged on reads # that account's profile, and the Report would only say so when it is all over. if (-not (Confirm-UserContext -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` -LoggedOnUser (Get-InteractiveUser) -Interactive (Test-RunInteractive) ` -Elevated ($privilege -eq 'admin') -Stay ([bool]$NoUserSwitch -or [bool]$Switched) ` -KeepRights ([bool]$NoElevation) -CanDropRights (Get-ElevationDroppable) -BoundParameter $PSBoundParameters)) { return } if (-not $OutputPath) { $folder = 'Gutcheck_{0}_{1:yyyyMMdd_HHmm}' -f $computerName, $asOf $own = [Environment]::GetFolderPath('Desktop') $root = Select-OutputRoot -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` -LoggedOnUser (Get-InteractiveUser) -OwnDesktop $own ` -SharedDesktop ([Environment]::GetFolderPath('CommonDesktopDirectory')) $OutputPath = Join-Path $root $folder # The shared Desktop takes admin rights to write to. A Run in another user's name # without them falls back to its own. if ($root -ne $own -and $own) { try { New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop | Out-Null } catch { $OutputPath = Join-Path $own $folder } } } New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null # The same click freezes the Main Part. This window is the Technician's own, so what # it was is remembered and put back however the Run ends. $consoleMode = Disable-ConsoleQuickEdit # The console log is part of what a Technician hands to second level, so it starts # before the first Check and stops however the Run ends. $transcribing = $false try { Start-Transcript -Path (Join-Path $OutputPath 'console.log') -Force | Out-Null $transcribing = $true } catch { } try { Show-Banner -Version $moduleVersion # Asked for before anything else, so a Technician who wants the sign-in off this # machine gets it off whether or not the rest of the Run works. if ($ForgetSignIn) { Clear-FetchTokenCache Write-Host (Get-Text 'Console.Fetch.SignInForgotten') -ForegroundColor Gray } # A sign-in past its week comes off the disk on every Run. The fetch does it # itself, so only a Run that skips the fetch has to be told to. if ($NoFetch) { Remove-ExpiredKeptSignIn } # Exactly two states for the Definitions themselves, and no cache of those. Either # this fetch produced a document or it did not, and the Report says which either # way. A fetch that fails for any reason - nothing configured, a declined prompt, # an unreachable tenant - returns nothing and the Run continues on what shipped # inside the module. What a Kept Sign-In remembers is the sign-in, never the # Definitions: a stale document would make Provenance a lie, where a reused token # only saves a prompt. $fetched = $null if (-not $NoFetch) { # Three answers, not two: -KeepSignIn is yes, -KeepSignIn:$false is no, and not # saying anything means ask. A plain switch cannot tell "no" from "not said". $keep = $null if ($PSBoundParameters.ContainsKey('KeepSignIn')) { $keep = [bool]$KeepSignIn } $fetched = Invoke-DefinitionFetch -TenantId $TenantId -ClientId $ClientId ` -Address $PublishedDefinitions -KeepSignIn $keep } $resolved = Resolve-CheckDefinition -LocalPath (Get-LocalDefinitionPath) ` -Fetched $fetched -ModuleVersion $moduleVersion Write-Host (Get-ProvenanceStatement -Provenance $resolved.Provenance -AsOf $asOf) -ForegroundColor DarkCyan # List[psobject] rather than List[object], and not only because that is what these # hold. On both editions, @($x) throws "Argument types do not match" when $x is a # List[object] - the array subexpression is the single most common way to # materialise a collection in this codebase, and every Judge uses it on whatever a # Gatherer handed over. List[psobject] is unaffected. Verified on Windows # PowerShell 5.1.26100 and PowerShell 7.6.5. $findings = New-Object System.Collections.Generic.List[psobject] $sections = New-Object System.Collections.Generic.List[psobject] $events = New-Object System.Collections.Generic.List[psobject] $findings.Add((New-UpdateFinding -Decision $update.Decision)) $findings.Add((New-ProvenanceFinding -Provenance $resolved.Provenance -AsOf $asOf)) # Asked before the first Check, so every question a Run puts to the Technician # comes while they are still watching it start. $selection = Resolve-AppCheck -Definition $resolved.Check -Requested $Apps $findings.Add((New-AppSelectionFinding -Selection $selection)) # The one Check a Technician has to ask for by name. It arrives as a Definition # rather than as a flag the Kind reads, so that asking for it is the same kind of # act as any other Check being in the Run. $checks = @($selection.Check) if ($RunIntegrityCheck) { $checks += New-IntegrityCheckDefinition } # Before the first Check, and once: what the Hints of this Run may assume about # the machine. Both Parts are handed this one. $situation = ConvertTo-Situation -Reading (Get-SituationReading) $partition = Split-AdminCheck -Definition $checks $mainChecks = @($partition.Main) # Where the admin Checks happen, and what the Report says about it. $elevated = $null if ($privilege -eq 'admin') { # Already elevated: a second process would raise a second prompt to reach # rights this one already has. Still the Main Part, now carrying admin. $mainChecks = @($checks) $findings.Add((New-ElevationFinding -Status (Get-Text 'Value.Elevation.AlreadyElevated'))) } elseif ($NoElevation) { $mainChecks = @($checks) $findings.Add((New-ElevationFinding -Status (Get-Text 'Value.Elevation.NotRequested'))) } elseif (@($partition.Admin).Count) { # The long Checks extend the deadline, or a Run would report a timeout while # the scan the Technician asked for was still running. $elevated = Invoke-ElevatedPart -Definition $partition.Admin -OutputPath $OutputPath ` -ExtraTimeoutSeconds (Get-ElevatedExtraTimeout -Definition $partition.Admin) -Situation $situation if ($elevated.Returned) { $findings.Add((New-ElevationFinding -Status $elevated.Status)) } else { # The Run continues and says what it cost, rather than stopping. A refused # prompt is a Technician's decision, not a failure of the Run. $findings.Add((New-ElevationFinding -Status $elevated.Status -Skipped $partition.Admin -Severity WARN ` -BrokeOff:$elevated.BrokeOff)) } } # The names of accounts are asked of Windows once in a Run, and not remembered # from the Run before it in this window: see Private/Account.ps1. Clear-AccountNameCache # What each Check gathered, keyed by Kind, for the Checks that come after it. The # Run owns this and hands it over; no Check reaches into it. See Private/Kind.ps1. # Two keys are no Kind, and are the Run's own: 'AppSelection' and 'RunUser', further # down. $observed = @{} # It starts with what the Elevated Part gathered, which ran before any Check here: # a Judge that weighs crashes against network events only an admin may read needs # both, and they were gathered in different processes. Only from a Part that came # back whole - one that was refused or broke off leaves nothing here, and a Check # that wanted its data finds the Kind absent and says what it could not assess. # # A Kind performed in both Parts: the Elevated Part's gathering is what a Check is # handed until the Main Part has performed that Kind itself, and from then on the # Main Part's, which replaces it in the loop below. Gathered later, and in the # session of the person whose machine this is. if ($elevated -and $elevated.Returned) { $observed = ConvertFrom-ElevatedData -Data (Get-DataProperty $elevated 'Data') } # And the one thing the Run knows that no Check gathered: which applications were # chosen. A Check that belongs to the machine runs before theirs and cannot ask # what they found; it can know that they will be looked at. # Only here, in the Main Part: no Check of the Elevated Part asks for it. $observed['AppSelection'] = ConvertTo-AppSelectionObserved -Selection $selection -Skip $Skip -ModuleVersion $moduleVersion # And whose Session this is: the account the Run runs as against the one working # at the machine. Decided here, once, for every Check about the User. $observed['RunUser'] = ConvertTo-RunUserObserved ` -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LoggedOnUser (Get-InteractiveUser) # The case the two-Part design exists to avoid. It still happens when somebody # right-clicks "Run as administrator" or opens a window as another user, and then # every Check about the user's own profile would answer confidently about the # wrong one. Said here, once, by what the Checks were told: they say nothing of # it themselves, and this names what they therefore did not read - of the Checks # this Run performs, which it knows and they do not. $wrongUser = New-UserContextFinding -RunUser $observed['RunUser'] -Kind @( $mainChecks | Where-Object { $_ } | ForEach-Object { $checkName = "$(Get-DataProperty $_ 'Name')" $checkKind = "$(Get-DataProperty $_ 'Kind')" if (@($Skip) -notcontains $checkName -and @($Skip) -notcontains $checkKind) { $checkKind } }) if ($wrongUser) { $findings.Add($wrongUser) } $checkIndex = 0 $checkCount = @($mainChecks).Count foreach ($definition in $mainChecks) { $checkIndex++ Write-Host ("`n== {0} ==" -f $definition.Name) -ForegroundColor Cyan Write-RunProgress -Index $checkIndex -Count $checkCount -Name $definition.Name $result = Invoke-CheckDefinition -Definition $definition -ModuleVersion $moduleVersion ` -Skip $Skip -Observed $observed -Situation $situation foreach ($finding in $result.Finding) { $findings.Add($finding) Write-FindingToHost -Finding $finding } foreach ($section in $result.Section) { $sections.Add($section) } foreach ($entry in $result.Event) { $events.Add($entry) } if ($result.Kind -and $null -ne $result.Data) { $observed[$result.Kind] = $result.Data } } Complete-RunProgress # A Judge cannot know what rights its data was gathered with, so the Part says. $stamped = @($findings | Set-FindingPrivilege -Privilege $privilege) $evidence = @($sections | Set-SectionPrivilege -Privilege $privilege) # Merged after stamping, and stamped admin by its own projection: what came back # over CliXML was gathered with different rights than this Part carries. if ($elevated -and $elevated.Returned) { $stamped = @($stamped) + @(ConvertFrom-ElevatedFinding -Finding $elevated.Finding) $evidence = @($evidence) + @(ConvertFrom-ElevatedSection -Section $elevated.Section) foreach ($entry in $elevated.Event) { $events.Add($entry) } } # The Hints of this Report were worded by the Situation, so the Report states it: # in the System Check's box where that Check ran, and in a box of its own where it # was skipped or failed. $evidence = @(Add-SituationSection -Section $evidence -Situation $situation) # And before everything else in that box: who ran this, and with what rights, and # since when the User is signed in and what kind of User Profile they have, each # where the Check that reads it was performed. # Who ran it is what the Checks were told, and is not asked again: the two names # are read once in a Run, so the top row cannot say otherwise than the Findings. $evidence = @(Add-TopRow -Section $evidence -Row @( Get-RunRow -RunUser $observed['RunUser'] -Rights (Get-RunRights -Finding $stamped -Privilege $privilege) Get-UserTopRow -RunUser $observed['RunUser'] -Session $observed['Session'] -UserProfile $observed['UserProfile'])) # Once every Finding of both Parts is there: what several of them point to. $leads = @(Get-Lead -Finding $stamped -Situation $situation) $reportPath = Write-Report -Finding $stamped -Section $evidence -EventLogEntry $events ` -OutputPath $OutputPath -Provenance $resolved.Provenance ` -ComputerName $computerName -AsOf $asOf -Lead $leads Write-WorstFindingsToHost -Finding $stamped -ReportPath $reportPath if (-not $NoShow -and [Environment]::UserInteractive) { Start-Process $reportPath -ErrorAction SilentlyContinue } [pscustomobject]@{ PSTypeName = 'Gutcheck.Run' Report = $reportPath OutputPath = $OutputPath Finding = $stamped Section = $evidence Event = @($events) Provenance = $resolved.Provenance } } finally { if ($transcribing) { try { Stop-Transcript | Out-Null } catch { } } Restore-ConsoleMode -Mode $consoleMode } } |