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> .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 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, [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') { 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) { return } if (-not $OutputPath) { $OutputPath = Join-Path ([Environment]::GetFolderPath('Desktop')) ` ('Gutcheck_{0}_{1:yyyyMMdd_HHmm}' -f $computerName, $asOf) } New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null # 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 } $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'))) # The case the two-Part design exists to avoid. It still happens when somebody # right-clicks "Run as administrator", and then every Check about the user's # own profile answers confidently about the wrong one. $wrongUser = New-UserContextFinding ` -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) ` -LoggedOnUser (Get-InteractiveUser) if ($wrongUser) { $findings.Add($wrongUser) } } 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) 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)) } } # 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. $observed = @{} foreach ($definition in $mainChecks) { Write-Host ("`n== {0} ==" -f $definition.Name) -ForegroundColor Cyan $result = Invoke-CheckDefinition -Definition $definition -ModuleVersion $moduleVersion ` -Skip $Skip -Observed $observed 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 } } # 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) } } $reportPath = Write-Report -Finding $stamped -Section $evidence -EventLogEntry $events ` -OutputPath $OutputPath -Provenance $resolved.Provenance ` -ComputerName $computerName -AsOf $asOf 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 { } } } } |