CoursesCheckov & IaC scanningScanning plan vs code

Scanning plan vs code

Catch what only appears at plan.

Intermediate12 min · lesson 7 of 12

A recipe card tells you what somebody wrote down. It does not tell you what actually went in the oven, because the cook swapped margarine for butter and doubled the sugar. Terraform source has the same gap. Variables carry a default that production quietly overrides, modules bury a setting three directories down, and the tfvars file (a file of variable values) you only use in prod never appears in the .tf files a source scanner reads. Scanning raw HCL (HashiCorp Configuration Language, the syntax your .tf files are written in) catches the obvious mistakes early. Scanning the plan catches what will actually be created once every variable, local and module output has resolved.

The sequence is short. Run terraform plan -out, then terraform show -json, then checkov -f tfplan.json --framework terraform_plan. That JSON (JavaScript Object Notation, a plain text data format) has every value filled in, which makes it the last honest gate before apply, the step that creates real infrastructure. Source scans stay cheap enough to run on every commit. Plan scans tell you what your variables and modules really produce. One catch to hold onto: suppressions do not work the same way on a plan, because inline #checkov:skip comments never carry over.

Get the plan into JSON

Run init, then plan with -out to save the binary plan file, then show -json to flatten it into text. The JSON carries a planned_values section with every attribute resolved: the public ACL (access control list, the setting that decides who may read a bucket) that a module set for you, the encryption flag driven by a variable, security group rules a count loop merged together. Checkov's terraform_plan framework reads that structure. It never opens your .tf files at all.

terminal
$ terraform init
$ terraform plan -out=tfplan.bin
$ terraform show -json tfplan.bin > tfplan.json
$ checkov -f tfplan.json --framework terraform_plan --compact
output
terraform_plan scan results:
Passed checks: 156, Failed checks: 2, Skipped checks: 0
Check: CKV_AWS_20: "S3 Bucket has public access block"
FAILED for resource: aws_s3_bucket.uploads
File: tfplan.json: aws_s3_bucket.uploads
Check: CKV2_AWS_6: "Ensure S3 bucket does not allow public access"
FAILED for resource: aws_s3_bucket.uploads

What the plan sees and the source hides

Say a module sets acl = var.is_public ? "public-read" : "private". A source scan of that module sees a ternary and cannot tell you which branch wins. The plan JSON shows acl: "public-read", flat out, because the production tfvars set is_public = true. Encryption defaults that live inside a module, tags merged from three places, dynamic blocks that only take shape at plan time: all of it lands here. That is why regulated teams put the plan scan directly in front of apply.

terminal
$ checkov -f tfplan.json --framework terraform_plan -o json | jq '.results.failed_checks[] | {id: .check_id, resource: .resource, file: .file_path}'
output
{
"id": "CKV_AWS_20",
"resource": "aws_s3_bucket.uploads",
"file": "tfplan.json"
}
Scanning the resolved plan
1terraform plan -out
-out=tfplan.bin
2terraform show -json
flatten to JSON
3checkov -f tfplan.json
--framework terraform_plan
4Resolved findings
var- and module-driven values visible
Plan JSON carries fully-resolved values, so it catches risk a code-only scan cannot see.

Running both scans

Each scan buys you something different. Source is fast, needs no cloud credentials for pure static modules, and hands developers a file and line number they can click. Plan needs an init and a plan run (often with credentials, because data sources go and look things up in the account), returns the authoritative resolved config, and ignores inline #checkov:skip completely. So run source on every pull request for speed, and run plan on the merge queue or the pre-apply stage, where the gate actually matters.

terminal
# PR: fast source feedback
checkov -d . --framework terraform --compact
# Merge queue: authoritative plan gate
checkov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline
output
terraform scan results:
Passed checks: 812, Failed checks: 37
terraform_plan scan results:
Passed checks: 156, Failed checks: 2
# plan may fail checks that source missed — treat plan failures as blocking

Wiring it into the pipeline

The steps run in this order: check out the code, terraform init, plan with -out, show -json, upload tfplan.json as a build artefact, then checkov -f. Store one plan per commit so the findings map to the exact diff being merged. Add --baseline when a legacy repo throws plan-only findings the team already accepted, so brand-new risk still blocks the merge while old debt does not.

