Skip to content
Stribog

Security

All writing

The CIS Kubernetes Benchmark as Evidence, Not a Checklist

The CIS Kubernetes Benchmark is five control files, not one list, and which of them a kube-bench run reaches is decided before any check executes.

Stribog19 min read

Search the phrase and you get installation walkthroughs that stop at the first coloured terminal output. That part is easy. The hard part is that the report is a claim about a specific benchmark revision, a specific node and a specific set of API permissions — none of which appear in the output unless you put them there.

What Is Actually Inside the Benchmark

Start with the version, because almost every post gets it wrong. CIS's public index lists the Kubernetes Benchmark at 2.0.1, alongside per-platform benchmarks currently at 2.0.0 for EKS, AKS and GKE. "CIS 1.x" is not the current revision of anything, and the platform-neutral document is not the only one CIS publishes.

What your tool ships is a different question. kube-bench's supported-platform table maps CIS benchmark 2.0 to a config directory named cis-2.0, scoped to Kubernetes 1.34–1.35. That directory landed on main in commit 9f133cb7 on 2026-08-18 — after the v0.16.0 release of 2026-08-05 — so no published kube-bench image contains it yet. Everything counted below is kube-bench's transcription at that commit, not the CIS document.

Counted that way, cfg/cis-2.0 holds 131 checks in five files: 60 in master.yaml (section 1), 7 in etcd.yaml (2), 5 in controlplane.yaml (3), 25 in node.yaml (4, worker node) and 34 in policies.yaml (5). That puts 72 of the 131 in sections 1 to 3 — the part a managed platform does not hand over.

The more useful split is by scoring, because it decides what can ever be reported as a failure. The scored checks number 47, 6, 0, 12 and 0 respectively: sections 3 and 5 are entirely scored: false, so nothing in either becomes a FAIL whatever your cluster looks like.

bash
# Count kube-bench's cis-2.0 control files directly. The figures in this
# article come from this command at commit 9f133cb7 (2026-08-18).
git clone https://github.com/aquasecurity/kube-bench.git
cd kube-bench && git checkout 9f133cb7 && cd cfg/cis-2.0

for f in master.yaml etcd.yaml controlplane.yaml node.yaml policies.yaml; do
  total=$(grep -cE '^      - id: ' "$f")
  unscored=$(grep -cE '^        scored: false$' "$f")
  manual=$(grep -cE '^        type: *"?manual"?$' "$f")
  titled=$(grep -cE '^        text: .*\(Manual\)"?$' "$f")
  printf '%-18s total=%-4s unscored=%-4s manual=%-4s titled_Manual=%s\n' \
    "$f" "$total" "$unscored" "$manual" "$titled"
done

# master.yaml        total=60   unscored=13   manual=1    titled_Manual=13
# etcd.yaml          total=7    unscored=1    manual=0    titled_Manual=1
# controlplane.yaml  total=5    unscored=5    manual=4    titled_Manual=5
# node.yaml          total=25   unscored=13   manual=0    titled_Manual=13
# policies.yaml      total=34   unscored=34   manual=23   titled_Manual=34
Do not take any of these numbers on trust — they are one command against whatever tree your own image was built from. Checks sit at six spaces of indent in kube-bench's control files; the two-space `- id:` entries are groups, not checks. Run it against the tree your image was built from, not a tag you assume matches it.
The three gates run in that order, and each can silence whole sections before a single check executes. A summary line reading 47 PASS is meaningless until you know which of the five files the run even loaded. Every count here is off kube-bench's YAML rather than the CIS document: the benchmark PDF sits behind a registration wall, and its wording is the authority if the two ever disagree.

Automated, Manual, and What kube-bench Does With Each

The most expensive misreading of a kube-bench report is treating WARN as one thing. In check/check.go it is three: type: manual is forced to WARN *before any audit runs*; a scored check with no tests is WARN with the reason "There are no tests"; and an executed failure is WARN when it carries scored: false, FAIL being reserved for a scored one. (type: skip gives a fourth state, INFO.) So the constant's own source comment, "could not carry out check", describes only some WARNs; the rest are real failures in an unscored check.

