Skip to main content
Cloud Security · lesson 2 of 8
Cloud Security intermediate Lesson 2 of 8

Also part of: DevSecOps (AppSec in the pipeline) Terraform

Policy as Code: Testing Terraform Plans with OPA and Conftest

Write Rego rules and run them on a real terraform plan: untagged resources and SSH open to the internet fail, the fix passes, the policy has its own tests.

By SkillByExample · Published

cloud-security terraform opa rego conftest

Checkov ships with someone else’s rules. Your organisation has its own: every resource has an owner, nothing listens to the whole internet on SSH. Policy as code writes those rules in Rego, the language of Open Policy Agent, and Conftest runs them against the plan Terraform is about to apply.

Tested with Terraform 1.9.8, the AWS provider 6.68.0 and Conftest 0.71.1 (OPA 1.21.1) in Docker (openpolicyagent/conftest:v0.71.1). Define the alias first, and on Git Bash for Windows prefix docker with MSYS_NO_PATHCONV=1:

alias conftest='docker run --rm -v "$PWD:/project" openpolicyagent/conftest:v0.71.1'

A real plan, with fake credentials

The provider block uses placeholder keys and skips every call that would need a real account, so terraform plan runs offline. Nothing here is ever applied:

# main.tf: planned offline with fake credentials, never applied
provider "aws" {
  region                      = "eu-west-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_requesting_account_id  = true
  skip_metadata_api_check     = true
}

resource "aws_s3_bucket" "reports" {
  bucket = "acme-reports"
}

resource "aws_security_group" "admin" {
  name = "admin"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
$ terraform init -no-color | grep -E "Installed|initialized"
- Installed hashicorp/aws v6.68.0 (signed by HashiCorp)
Terraform has been successfully initialized!
$ terraform plan -no-color -out=tfplan | grep -E "^Plan:|Saved"
Plan: 2 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
$ terraform show -json tfplan > plan.json

init downloads the provider, which is the only network access needed. plan works out the two creates without calling AWS. show -json turns the binary plan into JSON with a resource_changes list: one entry per resource, with type, address and the values it will have in change.after. That list is what the policy reads.

Write the rules

A Conftest policy is a Rego file whose deny rules produce messages. Every message is a failure:

# policy/terraform.rego: every resource has an owner, no SSH from the internet
package main

taggable := {"aws_s3_bucket", "aws_security_group"}

deny contains msg if {
	some r in input.resource_changes
	taggable[r.type]
	not r.change.after.tags.owner
	msg := sprintf("%s has no owner tag", [r.address])
}

deny contains msg if {
	some r in input.resource_changes
	r.type == "aws_security_group"
	some rule in r.change.after.ingress
	"0.0.0.0/0" in rule.cidr_blocks
	msg := sprintf("%s allows port %d from 0.0.0.0/0", [r.address, rule.from_port])
}
$ conftest test --no-color plan.json
FAIL - plan.json - main - aws_s3_bucket.reports has no owner tag
FAIL - plan.json - main - aws_security_group.admin allows port 22 from 0.0.0.0/0
FAIL - plan.json - main - aws_security_group.admin has no owner tag

3 tests, 0 passed, 0 warnings, 3 failures, 0 exceptions
$ echo $?
1

Each rule body is a list of conditions that must all hold. The first rule fires for every taggable resource where tags.owner is missing; in the plan both resources have tags: null, so both fire. The second walks every ingress rule and fires when 0.0.0.0/0 is in its CIDR list. Conftest looks in policy/ and the main package by default, prints one FAIL per message, and exits 1.

Fix the plan, not the policy

Add owners and narrow SSH to the VPN range:

# main.tf: owners tagged, SSH only from the VPN range
provider "aws" {
  region                      = "eu-west-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_requesting_account_id  = true
  skip_metadata_api_check     = true
}

resource "aws_s3_bucket" "reports" {
  bucket = "acme-reports"
  tags   = { owner = "data-team" }
}

resource "aws_security_group" "admin" {
  name = "admin"
  tags = { owner = "platform-team" }
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.8.0.0/16"]
  }
}
$ terraform plan -no-color -out=tfplan | grep -E "^Plan:|Saved"
Plan: 2 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
$ terraform show -json tfplan > plan.json
$ conftest test --no-color plan.json

2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
$ echo $?
0

Clean, exit 0. The counting looks odd until you know the rule: each deny or warn rule body counts as one test, and a body that produces messages counts one failure or warning per message instead. Two rules with nothing to say is “2 tests, 2 passed”; the broken plan had three messages from the same two rules, so it reported “3 tests”.

Test the policy itself

A policy is code, so it can be wrong. Rules named test_ run against inputs you write by hand, with no plan needed:

# policy/terraform_test.rego: the policy's own tests, no plan needed
package main

sg(cidr) := {"resource_changes": [{
	"address": "aws_security_group.x",
	"type": "aws_security_group",
	"change": {"after": {
		"tags": {"owner": "ops"},
		"ingress": [{"from_port": 22, "cidr_blocks": [cidr]}],
	}},
}]}

