#!/usr/bin/env bash ######################################################################################### #### gcp-firewall-auditor.sh — Audit GCP VPC firewall rules for risky configs #### #### Finds 0.0.0.0/0 rules, dangerous ports, overly permissive access, unused rules #### #### Requires: bash 4+, gcloud CLI, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./gcp-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}" GCP_PROJECT="" VPC_NETWORK="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── Dependency and credential checks ──────────────────────────────── check_deps() { command -v gcloud &>/dev/null || die "gcloud CLI is required (install: https://cloud.google.com/sdk/docs/install)" command -v jq &>/dev/null || die "jq is required" } check_credentials() { local account account=$(gcloud auth list --filter="status:ACTIVE" --format="value(account)" 2>/dev/null) [[ -z "$account" ]] && die "No active gcloud credentials — run 'gcloud auth login'" if [[ -n "$GCP_PROJECT" ]]; then gcloud config set project "$GCP_PROJECT" --quiet 2>/dev/null \ || die "Cannot set project: ${GCP_PROJECT}" else GCP_PROJECT=$(gcloud config get-value project 2>/dev/null) [[ -z "$GCP_PROJECT" || "$GCP_PROJECT" == "(unset)" ]] && die "No project set — use --project or 'gcloud config set project'" fi verbose "Account: ${account}" log "Project: ${GCP_PROJECT}" } # ── gcloud wrapper ─────────────────────────────────────────────────── gc_cmd() { local args=("$@") [[ -n "$GCP_PROJECT" ]] && args+=(--project "$GCP_PROJECT") verbose "gcloud ${args[*]}" gcloud "${args[@]}" } # ── 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 } # ── Check if port falls in a range ─────────────────────────────────── port_in_range() { local port="$1" range="$2" if [[ "$range" == *-* ]]; then local start="${range%-*}" local end="${range#*-}" [[ "$port" -ge "$start" && "$port" -le "$end" ]] else [[ "$port" == "$range" ]] fi } # ── Fetch firewall rules ──────────────────────────────────────────── fetch_rules() { local args=(compute firewall-rules list --format=json) if [[ -n "$VPC_NETWORK" ]]; then args+=(--filter="network~${VPC_NETWORK}") fi gc_cmd "${args[@]}" 2>/dev/null } # ══════════════════════════════════════════════════════════════════════ # OPEN PORTS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_open_ports() { log "Auditing firewall rules for dangerous open ports..." log "Dangerous ports: ${DANGEROUS_PORTS}" echo "" printf " %-28s %-14s %-8s %-8s %-18s %s\n" \ "RULE_NAME" "NETWORK" "PROTO" "PORT" "SOURCE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..95})" local rules_json rules_json=$(fetch_rules) echo "$rules_json" | jq -c '.[] | select(.direction == "INGRESS" and .disabled != true)' 2>/dev/null | while IFS= read -r rule; do local rule_name network rule_name=$(echo "$rule" | jq -r '.name') network=$(echo "$rule" | jq -r '.network' | rev | cut -d/ -f1 | rev) local has_open="false" while IFS= read -r src; do if [[ "$src" == "0.0.0.0/0" ]]; then has_open="true" break fi done < <(echo "$rule" | jq -r '.sourceRanges[]? // empty' 2>/dev/null) [[ "$has_open" != "true" ]] && continue echo "$rule" | jq -c '.allowed[]? // empty' 2>/dev/null | while IFS= read -r allowed; do local protocol protocol=$(echo "$allowed" | jq -r '.IPProtocol') local ports ports=$(echo "$allowed" | jq -r '.ports[]? // empty' 2>/dev/null) if [[ -z "$ports" ]]; then if [[ "$protocol" == "all" ]]; then printf " %-28s %-14s %-8s %-8s %-18s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "all" "all" \ "0.0.0.0/0" "$RED" "CRITICAL" "$RESET" flag_crit else local IFS=',' for dp in $DANGEROUS_PORTS; do local svc svc=$(port_to_service "$dp") printf " %-28s %-14s %-8s %-8s %-18s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "$protocol" "$dp" \ "0.0.0.0/0" "$RED" "CRITICAL" "$RESET" flag_crit done fi continue fi while IFS= read -r port_spec; do [[ -z "$port_spec" ]] && continue local IFS=',' for dp in $DANGEROUS_PORTS; do if port_in_range "$dp" "$port_spec"; then local svc severity color svc=$(port_to_service "$dp") if [[ "$dp" == "80" || "$dp" == "443" ]]; then severity="INFO"; color="$CYAN"; flag_info else severity="CRITICAL"; color="$RED"; flag_crit fi printf " %-28s %-14s %-8s %-8s %-18s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "$protocol" \ "${dp} (${svc})" "0.0.0.0/0" "$color" "$severity" "$RESET" fi done done <<< "$ports" done done echo "" } # ══════════════════════════════════════════════════════════════════════ # PERMISSIVE RULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_permissive() { log "Auditing overly permissive firewall rules..." echo "" printf " %-28s %-14s %-14s %-18s %s\n" \ "RULE_NAME" "NETWORK" "PROTOCOLS" "SOURCE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..85})" local rules_json rules_json=$(fetch_rules) echo "$rules_json" | jq -c '.[] | select(.direction == "INGRESS" and .disabled != true)' 2>/dev/null | while IFS= read -r rule; do local rule_name network rule_name=$(echo "$rule" | jq -r '.name') network=$(echo "$rule" | jq -r '.network' | rev | cut -d/ -f1 | rev) local has_open="false" while IFS= read -r src; do if [[ "$src" == "0.0.0.0/0" ]]; then has_open="true" break fi done < <(echo "$rule" | jq -r '.sourceRanges[]? // empty' 2>/dev/null) [[ "$has_open" != "true" ]] && continue local has_all_traffic="false" while IFS= read -r allowed; do local proto proto=$(echo "$allowed" | jq -r '.IPProtocol') local port_count port_count=$(echo "$allowed" | jq '.ports // [] | length') if [[ "$proto" == "all" ]]; then has_all_traffic="true" elif [[ "$port_count" -eq 0 ]]; then has_all_traffic="true" fi done < <(echo "$rule" | jq -c '.allowed[]? // empty' 2>/dev/null) if [[ "$has_all_traffic" == "true" ]]; then local proto_list proto_list=$(echo "$rule" | jq -r '[.allowed[]?.IPProtocol] | join(",")' 2>/dev/null) printf " %-28s %-14s %-14s %-18s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "${proto_list:0:13}" \ "0.0.0.0/0" "$RED" "CRITICAL" "$RESET" flag_crit fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # EGRESS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_egress() { log "Auditing egress firewall rules..." echo "" printf " %-28s %-14s %-14s %-18s %s\n" \ "RULE_NAME" "NETWORK" "PROTOCOLS" "DESTINATION" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..85})" local rules_json rules_json=$(fetch_rules) echo "$rules_json" | jq -c '.[] | select(.direction == "EGRESS" and .disabled != true)' 2>/dev/null | while IFS= read -r rule; do local rule_name network rule_name=$(echo "$rule" | jq -r '.name') network=$(echo "$rule" | jq -r '.network' | rev | cut -d/ -f1 | rev) local has_wide="false" while IFS= read -r dest; do if [[ "$dest" == "0.0.0.0/0" ]]; then has_wide="true" break fi done < <(echo "$rule" | jq -r '.destinationRanges[]? // empty' 2>/dev/null) [[ "$has_wide" != "true" ]] && continue local proto_list proto_list=$(echo "$rule" | jq -r '[.allowed[]?.IPProtocol] | join(",")' 2>/dev/null) local severity="WARN" color="$YELLOW" if [[ "$proto_list" == "all" ]]; then severity="WARN"; color="$YELLOW"; flag_warn else severity="INFO"; color="$CYAN"; flag_info fi printf " %-28s %-14s %-14s %-18s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "${proto_list:0:13}" \ "0.0.0.0/0" "$color" "$severity" "$RESET" done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED RULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_unused() { log "Checking for disabled or potentially unused firewall rules..." echo "" printf " %-28s %-14s %-10s %-10s %s\n" \ "RULE_NAME" "NETWORK" "DIRECTION" "DISABLED" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..80})" local rules_json rules_json=$(fetch_rules) echo "$rules_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r rule; do local rule_name network direction disabled rule_name=$(echo "$rule" | jq -r '.name') network=$(echo "$rule" | jq -r '.network' | rev | cut -d/ -f1 | rev) direction=$(echo "$rule" | jq -r '.direction') disabled=$(echo "$rule" | jq -r '.disabled // false') if [[ "$disabled" == "true" ]]; then printf " %-28s %-14s %-10s %-10s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "$direction" "YES" \ "$YELLOW" "WARN — disabled" "$RESET" flag_warn continue fi local target_tags target_tags=$(echo "$rule" | jq -r '.targetTags // [] | join(",")' 2>/dev/null) if [[ -n "$target_tags" && "$target_tags" != "null" ]]; then local first_tag="${target_tags%%,*}" local instance_count instance_count=$(gcloud compute instances list \ --filter="tags.items=${first_tag}" \ --format="value(name)" 2>/dev/null | wc -l) if [[ "$instance_count" -eq 0 ]]; then printf " %-28s %-14s %-10s %-10s %b%s%b\n" \ "${rule_name:0:27}" "${network:0:13}" "$direction" "NO" \ "$YELLOW" "WARN — no targets" "$RESET" flag_warn else verbose "Rule ${rule_name}: ${instance_count} matching instance(s)" flag_ok fi else flag_ok fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # LIST ALL RULES # ══════════════════════════════════════════════════════════════════════ list_rules() { log "Listing all firewall rules..." echo "" printf " %-28s %-14s %-10s %-8s %-12s %-18s %s\n" \ "RULE_NAME" "NETWORK" "DIR" "PROTO" "PORTS" "SOURCE/DEST" "PRIORITY" printf " %s\n" "$(printf '%.0s─' {1..105})" local rules_json rules_json=$(fetch_rules) echo "$rules_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r rule; do local rule_name network direction priority rule_name=$(echo "$rule" | jq -r '.name') network=$(echo "$rule" | jq -r '.network' | rev | cut -d/ -f1 | rev) direction=$(echo "$rule" | jq -r '.direction') priority=$(echo "$rule" | jq -r '.priority') local cidr_list if [[ "$direction" == "INGRESS" ]]; then cidr_list=$(echo "$rule" | jq -r '.sourceRanges[0]? // "any"' 2>/dev/null) else cidr_list=$(echo "$rule" | jq -r '.destinationRanges[0]? // "any"' 2>/dev/null) fi echo "$rule" | jq -c '.allowed[]? // empty' 2>/dev/null | while IFS= read -r allowed; do local proto port_str proto=$(echo "$allowed" | jq -r '.IPProtocol') port_str=$(echo "$allowed" | jq -r '.ports // ["all"] | join(",")' 2>/dev/null) [[ "$port_str" == "null" ]] && port_str="all" local dir_color="$CYAN" [[ "$direction" == "EGRESS" ]] && dir_color="$YELLOW" printf " %-28s %-14s %b%-10s%b %-8s %-12s %-18s %s\n" \ "${rule_name:0:27}" "${network:0:13}" "$dir_color" "$direction" "$RESET" \ "$proto" "${port_str:0:11}" "${cidr_list:0:17}" "$priority" 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 " • Close 0.0.0.0/0 rules on SSH (22), RDP (3389), and database ports" echo " • Replace all-protocol allow rules with specific port lists" echo " • Use target tags or service accounts to scope rules" echo " • Delete disabled rules that are no longer needed" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" echo " Suggestions:" echo " • Review disabled rules for deletion" echo " • Check rules with no matching target instances" echo " • Restrict egress 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}GCP Firewall Auditor${RESET}" echo -e "Project: ${GCP_PROJECT}" echo -e "Mode: ${RUN_MODE}" if [[ -n "$VPC_NETWORK" ]]; then echo -e "Network: ${VPC_NETWORK}" fi 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 ;; permissive) audit_permissive ;; unused) audit_unused ;; egress) audit_egress ;; 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 "$@"