Modern DevSecOps Foundations Module 3 · IaC Foundations

Policy Gates That Ship

Last reviewed · content updated

Intermediate

What you'll learn

~15 min
  • Gate IaC on scanner findings by severity - hard-fail high, report the rest
  • Wire results into the CI surfaces engineers actually look at
  • Keep dependency chains intact when gates toggle, and catch what scanners can't at plan time

One clarification, then the wiring

Two different things get called “policy as code,” and teaching them separately is half this module’s clarity: this lesson is the scanner layer — off-the-shelf tools (Checkov, Trivy — the living ones per 3.3) with hundreds of built-in checks for known misconfigurations, wired as CI gates. Next lesson is the custom policy layer — your organization’s own rules, and the decision record that governs the whole stack. Scanner first, because it’s an afternoon’s work with immediate returns.

Severity-gated, surfaced, and honest

The reference estate’s wiring, which gets all three adjectives right:

# steps/security-scan.yml (a tier-1 template, naturally)
- script: |
checkov -d ${{ parameters.scanPath }} \
--hard-fail-on HIGH,CRITICAL \
--soft-fail-on MEDIUM,LOW \
--output junitxml --output sarif \
--output-file-path $(Agent.TempDirectory)/scan
# NOTE: severity metadata on open-source checks requires the vendor
# platform key - WITHOUT it the severity flags match nothing and this
# is a green no-op gate. Keyless shops gate on explicit check-ID lists
# instead (--check / --skip-check) - same discipline, different selector
displayName: 'IaC scan (gate: HIGH+)'
- task: PublishTestResults@2 # findings appear as TEST RESULTS
condition: succeededOrFailed() # MUST run when the scan FAILS - that is the point
inputs: { testResultsFiles: '$(Agent.TempDirectory)/scan/*.xml' }
- publish: $(Agent.TempDirectory)/scan # SARIF included - machine-readable, for Module 5
condition: succeededOrFailed()
artifact: scan-sarif

Three deliberate choices. Severity does the gating: HIGH/CRITICAL fail the build; MEDIUM/LOW report without blocking — because a gate that fails on everything gets disabled by Friday (the Zero Trust training’s skip-list lesson, meet its scanner sibling; suppressions here follow the same law: inline checkov:skip with a reason string, reviewed, never a global severity downgrade). Results land where engineers look: JUnit output makes findings first-class test results in the run summary — a failed check reads like a failed test, with the resource and the fix — instead of a wall of stdout nobody scrolls. SARIF is emitted even though nothing consumes it yet: that artifact is the evidence-pipeline’s raw material, and Module 5 collects on the promise.

The toggle that doesn’t break the graph

Real estates make scans optional per stack during rollouts (the reference estate’s default-off state is a wart 3.5 confronts — but the mechanism is sound). The naive toggle deletes the scan stage when disabled — and every stage that declared dependsOn: scan breaks. The estate’s fix is small and worth naming — the no-op shell:

- ${{ if parameters.scanEnabled }}:
- template: steps/security-scan.yml
- ${{ if and(not(parameters.scanEnabled), eq(parameters.environment, 'prod')) }}:
- template: FAIL-prod-stacks-cannot-opt-out-of-scanning.yml # deliberately
# nonexistent: expansion fails at COMPILE time with this path in the error
- ${{ if not(parameters.scanEnabled) }}:
- script: echo "scan skipped by parameter (stack: ${{ parameters.name }})"
displayName: 'IaC scan (DISABLED)'

The step slot always exists — enabled it scans, disabled it’s one honest echo line — so downstream dependsOn/condition chains keyed on the containing stage or job never dangle (whole-stage toggles get the same shell treatment at stage level), and the run summary shows “scan (DISABLED)” in plain sight rather than showing nothing. Silent absence is how default-off becomes permanent; a labeled no-op is at least a visible confession.

What scanners can’t see: plan-time preflight

Scanners read HCL; they can’t know your subscription’s policies or quotas. The 2026 addition that closes part of that gap: opt-in plan-time preflight validation in the Azure provider line (landed in the 5.x releases — version specifics live in Lesson 3.3’s fenced callout), which checks planned resources (a supported subset - six resource types at launch) against live Azure Policy and quota at terraform plan — moving “this will be denied at apply” failures to the review stage, where they cost minutes instead of a broken deploy. Enable it in the estate’s central provider configuration — the root stacks’ provider block, which 3.1’s wrapper library constrains but does not own (reusable modules declare provider requirements, never provider configurations) — and every stack inherits it. It complements rather than replaces the scanner: Checkov knows best practice, preflight knows your tenant’s actual rules — next lesson is about deciding, in one governed matrix, which rules live where.

Wire the gate: (1) add the security-scan step template with severity gating,
JUnit + SARIF surfaces, and the no-op shell; (2) enable it default-ON in the
starter pipelines - non-prod stacks may opt OUT by parameter, each opt-out
requiring a reason string (greppable, like every exception in this training),
prod opt-out failing at compile time; (3) turn on plan-time preflight in the
root stacks' provider config; (4) run the estate:
report count of stacks scanning vs opted-out-with-reason vs silently never
wired - that third number is the one to drive to zero.
KNOWLEDGE CHECK

A week after wiring, a team's deploy fails: Checkov flags HIGH on their storage stack — 'public network access enabled.' The team protests: 'that's dev, and dev's env.hcl has that exact risk-accepted override from Lesson 3.2 — the gate is contradicting our own governance!' What's the right fix?

Key takeaway

Gate on severity (block high, report the rest), surface findings as test results engineers actually read, emit SARIF for the evidence pipeline before you need it, keep toggled gates as visible no-ops instead of graph-breaking absences, and add plan-time preflight so tenant reality joins best practice at review time. Suppressions stay scoped and reasoned — and when two governance layers overlap, generate one from the other. Next: the matrix that decides all of it.

Search lessons