Scanning HCL: Trivy (tfsec) & Checkov
Static checks on every change, suppressions with an expiry.
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.
resource "aws_s3_bucket" "logs" {bucket = "acme-flow-logs"}resource "aws_security_group" "web" {name = "web"ingress {from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}}
trivy --version | head -1trivy config --quiet --severity HIGH,CRITICAL . | grep -E '^(Tests:|Failures:|AWS-)'
Version: 0.72.0Tests: 6 (SUCCESSES: 0, FAILURES: 6)Failures: 6 (HIGH: 6, CRITICAL: 0)AWS-0086 (HIGH): No public access block so not blocking public aclsAWS-0087 (HIGH): No public access block so not blocking public policiesAWS-0091 (HIGH): No public access block so not blocking public aclsAWS-0093 (HIGH): No public access block so not restricting public bucketsAWS-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.
trivy config --quiet --severity HIGH,CRITICAL . | grep -A 23 '^AWS-0107'
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 AWSresources. It is recommended that no security group allows unrestricted ingress access toremote server administration ports, such as SSH to port 22 and RDP to port 3389.See https://avd.aquasec.com/misconfig/aws-0107────────────────────────────────────────main.tf:12via main.tf:8-13 (ingress)via main.tf:5-14 (aws_security_group.web)────────────────────────────────────────5 resource "aws_security_group" "web" {6 name = "web"78 ingress {9 from_port = 2210 to_port = 2211 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.
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: $?"
trivy, default: 0trivy, --exit-code: 1checkov, default: 1
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.checkov -d . --framework terraform --quiet --compact
terraform scan results:Passed checks: 8, Failed checks: 10, Skipped checks: 0Check: CKV_AWS_23: "Ensure every security group and rule has a description"FAILED for resource: aws_security_group.webFile: /main.tf:5-14Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-31Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.webFile: /main.tf:5-14Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityCheck: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"FAILED for resource: aws_security_group.webFile: /main.tf:5-14Guide: 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.
variable "acl" {type = stringdefault = "private"}resource "aws_s3_bucket" "logs" {bucket = "acme-flow-logs"acl = var.acl}
acl = "public-read"
trivy config --quiet . | grep -E '^(Tests:|Failures:|AWS-0092)'
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.
trivy config --quiet --tf-vars prod.tfvars . | grep -E '^(Tests:|Failures:|AWS-0092)'
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.
checkov -d . --framework terraform --quiet --compact --var-file prod.tfvars \| grep -E 'Passed checks|CKV_AWS_20'
Passed checks: 3, Failed checks: 8, Skipped checks: 0Check: 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.
# 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.planterraform show -json tf.plan > tf.jsoncheckov -f tf.json --quiet --compact
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.
#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-31resource "aws_s3_bucket" "logs" {bucket = "acme-flow-logs"}
date -u +%Ftrivy config --quiet --severity HIGH,CRITICAL . | grep -E '^(Tests:|Failures:|AWS-)'
2026-07-21Tests: 2 (SUCCESSES: 0, FAILURES: 2)Failures: 2 (HIGH: 2, CRITICAL: 0)AWS-0093 (HIGH): No public access block so not restricting public bucketsAWS-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.
resource "aws_s3_bucket" "logs" {#checkov:skip=CKV_AWS_18:CDN ships access logs (TICKET-4417) expires=2026-09-30bucket = "acme-flow-logs"}resource "aws_s3_bucket" "assets" {#checkov:skip=CKV_AWS_21:Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15bucket = "acme-public-assets"}
checkov -d . --framework terraform --compact | grep -B1 -A4 "SKIPPED for resource"
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"SKIPPED for resource: aws_s3_bucket.logsSuppress comment: CDN ships access logs (TICKET-4417) expires=2026-09-30File: /main.tf:1-4Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-loggingCheck: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"SKIPPED for resource: aws_s3_bucket.assetsSuppress comment: Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15File: /main.tf:6-9Guide: 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.
#!/usr/bin/env bash# Fail the build when a checkov suppression has no expires= date or has passed it.set -euo pipefailtoday=$(date -u +%F)bad=0while IFS= read -r line; doexp=$(sed -n 's/.*expires=\([0-9-]\{10\}\).*/\1/p' <<<"$line")if [[ -z "$exp" ]]; thenecho "NO EXPIRY $line"; bad=1elif [[ "$exp" < "$today" ]]; thenecho "EXPIRED $line"; bad=1fidone < <(grep -rn "checkov:skip=" --include="*.tf" .)exit "$bad"
./ci/expired-skips.sh; echo "exit=$?"
EXPIRED ./main.tf:7: #checkov:skip=CKV_AWS_21:Static assets, rebuilt from source (TICKET-3901) expires=2026-01-15exit=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.
# Snapshot the debt you already have, and commit the filecheckov -d . --framework terraform --create-baseline --quiet --compact >/dev/nullgit add .checkov.baseline# Gate against it: nothing new, nothing saidcheckov -d . --framework terraform --quiet --compact --baseline .checkov.baselineecho "exit=$?"
exit=0
Silence and a zero. Now let somebody add an unencrypted database and push again.
cat >> main.tf <<'EOF'resource "aws_db_instance" "app" {identifier = "app"engine = "postgres"instance_class = "db.t3.micro"username = "admin"password = "changeme123"}EOFcheckov -d . --framework terraform --quiet --compact --baseline .checkov.baseline | head -9
terraform scan results:Passed checks: 0, Failed checks: 10, Skipped checks: 0Check: CKV_AWS_129: "Ensure that respective logs of Amazon Relational Database Service (Amazon RDS) are enabled"FAILED for resource: aws_db_instance.appFile: /main.tf:16-22Guide: 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-enabledCheck: 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
name: iac-scanon:pull_request:paths: ["**.tf", "**.tfvars"]permissions:contents: readsecurity-events: write # required to upload SARIF to the Security tabjobs:scan:runs-on: ubuntu-24.04steps:- 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/trivy-action@v0.36.0with:scan-type: configscan-ref: .tf-vars: envs/prod.tfvarsseverity: HIGH,CRITICALexit-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: trueuses: bridgecrewio/checkov-action@v12with:directory: .framework: terraformvar_file: envs/prod.tfvarsbaseline: .checkov.baselinequiet: truecompact: trueoutput_format: cli,sarifoutput_file_path: console,results.sarif- name: Every suppression needs a live expiry dateif: always()run: ./ci/expired-skips.sh- name: Upload findings to code scanningif: always()uses: github/codeql-action/upload-sarif@v3with: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.
tfsec . for trivy config . inside the same CI step. Pull requests still list HIGH findings in the job log, but no build has gone red since the swap. What explains it?--severity HIGH,CRITICAL decides which rows reach your screen and has no say in what the process hands back to CI.AVD-AWS-0086 is the same rule tfsec printed as aws-s3-block-public-acls, running at full strength..tf files as text and never calls AWS, which is precisely why it is safe on a pull request from a stranger. Credentials are not the missing ingredient.main.tf reads #checkov:skip=CKV_AWS_18:CDN ships access logs (TICKET-4417) expires=2026-09-30. CI runs Checkov again the following October. What does it do with that skip?ci/expired-skips.sh, which greps every checkov:skip= line and exits 1 when the date is missing or already behind you.expires=.--soft-fail 'until we clean the backlog up.' What do you do instead?.trivyignore belongs to Trivy and Checkov never opens it. Even in Trivy, silencing a whole rule also buries every future occurrence of that same mistake.Try this
Run trivy --version | head -1 on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: trivy's gate ships switched off. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.