100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Infrastructure as Code — Terraform & Ansible
50 minintermediate

Lab — Fix 5 Real Checkov HIGH-Severity Failures on an AWS Module

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.

Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: this starting module is like a club ground that hosts matches perfectly well but has never passed a safety inspection: the game functions, yet the sightscreens are unsecured, the gates are left open and the kit room has no lock. Just as a ground can stage a full match while quietly exposing players and spectators to hazards, this Terraform code deploys and runs — but carries five HIGH-severity Checkov findings, each mapping to a specific attack vector against the cricket analytics platform. Checkov plays the role of the pre-season ground inspector: it walks the venue with a checklist and issues a numbered report of exactly what fails and why, before the big match rather than after an incident. Just as an inspector's findings are fixed one hazard at a time — each fix re-verified — this lab has you remediate each finding and re-run the scan until it passes. The payoff of starting from deliberately broken code is the same as inspecting a flawed ground: you learn to recognise each hazard on sight, so you never build it into your own venue.
bash
#!/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 -5

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

Analogy🏏Cricket
🏏 Think of it like cricket: an unencrypted S3 bucket is like the team analyst leaving the tactical playbook in the pavilion in plain handwriting — anyone who gets into the room reads every bowling plan instantly. Encryption at rest rewrites that playbook in a cipher: just as a stolen kit bag full of coded notes is worthless to the opposition without the key, an attacker who reaches the bucket through a misconfigured policy or a leaked IAM credential finds only unreadable data at the storage layer. Just as the club can let the stadium store its bags overnight without trusting every groundsman, SSE-S3 means even raw access to AWS's underlying storage exposes nothing readable. And just as adding a cipher does not change how the analyst writes or reads notes day to day, server-side encryption is transparent to applications — the 'aws_s3_bucket_server_side_encryption_configuration' resource simply guarantees every object is encoded on write. The payoff: physical or credential-level access to the storage no longer equals access to the cricket match data.
hcl
# 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_19

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

Analogy🏏Cricket
🏏 Think of it like cricket: a bucket without access logging is like a dressing room with no visitors' register — if the team's match plans leak, nobody can say who entered, when, or what they touched. Access logging is the gatekeeper's ledger: just as every person entering the pavilion is written down with a name and a time, every S3 object access is recorded with the requester, source IP and timestamp, giving forensic evidence when cricket analytics data is accessed by an unauthorised party. Just as the register cannot be kept inside the very room it guards — an intruder would simply tear out the page — the logs must go to a separate logging bucket, since logging to the same bucket would create an infinite loop. And just as match officials and the governing body demand the register during an inquiry, compliance frameworks like PCI-DSS and SOC 2 require access logs outright. The payoff: when something goes wrong you investigate from evidence, not guesswork — and you can prove to the auditors that the ledger was always being kept.
hcl
# 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 --compact

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

Analogy🏏Cricket
🏏 Think of it like cricket: A public-read S3 bucket is the stadium gate flung open to the entire city, and the fix you just applied works at two levels for the same reason modern stadium security does. Removing the 'public-read' ACL closes this particular gate — necessary, but by itself fragile: some future renovation (a later Terraform change, a different team's bucket policy) could quietly reopen it. The aws_s3_bucket_public_access_block resource with all four flags true is the board-level standing order: 'no gate at this venue may EVER be configured for unticketed public entry, regardless of what any individual gate crew decides' — it blocks public ACLs and public bucket policies at the bucket level even if someone later writes one. Defence in depth means the mistake has to be made twice, in two different places, before data is exposed. And note what made this finding CRITICAL rather than merely HIGH: unlike a weak lock that an attacker must still pick (an encryption gap that requires first gaining access), an open gate requires no attacker skill at all — automated scanners sweep the internet for public buckets continuously, so exposure time is measured in minutes, not months. The gates you close fastest are the ones anyone can walk through.
hcl
# 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 --compact

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

Analogy🏏Cricket
🏏 Think of it like cricket: SSH open to 0.0.0.0/0 is like leaving the dressing-room door facing the public stands with a sign saying 'try the lock' — every one of thousands of spectators can walk up and attempt entry, and even a strong lock is exposed to the one pickpocket with a brand-new trick, just as key-based SSH remains exposed to zero-day exploits and brute-forcing. The best fix is not a shorter guest list but removing that door entirely: SSM Session Manager is the accredited officials' tunnel, where players enter through a controlled passage with passes checked by security — no inbound port 22 exists at all, and IAM decides who may pass, with every entry logged. If a door absolutely must remain, it opens only onto the team bus bay — the organisation's VPN CIDR or a bastion security group — never onto the general concourse. The payoff: the attack surface shrinks from the entire internet to zero (or to one guarded corridor), and access control moves from a lock anyone can probe to identity checks you fully control.
hcl
# 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 --compact

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

Analogy🏏Cricket
🏏 Think of it like cricket: an unencrypted, publicly accessible database is like storing the team's complete tactical archive in plain handwriting on a shelf in the stadium's front office window — readable by the building's owner, by anyone handling a copied file, or by a passer-by on the street. Encrypting RDS with a KMS key is rewriting the archive in a cipher whose key only the team analyst holds: just as even the stadium owner cannot read the coded notebooks without the team's key, AWS cannot read the cricket analytics data at rest without the team-controlled KMS key — physical media access and leaked storage snapshots yield only ciphertext. And just as the archive belongs in the members-only area behind pavilion security rather than in a street-facing window, removing 'publicly_accessible = true' moves the database behind the VPC boundary, reachable only through the specific security group rules the team has configured. The payoff is defence in depth: an attacker must first get past the venue's access controls and would still face the cipher — neither exposure alone gives up the data.
hcl
# 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 --compact

Verification — Run Full Checkov Scan

bash
#!/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.
Lesson 14 of 33
0% complete