Policy-as-Code Testing with Open Policy Agent (OPA) and conftest
Policy-as-code turns compliance requirements into machine-checkable rules. Instead of "all S3 buckets must have versioning enabled" living only in a wiki, it lives in code that runs in your CI pipeline and blocks non-compliant deployments automatically.
Open Policy Agent (OPA) is the most widely adopted policy-as-code engine. conftest is a wrapper that makes it easy to run OPA policies against common infrastructure file formats.
What Is OPA?
OPA is a general-purpose policy engine that evaluates decisions based on:
- Input: the data being checked (a Terraform plan, a Kubernetes manifest, a Docker image config)
- Data: external context (allowed registries, approved regions, team configurations)
- Policy: Rego code that defines rules
The output is a structured decision — allow, deny, a list of violations — whatever your policy defines.
What Is conftest?
conftest wraps OPA and provides a CLI for testing structured configuration files:
conftest test deployment.yaml <span class="hljs-comment"># Test Kubernetes YAML
conftest <span class="hljs-built_in">test plan.json <span class="hljs-comment"># Test Terraform plan
conftest <span class="hljs-built_in">test Dockerfile <span class="hljs-comment"># Test DockerfileIt handles parsing, loads your Rego policies from a policy/ directory, and reports violations.
Installation
# macOS
brew install open-policy-agent/tap/opa
brew install conftest
<span class="hljs-comment"># Linux
curl -L -o conftest https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz
tar xzf conftest*.tar.gz && <span class="hljs-built_in">mv conftest /usr/local/bin/
<span class="hljs-comment"># Docker
docker pull openpolicyagent/opa:latest
docker pull openpolicyagent/conftest:latestWriting Rego Policies
Rego is OPA's policy language. It uses a logic programming style — you define rules that evaluate to true or false based on input data.
Basic Structure
policy/
├── terraform/
│ └── security.rego
├── kubernetes/
│ └── required-labels.rego
└── docker/
└── base-image.regoTerraform Plan Policies
First, generate a JSON Terraform plan:
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.jsonNow write policies against it:
# policy/terraform/security.rego
package terraform.security
import rego.v1
# Deny S3 buckets without versioning
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
resource.change.actions[_] != "delete"
not bucket_has_versioning(resource.address)
msg := sprintf("S3 bucket '%s' must have versioning enabled", [resource.address])
}
bucket_has_versioning(address) if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_versioning"
resource.change.after.bucket == address
resource.change.after.versioning_configuration[_].status == "Enabled"
}
# Deny unencrypted RDS instances
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.actions[_] != "delete"
not resource.change.after.storage_encrypted
msg := sprintf("RDS instance '%s' must have storage_encrypted = true", [resource.address])
}
# Deny security groups allowing 0.0.0.0/0 SSH
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_security_group"
resource.change.actions[_] != "delete"
ingress := resource.change.after.ingress[_]
ingress.from_port <= 22
ingress.to_port >= 22
"0.0.0.0/0" in ingress.cidr_blocks
msg := sprintf(
"Security group '%s' must not allow SSH from 0.0.0.0/0",
[resource.address]
)
}
# Warn about missing required tags
warn contains msg if {
resource := input.resource_changes[_]
resource.change.actions[_] != "delete"
required_tag := {"Environment", "Team", "Service"}[_]
not resource.change.after.tags[required_tag]
msg := sprintf(
"Resource '%s' is missing required tag '%s'",
[resource.address, required_tag]
)
}Test with conftest:
conftest test plan.json --policy policy/terraform/ --parser jsonKubernetes Manifest Policies
# policy/kubernetes/security.rego
package kubernetes.security
import rego.v1
# Deny containers running as root
deny contains msg if {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.securityContext.runAsNonRoot
msg := sprintf(
"Container '%s' in Deployment '%s' must set securityContext.runAsNonRoot = true",
[container.name, input.metadata.name]
)
}
# Deny containers without resource limits
deny contains msg if {
input.kind in {"Deployment", "StatefulSet", "DaemonSet"}
container := input.spec.template.spec.containers[_]
not container.resources.limits.memory
msg := sprintf(
"Container '%s' must specify memory limits",
[container.name]
)
}
# Deny privileged containers
deny contains msg if {
input.kind in {"Deployment", "StatefulSet", "DaemonSet", "Pod"}
container := input.spec.template.spec.containers[_]
container.securityContext.privileged == true
msg := sprintf(
"Container '%s' must not run as privileged",
[container.name]
)
}
# Require specific labels
required_labels := {"app", "version", "team"}
deny contains msg if {
input.kind == "Deployment"
label := required_labels[_]
not input.metadata.labels[label]
msg := sprintf(
"Deployment '%s' is missing required label '%s'",
[input.metadata.name, label]
)
}conftest test deployment.yaml --policy policy/kubernetes/Dockerfile Policies
# policy/docker/base-image.rego
package docker.security
import rego.v1
# Only allow approved base images
approved_images := {
"node:20-alpine",
"python:3.11-slim",
"golang:1.21-alpine",
}
deny contains msg if {
instruction := input[_]
instruction.Cmd == "from"
not instruction.Value[0] in approved_images
msg := sprintf(
"Base image '%s' is not in the approved list",
[instruction.Value[0]]
)
}
# Deny running as root (no USER instruction)
deny contains msg if {
not any_user_instruction
msg := "Dockerfile must specify a non-root USER"
}
any_user_instruction if {
instruction := input[_]
instruction.Cmd == "user"
instruction.Value[0] != "root"
instruction.Value[0] != "0"
}
# Deny ADD instead of COPY (ADD can fetch remote URLs)
warn contains msg if {
instruction := input[_]
instruction.Cmd == "add"
msg := "Use COPY instead of ADD unless you need URL fetching or tar extraction"
}Unit Testing Rego Policies
OPA has a built-in test framework for testing Rego policies in isolation:
# policy/terraform/security_test.rego
package terraform.security_test
import rego.v1
# Test: S3 bucket without versioning is denied
test_s3_missing_versioning_is_denied if {
input := {
"resource_changes": [
{
"address": "aws_s3_bucket.my_bucket",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": {"bucket": "my-bucket"}
}
}
]
}
count(deny) > 0 with data.terraform.security.deny as deny
}
# Test: S3 bucket with versioning passes
test_s3_with_versioning_passes if {
input := {
"resource_changes": [
{
"address": "aws_s3_bucket.my_bucket",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": {"bucket": "my-bucket"}
}
},
{
"address": "aws_s3_bucket_versioning.my_bucket_versioning",
"type": "aws_s3_bucket_versioning",
"change": {
"actions": ["create"],
"after": {
"bucket": "aws_s3_bucket.my_bucket",
"versioning_configuration": [
{"status": "Enabled"}
]
}
}
}
]
}
count(deny) == 0 with data.terraform.security.deny as deny
}Run OPA tests:
opa test policy/ -vOutput:
PASS: 4/4 tests passed (3.2ms)CI Integration
GitHub Actions
name: Policy Checks
on:
pull_request:
paths:
- 'infrastructure/**'
- 'kubernetes/**'
- 'Dockerfile'
jobs:
opa-unit-tests:
name: OPA Policy Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install OPA
run: |
curl -L -o opa https://openpolicyagent.org/downloads/v0.60.0/opa_linux_amd64_static
chmod +x opa
sudo mv opa /usr/local/bin/
- name: Run OPA tests
run: opa test policy/ -v
terraform-policy:
name: Terraform Plan Policy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Install conftest
run: |
wget https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz
tar xzf conftest_0.50.0_Linux_x86_64.tar.gz
sudo mv conftest /usr/local/bin/
- name: Terraform Init
run: terraform init -backend=false
working-directory: infrastructure/
- name: Terraform Plan
run: |
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
working-directory: infrastructure/
- name: Run conftest
run: conftest test infrastructure/plan.json --policy policy/terraform/ --parser json
kubernetes-policy:
name: Kubernetes Manifest Policy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install conftest
run: |
wget https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz
tar xzf conftest_0.50.0_Linux_x86_64.tar.gz
sudo mv conftest /usr/local/bin/
- name: Test Kubernetes manifests
run: conftest test kubernetes/ --policy policy/kubernetes/
dockerfile-policy:
name: Dockerfile Policy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install conftest
run: |
wget https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz
tar xzf conftest_0.50.0_Linux_x86_64.tar.gz
sudo mv conftest /usr/local/bin/
- name: Test Dockerfile
run: conftest test Dockerfile --policy policy/docker/Sharing Policies Across Teams
For organization-wide policies, publish them to an OCI registry and pull with conftest:
# Push policy bundle
conftest push registry.example.com/policies/terraform:v1.0.0 --policy policy/terraform/
<span class="hljs-comment"># Pull in CI
conftest pull registry.example.com/policies/terraform:v1.0.0
conftest <span class="hljs-built_in">test plan.json --update registry.example.com/policies/terraform:v1.0.0This lets platform/security teams maintain centralized policies while application teams pull and run them automatically.
Deny vs Warn
Use deny for hard requirements (must fix before merge) and warn for soft recommendations (log but don't block):
# Hard requirement — blocks CI
deny contains msg if {
# ... policy logic ...
msg := "..."
}
# Advisory — logs but passes
warn contains msg if {
# ... policy logic ...
msg := "..."
}conftest exits non-zero only on violations in deny. Warnings are printed but don't fail the build.
Summary
| Tool | Best For | Output |
|---|---|---|
| OPA CLI | Testing Rego policies | Unit test results |
| conftest | Scanning config files | Pass/fail with violations |
| conftest + Terraform | Plan-time policy enforcement | Pre-apply violation report |
| conftest + Kubernetes | Manifest validation | Pre-apply validation |
Policy-as-code with OPA and conftest shifts compliance left — violations are caught in CI, not discovered in security audits weeks later. Combined with infrastructure testing (Terratest, Molecule) and continuous monitoring (HelpMeTest), you get a complete safety net from code to production.