Tools Used in DevSecOps · OPA & Conftest

OPA & Conftest

The Open Policy Agent (OPA) is a general-purpose policy engine: hand it a JSON document describing some situation — a Kubernetes object about to be admitted, an HTTP request hitting a service, a Terraform plan about to be applied — and a policy written in its language, Rego, and it returns a decision. It doesn't know or care what a "Kubernetes object" or a "Terraform plan" actually is; to OPA, everything is just structured data to evaluate a query against. Conftest is a thin, purpose-built CLI wrapped around that same engine, aimed squarely at one job: parse a config file — Terraform, Kubernetes YAML, a Dockerfile, Helm output, almost anything structured — into the JSON shape OPA already knows how to evaluate, run your Rego policies against it, and exit non-zero if anything fails, so a CI step can gate on the result. By the end of this page you should be able to write a real Rego rule, explain OPA's different deployment shapes and where Conftest sits among them, and say precisely why this pairing is the general-purpose, write-your-own-rules counterpart to a Kubernetes-only, no-new-language tool like Kyverno.

☺ Explain it like I'm 10

Imagine a rulebook checker who doesn't care what kind of form you hand them — a permission slip, a library card application, a science-fair entry — as long as it's filled out with clear boxes and labels, they can check it against whatever rules you gave them. OPA is that checker, and Rego is the language its rulebooks are written in. Conftest is a helper who specializes in one thing: taking messy paperwork — blueprints (Terraform), instruction sheets (Kubernetes YAML), recipe cards (Dockerfiles) — translating each one into the neat boxes-and-labels format the checker understands, handing it to the checker, and reporting back "approved" or exactly which rule got broken.

🤖Your host for this topic: Recon the Robot — Recon is the reconciler who never negotiates with drift, and OPA is the engine behind that discipline: one policy language, evaluated the same way every time, whether the input is a live admission request or a Terraform plan sitting in a CI job.

What OPA is, and the problem it solves

☺ Like you're 10: Instead of teaching every single app its own private rulebook, you write the rules once, in one place, and every app just asks that one place "is this allowed?"

The Open Policy Agent project started at Styra around 2016, led by Torin Sandall and a small team of engineers who'd watched the same problem repeat across companies: every service that needed to make an authorization or policy decision — is this API call allowed, is this Kubernetes object safe to admit, is this infrastructure change compliant — ended up hand-rolling its own if/else logic, buried inside application code, invisible to anyone who wasn't the original author. OPA's answer is decoupling: pull that decision-making logic out of the application entirely, express it as a declarative policy, and let any service ask OPA the question instead of encoding the answer itself. The project joined the CNCF as an incubating project in 2018 and graduated in 2021 — the same top tier of CNCF maturity as Kubernetes itself.

Mechanically, OPA's whole interface is one idea repeated everywhere it runs: give it an input document (whatever JSON-shaped data describes the thing being decided) plus a set of Rego rules, and it evaluates a query against both and returns a result — a boolean, a set of violation messages, or a more structured decision object. That's the entire contract. What differs from deployment to deployment is only what's plugged into input and how the result gets used: a Kubernetes admission webhook plugs in the incoming object and treats the result as allow/deny; an Envoy sidecar plugs in request metadata and treats the result as authorize/reject; Conftest plugs in a parsed config file and treats the result as pass/fail for a CI step.

◆ Key idea

OPA has no idea what a Kubernetes Deployment or a Terraform resource actually is. It only ever sees a JSON tree and a query. Every bit of domain awareness — "this field is a container's resource limits," "this field is a security group's ingress rule" — lives entirely in the Rego you write, not in OPA itself. That's the source of both its biggest strength (one engine, any input shape) and its biggest cost (zero built-in coverage on day one, unlike a purpose-built scanner that ships thousands of pre-written checks).

Rego: the policy language

☺ Like you're 10: You don't write step-by-step instructions — you write "here's what has to be true for this to count," and the engine figures out whether it's true.

Rego is a declarative query language, descended from Datalog and extended to work naturally over JSON. You're not writing a sequence of steps that runs top to bottom; you're writing a set of conditions that either hold or don't, given the current input and data. A file starts with a package declaration, which namespaces every rule inside it under data.<package>:

package main

# a "complete" rule: one value, computed once. Multiple conflicting
# definitions of a complete rule are an evaluation ERROR, not a merge.
allow := true if {
    input.method == "GET"
    input.path == ["healthz"]
}

