I manage about a dozen Linux boxes for client work, and somewhere around box five I stopped trusting my memory on which cron jobs are live and which systemd timer is the real one. A manual grep across /etc/crontab, /etc/cron.d*, spool directories, and systemd timer units turns into a half-hour chore with pages of grep output. So I wrote a bash script that walks the whole lot and prints a single hygiene report.
More bash audit scripts: TLS cert expiry checker | sshd_config audit | Docker disk reclamation
What It Checks
- Orphaned crontabs – user in spool dir does not exist in passwd
- Missing commands – scheduled binary absent from disk
- Invalid user fields – missing 6th column in cron.d
- Silent failure risk – MAILTO=”” no output redirect
- World-writable cron files – anyone can edit
- Duplicate scheduling – same binary in cron and timer
- Timer not enabled – no [Install] section
- Relative-path @reboot – commands that break at boot
Test Data
The script accepts a –root directory for testing against a synthetic filesystem. I built one with planted issues: orphaned crontab, missing user column, deleted binary, world-writable file, and same script double-scheduled. IPs use RFC 5737 TEST-NET ranges. Synthetic demo data is explicitly disclosed.
Running the Audit
Run the script with –root pointing to the synthetic tree.
cd /tmp/sandbox
bash cron-hygiene-audit.sh --root /tmp/sandbox/rootThe Audit Report
Three FAILs and four WARNs catch every planted issue.
╔═══════════════════════════════════════════════╗
║ CRON & SYSTEMD TIMER HYGIENE AUDIT REPORT ║
╚═══════════════════════════════════════════════╝
=== FAILURES ===
FAIL|var/spool/cron/crontabs/olduser|orphaned crontab - user 'olduser' not in passwd
FAIL|etc/crontab|command '/usr/local/bin/prune-kernels.sh' not found on disk
FAIL|etc/cron.d/metrics|missing/invalid user field (got '/usr/local/bin/collect-metrics.sh')
=== WARNINGS ===
WARN|etc/cron.d/backup|MAILTO="" no redirect
WARN|etc/systemd/system/apt-notify.timer|no [Install] section - not enabled
WARN|etc/cron.d/webstats|world-writable (perms 646)
WARN|duplicate|command '/usr/local/bin/collect-metrics.sh' in both cron + systemd timer
SUMMARY - FAIL: 3 WARN: 4 PASS (entries scanned): 13

