#!/usr/bin/env bash ######################################################################################### #### nsg-auditor.sh — Audit Azure NSGs for risky inbound rules and misconfigurations #### #### Finds open ports, missing NSGs, overly permissive rules, and unused NSGs #### #### Requires: bash 4+, az, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./nsg-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}" RESOURCE_GROUP="${RESOURCE_GROUP:-}" SUBSCRIPTION="${SUBSCRIPTION:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── Azure CLI helpers ──────────────────────────────────────────────── az_cmd() { local cmd=("az" "$@") if [[ -n "$SUBSCRIPTION" ]]; then cmd+=(--subscription "$SUBSCRIPTION") fi verbose "Running: ${cmd[*]}" "${cmd[@]}" } check_credentials() { az account show &>/dev/null || die "Not logged in to Azure CLI — run 'az login' first" } check_deps() { command -v az &>/dev/null || die "az (Azure CLI) is required" command -v jq &>/dev/null || die "jq is required" } # ── 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 } # ── NSG helpers ────────────────────────────────────────────────────── get_all_nsgs() { local nsg_args=("network" "nsg" "list" "-o" "json") if [[ -n "$RESOURCE_GROUP" ]]; then nsg_args+=(--resource-group "$RESOURCE_GROUP") fi az_cmd "${nsg_args[@]}" } get_all_nics() { local nic_args=("network" "nic" "list" "-o" "json") if [[ -n "$RESOURCE_GROUP" ]]; then nic_args+=(--resource-group "$RESOURCE_GROUP") fi az_cmd "${nic_args[@]}" } # ══════════════════════════════════════════════════════════════════════ # OPEN PORTS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_open_ports() { log "Auditing NSG rules for dangerous open ports..." log "Dangerous ports: ${DANGEROUS_PORTS}" echo "" printf " %-20s %-22s %-8s %-8s %-18s %-12s %s\n" \ "NSG" "RULE" "PORT" "PROTO" "SOURCE" "SERVICE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..100})" local nsg_json nsg_json=$(get_all_nsgs) echo "$nsg_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r nsg; do local nsg_name nsg_name=$(echo "$nsg" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$nsg" | jq -c '.securityRules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local rule_name direction access priority protocol port_str source_addr rule_name=$(echo "$rule" | jq -r '.name // "unnamed"' 2>/dev/null) direction=$(echo "$rule" | jq -r '.direction // "Inbound"' 2>/dev/null) access=$(echo "$rule" | jq -r '.access // "Deny"' 2>/dev/null) priority=$(echo "$rule" | jq -r '.priority // 65000' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "*"' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.destinationPortRange // ""' 2>/dev/null) source_addr=$(echo "$rule" | jq -r '.sourceAddressPrefix // ""' 2>/dev/null) [[ "$direction" != "Inbound" ]] && continue [[ "$access" != "Allow" ]] && continue [[ "$priority" -ge 65000 ]] && continue if [[ "$source_addr" != "*" && "$source_addr" != "Internet" && "$source_addr" != "0.0.0.0/0" ]]; then continue fi if [[ "$port_str" == "*" ]]; then local IFS=',' for dp in $DANGEROUS_PORTS; do local svc svc=$(port_to_service "$dp") printf " %-20s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "$dp" "$protocol" \ "$source_addr" "${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 " %-20s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "$dp" "$protocol" \ "$source_addr" "${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 " %-20s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "$port_str" "$protocol" \ "$source_addr" "${svc:-unknown}" "$RED" "CRITICAL" "$RESET" flag_crit elif [[ "$port_str" == "80" || "$port_str" == "443" ]]; then local svc svc=$(port_to_service "$port_str") printf " %-20s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "$port_str" "$protocol" \ "$source_addr" "${svc:-$port_str}" "$CYAN" "INFO" "$RESET" flag_info else local svc svc=$(port_to_service "$port_str") printf " %-20s %-22s %-8s %-8s %-18s %-12s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "$port_str" "$protocol" \ "$source_addr" "${svc:-$port_str}" "$YELLOW" "WARN" "$RESET" flag_warn fi done done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNPROTECTED NICs # ══════════════════════════════════════════════════════════════════════ audit_unprotected() { log "Checking for NICs without NSG..." echo "" printf " %-28s %-28s %-16s %s\n" \ "NIC_NAME" "RESOURCE_GROUP" "IP_CONFIG" "NSG" printf " %s\n" "$(printf '%.0s─' {1..95})" local nic_json nic_json=$(get_all_nics) echo "$nic_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r nic; do local nic_name nic_rg nsg_id ip_config nic_name=$(echo "$nic" | jq -r '.name // "unknown"' 2>/dev/null) nic_rg=$(echo "$nic" | jq -r '.resourceGroup // "unknown"' 2>/dev/null) nsg_id=$(echo "$nic" | jq -r '.networkSecurityGroup.id // ""' 2>/dev/null) ip_config=$(echo "$nic" | jq -r '.ipConfigurations[0].name // "N/A"' 2>/dev/null) if [[ -z "$nsg_id" || "$nsg_id" == "null" ]]; then printf " %-28s %-28s %-16s %b%s%b\n" \ "${nic_name:0:26}" "${nic_rg:0:26}" "${ip_config:0:14}" \ "$RED" "NONE — UNPROTECTED" "$RESET" flag_crit else local nsg_short nsg_short=$(basename "$nsg_id") printf " %-28s %-28s %-16s %b%s%b\n" \ "${nic_name:0:26}" "${nic_rg:0:26}" "${ip_config:0:14}" \ "$GREEN" "✓ ${nsg_short:0:20}" "$RESET" flag_ok fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # PERMISSIVE RULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_permissive() { log "Auditing overly permissive NSG rules..." echo "" printf " %-20s %-22s %-10s %-8s %-18s %-14s %s\n" \ "NSG" "RULE" "PORTS" "PROTO" "SOURCE" "ISSUE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..105})" local nsg_json nsg_json=$(get_all_nsgs) echo "$nsg_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r nsg; do local nsg_name nsg_name=$(echo "$nsg" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$nsg" | jq -c '.securityRules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local rule_name direction access priority port_str protocol source_addr rule_name=$(echo "$rule" | jq -r '.name // "unnamed"' 2>/dev/null) direction=$(echo "$rule" | jq -r '.direction // "Inbound"' 2>/dev/null) access=$(echo "$rule" | jq -r '.access // "Deny"' 2>/dev/null) priority=$(echo "$rule" | jq -r '.priority // 65000' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.destinationPortRange // ""' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "*"' 2>/dev/null) source_addr=$(echo "$rule" | jq -r '.sourceAddressPrefix // ""' 2>/dev/null) [[ "$direction" != "Inbound" ]] && continue [[ "$access" != "Allow" ]] && continue [[ "$priority" -ge 65000 ]] && continue if [[ "$port_str" == "*" ]] && [[ "$source_addr" == "*" || "$source_addr" == "Internet" ]]; then printf " %-20s %-22s %-10s %-8s %-18s %-14s %b%s%b\n" \ "${nsg_name:0:18}" "${rule_name:0:20}" "ALL" "$protocol" \ "$source_addr" "all-ports" "$RED" "CRITICAL" "$RESET" flag_crit fi done done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED NSGs # ══════════════════════════════════════════════════════════════════════ audit_unused() { log "Checking for unused NSGs..." echo "" printf " %-28s %-28s %-8s %s\n" \ "NSG_NAME" "RESOURCE_GROUP" "RULES" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..75})" local nsg_json nsg_json=$(get_all_nsgs) echo "$nsg_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r nsg; do local nsg_name nsg_rg rule_count nic_count subnet_count nsg_name=$(echo "$nsg" | jq -r '.name // "unnamed"' 2>/dev/null) nsg_rg=$(echo "$nsg" | jq -r '.resourceGroup // "unknown"' 2>/dev/null) rule_count=$(echo "$nsg" | jq '[.securityRules[]?] | length' 2>/dev/null || echo 0) nic_count=$(echo "$nsg" | jq '[.networkInterfaces[]?] | length' 2>/dev/null || echo 0) subnet_count=$(echo "$nsg" | jq '[.subnets[]?] | length' 2>/dev/null || echo 0) if [[ "$nic_count" -eq 0 && "$subnet_count" -eq 0 ]]; then printf " %-28s %-28s %-8s %b%s%b\n" \ "${nsg_name:0:26}" "${nsg_rg:0:26}" "$rule_count" \ "$YELLOW" "UNUSED" "$RESET" flag_warn else verbose "NSG ${nsg_name}: attached to ${nic_count} NIC(s), ${subnet_count} subnet(s)" flag_ok fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # LIST ALL RULES # ══════════════════════════════════════════════════════════════════════ list_rules() { log "Listing all custom NSG rules..." echo "" printf " %-18s %-20s %-8s %-8s %-8s %-12s %-18s %s\n" \ "NSG" "RULE" "DIR" "ACCESS" "PROTO" "PORTS" "SOURCE/DEST" "SERVICE" printf " %s\n" "$(printf '%.0s─' {1..105})" local nsg_json nsg_json=$(get_all_nsgs) echo "$nsg_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r nsg; do local nsg_name nsg_name=$(echo "$nsg" | jq -r '.name // "unnamed"' 2>/dev/null) echo "$nsg" | jq -c '.securityRules[]? // empty' 2>/dev/null | while IFS= read -r rule; do local rule_name direction access protocol port_str source_addr dest_addr priority rule_name=$(echo "$rule" | jq -r '.name // "unnamed"' 2>/dev/null) direction=$(echo "$rule" | jq -r '.direction // "Inbound"' 2>/dev/null) access=$(echo "$rule" | jq -r '.access // "Deny"' 2>/dev/null) protocol=$(echo "$rule" | jq -r '.protocol // "*"' 2>/dev/null) port_str=$(echo "$rule" | jq -r '.destinationPortRange // "*"' 2>/dev/null) source_addr=$(echo "$rule" | jq -r '.sourceAddressPrefix // "*"' 2>/dev/null) dest_addr=$(echo "$rule" | jq -r '.destinationAddressPrefix // "*"' 2>/dev/null) priority=$(echo "$rule" | jq -r '.priority // 0' 2>/dev/null) [[ "$port_str" == "null" ]] && port_str="*" local cidr_display if [[ "$direction" == "Inbound" ]]; then cidr_display="$source_addr" else cidr_display="$dest_addr" fi local svc="" if [[ "$port_str" =~ ^[0-9]+$ ]]; then svc=$(port_to_service "$port_str") fi local dir_color="$CYAN" [[ "$direction" == "Outbound" ]] && dir_color="$YELLOW" local access_color="$GREEN" [[ "$access" == "Deny" ]] && access_color="$RED" printf " %-18s %-20s %b%-8s%b %b%-8s%b %-8s %-12s %-18s %s\n" \ "${nsg_name:0:16}" "${rule_name:0:18}" \ "$dir_color" "$direction" "$RESET" \ "$access_color" "$access" "$RESET" \ "$protocol" "${port_str:0:10}" "${cidr_display:0:16}" "${svc}" done done echo "" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local elapsed elapsed=$(( $(date +%s) - START_TIME )) echo "" echo " ══════════════════════════════════════════" echo " NSG 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 NSGs to all unprotected NICs and subnets" echo " • Close */Internet rules on SSH (22), RDP (3389), and database ports" echo " • Replace all-port allow rules with specific port lists" echo " • Remove unused NSGs 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-open rules and narrow where possible" echo " • Delete unused NSGs" 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}Azure NSG Auditor${RESET}" echo -e "Mode: ${RUN_MODE}" [[ -n "$RESOURCE_GROUP" ]] && echo -e "RG: ${RESOURCE_GROUP}" [[ -n "$SUBSCRIPTION" ]] && echo -e "Sub: ${SUBSCRIPTION}" 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 "$@"