Here is the whole partition rather than an example. Of the 66 unscored checks, 28 are declared manual and never execute — 23 in policies.yaml, 4 in controlplane.yaml, master.yaml's 1.2.20 — while 38 carry an audit and tests and do execute: 12 in master.yaml, 2.7 in etcd.yaml, 3.2.1 in controlplane.yaml, 13 of node.yaml's 25, and 11 in policies.yaml (5.1.1–5.1.6, 5.2.2–5.2.6).

The trap has a name: CIS's (Manual) assessment status and kube-bench's type: "manual" are different sets. Check 5.2.2, "Minimize the admission of privileged containers (Manual)", ships a real kubectl audit that runs; so does 4.2.13, on pod PIDs, which audits the kubelet with /bin/ps. Both are unscored, so both return PASS or WARN and never FAIL.

Check 3.2.1, on a minimal audit policy, shows why an unscored PASS is not reassurance: its test looks for --audit-policy-file on the API server process, so a PASS proves a file was passed and nothing about whether the policy inside it records anything useful.

Running It So the Output Is an Artefact, Not a Screenshot

Three things have to be pinned for the JSON to still mean something in eleven months: the tool image by tag, the benchmark id that image actually contains, and the cluster and node identity. Auto-detection is the enemy of all three, because it resolves differently as the cluster moves under it.

The image pin needs care. cfg/ at tag v0.16.0 holds 48 directories against main's 50, and the difference is exactly two: cis-2.0 and k3s-cis-1.10. Running cis-2.0 therefore means building from main at or after 9f133cb7. On the released image the newest platform-neutral directory is cis-1.12 — the right pin for a 1.32-or-later cluster, the wrong one for an older minor whose version_mapping row in that image points at an earlier cis-1.x. That tag does ship gke-1.9.0, eks-1.8.0 and aks-1.8, so a managed-platform reader can name their id without building.

Then there is the RBAC nobody ships. Upstream's job.yaml declares no serviceAccountName and carries no Role or ClusterRole at all. The pod runs as its namespace's default service account, so the eleven executed section 5 audits get whatever that account can read cluster-wide, which is nothing.

Read the audits and the verbs fall out: get and list on pods, serviceaccounts, roles and clusterroles; list on clusterrolebindings. Checks 5.1.2 and 5.1.4 also run kubectl auth can-i ... --as=system:authenticated, and theirs is the non-obvious requirement. kubectl auth can-i does not create a SubjectAccessReview — it builds a SelfSubjectAccessReview, and the bootstrap system:basic-user role already grants create on that to system:authenticated on every cluster. What is missing is impersonate on the core resource users, narrowed with resourceNames to system:authenticated. That legacy rule is the one these audits match; constrained impersonation adds a separate users/user-info path tried first, so do not let a rewrite drop it. Two omissions below are deliberate: rolebindings, which no executed audit reads, and create on subjectaccessreviews. Dry-run the role — it is read off the audit commands, not upstream's recommendation.

yaml
# kube-bench collector. Nothing here is auto-detected.
#
# --benchmark cis-1.12 is the newest platform-neutral CIS directory the
# v0.16.0 image contains, and the correct pin on that image for a 1.32-or-later
# cluster. Two consequences to record in the artefact:
#   1. It is platform-neutral. Because getBenchmarkVersion consults a non-empty
#      --benchmark before platform detection, this flag also opts the run out of
#      GKE and EKS auto-selection. A GKE or EKS reader must NOT file cis-1.12 as
#      their provider benchmark.
#   2. cis-2.0 is not in this image (nor is k3s-cis-1.10). Running it needs an
#      image built from a main commit at or after 9f133cb7.
# This manifest also grants no read on nodes, so IsAKS() cannot succeed, the
# -aks1 suffix is never appended, and aks-1.7 / aks-1.8 would have to be named.
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: kube-bench
  namespace: cis-benchmark
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: kube-bench-policies-reader
rules:
  # 5.1.1, 5.1.3, 5.1.5, 5.1.6, 5.2.2-5.2.6: list then get, cluster-wide.
  - apiGroups: [""]
    resources: ["pods", "serviceaccounts"]
    verbs: ["get", "list"]
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["roles", "clusterroles"]
    verbs: ["get", "list"]
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["clusterrolebindings"]
    verbs: ["list"]
  # 5.1.2 and 5.1.4 run: kubectl auth can-i ... --as=system:authenticated
  # That builds a SelfSubjectAccessReview, which system:basic-user already lets
  # system:authenticated create. The missing permission is impersonation, and it
  # is narrowed to the single identity those two audits name.
  - apiGroups: [""]
    resources: ["users"]
    verbs: ["impersonate"]
    resourceNames: ["system:authenticated"]
  # Deliberately absent: rolebindings (no executed audit reads them) and
  # create on subjectaccessreviews (not the permission these checks lack).
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: kube-bench-policies-reader
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: kube-bench-policies-reader
subjects:
  - kind: ServiceAccount
    name: kube-bench
    namespace: cis-benchmark
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: kube-bench-node
  namespace: cis-benchmark
