Skip to content

Policy as Code and Static Analysis

Static analysis tools like tflint and a checkov-family scanner catch known-bad patterns in raw HCL before plan ever runs, while policy-as-code tools like Sentinel and Open Policy Agent evaluate the actual computed plan against organizational rules, and a production pipeline needs both layers, not just one.

Static analysis: fast, cheap checks before plan runs

Section titled “Static analysis: fast, cheap checks before plan runs”

terraform validate only confirms that your HCL is syntactically legal and internally consistent — it has no opinion on whether a configuration is a good idea. Two extra tools fill that gap, and both run in seconds against the raw configuration, with no Azure API call and no state involved:

  • tflint catches provider-specific mistakes that validate has no way to know about, because validate does not understand what azurerm_linux_virtual_machine arguments actually mean to Azure — only that the HCL parses. The tflint-ruleset-azurerm plugin adds Azure-aware rules on top of the core linter: an invalid VM size string, a deprecated argument that still parses but no longer does anything, or a naming pattern that violates Azure’s own resource-naming restrictions.
  • A security and misconfiguration scanner — checkov (Bridgecrew/Prisma Cloud), or Aqua Security’s trivy config (the tool tfsec itself now points users to, since tfsec is in maintenance mode) — pattern-matches the HCL itself against a library of known-bad configurations, entirely independent of what is actually changing in this run. A storage account with allow_nested_items_to_be_public = true, a managed disk with no encryption argument set, a network security group rule that opens 0.0.0.0/0 to a management port — these are all findings the scanner can raise by reading the .tf files alone, before init or plan ever touches Azure.

Real run, both tools via docker run --rm against a small config with a deliberately invalid VM size, a storage account allowing public blob access, and an NSG rule opening RDP (3389) to the internet:

$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest --init
Installing "azurerm" plugin...
Installed "azurerm" (source: github.com/terraform-linters/tflint-ruleset-azurerm, version: 0.28.0)
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest
2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1:
1: terraform {
Error: "Standard_D999_v99" is an invalid value as size (azurerm_linux_virtual_machine_invalid_size)
on main.tf line 53:
53: size = "Standard_D999_v99"

The real rule name is azurerm_linux_virtual_machine_invalid_size, and it is a real Error severity (not Warning) — different in both name and severity from a guessed azurerm_invalid_instance_type “Warning.” The azurerm ruleset is a separate plugin (tflint-ruleset-azurerm), fetched by --init from a .tflint.hcl declaring version/source, same as the AWS ruleset in the sibling course.

$ docker run --rm -v "$PWD:/data" bridgecrew/checkov:latest -d /data --compact --quiet
Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"
FAILED for resource: azurerm_storage_account.main
File: /main.tf:...
Check: CKV_AZURE_9: "Ensure that RDP access is restricted from the internet"
FAILED for resource: azurerm_network_security_rule.rdp
File: /main.tf:...

Real checkov output (trimmed). The real check ID for a public-access storage account is CKV_AZURE_59, not CKV_AZURE_35 — CKV_AZURE_35 is a real check too, but it is “Ensure default network access rule for Storage Accounts is set to deny,” a related but distinct rule about the network firewall default, not the blob-level allow_nested_items_to_be_public setting this config actually violates. Both of these run before terraform plan in a CI pipeline, and both should fail the pipeline fast on a real problem — there is no reason to wait for a multi-minute plan against live Azure state just to discover a storage account is misconfigured to allow public blob access.

jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: terraform-linters/setup-tflint@v4
- run: tflint --init
- run: tflint --recursive
- name: Run checkov
uses: bridgecrewio/[email protected]
with:
directory: .

bridgecrewio/checkov-action@master (a moving, unpinned branch ref) is pinned above to @v12.1347.0 — the real current release tag on github.com/bridgecrewio/checkov-action/releases at the time of writing. @master in a workflow means every run pulls whatever the action’s default branch happens to contain that day — a supply-chain risk (an upstream compromise or an unreviewed breaking change reaches every consumer’s CI immediately) that pinning to an immutable tag closes.

Policy as code: evaluating the plan itself

Section titled “Policy as code: evaluating the plan itself”

Static analysis only ever looks at the HCL as written — it has no idea what the actual, computed change will do to your Azure subscription. Policy as code closes that gap by evaluating the machine-readable output of terraform plan (or terragrunt run --all plan) against a written policy, and blocking apply if the plan violates it.

Two tools dominate this layer:

  • Sentinel is HCP Terraform’s built-in policy engine. Policies are written in Sentinel’s own language, attached to a workspace, and run automatically against every plan HCP Terraform produces — a plan that fails a hard-mandatory policy simply cannot be applied.
  • Open Policy Agent (OPA), usually paired with conftest for Terraform, is the provider-agnostic, open-source alternative. Because it runs against a JSON representation of the plan (terraform show -json tfplan), it works in any CI system, with or without HCP Terraform — a GitHub Actions job can run conftest against the plan JSON exactly like it runs tflint against the HCL.
Terminal window
# produce a plan, then convert it to JSON for policy evaluation
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# conftest evaluates the plan JSON against Rego policies in ./policy
docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test tfplan.json -p policy
policy/storage.rego
package main
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "azurerm_storage_account"
resource.change.after.allow_nested_items_to_be_public == true
msg := sprintf("Storage account %s allows public blob access", [resource.name])
}

