Compliance for cloud engineers · Lesson 5 of 7 · about 35 minutes

Policy as code: checking the rules automatically

Turning controls into checks a computer runs every time: scanning Terraform with Checkov before it's applied, checking a Kubernetes cluster against the CIS Benchmark with kube-bench, and blocking bad deployments at the door with Kyverno. Hands-on, with real failures.

By the end you'll know

  • What policy as code is, and why it beats a checklist
  • The difference between checking before deployment and checking what's running
  • How to scan Terraform for security problems with Checkov, and gate a pull request on it
  • How to check a Kubernetes cluster against the CIS Benchmark with kube-bench
  • How to block bad deployments before they start, with a Kyverno policy

Where you are

Lesson 4 showed how Git, pull requests and Terraform record every change. That's evidence after the fact. This lesson moves earlier: checking every change against the rules automatically, before it reaches production, and turning some controls from "please remember to" into "the system won't let you".

This one is hands-on throughout. You'll run three real tools, watch them fail on purpose, and fix what they find.

Spell-check, not proofreading

When you write a document, you could ask a friend to read every word for spelling mistakes, every time. Or you could let the spell-checker underline them as you type. The friend is still useful for whether the writing makes sense; but nobody uses a friend to find typos any more.

Policy as code is the spell-checker for infrastructure. Instead of a written rule like "SSH must never be open to the internet" that a reviewer has to remember to check, you write the rule as code, and a tool checks every change against it, every time, in seconds. Reviewers are still needed for judgement. They're just not needed to catch the same ten mistakes over and over.

The written rule becomes a test, the test result becomes evidence, and a failing test can block the change. That's three controls from earlier lessons in one: preventive, detective and evidence-producing.

Two places to check

Policy checks run at two points, and good setups use both:

  • Before deployment, on the code: scanning Terraform and Kubernetes files on every pull request. A problem found here costs a minute to fix and never reaches production. People call this shifting left, because it moves the check earlier, to the left of a timeline.
  • At the door, and after deployment, on what's actually running: blocking bad resources as they're created, and scanning the live system for anything that slipped through, or drifted, as Lesson 4 described.
Three places to check the rulesA change moves left to right: code in a pull request, then the cluster's front door, then the running cluster. Checkov scans the code in the pull request and fails the check if a rule is broken. Kyverno sits at the cluster's door and refuses resources that break a policy. kube-bench checks the running cluster against the CIS Benchmark. The earlier a problem is caught, the cheaper it is to fix.Code in a pull requestTerraform, Kubernetes filesThe cluster's doorevery create or changeThe running clusterwhat's actually thereCheckovfails the pull requestKyvernorefuses the resourcekube-benchreports what doesn't matchThe earlier a check runs, the cheaper the fix.
The three tools in this lesson, at the three points they check. Real teams usually run something at each.

Checkov: scan Terraform before it's applied

Checkov is a free, open-source scanner that reads Terraform, Kubernetes files and more, and checks them against hundreds of built-in security rules. It doesn't need a cloud account: it reads the files, so you can try it on Terraform you've never applied. Install it with Homebrew on a Mac, or with pip:

BASH
brew install checkov

In a new folder, create main.tf with a security group from Lesson 8 of the Networking track, with a mistake in it:

