Home › The Stash

Linux for Defenders 08: Capstone — A Daily Health-Check Script in Bash, Kept in Git

LabOctober 20, 2026

IntermediateTestedTime ~45 minTool bash, systemd timer, gitNeeds labs 01–07Cost freeExam Linux+ · CySA+

0 / 7 steps

The goal

The capstone. By the end you will have one bash script that checks everything the last seven labs taught you to watch — who can become root, enabled services, scheduled jobs, the firewall, SSH settings, programs with extra rights and world-writable files — and compares it with a saved baseline. A systemd timer runs it every morning, the result lands in the journal, and the script lives in a Git repository so every change to it is recorded. Then you re-plant three problems from earlier labs and watch it catch all three.

Your own VM, with a snapshot. The script only reads, but step 06 re-plants a risky sudo rule, a scheduled job and an unsafe file permission on purpose. Use the same VM you used for labs 01 to 07, snapshot it first, and undo the plants in step 07.

Why this lab. Every check in this series works once, when you remember to run it. Attackers count on that. A defender’s real advantage is knowing what normal looks like and noticing the day it changes. That is all this script does: take a picture of normal, then compare every morning.

Steven’s current monitoring system is “the website feels slow”. This replaces it. — Nyx

Before you start. You need an Ubuntu 24.04 VM with ufw and SSH set up as in labs 04 and 05, and git (sudo apt install git). The script reads the same commands you ran in each lab, so if a lab is missing, its section will simply be shorter.

Steps

01Start a Git repository for the script

Make a folder for the script and turn it into a Git repository, so every version you write is kept.

$ mkdir ~/healthcheck && cd ~/healthcheck
$ git init -b main
$ git config user.name "Your Name"
$ git config user.email "you@example.test"

The repository is just a hidden .git folder inside ~/healthcheck. The two config lines put your name on each saved version.

Why Git. A security script that changes without a record is itself a risk: if it suddenly stops checking something, you want to know when and why. Git gives you that history for free. Never commit the script’s output; it describes your machine and does not belong in a repository. The leaked secrets lab shows why.

02Write the script

Save this as ~/healthcheck/healthcheck.sh. Each section is one earlier lab, using the same command you learned there.

#!/bin/bash
# healthcheck.sh - daily Linux health check (StashGrid Linux for Defenders 08)
# Takes a snapshot of the things labs 01-07 taught you to watch, and compares it
# with a saved baseline. Exit 0 = nothing changed, 1 = something changed, 2 = setup problem.
set -u
STATE=/var/lib/healthcheck
BASE="$STATE/baseline.txt"
NOW="$STATE/latest.txt"

