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 Three Checks in the Elevated Part take minutes rather than seconds, and all 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 } } # Measures the profile folders of every account for as long as its Definition # lets it: see Get-UserProfileElevatedData, which caps it the same. 'UserProfileElevated' { $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $definition 'Parameters') $seconds = ConvertTo-Number (Get-Parameter $parameters 'OtherMeasureSeconds' 180) if ($null -ne $seconds -and $seconds -gt 0) { $extra += [int][math]::Min(600, $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' } # Rebuilt like the Finding itself: a Reference is a target and a title, and # whatever else arrived on one stays behind. # A Signal this module does not have is left behind with whatever refers to it: # the other side may be a newer Gutcheck, and New-Finding would refuse it. $known = @($script:SignalValues.Keys) $references = @((Get-DataCollection $incoming 'References') | ForEach-Object { if ("$(Get-DataProperty $_ 'Target')" -eq 'Section' -and "$(Get-DataProperty $_ 'Title')") { [pscustomobject]@{ Target = 'Section'; Title = "$(Get-DataProperty $_ 'Title')" } } elseif ("$(Get-DataProperty $_ 'Target')" -eq 'Signal' -and "$(Get-DataProperty $_ 'Signal')" -in $known) { [pscustomobject]@{ Target = 'Signal'; Signal = "$(Get-DataProperty $_ 'Signal')"; SameSubject = [bool](Get-DataProperty $_ 'SameSubject') } } }) $signals = @((Get-DataCollection $incoming 'Signals') | ForEach-Object { "$_" } | Where-Object { $_ -in $known }) $unobserved = @((Get-DataCollection $incoming 'Unobserved') | ForEach-Object { "$_" } | Where-Object { $_ -in $known }) $cleared = @((Get-DataCollection $incoming 'Cleared') | ForEach-Object { "$_" } | Where-Object { $_ -in $known }) New-Finding -Category $incoming.Category -Check "$($incoming.Check)" -Value $incoming.Value ` -Severity $severity -Hint "$($incoming.Hint)" -Privilege $Privilege -Reference $references ` -Signal $signals -Subject "$(Get-DataProperty $incoming 'Subject')" -Meaning "$(Get-DataProperty $incoming 'Meaning')" ` -Unobserved $unobserved -Cleared $cleared | Set-CheckOrigin -CheckName "$(Get-DataProperty $incoming 'CheckName')" -App "$(Get-DataProperty $incoming 'App')" } } 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 { $_ })) { $placement = 'Evidence' if ("$(Get-DataProperty $incoming 'Placement')" -eq 'Top') { $placement = 'Top' } # The help is text like the title. Absent on a Section from a module that had none # yet, and then the Section has no "?" - which is what it had on the other side. New-Section -Title "$($incoming.Title)" -Row $incoming.Row -Text "$($incoming.Text)" -Privilege $Privilege -Placement $placement ` -Help "$(Get-DataProperty $incoming 'Help')" | Set-CheckOrigin -CheckName "$(Get-DataProperty $incoming 'CheckName')" -App "$(Get-DataProperty $incoming 'App')" ` -Kind "$(Get-DataProperty $incoming 'Kind')" } } function ConvertFrom-ElevatedData { <# .SYNOPSIS Reads what the Elevated Part's Gatherers gathered, keyed by Kind. Pure. .DESCRIPTION Always a table, and an empty one whenever there is nothing to believe: an Elevated Part from a module older than this returns no gathered data at all, and whatever arrives has been through CliXML from a process that may be another version of Gutcheck. Anything that is not a table keyed by name is read as no data rather than as an error, because the Findings that came with it are still good. Only the keying is re-established here. What sits under a Kind's name is that Gatherer's data as CliXML left it - a list arrives as an ArrayList, an absent property stays absent - and it is for a Gatherer or Judge to read, with Get-DataProperty and Get-DataCollection, as it reads everything else. It is data and is only ever read: nothing in it selects what a Run performs (ADR-0001). #> [CmdletBinding()] [OutputType([hashtable])] param([AllowNull()]$Data) $gathered = @{} if ($Data -is [System.Collections.IDictionary]) { foreach ($key in @($Data.Keys)) { $kind = "$key" # Absent and null read the same to a Gatherer that asks ContainsKey. if ($kind.Trim() -and $null -ne $Data[$key]) { $gathered[$kind] = $Data[$key] } } } $gathered } 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', # The Elevated Part started and broke off. Approving the prompt again will not help. [switch]$BrokeOff, # Where the folder of the hand-over is, where it could not be deleted. [AllowNull()][AllowEmptyString()][string]$TransferLeft ) $names = @(@($Skipped) | ForEach-Object { Get-DataProperty $_ 'Name' } | Where-Object { $_ }) $hint = '' if ($names.Count) { $text = if ($BrokeOff) { Get-Text 'Hint.Elevation.NotCheckedSendLog' } else { Get-Text 'Hint.Elevation.NotCheckedRerunAllow' } $hint = $text -f (($names | Sort-Object) -join ', ') } # What the Checks that did not run would have looked for. $unobserved = @(Get-SignalOfKind -Kind @(@($Skipped) | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" })) # A folder left behind holds what was read with admin rights, and is said whatever # else happened. $meaning = '' if ($TransferLeft) { $Status = (Get-Text 'Value.Elevation.TransferLeft') -f $Status, $TransferLeft $meaning = Get-Text 'Meaning.Elevation.TransferLeft' $hint = (@($hint, (Get-Text 'Hint.Elevation.TransferLeft')) | Where-Object { $_ }) -join ' | ' if ($Severity -in 'OK', 'INFO') { $Severity = 'WARN' } } New-Finding -Category Gutcheck -Check (Get-Text 'Check.Elevation.AdminPart') -Severity $Severity -Value $Status -Hint $hint ` -Meaning $meaning -Unobserved $unobserved } function Get-ElevatedOutcome { <# .SYNOPSIS What became of the Elevated Part, from what it left behind. Pure. .DESCRIPTION Four endings, and a Technician does something different about each: results came back; the Elevated Part started and broke off, and says why; nothing came back before the deadline; nothing came back at all. The second is kept apart from the last on purpose. "Refused or failed" sends a Technician to repeat the Run and approve the prompt, which does not help when the prompt was approved and the Elevated Part will break off the same way again. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Result, [bool]$TimedOut, [int]$TimeoutMinutes ) $failure = Get-DataProperty $Result 'Failure' $brokeOff = $null -ne $Result -and [bool]$failure $status = if ($brokeOff) { (Get-Text 'Value.Elevation.BrokeOff') -f $Result.Identity, $failure } elseif ($null -ne $Result) { (Get-Text 'Value.Elevation.RanElevatedAs') -f $Result.Identity } # Distinguished from a refusal on purpose: one is a Technician's decision and the # other is a machine that needs looking at. elseif ($TimedOut) { (Get-Text 'Value.Elevation.TimedOut') -f $TimeoutMinutes } else { Get-Text 'Value.Elevation.NoResults' } [pscustomobject]@{ Status = $status Returned = $null -ne $Result -and -not $brokeOff BrokeOff = $brokeOff } } function New-ElevatedTransferFolder { <# .SYNOPSIS Makes the folder the Elevated Part hands its readings over in, open to the account of the Run, to administrators and to Windows, and to nobody else. .DESCRIPTION Under ProgramData a new folder takes over what every User may do there: read. And a file the Elevated Part writes belongs to the administrators, and gave the account that started the Run no more than reading - so the Run could not always delete what it had been handed, and what was read of every User stayed there for every User to read. So the folder takes nothing over, and gives the account of the Run every right to whatever is written into it. The accounts are named by their SIDs: their names are in the language of Windows. Setting the rights may fail on a drive that has none (FAT). The folder is there all the same, and the Run goes on as it used to. #> [CmdletBinding()] [OutputType([void])] param([Parameter(Mandatory)][string]$Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null try { $inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $accounts = @( New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18' New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544' [Security.Principal.WindowsIdentity]::GetCurrent().User ) foreach ($account in $accounts) { $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( $account, 'FullControl', $inherit, 'None', 'Allow'))) } Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop } catch { } } function Remove-ElevatedTransferFolder { <# .SYNOPSIS Deletes that folder with what is in it, and says whether it is gone. .DESCRIPTION Tried more than once: the Elevated Part has only just written its files and may not have ended, and a virus scanner reads a new file before anybody else may delete it. A folder that is still there after that is said, by the caller, and not passed over: it holds what was read with admin rights. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string]$Path, [int]$Attempts = 6, [int]$WaitMilliseconds = 500 ) for ($attempt = 1; $attempt -le [math]::Max(1, $Attempts); $attempt++) { if (-not (Test-Path -LiteralPath $Path)) { return $true } try { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop } catch { } if (-not (Test-Path -LiteralPath $Path)) { return $true } if ($attempt -lt $Attempts) { Start-Sleep -Milliseconds $WaitMilliseconds } } $false } function Get-ElevatedWindowStyle { <# .SYNOPSIS How the window of the Elevated Part is opened: hidden, unless asked for. Pure. .DESCRIPTION A second window that opens, scrolls and closes by itself is one more thing on a User's screen. What it would show is in console-admin.log either way, and the window of the Run says which Check the Elevated Part is at. The question of Windows whether to grant admin rights is Windows' own window and is always shown. #> [CmdletBinding()] [OutputType([string])] param([switch]$Show) if ($Show) { return 'Normal' } 'Hidden' } function ConvertTo-ElevatedProgress { <# .SYNOPSIS Which Check the Elevated Part is at, as the one line it leaves for the Main Part. Pure. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][int]$Index, [Parameter(Mandatory)][int]$Count, [AllowNull()][AllowEmptyString()][string]$Name) '{0}|{1}|{2}' -f $Index, $Count, ("$Name" -replace '[\r\n]+', ' ') } function ConvertFrom-ElevatedProgress { <# .SYNOPSIS That line read back; nothing for what is no such line - the file is read while the other process may be writing it. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $line = @("$Text" -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -Last 1) if (-not $line.Count -or $line[0] -notmatch '^\s*(\d+)\|(\d+)\|(.*)$') { return } [pscustomobject]@{ Index = [int]$Matches[1]; Count = [int]$Matches[2]; Name = $Matches[3].Trim() } } function Get-ElevatedProgressText { <# .SYNOPSIS What the window of the Run says while it waits for the Elevated Part. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Progress, [int]$Seconds) if ($null -eq $Progress) { return (Get-Text 'Console.Progress.Elevated') -f $Seconds } (Get-Text 'Console.Progress.ElevatedCheck') -f $Progress.Index, $Progress.Count, $Progress.Name, $Seconds } function Get-ElevatedProgressStep { <# .SYNOPSIS How far the bar stands while the Run waits for the Elevated Part: the Checks it has finished, of those it has. Pure. .DESCRIPTION Not the time waited against the time it may take at most: that is half an hour and more, and a Part that takes three minutes left the bar at a tenth. Before the Elevated Part has said where it is - the question of Windows is still open, or PowerShell is starting - the bar is empty, and the text beside it counts the seconds. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Progress) $count = 0 $index = 0 if ($null -ne $Progress) { $count = [int]$Progress.Count; $index = [int]$Progress.Index } if ($count -le 0) { return [pscustomobject]@{ Step = 0; Of = 1 } } [pscustomobject]@{ Step = [math]::Max(0, [math]::Min($index - 1, $count)); Of = $count } } 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, # Handed over with the Check Definitions: the Elevated Part determines none. [AllowNull()]$Situation, # Show the window of the Elevated Part. Without this it runs hidden. [switch]$ShowWindow ) $transfer = Join-Path $env:ProgramData ('Gutcheck\{0}' -f [guid]::NewGuid()) $result = $null $status = '' $returned = $false $brokeOff = $false $left = $null try { New-ElevatedTransferFolder -Path $transfer # 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 ConvertTo-SituationDocument -Situation $Situation | Set-Content -Path (Join-Path $transfer 'situation.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) ) # Where the Checks are a card, only the first of these is said, as what the row of # the Elevated Part says while the UAC prompt is up: a line would stay under the # rows for the rest of the Run. if (-not $script:ConsolePart) { Write-ConsoleHeading (Get-Text 'Console.Elevation.Heading') -Color Cyan Write-ConsoleLine (Get-Text 'Console.Elevation.Explain') -Color Gray if (-not $ShowWindow) { Write-ConsoleLine (Get-Text 'Console.Elevation.Hidden') -Color Gray } } else { Write-ConsoleNote (Get-Text 'Console.Elevation.Heading') } $process = Start-Process -FilePath $executable -ArgumentList ($arguments -join ' ') ` -Verb RunAs -PassThru -WindowStyle (Get-ElevatedWindowStyle -Show:$ShowWindow) -ErrorAction Stop $timeout = $script:ElevationBaseTimeoutSeconds + $ExtraTimeoutSeconds $resultFile = Join-Path $transfer 'admin.clixml' $deadline = (Get-Date).AddSeconds($timeout) Write-ConsoleNote (Get-Text 'Console.Elevation.Waiting' ([int]($timeout / 60))) -Color Gray $waitStarted = Get-Date while ((Get-Date) -lt $deadline -and -not (Test-Path $resultFile)) { $elapsed = ((Get-Date) - $waitStarted).TotalSeconds # Which Check it is at, as it left it. Read while it may be writing: what # cannot be read this time is read the next. $at = $null try { $at = ConvertFrom-ElevatedProgress -Text ([IO.File]::ReadAllText((Join-Path $transfer 'progress.txt'))) } catch { } $bar = Get-ElevatedProgressStep -Progress $at Write-CheckProgress -Status (Get-ElevatedProgressText -Progress $at -Seconds ([int]$elapsed)) -Step $bar.Step -Of $bar.Of $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 } $outcome = Get-ElevatedOutcome -Result $result -TimedOut ((Get-Date) -ge $deadline) ` -TimeoutMinutes ([int]($timeout / 60)) $status = $outcome.Status $returned = $outcome.Returned $brokeOff = $outcome.BrokeOff # 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 { if (-not (Remove-ElevatedTransferFolder -Path $transfer)) { $left = $transfer } } [pscustomobject]@{ PSTypeName = 'Gutcheck.ElevatedResult' Status = $status # Where the folder of the hand-over is, where it could not be deleted. TransferLeft = $left Returned = $returned BrokeOff = $brokeOff Identity = $result.Identity Finding = @($result.Finding) Section = @($result.Section) Event = @($result.Event) # What the Elevated Part's Gatherers gathered, keyed by Kind. Empty when nothing # came back, and when what came back is from a module that sends none. Data = ConvertFrom-ElevatedData -Data (Get-DataProperty $result 'Data') } } 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, with the data they were judged from, so that a Judge in the Main Part can weigh what only this Part could read against what only the Main Part could. 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 = @() ) # Said here and not by the launcher, which runs outside the module and has no words. Write-Host ((Get-Text 'Console.Elevation.Running') -f [Security.Principal.WindowsIdentity]::GetCurrent().Name) -ForegroundColor Cyan $document = Read-CheckDefinitionDocument -Path (Join-Path $TransferPath 'checks.json') # The Main Part's, and not one of this Part's own. A Main Part older than this module # hands none over, and every fact is then "not known". $situationText = '' $situationFile = Join-Path $TransferPath 'situation.json' if (Test-Path -LiteralPath $situationFile) { $situationText = Get-Content -LiteralPath $situationFile -Raw -ErrorAction SilentlyContinue } $situation = ConvertFrom-SituationDocument -Text $situationText $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 = @{} # The names of accounts are asked of Windows once in this Part: see Private/Account.ps1. Clear-AccountNameCache $checkIndex = 0 $checkCount = @($document.Check).Count foreach ($definition in $document.Check) { $checkIndex++ Write-Host ("`n== {0} ==" -f $definition.Name) -ForegroundColor Cyan # For the window of the Run, which is the only one a Technician sees. A line that # cannot be written costs the Run nothing. try { [IO.File]::WriteAllText((Join-Path $TransferPath 'progress.txt'), (ConvertTo-ElevatedProgress -Index $checkIndex -Count $checkCount -Name "$($definition.Name)")) } catch { } $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 } } [pscustomobject]@{ PSTypeName = 'Gutcheck.ElevatedRun' Finding = @($findings | Set-FindingPrivilege -Privilege $Privilege) Section = @($sections | Set-SectionPrivilege -Privilege $Privilege) Event = @($events) # What each Gatherer gathered, keyed by Kind, so that a Check in the Main Part can # ask for it through -Observed. A Kind performed more than once in this Part is # here as its last Check left it, which is what the Checks after it were handed. Data = $observed } } 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. Windows tells the owner of another account's process to an administrator only. A window opened as another user without admin rights is told nothing, and that is the one case the warning about the wrong account is left with since Gutcheck asks for admin rights itself. So where the desktop's owner is not told, who Windows has down as signed in to the Session is read instead (Get-SessionSignedInUser), which every account may read. #> [CmdletBinding()] [OutputType([string])] param() $sessionId = $null $owner = $null 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 ($explorer) { $asked = Invoke-CimMethod -InputObject $explorer -MethodName GetOwner -ErrorAction Stop if ($asked.User) { $owner = '{0}\{1}' -f $asked.Domain, $asked.User } } } catch { } $signedIn = $null if (-not $owner -and $null -ne $sessionId) { $signedIn = Get-SessionSignedInUser -SessionId $sessionId } Select-InteractiveUser -DesktopOwner $owner -SignedIn $signedIn } function Get-SessionSignedInUser { <# .SYNOPSIS Who Windows has down as signed in to a Session, as DOMAIN\name, or $null. Never throws. .DESCRIPTION From HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI\ SessionData\<session>, value LoggedOnSAMUser: where the sign-in screen of Windows keeps it. Microsoft does not document the key. Read off this machine (German Windows 11 26300, 2026-10-11): it is there for the Session of the Run, holds the account as DOMAIN\name, and every user of the PC may read it. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][int]$SessionId) try { $key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI\SessionData\{0}' -f $SessionId if (-not (Test-Path -LiteralPath $key)) { return $null } $name = "$((Get-ItemProperty -LiteralPath $key -Name 'LoggedOnSAMUser' -ErrorAction Stop).LoggedOnSAMUser)".Trim() if ($name) { return $name } $null } catch { $null } } function Select-InteractiveUser { <# .SYNOPSIS Who is signed in to the Session of the Run, of the two things that say it: the owner of the desktop where Windows told, else who it has down as signed in. Pure. .DESCRIPTION Nothing where neither is known, and nothing for a name without the domain or the machine before it: it is compared with the account of the Run, which has one, and "j.doe" against "CONTOSO\j.doe" would be a wrong account that is none. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$DesktopOwner, [AllowNull()][AllowEmptyString()][string]$SignedIn) foreach ($name in "$DesktopOwner".Trim(), "$SignedIn".Trim()) { if ($name -match '^[^\\]+\\[^\\]+$') { return $name } } $null } function Select-OutputRoot { <# .SYNOPSIS Where a Run that was given no -OutputPath puts its folder: the Desktop of whoever runs it, or the Desktop every user of the machine shares. Pure. .DESCRIPTION A Run started in a window that belongs to an administrator - "Run as administrator", or a console opened with another account - has that account's Desktop, which the person sitting at the machine never sees: the Report was written and nobody could find it. Then the shared Desktop is used, which shows on everybody's. Only then. A Run in the logged-on user's own name keeps their own Desktop, and so does one where nobody can tell who is logged on: what a Report says about a machine is not for every account on it without a reason. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyString()][string]$RunningAs, [AllowNull()][AllowEmptyString()][string]$LoggedOnUser, [AllowNull()][AllowEmptyString()][string]$OwnDesktop, [AllowNull()][AllowEmptyString()][string]$SharedDesktop ) if ($SharedDesktop -and $LoggedOnUser -and $RunningAs -and $LoggedOnUser -ne $RunningAs) { return $SharedDesktop } if ($OwnDesktop) { return $OwnDesktop } # An account without a profile has no Desktop of its own. $SharedDesktop } function Read-UserSwitch { <# .SYNOPSIS Asks whether to start again in the Session of the logged-on user, without admin rights. Answers Switch, Stay or Stop. .DESCRIPTION Starting again is what Enter takes: it is the cure, and what the Technician would otherwise have to do by hand. Escape stops, as it stops any question. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$LoggedOnUser) $answers = @( @{ Key = 'Switch'; Label = (Get-Text 'Console.Elevation.SwitchChoice.Switch') } @{ Key = 'Stay'; Label = (Get-Text 'Console.Elevation.SwitchChoice.Stay') } @{ Key = 'Stop'; Label = (Get-Text 'Console.Elevation.SwitchChoice.Stop') } ) $chosen = Read-LanternChoice ((Get-Text 'Console.Elevation.SwitchPrompt') -f $LoggedOnUser) -Item $answers ` -Label { $_.Label } -Default { $_.Key -eq 'Switch' } if ($null -eq $chosen) { return 'Stop' } $chosen.Key } function Read-WrongUserGoOn { <# .SYNOPSIS Asks whether to go on in an account that is not the logged-on user's. $true to go on. .DESCRIPTION The answer defaults to stopping: starting again the right way is the cure, and whoever means it - a Run from an admin's own Session, on purpose - says so. #> [CmdletBinding()] [OutputType([bool])] param() [bool](Read-LanternConfirm (Get-Text 'Console.Elevation.WrongUserPrompt') -Default $false) } function Confirm-UserContext { <# .SYNOPSIS Says at the start of a Run that it is about to examine the wrong user's profile, and asks whether to go on. $true to go on. .DESCRIPTION The Report says so too, but the Report comes ten minutes later, and by then the Technician has the wrong answer about every Check of the user's own profile. Here it costs a keystroke. The answer defaults to stopping: starting again the right way is the cure, and whoever means it - a Run from an admin's own session, on purpose - says so with one letter. A Run nobody is in front of is not held up by a question nobody will answer. #> [CmdletBinding()] [OutputType([bool])] param( [AllowEmptyString()][AllowNull()][string]$RunningAs, [AllowEmptyString()][AllowNull()][string]$LoggedOnUser, [bool]$Interactive, # With admin rights the Run can start itself again as the logged-on user, which # is what the Technician would have to do by hand. Without them it can only ask. [bool]$Elevated, # The Technician said to stay in this account: nothing is switched or asked. [bool]$Stay, # The Technician said not to ask for admin rights: a Run that has them keeps them. [bool]$KeepRights, # Whether a new window of this account would be without the rights. Not with UAC # off, and not for the built-in Administrator: starting again would change nothing. [bool]$CanDropRights = $true, [AllowNull()][hashtable]$BoundParameter ) if (-not $LoggedOnUser -or -not $RunningAs) { return $true } # The right account, but started "as administrator": the profile is the right one, # and what is missing are the user's network drives, which an elevated session does # not have. The cure is the same as for another account - start again in the user's # own session, without the rights, and ask for them where they are needed. $ownAccount = $LoggedOnUser -eq $RunningAs if ($ownAccount -and (-not $Elevated -or -not $Interactive -or $Stay -or $KeepRights -or -not $CanDropRights)) { return $true } Write-ConsoleLine '' if ($ownAccount) { Write-ConsoleLine (Get-Text 'Console.Elevation.ElevatedStart') -Color Yellow } else { Write-ConsoleLine ((Get-Text 'Console.Elevation.WrongUser') -f ((Get-Text 'Value.Elevation.WrongUser') -f $RunningAs, $LoggedOnUser)) -Color Yellow if ($Stay) { return $true } Write-ConsoleLine (Get-Text 'Console.Elevation.WrongUserMeans') -Color Yellow } if ($Elevated -and $Interactive) { # Asked, and not done unasked: a window that says three lines and opens another # within a second leaves a Technician wondering what has just happened. $answer = 'Switch' try { $answer = Read-UserSwitch -LoggedOnUser $LoggedOnUser } catch { return $true } if ($answer -eq 'Stop') { Write-ConsoleLine (Get-Text 'Console.Elevation.WrongUserStopped') -Color Yellow return $false } if ($answer -eq 'Stay') { Write-ConsoleLine (Get-Text 'Console.Elevation.ElevatedGoesOn') -Color Gray return $true } Write-ConsoleLine ((Get-Text 'Console.Elevation.Switching') -f $LoggedOnUser) -Color Gray $problem = '' try { if (-not $BoundParameter) { $BoundParameter = @{} } if (-not (Start-GutcheckAsUser -User $LoggedOnUser -BoundParameter $BoundParameter)) { $problem = Get-Text 'Console.Elevation.SwitchNotStarted' } } catch { $problem = $_.Exception.Message } if (-not $problem) { Write-ConsoleLine ((Get-Text 'Console.Elevation.Switched') -f $LoggedOnUser) -Color Green return $false } Write-ConsoleLine ((Get-Text 'Console.Elevation.SwitchFailed') -f $LoggedOnUser, $problem) -Color Yellow if ($ownAccount) { # Nothing more to ask: it is the right profile, and the Run is worth having as it is. Write-ConsoleLine (Get-Text 'Console.Elevation.ElevatedGoesOn') -Color Gray return $true } } # Another account, and no way to start again as the user from here. Write-ConsoleLine (Get-Text 'Console.Elevation.WrongUserHow') -Color Yellow if (-not $Interactive) { Write-ConsoleLine (Get-Text 'Console.Elevation.WrongUserGoesOn') -Color Gray return $true } $goOn = $false try { $goOn = Read-WrongUserGoOn } catch { return $true } if ($goOn) { return $true } Write-ConsoleLine (Get-Text 'Console.Elevation.WrongUserStopped') -Color Yellow $false } function Test-ElevationDroppable { <# .SYNOPSIS Whether a new window of this account would run without admin rights. Pure. .DESCRIPTION Windows gives an administrator two tokens, and a new window the lesser one - unless UAC is off (EnableLUA 0), or the account is the built-in Administrator, whose RID is 500 and who gets one token unless FilterAdministratorToken says otherwise. Where nothing is known, the usual is assumed: asking once too often costs a keystroke. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()]$EnableLua, [AllowNull()][AllowEmptyString()][string]$Sid, [AllowNull()]$FilterAdministratorToken) if ($null -ne $EnableLua -and (ConvertTo-Number $EnableLua) -eq 0) { return $false } if ("$Sid" -match '-500$' -and (ConvertTo-Number $FilterAdministratorToken) -ne 1) { return $false } $true } function Get-ElevationDroppable { <# .SYNOPSIS Reads what Test-ElevationDroppable decides from, for this account on this machine. #> [CmdletBinding()] [OutputType([bool])] param() $policy = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue $sid = '' try { $sid = "$([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)" } catch { } Test-ElevationDroppable -EnableLua (Get-DataProperty $policy 'EnableLUA') -Sid $sid ` -FilterAdministratorToken (Get-DataProperty $policy 'FilterAdministratorToken') } function Get-RunRights { <# .SYNOPSIS What became of the admin rights in this Run, in the words of the Finding that says so. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Finding, [string]$Privilege) $check = Get-Text 'Check.Elevation.AdminPart' $said = @($Finding | Where-Object { $_ -and "$($_.Check)" -eq $check } | Select-Object -First 1) if ($said.Count) { return "$($said[0].Value)" } # No Check needed them, so nobody asked and nothing says. if ($Privilege -eq 'admin') { return Get-Text 'Value.Elevation.AlreadyElevated' } Get-Text 'Run.Rights.NotNeeded' } function ConvertTo-RunUserObserved { <# .SYNOPSIS Whose Session the Run is in, as a Check about the User may know it: the account the Run runs as, the one working at the machine, and whether they differ. Pure. .DESCRIPTION The Run hands this on as $Observed['RunUser'], before the first Check. This is where "the wrong Session" is decided, once: a Check about the User's Session, User Profile or settings reads Differs and does not compare two names itself, and the warning about the wrong account goes by it too. Nobody at the machine, or it could not be told - a scheduled task, a remote session - is not a difference: there is no other Session the Run could have been meant for. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$RunningAs, [AllowNull()][AllowEmptyString()][string]$LoggedOnUser) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.RunUser' RunningAs = "$RunningAs" LoggedOnUser = "$LoggedOnUser" Differs = [bool]($LoggedOnUser -and $RunningAs -and $LoggedOnUser -ne $RunningAs) } } function Get-RunRow { <# .SYNOPSIS Who ran the Run and with what rights, as rows of the box at the top of the Report: Group, Name, Value. Pure. .DESCRIPTION The Run hands over what it handed the Checks as $Observed['RunUser'], and the row says what that says: the two names and whether they differ are read once in a Run. Asked a second time, who is at the machine may be answered differently - a sign-out between the two - and the top row would contradict the Findings below it. .PARAMETER RunUser What the Checks were told: see ConvertTo-RunUserObserved. Where it is given, the two names are not read. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyString()][string]$RunningAs, [AllowNull()][AllowEmptyString()][string]$LoggedOnUser, [AllowNull()][AllowEmptyString()][string]$Rights, [AllowNull()]$RunUser ) if ($null -eq $RunUser) { $RunUser = ConvertTo-RunUserObserved -RunningAs $RunningAs -LoggedOnUser $LoggedOnUser } $RunningAs = "$(Get-DataProperty $RunUser 'RunningAs')" $LoggedOnUser = "$(Get-DataProperty $RunUser 'LoggedOnUser')" $group = Get-Text 'System.Info.Group.Run' $user = "$RunningAs" if ([bool](Get-DataProperty $RunUser 'Differs')) { $user = (Get-Text 'Run.UserOther') -f $RunningAs, $LoggedOnUser } if ($user) { [pscustomobject]@{ Group = $group; Name = (Get-Text 'Run.User'); Value = $user } } if ($Rights) { [pscustomobject]@{ Group = $group; Name = (Get-Text 'Run.Rights'); Value = "$Rights" } } } function Get-UserTopRow { <# .SYNOPSIS What the box at the top of the Report says of the User: since when they are signed in and what kind of User Profile they have, each where the Check that reads it was performed. Pure. .DESCRIPTION The rows are the Kinds' own: see Get-SessionTopRow and Get-UserProfileTopRow. Where the Run is not in the Session of the User there is one row in their place, and it says that nothing about the User was read and in whose Session the Run is not. One, however many Checks about the User there are: the box said it once for each of them, beside the row of who ran the Run and the warning below, and a thing said five times in one Report is read by nobody. None where no Check about the User was performed: then nothing of the User is missing from this Report. Whether the Run is in the Session of the User is what the Checks were told, and is not worked out here either. .PARAMETER RunUser What the Checks were told: see ConvertTo-RunUserObserved. .PARAMETER Session What the Session Check gathered, or nothing where it was not performed. .PARAMETER UserProfile What the UserProfile Check gathered, or nothing where it was not performed. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$RunUser, [AllowNull()]$Session, [AllowNull()]$UserProfile) if ([bool](Get-DataProperty $RunUser 'Differs')) { if ($null -eq $Session -and $null -eq $UserProfile) { return } return [pscustomobject]@{ Group = (Get-Text 'System.Info.Group.Run') Name = (Get-Text 'Run.TopRow.User') Value = (Get-Text 'Run.TopRow.WrongSession') -f "$(Get-DataProperty $RunUser 'LoggedOnUser')" } } Get-SessionTopRow -Data $Session Get-UserProfileTopRow -Data $UserProfile } function Add-TopRow { <# .SYNOPSIS Puts rows first in the box at the top of the Report, and makes the box where there is none. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Section, [AllowNull()][AllowEmptyCollection()]$Row) $sections = @($Section | Where-Object { $_ }) $rows = @($Row | Where-Object { $_ }) if (-not $rows.Count) { return $sections } $box = @($sections | Where-Object { "$(Get-DataProperty $_ 'Placement')" -eq 'Top' } | Select-Object -First 1) if (-not $box.Count) { return @(New-Section -Title (Get-Text 'Title.System.Information') -Placement Top -Row $rows) + $sections } foreach ($section in $sections) { if ([object]::ReferenceEquals($section, $box[0])) { $section.Row = @($rows) + @((Get-DataCollection $section 'Row')) } $section } } function New-UserSwitchCommand { <# .SYNOPSIS What the logged-on user's PowerShell is told to do: load Gutcheck and run it with what this Run was started with. Pure. .DESCRIPTION The module this Run was loaded from may be one the user cannot read - installed in the admin's own profile. So the command tries that, then a Gutcheck of the user's own, and installs one for the user where there is none. Not marked as updated: the user's own module may be older than the admin's, and the new Run is to find that out for itself. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$ManifestPath, [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$BoundParameter) $keep = @{} foreach ($name in $BoundParameter.Keys) { if ($name -notin 'NoUserSwitch', 'Switched', 'PassThru') { $keep[$name] = $BoundParameter[$name] } } # -Switched is what stops the new Run from switching again: on a machine where every # process has admin rights - UAC off, the built-in Administrator - it would have them # too, and start a third. $arguments = (((ConvertTo-RelaunchArgument -BoundParameter $keep) -replace '\s*-Updated$', '') + ' -Switched').Trim() $load = @( ('$m = ''{0}'';' -f ($ManifestPath -replace "'", "''")) 'if (Test-Path -LiteralPath $m) { Import-Module $m -Force }' 'elseif (Get-Module -ListAvailable -Name Gutcheck) { Import-Module Gutcheck -Force }' 'else { Install-Module Gutcheck -Scope CurrentUser -Force; Import-Module Gutcheck -Force }' ) -join ' ' ('{0}; Invoke-Gutcheck {1}' -f $load, $arguments).Trim() } function Start-GutcheckAsUser { <# .SYNOPSIS Starts Gutcheck in a window of its own in the logged-on user's session, as that user and without admin rights. $true when Windows started it. Needs admin rights. .DESCRIPTION A process cannot become another user without that user's password. The task scheduler can start one as whoever is logged on: a task that runs "only when the user is logged on" needs no password. The task exists for the seconds it takes to start and is removed again; what it started keeps running. #> [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][string]$User, [Parameter(Mandatory)][AllowEmptyCollection()][hashtable]$BoundParameter) $manifest = Join-Path (Split-Path $PSScriptRoot -Parent) 'Gutcheck.psd1' $command = New-UserSwitchCommand -ManifestPath $manifest -BoundParameter $BoundParameter $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) $executable = Join-Path $PSHOME $(if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh.exe' } else { 'powershell.exe' }) $name = 'Gutcheck-{0}' -f ([guid]::NewGuid().ToString('N').Substring(0, 8)) $action = New-ScheduledTaskAction -Execute $executable -Argument ('-NoExit -ExecutionPolicy Bypass -EncodedCommand {0}' -f $encoded) $principal = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited # A task does not start on battery unless it is told it may: a notebook in a meeting. $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) $null = Register-ScheduledTask -TaskName $name -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop try { Start-ScheduledTask -TaskName $name -ErrorAction Stop $started = $false foreach ($attempt in 1..20) { Start-Sleep -Milliseconds 250 $info = Get-ScheduledTaskInfo -TaskName $name -ErrorAction SilentlyContinue # 0x41301: running. A last run in this century: it has been started. if ($info -and ($info.LastTaskResult -eq 0x41301 -or ($info.LastRunTime -and $info.LastRunTime.Year -gt 2000))) { $started = $true; break } } $started } finally { Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue } } 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. This is the one place a Report says it. The Checks about the User - Session, User Profile, settings - are told the same thing and say nothing of it themselves: they leave out what would be the User's and report what is true of the machine. So what they left out is named here, for the Checks of this Run that left something out: a Technician reads in one sentence what the Report does not hold, and is not told the same by every Check in turn. Made for every Run whose account is not the one at the machine, with admin rights or without - a window opened as another user has none. It was made only for a Run started with admin rights, while the Checks about the User said it for the other case; now that they say nothing, it would go unsaid there. .PARAMETER RunUser What the Checks were told: see ConvertTo-RunUserObserved. Where it is given, the two names are not compared again: whether they differ is decided once in a Run. .PARAMETER Kind The Kinds of the Checks this Run performs. Of what a wrong Session leaves unread, only what a Check of this Run would have read is named. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowEmptyString()][AllowNull()][string]$RunningAs, [AllowEmptyString()][AllowNull()][string]$LoggedOnUser, [AllowNull()]$RunUser, [AllowNull()][AllowEmptyCollection()][string[]]$Kind ) # 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. # Decided where the Checks about the User are told it. if ($null -eq $RunUser) { $RunUser = ConvertTo-RunUserObserved -RunningAs $RunningAs -LoggedOnUser $LoggedOnUser } if (-not [bool](Get-DataProperty $RunUser 'Differs')) { return } $RunningAs = "$(Get-DataProperty $RunUser 'RunningAs')" $LoggedOnUser = "$(Get-DataProperty $RunUser 'LoggedOnUser')" # What the Checks about the User did not read, each named where its Check is in # this Run: see ConvertTo-SessionFinding, ConvertTo-UserProfileFinding and # ConvertTo-UserSettingFinding, which say nothing of it. $unread = @( if ($Kind -contains 'Session') { Get-Text 'Value.Elevation.NotRead.Session' } if ($Kind -contains 'UserProfile') { Get-Text 'Value.Elevation.NotRead.UserProfile'; Get-Text 'Value.Elevation.NotRead.Location' } if ($Kind -contains 'UserSetting') { Get-Text 'Value.Elevation.NotRead.UserSetting' } if ($Kind -contains 'OutlookAuth') { Get-Text 'Value.Elevation.NotRead.OutlookAuth' } ) $value = @((Get-Text 'Value.Elevation.WrongUser') -f $RunningAs, $LoggedOnUser) if ($unread.Count) { $value += (Get-Text 'Value.Elevation.WrongUserNotRead') -f $LoggedOnUser, ($unread -join ', ') } New-Finding -Category Gutcheck -Check (Get-Text 'Check.Elevation.WrongUserContext') -Severity WARN ` -Value ($value -join ' | ') ` -Hint (Get-Text 'Hint.Elevation.AppDataMappedDrivesShortcuts') } |