What You'll Build
This lab gives you a deliberately insecure Terraform module and asks you to identify, understand, and fix five HIGH-severity Checkov security findings. This is the skill that matters in production — not just running Checkov and seeing red, but understanding why each finding is a real security risk and applying the correct Terraform configuration change to resolve it. You will not just add boilerplate fixes; you will understand the threat model behind each finding, the specific AWS API changes required, and how to verify the fix both with Checkov and by inspecting the deployed infrastructure. This lab mirrors real-world security remediation work that cloud engineers perform when adopting a security scanner for the first time on an existing Terraform codebase.
Setup — Insecure Terraform Module
Start with the intentionally insecure module below. This is a realistic representation of Terraform code written quickly without security review — it works, but it contains five HIGH-severity Checkov findings that would expose the cricket analytics platform to specific attack vectors.
#!/bin/bash
mkdir -p ~/checkov_lab && cd ~/checkov_lab
# Install Checkov
pip install checkov --quiet
# Create the insecure module
cat > main.tf << 'INSECURE'
# INTENTIONALLY INSECURE — contains 5 HIGH-severity Checkov findings
# Do NOT deploy this in production. Lab exercise only.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "ap-south-1" }
# ── Resource 1: S3 bucket — multiple issues ───────────────────────────────────
resource "aws_s3_bucket" "cricket_data" {
bucket = "cricket-analytics-data-lab"
# FINDING 1: No encryption (CKV_AWS_19)
# FINDING 2: No access logging (CKV_AWS_18)
tags = { Name = "cricket-data" }
}
# Public ACL — FINDING 3: Publicly readable (CKV_AWS_20)
resource "aws_s3_bucket_acl" "cricket_data" {
bucket = aws_s3_bucket.cricket_data.id
acl = "public-read" # INTENTIONALLY INSECURE
}
# ── Resource 2: Security group — overly permissive ────────────────────────────
resource "aws_security_group" "cricket_app" {
name = "cricket-app-sg"
vpc_id = data.aws_vpc.default.id
# FINDING 4: SSH open to world (CKV_AWS_25)
ingress {
description = "SSH from anywhere — INSECURE"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # INTENTIONALLY INSECURE
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
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"]
}
}
# ── Resource 3: RDS — unencrypted and publicly accessible ─────────────────────
resource "aws_db_instance" "cricket" {
identifier = "cricket-lab-db"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "cricket_admin"
password = "hardcoded_password_123" # Additional finding: hardcoded secret
# FINDING 5: Encryption disabled (CKV_AWS_16)
storage_encrypted = false # INTENTIONALLY INSECURE
# Bonus finding: publicly accessible
publicly_accessible = true # INTENTIONALLY INSECURE
skip_final_snapshot = true
multi_az = false
tags = { Name = "cricket-lab-db" }
}
data "aws_vpc" "default" {
default = true
}
INSECURE
echo '=== Run initial Checkov scan ==='
checkov -d . --framework terraform --severity HIGH --compact 2>&1
echo
echo 'Count HIGH findings:'
checkov -d . --framework terraform --severity HIGH --quiet 2>&1 | grep 'Failed checks' | head -5Fix 1 — CKV_AWS_19: S3 Encryption Not Enabled
The S3 bucket stores cricket match data without encryption at rest. An attacker who gains access to the S3 bucket (through a misconfigured bucket policy, an IAM credential leak, or AWS employee access) can read the data without any additional decryption step. AWS KMS SSE-S3 encryption ensures that data is encrypted at the storage layer — even raw access to the underlying storage does not expose readable data. The fix requires adding an 'aws_s3_bucket_server_side_encryption_configuration' resource.
# FIX 1: Add S3 server-side encryption (CKV_AWS_19)
# Add to main.tf:
resource "aws_s3_bucket_server_side_encryption_configuration" "cricket_data" {
bucket = aws_s3_bucket.cricket_data.id
rule {
apply_server_side_encryption_by_default {
# SSE-S3: managed by AWS, no additional cost, minimal config
# Use "aws:kms" with kms_master_key_id for customer-managed keys
sse_algorithm = "AES256" # Minimum: SSE-S3
# Production preference: sse_algorithm = "aws:kms"
# kms_master_key_id = aws_kms_key.s3.arn
}
bucket_key_enabled = true # Reduces KMS costs if using KMS
}
}
# Verify fix:
# checkov -d . --check CKV_AWS_19 --compact
# Expected: PASSED check: CKV_AWS_19Fix 2 — CKV_AWS_18: S3 Access Logging Not Enabled
Without access logging, there is no audit trail of who accessed which S3 objects. If the cricket analytics data is accessed by an unauthorised party, there is no forensic evidence of which files were accessed, from which IP, and at what time. Access logs are also required for most compliance frameworks (PCI-DSS, SOC 2) and are essential for incident response. The fix requires a separate logging bucket (logging cannot be sent to the same bucket) and an 'aws_s3_bucket_logging' resource.
# FIX 2: Enable S3 access logging (CKV_AWS_18)
# Add to main.tf:
# Separate bucket for receiving access logs
# (Cannot log to the same bucket — creates infinite logging loop)
resource "aws_s3_bucket" "access_logs" {
bucket = "cricket-analytics-access-logs-${data.aws_caller_identity.current.account_id}"
tags = { Name = "cricket-access-logs", Purpose = "s3-access-logging" }
}
resource "aws_s3_bucket_public_access_block" "access_logs" {
bucket = aws_s3_bucket.access_logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Enable logging on the cricket data bucket
resource "aws_s3_bucket_logging" "cricket_data" {
bucket = aws_s3_bucket.cricket_data.id
target_bucket = aws_s3_bucket.access_logs.id
target_prefix = "cricket-data/" # Organise logs by source bucket
}
data "aws_caller_identity" "current" {}
# Verify fix:
# checkov -d . --check CKV_AWS_18 --compactFix 3 — CKV_AWS_20: S3 Bucket Publicly Readable
The 'public-read' ACL makes every object in the cricket data bucket accessible to anyone on the internet without authentication. This is the most critical finding — it directly exposes match data, player statistics and potentially sensitive business data to public access. The fix is twofold: remove the public ACL and add an 'aws_s3_bucket_public_access_block' resource that permanently prevents any future public access configuration.
# FIX 3: Remove public ACL and block all public access (CKV_AWS_20, CKV_AWS_53)
# In main.tf: REMOVE the aws_s3_bucket_acl resource with acl = "public-read"
# Then ADD:
# Remove this resource entirely:
# resource "aws_s3_bucket_acl" "cricket_data" {
# bucket = aws_s3_bucket.cricket_data.id
# acl = "public-read" <-- DELETE THIS RESOURCE
# }
# Replace with public access block:
resource "aws_s3_bucket_public_access_block" "cricket_data" {
bucket = aws_s3_bucket.cricket_data.id
# Four independent settings — all must be true for complete protection
block_public_acls = true # Blocks new public ACLs
block_public_policy = true # Blocks bucket policies that grant public access
ignore_public_acls = true # Ignores any existing public ACLs
restrict_public_buckets = true # Restricts cross-account public access
# All four settings together prevent any path to public access
# Setting only some of them leaves partial attack surface
}
# Threat model: without this block, an IAM user with s3:PutBucketAcl permission
# could re-enable public access. With all four settings enabled, public access
# is blocked at the bucket level regardless of IAM permissions.
# Verify fix:
# checkov -d . --check CKV_AWS_20,CKV_AWS_53 --compactFix 4 — CKV_AWS_25: Security Group Allows Port 22 from 0.0.0.0/0
An SSH port (22) open to the entire internet allows any machine in the world to attempt SSH authentication against cricket analytics EC2 instances. Even with strong passwords or key-based authentication, this creates an attack surface for zero-day SSH exploits and enables credential brute-forcing. The correct fix is not to restrict SSH to specific CIDRs — it is to remove SSH access entirely and use SSM Session Manager instead, which requires no inbound port 22 and is controlled by IAM. If SSH must be kept, it should be restricted to the organisation's VPN CIDR or a bastion host security group ID.
# FIX 4: Remove SSH from 0.0.0.0/0 (CKV_AWS_25)
# Preferred fix: remove port 22 entirely — use SSM Session Manager
# Alternative: restrict to specific CIDR (VPN or bastion host)
# REPLACE the security group resource in main.tf:
resource "aws_security_group" "cricket_app" {
name = "cricket-app-sg"
description = "Cricket analytics application security group"
vpc_id = data.aws_vpc.default.id
# REMOVED: port 22 from 0.0.0.0/0
# SSM Session Manager needs only outbound HTTPS (port 443)
# which is allowed by the egress rule below
ingress {
description = "HTTPS from internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# If SSH is genuinely required:
# ingress {
# description = "SSH from VPN only (restrict to VPN CIDR)"
# from_port = 22
# to_port = 22
# protocol = "tcp"
# cidr_blocks = ["10.100.0.0/16"] # Your VPN CIDR
# }
egress {
description = "HTTPS for SSM, package updates and AWS APIs"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "cricket-app-sg" }
}
# To use SSM Session Manager (no port 22 needed):
# 1. Attach AmazonSSMManagedInstanceCore policy to the EC2 instance role
# 2. Run: aws ssm start-session --target INSTANCE_ID
# 3. This opens a shell via HTTPS — no SSH key distribution needed
# Verify fix:
# checkov -d . --check CKV_AWS_25 --compactFix 5 — CKV_AWS_16: RDS Encryption Disabled
An unencrypted RDS database stores all cricket analytics data in plaintext on AWS's physical storage. Physical access to AWS storage media (rare but possible), storage snapshot leakage, or AWS employee access could expose the data without any authentication requirement. Enabling RDS encryption with KMS ensures the data is encrypted at rest with keys that are controlled by the cricket analytics team — AWS cannot access the data without the team's KMS key. Additionally, removing 'publicly_accessible = true' places the database behind the VPC boundary where it can only be reached through configured security group rules.
# FIX 5: Enable RDS encryption (CKV_AWS_16) and remove public access (CKV_AWS_17)
# Add KMS key for RDS encryption
resource "aws_kms_key" "rds" {
description = "Cricket analytics RDS encryption key"
enable_key_rotation = true # Automatic annual key rotation
deletion_window_in_days = 14 # 14-day recovery window before key is deleted
}
# REPLACE the aws_db_instance resource:
resource "aws_db_instance" "cricket" {
identifier = "cricket-lab-db"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "cricket_admin"
# Fix: remove hardcoded password — use random_password + Secrets Manager
password = random_password.db.result
# FIX CKV_AWS_16: Enable encryption at rest
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn # Customer-managed key
# FIX CKV_AWS_17: Move to private network (not publicly accessible)
publicly_accessible = false
# Additional hardening:
multi_az = false # Enable for production
deletion_protection = false # Enable for production
skip_final_snapshot = true # Set false for production
tags = { Name = "cricket-lab-db" }
lifecycle {
ignore_changes = [password] # Allow Secrets Manager rotation
}
}
resource "random_password" "db" {
length = 32
special = true
override_special = "!#$%&*-_=+<>?"
}
# IMPORTANT: storage_encrypted cannot be changed after RDS creation
# Enabling encryption on an existing unencrypted DB requires:
# 1. Take a snapshot of the existing DB
# 2. Copy the snapshot with encryption enabled
# 3. Restore from the encrypted snapshot
# 4. Update the Terraform resource to reference the new encrypted instance
# This process has downtime — plan the migration carefully
# Verify fix:
# checkov -d . --check CKV_AWS_16,CKV_AWS_17 --compactVerification — Run Full Checkov Scan
#!/bin/bash
cd ~/checkov_lab
echo '=== Run full Checkov scan after all fixes ==='
checkov -d . --framework terraform --severity HIGH --compact 2>&1
echo
echo '=== Verify all 5 specific checks pass ==='
FAILED=0
for CHECK in CKV_AWS_19 CKV_AWS_18 CKV_AWS_20 CKV_AWS_25 CKV_AWS_16; do
RESULT=$(checkov -d . --framework terraform --check "$CHECK" --quiet 2>&1 | tail -3)
if echo "$RESULT" | grep -q 'Failed checks: 0'; then
echo "PASS: ${CHECK}"
else
echo "FAIL: ${CHECK} — ${RESULT}"
FAILED=1
fi
done
[[ $FAILED -eq 0 ]] && echo 'All 5 HIGH-severity findings resolved!' || echo 'Some findings remain — check output above'
echo
echo '=== Compare finding counts ==='
echo "Insecure module findings: 8+ HIGH"
echo "After fixes: $(checkov -d . --framework terraform --severity HIGH --quiet 2>&1 | grep 'Failed checks' | awk -F: '{print $2}' | tr -d ' ') HIGH"Extension Challenge: After resolving the five HIGH findings, run Checkov again and address the remaining MEDIUM findings. The most impactful MEDIUM findings for this configuration are CKV_AWS_23 (RDS Multi-AZ not enabled), CKV_AWS_57 (S3 versioning not enabled), CKV_AWS_111 (Secrets Manager rotation not configured for the DB password), and CKV_AWS_37 (Lambda function X-Ray tracing not enabled, if a Lambda is added). Resolving all HIGH and MEDIUM findings brings the configuration to a security posture suitable for SOC 2 Type II audit preparation.
- CKV_AWS_19 (S3 encryption): add 'aws_s3_bucket_server_side_encryption_configuration' with SSE-S3 or SSE-KMS — data at rest is encrypted even if the storage layer is compromised.
- CKV_AWS_18 (S3 access logging): add 'aws_s3_bucket_logging' pointing to a separate logging bucket — creates an immutable audit trail of all object access for incident forensics and compliance.
- CKV_AWS_20 (S3 public read): remove the public ACL resource and add 'aws_s3_bucket_public_access_block' with all four settings enabled — prevents any future public access regardless of bucket policy or ACL changes.
- CKV_AWS_25 (SG port 22 from anywhere): remove the SSH ingress rule entirely and use SSM Session Manager for instance access — eliminates the internet-exposed attack surface for SSH brute force and zero-day exploits.
- CKV_AWS_16 (RDS encryption): set 'storage_encrypted = true' and add a KMS key — encryption cannot be enabled on an existing unencrypted database; plan the migration via snapshot restore.
- RDS 'storage_encrypted' cannot be changed on an existing instance — if an unencrypted database exists in production, the migration path is: snapshot → copy with encryption → restore → update Terraform state via import.