Skip to main content

Ensure Clusters Are Created With Private Nodes

More Info:

Create clusters with private nodes that have only private IP addresses, preventing direct public network exposure of worker nodes.

Risk Level

High

Address

Security

Compliance Standards

  • CIS OKE

Triage and Remediation

Remediation

Manual Steps
  1. Identify whether node pools use public IPs (OCI CLI – any machine with OCI CLI access)

    # List all node pools in the compartment
    oci ce node-pool list \
    --compartment-id <OCID_OF_COMPARTMENT> \
    --cluster-id <OCID_OF_CLUSTER> \
    --all

    # For each node pool OCID found, describe it and check `node-config-details.is-public-ip-enabled`
    oci ce node-pool get \
    --node-pool-id <OCID_OF_NODE_POOL> \
    --query "data.{name:name, isPublicIp:node-config-details.is-public-ip-enabled}" \
    --output table

    If isPublicIp is true for any node pool, those nodes are not private.

  2. Confirm at the instance level that worker nodes do not have public IPs (OCI CLI – any machine with OCI CLI access)

    # List instances created for a node pool (by display-name pattern or freeform tags)
    oci compute instance list \
    --compartment-id <OCID_OF_COMPARTMENT> \
    --display-name <NODE_POOL_NAME_PREFIX> \
    --all \
    --query "data[].{name:\"display-name\", publicIp:\"public-ip\"}" \
    --output table

    Any non-empty publicIp indicates non‑private nodes.

  3. Decide on remediation strategy (design decision – no command)

    • If any node pool has is-public-ip-enabled = true or nodes show publicIp values, decide whether to:
      • Recreate the node pool(s) with private nodes only, and cordon/drain/migrate workloads, or
      • Create new private-only node pools and then delete the public node pools after workload migration.
    • Ensure you have private connectivity (VCN, subnets, NAT/egress, bastion, etc.) to manage private nodes.
  4. Create a new private-only node pool (remediation – any machine with OCI CLI access)

    oci ce node-pool create \
    --compartment-id <OCID_OF_COMPARTMENT> \
    --cluster-id <OCID_OF_CLUSTER> \
    --name <NEW_PRIVATE_NODEPOOL_NAME> \
    --kubernetes-version <K8S_VERSION> \
    --node-shape <SHAPE> \
    --node-metadata '{}' \
    --node-config-details '{
    "placementConfigs": [{
    "availabilityDomain": "<AD_NAME>",
    "subnetId": "<OCID_OF_PRIVATE_SUBNET>"
    }],
    "size": <DESIRED_NODE_COUNT>,
    "isPublicIpEnabled": false
    }'

    Ensure the subnet is private (no public IP assignment, appropriate route tables/NSGs).

  5. Migrate workloads and remove old public node pools (any machine with kubectl and OCI CLI access)

    • Cordon and drain old public nodes:
      kubectl get nodes -o wide
      # For each node with a public IP:
      kubectl cordon <NODE_NAME>
      kubectl drain <NODE_NAME> --ignore-daemonsets --delete-emptydir-data
    • After workloads are stable on the new private node pool, delete the old node pool(s):
      oci ce node-pool delete \
      --node-pool-id <OCID_OF_OLD_PUBLIC_NODE_POOL> \
      --force
  6. Verify remediation (OCI CLI – any machine with OCI CLI access)

    # Re-check node pool config
    oci ce node-pool list \
    --compartment-id <OCID_OF_COMPARTMENT> \
    --cluster-id <OCID_OF_CLUSTER> \
    --all \
    --query "data[].{name:name, isPublicIp:node-config-details.is-public-ip-enabled}" \
    --output table

    # Re-check instances
    oci compute instance list \
    --compartment-id <OCID_OF_COMPARTMENT> \
    --all \
    --query "data[?contains(\"display-name\", '<CLUSTER_OR_NODEPOOL_NAME_PREFIX>')].{name:\"display-name\", publicIp:\"public-ip\"}" \
    --output table

    Confirm all node pools show isPublicIp = false and all worker node instances have empty publicIp values.

