EventMonitor/SessionDetection.ps1

# ── Session Detection ─────────────────────────────────────────────────────────
# Functions that detect active user sessions (RDP via quser), active SSH
# connections (via netstat), and enumerate Windows user profiles.

<#
.SYNOPSIS
    Parses quser output to detect active/idle RDP sessions and reports each to telemetry.
.DESCRIPTION
    Runs "query user" (quser) and parses the tabular output to extract session state,
    idle time, and logon time for each session. Sends each session as a telemetry event.
    Returns $true if at least one session has Active state with zero idle time.
.OUTPUTS
    [bool] $true if an active, non-idle user session exists.
.NOTES
    quser is available on Windows 10/11 Pro, Enterprise, and Server editions.
    It is NOT available on Windows 10/11 Home edition.
#>

function Get-ActiveUsersByQUsers {
    [CmdletBinding()]
    param(
        [string]$sessionId,

        [Parameter(Mandatory)]
        [string]$UserName
    )

    $hasActiveUserSession = $false

    try {
        $quserOutput = query user 2>&1
        if ($LASTEXITCODE -ne 0) {
            Write-EMLog -Message "quser returned exit code $LASTEXITCODE — no sessions or command unavailable." -Level Warning
            return $false
        }

        foreach ($line in $quserOutput) {
            $conInfo = ($line -split '\s+') | Where-Object { -not [string]::IsNullOrEmpty($_) }
            if ($null -eq $conInfo -or $conInfo.Count -lt 5) { continue }
            if ($conInfo[0] -like '*USERNAME*') { continue }

            # Active sessions have 8 columns (username, sessionname, id, state, idle, date, time, am/pm)
            # Disconnected sessions have 7 (no sessionname column)
            if ($conInfo.Count -eq 8) {
                $offset      = 0
                $sessionName = $conInfo[1]
            }
            else {
                $offset      = 1
                $sessionName = '-'
            }

            $state = $conInfo[3 - $offset]
            if ($state -eq 'Active' -and $conInfo[4 - $offset] -eq '.') {
                $hasActiveUserSession = $true
            }

            try {
                $idleMinutes = ConvertTo-IdleMinutes -IdleString $conInfo[4 - $offset]
                $quserName = ($conInfo[0]).Trim('>')

                # Filter to the requested user if specified
                if ($UserName -and $quserName -ne $UserName) { continue }

                $evProps = [System.Collections.Generic.Dictionary[string, string]]::new()
                $evProps['SessionId']        = $sessionId
                $evProps['USER-NAME']        = $quserName
                $evProps['SESSION-NAME']     = $sessionName
                $evProps['SESSION-ID']       = $conInfo[2 - $offset]
                $evProps['STATE']            = $state
                $evProps['IDLE-FOR-LAST(Min)'] = "$idleMinutes"
                $evProps['LOGON-TIME']       = "$($conInfo[5 - $offset]) $($conInfo[6 - $offset]) $($conInfo[7 - $offset])"

                TrackEvent -Name 'Query Active Session connection' -Properties $evProps
            }
            catch {
                Write-EMLog -Message "Failed to parse quser line: $line — $($_.Exception.Message)" -Level Warning
            }
        }

        return $hasActiveUserSession
    }
    catch {
        Write-EMLog -Message "Get-ActiveUsersByQUsers failed: $($_.Exception.Message)" -Level Error
        $errorProps = [System.Collections.Generic.Dictionary[string, string]]::new()
        $errorProps['SessionId'] = $sessionId
        $errorProps['UserName']  = $UserName
        $errorProps['Function']  = 'Get-ActiveUsersByQUsers'
        TrackException -ErrorRecord $_ -Properties $errorProps
        return $false
    }
}

<#
.SYNOPSIS
    Converts a quser idle-time string (e.g., "2:30", "1+3:15", "45", ".") into total minutes.
.OUTPUTS
    [int] Total idle minutes.
#>

