Private/Elevation.ps1

# The Elevated Part: the half of a Run that needs admin rights.
#
# A Technician starts Gutcheck as themselves, without admin rights, and an admin colleague
# enters their own credentials at the UAC prompt. Everything the Technician's own session
# knows - their profile, their mapped drives, their processes, their OST - stays in the
# Main Part, because an elevated session running as somebody else can see none of it.
#
# Three things make this harder than launching a second process.
#
# The elevated process may run as a different user, so the module cannot be imported by
# name: it is installed for the Technician's account and is invisible to the admin's. The
# module therefore ships a launcher which is started by absolute path and which imports the
# module by path.
#
# The module may live on a network location, which an elevated session cannot reach because
# drive mappings are per-logon. It is copied to local disk first when that is the case.
#
# And the elevated process may never return - a refused prompt, a crash, a machine that
# sleeps. A Run that hangs is worse than a Run that reports a gap, so it is waited for with
# a deadline and the gap is a Finding.

# How long to wait before giving up, before the long Checks add their own time. Ten
# minutes covers every Check in the Elevated Part with room to spare on a slow machine.
$script:ElevationBaseTimeoutSeconds = 600

# DriveType 3 is a local fixed disk, as Win32_LogicalDisk numbers them.
$script:ElevationLocalDriveType = 3

# What the component store scan can take on a machine that needs one. The script allowed
# the same half hour, and a scan cut off at ten minutes answers nothing.
$script:ElevationIntegrityTimeoutSeconds = 1800

function Get-ElevatedLauncherPath {
    <#
    .SYNOPSIS
        The launcher the elevated process is started against, by absolute path.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    Join-Path (Split-Path $PSScriptRoot -Parent) 'Invoke-GutcheckElevated.ps1'
}

function Test-LocalPath {
    <#
    .SYNOPSIS
        Whether a path is on a local fixed disk, and so visible to any logon on this machine.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)

    if (-not $Path) { return $false }
    if ($Path -match '^\\\\') { return $false }
    if ($Path -notmatch '^([A-Za-z]):') { return $false }

    $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$($Matches[1]):'" -ErrorAction SilentlyContinue
    $disk.DriveType -eq $script:ElevationLocalDriveType
}

function Split-AdminCheck {
    <#
    .SYNOPSIS
        Splits Check Definitions into the ones needing admin rights and the rest. Pure.
    .DESCRIPTION
        A Definition naming a Kind this module does not implement goes with the Main Part,
        where the version-skew Finding that explains it is already produced. Sending it to
        the Elevated Part would cost a UAC prompt to say the same thing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Definition)

    $admin = New-Object System.Collections.Generic.List[psobject]
    $main  = New-Object System.Collections.Generic.List[psobject]

    foreach ($definition in @($Definition)) {
        $implementation = Get-KindImplementation -Kind (Get-DataProperty $definition 'Kind')
        if ($implementation -and $implementation.NeedsAdmin) { $admin.Add($definition) }
        else { $main.Add($definition) }
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.CheckPartition'
        Admin      = @($admin)
        Main       = @($main)
    }
}

function Get-ElevatedExtraTimeout {
    <#
    .SYNOPSIS
        How much longer than the base deadline these Checks need. Pure.
    .DESCRIPTION
        Two Checks in the Elevated Part take minutes rather than seconds, and both are
        asked for deliberately. A deadline that ignored them would report a timeout while
        the scan the Technician waited for was still running - which is the worst of both:
        no answer, and the Run says the machine misbehaved rather than that Gutcheck gave
        up too early.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param([Parameter(Mandatory)][AllowEmptyCollection()]$Definition)

    $extra = 0
    foreach ($definition in @($Definition)) {
        switch (Get-DataProperty $definition 'Kind') {
            'ImageIntegrity' { $extra += $script:ElevationIntegrityTimeoutSeconds }
            'Stress' {
                $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $definition 'Parameters')
                $seconds = ConvertTo-Number (Get-Parameter $parameters 'DurationSeconds' 60)
                if ($null -ne $seconds -and $seconds -gt 0) { $extra += [int]$seconds }
            }
        }
    }
    $extra
}

