Hands-On Labs · The Capstone · Part 5 of 7

Part 5 — Scan IaC & Enforce Policy

This is the deep version of Secure a Pipeline's fifth stage. Part 1 already found both bugs you're fixing today — they're sitting in infra/main.tf, seeded on day one, waiting for a scanner that actually looks. You'll run Checkov against that file, watch it catch one finding cleanly and say nothing at all about the other, and then write two OPA/Conftest Rego rules that close both misconfiguration classes for good — not just for this one plan, but for any future plan that tries the same shape of mistake. The done-when is specific and mechanical: the policy fails against the original, broken plan, and passes against the fixed one. Nothing softer than that counts.

⚠ Where you're arriving from, and where you're headed

Arriving: a vulnerly repo where app/, the Dockerfile, and k8s/deploy.yaml are already gated — SAST and secrets scanning from Part 2, an SCA gate from Part 3, and a signed, Kyverno-verified container from Part 4. None of that touches infra/ — it's the one directory in the repo no scanner has ever looked at, still exactly as Part 1 seeded it. Leaving this page: infra/main.tf scanned clean by Checkov, a new policy/terraform/ directory holding two tested Rego rules, and a required CI check that rejects a Terraform plan carrying either seeded misconfiguration — or anything shaped like it — before terraform apply ever runs. Part 6 picks up from here by deploying this now-hardened infrastructure to a staging namespace and pointing OWASP ZAP at it.

☺ Explain it like I'm 10

A home inspector walks through a house with a checklist: smoke detectors, guardrails on the stairs, no exposed wiring. They're thorough, but the checklist only covers what someone thought to write down — if nobody added "the back door lock actually works" to the list, the inspector walks right past a door standing wide open and signs off anyway. Checkov is that inspector for Vulnerly's infrastructure: excellent at everything on its list, silent about the thing that isn't. Today you find the open door yourself, then you don't just close it — you write it onto the checklist permanently, in a language the inspector will check every single time from now on, whether it's this house or the next one.

🤖🦥Your hosts for this part: Recon the Robot & Sol the Sloth — Recon trusts a resource graph, not a gut feeling, and won't let a plan through without one; Sol is slow on purpose, rereading the same ingress rule three times before agreeing with Recon that it's actually clean. Between them, nothing gets applied that neither one has verified.

What this part assumes, and what it adds to the world Part 1 built

☺ Like you're 10: The laptop tools change today — Terraform and two new scanners join git and Docker — but the app and the repo are the same ones from every earlier part.

You need Terraform (1.5 or later), the Checkov CLI (pip install checkov, or the Docker image if you'd rather not touch your local Python), and the Conftest CLI (a single static binary — grab the release for your platform from github.com/open-policy-agent/conftest/releases). The plain opa binary is useful too, for running the Rego unit tests directly rather than through Conftest — install it from github.com/open-policy-agent/opa/releases if you want that step. None of this needs the kind cluster from Part 4 — IaC scanning and policy evaluation both happen against files on disk, before anything is provisioned anywhere.

Two new things join the shared world every remaining part builds on:

ThingNameIntroduced
IaC configinfra/main.tf — seeded in Part 1, completed and fixed todayPart 1 (seed) → Part 5 (this page)
Policy directorypolicy/terraform/ — two Rego rules plus their unit testsPart 5 — this page
Plan artifactplan.json — the resolved output of terraform show -json, what Conftest actually readsPart 5 — this page
CI jobiac-policy in .github/workflows/ci.yml — Parts 2-6 each add one required check; this is Part 5'sPart 5 — this page

The infra Terraform already provisions — and the two mistakes sitting in it

☺ Like you're 10: The exact same two lines Part 1 spotted just by reading the file are still there, unchanged, waiting for a tool to actually catch them.

Part 1 read infra/main.tf by eye and flagged both resources in it: a transaction-export S3 bucket with a public-read ACL, and a Postgres security group open to the entire internet. Nobody has fixed either one since — that's deliberate, the same way none of Parts 2 through 4 touched this file. Complete it with a provider block so it's actually runnable, and confirm this is what you're looking at before scanning anything:

# infra/main.tf — unchanged from Part 1, plus the provider block Part 5 needs to actually run it
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 4.0" }
  }
}

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

resource "aws_s3_bucket" "exports" {
  bucket = "vulnerly-transaction-exports"
  acl    = "public-read"                # transaction-export logs, world-readable
}

