the shed // linux // cron

Nobody remembers what is in /etc/cron.d. This script parses every crontab line in pure Ruby, tells you when each job will next run, and flags jobs that run every minute, lack absolute paths, or call world-writable scripts as root.

Step through the build below:

cron_job_auditor.rb

Cron jobs accumulate: a migration script from three years ago, a heartbeat that fires every minute, a root job that executes a script in /tmp. Nobody reads crontabs until something breaks.

This tool parses crontab syntax itself (ranges, steps, lists, month/day names and @daily-style macros), computes the next time each job will fire, and applies a handful of audit rules that catch real operational and security problems.

Writing the schedule matcher yourself is a nice Ruby exercise and avoids depending on a gem.

# cron_job_auditor.rb – complete script

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_job_auditor.rb - Parse crontab files, compute next run times and flag risky jobs.
# Pure Ruby stdlib. Linux/macOS.
#
# Usage:
#   cron_job_auditor.rb [--now "2026-10-05 16:00"] [--json] FILE...
# FILE is a crontab. Files under /etc/cron.d and /etc/crontab have a user column;
# per-user crontabs (crontab -l > file) do not - pass --user-column=false for those.
require 'time'
require 'json'
require 'optparse'
MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
DAYS   = %w[sun mon tue wed thu fri sat].freeze
FIELDS = [
  { name: :minute, range: 0..59 },
  { name: :hour,   range: 0..23 },
  { name: :dom,    range: 1..31 },
  { name: :month,  range: 1..12, names: MONTHS, offset: 1 },
  { name: :dow,    range: 0..7,  names: DAYS,   offset: 0 }
].freeze
MACROS = {
  '@hourly' => '0 * * * *', '@daily' => '0 0 * * *', '@midnight' => '0 0 * * *',
  '@weekly' => '0 0 * * 0', '@monthly' => '0 0 1 * *', '@yearly' => '0 0 1 1 *',
  '@annually' => '0 0 1 1 *'
}.freeze
class CronParseError < StandardError; end
# Expand one cron field ("*/15", "1-5", "mon,wed", "0-30/10") into a sorted Array of Integers.
def expand_field(text, spec)
  values = []
  text.split(',').each do |part|
    base, step = part.split('/', 2)
    step = step ? Integer(step, 10) : 1
    raise CronParseError, "step must be > 0 in '#{part}'" if step < 1
    lo, hi =
      if base == '*'
        [spec[:range].min, spec[:range].max]
      elsif base.include?('-')
        base.split('-', 2).map { |x| to_num(x, spec) }
      else
        n = to_num(base, spec)
        step > 1 ? [n, spec[:range].max] : [n, n]
      end
    raise CronParseError, "#{spec[:name]} value out of range in '#{part}'" unless spec[:range].cover?(lo) && spec[:range].cover?(hi) && lo <= hi
    values.concat((lo..hi).step(step).to_a)
  end
  values = values.map { |v| v == 7 ? 0 : v } if spec[:name] == :dow # 7 == Sunday
  values.uniq.sort
end
def to_num(token, spec)
  if spec[:names] && (i = spec[:names].index(token.downcase))
    return i + spec[:offset]
  end
  Integer(token, 10)
rescue ArgumentError
  raise CronParseError, "bad value '#{token}' for #{spec[:name]}"
end
Job = Struct.new(:file, :line_no, :schedule, :user, :command, :sets, :dom_star, :dow_star, :problem, keyword_init: true)
def parse_line(file, line_no, raw, user_column)
  line = raw.strip
  return nil if line.empty? || line.start_with?('#') || line =~ /\A[A-Za-z_][A-Za-z0-9_]*\s*=/ # comments / env vars
  tokens = line.split(/\s+/)
  sched_tokens =
    if MACROS.key?(tokens[0])
      tokens.shift
      MACROS[line.split(/\s+/)[0]].split
    else
      tokens.shift(5)
    end
  raise CronParseError, 'fewer than 5 schedule fields' if sched_tokens.size < 5
  user = user_column ? tokens.shift : nil
  command = tokens.join(' ')
  raise CronParseError, 'no command' if command.empty?
  sets = FIELDS.each_with_index.map { |spec, i| expand_field(sched_tokens[i], spec) }
  Job.new(file: file, line_no: line_no, schedule: sched_tokens.join(' '), user: user, command: command,
          sets: sets, dom_star: sched_tokens[2].start_with?('*'), dow_star: sched_tokens[4].start_with?('*'))
