--- # Cron Audit Playbook # Inventories all scheduled tasks across every host and flags basic security concerns. # Covers /etc/crontab, /etc/cron.d/, /etc/cron.{hourly,daily,weekly,monthly}, # user crontab spools, and systemd timers. # Usage: ansible-playbook playbooks/cron_audit.yml # Usage: ansible-playbook playbooks/cron_audit.yml -e "host_target=rpi" - name: Cron Audit — Scheduled Task Inventory hosts: "{{ host_target | default('active') }}" gather_facts: yes ignore_unreachable: true vars: report_dir: "/tmp/cron_audit" tasks: # ---------- Setup ---------- - name: Create cron audit report directory ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: '0755' delegate_to: localhost run_once: true # ---------- /etc/crontab ---------- - name: Read /etc/crontab ansible.builtin.shell: cat /etc/crontab 2>/dev/null || echo "(not present)" register: etc_crontab changed_when: false failed_when: false # ---------- /etc/cron.d/ ---------- - name: Read /etc/cron.d/ entries ansible.builtin.shell: | if [ -d /etc/cron.d ] && [ -n "$(ls /etc/cron.d/ 2>/dev/null)" ]; then for f in /etc/cron.d/*; do [ -f "$f" ] || continue echo "=== $f ===" cat "$f" 2>/dev/null echo "" done else echo "(not present or empty)" fi register: cron_d_entries changed_when: false failed_when: false # ---------- /etc/cron.{hourly,daily,weekly,monthly} ---------- - name: Read /etc/cron.{hourly,daily,weekly,monthly} script names ansible.builtin.shell: | for dir in hourly daily weekly monthly; do path="/etc/cron.$dir" if [ -d "$path" ]; then echo "=== $path ===" ls "$path" 2>/dev/null || echo "(empty)" echo "" fi done if [ ! -d /etc/cron.hourly ] && [ ! -d /etc/cron.daily ] && \ [ ! -d /etc/cron.weekly ] && [ ! -d /etc/cron.monthly ]; then echo "(no cron period directories present)" fi register: cron_period_dirs changed_when: false failed_when: false # ---------- List users with crontabs ---------- - name: List users with crontabs ansible.builtin.shell: | # Debian/Ubuntu path if [ -d /var/spool/cron/crontabs ]; then spool_dir="/var/spool/cron/crontabs" elif [ -d /var/spool/cron ]; then spool_dir="/var/spool/cron" else echo "(no crontab spool directory found)" exit 0 fi files=$(ls "$spool_dir" 2>/dev/null) if [ -z "$files" ]; then echo "(no user crontabs found in $spool_dir)" else echo "$files" fi register: crontab_users changed_when: false failed_when: false # ---------- Dump user crontab contents ---------- - name: Dump user crontab contents ansible.builtin.shell: | # Debian/Ubuntu path if [ -d /var/spool/cron/crontabs ]; then spool_dir="/var/spool/cron/crontabs" elif [ -d /var/spool/cron ]; then spool_dir="/var/spool/cron" else echo "(no crontab spool directory found)" exit 0 fi found=0 for f in "$spool_dir"/*; do [ -f "$f" ] || continue found=1 echo "=== $f ===" cat "$f" 2>/dev/null || echo "(unreadable)" echo "" done if [ "$found" -eq 0 ]; then echo "(no user crontab files found)" fi register: crontab_contents changed_when: false failed_when: false # ---------- Systemd timers ---------- - name: List systemd timers ansible.builtin.shell: | if command -v systemctl >/dev/null 2>&1; then systemctl list-timers --all --no-pager 2>/dev/null else echo "(not a systemd host)" fi register: systemd_timers changed_when: false failed_when: false # ---------- Security flag: REDACTED_APP_PASSWORD world-writable paths ---------- - name: Security flag - REDACTED_APP_PASSWORD world-writable path references ansible.builtin.shell: | flagged="" # Collect root cron entries from /etc/crontab if [ -f /etc/crontab ]; then while IFS= read -r line; do # Skip comments, empty lines, and variable assignment lines (e.g. MAILTO="") case "$line" in '#'*|''|*'='*) continue ;; esac # Lines where 6th field indicates root user (field 6) — format: min hr dom mon dow user cmd user=$(echo "$line" | awk '{print $6}') if [ "$user" = "root" ]; then cmd=$(echo "$line" | awk '{for(i=7;i<=NF;i++) printf "%s ", $i; print ""}') bin=$(echo "$cmd" | awk '{print $1}') if [ -n "$bin" ] && [ -f "$bin" ]; then if [ "$(find "$bin" -maxdepth 0 -perm -002 2>/dev/null)" = "$bin" ]; then flagged="$flagged\nFLAGGED: /etc/crontab root job uses world-writable binary: $bin" fi fi fi done < /etc/crontab fi # Collect root cron entries from /etc/cron.d/* if [ -d /etc/cron.d ]; then for f in /etc/cron.d/*; do [ -f "$f" ] || continue while IFS= read -r line; do case "$line" in '#'*|''|*'='*) continue ;; esac user=$(echo "$line" | awk '{print $6}') if [ "$user" = "root" ]; then cmd=$(echo "$line" | awk '{for(i=7;i<=NF;i++) printf "%s ", $i; print ""}') bin=$(echo "$cmd" | awk '{print $1}') if [ -n "$bin" ] && [ -f "$bin" ]; then if [ "$(find "$bin" -maxdepth 0 -perm -002 2>/dev/null)" = "$bin" ]; then flagged="$flagged\nFLAGGED: $f root job uses world-writable binary: $bin" fi fi fi done < "$f" done fi # Collect root crontab from spool for spool in /var/spool/cron/crontabs/root /var/spool/cron/root; do if [ -f "$spool" ]; then while IFS= read -r line; do case "$line" in '#'*|'') continue ;; esac # User crontab format: min hr dom mon dow cmd (no user field) cmd=$(echo "$line" | awk '{for(i=6;i<=NF;i++) printf "%s ", $i; print ""}') bin=$(echo "$cmd" | awk '{print $1}') if [ -n "$bin" ] && [ -f "$bin" ]; then if [ "$(find "$bin" -maxdepth 0 -perm -002 2>/dev/null)" = "$bin" ]; then flagged="$flagged\nFLAGGED: $spool job uses world-writable binary: $bin" fi fi done < "$spool" fi done # Check /etc/cron.{hourly,daily,weekly,monthly} scripts (run as root by run-parts) for dir in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do [ -d "$dir" ] || continue for f in "$dir"/*; do [ -f "$f" ] || continue if [ "$(find "$f" -maxdepth 0 -perm -002 2>/dev/null)" = "$f" ]; then flagged="${flagged}\nFLAGGED: $f (run-parts cron dir) is world-writable" fi done done if [ -z "$flagged" ]; then echo "No world-writable cron script paths found" else printf "%b\n" "$flagged" fi register: security_flags changed_when: false failed_when: false # ---------- Per-host summary ---------- - name: Per-host cron audit summary ansible.builtin.debug: msg: | ========================================== CRON AUDIT SUMMARY: {{ inventory_hostname }} ========================================== === /etc/crontab === {{ etc_crontab.stdout | default('(not collected)') }} === /etc/cron.d/ === {{ cron_d_entries.stdout | default('(not collected)') }} === Cron Period Directories === {{ cron_period_dirs.stdout | default('(not collected)') }} === Users with Crontabs === {{ crontab_users.stdout | default('(not collected)') }} === User Crontab Contents === {{ crontab_contents.stdout | default('(not collected)') }} === Systemd Timers === {{ systemd_timers.stdout | default('(not collected)') }} === Security Flags === {{ security_flags.stdout | default('(not collected)') }} ========================================== # ---------- Per-host JSON report ---------- - name: Write per-host JSON cron audit report ansible.builtin.copy: content: "{{ { 'timestamp': ansible_date_time.iso8601, 'hostname': inventory_hostname, 'etc_crontab': etc_crontab.stdout | default('') | trim, 'cron_d_entries': cron_d_entries.stdout | default('') | trim, 'cron_period_dirs': cron_period_dirs.stdout | default('') | trim, 'crontab_users': crontab_users.stdout | default('') | trim, 'crontab_contents': crontab_contents.stdout | default('') | trim, 'systemd_timers': systemd_timers.stdout | default('') | trim, 'security_flags': security_flags.stdout | default('') | trim } | to_nice_json }}" dest: "{{ report_dir }}/{{ inventory_hostname }}_{{ ansible_date_time.date }}.json" delegate_to: localhost changed_when: false