IaC compliance gates
Checkov/tfsec/Terrascan mapped to frameworks.
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.
resource "aws_s3_bucket" "data" {bucket = "acme-prod-customer-data"}
pip install checkov # or: pipx install checkov / brew install checkovcheckov -d . --compact --quiet # scan this directory; --quiet hides passed checks
Passed checks: 6, Failed checks: 3, Skipped checks: 0Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"FAILED for resource: aws_s3_bucket.dataFile: /main.tf:1-3Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"FAILED for resource: aws_s3_bucket.dataFile: /main.tf:1-3Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"FAILED for resource: aws_s3_bucket.dataFile: /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.
# 1) Default = hard-fail: any failure exits non-zero and blocks the mergecheckov -d . --compact --quiet ; echo "exit=$?"# 2) Report-only for a noisy legacy repo: never blocks, just surfaces findingscheckov -d . --soft-fail ; echo "exit=$?"# 3) Rollout gate: report everything, but BLOCK on non-negotiable controls onlycheckov -d . --soft-fail --hard-fail-on CKV2_AWS_6,CKV_AWS_18 ; echo "exit=$?"
exit=1 # default: 3 checks failed -> hard-fail -> PR blockedexit=0 # --soft-fail: same 3 findings printed, build stays greenexit=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.
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)}
Passed checks: 6, Failed checks: 2, Skipped checks: 1Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"SKIPPED for resource: aws_s3_bucket.dataSuppress 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.
# Trivy absorbed tfsec's misconfig engine; scan the same Terraform directorytrivy config --severity HIGH,CRITICAL --exit-code 1 . ; echo "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
checkov -d . --soft-fail in as a required status check, yet non-compliant Terraform keeps merging. What is going on?--hard-fail-on scoped to the controls that must not ship.--soft-fail flag pinning the exit code green.checkov -d . --create-baseline, commit the file, and future runs pass --baseline .checkov.baseline. What does that buy you?--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.--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?--soft-fail guarantees exit 0 forever, which locks in the exact problem you are trying to fix.--hard-fail-on HIGH can quietly pass everything when you run offline--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.