Kubernetes security hardening: CIS benchmark deep dive
, 4 min read
The CIS Kubernetes Benchmark is a set of secure-configuration recommendations for Kubernetes, written through the Center for Internet Security's community consensus process. It covers the control plane, etcd, the kubelet and cluster policies. Each recommendation says what to check and how to fix it. CIS publishes versions for upstream Kubernetes and separate benchmarks for managed services: EKS, GKE, AKS and Oracle's OKE.
On a managed service you can't touch the control plane, so the managed benchmarks drop those sections. Use the one that matches your platform.
Measuring where you are
kube-bench, from Aqua Security, runs the benchmark's checks against a live cluster. The simplest way is as a Kubernetes Job, using the job.yaml in the project's repository (there are variants for EKS, GKE and others):
kubectl apply -f job.yaml
kubectl logs job/kube-benchOn a self-managed control-plane node you can also run it directly and pick sections:
kube-bench run --targets master,etcd,node,policiesThe output lists PASS, FAIL and WARN per recommendation. WARN usually means "check this by hand". The benchmark has plenty of those, because some settings depend on your environment.
API server
A representative subset of the flags the benchmark checks:
- --anonymous-auth=false
- --authorization-mode=Node,RBAC
- --enable-admission-plugins=NodeRestriction
- --audit-log-path=/var/log/kubernetes/audit.log
- --audit-log-maxage=30
- --profiling=false
- --tls-min-version=VersionTLS12Older hardening guides also tell you to set --insecure-port=0. The unauthenticated insecure port was disabled in Kubernetes 1.20 and the flag was removed in 1.24. On a current cluster that line does nothing, or stops the API server from starting.
etcd and secrets at rest
etcd holds every object in the cluster, Secrets included. The benchmark expects TLS with client-certificate authentication for both client and peer traffic, and no auto-generated self-signed certificates:
- --client-cert-auth=true
- --peer-client-cert-auth=true
- --auto-tls=false
- --peer-auto-tls=falseBy default, the API server writes Secrets to etcd without encryption. Anyone who can read an etcd backup can base64-decode them. Turning on encryption at rest is an EncryptionConfiguration file passed to the API server.
Many older guides use the aescbc provider. The Kubernetes documentation now says aescbc isn't recommended, because CBC mode is open to padding-oracle attacks. It also marks aesgcm as unsafe without automatic key rotation. The recommended option is KMS v2, stable since Kubernetes 1.29, which keeps the key-encryption key in an external key management service. If you have no KMS, secretbox is the documented local choice:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- secretbox:
keys:
- name: key1
secret: <base64-encoded 32-byte key>
- identity: {}The order matters. The first provider encrypts new writes. identity at the end lets the API server still read Secrets written before encryption was turned on. After you enable it, rewrite every existing Secret so it's stored encrypted.
RBAC
RBAC drifts. A cluster-admin binding added for a quick fix is easy to forget. Two commands show where you stand:
# Every subject bound to cluster-admin
kubectl get clusterrolebindings -o json | jq '.items[]
| select(.roleRef.name=="cluster-admin")
| {name: .metadata.name, subjects: .subjects}'
# What one service account can actually do
kubectl auth can-i --list --as=system:serviceaccount:production:myappGive workloads namespaced Roles that grant only the verbs they use. Most pods never talk to the API server. For those, turn off the automatic token mount:
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp
namespace: production
automountServiceAccountToken: falsePod Security Admission
PodSecurityPolicy was deprecated in Kubernetes 1.21 and removed in 1.25. Its built-in replacement is Pod Security Admission, which enforces the Pod Security Standards per namespace through labels:
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restrictedbaseline blocks the known privilege escalations: privileged containers, host namespaces, host-path volumes and added dangerous capabilities. restricted also requires running as non-root, blocks privilege escalation, requires a seccomp profile and drops all capabilities. A useful rollout is warn and audit at restricted first, then enforce once the warnings stop.
Network policies
Without a NetworkPolicy, every pod can reach every other pod. Start each namespace with a default deny, then allow flows one at a time:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]NetworkPolicy objects only take effect if your network plugin enforces them. A cluster whose CNI ignores them will accept the YAML and change nothing, so test that a denied connection really fails.
Default-deny egress also blocks DNS. Your first allow rule is usually traffic to the cluster DNS service.
Keep it continuous
A benchmark run is a snapshot. Run kube-bench on a schedule, keep the results, and alert when a check that passed starts failing. Pair it with runtime detection, such as Falco's default rule for a shell spawned inside a container, because configuration checks can't see what a running workload does. And re-read the benchmark when you upgrade Kubernetes. Flags are added, deprecated and removed, as --insecure-port shows.
Sources
- CIS — Kubernetes Benchmarks
- Aqua Security — kube-bench: running kube-bench
- Kubernetes docs — Encrypting confidential data at rest (provider comparison)
- Kubernetes docs — Pod Security Standards
- Kubernetes docs — PodSecurityPolicy (deprecation and removal)
- Kubernetes docs — Network policies
- Kubernetes docs — Using RBAC authorization
- Kubernetes 1.24 changelog — insecure address and port flags removed