Hands-On Labs · Guided Drills · Fix a Broken Terraform Plan

Drill — Fix a Broken Terraform Plan

You've inherited infra/reports/main.tf — a small module that provisions a bucket for usage reports, an IAM policy for the pipeline that writes to it, and an EC2 instance that processes them. It already merged. Three people approved the pull request. None of them ran a scanner against it, because the repo didn't have one wired in yet — today you're the one adding it, and the first thing it finds is three real findings sitting in code everyone already signed off on. You'll run Checkov against the module, read exactly what it reports and why, fix each finding one at a time, and re-scan until the exit code says so, not until the diff merely looks right. The done-when is mechanical: zero failed checks, and a one-sentence answer for each fix explaining why the original line was actually dangerous — not just what you changed it to.

☺ Explain it like I'm 10

Three friends already checked your fort for weak spots and said it looked fine, because they were checking whether it looked like a fort, not whether the door actually locked. Today a much stricter inspector walks through with a checklist and finds three doors that don't lock at all. Fixing a door isn't "make it look different" — it's making the specific thing that was actually dangerous about it stop being true. That's the part this drill grades you on: not "did you change the line," but "can you say, in one sentence, what would have gone wrong if you hadn't."

🤖🐢Your hosts for this drill: Recon the Robot & Timmy the Turtle — Recon reads the resource graph a plan actually produces, not the story a pull-request description tells about it; Timmy won't call a finding fixed on the strength of your first explanation. He wants the second scan.

The scenario: a module three reviewers already waved through

☺ Like you're 10: The code already passed human review. That's exactly why a human review alone was never going to be enough.

The module is small on purpose — four resources, one file, nothing exotic — because a drill works better when there's nowhere for a mistake to hide once you're looking for it. Read it once, the way the three reviewers presumably did, before you scan anything:

# infra/reports/main.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "usage_reports" {
  bucket = "acme-usage-reports"
}

resource "aws_s3_bucket_acl" "usage_reports" {
  bucket = aws_s3_bucket.usage_reports.id
  acl    = "public-read"
}

resource "aws_iam_policy" "report_pipeline_deploy" {
  name = "report-pipeline-deploy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "*"
      Resource = "*"
    }]
  })
}

resource "aws_instance" "report_processor" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  root_block_device {
    volume_size = 50
    encrypted   = false
  }
}

Nothing here is exotic malice — it's the ordinary shape of a first draft. Someone made the bucket public because the reports needed to be reachable by a script and public was the fastest way to make that true today. Someone gave the deploy policy Action = "*" because scoping it down felt like a later problem while the pipeline was still being built. Someone left encrypted = false because it's the provider's own default and nobody typed anything at all. Three ordinary shortcuts, one file, and every one of them is a real finding.

Before you scan: what you need, and what you don't

☺ Like you're 10: You don't need a real AWS account for this — the inspector reads the blueprint, it doesn't need the house built first.

You need the Checkov CLI — pip install checkov, or the bridgecrew/checkov Docker image if you'd rather not touch a local Python environment — and, if you want terraform validate to run alongside it, Terraform 1.5 or later. You do not need an AWS account, a configured provider credential, or a terraform plan. Checkov parses the raw HCL through its own resource graph and resolves what it can without ever touching a cloud API — see the Checkov tool page for how that graph gets built — so every command in this drill runs against nothing but the file on disk.

⚠ Watch out

If you do run terraform init and terraform plan anyway — reasonable, since it's good practice to confirm the file is syntactically valid before trusting a scan of it — remember that a plan against no real credentials will fail the moment Terraform tries to reach the AWS API for anything it can't resolve locally. That's expected here and not a sign anything is broken; this drill only ever needs the source file, never a real plan.

The first scan: three failures, one non-zero exit code

☺ Like you're 10: The inspector doesn't grade on effort — it prints exactly what's wrong, where, and keeps grading the whole house at once.

$ checkov -d infra/reports/ --compact --quiet

Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."
    FAILED for resource: aws_s3_bucket_acl.usage_reports
    File: /main.tf:16-19