test_ssh_from_internet_is_denied if {
	deny["aws_security_group.x allows port 22 from 0.0.0.0/0"] with input as sg("0.0.0.0/0")
}

test_ssh_from_vpn_is_allowed if {
	count(deny) == 0 with input as sg("10.8.0.0/16")
}
$ conftest verify --no-color

2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions, 0 skipped

with input as replaces the input for one expression, so each test builds a tiny plan with one security group. The first proves the bad case is caught with the exact message. The second proves the good case produces no deny at all. conftest test ignores test_ rules, so the same policy/ directory serves both commands.

Practice

  1. Add a rule that warns, without failing, when a taggable resource has no cost-center tag. Run it against the fixed plan, then again with --fail-on-warn.
Solution

Put this in policy/cost.rego. Conftest merges files that share a package, so taggable is visible:

package main

warn contains msg if {
	some r in input.resource_changes
	taggable[r.type]
	not r.change.after.tags["cost-center"]
	msg := sprintf("%s has no cost-center tag", [r.address])
}
$ conftest test --no-color plan.json
WARN - plan.json - main - aws_s3_bucket.reports has no cost-center tag
WARN - plan.json - main - aws_security_group.admin has no cost-center tag

3 tests, 1 passed, 2 warnings, 0 failures, 0 exceptions
$ echo $?
0
$ conftest test --no-color --fail-on-warn plan.json
WARN - plan.json - main - aws_s3_bucket.reports has no cost-center tag
WARN - plan.json - main - aws_security_group.admin has no cost-center tag

3 tests, 1 passed, 2 warnings, 0 failures, 0 exceptions
$ echo $?
1

warn is how a new rule rolls out: visible in every run, blocking nothing, until the team flips --fail-on-warn. The hyphenated key needs tags["cost-center"], because tags.cost-center would parse as a subtraction.

  1. A plan that deletes an old bucket has "actions": ["delete"] and "after": null. Write a test that deleting a bucket is allowed, run it against the policy, then fix the policy.
Solution
package main

test_deleting_a_bucket_is_allowed if {
	count(deny) == 0 with input as {"resource_changes": [{
		"address": "aws_s3_bucket.old",
		"type": "aws_s3_bucket",
		"change": {"actions": ["delete"], "after": null},
	}]}
}
$ conftest verify --no-color
FAIL - policy/delete_test.rego -  - data.main.test_deleting_a_bucket_is_allowed

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

The owner rule fires on the deletion: after is null, so tags.owner is undefined and not succeeds. Removing an untagged resource is exactly what you want, yet the policy would block it. Add r.change.after != null before the not line in the first rule:

$ conftest verify --no-color

3 tests, 3 passed, 0 warnings, 0 failures, 0 exceptions, 0 skipped
  1. Change the message in the second deny rule to "%s port %d is open to the internet", and run conftest verify with the original tests. What happens, and what does that tell you about testing exact messages?
Solution
$ conftest verify --no-color
FAIL - policy/terraform_test.rego -  - data.main.test_ssh_from_internet_is_denied

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

test_ssh_from_internet_is_denied fails, because it checks for the exact old string, while test_ssh_from_vpn_is_allowed still passes. Matching the whole message makes a test break on wording changes. If the wording isn’t part of the contract, assert count(deny) == 1 instead, or check that some message contains the resource address.

Frequently Asked Questions

What is policy as code?
Policy as code means writing an organisation's infrastructure rules, such as every resource needs an owner tag or no SSH from the internet, as code that a tool evaluates automatically. The rules live in version control, get reviewed like any other change, and run in CI against every proposed infrastructure change instead of being checked by hand.
What is the difference between OPA and Conftest?
Open Policy Agent (OPA) is the general-purpose policy engine and Rego is its language. Conftest is a command-line tool built on OPA for testing structured files such as JSON, YAML and HCL. It looks for deny, warn and violation rules in the main package by default and turns their messages into pass and fail results with a CI-friendly exit code.
Why test the terraform plan JSON instead of the .tf files?
The output of terraform show -json contains the final values Terraform intends to create, after variables, locals, modules and defaults are resolved. A rule written against resource_changes in the plan sees what will actually be deployed, which the raw .tf files may not show when values come from variables or modules.
Can terraform plan run without real cloud credentials?
For many configurations, yes. The AWS provider accepts placeholder keys when skip_credentials_validation, skip_requesting_account_id and skip_metadata_api_check are set, so a plan that creates new resources and reads no data sources completes offline. Plans that read existing infrastructure through data sources or refresh state still need real access.
How do I test a Rego policy itself?
Write rules whose names start with test_ in a file next to the policy, feed them small inputs with the with input as keyword, and run conftest verify or opa test. Each test passes when its body is true, so a policy change that stops catching a known bad case fails the policy's own test suite before it reaches CI.