spec:
  schedule: "17 3 * * 1"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          serviceAccountName: kube-bench
          restartPolicy: Never
          # hostPID is what lets the section 4 audits see the kubelet's own
          # process line via ps - without it those checks go blind. isMaster()
          # is not in play here: run --targets loads the named files directly
          # and never calls it. It is also exactly what check 5.2.3 flags on
          # this pod - see the failure-modes section.
          hostPID: true
          containers:
            - name: kube-bench
              image: docker.io/aquasec/kube-bench:v0.16.0
              command: ["kube-bench", "run"]
              args:
                - --targets=node,policies
                - --benchmark=cis-1.12
                - --json
                - --outputfile=/evidence/$(NODE_NAME)-$(RUN_ID).json
              env:
                - name: NODE_NAME
                  valueFrom:
                    fieldRef:
                      fieldPath: spec.nodeName
                - name: RUN_ID
                  value: "REPLACED_BY_YOUR_SCHEDULER"
              volumeMounts:
                - name: evidence
                  mountPath: /evidence
                - name: var-lib-kubelet
                  mountPath: /var/lib/kubelet
                  readOnly: true
                - name: etc-kubernetes
                  mountPath: /etc/kubernetes
                  readOnly: true
                - name: etc-systemd
                  mountPath: /etc/systemd
                  readOnly: true
          volumes:
            - name: evidence
              persistentVolumeClaim:
                claimName: cis-evidence
            - name: var-lib-kubelet
              hostPath:
                path: /var/lib/kubelet
            - name: etc-kubernetes
              hostPath:
                path: /etc/kubernetes
            - name: etc-systemd
              hostPath:
                path: /etc/systemd
Everything named explicitly: the image by tag, the benchmark id that tag contains, and the verbs the section 5 audits actually issue. Note `kube-bench run`, not bare `kube-bench` — `--targets` is registered on the `run` subcommand, so appending it to the root command upstream's job.yaml invokes is an unknown flag. That subcommand also loads the named files directly and never calls isMaster(), so a `--targets master` line copied off a forum will happily execute master.yaml on a worker, against config files the node does not have.

One flag is absent on purpose. --exit-code defaults to 0, and even when set it is returned only if some control's Fail count exceeds zero — and only state FAIL increments Fail. A run of nothing but PASS and WARN exits 0. Gate a pipeline on the exit code and you have gated it on 65 of 131 checks.

What a Managed Platform Hides, and What It Gives You Instead

Selection comes first, and it is not one mechanism. With neither --benchmark nor --version passed, getBenchmarkVersion asks platform detection before it consults version_mapping: getPlatformInfoFromVersion regexps the API server's GitVersion, so an in-cluster v1.33.4-gke.1 yields gke and v1.32.6-eks-4c6976 yields eks, and cmd/util.go picks the id per Kubernetes minor — gke-1.9.0 for 1.31–1.34, eks-1.8.0 for 1.32–1.34 with eks-1.5.0 as EKS's default. A suffix-free v1.33.4 parses to an empty Platform and falls through to the version walk.

