Infrastructure as Code Security: Scanning Terraform and CloudFormation
How to shift left on cloud security by scanning Terraform and CloudFormation with tfsec, Checkov, and Bridgecrew before misconfigurations reach production.
Infrastructure as Code Security: Scanning Terraform and CloudFormation
Infrastructure as Code (IaC) has transformed how cloud infrastructure is provisioned — enabling repeatable, version-controlled deployments. But it has also introduced a new class of security risk: a single misconfigured Terraform module can instantiate hundreds of insecure resources across dozens of environments simultaneously.
Traditional security tools scan resources after they are deployed. IaC security scanning examines configuration files before terraform apply runs — catching misconfigurations at the pull request stage, where they are easiest and cheapest to fix.
What IaC Security Scanning Detects
IaC scanners analyze infrastructure configuration for security policy violations. Common findings include:
- Public S3 buckets —
aws_s3_bucketwithoutaws_s3_bucket_public_access_block - Unrestricted security groups —
cidr_blocks = ["0.0.0.0/0"]on sensitive ports - Unencrypted storage — EBS volumes, RDS instances, S3 buckets without KMS encryption
- Missing logging — CloudTrail disabled, VPC flow logs absent, S3 access logging off
- Weak TLS — Load balancers accepting TLS 1.0 or 1.1
- Overly permissive IAM — Wildcard actions (
"Action": ["*"]) on policies - Exposed secrets — API keys or passwords hardcoded in resource tags or variables
tfsec: Fast Terraform Scanning
tfsec (by Aqua Security) is a static analysis tool for Terraform that checks for security misconfigurations. It is fast, easy to install, and integrates natively with GitHub Actions.
Installation:
# Homebrew
brew install tfsec
# Or via binary
curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash
Basic usage:
# Scan a directory
tfsec ./terraform
# Scan with specific severity threshold
tfsec ./terraform --minimum-severity HIGH
# Output in JSON for CI/CD processing
tfsec ./terraform --format json --out results.json
Sample findings:
Result #1 HIGH
───────────────────────────────────────────────────
ID aws-s3-no-public-buckets
Impact The contents of the bucket may be read publicly
Resource aws_s3_bucket.data (terraform/s3.tf:5)
5 resource "aws_s3_bucket" "data" {
6 bucket = "my-data-bucket"
7 }
# Missing: aws_s3_bucket_public_access_block
GitHub Actions integration:
- name: tfsec
uses: aquasecurity/tfsec-action@v1.0.0
with:
working_directory: terraform/
minimum_severity: HIGH
github_token: ${{ secrets.GITHUB_TOKEN }}
soft_fail: false
tfsec comments inline on pull requests, showing exactly which line contains the misconfiguration.
Checkov: Multi-Framework IaC Scanner
Checkov (by Bridgecrew/Prisma Cloud) supports Terraform, CloudFormation, Kubernetes manifests, Dockerfile, Helm charts, and ARM templates — making it the right choice for polyglot infrastructure.
Installation:
pip install checkov
# or
brew install checkov
Scanning Terraform:
checkov -d ./terraform \
--framework terraform \
--check CKV_AWS_*,CKV2_AWS_* \
--output junitxml \
--output-file-path results.xml
Scanning CloudFormation:
checkov -f cloudformation/stack.yaml \
--framework cloudformation
Writing custom checks:
Checkov supports Python-based custom checks for organization-specific policies:
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
class MandatoryTagsCheck(BaseResourceCheck):
def __init__(self):
name = "All AWS resources must have mandatory tags"
id = "CKV_CUSTOM_001"
supported_resources = ["aws_instance", "aws_rds_cluster", "aws_s3_bucket"]
categories = [CheckCategories.GENERAL_SECURITY]
super().__init__(name=name, id=id, categories=categories,
supported_resources=supported_resources)
def scan_resource_conf(self, conf):
tags = conf.get("tags", [{}])[0]
required_tags = {"Environment", "Team", "CostCenter"}
if isinstance(tags, dict) and required_tags.issubset(tags.keys()):
return CheckResult.PASSED
return CheckResult.FAILED
scanner = MandatoryTagsCheck()
GitHub Actions with SARIF output (shows in Security tab):
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: terraform/
framework: terraform
output_format: sarif
output_file_path: checkov-results.sarif
soft_fail: false
check: CKV_AWS_*,CKV_K8S_*
skip_check: CKV_AWS_144 # S3 cross-region replication — not required in our setup
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov-results.sarif
Addressing Common Misconfigurations
Fix 1: S3 Public Access Block
# BEFORE — missing public access block
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
}
# AFTER — explicitly block all public access
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3_key.arn
}
}
}
Fix 2: Restrictive Security Groups
# BEFORE — open to world
resource "aws_security_group_rule" "app_ingress" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.app.id
}
# AFTER — only allow from ALB security group
resource "aws_security_group_rule" "app_ingress" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
source_security_group_id = aws_security_group.alb.id
security_group_id = aws_security_group.app.id
}
Fix 3: Encrypted RDS
resource "aws_db_instance" "main" {
identifier = "main-db"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.medium"
allocated_storage = 20
# Encryption
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
# Logging
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
# Backup
backup_retention_period = 7
deletion_protection = true
}
Managing Findings at Scale
In a large organization with many Terraform modules, an initial scan may return hundreds of findings. A structured approach:
-
Baseline scan — Run the scanner and categorize all findings as pre-existing. Create tickets for each HIGH and CRITICAL finding.
-
Block new misconfigurations — Configure CI to fail on any NEW HIGH+ findings introduced in the PR. Do not fail on pre-existing issues (use
--baselineflag in Checkov). -
Risk acceptance workflow — For findings that are intentionally accepted (e.g., a public S3 bucket for a CDN), add an inline suppression with a justification comment:
resource "aws_s3_bucket" "cdn" {
bucket = "my-public-cdn-bucket"
#checkov:skip=CKV_AWS_144:Cross-region replication not required for CDN bucket
#checkov:skip=CKV2_AWS_62:Public access intentional — serves static assets
}
-
Remediation sprints — Schedule dedicated time each quarter to reduce the pre-existing findings backlog.
-
Policy as code review — Review scanner rules quarterly. Disable rules that do not apply to your environment; add custom rules for organization-specific policies.
IaC security scanning is most valuable when it is invisible friction — developers see the feedback inline in their PR, fix it in minutes, and move on. Policies that require security team approval for every finding quickly become bottlenecks. Automate the easy checks; reserve human review for complex risk decisions.