function ConvertFrom-ElevatedFinding {
    <#
    .SYNOPSIS
        Projects Findings that came back over CliXML into Findings this Run trusts.
    .DESCRIPTION
        The single place Privilege is assigned to what the Elevated Part produced, and the
        single place its invariants are re-established. Nothing arriving here has passed
        through New-Finding in this process: CliXML carries whatever shape the other side
        had, which may be an older or newer Gutcheck, and a property list rather than a
        contract. Rebuilding each Finding rather than stamping it means a field that was
        added on one side cannot travel into a Report the other side renders, an OK Finding
        cannot arrive carrying a Hint, and a Category this module does not have fails
        loudly here rather than quietly adding a group to the Report.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [ValidateSet('user', 'admin')][string]$Privilege = 'admin'
    )

    foreach ($incoming in @($Finding | Where-Object { $_ })) {
        $severity = "$($incoming.Severity)".ToUpperInvariant()
        if ($severity -notin $script:SeverityValues) { $severity = 'INFO' }

        New-Finding -Category $incoming.Category -Check "$($incoming.Check)" -Value $incoming.Value `
            -Severity $severity -Hint "$($incoming.Hint)" -Privilege $Privilege
    }
}

function ConvertFrom-ElevatedSection {
    <#
    .SYNOPSIS
        Projects Sections that came back over CliXML, for the same reasons.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Section,
        [ValidateSet('user', 'admin')][string]$Privilege = 'admin'
    )

    foreach ($incoming in @($Section | Where-Object { $_ })) {
        New-Section -Title "$($incoming.Title)" -Row $incoming.Row -Text "$($incoming.Text)" -Privilege $Privilege
    }
}

function New-ElevationFinding {
    <#
    .SYNOPSIS
        What happened to the privileged half of this Run, as a Finding.
    .DESCRIPTION
        Always present, whatever happened, because "no admin Findings in the Report" and
        "the machine has no disk problems" must never look the same.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Status,
        [AllowNull()][AllowEmptyCollection()]$Skipped = @(),
        [ValidateSet('OK', 'INFO', 'WARN', 'FAIL')][string]$Severity = 'INFO'
    )

    $names = @(@($Skipped) | ForEach-Object { Get-DataProperty $_ 'Name' } | Where-Object { $_ })

    $hint = ''
    if ($names.Count) {
        $hint = (Get-Text 'Hint.Elevation.NotCheckedRerunAllow') -f (($names | Sort-Object) -join ', ')
    }

    New-Finding -Category Gutcheck -Check (Get-Text 'Check.Elevation.AdminPart') -Severity $Severity -Value $Status -Hint $hint
}

function Invoke-ElevatedPart {
    <#
    .SYNOPSIS
        Performs the admin Checks in a second, elevated process and returns what came back.
    .DESCRIPTION
        Untested, like every other thing that needs a real UAC prompt and a second user.
        What is tested is the decision about which Checks belong here, and the projection
        of what comes back - the two places this can go wrong quietly.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()]$Definition,
        [Parameter(Mandatory)][string]$OutputPath,
        [int]$ExtraTimeoutSeconds = 0
    )

    $transfer = Join-Path $env:ProgramData ('Gutcheck\{0}' -f [guid]::NewGuid())
    $result   = $null
    $status   = ''

    try {
        New-Item -ItemType Directory -Path $transfer -Force | Out-Null

        # The Definitions the elevated process performs travel as data, like every other
        # Definition. It is handed a document, not a command.
        $document = [ordered]@{
            Version   = 'transfer'
            Generated = (Get-Date).ToString('yyyy-MM-dd')
            Checks    = @(@($Definition) | ForEach-Object {
                [ordered]@{
                    Name                 = Get-DataProperty $_ 'Name'
                    Kind                 = Get-DataProperty $_ 'Kind'
                    Parameters           = Get-DataProperty $_ 'Parameters'
                    MinimumModuleVersion = Get-DataProperty $_ 'MinimumModuleVersion'
                }
            })
        }
        $document | ConvertTo-Json -Depth 8 |
            Set-Content -Path (Join-Path $transfer 'checks.json') -Encoding UTF8

        $launcher = Get-ElevatedLauncherPath
        $manifest = Join-Path (Split-Path $PSScriptRoot -Parent) 'Gutcheck.psd1'

        # An elevated session gets its own logon and so its own drive mappings, which means
        # none of the Technician's. A module on a share is invisible to it.
        if (-not (Test-LocalPath -Path $launcher)) {
            $copy = Join-Path $transfer 'Gutcheck'
            Copy-Item (Split-Path $launcher -Parent) $copy -Recurse -Force
            $launcher = Join-Path $copy (Split-Path $launcher -Leaf)
            $manifest = Join-Path $copy 'Gutcheck.psd1'
        }

        $executable = Join-Path $PSHOME $(if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' })
        $arguments  = @(
            '-NoProfile', '-ExecutionPolicy', 'Bypass',
            '-File', ('"{0}"' -f $launcher),
            '-ManifestPath', ('"{0}"' -f $manifest),
            '-TransferPath', ('"{0}"' -f $transfer)
        )

        Write-Host (Get-Text 'Console.Elevation.Heading') -ForegroundColor Cyan
        Write-Host (Get-Text 'Console.Elevation.Explain') -ForegroundColor Gray

        $process = Start-Process -FilePath $executable -ArgumentList ($arguments -join ' ') `
            -Verb RunAs -PassThru -WindowStyle Normal -ErrorAction Stop

        $timeout    = $script:ElevationBaseTimeoutSeconds + $ExtraTimeoutSeconds
        $resultFile = Join-Path $transfer 'admin.clixml'
        $deadline   = (Get-Date).AddSeconds($timeout)
        Write-Host (Get-Text 'Console.Elevation.Waiting' ([int]($timeout / 60))) -ForegroundColor Gray

        while ((Get-Date) -lt $deadline -and -not (Test-Path $resultFile)) {
            $exited = $false
            try { $exited = $process.HasExited } catch { }
            # One more look after it exits: the file is written just before it does.
            if ($exited) { Start-Sleep -Seconds 1; break }
            Start-Sleep -Seconds 2
        }

        if (Test-Path $resultFile) {
            $result = Import-Clixml $resultFile
            $status = (Get-Text 'Value.Elevation.RanElevatedAs') -f $result.Identity
        }
        elseif ((Get-Date) -ge $deadline) {
            # Distinguished from a refusal on purpose: one is a Technician's decision and
            # the other is a machine that needs looking at.
            $status = (Get-Text 'Value.Elevation.TimedOut') -f [int]($timeout / 60)
        }
        else {
            $status = (Get-Text 'Value.Elevation.NoResults')
        }

        # Collected before the transfer directory goes, because it is the only record of
        # what the elevated half did and a Technician hands it to second level.
        $log = Join-Path $transfer 'console-admin.log'
        if (Test-Path $log) { Copy-Item $log (Join-Path $OutputPath 'console-admin.log') -Force }
    }
    catch {
        $status = (Get-Text 'Value.Elevation.NotPossible') -f $_.Exception.Message
    }
    finally {
        Remove-Item $transfer -Recurse -Force -ErrorAction SilentlyContinue
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.ElevatedResult'
        Status     = $status
        Returned   = $null -ne $result
        Identity   = $result.Identity
        Finding    = @($result.Finding)
        Section    = @($result.Section)
        Event      = @($result.Event)
    }
}

