Last spring I was cleaning out an old VPS and found three crontab entries pointing at scripts that didn’t exist anymore. Two had been deleted during a refactor in 2022; one was a project I’d rm -rf‘d a year ago and forgotten about. The cron daemon had been dutifully invoking them four times a day, getting back /bin/sh: /opt/bin/scrape-feeds.sh: No such file or directory, and depositing that error into a mail spool I never read.
This is a tiny class of bug, and a one-liner finds it. Worth running once a quarter on every server you own.
The one-liner
(crontab -l 2>/dev/null; cat /etc/crontab /etc/cron.d/* 2>/dev/null) \
| awk '/^[^#]/ && NF >= 6 { for(i=6;i<=NF;i++) printf "%s ", $i; print "" }' \
| awk '{ print $1 }' \
| grep -E '^/' \
| sort -u \
| while read script; do
[ -x "$script" ] || echo "MISSING: $script"
done
Walk through it because every line earns its keep:
- Line 1 concatenates your user crontab and every system crontab into one stream. Add
/var/spool/cron/*if you want everyone’s user crontabs (root-only). - Line 2 strips comments and blank lines, then prints the command portion of each cron entry — fields 6 onward (5-field cron schedule + 1 user field for system crontabs, or 5+0 for user crontabs; this works for both because 1-field overrun on a comment is filtered out).
- Line 3 takes the first whitespace-separated token of that command. That’s the executable; everything after is arguments.
- Line 4 filters to absolute paths (cron entries that start with a relative command rely on
$PATHand are a separate rabbit hole). - Line 5 dedupes — many entries reference the same script.
- Line 6-8 tests each path with
-x(exists and executable) and prints the ones that fail.
Output looks like:
MISSING: /opt/bin/scrape-feeds.sh
MISSING: /home/old-user/cleanup.py
MISSING: /usr/local/lib/feeds-rotate
Three abandoned crons. Each one a tiny background failure on every fire.
What this catches and what it doesn’t
- Catches: direct-path crons whose script was deleted, moved, or renamed. Crons referencing scripts in user-owned directories that got nuked when the user was removed. Crons pointing at packages you uninstalled but whose cron.d entry survived.
- Doesn’t catch: crons using shell-builtin commands (
echo,test), crons using/bin/sh -c '...'wrappers (the executable is/bin/sh, not the actual logic), crons that pipe throughcdfirst, crons whose script exists but is broken inside. - False positives: entries pointing at scripts on a NFS mount that was unmounted at scan time. Re-run after mounting if you suspect this.
The version that handles wrappers
If you have a lot of /bin/sh -c "/opt/bin/foo && /opt/bin/bar" -style crons, the basic version misses the inner executables. This is the gnarlier variant:
(crontab -l 2>/dev/null; cat /etc/crontab /etc/cron.d/* 2>/dev/null) \
| awk '/^[^#]/ && NF >= 6 { for(i=6;i<=NF;i++) printf "%s ", $i; print "" }' \
| grep -oE '/[a-zA-Z0-9_./-]+' \
| sort -u \
| while read p; do
[ -e "$p" ] || echo "MISSING: $p"
done
The grep -oE '/[a-zA-Z0-9_./-]+' finds every absolute-looking path anywhere in the command line, including inside quoted shell wrappers. False-positive rate goes up — you’ll get hits on log paths, output redirects, things you don’t actually want flagged — but the coverage is much better.
I run the looser version and pipe the output through grep -v '/var/log/' to drop the obvious noise.
Make it a quarterly habit
Throw it in /usr/local/sbin/cron-audit.sh, give it a curl-ping to Healthchecks at the end, and run it on the first Sunday of every quarter:
0 9 1 1,4,7,10 0 /usr/local/sbin/cron-audit.sh | mail -s 'Cron audit' root@localhost
You’ll find one or two abandoned crons every audit on a server that’s been around a few years. Fix the path, comment out the entry, or remove it entirely. The cleanup is satisfying in the same way that finding a forgotten .pid file is — small, low-stakes, and exactly the kind of cruft that accumulates if you don’t go looking for it.
If you’ve never run this on a long-lived server, do it now. The output is almost never empty.
Cover photo: dof-pinhole on Pexels.