resource "aws_security_group" "db" {
  name = "vulnerly-db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]         # Postgres reachable from the whole internet
  }
}

The provider is pinned to the AWS 4.x line specifically because it's the last major version that accepts acl as an inline argument on aws_s3_bucket — deprecated, but still functional, which is exactly why it's such a realistic way for this to slip through in a real codebase: it isn't broken syntax, it's a working, silently-dangerous default. (5.x removes the inline argument entirely in favor of a separate aws_s3_bucket_acl resource — the finding Checkov reports is identical either way, so adjust the resource shape if you're starting fresh on 5.x.) Two findings, two blast radii worth naming precisely before you scan anything: the bucket leaks read access to whatever ends up in it, but only to someone who already knows — or guesses — the bucket name. The security group is worse in one specific way Vulnerly's own API never has to deal with: it's a path into Postgres that skips the API entirely, along with every SQL-injection fix from Part 2 and every ownership check waiting in Part 6. Neither control matters if the database itself is one psql connection away from anyone on the internet who finds the right host and port.

Running Checkov against the plan, and where its coverage stops

☺ Like you're 10: The inspector's checklist catches the bucket immediately — it's exactly the kind of thing checklists are built for — and says absolutely nothing about the open door, because nobody wrote "check this specific door" onto the list.

Initialize and plan first, the way you would for any real change — Checkov itself scans the raw HCL directly (it resolves variables and modules through its own graph, the way the Checkov tool page covers), so you don't strictly need a plan file for this step, but generating one now saves it for the Conftest section below:

cd infra
terraform init -input=false
terraform plan -input=false -out=vulnerly.tfplan
terraform show -json vulnerly.tfplan > plan.json
cd ..
$ checkov -d infra/ --compact --quiet

Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."
    FAILED for resource: aws_s3_bucket.exports
    File: /main.tf:11-14

Passed checks: 6, Failed checks: 1, Skipped checks: 0

One failure, exactly where the tool page said to expect it — CKV_AWS_20, the same check ID that page's own example uses. Now point Checkov specifically at the two built-in security-group checks it ships, the ones already named on that same tool page:

$ checkov -d infra/ --check CKV_AWS_24,CKV_AWS_25 --compact --quiet

Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0/0 to port 22"
    PASSED for resource: aws_security_group.db
Check: CKV_AWS_25: "Ensure no security groups allow ingress from 0.0.0.0/0 to port 3389"
    PASSED for resource: aws_security_group.db

Passed checks: 2, Failed checks: 0, Skipped checks: 0

Read those two "PASSED" lines carefully — this is the actual finding of this section. They are not saying the security group is safe. They're saying it doesn't happen to open port 22 or port 3389, which is true and irrelevant: it opens port 5432 instead, and Checkov's built-in AWS library ships dedicated, numbered checks for the ports attackers scan first — SSH and RDP — with nothing pre-built for an arbitrary application port like Postgres. Run the full scan again with every built-in check enabled and the security group still comes back clean. That's not a bug in Checkov; it's the honest shape of any library built from a finite list of known-bad patterns: it has a coverage floor, not a coverage ceiling, and a database open to the entire internet on a port nobody wrote a rule for sails straight through it.

◆ Key idea

"Checkov didn't flag it" and "it's fine" are different claims, and collapsing them is the single most common way a real IaC review goes wrong. A scanner's silence means one of two things — the resource is actually safe, or nobody has written the rule yet — and from the CLI output alone, those look identical. The only way to tell them apart is to already know what you're looking for, which is exactly what Part 1's threat model was for: it named this security group as a finding independently, before any scanner ran, which is the only reason you know to keep looking here instead of trusting Checkov's clean pass on faith.

Closing the exact gap: two Rego rules enforced through Conftest

☺ Like you're 10: Instead of waiting for someone to remember to add "check this door" to the inspector's list, you write the rule yourself, once, in a form the inspector will run forever after.

Write both rules against plan.json's resolved resource_changes array, the same shape the OPA & Conftest tool page already introduced. The first rule reinforces Checkov's own finding — belt and suspenders, enforced identically by a second, independent tool. The second closes the actual gap:

# policy/terraform/s3_public_read.rego
package main

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_s3_bucket"
    rc.change.after.acl == "public-read"
    msg := sprintf(
        "%s sets acl = \"public-read\" — transaction-export data must not be world-readable",
        [rc.address],
    )
}
# policy/terraform/db_open_ingress.rego
package main

