Why scan infrastructure code at all
A cloud misconfiguration is often a one-line mistake. An S3 bucket without a public access block, a database with publicly_accessible = true, a security group open to the world on port 22. In the console these are easy to miss. In Terraform they are text, which means a machine can read them before they ever exist.
Terraform security scanning moves this check to the cheapest possible moment: the pull request. Fixing a finding there takes minutes. Fixing it after an incident takes far longer. This page describes how we add it to the pipelines we build as part of our Terraform and infrastructure as code service.
What good looks like
- Every pull request that touches Terraform is scanned automatically.
- Serious findings block merge; minor ones are visible but do not stop work.
- Rules reflect your real risk, not a generic checklist.
- Suppressions are rare, explained and attributable.
- Scanning covers modules, not only root configurations.
Checkov, Trivy and the fate of tfsec
For years the standard choice was Checkov versus tfsec. Since then tfsec has been absorbed into Trivy, Aqua Security’s multi-purpose scanner, so its checks now run under trivy config. That leaves two mainstream open source options:
| Checkov | Trivy (misconfiguration) | |
|---|---|---|
| Scope | Infrastructure as code focus, many formats | Images, dependencies, secrets and IaC in one tool |
| Terraform source scan | Yes | Yes |
| Terraform plan scan | Yes, via JSON plan | Yes |
| Custom policies | Python or YAML | Rego |
| Best fit | Teams that want deep IaC policy coverage | Teams that want one scanner everywhere |
Neither is wrong. If your container pipeline already uses Trivy, extending it to Terraform avoids another tool. If you want a rich policy library and straightforward custom checks, Checkov is hard to beat. For wider policy needs, Open Policy Agent with Conftest can evaluate plan JSON against rules you write.
How it works in practice
Scanning source and plan
Source scanning is fast and needs no credentials. Plan scanning catches things only resolved at plan time, such as values from variables and data sources. We do both:
# Static scan of the directory
checkov -d . --framework terraform --compact --quiet
# Scan the rendered plan
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
checkov -f tfplan.json --framework terraform_plan
The Trivy equivalent for source is trivy config ., with the severity threshold controlling the exit code in CI.
Documented exceptions
Sometimes a finding is intentional. A public website bucket really is public. The exception belongs next to the resource, with a reason:
resource "aws_s3_bucket" "public_site" {
bucket = "example-marketing-site"
# checkov:skip=CKV_AWS_18: Access logging is handled at the CDN layer for this static site.
}
Because the skip lives in the code, it goes through review like anything else, and a search for checkov:skip gives you an audit list of every exception. Check the current check IDs in the version you run, since IDs and coverage evolve.
Putting it in the pipeline
The scan runs in the same job as formatting, validation and linting with tflint, so the pull request gets one pass or fail. In the Atlantis or Terraform Cloud workflow, a failed scan blocks the apply. Results can also be exported as SARIF so they appear in the code host’s security tab.
A minimal CI step
The pipeline step is short. This GitHub Actions job runs the static scan on pull requests that touch Terraform and uploads results in SARIF format so they appear in the repository’s security view:
name: terraform-scan
on:
pull_request:
paths: ["**.tf"]
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install checkov
- run: checkov -d . --framework terraform --output sarif --output-file-path results
Pin the Checkov version in a requirements file rather than installing the latest, so a new release cannot fail every build overnight.
Decisions and trade-offs
Enforce or advise. Start in advisory mode for a couple of weeks, clean up the backlog, then enforce on high severity. Turning on blocking against a legacy repository on day one produces hundreds of failures and a revolt.
Scan modules centrally. If a module is wrong, every consumer inherits the flaw. Scanning and fixing at the module level, as described in reusable Terraform modules, fixes it once.
Scanner versus human review. Scanners are good at known bad patterns. They are poor at judging whether an IAM role is too broad for its purpose. They complement, and do not replace, review by someone who knows your estate.
When this is not enough. Static scanning does not see what is running. Runtime checks, cloud configuration monitoring and Kubernetes policy are separate layers.
Failure modes we avoid
- Alert fatigue from default rules. We tune and baseline before enforcing.
- Blanket skips. A file-wide disable hides real problems. Skips are per resource and per check.
- Secrets in code. Scanners can flag hard-coded credentials, but the better fix is never to write them. Use a secrets manager and mark outputs sensitive.
- Scanner version drift. An upgrade can add checks and suddenly fail every build. We pin versions and upgrade deliberately.
Choosing which findings block
Not every check deserves to stop a merge. We usually sort rules into three groups. Blocking rules cover exposure that is hard to reverse or easy to exploit, such as public storage, world-open admin ports and unencrypted data stores. Warning rules cover good practice with a cost, such as extra logging. Ignored rules cover checks that do not apply to your architecture, each recorded with a reason. Reviewing the grouping every quarter keeps it aligned with how your estate changes.
Delivery and handover
You receive the scanner configured in your pipeline, a documented severity policy, a cleaned-up baseline, and a short guide for developers on reading findings and requesting exceptions.
How to tell it is working
The count of high-severity findings in the main branch is zero and stays there. New findings are caught in pull requests rather than discovered in audits. The list of suppressions is short and every item has a reason. If you want a baseline scan of your current code, talk to us.