DevOps / System Administration · Windows Server · Ruby 3.x · Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

The Problem

Windows Task Scheduler quietly accumulates cruft. Someone sets up a nightly job running as SYSTEM, leaves the org, and two years later nobody remembers what it does or whether it still works. A cert-renewal script starts failing silently because Task Scheduler doesn’t page anyone — it just marks the last run result and moves on. A “temporary” diagnostic task from a incident six months ago is still enabled, still running daily, still running as an administrator account.

The GUI (taskschd.msc) makes auditing this by hand painful: you click into each task individually to see its last result, its run-as account, its schedule. schtasks.exe — built into every Windows install — can dump everything as CSV in one call, but the raw output is not something you want to read row by row on a 200-task server. This tutorial builds win_task_audit.rb: a script that pulls the full task list, flags the three things that actually matter (tasks failing on their last run, tasks left disabled, and tasks running with SYSTEM-level privilege), and can also create or remove tasks so the same tool covers both auditing and provisioning.

Workflow diagram: schtasks.exe query feeds a Ruby Open3 wrapper, parsed by CSV.parse into TaskRecord structs, analyzed into failing, disabled, and high-privilege buckets, alongside a create/delete path

Prerequisites

  • Ruby 3.x for Windows (via RubyInstaller) — tested against the standard library shipped with Ruby 3.0–3.3
  • Windows 10, Windows 11, or Windows Server (2016+) — schtasks.exe is a stock component, nothing to install
  • No Ruby gems required. Just csv, open3, optparse, and json from the standard library
  • Run from an elevated (Administrator) prompt to see tasks owned by other users and by SYSTEM — a non-elevated session only sees tasks the current user owns
This script deliberately does not use the win32-taskscheduler gem, which wraps the native Task Scheduler COM API. That gem gives you finer control (triggers, conditions, XML task definitions) if you need it, but schtasks.exe covers the 90% case — query, create, delete — with zero native extensions to compile and zero platform-specific gem installation headaches on a fresh box.

The Complete Code

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_task_audit.rb -- Audit, create, and remove Windows Scheduled Tasks
# from Ruby by wrapping the built-in schtasks.exe command-line tool.
#
# No third-party gems required. Must be run on Windows (schtasks.exe is
# part of every Windows install), ideally from an elevated prompt so it
# can see tasks owned by SYSTEM and other users.
#
# Usage:
#   ruby win_task_audit.rb audit
#   ruby win_task_audit.rb audit --csv tasks_snapshot.csv   # re-analyze a saved snapshot
#   ruby win_task_audit.rb create --name "NightlyBackup" --run "C:\Scripts\backup.rb" ^
#        --schedule DAILY --start-time 02:00 --run-as SYSTEM
#   ruby win_task_audit.rb delete --name "NightlyBackup"
#
require 'csv'
require 'open3'
require 'optparse'
require 'json'

TaskRecord = Struct.new(:host, :name, :status, :last_run_time, :last_result,
                         :run_as_user, :schedule_type, :start_time, keyword_init: true) do
  # schtasks reports success as decimal 0 (sometimes literal "0", sometimes "0x0").
  def last_run_succeeded?
    last_result.to_s.strip =~ /\A0(x0+)?\z/i ? true : false
  end

  def disabled?
    status.to_s.strip.casecmp('disabled').zero?
  end

  # Windows Task Scheduler treats these as unusually privileged run-as accounts.
  def high_privilege?
    %w[SYSTEM LOCAL SERVICE NETWORK SERVICE].any? { |acct| run_as_user.to_s.upcase.include?(acct) }
  end
end

# ---------------------------------------------------------------------------
# Talks to schtasks.exe. Kept tiny and separate from parsing/analysis so
# the rest of the script can be unit-tested on any OS by feeding it
# pre-captured CSV text instead of shelling out for real.
# ---------------------------------------------------------------------------
module SchTasks
  module_function

  def query_csv
    stdout, stderr, status = Open3.capture3('schtasks', '/query', '/fo', 'CSV', '/v')
    raise "schtasks /query failed: #{stderr}" unless status.success?

    stdout
  end

  def create(name:, run:, schedule:, start_time:, run_as: nil, force: true)
    args = ['schtasks', '/create', '/tn', name, '/tr', run, '/sc', schedule, '/st', start_time]
    args += ['/ru', run_as] if run_as
    args << '/f' if force
    stdout, stderr, status = Open3.capture3(*args)
    raise "schtasks /create failed: #{stderr}" unless status.success?

    stdout
  end

  def delete(name:, force: true)
    args = ['schtasks', '/delete', '/tn', name]
    args << '/f' if force
    stdout, stderr, status = Open3.capture3(*args)
    raise "schtasks /delete failed: #{stderr}" unless status.success?

    stdout
  end
