Terraform Security — tfsec, Checkov, and Policy as Code
The fastest way to create a security incident is to deploy infrastructure that was never reviewed for misconfigurations. A public S3 bucket, an open security group, an unencrypted RDS instance — these are not sophisticated attacks. They are configuration mistakes that tools can catch automatically. Security scanning for Terraform has matured to the point where there is no excuse for skipping it.
Security Scanning Tools Comparison
The ecosystem has several strong options. Here is how they compare:
| Tool | Language | Custom Rules | CI Support | Terraform Support | License |
|---|---|---|---|---|---|
| tfsec (now Trivy) | Go | Rego + YAML | Excellent | HCL native | Open source |
| Checkov | Python | Python + YAML | Excellent | HCL + plan JSON | Open source |
| Terrascan | Go | Rego | Good | HCL native | Open source |
| Snyk IaC | SaaS | YAML | Excellent | HCL + plan JSON | Freemium |
| Sentinel | HCL-like | Sentinel lang | TF Cloud only | Plan JSON | Paid (TF Cloud) |
| OPA/Conftest | Go | Rego | Good | Plan JSON | Open source |
For most teams, Checkov or tfsec/Trivy is the best starting point — both are free, fast, and have hundreds of built-in rules covering AWS, Azure, and GCP.
Running tfsec (Now Part of Trivy)
tfsec scans HCL files directly — no need to run terraform plan first:
# Install
brew install tfsec
# Scan current directory
tfsec .
# Output:
# Result #1 CRITICAL Security group rule allows ingress from 0.0.0.0/0
# infra/security.tf:15-22
#
# Result #2 HIGH S3 bucket does not have encryption enabled
# infra/storage.tf:3-8
#
# 2 potential problems detected.
tfsec understands Terraform references and expressions. If a security group references a variable for the CIDR, tfsec traces it back to the variable's default value:
# Scan with specific severity threshold
tfsec . --minimum-severity HIGH
# Output as JUnit XML for CI
tfsec . --format junit --out results.xml
# Scan with custom config
tfsec . --config-file tfsec.yml
As of 2024, Aqua Security merged tfsec into Trivy. The standalone tfsec still works, but new rules ship in Trivy:
# Using Trivy instead
trivy config .
trivy config --severity HIGH,CRITICAL .
Running Checkov
Checkov has 1000+ built-in policies and supports Terraform, CloudFormation, Kubernetes, Dockerfile, and more:
# Install
pip install checkov
# Scan directory
checkov -d .
# Scan specific file
checkov -f main.tf
# Output:
# Passed checks: 12, Failed checks: 3, Skipped checks: 0
#
# Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
# FAILED for resource: aws_s3_bucket.data
# File: /storage.tf:1-10
#
# Check: CKV_AWS_145: "Ensure S3 bucket is encrypted with KMS"
# FAILED for resource: aws_s3_bucket.data
# File: /storage.tf:1-10
Checkov can also scan the Terraform plan output for a more accurate analysis:
# Generate plan JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# Scan plan output
checkov -f tfplan.json --framework terraform_plan
Writing Custom Checkov Policies
When built-in rules are not enough, write custom checks in Python or YAML:
# custom_policies/require_cost_center_tag.yaml
metadata:
id: "CUSTOM_001"
name: "Ensure all resources have a CostCenter tag"
severity: "MEDIUM"
definition:
cond_type: "attribute"
resource_types:
- "aws_instance"
- "aws_s3_bucket"
- "aws_rds_instance"
attribute: "tags.CostCenter"
operator: "exists"
# Run with custom policies
checkov -d . --external-checks-dir custom_policies/
OPA with Conftest
Open Policy Agent lets you write fine-grained policies in Rego. Conftest makes it easy to test Terraform plans against those policies:
# Install Conftest
brew install conftest
# Generate plan JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# Test against policies
conftest test tfplan.json --policy policy/
# policy/tags.rego
package main
required_tags := {"Environment", "Team", "CostCenter"}
deny[msg] {
resource := input.resource_changes[_]
resource.change.actions[_] == "create"
tags := object.get(resource.change.after, "tags", {})
missing := required_tags - {key | tags[key]}
count(missing) > 0
msg := sprintf(
"%s '%s' is missing required tags: %v",
[resource.type, resource.address, missing]
)
}
# policy/network.rego
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.type == "ingress"
resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
resource.change.after.from_port <= 22
resource.change.after.to_port >= 22
msg := sprintf(
"Security group rule '%s' allows SSH from the internet",
[resource.address]
)
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_db_instance"
resource.change.after.publicly_accessible == true
msg := sprintf(
"RDS instance '%s' is publicly accessible",
[resource.address]
)
}
Sentinel for Terraform Cloud
If you use Terraform Cloud or Enterprise, Sentinel provides policy-as-code that runs automatically before every apply:
# sentinel/require-encryption.sentinel
import "tfplan/v2" as tfplan
s3_buckets = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and
rc.mode is "managed" and
(rc.change.actions contains "create" or rc.change.actions contains "update")
}
main = rule {
all s3_buckets as _, bucket {
bucket.change.after.server_side_encryption_configuration is not null
}
}
Sentinel policies can be advisory (warn but allow), soft-mandatory (require override), or hard-mandatory (no exceptions).
Pre-commit Hooks for Security
Catch issues before code even reaches CI with pre-commit hooks:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-tf
rev: v1.83.6
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tfsec
args: ['--args=--minimum-severity HIGH']
- id: terraform_checkov
args: ['--args=--quiet --compact']
- repo: https://github.com/gruntwork-io/pre-commit
rev: v0.1.23
hooks:
- id: tflint
# Install and activate
pip install pre-commit
pre-commit install
# Now every git commit triggers scanning:
# $ git commit -m "Add S3 bucket"
# terraform_fmt........................................................Passed
# terraform_validate...................................................Passed
# terraform_tfsec......................................................Failed
# CRITICAL: S3 bucket has no encryption configured
Security Scanning in CI/CD
Integrate scanning into your pipeline alongside plan and apply:
# .github/workflows/security.yml
name: Terraform Security
on:
pull_request:
paths: ['infra/**']
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: infra
soft_fail: false
- name: Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: infra
framework: terraform
quiet: true
soft_fail: false
Common Findings and Fixes
These are the misconfigurations that scanners catch most often:
# BAD: Public S3 bucket
resource "aws_s3_bucket_public_access_block" "bad" {
bucket = aws_s3_bucket.data.id
block_public_acls = false # tfsec: CRITICAL
block_public_policy = false
}
# GOOD: Block all public access
resource "aws_s3_bucket_public_access_block" "good" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Suppressing False Positives
Sometimes a finding is intentional. Both tfsec and Checkov support inline suppression:
# tfsec suppression
resource "aws_security_group_rule" "public_http" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] #tfsec:ignore:aws-ec2-no-public-ingress-sgr -- Public web server
}
# Checkov suppression
resource "aws_s3_bucket" "public_assets" {
#checkov:skip=CKV_AWS_18:Access logging not needed for public static assets
bucket = "public-assets-cdn"
}
Always include a comment explaining why the suppression is justified. "Because it failed" is not a reason.
Closing Notes
Security scanning should be the first gate in your Terraform pipeline — even before terraform plan. Start with tfsec or Checkov for broad coverage, add OPA/Conftest for organization-specific policies, and enforce pre-commit hooks so engineers get instant feedback. The goal is not zero findings — it is zero unreviewed findings. In the next post, we will tackle Terraform state surgery — how to move, remove, and recover state when things go sideways.
