Ensure Clusters Are Created With Private Nodes
More Info:
Disable public IP addresses for cluster nodes, so that they only have private IP addresses. Private Nodes are nodes with no public IP addresses.
Risk Level
Low
Address
Security
Compliance Standards
- APRA CPS 234 (Australia)
- AWS Startup Security Baseline
- BSI C5 (Germany)
- Brazil LGPD
- CCPA / CPRA (California)
- CIS Critical Security Controls v8
- CIS EKS
- CMMC 2.0
- CSA Cloud Controls Matrix v4
- DPDPA
- Digital Operational Resilience Act (EU)
- ISO/IEC 27017
- ISO/IEC 27018
- ISO/IEC 27701
- KSA PDPL
- MAS Technology Risk Management (Singapore)
- MITRE ATT&CK (Cloud)
- NIS2 Directive
- NIST SP 800-171
- NYDFS 23 NYCRR 500
- SWIFT Customer Security Controls Framework
- Sarbanes-Oxley IT General Controls
- UK NCSC Cyber Assessment Framework
Triage and Remediation
- Remediation
Remediation
Manual Steps
-
Review current EKS cluster endpoint and node networking configuration
- Run on any machine with AWS CLI access:
aws eks describe-cluster \--region us-east-1 \--name my-cluster \--output json
- Inspect
.cluster.resourcesVpcConfig:endpointPublicAccesspublicAccessCidrsendpointPrivateAccesssubnetIds(to verify they are private subnets for nodes)
- Run on any machine with AWS CLI access:
-
Confirm whether worker nodes are using public IPs
- Get the nodegroup names:
aws eks list-nodegroups \--region us-east-1 \--cluster-name my-cluster
- For each nodegroup:
aws eks describe-nodegroup \--region us-east-1 \--cluster-name my-cluster \--nodegroup-name <NODEGROUP_NAME> \--output json
- Check
.resourcesVpcConfig.subnetsand then inspect those subnets:aws ec2 describe-subnets \--region us-east-1 \--subnet-ids <SUBNET_ID_1> <SUBNET_ID_2> ... - Confirm they are private subnets (no direct route to an Internet Gateway) and that your nodegroups are not configured with public IP assignment (check
ec2:AssociatePublicIpAddressin Launch Template or nodegroup config via the console/IaC).
- Get the nodegroup names:
-
Decide on the desired access model for the control plane endpoint
- If the cluster should be reachable only from inside the VPC or via VPN/Direct Connect, plan to:
- Set
endpointPrivateAccess=true - Set
endpointPublicAccess=false
- Set
- If you must keep public endpoint access temporarily, define the minimal
publicAccessCidrsnecessary (e.g., specific office/VPN egress IPs). - Ensure alternative access (bastion, VPN, Direct Connect) is in place before removing broad public access so operational access is not lost.
- If the cluster should be reachable only from inside the VPC or via VPN/Direct Connect, plan to:
-
Update the cluster endpoint access configuration (if changes are needed)
- To enable private access and restrict public access to specific CIDRs (example):
aws eks update-cluster-config \--region us-east-1 \--name my-cluster \--resources-vpc-config \endpointPublicAccess=true,publicAccessCidrs="203.0.113.5/32",endpointPrivateAccess=true
- To disable public access entirely and use only private access:
aws eks update-cluster-config \--region us-east-1 \--name my-cluster \--resources-vpc-config \endpointPublicAccess=false,endpointPrivateAccess=true
- Wait for the update to complete:
aws eks describe-cluster \--region us-east-1 \--name my-cluster \--query 'cluster.status'
- To enable private access and restrict public access to specific CIDRs (example):
-
Ensure all nodegroups use only private IPs
- For managed nodegroups:
- Reconfigure or recreate nodegroups so that:
- They use only private subnets.
- Public IP assignment is disabled in the nodegroup or its Launch Template (via AWS console or your IaC).
- Reconfigure or recreate nodegroups so that:
- For each updated or recreated nodegroup, verify that new EC2 instances have no public IP:
aws ec2 describe-instances \--region us-east-1 \--filters "Name>tag:eks:cluster-name,Values=my-cluster" \--query 'Reservations[].Instances[].{Id:InstanceId,PublicIp:PublicIpAddress,PrivateIp:PrivateIpAddress}' \--output table
- Confirm
PublicIpis empty for all instances.
- For managed nodegroups:
-
Verify the final state against the benchmark intent
- Confirm cluster endpoint config:
aws eks describe-cluster \--region us-east-1 \--name my-cluster \--query 'cluster.resourcesVpcConfig.{endpointPublicAccess:endpointPublicAccess,publicAccessCidrs:publicAccessCidrs,endpointPrivateAccess:endpointPrivateAccess}' \--output json
- Confirm worker nodes have only private IPs (re-run instance check from step 5).
- Document any justified exceptions where limited public access (
endpointPublicAccess=truewith tightly scopedpublicAccessCidrs) is required for business or operational reasons.
- Confirm cluster endpoint config:
Using kubectl
kubectl cannot modify whether an EKS cluster uses private nodes or adjust its VPC/private/public endpoint settings; those are managed control-plane and VPC settings configured via the AWS console, CLI, or IaC. Refer to the Manual Steps section for how to review and change the cluster’s networking configuration.
Automation
#!/usr/bin/env bash
#
# Report EKS cluster endpoint and node IP exposure
# Requirements:
# - aws CLI configured with permissions to describe EKS clusters
# - kubectl configured for each target cluster context
# - jq installed
#
# Usage:
# ./eks_private_nodes_report.sh # use all kubecontexts
# KUBECONFIG=/path/to/kubeconfig ./eks_private_nodes_report.sh
set -euo pipefail
if ! command -v aws >/dev/null 2>&1; then
echo "ERROR: aws CLI not found in PATH" >&2
exit 1
fi
if ! command -v kubectl >/dev/null 2>&1; then
echo "ERROR: kubectl not found in PATH" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq not found in PATH" >&2
exit 1
fi
contexts=$(kubectl config get-contexts -o name 2>/dev/null || true)
if [ -z "${contexts}" ]; then
echo "No kubectl contexts found."
exit 0
fi
echo "Cluster,Context,AWS_Region,ControlPlaneEndpointPublicAccess,ControlPlaneEndpointPrivateAccess,PublicAccessCidrs,AnyNodeHasPublicIP,NodeDetails"
for ctx in $contexts; do
# Switch context
kubectl config use-context "$ctx" >/dev/null
# Try to infer EKS cluster name and region from aws-auth ConfigMap and/or ARN
cluster_name=""
region=""
# First, try cluster-info (EKS ARNs usually contain cluster name)
server_url=$(kubectl cluster-info | awk '/is running at/ {print $NF}' | head -n1 || true)
# Example EKS endpoint: https://XXXXXXXX.gr7.us-west-2.eks.amazonaws.com
if [[ "$server_url" =~ eks\.amazonaws\.com ]]; then
host=$(printf "%s" "$server_url" | sed -E 's@https?://([^/]+)/?.*@\1@')
# region is the 3rd label from the right for EKS endpoints: xxxxx.region.eks.amazonaws.com
region=$(printf "%s" "$host" | awk -F. '{print $(NF-2)"-"$(NF-1)"-"$NF}' | sed -E 's/\.eks\.amazonaws\.com$//' || true)
fi
# If region parsing failed, fall back to an env var AWS_REGION/AWS_DEFAULT_REGION if set
if [ -z "$region" ]; then
region="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
fi
# Try to get cluster name from aws-auth ConfigMap (not always present)
aws_auth_ns="kube-system"
if kubectl get cm aws-auth -n "$aws_auth_ns" >/dev/null 2>&1; then
# This just confirms it's an EKS-like cluster but not the EKS cluster name
:
fi
# As there's no reliable way via kubectl to infer EKS cluster name, require user mapping via context name
# Assumption: kubectl context name == EKS cluster name (common practice with aws eks update-kubeconfig)
cluster_name="$ctx"
eks_desc=""
if [ -n "$cluster_name" ] && [ -n "$region" ]; then
eks_desc=$(aws eks describe-cluster \
--name "$cluster_name" \
--region "$region" \
--output json 2>/dev/null || true)
fi
if [ -z "$eks_desc" ]; then
# Not an EKS cluster (or cannot describe). We still check node external IPs via kubectl.
endpoint_public="unknown-not-eks-or-no-perms"
endpoint_private="unknown-not-eks-or-no-perms"
public_cidrs="unknown-not-eks-or-no-perms"
else
endpoint_public=$(printf "%s" "$eks_desc" | jq -r '.cluster.resourcesVpcConfig.endpointPublicAccess')
endpoint_private=$(printf "%s" "$eks_desc" | jq -r '.cluster.resourcesVpcConfig.endpointPrivateAccess')
public_cidrs=$(printf "%s" "$eks_desc" | jq -r '.cluster.resourcesVpcConfig.publicAccessCidrs | join(";")')
fi
# Gather node IP information
# Any node having an ExternalIP is a potential problem for this control
# (You must verify in your cloud console if those are truly public IPs and routable.)
nodes_json=$(kubectl get nodes -o json 2>/dev/null || true)
if [ -z "$nodes_json" ]; then
any_node_public="no-nodes"
node_details="no-nodes"
else
any_ext=$(printf "%s" "$nodes_json" | jq '[.items[].status.addresses[] | select(.type=="ExternalIP")] | length > 0')
any_node_public="$any_ext"
# Collect concise node/IP summary
node_details=$(printf "%s" "$nodes_json" |
jq -r '.items[] |
{
name: .metadata.name,
internalIPs: ([.status.addresses[] | select(.type=="InternalIP") | .address] | join("|")),
externalIPs: ([.status.addresses[] | select(.type=="ExternalIP") | .address] | join("|"))
} |
"\(.name):internal=\(.internalIPs),external=\(.externalIPs)"' |
paste -sd ";" -)
[ -z "$node_details" ] && node_details="none"
fi
echo "${cluster_name},${ctx},${region:-unknown},${endpoint_public},${endpoint_private},\"${public_cidrs}\",${any_node_public},\"${node_details}\""
done
Explanation of results (what indicates a problem):
-
For control-plane / cluster endpoint:
ControlPlaneEndpointPublicAccess == truewithout a justified restriction inPublicAccessCidrs(e.g., it is0.0.0.0/0or very broad) requires review.ControlPlaneEndpointPrivateAccess == falsemeans the API server is not reachable purely over private networking and should be reviewed.
-
For worker nodes:
AnyNodeHasPublicIP == truemeans at least one node reports anExternalIP.- This indicates a potential violation of “Private Nodes only” and those nodes must be checked in the AWS console/VPC to confirm whether the ExternalIP is a real, routable public IP.
NodeDetailsshows, per node, the internal and external addresses. Any non-emptyexternal=field marks that node for manual review.
This script is read-only: it does not change any configuration. Use it from any machine with kubectl and aws CLI access.