Kubernetes Cloud Hardening Review (EKS / AKS / GKE)

Kubernetes Cloud Hardening Review: EKS / AKS / GKE

Scope: everything that lives outside the Kubernetes API but decides how secure the cluster is - control-plane exposure, cloud identity ↔ cluster identity, node configuration, logging/detection, secrets encryption, image supply chain and cloud-native network controls. In-cluster items (RBAC, Pod Security, NetworkPolicy, etcd, kubelet ...) are in Kubernetes Pentesting Guide - this note only repeats them where the cloud layer changes the answer.

Format per item: Risk → Vulnerable when → CLI (copy/paste) → GUI (where to click) → Fix (CLI + GUI). Placeholders: <REGION>, <CLUSTER>, <RG>, <SUB>, <TENANT>, <PROJECT>, <LOCATION>. Set the env vars in §0.1 once and every command below works as-is.


0. Setup, access and automated baseline

0.1 Environment variables (set once)

# ---- AWS ----
export AWS_PROFILE=<profile>
export REGION=<REGION>            # e.g. eu-west-1
export CLUSTER=<CLUSTER>
export AWS_PAGER=""

# ---- Azure ----
export SUB=<SUB>                  # subscription id
export RG=<RG>                    # resource group of the cluster
export CLUSTER=<CLUSTER>
az account set --subscription "$SUB"
export AKS_ID=$(az aks show -g "$RG" -n "$CLUSTER" --query id -o tsv)

# ---- GCP ----
export PROJECT=<PROJECT>
export LOCATION=<LOCATION>        # zone (europe-west4-a) or region (europe-west4)
export CLUSTER=<CLUSTER>
gcloud config set project "$PROJECT"

0.2 What access you need as a reviewer (read-only is enough)

CloudCloud-side (read)Cluster-side (read)
AWSReadOnlyAccess (or SecurityAudit) + eks:AccessKubernetesApiEKS access entry with AmazonEKSViewPolicy (or aws-auth → a group bound to view)
AzureReader on the subscription/RG + Azure Kubernetes Service Cluster User RoleAzure Kubernetes Service RBAC Reader (Azure RBAC) or an Entra group bound to view
GCProles/viewer + roles/container.clusterViewerK8s view ClusterRole for your Google identity / group

SecurityAudit / Reader / roles/viewer do not give you kubectl by themselves. If the cluster API is private you also need a path in (VPN, bastion, Cloud Shell, az aks command invoke, gcloud ... --internal-ip).

0.3 Get a kubeconfig

# AWS
aws eks update-kubeconfig --name "$CLUSTER" --region "$REGION"
# Azure (never use --admin for a review; if it works, that is a finding - see §3.3.3)
az aks get-credentials -g "$RG" -n "$CLUSTER" --overwrite-existing
# GCP (needs gke-gcloud-auth-plugin: gcloud components install gke-gcloud-auth-plugin)
gcloud container clusters get-credentials "$CLUSTER" --location "$LOCATION"

kubectl cluster-info && kubectl auth whoami && kubectl auth can-i --list | head -40

0.4 One-shot "security dump" per cloud (do this first)

Everything in §2-§4 reads from these objects; save them to a file and grep as you go.

# AWS - the fields that matter, in one query
aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.{
  version:version, platform:platformVersion, support:upgradePolicy.supportType,
  authMode:accessConfig.authenticationMode,
  publicEndpoint:resourcesVpcConfig.endpointPublicAccess,
  privateEndpoint:resourcesVpcConfig.endpointPrivateAccess,
  publicCidrs:resourcesVpcConfig.publicAccessCidrs,
  egressMode:resourcesVpcConfig.controlPlaneEgressMode,
  logs:logging.clusterLogging[?enabled].types,
  kms:encryptionConfig[0].provider.keyArn,
  oidc:identity.oidc.issuer, deletionProtection:deletionProtection,
  autoMode:computeConfig.enabled}' --output yaml | tee eks-$CLUSTER.yaml
aws eks describe-cluster --name "$CLUSTER" --region "$REGION" > eks-$CLUSTER-full.json
# Azure - same idea
az aks show -g "$RG" -n "$CLUSTER" --query '{
  version:kubernetesVersion, support:supportPlan,
  privateCluster:apiServerAccessProfile.enablePrivateCluster,
  authorizedIps:apiServerAccessProfile.authorizedIpRanges,
  vnetIntegration:apiServerAccessProfile.enableVnetIntegration,
  runCommandDisabled:apiServerAccessProfile.disableRunCommand,
  entraManaged:aadProfile.managed, azureRbac:aadProfile.enableAzureRbac,
  adminGroups:aadProfile.adminGroupObjectIDs,
  localAccountsDisabled:disableLocalAccounts, k8sRbac:enableRbac,
  oidcIssuer:oidcIssuerProfile.enabled,
  workloadIdentity:securityProfile.workloadIdentity.enabled,
  defender:securityProfile.defender.securityMonitoring.enabled,
  etcdKms:securityProfile.azureKeyVaultKms.enabled,
  imageCleaner:securityProfile.imageCleaner.enabled,
  netPlugin:networkProfile.networkPlugin, netPluginMode:networkProfile.networkPluginMode,
  netPolicy:networkProfile.networkPolicy, dataplane:networkProfile.networkDataplane,
  outbound:networkProfile.outboundType,
  azurePolicy:addonProfiles.azurepolicy.enabled,
  kvSecretsProvider:addonProfiles.azureKeyvaultSecretsProvider.enabled,
  containerInsights:addonProfiles.omsagent.enabled,
  upgradeChannel:autoUpgradeProfile.upgradeChannel,
  nodeOsChannel:autoUpgradeProfile.nodeOsUpgradeChannel,
  identity:identity.type}' -o yaml | tee aks-$CLUSTER.yaml
az aks show -g "$RG" -n "$CLUSTER" > aks-$CLUSTER-full.json
# GCP - same idea
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(
  name,currentMasterVersion,releaseChannel,autopilot.enabled,
  privateClusterConfig,controlPlaneEndpointsConfig,masterAuthorizedNetworksConfig,
  legacyAbac,masterAuth.clientCertificateConfig,anonymousAuthenticationConfig,rbacBindingConfig,
  authenticatorGroupsConfig,workloadIdentityConfig,shieldedNodes,databaseEncryption,
  binaryAuthorization,networkPolicy,networkConfig.datapathProvider,
  networkConfig.enableIntraNodeVisibility,addonsConfig.networkPolicyConfig,
  securityPostureConfig,loggingConfig,monitoringConfig,secretManagerConfig,
  nodePools[].name,nodePools[].version,nodePools[].config.serviceAccount,
  nodePools[].config.oauthScopes,nodePools[].config.shieldedInstanceConfig,
  nodePools[].config.workloadMetadataConfig,nodePools[].config.imageType,
  nodePools[].config.metadata,nodePools[].config.sandboxConfig,nodePools[].management)" \
  | tee gke-$CLUSTER.yaml
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format=json > gke-$CLUSTER-full.json

0.5 Automated baseline (run these, then use §2-§4 to verify and explain)

# --- kube-bench: CIS benchmark for the *managed* flavour (runs as a Job on a node) ---
# Managed benchmarks: eks-1.7.0, aks-1.8, gke-1.6.0 (ls cfg/ in the repo for newer ones)
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-eks.yaml   # EKS
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-aks.yaml   # AKS
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job-gke.yaml   # GKE
kubectl logs -l app=kube-bench --tail=-1 ; kubectl delete job kube-bench
# Or on a node you can exec into:
kube-bench run --benchmark eks-1.7.0      # aks-1.8 / gke-1.6.0

# --- Prowler: cloud-side checks for the K8s service only ---
pip install prowler
prowler aws   -s eks --region "$REGION" -M csv html
prowler azure -s aks --subscription-id "$SUB" --az-cli-auth -M csv html
prowler gcp   -s gke --project-id "$PROJECT" -M csv html

# --- Steampipe / Powerpipe (you already have both installed) ---
steampipe plugin install aws azure gcp
steampipe query "select name, version, endpoint_public_access, public_access_cidrs, logging, encryption_config from aws_eks_cluster"
steampipe query "select name, kubernetes_version, api_server_access_profile, aad_profile, disable_local_accounts, network_profile ->> 'networkPolicy' as net_policy from azure_kubernetes_cluster"
steampipe query "select name, current_master_version, private_cluster_config, master_authorized_networks_config, workload_identity_config, database_encryption, shielded_nodes from gcp_kubernetes_cluster"
powerpipe mod install github.com/turbot/steampipe-mod-aws-compliance   # azure-compliance / gcp-compliance
powerpipe benchmark list | grep -i -E "eks|aks|gke|kubernetes"
powerpipe benchmark run <benchmark-name> --export html

# --- In-cluster scanners that know about the managed flavour ---
kubescape list frameworks | grep -i -E "cis-(eks|aks|gke)"
kubescape scan framework <cis-framework-name> --format html --output kubescape.html
trivy k8s --report summary cluster
# EKS only - AWS's own best-practices checker (needs eks:* read + kubectl)
pip install hardeneks && hardeneks --cluster "$CLUSTER" --region "$REGION" --export-html hardeneks.html

1. Universal checklist (cross-cloud matrix)

Tick these first; each row links to the detailed item. Bold = usually High/Critical when it fails.

#ControlEKSAKSGKE
1API server not reachable from the internet (private, or allow-listed CIDRs)§2.2.1§3.2.1§4.2.1
2Cloud IAM → cluster mapping is least-privilege (no broad cluster-admin / system:masters)§2.3§3.3§4.3
3Cloud-native auth used (access entries / Entra ID / Google IAM), no static or local credentials§2.3.1§3.3.1, §3.3.3§4.3.1-4.3.3
4Pod-level cloud identity (IRSA / Pod Identity, Workload Identity) - pods do not inherit the node's identity§2.4§3.4§4.4
5Metadata service (169.254.169.254) not reachable from pods (IMDSv2 + hop limit 1 / NetworkPolicy / GKE metadata server)§2.4.5§3.4.4§4.4.4
6Node identity is minimal (no AdministratorAccess, Contributor, Editor)§2.4.3§3.4.3§4.4.2
7Secrets encrypted with a customer-managed key (KMS / Key Vault / Cloud KMS)§2.5§3.5§4.5
8Control-plane audit logs on and retained (api/audit/authenticator; kube-audit; APISERVER)§2.6§3.6§4.6
9Cloud threat detection for K8s enabled (GuardDuty EKS / Defender for Containers / SCC + posture)§2.6.3§3.6.3§4.6.3
10Supported, patched K8s version; auto-upgrade / release channel§2.1§3.1§4.1
11Nodes: hardened OS image, no public IP, no SSH from internet, auto-upgrade§2.7§3.7§4.7
12NetworkPolicy enforcement available and used (VPC CNI policy / Azure-Calico-Cilium / Dataplane V2)§2.8.1§3.8.2§4.8.1
13Egress controlled (private subnets + NAT, Azure Firewall/UDR, Cloud NAT + FQDN policy)§2.8.3§3.8.3§4.8.3
14Only intended Services/Ingresses are internet-facing§5.2§5.2§5.2
15Images from a private, scanned, immutable registry; admission enforced (ECR / ACR + Policy / AR + Binary Authorization)§2.10§3.9§4.9
16Policy engine for pod security (PSA, Gatekeeper, Azure Policy, Policy Controller)§5.4§3.6.4§4.9.2
17Managed add-ons / system components current§2.9§3.7.4§4.7.3

2. AWS: EKS

Console root: https://console.aws.amazon.com/eks/home?region=<REGION>#/clusters Cluster page: https://console.aws.amazon.com/eks/home?region=<REGION>#/clusters/<CLUSTER> → tabs Overview · Resources · Compute · Networking · Add-ons · Access · Observability · Update history · Tags Full console map in §2.12.

2.1 Inventory and version

2.1.1 Find every cluster (all regions - clusters hide in regions nobody uses)

for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  for c in $(aws eks list-clusters --region "$r" --query 'clusters[]' --output text); do
    echo "$r $c"
  done
done

GUI: EKS console → Clusters (switch region in the top-right). Faster for many accounts: Resource Explorer https://console.aws.amazon.com/resource-explorer/home → search resourcetype:eks:cluster.

2.1.2 Kubernetes version is supported (not in extended support, not end-of-life)

Risk: Unsupported versions miss security patches for the API server and components; extended support costs 6× and signals an unmaintained cluster. Vulnerable when: version is older than the oldest version in standard support (EKS supports ~14 months per minor), or upgradePolicy.supportType == EXTENDED.

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" \
  --query 'cluster.{version:version,platform:platformVersion,support:upgradePolicy.supportType}'
# Upgrade-readiness insights (deprecated API usage, add-on compatibility)
aws eks list-insights --cluster-name "$CLUSTER" --region "$REGION" --output table
aws eks describe-addon-versions --kubernetes-version <VERSION> --query 'addons[].addonName' # what is supported for that version

GUI: Cluster → Overview (Kubernetes version, platform version, Upgrade policy) · Update history · Upgrade insights (Overview tab). Fix: aws eks update-cluster-version --name "$CLUSTER" --region "$REGION" --kubernetes-version <NEXT_MINOR> (one minor at a time; upgrade node groups + add-ons after) · GUI: Overview → Upgrade version. Set --upgrade-policy supportType=STANDARD so the cluster is auto-upgraded instead of silently entering extended support.

2.1.3 Deletion protection enabled (production)

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.deletionProtection'

Fix: aws eks update-cluster-config --name "$CLUSTER" --region "$REGION" --deletion-protection · GUI: Overview → Edit → Deletion protection.

2.2 Control-plane endpoint exposure

2.2.1 API endpoint reachable from the internet

Risk: The API server is the crown jewel. Public + 0.0.0.0/0 means any leaked token/kubeconfig is usable from anywhere, and the endpoint is brute-forceable/DoS-able. Vulnerable when: endpointPublicAccess: true and publicAccessCidrs contains 0.0.0.0/0 (the default). Public access restricted to office/VPN CIDRs is Medium; public with 0.0.0.0/0 is High.

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" \
  --query 'cluster.resourcesVpcConfig.{public:endpointPublicAccess,private:endpointPrivateAccess,cidrs:publicAccessCidrs}'
# Prove it from the outside (expect 401/403 JSON if reachable at all):
curl -sk "$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.endpoint --output text)/version"

GUI: Cluster → Networking tab → API server endpoint access (Public / Public and private / Private, and the CIDR list). Fix (CLI):

# Preferred: private only (needs VPN/bastion/VPC access to run kubectl)
aws eks update-cluster-config --name "$CLUSTER" --region "$REGION" \
  --resources-vpc-config endpointPublicAccess=false,endpointPrivateAccess=true
# Acceptable: public + allow-list
aws eks update-cluster-config --name "$CLUSTER" --region "$REGION" \
  --resources-vpc-config endpointPublicAccess=true,endpointPrivateAccess=true,publicAccessCidrs="<OFFICE_CIDR>/32,<VPN_CIDR>/24"

Fix (GUI): Networking tab → Manage endpoint access → choose Private, or Public and private + Advanced settings → CIDR blocks.

2.2.2 Private endpoint enabled when nodes talk to the control plane

Risk: With only the public endpoint, node ↔ API traffic leaves the VPC (via NAT) and is subject to the public allow-list; locking the allow-list then breaks nodes. Vulnerable when: endpointPrivateAccess: false. Fix: include endpointPrivateAccess=true in the command above · GUI: same dialog.

2.2.3 Cluster security group only allows what EKS needs

Risk: The cluster SG is attached to the control-plane ENIs and managed nodes; a wide inbound rule exposes kubelet (10250), NodePorts and anything on the nodes to the whole VPC/peered networks. Vulnerable when: inbound rules other than "self" (SG → same SG) and 443 from known sources.

SG=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.resourcesVpcConfig.clusterSecurityGroupId --output text)
aws ec2 describe-security-group-rules --region "$REGION" --filters Name=group-id,Values=$SG \
  --query 'SecurityGroupRules[?IsEgress==`false`].{proto:IpProtocol,from:FromPort,to:ToPort,cidr:CidrIpv4,srcSG:ReferencedGroupInfo.GroupId}' --output table
# Also the *additional* SGs passed at creation:
aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.resourcesVpcConfig.securityGroupIds

GUI: Cluster → Networking tab → Cluster security group link → EC2 → Security Groups → Inbound rules. Fix: remove 0.0.0.0/0 / wide-CIDR inbound rules; keep only SG-self and 443 from bastion/VPN SGs: aws ec2 revoke-security-group-ingress --group-id $SG --ip-permissions '<rule json>'.

2.3 Authentication & authorization

2.3.1 Authentication mode uses access entries (not only the aws-auth ConfigMap)

Risk: aws-auth is a plain ConfigMap: anyone with write on it (or on kube-system) grants themselves system:masters; it has no IAM audit trail and typos lock everyone out. Access entries are IAM-managed, CloudTrail-logged and can be scoped to namespaces. Vulnerable when: accessConfig.authenticationMode == CONFIG_MAP. API_AND_CONFIG_MAP is a transitional state - check that aws-auth no longer contains humans.

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.accessConfig'
kubectl -n kube-system get configmap aws-auth -o yaml 2>/dev/null   # should be absent or only contain the node role(s)

GUI: Cluster → Access tab → Authentication mode and the IAM access entries table. Fix: aws eks update-cluster-config --name "$CLUSTER" --region "$REGION" --access-config authenticationMode=API_AND_CONFIG_MAP → migrate every mapRoles/mapUsers entry to an access entry → --access-config authenticationMode=API (one-way). GUI: Access tab → Manage access → Authentication mode.

