Detecting Terraform Vulnerabilities with Checkov: An Insecure vs Secure Comparison Demo

Tadashi Shigeoka ·  Fri, April 10, 2026

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.yml

The 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 checkov

Run the scan against the insecure/ directory:

checkov -d insecure/ --framework terraform

The 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 IDDescription
CKV_AWS_18Access logging disabled
CKV_AWS_20Public READ access allowed
CKV_AWS_21Versioning disabled
CKV_AWS_144Cross-region replication not configured
CKV_AWS_145KMS encryption not configured
CKV2_AWS_6Public access block not configured
CKV2_AWS_61Lifecycle configuration missing
CKV2_AWS_62Event notifications disabled

Security Group (aws_security_group.web): 6 failures

Check IDDescription
CKV_AWS_23Rules missing descriptions
CKV_AWS_24SSH (22) open to 0.0.0.0/0
CKV_AWS_25RDP (3389) open to 0.0.0.0/0
CKV_AWS_260HTTP (80) open to 0.0.0.0/0
CKV_AWS_382Egress open to 0.0.0.0/0 on all ports
CKV2_AWS_5Not attached to any resource

RDS (aws_db_instance.main): 9 failures

Check IDDescription
CKV_AWS_16Storage encryption disabled
CKV_AWS_17Publicly accessible
CKV_AWS_118Enhanced Monitoring disabled
CKV_AWS_129Logging disabled
CKV_AWS_157Multi-AZ disabled
CKV_AWS_161IAM authentication disabled
CKV_AWS_226Auto minor version upgrade disabled
CKV_AWS_293Deletion protection disabled
CKV2_AWS_60Copy tags to snapshots disabled

IAM (aws_iam_policy.admin): 9 failures

Check IDDescription
CKV_AWS_62Grants full administrative privileges
CKV_AWS_63Allows * as action
CKV_AWS_286Allows privilege escalation
CKV_AWS_287Allows credentials exposure
CKV_AWS_288Allows data exfiltration
CKV_AWS_289Allows resource exposure without constraints
CKV_AWS_290Allows write access without constraints
CKV_AWS_355Allows * as resource for restrictable actions
CKV2_AWS_40Grants full IAM privileges

CloudTrail (aws_cloudtrail.main): 5 failures

Check IDDescription
CKV_AWS_35KMS encryption not configured
CKV_AWS_36Log file validation disabled
CKV_AWS_67Not enabled in all regions
CKV_AWS_252SNS topic not configured
CKV2_AWS_10Not 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 terraform

The 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: cli

Two key design decisions:

  • insecure/ uses soft_fail: true, so failures do not block CI (since the vulnerabilities are intentional)
  • secure/ uses soft_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.

That’s all from the Gemba, where we catch Terraform vulnerabilities before they ever reach apply.