DEV Community

Cover image for CIS Benchmark for Kubernetes with kube-bench and Kubescape
Billy Walker for Core Solutions

Posted on Originally published at coresolutions.ltd

CIS Benchmark for Kubernetes with kube-bench and Kubescape

Most teams get told to “harden Kubernetes to CIS” long before anyone explains what happens after the first scan. You run the tool, it prints a wall of FAIL and WARN, half the findings turn out to be somebody else’s problem on a managed control plane, and the rest sit in a ticket queue until the next audit comes round. There is a narrower version that works. Use kube-bench for the host and control-plane checks only it can see, use Kubescape for the posture checks you want to keep running from the API side, and treat the CIS Kubernetes Benchmark as a triage tool rather than a moral score.

One thing to know before either tool runs: the scanners trail the benchmark. CIS revises the benchmark as new Kubernetes releases land, and each tool picks the revision up some time later, as a new profile that somebody has to write and merge. On a recently upgraded cluster there is a fair chance your scanner is running the closest older profile and hasn’t mentioned it. The result is still useful, but that caveat belongs in the first line of anything you hand to an auditor, and there is a quick way to check, which we’ll get to.

Two scanners, two views of the cluster

The short version:

  • kube-bench runs on a node. It reads config files, file permissions and the flags on running processes, then compares them with the CIS checks.
  • Kubescape talks to the API server. It evaluates the resources in your cluster, or the manifests in your repo, against frameworks such as NSA-CISA, MITRE ATT&CK and CIS.
  • They overlap on the policy-style checks, and that is about it.

That is why “kube-bench or Kubescape?” is the wrong question. Run only kube-bench and you get a point-in-time audit with no idea what changed last Tuesday. Run only the Kubescape CLI and you miss everything that depends on a file mode or a kubelet flag on a real node.

There is also one deliciously awkward detail to get out early. The standard kube-bench Job sets hostPID: true and mounts host paths such as /etc/kubernetes and /var/lib/kubelet, so the compliance tool needs an exemption from the restricted pod policy it is about to tell you to enforce. A host-level audit has to see the host, so plan for the exemption now rather than discovering it when admission rejects the pod.

What the CIS Kubernetes Benchmark is, and what it is not

The benchmark is a hardening checklist for cluster configuration. Its sections cover the control plane components, etcd, control plane configuration, worker nodes, and policies: RBAC, pod security, network policies and secrets. Every recommendation comes with an audit procedure and a remediation, which is what makes it automatable.

What it leaves out is where people over-read the score. A good CIS result tells you the configuration is in better shape than it was. It does not tell you:

  • whether workloads behave safely at runtime
  • whether your images are full of known CVEs
  • whether your admission policies are broad enough for your actual risk model
  • whether the control plane you rent from a provider was set up the way you would have done it

So treat the benchmark the way you would treat a good linter: very useful, occasionally annoying, and dangerous only when somebody mistakes it for a complete security strategy. If a score is going in front of leadership, put that caveat on the same slide. “100% CIS” and “secure” are different claims.

Start with kube-bench, on the nodes

kube-bench is an open-source project from Aqua Security, and it does one blunt thing well: read the files and flags that exist on the host and compare them with the benchmark profile it knows about.

The normal entry point is the Job manifest in the root of the repo. On a self-managed cluster, apply it and read the logs:

kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
Enter fullscreen mode Exit fullscreen mode

Every check comes back in one of four states. PASS and FAIL are what they sound like. WARN means kube-bench could not make the call for you, either because the benchmark marks the check as manual or because the check is unscored. INFO is a section header or a check you skipped. The habit to avoid is reading WARN as “fine”. In the vanilla profile nothing in the policies section is scored, so nothing in it can ever show as FAIL, and that section holds the RBAC and pod security checks you probably care about most.

The Job needs hostPID and that stack of read-only host mounts because kube-bench is looking at the node directly rather than asking the API server to summarise it. You can see the same assumption in the container one-liner from the upstream docs:

