← All writing
AzureSecurityGRC

Scanning Terraform Against Azure Policy at Build Time in Azure DevOps

· 8 min read

Azure Policy is the backstop I trust most in a cloud estate. Assign a Deny policy and a noncompliant resource simply will not be created, no matter how it is requested. Because Terraform talks to the same Azure Resource Manager API as everything else, a Deny policy blocks a noncompliant terraform apply exactly as it blocks a portal click. That is the runtime guarantee, and it is real.

The trouble is timing. If the only place a violation gets caught is at apply, a developer finds out their storage account is noncompliant after the pipeline has run for ten minutes and half the plan has already applied. You want that feedback at build time, on the pull request, before anything reaches Azure. So the honest answer to “scan Terraform against Azure Policy” is two layers working together: shift the check left into the build, and keep Azure Policy as the authoritative gate at apply.

One point trips people up here. There is no Microsoft task that takes your Azure Policy definitions and evaluates a Terraform plan against them directly. Azure Policy evaluates resources at the ARM layer, not HCL on disk. So the build time check is done with static scanners that encode the same rules, and you keep the policies themselves as the thing that actually enforces them.

Prerequisites

  • A Terraform pipeline in Azure DevOps (init, plan, apply). If you do not have one yet, set that up first.
  • Your Azure Policies assigned at a management group or subscription, ideally in Deny.
  • Python available on the agent, which the Microsoft hosted Ubuntu image already has.

1. Turn the plan into something a scanner can read

Most scanners can read raw HCL, but you get far more accurate results by scanning the plan, because the plan has resolved variables, modules, and defaults into the resources Azure will actually see. Generate a JSON plan in your pipeline:

- task: TerraformTaskV4@4
  displayName: terraform plan
  inputs:
    provider: azurerm
    command: plan
    environmentServiceNameAzureRM: sc-azure-prod
    commandOptions: '-out=tfplan'

- script: terraform show -json tfplan > tfplan.json
  displayName: Export plan as JSON

tfplan.json is what the next steps scan.

2. Static scanning with Checkov

Checkov is the tool I start teams on. It ships with hundreds of Azure checks that map closely to the things Azure Policy cares about: public network access, TLS versions, encryption, allowed locations. It reads the plan JSON and fails the build when a check fails.

- script: |
    pip install checkov
    checkov -f tfplan.json --compact --output cli --output junitxml \
      --output-file-path console,checkov-report.xml
  displayName: Checkov scan

- task: PublishTestResults@2
  displayName: Publish Checkov results
  condition: always()
  inputs:
    testResultsFormat: JUnit
    testResultsFiles: checkov-report.xml

Publishing the JUnit output means failures show up on the build summary as test results, not buried in the log. A developer opening the pull request sees exactly which resource broke which rule.

3. Let Microsoft Security DevOps run several scanners at once

Rather than wiring up each tool by hand, the Microsoft Security DevOps extension runs a set of analyzers together, including Template Analyzer and Terrascan for infrastructure as code, and emits the results as SARIF. If you use Microsoft Defender for Cloud, those findings also flow back into its DevOps security view, so build time issues sit next to your runtime posture.

- task: MicrosoftSecurityDevOps@1
  displayName: Microsoft Security DevOps
  inputs:
    categories: 'IaC'

I treat this as breadth. Checkov gives me strong Terraform coverage, and the Security DevOps task adds a second set of eyes and the Defender integration.

4. Mirror your actual Azure Policies with Conftest

Checkov covers common rules, but your organisation almost certainly has specific policies that no off the shelf check knows about, an allowed list of regions, a required tag, a banned SKU. For those, encode the rule yourself with Conftest, which runs Open Policy Agent rules written in Rego against the plan JSON. This is the closest you get to evaluating the plan against your own Azure Policy intent at build time.

Here is a rule that denies any storage account with public network access enabled, the build time twin of an Azure Policy you would assign in Deny:

package main

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "azurerm_storage_account"
  rc.change.after.public_network_access_enabled == true
  msg := sprintf("%s enables public network access", [rc.address])
}
- script: |
    curl -L https://github.com/open-policy-agent/conftest/releases/latest/download/conftest_Linux_x86_64.tar.gz | tar xz
    ./conftest test tfplan.json --policy ./policy
  displayName: Conftest (org policy rules)

Keep the Rego rules in the repo next to the equivalent Azure Policy definitions, and review them together so the build time check and the runtime policy never drift apart.

5. Keep Azure Policy as the gate that actually enforces

Static scanners can be bypassed, misconfigured, or simply out of date, so they are an early warning, not the enforcement. Azure Policy is the enforcement. Keep your policies assigned in Deny at the management group, and the build time scans become what they should be: a way to fail fast and fix early, knowing that even if something slips through, the apply still cannot create a noncompliant resource.

If you want the pipeline to prove compliance against the real policy engine before merging, add a step that runs terraform plan and a gated apply against a short lived sandbox subscription that carries the same policy assignments. A Deny there fails the pull request build for the same reason it would fail production, except nothing real was touched.

6. The same idea for Bicep

If you are on Bicep rather than Terraform, the build time tool to reach for is PSRule for Azure, which validates Bicep and ARM templates against a large rule set aligned to the Azure security baseline and Well-Architected guidance. Run it in the build, and keep az deployment group what-if plus Azure Policy Deny as the deploy time check, which is the same layered pattern.

Closing thoughts

Treat this as defence in depth rather than one clever scan. The build time tools, Checkov, Microsoft Security DevOps, and Conftest rules that mirror your own policies, give developers fast feedback on the pull request. Azure Policy in Deny gives you the guarantee that a noncompliant resource cannot be created even if a scan is wrong. Keep the two in step, review the Rego rules against the policy definitions they shadow, and a bad configuration gets caught on the developer’s screen instead of halfway through a production apply.