Your Infrastructure Has Bugs Too: Scanning Terraform with Checkov (IaC SAST)
SAST for infrastructure? Yes, that's a thing
In my previous article I used Bandit to find security bugs in Python code. But modern applications are deployed with Infrastructure as Code (Terraform, Pulumi, OpenTofu, CloudFormation) - and a misconfigured S3 bucket has caused more real-world data breaches than most code vulnerabilities.
The good news: since infrastructure is now code, it can be statically analyzed like code. That's exactly what Checkov does: an open-source tool (Python, maintained by Prisma Cloud) with 1,000+ built-in policies that scans Terraform, CloudFormation, Kubernetes manifests, Dockerfiles, and more - no cloud credentials needed, it never touches your AWS account.
The target: a deliberately insecure AWS stack
I wrote a main.tf that concentrates the misconfigurations behind many famous breaches:
# Issue 1: S3 bucket - public, unencrypted, no versioning, no logging
resource "aws_s3_bucket" "data" {
bucket = "company-customer-data-bucket"
acl = "public-read"
}
# Issue 2: security group open to the entire internet, including SSH
resource "aws_security_group" "web" {
name = "web-sg"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Issue 3: database publicly accessible, unencrypted, hardcoded password
resource "aws_db_instance" "db" {
identifier = "app-db"
engine = "mysql"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password = "SuperSecret123!"
publicly_accessible = true
storage_encrypted = false
skip_final_snapshot = true
}
# Issue 4: EC2 instance without IMDSv2 and with unencrypted root volume
resource "aws_instance" "app" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
root_block_device {
encrypted = false
}
}
# Issue 5: IAM policy with full admin wildcard
resource "aws_iam_policy" "admin" {
name = "app-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "*"
Resource = "*"
}
]
})
}
Five resources. Looks harmless, right? Let's see.
Running Checkov
pip install checkov
checkov -f main.tf
The verdict, in a few seconds and without touching AWS:
Passed checks: 14, Failed checks: 35, Skipped checks: 0
35 failed checks in 70 lines of Terraform. Some highlights:
๐ด The public S3 bucket (the classic breach)
- Check:
CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access." FAILED for resource:aws_s3_bucket.data - Check:
CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block" FAILED for resource:aws_s3_bucket.data - Check:
CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default" FAILED for resource:aws_s3_bucket.data
A bucket named company-customer-data-bucket with acl = "public-read" - this exact pattern exposed hundreds of millions of records in real incidents (Capital One, Accenture, the US voter records leak...).
๐ด The god-mode IAM policy
- Check:
CKV_AWS_62: "Ensure IAM policies that allow full"*:*"administrative privileges are not created" FAILED for resource:aws_iam_policy.admin - Check:
CKV_AWS_286: "Ensure IAM policies does not allow privilege escalation" FAILED for resource:aws_iam_policy.admin - Check:
CKV_AWS_288: "Ensure IAM policies does not allow data exfiltration" FAILED for resource:aws_iam_policy.admin
Action = "*" on Resource = "*" earned eight failed checks by itself: privilege escalation, credentials exposure, data exfiltration... one wildcard, every attack path.
๐ด SSH open to the world + public database
Checkov also flagged the security group allowing 0.0.0.0/0 on port 22 (CKV_AWS_24), the RDS instance with publicly_accessible = true (CKV_AWS_17), no storage encryption (CKV_AWS_16), and the hardcoded database password sitting in version control.
Fixing it
The remediated main_fixed.tf applies standard hardening:
| Misconfiguration | Fix |
|---|---|
| Public S3 bucket | aws_s3_bucket_public_access_block + private ACL |
| Unencrypted bucket | KMS key with rotation + SSE configuration |
| No versioning/logging | aws_s3_bucket_versioning + access logging to a second bucket |
SSH open to 0.0.0.0/0 |
Ingress restricted to a trusted CIDR variable, HTTPS only |
| Hardcoded DB password | variable "db_password" { sensitive = true } via TF_VAR_db_password |
| Public, unencrypted RDS | publicly_accessible = false, storage_encrypted = true, multi-AZ |
| EC2 metadata v1 | metadata_options { http_tokens = "required" } (IMDSv2) |
| Admin wildcard IAM | Least privilege: s3:GetObject on one bucket ARN only |
Two checks didn't apply to this workload (cross-region replication, S3 event notifications), so instead of silencing the scanner I documented the decision inline - Checkov's equivalent of Bandit's # nosec:
resource "aws_s3_bucket" "data" {
#checkov:skip=CKV_AWS_144:Single-region deployment, replication not required
#checkov:skip=CKV2_AWS_62:No downstream consumers need S3 event notifications
bucket = "company-customer-data-bucket"
}
The result:
Passed checks: 79, Failed checks: 0, Skipped checks: 5
From 35 failed checks to 0, with every skip justified and auditable. โ
Automating with GitHub Actions
Same principle as any SAST tool: a manual scan is a snapshot, a CI scan is a security gate. This workflow runs on every push and PR, and fails the build if the hardened configuration regresses:
name: Checkov IaC Scan
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Checkov
run: pip install checkov
- name: Scan vulnerable config (findings expected, does not block)
run: checkov -f main.tf --compact || true
- name: Scan fixed config (security gate)
run: checkov -f main_fixed.tf --compact
- name: Generate JSON report
if: always()
run: checkov -f main_fixed.tf -o json > checkov-report.json || true
- uses: actions/upload-artifact@v4
if: always()
with:
name: checkov-report
path: checkov-report.json
Now a pull request that opens port 22 to the internet gets a red โ before any human even reviews it.
Strengths and limitations
What impressed me about Checkov:
- Enormous policy coverage out of the box
- Scans finish in seconds with zero cloud credentials
- Checks map to real breach patterns
- The graph-based engine understands connections between resources (that's what the
CKV2_*checks do - for example, it knows my bucket lacks a Public Access Block resource, not just a bad attribute)
Limitations, in line with what OWASP says about SAST generally:
- It analyzes declared state, not what actually runs in your account (drift is invisible to it)
- It can't know your business context, so expect policies that don't apply to your workload - that's what documented skips are for
- It won't catch logic flaws in how your services use the infrastructure
Combine it with cloud posture management (CSPM) and runtime monitoring for full coverage.
Conclusion
The same lesson as application SAST, but the stakes are arguably higher: nobody exploits your off-by-one error as fast as a scanner finds your public S3 bucket. Checkov made 35 concrete problems visible in seconds, taught me the hardening pattern for each one, and now guards my infrastructure on every commit - for free.
Full demo code + workflow: github.com/Dayan-18/checkov-demo
Do you scan your IaC in CI? Which tool? Tell me in the comments! ๐
References: OWASP Source Code Analysis Tools ยท Checkov documentation ยท Terraform AWS provider docs
Comments
No comments yet. Start the discussion.