function Invoke-ElevatedCheck {
    <#
    .SYNOPSIS
        The Elevated Part's whole job: perform the Checks it was handed and return them.
    .DESCRIPTION
        No Report, no output folder, no app selection, no second elevation. A Run produces
        one Report and the Main Part assembles it; this half produces Findings and hands
        them back. Keeping it this small is what makes the two Parts' relationship legible.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$TransferPath,
        [Parameter(Mandatory)][version]$ModuleVersion,
        [Parameter(Mandatory)][ValidateSet('user', 'admin')][string]$Privilege,
        [AllowNull()][AllowEmptyCollection()][string[]]$Skip = @()
    )

    $document = Read-CheckDefinitionDocument -Path (Join-Path $TransferPath 'checks.json')

    $findings = New-Object System.Collections.Generic.List[psobject]
    $sections = New-Object System.Collections.Generic.List[psobject]
    $events   = New-Object System.Collections.Generic.List[psobject]
    $observed = @{}

    foreach ($definition in $document.Check) {
        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 }
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.ElevatedRun'
        Finding    = @($findings | Set-FindingPrivilege -Privilege $Privilege)
        Section    = @($sections | Set-SectionPrivilege -Privilege $Privilege)
        Event      = @($events)
    }
}

function New-IntegrityCheckDefinition {
    <#
    .SYNOPSIS
        The Check Definition -RunIntegrityCheck adds to a Run.
    .DESCRIPTION
        The component store scan is not in the Local Definitions, because a Definition
        that shipped and was skipped by default would need a second opt-out mechanism
        beside -Skip to explain itself. Adding the Definition when it is asked for keeps
        one rule: a Check is in a Run because a Definition put it there.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    [pscustomobject]@{
        PSTypeName           = 'Gutcheck.CheckDefinition'
        Name                 = 'Windows image integrity'
        Kind                 = 'ImageIntegrity'
        Parameters           = @{}
        MinimumModuleVersion = $null
    }
}

