Files
homelab-optimized/ansible/automation/playbooks/cron_audit.yml
Gitea Mirror Bot 5c4307231f
Some checks failed
Documentation / Build Docusaurus (push) Failing after 5m1s
Documentation / Deploy to GitHub Pages (push) Has been skipped
Sanitized mirror from private repository - 2026-04-18 12:21:34 UTC
2026-04-18 12:21:34 +00:00

277 lines
9.6 KiB
YAML

---
# 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