snapshot() {
  echo "## 01 who can become root"
  grep -rhvE '^\s*(#|$|Defaults)' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | sort
  echo "sudo group: $(getent group sudo | cut -d: -f4)"
  echo "## 02 enabled services"
  systemctl list-unit-files --type=service --state=enabled --no-legend | awk '{print $1}' | sort
  echo "## 03 scheduled jobs"
  for u in $(cut -d: -f1 /etc/passwd); do
    crontab -l -u "$u" 2>/dev/null | grep -vE '^\s*(#|$)' | sed "s|^|crontab $u: |"
  done
  grep -H -vE '^\s*(#|$)' /etc/cron.d/* | sed 's|^|cron.d: |'
  systemctl list-unit-files --type=timer --state=enabled --no-legend | awk '{print "timer: " $1}' | sort
  echo "## 04 firewall"
  ufw status | head -1
  echo "## 05 ssh settings"
  sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|maxauthtries) '
  echo "## 06 programs with extra rights (SUID/SGID)"
  find / -xdev -perm /6000 -type f 2>/dev/null | sort
  echo "## 07 world-writable files"
  find / -xdev -type f -perm -0002 2>/dev/null | sort
}

info() {
  echo "security updates waiting: $(apt list --upgradable 2>/dev/null | grep -c -- '-security')"
  echo "failed SSH logins, last 24h: $(journalctl -u ssh --since -24h --no-pager 2>/dev/null | grep -cE 'Invalid user|Failed password')"
}

if [ "$(id -u)" -ne 0 ]; then echo "run me as root (sudo)"; exit 2; fi
mkdir -p "$STATE" && chmod 700 "$STATE"
snapshot > "$NOW"

if [ "${1:-}" = "--baseline" ]; then
  cp "$NOW" "$BASE"
  echo "baseline saved: $(wc -l < "$BASE") lines in $BASE"
  exit 0
fi
if [ ! -f "$BASE" ]; then echo "no baseline yet: run with --baseline first"; exit 2; fi

info
if diff -u "$BASE" "$NOW" > "$STATE/changes.diff"; then
  echo "OK: nothing changed since the baseline"
  exit 0
else
  echo "CHANGED since the baseline (+ new, - gone):"
  grep -E '^[+-][^+-]' "$STATE/changes.diff"
  exit 1
fi

Read it top to bottom. snapshot prints seven sections: sudo rules and the sudo group (lab 01), enabled services (02), every crontab, every line in /etc/cron.d and enabled timers (03), the firewall state (04), three SSH settings from sshd -T (05), SUID and SGID programs, and world-writable files (07). info prints two numbers that change daily (waiting security updates from lab 06, failed SSH logins), so they are reported but not compared. The end compares today with the baseline using diff and exits 0 for no change, 1 for changes.

Why compare instead of judge. The script never decides what is “bad”. It only reports what is different. That keeps it short, means it works on any machine, and leaves the judgement to you — which is exactly what you practised in every lab.

03Commit it, install it, take the baseline

Save the first version in Git, install a root-owned copy where root’s tools live, and take the picture of normal.

$ chmod 755 healthcheck.sh
$ git add healthcheck.sh && git commit -m "First version of the daily health check"
$ git log --oneline
827202c First version of the daily health check
$ sudo install -o root -g root -m 0755 healthcheck.sh /usr/local/sbin/healthcheck
$ sudo healthcheck --baseline
baseline saved: 64 lines in /var/lib/healthcheck/baseline.txt
$ sudo head -6 /var/lib/healthcheck/baseline.txt
## 01 who can become root
%admin ALL=(ALL) ALL
%sudo	ALL=(ALL:ALL) ALL
@includedir /etc/sudoers.d
root	ALL=(ALL:ALL) ALL
sudo group: ubuntu,alex

Root will run this script every day, so root must own the copy it runs — the lesson from lab 07. install copies the file and sets owner and permissions in one step. The baseline is 64 lines of “normal”, stored in /var/lib/healthcheck/, which the script locks to root only (chmod 700).

Why this matters. Take the baseline on a machine you trust, right after you have checked it. A baseline of a machine that is already compromised just teaches the script that the compromise is normal.

04Run it again: all clear

Nothing has changed yet, so the check should say so.

$ sudo healthcheck; echo "exit=$?"
security updates waiting: 0
failed SSH logins, last 24h: 0
OK: nothing changed since the baseline
exit=0

Two information lines, then the verdict. Exit code 0 means clean. That number is what lets other tools react to the script, as the next step shows.

Why this matters. Run it a few times on a normal day before you trust it. If anything shows up as changed when nothing really changed, that section is too noisy and needs tightening before you rely on it.

05Run it every morning with a systemd timer

A timer and service pair, exactly like the ones you read in lab 03, runs the script at 07:00 daily.

$ sudo tee /etc/systemd/system/healthcheck.service >/dev/null <<'EOF'
[Unit]
Description=Daily Linux health check (StashGrid)

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/healthcheck
EOF
$ sudo tee /etc/systemd/system/healthcheck.timer >/dev/null <<'EOF'
[Unit]
Description=Run the health check every morning

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target
EOF
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now healthcheck.timer
Created symlink /etc/systemd/system/timers.target.wants/healthcheck.timer → /etc/systemd/system/healthcheck.timer.
$ systemctl list-timers healthcheck.timer --no-pager
NEXT                        LEFT LAST PASSED UNIT              ACTIVATES
Wed 2026-10-21 07:00:00 UTC  11h -         - healthcheck.timer healthcheck.service

Persistent=true means that if the VM was off at 07:00, the check runs as soon as it boots. The output of each run goes to the journal. And because the script exits 1 when something changed, systemd marks that run as failed — so a change shows up in systemctl --failed, the first thing many admins look at.

Why a timer, not cron. Either works. A timer gives you the run history and output in journalctl, catches up after downtime, and turns a change into a visible failed unit with no extra code.

06Re-plant three problems and watch it catch them

Bring back one problem each from labs 01, 03 and 07: a sudo rule that hands out root, a scheduled job, and a world-writable script root runs. Then run the check the way the timer does.

$ echo 'sam ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90-temp >/dev/null; sudo chmod 440 /etc/sudoers.d/90-temp
$ echo '*/10 * * * * root /bin/bash -c "id > /tmp/marker"' | sudo tee /etc/cron.d/sysmon >/dev/null
$ sudo chmod 777 /usr/local/bin/nightly-backup.sh
$ sudo systemctl start healthcheck.service
Job for healthcheck.service failed because the control process exited with error code.
$ journalctl -u healthcheck.service -t healthcheck -o cat --no-pager | tail -7
security updates waiting: 0
failed SSH logins, last 24h: 0
CHANGED since the baseline (+ new, - gone):
+sam ALL=(ALL) NOPASSWD: ALL
+cron.d: sysmon
+timer: healthcheck.timer
+/usr/local/bin/nightly-backup.sh
$ systemctl --failed --no-legend --no-pager
● healthcheck.service loaded failed failed Daily Linux health check (StashGrid)

All three are caught, each in its own section: the new sudo rule, the new cron file, and the script that became world-writable. The fourth line is the timer you added in step 05 — also a real change since the baseline, and a good reminder that the script reports your changes too. The service shows as failed, which is the alarm working. (This run used version 1 of the script, which lists cron files by name; step 07 improves that.) -t healthcheck shows only the script’s own lines; without it you also see a line per user from crontab -l.

The defender’s move. Three persistence and privilege tricks from three different labs, all caught by one comparison you did not have to remember to run. That is the payoff of the whole series: you know what normal looks like, and the machine tells you when it stops being normal.

07Clean up, accept the timer, improve the script

Move the two planted files out of the way (keeping them as evidence), fix the permission, accept the timer into the baseline, and check again.

$ sudo mv /etc/cron.d/sysmon /root/removed-sysmon.cron
$ sudo mv /etc/sudoers.d/90-temp /root/removed-90-temp.sudoers
$ sudo chmod 755 /usr/local/bin/nightly-backup.sh
$ sudo healthcheck
security updates waiting: 0
failed SSH logins, last 24h: 0
CHANGED since the baseline (+ new, - gone):
+timer: healthcheck.timer
$ sudo healthcheck --baseline
baseline saved: 65 lines in /var/lib/healthcheck/baseline.txt
$ sudo healthcheck
security updates waiting: 0
failed SSH logins, last 24h: 0
OK: nothing changed since the baseline
$ sudo systemctl reset-failed healthcheck.service

Only the timer was left, and that change was yours, so you fold it into the baseline. Now a weakness you just saw: version 1 only lists cron file names, so someone editing an existing cron file would slip past. Fix it in the repository, commit, and reinstall:

$ git diff
-  ls /etc/cron.d/ | sed 's|^|cron.d: |'
+  grep -H -vE '^\s*(#|$)' /etc/cron.d/* | sed 's|^|cron.d: |'
$ git commit -am "Watch the contents of /etc/cron.d, not just file names"
$ git log --oneline
63229c8 Watch the contents of /etc/cron.d, not just file names
827202c First version of the daily health check
$ sudo install -o root -g root -m 0755 healthcheck.sh /usr/local/sbin/healthcheck
$ sudo healthcheck --baseline
baseline saved: 66 lines in /var/lib/healthcheck/baseline.txt

The git diff lines are trimmed to the change itself. Version 2 records every line of every cron file, so an edited job shows up too. Re-take the baseline after every change to the script, because its output format changed. The Git log now tells the story: what changed, when, and why.

Why this matters. This is the habit that makes a security check last: notice a gap, fix it in version control, re-baseline. Ideas for version 3 — listening ports from ss -tlnp (lab 04), the AIDE check from lab 07, or emailing the result instead of only logging it.

What you learned

  • baseline and compare — a picture of normal, and diff to say what changed; the script reports, you judge
  • one section per lab — sudo, services, schedules, firewall, SSH, SUID and world-writable files, in under 60 lines of bash
  • exit codes — 0 clean, 1 changed, so systemd marks a change as a failed unit
  • a systemd timer — daily at 07:00, catches up after downtime with Persistent=true, output in journalctl
  • root-owned install — the copy root runs lives in /usr/local/sbin, owned by root, mode 755
  • Git — every change to the script is recorded; the script’s output never goes in the repository

On Red Hat (RHEL, Rocky, Fedora). The structure works unchanged, but four lines need swapping, and this version of the script was only run on Ubuntu: use getent group wheel instead of sudo, firewall-cmd --state instead of ufw status, journalctl -u sshd instead of -u ssh, and dnf -q check-update --security for the updates count. Each of those commands was checked on Rocky Linux 9.

Put it on your resume. Wrote a bash health-check script that baselines and detects changes in Linux privileges, services, scheduled jobs, firewall, SSH and file-permission state, scheduled it with a systemd timer, and maintained it in Git. Change it to match what you actually did.

Series complete

Eight labs: who can become root, what runs, when it runs, what the network can reach, the front door, what is installed, what is dangerous on disk, and a script that watches all of it every morning. That is a real Linux defender’s routine.

Tried it? Tell us

Nova built this lab from Steven’s ideas, and every command above was run on a real machine before it went up. If a step behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.

NextLinux for Defenders 09 — What is this machine connected to?→

Go deeper

Original walkthrough. Every command and output block above was run on Ubuntu 24.04 (bash 5.2, systemd 255, git 2.43) on a VM set up as in labs 01 to 07; the Red Hat swaps were checked command by command on Rocky Linux 9.8, not as a full script run. Commit hashes and line counts will differ on yours. If something does not match your machine, tell me and I’ll fix it.