#!/usr/bin/env bash ######################################################################################### #### vpc-audit.sh — Audit AWS VPC configuration, security groups, and networking #### #### Reports on open security groups, missing flow logs, peering, subnet utilization #### #### Requires: bash 4+, aws-cli v2, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### export AWS_PROFILE="production" #### #### ./vpc-audit.sh --full #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── AWS_REGION="${AWS_REGION:-}" VPC_ID="${VPC_ID:-}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # Section flags RUN_FULL="false" RUN_SGS="false" RUN_FLOW="false" RUN_PEER="false" RUN_SUBNETS="false" RUN_ENDPOINTS="false" RUN_NACLS="false" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" PASS_COUNT=0 WARN_COUNT=0 CRIT_COUNT=0 readonly DANGEROUS_PORTS="22 3389 3306 5432 6379 27017 9200 9300 11211" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" 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' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; ((WARN_COUNT++)) || true; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } # ── AWS CLI wrapper ─────────────────────────────────────────────────── aws_cmd() { local args=("$@") [[ -n "$AWS_REGION" ]] && args+=(--region "$AWS_REGION") verbose "aws ${args[*]}" aws "${args[@]}" } # ── Dependency check ────────────────────────────────────────────────── check_deps() { for cmd in aws jq; do if ! command -v "$cmd" &>/dev/null; then die "${cmd} is required but not installed" fi done if ! aws sts get-caller-identity &>/dev/null; then die "AWS credentials not configured or expired" fi if [[ -z "$AWS_REGION" ]]; then AWS_REGION=$(aws configure get region 2>/dev/null || echo "") if [[ -z "$AWS_REGION" ]]; then die "AWS_REGION is required" fi fi verbose "Using region: ${AWS_REGION}" } should_show() { [[ "$RUN_FULL" == "true" ]] && return 0 case "$1" in sgs) [[ "$RUN_SGS" == "true" ]] ;; flow) [[ "$RUN_FLOW" == "true" ]] ;; peer) [[ "$RUN_PEER" == "true" ]] ;; subnets) [[ "$RUN_SUBNETS" == "true" ]] ;; endpoints) [[ "$RUN_ENDPOINTS" == "true" ]] ;; nacls) [[ "$RUN_NACLS" == "true" ]] ;; *) return 1 ;; esac } # ── Get VPC list ────────────────────────────────────────────────────── get_vpcs() { if [[ -n "$VPC_ID" ]]; then echo "$VPC_ID" else aws_cmd ec2 describe-vpcs \ --query 'Vpcs[*].VpcId' \ --output text 2>/dev/null | tr '\t' '\n' fi } get_vpc_name() { local vid="$1" # shellcheck disable=SC2016 aws_cmd ec2 describe-vpcs \ --vpc-ids "$vid" \ --query 'Vpcs[0].Tags[?Key==`Name`].Value | [0]' \ --output text 2>/dev/null || echo "N/A" } # ══════════════════════════════════════════════════════════════════════ # SECURITY GROUPS # ══════════════════════════════════════════════════════════════════════ audit_security_groups() { log "Auditing security groups..." local sg_filter=() [[ -n "$VPC_ID" ]] && sg_filter=(--filters "Name=vpc-id,Values=${VPC_ID}") local sgs_json sgs_json=$(aws_cmd ec2 describe-security-groups \ "${sg_filter[@]}" \ --query 'SecurityGroups[*].{Id:GroupId,Name:GroupName,VpcId:VpcId,Ingress:IpPermissions}' \ --output json 2>/dev/null) local total_sgs total_sgs=$(echo "$sgs_json" | jq 'length') log "Found ${total_sgs} security group(s)" # Check for 0.0.0.0/0 ingress echo "" echo -e " ${BOLD}Open Ingress Rules (0.0.0.0/0)${RESET}" printf " ${BOLD}%-14s %-24s %-10s %-8s %s${RESET}\n" "SG ID" "NAME" "PORT" "PROTO" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..70})" local open_count=0 echo "$sgs_json" | jq -c '.[]' | while IFS= read -r sg; do local sg_id sg_name sg_id=$(echo "$sg" | jq -r '.Id') sg_name=$(echo "$sg" | jq -r '.Name') echo "$sg" | jq -c '.Ingress[]?' | while IFS= read -r rule; do local has_open has_open=$(echo "$rule" | jq '[.IpRanges[]? | select(.CidrIp == "0.0.0.0/0")] | length') if [[ "$has_open" -gt 0 ]]; then local proto from_port to_port proto=$(echo "$rule" | jq -r '.IpProtocol') from_port=$(echo "$rule" | jq -r '.FromPort // "all"') to_port=$(echo "$rule" | jq -r '.ToPort // "all"') local port_display="$from_port" [[ "$from_port" != "$to_port" ]] && port_display="${from_port}-${to_port}" [[ "$proto" == "-1" ]] && { proto="all"; port_display="all"; } local severity="WARN" local sev_color="$YELLOW" for dp in $DANGEROUS_PORTS; do if [[ "$from_port" == "$dp" || "$port_display" == "all" ]]; then severity="CRITICAL" sev_color="$RED" ((CRIT_COUNT++)) || true break fi done [[ "$severity" == "WARN" ]] && { ((WARN_COUNT++)) || true; } printf " %-14s %-24s %-10s %-8s ${sev_color}%s${RESET}\n" \ "$sg_id" "${sg_name:0:24}" "$port_display" "$proto" "$severity" ((open_count++)) || true fi done done if [[ "$open_count" -eq 0 ]]; then echo -e " ${GREEN}No open ingress rules found ✓${RESET}" ((PASS_COUNT++)) || true fi # Unused security groups echo "" echo -e " ${BOLD}Unused Security Groups${RESET}" local unused=0 echo "$sgs_json" | jq -r '.[].Id' | while IFS= read -r sg_id; do local eni_count eni_count=$(aws_cmd ec2 describe-network-interfaces \ --filters "Name=group-id,Values=${sg_id}" \ --query 'NetworkInterfaces | length(@)' \ --output text 2>/dev/null) || continue if [[ "$eni_count" -eq 0 ]]; then local sg_name sg_name=$(echo "$sgs_json" | jq -r ".[] | select(.Id == \"${sg_id}\") | .Name") [[ "$sg_name" == "default" ]] && continue echo -e " ${YELLOW}⊘${RESET} ${sg_id} (${sg_name}) — not attached to any ENI" ((unused++)) || true fi done if [[ "$unused" -eq 0 ]]; then echo -e " ${GREEN}No unused security groups ✓${RESET}" fi } # ══════════════════════════════════════════════════════════════════════ # FLOW LOGS # ══════════════════════════════════════════════════════════════════════ audit_flow_logs() { log "Checking VPC flow logs..." echo "" echo -e " ${BOLD}VPC Flow Log Status${RESET}" printf " ${BOLD}%-22s %-24s %s${RESET}\n" "VPC ID" "NAME" "FLOW LOGS" printf " %s\n" "$(printf '%.0s─' {1..60})" local vpcs vpcs=$(get_vpcs) while IFS= read -r vpc_id; do [[ -z "$vpc_id" ]] && continue local vpc_name vpc_name=$(get_vpc_name "$vpc_id") [[ "$vpc_name" == "None" ]] && vpc_name="N/A" local flow_count flow_count=$(aws_cmd ec2 describe-flow-logs \ --filter "Name=resource-id,Values=${vpc_id}" \ --query 'FlowLogs | length(@)' \ --output text 2>/dev/null) || flow_count=0 if [[ "$flow_count" -gt 0 ]]; then printf " %-22s %-24s ${GREEN}enabled (%s)${RESET}\n" "$vpc_id" "${vpc_name:0:24}" "$flow_count" ((PASS_COUNT++)) || true else printf " %-22s %-24s ${RED}NONE${RESET}\n" "$vpc_id" "${vpc_name:0:24}" ((CRIT_COUNT++)) || true fi done <<< "$vpcs" } # ══════════════════════════════════════════════════════════════════════ # VPC PEERING # ══════════════════════════════════════════════════════════════════════ audit_peering() { log "Checking VPC peering connections..." local peering_json peering_json=$(aws_cmd ec2 describe-vpc-peering-connections \ --query 'VpcPeeringConnections[*].{Id:VpcPeeringConnectionId,Status:Status.Code,Requester:RequesterVpcInfo.VpcId,Accepter:AccepterVpcInfo.VpcId,RequesterCIDR:RequesterVpcInfo.CidrBlock,AccepterCIDR:AccepterVpcInfo.CidrBlock}' \ --output json 2>/dev/null) local total total=$(echo "$peering_json" | jq 'length') echo "" echo -e " ${BOLD}VPC Peering Connections${RESET}" if [[ "$total" -eq 0 ]]; then echo " No peering connections found" return fi printf " ${BOLD}%-26s %-14s %-14s %s${RESET}\n" "PEERING ID" "REQUESTER" "ACCEPTER" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..65})" echo "$peering_json" | jq -c '.[]' | while IFS= read -r peer; do local peer_id status requester accepter peer_id=$(echo "$peer" | jq -r '.Id') status=$(echo "$peer" | jq -r '.Status') requester=$(echo "$peer" | jq -r '.Requester') accepter=$(echo "$peer" | jq -r '.Accepter') local color="$GREEN" [[ "$status" != "active" ]] && color="$YELLOW" printf " %-26s %-14s %-14s ${color}%s${RESET}\n" "$peer_id" "$requester" "$accepter" "$status" done } # ══════════════════════════════════════════════════════════════════════ # SUBNET UTILIZATION # ══════════════════════════════════════════════════════════════════════ audit_subnets() { log "Checking subnet utilization..." local subnet_filter=() [[ -n "$VPC_ID" ]] && subnet_filter=(--filters "Name=vpc-id,Values=${VPC_ID}") local subnets_json subnets_json=$(aws_cmd ec2 describe-subnets \ "${subnet_filter[@]}" \ --query 'Subnets[*].{Id:SubnetId,VpcId:VpcId,AZ:AvailabilityZone,CIDR:CidrBlock,Available:AvailableIpAddressCount}' \ --output json 2>/dev/null) echo "" echo -e " ${BOLD}Subnet IP Utilization${RESET}" printf " ${BOLD}%-18s %-14s %-16s %-20s %8s %s${RESET}\n" "SUBNET" "VPC" "AZ" "CIDR" "AVAIL" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..90})" echo "$subnets_json" | jq -c '.[]' | while IFS= read -r subnet; do local sid vpc_id az cidr avail sid=$(echo "$subnet" | jq -r '.Id') vpc_id=$(echo "$subnet" | jq -r '.VpcId') az=$(echo "$subnet" | jq -r '.AZ') cidr=$(echo "$subnet" | jq -r '.CIDR') avail=$(echo "$subnet" | jq -r '.Available') # Calculate total IPs from CIDR local prefix prefix=$(echo "$cidr" | cut -d/ -f2) local total_ips=$(( (1 << (32 - prefix)) - 5 )) [[ "$total_ips" -lt 0 ]] && total_ips=0 local pct_free=100 if [[ "$total_ips" -gt 0 ]]; then pct_free=$(( avail * 100 / total_ips )) fi local status="OK" local color="$GREEN" if [[ "$pct_free" -lt 10 ]]; then status="CRITICAL" color="$RED" ((CRIT_COUNT++)) || true elif [[ "$pct_free" -lt 20 ]]; then status="LOW" color="$YELLOW" ((WARN_COUNT++)) || true else ((PASS_COUNT++)) || true fi printf " %-18s %-14s %-16s %-20s %8s ${color}%s${RESET}\n" \ "$sid" "${vpc_id:0:14}" "$az" "$cidr" "$avail" "$status" done } # ══════════════════════════════════════════════════════════════════════ # VPC ENDPOINTS # ══════════════════════════════════════════════════════════════════════ audit_endpoints() { log "Listing VPC endpoints..." local ep_filter=() [[ -n "$VPC_ID" ]] && ep_filter=(--filters "Name=vpc-id,Values=${VPC_ID}") local ep_json ep_json=$(aws_cmd ec2 describe-vpc-endpoints \ "${ep_filter[@]}" \ --query 'VpcEndpoints[*].{Id:VpcEndpointId,Service:ServiceName,Type:VpcEndpointType,VpcId:VpcId,State:State}' \ --output json 2>/dev/null) local total total=$(echo "$ep_json" | jq 'length') echo "" echo -e " ${BOLD}VPC Endpoints${RESET}" if [[ "$total" -eq 0 ]]; then echo " No VPC endpoints found" return fi printf " ${BOLD}%-26s %-40s %-12s %s${RESET}\n" "ENDPOINT ID" "SERVICE" "TYPE" "STATE" printf " %s\n" "$(printf '%.0s─' {1..85})" echo "$ep_json" | jq -c '.[]' | while IFS= read -r ep; do local ep_id service ep_type state ep_id=$(echo "$ep" | jq -r '.Id') service=$(echo "$ep" | jq -r '.Service') ep_type=$(echo "$ep" | jq -r '.Type') state=$(echo "$ep" | jq -r '.State') local color="$GREEN" [[ "$state" != "available" ]] && color="$YELLOW" printf " %-26s %-40s %-12s ${color}%s${RESET}\n" "$ep_id" "${service:0:40}" "$ep_type" "$state" done } # ══════════════════════════════════════════════════════════════════════ # NETWORK ACLS # ══════════════════════════════════════════════════════════════════════ audit_nacls() { log "Auditing network ACLs..." local nacl_filter=() [[ -n "$VPC_ID" ]] && nacl_filter=(--filters "Name=vpc-id,Values=${VPC_ID}") local nacls_json nacls_json=$(aws_cmd ec2 describe-network-acls \ "${nacl_filter[@]}" \ --output json 2>/dev/null) echo "" echo -e " ${BOLD}Overly Permissive Network ACL Rules${RESET}" local found=0 echo "$nacls_json" | jq -c '.NetworkAcls[]' | while IFS= read -r nacl; do local nacl_id nacl_id=$(echo "$nacl" | jq -r '.NetworkAclId') echo "$nacl" | jq -c '.Entries[]' | while IFS= read -r entry; do local cidr rule_action egress protocol rule_num cidr=$(echo "$entry" | jq -r '.CidrBlock // ""') rule_action=$(echo "$entry" | jq -r '.RuleAction') egress=$(echo "$entry" | jq -r '.Egress') protocol=$(echo "$entry" | jq -r '.Protocol') rule_num=$(echo "$entry" | jq -r '.RuleNumber') # Skip default deny-all rules (rule 32767) [[ "$rule_num" == "32767" ]] && continue if [[ "$cidr" == "0.0.0.0/0" && "$rule_action" == "allow" && "$protocol" == "-1" ]]; then local direction="ingress" [[ "$egress" == "true" ]] && direction="egress" echo -e " ${YELLOW}!${RESET} ${nacl_id} — rule ${rule_num}: allow all ${direction} from 0.0.0.0/0" found=1 ((WARN_COUNT++)) || true fi done done if [[ "$found" -eq 0 ]]; then echo -e " ${GREEN}No overly permissive NACL rules found ✓${RESET}" fi } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { echo "" echo -e " ${BOLD}═══════════════════════════════════════${RESET}" echo -e " ${BOLD}VPC Audit Summary${RESET}" echo -e " ${BOLD}═══════════════════════════════════════${RESET}" echo -e " ${GREEN}PASS:${RESET} ${PASS_COUNT}" echo -e " ${YELLOW}WARN:${RESET} ${WARN_COUNT}" echo -e " ${RED}CRITICAL:${RESET} ${CRIT_COUNT}" echo " ───────────────────────────────────────" local end_time end_time=$(date +%s) echo " Completed in $(( end_time - START_TIME ))s" if [[ "$CRIT_COUNT" -gt 0 ]]; then echo "" echo -e " ${RED}Action required: ${CRIT_COUNT} critical finding(s)${RESET}" exit 2 elif [[ "$WARN_COUNT" -gt 0 ]]; then echo "" echo -e " ${YELLOW}Review recommended: ${WARN_COUNT} warning(s)${RESET}" exit 1 fi } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat </dev/null || echo 'default')}" [[ -n "$VPC_ID" ]] && echo "VPC: ${VPC_ID}" echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "" check_deps should_show "sgs" && audit_security_groups should_show "flow" && audit_flow_logs should_show "peer" && audit_peering should_show "subnets" && audit_subnets should_show "endpoints" && audit_endpoints should_show "nacls" && audit_nacls print_summary } main "$@"