Hands-On Labs · Guided Drills · Write a Policy-as-Code Rule

Drill — Write a Policy-as-Code Rule

Take one plain-English security requirement — "no security group may expose port 22 to 0.0.0.0/0" — and write it twice: once as an OPA/Rego rule tested with Conftest against a Terraform plan, and once as a Kyverno ClusterPolicy tested against a Kubernetes-native SecurityGroup custom resource. Both first drafts below have a real, specific bug — not a typo, the same class of logic error a working engineer actually makes under deadline pressure — and in both rounds you'll watch the rule's own test suite catch that bug before you ever point it at anything live. The payoff isn't "which policy engine is better." It's leaving this page with a fixed rule in both languages, proof that each one blocks what it should and lets through what it should, and a specific, defensible answer to which tool a Kubernetes-only platform team versus a multi-tool team would actually still be maintaining a year from now.

☺ Explain it like I'm 10

Imagine you have to teach the exact same house rule — "the back door stays locked" — to two different dogs who guard two different doors. One dog only ever patrols the driveway gate; the other only ever patrols the garden fence. You can't just teach the rule once and assume both dogs learned it, because each one only reacts to whatever walks past its own post. So you teach it twice, in whatever way each dog actually understands, and then you test each dog separately by walking an unlocked door past it on purpose, to see if it actually barks.

🤖🐢Your hosts for this drill: Recon the Robot & Timmy the Turtle — Recon writes and tests both versions of the rule, because reconciling the same intent across two different systems without letting either one drift from what was actually meant is exactly Recon's job; Timmy refuses to trust either one until it's watched it fail on a broken fixture and pass on a clean one, in that order.

The requirement, and why one sentence needs two different gates

☺ Like you're 10: One rule, but it has to be taught to two completely different watchers, because each one only ever sees the resources that walk through its own front door.

Before writing a single line of policy, it's worth being honest about something: Checkov already ships a numbered, built-in check for exactly this requirement — CKV_AWS_24, "Ensure no security groups allow ingress from 0.0.0.0/0 to port 22." If a scanner already has this rule pre-written, why hand-write it twice yourself? Because the point of this drill was never that nobody has written this specific rule before. It's building the muscle to write the next one — the one nobody's shipped a check for, the way Part 5 of the capstone found out the hard way that Checkov has dedicated checks for ports 22 and 3389 specifically, and nothing at all for an arbitrary application port like Postgres's 5432. Port 22 is deliberately the familiar case here, precisely so the unfamiliar part of the drill — writing and testing the logic yourself, in two different languages, catching a bug in each — is the only new thing you have to concentrate on.

The harder problem this drill is actually about is that "security group" doesn't name one single resource type — it names a real-world thing that gets created through two structurally different paths, depending on how your organization provisions cloud infrastructure. A team running plain Terraform against the AWS provider creates an aws_security_group resource directly against the AWS API; that resource never touches a Kubernetes cluster at any point, so a Kubernetes-only tool like Kyverno has no way to see it — not because Kyverno's rule engine is weak, but because the request never crosses the one chokepoint Kyverno watches, the Kubernetes API server's admission webhook. A team that has standardized on AWS Controllers for Kubernetes (ACK) instead manages that same kind of AWS resource as a Kubernetes custom resource — a SecurityGroup object in the ec2.services.k8s.aws API group, applied with plain kubectl apply and reconciled into a real AWS security group by ACK's own controller after admission. That second path does cross the Kubernetes API server, which means Kyverno can see it, gate it, and reject it before it's ever admitted — the exact same requirement, enforced at a completely different checkpoint, because the resource took a completely different road to get created.

"No security group may expose port 22 to 0.0.0.0/0." — one plain-English requirement Engineer writes an aws_security_group resource (HCL) Engineer writes a SecurityGroup custom resource (ACK) terraform plan → plan.json kubectl apply → Kubernetes API server OPA / Conftest CI job — runs before terraform apply Kyverno admission webhook — runs in-cluster AWS Security Group the same real resource, whichever path created it Each gate only ever sees requests that travel its own path — neither one can see the other's.

That's the whole reason this drill asks you to write the same predicate twice instead of once. It isn't busywork, and it isn't "learn two tools for redundancy." It's the honest shape of the problem: the same governance intent has to be re-expressed, correctly, at every checkpoint a resource can actually pass through — and a rule sitting only at one checkpoint is invisible to anything that takes the other road in.

Set up both toolchains and every fixture you'll test against

☺ Like you're 10: Before you can grade an answer, you need a few example answers already written down — some right on purpose, some wrong on purpose.

