Private/Kinds/Program.ps1

# The Program Kind: every program running on the machine, and what it holds.
#
# The App Kind answers for the applications somebody wrote a Check Definition for. A
# line-of-business program nobody did can sit at the limit of GDI objects, or leak handles
# all day, and no Check would say a word. This one looks at all of them: a table of every
# running program, and a Finding only for one over a limit.
#
# A program an application's own Check judges in this Run is in the table and gets no
# Finding here. Which those are the Run says, as $Observed['AppSelection']: this Check
# belongs to the machine and runs before the applications' Checks, so it cannot ask what
# they found.
#
# What this session may not open - the processes of another account - the ProgramElevated
# Check read with admin rights, where the Elevated Part ran. Its reading fills in.
#
# Get-ProgramData reads the machine and decides nothing. ConvertTo-ProgramFinding and
# ConvertTo-ProgramSection are pure.

# What Windows runs as a process and is none: the kernel and what it keeps for itself.
# They hold handles by the ten thousand by their nature and are not judged. They have no
# file: a program that only bears one of these names has one, and is judged.
$script:ProgramNotJudgedPattern = '^(System|Idle|Registry|Memory Compression|Secure System)$'

function Get-ProgramData {
    <#
    .SYNOPSIS
        Every running process and what it holds, and the applications that have a Check of
        their own in this Run. Decides nothing.
    .PARAMETER Observed
        What the Run knows before this Check. 'AppSelection' is the applications the
        Technician chose, each with the pattern of its processes. 'ProgramElevated' is
        the same processes as an administrator may read them, absent where that Check
        was not performed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{})

    $own = @()
    if ($Observed -and $Observed.ContainsKey('AppSelection')) {
        $own = @((Get-DataCollection $Observed['AppSelection'] 'Selected') | ForEach-Object { $_ })
    }

    # Copied across as it came. Which of it fills in what is for the pure half to say.
    $elevated = $null
    if ($Observed -and $Observed.ContainsKey('ProgramElevated')) { $elevated = $Observed['ProgramElevated'] }

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Data.Program'
        # Read as the App Kind reads an application's, of every process there is.
        Processes         = @(Get-AppProcess -Pattern '.')
        OwnChecks         = $own
        ElevatedRead      = ($null -ne $elevated)
        ElevatedProcesses = @((Get-DataCollection $elevated 'Processes') | ForEach-Object { $_ })
    }
}