# a "partial set" rule: builds up a SET of values, one member per rule
# body that's satisfied. This is the shape almost every policy-as-code
# check uses — each satisfied condition contributes one violation.
deny contains msg if {
    input.kind == "Deployment"
    some container in input.spec.template.spec.containers
    not container.resources.limits
    msg := sprintf("container %q has no resource limits", [container.name])
}

Two things about that deny rule are worth internalizing before writing a single real policy. First, everything inside one rule's curly braces is implicitly ANDed — all of those lines have to hold for that rule body to fire. Second, and far more consequential: if you write two separate deny contains msg if { ... } blocks in the same file, Rego doesn't require both to fire — it evaluates each independently and unions the results into one set. Either block firing on its own adds a message to deny. For a rule literally named deny, that union-as-OR behavior is exactly what you want — any one violation should still be a violation. It becomes a genuine security bug the moment the same pattern is applied to an allow-style rule: two separate allow blocks, each checking a different condition, don't require both — they silently grant access if either one is satisfied, which is rarely what the author intended. The fix is one rule body with every required condition ANDed inside it, not several same-named rules assuming they combine as AND.

The syntax above uses Rego v1if introducing a rule body and contains marking a partial-set rule — which has been the default dialect since OPA v1.0 (mid-2024). Code written against older tutorials often omits both keywords (deny[msg] { ... }) or requires import future.keywords to opt in early. If a policy fails to parse with a keyword-related error, that version mismatch is almost always the cause; import rego.v1 at the top of a file restores v1 semantics explicitly on an OPA build that still defaults to the older dialect, and opa fmt can mechanically migrate old syntax forward. Because this detail moves with every major OPA release, verify the current default against the OPA documentation for whatever version is actually pinned in your pipeline rather than assuming either syntax is safe everywhere.

A short list of built-ins covers most real policies: sprintf for building message strings, contains/startswith/endswith for string checks, count/sum for aggregation, some x in collection for iteration, and comprehensions ([x | ...], {x | ...}) for building new sets or arrays from existing data. opa test — covered below — runs alongside real policy files with a _test.rego suffix, using functions prefixed test_, which is how a policy earns the same "code" treatment described in IaC security & policy as code: reviewable, versioned, and unit-tested like anything else in the repository.

Where OPA runs, and where Conftest fits in that picture

☺ Like you're 10: The same rulebook checker can sit in four different jobs — answering one question and leaving, sitting at a desk all day answering questions as they arrive, working inside another employee's own office, or checking every form before it's allowed into the building. Conftest is that fourth job, specialized for paperwork specifically.

OPA is a single evaluation engine that gets packaged into a running system four distinct ways, and knowing which shape a given integration uses explains a lot about how it behaves operationally.

Rego policy .rego files, a package namespace under data.* input document JSON — an admission request, a parsed file, ... Rego evaluation core compiles + runs a query, returns a result document opa eval one-shot CLI — scripts, CI, ad hoc queries opa run --server daemon + REST API — Envoy ext_authz, apps Go SDK, embedded in-process, inside your own service binary Gatekeeper K8s admission webhook + Constraint CRDs Conftest — a fifth, config-file-shaped front end same one-shot core as opa eval, plus built-in parsers (YAML, JSON, HCL, Dockerfile, TOML...) and a deny/warn convention — one CI-shaped binary, not a daemon

The two ends of that spectrum matter most in practice. opa run --server turns OPA into a long-running daemon with a REST API — the shape a sidecar like Envoy's ext_authz filter calls over the network on every request, trading a network hop for centralized policy management. Gatekeeper is the OPA-based Kubernetes admission controller: it wraps the same engine behind a validating (and, more recently, mutating) webhook, with policies expressed as ConstraintTemplate CRDs (compiling Rego) parameterized by separate Constraint CRDs. That's worth naming explicitly, because it's easy to conflate two different "OPA meets Kubernetes" stories: Gatekeeper is OPA inside the cluster, gating live admission requests in real time; Conftest — this page's other half — is OPA outside the cluster entirely, testing a YAML file that hasn't been applied yet, as a CI step. Both are legitimate, and many teams run both — Conftest catching an obvious misconfiguration in a pull request before it's even merged, Gatekeeper as the runtime backstop for anything that reaches the API server by some other path (a hotfix applied directly with kubectl, a controller-generated object, a manifest that skipped review).

Conftest sits architecturally next to opa eval — a one-shot process that starts, evaluates, prints a result, and exits, not a daemon. What it adds on top is the part that makes it worth reaching for instead of opa eval directly: built-in parsers for the file formats a platform team actually deals with, and a rule-naming convention (deny/warn) that turns "evaluate this query" into "test this file and give me a pass/fail," which is a much better fit for a CI step than a raw Rego query string.

