Scanning HCL: Trivy (tfsec) & Checkov

Static checks on every change, suppressions with an expiry.

Intermediate25 min · lesson 5 of 12

A Terraform file is a set of building plans. Run `terraform apply` and the contractor pours concrete exactly where the drawing says, including the spot where somebody forgot to draw a wall. Trivy and Checkov are the inspectors who read those plans while they are still paper. Both are static analyzers, which means they read your `.tf` files as text, match every resource against a library of known-bad shapes, and report the problems before anything gets built. They never call your cloud provider. They hold no credentials. A scan of a large repository finishes in seconds and gives the same answer every time.

The failure they catch is the one that fills breach write-ups: the misconfiguration. A storage bucket left publicly readable. A security group (the cloud's version of a door policy, listing who is allowed to knock and on which door) that accepts SSH (Secure Shell, the protocol you log into servers with) from `0.0.0.0/0`, which in CIDR notation (the shorthand for a range of internet addresses) means everybody, everywhere. A database with encryption switched off. An attacker needs no skill for any of these. They sweep the internet looking for open things, find yours, and walk in. Each one is a single missing attribute in a `.tf` file. The previous lesson protected the state file after apply. This one is about the ten minutes before apply, when the fix costs a review comment instead of an incident channel.

What the scanner actually does

Three steps, and both tools do the same three. First they parse your HCL (HashiCorp Configuration Language, the syntax Terraform is written in) into a structured model: every resource, every attribute, every reference between them. Then they run that model past a few hundred built-in policies. A policy is one sentence turned into code, something like "if this is an `aws_s3_bucket` and no public access block covers it, raise a finding." Last, they print each finding with a stable rule identifier, a severity, and a link to the documented fix. Text in, findings out. Nothing touches your account, which is exactly why this step is safe to run on a pull request opened by a stranger.

The two tools have different family histories. tfsec was a Go binary that read only Terraform. Aqua folded its engine into Trivy, their general-purpose scanner, and the tfsec README now says engineering attention goes to Trivy from here on. `trivy config .` runs the same rules the old binary ran, with identifiers renamed to the AVD (Aqua Vulnerability Database) scheme: `AVD-AWS-0086` where tfsec said `aws-s3-block-public-acls`. The standalone binary still works. New rules land in Trivy. Checkov comes from the other direction: Python, from Prisma Cloud, over a thousand policies, and it also reads CloudFormation, Kubernetes manifests, Dockerfiles, GitHub Actions workflows, and Terraform plan output. The two overlap heavily and not completely.

/infra/main.tf
resource "aws_s3_bucket" "logs" {
bucket = "acme-flow-logs"
}
resource "aws_security_group" "web" {
name = "web"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
terminal
trivy --version | head -1
trivy config --quiet --severity HIGH,CRITICAL . | grep -E '^(Tests:|Failures:|AWS-)'
output
Version: 0.72.0
Tests: 6 (SUCCESSES: 0, FAILURES: 6)
Failures: 6 (HIGH: 6, CRITICAL: 0)
AWS-0086 (HIGH): No public access block so not blocking public acls
AWS-0087 (HIGH): No public access block so not blocking public policies
AWS-0091 (HIGH): No public access block so not blocking public acls
AWS-0093 (HIGH): No public access block so not restricting public buckets
AWS-0107 (HIGH): Security group rule allows unrestricted ingress from any IP address.
AWS-0132 (HIGH): Bucket does not encrypt data with a customer managed key.

Five of those six sit on the three-line bucket, which declares nothing and therefore inherits every unsafe default going. One sits on the security group. Notice how the identifiers print: `AWS-0086`, with no prefix, while the rule pages and the suppression syntax you are about to meet both use the longer `AVD-AWS-0086` form. Trivy accepts either spelling when you silence a rule, so the mismatch costs you nothing, but it does confuse people who copy an identifier straight out of the log and expect it to look like the documentation. Now pull the security group finding apart.

terminal
trivy config --quiet --severity HIGH,CRITICAL . | grep -A 23 '^AWS-0107'
output
AWS-0107 (HIGH): Security group rule allows unrestricted ingress from any IP address.
════════════════════════════════════════
Security groups provide stateful filtering of ingress and egress network traffic to AWS
resources. It is recommended that no security group allows unrestricted ingress access to
remote server administration ports, such as SSH to port 22 and RDP to port 3389.
See https://avd.aquasec.com/misconfig/aws-0107
────────────────────────────────────────
main.tf:12
via main.tf:8-13 (ingress)
via main.tf:5-14 (aws_security_group.web)
────────────────────────────────────────
5 resource "aws_security_group" "web" {
6 name = "web"
7
8 ingress {
9 from_port = 22
10 to_port = 22
11 protocol = "tcp"
12 [ cidr_blocks = ["0.0.0.0/0"]
13 }
14 }
────────────────────────────────────────

Read a finding from the bottom up. The `via` chain is the part that saves you time: line 12 holds the offending value, reached through the `ingress` block at lines 8 to 13, inside `aws_security_group.web` at lines 5 to 14. On a toy file that is obvious. When the bad value arrives through a module nested three levels deep, that chain is how you work out who passed it in. Every finding also carries a URL to the rule page, where the fix is written out for you.

The exit code is the entire gate

Picture a guard who writes every suspicious visitor into a logbook but never lowers the barrier. The logbook fills up. Everyone still walks through. CI (continuous integration, the automation that runs checks on every proposed change) has exactly one way to stop a merge: the job has to fail. A job fails when its last command returns a non-zero exit status, the small number every program hands back to say whether it succeeded. The pretty table on your screen is the logbook. The exit status is the barrier. Only one of them stops anybody, so check it, for both tools, before you trust either.

terminal
trivy config --quiet . >/dev/null; echo "trivy, default: $?"
trivy config --quiet --exit-code 1 . >/dev/null; echo "trivy, --exit-code: $?"
checkov -d . --framework terraform --quiet --compact >/dev/null; echo "checkov, default: $?"
output
trivy, default: 0
trivy, --exit-code: 1
checkov, default: 1
Trivy's gate ships switched off
`trivy config` prints every finding and then exits 0. tfsec exited non-zero on findings, so teams that migrated by swapping the binary in place and keeping the same CI step silently turned their gate into a report nobody reads. The GitHub Action has the same shape: its `exit-code` input has no default value at all. Pass `--exit-code 1` on the command line, or `exit-code: '1'` in the workflow, then prove it by pushing a deliberately public bucket on a throwaway branch and watching the job go red. A gate you have never seen fail is not a gate.
terminal
checkov -d . --framework terraform --quiet --compact
output
terraform scan results:
Passed checks: 8, Failed checks: 10, Skipped checks: 0
Check: CKV_AWS_23: "Ensure every security group and rule has a description"
FAILED for resource: aws_security_group.web
File: /main.tf:5-14
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-31
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.web
File: /main.tf:5-14
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-security
Check: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"
FAILED for resource: aws_security_group.web
File: /main.tf:5-14
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/ensure-that-security-groups-are-attached-to-ec2-instances-or-elastic-network-interfaces-enis

Same two resources, ten failures instead of six, and mostly different ones. `--quiet` drops the passing checks and `--compact` drops the code excerpts, so what survives fits in a review comment. `--framework terraform` stops Checkov from also running its Kubernetes, Dockerfile, and secrets scanners across the same tree, which keeps unrelated findings out of a Terraform gate. On a big mixed repository that saves real time. On a small one, Python's startup cost dominates and you will measure no difference at all, so pick the flag for focus rather than for speed. One more thing to notice in that output: `CKV_` identifiers are single-resource rules, while `CKV2_` ones are graph checks that reason across resources. That is how Checkov worked out that this security group is attached to nothing at all, a fact no single-resource rule could see.

Where static analysis goes blind

The blueprint says "door: see spec sheet," and the spec sheet sits in a different envelope. That is a Terraform variable. The scanner reads `acl = var.acl`, looks up the variable's declared default, and reports on that value. Production may hand it something else entirely.

/infra/main.tf
variable "acl" {
type = string
default = "private"
}
resource "aws_s3_bucket" "logs" {
bucket = "acme-flow-logs"
acl = var.acl
}
/infra/prod.tfvars
acl = "public-read"
terminal
trivy config --quiet . | grep -E '^(Tests:|Failures:|AWS-0092)'
output
Tests: 8 (SUCCESSES: 0, FAILURES: 8)
Failures: 8 (UNKNOWN: 0, LOW: 2, MEDIUM: 1, HIGH: 5, CRITICAL: 0)

Eight findings and not one of them mentions the public ACL (access control list, the permission setting that decides who can read a bucket and the objects inside it), because as far as the scanner can see the value is `private`. Now hand it the variable file production actually uses.

terminal
trivy config --quiet --tf-vars prod.tfvars . | grep -E '^(Tests:|Failures:|AWS-0092)'
output
Tests: 9 (SUCCESSES: 0, FAILURES: 9)
Failures: 9 (UNKNOWN: 0, LOW: 2, MEDIUM: 1, HIGH: 6, CRITICAL: 0)
AWS-0092 (HIGH): Bucket has a public ACL: "public-read"

One extra flag, one extra HIGH, and the world-readable bucket gets caught in review instead of in production. Checkov has the same lever under a different name.

terminal
checkov -d . --framework terraform --quiet --compact --var-file prod.tfvars \
| grep -E 'Passed checks|CKV_AWS_20'
output
Passed checks: 3, Failed checks: 8, Skipped checks: 0
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."

If your repository keeps per-environment `.tfvars` files in the tree, scan each one in its own CI step and you close most of this hole for free. What a variable file cannot resolve is anything Terraform learns only by asking the cloud: a `data` source lookup, an attribute computed by another resource, a value pulled from remote state. For those you scan the resolved plan, which is the whole of the next lesson.

terminal
# Everything resolved, including data sources and computed attributes.
# This needs read credentials, so it belongs in a later, more trusted
# pipeline stage than the credential-free HCL scan.
terraform plan -out tf.plan
terraform show -json tf.plan > tf.json
checkov -f tf.json --quiet --compact
Three tiers of scanner vision, three levels of trust
Tier 1: raw HCL
trivy config .
checkov -d . --framework terraform
Sees literals and defaults
var.acl reads as "private"
Zero credentials, seconds
Safe on a fork's pull request
Tier 2: HCL + tfvars
--tf-vars prod.tfvars
checkov: --var-file prod.tfvars
Sees what prod will set
AWS-0092 / CKV_AWS_20 fire
Still zero credentials
Needs the tfvars file in the repo
Tier 3: plan JSON
checkov -f tf.json
terraform show -json tf.plan
Sees data sources, computed values
Nothing left unresolved
Needs read credentials
Trusted stage only
Each tier sees more and costs more trust. Run tier 1 on every push, including forks. Never put cloud credentials behind a scanner that runs on untrusted pull requests.

Suppressions with a shelf life

Every real repository needs exceptions. The bucket that genuinely must be public because it serves a marketing site. The rule that does not apply to your provider version. What matters is the shape the exception takes. Tape over a smoke detector and nobody ever remembers to peel it off. A dated work permit on the wall expires whether or not anyone is paying attention. Trivy suppressions can be the permit.

/infra/main.tf
#trivy:ignore:AVD-AWS-0086
#trivy:ignore:aws-s3-block-public-policy
#trivy:ignore:AVD-AWS-0091:exp:2026-12-31
#trivy:ignore:AVD-AWS-0093:exp:2026-03-31
resource "aws_s3_bucket" "logs" {
bucket = "acme-flow-logs"
}
terminal
date -u +%F
trivy config --quiet --severity HIGH,CRITICAL . | grep -E '^(Tests:|Failures:|AWS-)'
output
2026-07-21
Tests: 2 (SUCCESSES: 0, FAILURES: 2)
Failures: 2 (HIGH: 2, CRITICAL: 0)
AWS-0093 (HIGH): No public access block so not restricting public buckets
AWS-0132 (HIGH): Bucket does not encrypt data with a customer managed key.

Four suppressions, two survivors, and the pattern is the whole lesson. `AVD-AWS-0086` is silenced forever, which is the dangerous form. The second line proves the old tfsec-style rule name still works as an alias, handy while you migrate. `AVD-AWS-0091` carries `exp:2026-12-31`, still in the future, so it holds. `AVD-AWS-0093` expired back in March, so it climbed out of hiding and back into the exit code without anyone filing a ticket. The date did the remembering. `AVD-AWS-0132` was never suppressed at all.

The same `exp:` suffix works in a repo-level `.trivyignore` file, one identifier per line, written as `AVD-AWS-0093 exp:2026-09-30`. Trivy finds that file on its own in the working directory. There is a newer YAML (a plain-text format for structured settings) variant as well, `.trivyignore.yaml`, which adds `paths`, an `expired_at` date, and a `statement` field for the written reason. That one is not picked up automatically. You have to point at it with `--ignorefile`, and it is still marked experimental, so teach your team the inline comment instead. An exception that lives beside the resource it excuses shows up in the diff the next time somebody edits that resource, which is precisely when it should be questioned.

Checkov's version goes inside the resource body, with a free-text reason after a second colon. Note what it does not have.

/infra/main.tf
resource "aws_s3_bucket" "logs" {
#checkov:skip=CKV_AWS_18:CDN ships access logs (TICKET-4417) expires=2026-09-30
bucket = "acme-flow-logs"
}
resource "aws_s3_bucket" "assets" {
#checkov:skip=CKV_AWS_21:Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15
bucket = "acme-public-assets"
}
terminal
checkov -d . --framework terraform --compact | grep -B1 -A4 "SKIPPED for resource"
output
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
SKIPPED for resource: aws_s3_bucket.logs
Suppress comment: CDN ships access logs (TICKET-4417) expires=2026-09-30
File: /main.tf:1-4
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-logging
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
SKIPPED for resource: aws_s3_bucket.assets
Suppress comment: Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15
File: /main.tf:6-9
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-16-enable-versioning

Both skips are honoured and both reasons are echoed back, including the January date that has long since passed. Checkov has no expiry mechanism of its own, so `expires=` is decoration until you enforce it yourself. The reason field is optional in the syntax and should be mandatory in your review standards. Fifteen lines of shell in the same CI job turn that convention into a rule with teeth.

/ci/expired-skips.sh
#!/usr/bin/env bash
# Fail the build when a checkov suppression has no expires= date or has passed it.
set -euo pipefail
today=$(date -u +%F)
bad=0
while IFS= read -r line; do
exp=$(sed -n 's/.*expires=\([0-9-]\{10\}\).*/\1/p' <<<"$line")
if [[ -z "$exp" ]]; then
echo "NO EXPIRY $line"; bad=1
elif [[ "$exp" < "$today" ]]; then
echo "EXPIRED $line"; bad=1
fi
done < <(grep -rn "checkov:skip=" --include="*.tf" .)
exit "$bad"
terminal
./ci/expired-skips.sh; echo "exit=$?"
output
EXPIRED ./main.tf:7: #checkov:skip=CKV_AWS_21:Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15
exit=1

Switching it on in a repo that already fails 300 checks

Day one on an existing repository looks like a wall of red. The reflex is `--soft-fail` "until we clean up." It never comes off. Six months later the job is green, the findings are unchanged, and everyone has quietly learned that the security column means nothing. Do what a landlord does at move-in instead: walk the flat, photograph every scratch that is already there, sign the list, and from then on argue only about new damage. That list is a baseline.

terminal
# Snapshot the debt you already have, and commit the file
checkov -d . --framework terraform --create-baseline --quiet --compact >/dev/null
git add .checkov.baseline
# Gate against it: nothing new, nothing said
checkov -d . --framework terraform --quiet --compact --baseline .checkov.baseline
echo "exit=$?"
output
exit=0

Silence and a zero. Now let somebody add an unencrypted database and push again.

terminal
cat >> main.tf <<'EOF'
resource "aws_db_instance" "app" {
identifier = "app"
engine = "postgres"
instance_class = "db.t3.micro"
username = "admin"
password = "changeme123"
}
EOF
checkov -d . --framework terraform --quiet --compact --baseline .checkov.baseline | head -9
output
terraform scan results:
Passed checks: 0, Failed checks: 10, Skipped checks: 0
Check: CKV_AWS_129: "Ensure that respective logs of Amazon Relational Database Service (Amazon RDS) are enabled"
FAILED for resource: aws_db_instance.app
File: /main.tf:16-22
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-iam-policies/ensure-that-respective-logs-of-amazon-relational-database-service-amazon-rds-are-enabled
Check: CKV_AWS_226: "Ensure DB instance gets all minor upgrades automatically"

The baseline is a small JSON (a plain-text format for structured data) file listing each path, each resource, and the check identifiers already failing there. Findings inside it stay quiet, so the first run says nothing and exits 0. Anything new fails the build, and every one of those ten findings sits on the database somebody added thirty seconds ago. Regenerate the baseline as you fix things, and review the diff when it shrinks, because a baseline that grows is debt you accepted without noticing. Trivy's equivalent ratchet is severity: start the gate at `--severity CRITICAL`, get clean, move to `HIGH,CRITICAL`, keep tightening.

Wiring it into CI so it stays honest

.github/workflows/iac-scan.yml
name: iac-scan
on:
pull_request:
paths: ["**.tf", "**.tfvars"]
permissions:
contents: read
security-events: write # required to upload SARIF to the Security tab
jobs:
scan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# The blocking gate. exit-code has NO default: leave it out and this job
# is green forever, however many HIGH findings scroll past in the log.
- name: Trivy (blocking)
uses: aquasecurity/[email protected]
with:
scan-type: config
scan-ref: .
tf-vars: envs/prod.tfvars
severity: HIGH,CRITICAL
exit-code: "1"
hide-progress: true
# Every step below needs if: always(). A failed step normally skips the
# rest of the job, so without it the gate above would cancel the very
# report you opened the pull request to read.
- name: Checkov (advisory)
if: always()
continue-on-error: true
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
var_file: envs/prod.tfvars
baseline: .checkov.baseline
quiet: true
compact: true
output_format: cli,sarif
output_file_path: console,results.sarif
- name: Every suppression needs a live expiry date
if: always()
run: ./ci/expired-skips.sh
- name: Upload findings to code scanning
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

Five decisions in there are worth copying. The `paths` filter keeps the scan out of builds that never touched Terraform. `exit-code: "1"` is the gate. Checkov is deliberately not a second gate, because two tools both blocking on overlapping rules means one bad bucket generates a dozen review comments and people stop reading all of them; pick one blocker and make the other advisory. The `if: always()` lines matter far more than they look, and leaving them out is the most common way this file breaks: the Trivy step fails on a real finding, GitHub skips everything after it, and the Checkov report, the expiry check, and the upload all vanish at the exact moment you needed them. The SARIF upload (Static Analysis Results Interchange Format, a standard JSON shape for findings that GitHub, GitLab, and most code-scanning dashboards understand) turns results into inline annotations on the diff, so the author sees the offending line without opening a log. And the job holds zero cloud credentials, which means a compromised scanner action has nothing worth stealing.

Two sharp edges will find you later. Version tags are mutable, so anyone who can push to `aquasecurity/trivy-action` can move `v0.36.0` onto different code, which then runs in your pipeline with your repository already checked out. In production, pin every `uses:` to a full 40-character commit SHA (the long fingerprint that names one exact commit and cannot be repointed) and let a bot raise the bumps as reviewable pull requests. The second edge appears the day you add `format: sarif` to the Trivy step: `severity` filters the table you read, not the SARIF file it writes, so your Security tab quietly fills with the LOW findings you deliberately kept out of the gate. Set `limit-severities-for-sarif: true` when you want the two to agree.

Three limits deserve a note on the wall. Local `module` blocks are followed into their source directories, but a remote module is only scanned as downloaded, so you inherit whatever its author wrote, and Checkov will not fetch remote modules at all without `--download-external-modules true`. Rule libraries lag new provider resources, so a brand new resource type may have no rules aimed at it and will sail through clean, which reads identically to safe. And the built-in rules answer exactly one question: is this objectively insecure by industry consensus? They cannot answer whether it is allowed here. Approved regions, a mandatory cost-center tag, no public load balancer outside one account. Those are yours to write, and writing them needs a policy language rather than a fixed checklist.

Before you trust any of this, break it on purpose. Open a throwaway branch, add a bucket with `acl = "public-read"` and a security group open to `0.0.0.0/0`, push it, and watch the job. If it goes red on the line you expect, you have a gate. If it goes green, or red for some unrelated reason, you have found the bug now rather than during an incident. Run that drill again after every edit to the workflow file, because the failure mode of a security check is silence, and silence looks exactly like success.

Quick check
01Your team migrates from tfsec to Trivy by swapping `tfsec .` for `trivy config .` in the same CI step. Pull requests still show HIGH findings in the job log, but no build has failed since the change. What happened?
Incorrect — Trivy inherited tfsec's engine and rules; the findings are the same real ones, only renamed to AVD identifiers.
Correct — tfsec exited non-zero on findings; Trivy reports and exits 0 unless you ask it to fail, which turns the gate into a report.
Incorrect — Misconfiguration scanning never calls the cloud, which is exactly why it is safe on untrusted pull requests.
Incorrect — There is no default failure threshold. `--severity` only filters what gets printed; it does not decide the exit status.
02A Checkov suppression reads `#checkov:skip=CKV_AWS_18:CDN ships access logs (TICKET-4417) expires=2026-09-30`. Once that date passes, what does Checkov do with the suppression?
Incorrect — unlike Trivy's `exp:` suffix, Checkov has no built-in expiry, so the date passing changes nothing on its own.
Incorrect — a Checkov skip is all-or-nothing; there is no automatic downgrade to a warning.
Correct — Checkov honours the skip forever and treats `expires=` as free text, which is why the lesson adds a small CI script to fail the build on missing or past dates.
Incorrect — `expires=` is part of a free-text reason and parses fine; nothing forces the comment's removal.
03You add Checkov to a repository that already fails about 300 checks. Blocking on all of them would halt every merge, so a colleague suggests running with `--soft-fail` 'until we clean the backlog up.' What is the better approach and why?
Correct — a committed baseline records the existing debt so the first run is silent, then fails only on new findings, keeping the gate meaningful without blocking every merge on day one.
Incorrect — `--soft-fail` never comes back off, and six months later the job is green, the findings are unchanged, and the check has quietly stopped meaning anything.
Incorrect — `.trivyignore` is Trivy's file, not Checkov's, and blanket-silencing whole rules would also hide every future occurrence of the same misconfiguration.
Incorrect — Checkov findings are not gated by a severity dial this way, and hiding everything behind a threshold with a promise to raise it later is the same trap as permanent soft-fail.

Related