end

# ---------------------------------------------------------------------------
# Parses the verbose CSV that `schtasks /query /fo CSV /v` prints (one row
# per task, first row of a fresh dump is a header). Ruby's CSV handles the
# quoting Microsoft uses for embedded commas without any extra work.
# ---------------------------------------------------------------------------
def parse_tasks(csv_text)
  rows = CSV.parse(csv_text, headers: true)
  rows.reject { |r| r['TaskName'].nil? || r['TaskName'] == 'TaskName' }.map do |r|
    TaskRecord.new(
      host: r['HostName'],
      name: r['TaskName'],
      status: r['Status'],
      last_run_time: r['Last Run Time'],
      last_result: r['Last Result'],
      run_as_user: r['Run As User'],
      schedule_type: r['Schedule Type'],
      start_time: r['Start Time']
    )
  end
end

# ---------------------------------------------------------------------------
# Flags the things a DevOps engineer actually cares about during an audit:
# scheduled tasks that are silently failing, disabled tasks nobody cleaned
# up, and tasks running with SYSTEM-level privilege that deserve a second
# look before someone treats them as "just another task".
# ---------------------------------------------------------------------------
def analyze(tasks)
  {
    failing: tasks.reject { |t| t.last_run_succeeded? || t.last_run_time.to_s.strip == 'N/A' },
    disabled: tasks.select(&:disabled?),
    high_privilege: tasks.select(&:high_privilege?)
  }
end

def print_report(tasks)
  findings = analyze(tasks)

  puts "Scanned #{tasks.size} scheduled task(s) on #{tasks.first&.host || 'this host'}"
  puts

  puts "== Failing on last run (#{findings[:failing].size}) =="
  if findings[:failing].empty?
    puts '  none'
  else
    findings[:failing].each { |t| puts "  #{t.name}  (last_result=#{t.last_result}, last_run=#{t.last_run_time})" }
  end
  puts

  puts "== Disabled tasks (#{findings[:disabled].size}) =="
  if findings[:disabled].empty?
    puts '  none'
  else
    findings[:disabled].each { |t| puts "  #{t.name}" }
  end
  puts

  puts "== Running as SYSTEM / high-privilege account (#{findings[:high_privilege].size}) =="
  if findings[:high_privilege].empty?
    puts '  none'
  else
    findings[:high_privilege].each { |t| puts "  #{t.name}  (run_as=#{t.run_as_user})" }
  end
end

if $PROGRAM_NAME == __FILE__
  command = ARGV.shift

  case command
  when 'audit'
    options = {}
    OptionParser.new do |opts|
      opts.on('--csv PATH', 'Analyze a previously saved schtasks CSV export instead of querying live') { |v| options[:csv] = v }
      opts.on('--json', 'Emit JSON instead of a text report') { options[:json] = true }
    end.parse!(ARGV)

    csv_text = options[:csv] ? File.read(options[:csv]) : SchTasks.query_csv
    tasks = parse_tasks(csv_text)

    if options[:json]
      puts JSON.pretty_generate(analyze(tasks).transform_values { |list| list.map(&:to_h) })
    else
      print_report(tasks)
    end

  when 'create'
    options = { force: true }
    OptionParser.new do |opts|
      opts.on('--name NAME') { |v| options[:name] = v }
      opts.on('--run CMD') { |v| options[:run] = v }
      opts.on('--schedule TYPE', 'ONCE, MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY') { |v| options[:schedule] = v }
      opts.on('--start-time HH:MM') { |v| options[:start_time] = v }
      opts.on('--run-as ACCOUNT') { |v| options[:run_as] = v }
    end.parse!(ARGV)

    puts SchTasks.create(name: options[:name], run: options[:run], schedule: options[:schedule],
                          start_time: options[:start_time], run_as: options[:run_as])

  when 'delete'
    options = {}
    OptionParser.new { |opts| opts.on('--name NAME') { |v| options[:name] = v } }.parse!(ARGV)
    puts SchTasks.delete(name: options[:name])

  else
    warn 'Usage: win_task_audit.rb [audit|create|delete] [options]'
    exit 1
  end
end

Step-by-Step Walkthrough

1. Why the CSV wrapper is a separate module