You need five things on your laptop for the first four milestones: Terraform (1.5+), the OPA CLI, the Conftest CLI, and the Kyverno CLI — all four are single static binaries; grab the release for your platform from each project's GitHub releases page. The last two milestones need a throwaway kind cluster and Helm, covered when you get there. Lay out the fixture repo exactly like this — three independent Terraform root modules (so each one can be planned on its own without three resources of the same name colliding in one state) and three standalone Kubernetes manifests:

policy-drill/
├── terraform/
│   ├── ok/main.tf            # ingress restricted to the office CIDR — should PASS
│   ├── bad/main.tf           # ingress open to 0.0.0.0/0 directly — should FAIL
│   └── bad-multi/main.tf     # 0.0.0.0/0 buried as the SECOND cidr in the list — should FAIL
├── kubernetes/
│   ├── sg-ok.yaml             # SecurityGroup, restricted CIDR — should PASS
│   ├── sg-bad.yaml            # SecurityGroup, port 22 open to 0.0.0.0/0 — should FAIL
│   └── sg-bad-wide.yaml       # SecurityGroup, ALL ports open to 0.0.0.0/0 — should FAIL
├── opa/
│   ├── ssh_open_to_world.rego
│   └── ssh_open_to_world_test.rego
└── kyverno/
    ├── no-ssh-from-anywhere.yaml
    └── kyverno-test.yaml

The Terraform fixtures — three self-contained root modules, identical except for one line each:

# terraform/ok/main.tf — restricted to the office CIDR, should PASS
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 4.0" }
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_security_group" "bastion" {
  name = "bastion-sg"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["203.0.113.0/24"]   # office network only
  }
}
# terraform/bad/main.tf — the obvious case: 0.0.0.0/0, should FAIL
# ...same terraform/provider block as above...
resource "aws_security_group" "bastion" {
  name = "bastion-sg"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
# terraform/bad-multi/main.tf — the trap: 0.0.0.0/0 is the SECOND entry, should still FAIL
# ...same terraform/provider block as above...
resource "aws_security_group" "bastion" {
  name = "bastion-sg"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["203.0.113.0/24", "0.0.0.0/0"]   # world-open CIDR, listed second
  }
}

And the Kubernetes fixtures — three SecurityGroup custom resources in ACK's ec2.services.k8s.aws API group, whose ingressRules mirror the same shape a real AWS IpPermission takes:

# kubernetes/sg-ok.yaml — restricted CIDR, should PASS
apiVersion: ec2.services.k8s.aws/v1alpha1
kind: SecurityGroup
metadata:
  name: bastion-sg-ok
spec:
  description: "Bastion host access"
  groupName: bastion-sg
  vpcID: vpc-0123456789abcdef0
  ingressRules:
    - ipProtocol: tcp
      fromPort: 22
      toPort: 22
      ipRanges:
        - cidrIP: "203.0.113.0/24"
          description: "office network"
# kubernetes/sg-bad.yaml — the obvious case: 0.0.0.0/0 on port 22, should FAIL
apiVersion: ec2.services.k8s.aws/v1alpha1
kind: SecurityGroup
metadata:
  name: bastion-sg-bad
spec:
  description: "Bastion host access"
  groupName: bastion-sg
  vpcID: vpc-0123456789abcdef0
  ingressRules:
    - ipProtocol: tcp
      fromPort: 22
      toPort: 22
      ipRanges:
        - cidrIP: "0.0.0.0/0"
          description: "oops"
# kubernetes/sg-bad-wide.yaml — the trap: EVERY port open, port 22 included, should FAIL
apiVersion: ec2.services.k8s.aws/v1alpha1
kind: SecurityGroup
metadata:
  name: bastion-sg-bad-wide
spec:
  description: "Bastion host access"
  groupName: bastion-sg
  vpcID: vpc-0123456789abcdef0
  ingressRules:
    - ipProtocol: "-1"          # all protocols
      fromPort: 0
      toPort: 65535
      ipRanges:
        - cidrIP: "0.0.0.0/0"
          description: "someone fat-fingered a demo and never locked it back down"
⚠ Field names here are generated, and generated CRDs drift

ACK's controllers generate their CRD schemas from the underlying AWS SDK, and exact field casing (cidrIP vs cidrIp, whether it's ipRanges or something renamed in a later release) has shifted before between ec2-controller versions. Before trusting the YAML above verbatim against a real cluster, run kubectl explain securitygroup.spec.ingressRules against whatever CRD version you actually installed in the rollout section below and adjust field names to match. This is the same caution the OPA & Conftest page gives about Conftest's own less-common parsers — verify a generated schema against its current output before assuming an older example still matches.