Check: CKV_AWS_62: "Ensure IAM policies that allow full "*:*" administrative privileges are not created"
    FAILED for resource: aws_iam_policy.report_pipeline_deploy
    File: /main.tf:21-31

Check: CKV_AWS_3: "Ensure all data stored in the EBS is securely encrypted"
    FAILED for resource: aws_instance.report_processor
    File: /main.tf:33-41

Passed checks: 9, Failed checks: 3, Skipped checks: 0

$ echo $?
1

Three findings, each naming a resource, a file, and a line range — and an exit code of 1, because Checkov fails the run by default the instant any check reports FAILED, with no severity flag required to make that a real gate. That default matters for what comes next: a CI job that runs this exact command will already refuse to merge this file, which is the entire reason this drill exists — the three reviewers who approved this pull request never had that gate in the pipeline to stop them. Treat the exact IDs above as illustrative of the check names, not a fixed table to memorize — run checkov --list against your own installed version if a message's wording doesn't match verbatim, the same caution the tool page gives for every check ID it names.

Fix #1 — the bucket anyone on the internet can read

☺ Like you're 10: "Public" doesn't mean "reachable by our script" — it means reachable by anyone who can guess or find the bucket's name, with no invitation required.

Setting the bucket private again isn't the whole fix — an ACL is one layer, and AWS ships a second, independent layer specifically because ACLs and bucket policies can each reintroduce public access on their own later. Close both:

resource "aws_s3_bucket_acl" "usage_reports" {
  bucket = aws_s3_bucket.usage_reports.id
  acl    = "private"                # fixed — was "public-read"
}

resource "aws_s3_bucket_public_access_block" "usage_reports" {
  bucket                  = aws_s3_bucket.usage_reports.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Why it was risky, in one sentence: a public-read ACL means anyone who guesses, enumerates, or finds the bucket name through a leaked URL or a misconfigured link can list and download every usage report inside it, with no AWS credential of their own and no record of who took what.

Fix #2 — the IAM policy that grants the whole account

☺ Like you're 10: A key that's supposed to open one specific door shouldn't also happen to open every other door in the building, the safe, and the front gate.

Scope the policy down to exactly the two actions the pipeline actually performs, against exactly the one bucket it writes to — not "every action, every resource," which is what Action = "*", Resource = "*" actually grants, regardless of what the policy's name suggests it's for:

resource "aws_iam_policy" "report_pipeline_deploy" {
  name = "report-pipeline-deploy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket"
      ]
      Resource = [
        aws_s3_bucket.usage_reports.arn,
        "${aws_s3_bucket.usage_reports.arn}/*"
      ]
    }]
  })
}

Why it was risky, in one sentence: a policy scoped to Action: "*" and Resource: "*" hands whatever principal assumes it full administrative control of the entire AWS account — not just this reports pipeline — so a single leaked credential or a compromised CI runner stops being a reports-bucket incident and becomes an account takeover.

Fix #3 — the EBS volume storing raw data in the clear

☺ Like you're 10: A locked front door doesn't matter if someone can just walk off with the whole filing cabinet and read it at home.

One field, flipped — the smallest diff of the three, and the easiest to mistake for the least important one:

resource "aws_instance" "report_processor" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  root_block_device {
    volume_size = 50
    encrypted   = true               # fixed — was false
  }
}

Why it was risky, in one sentence: an unencrypted root volume means the raw usage data resting on that disk is recoverable in plaintext by anyone who ends up with a copy of it outside your control — a stolen EBS snapshot, a decommissioned host resold without being wiped, or a misconfigured backup — none of which requires ever compromising the running instance itself.

FindingCheck IDWhat actually changed the risk
Public S3 bucketCKV_AWS_20Removed public-read ACL and added a public-access-block, so no future ACL or bucket policy can silently reopen it either
Wildcard IAM policyCKV_AWS_62Scoped Action and Resource down to the two S3 calls the pipeline actually makes, against the one bucket it makes them on
Unencrypted EBS volumeCKV_AWS_3Set encrypted = true on the root device, so the disk is unreadable outside the instance without the key