docker run --pid=host \
  -v /etc:/etc:ro \
  -v /var:/var:ro \
  -t docker.io/aquasec/kube-bench:latest
Enter fullscreen mode Exit fullscreen mode

That is how it catches things an API-only scanner cannot. It is also why the Job belongs in its own clearly named namespace, labelled privileged for Pod Security Admission, rather than squeezed under your strictest workload policy. One more practical note: to audit control-plane nodes the pod has to be scheduled onto them, which means adding a nodeSelector and tolerations to the manifest first.

Benchmark lag is normal, so check which profile ran

The easiest way to confuse yourself is to assume the latest kube-bench means the latest CIS revision. kube-bench maps your Kubernetes version to a benchmark profile using a table in its config, and the platforms page in the docs shows the same mapping. When your cluster is newer than anything in that table, kube-bench walks the minor version down until it finds a match and runs that profile instead. There is no error, and at the default log level there is no warning either.

The JSON output is where you catch it:

kube-bench run --targets node,policies --json
Enter fullscreen mode Exit fullscreen mode

Each block carries a version field, which is the profile that ran, and a detected_version field, which is the Kubernetes version kube-bench found. Compare the pair with the platforms table before you write policy around the result.

Once you know which profile you want, pin it:

kube-bench run --benchmark <profile> --targets node,policies
Enter fullscreen mode Exit fullscreen mode

Here <profile> is a name from the “kube-bench config” column of that table. Pinning can’t give you a newer benchmark than the tool ships. What it buys is a result you can describe accurately, and a scheduled run that won’t switch profile underneath you on the day somebody bumps the image tag.

The same table matters if you are not on vanilla Kubernetes. k3s, RKE2, OpenShift and the managed services each have their own profiles, because the vanilla one looks in paths those platforms do not use and rewards you with a page of false failures.

One finding, end to end

Reports are easy to admire from a distance, so take one finding all the way through. In the current vanilla profiles, check 5.2.3 is “Minimize the admission of containers wishing to share the host process ID namespace”. Four questions get you from the line in the report to a decision:

  1. Which check fired, and in which profile? IDs move between profiles. The same control is 4.2.2 in the EKS profile, which is one more reason to pin.
  2. What did kube-bench run to decide? For this check, a kubectl loop over every pod in every namespace that reads .spec.hostPID. It reports pods that share the host PID namespace today, and never asks whether a policy would stop the next one.
  3. Is this a real gap, an intentional exception, or the platform’s business?
  4. What is the narrowest fix that changes the risk, not just the score?

The first answer matters more than it looks. The vanilla profile treats this check as unscored, so a failure prints as WARN. The EKS profile treats it as automated and scored, so the same pods print as FAIL. The headline changes with the profile; the risk does not.

The benchmark’s remediation text is “Add policies to each namespace in the cluster which has user workloads to restrict the admission of hostPID containers”, and on a current cluster the policy in question is a Pod Security Admission label. The baseline level already forbids host namespaces, so you do not need restricted to clear this finding. Ask the API server what would break before you enforce anything:

kubectl label --dry-run=server --overwrite ns --all \
  pod-security.kubernetes.io/enforce=baseline
Enter fullscreen mode Exit fullscreen mode

A server-side dry run changes nothing, and it prints a warning for every namespace with pods that would violate the level (any baseline violation, so host networking and hostPath volumes show up too). The namespaces it names are your candidate exceptions: usually a node exporter, a logging or security agent, and kube-bench itself. From there the fix is a split model:

  • keep most namespaces on baseline or restricted
  • carve out one clearly named namespace for infrastructure that needs host access
  • write down why that namespace exists and who can deploy into it
  • re-run only the relevant check, so you know you changed the right thing
kube-bench run --targets policies --check 5.2.3
Enter fullscreen mode Exit fullscreen mode

Expect the check to stay at WARN, because the pods in your exception namespace still have hostPID: true. That is correct. What has changed is that you can now explain every pod behind it, and an explained exception is what an auditor is looking for.

