Policy-as-Code Testing with Open Policy Agent (OPA) and conftest

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 Dockerfile

It 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:latest

Writing 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.rego

Terraform Plan Policies

First, generate a JSON Terraform plan:

terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json

Now 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 json

Kubernetes 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/ -v

Output:

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.0

This 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.

Read more