CoursesOpenTofuPolicy-as-code & scanning

Policy-as-code & scanning

OPA/Conftest and Checkov on OpenTofu.

Advanced12 min · lesson 11 of 12

A building inspector does not take your word for it. They read the stamped drawings, then they walk the site and look at what is actually about to be poured. Policy-as-code is that second inspection, written down as machine-checkable rules and run by a robot on every change. No storage bucket reachable from the internet. No admin port open to the world. Every resource carrying an owner tag. Break a rule and the pipeline stops before anything reaches the cloud. HCP Terraform, the hosted service that used to be called Terraform Cloud, ships a policy language called Sentinel, but Sentinel is HashiCorp's own closed product and OpenTofu cannot use it. The open route is OPA (Open Policy Agent, a general-purpose rules engine that answers yes-or-no questions about structured data), driven by a wrapper called Conftest, with a static scanner such as Checkov as the fast first layer.

The two tools are not rivals. What separates them is which document they read. Checkov reads your HCL (HashiCorp Configuration Language, the .tf files you typed) and matches known-bad shapes in the text. Conftest reads the plan, which is OpenTofu's work order for this exact run, produced after every variable, module input, count and cross-resource reference has been resolved into a concrete value. Your code says what you meant. The plan says what will happen. The gap between the two is where trouble hides, and it is the gap an attacker aims for. Nobody sneaks in a line that says public. They point a firewall rule at a value that lives somewhere else and let the pipeline fetch it.

main.tf
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-prod"
}
# Looks careful. Two of the four switches are off, and a diff hides that well.
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = false
ignore_public_acls = true
restrict_public_buckets = false
}
# The address range is not in this repository at all.
data "aws_ssm_parameter" "admin_cidr" {
name = "/net/admin-cidr"
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = var.bastion_sg_id
cidr_ipv4 = nonsensitive(data.aws_ssm_parameter.admin_cidr.value)
from_port = 22
to_port = 22
ip_protocol = "tcp"
}

That configuration would survive most code reviews. The S3 (Simple Storage Service) bucket has a public access block attached, which looks responsible, and two of its four switches are on. The two that are off are the ones that matter. block_public_policy rejects any bucket policy that grants access to the world, and restrict_public_buckets stops a bucket that already carries such a policy from serving strangers. With both set to false, anyone who can write a bucket policy can publish your log archive. The other two switches govern ACLs (access control lists, the older per-object permission system that most accounts no longer use).

The SSH rule (Secure Shell, the standard way to log into a Linux machine over the network) is worse, because it carries no address range at all. The attribute is called cidr_ipv4 because firewall rules are written in CIDR notation (Classless Inter-Domain Routing, the a.b.c.d/n way of naming a block of addresses, where /32 means one machine and /0 means all of them). Here that value is read at runtime from AWS Systems Manager Parameter Store, a key-value store that lives in the cloud account rather than in git. Anyone who can edit /net/admin-cidr can widen your firewall without touching this repository, without a pull request, and without a reviewer ever seeing it.

Turn the plan into data

Two commands take you from configuration to something a rules engine can chew on. The first works out what would happen and saves it to a binary file. The second prints that file as JSON (JavaScript Object Notation, a plain-text format built for machines to parse rather than for people to read).