Using kubectl

kubectl cannot change whether cluster nodes use private or public IP addresses; that setting is managed in the cloud provider’s control‑plane / cluster configuration (console, CLI, or IaC) where the cluster is created or updated. Refer to the Manual Steps section for how to review and remediate this setting in your environment.

Automation
#!/usr/bin/env bash
#
# Report whether Kubernetes nodes appear to be privately or publicly addressed.
# Run on any machine with kubectl access and KUBECONFIG set for the target cluster.

set -euo pipefail

echo "Checking node IP exposure for cluster: $(kubectl config current-context)"
echo "Timestamp: $(date -Iseconds)"
echo

# 1) List all nodes and their Internal/External IPs from the Kubernetes API
echo "=== Node IPs from Kubernetes Node objects ==="
kubectl get nodes -o json \
| jq -r '
.items[]
| {
name: .metadata.name,
addresses: (
.status.addresses
| map({(.type): .address})
| add
)
}
| [
.name,
( .addresses.InternalIP // "NONE" ),
( .addresses.ExternalIP // "NONE" )
]
| @tsv
' \
| awk 'BEGIN { printf "%-40s %-18s %-18s\n", "NODE", "INTERNAL_IP", "EXTERNAL_IP";
print "--------------------------------------------------------------------------------" }
{ printf "%-40s %-18s %-18s\n", $1, $2, $3 }'
echo

# 2) Highlight nodes that have an ExternalIP in the Kubernetes API
echo "=== Nodes with ExternalIP set (potentially PUBLICLY EXPOSED) ==="
HAS_EXTERNAL=$(kubectl get nodes -o json \
| jq -r '
.items[]
| {
name: .metadata.name,
external: (
.status.addresses[]
| select(.type=="ExternalIP")
| .address
)
}
| select(.external != null)
| [.name, .external]
| @tsv
' || true)

if [ -z "$HAS_EXTERNAL" ]; then
echo "No nodes report an ExternalIP in the Kubernetes API."
else
printf "%-40s %-18s\n" "NODE" "EXTERNAL_IP"
echo "--------------------------------------------------------------"
printf '%s\n' "$HAS_EXTERNAL" \
| awk '{ printf "%-40s %-18s\n", $1, $2 }'
fi
echo

# 3) Optional: try to infer public vs private via RFC1918 ranges
# (This is heuristic; final determination must be done in the cloud console/IaC.)
echo "=== Heuristic: Nodes with non-RFC1918 InternalIP (suspicious) ==="
kubectl get nodes -o json \
| jq -r '
.items[]
| {
name: .metadata.name,
addresses: (
.status.addresses
| map({(.type): .address})
| add
)
}
| select(.addresses.InternalIP != null)
| select(
(.addresses.InternalIP
| test("^10\\.") == false
and test("^192\\.168\\.") == false
and test("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.") == false
)
)
| [.name, .addresses.InternalIP]
| @tsv
' | awk 'BEGIN {
printf "%-40s %-18s\n", "NODE", "INTERNAL_IP (NON-RFC1918)"
print "--------------------------------------------------------------"
}
{ printf "%-40s %-18s\n", $1, $2 }' || true

echo
echo "INTERPRETATION:"
echo "- Any node listed in 'Nodes with ExternalIP set' must be reviewed: CIS requires nodes to"
echo " NOT have public IPs (private nodes only). If any appear there, this is a problem."
echo "- Nodes shown under the heuristic section use non-RFC1918 InternalIP ranges and should be"
echo " reviewed in the cloud provider console/IaC to confirm whether they are actually public."
echo
echo "Next steps (manual review required):"
echo "- In your cloud provider console or IaC, confirm that worker node groups / node pools"
echo " are configured as private (no public IPs) and attached to private subnets only."

What output indicates a problem

  • If the “Nodes with ExternalIP set” section lists any node and IP, that node appears to be publicly reachable and violates the requirement for private nodes.
  • If the heuristic section lists nodes with non‑RFC1918 InternalIP, those nodes might be using publicly routable addresses and should be manually verified in the cloud provider configuration.