Skip to main content

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

Manual Steps
  1. 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:
      • endpointPublicAccess
      • publicAccessCidrs
      • endpointPrivateAccess
      • subnetIds (to verify they are private subnets for nodes)
  2. 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.subnets and 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:AssociatePublicIpAddress in Launch Template or nodegroup config via the console/IaC).
  3. 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
    • If you must keep public endpoint access temporarily, define the minimal publicAccessCidrs necessary (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.
  4. 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'
  5. 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).
    • 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 PublicIp is empty for all instances.
  6. 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=true with tightly scoped publicAccessCidrs) is required for business or operational reasons.
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 == true without a justified restriction in PublicAccessCidrs (e.g., it is 0.0.0.0/0 or very broad) requires review.
    • ControlPlaneEndpointPrivateAccess == false means the API server is not reachable purely over private networking and should be reviewed.
  • For worker nodes:

    • AnyNodeHasPublicIP == true means at least one node reports an ExternalIP.
      • 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.
    • NodeDetails shows, per node, the internal and external addresses. Any non-empty external= 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.