2.3.2 Who is cluster-admin? (access entries + access policies)

Risk: AmazonEKSClusterAdminPolicy at cluster scope == cluster-admin; a kubernetesGroups: [system:masters] entry bypasses RBAC entirely. Every such principal is a full-cluster compromise path through IAM (role assumption, leaked keys, SSO misconfig). Vulnerable when: more than a break-glass role has admin; admin granted to IAM users (static keys); broad roles (OrganizationAccountAccessRole, SSO AdministratorAccess permission set) mapped in.

# All principals that can authenticate
aws eks list-access-entries --cluster-name "$CLUSTER" --region "$REGION" --output table
# For each: K8s groups + attached access policies and scope
for p in $(aws eks list-access-entries --cluster-name "$CLUSTER" --region "$REGION" --query 'accessEntries[]' --output text); do
  echo "== $p"
  aws eks describe-access-entry --cluster-name "$CLUSTER" --region "$REGION" --principal-arn "$p" --query 'accessEntry.{type:type,groups:kubernetesGroups,user:username}'
  aws eks list-associated-access-policies --cluster-name "$CLUSTER" --region "$REGION" --principal-arn "$p" --query 'associatedAccessPolicies[].{policy:policyArn,scope:accessScope}'
done
# Legacy path - anything in aws-auth with system:masters
kubectl -n kube-system get configmap aws-auth -o yaml | grep -B3 -A3 'system:masters'
# The cluster creator is a hidden admin unless bootstrapClusterCreatorAdminPermissions was false at creation
aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.accessConfig.bootstrapClusterCreatorAdminPermissions'

GUI: Cluster → Access tab → IAM access entries → click each → Access policies (shows AmazonEKSClusterAdminPolicy / AmazonEKSAdminPolicy / AmazonEKSEditPolicy / AmazonEKSViewPolicy and scope). Fix (CLI):

# Downgrade an admin to namespace-scoped edit
aws eks disassociate-access-policy --cluster-name "$CLUSTER" --region "$REGION" --principal-arn <ARN> \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy
aws eks associate-access-policy --cluster-name "$CLUSTER" --region "$REGION" --principal-arn <ARN> \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy --access-scope type=namespace,namespaces=<NS>
# Remove a principal entirely
aws eks delete-access-entry --cluster-name "$CLUSTER" --region "$REGION" --principal-arn <ARN>

Fix (GUI): Access tab → select entry → Edit / Delete; Access policies → Add/Remove. Keep one break-glass role with MFA-enforced trust policy.

2.3.3 IAM principals that can grant themselves cluster access

Risk: eks:CreateAccessEntry, eks:AssociateAccessPolicy, eks:UpdateClusterConfig (auth mode, endpoint), eks:CreateCluster, and iam:PassRole on the cluster role are cluster-admin-equivalent at the IAM layer - classic privilege-escalation path from a "harmless" DevOps policy.

# Who has eks:* or the dangerous actions (quick and dirty, inline + attached policies)
for role in $(aws iam list-roles --query 'Roles[].RoleName' --output text); do
  for pol in $(aws iam list-attached-role-policies --role-name "$role" --query 'AttachedPolicies[].PolicyArn' --output text); do
    ver=$(aws iam get-policy --policy-arn "$pol" --query Policy.DefaultVersionId --output text)
    aws iam get-policy-version --policy-arn "$pol" --version-id "$ver" --query PolicyVersion.Document --output json \
      | grep -q -E '"eks:\*"|eks:CreateAccessEntry|eks:AssociateAccessPolicy|eks:UpdateClusterConfig|"\*"' && echo "$role -> $pol"
  done
done
# Better: IAM Access Analyzer / Prowler iam_* checks, or Steampipe:
steampipe query "select name from aws_iam_policy where policy_std -> 'Statement' @> '[{\"Action\":[\"eks:*\"]}]'"

GUI: IAM → Roles → role → Permissions → search eks:. IAM → Access Analyzer → Unused access / policy checks. Fix: split eks:Describe*/List* (read) from eks:*Access*/eks:UpdateClusterConfig (admin); require MFA/aws:SourceIp conditions on the admin set; put a Service Control Policy on eks:UpdateClusterConfig outside the platform role.

2.3.4 OIDC identity providers on the cluster

Risk: An external OIDC IdP (aws eks associate-identity-provider-config) lets non-IAM identities log in; a weak groupsClaim/usernameClaim mapping or a shared tenant can map arbitrary users to admin groups.

aws eks list-identity-provider-configs --cluster-name "$CLUSTER" --region "$REGION"
aws eks describe-identity-provider-config --cluster-name "$CLUSTER" --region "$REGION" --identity-provider-config type=oidc,name=<NAME>
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.subjects[]?.kind=="Group") | "\(.metadata.name): \(.roleRef.name) <- \([.subjects[].name]|join(","))"'

GUI: Cluster → Access tab → OIDC identity providers. Fix: use groupsPrefix/usernamePrefix so external groups can never collide with system:*; bind external groups to namespaced Roles, not cluster-admin.

2.4 Pod identity, node IAM and the metadata service

2.4.1 IRSA / Pod Identity configured (pods get their own IAM role)

Risk: Without IRSA or Pod Identity, every pod uses the node's instance role → one SSRF or RCE in any pod == the node's AWS permissions. Vulnerable when: there is no IAM OIDC provider for the cluster and no Pod Identity associations, while workloads call AWS APIs.

# OIDC issuer registered in IAM? (required for IRSA)
ISSUER=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.identity.oidc.issuer --output text | sed 's#https://##')
aws iam list-open-id-connect-providers --query "OpenIDConnectProviderList[?contains(Arn, '$ISSUER')]"
# IRSA in use? (annotated service accounts)
kubectl get sa -A -o json | jq -r '.items[] | select(.metadata.annotations["eks.amazonaws.com/role-arn"]) | "\(.metadata.namespace)/\(.metadata.name) -> \(.metadata.annotations["eks.amazonaws.com/role-arn"])"'
# Pod Identity in use? (agent add-on + associations)
aws eks describe-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name eks-pod-identity-agent --query 'addon.{status:status,version:addonVersion}'
aws eks list-pod-identity-associations --cluster-name "$CLUSTER" --region "$REGION" --output table

GUI: Cluster → Access tab → Pod Identity associations · Cluster → Overview → OpenID Connect provider URL · IAM → Identity providers https://console.aws.amazon.com/iam/home#/identity_providers. Fix: aws eks create-addon --cluster-name "$CLUSTER" --addon-name eks-pod-identity-agent + aws eks create-pod-identity-association --cluster-name "$CLUSTER" --namespace <NS> --service-account <SA> --role-arn <ROLE_ARN> (or eksctl utils associate-iam-oidc-provider --cluster "$CLUSTER" --approve for IRSA). GUI: Access tab → Create Pod Identity association.

2.4.2 IRSA trust policies are scoped to one namespace/service account

Risk: A trust policy with "...:sub": "system:serviceaccount:*:*" or only an aud condition lets any pod in the cluster assume the role.

for role in $(kubectl get sa -A -o json | jq -r '.items[].metadata.annotations["eks.amazonaws.com/role-arn"] // empty' | sort -u | awk -F/ '{print $NF}'); do
  echo "== $role"; aws iam get-role --role-name "$role" --query 'Role.AssumeRolePolicyDocument.Statement[].Condition'
done

GUI: IAM → Roles → role → Trust relationships. Fix: condition must be "StringEquals": {"<ISSUER>:sub": "system:serviceaccount:<NS>:<SA>", "<ISSUER>:aud": "sts.amazonaws.com"}.

2.4.3 Node IAM role is minimal

Risk: The node role is reachable from every pod that can hit IMDS (see 2.4.5). Extra policies (S3 full, AdministratorAccess, ecr:* push, secretsmanager:*) turn any container escape or SSRF into an account compromise. Vulnerable when: anything beyond AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly/AmazonEC2ContainerRegistryPullOnly, AmazonEKS_CNI_Policy (and AmazonSSMManagedInstanceCore if SSM is used). Note AmazonEKS_CNI_Policy itself should move to IRSA for aws-node.

for ng in $(aws eks list-nodegroups --cluster-name "$CLUSTER" --region "$REGION" --query 'nodegroups[]' --output text); do
  ROLE=$(aws eks describe-nodegroup --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name "$ng" --query nodegroup.nodeRole --output text | awk -F/ '{print $NF}')
  echo "== $ng -> $ROLE"
  aws iam list-attached-role-policies --role-name "$ROLE" --query 'AttachedPolicies[].PolicyName' --output text
  aws iam list-role-policies --role-name "$ROLE" --output text   # inline
done
# Self-managed nodes / Karpenter: look at the instance profile of the instances
aws ec2 describe-instances --region "$REGION" --filters "Name=tag:kubernetes.io/cluster/$CLUSTER,Values=owned" \
  --query 'Reservations[].Instances[].{id:InstanceId,profile:IamInstanceProfile.Arn}' --output table

GUI: Cluster → Compute tab → node group → Node IAM role ARN link → IAM role → Permissions. Fix: detach everything not in the list above; move AmazonEKS_CNI_Policy to an IRSA role for kube-system/aws-node; give workloads their own roles (2.4.1).

2.4.4 Instance metadata hardened: IMDSv2 required and hop limit 1

Risk: With IMDSv1 or hop limit ≥ 2, any pod (even with hostNetwork: false) can curl 169.254.169.254 and read the node role credentials. Hop limit 1 makes the IMDS reply die at the node, so only host-network pods can reach it. Vulnerable when: HttpTokens != required or HttpPutResponseHopLimit > 1 on the node group's launch template / the instances.

# From the running instances (source of truth)
aws ec2 describe-instances --region "$REGION" --filters "Name=tag:kubernetes.io/cluster/$CLUSTER,Values=owned" \
  --query 'Reservations[].Instances[].{id:InstanceId,tokens:MetadataOptions.HttpTokens,hops:MetadataOptions.HttpPutResponseHopLimit,endpoint:MetadataOptions.HttpEndpoint}' --output table
# From the launch template of a managed node group
LT=$(aws eks describe-nodegroup --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> --query 'nodegroup.launchTemplate.{id:id,v:version}' --output text)
aws ec2 describe-launch-template-versions --region "$REGION" --launch-template-id <LT_ID> --versions <VER> --query 'LaunchTemplateVersions[].LaunchTemplateData.MetadataOptions'

GUI: EC2 → Instances → instance → Details tab → IMDSv2 / Metadata response hop limit · EC2 → Launch Templates https://console.aws.amazon.com/ec2/home?region=<REGION>#LaunchTemplates: → version → Advanced details → Metadata. Fix (CLI):

# Immediate, per instance
aws ec2 modify-instance-metadata-options --region "$REGION" --instance-id <ID> --http-tokens required --http-put-response-hop-limit 1 --http-endpoint enabled
# Permanent: new launch template version with MetadataOptions {HttpTokens: required, HttpPutResponseHopLimit: 1}, then
aws eks update-nodegroup-version --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> --launch-template id=<LT_ID>,version=<NEW_VER>

Fix (GUI): EC2 → Instances → Actions → Instance settings → Modify instance metadata options; Launch Templates → Modify template (create new version) → Advanced → Metadata version = V2 only, hop limit = 1.

2.4.5 Prove whether pods can reach IMDS (the test that matters)

kubectl run imds-test --rm -it --restart=Never --image=curlimages/curl -- sh -c \
 'echo "--- v1:"; curl -s -m 3 http://169.254.169.254/latest/meta-data/iam/security-credentials/ ; echo; \
  echo "--- v2:"; T=$(curl -s -m 3 -X PUT http://169.254.169.254/latest/api/token -H "X-aws-ec2-metadata-token-ttl-seconds: 60"); \
  curl -s -m 3 -H "X-aws-ec2-metadata-token: $T" http://169.254.169.254/latest/meta-data/iam/security-credentials/'
# Anything other than a timeout / empty response == finding (role name printed == Critical if the node role is fat)

Fix: 2.4.4 (hop limit 1) plus a NetworkPolicy denying egress to 169.254.169.254/32 for all namespaces (VPC CNI network policy, see 2.8.1), or GuardDuty/Falco alerting on IMDS access from pods.

2.5 Secrets encryption

2.5.1 Kubernetes Secrets envelope-encrypted with a customer-managed KMS key

Risk: Secrets in etcd are only base64. EKS encrypts the etcd volume and (on current platform versions) envelope-encrypts API data with an AWS-owned key by default, but a customer-managed key gives you key policy control, rotation, CloudTrail on every decrypt, and the ability to revoke all secrets at once. Vulnerable when: encryptionConfig is empty / null (Medium), or the key policy lets */broad principals kms:Decrypt.

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.encryptionConfig'
KEY=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.encryptionConfig[0].provider.keyArn' --output text)
aws kms get-key-policy --key-id "$KEY" --policy-name default --output text | jq .   # who can Decrypt?
aws kms get-key-rotation-status --key-id "$KEY"

GUI: Cluster → Overview → Secrets encryption (KMS key ARN) · KMS https://console.aws.amazon.com/kms/home?region=<REGION>#/kms/keys → key → Key policy / Key rotation. Fix: aws eks associate-encryption-config --cluster-name "$CLUSTER" --region "$REGION" --encryption-config '[{"resources":["secrets"],"provider":{"keyArn":"<KMS_ARN>"}}]' (irreversible, then re-write existing secrets: kubectl get secrets -A -o json | kubectl replace -f -). GUI: Overview → Enable secrets encryption.

2.5.2 Secrets sourced from Secrets Manager / Parameter Store rather than stored in-cluster

kubectl get pods -n kube-system -l app=secrets-store-csi-driver 2>/dev/null
kubectl get secretproviderclass -A 2>/dev/null
kubectl get externalsecrets.external-secrets.io -A 2>/dev/null

Fix: AWS Secrets and Configuration Provider (ASCP) for the Secrets Store CSI driver, or External Secrets Operator with IRSA-scoped read on specific secret ARNs.

2.6 Logging, detection and compliance

2.6.1 All five control-plane log types enabled

Risk: Without audit you cannot reconstruct who did what via the API; without authenticator you cannot see IAM → K8s mappings/failures. GuardDuty EKS Protection also reads these. Vulnerable when: fewer than api, audit, authenticator, controllerManager, scheduler are enabled.

aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query 'cluster.logging.clusterLogging[?enabled].types'
# Retention of the log group (default = never expire; cost vs. retention policy)
aws logs describe-log-groups --region "$REGION" --log-group-name-prefix "/aws/eks/$CLUSTER/cluster" --query 'logGroups[].{name:logGroupName,retention:retentionInDays,kms:kmsKeyId}'

GUI: Cluster → Observability tab → Control plane logging · CloudWatch → Log groups https://console.aws.amazon.com/cloudwatch/home?region=<REGION>#logsV2:log-groups → /aws/eks/<CLUSTER>/cluster. Fix: aws eks update-cluster-config --name "$CLUSTER" --region "$REGION" --logging '{"clusterLogging":[{"types":["api","audit","authenticator","controllerManager","scheduler"],"enabled":true}]}' and aws logs put-retention-policy --log-group-name /aws/eks/$CLUSTER/cluster --retention-in-days 365. GUI: Observability → Manage logging → toggle all.

2.6.2 CloudTrail covers the account/region (EKS control-plane API + IAM)

aws cloudtrail describe-trails --region "$REGION" --query 'trailList[].{name:Name,multiRegion:IsMultiRegionTrail,org:IsOrganizationTrail,validation:LogFileValidationEnabled,kms:KmsKeyId}'
aws cloudtrail get-trail-status --name <TRAIL> --query '{logging:IsLogging,lastDelivery:LatestDeliveryTime}'
# Recent EKS admin actions (who changed access/endpoint)
aws cloudtrail lookup-events --region "$REGION" --lookup-attributes AttributeKey=EventSource,AttributeValue=eks.amazonaws.com --max-results 50 \
  --query 'Events[].{time:EventTime,name:EventName,user:Username}' --output table

GUI: CloudTrail → Trails https://console.aws.amazon.com/cloudtrailv2/home?region=<REGION>#/trails · Event history → filter Event source = eks.amazonaws.com. Fix: one multi-region (preferably organization) trail with log-file validation and KMS; alarms on CreateAccessEntry, AssociateAccessPolicy, UpdateClusterConfig, DeleteCluster.

2.6.3 GuardDuty EKS Protection (audit logs) + Runtime Monitoring

Risk: Audit-log analysis catches anonymous access, privileged-container launches, credential misuse; runtime monitoring (eBPF agent) catches crypto-miners, reverse shells, container escapes. Vulnerable when: no detector, EKS_AUDIT_LOGS disabled, or RUNTIME_MONITORING disabled / agent not on the cluster.

DET=$(aws guardduty list-detectors --region "$REGION" --query 'DetectorIds[0]' --output text)
aws guardduty get-detector --region "$REGION" --detector-id "$DET" \
  --query 'Features[?Name==`EKS_AUDIT_LOGS` || Name==`RUNTIME_MONITORING` || Name==`EKS_RUNTIME_MONITORING`].{name:Name,status:Status,addl:AdditionalConfiguration}'
# Agent actually running?
aws eks describe-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name aws-guardduty-agent --query 'addon.status' 2>/dev/null
kubectl -n amazon-guardduty get ds 2>/dev/null
# Open EKS findings
aws guardduty list-findings --region "$REGION" --detector-id "$DET" --finding-criteria '{"Criterion":{"resource.resourceType":{"Eq":["EKSCluster"]},"service.archived":{"Eq":["false"]}}}'