Round 1 — write the rule in Rego, watch your own test catch a bug, then fix it

☺ Like you're 10: You write a rule, you write a test for it, and the test itself tells you the rule isn't quite right yet — before anything real ever sees it.

Write a first draft against plan.json's resource_changes array, the same shape the OPA & Conftest tool page and the capstone's own Rego rule both use. This draft has a real bug — keep reading before you run it:

# opa/ssh_open_to_world.rego — draft 1, has a bug
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_security_group"
    some rule in rc.change.after.ingress
    rule.from_port <= 22
    rule.to_port >= 22
    rule.cidr_blocks[0] == "0.0.0.0/0"        # bug: only ever looks at the FIRST entry
    msg := sprintf("%s allows SSH (port 22) from 0.0.0.0/0", [rc.address])
}

Write the unit tests before running the rule against anything real — three cases, matching the three Terraform fixtures above:

# opa/ssh_open_to_world_test.rego
package main_test

import data.main

test_deny_when_ssh_open_to_world if {
    count(main.deny) > 0 with input as {"resource_changes": [{
        "address": "aws_security_group.bastion",
        "type": "aws_security_group",
        "change": {"after": {"ingress": [{
            "from_port": 22, "to_port": 22, "cidr_blocks": ["0.0.0.0/0"],
        }]}},
    }]}
}

test_deny_when_world_cidr_is_not_first_in_the_list if {
    count(main.deny) > 0 with input as {"resource_changes": [{
        "address": "aws_security_group.bastion",
        "type": "aws_security_group",
        "change": {"after": {"ingress": [{
            "from_port": 22, "to_port": 22,
            "cidr_blocks": ["203.0.113.0/24", "0.0.0.0/0"],
        }]}},
    }]}
}

test_allow_when_restricted_to_office_cidr if {
    count(main.deny) == 0 with input as {"resource_changes": [{
        "address": "aws_security_group.bastion",
        "type": "aws_security_group",
        "change": {"after": {"ingress": [{
            "from_port": 22, "to_port": 22, "cidr_blocks": ["203.0.113.0/24"],
        }]}},
    }]}
}
$ opa test opa -v
opa/ssh_open_to_world_test.rego:
data.main_test.test_deny_when_ssh_open_to_world: PASS (0.3ms)
data.main_test.test_deny_when_world_cidr_is_not_first_in_the_list: FAIL (0.2ms)
data.main_test.test_allow_when_restricted_to_office_cidr: PASS (0.3ms)
--------------------------------------------------------------------------------
PASS: 2/3
FAIL: 1/3

That failure is the whole point of writing the test before trusting the rule. rule.cidr_blocks[0] only ever reads index zero — a security group whose world-open CIDR happens to be the second entry in the list (a very ordinary way for this to actually happen: someone adds the office CIDR first, then someone else appends a "temporary" 0.0.0.0/0 for a demo and never removes it) sails straight past the check. Fix it by iterating the whole list instead of indexing into it:

# opa/ssh_open_to_world.rego — fixed
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_security_group"
    some rule in rc.change.after.ingress
    rule.from_port <= 22
    rule.to_port >= 22
    some cidr in rule.cidr_blocks
    cidr == "0.0.0.0/0"
    msg := sprintf("%s allows SSH (port 22) from 0.0.0.0/0", [rc.address])
}
$ opa test opa -v
opa/ssh_open_to_world_test.rego:
data.main_test.test_deny_when_ssh_open_to_world: PASS (0.3ms)
data.main_test.test_deny_when_world_cidr_is_not_first_in_the_list: PASS (0.3ms)
data.main_test.test_allow_when_restricted_to_office_cidr: PASS (0.2ms)
--------------------------------------------------------------------------------
PASS: 3/3

With the unit tests green, prove it against the three real fixtures — generate a resolved plan for each one independently, then run Conftest against each plan.json:

for dir in ok bad bad-multi; do
  (cd terraform/$dir && terraform init -input=false -no-color >/dev/null \
    && terraform plan -input=false -out=p.tfplan -no-color >/dev/null \
    && terraform show -json p.tfplan > plan.json)
done

$ conftest test --policy opa terraform/ok/plan.json
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions

$ conftest test --policy opa terraform/bad/plan.json
FAIL - terraform/bad/plan.json - main - aws_security_group.bastion allows SSH (port 22) from 0.0.0.0/0
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

