#!/usr/bin/env bash ######################################################################################### #### security-group-auditor.sh — Audit AWS Security Groups for risky configs #### #### Finds 0.0.0.0/0 rules, dangerous ports, all-port rules, unused groups #### #### Requires: bash 4+, aws CLI, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./security-group-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}" AWS_REGION="" VPC_ID="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── Dependency and credential checks ──────────────────────────────── check_deps() { command -v aws &>/dev/null || die "aws CLI is required (install: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html)" command -v jq &>/dev/null || die "jq is required" } check_credentials() { local identity identity=$(aws_cmd sts get-caller-identity --output json 2>/dev/null) \ || die "No valid AWS credentials — run 'aws configure' or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY" local account arn account=$(echo "$identity" | jq -r '.Account') arn=$(echo "$identity" | jq -r '.Arn') verbose "Account: ${account}" verbose "ARN: ${arn}" local region region=$(aws_cmd configure get region 2>/dev/null || true) if [[ -z "$AWS_REGION" && -z "$region" ]]; then die "No region set — use --region or 'aws configure set region REGION' or set AWS_DEFAULT_REGION" fi log "Account: ${account}" log "Region: ${AWS_REGION:-$region}" } # ── aws wrapper ────────────────────────────────────────────────────── aws_cmd() { local args=("$@") if [[ -n "$AWS_REGION" ]]; then args+=(--region "$AWS_REGION") fi verbose "aws ${args[*]}" aws "${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 dangerous port falls in a from-to range ──────────────── port_in_range() { local port="$1" from="$2" to="$3" [[ "$port" -ge "$from" && "$port" -le "$to" ]] } # ── Fetch security groups ─────────────────────────────────────────── fetch_sgs() { local args=(ec2 describe-security-groups --output json) if [[ -n "$VPC_ID" ]]; then args+=(--filters "Name=vpc-id,Values=${VPC_ID}") fi aws_cmd "${args[@]}" 2>/dev/null | jq '.SecurityGroups' } # ── Fetch ENI attachments (SG IDs in use) ──────────────────────────── fetch_attached_sg_ids() { aws_cmd ec2 describe-network-interfaces --output json \ --query 'NetworkInterfaces[].Groups[].GroupId' 2>/dev/null | jq -r '.[]' | sort -u } # ══════════════════════════════════════════════════════════════════════ # OPEN PORTS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_open_ports() { log "Auditing security groups for dangerous open ports..." log "Dangerous ports: ${DANGEROUS_PORTS}" echo "" printf " %-24s %-14s %-14s %-8s %-12s %-18s %s\n" \ "SG_NAME" "SG_ID" "VPC" "PROTO" "PORT" "SOURCE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..105})" local sgs_json sgs_json=$(fetch_sgs) echo "$sgs_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r sg; do local sg_name sg_id vpc_id sg_name=$(echo "$sg" | jq -r '.GroupName') sg_id=$(echo "$sg" | jq -r '.GroupId') vpc_id=$(echo "$sg" | jq -r '.VpcId // "none"') echo "$sg" | jq -c '.IpPermissions[]? // empty' 2>/dev/null | while IFS= read -r perm; do local protocol from_port to_port protocol=$(echo "$perm" | jq -r '.IpProtocol') from_port=$(echo "$perm" | jq -r '.FromPort // -1') to_port=$(echo "$perm" | jq -r '.ToPort // -1') # Check for 0.0.0.0/0 or ::/0 local has_open="false" local source_cidr="" while IFS= read -r cidr; do if [[ "$cidr" == "0.0.0.0/0" || "$cidr" == "::/0" ]]; then has_open="true" source_cidr="$cidr" break fi done < <(echo "$perm" | jq -r '(.IpRanges[].CidrIp // empty), (.Ipv6Ranges[].CidrIpv6 // empty)' 2>/dev/null) [[ "$has_open" != "true" ]] && continue # Protocol -1 means all traffic if [[ "$protocol" == "-1" ]]; then printf " %-24s %-14s %-14s %-8s %-12s %-18s %b%s%b\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" "all" "all" \ "$source_cidr" "$RED" "CRITICAL" "$RESET" flag_crit continue fi # No port range (e.g. icmp) — skip dangerous port check if [[ "$from_port" == "-1" && "$to_port" == "-1" && "$protocol" != "-1" ]]; then verbose "SG ${sg_id}: ${protocol} rule without port range — skipping port check" continue fi # Check if any dangerous port is in the from-to range local IFS=',' for dp in $DANGEROUS_PORTS; do if port_in_range "$dp" "$from_port" "$to_port"; 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 local port_display="$dp" [[ -n "$svc" ]] && port_display="${dp} (${svc})" printf " %-24s %-14s %-14s %-8s %-12s %-18s %b%s%b\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" "$protocol" \ "$port_display" "$source_cidr" "$color" "$severity" "$RESET" fi done done done echo "" } # ══════════════════════════════════════════════════════════════════════ # PERMISSIVE RULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_permissive() { log "Auditing overly permissive security groups..." echo "" printf " %-24s %-14s %-14s %-18s %s\n" \ "SG_NAME" "SG_ID" "VPC" "SOURCE" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..85})" local sgs_json sgs_json=$(fetch_sgs) echo "$sgs_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r sg; do local sg_name sg_id vpc_id sg_name=$(echo "$sg" | jq -r '.GroupName') sg_id=$(echo "$sg" | jq -r '.GroupId') vpc_id=$(echo "$sg" | jq -r '.VpcId // "none"') echo "$sg" | jq -c '.IpPermissions[]? // empty' 2>/dev/null | while IFS= read -r perm; do local protocol protocol=$(echo "$perm" | jq -r '.IpProtocol') [[ "$protocol" != "-1" ]] && continue local source_cidr="" while IFS= read -r cidr; do if [[ "$cidr" == "0.0.0.0/0" || "$cidr" == "::/0" ]]; then source_cidr="$cidr" break fi done < <(echo "$perm" | jq -r '(.IpRanges[].CidrIp // empty), (.Ipv6Ranges[].CidrIpv6 // empty)' 2>/dev/null) [[ -z "$source_cidr" ]] && continue printf " %-24s %-14s %-14s %-18s %b%s%b\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" \ "$source_cidr" "$RED" "CRITICAL" "$RESET" flag_crit done done echo "" } # ══════════════════════════════════════════════════════════════════════ # EGRESS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_egress() { log "Auditing egress security group rules..." echo "" printf " %-24s %-14s %-14s %-18s %s\n" \ "SG_NAME" "SG_ID" "VPC" "DESTINATION" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..85})" local sgs_json sgs_json=$(fetch_sgs) echo "$sgs_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r sg; do local sg_name sg_id vpc_id sg_name=$(echo "$sg" | jq -r '.GroupName') sg_id=$(echo "$sg" | jq -r '.GroupId') vpc_id=$(echo "$sg" | jq -r '.VpcId // "none"') echo "$sg" | jq -c '.IpPermissionsEgress[]? // empty' 2>/dev/null | while IFS= read -r perm; do local protocol protocol=$(echo "$perm" | jq -r '.IpProtocol') [[ "$protocol" != "-1" ]] && continue local dest_cidr="" while IFS= read -r cidr; do if [[ "$cidr" == "0.0.0.0/0" || "$cidr" == "::/0" ]]; then dest_cidr="$cidr" break fi done < <(echo "$perm" | jq -r '(.IpRanges[].CidrIp // empty), (.Ipv6Ranges[].CidrIpv6 // empty)' 2>/dev/null) [[ -z "$dest_cidr" ]] && continue printf " %-24s %-14s %-14s %-18s %b%s%b\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" \ "$dest_cidr" "$YELLOW" "WARN" "$RESET" flag_warn done done echo "" } # ══════════════════════════════════════════════════════════════════════ # UNUSED GROUPS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_unused() { log "Checking for unused security groups..." echo "" printf " %-24s %-14s %-14s %-14s %s\n" \ "SG_NAME" "SG_ID" "VPC" "ATTACHED_ENIS" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..85})" local sgs_json attached_ids sgs_json=$(fetch_sgs) attached_ids=$(fetch_attached_sg_ids) echo "$sgs_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r sg; do local sg_name sg_id vpc_id sg_name=$(echo "$sg" | jq -r '.GroupName') sg_id=$(echo "$sg" | jq -r '.GroupId') vpc_id=$(echo "$sg" | jq -r '.VpcId // "none"') # Skip default SG — can't be deleted if [[ "$sg_name" == "default" ]]; then verbose "Skipping default SG ${sg_id} (cannot be deleted)" flag_ok continue fi if echo "$attached_ids" | grep -qx "$sg_id"; then verbose "SG ${sg_id} (${sg_name}): attached to ENI(s)" flag_ok else printf " %-24s %-14s %-14s %-14s %b%s%b\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" \ "0" "$YELLOW" "WARN — unused" "$RESET" flag_warn fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # LIST ALL RULES # ══════════════════════════════════════════════════════════════════════ list_rules() { log "Listing all security group rules..." echo "" printf " %-24s %-14s %-14s %-10s %-8s %-12s %-18s\n" \ "SG_NAME" "SG_ID" "VPC" "DIR" "PROTO" "PORTS" "SOURCE/DEST" printf " %s\n" "$(printf '%.0s─' {1..105})" local sgs_json sgs_json=$(fetch_sgs) echo "$sgs_json" | jq -c '.[]' 2>/dev/null | while IFS= read -r sg; do local sg_name sg_id vpc_id sg_name=$(echo "$sg" | jq -r '.GroupName') sg_id=$(echo "$sg" | jq -r '.GroupId') vpc_id=$(echo "$sg" | jq -r '.VpcId // "none"') # Ingress rules echo "$sg" | jq -c '.IpPermissions[]? // empty' 2>/dev/null | while IFS= read -r perm; do local protocol from_port to_port port_str cidr_list protocol=$(echo "$perm" | jq -r '.IpProtocol') from_port=$(echo "$perm" | jq -r '.FromPort // -1') to_port=$(echo "$perm" | jq -r '.ToPort // -1') if [[ "$protocol" == "-1" ]]; then port_str="all" protocol="all" elif [[ "$from_port" == "$to_port" ]]; then port_str="$from_port" else port_str="${from_port}-${to_port}" fi cidr_list=$(echo "$perm" | jq -r '(.IpRanges[0].CidrIp // .Ipv6Ranges[0].CidrIpv6 // .UserIdGroupPairs[0].GroupId // "any")' 2>/dev/null) printf " %-24s %-14s %-14s %b%-10s%b %-8s %-12s %-18s\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" "$CYAN" "INGRESS" "$RESET" \ "$protocol" "${port_str:0:11}" "${cidr_list:0:17}" done # Egress rules echo "$sg" | jq -c '.IpPermissionsEgress[]? // empty' 2>/dev/null | while IFS= read -r perm; do local protocol from_port to_port port_str cidr_list protocol=$(echo "$perm" | jq -r '.IpProtocol') from_port=$(echo "$perm" | jq -r '.FromPort // -1') to_port=$(echo "$perm" | jq -r '.ToPort // -1') if [[ "$protocol" == "-1" ]]; then port_str="all" protocol="all" elif [[ "$from_port" == "$to_port" ]]; then port_str="$from_port" else port_str="${from_port}-${to_port}" fi cidr_list=$(echo "$perm" | jq -r '(.IpRanges[0].CidrIp // .Ipv6Ranges[0].CidrIpv6 // .UserIdGroupPairs[0].GroupId // "any")' 2>/dev/null) printf " %-24s %-14s %-14s %b%-10s%b %-8s %-12s %-18s\n" \ "${sg_name:0:23}" "$sg_id" "${vpc_id:0:13}" "$YELLOW" "EGRESS" "$RESET" \ "$protocol" "${port_str:0:11}" "${cidr_list:0:17}" done done echo "" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local elapsed elapsed=$(( $(date +%s) - START_TIME )) echo "" echo " ══════════════════════════════════════════" echo " Security Group 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-traffic (-1) rules with specific port/protocol pairs" echo " • Use VPC endpoints or NAT gateways instead of public access" echo " • Delete unused security groups to reduce attack surface" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" echo " Suggestions:" echo " • Review unused security groups for deletion" echo " • Restrict overly broad egress rules" echo " • Use security group references instead of CIDR blocks where possible" 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}AWS Security Group Auditor${RESET}" echo -e "Region: ${AWS_REGION:-$(aws configure get region 2>/dev/null || echo 'default')}" echo -e "Mode: ${RUN_MODE}" if [[ -n "$VPC_ID" ]]; then echo -e "VPC: ${VPC_ID}" 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 "$@"