Cloud Security · lesson 5 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
Also part of: Kubernetes
CIS Kubernetes Benchmark Checks with kube-bench in kind
Run kube-bench against a local kind cluster, read a real FAIL from the CIS Kubernetes Benchmark, fix it in the cluster config and re-run it to PASS.
By SkillByExample · Published
Docker has a CIS benchmark, and so does Kubernetes. kube-bench runs its checks inside the
cluster and reports a PASS or FAIL for each one. A kind cluster is a real kubeadm control plane in
a Docker container, so the checks it reports are the same ones a self-managed production cluster
gets, and you need no cloud account.
Tested on 2026-10-15 with kind v0.33.0, the kindest/node:v1.34.11 image (Kubernetes 1.34.11),
kube-bench v0.16.0, kubectl 1.35.2 and Docker Desktop with Engine 29.8.2. kube-bench picks the
benchmark from the Kubernetes version, and v0.16.0 maps releases only up to 1.34 (to CIS 1.12),
so the node image is pinned instead of taking kind’s newer default:
# kind-cis.yaml: a one-node lab cluster on a Kubernetes release kube-bench v0.16.0 knows
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cis-lab
nodes:
- role: control-plane
image: kindest/node:v1.34.11@sha256:44e222ee2132dab25ff87301682f89eb82c7880ea3a1bf543bfe9708fd08d67d
$ kind version
kind v0.33.0 go1.26.7 windows/amd64
$ kind create cluster --config kind-cis.yaml 2>&1 | grep "kubectl context"
Set kubectl context to "kind-cis-lab"
$ kubectl version | tail -1
Server Version: v1.34.11
The @sha256: digest is the one the kind v0.33.0 release notes publish for that image, so the
node can’t change underneath you. The cluster is named cis-lab, and kind switched kubectl to it.
Run the benchmark
kube-bench’s repository ships a Job manifest for each release. It mounts the node’s configuration directories read-only and shares the host’s PID namespace, so it can read the flags of the running control-plane processes:
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench created
$ kubectl wait --for=condition=complete job/kube-bench --timeout=300s
job.batch/kube-bench condition met
$ kubectl logs job/kube-bench | grep -A4 "Summary total"
== Summary total ==
63 checks PASS
12 checks FAIL
56 checks WARN
0 checks INFO
$ kubectl logs job/kube-bench | grep "^\[FAIL\]"
[FAIL] 1.1.12 Ensure that the etcd data directory ownership is set to etcd:etcd (Automated)
[FAIL] 1.2.5 Ensure that the --kubelet-certificate-authority argument is set as appropriate (Automated)
[FAIL] 1.2.15 Ensure that the --profiling argument is set to false (Automated)
[FAIL] 1.2.16 Ensure that the --audit-log-path argument is set (Automated)
[FAIL] 1.2.17 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate (Automated)
[FAIL] 1.2.18 Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate (Automated)
[FAIL] 1.2.19 Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate (Automated)
[FAIL] 1.2.30 Ensure that the --service-account-extend-token-expiration parameter is set to false (Automated)
[FAIL] 1.3.2 Ensure that the --profiling argument is set to false (Automated)
[FAIL] 1.4.1 Ensure that the --profiling argument is set to false (Automated)
[FAIL] 4.1.1 Ensure that the kubelet service file permissions are set to 600 or more restrictive (Automated)
[FAIL] 4.1.9 If the kubelet config.yaml configuration file is being used validate permissions set to 600 or more restrictive (Automated)
A default kubeadm cluster fails 12 checks out of the box. The numbering follows the benchmark: 1.x is the control plane (1.2 the API server, 1.3 the controller manager, 1.4 the scheduler) and 4.x is the worker node. The 56 WARNs are Manual recommendations, such as “minimise access to secrets”, that no script can decide. They are a review list, not a pass.
Read one FAIL
Three of the FAILs are the same setting on three components. After the results, the log has a remediation for each FAIL:
$ kubectl logs job/kube-bench | grep -A2 "^1.2.15 "
1.2.15 Edit the API server pod specification file /etc/kubernetes/manifests/kube-apiserver.yaml
on the control plane node and set the below parameter.
--profiling=false
$ kubectl -n kube-system get pod kube-apiserver-cis-lab-control-plane -o yaml | grep -c -- --profiling
0
$ kubectl get --raw "/debug/pprof/heap?debug=1" | grep -c "^heap profile"
1
The API server’s pod spec doesn’t mention --profiling at all, and the default is true. The last
command proves what that means: /debug/pprof/ is served, and anyone with access to it can pull
heap profiles and goroutine dumps out of the process that holds every secret in the cluster.
Profiling is a debugging aid that production doesn’t need, which is why CIS wants it off on the
API server, controller manager and scheduler.
Fix it where the cluster is defined
The remediation says to edit the manifest on the node. That works, but kubeadm regenerates
the manifest on every rebuild or upgrade, so the hand edit is lost. The lasting fix goes in the
config that builds the cluster. kind passes kubeadmConfigPatches through to kubeadm, and its
extraArgs become command-line flags:
# kind-cis.yaml: the same cluster, with profiling off on all three control-plane components
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cis-lab
nodes:
- role: control-plane
image: kindest/node:v1.34.11@sha256:44e222ee2132dab25ff87301682f89eb82c7880ea3a1bf543bfe9708fd08d67d
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
profiling: "false"
controllerManager:
extraArgs:
profiling: "false"
scheduler:
extraArgs:
profiling: "false"
This map form of extraArgs is kubeadm’s v1beta3 config, which kind uses for nodes before
Kubernetes 1.36. On a 1.36 or later node image, kind switches to v1beta4, where extraArgs is a
list of {name: profiling, value: "false"} entries, so rewrite the patch in that shape.
kind can’t change the flags of a running cluster, so rebuild it and re-run the benchmark:
$ kind delete cluster --name cis-lab
Deleting cluster "cis-lab" ...
Deleted nodes: ["cis-lab-control-plane"]
$ kind create cluster --config kind-cis.yaml 2>&1 | grep "kubectl context"
Set kubectl context to "kind-cis-lab"
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench created
$ kubectl wait --for=condition=complete job/kube-bench --timeout=300s
job.batch/kube-bench condition met
$ kubectl logs job/kube-bench | grep -E "^\[(PASS|FAIL)\] (1.2.15|1.3.2|1.4.1) "
[PASS] 1.2.15 Ensure that the --profiling argument is set to false (Automated)
[PASS] 1.3.2 Ensure that the --profiling argument is set to false (Automated)
[PASS] 1.4.1 Ensure that the --profiling argument is set to false (Automated)
$ kubectl logs job/kube-bench | grep -A4 "Summary total"
== Summary total ==
66 checks PASS
9 checks FAIL
56 checks WARN
0 checks INFO
$ kubectl -n kube-system get pod kube-apiserver-cis-lab-control-plane -o yaml | grep -- --profiling
- --profiling=false
$ kubectl get --raw "/debug/pprof/heap?debug=1" | grep -c "^heap profile"
Error from server (NotFound): the server could not find the requested resource
0
Three FAILs became PASS, and the totals moved by exactly three. The flag is now in the pod spec because kubeadm wrote it there, and the profiling endpoint has gone. This is the loop to keep for every FAIL: read the remediation, confirm the setting on the live cluster, fix it in the source of truth, rebuild and re-run. The config file is also what you review and commit, so the hardening can’t drift.
Practice
- With the hardened cluster still running, re-run the benchmark by applying
job.yamlagain. What doeskubectl applyprint, and which results doeskubectl logsshow?
Solution
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench unchanged
$ kubectl get pods -l app=kube-bench
NAME READY STATUS RESTARTS AGE
kube-bench-4z9kp 0/1 Completed 0 44s
$ kubectl delete job kube-bench
job.batch "kube-bench" deleted from default namespace
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench created
$ kubectl wait --for=condition=complete job/kube-bench --timeout=300s
job.batch/kube-bench condition met
$ kubectl get pods -l app=kube-bench
NAME READY STATUS RESTARTS AGE
kube-bench-kmvms 0/1 Completed 0 5s
unchanged means nothing ran. A finished Job is not started again just because you apply the same
manifest, so kubectl logs would show the old pod’s results. After a fix made in place, that reads
as “my fix didn’t work”. Delete the Job first. The new pod name and age show it is a fresh run. The
pod names are random, so yours will differ.
- Fix FAIL 1.2.30. With
--service-account-extend-token-expirationleft at its defaulttrue, the API server extends the service-account tokens it injects into pods to up to a year, however short a lifetime was requested. Add the flag tokind-cis.yaml, rebuild and check 1.2.30.
Solution
# kind-cis.yaml: profiling off, and service-account tokens no longer silently extended
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cis-lab
nodes:
- role: control-plane
image: kindest/node:v1.34.11@sha256:44e222ee2132dab25ff87301682f89eb82c7880ea3a1bf543bfe9708fd08d67d
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
profiling: "false"
service-account-extend-token-expiration: "false"
controllerManager:
extraArgs:
profiling: "false"
scheduler:
extraArgs:
profiling: "false"
$ kind delete cluster --name cis-lab
Deleting cluster "cis-lab" ...
Deleted nodes: ["cis-lab-control-plane"]
$ kind create cluster --config kind-cis.yaml 2>&1 | grep "kubectl context"
Set kubectl context to "kind-cis-lab"
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench created
$ kubectl wait --for=condition=complete job/kube-bench --timeout=300s
job.batch/kube-bench condition met
$ kubectl logs job/kube-bench | grep -E "^\[(PASS|FAIL)\] 1.2.30 "
[PASS] 1.2.30 Ensure that the --service-account-extend-token-expiration parameter is set to false (Automated)
$ kubectl logs job/kube-bench | grep -A4 "Summary total"
== Summary total ==
67 checks PASS
8 checks FAIL
56 checks WARN
0 checks INFO
Any API server flag is fixed the same way, as one more extraArgs key. The extension exists so
that old clients survive the move to short-lived tokens. Turn it off once your workloads refresh
their tokens, which current client libraries do.
- FAIL 4.1.9 is a file permission on the node, not a flag. Check the mode of
/var/lib/kubelet/config.yamlinside the kind node, fix it, and re-run only to confirm 4.1.9.
Solution
$ docker exec cis-lab-control-plane stat -c "%a %n" /var/lib/kubelet/config.yaml
644 /var/lib/kubelet/config.yaml
$ docker exec cis-lab-control-plane chmod 600 /var/lib/kubelet/config.yaml
$ kubectl delete job kube-bench
job.batch "kube-bench" deleted from default namespace
$ kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
job.batch/kube-bench created
$ kubectl wait --for=condition=complete job/kube-bench --timeout=300s
job.batch/kube-bench condition met
$ kubectl logs job/kube-bench | grep -E "^\[(PASS|FAIL)\] 4.1.9 "
[PASS] 4.1.9 If the kubelet config.yaml configuration file is being used validate permissions set to 600 or more restrictive (Automated)
A kind node is a container, so docker exec is the equivalent of SSH to a node. This fix is a
hand edit, though, and it disappears with the next kind create cluster. On real nodes, file
modes belong in the node image or in configuration management, such as Ansible, for the same
reason the flags belong in the kubeadm config.
- Clean up the lab.
Solution
$ kind delete cluster --name cis-lab
Deleting cluster "cis-lab" ...
Deleted nodes: ["cis-lab-control-plane"]
$ kubectl config current-context
error: current-context is not set
Deleting the cluster removes the node container and its kubeconfig entry. kubectl is left with
no current context, so switch back to your usual cluster with kubectl config use-context <name>
if you have one.