Conftest in CI
Gate configs against policy, shift-left.
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.
# macOSbrew 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 conftestconftest --version
Conftest: 0.68.2OPA: 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.
package mainrequired_tags := {"owner", "data_classification"}# SOC2 CC6.1 — every bucket must declare an accountable owner + classificationdeny contains msg if {resource := input.resource_changes[_]resource.type == "aws_s3_bucket"some tag in required_tagsnot 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 internetdeny contains msg if {resource := input.resource_changes[_]resource.type == "aws_security_group"rule := resource.change.after.ingress[_]rule.from_port <= 22rule.to_port >= 22rule.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.
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 = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"] # SSH open to the world}}
terraform init -input=falseterraform plan -out plan.tfplan -input=falseterraform show -json plan.tfplan > plan.jsonconftest test plan.jsonecho "exit code: $?"
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 exceptionsexit 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.
# main.tf now: tags = { owner = "platform-team", data_classification = "confidential" }# cidr_blocks = ["10.0.0.0/8"]terraform plan -out plan.tfplan -input=falseterraform show -json plan.tfplan > plan.jsonconftest test plan.jsonecho "exit code: $?"
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptionsexit 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.
name: compliance-gateon: pull_requestjobs:conftest:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: hashicorp/setup-terraform@v3- name: Render the plan as JSONrun: |terraform init -input=falseterraform plan -out plan.tfplan -input=falseterraform show -json plan.tfplan > plan.json- name: Install Conftestrun: |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 blockedrun: conftest test plan.json --all-namespaces
conftest test deploy.yaml prints 0 tests, 0 passed and never fails the build. What is most likely going on?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?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.