Detecting Terraform Vulnerabilities with Checkov: An Insecure vs Secure Comparison Demo
Discovering that an S3 bucket was never encrypted after running terraform apply is a situation best avoided. Ideally, IaC security should be verified at the time the code is written.
Checkov is an open-source static analysis tool developed by Prisma Cloud (formerly Bridgecrew). It supports Terraform, CloudFormation, Kubernetes, Dockerfile, and many other frameworks, detecting security and compliance issues without requiring plan or apply.
In this post, I create a demo repository with intentionally vulnerable Terraform code alongside hardened code, and walk through how Checkov detects each vulnerability.
Demo repository: codenote-net/checkov-terraform-demo
Repository structure
The repository is organized into two directories: insecure/ and secure/.
checkov-terraform-demo/
├── insecure/ # Intentionally vulnerable Terraform code
│ ├── provider.tf
│ ├── s3.tf
│ ├── sg.tf
│ ├── rds.tf
│ ├── iam.tf
│ └── cloudtrail.tf
├── secure/ # Hardened Terraform code
│ ├── provider.tf
│ ├── s3.tf
│ ├── sg.tf
│ ├── rds.tf
│ ├── iam.tf
│ └── cloudtrail.tf
└── .github/workflows/
└── checkov.ymlThe target resources cover five AWS services:
- S3: Bucket encryption, public access, versioning
- Security Group: Port exposure, SSH access restrictions
- RDS: Storage encryption, public access, Multi-AZ
- IAM: Policy permission scope
- CloudTrail: Log encryption, validation
Since Checkov is a static analysis tool, neither terraform init nor terraform plan is required. It scans .tf files directly.
Writing the insecure code
First, we place intentionally vulnerable code in the insecure/ directory. See PR #1 for the full implementation.
S3: No encryption, public access allowed
resource "aws_s3_bucket" "data" {
bucket = "demo-insecure-bucket"
}
resource "aws_s3_bucket_acl" "data" {
bucket = aws_s3_bucket.data.id
acl = "public-read"
}No encryption is configured, and the ACL is set to public-read. Versioning, access logging, and public access blocks are also missing.
Security Group: All ports open to all IPs
resource "aws_security_group" "web" {
name = "demo-insecure-sg"
description = "Insecure security group for demo"
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}All TCP ports (0-65535) are open to 0.0.0.0/0, and SSH is accessible from any IP address.
RDS: No encryption, publicly accessible
resource "aws_db_instance" "main" {
identifier = "demo-insecure-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password = "password123"
publicly_accessible = true
storage_encrypted = false
skip_final_snapshot = true
}Storage is unencrypted, public access is enabled, and the password is hardcoded.
IAM: Wildcard policy
resource "aws_iam_policy" "admin" {
name = "demo-insecure-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "*"
Resource = "*"
}
]
})
}Both Action and Resource are set to *, granting all operations on all resources.
CloudTrail: No encryption, validation disabled
resource "aws_cloudtrail" "main" {
name = "demo-insecure-trail"
s3_bucket_name = aws_s3_bucket.data.id
enable_log_file_validation = false
}No KMS encryption, and log file validation is disabled.
Scanning with Checkov
Install Checkov via pip:
pip install checkovRun the scan against the insecure/ directory:
checkov -d insecure/ --framework terraformThe result: Passed: 13 / Failed: 37.
All 37 detected vulnerabilities
Here is a breakdown of every failed check, grouped by resource.
S3 (aws_s3_bucket.data): 8 failures
| Check ID | Description |
|---|---|
| CKV_AWS_18 | Access logging disabled |
| CKV_AWS_20 | Public READ access allowed |
| CKV_AWS_21 | Versioning disabled |
| CKV_AWS_144 | Cross-region replication not configured |
| CKV_AWS_145 | KMS encryption not configured |
| CKV2_AWS_6 | Public access block not configured |
| CKV2_AWS_61 | Lifecycle configuration missing |
| CKV2_AWS_62 | Event notifications disabled |
Security Group (aws_security_group.web): 6 failures
| Check ID | Description |
|---|---|
| CKV_AWS_23 | Rules missing descriptions |
| CKV_AWS_24 | SSH (22) open to 0.0.0.0/0 |
| CKV_AWS_25 | RDP (3389) open to 0.0.0.0/0 |
| CKV_AWS_260 | HTTP (80) open to 0.0.0.0/0 |
| CKV_AWS_382 | Egress open to 0.0.0.0/0 on all ports |
| CKV2_AWS_5 | Not attached to any resource |
RDS (aws_db_instance.main): 9 failures
| Check ID | Description |
|---|---|
| CKV_AWS_16 | Storage encryption disabled |
| CKV_AWS_17 | Publicly accessible |
| CKV_AWS_118 | Enhanced Monitoring disabled |
| CKV_AWS_129 | Logging disabled |
| CKV_AWS_157 | Multi-AZ disabled |
| CKV_AWS_161 | IAM authentication disabled |
| CKV_AWS_226 | Auto minor version upgrade disabled |
| CKV_AWS_293 | Deletion protection disabled |
| CKV2_AWS_60 | Copy tags to snapshots disabled |
IAM (aws_iam_policy.admin): 9 failures
| Check ID | Description |
|---|---|
| CKV_AWS_62 | Grants full administrative privileges |
| CKV_AWS_63 | Allows * as action |
| CKV_AWS_286 | Allows privilege escalation |
| CKV_AWS_287 | Allows credentials exposure |
| CKV_AWS_288 | Allows data exfiltration |
| CKV_AWS_289 | Allows resource exposure without constraints |
| CKV_AWS_290 | Allows write access without constraints |
| CKV_AWS_355 | Allows * as resource for restrictable actions |
| CKV2_AWS_40 | Grants full IAM privileges |
CloudTrail (aws_cloudtrail.main): 5 failures
| Check ID | Description |
|---|---|
| CKV_AWS_35 | KMS encryption not configured |
| CKV_AWS_36 | Log file validation disabled |
| CKV_AWS_67 | Not enabled in all regions |
| CKV_AWS_252 | SNS topic not configured |
| CKV2_AWS_10 | Not integrated with CloudWatch Logs |
A single IAM policy with Action: "*" triggers 9 failures on its own. Checkov goes beyond simple pattern matching; it analyzes the potential for privilege escalation, data exfiltration, and credential exposure.
Fixing the code to achieve all PASSED
The secure/ directory contains the hardened code. See PR #2 for the full implementation.
Here are the key fixes for each resource.
S3: Encryption, access control, lifecycle management
resource "aws_s3_bucket" "data" {
bucket = "demo-secure-bucket"
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
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"
}
bucket_key_enabled = true
}
}
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
}KMS encryption, versioning, and public access blocks are enabled. Access logging, lifecycle configuration, and cross-region replication are also set up (see secure/s3.tf for the full code).
Security Group: Restricted ports and access sources
variable "allowed_ssh_cidr" {
description = "CIDR block allowed to SSH"
type = string
default = "203.0.113.0/24"
}
resource "aws_security_group" "web" {
name = "demo-secure-sg"
description = "Secure security group for demo - restricted access"
ingress {
description = "HTTPS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from allowed CIDR only"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.allowed_ssh_cidr]
}
egress {
description = "Allow HTTPS outbound"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}The blanket all-ports rule is replaced with HTTPS (443) only. SSH is restricted to a specific CIDR block via a variable, and each rule includes a description. An EC2 instance is also defined to resolve CKV2_AWS_5 (security group not attached to any resource).
RDS: Encryption and availability hardening
variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}
resource "aws_db_instance" "main" {
identifier = "demo-secure-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password = var.db_password
publicly_accessible = false
storage_encrypted = true
copy_tags_to_snapshot = true
auto_minor_version_upgrade = true
deletion_protection = true
iam_database_authentication_enabled = true
multi_az = true
monitoring_interval = 60
monitoring_role_arn = aws_iam_role.rds_monitoring.arn
enabled_cloudwatch_logs_exports = ["audit", "error", "general", "slowquery"]
}The password is extracted into a sensitive variable. Storage encryption, Multi-AZ, IAM authentication, Enhanced Monitoring, and CloudWatch Logs export are all enabled.
IAM: Least privilege
resource "aws_iam_policy" "app" {
name = "demo-secure-policy"
description = "Least privilege policy for application"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
]
Resource = [
"arn:aws:s3:::demo-secure-bucket",
"arn:aws:s3:::demo-secure-bucket/*"
]
}
]
})
}The wildcard * is replaced with only the S3 operations needed, scoped to a specific bucket.
CloudTrail: Encryption and monitoring integration
resource "aws_cloudtrail" "main" {
name = "demo-secure-trail"
s3_bucket_name = aws_s3_bucket.data.id
is_multi_region_trail = true
enable_log_file_validation = true
kms_key_id = aws_kms_key.cloudtrail.arn
cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
cloud_watch_logs_role_arn = aws_iam_role.cloudtrail_cloudwatch.arn
sns_topic_name = aws_sns_topic.cloudtrail.arn
}KMS encryption, log file validation, multi-region support, CloudWatch Logs integration, and SNS notifications are all configured.
Scan results after fixes
checkov -d secure/ --framework terraformThe result: Passed: 111 / Failed: 0. All 37 failures are resolved, and the total number of checks increased from 50 to 111. This is because the fixes introduce additional resources (log buckets, KMS keys, IAM roles, etc.), which expand the scope of what Checkov evaluates.
Integrating with GitHub Actions
The repository includes a GitHub Actions workflow that runs Checkov automatically on pushes and pull requests, posting results as PR comments.
name: Checkov
on:
push:
branches: [main]
paths:
- "insecure/**"
- "secure/**"
- ".github/workflows/checkov.yml"
pull_request:
branches: [main]
paths:
- "insecure/**"
- "secure/**"
- ".github/workflows/checkov.yml"
permissions:
contents: read
pull-requests: write
jobs:
scan-insecure:
name: "Scan insecure/"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov on insecure/
id: checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: insecure/
framework: terraform
soft_fail: true
output_format: cli
scan-secure:
name: "Scan secure/"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check if secure/ exists
id: check
run: |
if [ -d "secure" ] && [ "$(ls -A secure/)" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Run Checkov on secure/
if: steps.check.outputs.exists == 'true'
uses: bridgecrewio/checkov-action@v12
with:
directory: secure/
framework: terraform
soft_fail: false
output_format: cliTwo key design decisions:
insecure/usessoft_fail: true, so failures do not block CI (since the vulnerabilities are intentional)secure/usessoft_fail: false, so any failure blocks CI
The paths filter ensures that changes unrelated to .tf files (such as README updates) skip the scan entirely.
Takeaways
Checkov lets you catch Terraform vulnerabilities before apply.
In this demo, we detected 37 vulnerabilities across five AWS resources and resolved all of them, achieving 111 passed checks. Because Checkov requires neither terraform init nor any cloud credentials for scanning, the barrier to adoption is low.
By integrating Checkov into GitHub Actions, every PR gets an automatic security check. This prevents insecure configurations from being merged by relying on tooling rather than code review alone.
All the code in this demo is publicly available. Try running checkov -d insecure/ locally to see the detection results for yourself.
- Demo repository: codenote-net/checkov-terraform-demo
- Checkov: bridgecrewio/checkov
- Checkov documentation: checkov.io
That’s all from the Gemba, where we catch Terraform vulnerabilities before they ever reach apply.