Scanning the plan, not just the code
Why HCL-only scanning misses things the plan JSON reveals.
A mail-merge letter is a template full of blanks: Dear [NAME], your approved credit limit is [LIMIT]. You can proofread that template for an hour and still not know that tonight's merge run is about to tell forty thousand customers their limit is a million pounds. The blanks get filled from a spreadsheet nobody opened.
Terraform configuration has the same shape. Your .tf files are the template. The values that decide whether a firewall rule is sensible or a standing invitation usually arrive later: from a variables file chosen at run time, an environment variable your build system sets, an input handed down into a module, or a lookup against your live cloud account. A scanner that reads only the template is proofreading blanks.
HCL (HashiCorp Configuration Language) is the language .tf files are written in. Point Checkov or Trivy at a directory and it parses that text, builds a model of every resource, and tests each one against a rule library. That is fast, needs no cloud access, and catches plenty. It also has a ceiling it cannot climb, because it can only reason about values it can find in the files in front of it. The plan is the other half of the picture. terraform plan resolves variables, module inputs, and data source lookups into concrete values, then writes the result to a file. Scan that file and you are reading the merged letter instead of the template.
A clean file that opens SSH to the internet
terraform {required_providers {aws = {source = "hashicorp/aws"version = "~> 6.0"}}}provider "aws" {region = "eu-west-1"access_key = "mock"secret_key = "mock"skip_credentials_validation = trueskip_requesting_account_id = trueskip_metadata_api_check = true}resource "aws_security_group" "bastion" {name = "bastion-ssh"description = "SSH access to the bastion host"vpc_id = "vpc-0abc123def4567890"ingress {description = "SSH from approved networks"from_port = 22to_port = 22protocol = "tcp"cidr_blocks = var.admin_cidrs}egress {description = "HTTPS to the package mirror"from_port = 443to_port = 443protocol = "tcp"cidr_blocks = ["10.0.0.0/8"]}}resource "aws_network_interface" "bastion" {subnet_id = "subnet-0abc123def4567890"security_groups = [aws_security_group.bastion.id]}
variable "admin_cidrs" {description = "Networks allowed to SSH to the bastion"type = list(string)default = ["10.0.0.0/8"]}
admin_cidrs = ["0.0.0.0/0"]
Read those three files the way a reviewer reads them at five o'clock on a Friday. The default looks like the corporate network, the rule carries a tidy description, and nothing in main.tf contains a suspicious string. The dangerous value sits in prod.tfvars, widened during a 2 a.m. incident eighteen months ago and never put back. In CIDR (Classless Inter-Domain Routing) notation, 0.0.0.0/0 means every address on the internet, and port 22 is SSH (Secure Shell), the remote login port. Applied, this security group offers a login prompt to every credential-stuffing bot on the planet, and internet-wide scanners tend to find a fresh SSH port within minutes. The mock keys and skip_* flags in the provider block let you run every command in this lesson without an AWS (Amazon Web Services) account; a real pipeline authenticates with a read-only role instead.
$ checkov -d . --framework terraform --compact --quiet$ echo "exit status: $?"
terraform scan results:Passed checks: 8, Failed checks: 0, Skipped checks: 0exit status: 0
Eight checks, zero failures, exit status 0. Any gate wired to that exit code waves the change through. Checkov is not being careless here, and this part deserves precision: it does evaluate the variables it can see. Change the default in variables.tf to ["0.0.0.0/0"] and the very same command fails CKV_AWS_24 immediately. What it never opened was prod.tfvars. Terraform loads terraform.tfvars, terraform.tfvars.json, and anything matching *.auto.tfvars or *.auto.tfvars.json on its own, and nothing else. Every other filename is chosen at run time with -var-file, so a scanner walking the directory has no way to know which one production uses.
Make Terraform resolve the values for you
$ terraform plan -input=false -var-file=prod.tfvars -out=tfplan
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform will perform the following actions:# aws_network_interface.bastion will be created+ resource "aws_network_interface" "bastion" {# (computed attributes trimmed for this lesson)}# aws_security_group.bastion will be created+ resource "aws_security_group" "bastion" {+ arn = (known after apply)+ description = "SSH access to the bastion host"# (egress trimmed for this lesson)+ id = (known after apply)+ ingress = [+ {+ cidr_blocks = [+ "0.0.0.0/0",]+ description = "SSH from approved networks"+ from_port = 22+ ipv6_cidr_blocks = []+ prefix_list_ids = []+ protocol = "tcp"+ security_groups = []+ self = false+ to_port = 22},]+ name = "bastion-ssh"+ name_prefix = (known after apply)+ owner_id = (known after apply)+ region = "eu-west-1"+ revoke_rules_on_delete = false+ tags_all = (known after apply)+ vpc_id = "vpc-0abc123def4567890"}Plan: 2 to add, 0 to change, 0 to destroy.Saved the plan to: tfplanTo perform exactly these actions, run the following command to apply:terraform apply "tfplan"
The resolved value is already sitting there in the open: cidr_blocks = ["0.0.0.0/0"]. A human skimming a 900-line plan will scroll straight past it, and no rule engine can parse that layout anyway. Convert the saved plan to JSON (JavaScript Object Notation), which every scanner understands.
$ terraform show -json tfplan > plan.json$ jq '.planned_values.root_module.resources[]| select(.type == "aws_security_group") | .values.ingress' plan.json
[{"cidr_blocks": ["0.0.0.0/0"],"description": "SSH from approved networks","from_port": 22,"ipv6_cidr_blocks": [],"prefix_list_ids": [],"protocol": "tcp","security_groups": [],"self": false,"to_port": 22}]
Run terraform show from the same initialized directory that produced the plan. The binary plan file does not carry provider schemas inside it, so show loads them from .terraform/. Copy tfplan to another folder and you get Error: Failed to load plugin schemas instead of output. Four parts of plan.json are worth knowing by name. planned_values holds the final attributes of every resource, which is what most rules read. resource_changes holds a before-and-after pair per resource plus the action (create, update, delete), which lets a policy care about deletions specifically. variables holds the resolved inputs. configuration holds the raw expressions, still unresolved, for tools that want to compare the two.
Same scanner, same commit, different verdict
$ checkov -f plan.json --framework terraform_plan --compact --quiet$ echo "exit status: $?"
terraform_plan scan results:Passed checks: 7, Failed checks: 1, Skipped checks: 0Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.bastionFile: /plan.json:0-0Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityexit status: 1
Nothing in the repository changed between those two runs. The same eight checks ran both times, CKV_AWS_24 among them, and it passed on the first run because the value it needed did not exist yet. Now the exit status is 1, the only thing a pipeline gate actually reads. One irritation remains. The finding points at /plan.json:0-0, a file the developer never wrote and a line number that means nothing. Hand Checkov the source directory alongside the plan and it maps the resource back to where it lives.
$ checkov -f plan.json --framework terraform_plan \--repo-root-for-plan-enrichment . --compact --quiet$ echo "exit status: $?"
terraform_plan scan results:Passed checks: 7, Failed checks: 1, Skipped checks: 0Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.bastionFile: main.tf:19-39Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityexit status: 1
main.tf:19-39 is a range somebody can open and edit. The verdict still comes from the resolved value in the plan, but the address now points at the code that produced it, and that is the version of this feedback developers act on. Trivy reaches the same place from another direction: it absorbed tfsec and carries those Terraform rules under trivy config, and it reads the binary plan snapshot directly, so trivy config tfplan needs no terraform show step at all. Watch one flag there. trivy config returns 0 even when it prints HIGH findings, unless you pass --exit-code 1, so a pipeline missing that flag is running a scanner that can never fail a build.
sensitive = true. Pass a database password in through TF_VAR_db_password and jq '.variables' plan.json prints it straight back at you. That flag governs display, not storage; it encrypts nothing. The human-readable plan is barely better, because a value masked as (sensitive value) under tags reappears in full under tags_all. Anyone who can download either file gets your production secrets and a complete map of what you are about to build. So: never publish tfplan or plan.json as a build artifact people can fetch, never paste them into a pull request comment, keep them out of the job log, and delete both in a cleanup step that runs even when the job fails. A scanner reading the file inside the runner is fine. A copy sitting in an artifact bucket for 90 days is an incident waiting to be found.What HCL scanning can never resolve
That security group is one instance of a general gap. A directory-walking scanner is blind to anything supplied with -var-file or -var at run time, or through a TF_VAR_ environment variable your build system injects. It is blind to data source lookups, so an AMI (Amazon Machine Image) picked by a filter, or a subnet's real address range, is unknown to it. It is blind to terraform_remote_state, where the networking team's output quietly becomes your input. It is blind to computed values: a for_each over a map built at run time, a merge() of shared tags, a conditional keyed on var.environment. And it is blind to context, since a module can look perfectly safe in isolation while the call site is the thing that makes it dangerous.
Part of that gap closes cheaply. checkov -d . --var-file prod.tfvars renders the file and fails the SSH check, and Trivy offers --tf-vars for the same purpose. Both are worth wiring up. Neither helps with data sources, remote state, or attributes the provider computes, and both depend on somebody remembering every variables file for every environment, forever. The plan closes the gap by construction, because resolving values is the entire job of a plan.
The trade-off, stated honestly
Scanning the plan costs two things. It needs cloud credentials, because terraform plan refreshes state and calls provider APIs (application programming interfaces) to read what exists right now. And it is slower: parsing HCL takes a second or two, while a real plan over a few hundred resources takes tens of seconds to several minutes, plus terraform init and provider downloads on a clean runner. Neither cost justifies skipping the plan scan, and neither is comfortable on every keystroke. So most teams split the difference and run the HCL scan on every push, the plan scan on the pull request.
name: terraformon: [push, pull_request]permissions:contents: readid-token: write # needed to request the OIDC tokenjobs:scan-hcl: # every push: fast, no cloud access at allruns-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: bridgecrewio/checkov-action@v12with:directory: .framework: terraformscan-plan: # pull requests only: needs a read-only roleif: github.event_name == 'pull_request'runs-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: aws-actions/configure-aws-credentials@v6with:role-to-assume: arn:aws:iam::111122223333:role/terraform-plan-readonlyaws-region: eu-west-1- uses: hashicorp/setup-terraform@v4with:terraform_wrapper: false- run: terraform init -input=false- run: terraform plan -input=false -var-file=prod.tfvars -out=tfplan- run: terraform show -json tfplan > plan.json- uses: bridgecrewio/checkov-action@v12 # non-zero exit fails the jobwith:file: plan.jsonframework: terraform_planrepo_root_for_plan_enrichment: .- name: Shred the planif: always()run: rm -f tfplan plan.json
Three things in that file earn their keep. The id-token: write permission lets the job ask GitHub for an OIDC (OpenID Connect) token, a short signed statement of who is running, and trade it for temporary credentials on terraform-plan-readonly, so no long-lived AWS key is stored anywhere. The trust policy on that role is what makes the arrangement safe, and it is the part people skip: pin the subject claim to your own repository and event, "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:pull_request", and pin the audience claim to sts.amazonaws.com. Leave a wildcard in that subject and a workflow in somebody else's GitHub account can assume your role. The Shred the plan step then carries if: always(), which removes the secret-bearing files even when the scan fails, which is exactly the run where you were least likely to remember them.
Read-only is not quite literal, and this trips people up on the first attempt. terraform plan takes a lock on the state backend while it runs, so the plan role needs write permission on whatever holds that lock, even though it changes no infrastructure. On the Amazon S3 (Simple Storage Service) backend with use_lockfile = true, the lock is a small .tflock object written next to your state file. Older setups keep a DynamoDB item instead, through dynamodb_table, which HashiCorp now documents as deprecated and due for removal in a future minor version. Grant that one permission narrowly and keep every create, update, and delete away from the plan role. The apply job runs later, under a different role, once the gates have passed. Two flags trim the job further: -refresh=false skips reading current state from the provider, which is faster and needs fewer read permissions but plans against a possibly stale picture, and -lock=false drops the lock altogether, which is safe only when nothing else can be applying at the same moment.
Verify the gate the way you verify a smoke alarm, by setting it off on purpose. Open a branch that sets admin_cidrs = ["0.0.0.0/0"], push it, and watch the pull request go red with CKV_AWS_24 named in the output. If it goes green, something is swallowing the exit code, usually soft_fail, a stray || true, or a continue-on-error somebody added to get an urgent release out. Run that test again every time you touch the workflow file, because a gate nobody has fired is decoration.
checkov -d . run passes and a terraform_plan run on the same commit fails CKV_AWS_24. Nothing in the repository changed between the two. What explains the split verdict?CKV_AWS_24 among them. The rule set held still while the input underneath it changed.var.admin_cidrs stops being a name and turns into 0.0.0.0/0, which the rule can finally judge.terraform plan before it writes anything, so you would be holding no plan file, no findings, and a very different error.-var-file, so only the files Terraform opens by itself can reach the plan. Which of these does it open?admin_cidrs = ["0.0.0.0/0"] that the directory scan never opened.*.auto.tfvars and *.auto.tfvars.json patterns load beside terraform.tfvars, so this file reaches the plan with nobody naming it.terraform.tfvars.json under that exact name loads by itself. A different JSON name, tucked in a subfolder, still waits for -var-file..tfvars name that is not terraform.tfvars loads only when you name it on the command line.tfplan, copy that single file into a fresh empty directory, and run terraform show -json tfplan > plan.json there. It stops with Error: Failed to load plugin schemas. What went wrong?show reads provider schemas out of .terraform/, which is why you run it in the same directory that produced the plan.Try this
Run checkov -d . --framework terraform --compact --quiet 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: plan.json is a secrets file, treat it like state. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.