$ conftest test --policy opa terraform/bad-multi/plan.json
FAIL - terraform/bad-multi/plan.json - main - aws_security_group.bastion allows SSH (port 22) from 0.0.0.0/0
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

One pass, two fails, exactly matching the three fixtures' names — including bad-multi, the one draft 1 would have silently let through. Round 1 is done.

Round 2 — the same rule in Kyverno, and the same class of bug shows up again

☺ Like you're 10: Different language, but the same kind of mistake sneaks in — checking one exact number instead of a whole range.

Write the Kyverno equivalent against SecurityGroup's ingressRules, using foreach to walk the list the same way the Rego rule iterates rc.change.after.ingress. Ship it in Audit mode from the start — reasons for that come in the next section — but this draft still has the same equality-instead-of-range bug as Round 1's first attempt:

# kyverno/no-ssh-from-anywhere.yaml — draft 1, has a bug
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: no-ssh-from-anywhere
spec:
  validationFailureAction: Audit    # correct to start here — see the rollout section below
  background: false                  # this rule reads request.object; background re-scans don't have one
  rules:
    - name: block-ssh-open-to-world
      match:
        any:
          - resources:
              kinds:
                - ec2.services.k8s.aws/v1alpha1/SecurityGroup
      validate:
        message: "SSH (port 22) may not be reachable from 0.0.0.0/0."
        foreach:
          - list: "request.object.spec.ingressRules"
            deny:
              conditions:
                all:
                  - key: "{{ element.fromPort }}"
                    operator: Equals
                    value: 22
                  - key: "{{ element.toPort }}"
                    operator: Equals
                    value: 22
                  - key: "{{ element.ipRanges[].cidrIP }}"
                    operator: AnyIn
                    value:
                      - "0.0.0.0/0"

Declare the expected result for all three Kubernetes fixtures in a kyverno-test.yaml, the same CI-testable discipline the Kyverno tool page covers, before ever pointing this at a real cluster:

# kyverno/kyverno-test.yaml
name: no-ssh-from-anywhere-test
policies:
  - no-ssh-from-anywhere.yaml
resources:
  - ../kubernetes/sg-ok.yaml
  - ../kubernetes/sg-bad.yaml
  - ../kubernetes/sg-bad-wide.yaml
results:
  - policy: no-ssh-from-anywhere
    rule: block-ssh-open-to-world
    resource: bastion-sg-ok
    kind: SecurityGroup
    result: pass
  - policy: no-ssh-from-anywhere
    rule: block-ssh-open-to-world
    resource: bastion-sg-bad
    kind: SecurityGroup
    result: fail
  - policy: no-ssh-from-anywhere
    rule: block-ssh-open-to-world
    resource: bastion-sg-bad-wide
    kind: SecurityGroup
    result: fail
$ kyverno test kyverno/

Executing no-ssh-from-anywhere-test...
  1. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-ok  ... ok
  2. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-bad ... ok
  3. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-bad-wide
     expected: fail, got: pass                                                            ... FAIL

Test Summary: 2 tests passed, 1 test failed

Same bug family as Round 1, different exact shape. fromPort: 0, toPort: 65535 opens every port — port 22 included — but Equals 22 only ever matches a rule whose fromPort is exactly 22, so the widest-open fixture in the whole set is the one draft 1 misses entirely. Fix it the same way Round 1 was fixed: check whether 22 falls anywhere inside the rule's open range, not whether it equals one specific number:

# kyverno/no-ssh-from-anywhere.yaml — fixed
  # ...same match block as above...
      validate:
        message: "SSH (port 22) may not be reachable from 0.0.0.0/0."
        foreach:
          - list: "request.object.spec.ingressRules"
            deny:
              conditions:
                all:
                  - key: "{{ element.fromPort }}"
                    operator: LessThanOrEquals
                    value: 22
                  - key: "{{ element.toPort }}"
                    operator: GreaterThanOrEquals
                    value: 22
                  - key: "{{ element.ipRanges[].cidrIP }}"
                    operator: AnyIn
                    value:
                      - "0.0.0.0/0"
$ kyverno test kyverno/

Executing no-ssh-from-anywhere-test...
  1. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-ok        ... ok
  2. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-bad       ... ok
  3. policy no-ssh-from-anywhere / rule block-ssh-open-to-world / resource bastion-sg-bad-wide  ... ok

Test Summary: 3 tests passed, 0 tests failed

$ kyverno apply kyverno/no-ssh-from-anywhere.yaml --resource kubernetes/sg-bad-wide.yaml