GUI: GuardDuty → EKS Protection https://console.aws.amazon.com/guardduty/home?region=<REGION>#/eks-protection · Runtime Monitoring ...#/runtime-monitoring → Runtime coverage → EKS clusters coverage. Fix: aws guardduty update-detector --region "$REGION" --detector-id "$DET" --features '[{"Name":"EKS_AUDIT_LOGS","Status":"ENABLED"},{"Name":"RUNTIME_MONITORING","Status":"ENABLED","AdditionalConfiguration":[{"Name":"EKS_ADDON_MANAGEMENT","Status":"ENABLED"}]}]'. GUI: EKS Protection → Enable; Runtime Monitoring → Enable + Automated agent configuration for EKS. Requires 2.6.1 (audit logs) and the VPC endpoint for the agent.

2.6.4 Security Hub standards + Config rules for EKS

aws securityhub get-enabled-standards --region "$REGION" --query 'StandardsSubscriptions[].{arn:StandardsArn,status:StandardsStatus}'
# EKS-specific controls: EKS.1 (public endpoint), EKS.2 (supported version), EKS.3 (secrets encryption), EKS.6/EKS.8 (tags/logging)
aws securityhub get-findings --region "$REGION" --filters '{"ResourceType":[{"Value":"AwsEksCluster","Comparison":"EQUALS"}],"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}]}' \
  --query 'Findings[].{title:Title,sev:Severity.Label,res:Resources[0].Id}' --output table
aws configservice describe-config-rules --region "$REGION" --query 'ConfigRules[?contains(ConfigRuleName,`eks`)].ConfigRuleName'
# Inspector (ECR + EC2 node scanning) enabled?
aws inspector2 batch-get-account-status --region "$REGION" --query 'accounts[].{ec2:resourceState.ec2.status,ecr:resourceState.ecr.status}'

GUI: Security Hub → Standards https://console.aws.amazon.com/securityhub/home?region=<REGION>#/standards · Findings → filter Resource type AwsEksCluster · Config → Rules https://console.aws.amazon.com/config/home?region=<REGION>#/rules · Inspector https://console.aws.amazon.com/inspector/v2/home?region=<REGION>#/dashboard. Fix: enable AWS Foundational Security Best Practices (contains the EKS.* controls) and the managed Config rules eks-endpoint-no-public-access, eks-secrets-encrypted, eks-cluster-logging-enabled, eks-cluster-supported-version, eks-cluster-oldest-supported-version; aws inspector2 enable --resource-types ECR EC2.

2.7 Nodes and node groups

2.7.1 Inventory: managed node groups, Fargate, Karpenter, Auto Mode

aws eks list-nodegroups --cluster-name "$CLUSTER" --region "$REGION"
aws eks describe-nodegroup --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> \
  --query 'nodegroup.{ami:amiType,release:releaseVersion,version:version,ssh:remoteAccess,subnets:subnets,lt:launchTemplate,role:nodeRole,status:status}'
aws eks list-fargate-profiles --cluster-name "$CLUSTER" --region "$REGION"
kubectl get nodes -o custom-columns='NAME:.metadata.name,OS:.status.nodeInfo.osImage,KUBELET:.status.nodeInfo.kubeletVersion,RUNTIME:.status.nodeInfo.containerRuntimeVersion,ZONE:.metadata.labels.topology\.kubernetes\.io/zone'
kubectl get nodepools.karpenter.sh,ec2nodeclasses.karpenter.k8s.aws -A 2>/dev/null

GUI: Cluster → Compute tab (node groups, Fargate profiles, Auto Mode node pools).

2.7.2 Hardened, current node OS (Bottlerocket or AL2023; AL2 is EOL)

Risk: Amazon Linux 2 is end-of-life for EKS (no AL2 AMIs for new K8s versions, no further updates to existing ones); an old releaseVersion means an unpatched kernel/containerd/kubelet. Bottlerocket has a read-only root, SELinux enforcing, no shell/SSH by default. Vulnerable when: amiType is AL2_*, or releaseVersion is months behind the latest for that K8s version.

# Latest recommended AMI for the cluster version (compare with releaseVersion above)
aws ssm get-parameter --region "$REGION" --name /aws/service/eks/optimized-ami/<VERSION>/amazon-linux-2023/x86_64/standard/recommended/release_version --query Parameter.Value --output text
aws ssm get-parameter --region "$REGION" --name /aws/service/bottlerocket/aws-k8s-<VERSION>/x86_64/latest/image_version --query Parameter.Value --output text

GUI: Compute tab → node group → AMI release version (shows "Update now" if behind). Fix: aws eks update-nodegroup-version --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> (rolls to latest AMI for that version) · GUI: node group → Update now. Migrate AL2 groups to BOTTLEROCKET_x86_64 / AL2023_x86_64_STANDARD.

2.7.3 No SSH key / SSH open to the world on node groups - use SSM instead

Risk: remoteAccess.ec2SshKey without sourceSecurityGroups creates an SG rule 0.0.0.0/0 → 22 on the nodes. A node login == every pod's secrets and tokens on that node.

aws eks describe-nodegroup --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> --query 'nodegroup.remoteAccess'
# SG rules with port 22 on any node SG
aws ec2 describe-security-group-rules --region "$REGION" --query 'SecurityGroupRules[?FromPort==`22` && IsEgress==`false`].{sg:GroupId,cidr:CidrIpv4,src:ReferencedGroupInfo.GroupId}' --output table
# Nodes manageable through SSM instead?
aws ssm describe-instance-information --region "$REGION" --query 'InstanceInformationList[].{id:InstanceId,ping:PingStatus,agent:AgentVersion}' --output table

GUI: Compute tab → node group → Remote access; EC2 → Security Groups → eks-remoteAccess-* · Systems Manager → Fleet Manager. Fix: recreate the node group without remoteAccess; attach AmazonSSMManagedInstanceCore to the node role and use aws ssm start-session --target <INSTANCE_ID> (logged to CloudTrail/CloudWatch) for break-glass.

2.7.4 Nodes in private subnets, no public IPs

Risk: Public nodes expose kubelet (10250), NodePorts (30000-32767) and SSH to the internet behind only a security group.

for s in $(aws eks describe-nodegroup --cluster-name "$CLUSTER" --region "$REGION" --nodegroup-name <NG> --query 'nodegroup.subnets[]' --output text); do
  aws ec2 describe-subnets --region "$REGION" --subnet-ids $s --query 'Subnets[].{id:SubnetId,publicIpOnLaunch:MapPublicIpOnLaunch,cidr:CidrBlock}'
  aws ec2 describe-route-tables --region "$REGION" --filters Name=association.subnet-id,Values=$s --query 'RouteTables[].Routes[?GatewayId!=`local`].{dest:DestinationCidrBlock,target:GatewayId||NatGatewayId}'
done
kubectl get nodes -o json | jq -r '.items[] | "\(.metadata.name) \(.status.addresses[] | select(.type=="ExternalIP") | .address)"'

GUI: VPC → Subnets https://console.aws.amazon.com/vpcconsole/home?region=<REGION>#subnets: → subnet → Auto-assign public IPv4 + Route table (igw- route = public). Fix: node groups only in private subnets (route to nat-), aws ec2 modify-subnet-attribute --subnet-id <ID> --no-map-public-ip-on-launch; public subnets are for load balancers only (tag kubernetes.io/role/elb=1, private: kubernetes.io/role/internal-elb=1).

2.7.5 Node-group drift: kubelet version vs control plane, and unmanaged nodes

kubectl get nodes -o custom-columns='NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion,NG:.metadata.labels.eks\.amazonaws\.com/nodegroup,CAP:.metadata.labels.eks\.amazonaws\.com/capacityType'

Nodes without the eks.amazonaws.com/nodegroup label are self-managed → verify their launch template / user data by hand (bootstrap args, --kubelet-extra-args, --anonymous-auth).

2.8 Networking

2.8.1 NetworkPolicy enforcement available (VPC CNI network policy or Calico/Cilium) - and policies exist

Risk: EKS ships with no NetworkPolicy enforcement unless the VPC CNI's network policy feature (or a third-party CNI) is enabled; policies you write are silently ignored. Vulnerable when: enableNetworkPolicy is "false" in the vpc-cni add-on config and no Calico/Cilium is installed; or enforcement is on but kubectl get networkpolicies -A is empty.

aws eks describe-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name vpc-cni --query 'addon.{version:addonVersion,config:configurationValues}'
kubectl -n kube-system get ds aws-node -o jsonpath='{.spec.template.spec.containers[?(@.name=="aws-node")].env}' | jq '.[] | select(.name=="ENABLE_NETWORK_POLICY" or .name=="ENABLE_POD_ENI")'
kubectl get ds -n kube-system | grep -E 'aws-node|calico|cilium'
kubectl get networkpolicies -A ; kubectl get ciliumnetworkpolicies -A 2>/dev/null

GUI: Cluster → Add-ons tab → Amazon VPC CNI → Configuration values (enableNetworkPolicy: "true"). Fix: aws eks update-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name vpc-cni --configuration-values '{"enableNetworkPolicy":"true"}' --resolve-conflicts OVERWRITE, then a default-deny policy per namespace (see Kubernetes Pentesting Guide - Network Policy & Service Mesh). GUI: Add-ons → VPC CNI → Edit → Optional configuration settings → enableNetworkPolicy: true.

2.8.2 Security groups for pods (optional, for sensitive workloads)

kubectl get securitygrouppolicies.vpcresources.k8s.aws -A 2>/dev/null

Use when a pod needs its own SG (e.g. only it may reach RDS). Requires ENABLE_POD_ENI=true on the CNI and supported instance types.

2.8.3 Egress control: private subnets + NAT, VPC endpoints, no 0.0.0.0/0 on node SGs outbound where avoidable

aws ec2 describe-vpc-endpoints --region "$REGION" --filters Name=vpc-id,Values=$(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.resourcesVpcConfig.vpcId --output text) \
  --query 'VpcEndpoints[].{service:ServiceName,type:VpcEndpointType,policy:PolicyDocument}' --output table

Why: endpoints for ecr.api, ecr.dkr, s3, sts, eks, logs, ssm let the cluster function with no internet route at all (fully private). Egress filtering of pods to the internet: AWS Network Firewall in the NAT path, or FQDN-based Cilium policies. GUI: VPC → Endpoints https://console.aws.amazon.com/vpcconsole/home?region=<REGION>#Endpoints:.

2.8.4 Load balancers created by the cluster

kubectl get svc -A -o json | jq -r '.items[] | select(.spec.type=="LoadBalancer") | "\(.metadata.namespace)/\(.metadata.name) scheme=\(.metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-scheme"] // "internet-facing(default)") \(.status.loadBalancer.ingress[0].hostname // "")"'
kubectl get ingress -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) scheme=\(.metadata.annotations["alb.ingress.kubernetes.io/scheme"] // "internal(default)") \(.status.loadBalancer.ingress[0].hostname // "")"'
aws elbv2 describe-load-balancers --region "$REGION" --query 'LoadBalancers[].{name:LoadBalancerName,scheme:Scheme,type:Type,dns:DNSName}' --output table

GUI: EC2 → Load Balancers https://console.aws.amazon.com/ec2/home?region=<REGION>#LoadBalancers: (Scheme column) · Cluster → Resources tab → Service and networking → Services / Ingresses. Fix: annotate service.beta.kubernetes.io/aws-load-balancer-scheme: internal / alb.ingress.kubernetes.io/scheme: internal unless intentionally public; put WAF on public ALBs (alb.ingress.kubernetes.io/wafv2-acl-arn); restrict loadBalancerSourceRanges.

2.9 Add-ons and versions

Risk: Outdated vpc-cni, coredns, kube-proxy, aws-ebs-csi-driver carry CVEs and run with high privileges in kube-system; add-ons installed with the node role instead of IRSA inherit 2.4.3.

aws eks list-addons --cluster-name "$CLUSTER" --region "$REGION"
for a in $(aws eks list-addons --cluster-name "$CLUSTER" --region "$REGION" --query 'addons[]' --output text); do
  cur=$(aws eks describe-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name "$a" --query 'addon.{v:addonVersion,role:serviceAccountRoleArn,pid:podIdentityAssociations}' --output json)
  latest=$(aws eks describe-addon-versions --region "$REGION" --addon-name "$a" --kubernetes-version $(aws eks describe-cluster --name "$CLUSTER" --region "$REGION" --query cluster.version --output text) \
    --query 'addons[0].addonVersions[?compatibilities[0].defaultVersion==`true`].addonVersion | [0]' --output text)
  echo "$a current=$cur latest-default=$latest"
done

GUI: Cluster → Add-ons tab (shows Update available; click add-on → Edit for version + IRSA role). Fix: aws eks update-addon --cluster-name "$CLUSTER" --region "$REGION" --addon-name <ADDON> --addon-version <VER> --resolve-conflicts PRESERVE [--service-account-role-arn <IRSA_ROLE>].

2.10 Image supply chain

2.10.1 ECR: scanning on push (enhanced/continuous), immutable tags, private

Vulnerable when: scanType: BASIC with no scanOnPush, imageTagMutability: MUTABLE (tag latest can be re-pointed → silent supply-chain swap), repository policy allowing * principals, or images pulled from Docker Hub / unknown registries.

aws ecr get-registry-scanning-configuration --region "$REGION"
aws ecr describe-repositories --region "$REGION" --query 'repositories[].{name:repositoryName,mutability:imageTagMutability,scanOnPush:imageScanningConfiguration.scanOnPush,encryption:encryptionConfiguration.encryptionType}' --output table
for r in $(aws ecr describe-repositories --region "$REGION" --query 'repositories[].repositoryName' --output text); do
  aws ecr get-repository-policy --region "$REGION" --repository-name "$r" --query policyText --output text 2>/dev/null | grep -q '"\*"' && echo "PUBLIC/WILDCARD policy: $r"
done
# What registries are actually running in the cluster?
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | awk -F/ '{print $1}' | sort | uniq -c | sort -rn
# Anything using :latest or no digest
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | grep -v '@sha256' | grep -E ':latest$|^[^:]+$'

GUI: ECR → Private registry → Settings → Scanning configuration · Repositories → repo → Edit (Tag immutability) / Permissions https://console.aws.amazon.com/ecr/private-registry/repositories?region=<REGION>. Fix: aws ecr put-registry-scanning-configuration --region "$REGION" --scan-type ENHANCED --rules '[{"scanFrequency":"CONTINUOUS_SCAN","repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]'; aws ecr put-image-tag-mutability --region "$REGION" --repository-name <REPO> --image-tag-mutability IMMUTABLE; ECR pull-through cache for upstream images; admission policy (Kyverno/Gatekeeper) allowing only <ACCOUNT>.dkr.ecr.<REGION>.amazonaws.com/* by digest, signed with cosign/AWS Signer.

2.11 EKS-specific in-cluster checks

# 1. aws-auth leftovers (even in API mode it may still exist and confuse people)
kubectl -n kube-system get cm aws-auth -o yaml 2>/dev/null
# 2. Who holds system:masters / cluster-admin inside RBAC (access entries bypass this list - check both)
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'
# 3. aws-node (CNI) still using the node role? (should have an IRSA annotation / pod identity)
kubectl -n kube-system get sa aws-node -o jsonpath='{.metadata.annotations}'
# 4. Pods that mount the host filesystem paths holding node creds / kubelet config
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.hostPath.path | test("^/(var/lib/kubelet|etc/kubernetes|root|var/run/docker|run/containerd)")) | "\(.metadata.namespace)/\(.metadata.name)"'
# 5. Pods on hostNetwork can always reach IMDS regardless of hop limit
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.hostNetwork==true) | "\(.metadata.namespace)/\(.metadata.name)"'
# 6. Projected IRSA tokens: audience should be sts.amazonaws.com, expiry ~24h max
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.projected.sources[]?.serviceAccountToken.audience=="sts.amazonaws.com") | "\(.metadata.namespace)/\(.metadata.name)"' | head
# 7. Pod Security Admission labels on namespaces (EKS has no PSP; nothing is enforced by default)
kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,WARN:.metadata.labels.pod-security\.kubernetes\.io/warn'

2.12 EKS console map

WhatWhere (console)
Cluster listhttps://console.aws.amazon.com/eks/home?region=<REGION>#/clusters
Version, platform, OIDC URL, secrets encryption, upgrade policy, deletion protectionCluster → Overview
Access entries, auth mode, Pod Identity associations, OIDC IdPsCluster → Access
Endpoint access + CIDRs, VPC/subnets, cluster SGCluster → Networking
Node groups (AMI, release, remote access, IAM role, launch template), Fargate, Auto ModeCluster → Compute
vpc-cni / coredns / kube-proxy / pod-identity-agent / guardduty-agent versions + configCluster → Add-ons
Control-plane logging, CloudWatch Container InsightsCluster → Observability
Workloads, Services, Ingresses, ConfigMaps, Secrets (needs eks:AccessKubernetesApi + view access entry)Cluster → Resources
Upgrade insights (deprecated APIs)Cluster → Overview → Upgrade insights
IAM roles / trust policies / OIDC providershttps://console.aws.amazon.com/iam/home#/roles · ...#/identity_providers
KMS key policy & rotationhttps://console.aws.amazon.com/kms/home?region=<REGION>#/kms/keys
Control-plane log grouphttps://console.aws.amazon.com/cloudwatch/home?region=<REGION>#logsV2:log-groups → /aws/eks/<CLUSTER>/cluster
GuardDuty EKS Protection / Runtime Monitoringhttps://console.aws.amazon.com/guardduty/home?region=<REGION>#/eks-protection · ...#/runtime-monitoring
Security Hub standards & findingshttps://console.aws.amazon.com/securityhub/home?region=<REGION>#/standards
Config rules (eks-*)https://console.aws.amazon.com/config/home?region=<REGION>#/rules
Inspector coveragehttps://console.aws.amazon.com/inspector/v2/home?region=<REGION>#/dashboard
ECR scanning config / repositorieshttps://console.aws.amazon.com/ecr/private-registry/repositories?region=<REGION> → Private registry → Settings
Launch templates (IMDS options)https://console.aws.amazon.com/ec2/home?region=<REGION>#LaunchTemplates:
Security groups / Load balancershttps://console.aws.amazon.com/ec2/home?region=<REGION>#SecurityGroups: · ...#LoadBalancers:
Subnets / route tables / VPC endpointshttps://console.aws.amazon.com/vpcconsole/home?region=<REGION>#subnets: · ...#RouteTables: · ...#Endpoints:
CloudTrail trails / event historyhttps://console.aws.amazon.com/cloudtrailv2/home?region=<REGION>#/trails
SSM Session Manager (node access without SSH)https://console.aws.amazon.com/systems-manager/session-manager?region=<REGION>

