PulseWatchPulsewatch

The crontab audit script

One command that lists every scheduled job on a machine and tells you which ones nothing is watching, most consequential first.

Run it

curl -fsSL https://pulsewatcher.vercel.app/audit.sh | sh

Piping a script into a shell is a real thing to be careful about, so: the source is published in full at github.com/walid-zeroual/crontab-audit-script. It is one file, and it is byte-for-byte the file that URL serves — asserted by a test, so it cannot drift. Read it first. If you would rather not pipe it at all, download it and run it yourself.

What it reads

Your user crontabvia crontab -l
/etc/crontabthe system crontab, including its user column
/etc/cron.d/*skipping the names run-parts itself ignores
/etc/cron.{hourly,daily,weekly,monthly}the scripts run-parts executes directly
systemd timerssystemctl list-timers, and the unit each one starts
Kubernetes CronJobsonly when kubectl is installed
Docker containers running a scheduleronly when docker is installed

What it never does

  • ·Send anything about your machine anywhere. There is no telemetry and no phone-home — there is no code in the script that contacts PulseWatch at all.
  • ·Write a file, unless you pass --apply.
  • ·Touch anything under /etc. --apply rewrites your user crontab and nothing else, ever.
  • ·Run any of your jobs. It reads their definitions; it never executes them.

One honest caveat, because “no network calls” would be a small lie: when kubectl or docker are installed, the script asks them to list scheduled jobs, and kubectl in turn queries whatever cluster you are already pointed at. That is your own infrastructure, it is a read, and nothing reaches us — but it is a socket, so the script says so in its own output rather than glossing over it. --no-cluster skips both.

What it prints

text
PulseWatch crontab audit v1.0.0
Read-only. Nothing about this machine was sent anywhere.

15 scheduled jobs found.
  1 already ping PulseWatch
  1 ping another monitoring service
  13 unmonitored - if these stop, nothing tells you

Unmonitored, most consequential first

  30 2 * * 1  crontab
  30 2 * * 1 /opt/reports/weekly-invoice-report.sh

  suggested replacement (monitor name: weekly-invoice-report)
    30 2 * * 1 /opt/reports/weekly-invoice-report.sh && curl -fsS -m 10 --retry 5 \
      https://pulsewatcher.up.railway.app/ping/<your-key>/weekly-invoice-report

A job already pinging another monitoring service is counted as watched, not as a gap. Counting somebody's healthchecks.io job as unmonitored would inflate the number that makes the argument, which is a good way to lose the argument.

Why that order

The list is sorted by how much it would hurt if the job stopped. Two signals: what the job does — anything mentioning backup, dump, invoice, billing, payout, sync, export, report or cleanup outranks a job whose name says nothing — and how rarely it runs. A weekly job that fails is invisible for seven days and you get one chance a week to notice; a five-minute cache warm that misses a beat has healed itself before you read about it. A job scheduled between midnight and 06:00 gets a nudge up, because nobody is awake to see it fail.

The suggested line

The ping is joined with && and not ; because a heartbeat means the job succeeded. A semicolon would ping after a failure too — reporting health that is not there, which is worse than no monitoring at all.

It is appended to your line exactly as that line appears on disk, byte for byte, rather than to a re-parsed version of it. A command containing a literal tab — cut -d'⇥' -f1,3, the ordinary way to write a TSV delimiter — must come back with that tab intact.

For six shapes the script refuses to give you a pasteable line at all. It prints the ping URL and tells you to place it by hand, because appending would be actively wrong:

a trailing # commentcron hands the whole line to a shell, which discards everything after the #. The ping would land inside the comment and never run — while the job keeps working, so the monitor goes red and stays red.
; or ||&& binds to the last command of a list. In job || fallback && ping, the ping fires exactly when the job failed and the fallback succeeded. A trailing ; is worse: ; && curl is a syntax error and the job never runs again.
a pipelineA pipeline's exit status is its last stage, and cron's /bin/sh has no pipefail. mysqldump | gzip && ping pings whenever gzip succeeds, which it does even when the dump it compressed was truncated.
a trailing &The job is backgrounded, so the ping fires immediately, before it has finished.
% anywhere in the commandcron turns an unescaped % into a newline, and everything after the first one becomes stdin.
files under cron.daily and friendsrun-parts executes the file directly, so there is no crontab line to edit — the ping belongs inside the script.

A crontab line that looks right and is not is a job that stops running — or a green monitor over a job that has been failing for weeks. Saying “this one needs a human” is the only honest option.

Using it as a check

It exits 1 when unmonitored jobs are found, so it works as a CI or config-management gate:

bash
# fail the build if somebody adds an unmonitored cron job
./audit.sh --quiet || exit 1

--apply

Rewrites your user crontab. It takes a timestamped backup to ~/crontab.backup.…, shows a unified diff, and asks you to type yes. It reads that answer from /dev/tty rather than stdin — under curl … | sh stdin is the script, and reading from it would consume the rest of the program and answer its own question. With no terminal it refuses and tells you to download the script instead.

It skips every shape in the table above and names each one it skipped. It never touches /etc.

Then what

Nothing to set up beforehand: the monitor is created by its first ping. Take your auto-provision key from Settings → Create monitors from a ping, pass it as --key, and the suggested lines come out ready to paste. More on that in the pinging API.