Private/Kinds/Startup.ps1

# The Startup Kind: what runs before the Technician's Customer gets to work.
#
# The Finding is a count, which is all a first-level Technician needs to act on. The
# Section beneath it is the list, which is what second level needs to say which entry to
# remove - and which is why the list travels with the Report rather than being something
# to go back to the machine for.

function Get-StartupData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $entries   = @()
    $available = $true
    try {
        $entries = @(Get-CimInstance Win32_StartupCommand -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                Name     = "$($_.Name)"
                Command  = "$($_.Command)"
                Location = "$($_.Location)"
                User     = "$($_.User)"
            }
        })
    }
    catch { $available = $false }

    $tasks = Get-StartupScheduledTask

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Startup'
        Available  = $available
        Entries    = $entries
        # $null where Windows has no way to list them, which is not the same as none.
        TasksRead  = $null -ne $tasks
        Tasks      = @($tasks)
    }
}

function Get-StartupScheduledTask {
    <#
    .SYNOPSIS
        The scheduled tasks that are not Windows' own, with when each last ran and how
        that ended. $null where they cannot be listed.
    .DESCRIPTION
        Windows ships some two hundred tasks under \Microsoft\, and a list of those hides
        the dozen somebody put on this machine. Without admin rights Windows hands out
        the tasks this user may see and says nothing of the rest.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    if (-not (Get-Command -Name Get-ScheduledTask -ErrorAction SilentlyContinue)) { return $null }

    $found = New-Object System.Collections.Generic.List[object]
    foreach ($task in @(Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { "$($_.TaskPath)" -notlike '\Microsoft\*' })) {
        $info = $null
        try { $info = $task | Get-ScheduledTaskInfo -ErrorAction Stop } catch { }

        $account = "$($task.Principal.UserId)"
        if (-not $account) { $account = "$($task.Principal.GroupId)" }

        $found.Add([pscustomobject]@{
            Name       = "$($task.TaskName)"
            Folder     = "$($task.TaskPath)"
            State      = "$($task.State)"
            Account    = $account
            Triggers   = @($task.Triggers | ForEach-Object { "$($_.CimClass.CimClassName)" })
            Actions    = @($task.Actions | ForEach-Object { ("{0} {1}" -f $_.Execute, $_.Arguments).Trim() })
            LastRun    = $(if ($info) { $info.LastRunTime })
            LastResult = $(if ($info) { $info.LastTaskResult })
            NextRun    = $(if ($info) { $info.NextRunTime })
        })
    }
    , $found.ToArray()
}

function Test-StartupTaskFailed {
    <#
    .SYNOPSIS
        Whether a task's last run ended badly. Pure.
    .DESCRIPTION
        A result is not a failure for being other than zero: 0x41300 to 0x41305 and
        0x41325 say the task is ready, running, disabled, has not run yet or is queued.
        A task that is switched off is nobody's problem, whatever its last run was.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Task)

    if ("$(Get-DataProperty $Task 'State')" -eq 'Disabled') { return $false }
    $result = ConvertTo-Number (Get-DataProperty $Task 'LastResult')
    if ($null -eq $result -or $result -eq 0) { return $false }
    if (($result -ge 0x41300 -and $result -le 0x41305) -or $result -eq 0x41325) { return $false }
    $true
}

