Bash Script to Audit Cron and Systemd Timer Hygiene

– min read

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

  1. Orphaned crontabs – user in spool dir does not exist in passwd
  2. Missing commands – scheduled binary absent from disk
  3. Invalid user fields – missing 6th column in cron.d
  4. Silent failure risk – MAILTO=”” no output redirect
  5. World-writable cron files – anyone can edit
  6. Duplicate scheduling – same binary in cron and timer
  7. Timer not enabled – no [Install] section
  8. 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/root


The 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


Audit report output
Report output


Script excerpt
Script excerpt


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.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page