rescue CronParseError, ArgumentError => e
  Job.new(file: file, line_no: line_no, schedule: raw.strip, command: '', problem: e.message)
end
# Does time t match the job? Vixie cron rule: if BOTH dom and dow are restricted, either may match.
def matches?(job, t)
  m, h, dom, mon, dow = job.sets
  return false unless m.include?(t.min) && h.include?(t.hour) && mon.include?(t.month)
  dom_ok = dom.include?(t.day)
  dow_ok = dow.include?(t.wday)
  if !job.dom_star && !job.dow_star
    dom_ok || dow_ok
  else
    dom_ok && dow_ok
  end
end
# Walk forward minute by minute (max ~5 years) until the schedule matches.
def next_run(job, from)
  t = Time.at((from.to_i / 60 + 1) * 60) # next whole minute
  limit = from + 5 * 366 * 86_400
  while t < limit
    return t if matches?(job, t)
    t += 60
  end
  nil
end
def runs_per_day(job)
  job.sets[0].size * job.sets[1].size
end
# Returns an Array of [severity, message].
def audit(job)
  return [[:error, "unparseable: #{job.problem}"]] if job.problem
  findings = []
  findings << [:warn, "runs #{runs_per_day(job)}x/day (every minute-level schedule is rarely intentional)"] if runs_per_day(job) >= 288
  unless job.command =~ />\s*\S|\|\s*\S|logger|MAILTO/
    findings << [:info, 'no output redirection: output is mailed to the user (or silently lost)']
  end
  exe = job.command.split(/\s+/).first.to_s
  if !exe.start_with?('/') && exe !~ /\A(cd|test|\[|\.)\z/ && !exe.include?('=')
    findings << [:warn, "relative command '#{exe}': cron has a minimal PATH, use an absolute path"]
  end
  if exe.start_with?('/') && File.file?(exe)
    mode = File.stat(exe).mode
    findings << [:error, "#{exe} is world-writable: any local user can hijack this job"] if mode & 0o002 != 0
  elsif exe.start_with?('/') && !exe.include?('$')
    findings << [:warn, "#{exe} does not exist on this host"]
  end
  findings << [:info, 'uses unescaped % (cron turns % into a newline)'] if job.command =~ /(?<!\\)%/ && job.command !~ /date\s+\+/ 
  findings << [:warn, 'runs as root from a writable-looking path (/tmp, /home)'] if job.user == 'root' && exe =~ %r{\A/(tmp|home|var/tmp)/}
  findings
end
if $PROGRAM_NAME == __FILE__
  opts = { user_column: true, json: false, now: Time.now }
  OptionParser.new do |op|
    op.on('--now T', 'pretend it is T (for repeatable output)') { |v| opts[:now] = Time.parse(v) }
    op.on('--[no-]user-column', 'files have a user column (default yes)') { |v| opts[:user_column] = v }
    op.on('--json') { opts[:json] = true }
  end.parse!(ARGV)
  abort 'Give at least one crontab file' if ARGV.empty?
  report = []
  ARGV.each do |file|
    File.readlines(file).each_with_index do |raw, i|
      job = parse_line(file, i + 1, raw, opts[:user_column])
      next unless job
      nxt = job.problem ? nil : next_run(job, opts[:now])
      report << { job: job, next: nxt, findings: audit(job) }
    end
  end
  if opts[:json]
    puts JSON.pretty_generate(report.map { |r|
      { file: r[:job].file, line: r[:job].line_no, schedule: r[:job].schedule, user: r[:job].user,
        command: r[:job].command, next_run: r[:next]&.iso8601,
        findings: r[:findings].map { |s, m| { severity: s, message: m } } }
    })
  else
    report.each do |r|
      j = r[:job]
      puts "#{File.basename(j.file)}:#{j.line_no}  [#{j.schedule}]  #{j.user}  #{j.command}"
      puts "    next run: #{r[:next] ? r[:next].strftime('%a %Y-%m-%d %H:%M') : 'never'}" unless j.problem
      r[:findings].each { |sev, msg| puts "    #{sev.to_s.upcase.ljust(5)} #{msg}" }
    end
    c = report.flat_map { |r| r[:findings].map(&:first) }.tally
    puts "\nSummary: #{report.size} jobs, #{c[:error].to_i} errors, #{c[:warn].to_i} warnings, #{c[:info].to_i} notes"
  end
  exit(report.any? { |r| r[:findings].any? { |s, _| s == :error } } ? 2 : 0)
end

**expand_field turns text into numbers** Each of the five fields is split on commas; each part may have a /step and be *, a range, a single value or a name like mon. The result is a sorted array of integers, with 7 normalised to 0 for Sunday. Out-of-range values raise a parse error.

**parse_line handles the extras** Comments, blank lines and VAR=value lines are skipped. @daily and friends are expanded to their five-field equivalents. System crontabs have a user column that is peeled off before the command.

**next_run walks forward minute by minute** Starting at the next whole minute it tests matches? until a match or a five-year limit. Brute force is fast enough (a few hundred thousand iterations worst case) and impossible to get subtly wrong.

**The day-of-month / day-of-week quirk** Vixie cron runs a job when *either* field matches if both are restricted, but requires both when one is *. matches? implements that rule, which is why 30 3 1 * mon fires every Monday and on the 1st.

**Audit rules** The audit function flags schedules at 288+ runs/day, missing output redirection, relative executables (cron’s PATH is minimal), executables that do not exist, world-writable executables, unescaped %, and root jobs run from /tmp or /home. The exit code is 2 if any ERROR is found.

$ ruby cron_job_auditor.rb –now "2026-10-05 16:23" sample.crontab

sample.crontab:3  [*/15 * * * *]  root  /usr/local/bin/sync-metrics.sh >> /var/log/sync.log 2>&1
    next run: Mon 2026-10-05 16:30
    WARN  /usr/local/bin/sync-metrics.sh does not exist on this host
sample.crontab:4  [0 2 * * 1-5]  root  /usr/bin/rsync -a /srv/ /backup/srv/
    next run: Tue 2026-10-06 02:00
    INFO  no output redirection: output is mailed to the user (or silently lost)
    WARN  /usr/bin/rsync does not exist on this host
sample.crontab:5  [* * * * *]  root  /opt/scripts/heartbeat
    next run: Mon 2026-10-05 16:24
    WARN  runs 1440x/day (every minute-level schedule is rarely intentional)
    INFO  no output redirection: output is mailed to the user (or silently lost)
    WARN  /opt/scripts/heartbeat does not exist on this host
sample.crontab:6  [30 3 1 * mon]  deploy  cleanup.sh --all
    next run: Mon 2026-10-12 03:30
    INFO  no output redirection: output is mailed to the user (or silently lost)
    WARN  relative command 'cleanup.sh': cron has a minimal PATH, use an absolute path
sample.crontab:7  [0 0 * * *]  root  /tmp/rotate.sh > /dev/null
    next run: Tue 2026-10-06 00:00
    ERROR /tmp/rotate.sh is world-writable: any local user can hijack this job
    WARN  runs as root from a writable-looking path (/tmp, /home)
sample.crontab:8  [61 * * * * root /bin/true]    
    ERROR unparseable: minute value out of range in '61'
sample.crontab:9  [0 4 * * *]  root  echo "report $(date +%F)" | logger
    next run: Tue 2026-10-06 04:00
    WARN  relative command 'echo': cron has a minimal PATH, use an absolute path
Summary: 7 jobs, 2 errors, 7 warnings, 3 notes
Get the code

Full script + README on GitHub: ruby-devops-toolkit/cron-job-auditor

Flow diagram of the cron job auditor

Crontab text in, findings out.
Background

The problem

Cron jobs accumulate: a migration script from three years ago, a heartbeat that fires every minute, a root job that executes a script in /tmp. Nobody reads crontabs until something breaks.

This tool parses crontab syntax itself (ranges, steps, lists, month/day names and @daily-style macros), computes the next time each job will fire, and applies a handful of audit rules that catch real operational and security problems.

Writing the schedule matcher yourself is a nice Ruby exercise and avoids depending on a gem.

Setup

Prerequisites

  • Ruby 3.0+ (tested on 3.3.6); standard library only
  • Linux or macOS crontab files (/etc/crontab, /etc/cron.d/*, or crontab -l > file)
  • Pass --no-user-column for per-user crontabs, which have no user field
Full script

The complete code

cron_job_auditor.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_job_auditor.rb - Parse crontab files, compute next run times and flag risky jobs.
# Pure Ruby stdlib. Linux/macOS.
#
# Usage:
#   cron_job_auditor.rb [--now "2026-10-05 16:00"] [--json] FILE...
# FILE is a crontab. Files under /etc/cron.d and /etc/crontab have a user column;
# per-user crontabs (crontab -l > file) do not - pass --user-column=false for those.
require 'time'
require 'json'
require 'optparse'
MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
DAYS   = %w[sun mon tue wed thu fri sat].freeze
FIELDS = [
  { name: :minute, range: 0..59 },
  { name: :hour,   range: 0..23 },
  { name: :dom,    range: 1..31 },
  { name: :month,  range: 1..12, names: MONTHS, offset: 1 },
  { name: :dow,    range: 0..7,  names: DAYS,   offset: 0 }
].freeze
MACROS = {
  '@hourly' => '0 * * * *', '@daily' => '0 0 * * *', '@midnight' => '0 0 * * *',
  '@weekly' => '0 0 * * 0', '@monthly' => '0 0 1 * *', '@yearly' => '0 0 1 1 *',
  '@annually' => '0 0 1 1 *'
}.freeze
class CronParseError < StandardError; end
# Expand one cron field ("*/15", "1-5", "mon,wed", "0-30/10") into a sorted Array of Integers.
def expand_field(text, spec)
  values = []
  text.split(',').each do |part|
    base, step = part.split('/', 2)
    step = step ? Integer(step, 10) : 1
    raise CronParseError, "step must be > 0 in '#{part}'" if step < 1
    lo, hi =
      if base == '*'
        [spec[:range].min, spec[:range].max]
      elsif base.include?('-')
        base.split('-', 2).map { |x| to_num(x, spec) }
      else
        n = to_num(base, spec)
        step > 1 ? [n, spec[:range].max] : [n, n]
      end
    raise CronParseError, "#{spec[:name]} value out of range in '#{part}'" unless spec[:range].cover?(lo) && spec[:range].cover?(hi) && lo <= hi
    values.concat((lo..hi).step(step).to_a)
  end
  values = values.map { |v| v == 7 ? 0 : v } if spec[:name] == :dow # 7 == Sunday
  values.uniq.sort
end
def to_num(token, spec)
  if spec[:names] && (i = spec[:names].index(token.downcase))
    return i + spec[:offset]
  end
  Integer(token, 10)
rescue ArgumentError
  raise CronParseError, "bad value '#{token}' for #{spec[:name]}"
end
Job = Struct.new(:file, :line_no, :schedule, :user, :command, :sets, :dom_star, :dow_star, :problem, keyword_init: true)
def parse_line(file, line_no, raw, user_column)
  line = raw.strip
  return nil if line.empty? || line.start_with?('#') || line =~ /\A[A-Za-z_][A-Za-z0-9_]*\s*=/ # comments / env vars
  tokens = line.split(/\s+/)
  sched_tokens =
    if MACROS.key?(tokens[0])
      tokens.shift
      MACROS[line.split(/\s+/)[0]].split
    else
      tokens.shift(5)
    end
  raise CronParseError, 'fewer than 5 schedule fields' if sched_tokens.size < 5
  user = user_column ? tokens.shift : nil
  command = tokens.join(' ')
  raise CronParseError, 'no command' if command.empty?
  sets = FIELDS.each_with_index.map { |spec, i| expand_field(sched_tokens[i], spec) }
  Job.new(file: file, line_no: line_no, schedule: sched_tokens.join(' '), user: user, command: command,
          sets: sets, dom_star: sched_tokens[2].start_with?('*'), dow_star: sched_tokens[4].start_with?('*'))
rescue CronParseError, ArgumentError => e
  Job.new(file: file, line_no: line_no, schedule: raw.strip, command: '', problem: e.message)
end
# Does time t match the job? Vixie cron rule: if BOTH dom and dow are restricted, either may match.
def matches?(job, t)
  m, h, dom, mon, dow = job.sets
  return false unless m.include?(t.min) && h.include?(t.hour) && mon.include?(t.month)
  dom_ok = dom.include?(t.day)
  dow_ok = dow.include?(t.wday)
  if !job.dom_star && !job.dow_star
    dom_ok || dow_ok
  else
    dom_ok && dow_ok
  end
end
# Walk forward minute by minute (max ~5 years) until the schedule matches.
def next_run(job, from)
  t = Time.at((from.to_i / 60 + 1) * 60) # next whole minute
  limit = from + 5 * 366 * 86_400
  while t < limit
    return t if matches?(job, t)
    t += 60
  end
  nil
end
def runs_per_day(job)
  job.sets[0].size * job.sets[1].size
end
# Returns an Array of [severity, message].
def audit(job)
  return [[:error, "unparseable: #{job.problem}"]] if job.problem
  findings = []
  findings << [:warn, "runs #{runs_per_day(job)}x/day (every minute-level schedule is rarely intentional)"] if runs_per_day(job) >= 288
  unless job.command =~ />\s*\S|\|\s*\S|logger|MAILTO/
    findings << [:info, 'no output redirection: output is mailed to the user (or silently lost)']
  end
  exe = job.command.split(/\s+/).first.to_s
  if !exe.start_with?('/') && exe !~ /\A(cd|test|\[|\.)\z/ && !exe.include?('=')
    findings << [:warn, "relative command '#{exe}': cron has a minimal PATH, use an absolute path"]
  end
  if exe.start_with?('/') && File.file?(exe)
    mode = File.stat(exe).mode
    findings << [:error, "#{exe} is world-writable: any local user can hijack this job"] if mode & 0o002 != 0
  elsif exe.start_with?('/') && !exe.include?('$')
    findings << [:warn, "#{exe} does not exist on this host"]
  end
  findings << [:info, 'uses unescaped % (cron turns % into a newline)'] if job.command =~ /(?<!\\)%/ && job.command !~ /date\s+\+/ 
  findings << [:warn, 'runs as root from a writable-looking path (/tmp, /home)'] if job.user == 'root' && exe =~ %r{\A/(tmp|home|var/tmp)/}
  findings
end
if $PROGRAM_NAME == __FILE__
  opts = { user_column: true, json: false, now: Time.now }
  OptionParser.new do |op|
    op.on('--now T', 'pretend it is T (for repeatable output)') { |v| opts[:now] = Time.parse(v) }
    op.on('--[no-]user-column', 'files have a user column (default yes)') { |v| opts[:user_column] = v }
    op.on('--json') { opts[:json] = true }
  end.parse!(ARGV)
  abort 'Give at least one crontab file' if ARGV.empty?
  report = []
  ARGV.each do |file|
    File.readlines(file).each_with_index do |raw, i|
      job = parse_line(file, i + 1, raw, opts[:user_column])
      next unless job
      nxt = job.problem ? nil : next_run(job, opts[:now])
      report << { job: job, next: nxt, findings: audit(job) }
    end
  end
  if opts[:json]
    puts JSON.pretty_generate(report.map { |r|
      { file: r[:job].file, line: r[:job].line_no, schedule: r[:job].schedule, user: r[:job].user,
        command: r[:job].command, next_run: r[:next]&.iso8601,
        findings: r[:findings].map { |s, m| { severity: s, message: m } } }
    })
  else
    report.each do |r|
      j = r[:job]
      puts "#{File.basename(j.file)}:#{j.line_no}  [#{j.schedule}]  #{j.user}  #{j.command}"
      puts "    next run: #{r[:next] ? r[:next].strftime('%a %Y-%m-%d %H:%M') : 'never'}" unless j.problem
      r[:findings].each { |sev, msg| puts "    #{sev.to_s.upcase.ljust(5)} #{msg}" }
    end
    c = report.flat_map { |r| r[:findings].map(&:first) }.tally
    puts "\nSummary: #{report.size} jobs, #{c[:error].to_i} errors, #{c[:warn].to_i} warnings, #{c[:info].to_i} notes"
  end
  exit(report.any? { |r| r[:findings].any? { |s, _| s == :error } } ? 2 : 0)
end
Step by step

How it works

expand_field turns text into numbers

Each of the five fields is split on commas; each part may have a /step and be *, a range, a single value or a name like mon. The result is a sorted array of integers, with 7 normalised to 0 for Sunday. Out-of-range values raise a parse error.

parse_line handles the extras

Comments, blank lines and VAR=value lines are skipped. @daily and friends are expanded to their five-field equivalents. System crontabs have a user column that is peeled off before the command.

next_run walks forward minute by minute

Starting at the next whole minute it tests matches? until a match or a five-year limit. Brute force is fast enough (a few hundred thousand iterations worst case) and impossible to get subtly wrong.

The day-of-month / day-of-week quirk

Vixie cron runs a job when *either* field matches if both are restricted, but requires both when one is *. matches? implements that rule, which is why 30 3 1 * mon fires every Monday and on the 1st.

Audit rules

The audit function flags schedules at 288+ runs/day, missing output redirection, relative executables (cron’s PATH is minimal), executables that do not exist, world-writable executables, unescaped %, and root jobs run from /tmp or /home. The exit code is 2 if any ERROR is found.

Run it

Example output

$ ruby cron_job_auditor.rb –now "2026-10-05 16:23" sample.crontab
sample.crontab:3 [*/15 * * * *] root /usr/local/bin/sync-metrics.sh >> /var/log/sync.log 2>&1
next run: Mon 2026-10-05 16:30
WARN /usr/local/bin/sync-metrics.sh does not exist on this host
sample.crontab:4 [0 2 * * 1-5] root /usr/bin/rsync -a /srv/ /backup/srv/
next run: Tue 2026-10-06 02:00
INFO no output redirection: output is mailed to the user (or silently lost)
WARN /usr/bin/rsync does not exist on this host
sample.crontab:5 [* * * * *] root /opt/scripts/heartbeat
next run: Mon 2026-10-05 16:24
WARN runs 1440x/day (every minute-level schedule is rarely intentional)
INFO no output redirection: output is mailed to the user (or silently lost)
WARN /opt/scripts/heartbeat does not exist on this host
sample.crontab:6 [30 3 1 * mon] deploy cleanup.sh –all
next run: Mon 2026-10-12 03:30
INFO no output redirection: output is mailed to the user (or silently lost)
WARN relative command 'cleanup.sh': cron has a minimal PATH, use an absolute path
sample.crontab:7 [0 0 * * *] root /tmp/rotate.sh > /dev/null
next run: Tue 2026-10-06 00:00
ERROR /tmp/rotate.sh is world-writable: any local user can hijack this job
WARN runs as root from a writable-looking path (/tmp, /home)
sample.crontab:8 [61 * * * * root /bin/true]
ERROR unparseable: minute value out of range in '61'
sample.crontab:9 [0 4 * * *] root echo "report $(date +%F)" | logger
next run: Tue 2026-10-06 04:00
WARN relative command 'echo': cron has a minimal PATH, use an absolute path
Summary: 7 jobs, 2 errors, 7 warnings, 3 notes
When it breaks

Troubleshooting

  • Every job says does not exist on this host: you are auditing another machine’s crontab; that check only makes sense on the host that runs the job.
  • Per-user crontab lines misparse: you forgot --no-user-column.
  • Next run differs from reality: cron uses the system timezone; --now is parsed in the local zone of the machine running the script.
  • Special macros like @reboot have no schedule and are reported as unparseable.
Next steps

Extending the script

  • Read /var/spool/cron/crontabs/* for every user and merge into one report.
  • Compare the next-run list against a maintenance window to find conflicts.
  • Add a rule for jobs that lack flock and may overlap themselves.
  • Export JSON (--json) into your CMDB or a Prometheus textfile collector.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating