Abstract
Application code isn't the only place a SAST tool belongs — infrastructure definitions are source code too, and misconfigurations written into a Terraform file ship to production exactly as reliably as a bug in application logic does. This article applies Checkov, an open-source static analysis tool for Infrastructure as Code, to a Terraform stack that provisions storage, networking, a database, and IAM permissions for an order service. The unmodified stack produced 36 failed checks against 14 passed. After remediating every finding that represented an actual exploitable risk, the same stack passed 57 checks with 6 remaining — and those 6 turned out to be business decisions (replication, lifecycle policies) rather than vulnerabilities, which is itself the more useful finding: not every flagged item deserves the same response.
Why IaC needs its own static analysis, separate from application SAST
A SAST tool for application code looks for dangerous function calls and data flows. A SAST tool for IaC looks for a different category entirely: resource configurations that violate a known-safe default. An aws_s3_bucket with no aws_s3_bucket_public_access_block isn't a code bug in the traditional sense — the Terraform is syntactically perfect and will apply cleanly. The risk is purely in what gets provisioned. That's precisely the gap Checkov, Terrascan, and similar tools are built to close: they evaluate the intended infrastructure state, not the code that produces it.
The real-world example: a Terraform stack for an order service
A small but realistic stack: an S3 bucket for order exports, a security group, an RDS database, an IAM policy, an EBS volume, and a CloudWatch log group.
# terraform-vulnerable/main.tf
resource "aws_s3_bucket" "order_exports" {
bucket = "store-order-exports"
acl = "public-read"
}
resource "aws_security_group" "app_sg" {
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "orders_db" {
password = "Sup3rSecret!2024"
publicly_accessible = true
storage_encrypted = false
}
resource "aws_iam_policy" "app_policy" {
policy = jsonencode({
Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }]
})
}
Nothing here fails terraform validate — it's all valid HCL that will provision successfully. That's exactly why this category of risk needs a dedicated scanner instead of relying on the provider to reject something dangerous.
Running Checkov against it
pip install checkov
checkov -d terraform-vulnerable --compact
Real, unedited output (excerpt):
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."
FAILED for resource: aws_s3_bucket.order_exports
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.app_sg
Check: CKV_AWS_16: "Ensure all data stored in the RDS is securely encrypted at rest"
FAILED for resource: aws_db_instance.orders_db
Check: CKV_AWS_17: "Ensure all data stored in RDS is not publicly accessible"
FAILED for resource: aws_db_instance.orders_db
Check: CKV_AWS_62: "Ensure IAM policies that allow full "*-*" administrative privileges are not created"
FAILED for resource: aws_iam_policy.app_policy
Check: CKV_AWS_3: "Ensure all data stored in the EBS is securely encrypted"
FAILED for resource: aws_ebs_volume.app_data
Passed checks: 14, Failed checks: 36, Skipped checks: 0
Every failed check carries a stable ID (CKV_AWS_*), which is what makes a finding something a team can track, suppress with a documented reason, or treat as a release blocker — instead of a one-off comment in a pull request.
Fixing what's actually a risk
| Resource | Finding | Fix |
|---|---|---|
| S3 bucket | Public-read ACL, no versioning, no encryption | Remove the ACL, add aws_s3_bucket_public_access_block, aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration
|
| Security group | SSH and Postgres open to 0.0.0.0/0
|
Scope cidr_blocks to the office VPN and the app subnet specifically |
| RDS instance | Public access, unencrypted storage, hardcoded password |
publicly_accessible = false, storage_encrypted = true, manage_master_user_password = true (AWS-managed via Secrets Manager) |
| IAM policy |
Action: "*" on Resource: "*"
|
Scope to the two S3 actions the service actually needs, on the specific bucket ARN |
| EBS volume | Unencrypted |
encrypted = true with a customer-managed KMS key |
| CloudWatch log group | No retention period | retention_in_days = 365 |
# terraform-fixed/main.tf
resource "aws_security_group" "app_sg" {
ingress {
description = "SSH from the office VPN only"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
}
}
resource "aws_db_instance" "orders_db" {
manage_master_user_password = true
publicly_accessible = false
storage_encrypted = true
deletion_protection = true
}
resource "aws_iam_policy" "app_policy" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "${aws_s3_bucket.order_exports.arn}/*"
}]
})
}
Re-scanning: from 36 failures to 6
$ checkov -d terraform-fixed --compact
Passed checks: 57, Failed checks: 6, Skipped checks: 0
The remaining 6 are worth looking at individually, because none of them are the same category of risk as the original 36:
CKV2_AWS_62 Ensure S3 buckets should have event notifications enabled
CKV_AWS_144 Ensure that S3 bucket has cross-region replication enabled
CKV2_AWS_61 Ensure that an S3 bucket has a lifecycle configuration
CKV2_AWS_64 Ensure KMS key Policy is defined
CKV2_AWS_30 Ensure Postgres RDS has Query Logging enabled
CKV_AWS_354 Ensure RDS Performance Insights are encrypted using KMS CMKs
Cross-region replication and lifecycle rules are cost and disaster-recovery decisions, not vulnerabilities — applying them blindly to satisfy a scanner would add infrastructure cost without addressing any actual exposure. Query logging and Performance Insights encryption are reasonable hardening that a team might genuinely choose to defer. This is the point a checklist mentality misses: the job of a SAST tool is to surface every deviation from a safe default, and the job of the engineer is still to decide which deviations matter for this specific system.
Wiring it into CI
# .github/workflows/checkov.yml
name: IaC SAST scan (Checkov)
on:
push:
branches: ["main"]
pull_request:
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install checkov
- run: checkov -d terraform-fixed --compact --soft-fail-on LOW
Running this on every terraform plan means a public S3 bucket or an open security group never reaches apply without someone explicitly seeing — and accepting — the finding first.
Conclusion
The first scan caught real, exploitable misconfiguration: a publicly readable bucket, an internet-facing database with a hardcoded password, an IAM policy with unrestricted * access. Those aren't edge cases — they're exactly the kind of thing that ends up in a breach disclosure. The second scan is the more instructive result: not every one of the remaining 6 findings deserved a fix, and treating a SAST tool's output as a literal to-do list rather than a prioritized list of deviations to evaluate is how teams either burn time hardening low-risk defaults or, worse, learn to ignore the tool altogether. Static analysis for infrastructure is most useful exactly where this example landed — as the thing that catches the dangerous default, while leaving the judgment calls to the team that owns the system.
Top comments (0)