function ConvertTo-IdleMinutes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$IdleString
    )

    # "." means zero idle time (currently active)
    if ($IdleString -eq '.' -or $IdleString -eq 'none') { return 0 }

    # Pure number = minutes
    if ($IdleString -match '^\d+$') {
        return [int]$IdleString
    }

    # Format: [days+]hours:minutes
    if ($IdleString -match '^(?:(\d+)\+)?(\d+):(\d+)$') {
        $days  = if ($Matches[1]) { [int]$Matches[1] } else { 0 }
        $hours = [int]$Matches[2]
        $mins  = [int]$Matches[3]
        return ($days * 1440) + ($hours * 60) + $mins
    }

    return 0
}

<#
.SYNOPSIS
    Checks whether the machine has any active SSH connections via netstat.
.DESCRIPTION
    Runs "netstat -b" (requires elevation) and looks for sshd.exe in the output.
    Reports the result via telemetry if an active connection is found.
.OUTPUTS
    [bool] $true if an active sshd connection exists.
#>

function Get-ActiveSSHDConnectionByNetStat {
    [CmdletBinding()]
    param(
        [string]$sessionId
    )

    $hasSSHDConnection = $false

    try {
        $nsOutput = netstat -b 2>&1
        foreach ($line in $nsOutput) {
            if ($line -like '*sshd.exe*') {
                $hasSSHDConnection = $true
                break
            }
        }

        if ($hasSSHDConnection) {
            $evProps = [System.Collections.Generic.Dictionary[string, string]]::new()
            $evProps['SessionId']                  = $sessionId
            $evProps['ACTIVE SSH connection exist'] = 'True'
            TrackEvent -Name 'Query Active SSH connection' -Properties $evProps
        }

        return $hasSSHDConnection
    }
    catch {
        Write-EMLog -Message "Get-ActiveSSHDConnectionByNetStat failed: $($_.Exception.Message)" -Level Error
        $errorProps = [System.Collections.Generic.Dictionary[string, string]]::new()
        $errorProps['SessionId'] = $sessionId
        $errorProps['Function']  = 'Get-ActiveSSHDConnectionByNetStat'
        TrackException -ErrorRecord $_ -Properties $errorProps
        return $false
    }
}

<#
.SYNOPSIS
    Enumerates non-special Windows user profiles on the current machine.
.DESCRIPTION
    Uses Win32_UserProfile CIM class to list real user accounts (excludes system accounts).
    Works on Windows 10, Windows 11, and Windows Server 2016+.
.OUTPUTS
    Array of hashtables with UserName, SID, LastUseTime, Loaded, Special, LocalPath.
#>

function Get-WindowsUsers {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$sessionId
    )

    try {
        $users = [System.Collections.ArrayList]::new()
        $profiles = Get-CimInstance -ClassName Win32_UserProfile -Filter "Special = 'False'"

        foreach ($profile in $profiles) {
            try {
                $sid     = $profile.SID
                $objSID  = [System.Security.Principal.SecurityIdentifier]::new($sid)
                $objUser = $objSID.Translate([System.Security.Principal.NTAccount])
                $fullName = $objUser.Value
                # Strip domain prefix (DOMAIN\username -> username)
                # Security log events store just the username without domain
                $shortName = if ($fullName -match '\\(.+)$') { $Matches[1] } else { $fullName }

                [void]$users.Add(@{
                    UserName    = $shortName
                    FullName    = $fullName
                    SID         = $sid
                    LastUseTime = $profile.LastUseTime
                    Loaded      = $profile.Loaded
                    Special     = $profile.Special
                    LocalPath   = $profile.LocalPath
                })
            }
            catch {
                # SID may not translate (orphaned profile) — skip
                Write-Verbose "Skipping profile SID $($profile.SID): $($_.Exception.Message)"
            }
        }

        return $users
    }
    catch {
        Write-EMLog -Message "Get-WindowsUsers failed: $($_.Exception.Message)" -Level Error
        $errorProps = [System.Collections.Generic.Dictionary[string, string]]::new()
        $errorProps['SessionId'] = $sessionId
        $errorProps['Function']  = 'Get-WindowsUsers'
        TrackException -ErrorRecord $_ -Properties $errorProps
        throw "Failed to enumerate Windows user profiles: $_"
    }
}