terminal
# save the plan; nothing is created yet
$ tofu plan -out=tfplan.bin
output
data.aws_ssm_parameter.admin_cidr: Reading...
data.aws_ssm_parameter.admin_cidr: Read complete after 0s [id=/net/admin-cidr]
OpenTofu used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
OpenTofu will perform the following actions:
# aws_s3_bucket.logs will be created
+ resource "aws_s3_bucket" "logs" {
+ acceleration_status = (known after apply)
+ acl = (known after apply)
+ arn = (known after apply)
+ bucket = "acme-logs-prod"
+ bucket_domain_name = (known after apply)
+ bucket_prefix = (known after apply)
+ bucket_regional_domain_name = (known after apply)
+ force_destroy = false
+ hosted_zone_id = (known after apply)
+ id = (known after apply)
+ object_lock_enabled = (known after apply)
+ policy = (known after apply)
+ region = (known after apply)
+ request_payer = (known after apply)
+ tags_all = (known after apply)
+ website_domain = (known after apply)
+ website_endpoint = (known after apply)
}
# aws_s3_bucket_public_access_block.logs will be created
+ resource "aws_s3_bucket_public_access_block" "logs" {
+ block_public_acls = true
+ block_public_policy = false
+ bucket = (known after apply)
+ id = (known after apply)
+ ignore_public_acls = true
+ restrict_public_buckets = false
}
# aws_vpc_security_group_ingress_rule.ssh will be created
+ resource "aws_vpc_security_group_ingress_rule" "ssh" {
+ arn = (known after apply)
+ cidr_ipv4 = "0.0.0.0/0"
+ from_port = 22
+ id = (known after apply)
+ ip_protocol = "tcp"
+ security_group_id = "sg-0a1b2c3d4e5f67890"
+ security_group_rule_id = (known after apply)
+ tags_all = (known after apply)
+ to_port = 22
}
Plan: 3 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan.bin
To perform exactly these actions, run the following command to apply:
tofu apply "tfplan.bin"

There it is. The address range the code never spelled out is now the literal string 0.0.0.0/0, which means every address on the internet, because OpenTofu read the parameter while planning. A reviewer staring at the HCL could not have seen that. A rule reading the plan cannot miss it.

terminal
$ tofu show -json tfplan.bin > plan.json
$ jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"' plan.json
output
create aws_s3_bucket.logs
create aws_s3_bucket_public_access_block.logs
create aws_vpc_security_group_ingress_rule.ssh

Every managed resource that will change lands in the resource_changes array, and each entry carries an address, a type, a mode and a change object. Data sources are normally read during the plan, so they are resolved before the array is written and do not appear as changes. There is an exception, and it matters when you write rules: if a data source cannot be read until apply time, OpenTofu defers it and it does show up in resource_changes with mode set to "data" and actions set to ["read"]. So filter on mode == "managed" instead of assuming every entry is a real resource. If you turned on plan encryption in the earlier lesson, none of this changes for you. The tofu show command reads the same key configuration and decrypts on the way out, so Conftest never sees the encrypted form.

