#!/usr/bin/env bash ###################################################################################### #### firewall-rule-diff.sh — Detect firewall rule drift against a saved baseline #### #### Supports UFW, iptables, and nftables. Saves snapshots, diffs against #### #### baseline, exports Prometheus metrics via textfile collector. #### #### Requires: bash 4+, diff, coreutils #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### sudo ./firewall-rule-diff.sh --save #### #### sudo ./firewall-rule-diff.sh --check #### #### #### #### See --help for all options. #### ###################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── MODE="" # save or check BACKEND="" # auto-detect: ufw, iptables, nftables BASELINE_DIR="/etc/firewall-baseline" MAX_AGE_DAYS=30 TEXTFILE_MODE=false PROM_FILE="/var/lib/node_exporter/firewall_drift.prom" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" JUNIT_FILE="${JUNIT_FILE:-firewall-drift-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 WARN=0 TOTAL=0 RESULTS=() START_TIME="" RULES_ADDED=0 RULES_REMOVED=0 RULES_TOTAL=0 DRIFT_DETECTED=0 BASELINE_AGE=0 DETECTED_BACKEND="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_warn() { local name="$1" local detail="${2:-}" ((WARN++)) || true ((TOTAL++)) || true RESULTS+=("WARN|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${detail}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e " ${YELLOW}⊘${RESET} ${name}${detail:+ — ${detail}}" fi } # ── Help ────────────────────────────────────────────────────────────── show_help() { cat <<'EOF' Usage: firewall-rule-diff.sh [OPTIONS] Detect firewall rule drift by comparing current state against a saved baseline. Supports UFW, iptables, and nftables with auto-detection. Modes: --save Save current firewall rules as new baseline --check Compare current rules against baseline (default) Options: --backend BACKEND Force backend: ufw, iptables, nftables (default: auto-detect) --baseline-dir PATH Baseline storage directory (default: /etc/firewall-baseline/) --max-age DAYS Warn if baseline older than N days (default: 30) --textfile Write Prometheus metrics to textfile collector --prom-file PATH Textfile path (default: /var/lib/node_exporter/firewall_drift.prom) --format FORMAT Output: text (default), tap, junit --junit-file FILE JUnit output path (default: firewall-drift-results.xml) --verbose Show debug output --no-color Disable colored output -h, --help Show this help Examples: sudo ./firewall-rule-diff.sh --save sudo ./firewall-rule-diff.sh --check sudo ./firewall-rule-diff.sh --check --textfile sudo ./firewall-rule-diff.sh --backend iptables --check sudo ./firewall-rule-diff.sh --check --max-age 7 EOF exit 0 } # ── Parse Arguments ─────────────────────────────────────────────────── parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --save) MODE="save"; shift ;; --check) MODE="check"; shift ;; --backend) BACKEND="$2"; shift 2 ;; --baseline-dir) BASELINE_DIR="$2"; shift 2 ;; --max-age) MAX_AGE_DAYS="$2"; shift 2 ;; --textfile) TEXTFILE_MODE=true; shift ;; --prom-file) PROM_FILE="$2"; shift 2 ;; --format) OUTPUT_FORMAT="$2"; shift 2 ;; --junit-file) JUNIT_FILE="$2"; shift 2 ;; --verbose) VERBOSE=true; shift ;; --no-color) COLOR="never"; shift ;; -h|--help) show_help ;; *) err "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;; esac done if [[ -z "$MODE" ]]; then MODE="check" fi } # ── Detect Backend ──────────────────────────────────────────────────── detect_backend() { if [[ -n "$BACKEND" ]]; then DETECTED_BACKEND="$BACKEND" verbose "Backend forced: ${DETECTED_BACKEND}" return fi if command -v ufw &>/dev/null && ufw status &>/dev/null; then local ufw_status ufw_status=$(ufw status 2>/dev/null | head -1) if [[ "$ufw_status" == *"active"* ]]; then DETECTED_BACKEND="ufw" verbose "Detected active UFW" return fi fi if command -v nft &>/dev/null; then local nft_rules nft_rules=$(nft list ruleset 2>/dev/null | wc -l) if [[ "$nft_rules" -gt 0 ]]; then DETECTED_BACKEND="nftables" verbose "Detected nftables with ${nft_rules} lines" return fi fi if command -v iptables-save &>/dev/null; then DETECTED_BACKEND="iptables" verbose "Detected iptables" return fi err "No supported firewall backend found (ufw, nftables, iptables)" exit 1 } # ── Snapshot Functions ──────────────────────────────────────────────── snapshot_ufw() { local dir="$1" ufw status numbered > "${dir}/ufw-status.txt" 2>/dev/null || true ufw status verbose > "${dir}/ufw-verbose.txt" 2>/dev/null || true if [[ -f /etc/ufw/user.rules ]]; then cp /etc/ufw/user.rules "${dir}/user.rules" fi if [[ -f /etc/ufw/user6.rules ]]; then cp /etc/ufw/user6.rules "${dir}/user6.rules" fi # count rules from numbered output (skip header lines) RULES_TOTAL=$(grep -cE '^\[' "${dir}/ufw-status.txt" 2>/dev/null) || RULES_TOTAL=0 verbose "UFW snapshot: ${RULES_TOTAL} rules" } snapshot_iptables() { local dir="$1" iptables-save > "${dir}/iptables-v4.rules" 2>/dev/null || true if command -v ip6tables-save &>/dev/null; then ip6tables-save > "${dir}/iptables-v6.rules" 2>/dev/null || true fi # count non-comment, non-empty lines RULES_TOTAL=$(grep -cvE '^(#|$|\*|COMMIT|:)' "${dir}/iptables-v4.rules" 2>/dev/null) || RULES_TOTAL=0 if [[ -f "${dir}/iptables-v6.rules" ]]; then local v6_count v6_count=$(grep -cvE '^(#|$|\*|COMMIT|:)' "${dir}/iptables-v6.rules" 2>/dev/null) || v6_count=0 RULES_TOTAL=$((RULES_TOTAL + v6_count)) fi verbose "iptables snapshot: ${RULES_TOTAL} rules" } snapshot_nftables() { local dir="$1" nft list ruleset > "${dir}/nftables.rules" 2>/dev/null || true RULES_TOTAL=$(grep -cE '^\s+(rule|chain|table)' "${dir}/nftables.rules" 2>/dev/null) || RULES_TOTAL=0 verbose "nftables snapshot: ${RULES_TOTAL} lines" } take_snapshot() { local dir="$1" case "$DETECTED_BACKEND" in ufw) snapshot_ufw "$dir" ;; iptables) snapshot_iptables "$dir" ;; nftables) snapshot_nftables "$dir" ;; esac echo "$DETECTED_BACKEND" > "${dir}/backend.txt" date +%s > "${dir}/timestamp.txt" date -Is > "${dir}/timestamp-human.txt" } # ── Save Mode ───────────────────────────────────────────────────────── do_save() { mkdir -p "${BASELINE_DIR}" local snapshot_dir="${BASELINE_DIR}/baseline" # clean up any previous baseline if [[ -d "$snapshot_dir" ]]; then local prev_ts prev_ts=$(cat "${snapshot_dir}/timestamp.txt" 2>/dev/null || echo "unknown") local archive_dir="${BASELINE_DIR}/archive-${prev_ts}" mv "$snapshot_dir" "$archive_dir" 2>/dev/null || rm -rf "$snapshot_dir" verbose "Archived previous baseline" fi mkdir -p "$snapshot_dir" verbose "Taking ${DETECTED_BACKEND} snapshot to ${snapshot_dir}" take_snapshot "$snapshot_dir" if [[ ! -f "${snapshot_dir}/backend.txt" ]]; then err "Snapshot failed — ${snapshot_dir}/backend.txt not created" exit 1 fi if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "${BOLD}Firewall Rule Diff${RESET}" echo "Backend: ${DETECTED_BACKEND}" echo "Baseline: ${snapshot_dir}" echo "Time: $(cat "${snapshot_dir}/timestamp-human.txt")" echo "Rules: ${RULES_TOTAL}" echo "" echo -e " ${GREEN}✓${RESET} Baseline saved — ${RULES_TOTAL} rules (${DETECTED_BACKEND})" elif [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "1..1" echo "ok 1 - Baseline saved (${RULES_TOTAL} rules, ${DETECTED_BACKEND})" fi } # ── Check Mode ──────────────────────────────────────────────────────── do_check() { START_TIME=$(date +%s) local baseline_dir="${BASELINE_DIR}/baseline" if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo -e "${BOLD}Firewall Rule Diff${RESET}" echo "Backend: ${DETECTED_BACKEND}" echo "Baseline: ${baseline_dir}" echo "Time: $(date -Is)" echo "" elif [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "TAP version 13" fi # check baseline exists if [[ ! -d "$baseline_dir" ]]; then record_fail "Baseline exists" "no baseline found — run with --save first" DRIFT_DETECTED=1 print_summary return fi # check backend matches local baseline_backend baseline_backend=$(cat "${baseline_dir}/backend.txt" 2>/dev/null || echo "unknown") if [[ "$baseline_backend" != "$DETECTED_BACKEND" ]]; then record_fail "Backend match" "baseline uses ${baseline_backend}, current is ${DETECTED_BACKEND}" DRIFT_DETECTED=1 else record_pass "Backend match" "${DETECTED_BACKEND}" fi # check baseline age local baseline_ts baseline_ts=$(cat "${baseline_dir}/timestamp.txt" 2>/dev/null || echo "0") local now now=$(date +%s) BASELINE_AGE=$((now - baseline_ts)) local age_days=$((BASELINE_AGE / 86400)) if [[ $age_days -gt $MAX_AGE_DAYS ]]; then record_warn "Baseline age" "${age_days} days old (threshold: ${MAX_AGE_DAYS})" else record_pass "Baseline age" "${age_days} days old" fi # take current snapshot to temp dir local tmp_dir tmp_dir=$(mktemp -d) trap 'rm -rf "'"$tmp_dir"'"' EXIT take_snapshot "$tmp_dir" if [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo -e "${BOLD}Rule Comparison${RESET}" fi # diff based on backend case "$DETECTED_BACKEND" in ufw) diff_ufw "$baseline_dir" "$tmp_dir" ;; iptables) diff_iptables "$baseline_dir" "$tmp_dir" ;; nftables) diff_nftables "$baseline_dir" "$tmp_dir" ;; esac print_summary } # ── Diff Functions ──────────────────────────────────────────────────── diff_rules_file() { local label="$1" local baseline_file="$2" local current_file="$3" if [[ ! -f "$baseline_file" ]] && [[ ! -f "$current_file" ]]; then verbose "Both files missing for ${label} — skipping" return fi if [[ ! -f "$baseline_file" ]]; then record_fail "${label}" "file missing from baseline but present now" DRIFT_DETECTED=1 return fi if [[ ! -f "$current_file" ]]; then record_fail "${label}" "file present in baseline but missing now" DRIFT_DETECTED=1 return fi local diff_output diff_output=$(diff --unified=0 "$baseline_file" "$current_file" 2>/dev/null) || true if [[ -z "$diff_output" ]]; then record_pass "${label}" "no changes" return fi DRIFT_DETECTED=1 local added removed added=$(echo "$diff_output" | grep -c '^+[^+]' 2>/dev/null) || added=0 removed=$(echo "$diff_output" | grep -c '^-[^-]' 2>/dev/null) || removed=0 RULES_ADDED=$((RULES_ADDED + added)) RULES_REMOVED=$((RULES_REMOVED + removed)) record_fail "${label}" "${added} added, ${removed} removed" if [[ "$VERBOSE" == "true" || "$OUTPUT_FORMAT" == "text" ]]; then # show the actual diff lines (limit to 20 lines) local count=0 while IFS= read -r line; do if [[ "$line" == +* && "$line" != +++* ]]; then echo -e " ${GREEN}${line}${RESET}" ((count++)) || true elif [[ "$line" == -* && "$line" != ---* ]]; then echo -e " ${RED}${line}${RESET}" ((count++)) || true fi [[ $count -ge 20 ]] && { echo " ... (truncated)"; break; } done <<< "$diff_output" fi } diff_ufw() { local baseline="$1" local current="$2" diff_rules_file "UFW status" "${baseline}/ufw-status.txt" "${current}/ufw-status.txt" diff_rules_file "UFW IPv4 rules" "${baseline}/user.rules" "${current}/user.rules" diff_rules_file "UFW IPv6 rules" "${baseline}/user6.rules" "${current}/user6.rules" # rule count comparison local baseline_count current_count baseline_count=$(grep -cE '^\[' "${baseline}/ufw-status.txt" 2>/dev/null) || baseline_count=0 current_count=$(grep -cE '^\[' "${current}/ufw-status.txt" 2>/dev/null) || current_count=0 if [[ $baseline_count -ne $current_count ]]; then record_fail "Rule count" "baseline: ${baseline_count}, current: ${current_count}" DRIFT_DETECTED=1 else record_pass "Rule count" "${current_count} rules" fi } diff_iptables() { local baseline="$1" local current="$2" diff_rules_file "iptables IPv4 rules" "${baseline}/iptables-v4.rules" "${current}/iptables-v4.rules" diff_rules_file "iptables IPv6 rules" "${baseline}/iptables-v6.rules" "${current}/iptables-v6.rules" # chain count comparison local baseline_chains current_chains baseline_chains=$(grep -cE '^:' "${baseline}/iptables-v4.rules" 2>/dev/null) || baseline_chains=0 current_chains=$(grep -cE '^:' "${current}/iptables-v4.rules" 2>/dev/null) || current_chains=0 if [[ $baseline_chains -ne $current_chains ]]; then record_fail "Chain count (IPv4)" "baseline: ${baseline_chains}, current: ${current_chains}" DRIFT_DETECTED=1 else record_pass "Chain count (IPv4)" "${current_chains} chains" fi } diff_nftables() { local baseline="$1" local current="$2" diff_rules_file "nftables ruleset" "${baseline}/nftables.rules" "${current}/nftables.rules" # table count comparison local baseline_tables current_tables baseline_tables=$(grep -c '^table' "${baseline}/nftables.rules" 2>/dev/null) || baseline_tables=0 current_tables=$(grep -c '^table' "${current}/nftables.rules" 2>/dev/null) || current_tables=0 if [[ $baseline_tables -ne $current_tables ]]; then record_fail "Table count" "baseline: ${baseline_tables}, current: ${current_tables}" DRIFT_DETECTED=1 else record_pass "Table count" "${current_tables} tables" fi } # ── Summary ─────────────────────────────────────────────────────────── print_summary() { local end_time end_time=$(date +%s) local elapsed=$((end_time - START_TIME)) if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "1..${TOTAL}" elif [[ "$OUTPUT_FORMAT" == "text" ]]; then echo "" echo "────────────────────────────────────────" echo -e "${BOLD}Summary${RESET} ${DETECTED_BACKEND}" echo -e " ${PASS} passed ${FAIL} failed ${WARN} skipped (${elapsed}s)" if [[ $DRIFT_DETECTED -eq 1 ]]; then echo -e " Rules added: ${RULES_ADDED} removed: ${RULES_REMOVED}" echo -e " ${RED}Drift detected.${RESET}" else echo -e " ${GREEN}No drift detected.${RESET}" fi echo "────────────────────────────────────────" fi if [[ "$OUTPUT_FORMAT" == "junit" ]]; then write_junit fi if [[ "$TEXTFILE_MODE" == "true" ]]; then write_prometheus fi } # ── JUnit Output ────────────────────────────────────────────────────── write_junit() { local end_time end_time=$(date +%s) local elapsed=$((end_time - START_TIME)) { echo '' echo "" echo " " for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # escape XML name="${name//&/&}" name="${name///>}" detail="${detail//&/&}" detail="${detail///>}" echo " " if [[ "$status" == "FAIL" ]]; then echo " " elif [[ "$status" == "WARN" ]]; then echo " " fi echo " " done echo " " echo "" } > "$JUNIT_FILE" verbose "JUnit report written to ${JUNIT_FILE}" } # ── Prometheus Output ───────────────────────────────────────────────── write_prometheus() { local prom_dir prom_dir=$(dirname "$PROM_FILE") if [[ ! -d "$prom_dir" ]]; then warn "Prometheus textfile directory does not exist: ${prom_dir}" return fi local tmp_file="${PROM_FILE}.$$" { echo "# HELP firewall_drift_detected Whether firewall rules differ from baseline" echo "# TYPE firewall_drift_detected gauge" echo "firewall_drift_detected ${DRIFT_DETECTED}" echo "# HELP firewall_rules_added Rules added since baseline" echo "# TYPE firewall_rules_added gauge" echo "firewall_rules_added ${RULES_ADDED}" echo "# HELP firewall_rules_removed Rules removed since baseline" echo "# TYPE firewall_rules_removed gauge" echo "firewall_rules_removed ${RULES_REMOVED}" echo "# HELP firewall_rules_total Current total firewall rules" echo "# TYPE firewall_rules_total gauge" echo "firewall_rules_total ${RULES_TOTAL}" echo "# HELP firewall_baseline_age_seconds Seconds since baseline was saved" echo "# TYPE firewall_baseline_age_seconds gauge" echo "firewall_baseline_age_seconds ${BASELINE_AGE}" echo "# HELP firewall_scan_timestamp Unix timestamp of last scan" echo "# TYPE firewall_scan_timestamp gauge" echo "firewall_scan_timestamp $(date +%s)" echo "# HELP firewall_backend Active firewall backend" echo "# TYPE firewall_backend gauge" echo "firewall_backend{backend=\"${DETECTED_BACKEND}\"} 1" } > "$tmp_file" mv "$tmp_file" "$PROM_FILE" verbose "Prometheus metrics written to ${PROM_FILE}" } # ── Main ────────────────────────────────────────────────────────────── main() { setup_colors parse_args "$@" setup_colors # re-apply after --no-color if [[ $EUID -ne 0 ]]; then err "This script must be run as root." exit 1 fi detect_backend case "$MODE" in save) do_save ;; check) do_check ;; esac if [[ $FAIL -gt 0 ]]; then exit 1 fi exit 0 } main "$@"