More Info:
Run node pools with a dedicated, minimally privileged service account instead of the Compute Engine default account, which has broad project permissions. This reduces the privileges available if a node is compromised.Risk Level
MediumAddress
SecurityCompliance Standards
- CIS GKE
Triage and Remediation
- Remediation
Remediation
Manual Steps
Manual Steps
-
Create a dedicated GKE node service account (run on any machine with
gcloudconfigured for the project)PROJECT_ID="$(gcloud config get-value project)" gcloud iam service-accounts create gke-node-sa \ --display-name "GKE Node Service Account" NODE_SA_EMAIL="$(gcloud iam service-accounts list \ --format='value(email)' \ --filter='displayName:GKE Node Service Account')" -
Grant the required minimal roles to the node service account (same machine)
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member "serviceAccount:${NODE_SA_EMAIL}" \ --role "roles/monitoring.metricWriter" gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member "serviceAccount:${NODE_SA_EMAIL}" \ --role "roles/monitoring.viewer" gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member "serviceAccount:${NODE_SA_EMAIL}" \ --role "roles/logging.logWriter" -
Identify the cluster and node pool currently using the default service account (same machine)
PROJECT_ID="$(gcloud config get-value project)" CLUSTER_NAME="<YOUR_CLUSTER_NAME>" LOCATION="<YOUR_CLUSTER_LOCATION>" # e.g. us-central1, us-central1-a # Get the node pool to migrate (replace if needed) NODE_POOL="$(gcloud container clusters describe "${CLUSTER_NAME}" \ --location "${LOCATION}" --project "${PROJECT_ID}" \ --format='value(nodePools[0].name)')" # Confirm the node pool is using the default SA gcloud container node-pools describe "${NODE_POOL}" \ --cluster "${CLUSTER_NAME}" \ --location "${LOCATION}" \ --project "${PROJECT_ID}" \ --format='value(config.serviceAccount)' -
Create a new node pool that uses the dedicated service account (same machine)
Choose a new node pool name, e.g.secure-pool:Adjustgcloud container node-pools create "secure-pool" \ --cluster="${CLUSTER_NAME}" \ --location="${LOCATION}" \ --service-account="${NODE_SA_EMAIL}" \ --num-nodes=3--num-nodesand other flags (machine type, autoscaling, etc.) to match the existing pool’s requirements. -
Migrate workloads and delete the old node pool using the default service account (same machine)
a. Cordon and drain nodes in the old pool so workloads move to the new pool:Then, from any machine with# Get nodes in the old pool gcloud compute instances list \ --project "${PROJECT_ID}" \ --filter="metadata['cluster-name']:${CLUSTER_NAME} AND metadata['gke-nodepool']:${NODE_POOL}" \ --format='value(name,zone)'kubectlconfigured for the cluster:b. After workloads are running on the new pool, delete the old pool:# Example: cordon and drain each node (replace <node_name> with actual node names) kubectl cordon <node_name> kubectl drain <node_name> --ignore-daemonsets --delete-emptydir-datagcloud container node-pools delete "${NODE_POOL}" \ --cluster="${CLUSTER_NAME}" \ --location="${LOCATION}" \ --project "${PROJECT_ID}" \ --quiet -
Verification (same machine; confirms no node pool is using the default service account)
Confirm that none of the listed service accounts is
PROJECT_ID="$(gcloud config get-value project)" CLUSTER_NAME="<YOUR_CLUSTER_NAME>" LOCATION="<YOUR_CLUSTER_LOCATION>" for NP in $(gcloud container node-pools list \ --cluster "${CLUSTER_NAME}" \ --location "${LOCATION}" \ --project "${PROJECT_ID}" \ --format='value(name)'); do SA="$(gcloud container node-pools describe "${NP}" \ --cluster "${CLUSTER_NAME}" \ --location "${LOCATION}" \ --project "${PROJECT_ID}" \ --format='value(config.serviceAccount)')" echo "Node pool: ${NP} Service Account: ${SA}" donedefault.
Using kubectl
Using kubectl
kubectl cannot change which Google Cloud service account GKE nodes use, because this setting is defined on the managed control plane / node pool configuration in GCP (via gcloud, console, or IaC). To remediate this finding, make the changes described in the Manual Steps section using the cloud provider tools rather than kubectl.
Automation
Automation
#!/usr/bin/env bash
#
# Remediate CIS GKE 5.2.1:
# Ensure GKE node pools are not using the Compute Engine default service account.
#
# Requirements (run on any machine with gcloud configured and GKE admin/IAM admin perms):
# - gcloud CLI authenticated
# - PROJECT_ID, CLUSTER_NAME, LOCATION set in the environment
#
# Optional:
# - NODE_POOL_FILTER: substring to match node pool names to migrate (default: all)
# - DRY_RUN=true to see what would be changed without applying
#
# Behavior:
# - Creates (or reuses) a minimally privileged node service account
# - Creates new node pools using that SA for any node pools using the default SA
# - Does NOT delete old node pools (manual step to avoid disruption)
# - Verifies all node pools are no longer using the default SA
#
set -euo pipefail
############################
# Config and safety checks #
############################
: "${PROJECT_ID:?PROJECT_ID must be set}"
: "${CLUSTER_NAME:?CLUSTER_NAME must be set}"
: "${LOCATION:?LOCATION must be set}"
NODE_SA_NAME="${NODE_SA_NAME:-gke-node-sa}"
NODE_SA_DISPLAY_NAME="${NODE_SA_DISPLAY_NAME:-GKE Node Service Account}"
NODE_POOL_FILTER="${NODE_POOL_FILTER:-}" # if non-empty, only node pools whose name contains this string are processed
DRY_RUN="${DRY_RUN:-false}"
echo "Project: ${PROJECT_ID}"
echo "Cluster: ${CLUSTER_NAME}"
echo "Location: ${LOCATION}"
echo "Node SA name: ${NODE_SA_NAME}"
echo "Dry-run: ${DRY_RUN}"
[[ -n "${NODE_POOL_FILTER}" ]] && echo "Node pool filter: ${NODE_POOL_FILTER}"
gcloud config set project "${PROJECT_ID}" >/dev/null
########################################
# Helper: run or echo depending on DRY #
########################################
run_cmd() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY-RUN] $*"
else
echo "[RUN] $*"
eval "$@"
fi
}
###################################
# Step 1: Ensure node SA exists #
###################################
echo
echo "==> Ensuring node service account exists"
EXISTING_SA_EMAIL="$(gcloud iam service-accounts list \
--format='value(email)' \
--filter="email:${NODE_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
2>/dev/null || true)"
if [[ -z "${EXISTING_SA_EMAIL}" ]]; then
run_cmd "gcloud iam service-accounts create ${NODE_SA_NAME} --display-name \"${NODE_SA_DISPLAY_NAME}\""
else
echo "Service account already exists: ${EXISTING_SA_EMAIL}"
fi
NODE_SA_EMAIL="${NODE_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
echo "Using service account: ${NODE_SA_EMAIL}"
###########################################
# Step 2: Ensure required IAM roles bound #
###########################################
echo
echo "==> Ensuring required IAM roles on project ${PROJECT_ID}"
REQUIRED_ROLES=(
"roles/monitoring.metricWriter"
"roles/monitoring.viewer"
"roles/logging.logWriter"
)
for ROLE in "${REQUIRED_ROLES[@]}"; do
echo "Checking role: ${ROLE}"
BINDING_EXISTS="$(gcloud projects get-iam-policy "${PROJECT_ID}" \
--format="flatten(bindings[].members) \
| filter(bindings.role:'${ROLE}') \
| filter(bindings.members:'serviceAccount:${NODE_SA_EMAIL}') \
| format('yes')" 2>/dev/null || true)"
if [[ "${BINDING_EXISTS}" == "yes" ]]; then
echo " Binding already present for ${ROLE}"
else
run_cmd "gcloud projects add-iam-policy-binding ${PROJECT_ID} \
--member serviceAccount:${NODE_SA_EMAIL} \
--role ${ROLE}"
fi
done
#####################################################
# Step 3: Identify node pools using default SA #
#####################################################
echo
echo "==> Inspecting node pools on cluster ${CLUSTER_NAME}"
NODE_POOLS_JSON="$(gcloud container clusters describe "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" \
--format=json)"
DEFAULT_NODE_POOLS=()
MAPFILE -t ALL_POOLS < <(echo "${NODE_POOLS_JSON}" | jq -r '.nodePools[].name')
for NP in "${ALL_POOLS[@]}"; do
if [[ -n "${NODE_POOL_FILTER}" ]] && [[ "${NP}" != *"${NODE_POOL_FILTER}"* ]]; then
echo "Skipping node pool (filter): ${NP}"
continue
fi
SA=$(gcloud container node-pools describe "${NP}" \
--cluster "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" \
--format='value(config.serviceAccount)' 2>/dev/null || echo "")
echo "Node pool: ${NP} Service Account: ${SA}"
if [[ "${SA}" == "default" ]] || [[ -z "${SA}" ]]; then
echo " -> Marked for remediation (uses default or empty SA)"
DEFAULT_NODE_POOLS+=("${NP}")
fi
done
if [[ "${#DEFAULT_NODE_POOLS[@]}" -eq 0 ]]; then
echo
echo "No node pools using the default service account were found (within filter)."
echo "Verification: all node pools should show DEFAULT_SA_NOT_IN_USE in the audit."
else
echo
echo "Node pools using default service account:"
printf ' - %s\n' "${DEFAULT_NODE_POOLS[@]}"
fi
####################################################
# Step 4: Create new node pools with custom SA #
####################################################
if [[ "${#DEFAULT_NODE_POOLS[@]}" -gt 0 ]]; then
echo
echo "==> Creating replacement node pools using ${NODE_SA_EMAIL}"
for OLD_NP in "${DEFAULT_NODE_POOLS[@]}"; do
# Derive a new node pool name that is deterministic and idempotent
NEW_NP="${OLD_NP}-sa"
# Check if replacement already exists
if gcloud container node-pools describe "${NEW_NP}" \
--cluster "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" >/dev/null 2>&1; then
echo "Replacement node pool already exists: ${NEW_NP} (skipping create)"
continue
fi
# Get template configuration from old node pool to keep size and machine type
echo "Preparing replacement for node pool: ${OLD_NP}"
NODE_COUNT=$(gcloud container node-pools describe "${OLD_NP}" \
--cluster "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" \
--format='value(initialNodeCount)' 2>/dev/null || echo "3")
MACHINE_TYPE=$(gcloud container node-pools describe "${OLD_NP}" \
--cluster "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" \
--format='value(config.machineType)' 2>/dev/null || echo "")
MT_FLAG=""
if [[ -n "${MACHINE_TYPE}" ]]; then
MT_FLAG="--machine-type=${MACHINE_TYPE}"
fi
echo " New node pool: ${NEW_NP}"
echo " Node count: ${NODE_COUNT}"
[[ -n "${MACHINE_TYPE}" ]] && echo " Machine type: ${MACHINE_TYPE}"
run_cmd "gcloud container node-pools create ${NEW_NP} \
--cluster=${CLUSTER_NAME} \
--location=${LOCATION} \
--num-nodes=${NODE_COUNT} \
${MT_FLAG} \
--service-account=${NODE_SA_EMAIL}"
done
echo
echo "NOTE: Workloads must be migrated to the new node pools and old node pools deleted manually once safe."
echo " This script intentionally does NOT delete any existing node pools."
fi
########################################
# Step 5: Verification (CIS-style) #
########################################
echo
echo "==> Verification: checking node pool service accounts"
for NP in "${ALL_POOLS[@]}"; do
SA=$(gcloud container node-pools describe "${NP}" \
--cluster "${CLUSTER_NAME}" \
--location "${LOCATION}" \
--project "${PROJECT_ID}" \
--format='value(config.serviceAccount)' 2>/dev/null || echo "")
STATUS="DEFAULT_SA_NOT_IN_USE"
if [[ "${SA}" == "default" ]] || [[ -z "${SA}" ]]; then
STATUS="DEFAULT_SA_IN_USE"
fi
echo "Node pool: ${NP} SA: ${SA:-<none>} Status: ${STATUS}"
done
echo
echo "Script completed."
echo "Any node pool still showing DEFAULT_SA_IN_USE must be migrated manually or via a follow-up run."