# well-known database ports — extend this set as new engines join Vulnerly's stack
db_ports := {5432, 3306, 1433, 27017}

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_security_group"
    some rule in rc.change.after.ingress
    some cidr in rule.cidr_blocks
    cidr == "0.0.0.0/0"
    some port in db_ports
    rule.from_port <= port
    rule.to_port >= port
    msg := sprintf(
        "%s opens database port %d to the entire internet (0.0.0.0/0)",
        [rc.address, port],
    )
}

Two details in the second rule are worth being deliberate about, not accidental. First, it checks a rangerule.from_port <= port and rule.to_port >= port — instead of rule.from_port == port. A security group that opens every port (from_port = 0, to_port = 65535) to 0.0.0.0/0 is at least as dangerous as one that opens 5432 specifically, and an equality check would miss it entirely; a range check catches both shapes of the same underlying mistake with one rule. Second, db_ports is a set, not a single hardcoded number — the rule already covers MySQL, MSSQL, and MongoDB's default ports too, on the theory that the next database Vulnerly adds shouldn't need a third Rego file written from scratch.

Test both rules with opa test before trusting them against a real plan — the same unit-testing discipline the tool page covers, and the actual reason a Rego policy counts as code rather than a hopeful guess:

# policy/terraform/db_open_ingress_test.rego
package main_test

import data.main

test_deny_when_db_port_open_to_internet if {
    count(main.deny) > 0 with input as {"resource_changes": [{
        "address": "aws_security_group.db",
        "type": "aws_security_group",
        "change": {"after": {"ingress": [{
            "from_port": 5432, "to_port": 5432, "cidr_blocks": ["0.0.0.0/0"],
        }]}},
    }]}
}

test_allow_when_restricted_to_app_subnet if {
    count(main.deny) == 0 with input as {"resource_changes": [{
        "address": "aws_security_group.db",
        "type": "aws_security_group",
        "change": {"after": {"ingress": [{
            "from_port": 5432, "to_port": 5432, "cidr_blocks": ["10.0.1.0/24"],
        }]}},
    }]}
}
$ opa test policy/terraform -v
policy/terraform/db_open_ingress_test.rego:
data.main_test.test_deny_when_db_port_open_to_internet: PASS (0.4ms)
data.main_test.test_allow_when_restricted_to_app_subnet: PASS (0.3ms)
--------------------------------------------------------------------------------
PASS: 2/2

With both rules tested in isolation, run them for real against the plan you already generated — the original, still-broken one:

$ conftest test --policy policy/terraform infra/plan.json
FAIL - infra/plan.json - main - aws_s3_bucket.exports sets acl = "public-read" — transaction-export data must not be world-readable
FAIL - infra/plan.json - main - aws_security_group.db opens database port 5432 to the entire internet (0.0.0.0/0)

2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions

That's the first half of the done-when, satisfied: the policy fails against the original plan, and it fails on both findings — including the one Checkov's built-in library walked straight past.

⚠ Point Conftest at the wrong file and both rules pass for the wrong reason

Run conftest test --policy policy/terraform infra/main.tf instead of plan.json and both rules report success — not because the misconfigurations are gone, but because raw HCL has no resource_changes key at all. some rc in input.resource_changes iterates over nothing, the rule body never fires, and Conftest reports a clean pass for a reason that has nothing to do with your Terraform. This is the exact gotcha the OPA & Conftest page names explicitly: raw .tf files give Conftest literal values only, with no variables or modules resolved — always test against terraform show -json output for policies written this way, never the source file directly.

Fixing the Terraform, and proving the gate holds in both directions

☺ Like you're 10: The actual fix is two small edits — the real work today was making sure a third mistake just like these two can never sneak through again.

The fix itself is almost anticlimactic next to the policy work above — which is exactly the point. A misconfiguration is usually a one-line diff; what's hard, and what today's Rego actually buys you, is making sure the next one-line mistake gets caught automatically instead of waiting for another threat model to spot it by eye:

# infra/main.tf — the fix, both resources
resource "aws_s3_bucket" "exports" {
  bucket = "vulnerly-transaction-exports"
  acl    = "private"                    # fixed — was "public-read"
}

resource "aws_security_group" "db" {
  name = "vulnerly-db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.1.0/24"]       # fixed — was 0.0.0.0/0; now only the app subnet
  }
}

Regenerate the plan against the fixed file and run the exact same two checks again — this is the direction it's tempting to skip, and the one that actually proves anything:

