Every box has cron jobs nobody remembers writing. Some are broken, some never fire, and some run world-writable scripts as root. One stdlib-only Ruby script inventories all of them, computes real next-run times, and ranks the findings.
Step through the build below:
Cron is where automation goes to be forgotten. Jobs accumulate across /etc/crontab, /etc/cron.d/*, and per-user spools; some reference scripts deleted two migrations ago, some have schedules that literally never fire (0 4 31 2 * waits for February 31st), and some run world-writable shell scripts as root — which is a privilege escalation, not a maintenance job.
This tutorial builds a stdlib-only Ruby auditor that walks the whole cron surface and, for every job, validates the schedule, computes the actual next run time with a small pure-Ruby cron matcher, and risk-checks the command line: missing scripts, writable scripts, curl | sh pipes, and PATH-dependent relative commands.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_audit.rb — inventory, validate, and risk-check cron jobs.
#
# Cron is where automation goes to be forgotten. This script walks the
# system cron surface (/etc/crontab, /etc/cron.d/*, optionally user spool
# files), and for every job:
#
# * validates the schedule (5-field syntax, ranges, steps, @aliases)
# * computes the NEXT RUN time with a small pure-Ruby cron matcher
# * risk-checks the command line:
# - script referenced by the job is missing -> BROKEN
# - script is world/group-writable or not owned root -> RISK
# - `curl ... | sh` style pipe-to-shell -> RISK
# - relative path in command (PATH surprises) -> WARN
#
# Stdlib only. Text and --json output. Exit codes: 0 clean, 1 warnings,
# 2 broken/risky findings — drop it straight into cron itself or CI.
#
# Usage:
# ruby cron_audit.rb # /etc/crontab + /etc/cron.d
# ruby cron_audit.rb --file mycrontab --no-system --json
require 'json'
require 'optparse'
require 'time'
require 'etc'
options = { system: true, files: [], spool: nil, json: false, horizon_days: 8 }
OptionParser.new do |o|
o.banner = 'Usage: cron_audit.rb [options]'
o.on('--file FILE', 'Audit an extra crontab file (repeatable)') { |v| options[:files] << v }
o.on('--spool DIR', 'Also read user spool dir (e.g. /var/spool/cron/crontabs)') { |v| options[:spool] = v }
o.on('--no-system', 'Skip /etc/crontab and /etc/cron.d') { options[:system] = false }
o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!
# ---------------------------------------------------------------------------
# Schedule parsing. Each of the 5 fields expands to a Set of allowed values;
# validation and next-run matching then both fall out of the same structure.
# ---------------------------------------------------------------------------
FIELD_RANGES = [0..59, 0..23, 1..31, 1..12, 0..7].freeze # min hour dom mon dow
MONTH_NAMES = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
DAY_NAMES = %w[sun mon tue wed thu fri sat].freeze
ALIASES = {
'@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
# Expand one cron field ("*/15", "1-5", "mon,wed", "3") into a sorted array.
# Returns nil if the field is invalid — that's how validation reports errors.
def expand_field(field, idx)
range = FIELD_RANGES[idx]
values = []
field.downcase.split(',').each do |part|
step = 1
if part.include?('/')
part, step_s = part.split('/', 2)
step = step_s.to_i
return nil if step < 1
end
# translate month/day names into numbers where the field allows them
part = (MONTH_NAMES.index(part) + 1).to_s if idx == 3 && MONTH_NAMES.include?(part)
part = DAY_NAMES.index(part).to_s if idx == 4 && DAY_NAMES.include?(part)
lo, hi =
if part == '*'
[range.first, range.last]
elsif part =~ /\A(\d+)-(\d+)\z/
[Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
elsif part =~ /\A\d+\z/
[part.to_i, part.to_i]
else
return nil
end
return nil if lo < range.first || hi > range.last || lo > hi
lo.step(hi, step) { |v| values << v }
end
# cron treats dow 7 as sunday
values.map! { |v| idx == 4 && v == 7 ? 0 : v }
values.uniq.sort
end
def parse_schedule(sched)
sched = ALIASES.fetch(sched, sched)
return { reboot: true } if sched == '@reboot'
fields = sched.split
return nil unless fields.size == 5
expanded = fields.each_with_index.map { |f, i| expand_field(f, i) }
return nil if expanded.any?(&:nil?)
{ min: expanded[0], hour: expanded[1], dom: expanded[2], mon: expanded[3], dow: expanded[4] }
end
# Walk forward minute-by-minute until the schedule matches. Cron semantics:
# if BOTH dom and dow are restricted, a match on either one fires the job.
def next_run(sched, from = Time.now, horizon_days = 8)
return nil if sched[:reboot]
t = Time.new(from.year, from.month, from.day, from.hour, from.min) + 60
dom_restricted = sched[:dom].size < 31
dow_restricted = sched[:dow].size < 7
(horizon_days * 1440).times do
if sched[:min].include?(t.min) && sched[:hour].include?(t.hour) && sched[:mon].include?(t.month)
day_ok =
if dom_restricted && dow_restricted
sched[:dom].include?(t.day) || sched[:dow].include?(t.wday)
else
sched[:dom].include?(t.day) && sched[:dow].include?(t.wday)
end
return t if day_ok
end
t += 60
end
nil
end
# ---------------------------------------------------------------------------
# Command risk checks
# ---------------------------------------------------------------------------
def first_script(command)
# strip env assignments (FOO=bar cmd) and leading wrappers we can see through
tokens = command.strip.split(/\s+/)
tokens.shift while tokens.first =~ /\A\w+=/
tokens.shift if %w[nice ionice timeout flock].include?(tokens.first) # skip common wrappers + their flag args crudely
tok = tokens.find { |t| t.start_with?('/') }
tok
end
def check_command(command)
findings = []
findings << ['RISK', 'pipe-to-shell (curl|wget piped into a shell)'] if command =~ /\b(curl|wget)\b[^|;]*\|\s*(ba|z|da)?sh\b/
script = first_script(command)
if script.nil?
findings << ['WARN', 'no absolute path in command — relies on cron PATH (often just /usr/bin:/bin)']
elsif !File.exist?(script)
findings << ['BROKEN', "referenced file missing: #{script}"]
else
st = File.stat(script)
findings << ['RISK', "#{script} is world-writable"] if st.mode & 0o002 != 0
findings << ['RISK', "#{script} is group-writable"] if st.mode & 0o020 != 0
if st.uid != 0
owner = (Etc.getpwuid(st.uid).name rescue st.uid.to_s)
findings << ['WARN', "#{script} not owned by root (owner: #{owner}) — anyone with that account can change what cron runs"]
end
end
findings
end
# ---------------------------------------------------------------------------
# Crontab file parsing. system_format=true means 6th column is the user.
# ---------------------------------------------------------------------------
def parse_crontab(path, system_format:, default_user: nil)
jobs = []
return jobs unless File.readable?(path)
File.foreach(path).with_index(1) do |line, ln|
line = line.strip
next if line.empty? || line.start_with?('#') || line =~ /\A\w+=/ # skip env lines
if line.start_with?('@')
sched_s, rest = line.split(/\s+/, 2)
else
parts = line.split(/\s+/, 6)
next if parts.size < 6
sched_s = parts[0, 5].join(' ')
rest = parts[5]
end
if system_format && !sched_s.start_with?('@reboot')
user, command = rest.split(/\s+/, 2)
elsif system_format
user, command = rest.split(/\s+/, 2)
else
user = default_user
command = rest
end
next if command.nil? || command.empty?
jobs << { file: path, line: ln, user: user, schedule: sched_s, command: command }
end
jobs
end
jobs = []
if options[:system]
jobs.concat parse_crontab('/etc/crontab', system_format: true)
Dir.glob('/etc/cron.d/*').sort.each do |f|
next unless File.file?(f)
jobs.concat parse_crontab(f, system_format: true)
end
end
options[:files].each { |f| jobs.concat parse_crontab(f, system_format: false, default_user: Etc.getlogin) }
if options[:spool]
Dir.glob(File.join(options[:spool], '*')).sort.each do |f|
jobs.concat parse_crontab(f, system_format: false, default_user: File.basename(f))
end
end
now = Time.now
results = jobs.map do |job|
sched = parse_schedule(job[:schedule])
findings = []
nxt = nil
if sched.nil?
findings << ['BROKEN', "invalid schedule: '#{job[:schedule]}'"]
else
nxt = next_run(sched, now, options[:horizon_days])
findings << ['WARN', "schedule never fires in next #{options[:horizon_days]} days"] if nxt.nil? && !sched[:reboot]
end
findings.concat check_command(job[:command])
sev_rank = { 'BROKEN' => 2, 'RISK' => 2, 'WARN' => 1 }
worst = findings.map { |s, _| sev_rank[s] }.max || 0
job.merge(next_run: nxt&.strftime('%Y-%m-%d %H:%M'), findings: findings, worst: worst)
end
overall = results.map { |r| r[:worst] }.max || 0
if options[:json]
puts JSON.pretty_generate(
'generated_at' => now.iso8601,
'status' => %w[OK WARN CRIT][overall],
'jobs' => results.map do |r|
{ 'file' => r[:file], 'line' => r[:line], 'user' => r[:user],
'schedule' => r[:schedule], 'command' => r[:command],
'next_run' => r[:next_run],
'findings' => r[:findings].map { |s, m| { 'severity' => s, 'message' => m } } }
end
)
else
puts "cron audit — #{now.strftime('%Y-%m-%d %H:%M')} jobs: #{results.size} [#{%w[OK WARN CRIT][overall]}]"
results.each do |r|
mark = r[:worst] == 2 ? '!!' : r[:worst] == 1 ? ' !' : ' '
puts
puts "#{mark} #{r[:file]}:#{r[:line]} (user: #{r[:user]})"
puts " #{r[:schedule]} -> next run: #{r[:next_run] || 'n/a'}"
puts " #{r[:command]}"
r[:findings].each { |sev, msg| puts " [#{sev}] #{msg}" }
end
end
exit(overall == 2 ? 2 : overall == 1 ? 1 : 0)
Field expansion is the whole parser. Each of the five schedule fields expands into a sorted array of allowed values — */15 becomes [0,15,30,45], mon becomes 1, day-of-week 7 normalises to Sunday 0. Both validation (a field that won’t expand is invalid) and next-run matching fall out of that one structure.
The dom/dow OR rule. The classic vixie-cron gotcha: when both day-of-month and day-of-week are restricted, the job fires when either matches. The matcher implements exactly that, then walks forward minute-by-minute — at most ~11,500 iterations for an 8-day horizon, microseconds in practice, and impossible to get subtly wrong the way clever closed-form solutions are.
Risk checks read the filesystem. The first absolute path in each command gets File.stat‘d: missing → BROKEN, world/group-writable → RISK (anyone who can edit the script owns the cron user), non-root owner → WARN. A regex flags curl | sh pipes, and commands with no absolute path get a WARN for depending on cron’s minimal PATH.
Run it on itself. Pointed at this sandbox’s real /etc/crontab, it immediately flagged three jobs referencing a missing /usr/sbin/anacron — a genuine finding, not a fixture.
cron audit — 2026-08-24 14:50 jobs: 7 [CRIT]
! /tmp/appjobs.cron:3 (user: serene-optimistic-carson)
*/15 * * * * -> next run: 2026-08-24 15:00
/tmp/bin/rotate_logs.sh
[WARN] /tmp/bin/rotate_logs.sh not owned by root (owner: serene-optimistic-carson) — anyone with that account can change what cron runs
!! /tmp/appjobs.cron:4 (user: serene-optimistic-carson)
30 2 * * sun -> next run: 2026-08-30 02:30
/tmp/bin/full_backup.sh --target /backup
[RISK] /tmp/bin/full_backup.sh is world-writable
[RISK] /tmp/bin/full_backup.sh is group-writable
[WARN] /tmp/bin/full_backup.sh not owned by root (owner: serene-optimistic-carson) — anyone with that account can change what cron runs
!! /tmp/appjobs.cron:5 (user: serene-optimistic-carson)
0 4 * * * -> next run: 2026-08-25 04:00
/usr/local/bin/prune_uploads.sh
[BROKEN] referenced file missing: /usr/local/bin/prune_uploads.sh
! /tmp/appjobs.cron:6 (user: serene-optimistic-carson)
0 4 31 2 * -> next run: n/a
/tmp/bin/rotate_logs.sh
[WARN] schedule never fires in next 8 days
[WARN] /tmp/bin/rotate_logs.sh not owned by root (owner: serene-optimistic-carson) — anyone with that account can change what cron runs
! /tmp/appjobs.cron:7 (user: serene-optimistic-carson)
@daily -> next run: 2026-08-25 00:00
cleanup-temp
[WARN] no absolute path in command — relies on cron PATH (often just /usr/bin:/bin)
!! /tmp/appjobs.cron:8 (user: serene-optimistic-carson)
5 3 * * * -> next run: 2026-08-25 03:05
curl -fsSL https://example.com/install.sh | sh
[RISK] pipe-to-shell (curl|wget piped into a shell)
[WARN] no absolute path in command — relies on cron PATH (often just /usr/bin:/bin)
!! /tmp/appjobs.cron:9 (user: serene-optimistic-carson)
61 * * * * -> next run: n/a
/tmp/bin/rotate_logs.sh
[BROKEN] invalid schedule: '61 * * * *'
[WARN] /tmp/bin/rotate_logs.sh not owned by root (owner: serene-optimistic-carson) — anyone with that account can change what cron runs
Full script + README on GitHub: ruby-devops-toolkit/cron-audit
Prerequisites
- Ruby >= 2.7 — stdlib only:
json,optparse,time,etc. - Linux (or any cron-carrying Unix). The defaults audit
/etc/crontab+/etc/cron.d/*; add--spool /var/spool/cron/crontabsundersudofor user crontabs, or--filefor standalone files. - Root only for the spool — everything else works unprivileged.
From crontab surface to verdict
The full script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_audit.rb — inventory, validate, and risk-check cron jobs.
#
# Cron is where automation goes to be forgotten. This script walks the
# system cron surface (/etc/crontab, /etc/cron.d/*, optionally user spool
# files), and for every job:
#
# * validates the schedule (5-field syntax, ranges, steps, @aliases)
# * computes the NEXT RUN time with a small pure-Ruby cron matcher
# * risk-checks the command line:
# - script referenced by the job is missing -> BROKEN
# - script is world/group-writable or not owned root -> RISK
# - `curl ... | sh` style pipe-to-shell -> RISK
# - relative path in command (PATH surprises) -> WARN
#
# Stdlib only. Text and --json output. Exit codes: 0 clean, 1 warnings,
# 2 broken/risky findings — drop it straight into cron itself or CI.
#
# Usage:
# ruby cron_audit.rb # /etc/crontab + /etc/cron.d
# ruby cron_audit.rb --file mycrontab --no-system --json
require 'json'
require 'optparse'
require 'time'
require 'etc'
options = { system: true, files: [], spool: nil, json: false, horizon_days: 8 }
OptionParser.new do |o|
o.banner = 'Usage: cron_audit.rb [options]'
o.on('--file FILE', 'Audit an extra crontab file (repeatable)') { |v| options[:files] << v }
o.on('--spool DIR', 'Also read user spool dir (e.g. /var/spool/cron/crontabs)') { |v| options[:spool] = v }
o.on('--no-system', 'Skip /etc/crontab and /etc/cron.d') { options[:system] = false }
o.on('--json', 'Emit JSON instead of text') { options[:json] = true }
end.parse!
# ---------------------------------------------------------------------------
# Schedule parsing. Each of the 5 fields expands to a Set of allowed values;
# validation and next-run matching then both fall out of the same structure.
# ---------------------------------------------------------------------------
FIELD_RANGES = [0..59, 0..23, 1..31, 1..12, 0..7].freeze # min hour dom mon dow
MONTH_NAMES = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
DAY_NAMES = %w[sun mon tue wed thu fri sat].freeze
ALIASES = {
'@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
# Expand one cron field ("*/15", "1-5", "mon,wed", "3") into a sorted array.
# Returns nil if the field is invalid — that's how validation reports errors.
def expand_field(field, idx)
range = FIELD_RANGES[idx]
values = []
field.downcase.split(',').each do |part|
step = 1
if part.include?('/')
part, step_s = part.split('/', 2)
step = step_s.to_i
return nil if step < 1
end
# translate month/day names into numbers where the field allows them
part = (MONTH_NAMES.index(part) + 1).to_s if idx == 3 && MONTH_NAMES.include?(part)
part = DAY_NAMES.index(part).to_s if idx == 4 && DAY_NAMES.include?(part)
lo, hi =
if part == '*'
[range.first, range.last]
elsif part =~ /\A(\d+)-(\d+)\z/
[Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
elsif part =~ /\A\d+\z/
[part.to_i, part.to_i]
else
return nil
end
return nil if lo < range.first || hi > range.last || lo > hi
lo.step(hi, step) { |v| values << v }
end
# cron treats dow 7 as sunday
values.map! { |v| idx == 4 && v == 7 ? 0 : v }
values.uniq.sort
end
def parse_schedule(sched)
sched = ALIASES.fetch(sched, sched)
return { reboot: true } if sched == '@reboot'
fields = sched.split
return nil unless fields.size == 5
expanded = fields.each_with_index.map { |f, i| expand_field(f, i) }
return nil if expanded.any?(&:nil?)
{ min: expanded[0], hour: expanded[1], dom: expanded[2], mon: expanded[3], dow: expanded[4] }
end
# Walk forward minute-by-minute until the schedule matches. Cron semantics:
# if BOTH dom and dow are restricted, a match on either one fires the job.
def next_run(sched, from = Time.now, horizon_days = 8)
return nil if sched[:reboot]
t = Time.new(from.year, from.month, from.day, from.hour, from.min) + 60
dom_restricted = sched[:dom].size < 31
dow_restricted = sched[:dow].size < 7
(horizon_days * 1440).times do
if sched[:min].include?(t.min) && sched[:hour].include?(t.hour) && sched[:mon].include?(t.month)
day_ok =
if dom_restricted && dow_restricted
sched[:dom].include?(t.day) || sched[:dow].include?(t.wday)
else
sched[:dom].include?(t.day) && sched[:dow].include?(t.wday)
end
return t if day_ok
end
t += 60
end
nil
end
# ---------------------------------------------------------------------------
# Command risk checks
# ---------------------------------------------------------------------------
def first_script(command)
# strip env assignments (FOO=bar cmd) and leading wrappers we can see through
tokens = command.strip.split(/\s+/)
tokens.shift while tokens.first =~ /\A\w+=/
tokens.shift if %w[nice ionice timeout flock].include?(tokens.first) # skip common wrappers + their flag args crudely
tok = tokens.find { |t| t.start_with?('/') }
tok
end
def check_command(command)
findings = []
findings << ['RISK', 'pipe-to-shell (curl|wget piped into a shell)'] if command =~ /\b(curl|wget)\b[^|;]*\|\s*(ba|z|da)?sh\b/
script = first_script(command)
if script.nil?
findings << ['WARN', 'no absolute path in command — relies on cron PATH (often just /usr/bin:/bin)']
elsif !File.exist?(script)
findings << ['BROKEN', "referenced file missing: #{script}"]
else
st = File.stat(script)
findings << ['RISK', "#{script} is world-writable"] if st.mode & 0o002 != 0
findings << ['RISK', "#{script} is group-writable"] if st.mode & 0o020 != 0
if st.uid != 0
owner = (Etc.getpwuid(st.uid).name rescue st.uid.to_s)
findings << ['WARN', "#{script} not owned by root (owner: #{owner}) — anyone with that account can change what cron runs"]
end
end
findings
end
# ---------------------------------------------------------------------------
# Crontab file parsing. system_format=true means 6th column is the user.
# ---------------------------------------------------------------------------
def parse_crontab(path, system_format:, default_user: nil)
jobs = []
return jobs unless File.readable?(path)
File.foreach(path).with_index(1) do |line, ln|
line = line.strip
next if line.empty? || line.start_with?('#') || line =~ /\A\w+=/ # skip env lines
if line.start_with?('@')
sched_s, rest = line.split(/\s+/, 2)
else
parts = line.split(/\s+/, 6)
next if parts.size < 6
sched_s = parts[0, 5].join(' ')
rest = parts[5]
end
if system_format && !sched_s.start_with?('@reboot')
user, command = rest.split(/\s+/, 2)
elsif system_format
user, command = rest.split(/\s+/, 2)
else
user = default_user
command = rest
end
next if command.nil? || command.empty?
jobs << { file: path, line: ln, user: user, schedule: sched_s, command: command }
end
jobs
end
jobs = []
if options[:system]
jobs.concat parse_crontab('/etc/crontab', system_format: true)
Dir.glob('/etc/cron.d/*').sort.each do |f|
next unless File.file?(f)
jobs.concat parse_crontab(f, system_format: true)
end
end
options[:files].each { |f| jobs.concat parse_crontab(f, system_format: false, default_user: Etc.getlogin) }
if options[:spool]
Dir.glob(File.join(options[:spool], '*')).sort.each do |f|
jobs.concat parse_crontab(f, system_format: false, default_user: File.basename(f))
end
end
now = Time.now
results = jobs.map do |job|
sched = parse_schedule(job[:schedule])
findings = []
nxt = nil
if sched.nil?
findings << ['BROKEN', "invalid schedule: '#{job[:schedule]}'"]
else
nxt = next_run(sched, now, options[:horizon_days])
findings << ['WARN', "schedule never fires in next #{options[:horizon_days]} days"] if nxt.nil? && !sched[:reboot]
end
findings.concat check_command(job[:command])
sev_rank = { 'BROKEN' => 2, 'RISK' => 2, 'WARN' => 1 }
worst = findings.map { |s, _| sev_rank[s] }.max || 0
job.merge(next_run: nxt&.strftime('%Y-%m-%d %H:%M'), findings: findings, worst: worst)
end
overall = results.map { |r| r[:worst] }.max || 0
if options[:json]
puts JSON.pretty_generate(
'generated_at' => now.iso8601,
'status' => %w[OK WARN CRIT][overall],
'jobs' => results.map do |r|
{ 'file' => r[:file], 'line' => r[:line], 'user' => r[:user],
'schedule' => r[:schedule], 'command' => r[:command],
'next_run' => r[:next_run],
'findings' => r[:findings].map { |s, m| { 'severity' => s, 'message' => m } } }
end
)
else
puts "cron audit — #{now.strftime('%Y-%m-%d %H:%M')} jobs: #{results.size} [#{%w[OK WARN CRIT][overall]}]"
results.each do |r|
mark = r[:worst] == 2 ? '!!' : r[:worst] == 1 ? ' !' : ' '
puts
puts "#{mark} #{r[:file]}:#{r[:line]} (user: #{r[:user]})"
puts " #{r[:schedule]} -> next run: #{r[:next_run] || 'n/a'}"
puts " #{r[:command]}"
r[:findings].each { |sev, msg| puts " [#{sev}] #{msg}" }
end
end
exit(overall == 2 ? 2 : overall == 1 ? 1 : 0)
Step by step
1. Expanding schedule fields into sets
expand_field handles the entire cron grammar for one field: lists (1,15), ranges (1-5), steps (*/15, 1-30/5), month/day names, and @daily-style aliases resolved before parsing. It returns nil on anything out of range — 61 * * * * is caught here — which is how validation and parsing stay one code path.
2. Next-run matching, the boring way
From now, round to the next minute and walk forward, testing minute, hour, month, and the day rule against the expanded sets. The day rule implements vixie-cron’s OR semantics when both dom and dow are restricted. A job that doesn’t fire within the horizon gets flagged — that’s how 0 4 31 2 * (February 31st) is caught without any special-casing.
3. Two crontab dialects
System files (/etc/crontab, /etc/cron.d/*) carry a sixth user column; user spool files don’t. parse_crontab handles both, skips comments and NAME=value environment lines, and records file + line number for every job so findings are jump-to-able.
4. Command risk checks
first_script skips env-var prefixes and common wrappers (nice, timeout, flock) and takes the first absolute path. Then File.stat: mode bit 0o002 set means anyone on the box can rewrite what root runs tonight — that’s RISK, same class as the curl | sh regex hit. Missing file is BROKEN: the job fails silently every night.
Against a real system crontab
Troubleshooting
jobs: 0— nothing readable at the defaults; your distro may live entirely in systemd timers, or the spool needssudo.- Next run off by an hour — the matcher uses host local time, exactly like crond; check the box’s timezone and recent DST jumps.
- WARN on
cd / && ...compounds — the first-absolute-path heuristic can latch onto/. Findings are advisory: read the line before filing the ticket. - anacron/systemd timers invisible — deliberately out of scope here; see the extension list.
Where to take it
- Parse
systemctl list-timers --allfor the systemd half of the scheduled-job surface. - Diff consecutive
--jsonruns and alert on new cron entries — cron persistence is an attacker favourite. - Flag jobs that discard output (
>/dev/null 2>&1) with no MAILTO and no logger in the pipeline. - Ship
--jsonfleet-wide into your SIEM and trend RISK findings per host.