3. Azure: AKS

Portal root (all clusters): https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ContainerService%2FmanagedClusters Cluster page: https://portal.azure.com/#@<TENANT>/resource/subscriptions/<SUB>/resourceGroups/<RG>/providers/Microsoft.ContainerService/managedClusters/<CLUSTER>/overview Left menu groups: Overview · Activity log · Access control (IAM) · Microsoft Defender for Cloud · Kubernetes resources (Namespaces, Workloads, Services and ingresses, Storage, Configuration, Custom resources, Run command) · Settings (Node pools, Cluster configuration, Networking, Security configuration, Extensions + applications, Policies) · Monitoring (Insights, Diagnostic settings, Logs). Blade names move around between portal releases; the az commands are canonical. Full map in §3.11.

Fleet-wide view in one query - Resource Graph Explorer https://portal.azure.com/#view/HubsExtension/ArgQueryBlade:

resources
| where type =~ 'microsoft.containerservice/managedclusters'
| project name, resourceGroup, subscriptionId, version = properties.kubernetesVersion,
  privateCluster = properties.apiServerAccessProfile.enablePrivateCluster,
  authorizedIps = properties.apiServerAccessProfile.authorizedIpRanges,
  localAccountsDisabled = properties.disableLocalAccounts,
  entraManaged = properties.aadProfile.managed, azureRbac = properties.aadProfile.enableAzureRbac,
  netPolicy = properties.networkProfile.networkPolicy, outbound = properties.networkProfile.outboundType,
  defender = properties.securityProfile.defender.securityMonitoring.enabled,
  kms = properties.securityProfile.azureKeyVaultKms.enabled,
  workloadIdentity = properties.securityProfile.workloadIdentity.enabled

CLI equivalent: az extension add -n resource-graph && az graph query -q "<query above>" -o table

3.1 Inventory and version

3.1.1 Find every cluster in every subscription

az account list --query '[].{name:name,id:id}' -o table
for s in $(az account list --query '[].id' -o tsv); do
  az aks list --subscription "$s" --query '[].{sub:"'"$s"'",rg:resourceGroup,name:name,version:kubernetesVersion,sku:sku.tier,location:location}' -o table
done

3.1.2 Supported Kubernetes version, support plan and auto-upgrade channels

Risk: AKS only patches the N-2 minor versions (plus LTS versions on AKSLongTermSupport); older clusters cannot be patched or scaled reliably and miss API-server fixes. Vulnerable when: kubernetesVersion is not in az aks get-versions; autoUpgradeProfile.upgradeChannel is none/null; nodeOsUpgradeChannel is None/Unmanaged.

az aks show -g "$RG" -n "$CLUSTER" --query '{version:kubernetesVersion,current:currentKubernetesVersion,support:supportPlan,channel:autoUpgradeProfile.upgradeChannel,nodeOs:autoUpgradeProfile.nodeOsUpgradeChannel}'
az aks get-versions --location <LOCATION> --query 'values[].{version:version,patches:keys(patchVersions),supportPlan:capabilities.supportPlan,preview:isPreview}' -o table
az aks get-upgrades -g "$RG" -n "$CLUSTER" --query 'controlPlaneProfile.upgrades[].kubernetesVersion'

GUI: Cluster → Settings → Cluster configuration → Kubernetes version / Automatic upgrade / Node security channel type. Fix: az aks upgrade -g "$RG" -n "$CLUSTER" --kubernetes-version <NEXT>; az aks update -g "$RG" -n "$CLUSTER" --auto-upgrade-channel patch --node-os-upgrade-channel NodeImage (use stable + planned maintenance windows for production: az aks maintenanceconfiguration add). GUI: Cluster configuration → Upgrade version / channel drop-downs.

3.2 API server exposure

3.2.1 API server reachable from the internet

Risk: Default AKS clusters have a public FQDN *.hcp.<region>.azmk8s.io open to the world; a leaked kubeconfig or a stolen Entra token works from anywhere. Vulnerable when: enablePrivateCluster: false and authorizedIpRanges is empty/null (High). Public + authorized IP ranges = Medium. Private cluster = OK (then check 3.2.3 run command and the private DNS zone).

az aks show -g "$RG" -n "$CLUSTER" --query 'apiServerAccessProfile'
az aks show -g "$RG" -n "$CLUSTER" --query '{fqdn:fqdn,privateFqdn:privateFqdn,azurePortalFqdn:azurePortalFqdn}'
# Prove it from outside
curl -sk "https://$(az aks show -g "$RG" -n "$CLUSTER" --query fqdn -o tsv)/version"

GUI: Cluster → Settings → Networking → API server access (Public / Private; Set authorized IP ranges). Fix (CLI):

# Allow-list (also add the cluster's outbound IP(s) or in-cluster tooling breaks - see networkProfile.loadBalancerProfile.effectiveOutboundIPs)
az aks update -g "$RG" -n "$CLUSTER" --api-server-authorized-ip-ranges "<OFFICE_CIDR>/32,<VPN_CIDR>/24,<CLUSTER_EGRESS_IP>/32"
# Private cluster: only at creation (az aks create --enable-private-cluster) unless API Server VNet Integration is enabled, then:
az aks update -g "$RG" -n "$CLUSTER" --enable-apiserver-vnet-integration --enable-private-cluster

Fix (GUI): Networking → API server access → Set authorized IP ranges or Private cluster (VNet-integrated clusters only).

3.2.2 Private cluster: public FQDN disabled, private DNS zone sane

az aks show -g "$RG" -n "$CLUSTER" --query '{private:apiServerAccessProfile.enablePrivateCluster,publicFqdn:apiServerAccessProfile.enablePrivateClusterPublicFqdn,dnsZone:apiServerAccessProfile.privateDnsZone,vnetIntegration:apiServerAccessProfile.enableVnetIntegration}'

Fix: az aks update -g "$RG" -n "$CLUSTER" --disable-public-fqdn (the public FQDN still resolves to the private IP, but drop it anyway); private DNS zone linked only to the VNets that need it.

3.2.3 az aks command invoke (run command) disabled on hardened clusters

Risk: Run command executes kubectl inside the cluster via the Azure control plane and bypasses authorized IP ranges / private cluster. Anyone with Microsoft.ContainerService/managedClusters/runCommand/action (Contributor, Owner, Azure Kubernetes Service Contributor Role) gets cluster-admin-equivalent exec with no network path required. Vulnerable when: disableRunCommand is false/null on a private or allow-listed cluster.

az aks show -g "$RG" -n "$CLUSTER" --query 'apiServerAccessProfile.disableRunCommand'
az aks command invoke -g "$RG" -n "$CLUSTER" --command "kubectl auth can-i --list"   # if this works for a Reader/Contributor-only identity → finding

GUI: Cluster → Kubernetes resources → Run command (if the blade accepts commands, it is enabled). Fix: az aks update -g "$RG" -n "$CLUSTER" --disable-run-command · GUI: Networking → API server access → untick run command (where exposed).

3.3 Authentication & authorization

3.3.1 Entra ID integration (managed) enabled

Risk: Without Entra integration the only identities are the built-in clusterAdmin/clusterUser certificates - shared, non-expiring, un-auditable. Vulnerable when: aadProfile is null or aadProfile.managed != true. Legacy (non-managed, app-registration based) AAD integration is deprecated.

az aks show -g "$RG" -n "$CLUSTER" --query 'aadProfile'
# Resolve the admin groups to names + member counts
for g in $(az aks show -g "$RG" -n "$CLUSTER" --query 'aadProfile.adminGroupObjectIDs[]' -o tsv); do
  az ad group show --group "$g" --query '{name:displayName,id:id}' -o tsv; az ad group member list --group "$g" --query 'length(@)'
done

GUI: Cluster → Settings → Cluster configuration → Authentication and Authorization (Local accounts with Kubernetes RBAC / Microsoft Entra ID authentication with Kubernetes RBAC / Microsoft Entra ID authentication with Azure RBAC). Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-aad --aad-admin-group-object-ids <GROUP_ID> --aad-tenant-id <TENANT> (admin group = PIM-eligible break-glass group, not "all DevOps").

3.3.2 Azure RBAC for Kubernetes authorization (or tightly-managed K8s RBAC)

Risk: With Azure RBAC, cluster permissions are Azure role assignments (PIM, access reviews, Activity Log). With plain K8s RBAC, Entra groups are bound inside the cluster where nobody reviews them. Vulnerable when: aadProfile.enableAzureRbac != true and ClusterRoleBindings reference broad groups / cluster-admin.

az aks show -g "$RG" -n "$CLUSTER" --query 'aadProfile.enableAzureRbac'
# Azure RBAC data-plane roles on the cluster (and namespace scopes)
az role assignment list --scope "$AKS_ID" --include-inherited --include-groups \
  --query "[?contains(roleDefinitionName,'Kubernetes')].{who:principalName,type:principalType,role:roleDefinitionName,scope:scope}" -o table
# K8s RBAC side: group GUIDs bound to cluster-admin
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'

GUI: Cluster → Access control (IAM) → Role assignments → filter "Azure Kubernetes Service RBAC ..." · Cluster configuration → Authentication and Authorization. Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-azure-rbac; assign Azure Kubernetes Service RBAC Reader/Writer/Admin at namespace scope: az role assignment create --role "Azure Kubernetes Service RBAC Writer" --assignee <GROUP_ID> --scope "$AKS_ID/namespaces/<NS>"; reserve RBAC Cluster Admin for the break-glass group.

3.3.3 Local accounts disabled (--admin credentials must not work)

Risk: While local accounts exist, every identity with listClusterAdminCredential/action (Owner, Contributor, Azure Kubernetes Service Cluster Admin Role) can run az aks get-credentials --admin and receive a non-expiring cluster-admin client certificate that bypasses Entra, Conditional Access and Azure RBAC. Vulnerable when: disableLocalAccounts is false/null. (Critical if the cluster has many Contributors.)

az aks show -g "$RG" -n "$CLUSTER" --query 'disableLocalAccounts'
az aks get-credentials -g "$RG" -n "$CLUSTER" --admin --file /tmp/admin.kubeconfig && echo "LOCAL ADMIN CREDS ISSUED → finding"; rm -f /tmp/admin.kubeconfig
# Who could do that?
az role assignment list --scope "$AKS_ID" --include-inherited --include-groups \
  --query "[?roleDefinitionName=='Owner' || roleDefinitionName=='Contributor' || contains(roleDefinitionName,'Cluster Admin')].{who:principalName,type:principalType,role:roleDefinitionName,scope:scope}" -o table

GUI: Cluster → Settings → Cluster configuration → Authentication and Authorization → Local accounts (should be disabled) · Access control (IAM) → Role assignments. Fix: az aks update -g "$RG" -n "$CLUSTER" --disable-local-accounts (requires 3.3.1); rotate afterwards: az aks rotate-certs -g "$RG" -n "$CLUSTER" invalidates already-issued admin certs. GUI: Cluster configuration → untick Local accounts.

3.3.4 Cluster identity and kubelet identity are managed identities with minimal roles

Risk: A service-principal-based cluster keeps a client secret in the cluster (and on nodes in /etc/kubernetes/azure.json on older clusters); an over-privileged kubelet identity (reachable from every pod via IMDS, see 3.4.4) is a direct path to the subscription. Vulnerable when: servicePrincipalProfile.clientId != "msi"; kubelet identity has anything beyond AcrPull; control-plane identity has Contributor on the whole subscription instead of Network Contributor on the VNet/route table.

az aks show -g "$RG" -n "$CLUSTER" --query '{identity:identity,sp:servicePrincipalProfile.clientId,kubelet:identityProfile.kubeletidentity}'
# Roles of the control-plane identity
az role assignment list --assignee "$(az aks show -g "$RG" -n "$CLUSTER" --query identity.principalId -o tsv)" --all -o table
# Roles of the kubelet identity (expect AcrPull only)
az role assignment list --assignee "$(az aks show -g "$RG" -n "$CLUSTER" --query identityProfile.kubeletidentity.objectId -o tsv)" --all -o table
# If it is still a service principal: secret expiry
az ad sp credential list --id "$(az aks show -g "$RG" -n "$CLUSTER" --query servicePrincipalProfile.clientId -o tsv)" --query '[].{end:endDateTime}' 2>/dev/null

GUI: Cluster → Settings → Security configuration (or Overview → Properties) → Identity · Managed identities https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ManagedIdentity%2FuserAssignedIdentities → identity → Azure role assignments. Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-managed-identity (migrates from SP); az role assignment delete ... for extra roles; az aks update ... --attach-acr <ACR> is the right way to give pull (adds only AcrPull).

3.4 Workload identity, managed identities and IMDS

3.4.1 OIDC issuer + Workload Identity enabled (pods get their own Entra identity)

Risk: Without workload identity, pods that need Azure access either embed SP secrets or use the kubelet identity through IMDS. Vulnerable when: oidcIssuerProfile.enabled != true or securityProfile.workloadIdentity.enabled != true while workloads call Azure APIs; or the deprecated aad-pod-identity (podIdentityProfile) is still in use.

az aks show -g "$RG" -n "$CLUSTER" --query '{oidc:oidcIssuerProfile,wi:securityProfile.workloadIdentity,podIdentity:podIdentityProfile}'
# Who uses it? (annotated SAs) - and leftover aad-pod-identity components
kubectl get sa -A -o json | jq -r '.items[] | select(.metadata.annotations["azure.workload.identity/client-id"]) | "\(.metadata.namespace)/\(.metadata.name) -> \(.metadata.annotations["azure.workload.identity/client-id"])"'
kubectl get azureidentitybindings.aadpodidentity.k8s.io -A 2>/dev/null; kubectl get ds -A | grep -i nmi
# Secrets that look like SP credentials
kubectl get secrets -A -o json | jq -r '.items[] | select(.data | keys[] | test("AZURE_CLIENT_SECRET|clientSecret|client_secret";"i")) | "\(.metadata.namespace)/\(.metadata.name)"'

GUI: Cluster → Settings → Security configuration → Workload identity / OIDC issuer (also shown on Overview → Properties). Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-oidc-issuer --enable-workload-identity; per workload: user-assigned MI + az identity federated-credential create --identity-name <MI> -g <RG> --name <NAME> --issuer "$(az aks show -g "$RG" -n "$CLUSTER" --query oidcIssuerProfile.issuerUrl -o tsv)" --subject system:serviceaccount:<NS>:<SA> --audiences api://AzureADTokenExchange.

3.4.2 Federated credentials are scoped to one namespace/service account