HCL
resource "aws_security_group" "web" {
  name        = "web"
  description = "Web servers"

  ingress {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH for admins"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Scan it. --compact leaves out the code snippets, and --quiet shows only what failed:

BASH
checkov -f main.tf --compact --quiet
OUTPUT
terraform scan results:

Passed checks: 5, Failed checks: 2, Skipped checks: 0

Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
	FAILED for resource: aws_security_group.web
	File: /main.tf:1-20
Check: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"
	FAILED for resource: aws_security_group.web
	File: /main.tf:1-20

Two failures, each with an ID and a plain description:

  • CKV_AWS_24 is the real problem: SSH open to the whole internet, exactly what Networking Lesson 8 warned against. It's a breach of least privilege, AC-6, and of boundary protection, SC-7.
  • CKV2_AWS_5 says the security group isn't attached to anything. In this file it isn't, because the servers would be defined somewhere else. That's a false positive here: the rule is sensible, but it doesn't apply.

Fix the first, and record a reason for the second. Checkov reads special comments to skip a check, and the reason you write is kept in the report, which makes the exception itself evidence:

HCL
resource "aws_security_group" "web" {
  #checkov:skip=CKV2_AWS_5:Attached to the web servers in servers.tf
  name        = "web"
  description = "Web servers"

  ingress {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH from the office only"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["198.51.100.7/32"]
  }
}

SSH now only accepts the office's one address, a /32 from the Networking track. Scan again:

OUTPUT
terraform scan results:

Passed checks: 6, Failed checks: 0, Skipped checks: 1

The part that makes it a control is the exit code. Checkov exits with 1 when any check fails, and 0 when everything passes, the same idea as Terraform's exit codes in Lesson 4. Run it as a required check on every pull request, and a change that opens SSH to the internet simply can't be merged. That's a preventive control, enforced by a machine, with the scan results as evidence.

kube-bench: check a cluster against the CIS Benchmark

Lesson 2 introduced the CIS Benchmarks, the community's hardening guides. There's one for Kubernetes, with checks like "the API server should not allow anonymous access" and "the kubelet's config file should have restrictive permissions". kube-bench is a free tool that runs those checks on a real cluster and reports each one as PASS, FAIL or WARN.

You'll need a kind cluster, from the Kubernetes track's first cluster lesson. Create a throwaway one:

BASH
kind create cluster --name policy

kube-bench runs as a short job inside the cluster, so it can see the node's files. Apply the job from the project's own release, wait for it to finish, and read the summary:

BASH
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/v0.16.0/job.yaml
kubectl wait --for=condition=complete job/kube-bench --timeout=180s
kubectl logs job/kube-bench | tail -8
OUTPUT
== Summary total ==
63 checks PASS
12 checks FAIL
56 checks WARN
0 checks INFO

That's a real run on a brand new kind cluster: 63 checks pass, 12 fail, and 56 are warnings, mostly checks kube-bench can't decide automatically and leaves for a person. kind is built for learning on a laptop, not for production, so failures are expected. Look at a few of them:

BASH
kubectl logs job/kube-bench | grep "^\[FAIL\]"
OUTPUT
[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)

Those are a few of the 12. Four of them say the same thing: the cluster's API server isn't keeping an audit log, the record of who asked the cluster to do what. That's event logging, AU-2 from Lesson 3, missing on this cluster, found in seconds by a tool rather than by an auditor months later.

On a cloud provider's managed cluster, many checks don't apply, because the provider runs the control plane you can't see, which is why providers publish their own versions of the benchmark. The value isn't a perfect score; it's knowing which settings differ from the benchmark, and having a reason for each one. That list of reasons is exactly what an auditor asks for.

Kyverno: block bad deployments at the door

Checkov checks code, and kube-bench checks the running cluster. Kyverno checks at the door: it plugs into Kubernetes's admission control, the step where the cluster decides whether to accept a new or changed resource, and refuses anything that breaks a policy.

Install it on the same cluster, from its own release, and wait for it to be ready:

BASH
kubectl create -f https://github.com/kyverno/kyverno/releases/download/v1.19.1/install.yaml
kubectl -n kyverno wait --for=condition=Available deployment --all --timeout=240s

Here's a policy for a real control. CM-2 asks for a known, recorded baseline. An image tagged latest, or with no tag at all, could be a different version every time it's pulled, so nobody can say exactly what's running. This policy refuses both. Create no-latest.yaml:

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  rules:
  - name: require-image-tag
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      failureAction: Enforce
      message: "Pin every image to a version."
      pattern:
        spec:
          containers:
          - image: "*:*"
  - name: no-latest-tag
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      failureAction: Enforce
      message: "The latest tag is not allowed. Pin the image to a version."
      pattern:
        spec:
          containers:
          - image: "!*:latest"

It has two rules: every image must have a tag (*:*), and that tag can't be latest (!*:latest, where ! means not). failureAction: Enforce means refuse; the other choice, Audit, lets the resource in but records the problem, which is a good way to roll a new policy out gently. Apply it, and try it:

BASH
kubectl apply -f no-latest.yaml
OUTPUT
Warning: kyverno.io/v1 ClusterPolicy is deprecated and will be removed in a future release; migrate to ValidatingPolicy, MutatingPolicy, GeneratingPolicy or ImageValidatingPolicy (policies.kyverno.io), see https://kyverno.io/docs/guides/migration-to-cel/
clusterpolicy.kyverno.io/disallow-latest-tag created

The policy is in, with a warning worth understanding. Kyverno is moving to a newer kind of policy, ValidatingPolicy, written in CEL (Common Expression Language), the same expression language Kubernetes uses for its own built-in policies. The ClusterPolicy style here still works, and it's what most existing policies and examples you'll find use, so it's the clearer one to learn first. New policies in a real cluster are worth writing in the new style; the link at the end of the warning shows how. Now try to break the rule:

BASH
kubectl run bad --image=nginx:latest
OUTPUT
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request: 

resource Pod/default/bad was blocked due to the following policies 

disallow-latest-tag:
  no-latest-tag: 'validation error: The latest tag is not allowed. Pin the image to a version. rule no-latest-tag failed at path /spec/containers/0/image/'

Now an image with no tag at all, which means latest without saying so:

BASH
kubectl run bad2 --image=nginx
OUTPUT
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request: 

resource Pod/default/bad2 was blocked due to the following policies 

disallow-latest-tag:
  require-image-tag: 'validation error: Pin every image to a version. rule require-image-tag failed at path /spec/containers/0/image/'

Each refusal names the policy, the rule that failed and your message. And a pinned image:

BASH
kubectl run good --image=nginx:1.27
OUTPUT
pod/good created

The first pod never existed: the cluster refused it at the door, with your message. The second, pinned to a version, went straight in. That's a preventive control enforced on every pod anyone creates, by any route: kubectl, a pipeline or Argo CD.

When you're done, delete the cluster:

BASH
kind delete cluster --name policy

In my real setup

CivicGrid doesn't run any of these yet, and writing this lesson made that obvious. Its Terraform has never been scanned by Checkov, its cluster has never had a kube-bench run, and nothing stops an image tagged latest reaching it, apart from my own habit. Each is a small job, and Lesson 7 lists them as real gaps rather than pretending otherwise. It's also a fair test of the learning-in-public idea: the honest version of this track includes the controls I haven't built yet.

Break it on purpose

Three ways policy as code goes wrong. You've seen the first already.

The false positive. Checkov failed CKV2_AWS_5, "security group not attached", on a group that was attached in another file. If people get too many false positives, they stop reading the results, and real failures hide among them. The fix isn't to turn the scanner off; it's to skip specific checks with a recorded reason, as you did, and to review those skips now and then. A skip with a reason is a decision; a skip without one is a gap.

Audit forever. A team rolls out Kyverno with every policy on Audit, to see what would break. Six months later they're still on Audit, and the policy reports list hundreds of violations nobody looks at. What went wrong? Audit mode is a detective control, and, like the drift check in Lesson 4, a detective control nobody acts on is no control at all. Audit is the right way to start; the plan should always end with switching to Enforce, policy by policy, with a date.

The scan that doesn't block. A team adds Checkov to its pipeline, and it runs on every pull request. But it's not a required check, so when it fails, people merge anyway "to fix it later". Is this a control? Barely. It produces evidence that problems were known and ignored, which is arguably worse than no evidence. A policy check only prevents anything if a failure stops the merge, which is the branch protection setting from Lesson 4.

Where you are now

You've turned controls into code: Checkov blocking an open SSH port before it's applied, kube-bench measuring a cluster against the CIS Benchmark, and Kyverno refusing an unpinned image at the cluster's door. You know where each one checks, how exit codes and enforcement make them preventive, and why every exception needs a reason. Lesson 6 steps back from tools to process: the federal path, RMF, FedRAMP and the ATO, where all of this evidence ends up.

Check yourself

  1. What does policy as code replace?

  2. Checkov exits with code 1. What should a pipeline do?

  3. Checkov fails a check that doesn't apply to your setup. What's the right response?

  4. Where does Kyverno check resources?

  5. Why does the policy refuse images tagged latest?

  6. A team's Kyverno policies have been on Audit for six months. What's the problem?

Key terms

  • Policy as code: writing security rules as code that tools check automatically.
  • Shift left: moving checks earlier, to the code and the pull request, where fixes are cheapest.
  • Checkov: an open-source scanner for Terraform, Kubernetes files and more.
  • False positive: a check that fails on something that isn't actually a problem.
  • CIS Kubernetes Benchmark: the community's hardening checks for Kubernetes clusters.
  • kube-bench: a tool that runs the CIS Kubernetes Benchmark checks on a real cluster.
  • Admission control: the step where Kubernetes decides whether to accept a new or changed resource.
  • Kyverno: a policy engine that checks resources at admission, and can enforce or audit.
  • Enforce and Audit: refusing what breaks a policy, or allowing it and recording it.

Go deeper