cd infra
terraform plan -input=false -out=vulnerly-fixed.tfplan
terraform show -json vulnerly-fixed.tfplan > plan-fixed.json
cd ..

checkov -d infra/ --compact --quiet
# Passed checks: 7, Failed checks: 0, Skipped checks: 0

conftest test --policy policy/terraform infra/plan-fixed.json
# 2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions

Both tools now agree, and — more to the point — they agree for two different reasons that both matter: Checkov's built-in CKV_AWS_20 passes because the bucket is private again, and your own two Rego rules pass because neither pattern exists in the plan anymore. That second pass is the one Checkov alone could never have given you, because Checkov alone never had a rule to fail against in the first place.

Wiring the gate into CI, before terraform apply ever runs

☺ Like you're 10: A rule that only runs when you happen to remember to run it isn't a gate — it's a suggestion. Today's rule goes into the pipeline so nobody has to remember.

A policy that only lives on your own laptop protects exactly one person's Terraform. Add iac-policy as a new job in .github/workflows/ci.yml — the same stub file Part 1 seeded, sitting alongside the build job and whatever Part 2 through Part 4 already added — and mark it required in the repo's branch protection settings, the same way every earlier gate in this capstone became merge-blocking rather than merely informative:

# .github/workflows/ci.yml — add this job; Parts 2-4 already added the ones before it
  iac-policy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "1.7.5" }

      - name: terraform init & plan
        working-directory: infra
        run: |
          terraform init -input=false
          terraform plan -input=false -out=vulnerly.tfplan
          terraform show -json vulnerly.tfplan > plan.json

      - name: Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: infra
          quiet: true

      - name: Conftest
        run: |
          curl -sSL -o conftest.tar.gz \
            https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz
          tar xzf conftest.tar.gz conftest && sudo mv conftest /usr/local/bin
          conftest test --policy policy/terraform infra/plan.json

Every step in that job exits non-zero on a real finding, and GitHub's own required-checks setting is what turns "the pipeline reported a problem" into "the merge button is physically disabled" — the same distinction security in CI/CD makes about report-only versus build-blocking gates. Once this job is required, the two resources above can't regress silently: a future pull request that reintroduces either seeded pattern — or a brand-new one shaped just like it — fails here, before terraform apply is ever a live option.

What "done" looks like for Part 5

☺ Like you're 10: Two rules, tested on their own, proven against a real broken plan and a real fixed one, and wired so nobody has to remember to run them by hand.

At the end of this part: infra/main.tf scans clean under Checkov, policy/terraform/ holds two Rego rules with passing opa test unit tests, and conftest test --policy policy/terraform against the original plan produces exactly two failures while the same command against the fixed plan produces zero — the literal done-when this page opened with, proven in both directions, not asserted in one. The iac-policy job is a required check in .github/workflows/ci.yml, so the next pull request that tries either seeded pattern again fails automatically. Nothing here is thrown away: Part 6 deploys this now-hardened infrastructure to a staging namespace and runs OWASP ZAP against it from the outside, the same way Part 1's own findings table always said it would.

🎬 At the Shift-Left Squad
🦉

Professor Owl: Infra's the one directory nobody's scanned yet. Recon, Sol — this stage is yours.

🦊

Foxy: Checkov already scans Terraform, though. Why write a second policy on top of it?

🤖

Recon the Robot: Because I ran it myself. One failure — the bucket. Total silence on port 5432 open to the whole internet.

🦥

Sol the Sloth: ...I checked that ingress rule three times before I believed it. Nothing's broken about it. Checkov just never got a rule written for that exact port. Slow going, but I'd rather be sure than fast and wrong.

🦝

Rocky the Raccoon: A scanner said "clean" about a database anyone on the internet can reach. That gap is exactly what I go looking for.

🤖

Recon the Robot: Which is why the rule lives in Rego now, not in whichever tool happened to remember to check for it. conftest test, every plan, before apply — no exceptions, no memory required.

🐢

Timmy the Turtle: And I don't trust it until it fails on the broken plan and passes on the fixed one. Both directions. Show me.

Milestones

☺ Like you're 10: Tick a box only once you've watched the command actually run on your own screen — a step that "sounds right" isn't the same as one you've verified.

Work these in order. Progress saves in this browser.

0 / 9 milestones complete
1Complete infra/main.tf and push it
Add the terraform and provider blocks around Part 1's two seeded resources, exactly as shown above, then commit and push.
Done when: terraform init and terraform validate both succeed with no errors.
2Generate the original plan as JSON
terraform plan -out=vulnerly.tfplan, then terraform show -json vulnerly.tfplan > plan.json.
Done when: infra/plan.json exists and its resource_changes array lists both aws_s3_bucket.exports and aws_security_group.db.
3Run Checkov and confirm the bucket finding
checkov -d infra/ --compact --quiet.
Done when: the report shows exactly one failure, CKV_AWS_20, against aws_s3_bucket.exports.
Tool: Checkov
4Prove the security-group coverage gap yourself
checkov -d infra/ --check CKV_AWS_24,CKV_AWS_25 --compact --quiet against the still-broken aws_security_group.db.
Done when: both checks report PASSED for a security group you know is open to 0.0.0.0/0 on port 5432 — the gap this part exists to close.
5Write both Rego rules and their unit tests
Create policy/terraform/s3_public_read.rego, db_open_ingress.rego, and db_open_ingress_test.rego exactly as shown above.
Done when: opa test policy/terraform -v reports PASS: 2/2.
6Run Conftest against the original plan
conftest test --policy policy/terraform infra/plan.json.
Done when: the output shows exactly 2 failures — the public-read ACL and the open ingress rule, both named by address in the message.
This is the first half of Part 5's done-when.
7Fix infra/main.tf
Set acl = "private" and change the security group's cidr_blocks to the app subnet only, exactly as shown above.
Done when: terraform validate still passes with no errors after the edit.
8Prove the fixed plan passes both tools
Regenerate plan-fixed.json, then run both checkov -d infra/ and conftest test --policy policy/terraform infra/plan-fixed.json again.
Done when: Checkov reports zero failures and Conftest reports 2 tests, 2 passed ... 0 failures — the second half of the done-when.
This completes Part 5's done-when.
9Add iac-policy as a required CI check
Add the job shown above to .github/workflows/ci.yml, push, then mark it required in the repo's branch protection settings.
Done when: a test pull request that reintroduces acl = "public-read" is blocked from merging by this check, with no manual review needed to catch it.
✓ Checkpoint

1. Name the two deliberately over-permissive resources in infra/main.tf and the exact fix that closes each one. 2. Why does Checkov's own scan report a clean pass for aws_security_group.db even though it's open to the entire internet on the database port? 3. In the db_open_ingress.rego rule, why does it check a port range (from_port <= port and to_port >= port) instead of testing from_port == port directly? 4. What two separate Conftest runs together prove the policy actually enforces anything, and why does running only one of them fail to prove that?

Check your answers
  1. aws_s3_bucket.exports has acl = "public-read", fixed by setting it to "private". aws_security_group.db has an ingress rule on port 5432 with cidr_blocks = ["0.0.0.0/0"], fixed by restricting it to the app subnet's CIDR instead.
  2. Checkov ships dedicated, numbered built-in checks for the specific ports attackers scan first — CKV_AWS_24 for SSH (port 22) and CKV_AWS_25 for RDP (port 3389) — but has no equivalent built-in check for an arbitrary application port like Postgres's 5432. The security group genuinely doesn't match either of those two checks' conditions, so both correctly report PASSED; the scan simply never had a rule capable of catching this specific finding in the first place.
  3. A range check also catches a security group that opens every port (for example from_port = 0, to_port = 65535) to 0.0.0.0/0, which is at least as dangerous as opening 5432 specifically but would slip past an exact-equality check entirely. Checking whether the database port falls anywhere inside the rule's open range catches both shapes of the same underlying mistake with one rule.
  4. Running Conftest against the original, broken plan.json (which must show 2 failures) and against the fixed plan-fixed.json (which must show 0 failures). Testing only the broken plan proves the policy can detect the problem but says nothing about whether it's written correctly — an overly broad rule that fails on everything would also pass that half. Testing only the fixed plan proves nothing at all, since a rule that never fires on anything would pass it too. Only both together prove the policy discriminates correctly between the two states.

Part 5 leaves you with a scanned, policy-gated infra/ directory and a Rego policy proven to fail on the broken plan and pass on the fixed one. Continue to Capstone Part 6 — Run DAST Against Staging, where this now-hardened infrastructure gets a real deployment and a real external scan. Or step back to the full capstone hub to see how this part fits the other six, and revisit IaC security & policy as code and OPA & Conftest for the concepts behind what you just built.