Repository navigation
fix(harness): sessions from the desktop processes' owners, matched by SID #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,45 +1,46 @@ | ||
| function Get-IslLogonSession { | ||
| <# | ||
| .SYNOPSIS | ||
| Lists the interactive logon sessions on this machine: user, session name, id and state. | ||
| Lists the logon sessions with a desktop on this machine: user, SID and session id. | ||
|
|
||
| .DESCRIPTION | ||
| Parses "query user". The agent runs user-context scripts inside the signed-in user's own | ||
| session (session 2, UserInteractive true, on the lab device: REM-PROBE-USER64), so the | ||
| harness needs to know whether the account it is asked to run as holds a session before it | ||
| chooses between an interactive scheduled task and a stored-password one. | ||
| The agent runs user-context scripts inside the signed-in user's own session (session 2, | ||
| UserInteractive true, on the lab device: REM-PROBE-USER64), so the harness needs to know | ||
| whether the account it is asked to run as holds a session before it chooses between an | ||
| interactive scheduled task and a stored-password one. | ||
|
|
||
| The columns are separated by runs of spaces; a disconnected session prints no session | ||
| name, and the current session is marked with a leading ">". | ||
| A session with a desktop runs explorer.exe, or sihost.exe while the desktop is still | ||
| starting; the owner of that process is the session's user, and its SID is what the | ||
| credential's account is matched on. Earlier versions parsed "query user", which is not on | ||
| Windows Home editions and prints localized text. Reading another account's process owner | ||
| needs an elevated session, which the credential launch needs anyway; a process whose owner | ||
| cannot be read is left out. | ||
|
|
||
| .EXAMPLE | ||
| Get-IslLogonSession | Where-Object UserName -eq 'isl-user' | ||
| Get-IslLogonSession | Where-Object Sid -eq $account.Sid | ||
|
|
||
| The session of that account, if it has one. | ||
| #> | ||
| [CmdletBinding()] | ||
| [OutputType('IntuneScriptLab.LogonSession')] | ||
| param() | ||
|
|
||
| $lines = @(Get-IslQueryUserOutput) | ||
| $sessions = foreach ($line in ($lines | Select-Object -Skip 1)) { | ||
| if ([string]::IsNullOrWhiteSpace($line)) { continue } | ||
| $parts = @($line.Trim().TrimStart('>').Trim() -split '\s{2,}') | ||
| # user, session name, id, state, idle, logon time; a disconnected session has no name | ||
| if ($parts.Count -ge 6 -and $parts[2] -match '^\d+$') { | ||
| $userName, $sessionName, $id, $state = $parts[0], $parts[1], $parts[2], $parts[3] | ||
| } | ||
| elseif ($parts.Count -ge 5 -and $parts[1] -match '^\d+$') { | ||
| $userName, $sessionName, $id, $state = $parts[0], '', $parts[1], $parts[2] | ||
| } | ||
| else { continue } | ||
| $filter = "Name='explorer.exe' OR Name='sihost.exe'" | ||
| $shells = @(Get-CimInstance -ClassName Win32_Process -Filter $filter -ErrorAction SilentlyContinue) | ||
| $seen = @{} | ||
| foreach ($shell in ($shells | Sort-Object -Property SessionId, Name)) { | ||
| $owner = Invoke-CimMethod -InputObject $shell -MethodName GetOwner -ErrorAction SilentlyContinue | ||
| $sid = (Invoke-CimMethod -InputObject $shell -MethodName GetOwnerSid -ErrorAction SilentlyContinue).Sid | ||
| if (-not $sid -or -not $owner.User) { continue } | ||
| $key = "$($shell.SessionId)|$sid" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One session can hold several of these processes: sihost.exe next to explorer.exe, and a second explorer.exe when a folder window runs as its own process. One row per session and owner is what the caller counts. |
||
| if ($seen.ContainsKey($key)) { continue } | ||
| $seen[$key] = $true | ||
| [pscustomobject]@{ | ||
| PSTypeName = 'IntuneScriptLab.LogonSession' | ||
| UserName = $userName | ||
| SessionName = $sessionName | ||
| Id = [int]$id | ||
| State = $state | ||
| PSTypeName = 'IntuneScriptLab.LogonSession' | ||
| UserName = $owner.User | ||
| Domain = $owner.Domain | ||
| Sid = $sid | ||
| Id = [int]$shell.SessionId | ||
| } | ||
| } | ||
| @($sessions) | ||
| } | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -140,14 +140,16 @@ | |
| } | ||
| else { | ||
| $userName = $Credential.UserName | ||
| # "query user" lists the name Windows gives the account, which for an Entra account is | ||
| # neither the sign-in name nor a part of it; ask Windows before taking the name apart | ||
| # The session is found by the account's SID when Windows resolves the name; an Entra | ||
| # account's Windows name is neither the sign-in name nor a part of it. A name Windows | ||
| # cannot resolve is matched as text against the session owner's name | ||
| $resolved = Resolve-IslAccount -Name $userName | ||
| $account = if ($resolved) { ($resolved.Name -split '\\')[-1] } | ||
| elseif ($userName -match '\\') { ($userName -split '\\')[-1] } | ||
| $account = if ($userName -match '\\') { ($userName -split '\\')[-1] } | ||
| elseif ($userName -match '@') { ($userName -split '@')[0] } | ||
| else { $userName } | ||
| $sessions = @(Get-IslLogonSession | Where-Object { $_.UserName -eq $account }) | ||
| $sessions = @(Get-IslLogonSession | Where-Object { | ||
| if ($resolved) { $_.Sid -eq $resolved.Sid } else { $_.UserName -eq $account } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The text match is the path for a name Windows cannot translate, where Resolve-IslAccount returns nothing. It keeps the behaviour from before #6 there, and the scheduler then reports the real error for the name itself. |
||
| }) | ||
| $logon = if ($LogonType -ne 'Auto') { $LogonType } | ||
| elseif ($sessions.Count -gt 0) { 'Interactive' } | ||
| else { 'Password' } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,42 @@ | ||
| #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.2.0' } | ||
|
|
||
| <# | ||
| The "query user" parser the credential launch relies on to tell whether the account it runs as | ||
| holds a session (the agent runs user-context scripts inside the signed-in user's session, | ||
| REM-PROBE-USER64). The tool's output is mocked; one test reads the real tool for shape. | ||
| The session list the credential launch relies on to tell whether the account it runs as holds a | ||
| session (the agent runs user-context scripts inside the signed-in user's session, | ||
| REM-PROBE-USER64). Sessions come from the owners of explorer.exe and sihost.exe through CIM, | ||
| mocked here; one test reads the real machine for shape. | ||
| #> | ||
|
|
||
| BeforeAll { | ||
| $script:ModuleRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) | ||
| Import-Module (Join-Path $script:ModuleRoot 'IntuneScriptLab.psd1') -Force | ||
|
|
||
| function script:Get-SessionFromText { | ||
| param([string[]]$Lines) | ||
| InModuleScope IntuneScriptLab -Parameters @{ Lines = $Lines } { | ||
| Mock Get-IslQueryUserOutput { $Lines } | ||
| @(Get-IslLogonSession) | ||
| # The fake machine: shell processes by session, and what GetOwner / GetOwnerSid answer for each. | ||
| # Module-scoped mocks see this file's $script: variables | ||
| $script:EntraSid = 'S-1-12-1-1497552185-1263987200-3276725654-805488699' | ||
| $script:LocalSid = 'S-1-5-21-1-2-3-1001' | ||
| $script:Owners = @{ | ||
| 100 = @{ Domain = 'KRBETYP-AIEPVQ5'; User = 'isl-user'; Sid = $script:LocalSid } | ||
| 101 = @{ Domain = 'KRBETYP-AIEPVQ5'; User = 'isl-user'; Sid = $script:LocalSid } | ||
| 200 = @{ Domain = 'AzureAD'; User = 'IslVerylongdisplayna'; Sid = $script:EntraSid } | ||
| 300 = $null # an owner this session may not read | ||
| } | ||
| function script:New-Shell { | ||
| param([string]$Name, [int]$SessionId, [int]$ProcessId) | ||
| [pscustomobject]@{ Name = $Name; SessionId = $SessionId; ProcessId = $ProcessId } | ||
| } | ||
| function script:Get-Session { | ||
| param([object[]]$Shells) | ||
| $script:Shells = $Shells | ||
| Mock Get-CimInstance -ModuleName IntuneScriptLab { $script:Shells } | ||
| # The fake shells are plain objects, not CimInstances, so the parameter's type is lifted | ||
| Mock Invoke-CimMethod -ModuleName IntuneScriptLab -RemoveParameterType InputObject { | ||
| $owner = $script:Owners[[int]$InputObject.ProcessId] | ||
| if (-not $owner) { return } | ||
| if ($MethodName -eq 'GetOwnerSid') { [pscustomobject]@{ Sid = $owner.Sid } } | ||
| else { [pscustomobject]@{ Domain = $owner.Domain; User = $owner.User } } | ||
| } | ||
| InModuleScope IntuneScriptLab { @(Get-IslLogonSession) } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -25,57 +46,41 @@ AfterAll { | |
|
|
||
| Describe 'Get-IslLogonSession' -Tag 'Unit', 'Private' { | ||
|
|
||
| It 'parses an active console session and a disconnected one without a session name' { | ||
| $sessions = Get-SessionFromText -Lines @( | ||
| ' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME' | ||
| '>jeffstuhr console 2 Active none 9/23/2026 8:50 AM' | ||
| ' isl-user 3 Disc 1:23 9/28/2026 1:02 AM' | ||
| It 'lists one session per desktop user, from the owner of its shell processes' { | ||
| $sessions = Get-Session @( | ||
| (New-Shell 'sihost.exe' 1 101), (New-Shell 'explorer.exe' 1 100), (New-Shell 'explorer.exe' 2 200) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sihost.exe is listed before explorer.exe, and with the higher process id, on purpose: the result must not depend on the order CIM hands the processes back. |
||
| ) | ||
| $sessions.Count | Should-Be 2 | ||
| $sessions[0].PSObject.TypeNames | Should-ContainCollection 'IntuneScriptLab.LogonSession' | ||
| $sessions[0].UserName | Should-Be 'jeffstuhr' | ||
| $sessions[0].SessionName | Should-Be 'console' | ||
| $sessions[0].Id | Should-Be 2 | ||
| $sessions[0].State | Should-Be 'Active' | ||
| $sessions[1].UserName | Should-Be 'isl-user' | ||
| $sessions[1].SessionName | Should-Be '' | ||
| $sessions[1].Id | Should-Be 3 | ||
| $sessions[1].State | Should-Be 'Disc' | ||
| $sessions[0].UserName | Should-Be 'isl-user' | ||
| $sessions[0].Domain | Should-Be 'KRBETYP-AIEPVQ5' | ||
| $sessions[0].Sid | Should-Be $script:LocalSid | ||
| $sessions[0].Id | Should-Be 1 | ||
| # The Entra account by the name Windows gives it, not its sign-in name (VM 125) | ||
| $sessions[1].UserName | Should-Be 'IslVerylongdisplayna' | ||
| $sessions[1].Domain | Should-Be 'AzureAD' | ||
| $sessions[1].Sid | Should-Be $script:EntraSid | ||
| $sessions[1].Id | Should-Be 2 | ||
| } | ||
|
|
||
| It 'reads a 20-character name, the longest Windows gives an account, as printed on VM 125' { | ||
| # An Entra user named "Isl Verylongdisplayname Testaccount" who signs in as | ||
| # isl-verylongusername-test01@...: Windows calls it AzureAD\IslVerylongdisplayna, the display | ||
| # name without spaces cut at 20 characters, and the column still ends in two spaces | ||
| $sessions = @(Get-SessionFromText -Lines @( | ||
| ' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME' | ||
| ' islverylongdisplayna console 2 Active none 10/5/2026 11:05 AM' | ||
| )) | ||
| It 'lists a session whose desktop is still starting, from sihost.exe alone' { | ||
| $sessions = @(Get-Session @((New-Shell 'sihost.exe' 2 200))) | ||
| $sessions.Count | Should-Be 1 | ||
| $sessions[0].UserName | Should-Be 'islverylongdisplayna' | ||
| $sessions[0].SessionName | Should-Be 'console' | ||
| $sessions[0].Id | Should-Be 2 | ||
| } | ||
|
|
||
| It 'returns nothing when nobody is logged on, the tool is missing, or only the header prints' { | ||
| @(Get-SessionFromText -Lines @()).Count | Should-Be 0 | ||
| @(Get-SessionFromText -Lines @(' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME')).Count | | ||
| Should-Be 0 | ||
| $sessions[0].Sid | Should-Be $script:EntraSid | ||
| } | ||
|
|
||
| It 'skips lines it cannot read rather than failing' { | ||
| $sessions = @(Get-SessionFromText -Lines @( | ||
| ' USERNAME SESSIONNAME ID STATE IDLE TIME LOGON TIME' | ||
| 'garbage line' | ||
| ' isl-user rdp-tcp#1 4 Active none 9/28/2026 1:02 AM' | ||
| )) | ||
| It 'leaves out a process whose owner it may not read, and returns nothing with no desktop at all' { | ||
| $sessions = @(Get-Session @((New-Shell 'explorer.exe' 3 300), (New-Shell 'explorer.exe' 1 100))) | ||
| $sessions.Count | Should-Be 1 | ||
| $sessions[0].SessionName | Should-Be 'rdp-tcp#1' | ||
| $sessions[0].Id | Should-Be 1 | ||
| @(Get-Session @()).Count | Should-Be 0 | ||
| } | ||
|
|
||
| It 'reads the real tool without error and names a user for every session it finds' { | ||
| It 'reads the real machine without error and gives every session a SID and an id' { | ||
| # A CI runner has no desktop session; this machine has at least the one running the tests | ||
| $real = @(InModuleScope IntuneScriptLab { Get-IslLogonSession }) | ||
| foreach ($session in $real) { | ||
| $session.Sid | Should-MatchString '^S-1-' | ||
| $session.UserName | Should-NotBeWhiteSpaceString | ||
| $session.Id | Should-BeGreaterThanOrEqual 0 | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CIM returns processes in no fixed order. Sorted so the output order is the same on both hosts and, where a session has both processes, the explorer.exe row is the one kept by the check below.