CoursesCompliance as codeIaC compliance gates

IaC compliance gates

Checkov/tfsec/Terrascan mapped to frameworks.

Advanced30 min · lesson 11 of 15

A locked door beats a sign asking people to keep the door shut. That gap is the whole lesson. An auditor for SOC 2 (System and Organization Controls 2, the report your customers ask for before they trust you with their data) will put a question to you that sounds easy: how do you know that nobody, ever, creates an S3 bucket (Amazon Simple Storage Service, its file storage service) in production without access logging, versioning and a public-access block? The weak answer is a wiki page and a promise. The strong answer is a check that reads your Terraform, looks for those three properties by name, and fails the pull request the moment one of them is missing. The non-compliant bucket dies in code review. It never reaches an account an auditor could go looking in. That check is an IaC compliance gate (IaC is infrastructure as code, your servers and buckets written down as files), and you are going to build one here, end to end.

What an IaC compliance gate is

A building inspector reads the blueprints before the concrete truck shows up. An IaC compliance gate does that job for infrastructure code. It is a step in CI (continuous integration, the automation that runs on every push) that points a static scanner at your Terraform, CloudFormation, Kubernetes manifests and Helm charts before anything is applied to a real account. The scanner parses each file into a resource graph, resolving variables, locals and module inputs, so it judges the configuration that would actually be created rather than the literal text on the page. Then it tests every resource against a policy library: hundreds of built-in checks already mapped to CIS Benchmarks (Center for Internet Security hardening guides), PCI-DSS (Payment Card Industry Data Security Standard, the rules for handling card data), SOC 2 and HIPAA (Health Insurance Portability and Accountability Act, the US health data law), plus any custom policies you write in Rego or YAML. Every check carries a stable identifier such as CKV_AWS_18 or AVD-AWS-0089, and each identifier names exactly one control, so a failure hands the developer the precise requirement they broke instead of a vague warning. Three engines dominate: Checkov, Trivy (which took over tfsec's misconfiguration engine when tfsec was folded into it) and KICS. They overlap heavily, and most teams standardise on one. The value of the gate is timing. It fires at the point where a fix costs minutes rather than months.

Install, run, and read Checkov against a misconfigured module

Compliance as code means you can watch a control run and fail against a real input, the same way you watch a unit test go red. Start with a module that breaks the rule on purpose, a bucket declared with none of the three controls. Then install the scanner, point it at the directory, and read the verdict. That loop is what an auditor cares about: a stated control, an executable test of it, and a recorded pass or fail.

main.tf — a bucket missing every control
resource "aws_s3_bucket" "data" {
bucket = "acme-prod-customer-data"
}
install the scanner and run the gate
pip install checkov # or: pipx install checkov / brew install checkov
checkov -d . --compact --quiet # scan this directory; --quiet hides passed checks
checkov output — one failed check id per broken control
Passed checks: 6, Failed checks: 3, Skipped checks: 0
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
FAILED for resource: aws_s3_bucket.data
File: /main.tf:1-3
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.data
File: /main.tf:1-3
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
FAILED for resource: aws_s3_bucket.data
File: /main.tf:1-3

Read the summary line first. Six checks passed, because the insecure pattern each one hunts for is nowhere in this HCL (HashiCorp Configuration Language, the file format Terraform is written in). Three failed. Each FAILED block is one control the module breaks, versioning, access logging and a public-access block, named by its check id and pinned to a resource and a line number. Checkov exited non-zero, so in CI the step turns red and the required check blocks the merge. The developer adds the three missing resources, runs it again, and the summary reads Failed checks: 0. No credentials were involved anywhere. The scan is static over the HCL, which is why it finishes in seconds on every push. Be precise about what a green run proves, though. It is evidence toward the control, not a certification. An auditor still wants the narrative that explains what the check enforces and how it maps to the written requirement, plus the sampling method that decides which runs and which repositories get examined.

Soft-fail vs hard-fail: gate on the exit code

A smoke alarm that somebody disconnects after the third false trigger protects nobody. Whether your pipeline actually stops a merge comes down to one number, the process exit code, and that is the knob you turn. Checkov exits 1 by default when any check fails. That is a hard fail: the step goes red and the merge is blocked. Correct for a codebase that is already clean. Drop the same hard gate onto a large old repo and you bury developers under hundreds of pre-existing failures, and someone switches the gate off inside a week. Soft fail is the transitional setting. The --soft-fail flag forces exit 0, so the scan prints its findings and blocks nothing while you drive the count down. In between sit --hard-fail-on and --soft-fail-on, which gate on a subset. You run soft everywhere and hard-fail only on the checks that map to controls you will not ship without. Encryption and public access block from day one. Everything else reports until the backlog clears.

three gating modes, each printing its exit code
# 1) Default = hard-fail: any failure exits non-zero and blocks the merge
checkov -d . --compact --quiet ; echo "exit=$?"
# 2) Report-only for a noisy legacy repo: never blocks, just surfaces findings
checkov -d . --soft-fail ; echo "exit=$?"
# 3) Rollout gate: report everything, but BLOCK on non-negotiable controls only
checkov -d . --soft-fail --hard-fail-on CKV2_AWS_6,CKV_AWS_18 ; echo "exit=$?"
the exit code is what CI reads to pass or block
exit=1 # default: 3 checks failed -> hard-fail -> PR blocked
exit=0 # --soft-fail: same 3 findings printed, build stays green
exit=1 # --hard-fail-on: public-access + logging are gated -> blocked

Baselines and suppressions for legacy findings

Two mechanisms keep a hard gate survivable on a repo that already has violations. The first is a baseline, which works like photographing the mess before you move in so nobody can pin it on you later. checkov -d . --create-baseline writes a .checkov.baseline file, and later runs with --baseline .checkov.baseline let those grandfathered findings pass while still failing anything freshly introduced. You commit the file, the bleeding stops the same day, and you burn the backlog down on your own schedule. The second is an inline skip, which suits a single deliberate exception better because it sits next to the resource and forces someone to write down why. A #checkov:skip comment records the check id and a reason, and the finding moves out of the Failed column into Skipped. Here is the line between a real program and a rubber stamp: every suppression carries a reason, a ticket and an expiry date. Auditors accept a documented, time-boxed exception. A silent skip with no rationale is how a gate rots without anyone noticing.

main.tf — a reviewed, documented, time-boxed exception
resource "aws_s3_bucket" "data" {
bucket = "acme-prod-customer-data"
#checkov:skip=CKV2_AWS_6:Public access blocked account-wide via S3 Block Public Access — waived until 2026-12-31 (SEC-1420)
}
checkov -d . --compact — the waived control is now Skipped, not Failed
Passed checks: 6, Failed checks: 2, Skipped checks: 1
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
SKIPPED for resource: aws_s3_bucket.data
Suppress comment: Public access blocked account-wide via S3 Block Public Access — waived until 2026-12-31 (SEC-1420)
File: /main.tf:1-4

Add a second scanner for coverage and cross-checking

Two proofreaders catch more typos than one. No policy library is complete, so plenty of teams run a second engine to find what the first one misses and to avoid leaning on a single vendor's ruleset. Trivy is the natural partner. It carries the tfsec misconfiguration engine now, scans the same directory, tags each finding with an AVD id (Aqua Vulnerability Database, the public catalogue those links point at) and a severity, and gates on --exit-code the way Checkov does. Run it with a severity filter so the gate blocks on HIGH and CRITICAL while quieter findings stay informational. That keeps the signal high and stops low-value noise from stalling delivery.

second scanner: Trivy config, gated on severity
# Trivy absorbed tfsec's misconfig engine; scan the same Terraform directory
trivy config --severity HIGH,CRITICAL --exit-code 1 . ; echo "exit=$?"
trivy output — AVD ids, severity, and a non-zero exit
main.tf (terraform)
===================
Tests: 9 (SUCCESSES: 6, FAILURES: 3, EXCEPTIONS: 0)
Failures: 3 (HIGH: 3, CRITICAL: 0)
HIGH: Bucket does not have encryption enabled.
═══════════════════════════════════════
See https://avd.aquasec.com/misconfig/avd-aws-0088
───────────────────────────────────────
main.tf:1-3
───────────────────────────────────────
exit=1
What the exit code decides
checkov -d . runs on every pull request
the exit code, not the report, gates the merge
0 failed
exit 0 -> check green
compliant infra merges
failed, but --soft-fail / baseline
exit 0 -> report only
findings surfaced, rollout mode
failed a hard-gated control
exit 1 -> check red
PR blocked until fixed or waived
One scan, three outcomes, picked entirely by the exit-code flags. Soft-fail and baselines let you adopt the gate without a wall of legacy failures. Hard-fail on the non-negotiables is what actually blocks.
Quick check
01Your team wires checkov -d . --soft-fail in as a required status check, yet non-compliant Terraform keeps merging. What is going on?
Correct — Soft fail is report-only. To block you need the default hard fail, or --hard-fail-on scoped to the controls that must not ship.
Incorrect — Checkov reads the HCL statically and offline. A directory scan involves no API keys and no credentials at all.
Incorrect — A baseline grandfathers pre-existing findings only, and brand-new violations still fail. Here it is the --soft-fail flag pinning the exit code green.
Incorrect — Every check carries a CKV or CKV2 id. Whether a failure blocks is decided by the gating flags and the exit code, never by whether an id exists.
02You inherit a Terraform repo that already fails plenty of checks. You run checkov -d . --create-baseline, commit the file, and future runs pass --baseline .checkov.baseline. What does that buy you?
Incorrect — The checks keep running on every scan. A baseline only stops the findings that already existed from failing the build.
Incorrect — Checkov reads the HCL and reports on it. Nothing here has it edit your code; the developer adds the missing resources and re-runs the scan.
Correct — The bleeding stops the day you commit the file, and you burn the existing backlog down on your own schedule while the gate keeps blocking new violations.
Incorrect — --soft-fail is the flag that pins the exit code to 0 on everything. A baseline still fails brand-new violations, which is exactly why the two are different tools.
03Your CI job runs Checkov with a severity-based --hard-fail-on HIGH,CRITICAL, no BC_API_KEY is set, and the log shows Failed checks: 3 while the step prints exit=0 and the pull request merges. What do you do next?
Incorrect — The findings already print. Adding --soft-fail guarantees exit 0 forever, which locks in the exact problem you are trying to fix.
Correct — Severity metadata for community checks comes from the Prisma Cloud platform, so an offline run has no severities and a HIGH,CRITICAL filter matches zero checks. Gating on ids works offline, and the exit-code assertion catches the next mis-scoped gate.
Incorrect — A directory scan is purely static over the HCL. Credentials change nothing about which checks run or what exit code comes back.
Incorrect — Wrong twice over. The violations are real, and a skip with no reason, ticket and expiry date is the silent suppression that makes a gate rot.
--hard-fail-on HIGH can quietly pass everything when you run offline
Severity metadata for community checks comes from the Prisma Cloud platform, not from the local ruleset. Run Checkov offline with no BC_API_KEY set and most checks report no severity at all, so a severity-based --hard-fail-on HIGH,CRITICAL matches zero checks. The build goes green while real violations sit right there in the report. Gate on explicit check ids instead, or wire in an API key that supplies the severities, and always assert the exit code in a smoke test so a mis-scoped gate cannot stop blocking without anyone noticing.

An IaC gate is the cheapest prevention you will ever buy, and it sees only what flows through the pipeline. Someone clicking around the console at two in the morning is invisible to it. Pair it with the runtime half: post-deploy posture checks in AWS Config or a CSPM (cloud security posture management tool, the thing that grades live accounts), plus drift detection for those out-of-band console changes. The same control then gets enforced before deploy and evaluated after it, with each side producing its own evidence. Blocking a bad change is prevention. The next lesson, Remediation and prevention, takes on the violations that get through anyway and the work of designing whole classes of them out of existence, so the gate has less to catch with every release.

Try this

Work through “Add a second scanner for coverage and cross-checking” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: --hard-fail-on HIGH can quietly pass everything when you run offline. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related