diff --git a/CHANGELOG.md b/CHANGELOG.md index 037fd0e..4330f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ release notes. ## [Unreleased] +### Fixed + +- **`-Credential` found no session on Windows Home editions, and matched by name.** The session + list came from parsing `query user`, which Home editions do not ship (every run there fell back + to the stored-password task) and which prints localized text. Sessions now come from the owners + of each desktop's `explorer.exe` and `sihost.exe` through CIM, and the account is matched by + its SID when Windows resolves the credential's name, by the owner's name otherwise. Verified on + this Home machine and on the joined lab device with the Entra user signed in. + ### Changed - Validation kit: `Collect` moves its payload through the guest agent's file-read call in one diff --git a/Private/Get-IslLogonSession.ps1 b/Private/Get-IslLogonSession.ps1 index db14030..8dd205b 100644 --- a/Private/Get-IslLogonSession.ps1 +++ b/Private/Get-IslLogonSession.ps1 @@ -1,19 +1,23 @@ 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. #> @@ -21,25 +25,22 @@ function Get-IslLogonSession { [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" + 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) } diff --git a/Private/Get-IslQueryUserOutput.ps1 b/Private/Get-IslQueryUserOutput.ps1 deleted file mode 100644 index 6ef2653..0000000 --- a/Private/Get-IslQueryUserOutput.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -function Get-IslQueryUserOutput { - <# - .SYNOPSIS - Returns the lines of "query user", or nothing where the tool is missing or nobody is logged on. - - .DESCRIPTION - Separated from Get-IslLogonSession so the parser can be tested with fixed text. query.exe - writes "No User exists for *" to stderr and exits 1 when no session exists; that is an empty - result here, not an error. - - .EXAMPLE - Get-IslQueryUserOutput - - The header line and one line per session, as query.exe prints them. - #> - [CmdletBinding()] - [OutputType([string[]])] - param() - - $query = Get-Command -Name query.exe -ErrorAction SilentlyContinue - if (-not $query) { return @() } - @(& $query.Source user 2>$null | ForEach-Object { "$_" }) -} diff --git a/Private/Invoke-IslProcess.ps1 b/Private/Invoke-IslProcess.ps1 index ea01f5d..63c425f 100644 --- a/Private/Invoke-IslProcess.ps1 +++ b/Private/Invoke-IslProcess.ps1 @@ -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 } + }) $logon = if ($LogonType -ne 'Auto') { $LogonType } elseif ($sessions.Count -gt 0) { 'Interactive' } else { 'Password' } diff --git a/Tests/Unit/Private/Get-IslLogonSession.Tests.ps1 b/Tests/Unit/Private/Get-IslLogonSession.Tests.ps1 index 588a9dc..47ebfea 100644 --- a/Tests/Unit/Private/Get-IslLogonSession.Tests.ps1 +++ b/Tests/Unit/Private/Get-IslLogonSession.Tests.ps1 @@ -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) ) $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 } diff --git a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 index 290c605..0bd8953 100644 --- a/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 +++ b/Tests/Unit/Private/Invoke-IslProcess.Tests.ps1 @@ -192,7 +192,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { It 'registers an interactive task for the account when it holds a session (REM-PROBE-USER64)' { Mock Get-IslLogonSession -ModuleName IntuneScriptLab { - [pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' } + [pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 } } $result = Invoke-Process $script:LaunchSplat $result.ExitCode | Should-Be 0 @@ -219,7 +219,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { It 'lets -LogonType force the stored-password task even when the account has a session' { Mock Get-IslLogonSession -ModuleName IntuneScriptLab { - [pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' } + [pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 } } $result = Invoke-Process ($script:LaunchSplat + @{ LogonType = 'Password' }) $result.LogonType | Should-Be 'Password' @@ -230,7 +230,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { It 'matches the session by account name whatever the credential prefix' { Mock Get-IslLogonSession -ModuleName IntuneScriptLab { - [pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' } + [pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 } } $launchSplat = $script:LaunchSplat.Clone() $launchSplat.Credential = [pscredential]::new('isl-user@lab.local', $script:Credential.Password) @@ -240,8 +240,8 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { } It 'finds an Entra account''s session by the name Windows gives it, not by its sign-in name (VM 125)' { - # Signed in as isl-verylongusername-test01@..., listed by "query user" as - # islverylongdisplayna; the scheduler takes the Windows name and refuses the sign-in name + # Signed in as isl-verylongusername-test01@..., the session owned by the SID that name + # resolves to; the scheduler takes the Windows name and refuses the sign-in name Mock Resolve-IslAccount -ModuleName IntuneScriptLab { [pscustomobject]@{ Name = 'AzureAD\IslVerylongdisplayna' @@ -250,7 +250,8 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { } Mock Get-IslLogonSession -ModuleName IntuneScriptLab { [pscustomobject]@{ - UserName = 'islverylongdisplayna'; SessionName = 'console'; Id = 2; State = 'Active' + UserName = 'IslVerylongdisplayna'; Domain = 'AzureAD' + Sid = 'S-1-12-1-1497552185-1263987200-3276725654-805488699'; Id = 2 } } $launchSplat = $script:LaunchSplat.Clone() @@ -274,7 +275,7 @@ Describe 'Invoke-IslProcess' -Tag 'Unit', 'Private' { [pscustomobject]@{ Name = 'AzureAD\SomeoneElse'; Sid = 'S-1-12-1-1-2-3-4' } } Mock Get-IslLogonSession -ModuleName IntuneScriptLab { - [pscustomobject]@{ UserName = 'isl-user'; SessionName = 'console'; Id = 2; State = 'Active' } + [pscustomobject]@{ UserName = 'isl-user'; Domain = 'LAB'; Sid = 'S-1-5-21-1-2-3-1001'; Id = 2 } } $launchSplat = $script:LaunchSplat.Clone() $launchSplat.Credential = [pscredential]::new('isl-user@lab.local', $script:Credential.Password) diff --git a/Validation/Findings.md b/Validation/Findings.md index e49a1b4..14dd8c8 100644 --- a/Validation/Findings.md +++ b/Validation/Findings.md @@ -391,8 +391,17 @@ anywhere, and the launcher waited out its timeout. 0.26.0 treats five seconds of apart as text, which can only work when the Windows name happens to equal that text. That holds for a local account and fails for an Entra account whatever its length: the Windows name comes from the display name. `Resolve-IslAccount` now asks Windows for the account's SID (trying -`AzureAD\` in front of a sign-in name) and for the name behind that SID; the session is matched on -that name, the interactive task is registered for it, and the run folder is granted by SID. +`AzureAD\` in front of a sign-in name) and for the name behind that SID; the interactive task is +registered for that name and the run folder is granted by SID. + +2026-10-06, the same device and user: the session list no longer comes from `query user`, which +Windows Home editions do not ship and which prints localized text, but from the owners of each +desktop's `explorer.exe` and `sihost.exe` through CIM, and the credential is matched by SID. As +SYSTEM the device listed one session, `AzureAD\IslVerylongdisplayna`, +`S-1-12-1-1497552185-1263987200-3276725654-805488699`, id 2, the SID the sign-in name resolves to; +`-Credential` with the sign-in name, `AzureAD\` and the Windows name each ran the +script inside session 2 (`interactive=True`) and left no task or run folder behind. On a Windows +11 Home machine without `query.exe` the same list named its one console session. ### Reporting latency, re-measured (2026-09-29)