Skip to main content

Kube-bench

kube-bench checks a cluster against the CIS Kubernetes Benchmark. On this layout it runs as two CronJobs in namespace kube-bench, reconciled by Argo CD from public homelabs manifests. Do not kubectl apply the Jobs from a workstation as the lasting install. The scanner is privileged and mounts host paths under /var/lib/rancher and /etc/rancher so it can see k3s files.

The pin is image docker.io/aquasec/kube-bench:v0.16.0 and benchmark k3s-cis-1.9. That profile is the k3s-specific CIS 1.9 set (control plane, etcd, kubelet, and policies). Vanilla cis-1.11 is the wrong target on k3s. A new major image tag is a deliberate re-pin.

GitOps layout
#

PieceWhere
Applicationhomelabs-private/clusters/<cluster>/argocd/applications/kube-bench.yaml
Manifestshomelabs/kube-bench/
Dest nskube-bench
Sync-wave0

The Application is a single source: public git path kube-bench (Kustomize). There is no Helm chart and no private overlay in v1. Automated prune and selfHeal are on. App-of-apps discovers the Application under clusters/<cluster>/argocd/applications/.

Allow destination namespace kube-bench on the homelab AppProject. ClusterRole and ClusterRoleBinding for the scanner are already on the project whitelist. The AppProject is kubectl-managed, not app-of-apps. Commit the YAML, then apply the live project before you expect Argo to accept the Application:

kubectl apply -f homelabs-private/argocd/projects/homelab.yaml

Merge the public homelabs path to main before the private Application points at it.

The namespace is labeled for Pod Security privileged because the pods use hostPID and hostPath. Kyverno webhook selectors exclude kube-bench so a Kyverno outage cannot block the CronJobs. Baseline policies stay Audit on other namespaces; that exclude is for Fail-closed admission, not for hiding the scanner.

What runs
#

Two CronJobs share schedule 0 1 * * 0 (Sunday 01:00 UTC), concurrencyPolicy: Forbid, and keep three successful and three failed Jobs.

kube-bench-controlplane lands on a control-plane node (tolerates the master taint) and runs all k3s-cis-1.9 targets. kube-bench-node lands on a worker and runs --targets node only. Results live in the Job pod logs. Finished Jobs expire after a day (ttlSecondsAfterFinished: 86400).

The first GitOps sync creates the CronJobs. It does not start a scan until the next schedule. To run once after sync:

kubectl -n kube-bench create job kube-bench-controlplane-manual --from=cronjob/kube-bench-controlplane
kubectl -n kube-bench create job kube-bench-node-manual --from=cronjob/kube-bench-node

Wait until both Jobs are Complete, then read the reports:

kubectl -n kube-bench get cronjobs,jobs,pods
kubectl -n kube-bench logs job/kube-bench-controlplane-manual
kubectl -n kube-bench logs job/kube-bench-node-manual

Replace the Job names with the latest CronJob-spawned names after a scheduled run (kube-bench-controlplane-<timestamp>).

Reading the report
#

Each log ends with per-section and total counts of PASS, FAIL, WARN, and INFO. WARN is mostly manual CIS controls. INFO is often a check the k3s profile skips because the component does not exist as a kubeadm static pod.

Many control-plane API server checks (section 1.2 and similar) audit via host journalctl -u k3s. The Aqua image is Alpine. Mounting host /usr/bin at /usr/local/mount-from-host/bin puts the host journalctl on PATH, and that binary fails inside the container (libsystemd-shared missing). Those FAILs are scanner false negatives when Aqua’s own remediation text says k3s already sets the secure default. File-permission checks and RBAC/policy checks that talk to the API do not use journalctl and are worth treating as real.

Do not publish raw scan logs or node names on the public docs site. Keep the report in the cluster (or a private issue).

Related#