Managed clusters: part of the benchmark is out of reach

The kube-bench docs are direct about this: “It is impossible to inspect the master nodes of managed clusters, e.g. GKE, EKS, AKS and ACK”. That sentence should change how you describe the result. On a managed control plane, you are auditing:

  • the worker nodes you can reach
  • the policy and workload checks that are visible from inside the cluster
  • whatever managed-service profile kube-bench ships for your platform

The project ships separate manifests such as job-eks.yaml and job-gke.yaml for exactly this reason. Running kube-bench on EKS, for example, you cannot schedule a pod onto the control-plane nodes, so the master checks are simply unavailable.

Your compliance story on a managed platform is therefore partly technical and partly contractual. You verify what you can, and for the control plane you lean on the provider’s documentation, attestations and shared-responsibility model. Say so in the report: “out of our reach, covered by the provider’s attestation” is accurate, and easier to defend than a suspiciously complete set of green ticks.

Kubescape for the part that keeps running

Kubescape is a CNCF project and it now covers a lot more than compliance: misconfiguration scanning, image scanning, admission control and runtime detection all live under the same name. The slice that matters here is narrower: the API-side posture layer that sits next to kube-bench.

The CLI is straightforward:

kubescape list frameworks
kubescape scan framework nsa
Enter fullscreen mode Exit fullscreen mode

Run them in that order. The CIS framework IDs have the benchmark revision baked into the name, so an ID copied from an old tutorial may no longer exist in the version you installed. nsa and mitre are stable names; for CIS, list first and use what is actually there.

For CI or a recurring check, the threshold flag is the practical one:

kubescape scan framework nsa --compliance-threshold 80
Enter fullscreen mode Exit fullscreen mode

Below the score you set, the command exits with code 1. That is usually enough to stop “we should probably look at this later” from becoming a permanent state. Pick the number from a real baseline run, though. A threshold the cluster can’t currently meet tends to get switched off rather than fixed.

Kubescape vs kube-bench: which question goes where

Reach for kube-bench when the question is:

  • are kubelet and control-plane settings aligned with the benchmark?
  • do file permissions and host-level configuration match the guidance?

Reach for Kubescape when the question is:

  • what is the posture of the workloads and resources in this cluster right now?
  • can I run this in CI against manifests, before anything reaches a cluster?

The numbers won’t line up, because the two tools count different things. Where they do overlap, use it as a cross-check: if both are unhappy about the same class of policy gap, that finding goes to the top of the pile.

The in-cluster path is where Kubescape starts paying for itself

The Kubescape operator is the natural next step once you are past one-off CLI scans, and the install is refreshingly ordinary:

helm repo add kubescape https://kubescape.github.io/helm-charts/
helm upgrade --install kubescape kubescape/kubescape-operator \
  --namespace kubescape \
  --create-namespace
Enter fullscreen mode Exit fullscreen mode

Two details in the chart’s values are easy to miss. Out of the box, configuration scans run on a schedule; the change-driven rescans that most people picture when they hear “continuous” sit behind capabilities.continuousScan, which is disabled by default. And the operator’s node agent mounts the host filesystem read-only, so the kubescape namespace needs the same Pod Security exception as kube-bench. Your one documented exceptions namespace becomes two, which is still a list short enough to defend.

A minimal recurring setup, and where to start

If you want the smallest loop that is still worth having, I would keep it to this:

  • kube-bench on a schedule, as a CronJob with the profile pinned, for the host-level checks only it can see
  • Kubescape in CI or in-cluster for the API-side posture checks
  • one threshold that fails something real, instead of a dashboard with no owner
  • one exception path for justified infrastructure privileges, documented and reviewed

As for where to start: run the kube-bench Job once, and before you fix anything, sort every FAIL and WARN into three piles: real fixes, documented exceptions, and the provider’s side of the line. That sorted list is a more useful deliverable than a score, and it is what the recurring scans then keep current.

Top comments (0)