AKS gets there differently. Its API server reports a plain GitVersion, so kube-bench synthesizes the suffix — appending -aks1 in getKubeVersion only on the in-cluster REST path, and only after IsAKS() lists a node and matches the label kubernetes.azure.com/cluster or an azure:// providerID. Any list error returns false, and neither upstream job.yaml nor the ClusterRole above grants a nodes list — so a bare Job on AKS resolves by minor, never to aks-1.7 or aks-1.8.

kube-bench's own platform manifests bypass the selector by pinning the id, and they do not select the same sections: job-gke.yaml pins gke-1.2.0 with --targets node,policies,managedservices, job-eks.yaml pins eks-1.5.0 and adds controlplane, job-aks.yaml pins aks-1.7 with --targets node alone. docs/platforms.md is staler still and is not the inventory. One property matters more than the version lag: every platform config a current cluster lands on lists no etcd target, so validTargets rejects etcd before isEtcd() runs.

Placement comes second, and it is a property of the pod, not the topology. isMaster() fails closed — getBinaries errors on the first non-optional component whose binary is absent, and cfg/config.yaml leaves only etcd, flanneld and kubelet optional on the master type, so apiserver, scheduler and controllermanager must all be found. Miss one and sections 1 and 3 are skipped together: 65 of 131 checks gone on one detection failure. Section 2 is gated outside that branch, on target_mapping listing etcd *and* isEtcd() finding a running etcd process here — so external etcd, as under an immutable control-plane OS like Talos, is visible from no Kubernetes node at all. Section 4's audits are local stat, ps and cat calls, so its result describes one node.

And the section a --targets node,policies run drops is not inert everywhere. On gke-1.9.0, managedservices.yaml holds 33 checks, 30 of them manual — but three are executed *and scored*: 5.1.1 and 5.2.1, which shell out to gcloud, and 5.6.7. Being scored, an environment failure lands as FAIL rather than as a benchmark result — and on the stock image, which carries no gcloud, both FAIL by different routes:

  • 5.2.1 fails on the audit. It exits 1 with MISSING_REQUIRED_VARIABLES unless PROJECT_ID, CLUSTER_NAME and LOCATION are set, and with FAILED_NODE_POOL when they are but the lookup comes back empty. check.go runs test_items only when runAuditCommands returned no error, so nothing is evaluated: the evidence lands in reason and actual_value stays empty.
  • 5.1.1 fails on the test. Its if/else audit echoes disabled and exits 0, so the audit *succeeds* and the test runs. Its one test_item is a bare flag: enabled; set defaults to true, there is no compare, and findValue matches by strings.Contains — disabled does not contain enabled. Scored, so FAIL.

On eks-1.8.0 and aks-1.8 that same file is type: "manual" throughout, so dropping it costs only did-not-run WARNs. What they give instead is documentation you can cite. Google states that the CIS Kubernetes Benchmark "contains many recommendations for controls that you can't view or modify in GKE" and that you "can't evaluate the managed control plane against those recommendations", while leaving worker-node and workload controls in your scope. Microsoft publishes an attestation rather than a scan — CIS Kubernetes Benchmark v1.12.0, for AKS 1.32.x through 1.34.x — a different revision from the cis-2.0 files counted above, so do not lay the two side by side. Which leaves a choice rather than a gap: the provider benchmark, the attestation as an inherited control, or a control plane you can evaluate yourself. 72 of 131 checks sit in sections 1–3; five are type: manual and never execute — 1.2.20 plus four in section 3 — leaving 67 that run only where the control-plane binaries or a local etcd process actually are.

Writing the Deviation Record for a Failure You Accept

A run with no accepted failures is a fiction, and one with undocumented ones is worse than none — an argument waiting for an assessment. NIST SP 800-53r5's CM-6 tells you what to write: part a asks you to establish and document configuration settings reflecting "the most restrictive mode consistent with operational requirements", part c to "identify, document, and approve any deviations" from them.

Its discussion names what a CIS Benchmark is in that scheme: common secure configurations, "also known as security configuration checklists, lockdown and hardening guides". The benchmark is the baseline, the run verifies it, the register carries the exceptions. CM-6(1) covers the automation — "manage, apply, and verify configuration settings ... using automated mechanisms" — and a scheduled run automates the *verify* leg only; applying the settings stays in your policy-as-code layer.