terminal
$ terraform plan -out=tfplan.bin -input=false
$ terraform show -json tfplan.bin > tfplan.json
$ checkov -f tfplan.json --framework terraform_plan -o sarif --output-file-path results
output
Wrote SARIF output to results/results_sarif.sarif
terraform_plan scan results:
Passed checks: 156, Failed checks: 2
Clean .tf does not mean a clean plan
When the security-relevant settings arrive from a module or a tfvars file, your raw .tf can come back with a clean bill of health while the plan goes on to create something wide open. Treat the plan JSON scan as the authoritative gate before apply, and treat the source scan as the fast feedback you get earlier.

Credentials and plan scope

With some providers, terraform plan has to read the account to resolve data sources, so plan scanning inherits that requirement. Give the job a read-only CI role (continuous integration, the automated pipeline that builds and checks every change), scoped to the workspace being planned and nothing wider. Remember that a plan only describes the changes it was asked about. A destroy-only plan produces a different shape of resources, so make sure your pipeline plans the same workspace your developers apply.

terminal
$ AWS_PROFILE=ci-readonly terraform plan -out=tfplan.bin -input=false
$ checkov -f tfplan.json --framework terraform_plan --compact --quiet
output
terraform_plan scan results:
Passed checks: 156, Failed checks: 2

Inline #checkov:skip comments live in source .tf files and nowhere else. A plan scan never sees them, so a finding you accepted in a comment last month still fails the plan gate today. If the waiver genuinely stands, mirror it in .checkov.yaml under skip-check so both surfaces say the same thing. Skip that step and you will lose afternoons to CI failures nobody can explain.

Keep tfplan.json as a build artefact, keyed to the commit SHA (the unique fingerprint git gives every commit). Security can then re-run checkov -f against that exact file later without re-planning anything. Plans go stale the moment the code moves, so findings have to come from the plan built on the same merge commit the pipeline will apply. When the scan job and the apply job drift apart, usually because someone injected different tfvars, you get the classic complaint: "Checkov passed in CI but prod was wrong."

Estates built out of many modules gain the most from this. Child module settings get merged at plan time, and that is where a public ACL or a missing encryption flag finally becomes visible, especially when the modules live in a private git repo that Checkov never downloaded during a source scan. Pair --download-external-modules on the source scan with a plan scan on the merge queue and you cover both angles, without doubling the runtime of every pull request job.

terraform plan -out captures one exact set of planned changes and nothing else. Scanning yesterday's plan JSON from a different branch is worse than not scanning at all, because it gates a delta nobody is merging. Plan from the merge commit SHA, scan that artefact, and attach the SARIF (Static Analysis Results Interchange Format, the file format scanners use to hand findings to a code host like GitHub) to the same pull request. Destroy plans and replace-only workspaces change which resources appear at all, so write down which workspace each pipeline job plans.

Plan JSON can hold values you would rather nobody read. If a pipeline step echoes the file, those values land in the build log where anyone with repo access can scroll to them, so treat tfplan.json as credentials-adjacent data when you set artefact retention. checkov -f reads the JSON locally and does not redact what Terraform marked sensitive, which leaves log scrubbing on your side of the line.

When a developer asks why CI flagged a bucket their local scan called clean, this is your answer. Raw .tf leaves variables, locals and module outputs unresolved, so a bucket made public by a variable is invisible to a code-only scan. The fix is the three commands you already have: terraform plan -out, terraform show -json, then checkov -f tfplan.json --framework terraform_plan. The plan holds resolved values, which is to say the thing that will actually be created.

Plan scanning is not free, and that is the whole reason nobody runs it on every push. init pulls down providers, plan itself takes minutes on a large workspace, and it often wants credentials to get there. A source scan costs seconds and gives back a file and line a developer can jump straight to. Mature pipelines split the work along exactly that line: source on every commit for feedback, plan as the gating step before apply. And because plan JSON ignores inline skips, anything you have agreed to waive needs to sit in .checkov.yaml before the gate runs.

Full plan-scan command sequence

Here is the whole pre-merge sequence in one place: init, plan with -input=false and -out, show -json, then checkov -f with the terraform_plan framework, plus --baseline if the repo carries plan-only debt the team has already accepted. Save tfplan.json as an artefact. While debugging you can re-run Checkov against that saved file instead of re-planning, as long as the plan is hours old at most. Older than that and it is describing code nobody has any more.

