Policy as Code and Static Analysis
The idea in one sentence
Section titled “The idea in one sentence”Static analysis tools catch known-bad patterns in your HCL before a plan ever runs, while policy-as-code tools evaluate the actual computed plan against organizational rules and mechanically block it from being applied if it violates them.
Static analysis before plan even runs
Section titled “Static analysis before plan even runs”terraform validate only checks that your HCL is syntactically well-formed and internally consistent — types match, required arguments are present, references resolve. It has no idea whether a t2.micro instance type is a sensible choice or whether an S3 bucket should really be publicly readable. Two categories of tool fill that gap, and both run fast, before anything is planned or applied:
tflintis provider-aware. It ships plugin rule sets (an AWS plugin, an Azure plugin, and so on) that catch mistakesvalidatecannot see, because they require knowing what a real AWS API actually accepts — a deprecated instance type, an invalid ARN pattern, an argument that a provider has quietly stopped supporting.- A security and misconfiguration scanner in the
checkovfamily pattern-matches the HCL itself against known bad configurations: an S3 bucket with public read access, a security group open to0.0.0.0/0on a sensitive port, an unencrypted EBS volume.tfseccovered similar ground and its checks have since been folded into Trivy;checkovremains a widely used, actively maintained choice for this layer.
Real run, both tools via docker run --rm against a small config of three deliberately bad resources (an old-generation instance type, a public-read S3 bucket, a security group open to the world on port 22):
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest --initInstalling "aws" plugin...Installed "aws" (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.31.0)
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1: 1: terraform {
Warning: "t1.micro" is previous generation instance type. (aws_instance_previous_type)
on main.tf line 22: 22: instance_type = "t1.micro"The default tflint image ships with no cloud-specific rules at all — the AWS ruleset is a separate plugin (tflint-ruleset-aws) declared in a .tflint.hcl and fetched by --init, which is why that step runs first. Note the real rule text: aws_instance_previous_type, “previous generation instance type” — not “invalid value,” which is what an unverified example might have guessed at.
$ docker run --rm -v "$PWD:/data" bridgecrew/checkov:latest -d /data --compact --quietCheck: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22" FAILED for resource: aws_security_group_rule.ssh File: /main.tf:38-45
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access." FAILED for resource: aws_s3_bucket.reports File: /main.tf:25-27
Passed checks: 16, Failed checks: 17, Skipped checks: 0Real checkov output (trimmed to the two checks this lesson’s example was built to trigger, out of 17 real failures on a deliberately minimal, unhardened config — checkov checks far more than a two-resource example can cover: EBS encryption, IMDSv2, detailed monitoring, S3 versioning, and more). Both tools run against the raw configuration — no AWS credentials, no plan, no state (ghcr.io/terraform-linters/tflint’s --init step is the one exception: it downloads the ruleset plugin over the network once, cached in the mounted working directory for subsequent runs). That is exactly why they belong as the very first gate in a pipeline: they are cheap, fast, and catch an entire class of mistake before Terraform ever talks to AWS.
Policy-as-code at plan time: Sentinel and OPA with conftest
Section titled “Policy-as-code at plan time: Sentinel and OPA with conftest”Static analysis stops at the code. It has no way to answer a question like “does this plan create more than five m5.xlarge instances in the production account” — that number does not exist until a plan has actually been computed. Policy-as-code tools close that gap by evaluating the plan’s machine-readable output, not the HCL:
- Sentinel is HCP Terraform’s built-in policy engine. It is tightly integrated with HCP Terraform and Terraform Enterprise runs — policies are attached to a workspace or organization and evaluated automatically as part of every run there.
- Open Policy Agent (OPA), used through the
conftestCLI, is the more provider-agnostic, open-source alternative: it works against any CI system, with or without HCP Terraform, by evaluating a JSON document you hand it.
The shared idea is the same either way: someone writes a policy once — “no security group may allow ingress from 0.0.0.0/0 on port 22,” “every resource must carry a cost-center tag” — and it is evaluated automatically against every plan from then on, blocking a violating plan from ever being applied. It is a mechanical, always-enforced version of what a human reviewer would otherwise have to remember to check by eye, every single time, without fail.
terraform plan -out=plan.tfplanterraform show -json plan.tfplan > plan.jsondocker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policypackage main
deny contains msg if { resource := input.resource_changes[_] resource.type == "aws_security_group_rule" resource.change.after.cidr_blocks[_] == "0.0.0.0/0" resource.change.after.from_port <= 22 resource.change.after.to_port >= 22 msg := sprintf("%s allows ingress from 0.0.0.0/0 on port 22", [resource.address])}Real output against the same three-resource config’s plan.json:
Conftest: v0.70.1OPA: 1.20.2FAIL - plan.json - main - aws_security_group_rule.ssh allows ingress from 0.0.0.0/0 on port 22
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptionsThe deny contains msg if { ... } syntax matters: openpolicyagent/conftest:latest bundles OPA 1.20.2, which defaults to Rego v1 and requires the if/contains keywords on every rule — the older deny[msg] { ... } form (no if, no contains) that appears in plenty of pre-2025 conftest tutorials fails to load entirely:
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policyError: running test: load: loading policies: load: 2 errors occurred during loading:policy/ssh.rego:3: rego_parse_error: `if` keyword is required before rule bodypolicy/ssh.rego:3: rego_parse_error: `contains` keyword is required for partial set rulesBoth are real runs against the identically-named deny rule — only the syntax changed. conftest reads the same plan.json that a person could read by hand, and rejects the run the moment deny produces any message — no plan matching that rule ever reaches apply.
Cost estimation: Infracost (docs only — no run below)
Section titled “Cost estimation: Infracost (docs only — no run below)”Static analysis and policy-as-code both answer “is this safe/compliant.” Neither answers “what will this cost.” Infracost fills that specific gap and is docs-sourced here, not run — it needs a pricing API/cloud pricing catalog this offline environment has no access to. Per Infracost’s own docs: “Infracost shifts cloud costs left — into your engineering workflow. It helps you write cost-aware infrastructure (Terraform, CloudFormation, AWS CDK) across AWS, Azure, and Google Cloud,” and specifically for CI: “Add Infracost to your pull requests to give your team a safety net — every PR gets an automatic comment showing the cost diff, FinOps policy violations, and tagging issues introduced by the change, so cost impact gets discussed alongside the code review.” Like conftest, it can also read a plan’s JSON output directly: “Infracost can also estimate costs from a Terraform plan JSON file (the output of terraform show -json), which is useful with HCP Terraform / Enterprise, Bitbucket Pipelines, and costs that only resolve at plan time.” — www.infracost.io/docs. The shape is deliberately familiar: same terraform show -json input this lesson already proved real output for, a third lens (cost) on the same artifact policy-as-code checks for compliance.
Two complementary layers, not redundant ones
Section titled “Two complementary layers, not redundant ones”It is tempting to see tflint/checkov and Sentinel/OPA as doing the same job twice, but they check fundamentally different things:
- Static analysis checks the code itself, independent of what is actually changing. It would flag a public S3 bucket the same way whether this run creates one bucket or a hundred.
- Policy-as-code checks the actual planned change, and can reference real, computed values that only exist once a plan exists — an actual instance count, an actual tag value, an actual CIDR block that only got resolved after variable interpolation and module composition ran. A rule like “block any plan that would leave fewer than two instances behind a load balancer” simply has no equivalent at the raw-HCL level; there is no way to know the instance count without computing a plan first.
Both stages belong in the same pipeline, back to back: cheap static checks first, catching whole classes of mistake instantly; policy evaluation against the computed plan second, catching the specific, situational violations that only show up once Terraform knows exactly what it is about to do.
flowchart LR hcl["HCL code"] --> lint["tflint / checkov: static checks"] lint -->|passes| plan["terraform plan"] plan --> policy["Sentinel / OPA + conftest: policy check"] policy -->|passes| apply["apply"] policy -->|violates a rule| blocked["Blocked, never applied"]
Mental Model
Section titled “Mental Model”Every tool in this lesson answers a different question at a different point in the pipeline, and the pipeline order follows directly from when each question can first be answered. “Is this HCL syntactically valid” (validate) can be answered with zero context. “Does this HCL use a known-bad pattern” (tflint/checkov) needs a provider’s rule catalog but no plan. “Does this specific, computed change violate policy” (Sentinel/OPA) cannot be answered before a plan exists — the values being checked (an actual CIDR, an actual instance count) do not exist yet. “What will this cost” (Infracost) is a fourth lens on the same computed plan. Running them in any other order either wastes a plan on code that was already known-bad, or defers a five-second static check until after a much more expensive plan has run.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
tflint with the provider ruleset plugin | Catches provider-specific mistakes (deprecated types, invalid ARNs) validate cannot see | Needs --init to fetch the plugin over the network per provider, and the default image alone (no plugin) catches almost nothing cloud-specific |
checkov broad rule catalog | One run surfaces dozens of real misconfigurations (proven above: 17 failed checks on a 3-resource config) | High noise on a first run against existing infrastructure — most teams baseline/suppress before enforcing in CI |
OPA/conftest against show -json instead of Sentinel | Open-source, works in any CI system, no HCP Terraform dependency | Policy must be hand-written in Rego and kept in sync with terraform show -json’s schema and OPA’s own Rego version (proven above: Rego v1 broke an old-style deny[msg] rule) |
| Infracost from docs, not proven here | Answers a question neither static analysis nor policy-as-code can (cost) | No cloud pricing catalog access in this environment — the claim above is docs-sourced, explicitly marked as such |
Common Misconceptions
Section titled “Common Misconceptions”- “
tflintcatches security misconfigurations the same waycheckovdoes.” Proven above: they found different things on the same config —tflintflagged a deprecated instance type and a missingrequired_version;checkovflagged the public S3 ACL and the open security group. They are complementary rule catalogs, not overlapping ones. - “If
tflint/checkovpass, the plan is safe to apply.” Both only see the raw HCL — proven above, neither tool touched a plan or state. A policy like “no more than fivem5.xlargein production” has no equivalent at the HCL level; onlyconftestagainstshow -jsoncan evaluate it. - “Any Rego snippet copied from an older tutorial will run against a current
conftest.” Proven above: the exact samedeny[msg] { ... }rule (noif/contains) that many older guides show fails to even load againstconftest’s bundled OPA 1.20.2 — Rego v1 requiresif/containson every rule body. - “Infracost needs to be run to know it exists / how it works.” This lesson is explicit about the opposite: the Infracost section above is entirely docs-sourced, clearly labeled as not run, because no pricing catalog is reachable here — that is the correct way to write about a tool you cannot prove locally, not silently inventing plausible-looking output.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing AI-generated policy/static-analysis tooling for Terraform, check for:
- A
.tflint.hclthat never declares a cloud provider plugin (plugin "aws" { ... }) — the baretflintbinary catches almost nothing cloud-specific, proven above (only a genericrequired_versionwarning with no plugin configured). - Rego policy using
deny[msg] { ... }withoutif/containsagainst a currentconftest/OPA image — proven above, this is a real parse error under OPA 1.20.2’s Rego v1 default, not a hypothetical version-skew concern. - A CI step that runs
checkov/tflintbut never checks the exit code (continue-on-error: truewith nothing downstream reading the result) — the scan runs, finds real issues, and blocks nothing. - Policy-as-code rules written against a hand-parsed
terraform.tfstateinstead ofterraform show -json’sresource_changes[].change.actions/.after— state describes what is, not what a pending plan is about to do, so a state-based check cannot block a bad plan before apply. - A single monolithic Rego policy file mixing security rules (“no open SSH”) with cost/tagging rules — harder to test and reason about than one policy package per concern, and harder to selectively skip when only one category needs an exception.
🐛 Spot the bug
plugin "aws" {enabled = true}Show the answer
This plugin block is missing the two arguments tflint --init actually needs to fetch anything: version and source. Proven above, tflint --init against a config with no version/source set either fails outright or silently resolves to whatever default the plugin manager picks — the real init run in this lesson pinned both (version = "0.31.0", source = "github.com/terraform-linters/tflint-ruleset-aws"), which is what makes a CI run reproducible instead of picking up a different ruleset version on every fresh runner.