TerraformXVI · Plan Review and Saved PlansProduction Terraform
Reading the Plan: A Disciplined Walk
What you'll learn
- Explain why a static reading of the plan must be augmented by automated scanners in CI
- Compare the four families of HCL scanner (tflint, tfsec, checkov, trivy) and the questions each answers
- Configure a severity gate that fails the pipeline on the right findings and passes on the rest
- Suppress false positives with evidence rather than blanket ignores
- Schedule the audit cadence that catches the findings a PR-time scanner missed
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A human reading the symbols is necessary. It is not
sufficient. The symbols tell you what the plan will do;
they do not tell you whether the configuration that
generated them is wrong. Catching a security-group rule
that opens port 22 to 0.0.0.0/0 is the job of a scanner,
not the job of the reviewer squinting at 200 lines of ~
arrows.
The disciplined walk through the plan has two passes. The first pass is the human read of the action tuples and resource addresses. The second pass is the automated read of the configuration by a set of scanners that look for patterns the human cannot catch reliably in every PR: default encryptions, public CIDRs, instance types against policy, IAM action widenings. This lesson is the second pass.
The four scanners and what each one does
The HCL scanner ecosystem is large. Most production pipelines settle on four tools because they cover different ground and rarely duplicate findings.
| Scanner | Question it answers | Source |
|---|---|---|
| tflint | “Is this HCL valid and idiomatic for the target provider?” | terraform-linters/tflint |
| tfsec | “Does this configuration violate a security best practice?” | aquasecurity/tfsec |
| checkov | “Does this configuration meet policy and compliance rules?” | bridgecrewio/checkov |
| trivy | “Are there misconfigurations and exposed secrets in this repository?” | aquasecurity/trivy |
Each tool returns a structured finding list (file, line, rule ID, severity, description). Each tool has its own severity scale. The trick is deciding what to gate the pipeline on.
tflint: provider-aware linting
tflint parses the HCL and runs rules against it. Most
rules are provider-specific. The AWS plugin checks that
instance_type values are valid; the AWS plugin checks
that cidr_blocks are not RFC1918 ranges used in
unintended ways; the AWS plugin checks IAM action patterns
against a denylist. The Google, Azure, and Kubernetes
plugins do the same for their respective providers.
tflint --init
tflint --recursive --format junit > tflint.junit.xml
1 issue(s) found:
Warning: "instance_type" is not a valid value [from google]
on main.tf line 12
9: resource "google_compute_instance" "web" {
12: machine_type = "n2-standard-not-a-real-size"
The structured format (junit) is what the CI pipeline
parses. The exit code is non-zero on warnings by default;
use --max-warnings=N to control the gate or set it via
.tflint.hcl.
tfsec: security best practices
tfsec (and its successor trivy config) checks the
configuration against a security ruleset. The default
ruleset covers the common OWASP Top 10 for cloud: public
S3 buckets, security groups with 0.0.0.0/0 ingress on
sensitive ports, unencrypted storage, IAM policies with
wildcards, missing logging, hard-coded credentials.
tfsec --format json --soft-fail-on-warnings \
--exclude-checks GEN001,GEN002 \
--out tfsec.json
{
"results": [
{
"rule_id": "AWS017",
"long_id": "AWS017",
"severity": "ERROR",
"description": "Cross-region networking is denied by AWS Config",
"filename": "network/main.tf",
"start_line": 87
}
]
}
The ruleset is opinionated. The severity (HIGH / MEDIUM / LOW) is the scanner’s; the gate is yours to set.
checkov: policy and compliance
checkov runs an OPA-based policy engine over the
configuration. The default ruleset includes CIS, PCI, and
HIPAA-derived checks. Checkov’s primary value over tfsec
is the policy framework: rules can be written as Rego
policies in-house and consumed alongside the public
ruleset.
checkov -d . --framework terraform \
--output json --soft-fail \
--skip-check CKV_AWS_8,CKV_AWS_18 \
> checkov.json
Passed checks: 47
Failed checks: 3
Skipped checks: 2
Resource: aws_security_group_rule.open_ssh
File: /network/sg.tf:24
Check: CKV_AWS_24
Guide: https://docs.bridgecrew.io/aws/...
The --skip-check flag is the standard suppress mechanism;
the skip directive is recorded in the scan output so the
audit trail shows which rules were waived and where.
trivy: misconfiguration and secrets
trivy config combines misconfiguration scanning (a
superset of many tfsec checks) with secret scanning. The
secret scanning is the differentiator: trivy will fail if
the configuration or any text file in the repository
contains what looks like an AWS access key, a GitHub
token, or a generic high-entropy string.
trivy config --format sarif \
--output trivy.sarif \
--severity HIGH,CRITICAL \
.
network/sg.tf (terraform)
============================
HIGH: Security group rule allows ingress from public internet
ID: AVD-AWS-0007
File: network/sg.tf
Line: 24
SARIF is the standard format. Many CI systems surface SARIF findings in code review directly, which makes the reviewer’s job easier.
The right severity gate
Not every finding should fail the pipeline. Not every finding should pass. The gate is a deliberate configuration that names the severity levels that block the merge.
Severity Gate behaviour
-------- ------------------------------------------
CRITICAL Always fails the pipeline. Production-block.
HIGH Fails the pipeline. Production-block.
MEDIUM Fails the pipeline on production-bound PRs;
allowed on internal workspaces with a
documented exception.
LOW Warning only. Logged, surfaced in the PR
comment, not gate.
INFO Diagnostic. Logged only.
CRITICAL findings are the ones that are unambiguously
broken: a public S3 bucket, a * action in an IAM
policy, a secret in the configuration. HIGH findings are
the ones that are almost always wrong but have an
occasional legitimate use: a security group opening
0.0.0.0/0 on port 80 for a load balancer, an unencrypted
EBS volume on a non-sensitive workload.
# Gate policy in CI
trivy config --severity CRITICAL,HIGH --exit-code 1 .
tfsec --soft-fail-on-warnings --exit-code 0 .
tflint --max-warnings 0 ; [ $? -le 1 ] || exit 1
checkov --check HIGH,CRITICAL --soft-fail .
CRITICAL and HIGH fail. MEDIUM and below pass with a log. The exception process is documented: MEDIUM findings on internal workspaces require a comment in the PR that names the rule and the compensating control.
Suppressing false positives
Every scanner has rules that fire on legitimate patterns. The right way to suppress is with evidence: a comment at the resource, a justification in the PR, and a review by someone with the authority to grant the exception.
resource "aws_security_group_rule" "public_http" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.alb.id
# tfsec:ignore:AWS017
# checkov:skip=CKV_AWS_24:public HTTP entry point for ALB; WAF in front
description = "ALB entry, HTTP only, WAF inspected"
}
The suppression is not a blanket ignore. It is at the resource level. It names the rule being suppressed and the reason. The reason must be short, must point to a compensating control, and must be reviewable.
Three rules for suppressing:
- Suppress at the resource, not the file or the directory. A file-wide ignore hides other findings on resources that were not considered.
- Name the rule.
# tflint:ignore=aws_instance_invalid_typeis the right shape;# lint ignoreis not. - Justify in the comment. “WAF in front” is a justification. “this is fine” is not.
A suppression with no justification is a suppression that the audit cadence will flag.
The audit cadence
PR-time scanning catches findings in the configuration that was added. It does not catch findings that exist in the cloud and were never in the configuration. It does not catch findings from a scanner version that came out after the last scan. The audit cadence catches what the PR-time scan misses.
Frequency Check
---------- -------------------------------------------------------
Per-run tflint, tfsec, checkov, trivy (PR-time)
Daily trivy fs on the live state file in the backend
(catches secrets committed to state by old code)
Weekly trivy config against the merged main branch
Monthly Manual review of all suppressions;
the suppression list is the deferred debt
Quarterly Ruleset update; bump scanner versions,
review new rules, decide which to adopt
The daily state-file scan is the one most teams skip. A
secret that was committed to the state file by an old
apply is not caught by PR-time configuration scan. A
trivy fs against the live state file is the catch.
# Daily state-file scan
aws s3 cp s3://runbook-tfstate/prod/terraform.tfstate /tmp/state
trivy fs --severity CRITICAL,HIGH --secret-config trivy-secret.yaml /tmp/state
rm -f /tmp/state
The state file is sensitive; it is downloaded to an ephemeral location on a runner, scanned, and the copy is deleted. The scan output is the artifact; the state content is not.
Validating the pipeline
tflint --recursive --format junit > tflint.xml
tfsec --format json > tfsec.json
checkov -d . --framework terraform --output json > checkov.json
trivy config --format sarif > trivy.sarif .
tflint: 1 warning, 1/1 plugins loaded
tfsec: 2 ERROR, 1 WARNING, 0 LOW
checkov: passed 47, failed 3, skipped 2
trivy: 2 HIGH, 1 MEDIUM (1 secret)
Run all four. Compare the failure counts across PRs. A PR that introduces 0 CRITICAL, 0 HIGH, but 4 MEDIUM findings is a candidate for the MEDIUM-exception process; it is not a candidate for blanket approval. The scanner output is the receipt.
Production failure modes
-
The gate was set at CRITICAL only. HIGH and MEDIUM findings pass. A public S3 bucket pattern slips through on an MEDIUM finding. The fix is to set the gate at HIGH for all paths into production.
-
A blanket
# tfsec:ignorewas added at the top of every file. A new resource added later is not scanned. The fix is to attach the ignore directive to the resource, not the file. -
The scanner runs against
terraform plan -outoutput but the plan file is an old binary format. The new scanner version does not parse it. The fix is to run the scanner against the source HCL (and againstterraform show -json tfplanfor the change-level scan), not against the binary plan file. -
A suppression was added without a justification. The monthly review surfaces the suppression. The auditor flags the resource. The fix is to require a reason on every suppression at PR time.
-
The scanner version was pinned, then the upstream version published a critical rule that the pinned version lacks. Drift in scanner versions is drift in security posture. The fix is to update the scanner version monthly as a routine and to review the new rules before pinning.
-
The state-file scan was deleted during a runner cleanup and the secret stayed in
/tmp/state. A subsequent build artifact contained the live state. The fix is to scan in a memory-only mount and never write the state file to durable storage.
Security and performance
Security: scanner output is itself a sensitive artifact. A scanner finding that lists the unencrypted bucket is the same information an attacker needs to find the unencrypted bucket. Store scan output with the same retention as the plan artifact.
Performance: four scanners per PR is roughly 10 to 30 seconds of additional CI time on a small-to-medium estate. On a large estate with thousands of resources, the scanners run against the source HCL, not the live configuration, and stay under a minute. The performance cost is small; the security benefit is large.
Production guidance
- Run all four scanners on every PR. Different tools catch different findings; the union is the protection.
- Set the gate at HIGH by default. CRITICAL is too lax; MEDIUM is too strict.
- Require a justification on every suppression. The justification is the audit trail.
- Scan the state file daily. The state file is where old secrets go.
- Bump scanner versions monthly. A scanner that does not get updated is a scanner that is not catching new finding classes.
- Output all scan results in SARIF or junit. The PR comment is the reviewer’s interface; structured output surfaces the right lines.
What comes next
The next lesson is the saved plan artifact: how to serialise a plan to a file, how to ship the file from the plan stage to the apply stage, and why a plan file is itself a sensitive artefact.
Verification
trivy config --severity CRITICAL,HIGH --exit-code 1 \
--format sarif --output trivy.sarif .
tfsec --exit-code 1 --format json --out tfsec.json .
tflint --recursive --max-warnings 0 .
checkov -d . --check HIGH,CRITICAL --soft-fail .
trivy: 0 HIGH, 0 CRITICAL → exit 0
tfsec: 1 HIGH → exit 1 (gate failed)
tflint: 0 warnings → exit 0
checkov: 4 HIGH, 0 CRITICAL → exit 0 (logged)
A CI run with one HIGH finding from tfsec and zero elsewhere is a CI run that the gate stopped. The finding is in the PR; the suppression, if legitimate, is added with a reason; the second run passes.
Knowledge check · 7 questions
Q1. Which of the four scanners covers misconfiguration AND secret scanning in a single tool?
Q2. What severity gate is the right production default for a Terraform PR pipeline?
Q3. It is appropriate to add a single file-level ignore directive at the top of every Terraform file to disable scanner noise.
Q4. Where should a scanner suppression carry its justification?
Q5. Which of the following are valid reasons to run a daily scanner against the state file rather than only against the source configuration? (Select all that apply.)
Q6. PR-time scanning catches findings in changes. What category of finding does the daily state-file scan catch that PR-time scanning cannot?
Q7. A team adds # tfsec:ignore:AWS017 at the top of network/sg.tf to silence a recurring finding on the ALB's public ingress rule. A junior engineer adds a new resource to the same file in a PR. What is the failure mode?
Passing score: 75%. Answers are checked in this browser.