terminal
# the second entry is the public access block
$ jq '.resource_changes[1].change' plan.json
output
{
"actions": [
"create"
],
"before": null,
"after": {
"block_public_acls": true,
"block_public_policy": false,
"ignore_public_acls": true,
"restrict_public_buckets": false
},
"after_unknown": {
"bucket": true,
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}

Three fields carry the weight. actions is a list: ["create"], ["update"], ["delete"], or ["delete","create"] when a resource has to be destroyed and rebuilt. after is the resolved future state. after_unknown marks the attributes OpenTofu cannot work out until apply time, which is why bucket sits there: it points at an identifier that does not exist yet. The mechanical detail to hold onto is that unknown attributes are dropped from after entirely rather than written as null. That hole is the single biggest source of policies that look like they work.

Before you go any further, treat plan.json as a secret. It holds every resolved value, including database passwords and API keys. Marking a variable sensitive only hides it from the human-readable plan, where it prints as (sensitive value); inside plan.json the real string still sits in after, with after_sensitive merely noting that it is secret. Never publish plan.json as a build artifact, never let a bot paste it into a pull-request comment, and delete it when the job ends. Run the plan stage with read-only cloud credentials while you are at it, because planning needs no write access.

Writing rules in Rego

Rego is the language OPA speaks, and the mental model is a complaints box on a factory wall. You define a set called deny, and every rule that matches drops one sentence into the box. Empty box, the change passes. Any sentence at all, and Conftest exits non-zero and the pipeline stops. Rules are unordered and independent, so adding one can never weaken another. That is exactly the property you want in a guardrail.

One syntax note will bite you on an inherited repository. OPA 1.0 changed Rego's default grammar. The old shape, deny[msg] { ... }, no longer parses, and current Conftest releases embed OPA 1.x, so an old policy file dies with a parse error complaining that the contains keyword is missing. The current shape uses contains and if. Adding import rego.v1 at the top keeps one file working on the new engine and on older ones back to OPA 0.59, where that import was introduced. To convert a legacy file wholesale, opa fmt --v0-v1 rewrites it for you.

policy/s3.rego
package main
# OPA 1.0 and later parse this syntax by default; the import keeps the
# same file working on OPA 0.59 and later 0.x engines too.
import rego.v1
public_access_switches := {
"block_public_acls",
"block_public_policy",
"ignore_public_acls",
"restrict_public_buckets",
}
# Judge real resources that are being created or changed, nothing else.
# A deferred data source read also lands in resource_changes, and
# deleting a badly configured bucket is good news, not a violation.
is_changing(r) if {
r.mode == "managed"
some action in r.change.actions
action in {"create", "update"}
}
deny contains msg if {
some r in input.resource_changes
r.type == "aws_s3_bucket_public_access_block"
is_changing(r)
off := {s | some s in public_access_switches; r.change.after[s] == false}
count(off) > 0
msg := sprintf("%s leaves public access open: %s", [r.address, concat(", ", sort(off))])
}

Read that the way OPA runs it. Walk every entry in resource_changes. Keep the ones of the right type. Keep the managed ones being created or updated. Then build a set of the switches known to be false. If that set has anything in it, write one sentence naming the resource and the switches. The set comprehension is what keeps the output readable: four bad switches produce one clear line instead of four. Notice what the comparison does not catch. A switch missing from after never lands in off, because a missing key is undefined rather than false, and undefined quietly matches nothing. Hold that thought.

policy/network.rego
package main
import rego.v1
# Ports that should never be reachable from the whole internet.
admin_ports := {22, 3389}
# Two definitions of one helper mean "either of these counts".
# 0.0.0.0/0 is every IPv4 address; ::/0 is every IPv6 address.
open_to_world(after) if after.cidr_ipv4 == "0.0.0.0/0"
open_to_world(after) if after.cidr_ipv6 == "::/0"
deny contains msg if {
some r in input.resource_changes
r.type == "aws_vpc_security_group_ingress_rule"
is_changing(r) # defined in s3.rego, same package
open_to_world(r.change.after)
some port in admin_ports
r.change.after.from_port <= port
r.change.after.to_port >= port
msg := sprintf("%s exposes port %d to the whole internet", [r.address, port])
}

Both files declare package main, so they share everything in it. network.rego calls is_changing without importing anything, the way two chapters of one book share the same characters. That convenience has a price: lift network.rego into another repository on its own and it will not compile, so keep shared helpers in an obviously named file. Now look at the port comparison. A rule that opens 1 to 65535 never mentions 22 anywhere, yet the range covers it. Testing against from_port and to_port catches that, and rules that check a single port for equality are exactly the ones a wide-open range walks straight through. Port 3389 is in the set for the same reason 22 is: it is RDP (Remote Desktop Protocol, the Windows equivalent of SSH).

terminal
$ conftest test plan.json --policy policy/
$ echo "exit code: $?"
output
FAIL - plan.json - main - aws_s3_bucket_public_access_block.logs leaves public access open: block_public_policy, restrict_public_buckets
FAIL - plan.json - main - aws_vpc_security_group_ingress_rule.ssh exposes port 22 to the whole internet
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
exit code: 1

Exit code 1 is the whole product. Everything else is presentation. Conftest also understands warn rules, which print their message but leave the exit code at 0 unless you pass --fail-on-warn. That is a decent way to land a new rule in a busy repository: ship it as a warning, watch what it catches for a fortnight, then promote it to deny.

An unknown value is a hole in your gate
Your rule compares after.cidr_ipv4 against a string. If that attribute is computed from a resource that does not exist yet, it is not in after at all, it is flagged in after_unknown. The comparison is undefined, no message gets written, and the plan sails through green. Someone who wants a change past your gate can route the value through a resource attribute on purpose and your rule will never fire. Close the hole by denying unknowns on the specific fields your policies depend on.
policy/network.rego
# A value nobody can know until apply time is a value no rule can judge.
# Say so out loud instead of passing silently.
deny contains msg if {
some r in input.resource_changes
r.type == "aws_vpc_security_group_ingress_rule"
is_changing(r)
r.change.after_unknown.cidr_ipv4 == true
msg := sprintf("%s has an unknown cidr_ipv4 at plan time, so no rule can check it", [r.address])
}
TWO INSPECTIONS, TWO DIFFERENT DOCUMENTS
1main.tf in git
what you wrote
2checkov -d .
text-level rules, seconds
3tofu plan -out=tfplan.bin
variables and lookups resolved
4tofu show -json
resource_changes, after, after_unknown
5conftest test plan.json
your rules, exit 1 stops the job
6tofu apply tfplan.bin
the exact plan that passed
The scanner reads intent in seconds and catches the obvious. The plan gate reads the resolved outcome and is the one that decides. Apply the exact plan file that passed, never a fresh one.

The fast layer that reads your code

Writing every rule yourself would take a year. Static scanners arrive with hundreds already written: unencrypted volumes, permissive IAM (Identity and Access Management, the service that decides who may do what in your cloud account) policies, missing logging, public snapshots. Checkov, Trivy (which absorbed the older tfsec), Terrascan and KICS all parse OpenTofu and Terraform configuration, because the file format is identical, so the tools work unchanged. They read text, touch no cloud account, and finish in seconds, which makes them safe to run against a pull request that arrived from a stranger's fork.

terminal
# the Guide URLs are long, so trim them out of the log
$ checkov -d . --framework terraform --compact --quiet | grep -v "Guide:"
output
terraform scan results:
Passed checks: 10, Failed checks: 10, Skipped checks: 0
Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"
FAILED for resource: aws_s3_bucket_public_access_block.logs
File: /main.tf:6-12
Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"
FAILED for resource: aws_s3_bucket_public_access_block.logs
File: /main.tf:6-12
Check: CKV_AWS_23: "Ensure every security group and rule has a description"
FAILED for resource: aws_vpc_security_group_ingress_rule.ssh
File: /main.tf:19-25
Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV2_AWS_61: "Ensure that an S3 bucket has a lifecycle configuration"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:1-3

Ten failures across three resources in about two seconds, every one of them with a file and a line range. Most are bucket hygiene you would expect: no versioning, no access logging, no default encryption key. It found both of the bad public-access switches, which is the fast layer doing its job. Now read the security group entry carefully. Checkov flagged aws_vpc_security_group_ingress_rule.ssh for exactly one thing, a missing description. It said nothing whatsoever about the address range, because the address range is not in the text.

Checkov is smarter than plain pattern matching. It renders variable defaults and locals, so if that rule had said cidr_ipv4 = var.admin_cidr with a default of "0.0.0.0/0" declared in the same repository, Checkov resolves the value and fails the check. What it cannot do is follow a value into a data source, another module's output, or a resource attribute that will not exist until apply. Swap the variable for the Parameter Store lookup and the finding disappears. You can watch that boundary move by editing a single line.

Two flags earn their place in CI (Continuous Integration, the robot that runs checks on every push). --compact drops the source snippets so the log stays readable, and --quiet prints only failures. The flag you will reach for and not find is severity gating. Checkov's severity metadata comes from its vendor platform, so filtering on HIGH or CRITICAL wants an API key. Without one it exits 1 on any failure at all, which is at least honest. Manage the noise with explicit --skip-check identifiers a reviewer can argue with, rather than a threshold that quietly drops findings. If you want severity inside the gate, Trivy carries its own and takes --severity HIGH,CRITICAL directly.

Checkov will also read the plan, and this is where the two-layer story stops being about tools at all. Point the same scanner at plan.json, tell it the framework is terraform_plan, and hand it the repository root so it can map findings back to source lines. Here only the check titles are shown, so the two runs sit side by side.

terminal
$ checkov -f plan.json --framework terraform_plan \
--repo-root-for-plan-enrichment . --compact --quiet \
| grep -E "^(Passed|Check:)"
output
Passed checks: 9, Failed checks: 11, Skipped checks: 0
Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"
Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"
Check: CKV_AWS_23: "Ensure every security group and rule has a description"
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"
Check: CKV2_AWS_61: "Ensure that an S3 bucket has a lifecycle configuration"

Eleven failures instead of ten, and the extra one is CKV_AWS_24: ingress from the whole internet to port 22. Same tool, same rule set, same repository. The only thing that changed is which document it read. That is the argument for gating on the plan, demonstrated without a line of Rego. It is also the reason to keep writing your own rules anyway: Checkov's catalogue covers mistakes everybody makes, while Conftest covers the things only your organisation knows, such as which accounts are allowed to hold customer data. Because of --repo-root-for-plan-enrichment, those plan findings still carry main.tf line ranges, so a reviewer gets a location to click rather than a bare resource address.

Both tools ship an escape hatch, and in both cases the hatch is a review problem rather than a technical one. Checkov honours a #checkov:skip=CKV_AWS_18:reason comment placed inside the resource block. Conftest has exception rules that switch off named policies. Either can be added in the very pull request that introduces the violation it hides, so make suppressions loud enough that somebody notices.

terminal
$ grep -rn "checkov:skip" --include='*.tf' .
output
./modules/rds/main.tf:41: #checkov:skip=CKV_AWS_16:encryption handled by the KMS key policy

One more failure mode is worth knowing because it fails silently in the worst direction. OpenTofu 1.8 and later accept both .tf and .tofu file extensions, and most scanners glob for *.tf only. Rename your files and Checkov parses zero resources, prints nothing, and exits 0. The pipeline goes green because nothing was checked. Assert on the resource count instead of trusting the tick.

terminal
$ checkov -d . --framework terraform -o json --quiet \
| jq '.summary.resource_count // .resource_count'
output
3

Three, matching the three managed resources in main.tf, and note that the data source is not counted. Run the same command against a directory holding only main.tofu and it prints 0. The fallback in that jq expression is there for a reason: when Checkov finds nothing to scan it emits a bare summary object with no .summary wrapper, so the obvious query returns null instead of a number and a naive test passes. Wire that count into the gate, or keep your files as .tf unless you genuinely need OpenTofu-only syntax.

One gate, wired into CI

ci/policy-gate.sh
#!/usr/bin/env bash
# Stop bad infrastructure before it reaches the cloud.
set -euo pipefail
# Both files hold resolved values in cleartext. Never leave them behind.
trap 'rm -f plan.json tfplan.bin' EXIT
# 1. cheap text scan of the code: seconds, no cloud access needed
checkov -d . --framework terraform --compact --quiet \
--skip-check CKV_AWS_18 # accepted, tracked in the risk register
# 2. prove the scanner actually parsed something
test "$(checkov -d . --framework terraform -o json --quiet \
| jq '.summary.resource_count // .resource_count')" -gt 0
# 3. suppressions are a review decision, so print them every run
grep -rn "checkov:skip" --include='*.tf' . || true
# 4. the authoritative gate: your rules against the resolved plan
tofu plan -out=tfplan.bin
tofu show -json tfplan.bin > plan.json
conftest test plan.json --policy policy/ --all-namespaces
# 5. apply the exact plan that passed, not a freshly generated one
tofu apply tfplan.bin

set -euo pipefail makes any failing command kill the script, which is what turns a non-zero exit from Conftest into a red pipeline. --all-namespaces tells Conftest to evaluate every package it finds rather than only main, so a policy filed under a team namespace still counts. Step 5 is the one people skip. Re-plan after the gate instead of applying tfplan.bin and you have applied something nobody checked, because between the two runs that Parameter Store lookup can return a different answer. When several repositories need the same rules, conftest pull oci://ghcr.io/acme/policies:v3 fetches them from a container registry using OCI (Open Container Initiative, the same standard that defines container image formats), so rules ship and get versioned like any other artifact.

Test the alarm, not only the building

A smoke alarm with a dead battery looks exactly like a working one, right up until there is a fire. A Rego rule with a typo in the resource type looks exactly like a passing gate: it matches nothing, writes no messages, and reports success forever. So unit-test the rules against fake plan input, in both directions, and run conftest verify in the same job that runs the policies.

policy/s3_test.rego
package main
import rego.v1
# A minimal fake plan holding one public access block resource.
pab(switches) := {"resource_changes": [{
"address": "aws_s3_bucket_public_access_block.demo",
"mode": "managed",
"type": "aws_s3_bucket_public_access_block",
"change": {"actions": ["create"], "after": switches},
}]}
all_on := {
"block_public_acls": true,
"block_public_policy": true,
"ignore_public_acls": true,
"restrict_public_buckets": true,
}
test_allows_fully_blocked_bucket if {
count(deny) == 0 with input as pab(all_on)
}
test_denies_open_bucket_policy if {
count(deny) == 1 with input as pab(object.union(all_on, {"block_public_policy": false}))
}
terminal
$ conftest verify --policy policy/
output
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions

Unit tests prove the logic. They do not prove the wiring. Keep one known-bad plan under version control and make the pipeline assert that the real command fails on it. A wrong policy path, a typo in a flag, a job step that swallows the exit code: all of them show up here and nowhere else.

terminal
$ conftest test tests/known-bad-plan.json --policy policy/
$ echo "exit code: $?"
output
FAIL - tests/known-bad-plan.json - main - aws_vpc_security_group_ingress_rule.ssh exposes port 22 to the whole internet
3 tests, 2 passed, 0 warnings, 1 failure, 0 exceptions
exit code: 1

Store that file next to the policies, regenerate it whenever you add a rule family, and make the job that checks it a required status check on the branch so nobody can merge past a gate that has quietly stopped working. A gate you have watched fail on purpose is the only kind you can trust to fail by accident.

Quick check
01A rule denies when r.change.after.cidr_ipv4 equals "0.0.0.0/0". Someone changes the configuration so cidr_ipv4 comes from an attribute of a resource that does not exist yet. What does the gate do?
Incorrect — There is no such default. OPA only knows what is in the JSON you hand it, and an unknown attribute is not in there.
Correct — The attribute is dropped from after and flagged in after_unknown instead, which is why you add a rule that denies unknowns on the fields your policies depend on.
Incorrect — A Rego comparison against a missing field is undefined, not an error. Undefined writes no message and stops nothing.
Incorrect — Nothing re-runs. A plan is a snapshot, and values that depend on apply-time results stay unknown inside it.
02Checkov reads your HCL text rather than the resolved plan, yet the lesson notes it still renders variable defaults and locals declared in the same repository. Which of these wide-open SSH rules would Checkov catch on its own?
Correct — Checkov renders variable defaults and locals declared in the repository, so it resolves this value and fails the check.
Incorrect — Checkov cannot follow a value into a data source, so the address range never appears in the text and the finding disappears.
Incorrect — Checkov does not chase a value through another module's output, so it cannot see what that output resolves to.
Incorrect — an apply-time attribute has no value in the source text, so Checkov has nothing to compare against.
03Your Checkov stage prints "Passed checks: 0, Failed checks: 0" and exits 0, so the pipeline turns green. The team recently renamed the module's *.tf files to *.tofu. What is actually happening?
Incorrect — renaming a file changes nothing about the resources inside it; the same switches and rules are still there.
Incorrect — there is no such auto-blocking; changing the extension does not alter what the resources do.
Incorrect — Checkov did not scan them at all; it globs *.tf and never saw the renamed files.
Correct — with no *.tf files Checkov finds nothing to scan and passes silently, which is why the lesson wires a resource_count > 0 assertion into the gate.

Try this

Run tofu plan -out=tfplan.bin on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: an unknown value is a hole in your gate. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related