Applying 1 policy rule to 1 resource...

policy no-ssh-from-anywhere -> resource SecurityGroup/bastion-sg-bad-wide failed:
1. block-ssh-open-to-world: validation error: SSH (port 22) may not be reachable from 0.0.0.0/0.
   rule block-ssh-open-to-world failed at path /spec/ingressRules/0/ipRanges/0/cidrIP/

pass: 0, fail: 1, warn: 0, error: 0, skip: 0

All three now behave exactly like the Terraform fixtures did — one clean pass, two named failures, including the wide-open one that slipped past draft 1. (Exact CLI banner text and column formatting move between Kyverno releases; treat the shape above as what to expect, not a byte-for-byte transcript to match against your own terminal.)

⚠ Watch out — a passing local test isn't the same as a blocking gate

Every command in both rounds above — opa test, conftest test, kyverno test, kyverno apply --resource — evaluates a policy's own logic against a fixture file, completely offline, with no cluster and no CI pipeline anywhere in the loop. That's exactly the right tool for proving the rule itself is correct, and it's also blind to the one thing that actually decides whether any of this stops anyone: whether the real ClusterPolicy object running in production has validationFailureAction: Enforce or Audit, and whether the OPA/Conftest job is wired in as a required, merge-blocking CI check or one nobody's ever made mandatory. A logically perfect rule left at Audit produces the identical "failed" line in kyverno apply output as the same rule set to Enforce — the local CLI has no way to tell you which one it's actually looking at, because it never asks. Proving the rule is correct and proving it's actually enforced are two separate claims. The next section closes that second gap deliberately, rather than assuming the first one already covered it.

Rolling the fixed Kyverno rule from watching to blocking, for real

☺ Like you're 10: First you count how many things would have broken the rule, then — only once you know that number — you flip the switch that actually stops them.

Everything so far has been offline. This section is the one place in the drill that needs a real, if throwaway, cluster — and it's the exact Audit-then-Enforce rollout the Kyverno tool page recommends for any new rule, walked end to end against this one:

# a disposable cluster, Kyverno itself, and just the ACK EC2 CRD — no controller, no AWS credentials needed
kind create cluster --name policy-drill
helm repo add kyverno https://kyverno.github.io/kyverno/ && helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace
kubectl -n kyverno rollout status deploy/kyverno-admission-controller

# CRD only — this registers the SecurityGroup kind with the API server so kubectl
# and Kyverno's webhook can both see it, without running the real ACK controller
# or touching an AWS account. Check the ec2-controller repo's config/crd/bases/
# directory for the current filename before assuming this exact path still resolves.
kubectl apply -f https://raw.githubusercontent.com/aws-controllers-k8s/ec2-controller/main/config/crd/bases/ec2.services.k8s.aws_securitygroups.yaml
kubectl explain securitygroup.spec.ingressRules   # confirm field names against what you actually installed

Apply the fixed policy exactly as written above — still Audit — then apply the deliberately broken fixture for real:

$ kubectl apply -f kyverno/no-ssh-from-anywhere.yaml
clusterpolicy.kyverno.io/no-ssh-from-anywhere created

$ kubectl apply -f kubernetes/sg-bad.yaml
securitygroup.ec2.services.k8s.aws/bastion-sg-bad created

$ kubectl get policyreport -A
NAMESPACE   NAME                        PASS   FAIL   WARN   ERROR   SKIP   AGE
default     cpol-no-ssh-from-anywhere   0      1      0      0       0     4s

Read that sequence carefully: sg-bad.yaml was admittedkubectl apply reported success — even though it visibly violates the rule, exactly what Audit means. The violation is real and it's recorded, queryable, and reviewable in the PolicyReport, but nothing was blocked. This is the deliberate first step of the rollout, not a bug: it's how you find out how many already-existing resources would break before a rule that suddenly starts rejecting them goes live. With the report showing exactly the one finding you expected — no surprise backlog of pre-existing violations — flip the switch for real:

$ sed -i 's/validationFailureAction: Audit/validationFailureAction: Enforce/' kyverno/no-ssh-from-anywhere.yaml
$ kubectl apply -f kyverno/no-ssh-from-anywhere.yaml
clusterpolicy.kyverno.io/no-ssh-from-anywhere configured

$ kubectl delete -f kubernetes/sg-bad.yaml
securitygroup.ec2.services.k8s.aws "bastion-sg-bad" deleted

$ kubectl apply -f kubernetes/sg-bad.yaml
Error from server: error when creating "kubernetes/sg-bad.yaml": admission webhook
"validate.kyverno.svc-fail" denied the request:

