What You'll Build
In this exercise you will build `cricket_file_audit.sh` — a comprehensive file system audit script that a security engineer or DevOps engineer can run on a CricketPulse server to verify that file ownership, permissions, and structure match the expected deployment baseline. The script combines file operations, I/O redirection, links, wildcards, find, and permissions into a single production-quality tool.
The audit covers four areas: verifying that all required FHS-correct directories exist with correct ownership, checking that sensitive configuration files have restrictive permissions, identifying any world-readable secrets or world-writable application files, and confirming that the release symlink points to a valid versioned directory. Each check produces a structured pass/fail result that can be consumed by a CI/CD pipeline.
Real security audits at organisations like Netflix, Stripe, and Cloudflare run exactly this kind of automated filesystem check as part of their deployment verification pipelines. Building one from scratch cements the permission model, find syntax, and symlink mechanics covered in this module into practical, reusable knowledge that applies directly to every server you will ever provision or deploy to.
Prerequisites
- Completion of Lessons 05–07: file operations and I/O redirection, links/wildcards/find, and the full permission model.
- A Linux system where you have sudo access — the audit checks system directories that require elevated privileges to inspect fully.
- The CricketPulse directory structure from previous exercises or a simulated version created using the setup commands in this lesson.
- Familiarity with exit codes — the audit script uses non-zero exit codes to signal failures to a calling CI/CD pipeline.
- Basic understanding of bash arrays and loops — the audit uses arrays to define the expected state and loops to check against it.
Setup & Project Structure
Before writing the audit script, set up the directory structure that the script will audit. This creates a realistic target environment with both correct and intentionally misconfigured permissions, allowing you to verify that the audit script correctly identifies both passing and failing checks. Run the setup as root or with sudo — creating system directories and service users requires elevated privileges.
The setup creates the full CricketPulse directory structure from Module 2's permission model: a system service user, the FHS-correct directories with appropriate ownership, placeholder configuration and log files, the versioned release structure with a `current` symlink, and two intentional misconfigurations for the audit script to detect — a world-readable secret file and a world-writable application directory.
#!/bin/bash
# setup_audit_target.sh — creates CricketPulse structure to audit
set -euo pipefail
# Create service user if it does not exist
id cricketpulse &>/dev/null || \
useradd --system --no-create-home --shell /usr/sbin/nologin cricketpulse
# Create directory structure
mkdir -p /etc/cricketpulse
mkdir -p /var/log/cricketpulse
mkdir -p /opt/cricketpulse/releases/v2.1.0
mkdir -p /opt/cricketpulse/bin
# Set correct permissions
chown -R cricketpulse:cricketpulse /opt/cricketpulse /var/log/cricketpulse
chown root:cricketpulse /etc/cricketpulse
chmod 750 /etc/cricketpulse /var/log/cricketpulse
chmod 2775 /opt/cricketpulse/releases
# Create config files with correct permissions
echo 'app: cricketpulse' > /etc/cricketpulse/config.yaml
chmod 640 /etc/cricketpulse/config.yaml
chown root:cricketpulse /etc/cricketpulse/config.yaml
# ---- Intentional misconfigurations for the audit to detect ----
echo 'db_password=secret123' > /etc/cricketpulse/db.key
chmod 644 /etc/cricketpulse/db.key # ❌ World-readable secret
chmod 777 /opt/cricketpulse/bin # ❌ World-writable directory
# Create active release symlink
ln -sfn /opt/cricketpulse/releases/v2.1.0 /opt/cricketpulse/current
touch /var/log/cricketpulse/app.log
echo 'Setup complete — run cricket_file_audit.sh'
Step 1 — Foundation: Audit Framework
Step 1 establishes the audit script's framework: the exit code tracking, the pass/fail reporting functions, and the summary output. Unlike the inventory script from Module 1, this script must communicate a binary result to its caller — a CI/CD pipeline that runs this script needs to know whether the audit passed or failed via the exit code, not just by reading the output. Exit code 0 means all checks passed; any non-zero exit code means at least one check failed.
The framework uses a global counter to track failures. Each check function increments the counter when it finds a violation, and the script exits with the counter as its exit code. This design allows a CI pipeline to fail the build immediately on first violation (with `set -e`) or collect all violations and report them together — the choice depends on the pipeline's configuration.
#!/bin/bash
# cricket_file_audit.sh — Step 1: Audit Framework
set -euo pipefail
FAILURES=0
REPORT="/tmp/cricket_audit_$(date +%Y%m%d_%H%M%S).txt"
pass() { echo " [PASS] $1" | tee -a "$REPORT"; }
fail() { echo " [FAIL] $1" | tee -a "$REPORT"; ((FAILURES++)) || true; }
section() { echo; echo "=== $1 ==" | tee -a "$REPORT"; }
{
echo "CricketPulse File Audit — $(date)"
echo "Auditing host: $(hostname)"
} | tee "$REPORT"
# ---- All checks go here (Steps 2-4 expand this section) ----
# Summary and exit
echo | tee -a "$REPORT"
if [ "$FAILURES" -eq 0 ]; then
echo "RESULT: PASSED — all checks clean" | tee -a "$REPORT"
exit 0
else
echo "RESULT: FAILED — $FAILURES violation(s) found" | tee -a "$REPORT"
exit "$FAILURES"
fi
Step 2 — Core Logic: Directory and Ownership Checks
Step 2 adds the directory existence and ownership verification checks. Each directory in the CricketPulse deployment has an expected owner, group, and permission mode. The check function reads the actual values using `stat` and compares them against the expected values, calling `pass()` or `fail()` based on the result. This pattern — define expected state, read actual state, compare — is the same pattern used by configuration management tools like Ansible's `file` module and Chef's `directory` resource.
The `stat -c` format string is the key tool here. `stat -c '%a'` returns numeric permissions, `stat -c '%U'` returns the owner username, and `stat -c '%G'` returns the group name. Combining these into a single `stat -c '%a %U %G'` call reads all three values in one filesystem operation, reducing the number of `stat` system calls and making the check function efficient enough to run on dozens of directories without noticeable overhead.
# Step 2: Directory and ownership checks — add inside the report block
check_dir() {
local path=$1 expected_mode=$2 expected_owner=$3 expected_group=$4
if [ ! -d "$path" ]; then
fail "MISSING directory: $path"; return
fi
actual_mode=$(stat -c '%a' "$path")
actual_owner=$(stat -c '%U' "$path")
actual_group=$(stat -c '%G' "$path")
[ "$actual_mode" = "$expected_mode" ] || fail "$path permissions: got $actual_mode, expected $expected_mode"
[ "$actual_owner" = "$expected_owner" ] || fail "$path owner: got $actual_owner, expected $expected_owner"
[ "$actual_group" = "$expected_group" ] || fail "$path group: got $actual_group, expected $expected_group"
[ "$FAILURES" -eq 0 ] && pass "$path: mode=$actual_mode owner=$actual_owner:$actual_group"
}
section "DIRECTORY OWNERSHIP & PERMISSIONS"
check_dir /etc/cricketpulse 750 root cricketpulse
check_dir /var/log/cricketpulse 750 cricketpulse cricketpulse
check_dir /opt/cricketpulse 755 cricketpulse cricketpulse
check_dir /opt/cricketpulse/releases 2775 cricketpulse cricketpulse
section "SYMLINK VERIFICATION"
if [ -L /opt/cricketpulse/current ]; then
target=$(readlink /opt/cricketpulse/current)
if [ -d "$target" ]; then
pass "current symlink → $target (valid directory)"
else
fail "current symlink → $target (target does not exist)"
fi
else
fail "current symlink missing at /opt/cricketpulse/current"
fi
Step 3 — Integration: Security Permission Checks
Step 3 adds the security-focused permission checks that identify the two most dangerous misconfigurations: world-readable files in the configuration directory (which may expose secrets) and world-writable files in the application directory (which may allow unauthorised modification of application code). These checks use `find` with permission criteria, producing output that names every violating file.
The check for world-readable configuration files uses `find /etc/cricketpulse -perm -004 -type f`. The `-004` permission mask matches any file where the 'other read' bit is set, regardless of the other bits. The equivalent check for world-writable application files uses `-perm -002` to match any file where the 'other write' bit is set. Both checks count their results and report a failure if any violations exist.
# Step 3: Security permission checks
section "SECURITY — WORLD-READABLE CONFIG FILES"
world_readable=$(find /etc/cricketpulse -perm -004 -type f 2>/dev/null)
if [ -z "$world_readable" ]; then
pass "No world-readable files in /etc/cricketpulse"
else
while IFS= read -r f; do
perm=$(stat -c '%a' "$f")
fail "World-readable: $f (mode: $perm)"
done <<< "$world_readable"
fi
section "SECURITY — WORLD-WRITABLE APP FILES"
world_writable=$(find /opt/cricketpulse -perm -002 -type f 2>/dev/null)
world_writable_dirs=$(find /opt/cricketpulse -perm -002 -type d 2>/dev/null)
if [ -z "$world_writable" ] && [ -z "$world_writable_dirs" ]; then
pass "No world-writable files or dirs in /opt/cricketpulse"
else
for f in $world_writable $world_writable_dirs; do
perm=$(stat -c '%a' "$f")
fail "World-writable: $f (mode: $perm)"
done
fi
section "LOG DIRECTORY CHECK"
log_dir="/var/log/cricketpulse"
if [ -d "$log_dir" ]; then
log_count=$(find "$log_dir" -name '*.log' -type f | wc -l)
pass "Log directory exists with $log_count log file(s)"
else
fail "Log directory missing: $log_dir"
fi
Step 4 — Testing & Verification
Run the complete audit script against the prepared target environment. The intentional misconfigurations created in the setup — the world-readable `db.key` and the world-writable `bin/` directory — should cause the script to exit with a non-zero code and clearly identify the violations. Verify that the script correctly counts failures and that the exit code equals the number of violations found.
After verifying that failures are correctly detected, fix the two intentional misconfigurations with `chmod 640 /etc/cricketpulse/db.key` and `chmod 755 /opt/cricketpulse/bin`, then rerun the audit. The audit should now exit with code 0 and all checks should report PASS. This verify-fix-reverify cycle is the core workflow for using the audit in a deployment pipeline.
# Run the audit — expect failures from the intentional misconfigurations
sudo ./cricket_file_audit.sh
echo "Exit code: $?" # Should be non-zero (2 violations)
# Inspect the report
cat /tmp/cricket_audit_*.txt | grep -E '\[PASS\]|\[FAIL\]|RESULT'
# Fix the intentional misconfigurations
sudo chmod 640 /etc/cricketpulse/db.key
sudo chmod 755 /opt/cricketpulse/bin
# Rerun — should now exit 0
sudo ./cricket_file_audit.sh
echo "Exit code: $?" # Should be 0
# Test in CI mode: fail fast on first violation
sudo chmod 644 /etc/cricketpulse/db.key # Re-introduce one misconfiguration
sudo ./cricket_file_audit.sh && echo 'AUDIT PASSED' || echo "AUDIT FAILED: $? violations"
# Expected final output:
# CricketPulse File Audit — 2026-06-19 10:30:00
# === DIRECTORY OWNERSHIP & PERMISSIONS ==
# [PASS] /etc/cricketpulse: mode=750 owner=root:cricketpulse
# [PASS] /var/log/cricketpulse: mode=750 owner=cricketpulse:cricketpulse
# ...
# === SECURITY — WORLD-READABLE CONFIG FILES ==
# [FAIL] World-readable: /etc/cricketpulse/db.key (mode: 644)
# RESULT: FAILED — 1 violation(s) found
Warning: The `((FAILURES++)) || true` pattern is required because `set -e` treats any command that returns non-zero as an error. In Bash, `((expression))` returns exit code 1 when the expression evaluates to zero — and `((FAILURES++))` evaluates to zero when FAILURES is 0 before the increment. Without `|| true`, the script exits on the very first failure increment when FAILURES is 0, preventing subsequent checks from running. The `|| true` ensures the overall expression always succeeds, letting the failure count accumulate correctly.
Extension Challenge: Add three additional audit sections to `cricket_file_audit.sh`. First, add a STALE BACKUPS check using `find /etc/cricketpulse -name '*.bak' -mtime +7` to identify backup files older than one week that should be rotated. Second, add a LOG ROTATION check that verifies `/etc/logrotate.d/cricketpulse` exists and contains the string 'compress' — confirming log compression is configured. Third, add a DISK SPACE check that fails if any filesystem used by the application is above 85% — using `df` output parsed with `awk` to extract the usage percentage for specific mount points.
- Audit scripts must communicate results via exit codes (0 for pass, non-zero for fail) so CI/CD pipelines can act on them without parsing text output.
- Use `stat -c '%a %U %G'` to read permissions and ownership in a single system call — combining all three into one `stat` invocation is more efficient than calling `stat` three times.
- `find -perm -004` matches files where the world-read bit is set; `-perm -002` matches world-writable — these are the two permission bits most commonly misconfigured in deployments.
- The `((FAILURES++)) || true` pattern is necessary with `set -e` because Bash arithmetic expressions return exit code 1 when the result is zero, which would trigger early script exit.
- The verify-fix-reverify cycle — run audit, identify violations, fix them, rerun audit — is the core workflow for integrating automated security checks into a deployment process.
- Automated filesystem audits codify security requirements as executable specifications, replacing tribal knowledge with verifiable, repeatable checks that work consistently across all deployments.