The second scan: proving the fix, not just believing it

☺ Like you're 10: A diff that "looks right" and a diff that "actually is right" read identically until you run the check a second time.

Run the exact same command against the fixed file. Nothing about the invocation changes — that consistency is the point, because it means the same gate that failed this file an hour ago is the one now telling you it's clean:

$ checkov -d infra/reports/ --compact --quiet

Passed checks: 12, Failed checks: 0, Skipped checks: 0

$ echo $?
0

Twelve passed where nine passed and three failed before — the same nine, plus the three you just fixed, nothing suppressed and nothing skipped. echo $? printing 0 is the actual done-when for this drill, not "the code looks different now." A #checkov:skip comment on each finding would have gotten you to the same green report in about thirty seconds, and it would have proven nothing at all — see the Checkov tool page on exactly that distinction between an accepted-risk suppression and an actual fix. This drill only counts if the resource itself changed, which is why the one-sentence "why it was risky" answers above matter as much as the diffs: if you can't state what was actually dangerous, you can't tell your own fix apart from a skip comment that hides the same danger behind a clean exit code.

🎬 At the Shift-Left Squad
🤖

Recon the Robot: Plan's in. Checkov: three failures. Bucket ACL, IAM policy, EBS volume.

🦫

Benny the Beaver: It's just the usage-reports bucket, though — nobody's storing anything sensitive in there.

🐢

Timmy the Turtle: "Nobody's storing anything sensitive yet" isn't the same sentence as "safe." The bucket doesn't know what gets written to it next quarter.

🦥

Sol the Sloth: ...I'm reading this IAM policy a third time. Action: "*", Resource: "*". That's not scoped to the reports bucket. That's every action, on every resource, in the whole account.

🦝

Rocky the Raccoon: So if I get my hands on a credential that assumed this role, what can't I touch?

🦥

Sol the Sloth: Nothing. That's the answer. Nothing.

🤖

Recon the Robot: Same story on the volume — encrypted = false on the root device. Anyone who gets a copy of that disk reads it in plaintext, no key required.

🐢

Timmy the Turtle: Fix all three. Then show me the second scan. I don't trust the first explanation — I trust the second exit code.

✓ Checkpoint

1. Why did the first Checkov scan exit non-zero, and which three check IDs actually failed? 2. For each of the three fixes, state in one sentence why the original configuration was risky — not just what line changed. 3. Why does re-running the scan against the fixed module prove something that reading the diff by eye doesn't? 4. If you wanted a second, independent tool to also catch a wildcard IAM policy the way CKV_AWS_62 does — before terraform apply ever runs, not after — what kind of check would you write, and where does that live in this course?

Check your answers
  1. Checkov fails the run by default the instant any check reports FAILED — no severity flag or --exit-code argument required. The three that failed were CKV_AWS_20 (public-read S3 ACL), CKV_AWS_62 (an IAM policy granting full "*:*" administrative privileges), and CKV_AWS_3 (an unencrypted EBS volume).
  2. The public bucket let anyone who found or guessed its name read every report in it with no credential required. The wildcard IAM policy handed whatever assumed it full control of the entire AWS account, not just the reports pipeline it was named for. The unencrypted volume meant the raw data on disk was recoverable in plaintext by anyone who obtained a copy of the disk itself — a stolen snapshot or a resold host — without ever needing to compromise the running instance.
  3. A diff can look correct and still be wrong — a typo'd resource name, an edit to the wrong block, a fix that changes the value Checkov's parser doesn't actually resolve the way you expect. Re-running the exact same scan command against the fixed file and getting a real exit code of 0 is proof from the same tool that raised the finding in the first place, rather than a human's visual confidence that the change "looks right."
  4. A policy-as-code rule — for example an OPA/Rego policy run through Conftest against terraform show -json output — that walks a plan's resource_changes and denies any aws_iam_policy whose statement contains Action: "*" and Resource: "*". That's the same policy-as-code layer OPA & Conftest covers, and it's exactly the skill the next drill in this section, Drill — Write a Policy-as-Code Rule, has you build from scratch.