Policy as Code
Why Policies Must Be Code
Static analysis tools catch what terraform validate cannot: security misconfigurations, compliance violations, and organizational policy breaches. The critical insight is that policies written in documentation get ignored, while policies written in code get enforced automatically on every commit.
Policy-as-code transforms statements like "all S3 buckets must be encrypted" from a wiki page that developers forget to read into an automated gate that blocks non-compliant infrastructure from reaching production.
Tool Comparison
| Tool | Language | Scope | Speed | Custom Rules | CI Integration |
|---|---|---|---|---|---|
| tfsec | Go | Terraform only | Very fast | Rego, YAML | Native GitHub Action |
| Checkov | Python | Terraform, CloudFormation, K8s, Helm, Dockerfiles | Fast | Python | All major CI systems |
| Trivy (config mode) | Go | Terraform, CloudFormation, K8s, Dockerfiles | Very fast | Rego | GitHub Action, GitLab template |
| OPA/Conftest | Rego | Any structured data (JSON, YAML, HCL) | Fast | Rego (native) | Any CI via CLI |
| Bridgecrew | SaaS + Python | Multi-framework | Fast | Python (extends Checkov) | Full platform + CLI |
| Snyk IaC | SaaS + CLI | Terraform, CloudFormation, K8s, ARM | Moderate | UI-based | All major CI systems |
The choice between these tools depends on your stack, team skills, and compliance requirements. Many teams use multiple tools in combination -- for example, Checkov for broad coverage plus OPA for custom organizational policies.
OPA/Rego: The Universal Policy Engine
Open Policy Agent (OPA) evaluates structured data against Rego policies. This is the most flexible approach because it works with any infrastructure format -- Terraform plans, Kubernetes manifests, Docker Compose files, or any JSON/YAML configuration.
Writing Rego Policies
# policy/terraform/s3.rego
package terraform.s3
# Deny S3 buckets without encryption
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_encryption(resource)
msg := sprintf("S3 bucket '%s' must have server-side encryption enabled", [resource.address])
}
has_encryption(resource) {
resource.change.after.server_side_encryption_configuration[_].rule[_].apply_server_side_encryption_by_default[_].sse_algorithm
}
# Deny public ACLs
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
acl := resource.change.after.acl
acl != "private"
msg := sprintf("S3 bucket '%s' has ACL '%s' -- must be 'private'", [resource.address, acl])
}
# Deny buckets without versioning
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_versioning(resource)
msg := sprintf("S3 bucket '%s' must have versioning enabled", [resource.address])
}
has_versioning(resource) {
resource.change.after.versioning[_].enabled == true
}
Running OPA with Conftest
Conftest is the CLI tool that runs OPA policies against configuration files:
# Run policy against a Terraform plan
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/terraform/
# Run against Kubernetes manifests
conftest test k8s/deployment.yaml --policy policy/kubernetes/
# Run against Dockerfiles
conftest test Dockerfile --policy policy/docker/
# Run with multiple policy directories
conftest test tfplan.json \
--policy policy/security/ \
--policy policy/compliance/ \
--policy policy/cost/
Organizing Rego Policies by Concern
A well-organized policy repository separates concerns:
policy/
terraform/
s3.rego # S3-specific rules
rds.rego # RDS-specific rules
iam.rego # IAM policy rules
networking.rego # VPC, security group rules
tagging.rego # Resource tagging requirements
kubernetes/
security.rego # Pod security, RBAC
resources.rego # CPU/memory limits
networking.rego # Network policies
docker/
best_practices.rego # Dockerfile linting
common/
helpers.rego # Shared helper functions
Advanced Rego Patterns
# policy/terraform/iam.rego
package terraform.iam
# Deny IAM policies with wildcard actions
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
policy_doc := json.unmarshal(resource.change.after.policy)
statement := policy_doc.Statement[_]
statement.Effect == "Allow"
statement.Action[_] == "*"
msg := sprintf("IAM policy '%s' grants wildcard actions (*). Use specific actions.",
[resource.address])
}
# Deny IAM policies with wildcard resources on mutating actions
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_iam_policy"
policy_doc := json.unmarshal(resource.change.after.policy)
statement := policy_doc.Statement[_]
statement.Effect == "Allow"
statement.Resource[_] == "*"
action := statement.Action[_]
not is_readonly_action(action)
msg := sprintf("IAM policy '%s' uses wildcard resource with mutating action '%s'",
[resource.address, action])
}
is_readonly_action(action) {
endswith(action, ":Get*")
}
is_readonly_action(action) {
endswith(action, ":List*")
}
is_readonly_action(action) {
endswith(action, ":Describe*")
}
Checkov: Batteries Included
Checkov ships with 1000+ built-in rules and requires zero configuration to start catching issues. It is particularly strong for teams that want immediate value without writing custom policies.
Quick Start
# Scan a Terraform directory
checkov -d ./terraform/ --framework terraform
# Output example:
# Passed checks: 42, Failed checks: 3, Skipped: 0
#
# Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
# FAILED for resource: aws_s3_bucket.data
# File: /main.tf:15-22
#
# Check: CKV_AWS_145: "Ensure S3 bucket is encrypted with KMS"
# FAILED for resource: aws_s3_bucket.data
# File: /main.tf:15-22
# Run only specific checks
checkov -d ./terraform/ --check CKV_AWS_18,CKV_AWS_145
# Skip specific checks (with justification)
checkov -d ./terraform/ --skip-check CKV_AWS_18 \
--skip-check-reason "Access logging handled by CloudTrail"
# Output as JSON for CI parsing
checkov -d ./terraform/ --output json > checkov-results.json
# Scan multiple frameworks at once
checkov -d . --framework terraform,kubernetes,dockerfile
Custom Checkov Policies in Python
When built-in checks are not enough, write custom policies in Python:
# custom_checks/s3_naming_convention.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
class S3NamingConvention(BaseResourceCheck):
"""Ensure S3 bucket names follow organizational naming convention."""
def __init__(self):
name = "Ensure S3 bucket follows naming convention: {team}-{env}-{purpose}"
id = "CUSTOM_S3_001"
supported_resources = ["aws_s3_bucket"]
categories = [CheckCategories.CONVENTION]
super().__init__(name=name, id=id, categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
bucket_name = conf.get("bucket", [""])[0]
# Pattern: team-environment-purpose
parts = bucket_name.split("-")
if len(parts) < 3:
return CheckResult.FAILED
valid_envs = ["dev", "staging", "prod", "test"]
if parts[1] not in valid_envs:
return CheckResult.FAILED
return CheckResult.PASSED
check = S3NamingConvention()
Pre-Commit Integration
# .pre-commit-config.yaml
repos:
- repo: https://github.com/bridgecrewio/checkov
rev: '3.2.0'
hooks:
- id: checkov
args: ['--compact', '--framework', 'terraform']
Combining Tools in a CI Pipeline
The strongest approach uses multiple tools, each catching different classes of issues:
# .github/workflows/iac-policy.yml
name: IaC Policy Checks
on:
pull_request:
paths:
- 'terraform/**'
- 'k8s/**'
jobs:
policy-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: tfsec (fast, Terraform-specific)
uses: aquasecurity/tfsec-action@v1.0.0
with:
working_directory: ./terraform/
soft_fail: false
- name: Checkov (broad coverage, multi-framework)
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform,kubernetes,dockerfile
output_format: cli,json
output_file_path: console,checkov-results.json
- name: OPA/Conftest (custom organizational policies)
run: |
terraform -chdir=terraform plan -out=tfplan
terraform -chdir=terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/ --output json > opa-results.json
- name: Trivy config scan (Dockerfiles + K8s)
uses: aquasecurity/trivy-action@master
with:
scan-type: 'config'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
Choosing Your Combination
| Team Profile | Recommended Stack | Rationale |
|---|---|---|
| Small startup, Terraform only | tfsec + Checkov | Fast setup, broad coverage, no custom policies needed |
| Enterprise, multi-cloud | Checkov + OPA/Conftest | Checkov for broad checks, OPA for custom compliance |
| Security-focused | tfsec + OPA + Snyk IaC | Defense in depth, multiple scanning engines |
| Kubernetes-heavy | Checkov + Polaris + OPA | K8s-specific + custom policies |
The key principle: static policy checks should run in under 60 seconds and block merges for critical violations. They are your most cost-effective quality gate.