IaC scanning that actually catches real bugs
IaC scanning that actually catches real bugs
Infrastructure-as-Code scanning is the easiest win in DevSecOps. The tools are mature, the false-positive rate is manageable, and the findings are concrete: "your S3 bucket allows public access" is unambiguous. Yet most teams that adopt an IaC scanner end up turning it off within a quarter because the signal-to-noise ratio kills adoption.
This lesson walks through the three popular open-source scanners — Checkov, tfsec, Terrascan — on a real Terraform codebase. We'll look at what each catches, what each misses, and the muting / waiver mechanisms that let you keep a scanner running long-term without going insane.
The three scanners — quick orientation
| Tool | Language coverage | Strength | Weakness |
|---|---|---|---|
| Checkov | Terraform, CloudFormation, Kubernetes, Dockerfile, ARM, Bicep, Helm | Best policy coverage; strong custom-policy DSL (YAML); growing graph-based checks | Slow on large codebases (~30s for 500-file repo); noisier defaults |
| tfsec | Terraform | Fast (~3s on the same repo); cleanest output | Smaller ruleset; project effectively merged into Trivy IaC |
| Terrascan | Terraform, K8s, Helm, Dockerfile, ARM | Solid CIS-benchmark coverage; OPA Rego policy DSL | Less actively developed in 2026; weaker ergonomics |
In practice, pick one and run it well rather than three poorly.
Our recommendation in 2026: Checkov for the breadth, with
--quiet --compact flags for sane output. tfsec is the close
second if you only care about Terraform.
Running Checkov on a real codebase
Let's run Checkov on a Terraform module that provisions an S3 bucket and an EC2 instance. The module starts intentionally sloppy:
# s3.tf resource "aws_s3_bucket" "data" { bucket = "metalkartpay-prod-logs" } resource "aws_s3_bucket_acl" "data" { bucket = aws_s3_bucket.data.id acl = "public-read" } # ec2.tf resource "aws_security_group" "web" { name = "web-sg" ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_instance" "web" { ami = "ami-0123456789" instance_type = "t3.micro" user_data = <<EOF #!/bin/bash echo "DB_PASSWORD=hunter2" > /etc/myapp/env EOF }
Run Checkov:
pip install checkov checkov -d . --compact --quiet
You'll get something like this (abbreviated):
Check: CKV_AWS_19 — S3 bucket should have access logging enabled s3.tf:1-3 aws_s3_bucket.data FAILED Check: CKV_AWS_20 — S3 bucket should not be public s3.tf:1-3 aws_s3_bucket.data FAILED Check: CKV_AWS_57 — S3 bucket should have versioning enabled s3.tf:1-3 aws_s3_bucket.data FAILED Check: CKV_AWS_24 — Security group should restrict ingress on port 22 ec2.tf:1-9 aws_security_group.web FAILED Check: CKV_AWS_8 — EBS volume encryption should be enabled ec2.tf:11 aws_instance.web FAILED Check: CKV_AWS_46 — Ensure no hard-coded secrets in EC2 user data ec2.tf:11 aws_instance.web FAILED
Six failures. Each one is a real issue. Let's classify them:
Critical (block the PR):
CKV_AWS_20— public S3 bucket. This is the kind of bug that ends up on the news.CKV_AWS_24— SSH open to 0.0.0.0/0. Standard internet-exposed SSH is a credential-spray target.CKV_AWS_46— hard-coded password in user data. This is in plain text in the Terraform state file.
High (warn loudly):
CKV_AWS_57— no S3 versioning. Useful for incident recovery; not a security catastrophe.CKV_AWS_8— EBS not encrypted. Important for compliance; encrypted at-rest is table stakes.
Medium / informational (log only):
CKV_AWS_19— no S3 access logging. Useful for forensics; doesn't prevent a compromise.
This is the classification you have to do for every Checkov rule your codebase triggers. Without this triage step, your team gets six blocking failures the first time the scanner runs, panics, and turns the scanner off.
The muting / waiver mechanism
Inevitably, a finding will fire on code that's correct for a reason
the scanner doesn't see. The S3 bucket really does need to be public
because it serves the marketing site. The SSH ingress really is
restricted to the office IP range (and cidr_blocks is set via a
variable Checkov can't statically evaluate).
You have three options:
- Inline waiver — put a comment on the offending line:
The format isresource "aws_s3_bucket_acl" "data" { bucket = aws_s3_bucket.data.id acl = "public-read" # checkov:skip=CKV_AWS_20: marketing static site, public is intentional }checkov:skip=<CHECK_ID>: <justification>. The justification is required by team policy (not by Checkov), so that the next developer reading the code knows why the waiver exists. - Module-level waiver — a
.checkov.yamlfile at the directory root that excludes specific checks for specific files:
Use only when the waiver applies to every resource in that directory.skip-check: - CKV_AWS_20 - Global waiver — same
.checkov.yamlat the repo root with no path scoping. Avoid unless the check is genuinely useless to your team.
The discipline: every waiver has a justification comment. Reviews flag missing justifications. After three months, periodically audit waivers — the reason they were added may no longer apply, and the finding might now be a real issue.
Custom policies — what your team actually cares about
Checkov ships ~1,000 rules. Most of them are useful. A few are aspirational for your codebase, and a few are specific to your environment in a way Checkov can't anticipate.
Custom policies in Checkov can be written as YAML:
# .checkov-custom/no-public-rds.yaml metadata: name: "RDS instances must not be publicly accessible" id: "CKV_CUSTOM_001" category: "GENERAL_SECURITY" definition: cond_type: "attribute" resource_types: ["aws_db_instance"] attribute: "publicly_accessible" operator: "equals" value: false
Run with:
checkov -d . --external-checks-dir .checkov-custom --compact --quiet
Custom policies are how you encode the lessons your team learned the hard way. "We had an incident where someone made an RDS instance publicly accessible" → "We now have a custom check that blocks it."
The CI pipeline runs both built-in and custom checks together. Over time the custom check directory becomes your team's institutional memory of what not to do.
What Checkov misses
Three classes of bug Checkov won't catch:
- Logical bugs. "This IAM policy is over-permissioned for what
this app actually does." Checkov can flag
*:*(wildcards), but it can't tell whethers3:GetObjectonprod-pii-bucket/*is appropriate for this application. Tools that try — IAM Access Analyzer, Policy Simulator — are run at level 6-7, not level 1-3. - Cross-resource issues. "This S3 bucket policy and that IAM role together create an unintended path to the data." Each piece in isolation looks fine. The combination is the bug. Graph-based tools like Checkov's graph mode help here but are slow.
- Runtime configuration drift. "This was provisioned with the secure config, but someone changed it via the AWS Console three months later." This is what cloud-runtime security tools (Wiz, Lacework, Prisma) are for. Out of scope for IaC scanning.
The right frame: Checkov is your first line of defence. It catches the easy bugs cheaply. The other lines (level 5-7 tooling) catch the rest. Don't expect any single tool to catch everything; expect the stack to.
Wiring Checkov into CI
A minimal GitHub Actions job:
name: IaC security scan on: pull_request: paths: ['**.tf', '**.tfvars', 'modules/**'] jobs: checkov: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: bridgecrewio/checkov-action@v12 with: directory: . framework: terraform quiet: true soft_fail: false # block PR on critical findings # soft_fail_on flips this per check, see below skip_check: CKV_AWS_19,CKV_AWS_57 # informational only — don't block external_checks_dirs: ".checkov-custom"
soft_fail controls the BLOCK vs WARN decision globally. For
finer-grained control, use soft_fail_on to flip specific checks to
warn, and skip_check to flip specific checks to log-only.
Key takeaways
- Checkov is the practical default in 2026 for IaC scanning, with tfsec as a close second if you only do Terraform.
- The work isn't running the scanner — it's classifying findings into block / warn / log buckets that match your team's risk tolerance.
- Waivers must have justifications. Inline
checkov:skipwith a reason comment. Audit waivers quarterly. - Custom policies encode the lessons your team learned the hard way. Over time they become your team's institutional memory.
- IaC scanning is your first line of defence, not your only one. Cross-resource bugs and runtime drift need other tools at later pipeline stages.
What's next
Lesson 03 unpacks the block / warn / log decision in detail. We've used the framework loosely so far; Lesson 03 makes it a tool you can apply consistently to any new scanner you adopt — Semgrep, Trivy, Snyk, your custom rule, anything. After that, Lesson 04 on false-positive triage (the discipline that keeps the whole thing alive), and the end-of-week lab where you wire severity gates on a real PR pipeline.