Static analysis isn't just for application source code. Terraform, Pulumi, OpenTofu, and CloudFormation files are code too — and they get misconfigured just as often as a backend service. A public S3 bucket, a security group open to 0.0.0.0/0, or an unencrypted RDS instance are all bugs you can catch before apply ever runs.
TFSec is the tool most people reach for first, but it's not the only option on the OWASP Source Code Analysis Tools list. In this article I'll use Checkov, a free, open-source policy-as-code scanner built by Bridgecrew (now part of Palo Alto Networks), to scan a Terraform project end to end — from a local scan to a GitHub Actions gate that blocks merges on critical misconfigurations.
The same approach works with OpenTofu and Pulumi projects too, since Checkov understands HCL directly and also has native support for Pulumi's rendered plan output and CloudFormation/ARM/Kubernetes manifests.
Why Checkov?
- 100% open source (Apache 2.0), actively maintained, thousands of built-in policies.
- Understands Terraform, OpenTofu, CloudFormation, Kubernetes, Helm, Dockerfile, ARM, Serverless Framework, and Pulumi (via
cdktf/synthesized plans) — one tool across most of your IaC surface. - No account or API key required to run locally or in CI.
- Supports custom policies written in Python or YAML if the built-in rule set doesn't cover something specific to your org. ## 1. The sample infrastructure
A small AWS setup with a few intentionally introduced misconfigurations — the kind that get merged during a rushed sprint:
# main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "data" {
bucket = "company-app-data-bucket"
}
# Vulnerable: bucket has no encryption, no versioning, and is publicly readable
resource "aws_s3_bucket_acl" "data_acl" {
bucket = aws_s3_bucket.data.id
acl = "public-read"
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow web traffic"
# Vulnerable: SSH open to the entire internet
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "app_db" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
# Vulnerable: hardcoded credentials
username = "admin"
password = "SuperSecret123!"
# Vulnerable: no encryption at rest, publicly accessible
storage_encrypted = false
publicly_accessible = true
skip_final_snapshot = true
}
resource "aws_iam_policy" "broad_access" {
name = "broad-access-policy"
# Vulnerable: wildcard permissions
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "*"
Resource = "*"
}
]
})
}
Five issues live here: a public S3 bucket with no encryption or versioning, SSH exposed to the world, a hardcoded database password, an unencrypted publicly-accessible RDS instance, and an IAM policy with wildcard permissions.
2. Installing and running Checkov locally
pip install checkov
# Scan a directory
checkov -d .
# Scan a single file
checkov -f main.tf
Sample output:
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access"
FAILED for resource: aws_s3_bucket_acl.data_acl
File: /main.tf:9-12
Check: CKV_AWS_21: "Ensure the S3 bucket has versioning enabled"
FAILED for resource: aws_s3_bucket.data
File: /main.tf:6-8
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.web
File: /main.tf:14-27
Check: CKV_AWS_16: "Ensure RDS instances have storage encrypted"
FAILED for resource: aws_db_instance.app_db
File: /main.tf:34-47
Check: CKV_AWS_17: "Ensure RDS instances are not publicly accessible"
FAILED for resource: aws_db_instance.app_db
File: /main.tf:34-47
Check: CKV_SECRET_6: "Base64 High Entropy String"
FAILED for resource: aws_db_instance.app_db.password
File: /main.tf:41
Check: CKV_AWS_1: "Ensure IAM policies that allow full '*-*' administrative privileges are not created"
FAILED for resource: aws_iam_policy.broad_access
File: /main.tf:50-62
Passed checks: 12, Failed checks: 7, Skipped checks: 0
Checkov ships with over 1,000 built-in policies for AWS, Azure, and GCP, so a small file like this often surfaces more than what you deliberately planted.
3. Tuning it: a config file for reproducible scans
Create a .checkov.yaml at the repo root so local runs and CI runs always agree:
# .checkov.yaml
directory:
- .
framework:
- terraform
skip-check:
- CKV_AWS_8 # skip if you intentionally don't use launch config encryption in dev sandboxes
soft-fail-on:
- MEDIUM
- LOW
hard-fail-on:
- CRITICAL
- HIGH
output: cli
compact: true
Run it with:
checkov -d . --config-file .checkov.yaml
hard-fail-on / soft-fail-on is the key mechanism here: critical and high findings break the build, medium/low ones get reported without blocking — the same "gate on what matters" principle you'd apply to any SAST tool.
4. Machine-readable output for pipelines
checkov -d . --config-file .checkov.yaml -o json --output-file-path checkov-report.json
checkov -d . --config-file .checkov.yaml -o sarif --output-file-path checkov-report.sarif
The SARIF output is worth calling out: GitHub natively understands SARIF and can render findings directly in the Security > Code scanning alerts tab and inline on pull requests, without needing a third-party dashboard.
5. Automating it with GitHub Actions
# .github/workflows/sast-checkov.yml
name: SAST - Checkov (IaC)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
checkov-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Checkov
id: checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
config_file: .checkov.yaml
output_format: cli,sarif
output_file_path: console,checkov-report.sarif
soft_fail: false
- name: Upload SARIF to GitHub Security tab
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov-report.sarif
This runs on every push and pull request, fails the job when a critical/high finding hits (per the config file's hard-fail-on), and publishes results as native GitHub code-scanning alerts so reviewers see them inline on the diff — no external service required.
6. Fixing the findings
The remediated version of the same file, which passes with zero critical/high findings:
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "data" {
bucket = "company-app-data-bucket"
}
resource "aws_s3_bucket_versioning" "data_versioning" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data_encryption" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "data_block" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow web traffic"
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SSH restricted to a known bastion / VPN CIDR instead of the whole internet
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}
resource "aws_db_instance" "app_db" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
manage_master_user_password = true # credentials managed by AWS Secrets Manager
storage_encrypted = true
publicly_accessible = false
skip_final_snapshot = true
}
resource "aws_iam_policy" "scoped_access" {
name = "scoped-s3-read-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [aws_s3_bucket.data.arn, "${aws_s3_bucket.data.arn}/*"]
}
]
})
}
Running checkov -d . --config-file .checkov.yaml again returns zero critical or high findings.
Takeaways
- IaC misconfigurations are just as scannable as application code, and TFSec isn't the only tool for the job — the OWASP list points to several free alternatives, including Checkov, Terrascan, and KICS.
- Checkov's multi-framework support (Terraform, OpenTofu, Pulumi, CloudFormation, Kubernetes) makes it a reasonable default if your org has more than one IaC tool in play.
- SARIF output plus GitHub's native code-scanning integration gets you inline PR feedback without standing up a separate dashboard.
- Gate on severity (
hard-fail-on: CRITICAL, HIGH), report everything else — this keeps the pipeline useful instead of noisy. ## Demo repository
The vulnerable Terraform config, the fixed version, .checkov.yaml, and the GitHub Actions workflow above are all in this repo, ready to fork and run:
👉 https://github.com/<your-username>/sast-checkov-iac-demo
Fork it, open a pull request, and watch the Security tab populate with findings automatically.
If this was useful, drop a comment with which IaC tool you'd like to see covered next — Pulumi with native TypeScript policies, or OpenTofu, are both on my list.
Top comments (0)