function Get-InteractiveUser {
    <#
    .SYNOPSIS
        Who is actually logged on at this machine, or $null when it cannot be told.
    .DESCRIPTION
        Found through the explorer process in this session, because that is the one thing
        that is always the logged-on user's own. A Gatherer: it reads and decides nothing.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    try {
        $sessionId = (Get-Process -Id $PID).SessionId
        $explorer  = Get-CimInstance Win32_Process -Filter "Name='explorer.exe'" -ErrorAction Stop |
            Where-Object { $_.SessionId -eq $sessionId } | Select-Object -First 1
        if (-not $explorer) { return $null }

        $owner = Invoke-CimMethod -InputObject $explorer -MethodName GetOwner -ErrorAction Stop
        if ($owner.User) { return '{0}\{1}' -f $owner.Domain, $owner.User }
        $null
    }
    catch { $null }
}

function New-UserContextFinding {
    <#
    .SYNOPSIS
        Warns when a Run is examining the wrong user's profile. Pure: two names in, a
        Finding or nothing out.
    .DESCRIPTION
        A Technician who right-clicked "Run as administrator" gets a Run whose session
        belongs to the administrator, not to the person complaining. Their mail file,
        their mapped drives, their shortcuts and their processes are all somebody else's,
        and every one of those Checks answers confidently about the wrong profile. This is
        the whole reason Gutcheck asks for elevation itself instead of being started with
        it, so the case it exists to avoid has to be visible when it happens anyway.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$RunningAs,
        [AllowEmptyString()][AllowNull()][string]$LoggedOnUser
    )

    # Nobody logged on, or the question could not be answered: a scheduled task or a
    # remote session, where there is no other profile to have examined by mistake.
    if (-not $LoggedOnUser -or -not $RunningAs) { return }
    if ($LoggedOnUser -eq $RunningAs) { return }

    New-Finding -Category Gutcheck -Check (Get-Text 'Check.Elevation.WrongUserContext') -Severity WARN `
        -Value ((Get-Text 'Value.Elevation.WrongUser') -f $RunningAs, $LoggedOnUser) `
        -Hint (Get-Text 'Hint.Elevation.AppDataMappedDrivesShortcuts')
}