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.ps1powershell
# 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 $credentialUpdate 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.
Press Windows key → type Task Scheduler → open it
Click "Create Basic Task" → name it
Scheduled Task AuditTrigger: Weekly → Monday → 6:00am
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\ScheduledTaskAudit.ps1"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 $triggerIt should appear in the NEW TASKS section of the next email. Clean up after:
powershell
Unregister-ScheduledTask -TaskName "AO_Test_Task" -Confirm:$falseIf remote servers come back UNREACHABLE, enable PowerShell remoting on each target:
powershell
Enable-PSRemoting -ForceWHY 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.