The Full Script
Below is the complete script. Keywords like cd, test, and read are skipped. Only absolute-path regular files are checked for existence.
#!/usr/bin/env bash
set -u -o pipefail
ROOT="${2:-}"
[[ "${1:-}" == "--root" && -n "$ROOT" ]] || { echo "Usage: $0 --root DIR"; exit 1; }
ROOT="${ROOT%/}"
F_FAIL=0; F_WARN=0; ENTRIES=0
declare -a FA=() WA=()
CRO="/tmp/.cah_$$"
mkdir -p "$CRO"; : > "$CRO/cron.txt"; : > "$CRO/timer.txt"
valid_user() { local u="$1" a
[[ -f "$ROOT/etc/passwd" ]] || return 1
while IFS=: read -r a _; do [[ "$a" == "$u" ]] && return 0; done < "$ROOT/etc/passwd"; return 1; }
# first executable token in a command string (skips shell keywords)
first_exec() {
local w
for w in $1; do
case "$w" in
cd|test|if|then|else|fi|while|do|done|for|case|esac|function|local|export|read|echo|printf|exit|return|shift|source|\.|time|declare|typeset|select|until|eval|exec|builtin|command|type|ulimit|umask|unalias|wait|bg|fg|jobs|kill|trap) continue ;;
esac
[[ "$w" == /* ]] && { echo "$w"; return 0; }
# bare word, not a keyword - return it as-is for existence check
echo "$w"
return 0
done
return 1
}
has_redir() { case " $1 " in *">"*|*"2>"*|*"&>"*|*"|"*) return 0;; esac; return 1; }
scan_cron() {
local file="$1" mode="$2" mto="" mset=0 base owner u cmd first at norm mline
local key val _a _b _c _d _e _f _g
base="${file#$ROOT/}"
[[ $mode == spool ]] && owner=$(basename "$(dirname "$file")")
while IFS= read -r mline || [[ -n "$mline" ]]; do
mline="${mline%$'\r'}"
[[ -z "${mline// /}" || "${mline:0:1}" == "#" ]] && continue
if [[ "$mline" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
key="${mline%%=*}" ; val="${mline#*=}"
val="${val//\"/}"; val="${val//\'/}"
[[ "$key" == MAILTO ]] && { mset=1; mto="$val"; }
continue
fi
: $((ENTRIES++))
if [[ "$mline" == @* ]]; then
read -r _ cmd <<<"$mline"
if [[ $mode == system ]]; then
u="${cmd%% *}"; cmd="${cmd#* }"
valid_user "$u" || { FA+=("FAIL|$base|user '$u' in @-entry not in passwd"); : $((F_FAIL++)); }
fi
first="${cmd%% *}"
[[ "$first" == ./* || "$first" == ../* ]] && { WA+=("WARN|$base|@reboot relative path: $cmd"); : $((F_WARN++)); }
else
if [[ $mode == system ]]; then
read -r _a _b _c _d _e u cmd <<<"$mline"
valid_user "$u" 2>/dev/null || {
FA+=("FAIL|$base|missing/invalid user field (got '$u')"); : $((F_FAIL++))
read -r _a _b _c _d _e cmd <<<"$mline"; u=""
}
else
read -r _a _b _c _d cmd <<<"$mline"
fi
fi
# existence check
local exe
exe=$(first_exec "$cmd") && {
if [[ "$exe" == /* ]]; then
[[ ! -e "${ROOT}${exe}" ]] && { FA+=("FAIL|$base|command '$exe' not found on disk"); : $((F_FAIL++)); }
fi
# dup detection: only absolute paths
[[ "$exe" == /* ]] && echo "$exe" >> "$CRO/cron.txt"
}
# silent failure
[[ $mset -eq 1 && -z "$mto" ]] && ! has_redir "$cmd" && { WA+=("WARN|$base|MAILTO=\"\" no redirect"); : $((F_WARN++)); }
done < "$file"
}
scan_timers() {
local td="$ROOT/etc/systemd/system"
[[ -d "$td" ]] || return 0
local t base has_inst svc_name svc_file line exec_cmd at
for t in "$td"/*.timer; do
[[ -f "$t" ]] || continue
base="${t#$ROOT/}"; has_inst=0
svc_name="$(basename "$t" .timer).service"
while IFS= read -r line; do [[ "$line" =~ ^\[Install\] ]] && has_inst=1; done < "$t"
[[ $has_inst -eq 0 ]] && { WA+=("WARN|$base|no [Install] section - not enabled"); : $((F_WARN++)); }
svc_file="$td/$svc_name"
[[ -f "$svc_file" ]] || continue
while IFS= read -r line; do
if [[ "$line" =~ ^ExecStart= ]]; then
exec_cmd="${line#ExecStart=}"
at=$(first_exec "$exec_cmd") && [[ "$at" == /* ]] && echo "$at" >> "$CRO/timer.txt"
fi
done < "$svc_file"
done
}
chk_perms() {
local f base p
for f in "$@"; do
[[ -f "$f" ]] || continue
base="${f#$ROOT/}"
p=$(stat -c "%a" "$f" 2>/dev/null)
[[ -n "$p" && "${p: -1}" =~ [2367] ]] && { WA+=("WARN|$base|world-writable (perms $p)"); : $((F_WARN++)); }
done
}
main() {
local s u b f t c line
# orphaned spool check
for s in "$ROOT"/var/spool/cron/crontabs/*; do
[[ -f "$s" ]] || continue
u="${s##*/}"; b="${s#$ROOT/}"
valid_user "$u" || { FA+=("FAIL|$b|orphaned crontab - user '$u' not in passwd"); : $((F_FAIL++)); }
done
# system crontabs
[[ -f "$ROOT/etc/crontab" ]] && scan_cron "$ROOT/etc/crontab" system
for f in "$ROOT"/etc/cron.d/*; do [[ -f "$f" ]] && scan_cron "$f" system; done
# spool crontabs
for f in "$ROOT"/var/spool/cron/crontabs/*; do [[ -f "$f" ]] && scan_cron "$f" spool; done
# timers
scan_timers
# world-writable
chk_perms "$ROOT"/etc/cron.d/* "$ROOT/etc/crontab"
# duplicate detection
declare -A SC=()
while IFS= read -r c; do [[ -n "$c" ]] && SC["$c"]=1; done < "$CRO/cron.txt"
while IFS= read -r line; do
[[ -n "$line" ]] && [[ -n "${SC[$line]-}" ]] && { WA+=("WARN|duplicate|command '$line' in both cron + systemd timer"); : $((F_WARN++)); }
done < "$CRO/timer.txt"
echo
echo "╔═══════════════════════════════════════════════╗"
echo "║ CRON & SYSTEMD TIMER HYGIENE AUDIT REPORT ║"
echo "╚═══════════════════════════════════════════════╝"
echo
if [[ $F_FAIL -gt 0 ]]; then
echo "=== FAILURES ==="
for o in "${FA[@]}"; do echo " $o"; done
echo
fi
if [[ $F_WARN -gt 0 ]]; then
echo "=== WARNINGS ==="
for o in "${WA[@]}"; do echo " $o"; done
echo
fi
echo "SUMMARY - FAIL: $F_FAIL WARN: $F_WARN PASS (entries scanned): $ENTRIES"
exit $(( F_FAIL > 0 ? 1 : 0 ))
}
main "$@"One debug note: the first version used four instead of five read variables in the missing-user branch, skipping the command. The fix was one character. That is the edge case this audit catches.