In partnership with

THE PROBLEM

Task Scheduler is the most useful tool on a Windows server and the most overlooked place to hide.

Every environment accumulates tasks nobody remembers creating. A vendor installer added one during setup. A contractor scheduled a nightly export that's been failing silently for eight months. Somebody's PowerShell script runs as SYSTEM every hour and nobody knows what it does anymore.

And attackers know this. Scheduled tasks are one of the most common persistence mechanisms in Windows — create a task that runs a payload every 30 minutes, and it survives reboots, credential changes, and most cleanup efforts. It sits in a list nobody ever reads.

Ironically, if you've been following this newsletter, you've been adding scheduled tasks for weeks. This week we build the inventory that tracks them all.

THE SCRIPT

Save the script below to:

C:\Scripts\ScheduledTaskAudit.ps1

powershell

# Scheduled Task Inventory & Audit
# Automate & Operate — automateandoperate.com

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\ScheduledTaskAudit.log"
$reportFile = "C:\Logs\ScheduledTaskReport.csv"
$baselineFile = "C:\Logs\ScheduledTaskBaseline.csv"

# Servers to audit
$servers = @(
    "localhost",
    "SERVER01",
    "SERVER02"
)

# Task paths to ignore — Microsoft's built-in tasks are noisy
$ignorePaths = @(
    "\Microsoft\Windows\"
)

# High-privilege accounts worth flagging
$privilegedAccounts = @(
    "SYSTEM",
    "NT AUTHORITY\SYSTEM",
    "LOCAL SERVICE",
    "NETWORK SERVICE"
)

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs"
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$report = @()
$flagged = @()

foreach ($server in $servers) {
    try {
        $tasks = Invoke-Command -ComputerName $server -ScriptBlock {
            Get-ScheduledTask | ForEach-Object {
                $task = $_
                $info = $task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue

                # Pull the actual command being run
                $actions = ($task.Actions | ForEach-Object {
                    if ($_.Execute) { "$($_.Execute) $($_.Arguments)".Trim() }
                }) -join " | "

                [PSCustomObject]@{
                    TaskName    = $task.TaskName
                    TaskPath    = $task.TaskPath
                    State       = $task.State
                    RunAsUser   = $task.Principal.UserId
                    RunLevel    = $task.Principal.RunLevel
                    Action      = $actions
                    Author      = $task.Author
                    LastRunTime = $info.LastRunTime
                    LastResult  = $info.LastTaskResult
                    NextRunTime = $info.NextRunTime
                }
            }
        } -ErrorAction Stop

        foreach ($task in $tasks) {

            # Skip Microsoft built-ins
            $skip = $false
            foreach ($ignore in $ignorePaths) {
                if ($task.TaskPath -like "$ignore*") { $skip = $true; break }
            }
            if ($skip) { continue }

            # Work out why this task might need attention
            $flags = @()

            if ($privilegedAccounts -contains $task.RunAsUser) {
                $flags += "RUNS AS PRIVILEGED"
            }

            if ($task.RunLevel -eq "Highest") {
                $flags += "HIGHEST PRIVILEGE"
            }

            # Non-zero result that isn't "still running" or "not yet run"
            if ($null -ne $task.LastResult -and $task.LastResult -ne 0 -and $task.LastResult -ne 267011) {
                $flags += "LAST RUN FAILED (code $($task.LastResult))"
            }

            # Hasn't run in 90 days but is still enabled
            if ($task.State -eq "Ready" -and $task.LastRunTime -and
                $task.LastRunTime -lt (Get-Date).AddDays(-90)) {
                $flags += "STALE (no run in 90+ days)"
            }

            # Command runs from a user-writable location
            if ($task.Action -match "\\Users\\|\\Temp\\|\\AppData\\|\\ProgramData\\") {
                $flags += "RUNS FROM USER-WRITABLE PATH"
            }

            $status = if ($flags.Count -gt 0) { $flags -join "; " } else { "OK" }

            $entry = [PSCustomObject]@{
                Server      = $server
                TaskName    = $task.TaskName
                TaskPath    = $task.TaskPath
                State       = $task.State
                RunAsUser   = $task.RunAsUser
                RunLevel    = $task.RunLevel
                Action      = $task.Action
                Author      = $task.Author
                LastRunTime = if ($task.LastRunTime) { $task.LastRunTime.ToString("yyyy-MM-dd HH:mm") } else { "NEVER" }
                LastResult  = $task.LastResult
                Flags       = $status
            }

            $report += $entry
            if ($flags.Count -gt 0) { $flagged += $entry }

            Add-Content -Path $logFile -Value "$timestamp$server$($task.TaskPath)$($task.TaskName)$($task.RunAsUser)$status"
        }

    } catch {
        $report += [PSCustomObject]@{
            Server      = $server
            TaskName    = "UNREACHABLE"
            TaskPath    = "N/A"
            State       = "N/A"
            RunAsUser   = "N/A"
            RunLevel    = "N/A"
            Action      = "N/A"
            Author      = "N/A"
            LastRunTime = "N/A"
            LastResult  = "N/A"
            Flags       = "ERROR"
        }
        Add-Content -Path $logFile -Value "$timestamp$server — ERROR: $_"
    }
}