A record is samplable when it answers the scope questions without anyone reconstructing them: which check, against which benchmark revision, run by which tool image, over which clusters and node pools, mitigated by what, approved by whom, reviewed when. The same structure carries into an Annex A statement of applicability or a SOC 2 evidence set unchanged.

yaml
# deviations/cis-2.0-4.2.6.yaml
deviation_id: CIS-DEV-2026-014
control: CM-6c
status: approved

finding:
  check_id: "4.2.6"
  check_title: "Ensure that the --make-iptables-util-chains argument is set to true"
  result_state: FAIL
  scored: true
  benchmark_id: cis-2.0
  benchmark_source: "kube-bench cfg/cis-2.0 at commit 9f133cb7"
  cis_published_revision: "2.0.1 (per CIS public index; PDF not diffed)"
  tool_image: "ghcr.io/example/kube-bench:main-9f133cb7"

scope:
  clusters: ["prod-eu-1"]
  node_pools: ["ingress-a", "ingress-b"]
  # Section 4 describes ONE node. This deviation covers the pools whose runs
  # reproduced the finding, and no others.
  evidence_runs:
    - "s3://cis-evidence/prod-eu-1/ingress-a-20260922T0317Z.json"
    - "s3://cis-evidence/prod-eu-1/ingress-b-20260922T0317Z.json"

rationale: >-
  The CNI on these pools manages its own iptables chains and the kubelet flag
  conflicts with it. Enabling the flag caused packet loss on node restart.

compensating_control:
  description: >-
    Chain state is asserted by the CNI's own reconciler and verified by a
    scheduled connectivity probe across every namespace pair expected to be
    denied.
  verification_artefact: "netpol-probe/prod-eu-1/weekly.json"

approval:
  approver: "Head of Platform Engineering"
  approved_on: "2026-09-08"
  next_review: "2027-03-08"
  review_trigger: "CNI major upgrade, or a change of benchmark revision"
One record per accepted failure. The fields are chosen so that nothing about scope has to be reconstructed later — the benchmark revision and image tag are part of the claim, not metadata.

Schedule and Retention: The Records Schedule for the Artefact

Cadence should follow change, not the audit calendar. A run the week before an assessment proves that week's state and nothing about the other eleven months. Tie runs to the events that move the answer — a minor upgrade, a node-image roll, a change to admission policy or the CNI, a new node pool — with a weekly or monthly floor so the record stays continuous where nothing changed. And one run per node pool: section 4 describes the node the pod landed on, so one Job against a heterogeneous cluster has no scope you can put in a register.

Retention needs a framing argument, and a wrong one is in circulation. AU-11 is about audit records retained "to provide support for after-the-fact investigations of incidents". A kube-bench JSON file is not an audit record but a configuration-assessment artefact, so frame the period as an organisation-defined records schedule under the CM-6 family that produced it — and note CM-6(1) covers verification, not retention.

Tamper-evidence is cheap at write time and impossible retroactively: a SHA-256 beside the JSON under a stable name, both in a write-once store whose retention lock the operator cannot shorten. The same reasoning that governs an audit-log archive applies — an artefact you can silently rewrite is one whose date means nothing.

The fourth outcome is the one most summaries lose. A collector artefact routed into remediation wastes engineering time; routed into the register, it becomes a documented deviation for a finding that was never about the cluster.

Failure Modes That Quietly Void the Evidence

Five ways a green report lies, each leaving the output looking normal.

One: the collector's namespace gets quietly relaxed. kube-bench's Job needs hostPID: true and twelve read-only hostPath mounts, and the Pod Security Standards Baseline profile forbids *both* — dropping hostPID alone would not make it compliant. Scope the relaxation to one namespace, leave audit and warn stricter so it still leaves a trail, and record the label as a deviation. It is not an exemption: those are enumerated statically in the admission controller configuration, and a matching request is ignored — enforce, audit and warn skipped.

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: cis-benchmark
  labels:
    # The collector needs hostPID and hostPath mounts, which Baseline forbids
    # on two separate controls. This relaxation is scoped to this namespace and
    # is itself recorded as a CM-6c deviation.
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/enforce-version: latest
    # Left strict on purpose: audit and warn still fire, so anything else that
    # lands here shows up in the audit log and on kubectl's stderr.
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
A scoped policy level, not an exemption. `enforce: privileged` admits the collector; `audit` and `warn` at `restricted` mean every violating pod in this namespace still produces an annotation and a warning, so the relaxation is visible rather than silent.

