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:#fff

Why 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:#fff

Sentinel 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact