Policy as Code #
Being able to provision infrastructure quickly is a remarkable capability — but it can also become a risk if anyone can create resources with dangerous configurations without anyone detecting it. Policy as Code answers this question: how do you make sure every Terraform configuration entering the repository meets security, compliance, and internal convention standards — automatically, every time, without relying on reviewers who might be tired or unfamiliar with all the rules.
flowchart LR
A["terraform plan"] --> B["Policy Engine\n(OPA/Sentinel)"]
B -->|"pass"| C["Apply"]
B -->|"fail"| D["Block"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#8b5cf6,stroke:#6d28d9,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#ef4444,stroke:#dc2626,color:#fffWhy Policy as Code #
WITHOUT POLICY AS CODE:
Engineer A creates an S3 bucket with public access → goes to production
The reviewer doesn't realize that `block_public_acls = false` is a risk
Data leak happens 3 months later
Problem: Reviewers can't remember and check every security rule
in every PR, especially when there are dozens of PRs per week.
WITH POLICY AS CODE:
Engineer A creates an S3 bucket with public access
The CI pipeline automatically runs Checkov
Checkov: "FAILED: CKV_AWS_21 — S3 Bucket has public access blocks disabled"
The PR can't be merged until the problem is fixed
Rules run consistently in every PR, without fatigue
Checkov: A Security Scanner for Terraform #
Checkov is an open-source tool that analyzes Terraform configurations and detects hundreds of security and compliance issues based on best practices from various frameworks (CIS, HIPAA, PCI-DSS, SOC2).
# Installation
pip install checkov
# Scan a Terraform configuration directory
checkov -d infrastructure/environments/production
# Example output:
# Check: CKV_AWS_21: "Ensure the S3 bucket has access control list (ACL) disabled"
# FAILED for resource: aws_s3_bucket.logs
# File: /infrastructure/environments/production/main.tf:45-52
#
# Check: CKV_AWS_86: "Ensure S3 bucket has a lifecycle configuration"
# PASSED for resource: aws_s3_bucket.logs
#
# Passed checks: 42, Failed checks: 2, Skipped checks: 0
# Scan only specific files
checkov -f main.tf
# JSON output for parsing
checkov -d . --output json > checkov-results.json
# Skip specific checks (with a clear reason)
checkov -d . --skip-check CKV_AWS_20 # Skip the public ACL check (WAF already in place)
# Disabling a specific check directly in the configuration (with a reason comment)
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
# checkov:skip=CKV_AWS_53:Public bucket needed for static website hosting
# This website truly needs public reads, protected by Cloudflare WAF
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
tfsec: A Lighter Security Scanner #
tfsec is a lighter, faster Checkov alternative, suitable for running on every commit.
# Installation
brew install tfsec # macOS
# or
curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash
# Scan
tfsec infrastructure/
# Output in a more compact format
tfsec infrastructure/ --format compact
# Show only high severity
tfsec infrastructure/ --minimum-severity HIGH
# Custom checks with a configuration file
tfsec infrastructure/ --custom-check-dir ./security-checks/
OPA/Conftest: More Flexible Custom Policies #
For rules more specific to a team’s internal standards (naming conventions, required tags, region restrictions), OPA (Open Policy Agent) with Conftest provides more flexibility.
# Install conftest
brew install conftest
# Structure:
policy/
├── terraform.rego ← Policy for Terraform configuration
└── plan.rego ← Policy for the plan output
# policy/terraform.rego — internal convention rules
package main
# All resources must have an Environment tag
deny[msg] {
resource := input.resource_changes[_]
resource.change.after != null
not resource.change.after.tags.Environment
msg := sprintf("Resource '%s' must have an 'Environment' tag", [resource.address])
}
# The Environment tag must be one of the valid values
deny[msg] {
resource := input.resource_changes[_]
resource.change.after != null
env := resource.change.after.tags.Environment
not valid_environments[env]
msg := sprintf("Environment tag '%s' is invalid. Use: dev, staging, production", [env])
}
valid_environments := {"dev", "staging", "production"}
# Instance types must not be larger than t3.large in non-production
warn[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
resource.change.after.instance_type == "m5.xlarge"
resource.change.after.tags.Environment != "production"
msg := sprintf("Large instance type '%s' in non-production — consider downsizing", [resource.address])
}
# RDS must not have deletion_protection = false in production
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.tags.Environment == "production"
not resource.change.after.deletion_protection
msg := sprintf("RDS '%s' in production must have deletion_protection = true", [resource.address])
}
# Run conftest with the plan output
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/
# Output:
# FAIL - tfplan.json - main - Resource 'aws_instance.web' must have an 'Environment' tag
# WARN - tfplan.json - main - Large instance type 'module.compute.aws_instance.api' in non-production
# 1 test, 0 passed, 0 warnings, 1 failure
Integrating into the CI Pipeline #
# .github/workflows/terraform.yml — add a security scan job
security-scan:
name: Security & Policy Check
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: infrastructure/environments/production
framework: terraform
output_format: sarif
output_file_path: checkov-results.sarif
soft_fail: false # The pipeline fails if there's HIGH severity
- name: Upload Checkov Results to GitHub Security Tab
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even if Checkov fails
with:
sarif_file: checkov-results.sarif
- name: Setup Terraform (to generate the plan)
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Generate Plan JSON for OPA
run: |
terraform init -backend=false
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
working-directory: infrastructure/environments/production
- name: Run OPA Policy Check
run: |
curl -L -o conftest https://github.com/open-policy-agent/conftest/releases/download/v0.46.0/conftest_Linux_x86_64
chmod +x conftest
./conftest test tfplan.json --policy policy/
working-directory: infrastructure/environments/production
Blocking vs Warning #
Not every policy violation must block the pipeline. Deciding which are blocking and which are warnings is an important decision.
BLOCKING (pipeline fails, PR can't be merged):
✗ S3 buckets with public access without justification
✗ Security groups with 0.0.0.0/0 on sensitive ports (22, 3306, 5432)
✗ RDS without encryption at rest
✗ Resources without an Environment tag
✗ Hardcoded credentials in the configuration
WARNING (recorded but not blocking):
⚠ Large instance types in non-production
⚠ RDS without backups in the dev environment
⚠ Resources not using the latest provider version
⚠ Modules using old versions
flowchart TD
A["Terraform plan JSON"] --> B{"Policy check"}
B -->|"all pass"| C["Allow apply"]
B -->|"warn"| D["Apply + warning"]
B -->|"deny"| E["Block apply"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#f97316,stroke:#ea580c,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#f59e0b,stroke:#d97706,color:#fff
style E fill:#ef4444,stroke:#dc2626,color:#fffSentinel Policy Examples #
# Policy: Require specific instance types
import "tfplan/v2" as tfplan
allowed_types = ["t3.micro", "t3.small", "t3.medium"]
main = rule {
all tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
rc.change.after.instance_type in allowed_types
}
}
# OPA/Conftest alternative
# policy/deny.rego
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not startswith(resource.change.after.instance_type, "t3")
msg := sprintf("Instance type %s not allowed", [resource.change.after.instance_type])
}
Policy Testing #
# Test the Sentinel policy before deploying
sentinel test policy/
# Output: PASS/FAIL for each test case
# Test the Conftest policy
conftest test plan.json -p policy/
# Output: PASS/WARN/FAIL for each policy
# Test in the CI pipeline
conftest test plan.json -p policy/ --fail-on-warn
# The pipeline fails if there's a warning
# CI pipeline with policy testing
jobs:
policy-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Terraform Plan
run: terraform plan -out=tfplan -json > plan.json
- name: Run Conftest
run: conftest test plan.json -p policy/ --fail-on-warn
OPA (Open Policy Agent) Integration #
# Install Conftest for OPA-based policy testing
brew install conftest
# Run the tests
conftest test plan.json -p policy/
# Output:
# PASS - policy/check_encryption.rego
# FAIL - policy/check_public_access.rego
# Security group allows 0.0.0.0/0 access
# policy/deny_public_access.rego
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("Security group %s allows public access", [resource.name])
}
Summary #
- Policy as Code automates security review — hundreds of rules run consistently in every PR without relying on reviewers who might miss something.
- Checkov is the main choice for general security scanning — quick to set up, hundreds of built-in rules for AWS/GCP/Azure.
- tfsec is lighter than Checkov — suitable for fast scans on every commit or local development.
- OPA/Conftest for specific internal rules — tag conventions, naming, region restrictions, custom compliance rules.
- Integrate into the CI pipeline as a separate job running after validate and before apply.
- Distinguish blocking vs warning — not every violation needs to block the pipeline; too many blocking rules make teams look for ways around them.
← Previous: Automated Apply Next: Drift Detection Automation →