for mi in $(az identity list --query '[].{n:name,g:resourceGroup}' -o tsv | tr '\t' ','); do
  n=${mi%,*}; g=${mi#*,}
  az identity federated-credential list --identity-name "$n" -g "$g" --query '[].{mi:"'"$n"'",issuer:issuer,subject:subject,aud:audiences}' -o table 2>/dev/null
done

Vulnerable when: subject is not system:serviceaccount:<ns>:<sa> for exactly one SA, or the issuer belongs to another (dev/test) cluster. GUI: Managed identity → Federated credentials.

3.4.3 Node (kubelet) identity has only AcrPull - see 3.3.4

3.4.4 Prove whether pods can reach IMDS and obtain the kubelet identity token

kubectl run imds-test --rm -it --restart=Never --image=curlimages/curl -- sh -c \
 'curl -s -m 3 -H "Metadata: true" "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" | head -c 300; echo; \
  curl -s -m 3 -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01" | head -c 300'
# A token here == every pod can act as the kubelet identity (and as the control-plane identity on older clusters via azure.json)

Fix: NetworkPolicy denying egress to 169.254.169.254/32 in every namespace (needs 3.8.2), or the IMDS restriction feature (az aks update ... --enable-imds-restriction, currently in the aks-preview extension - pods on hostNetwork are still exempt); keep the kubelet identity at AcrPull so a leak is low-impact.

3.5 Secrets and encryption

3.5.1 etcd secrets encrypted with a Key Vault key (KMS plugin)

Risk: AKS encrypts the managed etcd disks, but Kubernetes Secrets are plaintext inside etcd unless KMS envelope encryption is on; a customer key lets you revoke/rotate and audit decrypts. Vulnerable when: securityProfile.azureKeyVaultKms.enabled != true.

az aks show -g "$RG" -n "$CLUSTER" --query 'securityProfile.azureKeyVaultKms'

GUI: Cluster → Settings → Security configuration → Key Management Service (KMS). Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-azure-keyvault-kms --azure-keyvault-kms-key-id <KEY_ID> --azure-keyvault-kms-key-vault-network-access Private --azure-keyvault-kms-key-vault-resource-id <KV_ID> then re-encrypt: az aks update -g "$RG" -n "$CLUSTER" --enable-azure-keyvault-kms --azure-keyvault-kms-key-id <KEY_ID> after kubectl get secrets -A -o json | kubectl replace -f -.

3.5.2 Secrets sourced from Key Vault (CSI provider with rotation) instead of stored in-cluster

az aks show -g "$RG" -n "$CLUSTER" --query 'addonProfiles.azureKeyvaultSecretsProvider'
kubectl get secretproviderclass -A 2>/dev/null
kubectl get externalsecrets.external-secrets.io -A 2>/dev/null

GUI: Cluster → Settings → Extensions + applications / Cluster configuration → Secrets Store CSI Driver. Fix: az aks enable-addons -g "$RG" -n "$CLUSTER" --addons azure-keyvault-secrets-provider --enable-secret-rotation; Key Vault with RBAC permission model, private endpoint, soft-delete + purge protection.

3.5.3 Encryption at host / customer-managed disk keys on node pools

az aks nodepool list -g "$RG" --cluster-name "$CLUSTER" --query '[].{name:name,encAtHost:enableEncryptionAtHost,osDisk:osDiskType,osDiskSize:osDiskSizeGb}' -o table
az aks show -g "$RG" -n "$CLUSTER" --query 'diskEncryptionSetId'

Fix: encryption at host is create-time per node pool: az aks nodepool add ... --enable-encryption-at-host; CMK: az aks create ... --node-osdisk-diskencryptionset-id <DES_ID>. (Medium/Low - temp disks and ephemeral OS disks are where unencrypted data lands.)

3.6 Logging, detection and policy

3.6.1 Diagnostic settings ship audit + auth logs to a workspace

Risk: AKS writes no audit log anywhere unless a diagnostic setting is created. kube-audit-admin (writes only) or kube-audit (everything, expensive) is the only record of who did what; guard logs Entra/Azure RBAC decisions. Vulnerable when: no diagnostic setting, or categories missing kube-audit-admin/kube-audit and guard; retention < 90 days.

az monitor diagnostic-settings list --resource "$AKS_ID" -o json | jq '(.value // .)[] | {name, workspaceId, storageAccountId, logs: [.logs[] | select(.enabled) | (.category // .categoryGroup)]}'
# Workspace retention
az monitor log-analytics workspace show --ids <LAW_ID> --query '{retention:retentionInDays,sku:sku.name}'
# Does anything actually arrive? (last 24h admin actions)
az monitor log-analytics query -w <LAW_WORKSPACE_GUID> --analytics-query "AKSAuditAdmin | where TimeGenerated > ago(24h) | summarize count() by User=tostring(User.username), Verb | top 20 by count_"

GUI: Cluster → Monitoring → Diagnostic settings → + Add diagnostic setting → tick kube-audit-admin, guard, kube-apiserver, kube-controller-manager, cluster-autoscaler → Log Analytics workspace. Fix: az monitor diagnostic-settings create --name aks-security --resource "$AKS_ID" --workspace <LAW_ID> --logs '[{"category":"kube-audit-admin","enabled":true},{"category":"guard","enabled":true},{"category":"kube-apiserver","enabled":true},{"category":"kube-controller-manager","enabled":true}]' (use resource-specific tables: add --export-to-resource-specific true).

3.6.2 Container Insights (node/pod telemetry) enabled

az aks show -g "$RG" -n "$CLUSTER" --query '{omsagent:addonProfiles.omsagent.enabled,workspace:addonProfiles.omsagent.config.logAnalyticsWorkspaceResourceID,azmon:azureMonitorProfile}'

GUI: Cluster → Monitoring → Insights. Fix: az aks enable-addons -g "$RG" -n "$CLUSTER" -a monitoring --workspace-resource-id <LAW_ID>.

3.6.3 Microsoft Defender for Containers enabled (plan + agent on the cluster)

Risk: Defender provides runtime threat detection (eBPF sensor), K8s API audit-based alerts, vulnerability assessment for ACR images and running containers, and the posture recommendations. Without it there is no cloud-side detection for the cluster. Vulnerable when: az security pricing show --name Containers → Free, or securityProfile.defender.securityMonitoring.enabled != true (plan on, cluster not onboarded).

az security pricing show --name Containers --query '{tier:pricingTier,extensions:extensions[].{name:name,on:isEnabled}}'
az aks show -g "$RG" -n "$CLUSTER" --query 'securityProfile.defender'
kubectl -n kube-system get ds,deploy -l app.kubernetes.io/part-of=microsoft-defender 2>/dev/null; kubectl get ns mdc 2>/dev/null
# Open Defender alerts/recommendations for this cluster
az security alert list --query "[?contains(to_string(resourceIdentifiers), '$CLUSTER')].{name:alertDisplayName,sev:severity,time:timeGeneratedUtc}" -o table
az security assessment list --query "[?contains(id, '$CLUSTER') && status.code=='Unhealthy'].{name:displayName,sev:metadata.severity}" -o table 2>/dev/null

GUI: Defender for Cloud → Environment settings https://portal.azure.com/#view/Microsoft_Azure_Security/SecurityMenuBlade/~/EnvironmentSettings → subscription → Defender plans → Containers (On, with Agentless/sensor components) · Cluster → Microsoft Defender for Cloud blade (recommendations + alerts). Fix: az security pricing create --name Containers --tier Standard; az aks update -g "$RG" -n "$CLUSTER" --enable-defender (optionally --defender-config <file> to point at a workspace).

3.6.4 Azure Policy add-on (Gatekeeper) with pod-security initiatives assigned

Risk: AKS enforces nothing on pod specs by default (Pod Security Admission is not pre-configured). The Azure Policy add-on + the built-in Kubernetes cluster pod security baseline/restricted standards initiatives give audit/deny of privileged pods, host namespaces, hostPath, etc., reported in Defender/Policy compliance. Vulnerable when: addonProfiles.azurepolicy.enabled != true, or enabled but no initiative assigned at the cluster/RG/subscription scope, or all assignments in audit effect only.

az aks show -g "$RG" -n "$CLUSTER" --query 'addonProfiles.azurepolicy'
kubectl get pods -n gatekeeper-system 2>/dev/null; kubectl get constraints -A 2>/dev/null | head
az policy assignment list --scope "$AKS_ID" --disable-scope-strict-match --query "[?contains(displayName,'Kubernetes') || contains(policyDefinitionId,'Kubernetes')].{name:displayName,effect:parameters.effect.value,scope:scope,enforce:enforcementMode}" -o table
az policy state list --resource "$AKS_ID" --filter "complianceState eq 'NonCompliant'" --query '[].{policy:policyDefinitionName,resource:resourceId}' -o table | head -40
# Native PSA labels as an alternative/complement
kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce'

GUI: Cluster → Settings → Policies (add-on status + assignments) · Policy → Compliance https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Compliance. Fix: az aks enable-addons -g "$RG" -n "$CLUSTER" --addons azure-policy; assign the built-in initiative Kubernetes cluster pod security restricted standards for Linux-based workloads with effect deny (exclude kube-system, gatekeeper-system): az policy assignment create --name aks-psr --scope "$AKS_ID" --policy-set-definition 42b8ef37-b724-4e24-bbc8-7a7708edfe00 --params '{"effect":{"value":"deny"}}'.

3.6.5 Activity log alerts for cluster-changing operations

az monitor activity-log list --resource-id "$AKS_ID" --offset 30d --query "[?operationName.value!='Microsoft.ContainerService/managedClusters/read'].{time:eventTimestamp,op:operationName.value,who:caller,status:status.value}" -o table | head -40
az monitor activity-log alert list -g "$RG" -o table

Fix: alerts on Microsoft.ContainerService/managedClusters/write, .../listClusterAdminCredential/action, .../runCommand/action, .../delete. GUI: Cluster → Activity log → Filter → Add activity log alert.

3.7 Nodes and node pools

3.7.1 Node pool inventory and hardening flags

az aks nodepool list -g "$RG" --cluster-name "$CLUSTER" --query '[].{name:name,mode:mode,os:osType,sku:osSku,image:nodeImageVersion,k8s:currentOrchestratorVersion,publicIp:enableNodePublicIp,fips:enableFips,encHost:enableEncryptionAtHost,secureBoot:securityProfile.enableSecureBoot,vtpm:securityProfile.enableVtpm,ssh:securityProfile.sshAccess,maxPods:maxPods,vm:vmSize}' -o table
az aks nodepool get-upgrades -g "$RG" --cluster-name "$CLUSTER" --nodepool-name <POOL> --query '{current:nodeImageVersion,latest:latestNodeImageVersion,k8s:kubernetesVersion}'
kubectl get nodes -o custom-columns='NAME:.metadata.name,OS:.status.nodeInfo.osImage,KERNEL:.status.nodeInfo.kernelVersion,KUBELET:.status.nodeInfo.kubeletVersion,POOL:.metadata.labels.agentpool,MODE:.metadata.labels.kubernetes\.azure\.com/mode'

GUI: Cluster → Settings → Node pools → pool → Overview (OS SKU, node image version, Upgrade available), Security section (secure boot, vTPM, SSH).

3.7.2 No node public IPs; no SSH from the internet

Risk: enableNodePublicIp: true puts every VMSS instance directly on the internet (kubelet 10250, NodePorts, SSH 22 behind the NSG only). AKS also still provisions an SSH key on Linux nodes; reachable SSH == node compromise == all pod secrets on that node. Vulnerable when: enableNodePublicIp: true; NSG rule allowing 22 or * from Internet/* on the node subnet or VMSS NSG.

MC_RG=$(az aks show -g "$RG" -n "$CLUSTER" --query nodeResourceGroup -o tsv)
az network nsg list -g "$MC_RG" --query '[].name' -o tsv
for nsg in $(az network nsg list -g "$MC_RG" --query '[].name' -o tsv); do
  az network nsg rule list -g "$MC_RG" --nsg-name "$nsg" --query "[?direction=='Inbound' && access=='Allow'].{name:name,prio:priority,src:sourceAddressPrefix,port:destinationPortRange,ports:destinationPortRanges}" -o table
done
# NSG on the node subnet (custom VNet)
az network vnet subnet show --ids "$(az aks nodepool show -g "$RG" --cluster-name "$CLUSTER" -n <POOL> --query vnetSubnetId -o tsv)" --query '{nsg:networkSecurityGroup.id,natGw:natGateway.id,udr:routeTable.id}'

GUI: Node resource group MC_<RG>_<CLUSTER>_<LOCATION> → NSG aks-agentpool-*-nsg → Inbound security rules · Node pool → Security → SSH access. Fix: recreate the pool without --enable-node-public-ip; remove inbound 22 rules; disable SSH on the pool (az aks nodepool update -g "$RG" --cluster-name "$CLUSTER" -n <POOL> --ssh-access disabled, aks-preview); use kubectl debug node/<NODE> -it --image=... or Run command for break-glass (both audited).

3.7.3 Hardened OS image, FIPS where required, OS auto-upgrade on

Risk: Ubuntu node images must be re-imaged to receive kernel/containerd fixes - the NodeImage channel does that; otherwise nodes drift for months. Azure Linux is the smaller-surface option. Vulnerable when: nodeImageVersion ≠ latestNodeImageVersion (3.7.1), nodeOsUpgradeChannel is None/Unmanaged (3.1.2), or secure boot/vTPM off on new pools.

Fix: az aks nodepool upgrade -g "$RG" --cluster-name "$CLUSTER" -n <POOL> --node-image-only; az aks update ... --node-os-upgrade-channel NodeImage; new pools with --os-sku AzureLinux --enable-secure-boot --enable-vtpm [--enable-fips-image].

3.7.4 Image Cleaner (removes unused vulnerable images from nodes)

az aks show -g "$RG" -n "$CLUSTER" --query 'securityProfile.imageCleaner'

Fix: az aks update -g "$RG" -n "$CLUSTER" --enable-image-cleaner --image-cleaner-interval-hours 24 (Low).

3.8 Networking

3.8.1 Network plugin: Azure CNI (overlay) - kubenet is deprecated

az aks show -g "$RG" -n "$CLUSTER" --query 'networkProfile.{plugin:networkPlugin,mode:networkPluginMode,dataplane:networkDataplane,policy:networkPolicy,outbound:outboundType,lbSku:loadBalancerSku,podCidr:podCidr,serviceCidr:serviceCidr,acns:advancedNetworking}'

Vulnerable when: networkPlugin: kubenet (retirement announced; no Windows, no network policy with Azure NPM, UDR hacks) or none without a verified BYO CNI. Fix: az aks update -g "$RG" -n "$CLUSTER" --network-plugin azure --network-plugin-mode overlay (in-place migration from kubenet). GUI: Cluster → Settings → Networking.

3.8.2 NetworkPolicy engine enabled - and policies exist

Risk: AKS does not enforce NetworkPolicy unless networkPolicy is azure, calico or cilium. With null/none every pod can reach every pod, kube-system, IMDS and the API server. Vulnerable when: networkProfile.networkPolicy is null/none; or enabled but kubectl get networkpolicies -A is (almost) empty.

az aks show -g "$RG" -n "$CLUSTER" --query 'networkProfile.networkPolicy'
kubectl get networkpolicies -A ; kubectl get ciliumnetworkpolicies,ciliumclusterwidenetworkpolicies -A 2>/dev/null
kubectl get ds -n kube-system | grep -E 'azure-npm|calico|cilium'

GUI: Cluster → Settings → Networking → Network policy. Fix: az aks update -g "$RG" -n "$CLUSTER" --network-policy azure (Azure CNI) or --network-dataplane cilium --network-policy cilium (recommended; Azure NPM is being retired in favour of Cilium); then default-deny per namespace + explicit allows; --enable-acns adds FQDN egress policies and L7 (ACNS).

3.8.3 Egress control: UDR + Azure Firewall (or NAT Gateway) instead of the default public load balancer

Risk: outboundType: loadBalancer gives every node unrestricted internet egress through a public IP - exfiltration and C2 are free. Hardened clusters route 0.0.0.0/0 to Azure Firewall with FQDN rules for the AKS required endpoints. Vulnerable when: outboundType: loadBalancer on a cluster with sensitive data and no other egress filtering (FQDN policies via ACNS/Cilium count as mitigation).

az aks show -g "$RG" -n "$CLUSTER" --query '{outbound:networkProfile.outboundType,egressIps:networkProfile.loadBalancerProfile.effectiveOutboundIPs[].id,natGw:networkProfile.natGatewayProfile}'
az network route-table list -g "$MC_RG" --query '[].{name:name,routes:routes[].{dest:addressPrefix,hop:nextHopType,ip:nextHopIpAddress}}'
# Egress test from a pod
kubectl run egress-test --rm -it --restart=Never --image=curlimages/curl -- sh -c 'curl -s -m 5 https://ifconfig.me; echo; curl -s -m 5 -o /dev/null -w "%{http_code}\n" https://example.com'

GUI: Cluster → Settings → Networking → Outbound type · Route tables in MC_* RG. Fix: az aks update -g "$RG" -n "$CLUSTER" --outbound-type userDefinedRouting with a route table 0.0.0.0/0 → <AZURE_FIREWALL_IP> on the node subnet, and firewall application rules for the AKS required outbound FQDNs. Cheaper middle ground: --outbound-type managedNATGateway (still unrestricted, but no inbound surface).

3.8.4 Load balancers and ingresses created by the cluster

kubectl get svc -A -o json | jq -r '.items[] | select(.spec.type=="LoadBalancer") | "\(.metadata.namespace)/\(.metadata.name) internal=\(.metadata.annotations["service.beta.kubernetes.io/azure-load-balancer-internal"] // "false") ip=\(.status.loadBalancer.ingress[0].ip // "")"'
kubectl get ingress -A -o wide
az network public-ip list -g "$MC_RG" --query '[].{name:name,ip:ipAddress,attached:ipConfiguration.id}' -o table
# App routing add-on / AGIC / Application Gateway for Containers
az aks show -g "$RG" -n "$CLUSTER" --query '{appRouting:ingressProfile.webAppRouting,agic:addonProfiles.ingressApplicationGateway.enabled}'

GUI: MC_* RG → public IP addresses · Cluster → Kubernetes resources → Services and ingresses. Fix: service.beta.kubernetes.io/azure-load-balancer-internal: "true" unless intentionally public; WAF (Application Gateway WAF_v2 / Front Door) in front of public ingresses; loadBalancerSourceRanges for admin UIs. Public LoadBalancer services also auto-create Internet → port NSG rules (3.7.2) - review them.

3.8.5 API server VNet integration (control plane reachable only via the VNet)

az aks show -g "$RG" -n "$CLUSTER" --query 'apiServerAccessProfile.{vnetIntegration:enableVnetIntegration,subnet:subnetId}'

Nice-to-have (Low) for public clusters; the prerequisite for turning an existing cluster private (3.2.1).

3.9 Image supply chain

3.9.1 ACR: admin user off, anonymous pull off, private network, Defender scanning

Vulnerable when: adminUserEnabled: true (shared username/password with push rights), anonymousPullEnabled: true, publicNetworkAccess: Enabled without IP rules, no Containers Defender plan (3.6.3) - or the cluster pulls from Docker Hub / unknown registries.

az acr list --query '[].{name:name,sku:sku.name,admin:adminUserEnabled,anonPull:anonymousPullEnabled,public:publicNetworkAccess,rg:resourceGroup}' -o table
az aks check-acr -g "$RG" -n "$CLUSTER" --acr <ACR>.azurecr.io
az acr show -n <ACR> --query '{networkRules:networkRuleSet,privateEndpoints:privateEndpointConnections[].privateLinkServiceConnectionState.status,policies:policies}'
# Registries actually in use in the cluster / mutable tags
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | awk -F/ '{print $1}' | sort | uniq -c | sort -rn
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | grep -v '@sha256' | grep -E ':latest$|^[^:]+$'

GUI: Container registries https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ContainerRegistry%2Fregistries → registry → Settings → Access keys (admin user), Networking (public access / private endpoints), Repositories → image → Vulnerabilities (Defender) · Defender for Cloud → Recommendations → "Container registry images should have vulnerability findings resolved". Fix: az acr update -n <ACR> --admin-enabled false --anonymous-pull-enabled false --public-network-enabled false --default-action Deny + private endpoint; az aks update -g "$RG" -n "$CLUSTER" --attach-acr <ACR> (AcrPull via kubelet MI); ACR Tasks / az acr import to mirror upstream images; Azure Policy "Kubernetes cluster containers should only use allowed images" (deny) + Notation/cosign signing with the Ratify admission controller.

3.10 AKS-specific in-cluster checks

# 1. Entra group GUIDs bound in RBAC → resolve them (who is really admin?)
for g in $(kubectl get clusterrolebindings,rolebindings -A -o json | jq -r '.items[].subjects[]? | select(.kind=="Group") | .name' | grep -E '^[0-9a-f-]{36}$' | sort -u); do
  printf "%s -> " "$g"; az ad group show --group "$g" --query displayName -o tsv 2>/dev/null || echo "(unknown/foreign tenant)"
done
# 2. Legacy azure.json with cluster credentials mounted into pods (hostPath /etc/kubernetes)
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.hostPath.path | test("^/etc/kubernetes|^/var/lib/kubelet|^/var/run/docker|^/run/containerd")) | "\(.metadata.namespace)/\(.metadata.name)"'
# 3. Pods on hostNetwork (always reach IMDS, exempt from IMDS restriction)
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.hostNetwork==true) | "\(.metadata.namespace)/\(.metadata.name)"'
# 4. Which security components are actually present
kubectl get ds,deploy -n kube-system -o name | grep -E 'azure-policy|gatekeeper|microsoft-defender|azure-npm|cilium|csi-secrets-store|azure-wi-webhook|aks-secrets-store|image-cleaner'
# 5. Node-level: kubelet config AKS applies (anonymous auth, read-only port, authz mode)
kubectl get --raw "/api/v1/nodes/$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')/proxy/configz" | jq '.kubeletconfig | {authentication, authorization, readOnlyPort, protectKernelDefaults}'
# 6. Secrets that look like Azure credentials
kubectl get secrets -A -o json | jq -r '.items[] | select((.data // {}) | keys[] | test("azure|client[-_]?secret|sas[-_]?token|connectionstring";"i")) | "\(.metadata.namespace)/\(.metadata.name)"' | sort -u

3.11 AKS portal map

WhatWhere (portal)
All clustershttps://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ContainerService%2FmanagedClusters
Cluster overview (version, FQDN, identity, properties)https://portal.azure.com/#@<TENANT>/resource/subscriptions/<SUB>/resourceGroups/<RG>/providers/Microsoft.ContainerService/managedClusters/<CLUSTER>/overview
Who has which Azure role on the clusterCluster → Access control (IAM) → Role assignments
Kubernetes version, upgrade channels, auth method (local accounts / Entra / Azure RBAC)Cluster → Settings → Cluster configuration
Network plugin/policy, API server access (public/private, authorized IPs), outbound typeCluster → Settings → Networking
Defender, workload identity, OIDC issuer, KMS, image cleanerCluster → Settings → Security configuration (older portals: scattered over Cluster configuration/Overview)
Node pools (OS SKU, image version, public IP, FIPS, secure boot, SSH)Cluster → Settings → Node pools
Azure Policy add-on + assignmentsCluster → Settings → Policies
Add-ons / extensions (Key Vault CSI, monitoring, app routing)Cluster → Settings → Extensions + applications
Audit logs destinationCluster → Monitoring → Diagnostic settings
Query logs (AKSAuditAdmin, AKSAudit, AKSControlPlane)Cluster → Monitoring → Logs
Defender alerts + recommendations for this clusterCluster → Microsoft Defender for Cloud
Run command (bypasses network restrictions)Cluster → Kubernetes resources → Run command
Workloads / Services and ingresses / Secrets (needs K8s or Azure RBAC)Cluster → Kubernetes resources
Who changed what (control-plane operations)Cluster → Activity log
Node VMSS, NSGs, public IPs, route tablesResource group MC_<RG>_<CLUSTER>_<LOCATION>
Defender plans per subscriptionhttps://portal.azure.com/#view/Microsoft_Azure_Security/SecurityMenuBlade/~/EnvironmentSettings
Policy compliancehttps://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyMenuBlade/~/Compliance
Entra groups (resolve admin group GUIDs)https://portal.azure.com/#view/Microsoft_AAD_IAM/GroupsManagementMenuBlade/~/AllGroups
Managed identities + federated credentialshttps://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ManagedIdentity%2FuserAssignedIdentities
Key Vaults (KMS key, CSI secrets)https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.KeyVault%2Fvaults
Container registrieshttps://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.ContainerRegistry%2Fregistries
Log Analytics workspaceshttps://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.OperationalInsights%2Fworkspaces
Fleet-wide KQL over all clustershttps://portal.azure.com/#view/HubsExtension/ArgQueryBlade

4. GCP: GKE

Console root: https://console.cloud.google.com/kubernetes/list/overview?project=<PROJECT> Cluster page: https://console.cloud.google.com/kubernetes/clusters/details/<LOCATION>/<CLUSTER>/details?project=<PROJECT> → tabs Details · Nodes · Storage · Observability · Logs. The Details tab is one long page with sections Cluster basics · Automation · Networking · Security · Metadata · Features - almost every item below is in Security or Networking (pencil icon = edit). Security posture dashboard: https://console.cloud.google.com/kubernetes/security_posture?project=<PROJECT> Full map in §4.11.

4.1 Inventory, mode and version

4.1.1 Find every cluster in every project

for p in $(gcloud projects list --format="value(projectId)"); do
  gcloud container clusters list --project "$p" --format="table[no-heading](name,location,currentMasterVersion,releaseChannel.channel,autopilot.enabled,status)" 2>/dev/null | sed "s/^/$p /"
done
# Org-wide, one call (needs Cloud Asset API + org viewer)
gcloud asset search-all-resources --scope=organizations/<ORG_ID> --asset-types=container.googleapis.com/Cluster --format="table(name,location,project)"

GUI: Kubernetes Engine → Clusters (project picker top-left; https://console.cloud.google.com/kubernetes/list/overview?project=<PROJECT>).

4.1.2 Autopilot vs Standard

Autopilot clusters already enforce: no privileged pods / host namespaces / hostPath (except read-only /var/log), Workload Identity, Shielded + private nodes, COS, auto-upgrade, no SSH, Dataplane V2. For Autopilot, §4.4, §4.7 and most of §4.8 are "by design" - still verify §4.2, §4.3, §4.5, §4.6, §4.9.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(autopilot.enabled)"

4.1.3 Release channel + supported version, node auto-upgrade/auto-repair

Risk: Off-channel ("No channel"/static) clusters only get upgraded when someone remembers; versions fall out of support and lose security patches. Node pools without auto-upgrade run old kubelets/kernels. Vulnerable when: releaseChannel is unset; currentMasterVersion not in validMasterVersions; any node pool with management.autoUpgrade: false or autoRepair: false.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(currentMasterVersion,currentNodeVersion,releaseChannel,nodePools[].name,nodePools[].version,nodePools[].management)"
gcloud container get-server-config --location "$LOCATION" --format="yaml(channels,validMasterVersions)"

GUI: Cluster → Details → Cluster basics (Version, Release channel) · Automation (upgrade channel, maintenance window) · Nodes tab → node pool → Management (Auto-upgrade / Auto-repair). Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --release-channel regular (or stable; extended buys time at a cost); gcloud container clusters upgrade "$CLUSTER" --location "$LOCATION" --master --cluster-version <VER>; gcloud container node-pools update <POOL> --cluster "$CLUSTER" --location "$LOCATION" --enable-autoupgrade --enable-autorepair.

4.2 Control-plane exposure

4.2.1 Control plane reachable from the internet

Risk: Default GKE exposes the API server on a public IP with no CIDR restriction; a leaked token (or gcloud credentials of anyone with container.clusters.get) works from anywhere. Vulnerable when: ipEndpointsConfig.enablePublicEndpoint: true (or legacy privateClusterConfig.enablePrivateEndpoint: false) and masterAuthorizedNetworksConfig.enabled is false/unset (High). Authorized networks with narrow CIDRs = Medium. Private endpoint only, or DNS endpoint with IP access disabled = OK.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(endpoint,privateClusterConfig,masterAuthorizedNetworksConfig,controlPlaneEndpointsConfig)"
# Prove it from outside
curl -sk "https://$(gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format='value(endpoint)')/version"

GUI: Cluster → Details → Networking section → Control plane access (public/private endpoint, Control plane authorized networks, Access using DNS). Fix (CLI):

# Option A (recommended, no CIDR juggling): DNS-based endpoint (IAM-authenticated) and switch IP access off
gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-dns-access
gcloud container clusters update "$CLUSTER" --location "$LOCATION" --no-enable-ip-access
# Option B: keep IP endpoint but allow-list + no Google-owned IPs (blocks Cloud Shell too)
gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-master-authorized-networks \
  --master-authorized-networks "<OFFICE_CIDR>/32,<VPN_CIDR>/24" --no-enable-google-cloud-access
# Option C: private endpoint only (needs private nodes; reach it via VPN/IAP bastion)
gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-private-endpoint
# And enforce the allow-list on the private endpoint as well (peered VPCs are not "trusted")
gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-authorized-networks-on-private-endpoint

Fix (GUI): Details → Networking → Control plane access → edit → tick Enable control plane authorized networks / untick Access using the control plane's external IP address / Access using DNS.

4.2.2 Control plane global access / peering exposure

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(privateClusterConfig.masterGlobalAccessConfig.enabled,controlPlaneEndpointsConfig.ipEndpointsConfig.globalAccess)"

Global access lets any region of the VPC (and peered/VPN networks) reach the private endpoint - fine if 4.2.1's authorized networks also apply on the private endpoint; otherwise Low/Medium.

4.3 Authentication & authorization

4.3.1 Legacy ABAC disabled, no client certificate, no basic auth

Risk: ABAC grants all authenticated identities broad rights and bypasses RBAC; a cluster-issued client certificate is a non-revocable cluster-admin credential sitting in masterAuth. Vulnerable when: legacyAbac.enabled: true; masterAuth.clientCertificateConfig.issueClientCertificate: true (or masterAuth.clientCertificate present); masterAuth.username set (pre-1.19 basic auth).

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(legacyAbac,masterAuth.clientCertificateConfig,masterAuth.username)"
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(masterAuth.clientCertificate)" | head -c 40   # non-empty == client cert issued

GUI: Cluster → Details → Security → Legacy authorization / Client certificate / Basic authentication. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --no-enable-legacy-authorization; client certificate cannot be revoked in place - rotate credentials (gcloud container clusters update "$CLUSTER" --location "$LOCATION" --start-credential-rotation ... --complete-credential-rotation) and recreate with --no-issue-client-certificate.

4.3.2 Anonymous access limited, no system:authenticated / system:unauthenticated bindings

Risk: GKE historically allowed system:anonymous/system:unauthenticated to hit discovery endpoints and let system:authenticated (= any Google account) be bound in RBAC - the "every Google user is in your cluster" bug class. Vulnerable when: anonymousAuthenticationConfig.mode is ENABLED (or unset on older versions) and bindings exist to system:unauthenticated/system:anonymous; rbacBindingConfig.enableInsecureBindingSystemAuthenticated: true with bindings to system:authenticated beyond the defaults (system:basic-user, system:discovery, system:public-info-viewer).

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(anonymousAuthenticationConfig,rbacBindingConfig)"
kubectl get clusterrolebindings,rolebindings -A -o json | jq -r '.items[] | select(.subjects[]?.name | test("^system:(authenticated|unauthenticated|anonymous)$")) | "\(.kind) \(.metadata.namespace // "-")/\(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name) <- \([.subjects[].name]|join(","))"'
# From outside: should be 401/403, not a discovery document
curl -sk "https://$(gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format='value(endpoint)')/api"

GUI: Cluster → Details → Security → Anonymous authentication / Insecure RBAC bindings (newer consoles) · Security posture dashboard → Concerns. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --anonymous-authentication-config=LIMITED --no-enable-insecure-binding-system-authenticated --no-enable-insecure-binding-system-unauthenticated; delete offending bindings.

4.3.3 Who is cluster-admin via Google IAM? (project roles that imply K8s admin)

Risk: GKE maps IAM to RBAC automatically: roles/owner, roles/editor, roles/container.admin and roles/container.clusterAdmin ≈ cluster-admin; roles/container.developer ≈ edit on all namespaces; roles/container.viewer ≈ view. Broad project roles (every "Editor" in the org) are therefore cluster admins, and gcloud container clusters get-credentials needs only container.clusters.get. Vulnerable when: many principals / groups / service accounts with these roles; allAuthenticatedUsers or allUsers anywhere in the policy.

gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" \
  --filter="bindings.role:(roles/owner OR roles/editor OR roles/container.admin OR roles/container.clusterAdmin OR roles/container.developer)" \
  --format="table(bindings.role,bindings.members)"
gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" --filter="bindings.members:(allUsers OR allAuthenticatedUsers)" --format="table(bindings.role,bindings.members)"
# Folder/org-level inheritance (often where "Editor for everyone" hides)
gcloud projects get-ancestors "$PROJECT"
gcloud organizations get-iam-policy <ORG_ID> --flatten="bindings[].members" --filter="bindings.role:(roles/owner OR roles/editor OR roles/container.admin)" --format="table(bindings.role,bindings.members)"
# Inside the cluster: explicit cluster-admin bindings (in addition to the IAM-derived ones)
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'

GUI: IAM & Admin → IAM https://console.cloud.google.com/iam-admin/iam?project=<PROJECT> → filter Role: Kubernetes Engine / Editor / Owner (tick Include Google-provided role grants; check Inherited column). Fix: replace editor/container.admin with roles/container.clusterViewer (get-credentials only) + namespaced K8s RoleBindings to Google Groups; use Google Groups for RBAC (4.3.4); IAM Conditions for time-bound admin; Privileged Access Manager for break-glass.

4.3.4 Google Groups for RBAC enabled

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(authenticatorGroupsConfig)"

Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --security-group="gke-security-groups@<YOUR_DOMAIN>" (group must exist in Workspace/Cloud Identity; bind nested groups in RBAC instead of individual users). GUI: Details → Security → Google Groups for RBAC.

4.4 Workload Identity, node service accounts and metadata

4.4.1 Workload Identity Federation for GKE enabled on cluster and every node pool

Risk: Without Workload Identity, every pod can read the node service account token from the metadata server. With the default compute SA that is roles/editor on the project (4.4.2). Vulnerable when: workloadIdentityConfig.workloadPool unset, or any node pool with config.workloadMetadataConfig.mode != GKE_METADATA.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(workloadIdentityConfig,nodePools[].name,nodePools[].config.workloadMetadataConfig)"
# Who uses it (KSA → GSA annotations) and the IAM side of each binding
kubectl get sa -A -o json | jq -r '.items[] | select(.metadata.annotations["iam.gke.io/gcp-service-account"]) | "\(.metadata.namespace)/\(.metadata.name) -> \(.metadata.annotations["iam.gke.io/gcp-service-account"])"'
for gsa in $(kubectl get sa -A -o json | jq -r '.items[].metadata.annotations["iam.gke.io/gcp-service-account"] // empty' | sort -u); do
  echo "== $gsa"; gcloud iam service-accounts get-iam-policy "$gsa" --format="yaml(bindings)"      # expect roles/iam.workloadIdentityUser for exactly one ns/sa
  gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" --filter="bindings.members:serviceAccount:$gsa" --format="value(bindings.role)"
done
# Direct (principal://) bindings without a GSA
gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" --filter="bindings.members~svc.id.goog" --format="table(bindings.role,bindings.members)"

GUI: Cluster → Details → Security → Workload Identity · Nodes tab → node pool → Security → GKE Metadata Server · IAM → Service accounts https://console.cloud.google.com/iam-admin/serviceaccounts?project=<PROJECT> → SA → Permissions / Principals with access. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --workload-pool="$PROJECT.svc.id.goog" then per pool gcloud container node-pools update <POOL> --cluster "$CLUSTER" --location "$LOCATION" --workload-metadata=GKE_METADATA; bind gcloud iam service-accounts add-iam-policy-binding <GSA> --role roles/iam.workloadIdentityUser --member "serviceAccount:$PROJECT.svc.id.goog[<NS>/<KSA>]".

4.4.2 Node service account is dedicated and minimal (not the default compute SA / no Editor)

Risk: nodePools[].config.serviceAccount: default = the Compute Engine default SA, which (unless the org policy iam.automaticIamGrantsForDefaultServiceAccounts is enforced) holds roles/editor on the project. Reachable from pods when 4.4.1 fails, from any node compromise always. Vulnerable when: serviceAccount is default or the SA has roles/editor/owner/anything beyond logging/monitoring/artifact-registry reader; oauthScopes contains cloud-platform while the SA is broad.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(nodePools[].name,nodePools[].config.serviceAccount,nodePools[].config.oauthScopes)"
PN=$(gcloud projects describe "$PROJECT" --format="value(projectNumber)")
gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" --filter="bindings.members:serviceAccount:${PN}-compute@developer.gserviceaccount.com" --format="value(bindings.role)"
gcloud projects get-iam-policy "$PROJECT" --flatten="bindings[].members" --filter="bindings.members:serviceAccount:<NODE_SA>" --format="value(bindings.role)"
gcloud resource-manager org-policies describe iam.automaticIamGrantsForDefaultServiceAccounts --project "$PROJECT" 2>/dev/null

GUI: Nodes tab → node pool → Security → Service account / Access scopes · IAM → filter the SA. Fix: node SA is create-time per pool: gcloud iam service-accounts create gke-node-sa + roles/container.defaultNodeServiceAccount (or logging.logWriter, monitoring.metricWriter, monitoring.viewer, stackdriver.resourceMetadata.writer, artifactregistry.reader), then gcloud container node-pools create <NEW_POOL> --cluster "$CLUSTER" --location "$LOCATION" --service-account gke-node-sa@$PROJECT.iam.gserviceaccount.com --workload-metadata=GKE_METADATA ..., cordon/drain/delete the old pool. Remove roles/editor from the default compute SA either way.

4.4.3 Legacy metadata endpoints disabled, kube-env not readable

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(nodePools[].name,nodePools[].config.metadata)"   # disable-legacy-endpoints: 'true'

kube-env (kubelet bootstrap credentials) is only hidden when the GKE metadata server (GKE_METADATA, 4.4.1) is on; the old metadata concealment is retired.

4.4.4 Prove what a pod can get from the metadata server

kubectl run imds-test --rm -it --restart=Never --image=curlimages/curl -- sh -c \
 'H="Metadata-Flavor: Google"; B=http://169.254.169.254/computeMetadata/v1; \
  echo "--- SA email:"; curl -s -m 3 -H "$H" $B/instance/service-accounts/default/email; echo; \
  echo "--- token:";    curl -s -m 3 -H "$H" $B/instance/service-accounts/default/token | head -c 120; echo; \
  echo "--- kube-env:"; curl -s -m 3 -H "$H" $B/instance/attributes/kube-env | head -c 200; echo'
# Expected with WI: email = the KSA-mapped GSA (or "default" with a 4xx on token when unmapped); kube-env → 403/404.
# Node SA email + token returned == every pod is the node SA (High/Critical depending on 4.4.2).

Fix: 4.4.1 + 4.4.2; additionally a NetworkPolicy denying egress to 169.254.169.254/32 for namespaces that never need Google APIs (Workload Identity itself talks to the same IP - the gke-metadata-server DaemonSet intercepts it - so only block it where no pod needs a Google token).

4.5 Secrets and encryption

4.5.1 Application-layer secrets encryption with Cloud KMS (etcd)

Risk: Google encrypts etcd at rest, but Secrets inside etcd are plaintext to anyone who gets an etcd backup/API-level access; a Cloud KMS key in your project adds revocation, rotation and decrypt audit logs. Vulnerable when: databaseEncryption.state != ENCRYPTED.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(databaseEncryption)"
gcloud kms keys get-iam-policy <KEY> --keyring <RING> --location <LOCATION> --format="yaml(bindings)"   # who can decrypt

GUI: Cluster → Details → Security → Application-layer secrets encryption · KMS https://console.cloud.google.com/security/kms?project=<PROJECT>. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --database-encryption-key projects/$PROJECT/locations/<LOCATION>/keyRings/<RING>/cryptoKeys/<KEY> (GKE service agent needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key). GUI: Security → pencil → Enable application-layer secrets encryption.

4.5.2 Secrets sourced from Secret Manager (add-on / CSI) and node boot-disk CMEK

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(secretManagerConfig,nodePools[].name,nodePools[].config.bootDiskKmsKey)"
kubectl get secretproviderclass -A 2>/dev/null; kubectl get externalsecrets.external-secrets.io -A 2>/dev/null

Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-secret-manager [--secret-manager-rotation-interval 1h]; boot-disk CMEK is create-time (--boot-disk-kms-key). GUI: Details → Security → Secret Manager.

4.6 Logging, detection and posture

4.6.1 Control-plane and workload logs to Cloud Logging; audit logs present and retained

Risk: SYSTEM only (the default) gives no API server / scheduler / controller-manager logs; NONE gives nothing. GKE writes Kubernetes audit logs to Cloud Audit Logs (k8s.io) by default - verify they actually arrive and are kept > 30 days. Vulnerable when: loggingConfig.componentConfig.enableComponents lacks APISERVER (and WORKLOADS for app logs); _Default log bucket retention is 30 days; audit log sink missing for long-term storage.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(loggingConfig,monitoringConfig)"
# Audit entries arriving?
gcloud logging read 'logName:"cloudaudit.googleapis.com" AND resource.type="k8s_cluster" AND resource.labels.cluster_name="'"$CLUSTER"'"' --project "$PROJECT" --limit 5 --format="table(timestamp,protoPayload.authenticationInfo.principalEmail,protoPayload.methodName)"
# Retention + sinks
gcloud logging buckets describe _Default --location=global --project "$PROJECT" --format="value(retentionDays,locked)"
gcloud logging sinks list --project "$PROJECT" --format="table(name,destination,filter)"
# Data Access audit config for GKE (Admin Activity is always on)
gcloud projects get-iam-policy "$PROJECT" --format="yaml(auditConfigs)"

GUI: Cluster → Details → Features → Logging / Cloud Monitoring · Logs Explorer https://console.cloud.google.com/logs/query?project=<PROJECT> (query resource.type="k8s_cluster") · Logging → Logs Storage (bucket retention) · IAM & Admin → Audit Logs https://console.cloud.google.com/iam-admin/audit?project=<PROJECT> → Kubernetes Engine API. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --logging=SYSTEM,WORKLOAD,API_SERVER,SCHEDULER,CONTROLLER_MANAGER --monitoring=SYSTEM,API_SERVER,SCHEDULER,CONTROLLER_MANAGER; gcloud logging buckets update _Default --location=global --retention-days=365; an aggregated sink of logName:"cloudaudit.googleapis.com" to a locked bucket/BigQuery.

4.6.2 Security posture dashboard: configuration + vulnerability scanning

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(securityPostureConfig)"   # mode: BASIC|ENTERPRISE, vulnerabilityMode: VULNERABILITY_BASIC|VULNERABILITY_ENTERPRISE

GUI: https://console.cloud.google.com/kubernetes/security_posture?project=<PROJECT> (concerns per cluster/workload; export via Logging resource.type="k8s_cluster" AND logName:"container.googleapis.com%2Fsecurity_posture"). Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --security-posture=standard --workload-vulnerability-scanning=standard (enterprise adds language-package + OS scanning and policy bundles; Autopilot has standard on by default).

4.6.3 Security Command Center + Container Threat Detection, GKE notifications

gcloud services list --enabled --project "$PROJECT" --filter="name:(securitycenter.googleapis.com OR containerscanning.googleapis.com OR containersecurity.googleapis.com)" --format="value(config.name)"
# Findings for this cluster (org-level SCC, needs securitycenter.findings.list)
gcloud scc findings list <ORG_ID> --filter='resource_name:"'"$CLUSTER"'" AND state="ACTIVE"' --format="table(finding.category,finding.severity,finding.eventTime)" 2>/dev/null | head
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(notificationConfig)"

GUI: Security Command Center https://console.cloud.google.com/security/command-center/overview?project=<PROJECT> → Settings → Services → Container Threat Detection (Premium/Enterprise tier; Standard tier has no runtime detection) · Cluster → Details → Automation → Notifications. Fix: SCC Premium/Enterprise with Container Threat Detection enabled for the project/folder; gcloud container clusters update "$CLUSTER" --location "$LOCATION" --notification-config=pubsub=ENABLED,pubsub-topic=projects/$PROJECT/topics/gke-notifications,filter="UpgradeEvent|SecurityBulletinEvent".

4.7 Nodes and node pools

4.7.1 Node pool inventory and hardening flags

gcloud container node-pools list --cluster "$CLUSTER" --location "$LOCATION" --format="table(name,version,config.imageType,config.machineType,config.serviceAccount,config.shieldedInstanceConfig.enableSecureBoot,config.shieldedInstanceConfig.enableIntegrityMonitoring,config.workloadMetadataConfig.mode,config.sandboxConfig.type,management.autoUpgrade,management.autoRepair,networkConfig.enablePrivateNodes)"
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(shieldedNodes,confidentialNodes,nodePoolDefaults,networkConfig.defaultEnablePrivateNodes)"
kubectl get nodes -o custom-columns='NAME:.metadata.name,OS:.status.nodeInfo.osImage,KUBELET:.status.nodeInfo.kubeletVersion,POOL:.metadata.labels.cloud\.google\.com/gke-nodepool,EXTIP:.status.addresses[?(@.type=="ExternalIP")].address'

GUI: Cluster → Nodes tab → node pool → Security (service account, access scopes, GKE Metadata Server, Shielded options, sandbox) / Management / Metadata.

4.7.2 Shielded GKE nodes with Secure Boot + integrity monitoring, COS image, no SSH from internet

Risk: Shielded nodes bind node credentials to the VM identity (stops a stolen kubelet cert from being replayed from a rogue VM); Secure Boot blocks unsigned kernel modules (rootkits). COS is read-only root, no package manager, auto-updating. Public node IPs + a firewall rule for 22 == node compromise path. Vulnerable when: shieldedNodes.enabled false; enableSecureBoot false; imageType is UBUNTU* without reason, or any *_DOCKER*/legacy; nodes have external IPs (enablePrivateNodes false); a firewall rule allows tcp:22 from 0.0.0.0/0 to the node tag.

NET=$(gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(network)")
gcloud compute firewall-rules list --filter="network:$NET AND direction=INGRESS AND disabled=false AND sourceRanges:0.0.0.0/0" \
  --format="table(name,sourceRanges.list(),allowed[].map().firewall_rule().list(),targetTags.list())"
gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(nodePools[].config.tags)"   # node tags to match above
gcloud compute project-info describe --project "$PROJECT" --format=json | jq -r '.commonInstanceMetadata.items[]? | select(.key=="enable-oslogin") | .value'   # expect TRUE

GUI: Cluster → Details → Security → Shielded GKE nodes · Nodes → pool → Security → Secure boot / Integrity monitoring · VPC network → Firewall https://console.cloud.google.com/net-security/firewall-manager/firewall-policies/list?project=<PROJECT>. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-shielded-nodes; Secure Boot / image type / sandbox / private nodes are create-time per pool: gcloud container node-pools create <POOL> --cluster "$CLUSTER" --location "$LOCATION" --image-type COS_CONTAINERD --shielded-secure-boot --shielded-integrity-monitoring --enable-private-nodes --workload-metadata=GKE_METADATA --service-account <NODE_SA> [--sandbox type=gvisor] [--enable-confidential-nodes] and drain the old one; delete 0.0.0.0/0 → 22 firewall rules, use IAP TCP forwarding (gcloud compute ssh --tunnel-through-iap) or kubectl debug node/ for break-glass; enforce OS Login (enable-oslogin=TRUE).

4.7.3 Kubelet read-only port (10255) disabled; node auto-upgrade on

Risk: The unauthenticated read-only kubelet port leaks pod specs, env vars (often secrets) and runs on every node reachable from pods. Vulnerable when: insecureKubeletReadonlyPortEnabled: true (cluster default or any pool); GKE 1.32+ defaults it off, older clusters keep it on.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(nodePoolDefaults.nodeConfigDefaults.nodeKubeletConfig,nodePools[].name,nodePools[].config.kubeletConfig)"
NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
kubectl run ro-test --rm -it --restart=Never --image=curlimages/curl --env="NODE_IP=$NODE_IP" -- sh -c 'curl -s -m 3 "http://$NODE_IP:10255/pods" | head -c 300'   # any JSON back == open

GUI: Nodes → pool → Metadata / Kubelet (newer consoles); Security posture dashboard flags it. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --no-enable-insecure-kubelet-readonly-port --no-autoprovisioning-enable-insecure-kubelet-readonly-port; per pool gcloud container node-pools update <POOL> --cluster "$CLUSTER" --location "$LOCATION" --no-enable-insecure-kubelet-readonly-port.

4.8 Networking

4.8.1 NetworkPolicy enforcement (Dataplane V2 or Calico) - and policies exist

Risk: Standard GKE without Dataplane V2 or --enable-network-policy ignores NetworkPolicy objects entirely. Vulnerable when: networkConfig.datapathProvider != ADVANCED_DATAPATH and (networkPolicy.enabled != true or addonsConfig.networkPolicyConfig.disabled: true); or enforcement on but kubectl get networkpolicies -A empty.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(networkConfig.datapathProvider,networkPolicy,addonsConfig.networkPolicyConfig,networkConfig.enableIntraNodeVisibility,networkConfig.enableFqdnNetworkPolicy,networkConfig.enableCiliumClusterwideNetworkPolicy)"
kubectl get networkpolicies -A; kubectl get ciliumnetworkpolicies,ciliumclusterwidenetworkpolicies -A 2>/dev/null; kubectl get fqdnnetworkpolicies.networking.gke.io -A 2>/dev/null

GUI: Cluster → Details → Networking → Dataplane V2 / Network policy / Intranode visibility. Fix: Dataplane V2 is create-time (--enable-dataplane-v2, default on new clusters/Autopilot); on existing Calico-less clusters gcloud container clusters update "$CLUSTER" --location "$LOCATION" --enable-network-policy (re-creates nodes); then default-deny per namespace; --enable-fqdn-network-policy / --enable-cilium-clusterwide-network-policy for egress by domain; --enable-intra-node-visibility so pod-to-pod on one node is logged by VPC flow logs.

4.8.2 VPC-native cluster, private nodes, Private Google Access, Cloud NAT

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(ipAllocationPolicy.useIpAliases,network,subnetwork,privateClusterConfig.enablePrivateNodes,networkConfig.defaultEnablePrivateNodes)"
SUBNET=$(gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="value(subnetwork)"); REG=${LOCATION%-?}
gcloud compute networks subnets describe "$SUBNET" --region "$REG" --format="yaml(privateIpGoogleAccess,logConfig.enable,purpose)"
gcloud compute routers list --filter="network:$NET" --format="value(name,region)" | while read r rg; do gcloud compute routers nats list --router "$r" --region "$rg" --format="table(name,sourceSubnetworkIpRangesToNat,logConfig.enable)"; done

Vulnerable when: routes-based (non-VPC-native) cluster; public nodes; no NAT (nodes with external IPs are the NAT); subnet flow logs off (no network forensics). GUI: Details → Networking; VPC network → Subnets; Network services → Cloud NAT https://console.cloud.google.com/net-services/nat/list?project=<PROJECT>. Fix: private nodes per pool (4.7.2) + gcloud compute routers nats create ... for egress; gcloud compute networks subnets update "$SUBNET" --region "$REG" --enable-private-ip-google-access --enable-flow-logs.

4.8.3 Egress control

Cloud NAT does not filter. Options: FQDN network policies (4.8.1), Cloud Next Generation Firewall policies with FQDN objects / Secure Web Proxy for pods that need the internet, VPC Service Controls around the project to stop data exfiltration to attacker-owned GCS/BigQuery.

gcloud compute network-firewall-policies list --project "$PROJECT" --format="table(name,ruleTupleCount)"
gcloud access-context-manager perimeters list --organization <ORG_ID> --format="table(name,status.resources)" 2>/dev/null

4.8.4 Load balancers, ingresses and Cloud Armor

kubectl get svc -A -o json | jq -r '.items[] | select(.spec.type=="LoadBalancer") | "\(.metadata.namespace)/\(.metadata.name) type=\(.metadata.annotations["networking.gke.io/load-balancer-type"] // .metadata.annotations["cloud.google.com/load-balancer-type"] // "External") ip=\(.status.loadBalancer.ingress[0].ip // "")"'
kubectl get ingress -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) class=\(.spec.ingressClassName // .metadata.annotations["kubernetes.io/ingress.class"] // "gce(external)") ip=\(.status.loadBalancer.ingress[0].ip // "")"'
kubectl get gateways.gateway.networking.k8s.io -A 2>/dev/null
gcloud compute forwarding-rules list --project "$PROJECT" --format="table(name,IPAddress,loadBalancingScheme,target)"
gcloud compute security-policies list --project "$PROJECT" --format="table(name,type)"           # Cloud Armor policies
kubectl get backendconfigs.cloud.google.com -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) armor=\(.spec.securityPolicy.name // "none")"'

GUI: Kubernetes Engine → Gateways, Services & Ingress https://console.cloud.google.com/kubernetes/discovery?project=<PROJECT> · Network services → Load balancing · Network security → Cloud Armor. Fix: networking.gke.io/load-balancer-type: "Internal" / kubernetes.io/ingress.class: "gce-internal" unless intentionally public; Cloud Armor policy via BackendConfig.spec.securityPolicy (or GCPBackendPolicy for Gateway); loadBalancerSourceRanges for admin UIs.

4.9 Policy and image supply chain

4.9.1 Binary Authorization enforcing (only signed / allow-listed images run)

Vulnerable when: binaryAuthorization.evaluationMode is DISABLED (or absent); or enabled with a policy whose defaultAdmissionRule.evaluationMode is ALWAYS_ALLOW / broad admissionWhitelistPatterns like *.

gcloud container clusters describe "$CLUSTER" --location "$LOCATION" --format="yaml(binaryAuthorization)"
gcloud container binauthz policy export --project "$PROJECT"

GUI: Cluster → Details → Security → Binary Authorization · Security → Binary Authorization https://console.cloud.google.com/security/binary-authorization/policy?project=<PROJECT>. Fix: gcloud container clusters update "$CLUSTER" --location "$LOCATION" --binauthz-evaluation-mode=PROJECT_SINGLETON_POLICY_ENFORCE; policy with defaultAdmissionRule: {evaluationMode: REQUIRE_ATTESTATION, enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG} and attestors fed by Cloud Build / cosign; admissionWhitelistPatterns only for <LOCATION>-docker.pkg.dev/<PROJECT>/<REPO>/* and the GKE system registries.

4.9.2 Pod security enforced (PSA labels, Policy Controller, or posture policy bundles)

kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce'
gcloud container fleet policycontroller describe --project "$PROJECT" 2>/dev/null | head -30
kubectl get constrainttemplates,constraints -A 2>/dev/null | head

Fix: PSA restricted on all app namespaces (Autopilot already blocks the dangerous fields); Policy Controller for fleets: gcloud container fleet policycontroller enable --memberships=<MEMBERSHIP> then install bundles with gcloud container fleet policycontroller content bundles set pss-restricted-v2022 --memberships=<MEMBERSHIP> --exempted-namespaces=kube-system (also cis-k8s-v1.5.1).

4.9.3 Artifact Registry: private, scanned, immutable tags; only trusted registries in use

gcloud artifacts repositories list --project "$PROJECT" --format="table(name,format,mode,cleanupPolicyDryRun)"
for r in $(gcloud artifacts repositories list --project "$PROJECT" --format="value(name)"); do
  loc=$(echo "$r" | awk -F/ '{print $4}'); name=$(echo "$r" | awk -F/ '{print $6}')
  gcloud artifacts repositories get-iam-policy "$name" --location "$loc" --format=json | grep -E "allUsers|allAuthenticatedUsers" && echo "PUBLIC: $r"
done
gcloud services list --enabled --project "$PROJECT" --filter="name:containerscanning.googleapis.com" --format="value(config.name)"   # vulnerability scanning on
gcloud artifacts vulnerabilities list <LOCATION>-docker.pkg.dev/$PROJECT/<REPO>/<IMAGE>@<DIGEST> --format="table(vulnerability.effectiveSeverity,vulnerability.packageIssue[0].affectedPackage,vulnerability.shortDescription)" | head
# Registries and tags actually running in the cluster
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | awk -F/ '{print $1}' | sort | uniq -c | sort -rn
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | grep -v '@sha256' | grep -E ':latest$|^[^:]+$'

GUI: Artifact Registry https://console.cloud.google.com/artifacts?project=<PROJECT> → repository → Permissions (no allUsers), image → Vulnerabilities tab · Settings → Vulnerability scanning. Fix: gcloud services enable containerscanning.googleapis.com; remove public bindings (gcloud artifacts repositories remove-iam-policy-binding <REPO> --location <LOC> --member allUsers --role roles/artifactregistry.reader); immutable tags (gcloud artifacts repositories update <REPO> --location <LOC> --immutable-tags); remote/virtual repositories as the only path to Docker Hub; Binary Authorization (4.9.1) to enforce it.

4.10 GKE-specific in-cluster checks

# 1. Bindings to system:authenticated / unauthenticated (every Google account) - see 4.3.2
kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.subjects[]?.name | test("^system:(un)?authenticated$")) | "\(.metadata.name) -> \(.roleRef.name)"'
# 2. GKE metadata server running on every node (Workload Identity really active)
kubectl -n kube-system get ds gke-metadata-server -o wide 2>/dev/null || echo "NO gke-metadata-server → pods can reach the node SA"
# 3. KSAs annotated to GSAs that no longer exist / over-privileged (cross-check 4.4.1)
kubectl get sa -A -o json | jq -r '.items[] | select(.metadata.annotations["iam.gke.io/gcp-service-account"]) | "\(.metadata.namespace)/\(.metadata.name) -> \(.metadata.annotations["iam.gke.io/gcp-service-account"])"'
# 4. Secrets that look like GCP service-account keys (type: service_account JSON)
kubectl get secrets -A -o json | jq -r '.items[] | select(.data | to_entries[] | .value | @base64d | test("\"type\": *\"service_account\"")) | "\(.metadata.namespace)/\(.metadata.name)"' 2>/dev/null
# 5. hostPath into node credentials / container runtime
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.hostPath.path | test("^/(var/lib/kubelet|etc/srv/kubernetes|home/kubernetes|var/run/docker|run/containerd)")) | "\(.metadata.namespace)/\(.metadata.name)"'
# 6. hostNetwork pods (bypass metadata-server interception and NetworkPolicy)
kubectl get pods -A -o json | jq -r '.items[] | select(.spec.hostNetwork==true) | "\(.metadata.namespace)/\(.metadata.name)"'
# 7. Which GKE security components are present
kubectl get ds,deploy -n kube-system -o name | grep -E 'gke-metadata-server|anetd|calico|netd|konnectivity|binauthz|gatekeeper|secrets-store|ip-masq'
# 8. Kubelet config on a node (read-only port, anonymous auth)
kubectl get --raw "/api/v1/nodes/$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')/proxy/configz" | jq '.kubeletconfig | {authentication, authorization, readOnlyPort}'