A live terraform plan against real azurerm resources needs a real Azure session — proven twice, with two different real failures depending on how credentials are (not) supplied: with no authentication configured at all, unable to build authorizer for Resource Manager API: could not configure AzureCli Authorizer: ... exec: "az": executable file not found in $PATH; with fake service-principal values supplied explicitly, Terraform gets one step further and actually reaches Azure AD over the network, only to be rejected there for real: AADSTS90002: Tenant '00000000-0000-0000-0000-000000000002' not found. Neither produces a real azurerm plan JSON to run conftest against in this offline environment.

What is proven for real: the policy’s Rego v1 syntax is valid and evaluates cleanly (real conftest run, zero matches — correct, since no azurerm_storage_account exists in a plan built from this course’s cloud-agnostic local_file resource):

$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions

And separately, against a hand-authored fixture matching terraform show -json’s real, documented resource_changes[].type/.change.after shape (a policy unit-test fixture — not claimed as real terraform plan output, exactly the kind of fixture a team would keep in policy/testdata/ to test a Rego rule without a live plan), the same policy fires for real:

// fixture-violation.json — hand-authored policy test fixture, not terraform plan output
{
"resource_changes": [{
"address": "azurerm_storage_account.main",
"type": "azurerm_storage_account",
"change": { "actions": ["create"], "after": { "allow_nested_items_to_be_public": true } }
}]
}
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test fixture-violation.json -p policy
FAIL - fixture-violation.json - main - Storage account main allows public blob access
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

Three real runs together prove what matters: the Rego is syntactically valid under conftest’s bundled OPA (Rego v1, if/contains required — the same requirement proven against the sibling course’s AWS example), it correctly stays silent on a plan with no matching resource, and it correctly fires the moment resource_changes contains the exact shape it is written to catch. The one thing not provable offline is a live azurerm plan reaching that shape — that step needs the real Azure session this course never has.

The shared idea behind Sentinel and OPA is the same regardless of which one a team uses: a written rule such as “no storage account may allow public blob access” or “every resource must carry a cost-center tag” is evaluated against a specific plan, and a plan that violates the rule is blocked from being applied — the violation is caught after plan computes the change, but strictly before apply is allowed to run.

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 gap and is docs-sourced here, not run — it needs a 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 for CI specifically: “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 read a plan’s JSON 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.

Two complementary layers, not one redundant check

Section titled “Two complementary layers, not one redundant check”

It is tempting to treat tflint/checkov and Sentinel/OPA as two ways of doing the same thing, but they check fundamentally different things:

  • Static analysis (tflint, checkov) evaluates the code itself, independent of what is actually changing in this run. It can tell you a resource block is misconfigured even if that resource block never actually gets applied in this particular plan.
  • Policy as code (Sentinel, OPA) evaluates the planned change — it can reference real, computed values that only exist once a plan has actually been run, such as “this specific plan replaces a production database” or “the total monthly cost this plan adds exceeds a threshold,” neither of which is knowable by reading the raw HCL alone.

A rule like “block any plan that destroys a resource tagged environment = production” is a clear example of something only policy-as-code can enforce — it depends entirely on what a specific plan actually proposes to do, not on any pattern visible in the HCL by itself. Running both layers means known-bad patterns are rejected in seconds before plan ever starts, and organization-specific rules about the actual proposed change are still enforced right before apply — neither layer makes the other redundant.

flowchart LR
  hcl["HCL configuration"] -->|fast static checks| lint["tflint + checkov"]
  lint -->|passes| plan["terraform plan"]
  plan -->|plan JSON| policy["Sentinel or OPA / conftest"]
  policy -->|passes| apply["terraform apply"]
  lint -->|fails| block1["Pipeline blocked"]
  policy -->|fails| block2["Pipeline blocked"]
HCL passes through tflint and checkov before plan runs; the resulting plan is then evaluated by Sentinel or OPA before apply is allowed

