Conftest in CI

Gate configs against policy, shift-left.

Advanced30 min · lesson 5 of 15

Your SOC 2 (Service Organization Control 2, the audit report that describes how you protect customer data) auditor asks a pointed question. Prove that no S3 bucket (Amazon Simple Storage Service, the object store) reaches production without an owner tag, and that no security group opens SSH (Secure Shell, the remote login protocol that lives on port 22) to the internet. A building inspector will not accept "we always use fire-rated doors" written on a form. They want the door checked before the wall closes up. Your auditor wants the same thing: a check that runs on every change and stops the ones that fail. The previous lesson wrote that control once, in Rego, the policy language of OPA (Open Policy Agent). Conftest takes that same Rego and aims it at the configuration files engineers actually type, turning the control into an ordinary pass/fail test the pipeline runs before anything merges.

What Conftest is

A spell-checker reads your document, holds a set of rules in its head, and underlines every word that breaks one. Conftest does that for configuration files. It is a command-line tool from the OPA project. Feed it structured configuration, meaning YAML, JSON, HCL (HashiCorp Configuration Language, what Terraform files are written in), INI, or a Dockerfile, and it runs your Rego rules over the contents and exits non-zero the moment a rule is broken. Rego is declarative, so you never write a loop. You describe the shape of a violation and the engine finds every instance of it in the document for you. Conftest looks for rules named deny, violation, and warn. If the deny set comes back with anything in it, the input failed a control, and each entry is the plain reason why. Two details trip people up. First, the namespace: Conftest evaluates only the package named main unless you tell it otherwise, so a policy you carefully wrote under package kubernetes.security gets skipped in total silence unless you pass --namespace or --all-namespaces. Second, the shape of the tool. Running OPA as a long-lived server gives you a daemon to operate and an API to call. Conftest gives you neither. It is one static binary aimed at the developer inner loop and at CI (continuous integration, the automated build that runs on every change): a file, a policy directory, and an exit code you can hang a build on.

Install it and check the version

Conftest ships as a single binary, so installing it is a one-liner either way, package manager or release archive. Then run the version command and read the second line as carefully as the first. Conftest carries a copy of OPA inside it, and that bundled OPA version decides which dialect of Rego your policies have to be written in. Conftest has shipped OPA 1.x since v0.57 (February 2025), and v0.60 (May 2025) flipped the default dialect from Rego v0 to v1. On any recent build the modern if and contains keywords work with no import line at the top of your policy.

install conftest (macOS or Linux)
# macOS
brew install conftest
# Linux (pin a real release; latest is v0.68.2, April 2026)
curl -sSL https://github.com/open-policy-agent/conftest/releases/download/v0.68.2/conftest_0.68.2_Linux_x86_64.tar.gz \
| sudo tar xz -C /usr/local/bin conftest
conftest --version
expected output
Conftest: 0.68.2
OPA: 1.15.2

Write the control down as policy

Two controls, written as code. The tagging one comes from SOC 2 CC6.1, the criterion about knowing who owns and is accountable for a resource: every S3 bucket carries an owner tag. The network one comes from PCI DSS 1.2.1 (Payment Card Industry Data Security Standard) and CIS AWS 5.2 (Center for Internet Security, the group that publishes hardening benchmarks): no security group may accept inbound SSH on port 22 from 0.0.0.0/0, the notation for every address on the internet. Put the policies in a policy/ directory under package main and Conftest finds them with no flags at all. What each rule walks over is a Terraform plan rendered as JSON. input.resource_changes is the list of every resource Terraform intends to create or change, and change.after holds the values it will actually apply. Notice the rule reads tags_all and not tags. A tag handed down by the provider's default_tags block lands in tags_all, and it should count as present. sprintf then stitches together a message naming both the offending resource and the control it breaks, so a failure reads like an audit finding rather than a stack trace.

policy/terraform.rego
package main
required_tags := {"owner", "data_classification"}
# SOC2 CC6.1 — every bucket must declare an accountable owner + classification
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
some tag in required_tags
not resource.change.after.tags_all[tag]
msg := sprintf("%s: missing required tag %q (SOC2 CC6.1)", [resource.address, tag])
}
# PCI 1.2.1 / CIS AWS 5.2 — SSH must not be open to the internet
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_security_group"
rule := resource.change.after.ingress[_]
rule.from_port <= 22
rule.to_port >= 22
rule.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("%s: SSH (22) open to 0.0.0.0/0 (PCI 1.2.1 / CIS 5.2)", [resource.address])
}

Render the plan, then run the gate

You can hand Conftest your main.tf and it will happily parse it. Do not. The warning below explains exactly what goes wrong. The input you want is the plan JSON, and Terraform gives it to you in two steps. terraform plan writes a binary plan file, and terraform show -json turns that file into JSON with every variable, local, and default_tags value already resolved into the value that will land on the account. Here is the change under review, and it breaks both controls: a bucket tagged with its classification but no owner, and a security group with SSH open to the whole world.

main.tf (the change under review)
resource "aws_s3_bucket" "data" {
bucket = "acme-prod-data"
tags = { data_classification = "confidential" } # owner tag missing
}
resource "aws_security_group" "web" {
name = "web"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # SSH open to the world
}
}
render the plan as JSON, then gate it
terraform init -input=false
terraform plan -out plan.tfplan -input=false
terraform show -json plan.tfplan > plan.json
conftest test plan.json
echo "exit code: $?"
expected output — the control fails the change
FAIL - plan.json - main - aws_s3_bucket.data: missing required tag "owner" (SOC2 CC6.1)
FAIL - plan.json - main - aws_security_group.web: SSH (22) open to 0.0.0.0/0 (PCI 1.2.1 / CIS 5.2)
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
exit code: 1

