Invoke-GutcheckElevated.ps1

<#
.SYNOPSIS
    The launcher the Elevated Part of a Run is started against. Not for a Technician to run.
.DESCRIPTION
    Started by absolute path by the Main Part, behind a UAC prompt, and possibly as a
    different user than the one who began the Run.
 
    It imports the module by path rather than by name. That is the entire reason this file
    exists: an admin colleague entering their own credentials gets their own logon, and a
    module installed for the Technician's account is not on that logon's PSModulePath. A
    launcher that did Import-Module Gutcheck would work on the developer's machine and fail
    at every Customer Site where the Technician is not a local admin - which is all of them,
    and the reason the two-Part design exists at all.
 
    Check Definitions arrive as JSON in the transfer directory. The elevated process is
    handed data and never a command, which is ADR-0001 holding at the one boundary where
    breaking it would mean a Run elevating something it was told to elevate.
.PARAMETER ManifestPath
    Absolute path to Gutcheck.psd1, so the module is imported by path.
.PARAMETER TransferPath
    Directory holding checks.json, and where admin.clixml and the console log are written.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$ManifestPath,
    [Parameter(Mandatory)][string]$TransferPath
)

$ErrorActionPreference = 'Stop'
$ProgressPreference    = 'SilentlyContinue'

# Nothing here may call into the module except Invoke-Gutcheck, the one command it exports.
# This file runs outside the module, so a private function is simply not there - and the
# place that would report the mistake is this same file. That is how the Elevated Part once
# ended within a second on every machine, saying nothing: the first line after the import
# called a private function, and so did the catch block that would have said so.

$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
$result   = $null
$failure  = $null

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

    # Started before the first Check and stopped however this ends: the Main Part collects
    # it, and it is what a Technician hands to second level when the admin half misbehaved.
    try { Start-Transcript -Path (Join-Path $TransferPath 'console-admin.log') -Force | Out-Null } catch { }

    Import-Module $ManifestPath -Force

    $result = Invoke-Gutcheck -Part Elevated -TransferPath $TransferPath -NoShow
}
catch {
    # In English and in full, because the module that holds the German may be the thing
    # that failed, and because whoever reads this line is second level.
    $failure = $_.Exception.Message
    Write-Host ('Gutcheck Elevated Part failed as {0}: {1}' -f $identity, $failure) -ForegroundColor Red
    Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
}

try { Stop-Transcript | Out-Null } catch { }

# Written however this ended. An absent result file means the prompt was refused or the
# process died; a Run that broke off for a reason says the reason, so the Report can.
#
# Data is what the Gatherers gathered, keyed by Kind, for the Checks of the Main Part that
# asked for it. A table whatever happened: empty when this broke off, and when the module
# that was imported is one that returns none.
$data = @{}
if ($result -and $result.Data -is [hashtable]) { $data = $result.Data }

$payload = [pscustomobject]@{
    Identity = $identity
    Failure  = $failure
    Finding  = @($result.Finding)
    Section  = @($result.Section)
    Event    = @($result.Event)
    Data     = $data
    Finished = Get-Date
}

try {
    # Written aside and moved into place, so the Main Part never reads a half-written file:
    # it is polling for this name and a partial read would lose the whole elevated half.
    $temporary = Join-Path $TransferPath 'admin.tmp'

    # The depth is not what lets nested gathered data through. Tables, lists and the plain
    # objects a Gatherer builds cost no depth at all, on either edition: rows holding lists
    # of rows arrive whole at any setting. Depth only limits how far a live .NET object is
    # followed, and there every level multiplies the file - a single process object is
    # 15 KB at 1 and 660 KB at 3. So it stays where it was, and a Gatherer returns plain
    # data, which is what makes it a Gatherer.
    $payload | Export-Clixml -Path $temporary -Depth 6
    Move-Item $temporary (Join-Path $TransferPath 'admin.clixml') -Force
}
catch { exit 1 }

if ($failure) { exit 1 }
exit 0