terminal
$ terraform init -input=false
$ terraform plan -out=tfplan.bin -input=false
$ terraform show -json tfplan.bin > tfplan.json
$ checkov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline -o sarif --output-file-path results
output
terraform_plan scan results:
Passed checks: 156, Failed checks: 1
Wrote SARIF output to results/results_sarif.sarif

Do not read the two failure counts as if they measured the same thing. The source run covers 812 checks across every .tf file in the directory. The plan run covers 156, because a plan only contains the resources it is about to touch. Different denominators, different meaning. What matters is which findings show up on the plan side and not the source side, since those are the ones your variables and modules created: the public ACL that came from a module, the encryption flag a var switched off.

If you inherit somebody else's pipeline, four things tell you it is wired correctly. terraform init and terraform plan -out=tfplan.bin run before anything scans. terraform show -json tfplan.bin > tfplan.json produces the file. The gate step is checkov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline. And tfplan.json is kept per commit SHA so an auditor can replay the scan. Missing that last one is the difference between showing your working and asking to be believed.

Three ways this goes wrong, roughly in the order teams hit them. A waiver that only exists as an inline comment, so the plan gate keeps failing on something already accepted, fixed by syncing skip-check in .checkov.yaml. A pipeline step that prints the plan file, leaving sensitive values in a build log until somebody scrubs it. A source-only setup with modules in a private repo, reporting clean because it never read them, which --download-external-modules on the source scan and a plan scan on the queue cover between them.

Try this

Generate a plan JSON once, scan it with the terraform_plan framework, then scan the same directory as plain source. Put the two failure counts side by side and go hunting for the findings that exist only on the plan side. Those are the ones your variables and modules put there.

terminal
$ terraform init -input=false
$ terraform plan -out=tfplan.bin -input=false
$ terraform show -json tfplan.bin > tfplan.json
$ checkov -d . --framework terraform --compact --quiet
$ checkov -f tfplan.json --framework terraform_plan --compact --quiet
output
terraform scan results:
Passed checks: 812, Failed checks: 37
terraform_plan scan results:
Passed checks: 156, Failed checks: 2
Check: CKV_AWS_20: "S3 Bucket has public access block"
FAILED for resource: aws_s3_bucket.uploads
# plan surfaced a module var that source never showed as public

Takeaway

Source scan is the smoke test. Plan scan is the pre-apply truth. Variables, module outputs and dynamic blocks keep risk out of sight right up until the plan JSON spells the values out. The price is time and credentials, since plan needs init and providers and runs slower, which is why most teams keep source on every push and plan on the merge queue.

Before you switch the gate on, move any inline #checkov:skip you actually rely on into .checkov.yaml, because the plan scan will not honour a comment. Next comes the baseline (cv-baseline), which parks a legacy repo's existing plan failures in a file instead of blocking every merge, while brand-new risk still stops the pipeline.

Quick check
01Why bother scanning tfplan.json when you already scan the .tf files?
Incorrect — No. Producing a plan costs init time and often credentials; you do it for accuracy, not for speed.
Correct — Yes. You are looking at what Terraform is about to create, not at what the source implies.
Incorrect — No. Checkov reads .tf natively with --framework terraform; the plan is a second lens, not a workaround.
Incorrect — Backwards. Inline #checkov:skip works in source and is ignored by plan scans.
02Which flag points Checkov at the plan-scanning framework?
Incorrect — No. That one reads .tf source files.
Correct — Yes, paired with -f tfplan.json.
Incorrect — No. --baseline takes .checkov.baseline, the file of accepted findings, not the plan.
Incorrect — No. A single plan file goes to -f; -d points at a directory.
03Your pull request source scan passes because the bucket carries an inline #checkov:skip comment, but the merge queue plan scan fails CKV_AWS_20 on aws_s3_bucket.uploads. What do you do next?
Incorrect — No. Plan scans never read inline comments, so the gate fails in exactly the same way.
Correct — Both surfaces have to agree, and .checkov.yaml is the one the plan gate actually reads.
Incorrect — No. A stale plan gates a delta nobody is merging, which is worse than not scanning.
Incorrect — No. That hands you back the blind spot: a bucket made public by a module variable never shows up in raw .tf.

Related