Public/Get-ScheduledTaskAudit.ps1

#!/usr/bin/env pwsh
Set-StrictMode -Version Latest

if ($IsWindows) {
    function Get-ScheduledTaskAudit {
        <#
        .SYNOPSIS
            Gets scheduled task configuration and last-run details.

        .DESCRIPTION
            Returns one report describing task states, last results, identities,
            privilege levels, actions, and reported next-run times. Results contains the
            individual records; Errors contains audit retrieval failures only.

            NeverRun is identified by 0x00041303, not by an old timestamp. A missing
            next-run time does not prove a task has no trigger. NonZeroExitCode requires
            application-specific interpretation. ReviewTasks selects enabled tasks with
            failures, nonzero exit codes, termination, or unavailable run information;
            it is not a risk verdict. State, outcome, identity, and scheduling counts
            overlap.

            Missing fields are Unknown; absent next-run times are Not reported.
            AuditError is None on successful retrieval.

            Runs without elevation, with a warning: tasks that run as SYSTEM are omitted.
            Windows only.

        .PARAMETER TaskPath
            The scheduled task path filter. Wildcards are accepted. Defaults to \*.

        .INPUTS
            None. You can't pipe objects to this function.

        .OUTPUTS
            System.Management.Automation.PSCustomObject. One report with task counts,
            ByState, ByLastResult, ByRunAs, ByRunLevel, ByActionKind, ReviewTasks,
            Results, and Errors. TotalTasks counts discovered tasks. AuditErrorTasks
            counts retrieval failures, independently of last-run outcomes. A filter that
            matches no tasks returns zero counts and empty arrays. Enumeration errors
            stop the audit.

        .EXAMPLE
            $report = Get-ScheduledTaskAudit
            $report.ReviewTasks | Format-Table TaskName, LastResultKind, LastResultHex

            Audits every task and lists the enabled tasks that need review.

        .EXAMPLE
            (Get-ScheduledTaskAudit -TaskPath '\Microsoft\Windows\Defrag\').Results

            Returns the records for the tasks in one folder.

        .EXAMPLE
            (Get-ScheduledTaskAudit).ByRunAs

            Counts tasks by the identity they run as.
        #>

        [CmdletBinding()]
        [OutputType([PSCustomObject])]
        param(
            [Parameter(HelpMessage = 'Scheduled task path filter. Defaults to \*.')]
            [ValidateNotNullOrEmpty()]
            [string]$TaskPath = '\*'
        )

        if (-not (Test-Administrator)) {
            Write-Warning 'Get-ScheduledTaskAudit is not elevated. Enumeration is incomplete and omits SYSTEM context tasks.'
        }

        $tasks = @(Get-ScheduledTask -TaskPath $TaskPath -ErrorAction Stop)
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
        $errors = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($task in $tasks) {
            $auditError = 'None'
            # A failed lookup outputs nothing, so $info is empty and $hasInfo is false.
            $info = try {
                $task | Get-ScheduledTaskInfo -ErrorAction Stop
            }
            catch {
                $auditError = $_.Exception.Message
                $errors.Add([PSCustomObject]@{
                        Path    = "$($task.TaskPath)$($task.TaskName)"
                        Message = $auditError
                    })
            }
            $hasInfo = [bool]$info
            if (-not $hasInfo -and $auditError -eq 'None') {
                $auditError = 'No task run information returned.'
                $errors.Add([PSCustomObject]@{
                        Path    = "$($task.TaskPath)$($task.TaskName)"
                        Message = $auditError
                    })
            }

            $isGroupPrincipal = [string]::IsNullOrWhiteSpace($task.Principal.UserId)
            $principal = if ($isGroupPrincipal) {
                $task.Principal.GroupId
            }
            else {
                $task.Principal.UserId
            }

            # LastTaskResult can be 0, so test its type rather than its truth.
            $hasResult = $hasInfo -and $info.LastTaskResult -is [System.ValueType]
            $resultKind = 'Unknown'
            $resultHex = 'Unknown'
            if ($hasResult) {
                $resultCode = [uint32]($info.LastTaskResult -band 0xFFFFFFFFL)
                $resultHex = '0x{0:X8}' -f $resultCode
                $resultKind = switch ($resultCode) {
                    0 { 'Success'; break }
                    0x00041300 { 'Ready'; break }
                    0x00041301 { 'Running'; break }
                    0x00041302 { 'Disabled'; break }
                    0x00041303 { 'NeverRun'; break }
                    0x00041304 { 'NoMoreRuns'; break }
                    0x00041305 { 'NotScheduled'; break }
                    0x00041306 { 'Terminated'; break }
                    0x00041307 { 'NoValidTriggers'; break }
                    0x00041308 { 'EventTrigger'; break }
                    0x0004131B { 'SomeTriggersFailed'; break }
                    0x0004131C { 'BatchLogonProblem'; break }
                    0x00041325 { 'Queued'; break }
                    default {
                        if ($resultCode -band 0x80000000L) {
                            'Failure'
                        }
                        else {
                            'NonZeroExitCode'
                        }
                    }
                }
            }

            $runs = [System.Collections.Generic.List[string]]::new()
            foreach ($action in @($task.Actions)) {
                switch ($action.CimClass.CimClassName) {
                    'MSFT_TaskExecAction' {
                        $runs.Add(($action.Execute, $action.Arguments -join ' ').Trim())
                    }
                    'MSFT_TaskComHandlerAction' {
                        $runs.Add("COM:$($action.ClassId)")
                    }
                    default {
                        $runs.Add("OTHER:$($action.CimClass.CimClassName)")
                    }
                }
            }

            $record = [PSCustomObject]@{
                TaskName       = $task.TaskName
                TaskPath       = $task.TaskPath
                State          = [string]$task.State
                Author         = $task.Author
                RunAs          = $principal
                PrincipalKind  = if ($isGroupPrincipal) { 'Group' } else { 'User' }
                LogonType      = [string]$task.Principal.LogonType
                RunLevel       = [string]$task.Principal.RunLevel
                ActionKind     = (@($task.Actions | ForEach-Object { $_.CimClass.CimClassName -replace '^MSFT_Task|Action$' }) -join ',')
                Runs           = ($runs -join ' | ')
                LastRunTime    = if ($hasInfo) { $info.LastRunTime } else { 'Unknown' }
                LastTaskResult = if ($hasInfo) { $info.LastTaskResult } else { 'Unknown' }
                LastResultHex  = $resultHex
                LastResultKind = $resultKind
                NextRunTime    = if ($hasInfo) { $info.NextRunTime } else { 'Unknown' }
                AuditError     = $auditError
            }
            if ([string]::IsNullOrWhiteSpace([string]$record.NextRunTime) -or $record.NextRunTime -eq 'Unknown') {
                $record.NextRunTime = 'Not reported'
            }
            foreach ($property in $record.PSObject.Properties) {
                if ([string]::IsNullOrWhiteSpace([string]$property.Value)) {
                    $property.Value = 'Unknown'
                }
            }
            $results.Add($record)
        }

        $rows = $results.ToArray()
        $enabled = @($rows | Where-Object -Property State -In 'Ready', 'Running', 'Queued')
        $reviewKinds = @(
            'Failure', 'NonZeroExitCode', 'Terminated', 'Unknown',
            'NoValidTriggers', 'NotScheduled', 'SomeTriggersFailed', 'BatchLogonProblem'
        )

        # Breakdowns count tasks, not action instances; mixed action kinds stay combined.
        $breakdowns = @{}
        foreach ($property in 'State', 'LastResultKind', 'RunAs', 'RunLevel', 'ActionKind') {
            $breakdowns[$property] = @(
                $rows | Group-Object -Property $property | Sort-Object -Property Count -Descending | ForEach-Object {
                    [PSCustomObject]@{ Name = $_.Name; Count = $_.Count }
                }
            )
        }

        [PSCustomObject]@{
            TotalTasks              = $tasks.Count
            EnabledTasks            = $enabled.Count
            DisabledTasks           = @($rows | Where-Object -Property State -EQ 'Disabled').Count
            RunningTasks            = @($rows | Where-Object -Property State -EQ 'Running').Count
            NeverRunTasks           = @($rows | Where-Object -Property LastResultKind -EQ 'NeverRun').Count
            LastRunSuccessTasks     = @($rows | Where-Object -Property LastResultKind -EQ 'Success').Count
            LastRunFailureTasks     = @($rows | Where-Object -Property LastResultKind -EQ 'Failure').Count
            NonZeroExitCodeTasks    = @($rows | Where-Object -Property LastResultKind -EQ 'NonZeroExitCode').Count
            HighestPrivilegeTasks   = @($rows | Where-Object -Property RunLevel -EQ 'Highest').Count
            SystemTasks             = @($rows | Where-Object -Property RunAs -In 'SYSTEM', 'NT AUTHORITY\SYSTEM', 'S-1-5-18').Count
            GroupPrincipalTasks     = @($rows | Where-Object -Property PrincipalKind -EQ 'Group').Count
            WithNextRunTimeTasks    = @($rows | Where-Object -Property NextRunTime -NE 'Not reported').Count
            WithoutNextRunTimeTasks = @($rows | Where-Object -Property NextRunTime -EQ 'Not reported').Count
            AuditErrorTasks         = $errors.Count
            ByState                 = $breakdowns['State']
            ByLastResult            = $breakdowns['LastResultKind']
            ByRunAs                 = $breakdowns['RunAs']
            ByRunLevel              = $breakdowns['RunLevel']
            ByActionKind            = $breakdowns['ActionKind']
            ReviewTasks             = @($enabled | Where-Object { $_.LastResultKind -in $reviewKinds -or $_.AuditError -ne 'None' })
            Results                 = $rows
            Errors                  = $errors.ToArray()
        }
    }
}