Two: an authorization failure filed as a benchmark result. True of a *list* denial specifically — the Forbidden text lands in the output with no is_compliant token, and an unscored failure is WARN. A get denial after a successful list is worse: 5.2.3, 5.2.4 and 5.2.5 send the per-pod get to /dev/null, so the denial becomes an empty value and the check passes. Those three mark is_compliant false for any non-empty value, so hostPID: false does not pass 5.2.3.

Three: auto-detection resolving a minor the mapping does not list. mapToBenchmarkVersion decrements the minor until it finds a version_mapping key, erroring only if the walk passes defaultKubeVersion 1.18 — which current minors never do. On main the table ends at 1.35, so 1.36 and 1.37 walk down to cis-2.0; on the v0.16.0 image it ends at 1.34, so 1.35 through 1.37 land on cis-1.12. Neither is an error, and neither is announced.

Four: coverage that was never there. One Job covers one node, --skip removes ids from the output entirely, and --targets narrows it on purpose — all legitimate, and all of which have to be stated, or the report's silence reads as a pass.

Five: the scanner reporting itself. 5.2.3 and 5.1.6 both carry use_multiple_values, so one non-compliant pod fails the whole check — and the collector fails both as upstream ships it. They are not equally stuck, and treating them as one problem is how a real finding gets dismissed alongside an artefact:

  • 5.2.3 cannot be remediated while section 4 needs hostPID. It passes only when .spec.hostPID is empty. Section 4's ps audits go blind without it. isMaster() is the root command only; this manifest's run --targets never calls it. Separate the collector namespace in the summary.
  • 5.1.6 is fixable, but not with the two fields alone. The audit reads both automountServiceAccountToken values and no volumes. It passes only in two cases: the ServiceAccount is false and the pod is false or unset, or the ServiceAccount is true and the pod is false. false on the ServiceAccount or the pod stops the automatic mount of /var/run/secrets/kubernetes.io/serviceaccount/token, which is what in-cluster kubectl reads — fields alone, and every section 5 audit dies. Set both false and mount a projected serviceAccountToken at /var/run/secrets/kubernetes.io/serviceaccount (token, kube-root-ca.crt's ca.crt, downward-API namespace). The audit never looks at volumes.
bash
#!/usr/bin/env bash
# cis-summary.sh <kube-bench.json> <collector-namespace>
# Splits a run four ways and exits non-zero on anything kube-bench's own
# --exit-code will not surface.
set -euo pipefail

report="${1:?usage: cis-summary.sh <report.json> <collector-namespace>}"
collector_ns="${2:-cis-benchmark}"

jq --arg ns "$collector_ns" '
  # Tokens that mean "the collector could not ask", not "the cluster is wrong".
  # Deliberately NOT a bare "not found": node.yaml 4.1.2 echoes "File not found"
  # and PASSes on it (bin_op: or, and that literal is the second test_item), so
  # the wide version files a clean run as an artefact and fails the gate below.
  def env_failure:
    (.reason // "") + " " + (.actual_value // "")
    | test("Error from server \\(Forbidden\\)|cannot list resource|ERROR_KUBECTL_LIST|MISSING_REQUIRED_VARIABLES|FAILED_NODE_POOL|command not found|: not found");

  # A use_multiple_values check reports every pod in one actual_value, so the
  # namespace appearing somewhere inside it proves nothing. Collector-only means
  # EVERY non-compliant line is the collector: 5.2.x prints "pod_namespace: X"
  # and 5.1.6 prints "namespace: X", both covered by the optional prefix.
  def bad_lines:
    (.actual_value // "") | split("\n") | map(select(test("is_compliant: false")));
  def is_collector_only:
    bad_lines as $bad
    | ($bad | length) > 0
      and all($bad[]; test("(^|[ *])(pod_)?namespace: " + $ns + "($| )"));

  [ .Controls[] | .id as $bench | .tests[] | .results[]
    | { benchmark: $bench, id: .test_number, state: .status,
        scored: .scored, reason: (.reason // ""),
        actual: (.actual_value // ""),
        collector_only: is_collector_only,
        env: env_failure } ] as $rows
  | {
      # 1. Real failures, scored. The only state --exit-code reacts to.
      scored_fail: [ $rows[]
        | select(.state == "FAIL" and .env == false and .collector_only == false) ],

      # 2. Ran, failed, unscored. Real results that never become FAIL.
      unscored_fail: [ $rows[]
        | select(.state == "WARN" and .scored == false)
        | select(.env == false and .collector_only == false)
        | select(.reason | test("manual test|no tests") | not) ],

      # 3. Never executed. For the deviation register, not for triage.
      never_ran: [ $rows[]
        | select(.state == "WARN")
        | select(.reason | test("manual test|no tests")) ],

      # 4. Collector artefacts. Fix the collector, not the cluster.
      #    A PASS is never an artefact, whatever its audit text happens to say.
      collector: [ $rows[]
        | select(.state != "PASS" and (.env or .collector_only)) ]
    }
  | . + { counts: (to_entries | map({ (.key): (.value | length) }) | add) }
' "$report" | tee /tmp/cis-summary.json | jq '.counts'

# Gate on the states, never on kube-bench's exit code.
jq -e '
  (.scored_fail | length) == 0
  and (.unscored_fail | length) == 0
  and (.collector | length) == 0
' /tmp/cis-summary.json > /dev/null || {
  echo "cis-summary: run is not clean - see /tmp/cis-summary.json" >&2
  exit 1
}
All five failure modes argue for one thing: a summary that never files a collector failure as a cluster finding, and never trusts the exit code. The Forbidden match reads both `reason` and `actual_value`, because `AuditOutput` is tagged `json:"-"` and never reaches the report — a non-zero audit stores the error in `reason`, while a `kubectl` pipeline into `while` without `pipefail` usually exits 0 and leaves the text in `actual_value`. Both matchers are narrower than the obvious version, for reasons the comments give.

Exit Ramps and the Decade View

Everything above is tool-shaped, and the tool will not last as long as the obligation. kube-bench's JSON is kube-bench's schema, the benchmark renumbers — cis-1.x became cis-2.0 in one commit — and the same runner already ships a DISA STIG profile, scoped to EKS rather than distribution-neutral. So the derived summary, not the raw output, is the record you retain: a neutral shape keyed by benchmark id, check id, state, scope and run timestamp, with the JSON beside it. Swap the scanner and the register keeps resolving; keep only the vendor's format and every historical artefact depends on a parser you no longer run.

The same test applies to any compliance platform offering to hold the evidence. If the contract ended tomorrow, what comes out — a signed archive of standard files, or a PDF while the artefacts stay in someone else's schema? The second is where your evidence now lives — the network-policy problem in a different costume: a control you cannot inspect is one you assert, not verify.

The decade framing is not sentimental. Revisions renumber checks, providers change what they expose, tools get replaced. What stays stable is the register — a dated list of deviations with names against them, each tied to a run you can still verify. That answers what an assessor is actually asking, which is not "are you hardened" but "do you know what you chose, and when". Mapped to a control family, it is one answer for CM-6, Annex A and a Trust Services criterion alike.

§FAQ/Common questions

Frequently asked

How many checks are in the CIS Kubernetes Benchmark, and how many does kube-bench actually run?

Counting kube-bench's cis-2.0 config as it stands on main at commit 9f133cb7 (2026-08-18), there are 131 checks across five control files: 60 in master.yaml, 7 in etcd.yaml, 5 in controlplane.yaml, 25 in node.yaml and 34 in policies.yaml. 66 carry scored: false, and 28 of those are declared type: manual and never execute at all — so 103 of the 131 execute, of which 65 are scored and can be reported FAIL. Those figures describe kube-bench's transcription at that commit, not the CIS Benchmark PDF, and not the v0.16.0 release image, which ships no cfg/cis-2.0 directory. Re-run the count against the tree your own image was built from.

Why does kube-bench report WARN instead of FAIL, and does a WARN mean the check did not run?

Not necessarily — WARN is three different outcomes in check/check.go. A check declared type: manual is forced to WARN before any audit runs. A scored check with no tests is WARN with the reason "There are no tests". And a check that genuinely ran and failed its test is WARN whenever it carries scored: false; FAIL is reserved for a scored check that ran and failed. In cis-2.0, 38 of the 66 unscored checks carry an audit and tests and do execute, including 13 of section 4's 25 — so on a managed cluster, where section 4 is often all you get, a WARN is more likely to be a real kubelet finding than a did-not-run. Check the reason field to tell them apart.

Can you run the CIS Kubernetes Benchmark against GKE, EKS or AKS?

Partly, and the mechanism differs by provider. Google states that the CIS Kubernetes Benchmark contains many recommendations for controls you cannot view or modify in GKE, and that you cannot evaluate the managed control plane against them — while leaving worker-node and workload controls in your scope. With neither --benchmark nor --version passed, kube-bench resolves the benchmark from platform detection before consulting version_mapping: an in-cluster API server GitVersion carrying a gke or eks suffix diverts the run to a platform config picked per Kubernetes minor. AKS is different — its API server reports no suffix, so kube-bench appends -aks1 itself, but only on the in-cluster REST path and only after IsAKS() successfully lists a node. A collector with no nodes list permission therefore never selects aks-1.7 or aks-1.8 unless --benchmark names it. Note also that the five platform configs a current cluster selects carry no etcd target at all.

What RBAC does kube-bench need for the section 5 policy checks?

Upstream job.yaml declares no serviceAccountName and ships no RBAC, so the pod runs as its namespace's default service account and the eleven executed section 5 audits can read nothing cluster-wide. Reading those audits, the verbs they issue are get and list on pods, serviceaccounts, roles and clusterroles, plus list on clusterrolebindings. Checks 5.1.2 and 5.1.4 additionally run kubectl auth can-i --as=system:authenticated, and the permission they need is impersonate on the core resource users, narrowed with resourceNames to system:authenticated. It is not create on subjectaccessreviews: kubectl auth can-i builds a SelfSubjectAccessReview, and Kubernetes' bootstrap system:basic-user role already grants create on that to the system:authenticated group on every cluster. That role set is derived from the audit commands rather than published upstream, so dry-run it before relying on it.

Why does kube-bench skip the control-plane checks on my node?

Because isMaster() fails closed. It is isThisNodeRunning(check.MASTER), and getBinaries errors on the first component that declares bins, is not marked optional: true, and whose binary is not on the node. In cfg/config.yaml the master node type marks etcd, flanneld and kubelet optional, so the binaries that must actually be found are apiserver, scheduler and controllermanager. Miss any one and sections 1 and 3 are skipped together, because both runs sit inside that branch. Section 2 is gated separately and outside it, on the selected benchmark's target_mapping listing etcd and on isEtcd() finding a running etcd process on this node — so external etcd is visible from no Kubernetes node at all. Running without hostPID has the same effect, since verifyBin() matches running processes via ps.

Which NIST control does a kube-bench run satisfy, and how long should the output be retained?

CM-6 is the fit. Part a asks you to establish and document configuration settings reflecting the most restrictive mode consistent with operational requirements, using organisation-defined common secure configurations — and the control's discussion names hardening guides and security configuration checklists as exactly that category, which is what a CIS Benchmark is. Part c asks you to identify, document and approve any deviations, which is the deviation register. CM-6(1) covers automating the verify leg. For retention, do not cite AU-11: that control is about audit records retained to support after-the-fact investigation of incidents, and a kube-bench JSON file is a configuration-assessment artefact rather than an audit record. Frame the period as an organisation-defined records schedule under the CM-6 family, and make the stored file tamper-evident with a checksum and a write-once store.

cis kubernetes benchmarkkube-bench self hosted clustercis benchmark kubernetes remediationkubernetes hardening guide control planecis kubernetes benchmark automated evidencekubernetes cis compliance report

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.