Conftest: testing structured config files with OPA

☺ Like you're 10: Conftest reads the file, turns it into the same kind of boxes-and-labels document OPA already understands, and asks your rulebook "any problems with this one?"

Conftest started as an independent open-source project by Gareth Rushgrove and has lived under the open-policy-agent GitHub organization for years now, developed as part of the wider OPA ecosystem. Its whole job is translation plus convention. The translation part: Conftest ships parsers that turn a config file into the JSON-shaped document Rego evaluates, for a wide range of formats — YAML, JSON, TOML, INI, XML, HCL/HCL2 (raw .tf files), Dockerfile (via a dedicated instruction-by-instruction parser, not a generic format), Jsonnet, CUE, EDN, and VCL among others. The convention part: rules named deny fail the check and set a non-zero exit code; rules named warn are printed but don't fail the build by default; a policy's package defaults to main, but multiple namespaces can coexist in one policy/ directory and be selected with --namespace or evaluated all together with --all-namespaces.

File typeWhat Conftest hands to Rego as input
Kubernetes YAMLThe manifest, parsed as-is — input.kind, input.spec.template.spec.containers, etc.
Terraform (raw .tf)The HCL2 structure — literal values only; anything resolved from a variable, module, or provider default is invisible
Terraform plan JSONWhatever terraform show -json emits — the fully resolved plan, including resource_changes
DockerfileAn array of instruction objects (each with a Cmd and a Value) — not a keyed document, unlike the formats above

That last row is a genuine trap covered again below: a policy written for the Kubernetes shape (input.kind == "Deployment") simply never matches against a Dockerfile input, because a Dockerfile's input has no kind field at all — it's a list, not an object. The failure mode isn't a parse error; it's a policy that silently checks nothing.

Real policies you'll actually write

☺ Like you're 10: Same language, three completely different-looking inputs — a cloud infrastructure plan, a Kubernetes YAML file, and a container build recipe.

A Terraform example first, run against a fully resolved plan rather than raw HCL — the reason for that distinction is covered under Gotchas. This checks for a security group open to the entire internet, the canonical policy-as-code example because it's exactly the kind of mistake a human reviewer skims past and an automated gate never does:

# policy/terraform/security_groups.rego
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_security_group_rule"
    rc.change.after.type == "ingress"
    rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
    msg := sprintf(
        "%s allows unrestricted ingress from 0.0.0.0/0",
        [rc.address],
    )
}
$ terraform show -json plan.out > plan.json
$ conftest test --policy policy/terraform plan.json
FAIL - plan.json - main - aws_security_group_rule.web_ssh allows unrestricted ingress from 0.0.0.0/0

2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

Next, a Kubernetes example — requiring every container to declare resource limits, the same requirement Kyverno enforces natively but expressed here in Rego instead of a Kyverno CRD:

# policy/kubernetes/resource_limits.rego
package main

deny contains msg if {
    input.kind == "Deployment"
    some container in input.spec.template.spec.containers
    not container.resources.limits
    msg := sprintf(
        "container %q in %q has no resource limits set",
        [container.name, input.metadata.name],
    )
}

And a Dockerfile example, working against the instruction-array shape from the table above — flagging a mutable :latest base-image tag, the same class of finding a supply-chain scan cares about because a tag can point to a different image tomorrow than it does today:

# policy/docker/base_image.rego
package main

deny contains msg if {
    some instruction in input
    instruction.Cmd == "from"
    val := instruction.Value
    contains(val[0], ":latest")
    msg := sprintf("base image %q uses the mutable :latest tag", [val[0]])
}

Exact field names for less common parsers do shift between Conftest releases — check the parser's current output against the -o json flag on a real file before assuming a field name from an older example still matches, the same caution the Terraform-vs-plan-JSON distinction above is really making.

A unit test for the Kubernetes rule, run with opa test directly rather than through Conftest — this is what makes a Rego policy itself reviewable and testable, not just the config it checks:

# policy/kubernetes/resource_limits_test.rego
package main_test

import data.main

test_deny_when_limits_missing if {
    count(main.deny) == 1 with input as {
        "kind": "Deployment",
        "metadata": {"name": "checkout"},
        "spec": {"template": {"spec": {"containers": [
            {"name": "web"},
        ]}}}}
}

test_allow_when_limits_present if {
    count(main.deny) == 0 with input as {
        "kind": "Deployment",
        "metadata": {"name": "checkout"},
        "spec": {"template": {"spec": {"containers": [
            {"name": "web", "resources": {"limits": {"memory": "512Mi"}}},
        ]}}}}
}
$ opa test policy/kubernetes -v
data.main_test.test_deny_when_limits_missing: PASS (0.4ms)
data.main_test.test_allow_when_limits_present: PASS (0.3ms)
--------------------------------------------------------------------------------
PASS: 2/2

Day-to-day commands and CI wiring

☺ Like you're 10: A handful of commands cover almost everything: test a file, test many files together, publish the rulebook somewhere shared, run the rulebook's own tests.

# the core command — test one or more files against a policy directory
$ conftest test --policy policy/kubernetes k8s/deployment.yaml

# glob a whole directory of manifests in one invocation
$ conftest test --policy policy/kubernetes k8s/*.yaml

# --combine merges every matched file into ONE input array, for policies that
# have to reason ACROSS files — e.g. "every Deployment needs a matching PDB"
$ conftest test --policy policy/kubernetes --combine k8s/*.yaml

# evaluate every namespace (package) found under policy/, not just "main"
$ conftest test --policy policy/ --all-namespaces k8s/*.yaml

# machine-readable output for a dashboard or GitHub PR annotations
$ conftest test --policy policy/ --output junit k8s/*.yaml > results.xml
$ conftest test --policy policy/ --output github k8s/*.yaml

# distribute a policy bundle as an OCI artifact — same idea as an OCI Helm chart
$ conftest push oci://ghcr.io/acme/policies:latest ./policy
$ conftest pull oci://ghcr.io/acme/policies:latest

# run the policies' OWN unit tests (wraps `opa test` under the hood)
$ conftest verify --policy policy/

# exit code: 0 = no deny hits, 1 = at least one deny hit — this is what a CI step checks
$ echo $?

A CI step usually chains a render step in front of Conftest, since Conftest's job is testing already-structured files, not producing them:

# .github/workflows/policy.yml — pin real action and Conftest versions before using
name: policy-check
on: [pull_request]
jobs:
  conftest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install conftest
        run: |
          curl -sSL -o conftest.tar.gz \
            https://github.com/open-policy-agent/conftest/releases/download/v0.56.0/conftest_0.56.0_Linux_x86_64.tar.gz
          tar xzf conftest.tar.gz
          sudo mv conftest /usr/local/bin

      - name: Render the Terraform plan to JSON
        run: |
          terraform init -input=false
          terraform plan -out=plan.out -input=false
          terraform show -json plan.out > plan.json

      - name: Test the plan against policy
        run: conftest test --policy policy/terraform plan.json

      - name: Test the Kubernetes manifests
        run: conftest test --policy policy/kubernetes --combine k8s/*.yaml

See Part 5 of the capstone — scan IaC & enforce policy for this exact pattern worked through end to end against a real pipeline, and the write-a-policy-as-code-rule drill for isolated Rego practice.

Gotchas and failure modes

☺ Like you're 10: Almost every surprise traces back to one of two things — Rego's OR-not-AND behavior across repeated rule names, or feeding it a file that doesn't contain what you assumed it did.

⚠ Watch out — "no findings" and "policy never ran" look identical

Conftest exits 0 both when every rule was evaluated and passed, and when a rule never matched anything because the input shape was wrong. Nothing in the default output distinguishes "checked, and clean" from "silently skipped." When adopting a new policy against an unfamiliar file type, deliberately break the file first — comment out a resource limit, add a :latest tag — and confirm the policy actually fails before trusting it to pass real files.

OPA & Conftest vs. Kyverno vs. Checkov/tfsec

☺ Like you're 10: One tool learns a whole new language so it can check anything; one tool only speaks Kubernetes but needs no new language at all; one tool arrives with thousands of rules already written for you.

These three aren't really competing for the same job — they trade generality, learning curve, and out-of-the-box coverage against each other in different amounts.

ToolScopePolicy languageBuilt-in coverageBest when
OPA & ConftestAnything JSON-shaped — Terraform, Kubernetes, Dockerfiles, live authorization requests, arbitrary business documentsRego — a real declarative language, genuinely new to most engineersNone. You write every rule, or pull a community bundleYou need one policy engine across heterogeneous inputs, or logic too custom for a pre-built library to express — including runtime authorization, not just static file checks
KyvernoKubernetes only — admission control, background scans, mutation, resource generationPlain YAML — no new language, if you already know a Kubernetes manifestA strong out-of-the-box policy library, but scoped to Kubernetes concepts specificallyEverything you need to gate is a Kubernetes object, and you'd rather not learn Rego at all
Checkov / tfsecInfrastructure-as-code specifically — Terraform, CloudFormation, Kubernetes, Dockerfiles, ARMBuilt-in checks in Python/YAML; some custom-check support, not a general query languageThousands of pre-written checks covering common misconfigurations across major cloud providersYou want broad IaC misconfiguration coverage fast, with minimal policy-authoring effort

The practical pattern many platform teams land on: a purpose-built scanner like Checkov for the broad, unglamorous coverage — the thousand small misconfigurations nobody wants to hand-write a rule for — with OPA and Conftest layered in specifically for the organization-specific rules no generic scanner could know about ("this Terraform module must set acme:cost-center," "this Deployment must reference an approved base-image registry"), and the same Rego skill reused at runtime for live authorization decisions where Checkov and Kyverno have no presence at all. See infrastructure-as-code hardening and compliance as code at scale for how this fits the broader gating strategy, and the Kubernetes security deep dive for where Gatekeeper and Kyverno sit relative to each other inside a cluster specifically.

🎬 At the Shift-Left Squad
🤖

Recon the Robot: Pushed a rendered Terraform plan through Conftest before the apply job even started. One failure: a security group open to the whole internet.

🦫

Benny the Beaver: That's my staging box. I opened port 22 while debugging Tuesday and never closed it back up.

🐢

Timmy the Turtle: Which is exactly why it's a gate and not a "remember to check" step. The apply job never got the chance to run with it open.

🦊

Foxy: Hold on — isn't this the same job Kyverno does? Why do we need both in the course?

🤖

Recon the Robot: Kyverno only ever sees a Kubernetes object crossing the admission webhook. This plan never touches a cluster — it's a Terraform apply, a different door entirely. Same idea, different threshold.

🐘

Ellie the Elephant: And the same Rego package covers the Dockerfile scan too, right next to the Terraform one. One language, three completely different file shapes.

🦉

Professor Owl: That's the entire pitch of a general-purpose engine. One policy language, wherever the input happens to come from — the trade is that nobody hands you the rules pre-written.

✓ Checkpoint

1. What's the difference between OPA and Conftest — what does Conftest add on top of the same evaluation core? 2. Explain the OR-not-AND gotcha with repeated same-named Rego rules, and why it's specifically dangerous for an allow rule rather than a deny rule. 3. Why does testing a raw .tf file catch less than testing terraform show -json output? 4. What does it mean that a Kubernetes-shaped policy run against a Dockerfile input "silently checks nothing," and how would you catch that before trusting a clean pipeline run? 5. Give the one-sentence version of how OPA/Conftest differs from Kyverno in scope and language.

Check your answers
  1. OPA is the general evaluation core — compile Rego, evaluate a query against an input document, return a result — and it gets deployed as a one-shot CLI, a long-running server, an embedded library, or a Kubernetes admission controller (Gatekeeper). Conftest is a specialized front end built on that same one-shot evaluation core, adding built-in parsers for config-file formats (YAML, JSON, HCL, Dockerfile, TOML, and more) and a deny/warn naming convention, packaged specifically for testing files in a CI step.
  2. Multiple rule definitions sharing the same name in Rego are evaluated independently and unioned into one result set — the union behaves as OR, not AND: any one matching block contributes to the result. For a deny rule that's exactly right, since any one violation should still count as a violation. For an allow rule it's dangerous: two separate allow blocks checking different conditions don't require both to hold — either one passing grants access, silently making the policy more permissive than intended. The fix is one rule body with every required condition ANDed together inside it.
  3. A raw .tf file only contains literal source values — anything computed from a variable, module input, or provider default appears as an unresolved reference (like the literal string "var.allowed_cidr"), not its real value. terraform show -json output is the fully resolved plan, showing what will actually be applied, which is the only version a policy can check meaningfully.
  4. Conftest's Dockerfile parser produces an array of instruction objects with no kind field, unlike Kubernetes YAML's keyed document shape. A policy written to check input.kind == "Deployment" simply never matches anything in that array — it doesn't error, it just never fires, so the pipeline reports a clean pass without ever having evaluated a meaningful check. Catching it means deliberately breaking a test file first (adding a violation on purpose) and confirming the policy actually fails before trusting it against real files.
  5. Kyverno is Kubernetes-only and needs no new language, since its policies are plain YAML CRDs; OPA and Conftest are general-purpose, working against any JSON-shaped input — Terraform, Dockerfiles, live authorization requests, and Kubernetes objects alike — at the cost of learning Rego, a genuinely new declarative language, and getting no pre-written rules out of the box.