# ---------- COMPARE AGAINST BASELINE ----------
$newTasks = @()

if (Test-Path $baselineFile) {
    $baseline = Import-Csv -Path $baselineFile
    $baselineKeys = $baseline | ForEach-Object { "$($_.Server)|$($_.TaskPath)$($_.TaskName)" }

    foreach ($entry in $report) {
        $key = "$($entry.Server)|$($entry.TaskPath)$($entry.TaskName)"
        if ($baselineKeys -notcontains $key) {
            $newTasks += $entry
        }
    }
} else {
    Add-Content -Path $logFile -Value "$timestamp — No baseline found. Creating one now."
}

# Export current state
$report | Export-Csv -Path $reportFile -NoTypeInformation
$report | Export-Csv -Path $baselineFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$emailBody = "Scheduled Task Audit — $timestamp`n"
$emailBody += "Servers audited: $($servers.Count)`n"
$emailBody += "Non-Microsoft tasks found: $($report.Count)`n"
$emailBody += "Tasks flagged: $($flagged.Count)`n"
$emailBody += "New since last run: $($newTasks.Count)`n`n"

if ($newTasks.Count -gt 0) {
    $emailBody += "NEW TASKS SINCE LAST AUDIT — REVIEW THESE FIRST:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($entry in $newTasks) {
        $emailBody += "Server:  $($entry.Server)`n"
        $emailBody += "Task:    $($entry.TaskPath)$($entry.TaskName)`n"
        $emailBody += "Runs as: $($entry.RunAsUser) ($($entry.RunLevel))`n"
        $emailBody += "Author:  $($entry.Author)`n"
        $emailBody += "Command: $($entry.Action)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($flagged.Count -gt 0) {
    $emailBody += "FLAGGED TASKS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($entry in $flagged) {
        $emailBody += "Server:  $($entry.Server)`n"
        $emailBody += "Task:    $($entry.TaskPath)$($entry.TaskName)`n"
        $emailBody += "Runs as: $($entry.RunAsUser)`n"
        $emailBody += "Command: $($entry.Action)`n"
        $emailBody += "Flags:   $($entry.Flags)`n"
        $emailBody += "-" * 40 + "`n"
    }
}

if ($newTasks.Count -eq 0 -and $flagged.Count -eq 0) {
    $emailBody += "No new or flagged tasks. Environment unchanged since last audit.`n"
}