policy no-ssh-from-anywhere/block-ssh-open-to-world fail:
SSH (port 22) may not be reachable from 0.0.0.0/0.

That's the difference the warning above was making concrete: the exact same rule, the exact same fixture, and this time the API server itself refused to create the object at all — no report to read afterward, no cleanup ticket, nothing to remediate, because it never got in. kubectl apply -f kubernetes/sg-ok.yaml still succeeds, unaffected — Enforce only changes the outcome for resources that were always going to fail the rule.

Wiring both gates so nobody has to remember to run them

☺ Like you're 10: A rule you have to remember to run by hand isn't a rule yet — it's a suggestion. Both the pipeline and the cluster need to run it for you, automatically, every time.

The OPA/Conftest half needs a CI job, since nothing about a Terraform plan on your own laptop protects the next person's pull request. Add it as a required, merge-blocking check the same way Part 5 of the capstone wires its own IaC gate:

# .github/workflows/policy-drill.yml
name: ssh-policy-check
on: [pull_request]
jobs:
  conftest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "1.7.5" }

      - name: Render each fixture's plan
        run: |
          for dir in terraform/*/; do
            (cd "$dir" && terraform init -input=false \
              && terraform plan -input=false -out=p.tfplan \
              && terraform show -json p.tfplan > plan.json)
          done

      - name: Conftest — must fail on bad/ and bad-multi/, pass on ok/
        run: |
          curl -sSL -o conftest.tar.gz \
            https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz
          tar xzf conftest.tar.gz && sudo mv conftest /usr/local/bin
          conftest test --policy opa terraform/*/plan.json

The Kyverno half doesn't need a separate pipeline at all, and that's worth naming explicitly rather than treating as an afterthought — it's one of the two engines' most consequential differences. A ClusterPolicy is just another Kubernetes manifest, applied the same way every other cluster object is: committed to the same GitOps repository a tool like Argo CD or Flux already reconciles (see IaC security & policy as code), or applied as part of whatever cluster-bootstrap process stands the cluster up in the first place. There is no "did the policy pipeline run today" question to ask, because the enforcement point is the cluster's own admission webhook — it's live the instant the ClusterPolicy object exists, for every single write to the API server from that moment on, with no separate CI job to keep green.

Kubernetes-only vs multi-tool: which one would your team actually keep?

☺ Like you're 10: It's not about which watchdog is smarter — it's about which front door your team's resources actually walk through, and how many other doors there are besides this one.

Both rules now work. Both are tested, both are wired into something that runs automatically, and both catch the exact fixture they're supposed to catch. That's exactly the setup where "which tool is better" is the wrong question — the honest comparison is narrower and more useful than that:

QuestionOPA/Rego + ConftestKyverno
What can it actually see?Anything you can shell into a JSON or HCL document — Terraform plans, raw Kubernetes YAML, Dockerfiles, live authorization requestsOnly resources that flow through a Kubernetes API server's admission webhook — nothing created any other way exists as far as it's concerned
Where does this rule actually block anything?Pre-apply, in a CI job, against plan.json — the only option at all if security groups are provisioned by plain terraform apply outside any cluster, including before a cluster existsAt admission, inside the cluster, the instant kubectl apply runs — only possible because this org happens to provision this resource type through ACK
Policy languageRego — a genuinely new declarative language; real ramp-up, typically days to weeks even for a strong engineerPlain YAML pattern overlay plus JMESPath — near-zero if the team already reads Kubernetes manifests fluently
Local, CI-runnable testingopa test against Rego unit testskyverno test against a declared kyverno-test.yaml
Reused anywhere outside this one resource type?Yes — the identical deny/sprintf idiom covers Terraform for any cloud, raw Kubernetes YAML, Dockerfiles, and live authorization decisions, with one skillsetNo, by design — Kubernetes-only; buys nothing for anything that never crosses the cluster boundary

A Kubernetes-only platform team — every piece of infrastructure they own is provisioned as a Kubernetes CRD through something like ACK or Crossplane, nobody on the team runs terraform apply from a laptop outside a cluster, and nobody has written Rego before — keeps Kyverno. The policy is one more ClusterPolicy object living beside every other manifest in the same GitOps repo, reviewed in the same pull requests, with no separate binary version to pin in CI and no new language a next hire has to learn before their first policy change. The cost they're accepting knowingly: the moment this org provisions even one security group some other way — a Terraform module a different team owns, say — Kyverno has categorically nothing to say about it. Not "it's difficult." It never sees the request at all, because that resource never touches a Kubernetes API server in the first place.