4.11 GKE console map

WhatWhere (console)
Cluster listhttps://console.cloud.google.com/kubernetes/list/overview?project=<PROJECT>
Cluster details (all security/network settings on one page)https://console.cloud.google.com/kubernetes/clusters/details/<LOCATION>/<CLUSTER>/details?project=<PROJECT>
Version, release channel, mode (Autopilot/Standard)Details → Cluster basics
Maintenance window, upgrade notificationsDetails → Automation
Control plane access (public/private, authorized networks, DNS access), Dataplane V2, network policy, intranode visibility, L4 ILB subsettingDetails → Networking
Shielded nodes, Workload Identity, Binary Authorization, secrets encryption, legacy auth, client cert, Google Groups for RBAC, Secret Manager, security postureDetails → Security
Logging / monitoring components, Managed Prometheus, Gateway APIDetails → Features
Node pools (image, SA, scopes, metadata server, shielded options, sandbox, auto-upgrade)https://console.cloud.google.com/kubernetes/nodepool/<LOCATION>/<CLUSTER>/<POOL>?project=<PROJECT>
Security posture dashboard (concerns, vulnerabilities per workload)https://console.cloud.google.com/kubernetes/security_posture?project=<PROJECT>
Workloads / Services & Ingress / Secrets & ConfigMaps / Storagehttps://console.cloud.google.com/kubernetes/workload/overview?project=<PROJECT> · .../kubernetes/discovery?project=<PROJECT> · .../kubernetes/config?project=<PROJECT>
IAM (who is Editor/Owner/Kubernetes Engine Admin)https://console.cloud.google.com/iam-admin/iam?project=<PROJECT>
Service accounts (node SA, Workload Identity GSAs, keys)https://console.cloud.google.com/iam-admin/serviceaccounts?project=<PROJECT>
Audit log configurationhttps://console.cloud.google.com/iam-admin/audit?project=<PROJECT>
Logs Explorer / log buckets & retentionhttps://console.cloud.google.com/logs/query?project=<PROJECT> · https://console.cloud.google.com/logs/storage?project=<PROJECT>
Binary Authorization policyhttps://console.cloud.google.com/security/binary-authorization/policy?project=<PROJECT>
Artifact Registry repositories, scanninghttps://console.cloud.google.com/artifacts?project=<PROJECT>
Cloud KMS keyshttps://console.cloud.google.com/security/kms?project=<PROJECT>
Security Command Centerhttps://console.cloud.google.com/security/command-center/overview?project=<PROJECT>
VPC firewall rules / policieshttps://console.cloud.google.com/net-security/firewall-manager/firewall-policies/list?project=<PROJECT>
Cloud NAThttps://console.cloud.google.com/net-services/nat/list?project=<PROJECT>
Load balancing / Cloud Armorhttps://console.cloud.google.com/net-services/loadbalancing/list/loadBalancers?project=<PROJECT> · https://console.cloud.google.com/net-security/securitypolicies/list?project=<PROJECT>
Org policies (default SA grants, external IPs, etc.)https://console.cloud.google.com/iam-admin/orgpolicies/list?project=<PROJECT>