$emailBody += "`nFull report saved to: $reportFile`n"
$emailBody += "`nTo inspect a task in detail:`n"
$emailBody += "Get-ScheduledTask -TaskName 'NAME' | Select-Object *`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($newTasks.Count -gt 0) {
    "TASK ALERT: $($newTasks.Count) new scheduled task(s) detected"
} elseif ($flagged.Count -gt 0) {
    "Scheduled Task Audit — $($flagged.Count) task(s) flagged for review"
} else {
    "Scheduled Task Audit — No changes detected"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $servers — your actual server names

THE BASELINE IS THE POINT

Most audit scripts dump a list and you skim it once. This one keeps a baseline file and tells you what's new since last week.

That's the difference between an inventory and a detection. A list of 200 scheduled tasks is noise. "One task appeared on SERVER02 since Monday, runs as SYSTEM, executes from AppData" is a finding.

The first run creates the baseline and reports nothing as new — that's expected. From the second run onward, the new-tasks section is the one you actually read.

If you deliberately add a task, it'll show up as new once and then fold into the baseline. That's fine. Ten seconds to recognise your own work is a fair price for catching the one you didn't create.

WHAT THE FLAGS MEAN

RUNS AS PRIVILEGED — task executes as SYSTEM. Legitimate for many things, but SYSTEM is what attackers want. Worth knowing which tasks have it.

LAST RUN FAILED — non-zero exit code on the last run. This one catches real breakage. Backup jobs and export scripts fail silently for months because nobody checks.

STALE — enabled but hasn't run in 90 days. Usually a leftover from a decommissioned process. Disable it or delete it.

RUNS FROM USER-WRITABLE PATH — the command lives somewhere a non-admin could modify. This is the one to look at hardest. A task running as SYSTEM that executes a script from C:\ProgramData\ means anyone who can write to that folder gets SYSTEM. That's a privilege escalation path, and it's a common misconfiguration in vendor software.

SETTING UP TASK SCHEDULER

Yes, a scheduled task that audits scheduled tasks. It'll appear in its own report — that's the correct behaviour.

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it Scheduled Task Audit

  3. Trigger: Weekly → Monday → 6:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\ScheduledTaskAudit.ps1"
  1. Click Next → Finish

Under Properties → General, check "Run with highest privileges" and use an account with remote admin rights on the target servers.

TEST IT

Open PowerShell as administrator and run:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\ScheduledTaskAudit.ps1"

Open C:\Logs\ScheduledTaskReport.csv. Sort by the Flags column and read everything that isn't "OK."

To test new-task detection, run the script once to build the baseline, then create a throwaway task and run it again:

powershell

$action = New-ScheduledTaskAction -Execute "notepad.exe"
$trigger = New-ScheduledTaskTrigger -Daily -At 3am
Register-ScheduledTask -TaskName "AO_Test_Task" -Action $action -Trigger $trigger

It should appear in the NEW TASKS section of the next email. Clean up after:

powershell

Unregister-ScheduledTask -TaskName "AO_Test_Task" -Confirm:$false

If remote servers come back UNREACHABLE, enable PowerShell remoting on each target:

powershell

Enable-PSRemoting -Force

WHY THIS MATTERS

Scheduled tasks sit in a blind spot. They're not services, so service monitoring misses them. They're not processes most of the time, so they don't show in a process list. They're not in your config management if they were created by hand. And the built-in Microsoft tasks are numerous enough that a malicious task hides comfortably among them.

The security case is real, but the operational payoff usually lands first: within a week or two of running this, most people find at least one scheduled job that's been failing quietly for months. Somebody assumed a report was being generated. It wasn't.

THIS WEEK'S ACTION

Run it against localhost today to build your baseline. Read the flagged section carefully — particularly anything running from a user-writable path. Then add your servers and let the weekly diff do the work from there.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

Write docs 4x faster. Without hating every second.

Nobody became a developer to write documentation. But the docs still need to get written — PRDs, README updates, architecture decisions, onboarding guides.

Wispr Flow lets you talk through it instead. Speak naturally about what the code does, how it works, and why you built it that way. Flow formats everything into clean, professional text you can paste into Notion, Confluence, or GitHub.

Used by engineering teams at OpenAI, Vercel, and Clay. 89% of messages sent with zero edits. Works system-wide on Mac, Windows, and iPhone.