Skip to main content
Cloud Security · lesson 5 of 8
Cloud Security intermediate Lesson 5 of 8

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

cloud-security kubernetes cis-benchmark kube-bench kind

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

  1. With the hardened cluster still running, re-run the benchmark by applying job.yaml again. What does kubectl apply print, and which results does kubectl logs show?
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.

  1. Fix FAIL 1.2.30. With --service-account-extend-token-expiration left at its default true, 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 to kind-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.

  1. FAIL 4.1.9 is a file permission on the node, not a flag. Check the mode of /var/lib/kubelet/config.yaml inside 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.

  1. 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.

Frequently Asked Questions

What is the CIS Kubernetes Benchmark?
The CIS Kubernetes Benchmark is a hardening guide from the Center for Internet Security. It lists recommended settings for the control plane, etcd and the worker nodes, such as file permissions on the static pod manifests and flags like --profiling=false on the API server. Audits and security teams use it as the common baseline for a self-managed cluster.
What does kube-bench do?
kube-bench is an open-source tool from Aqua Security that checks a Kubernetes node against the CIS Kubernetes Benchmark. It usually runs as a Job with the host's PID namespace and read-only mounts of /etc/kubernetes, /var/lib/kubelet and similar paths, reads the running processes and files, and prints PASS, FAIL, WARN or INFO for each recommendation, followed by remediation text.
Why does kube-bench report WARN for so many checks?
WARN marks a Manual recommendation that kube-bench cannot decide by itself, such as whether RBAC grants are minimal or whether secrets are encrypted at rest with a suitable provider. They still need a human review. FAIL is reserved for Automated checks whose audit command returned a value that breaks the recommendation.
Can I fix a kube-bench FAIL by editing the static pod manifest?
Yes. The remediation text says to edit /etc/kubernetes/manifests/kube-apiserver.yaml, and the kubelet restarts the API server with the new flag. On a cluster built by kubeadm or kind, the lasting fix is in the cluster configuration, such as kubeadm extraArgs, because a hand edit is lost when the node is rebuilt or upgraded.
Does kube-bench work on EKS, AKS or GKE?
Partly. Managed services run the control plane for you, so the master checks do not apply. kube-bench has separate benchmarks for EKS, AKS and GKE that cover the worker nodes and the settings you control, selected with the --benchmark flag. The CIS Kubernetes Benchmark itself targets clusters where you run the control plane.