🏗️ IaC Security
Terraform / IaC Security Reference
Common Terraform misconfigurations, scanning tools (tfsec, Checkov, Terrascan), secure module patterns, and CI/CD gate integration.
🚨 Common Terraform Misconfigurations — Bad vs Good
S3 Bucket — Public Access Not Blocked
Critical
AWS
❌ Vulnerable Terraform
# BAD — no public access block
resource "aws_s3_bucket" "data" {
bucket = "company-data"
}
✅ Secure Terraform
# GOOD — explicit public access block
resource "aws_s3_bucket" "data" {
bucket = "company-data"
}
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 = "AES256"
}
}
}
Security Group — Open to World
Critical
AWS
❌ Vulnerable Terraform
# BAD — SSH open to entire internet
resource "aws_security_group_rule" "ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # DANGEROUS
security_group_id = aws_security_group.main.id
}
✅ Secure Terraform
# GOOD — restrict to known CIDR or use SSM
resource "aws_security_group_rule" "ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.vpn_cidr] # Only your VPN/office IP
security_group_id = aws_security_group.main.id
}
# Even better: no SSH rule at all — use SSM Session Manager
# resource "aws_iam_role_policy_attachment" "ssm" {
# role = aws_iam_role.ec2.name
# policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
# }
RDS — Publicly Accessible
Critical
AWS
❌ Vulnerable Terraform
# BAD — RDS exposed to internet
resource "aws_db_instance" "main" {
identifier = "prod-db"
engine = "mysql"
instance_class = "db.t3.medium"
publicly_accessible = true # DANGEROUS
}
✅ Secure Terraform
# GOOD — private, encrypted, with backup
resource "aws_db_instance" "main" {
identifier = "prod-db"
engine = "mysql"
instance_class = "db.t3.medium"
publicly_accessible = false # Private only
storage_encrypted = true # Encrypt at rest
deletion_protection = true # Prevent accidental deletion
backup_retention_period = 7 # 7-day backups
db_subnet_group_name = aws_db_subnet_group.private.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = true # HA
}
IAM Role — Wildcard Permissions
Critical
AWS
❌ Vulnerable Terraform
# BAD — full admin access on IAM role
resource "aws_iam_role_policy" "app" {
name = "app-policy"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "*" # DANGEROUS
Resource = "*" # DANGEROUS
}]
})
}
✅ Secure Terraform
# GOOD — least privilege
resource "aws_iam_role_policy" "app" {
name = "app-policy"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject","s3:PutObject"]
Resource = "arn:aws:s3:::${var.bucket_name}/*"
},
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = aws_secretsmanager_secret.app.arn
}
]
})
}
Azure Storage — Public Blob Access
Critical
AZURE
❌ Vulnerable Terraform
# BAD — allows public blob access
resource "azurerm_storage_account" "main" {
name = "companystorage"
resource_group_name = azurerm_resource_group.main.name
location = "Central India"
account_tier = "Standard"
account_replication_type = "LRS"
# allow_blob_public_access defaults to true in older providers
}
✅ Secure Terraform
# GOOD — all security settings explicit
resource "azurerm_storage_account" "main" {
name = "companystorage"
resource_group_name = azurerm_resource_group.main.name
location = "Central India"
account_tier = "Standard"
account_replication_type = "GRS"
allow_blob_public_access = false # Block public access
enable_https_traffic_only = true # Force HTTPS
min_tls_version = "TLS1_2" # Minimum TLS 1.2
blob_properties {
delete_retention_policy { days = 30 }
versioning_enabled = true
}
}
GCP Firewall — SSH Open to World
Critical
GCP
❌ Vulnerable Terraform
# BAD — SSH open to internet
resource "google_compute_firewall" "ssh" {
name = "allow-ssh"
network = google_compute_network.main.name
allow {
protocol = "tcp"
ports = ["22"]
}
source_ranges = ["0.0.0.0/0"] # DANGEROUS
}
✅ Secure Terraform
# GOOD — SSH only via IAP
resource "google_compute_firewall" "ssh_iap" {
name = "allow-ssh-iap"
network = google_compute_network.main.name
allow {
protocol = "tcp"
ports = ["22"]
}
# Only allow from IAP (Google Identity-Aware Proxy)
source_ranges = ["35.235.240.0/20"]
target_tags = ["ssh-iap"]
}
# Enable OS Login for SA-based SSH auth
resource "google_project_iam_binding" "os_login" {
project = var.project_id
role = "roles/compute.osLogin"
members = var.ssh_users
}
Hardcoded Secrets in Terraform
Critical
ALL
❌ Vulnerable Terraform
# BAD — credential hardcoded in resource
resource "aws_db_instance" "main" {
username = "admin"
password = "P@ssw0rd123!" # DANGEROUS — in version control
}
# BAD — secret in variable without sensitive flag
variable "db_password" {
default = "P@ssw0rd123!" # Exposed in tfstate
}
✅ Secure Terraform
# GOOD — use Secrets Manager / Key Vault
data "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
}
resource "aws_db_instance" "main" {
username = "admin"
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
}
# Or use random password + store in Secrets Manager
resource "random_password" "db" {
length = 24
special = true
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = jsonencode({ password = random_password.db.result })
}
EC2 — IMDSv1 Enabled (Default)
High
AWS
❌ Vulnerable Terraform
# BAD — default allows IMDSv1 (SSRF risk)
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.medium"
# No metadata_options = IMDSv1 allowed
}
✅ Secure Terraform
# GOOD — enforce IMDSv2
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.medium"
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2 only
http_put_response_hop_limit = 1 # Prevent SSRF hops
}
}
# Enforce IMDSv2 as account default
resource "aws_ec2_instance_metadata_defaults" "main" {
http_tokens = "required"
}
🔍 IaC Scanning Tools
tfsec
Fast Terraform-specific scanner. 300+ checks across AWS, Azure, GCP. Outputs findings with severity, rule ID, and fix suggestion.
brew install tfsec
tfsec . --format json
tfsec . --format json
Checkov
Multi-IaC scanner (Terraform, CloudFormation, Kubernetes, Helm, Bicep). 1000+ policies. CIS benchmark mapped. Integrates with CI/CD.
pip install checkov
checkov -d . --framework terraform
checkov -d . --framework terraform
Terrascan
Detect compliance and security violations in IaC. Rego policy engine. Supports Terraform, Helm, Kustomize.
brew install terrascan
terrascan scan -t aws
terrascan scan -t aws
KICS (Checkmarx)
Keeping Infrastructure as Code Secure. Open-source. Scans Terraform, CloudFormation, Docker, Kubernetes. 2000+ queries.
docker run -v $(pwd):/path checkmarx/kics:latest scan -p /path
Trivy (IaC mode)
Aqua Security tool. Scans IaC alongside container images — one tool for multiple surface areas.
trivy config --severity HIGH,CRITICAL .
Infracost
Not a security scanner but critical: shows cost impact of IaC changes. Prevents expensive misconfigs (open IMDS, unlimited autoscaling).
infracost diff --path .
Running tfsec with custom rules
#!/bin/bash
# Install tfsec
brew install tfsec # Mac
# or: curl -sL https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64 -o tfsec && chmod +x tfsec
# Basic scan
tfsec .
# Scan with JSON output for CI/CD
tfsec . --format json > tfsec-results.json
# Only show HIGH and CRITICAL
tfsec . --minimum-severity HIGH
# Scan specific cloud
tfsec . --include-ignored-checks # Include ignored checks in output
# Custom checks (YAML format)
cat > .tfsec/custom-check.yaml << 'EOF'
checks:
- code: CUS001
description: "S3 bucket must have versioning enabled"
impact: "Data loss risk without versioning"
resolution: "Enable versioning on all S3 buckets"
requiredTypes:
- resource
requiredLabels:
- aws_s3_bucket
severity: HIGH
matchSpec:
name: versioning
action: isPresent
errorMessage: "S3 bucket does not have versioning configured"
relatedLinks:
- "https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html"
EOF
tfsec . --custom-check-dir .tfsec
✅ Secure Terraform Module Patterns
Sensitive variable handling
# variables.tf — mark secrets as sensitive
variable "db_password" {
type = string
description = "Database master password"
sensitive = true # Redacted from plan/apply output and state
}
variable "api_key" {
type = string
sensitive = true
}
# Never use default values for sensitive vars
# Force explicit input every time
Remote state with encryption
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "prod/terraform.tfstate"
region = "ap-south-1"
encrypt = true # Encrypt state at rest
kms_key_id = "arn:aws:kms:ap-south-1:ACCOUNT:key/KEY_ID"
dynamodb_table = "terraform-locks" # State locking
# Never store state locally — always remote
# Never store state in unencrypted S3
}
}
# State bucket must have:
# - Block all public access
# - Versioning enabled (recover accidental deletes)
# - MFA delete enabled
# - Server-side encryption (SSE-KMS)
# - Access logs enabled
Prevent destructive operations in production
resource "aws_db_instance" "prod" {
# ...
lifecycle {
prevent_destroy = true # terraform destroy will fail
ignore_changes = [
password, # Managed outside Terraform after creation
]
}
}
resource "aws_s3_bucket" "prod_data" {
bucket = "company-prod-data"
lifecycle {
prevent_destroy = true
}
}
# Use terraform workspace to separate environments
# terraform workspace new prod
# terraform workspace select prod
🔄 CI/CD Security Gate — GitHub Actions
name: Terraform Security Scan
on:
pull_request:
branches: [main]
paths: ['terraform/**','*.tf']
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: tfsec — Terraform security scan
uses: aquasecurity/tfsec-action@v1.0.0
with:
working_directory: terraform/
minimum_severity: HIGH
soft_fail: false # Fail PR on HIGH/CRITICAL findings
- name: Checkov — Multi-framework scan
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
framework: terraform
check: CKV_AWS_*,CKV_AZURE_*,CKV_GCP_*
soft_fail: false
- name: Trivy — IaC config scan
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: terraform/
severity: HIGH,CRITICAL
exit-code: '1' # Fail on findings
- name: Check for secrets in Terraform files
uses: trufflesecurity/trufflehog@main
with:
path: ./
extra_args: --only-verified
- name: Terraform plan with OPA policy check
run: |
terraform init
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# Run OPA against plan
opa eval --data opa-policies/ --input tfplan.json data.terraform.deny
OPA policy to block public S3 buckets in plan
package terraform
import future.keywords.contains
import future.keywords.if
# Deny if any S3 bucket has public access enabled
deny contains msg if {
r := input.resource_changes[_]
r.type == "aws_s3_bucket_public_access_block"
r.change.after.block_public_acls == false
msg := sprintf("S3 bucket '%s' has public ACLs not blocked", [r.address])
}
deny contains msg if {
r := input.resource_changes[_]
r.type == "aws_security_group_rule"
r.change.after.cidr_blocks[_] == "0.0.0.0/0"
r.change.after.from_port <= 22
r.change.after.to_port >= 22
msg := sprintf("Security group rule '%s' allows SSH from internet", [r.address])
}
deny contains msg if {
r := input.resource_changes[_]
r.type == "aws_db_instance"
r.change.after.publicly_accessible == true
msg := sprintf("RDS instance '%s' is publicly accessible", [r.address])
}