Two failures, each one naming the resource and the control it breaks. And an exit code of 1. That number is the whole mechanism. It is the only thing standing between a bad change and a merge, because every CI system on the planet knows how to fail a step that exits non-zero. Now fix the config: add the owner tag, and pull SSH back to the corporate address range. Rebuild the plan and run the same command against the same policies.

re-run after fixing main.tf
# main.tf now: tags = { owner = "platform-team", data_classification = "confidential" }
# cidr_blocks = ["10.0.0.0/8"]
terraform plan -out plan.tfplan -input=false
terraform show -json plan.tfplan > plan.json
conftest test plan.json
echo "exit code: $?"
expected output — the control passes
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
exit code: 0

Wire it into CI so the exit code does the blocking

On your laptop, Conftest is fast feedback. In CI, it is the gate. The job renders the plan JSON, installs the binary, and runs conftest test. A violated policy exits non-zero, so the step goes red, the job goes red, and the pull request sits blocked until somebody fixes the config. The control gets checked at the point where fixing it costs a few minutes, instead of during an audit eight months later. Two habits pay off here. Pass --all-namespaces so policies living outside main actually get evaluated. And keep the runs: every green one is a timestamped record that the control was tested on that change, and -o json hands you that record in a machine-readable form you can archive as an audit artifact. Be precise about what it proves. A green run is evidence toward the control, not a certificate of compliance, and your auditor still wants the written narrative and your sampling method. Because this is the same Rego that OPA and Gatekeeper run, one versioned policy library can enforce the control in the pipeline and at admission, out of a single source of truth.

.github/workflows/compliance.yml
name: compliance-gate
on: pull_request
jobs:
conftest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Render the plan as JSON
run: |
terraform init -input=false
terraform plan -out plan.tfplan -input=false
terraform show -json plan.tfplan > plan.json
- name: Install Conftest
run: |
curl -sSL https://github.com/open-policy-agent/conftest/releases/download/v0.68.2/conftest_0.68.2_Linux_x86_64.tar.gz \
| sudo tar xz -C /usr/local/bin conftest
- name: Enforce policy # non-zero exit fails the job -> PR is blocked
run: conftest test plan.json --all-namespaces
The exit code is the gate
conftest test plan.json
evaluate the plan against policy/*.rego in CI
exit 0
0 failures
step passes -> pipeline proceeds, merge allowed; run archived as evidence the control was checked
exit 1
1+ deny fires
step fails -> job red, PR blocked until the config is fixed
warn only
warn rules fire
advisory findings surface but the exit code stays 0, so a control written as warn never blocks a merge
Deny rules gate the build with a non-zero exit code; warn rules only advise. Write a hard control as deny, not warn, or it will not stop anything.
Gate the plan JSON, not raw .tf files
conftest test main.tf reads raw HCL, which is the recipe rather than the finished dish. Variables, locals, module outputs, and the provider's default_tags block are all still unresolved at that point. A bucket that inherits its owner tag from default_tags looks untagged, and you get a false alarm. A value Terraform only computes at apply time looks absent, and a real violation slips past. Run terraform plan -out, then terraform show -json, and gate the resulting plan.json. That file holds the fully resolved values that will actually be applied (default_tags land in tags_all, which is exactly why the policy reads that field), and those are the values your auditor and your running account both care about.
Quick check
01Your team keeps its controls under package kubernetes.security, but conftest test deploy.yaml prints 0 tests, 0 passed and never fails the build. What is most likely going on?
Correct — Conftest's default namespace is main. Point it at your package with --namespace kubernetes.security, or sweep every package with --all-namespaces.
Incorrect — No. Conftest parses YAML, JSON, HCL, INI, and Dockerfiles natively, so a YAML manifest is a supported input.
Incorrect — No. Conftest recognizes deny, violation, and warn equally, so the rule name is not what silenced this policy.
Incorrect — No. Recent Conftest bundles OPA 1.x with Rego v1 as the default dialect, so if and contains compile fine.
02You wrote a hard control as a warn rule instead of a deny rule. CI prints the finding on every pull request, and the pull requests merge anyway. Why?
Correct — The exit code is the entire gate, and only a non-empty deny set turns it non-zero. A control you want enforced has to be written as deny.
Incorrect — No. --all-namespaces decides which packages get evaluated, not which rule names block a build.
Incorrect — No. A finding printed at all means the input was parsed and the rule matched something inside it.
Incorrect — No. A non-zero exit fails the step and fails the job, which is precisely how the workflow in this lesson blocks a pull request.
03You run conftest test main.tf and it reports that aws_s3_bucket.data is missing its required owner tag. You check, and the bucket does get owner from the provider's default_tags block. What do you do next?
Correct — Raw HCL leaves default_tags unresolved, so the tag reads as missing. The plan JSON carries the resolved values, and default_tags land in tags_all, which is the field the policy reads.
Incorrect — No. tags_all is exactly the field that includes default_tags. Reading tags would miss inherited tags even against a correct plan JSON.
Incorrect — No. That flag widens which packages are evaluated. This rule already ran and already matched, so the problem is the input you fed it.
Incorrect — No. Nothing is actually wrong with the bucket. Papering over a false positive caused by the wrong input hides a control you can enforce for real.

One hole is left in this gate, and it is the reason there is a next lesson. Conftest only ever sees changes that travel through the pipeline. Somebody clicking around the cloud console at 11pm, or running kubectl apply from a laptop, walks straight past it, so the control you wrote holds for pipeline changes and nothing else. The fix is to run the same Rego a second time at the other end, as a Kubernetes admission controller that refuses a non-compliant resource at the instant it is created, whichever route it took to get there. That is Policy at admission, next.

Try this

Work through “Wire it into CI so the exit code does the blocking” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: gate the plan JSON, not raw .tf files. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related