function Join-ProgramReading {
    <#
    .SYNOPSIS
        The processes as this session read them, with what it could not open filled in
        from the reading taken with admin rights. Pure.
    .DESCRIPTION
        Only what was unread is filled in, and only from the same process: the same
        number and the same name, as a number is given to another process once its
        first owner has ended. Memory and handles stay this session's, read later.
 
        The filling in itself is Join-ElevatedReading's, which the Session Kind fills in
        by as well. Here is said what it is for and from what: for a process this session
        could not open, from the same process as the Elevated Part opened it. One that
        did not open with admin rights either has nothing to give.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    Join-ElevatedReading -Row (Get-DataCollection $Data 'Processes') -Elevated (Get-DataCollection $Data 'ElevatedProcesses') `
        -Key 'ProcessId' -Take 'Gdi', 'User', 'Bits', 'Path' `
        -Unread { param($Process) $null -eq (Get-DataProperty $Process 'Gdi') -or $null -eq (Get-DataProperty $Process 'User') } `
        -Same {
            param($Process, $Other)
            $null -ne (Get-DataProperty $Other 'Gdi') -and "$(Get-DataProperty $Other 'Process')" -eq "$(Get-DataProperty $Process 'Process')"
        } | ForEach-Object { $_.Row }
}

function ConvertTo-ProgramCount {
    <#
    .SYNOPSIS
        A count as a whole number, and nothing as nothing: a maximum comes back as a
        floating-point number, and a table would show "830,00" objects. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $null }
    [long]$Value
}

function ConvertTo-ProgramRow {
    <#
    .SYNOPSIS
        The processes as programs: one row per program, its processes together. Pure.
    .DESCRIPTION
        A program is a name and the file it runs from: "update" and "setup" are many
        programs' names. Processes whose file could not be read stand together under
        their name.
 
        Memory is the sum. Handles, GDI and USER objects are those of the largest process,
        because each limit is one per process. A program one of whose processes could not
        be opened is Unread: what is shown of it is what could be read. OwnCheck names the
        application's Check that judges it, NotJudged that it is the kernel's.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $own = @((Get-DataCollection $Data 'OwnChecks') | ForEach-Object { $_ })

    $rows = @(Join-ProgramReading -Data $Data | Where-Object { "$(Get-DataProperty $_ 'Process')".Trim() } |
        Group-Object { ('{0}|{1}' -f "$(Get-DataProperty $_ 'Process')".Trim(), "$(Get-DataProperty $_ 'Path')".Trim()).ToLowerInvariant() } | ForEach-Object {
            $group = @($_.Group)
            $name  = "$(Get-DataProperty $group[0] 'Process')".Trim()
            $path  = "$(Get-DataProperty $group[0] 'Path')".Trim()

            $ownCheck = ''
            $ownApp   = ''
            foreach ($check in $own) {
                $pattern = "$(Get-DataProperty $check 'ProcessPattern')"
                if (-not $pattern) { continue }
                $matched = $false
                try { $matched = $name -match $pattern } catch { }
                if ($matched) {
                    $ownCheck = "$(Get-DataProperty $check 'CheckName')"
                    $ownApp   = "$(Get-DataProperty $check 'App')"
                    if (-not $ownApp) { $ownApp = $ownCheck }
                    break
                }
            }

            $memory = (@($group | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'PrivateMB') } |
                Where-Object { $null -ne $_ }) | Measure-Object -Sum).Sum
            if ($null -eq $memory) { $memory = 0 }

            [pscustomobject]@{
                Program   = $name
                Processes = $group.Count
                PrivateMB = [long]$memory
                Handles   = ConvertTo-ProgramCount (Get-AppMaximum -Row $group -Property 'Handles')
                Gdi       = ConvertTo-ProgramCount (Get-AppMaximum -Row $group -Property 'Gdi')
                User      = ConvertTo-ProgramCount (Get-AppMaximum -Row $group -Property 'User')
                Bits      = (@($group | ForEach-Object { Get-DataProperty $_ 'Bits' } | Where-Object { $_ } |
                                Select-Object -Unique | Sort-Object) -join '/')
                Path      = $path
                Unread    = [bool]@($group | Where-Object { $null -eq (Get-DataProperty $_ 'Gdi') -or $null -eq (Get-DataProperty $_ 'User') }).Count
                OwnCheck  = $ownCheck
                OwnApp    = $ownApp
                NotJudged = [bool](-not $path -and $name -match $script:ProgramNotJudgedPattern)
            }
        })

    # Two keys, so that the order does not depend on how a sort treats equals.
    @($rows | Sort-Object @{ Expression = 'PrivateMB'; Descending = $true }, @{ Expression = 'Program'; Descending = $false },
        @{ Expression = 'Path'; Descending = $false })
}

function ConvertTo-ProgramFinding {
    <#
    .SYNOPSIS
        A Finding for each program over a limit, and one saying how many were looked at. Pure.
    .DESCRIPTION
        GuiObjectFailAbove (8,000) is the App Kind's. Windows gives a process 10,000 GDI
        and 10,000 USER objects (the registry values GDIProcessHandleQuota and
        USERProcessHandleQuota, as Microsoft documents under "GDI Objects" and "User
        Objects"), and at the limit it cannot draw any more.
 
        HandleWarnAbove (20,000) is not the App Kind's 10,000, and no limit of Windows:
        a process may hold about sixteen million handles (Microsoft, "Pushing the Limits
        of Windows: Handles"). It is a rule of thumb for "unusual", set above what a mail
        program holds on an ordinary day - a little under 15,000 on the machine this was
        written on - because this Check judges every program and would warn on most
        machines otherwise.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{})

    $guiFail    = ConvertTo-Number (Get-Parameter $Parameters 'GuiObjectFailAbove' 8000)
    $handleWarn = ConvertTo-Number (Get-Parameter $Parameters 'HandleWarnAbove' 20000)
    if ($null -eq $guiFail)    { $guiFail = 8000 }
    if ($null -eq $handleWarn) { $handleWarn = 20000 }

    $rows = @(ConvertTo-ProgramRow -Data $Data)
    if (-not $rows.Count) {
        return New-UnavailableFinding -Category Apps -Check (Get-Text 'Check.Program.Summary') `
            -Hint (Get-Text 'Hint.Program.NothingRead')
    }

    $over = New-Object System.Collections.Generic.List[string]
    $findings = @(foreach ($row in @($rows | Where-Object { -not $_.OwnCheck -and -not $_.NotJudged })) {
        $gui     = ($null -ne $row.Gdi -and $row.Gdi -gt $guiFail) -or ($null -ne $row.User -and $row.User -gt $guiFail)
        $handles = $null -ne $row.Handles -and $row.Handles -gt $handleWarn
        if (-not $gui -and -not $handles) { continue }
        $over.Add($row.Program)

        $value = @((Get-Text 'Value.Program.Processes') -f $row.Processes, (Format-DataSize -MB $row.PrivateMB))
        if ($null -ne $row.Gdi)     { $value += (Get-Text 'Value.Program.Gdi') -f $row.Gdi }
        if ($null -ne $row.User)    { $value += (Get-Text 'Value.Program.User') -f $row.User }
        if ($null -ne $row.Handles) { $value += (Get-Text 'Value.Program.Handles') -f $row.Handles }
        if ($row.Bits)              { $value += (Get-Text 'Value.Program.Bits') -f $row.Bits }
        if ($row.Path)              { $value += $row.Path }

        # The worse first: at the limit of GDI or USER objects the program dies, and that
        # is not to be read as merely many handles.
        if ($gui) {
            New-Finding -Category Apps -Check ((Get-Text 'Check.Program.OverLimit') -f $row.Program) -Severity FAIL `
                -Value ($value -join ' | ') -Meaning (Get-Text 'Meaning.Program.GuiObjects') -Hint (Get-Text 'Hint.Program.GuiObjects')
        }
        else {
            New-Finding -Category Apps -Check ((Get-Text 'Check.Program.OverLimit') -f $row.Program) -Severity WARN `
                -Value ($value -join ' | ') -Meaning ((Get-Text 'Meaning.Program.Handles') -f $handleWarn) -Hint (Get-Text 'Hint.Program.Handles')
        }
    })

    $processes = ($rows | Measure-Object -Property Processes -Sum).Sum
    $summary   = @((Get-Text 'Value.Program.LookedAt') -f $rows.Count, $processes)
    if ($over.Count) { $summary += (Get-Text 'Value.Program.OverLimit') -f $over.Count, ($over -join ', ') }
    else             { $summary += Get-Text 'Value.Program.NoneOverLimit' }

    $owned = @($rows | Where-Object { $_.OwnCheck })
    if ($owned.Count) {
        $apps = @($owned | ForEach-Object { $_.OwnApp } | Select-Object -Unique)
        $summary += (Get-Text 'Value.Program.OwnCheck') -f $owned.Count, ($apps -join ', ')
    }
    # In the Value and not as a Meaning: an OK Finding carries none, and this has to be
    # said when everything is in order too.
    $unread = @($rows | Where-Object { $_.Unread -and -not $_.NotJudged })
    if ($unread.Count) {
        # Whose fault it is that they are unread: nobody asked with admin rights, or
        # Windows does not give them to an administrator either.
        if ((Get-UnreadReason $Data) -eq 'Refused') { $summary += (Get-Text 'Value.Program.UnreadProtected') -f $unread.Count }
        else { $summary += (Get-Text 'Value.Program.Unread') -f $unread.Count }
    }

    $findings
    New-Finding -Category Apps -Check (Get-Text 'Check.Program.Summary') `
        -Severity $(if ($over.Count) { 'INFO' } else { 'OK' }) -Value ($summary -join ' | ') `
        -Hint (Get-Text 'Hint.Program.SeeFindings')
}

