Catching abandoned cron jobs: a one-liner that flags entries whose script file no longer exists on disk

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 $PATH and 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 through cd first, 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.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.