Cloud Security · lesson 2 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
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
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
- Add a rule that warns, without failing, when a taggable resource has no
cost-centertag. 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.
- 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
- Change the message in the second
denyrule to"%s port %d is open to the internet", and runconftest verifywith 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.