function ConvertTo-StartupTaskRow {
    <#
    .SYNOPSIS
        One scheduled task as a row of the Section, in a Technician's words. Pure.
    #>

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

    $state = switch ("$(Get-DataProperty $Task 'State')") {
        'Ready'    { Get-Text 'Value.Startup.TaskState.Ready' }
        'Disabled' { Get-Text 'Value.Startup.TaskState.Disabled' }
        'Running'  { Get-Text 'Value.Startup.TaskState.Running' }
        'Queued'   { Get-Text 'Value.Startup.TaskState.Queued' }
        default    { "$_" }
    }

    $triggers = @((Get-DataCollection $Task 'Triggers') | ForEach-Object {
        switch -Regex ("$_") {
            'Daily'        { Get-Text 'Value.Startup.TaskTrigger.Daily'; break }
            'Weekly'       { Get-Text 'Value.Startup.TaskTrigger.Weekly'; break }
            'Logon'        { Get-Text 'Value.Startup.TaskTrigger.Logon'; break }
            'Boot'         { Get-Text 'Value.Startup.TaskTrigger.Boot'; break }
            'Idle'         { Get-Text 'Value.Startup.TaskTrigger.Idle'; break }
            'Event'        { Get-Text 'Value.Startup.TaskTrigger.Event'; break }
            'Registration' { Get-Text 'Value.Startup.TaskTrigger.Registration'; break }
            'SessionState' { Get-Text 'Value.Startup.TaskTrigger.Session'; break }
            'Time'         { Get-Text 'Value.Startup.TaskTrigger.Time'; break }
            default        { Get-Text 'Value.Startup.TaskTrigger.Other' }
        }
    } | Select-Object -Unique)
    if (-not $triggers.Count) { $triggers = @(Get-Text 'Value.Startup.TaskTrigger.None') }

    $actions = @((Get-DataCollection $Task 'Actions') | ForEach-Object { if ("$_".Trim()) { "$_" } else { Get-Text 'Value.Startup.TaskAction.Handler' } })

    # Windows gives a task that never ran a date in 1999.
    $time = { param($value)
        $at = $value -as [datetime]
        if ($at -and $at.Year -gt 2000) { (Get-Text 'Value.Startup.TaskTime') -f $at } else { '' }
    }
    $lastRun = & $time (Get-DataProperty $Task 'LastRun')

    $number = ConvertTo-Number (Get-DataProperty $Task 'LastResult')
    $result = ''
    if ($null -ne $number) {
        $hex = '{0:X}' -f [int64]$number
        $result = if ($number -eq 0 -and $lastRun) { Get-Text 'Value.Startup.TaskResult.Success' }
                  elseif ($number -eq 0x41303 -or -not $lastRun) { Get-Text 'Value.Startup.TaskResult.NeverRun' }
                  elseif ($number -eq 0x41301) { Get-Text 'Value.Startup.TaskResult.Running' }
                  elseif (Test-StartupTaskFailed -Task $Task) { (Get-Text 'Value.Startup.TaskResult.Failed') -f $hex }
                  else { (Get-Text 'Value.Startup.TaskResult.Other') -f $hex }
    }

    $row = [ordered]@{}
    $row[(Get-Text 'Column.Startup.Task')]        = "$(Get-DataProperty $Task 'Name')"
    $row[(Get-Text 'Column.Startup.TaskFolder')]  = "$(Get-DataProperty $Task 'Folder')"
    $row[(Get-Text 'Column.Startup.TaskState')]   = $state
    $row[(Get-Text 'Column.Startup.TaskAccount')] = "$(Get-DataProperty $Task 'Account')"
    $row[(Get-Text 'Column.Startup.TaskTrigger')] = $triggers -join ', '
    $row[(Get-Text 'Column.Startup.TaskAction')]  = $actions -join ' | '
    $row[(Get-Text 'Column.Startup.TaskLastRun')] = $lastRun
    $row[(Get-Text 'Column.Startup.TaskResult')]  = $result
    $row[(Get-Text 'Column.Startup.TaskNextRun')] = & $time (Get-DataProperty $Task 'NextRun')
    [pscustomobject]$row
}

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

    $warnAbove = Get-Parameter $Parameters 'StartupEntryWarnAbove' 15
    $failAbove = Get-Parameter $Parameters 'StartupEntryFailAbove' ([double]::MaxValue)

    if (-not (Get-DataProperty $Data 'Available')) {
        return New-UnavailableFinding -Category Startup -Check (Get-Text 'Check.Startup.StartupEntries') `
            -Hint (Get-Text 'Hint.Startup.WindowsDidNotEnumerateIts')
    }

    $entries = Get-DataCollection $Data 'Entries'

    New-Finding -Category Startup -Check (Get-Text 'Check.Startup.StartupEntries') `
        -Severity (Get-Severity $entries.Count $warnAbove $failAbove) -Value $entries.Count `
        -Hint (Get-Text 'Hint.Startup.ManyAutostartProgramsReviewIn')

    # How many there are, and which ended badly: the list is the Section. No verdict - a
    # task that failed last night may be a laptop that was not at the office.
    if (Get-DataProperty $Data 'TasksRead') {
        $tasks  = Get-DataCollection $Data 'Tasks'
        $failed = @($tasks | Where-Object { Test-StartupTaskFailed -Task $_ } | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })
        if ($failed.Count) {
            New-Finding -Category Startup -Check (Get-Text 'Check.Startup.ScheduledTasks') -Severity INFO `
                -Value ((Get-Text 'Value.Startup.TasksFailed') -f $tasks.Count, ($failed -join ', ')) `
                -Hint (Get-Text 'Hint.Startup.TasksFailed')
        }
        else {
            New-Finding -Category Startup -Check (Get-Text 'Check.Startup.ScheduledTasks') -Severity INFO `
                -Value ((Get-Text 'Value.Startup.Tasks') -f $tasks.Count)
        }
    }
}

function ConvertTo-StartupSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    New-Section -Title (Get-Text 'Title.Startup.StartupEntries') -Row @(
        (Get-DataCollection $Data 'Entries') | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Startup.Entry')]    = Get-DataProperty $_ 'Name'
            $row[(Get-Text 'Column.Startup.Command')]  = Get-DataProperty $_ 'Command'
            # A registry key or a startup folder, as Windows names it.
            $row[(Get-Text 'Column.Startup.Location')] = Get-DataProperty $_ 'Location'
            $row[(Get-Text 'Column.Startup.User')]     = Get-DataProperty $_ 'User'
            [pscustomobject]$row
        }
    )

    $tasks = Get-DataCollection $Data 'Tasks'
    if ($tasks.Count) {
        New-Section -Title (Get-Text 'Title.Startup.ScheduledTasks') -Row @($tasks | ForEach-Object { ConvertTo-StartupTaskRow -Task $_ })
    }
}