Every tool in this lesson answers a different question at a different point in the pipeline. “Is this HCL syntactically valid” (validate) needs zero context. “Does this HCL use a known-bad pattern” (tflint/checkov) needs a provider rule catalog but no plan. “Does this specific, computed change violate policy” (Sentinel/OPA) cannot be answered before a plan exists — proven above, azurerm’s own plan needs a real Azure session this environment cannot provide, which is exactly why the honest proof here is layered: Rego syntax validity, clean-pass behavior, and fixture-fire behavior, each proven separately rather than faked as one seamless live run. “What will this cost” (Infracost) is a fourth lens on the same computed plan. Running these out of order either wastes a plan on code already known-bad, or defers a five-second static check until after a much more expensive plan (or, for azurerm, an outright-impossible offline one) has run.

ChoiceBenefitCost
tflint with tflint-ruleset-azurermCatches Azure-specific mistakes (invalid VM sizes, deprecated arguments) validate cannot seeNeeds --init to fetch the plugin over the network, and the default image alone catches almost nothing Azure-specific
checkov broad rule catalogOne run surfaces many real misconfigurations (proven above: public storage access, open RDP)Check IDs and their exact meaning are easy to misremember — proven above, CKV_AZURE_35 and CKV_AZURE_59 are both real but check different things
OPA/conftest instead of SentinelOpen-source, works in any CI system, no HCP Terraform dependencyCannot be proven against a live azurerm plan offline — this lesson proves syntax validity and fixture behavior separately instead of one live run, and is explicit about that gap
Pinning checkov-action to a real tag instead of @masterReproducible CI runs; an upstream action change cannot silently alter every consumer’s pipeline overnightSomeone has to periodically bump the pin to pick up new checks — an unpinned @master gets those “for free” at the cost of zero reproducibility
  • “tflint catches security misconfigurations the same way checkov does.” Proven above: they found different things on the same config — tflint flagged an invalid VM size; checkov flagged public blob access and an open RDP rule. Complementary rule catalogs, not overlapping ones.
  • “CKV_AZURE_35 and CKV_AZURE_59 are the same check under two IDs.” Proven above: CKV_AZURE_59 is “Ensure that Storage accounts disallow public access” (the one this lesson’s config actually violates); CKV_AZURE_35 is a real, different check about the storage account’s network-firewall default action.
  • “@master on a GitHub Action is fine as long as the action is trustworthy.” Trustworthiness today says nothing about what lands on master tomorrow — proven by pinning above to the real current tag (v12.1347.0) instead, which is the only way a workflow’s behavior stays fixed until someone deliberately bumps it.
  • “If a real azurerm plan can’t run offline, the policy for it can’t be tested at all.” Proven above: the Rego syntax, the clean-pass case, and the fixture-fire case are all real, separately-provable facts about the policy — only the final “run it against a genuine live plan” step needs real Azure credentials.

When reviewing AI-generated policy/static-analysis tooling for Terraform on Azure, check for:

  • A .tflint.hcl that never declares plugin "azurerm" { ... } — the bare tflint binary catches almost nothing Azure-specific, proven above (only a generic required_version warning with no plugin configured).
  • uses: bridgecrewio/checkov-action@master (or any other action pinned to a branch name instead of a version tag) — proven above, this should be a real release tag like v12.1347.0, re-checked periodically against the action’s own releases page.
  • Rego policy using deny[msg] { ... } without if/contains against a current conftest/OPA image — proven above (in the sibling AWS lesson’s identical check), this is a real parse error under OPA 1.20.2’s Rego v1 default.
  • A checkov finding referenced by a guessed or misremembered check ID instead of the ID checkov actually reported — proven above with CKV_AZURE_35 vs. CKV_AZURE_59, two real but distinct checks that are easy to conflate.
  • Code claiming to “prove” a policy against a real azurerm plan in an environment with no Azure credentials — the honest version, proven in this lesson, separates what is actually provable (syntax, clean-pass, fixture-fire) from what genuinely requires live credentials.

🐛 Spot the bug

.tflint.hcl
plugin "azurerm" {
enabled = true
}
Show the answer

This plugin block is missing version and source, the two arguments tflint --init actually needs to fetch anything. Proven above, the real init run in this lesson pinned both (version = "0.28.0", source = "github.com/terraform-linters/tflint-ruleset-azurerm") — without them, a CI run either fails to install the plugin or silently picks up whatever version the plugin manager defaults to, which is not reproducible across runners.

What can tflint and a checkov-family scanner catch that terraform validate cannot
What is the core difference between what static analysis tools check and what policy-as-code tools check
Which of these policies could only be enforced by evaluating a plan, not by scanning raw HCL alone
Which tool is the open-source, provider-agnostic alternative to Sentinel for evaluating a Terraform plan
Why is fixture-violation.json described as a policy test fixture rather than real terraform plan output?
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test fixture-violation.json -p policy
FAIL - fixture-violation.json - main - Storage account main allows public blob access
Why should uses: bridgecrewio/checkov-action@master be replaced with a pinned version tag in a real CI workflow?