function ConvertTo-ProgramSection {
    <#
    .SYNOPSIS
        Every running program as a table, those in order too. Pure.
    .DESCRIPTION
        What could not be read of a program is said in the cell in one of two wordings:
        "nicht gelesen" where nobody read with admin rights in this Run, "nicht lesbar"
        where Windows did not give it with admin rights either. See Get-UnreadText.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $unread    = Get-UnreadText -Reason (Get-UnreadReason $Data)
    $protected = (Get-UnreadReason $Data) -eq 'Refused'
    New-Section -Title (Get-Text 'Title.Program.All') -Row @(
        ConvertTo-ProgramRow -Data $Data | ForEach-Object {
            $note = @()
            if ($_.OwnCheck)  { $note += (Get-Text 'Value.Program.Note.OwnCheck') -f $_.OwnCheck }
            if ($_.NotJudged) { $note += Get-Text 'Value.Program.Note.Kernel' }
            elseif ($_.Unread -and $protected) { $note += Get-Text 'Value.Program.Note.Protected' }
            elseif ($_.Unread) { $note += Get-Text 'Value.Program.Note.Unread' }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.Program.Program')]   = $_.Program
            $row[(Get-Text 'Column.Program.Processes')] = $_.Processes
            $row[(Get-Text 'Column.Program.PrivateMB')] = $_.PrivateMB
            $row[(Get-Text 'Column.Program.Handles')]   = $(if ($null -ne $_.Handles) { $_.Handles } else { $unread })
            $row[(Get-Text 'Column.Program.Gdi')]       = $(if ($null -ne $_.Gdi) { $_.Gdi } else { $unread })
            $row[(Get-Text 'Column.Program.User')]      = $(if ($null -ne $_.User) { $_.User } else { $unread })
            $row[(Get-Text 'Column.Program.Bits')]      = $(if ($_.Bits) { $_.Bits } elseif ($_.Unread -and -not $_.NotJudged) { $unread } else { '' })
            $row[(Get-Text 'Column.Program.Path')]      = $(if ($_.Path) { $_.Path } elseif ($_.Unread -and -not $_.NotJudged) { $unread } else { '' })
            $row[(Get-Text 'Column.Note')]              = $note -join '; '
            [pscustomobject]$row
        }
    )
}