5. In-cluster checks that cross the cloud boundary

Same for all three clouds; run once per cluster after §2-§4.

5.1 Metadata reachability (the single most important test)

Run the per-cloud test pod: §2.4.5 (AWS) · §3.4.4 (Azure) · §4.4.4 (GCP). Run it in a non-system namespace with the default service account - that is the attacker's starting point. Then run it again with hostNetwork: true to document the residual risk.

kubectl run imds-hostnet --rm -it --restart=Never --image=curlimages/curl --overrides='{"spec":{"hostNetwork":true}}' -- sh -c 'curl -s -m 3 http://169.254.169.254/ -H "Metadata: true" -H "Metadata-Flavor: Google" | head -c 200'

5.2 What is actually exposed to the internet

# Services with public load balancers
kubectl get svc -A -o json | jq -r '.items[] | select(.spec.type=="LoadBalancer" or .spec.type=="NodePort") | "\(.spec.type) \(.metadata.namespace)/\(.metadata.name) ports=\([.spec.ports[] | "\(.port)->\(.targetPort)"]|join(",")) lb=\(.status.loadBalancer.ingress[0].hostname // .status.loadBalancer.ingress[0].ip // "-") srcRanges=\(.spec.loadBalancerSourceRanges // ["0.0.0.0/0"] | join(","))"'
# Ingresses / Gateways and their hosts
kubectl get ingress -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name) hosts=\([.spec.rules[]?.host]|join(",")) addr=\(.status.loadBalancer.ingress[0].hostname // .status.loadBalancer.ingress[0].ip // "-")"'
kubectl get gateways.gateway.networking.k8s.io,httproutes.gateway.networking.k8s.io -A 2>/dev/null
# Admin UIs that should never be public
kubectl get svc,ingress -A | grep -iE 'dashboard|grafana|argocd|kibana|prometheus|jenkins|vault|minio|rabbit|pgadmin|phpmyadmin'
# Resolve and probe everything from outside (unauthenticated)
for h in $(kubectl get ingress -A -o jsonpath='{range .items[*]}{range .spec.rules[*]}{.host}{"\n"}{end}{end}' | sort -u); do printf "%-50s " "$h"; curl -sk -m 5 -o /dev/null -w "%{http_code}\n" "https://$h/"; done

Fix: internal annotations (per cloud §2.8.4 / §3.8.4 / §4.8.4), loadBalancerSourceRanges, WAF in front, SSO/oauth2-proxy on admin UIs, NodePort services replaced by ClusterIP + Ingress.

5.3 Cloud credentials stored inside the cluster

# Secrets whose keys/values look like cloud credentials
kubectl get secrets -A -o json | jq -r '.items[] | . as $s | (.data // {}) | to_entries[] | select((.key | test("aws|azure|gcp|google|client[-_]?secret|access[-_]?key|service[-_]?account|credentials|\\.json$";"i")) or (.value | @base64d | test("AKIA[0-9A-Z]{16}|\"type\": *\"service_account\"|AccountKey=|SharedAccessSignature";"i"))) | "\($s.metadata.namespace)/\($s.metadata.name): \(.key)"' 2>/dev/null | sort -u
# ConfigMaps and env vars with the same patterns
kubectl get cm -A -o json | jq -r '.items[] | . as $c | (.data // {}) | to_entries[] | select(.value | test("AKIA[0-9A-Z]{16}|private_key|client_secret|AccountKey=";"i")) | "\($c.metadata.namespace)/\($c.metadata.name): \(.key)"'
kubectl get pods -A -o json | jq -r '.items[] | . as $p | .spec.containers[].env[]? | select(.name | test("AWS_SECRET_ACCESS_KEY|AZURE_CLIENT_SECRET|GOOGLE_APPLICATION_CREDENTIALS|ARM_CLIENT_SECRET";"i")) | "\($p.metadata.namespace)/\($p.metadata.name): \(.name) (value=\(.value // "fromRef"))"'

Any hit here is a High by itself (static cloud keys in-cluster == the cloud-native identity mechanism is being bypassed) - and the key must be rotated after the review.

5.4 Pod Security Admission / policy baseline (no managed cluster enforces this by default except Autopilot)

kubectl get ns -o custom-columns='NAME:.metadata.name,ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce,AUDIT:.metadata.labels.pod-security\.kubernetes\.io/audit'
# What would break under "restricted"?
kubectl label --dry-run=server --overwrite ns --all pod-security.kubernetes.io/enforce=restricted 2>&1 | grep -v "labeled$" | head -40
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations

Fix: kubectl label ns <NS> pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/warn=restricted on every workload namespace (baseline as a first step), plus Kyverno/Gatekeeper/Azure Policy/Policy Controller for registry allow-lists and image digests.

5.5 Storage: encrypted, non-public storage classes and buckets mounted by the cluster

kubectl get storageclass -o json | jq -r '.items[] | "\(.metadata.name) provisioner=\(.provisioner) params=\(.parameters // {} | tostring)"'
# AWS: EBS/EFS encrypted?   Azure: disk encryption set?   GCP: CMEK (disk-encryption-kms-key)?
kubectl get pv -o json | jq -r '.items[] | "\(.metadata.name) \(.spec.csi.driver // .spec.awsElasticBlockStore.volumeID // .spec.gcePersistentDisk.pdName // "-") \(.spec.csi.volumeHandle // "")"'
# AWS: unencrypted EBS volumes owned by the cluster
aws ec2 describe-volumes --region "$REGION" --filters "Name=tag:kubernetes.io/cluster/$CLUSTER,Values=owned" --query 'Volumes[?Encrypted==`false`].VolumeId'
# Azure: node-resource-group disks not using a customer-managed key
az disk list -g "$MC_RG" --query '[?encryption.type!=`EncryptionAtRestWithCustomerKey`].{name:name,type:encryption.type}' -o table
# GCP: GKE disks and their CMEK (empty = Google-managed key)
gcloud compute disks list --project "$PROJECT" --filter="labels.goog-gke-cluster:*" --format="table(name,zone,diskEncryptionKey.kmsKeyName)"

5.6 Everything else

RBAC, service-account tokens, privileged pods, hostPath, capabilities, etcd, kubelet, admission bypass, CI/CD service accounts, persistence → Kubernetes Pentesting Guide (its Security Checklist section is the in-cluster half of this review).


6. Severity guide for reporting

FindingTypical severityWhy
Pods can read node/kubelet cloud credentials from the metadata service and that identity is broad (Editor / Contributor / fat node role)CriticalAny RCE/SSRF in any pod = cloud account compromise
Local admin credentials obtainable (az aks get-credentials --admin works; GKE client cert issued; EKS creator hidden admin + many Contributors)Critical / HighNon-expiring cluster-admin outside the IdP, no Conditional Access
API server public with no CIDR restriction (0.0.0.0/0)HighLeaked token usable from anywhere; brute force/DoS surface
Broad IAM → cluster-admin mappings (Editor/Contributor/system:masters for teams, SSO admin permission sets)HighBlast radius of any identity compromise
Static cloud keys / SA JSON in Secrets, ConfigMaps or envHighBypasses workload identity, rarely rotated
No NetworkPolicy enforcement (engine off)High / MediumFlat network; metadata and kube-system reachable from every pod
No audit logging (EKS types off / no AKS diagnostic setting / GKE SYSTEM only)High / MediumNo forensics; compliance failure
Pods can reach metadata but the identity is minimal (AcrPull / pull-only)MediumStill enables registry pulls + lateral discovery
No cloud threat detection (GuardDuty EKS / Defender for Containers / SCC CTD)MediumDetection gap, not an exposure
Secrets not encrypted with a CMKMediumDefence in depth; cloud default encryption still applies
Unsupported / extended-support K8s version, off-channel, no node auto-upgradeMedium (High if a known API-server CVE applies)Patch gap
Node SSH open to internet / public node IPsHigh (SSH) / Medium (public IP, SG-restricted)Node compromise = all secrets on node
Public registry / mutable latest tags / no scanning / no admission controlMediumSupply-chain integrity
Run command enabled on a private AKS cluster; GKE global access without authorized networksMediumBypasses the network control that was supposed to be the boundary
Image Cleaner off, encryption-at-host off, intranode visibility off, no deletion protectionLowHygiene

7. References

Official hardening guides & benchmarks

Tools

ToolUse here
kube-benchCIS EKS/AKS/GKE benchmarks (job-eks.yaml, job-aks.yaml, job-gke.yaml)
ProwlerCloud-side EKS/AKS/GKE checks (-s eks / -s aks / -s gke)
Steampipe + Powerpipe compliance modsSQL over cluster configs across accounts; CIS/compliance benchmarks
hardeneksAWS's EKS best-practices checker
KubescapeCIS-EKS/AKS/GKE frameworks, RBAC visualiser
Trivy (trivy k8s)Misconfig + image CVEs across the cluster
Checkov / tfsecSame checks against the Terraform/Bicep that builds the clusters
rbac-police / KubiScanRBAC escalation paths after IAM mapping
PeiratesHas cloud-metadata → cluster escalation modules for all three clouds (offensive validation)