#!/usr/bin/env bash ######################################################################################### #### hetzner-firewall-auditor.sh — Audit Hetzner Cloud firewall rules #### #### Finds open ports, missing firewalls, overly permissive rules, and misconfigs #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./hetzner-firewall-auditor.sh --full #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' 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 "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } # ── Severity counters ──────────────────────────────────────────────── TOTAL_CRIT=0 TOTAL_WARN=0 TOTAL_INFO=0 TOTAL_OK=0 flag_crit() { ((TOTAL_CRIT++)) || true; } flag_warn() { ((TOTAL_WARN++)) || true; } flag_info() { ((TOTAL_INFO++)) || true; } flag_ok() { ((TOTAL_OK++)) || true; } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" DANGEROUS_PORTS="${DANGEROUS_PORTS:-22,3389,3306,5432,1433,6379,27017,9200,8080,8443}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── Credentials ─────────────────────────────────────────────────────── HCLOUD_TOKEN="${HCLOUD_TOKEN:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── API helpers ────────────────────────────────────────────────────── hetzner_api() { local method="$1" endpoint="$2" shift 2 local attempt=0 max_attempts=3 while (( attempt < max_attempts )); do local http_code http_code=$(curl -s -o /tmp/hfa_resp.json -w "%{http_code}" \ -X "$method" \ -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ -H "Content-Type: application/json" \ "https://api.hetzner.cloud/v1${endpoint}" "$@") verbose "API ${method} ${endpoint} → HTTP ${http_code}" if [[ "$http_code" == "429" ]]; then ((attempt++)) || true local wait=$(( attempt * 5 )) warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})" sleep "$wait" continue fi cat /tmp/hfa_resp.json return 0 done err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}" return 1 } check_credentials() { [[ -z "$HCLOUD_TOKEN" ]] && die "HCLOUD_TOKEN not set" } check_deps() { command -v curl &>/dev/null || die "curl is required" command -v jq &>/dev/null || die "jq is required" } # ── Server helpers ─────────────────────────────────────────────────── get_all_servers() { local page=1 per_page=50 result="[]" while true; do local resp resp=$(hetzner_api GET "/servers?page=${page}&per_page=${per_page}") local page_data page_data=$(echo "$resp" | jq '.servers // []' 2>/dev/null) local count count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$count" -eq 0 ]] && break result=$(echo "$result" "$page_data" | jq -s '.[0] + .[1]') local total total=$(echo "$resp" | jq '.meta.pagination.total_entries // 0' 2>/dev/null) (( page * per_page >= total )) && break ((page++)) || true done echo "$result" } # ── Firewall helpers ───────────────────────────────────────────────── get_all_firewalls() { local page=1 per_page=50 result="[]" while true; do local resp resp=$(hetzner_api GET "/firewalls?page=${page}&per_page=${per_page}") local page_data page_data=$(echo "$resp" | jq '.firewalls // []' 2>/dev/null) local count count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$count" -eq 0 ]] && break result=$(echo "$result" "$page_data" | jq -s '.[0] + .[1]') local total total=$(echo "$resp" | jq '.meta.pagination.total_entries // 0' 2>/dev/null) (( page * per_page >= total )) && break ((page++)) || true done echo "$result" } # ── Port-to-service mapping ───────────────────────────────────────── port_to_service() { local port="$1" case "$port" in 22) echo "SSH" ;; 80) echo "HTTP" ;; 443) echo "HTTPS" ;; 3306) echo "MySQL" ;; 5432) echo "PostgreSQL" ;; 1433) echo "MSSQL" ;; 3389) echo "RDP" ;; 6379) echo "Redis" ;; 27017) echo "MongoDB" ;; 9200) echo "Elasticsearch" ;; 8080) echo "HTTP-Alt" ;; 8443) echo "HTTPS-Alt" ;; 53) echo "DNS" ;; 25) echo "SMTP" ;; 5900) echo "VNC" ;; 11211) echo "Memcached" ;; 2379) echo "etcd" ;; 9090) echo "Prometheus" ;; *) echo "" ;; esac } # ── Check if port is in dangerous list ─────────────────────────────── is_dangerous_port() { local port="$1" local IFS=',' for dp in $DANGEROUS_PORTS; do if [[ "$port" == "$dp" ]]; then return 0 fi done return 1 } # ══════════════════════════════════════════════════════════════════════ # OPEN PORTS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_open_ports() { log "Auditing firewall rules for dangerous open ports..." log "Dangerous ports: ${DANGEROUS_PORTS}" echo "" printf " %-10s %-22s %-8s %-8s %-18s %-12s %s\n" \ "FW_ID" "FW_NAME" "PORT" "PROTO" "SOURCE" "SERVICE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..95})" local fw_json fw_json=$(get_all_firewalls) echo "$fw_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r fw; do local fw_id fw_name fw_id=$(echo "$fw" | jq -r '.id' 2>/dev/null) fw_name=$(echo "$fw" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$fw" | jq -c '.rules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local direction protocol port_str direction=$(echo "$rule" | jq -r '.direction // "in"' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "tcp"' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.port // ""' 2>/dev/null) [[ "$direction" != "in" ]] && continue local has_open="false" while IFS= read -r src; do if [[ "$src" == "0.0.0.0/0" || "$src" == "::/0" ]]; then has_open="true" break fi done < <(echo "$rule" | jq -r '.source_ips[]? // empty' 2>/dev/null) [[ "$has_open" != "true" ]] && continue if [[ -z "$port_str" || "$port_str" == "null" ]]; then local IFS=',' for dp in $DANGEROUS_PORTS; do local svc svc=$(port_to_service "$dp") printf " %-10s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "$dp" "$protocol" \ "0.0.0.0/0" "${svc:-unknown}" "$RED" "CRITICAL" "$RESET" flag_crit done continue fi if [[ "$port_str" == *-* ]]; then local range_start range_end range_start="${port_str%-*}" range_end="${port_str#*-}" local IFS=',' for dp in $DANGEROUS_PORTS; do if [[ "$dp" -ge "$range_start" && "$dp" -le "$range_end" ]]; then local svc svc=$(port_to_service "$dp") printf " %-10s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "$dp" "$protocol" \ "0.0.0.0/0" "${svc:-unknown}" "$RED" "CRITICAL" "$RESET" flag_crit fi done continue fi if is_dangerous_port "$port_str"; then local svc svc=$(port_to_service "$port_str") printf " %-10s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "$port_str" "$protocol" \ "0.0.0.0/0" "${svc:-unknown}" "$RED" "CRITICAL" "$RESET" flag_crit elif [[ "$port_str" == "80" || "$port_str" == "443" ]]; then local svc svc=$(port_to_service "$port_str") printf " %-10s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "$port_str" "$protocol" \ "0.0.0.0/0" "${svc:-$port_str}" "$CYAN" "INFO" "$RESET" flag_info fi done done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNPROTECTED SERVERS # ══════════════════════════════════════════════════════════════════════ audit_unprotected() { log "Checking for servers without firewalls..." echo "" printf " %-10s %-22s %-16s %-10s %s\n" \ "SRV_ID" "NAME" "IP" "STATUS" "FIREWALL" printf " %s\n" "$(printf '%.0s─' {1..75})" local servers servers=$(get_all_servers) local fw_json fw_json=$(get_all_firewalls) local protected_ids protected_ids=$(echo "$fw_json" | jq -r \ '[.[].applied_to[]? | select(.type == "server") | .server.id] | unique | .[]' 2>/dev/null || true) echo "$servers" | jq -c '.[]' 2>/dev/null | while IFS= read -r srv; do local sid sname ip status sid=$(echo "$srv" | jq -r '.id' 2>/dev/null) sname=$(echo "$srv" | jq -r '.name // "unknown"' 2>/dev/null) ip=$(echo "$srv" | jq -r '.public_net.ipv4.ip // "N/A"' 2>/dev/null) status=$(echo "$srv" | jq -r '.status // "unknown"' 2>/dev/null) local has_fw="false" if echo "$protected_ids" | grep -q "^${sid}$" 2>/dev/null; then has_fw="true" fi if [[ "$has_fw" == "false" ]]; then printf " %-10s %-22s %-16s %-10s %b%s%b\n" \ "$sid" "${sname:0:20}" "$ip" "$status" \ "$RED" "NONE — UNPROTECTED" "$RESET" flag_crit else printf " %-10s %-22s %-16s %-10s %b%s%b\n" \ "$sid" "${sname:0:20}" "$ip" "$status" \ "$GREEN" "✓ Protected" "$RESET" flag_ok fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # PERMISSIVE RULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_permissive() { log "Auditing overly permissive firewall rules..." echo "" printf " %-10s %-22s %-10s %-8s %-18s %-14s %s\n" \ "FW_ID" "FW_NAME" "PORTS" "PROTO" "SOURCE" "ISSUE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..100})" local fw_json fw_json=$(get_all_firewalls) echo "$fw_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r fw; do local fw_id fw_name fw_id=$(echo "$fw" | jq -r '.id' 2>/dev/null) fw_name=$(echo "$fw" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$fw" | jq -c '.rules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local direction protocol port_str direction=$(echo "$rule" | jq -r '.direction // "in"' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "tcp"' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.port // ""' 2>/dev/null) [[ "$direction" != "in" ]] && continue local has_open="false" local source_display="" while IFS= read -r src; do if [[ "$src" == "0.0.0.0/0" || "$src" == "::/0" ]]; then has_open="true" source_display="$src" break fi done < <(echo "$rule" | jq -r '.source_ips[]? // empty' 2>/dev/null) if [[ "$has_open" == "true" ]] && [[ -z "$port_str" || "$port_str" == "null" ]]; then printf " %-10s %-22s %-10s %-8s %-18s %-14s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "ALL" "$protocol" \ "$source_display" "all-ports" "$RED" "CRITICAL" "$RESET" flag_crit continue fi while IFS= read -r src; do if [[ -n "$src" && "$src" != "null" ]]; then if [[ "$src" == *"/8" || "$src" == *"/16" ]]; then printf " %-10s %-22s %-10s %-8s %-18s %-14s %b%s%b\n" \ "$fw_id" "${fw_name:0:20}" "${port_str:-ALL}" "$protocol" \ "${src:0:16}" "wide-cidr" "$YELLOW" "WARN" "$RESET" flag_warn fi fi done < <(echo "$rule" | jq -r '.source_ips[]? // empty' 2>/dev/null) done done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED FIREWALLS # ══════════════════════════════════════════════════════════════════════ audit_unused() { log "Checking for unused firewalls..." echo "" printf " %-10s %-28s %-8s %s\n" \ "FW_ID" "FW_NAME" "RULES" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..60})" local fw_json fw_json=$(get_all_firewalls) echo "$fw_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r fw; do local fw_id fw_name rule_count applied_count fw_id=$(echo "$fw" | jq -r '.id' 2>/dev/null) fw_name=$(echo "$fw" | jq -r '.name // "unnamed"' 2>/dev/null) rule_count=$(echo "$fw" | jq '[.rules[]?] | length' 2>/dev/null || echo 0) applied_count=$(echo "$fw" | jq '[.applied_to[]?] | length' 2>/dev/null || echo 0) if [[ "$applied_count" -eq 0 ]]; then printf " %-10s %-28s %-8s %b%s%b\n" \ "$fw_id" "${fw_name:0:26}" "$rule_count" \ "$YELLOW" "UNUSED" "$RESET" flag_warn else verbose "Firewall ${fw_id} (${fw_name}): applied to ${applied_count} resource(s)" flag_ok fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # LIST ALL RULES # ══════════════════════════════════════════════════════════════════════ list_rules() { log "Listing all firewall rules..." echo "" printf " %-10s %-20s %-8s %-8s %-12s %-18s %s\n" \ "FW_ID" "FW_NAME" "DIR" "PROTO" "PORTS" "SOURCE/DEST" "SERVICE" printf " %s\n" "$(printf '%.0s─' {1..90})" local fw_json fw_json=$(get_all_firewalls) echo "$fw_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r fw; do local fw_id fw_name fw_id=$(echo "$fw" | jq -r '.id' 2>/dev/null) fw_name=$(echo "$fw" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$fw" | jq -c '.rules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local direction protocol port_str direction=$(echo "$rule" | jq -r '.direction // "in"' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "tcp"' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.port // "all"' 2>/dev/null) [[ "$port_str" == "null" ]] && port_str="all" local cidr_list if [[ "$direction" == "in" ]]; then cidr_list=$(echo "$rule" | jq -r '.source_ips[]? // empty' 2>/dev/null | head -1) else cidr_list=$(echo "$rule" | jq -r '.destination_ips[]? // empty' 2>/dev/null | head -1) fi [[ -z "$cidr_list" ]] && cidr_list="any" local svc="" if [[ "$port_str" =~ ^[0-9]+$ ]]; then svc=$(port_to_service "$port_str") fi local dir_color="$CYAN" [[ "$direction" == "out" ]] && dir_color="$YELLOW" printf " %-10s %-20s %b%-8s%b %-8s %-12s %-18s %s\n" \ "$fw_id" "${fw_name:0:18}" "$dir_color" "$direction" "$RESET" \ "$protocol" "${port_str:0:10}" "${cidr_list:0:16}" "${svc}" done done echo "" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local elapsed elapsed=$(( $(date +%s) - START_TIME )) echo "" echo " ══════════════════════════════════════════" echo " Firewall Audit Summary" echo " ══════════════════════════════════════════" printf " %-20s %b%d%b\n" "CRITICAL:" "$RED" "$TOTAL_CRIT" "$RESET" printf " %-20s %b%d%b\n" "WARN:" "$YELLOW" "$TOTAL_WARN" "$RESET" printf " %-20s %b%d%b\n" "INFO:" "$CYAN" "$TOTAL_INFO" "$RESET" printf " %-20s %b%d%b\n" "OK:" "$GREEN" "$TOTAL_OK" "$RESET" echo " ──────────────────────────────────────────" printf " Completed in %ds\n" "$elapsed" echo "" if [[ "$TOTAL_CRIT" -gt 0 ]]; then echo -e " ${RED}${BOLD}Action required:${RESET} ${TOTAL_CRIT} critical finding(s)" echo "" echo " Top recommendations:" echo " • Assign firewalls to all unprotected servers" echo " • Close 0.0.0.0/0 rules on SSH (22), RDP (3389), and database ports" echo " • Replace all-port allow rules with specific port lists" echo " • Remove unused firewalls to reduce configuration sprawl" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" echo " Suggestions:" echo " • Review wide CIDR rules and narrow where possible" echo " • Delete unused firewalls" echo " • Restrict outbound where applicable" echo "" else echo -e " ${GREEN}All checks passed${RESET}" echo "" fi } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ show_help() { cat <&2 exit 1 fi RUN_MODE="${modes[*]}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors check_deps check_credentials START_TIME=$(date +%s) echo "" echo -e "${BOLD}Hetzner Cloud Firewall Auditor${RESET}" echo -e "Mode: ${RUN_MODE}" echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "" for mode in $RUN_MODE; do case "$mode" in open-ports) audit_open_ports ;; unprotected) audit_unprotected ;; permissive) audit_permissive ;; unused) audit_unused ;; rules) list_rules ;; esac done print_summary if [[ "$TOTAL_CRIT" -gt 0 ]]; then exit 2 elif [[ "$TOTAL_WARN" -gt 0 ]]; then exit 1 fi exit 0 } main "$@"