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
    }
}