A multi-tool team — Terraform for cloud infrastructure that isn't all funneled through Kubernetes CRDs, Dockerfiles to lint, more than one cluster, maybe some infrastructure that predates the cluster it now sits next to — keeps OPA and Conftest, for the mirror-image reason. The same deny idiom, the same opa test discipline, and the same CI wiring pattern covers a Terraform plan today and a raw Kubernetes manifest tomorrow, tested with plain conftest test and no cluster in the loop at all. One Rego skillset, amortized across every surface this team already has to gate, instead of Kyverno's YAML for the Kubernetes slice and nothing — or a second language — for everything Kyverno structurally cannot reach.

◆ Key idea

Plenty of real platform teams end up running both, and that's not indecision — it's matching the tool to whichever half of the infrastructure a given resource actually flows through. The skill this drill built isn't "pick a favorite policy engine." It's noticing, for any resource you're about to gate, whether it will ever cross a Kubernetes API server on its way into existence — because that answer decides which of these two tools can even see it, before either one's syntax is a factor at all.

Milestones

☺ Like you're 10: Tick a box only once you've actually watched the command run and seen the output described — not because it sounds like it should work.

Work these in order. Progress saves in this browser.

0 / 8 milestones complete
1Lay out the fixture repo
Create the six fixture files — three Terraform root modules and three Kubernetes manifests — exactly as shown above.
Done when: terraform validate succeeds independently in all three terraform/*/ directories, and all three YAML files under kubernetes/ parse with kubectl apply --dry-run=client -f (once the CRD from milestone 6 is installed) or a plain YAML linter.
2Write draft-1 Rego and its unit tests
Create opa/ssh_open_to_world.rego (the buggy, index-zero version) and opa/ssh_open_to_world_test.rego exactly as shown above.
Done when: opa test opa -v reports PASS: 2/3 with test_deny_when_world_cidr_is_not_first_in_the_list as the named failure.
3Fix the Rego rule and prove it against all three real plans
Swap the buggy line for some cidr in rule.cidr_blocks; cidr == "0.0.0.0/0", confirm opa test passes 3/3, then generate plan.json in each Terraform fixture directory and run conftest test --policy opa against each.
Done when: Conftest reports zero failures against terraform/ok/plan.json and exactly one named failure against each of terraform/bad/plan.json and terraform/bad-multi/plan.json.
This is the first half of the drill's done-when.
4Write draft-1 Kyverno and its test file
Create kyverno/no-ssh-from-anywhere.yaml (the buggy, exact-equality version, in Audit mode) and kyverno/kyverno-test.yaml exactly as shown above.
Done when: kyverno test kyverno/ reports 2 tests passed, 1 failed, naming bastion-sg-bad-wide as the mismatch — expected fail, got pass.
Tool: Kyverno
5Fix the Kyverno rule and prove it against all three fixtures
Swap Equals 22 on both fromPort and toPort for LessThanOrEquals 22 / GreaterThanOrEquals 22, exactly as shown above.
Done when: kyverno test kyverno/ reports 3 tests passed, 0 failed, and kyverno apply kyverno/no-ssh-from-anywhere.yaml --resource kubernetes/sg-bad-wide.yaml reports fail: 1.
This is the second half of the drill's done-when.
6Roll it out for real — Audit, review, then Enforce
Install Kyverno and the ACK SecurityGroup CRD on a throwaway kind cluster, apply the fixed policy in Audit, apply sg-bad.yaml and confirm it's admitted anyway, review kubectl get policyreport -A, then flip to Enforce and reapply.
Done when: after flipping to Enforce, deleting and reapplying kubernetes/sg-bad.yaml is rejected outright by the admission webhook, with an error naming no-ssh-from-anywhere and block-ssh-open-to-world.
7Wire the OPA/Conftest gate into CI as a required check
Add the ssh-policy-check job shown above to .github/workflows/, push, and mark it required in the repo's branch protection settings.
Done when: a test pull request that reintroduces cidr_blocks = ["0.0.0.0/0"] into terraform/bad/main.tf (or any fixture) is blocked from merging by this check, with no manual review needed to catch it.
8Write down which tool your team would actually keep
Using the comparison table above, write two or three sentences: for your own team's actual mix of tooling, which engine wins for this specific requirement, and name the one concrete resource type that would force you to use the other one too.
Done when: your answer names a specific resource or provisioning path — not a general preference — as the reason, the same way this page's own recommendation turns on whether a resource ever crosses a Kubernetes API server.
🎬 At the Shift-Left Squad
🤖

Recon the Robot: Both rules pass their own tests now. Rego's clean, Kyverno's clean. Same requirement, two languages, both correct.

🦊

Foxy: So why keep both? Pick the one that's easier and delete the other.

🤖

Recon the Robot: Because they don't watch the same door. The Terraform security group never touches this cluster — Kyverno has literally nothing to evaluate there. The ACK object never touches Terraform state — Conftest has nothing to plan against. Neither one is redundant with the other.

🦝

Rocky the Raccoon: Then let me try the gap on purpose. What if I skip both — provision the security group by hand, straight through the AWS console?

🤖

Recon the Robot: Neither policy sees that either. That's not a bug in today's rules — that's a third door this drill never built a watcher for. Worth remembering before anyone calls this "fully covered."

🐢

Timmy the Turtle: Which is exactly why I don't trust "the test passed" on its own. Show me it fails on the broken fixture, passes on the clean one, and blocks for real once it's live — in that order, every time.

✓ Checkpoint

1. Why does the exact same plain-English requirement need a separate implementation in Kyverno and in OPA/Conftest, rather than one rule covering both? 2. Describe the specific bug in each engine's first draft, and what the two bugs have in common. 3. What does validationFailureAction: Audit actually prove, and what does it deliberately not do — and why does this drill insist on that order before flipping to Enforce? 4. A local kyverno apply reports the same "failed" line whether the live policy is set to Audit or Enforce. What does that mean for how much a passing local test actually proves? 5. Name the one factor this page uses to decide whether a Kubernetes-only team or a multi-tool team should keep which tool — not a general opinion about which is "better."

Check your answers
  1. Because "security group" names a real-world resource that can be created through two structurally different paths — a plain Terraform aws_security_group that never touches a Kubernetes cluster, or an ACK SecurityGroup custom resource admitted through the Kubernetes API server. Kyverno's admission webhook only ever sees requests that cross the Kubernetes API server; OPA/Conftest evaluates whatever file you hand it, cluster or no cluster. Neither tool can see traffic on the other's path, so the same intent has to be re-implemented at each checkpoint the resource might actually pass through.
  2. The Rego draft checked only rule.cidr_blocks[0] — the first entry in the CIDR list — and missed a world-open CIDR listed second. The Kyverno draft checked fromPort and toPort for exact equality to 22, and missed a rule that opens every port (fromPort: 0, toPort: 65535), which includes port 22 without ever equaling it. Both bugs are the same underlying mistake in different clothing: checking one specific value or position instead of checking membership across a whole range or a whole list.
  3. Audit evaluates the policy against every matching resource and records findings in a PolicyReport, but it never blocks anything — a violating resource is still admitted. It proves how many existing resources would fail the rule before that failure becomes a live outage. This drill insists on Audit first specifically so the one expected finding (the deliberately-broken fixture) doesn't get lost among a surprise backlog of pre-existing violations nobody reviewed before flipping the switch.
  4. It means a passing (or failing) local test proves the rule's own logic is correct, and proves nothing at all about whether that logic is actually being enforced anywhere real. The local CLI never reads validationFailureAction — that's a property of the live ClusterPolicy object in the cluster, and of whether a CI job checking the Terraform side is actually marked as a required, merge-blocking check. Confirming the rule is correct and confirming it's actually enforced are two separate steps, and the second one has to be checked against the real, live system.
  5. Whether the resource being gated will ever cross a Kubernetes API server on its way into existence. If every resource of this type an organization creates flows through a Kubernetes CRD (ACK, Crossplane, or similar), Kyverno can see and block all of it with a language the team already reads. If even one path creates the resource outside Kubernetes entirely — a Terraform module run directly against a cloud provider — Kyverno has no visibility into that path at all, and OPA/Conftest, which only needs a JSON- or HCL-shaped file rather than a live cluster, is the only one of the two that can reach it.

You've now written the same governance intent twice, watched a realistic bug slip past each engine's first draft, and proven both directions — fails on broken, passes on clean, and actually blocks once it's live, not just once it's tested. Continue to Drill — Fix a Broken Terraform Plan for more Terraform-side practice, or Drill — Threat Model a New Feature for the design-time skill this page's rules were written to enforce downstream of. Revisit OPA & Conftest and Kyverno for the full depth behind each engine, IaC security & policy as code for where this fits the broader gating strategy, and the full capstone hub to see this same pattern worked through end to end against a real pipeline.