SchTasks.query_csv does exactly one thing: run schtasks /query /fo CSV /v and return the raw text, raising if the command fails. Everything else — parsing, analysis, reporting — is plain Ruby that doesn’t touch the OS at all. That separation is what lets audit --csv tasks_snapshot.csv exist: you can capture a snapshot on a server you’re not allowed to run arbitrary Ruby on (schtasks /query /fo CSV /v > snapshot.csv from an admin, no Ruby needed), then analyze it anywhere, including on Linux or macOS during development. It’s also exactly how this script’s logic was verified before publishing — by feeding it a realistic captured CSV sample rather than requiring a live Windows box for every test run.

2. Parsing the CSV

schtasks /fo CSV /v outputs one header row followed by one row per task, with fields double-quoted and commas inside values (task descriptions, command lines) properly escaped. Ruby’s built-in CSV.parse(text, headers: true) handles that quoting for free — no regex-splitting required — and gives you rows you can index by column name, which keeps the script readable even though the real output has 28 columns and we only care about eight of them.

3. Interpreting Last Result

This is the field that trips people up. Task Scheduler stores the exit code of the last run, but schtasks can print it as a plain decimal (0 for success) or, for certain failure codes, as a large decimal representing a signed 32-bit HRESULT (2147942402 is 0x80070522 — “a required privilege is not held by the client,” a classic sign a task’s run-as account got downgraded). last_run_succeeded? checks for exactly 0 and treats everything else as a failure worth flagging — including those large HRESULT-style codes.

4. The three audit buckets

analyze returns three lists, each answering one operational question:

  • failing — tasks whose last run did not return success, excluding tasks that have literally never run (Last Run Time of N/A, typical for on-demand-only tasks) so you’re not chasing false positives
  • disabled — tasks sitting in Disabled state; often forgotten rather than intentionally retired
  • high_privilege — anything running as SYSTEM, LOCAL SERVICE, or NETWORK SERVICE, worth a second look during any security review since a compromised script here has the run of the box

5. Creating and deleting tasks

SchTasks.create wraps schtasks /create with the flags you use in practice: /tn (task name), /tr (the command to run), /sc (schedule type — DAILY, WEEKLY, HOURLY, etc.), /st (start time), and optionally /ru to set the run-as account. /f forces overwrite of an existing task with the same name, matching how you’d want deploy scripts to behave (idempotent re-application, not “fail because it already exists”). delete is the same pattern in reverse.

Example Output

Verified against a realistic captured schtasks /query /fo CSV /v sample (five tasks, including one with a non-zero Last Result, one disabled, and three running under SYSTEM-level accounts) using audit --csv sample_tasks.csv:

Terminal output showing win_task_audit.rb audit results: 1 failing task, 1 disabled task, and 3 tasks running as SYSTEM

Troubleshooting

SymptomLikely cause / fix
Audit only shows a handful of tasks you know existYou’re not running from an elevated prompt. Non-admin sessions only see tasks owned by the current user; re-run as Administrator.
schtasks /query failed: with an “Access is denied” messageSame as above, or the account lacks the SeBatchLogonRight/scheduler-related privilege on that host. Confirm with the same command run directly in an elevated cmd.exe.
A task shows as failing but you know it ran fine manuallySome legitimate operations (a script that calls exit(1) on “nothing to do”) return non-zero by design. Treat the audit as a starting point for investigation, not an automatic incident.
CSV.parse raises a malformed-CSV errorLocale settings can change the CSV delimiter schtasks emits on some systems. Capture a sample with schtasks /query /fo CSV /v > out.csv and inspect the raw file before assuming the Ruby code is at fault.
create succeeds but the task doesn’t appear to runCheck the account passed to --run-as has “Log on as a batch job” rights, and that the --start-time format matches your system locale (HH:MM, 24-hour, is safest).

Extending This Script

  • Stale task detection: parse Next Run Time and flag tasks with no future run scheduled but still enabled — often a sign the trigger configuration silently broke.
  • Config-driven provisioning: read a YAML list of desired tasks and reconcile against the live audit — create missing ones, flag drift on existing ones — the same declarative pattern configuration management tools use.
  • Multi-host rollup: combine this with the SSH fleet automation script in this series (adapted for WinRM or PsExec) to audit scheduled tasks across an entire Windows fleet in one pass.
  • Export to a ticketing system: feed the --json output into your ticketing or CMDB API to auto-file a ticket for every task found running as SYSTEM without documented ownership.
  • Event Log correlation: cross-reference failing tasks against the Task Scheduler operational log (Event ID 201/202) via wevtutil for the actual error detail, not just the exit code.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese