Back to Blog
KubernetesDevSecOpsComplianceSOC 2ISO 27001SecurityCIS BenchmarkKyverno

Kubernetes DevSecOps: How to Automate Compliance for SOC 2, ISO 27001 and CIS Benchmarks

Run a CIS scan with kube-bench, enforce Pod Security and Kyverno policies, and turn their reports into SOC 2 and ISO 27001 evidence, with a six-week plan.

P
Davi Nunes
March 19, 2026Updated October 30, 202611 min read

An auditor will not ask whether your Kubernetes cluster is secure. They will ask you to show that a control existed, that it operated throughout the audit period, and that every deviation was approved. A hardened cluster with no records fails that test.

This guide is about producing those records from the cluster itself. Securing workloads in production is covered in our Kubernetes DevSecOps guide. This one covers a CIS benchmark baseline you can run today, admission rules that write their own reports, exceptions with an owner and an expiry date, and an evidence store mapped to SOC 2 and ISO 27001.

What an Auditor Actually Samples

Whatever the framework, each cluster control has to produce three things:

  • A definition. The rule, written down and versioned: a policy file in Git, merged through review.
  • Proof that it operated. Dated records showing the rule applied across the whole period, not just on audit day: scan results, reports, log entries.
  • An exception register. Every place the rule does not apply, with who approved it, why, and until when.

For a SOC 2 Type II report, the auditor picks dates from across the observation window and asks for evidence on each, so records must be produced on a schedule and stored where nobody can quietly edit them. Our SOC 2 playbook for startups covers scoping and the organizational controls; this is the cluster side.

Step 1: Run a CIS Benchmark Scan Today

The CIS Kubernetes Benchmark is a hardening baseline, not a certification: a published configuration standard to name in your security policy. kube-bench, an open-source scanner, tests your nodes against it.

kube-bench reads host files and inspects host processes, which the Pod Security baseline level forbids. Give it a namespace of its own:

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: compliance-scans
  labels:
    # kube-bench reads host files and processes, which baseline forbids.
    # Record this namespace as an exception, with the reason, in your register.
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/warn: baseline

That label is your first exception. Now the Job:

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: kube-bench-node
  namespace: compliance-scans
spec:
  backoffLimit: 0
  template:
    spec:
      hostPID: true
      restartPolicy: Never
      containers:
        - name: kube-bench
          # Mirror the official kube-bench image into your registry and pin the release
          image: registry.example.com/mirror/kube-bench:v0.16.0
          command: ["kube-bench", "run", "--targets", "node"]
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 256Mi
          volumeMounts:
            - name: var-lib-kubelet
              mountPath: /var/lib/kubelet
              readOnly: true
            - name: etc-systemd
              mountPath: /etc/systemd
              readOnly: true
            - name: lib-systemd
              mountPath: /lib/systemd
              readOnly: true
            - name: etc-kubernetes
              mountPath: /etc/kubernetes
              readOnly: true
            - name: usr-bin
              mountPath: /usr/local/mount-from-host/bin
              readOnly: true
      volumes:
        - name: var-lib-kubelet
          hostPath:
            path: /var/lib/kubelet
        - name: etc-systemd
          hostPath:
            path: /etc/systemd
        - name: lib-systemd
          hostPath:
            path: /lib/systemd
        - name: etc-kubernetes
          hostPath:
            path: /etc/kubernetes
        - name: usr-bin
          hostPath:
            path: /usr/bin

Run it and keep the output:

bash
kubectl apply -f compliance-scans-namespace.yaml -f kube-bench-node.yaml
kubectl -n compliance-scans wait --for=condition=complete job/kube-bench-node --timeout=5m
kubectl -n compliance-scans logs job/kube-bench-node > "kube-bench-$(date -u +%F).txt"
grep -E '^\[(FAIL|WARN)\]' "kube-bench-$(date -u +%F).txt"

A Job scans only the node it lands on, so add a nodeSelector, run one per node pool, and delete the finished Job before the next run. On a managed service, start from the provider's Job variant in the kube-bench repository instead; it mounts the paths those nodes use. kube-bench picks the benchmark from your Kubernetes version, falling back to the newest it knows; pass --benchmark to grade every run against the same document, and --json when the output goes into your evidence store.

Reading the results

Each check prints one line, in this format:

text
[PASS] 4.2.1 Ensure that the --anonymous-auth argument is set to false (Automated)
[FAIL] 4.2.6 Ensure that the --make-iptables-util-chains argument is set to true (Automated)
[WARN] 4.2.13 Ensure that a limit is set on pod PIDs (Manual)
  • FAIL means an automated check ran and the node does not comply. Each one becomes a ticket or a written risk acceptance.
  • WARN does not mean pass. kube-bench reports every Manual check, plus every unscored check it could not confirm, as WARN. Someone has to review each one and record the answer.
  • The remediation section gives the fix for each FAIL and WARN. Apply kubelet fixes through the node image or node pool configuration; a fix made on a running node is lost when the node is replaced.

Check IDs differ between benchmark versions, so store the version alongside every result.

What is not yours on a managed control plane

On a managed service, the control plane runs on the provider's machines, out of your reach. The CIS benchmarks for managed services shrink its chapters to little more than switching on audit logs, and add a chapter for settings you control through the provider's API.

  • The provider's responsibility: control-plane component flags and file permissions, etcd configuration and encryption, control-plane patching. Your evidence for these is the provider's own audit report or certification, filed under vendor management.
  • Still yours: worker node configuration (through the node image and node pool settings), whether control-plane audit logs are on and where they go, network access to the API endpoint, encryption of Secrets with a key you manage, the cloud identities that nodes and workloads use, and which image registries are approved.

If those settings live in Terraform, the code and its review history are the definition and the change record. On a self-managed cluster, also run a Job on a control-plane node with the master, controlplane and etcd targets; the repository ships that variant too.

Step 2: Pod Security Admission, Nothing to Install

Pod Security Admission is built into Kubernetes and has been stable since 1.25. It applies the Pod Security Standards levels (privileged, baseline, restricted) per namespace through labels, in three modes: enforce rejects, audit records, warn tells whoever applies the manifest.

A sensible starting point is to enforce baseline while auditing and warning at restricted:

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: latest
    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

Before labeling production namespaces, a server-side dry run warns about existing pods that would violate the level, without changing anything:

bash
kubectl label --dry-run=server --overwrite ns --all \
  pod-security.kubernetes.io/enforce=baseline

Two details catch teams out:

  • Enforce checks pods, not workloads. A Deployment that violates the enforced level is accepted; its ReplicaSet then fails to create pods, and the only trace is a FailedCreate event. The warn and audit modes do check workload resources, which is why you set them alongside enforce.
  • Audit mode writes to the API server audit log, as an annotation on the request (pod-security.kubernetes.io/audit-violations). If audit logging is off, audit mode records nothing.

What Pod Security Admission cannot do is check anything outside the Pod Security Standards, produce an exportable report, or grant an exception narrower than a namespace. That takes a policy engine.

Want this checked against your own clusters? In a free Kubernetes compliance assessment we run the CIS scan with you, review your namespace labels and admission policies, and separate the findings that are yours from the ones that belong to your provider.

Step 3: Policy as Code with Kyverno

Kyverno is an open-source policy engine that runs as an admission webhook. It also rescans existing resources in the background and writes PolicyReport objects: the proof of operation an auditor asks for.

One version note: since Kyverno 1.19, the long-standing ClusterPolicy kind is deprecated and applying one returns a warning. Its replacement, ValidatingPolicy, is written in CEL, the expression language of Kubernetes' built-in admission policies. The examples below were tested with the Kyverno 1.19.2 CLI.

Mind the scope: by default, the Helm chart makes Kyverno skip kube-system, kube-public, kube-node-lease and its own namespace. Unless you change that, list those namespaces in your control mapping as excluded, with the reason: an auditor will ask which namespaces each control does not cover.

Disallow privileged containers

Pod Security baseline already blocks these in labeled namespaces. Repeating the rule in Kyverno also covers namespaces that are unlabeled or labeled privileged, produces a report entry per workload, and lets you exempt one image instead of a namespace. It catches kubectl debug sessions too, since they add ephemeral containers to running pods.

yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: disallow-privileged-containers
spec:
  validationActions: [Audit, Warn]
  evaluation:
    background:
      enabled: true
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments", "statefulsets", "daemonsets"]
      - apiGroups: ["batch"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["jobs", "cronjobs"]
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["UPDATE"]
        resources: ["pods/ephemeralcontainers"]
  variables:
    - name: podSpec
      expression: >-
        object.spec.?jobTemplate.?spec.?template.?spec.orValue(
        object.spec.?template.?spec.orValue(object.spec))
    - name: allContainers
      expression: >-
        variables.podSpec.containers +
        variables.podSpec.?initContainers.orValue([]) +
        variables.podSpec.?ephemeralContainers.orValue([])
  validations:
    - expression: >-
        variables.allContainers.all(c,
        (has(exceptions.allowedImages) && c.image in exceptions.allowedImages) ||
        !c.?securityContext.?privileged.orValue(false))
      message: "Privileged containers are not allowed. Remove securityContext.privileged or request an exception."

Each policy matches workload resources as well as pods: a pods-only policy in Deny mode accepts the Deployment and silently rejects its pods, the same trap as Pod Security's enforce mode.

Require requests and limits

yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-requests-limits
spec:
  validationActions: [Audit, Warn]
  evaluation:
    background:
      enabled: true
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments", "statefulsets", "daemonsets"]
      - apiGroups: ["batch"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["jobs", "cronjobs"]
  variables:
    - name: podSpec
      expression: >-
        object.spec.?jobTemplate.?spec.?template.?spec.orValue(
        object.spec.?template.?spec.orValue(object.spec))
  validations:
    - expression: >-
        variables.podSpec.containers.all(c,
        c.?resources.?requests.?cpu.hasValue() &&
        c.?resources.?requests.?memory.hasValue() &&
        c.?resources.?limits.?memory.hasValue())
      message: "Every container needs CPU and memory requests and a memory limit."

Restrict images to an approved registry

yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: restrict-image-registries
spec:
  validationActions: [Audit, Warn]
  evaluation:
    background:
      enabled: true
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments", "statefulsets", "daemonsets"]
      - apiGroups: ["batch"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["jobs", "cronjobs"]
  variables:
    - name: podSpec
      expression: >-
        object.spec.?jobTemplate.?spec.?template.?spec.orValue(
        object.spec.?template.?spec.orValue(object.spec))
    - name: allContainers
      expression: >-
        variables.podSpec.containers +
        variables.podSpec.?initContainers.orValue([]) +
        variables.podSpec.?ephemeralContainers.orValue([])
  validations:
    - expression: >-
        variables.allContainers.all(c,
        c.image.startsWith('registry.example.com/'))
      message: "Images must come from registry.example.com. Mirror third-party images there first."

If third-party images are mirrored into your registry and scanned there, this policy enforces that convention. The kube-bench Job above passes all three policies.

Audit first, then enforce

All three policies start with validationActions: [Audit, Warn]: violations go into the reports and whoever applies a non-compliant manifest sees a warning, but nothing is blocked. Once a policy's report shows no failures outside recorded exceptions, change it to [Deny] through a reviewed merge request, which doubles as your change record. Flip one policy at a time.

Run the same policies in CI so developers find out first. This exits non-zero when a manifest fails, and passes in the exception files (next section) so approved workloads still build:

bash
kyverno apply policies/ --resource manifests/ \
  --exception "$(find exceptions -name '*.yaml' | paste -sd, -)"

Our guide to a secure CI/CD pipeline with GitOps shows where that gate fits.

Exceptions, recorded

Some workloads genuinely need what a policy forbids: storage and network drivers often run privileged. Exceptions are off by default in the Kyverno Helm chart. Enable them for one namespace only, so creating one is a permission you grant deliberately:

yaml
# values.yaml for the Kyverno Helm chart
features:
  policyExceptions:
    enabled: true
    namespace: policy-exceptions

Each exception is then a reviewed file in Git:

yaml
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
  name: csi-node-driver-privileged
  namespace: policy-exceptions
spec:
  policyRefs:
    - name: disallow-privileged-containers
      kind: ValidatingPolicy
  # Exact image reference: a new driver version needs a new approval
  images:
    - registry.example.com/mirror/csi-node-driver:2.1.0
  expiresAt: "2027-04-30T00:00:00Z"
  properties:
    reason: "Storage driver mounts volumes on the host; needs privileged mode"
    ticket: "SEC-142"
    approved-by: "head-of-engineering"

The images list is an exact match, so upgrading the driver requires a fresh approval. expiresAt and properties arrived in Kyverno 1.19: past the date the exception stops applying, and the reason, ticket and approver travel with it into every export.

Step 4: Turn Reports and Logs into Evidence

Policy reports show the current state: when a violation is fixed, the record that it existed disappears too. Kubernetes events expire, after one hour by default. Neither is history, so snapshot on a schedule.

The API server audit log

The audit log records who changed what, including every request admission control denied. On a self-managed cluster, you write the audit policy:

yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - "RequestReceived"
rules:
  # Secrets: record who touched them, never their content
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets"]
  # RBAC and admission policy changes: the full request and response
  - level: RequestResponse
    resources:
      - group: "rbac.authorization.k8s.io"
      - group: "policies.kyverno.io"
  # Interactive access to running containers
  - level: Metadata
    resources:
      - group: ""
        resources: ["pods/exec", "pods/attach", "pods/portforward"]
  # Every other change, including admission denials
  - level: Metadata
    verbs: ["create", "update", "patch", "delete", "deletecollection"]

On a managed service you usually switch control-plane audit logs on and choose where they go. Do it on day one: an audit period cannot start before its logs do.

A daily export

Run this from a scheduled CI job, or a CronJob with read-only access:

bash
#!/usr/bin/env bash
set -euo pipefail
OUT="evidence/$(date -u +%F)"
mkdir -p "$OUT"

# What is enforced, and every approved exception
kubectl get validatingpolicies.policies.kyverno.io -o yaml > "$OUT/policies.yaml"
kubectl get policyexceptions.policies.kyverno.io -A -o yaml > "$OUT/exceptions.yaml"
kubectl get namespaces -L pod-security.kubernetes.io/enforce,pod-security.kubernetes.io/audit,pod-security.kubernetes.io/warn > "$OUT/psa-labels.txt"

# Policy results as they stand today
kubectl get policyreports -A -o json > "$OUT/policyreports.json"
kubectl get clusterpolicyreports -o json > "$OUT/clusterpolicyreports.json"

# One line per namespace: failures and passes
jq -r '.items | group_by(.metadata.namespace)[]
  | [.[0].metadata.namespace, (map(.summary.fail // 0) | add), (map(.summary.pass // 0) | add)]
  | @tsv' "$OUT/policyreports.json" > "$OUT/summary-by-namespace.tsv"

# Fingerprint the snapshot before it leaves the cluster
(cd "$OUT" && sha256sum -- * > SHA256SUMS)

Add the kube-bench JSON from each scan, and the RBAC bindings export described in the SOC 2 playbook. Then set the cadence:

  • Policy reports, exceptions and namespace labels: daily.
  • kube-bench: weekly, and after every Kubernetes upgrade or node image change.
  • Audit log: continuously, shipped as it is written.

Store it in object storage with versioning and a retention lock, in a separate account that cluster administrators cannot delete from, written by credentials that can add objects but not remove them. Keep it for at least the audit period plus the time until the report is issued, or longer if your ISMS requires.

Mapping Cluster Controls to SOC 2 and ISO 27001

Neither framework mentions Kubernetes, but each export above lands in a control area both of them ask for:

  • CIS scan results and remediation tickets. ISO 27001: configuration management, and management of technical vulnerabilities. SOC 2: system operations, which covers detecting configuration changes that introduce vulnerabilities.
  • Pod Security labels and the privileged-container policy. ISO 27001: privileged access rights, and configuration management. SOC 2: logical access, restricting privileged access to infrastructure.
  • The approved-registry policy. ISO 27001: installation of software on operational systems. SOC 2: change management, since only images from a registry you control reach production.
  • Requests and limits. ISO 27001: capacity management. SOC 2: the availability criteria, if availability is in your scope.
  • Policy changes through merge requests. Change management, in both frameworks.
  • Audit log and policy report snapshots. ISO 27001: logging, and monitoring activities. SOC 2: system operations.
  • The exception register. Both expect deviations to be approved, time-limited and reviewed; auditors sample the register like any other population.

If NIS2 or DORA applies to you, the same exports support their ICT risk-management requirements. Write the mapping down as a short document pointing each control at its evidence path: it is what you hand the auditor on day one.

A Six-Week Rollout

  • Week 1, baseline. Run kube-bench on every node pool and turn every FAIL into a ticket or a written risk acceptance. Switch on API server audit logging. Deliverables: first scan in the evidence store, a triaged findings list, audit logs flowing.
  • Week 2, Pod Security. Dry-run, then label application namespaces to enforce baseline, with audit and warn at restricted. Record each namespace that cannot meet baseline, with a reason. Deliverables: the namespace label export and its exception list.
  • Week 3, Kyverno in audit. Install it with exceptions enabled for one namespace, apply the policies with [Audit, Warn], add the CI check. Deliverables: policies in Git, the first report snapshot, the CI gate.
  • Week 4, fix and except. Work through the failures with the owning teams; what cannot be fixed gets an exception with an expiry date and an approver. Deliverables: a falling failure count, the exception register.
  • Week 5, enforce. Move each policy with a clean report to [Deny], one merge request per policy. Deliverables: the change records.
  • Week 6, evidence. Schedule the daily export, lock the storage, write the control mapping. Then rehearse: pick a random past date and produce the evidence for every control. Deliverables: the evidence store, the mapping, rehearsal notes.

After that the work is routine: weekly scans, a monthly exception review, and a quarterly check that the mapping still matches the cluster.

Get a Free Kubernetes Compliance Assessment

If an audit is coming and you are not sure what your clusters would show today, our DevSecOps team will work through it with you at no cost.

The free assessment covers:

  • A CIS benchmark run on your node pools, with each finding sorted into yours, your provider's, or an accepted risk
  • A review of your admission controls: namespace labels, policies, and how exceptions are approved and whether they expire
  • An evidence gap list per SOC 2 or ISO 27001 control area: what your clusters record today, what is missing, and how to collect it automatically

You get a written list and a plan sized to your team, with no obligation attached. Request your free Kubernetes compliance assessment, or see how our Kubernetes consulting team runs production clusters.