Code Quality and Security Scanning
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Infrastructure code is code, and most of the mistakes that reach production can be caught without creating a single resource: a file that is not formatted, a variable that nobody uses, a module source that is not pinned, a bucket without encryption, a security group open to the world, a database that costs ten times what the team expected. This page is about the tools that find those mistakes statically, that is, by reading the configuration (and, for some tools, the JSON plan) without talking to the cloud API.
It is written against Terraform 1.17 and OpenTofu 1.13, the baseline of this section (Terraform 1.17.0 was released on
2026-10-21 and OpenTofu 1.13.2 on 2026-10-21). The third-party tools move on their own schedules. The versions observed on
their GitHub release pages while this page was written (2026-10-23) were TFLint 0.65.0, tflint-ruleset-terraform 0.15.0,
tflint-ruleset-aws 0.50.0, terraform-docs 0.24.0, pre-commit-terraform 1.109.3, Checkov 3.3.30, Trivy 0.76.1 and
Infracost CLI 2.18.2. Treat every version number in an example as a pin to be replaced by whatever is current when you read
this; the commands and configuration keys are the part that matters.
The page follows Testing Terraform and OpenTofu in the reading path: tests check that the code behaves as intended, the tools below check that it is well formed, consistent and safe. It leads into Policy as Code, which covers the policy engines that decide at plan time (Sentinel, OPA, Terraform Policy), and into CI/CD and Automation, where the commands shown here run in a pipeline. The module conventions that terraform-docs documents are in Module Development and Publishing, and the dependency lock file that Dependabot and Renovate maintain is introduced in Providers.
The running example is Bookshelf (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). Bucket names,
account IDs and tokens are placeholders. Because no cloud account is needed for static analysis, every command below can be
run on a laptop against the configuration alone, except where a tool is explicitly described as needing credentials
(TFLint deep checking, Infracost authentication).
The quality ladder
The tools differ in what they know. A formatter knows only the syntax tree. terraform validate knows the schema of every
provider in the configuration. A linter such as TFLint knows conventions and, with a provider ruleset, which instance types
or regions exist. A security scanner knows a catalogue of insecure configurations. A cost estimator knows a price list. The
cheapest checks run first and the slowest or most opinionated run last.
| Tool | What it catches | Needs | Typical place |
|---|---|---|---|
|
Layout and alignment drift |
Nothing |
Editor, pre-commit, CI |
|
Syntax errors, unknown arguments, wrong types, bad references |
|
Pre-commit, CI |
TFLint |
Unused declarations, missing versions, deprecated syntax, provider-specific mistakes (invalid instance type) |
The TFLint binary and a |
Pre-commit, CI |
terraform-docs |
Out-of-date module documentation |
Nothing |
Pre-commit, CI (check mode) |
Checkov |
Insecure resource settings (encryption, public access, logging) |
Python |
CI, optionally pre-commit |
Trivy |
The same class of misconfigurations, plus secrets and vulnerabilities |
The Trivy binary |
CI |
Infracost |
Cost of the change |
An Infracost account token |
CI on pull requests |
Dependabot / Renovate |
Stale provider, module and action versions |
Repository integration |
Scheduled |
The left half runs on every commit through pre-commit hooks and must be fast enough that nobody disables it. The right half runs in the pull request pipeline, where a few minutes are acceptable and where the results can be published as SARIF annotations or as a pull request comment.
terraform fmt
terraform fmt rewrites configuration files to the canonical layout described in the
style guide: two-space indentation, aligned = signs within a
block, and consistent spacing. It changes only whitespace and never the meaning of the code, which is why it is safe to
run automatically. Its usage is terraform fmt [options] [target...], where a target is a directory, a file, or a single
dash - to read from standard input.
| Flag | Effect |
|---|---|
|
Do not list the files that were (or would be) changed |
|
Show the formatting changes as a diff |
|
Do not overwrite files. Implied by |
|
Report whether the input is formatted without changing it. The exit status is non-zero when it is not, and the unformatted file names are listed |
|
Also process subdirectories. By default only the given or current directory is processed |
|
Remove colour from the output |
The documented exit status is 0 when the input is properly formatted, and non-zero when -check finds improperly formatted
input. The page does not document any other exit codes, so scripts should only test for zero versus non-zero.
$ terraform fmt -check -recursive -diff
modules/service/main.tf
--- old/modules/service/main.tf
+++ new/modules/service/main.tf
@@ -3,2 +3,2 @@
- name="catalog-api"
+ name = "catalog-api"
$ echo $?
3
The output above is illustrative and abbreviated, not copied from an official page; in particular the exact non-zero value is not documented.
Source: Terraform Docs — terraform fmt
Two habits make fmt painless. Run terraform fmt -recursive before committing (the pre-commit hook below does it for
you), and make CI run terraform fmt -check -recursive so a badly formatted pull request fails fast with a precise message.
fmt also formats .tfvars files and, in OpenTofu, .tofu files; tofu fmt accepts the same flags.
terraform validate
terraform validate checks that the configuration is syntactically valid and internally consistent, regardless of variable
values or existing state. It is meant for reusable modules too: it verifies attribute names and value types against each
provider’s schema. It does not contact remote services, so it will not notice that a bucket name is already taken or
that a remote state backend is unreachable. It does need an initialised working directory because the provider schemas come
from the installed plugins; to initialise without touching a backend, run terraform init -backend=false.
| Flag | Effect |
|---|---|
|
Set one input variable of the root module. Repeatable |
|
Load input variables from a |
|
Machine-readable JSON output for editors and automation. Always disables colour |
|
Remove colour from the output |
With -json the output is one object with format_version (1.0 as of Terraform 1.1.0), valid, error_count,
warning_count and a diagnostics array. Each diagnostic carries a severity (error or warning), a summary, an
optional detail, an optional range (file name plus start and end byte, line and column) and an optional
snippet. Consumers should ignore severities they do not recognise, and should treat non-JSON standard output as a generic
failure, because early errors that happen before validation begins may not be JSON.
# Validate a module without a backend, then show the machine-readable result
terraform init -backend=false -input=false
terraform validate
terraform validate -json | jq '{valid, error_count, warning_count}'
validate is the first check that needs the provider plugins, so in a pipeline it is also the step that proves the
provider requirements resolve. tofu validate behaves the same way. The expression-level checks it cannot do (does this
variable have a sensible value, does this resource satisfy a business rule) belong to
Lifecycle, Validation and Checks and to
tests.
TFLint
TFLint is a pluggable linter for Terraform. It reads the configuration,
evaluates variables and module calls, and runs rules supplied by rulesets. A ruleset is a plugin: one is bundled in the
binary (the Terraform language ruleset) and the provider-specific ones (AWS, Google Cloud, AzureRM) are installed with
tflint --init.
Installing and running
TFLint can be installed from a release archive, with Homebrew (brew install terraform-linters/tap/tflint), WinGet
(winget install -e --id TerraformLinters.tflint), go install github.com/terraform-linters/tflint@latest, or as the
container image ghcr.io/terraform-linters/tflint. The README recommends verifying a download with GitHub CLI artifact
attestations. By default TFLint inspects the files in the current directory.
| Flag | Effect |
|---|---|
|
Install the plugins declared in the configuration file |
|
Run in each directory recursively (a flat module layout needs this) |
|
Switch to another working directory before running |
|
Config file name. The default is |
|
Output format: |
|
Fix auto-fixable issues |
|
Lowest severity that causes a non-zero exit: |
|
Which module calls to inspect: |
Other flags include --only, --enable-rule, --disable-rule, --filter, --force and --max-workers; tflint --help
lists them all. Without --force, the exit status is 0 when no issues are found, 1 when errors occurred and 2 when
no errors occurred but issues were found. In recursive mode the format and force settings are ignored in the config
file and must be given as flags. The TFLINT_LOG environment variable turns on detailed logging.
Config files are located in this order: the file given with --config, the file named by TFLINT_CONFIG_FILE,
./.tflint.hcl, ./.tflint.json, ~/.tflint.hcl and ~/.tflint.json.
A configuration for Bookshelf
# .tflint.hcl
tflint {
required_version = ">= 0.50"
}
config {
call_module_type = "local"
force = false
disabled_by_default = false
}
# The Terraform language ruleset. Bundled with TFLint; declaring the block
# lets you pick a preset. "recommended" enables only the rules marked as such.
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "aws" {
enabled = true
version = "0.50.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
# Rules outside the recommended preset that the Bookshelf style guide wants
rule "terraform_documented_variables" {
enabled = true
}
rule "terraform_documented_outputs" {
enabled = true
}
rule "terraform_naming_convention" {
enabled = true
}
rule "terraform_standard_module_structure" {
enabled = true
}
Source: TFLint — Configuring TFLint
After declaring version and source, tflint --init downloads the plugin into ~/.tflint.d/plugins (or
./.tflint.d/plugins, or the directory set with plugin_dir), as in the documented example:
$ tflint --init
Installing "foo" plugin...
Installed "foo" (source: github.com/org/tflint-ruleset-foo, version: 0.1.0)
$ tflint -v
TFLint version 0.28.1
+ ruleset.foo (0.1.0)
Source: TFLint — Configuring Plugins
The config block also accepts format, plugin_dir, ignore_module (a map of module sources to skip when module calls
are inspected), varfile and variables. If terraform.tfvars or any *.auto.tfvars file is present, TFLint loads it
automatically. The same configuration can be written as JSON in .tflint.json, using the HCL-compatible JSON syntax.
The rulesets
The Terraform language ruleset (tflint-ruleset-terraform) implements recommendations from the Terraform language
documentation and deliberately offers no personal or team style rules. All of its rules are enabled by default when the
plugin is installed from GitHub (the all preset); the bundled copy defaults to the recommended preset, and
preset accepts exactly recommended or all. A rule block always wins over the preset, and the preset wins over
disabled_by_default. The rules listed in its README are:
| Rule | What it disallows or requires | Recommended |
|---|---|---|
|
Legacy dot index syntax |
yes |
|
Deprecated 0.11-style interpolation |
yes |
|
|
yes |
|
Comparing with |
yes |
|
JSON files without a root object |
yes |
|
Duplicate keys in a map object |
yes |
|
A Git or Mercurial module source that is not pinned to a version |
yes |
|
A registry module without a |
yes |
|
Providers without version constraints in |
yes |
|
A |
yes |
|
|
yes |
|
Variables, data sources and locals that are never used |
yes |
|
|
yes |
|
|
no |
|
|
no |
|
|
no |
|
Pinned Git modules not using shallow cloning |
no |
|
Names that break the configured convention |
no |
|
A module that departs from the standard module structure |
no |
|
A |
no |
Source: tflint-ruleset-terraform — Rules
The AWS ruleset (tflint-ruleset-aws) knows the AWS provider’s resources: it flags invalid instance types, invalid
AMI-related values and other arguments that the provider schema accepts as any string but the AWS API will reject at apply
time. Its optional deep checking calls AWS APIs to verify that, for example, a referenced resource exists; it is off by
default (deep_check = false) and uses the credentials of the provider block or the standard AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_REGION and AWS_PROFILE variables. Keep deep checking out of the fast pre-commit loop. The
Google Cloud (tflint-ruleset-google, observed 0.40.0) and AzureRM (tflint-ruleset-azurerm, observed 0.32.0) rulesets are
declared the same way, with the ruleset’s source address, github.com/terraform-linters/tflint-ruleset-google and
github.com/terraform-linters/tflint-ruleset-azurerm respectively.
Module inspection
TFLint evaluates each module block and reports issues that result from the input variables passed to it. By default it
calls only local modules (a relative ./ path). To inspect registry and Git modules, run terraform init (or
terraform get) first so the modules are in .terraform, then run tflint --call-module-type=all. The documented output
shows the callers of the offending line:
$ tflint
1 issue(s) found:
Error: instance_type is not a valid value (aws_instance_invalid_type)
on template.tf line 5:
5: instance_type = "t1.2xlarge"
Callers:
template.tf:5,19-31
module/instance.tf:5,19-36
Source: TFLint — Calling Modules
The --module flag that appears in older articles, and in an example of the pre-commit-terraform README, was replaced by
--call-module-type; prefer the latter.
Suppressing and fixing
A comment annotation suppresses an issue on the line that follows it. Errors (as opposed to issues) cannot be suppressed,
and a rule can opt out of suppression with ignorable = false. Always add the reason.
resource "aws_instance" "legacy_import" {
# Imported as-is; resized in a later change
# tflint-ignore: aws_instance_invalid_type
instance_type = "t1.2xlarge"
}
Source: TFLint — Annotations
Some issues are marked [Fixable]. Running tflint --fix repairs them and marks them [Fixed]:
$ tflint --fix
1 issue(s) found:
Warning: [Fixed] Single line comments should begin with # (terraform_comment_syntax)
on main.tf line 1:
1: // locals values
2: locals {
Source: TFLint — Autofix
TFLint also supports your own rules: a ruleset is a Go plugin built with the TFLint plugin SDK, which is the route taken in the "custom rules" section of Policy as Code.
terraform-docs
terraform-docs generates documentation from a module’s source: inputs, outputs, providers,
requirements, resources and the module’s header. It is the tool behind most README tables in the
public registry, and it is what keeps the README of a Bookshelf module from drifting away
from its variables. It is installed with Homebrew (brew install terraform-docs), Scoop, Chocolatey, a release binary,
go install github.com/terraform-docs/terraform-docs@v0.24.0, or the container image
quay.io/terraform-docs/terraform-docs.
Formatters include markdown (with the table and document variants), asciidoc (with table and document),
json, yaml, toml, xml, pretty, tfvars hcl and tfvars json; md, adoc, tbl and doc are accepted as short
names. Because this site is written in AsciiDoc, the asciidoc table formatter is the one to remember. The header comes from
main.tf by default, and --header-from accepts .adoc, .md, .tf, .tofu and .txt files.
The configuration file is .terraform-docs.yml, found in the module root, in a .config/ folder, in the current directory
or in $HOME/.tfdocs.d/; -c or --config selects another file, and CLI flags override the file. formatter is the only
required option.
# modules/service/.terraform-docs.yml
formatter: "markdown table"
header-from: main.tf
sections:
show:
- requirements
- providers
- inputs
- outputs
output:
file: README.md
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
sort:
enabled: true
by: name
settings:
lockfile: true
required: true
sensitive: true
type: true
Source: terraform-docs — Configuration
The output.mode is inject (the default) or replace. With inject, only the text between the begin and end comments
is replaced; the file is created if it does not exist and the block is appended if the comments are missing. With replace,
the whole file is rewritten, so hand-written content is lost. The template must contain the begin comment, the
{{ .Content }} slug (spaces inside the braces are mandatory) and the end comment, and for AsciiDoc output the comments may
be written // BEGIN_TF_DOCS and // END_TF_DOCS.
# Generate or refresh the README of one module using its config file
terraform-docs modules/service
# Same, without a config file
terraform-docs markdown table --output-file README.md --output-mode inject modules/service
# CI: fail when README.md is out of date, without changing it
terraform-docs --output-check modules/service
Source: terraform-docs — asciidoc table
--output-check is listed among the inherited options (default false) with the description "check if content of output
file is up to date", which is exactly what a CI job needs. Avoid recursive.enabled: true in a config file that the
pre-commit hook will use: pre-commit-terraform warns that it can cause unexpected behaviour. terraform-docs 0.12.0 or later is required for the terraform_docs hook, and the tool’s own
compatibility table maps terraform-docs >= 0.13 to Terraform >= 0.15.
pre-commit-terraform
The pre-commit framework runs checks on the files in a commit before it is created.
pre-commit-terraform is a collection of hooks for it that wrap the
tools in this page, and it works with Terraform, OpenTofu and Terragrunt. It can be used as standalone Git hooks or as the
container image ghcr.io/antonbabenko/pre-commit-terraform.
The hooks listed in its README include terraform_fmt, terraform_validate, terraform_docs,
terraform_docs_without_aggregate_type_defaults, terraform_providers_lock, terraform_tflint, terraform_trivy,
terraform_checkov, infracost_breakdown, terragrunt_fmt, terragrunt_validate, tfupdate and terrascan. Several are
marked deprecated: terraform_tfsec (use terraform_trivy), checkov (use terraform_checkov) and terraform_docs_replace.
Each hook requires its underlying tool to be on the PATH (for example tflint, terraform-docs, trivy, checkov, and
jq for terraform_validate --retry-once-with-cleanup and for infracost_breakdown).
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.109.3 # use the latest release
hooks:
- id: terraform_fmt
- id: terraform_validate
args:
- --hook-config=--retry-once-with-cleanup=true
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- --args=--call-module-type=all
- id: terraform_docs
args:
- --args=--config=.terraform-docs.yml
- id: terraform_trivy
args:
- --args=--severity=HIGH,CRITICAL
Source: pre-commit-terraform — README
Installing it into a clone is pre-commit install, and pre-commit run -a runs every hook against all files, not only the
changed ones. Three details from the README matter in practice.
-
Arguments. Each tool argument is passed as its own
--args=...entry, because spaces are not allowed inside one entry. Environment variables are expanded in--args, and the placeholder__GIT_WORKING_DIR__is replaced by the repository root. Hook-level behaviour is set with--hook-config=.... -
Pinning tools. Most hooks can pin the wrapped tool’s version independently of the
PATH; the tool is downloaded from its GitHub releases on first use. This does not apply tocheckovandterraform_checkov, which are installed from PyPI. -
Plugin cache. If you set
TF_PLUGIN_CACHE_DIR, the README recommends--hook-config=--retry-once-with-cleanup=trueor--hook-config=--parallelism-limit=1onterraform_validate, to avoid races when severalterraform initruns write the cache. The README notes the race was fixed in OpenTofu 1.10.
Other useful switches are PCT_LOG=trace for debugging and PRE_COMMIT_COLOR=never to disable colour.
The docs hook also takes --hook-config options. --path-to-file, --add-to-existing-file, --create-file-if-not-exist
and --use-standard-markers control where the documentation goes, and --custom-marker-begin and --custom-marker-end let
it use other comment styles; for AsciiDoc the README suggests // BEGIN_TF_DOCS and // END_TF_DOCS.
Checkov
Checkov is a static analysis tool for infrastructure as code. For Terraform it parses the HCL,
builds a graph of resources and their connections, and runs a catalogue of policies, each with an ID such as CKV_AWS_21
("Ensure all data stored in the S3 bucket have versioning enabled"). It also scans CloudFormation, Kubernetes manifests, Helm
charts, Dockerfiles and more, which is useful on a repository that mixes them. It is installed with pip install checkov
(a virtual environment is recommended on Debian 12), brew install checkov, or run from a container.
Scanning
# Whole directory (recursive)
checkov -d .
# One file: -f filters the runners by file type, so a .tf file runs only the Terraform checks
checkov -f modules/storage/main.tf
# Only failures, without the code blocks, for a readable CI log
checkov -d . --quiet --compact
# Inspect remote modules too (downloaded into .external_modules)
checkov -d . --download-external-modules true
# Run a subset, or everything except some checks
checkov -d . --check CKV_AWS_18,CKV_AWS_21
checkov -d . --skip-check CKV_AWS_144
Source: Checkov — CLI Command Reference
The flags worth knowing:
| Flag | Effect |
|---|---|
|
IaC root directory. Cannot be combined with |
|
A file to scan. Cannot be combined with |
|
Restrict the run to one framework, such as |
|
Run only (or skip) the listed check IDs, with wildcards. Severities need the Prisma Cloud platform integration and an API key |
|
Path to skip, as a regular expression relative to the working directory |
|
Output format; |
|
Where to save the chosen formats, for example |
|
Show only failed checks / hide code blocks |
|
Always exit with |
|
Exit status decided per check ID or severity |
|
Extra variable files, currently supported for Terraform source |
|
Download remote Terraform modules so their content is scanned |
|
Directory with custom checks. Repeatable |
|
Use, write or print the configuration |
|
Record current findings in |
The exit code is 0 for a passing result and 1 for a failing one; a result passes when every check passed or was skipped.
--soft-fail, --soft-fail-on and --hard-fail-on adjust that. The baseline feature is the practical way to adopt
Checkov on an existing repository: record today’s findings once and fail only on new ones.
Checkov can download third-party modules because a scan of the current directory sees only the module call, not the
resources inside it. Private modules need tokens as environment variables (GITHUB_PAT, BITBUCKET_TOKEN,
TF_REGISTRY_TOKEN, with TF_HOST_NAME for a registry other than app.terraform.io). Inject them from the CI secret store,
never from a file in the repository.
Source: Checkov — Terraform Scanning
Scanning the plan
Scanning the source misses values that only exist after evaluation (data sources, defaults, module outputs). Checkov can scan the JSON form of a plan, which has more context. Because the plan can contain secrets injected at run time, do this in a trusted pipeline and never publish the plan file.
terraform init
terraform plan --out tfplan.binary
terraform show -json tfplan.binary | jq > tfplan.json
checkov -f tfplan.json
The documented output, abbreviated:
checkov -f tf.json
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
FAILED for resource: aws_s3_bucket.customer
File: /tf/tf1.json:224-268
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-16-enable-versioning
Four checks are ignored for plan files because they evaluate the lifecycle block, which is not stored in a plan:
CKV_AWS_217, CKV_AWS_233, CKV_AWS_237 and CKV_GCP_82. --repo-root-for-plan-enrichment (used with -f) points
Checkov to the HCL that produced the plan so findings can be mapped back to source lines. The same plan JSON can be fed to
the policy engines of Policy as Code.
Suppressing a check
A comment inside the resource block skips a check for that resource and records the reason in the output as a SKIPPED
result. A skipped check is not a failing check.
resource "aws_s3_bucket" "covers" {
bucket = "bookshelf-covers-example"
# checkov:skip=CKV_AWS_144:Cross-region replication is not needed for re-creatable covers
}
Prefer a narrow in-code skip with a reason to a global --skip-check, because the skip then travels with the resource and
shows up in review. --check and --skip-check also accept wildcards (CKV_AWS*).
Custom checks in Python
Custom checks extend the built-in catalogue. A Python check subclasses a base class (for Terraform resources,
BaseResourceCheck), declares a name, a unique id, the supported_resources and the categories, and implements
scan_resource_conf, which receives the resource’s arguments as a dictionary of lists. Native IDs follow
CKV_providerType_serialNumber; choose your own prefix for local checks. The first time you create a custom checks
folder, add an __init__.py that exports the modules in it, exactly as in the documented example.
# policies/BookshelfServiceTag.py
from __future__ import annotations
from typing import Any
from checkov.common.models.enums import CheckCategories, CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
class BookshelfServiceTag(BaseResourceCheck):
def __init__(self) -> None:
name = "Ensure Bookshelf buckets carry a service tag"
id = "CKV_BOOKSHELF_1"
supported_resources = ("aws_s3_bucket",)
categories = (CheckCategories.GENERAL_SECURITY,)
super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf: dict[str, list[Any]]) -> CheckResult:
tags = conf.get("tags")
if tags and isinstance(tags[0], dict) and "service" in tags[0]:
return CheckResult.PASSED
return CheckResult.FAILED
check = BookshelfServiceTag()
checkov -d . --external-checks-dir policies --check CKV_BOOKSHELF_1 --run-all-external-checks
Source: Checkov — Python Custom Policies
The class is original to this page and follows the structure of the documented examples; it is illustrative and was not run
here. --run-all-external-checks makes external checks run even when they are not in the --check list. Checkov executes
the Python code of external checks, so load them only from sources you trust; --external-checks-git can download a
repository of checks, with an optional subdirectory after a double slash and ?ref=tags/... to pin a version.
Custom checks in YAML
For attribute and connection rules a YAML policy needs no Python. Its metadata has a name, an ID of the form
CKV2_<provider>_<number>, a category and an optional severity (INFO, LOW, MEDIUM, HIGH, CRITICAL). The
definition is an attribute block, a connection block, or a logical and, or or not around them. The operators include
equals, exists, not_exists, regex_match, contains, within, starting_with and ending_with.
---
metadata:
name: "Ensure all taggable resources carry a service tag"
id: "CKV2_BOOKSHELF_1"
category: "GENERAL_SECURITY"
scope:
provider: aws
definition:
cond_type: "attribute"
resource_types: "taggable"
attribute: "tags.service"
operator: "exists"
This is the "all resources must be tagged with the key env" example of the official documentation with the tag key changed
to service. A second documented pattern is a supply-chain rule on modules, resource_types: [module],
attribute: source, operator: starting_with, value: terraform-aws-modules, which enforces that only the official modules
are used. More on where custom rules sit in a layered strategy is in
Policy as Code.
Checkov in CI
- name: Checkov
run: |
pip install checkov==3.3.30
checkov -d . --framework terraform --quiet --compact \
--output cli --output sarif --output-file-path console,checkov.sarif
The bridgecrewio/checkov-action is an alternative: its README shows output_format: cli,sarif with
output_file_path: console,results.sarif followed by an upload of the SARIF file, plus inputs such as directory, file,
check, skip_check, quiet, soft_fail and framework.
Source: Checkov GitHub Action — README
Trivy
Trivy from Aqua Security is a scanner for containers, filesystems, repositories, Kubernetes and
infrastructure as code. Its IaC engine began as tfsec. The tfsec repository now states that the project has been
consolidated into Trivy, that Trivy offers "the same excellent Terraform scanning engine" with more integrations, and that
tfsec "will continue to remain available for the time being" while engineering attention goes to Trivy. For new work use
Trivy; for existing tfsec users there is a migration guide, and pre-commit-terraform has marked its terraform_tfsec hook
as deprecated in favour of terraform_trivy.
Source: tfsec — tfsec to Trivy migration
Scanning
Misconfiguration scanning is the trivy config command. It scans a directory recursively, detects the type of each file
(Terraform, CloudFormation, Kubernetes, Helm, Dockerfile) and applies the relevant checks. It evaluates variables, imports
and other elements within the Terraform files. Trivy can scan HCL, JSON, a Terraform plan snapshot and the plan in JSON form.
# Install (macOS); Linux: apt repository or the release archive
brew install trivy
# Scan the working directory, high and critical findings only
trivy config --severity HIGH,CRITICAL .
# Override defaults with a tfvars file
trivy config --tf-vars dev.terraform.tfvars ./infrastructure/tf
# Plan files
terraform plan --out tfplan
trivy config tfplan
terraform show -json tfplan > tfplan.json
trivy config tfplan.json
# Misconfigurations, secrets and vulnerabilities in one pass
trivy fs --scanners vuln,misconfig,secret .
Source: Trivy — Terraform coverage
The per-file summary has this shape (documented output, shown for a directory with mixed types):
main.tf (terraform)
===================
Tests: 23 (SUCCESSES: 14, FAILURES: 9)
Failures: 9 (HIGH: 6, CRITICAL: 1)
...
| Flag | Effect |
|---|---|
|
Output format, for example |
|
Write the report to a file |
|
Show only the listed severities |
|
Exit code to use when issues are found (use |
|
Paths to |
|
Mark findings from downloaded modules as ignored |
|
Enable a subset of misconfiguration scanners, for example |
|
Directories or files (globs) to skip |
|
Ignore file. The default is |
|
A Rego file that decides which findings to ignore |
|
Load custom Rego checks from files or directories |
|
Package prefixes to evaluate for custom checks (default |
|
Include passed checks in the report |
Four behaviours deserve attention.
-
Remote modules are downloaded. Downloads are on by default and cannot be turned off from the CLI. Scanning an untrusted configuration can therefore cause network requests to hosts chosen by its author. To avoid downloads, run
terraform initin an environment you control and include the.terraform/modulesdirectory; Trivy reuses that cache. Private registries authenticate throughTF_TOKEN_environment variables, not through.terraformrc. -
Static analysis only. Trivy does not call providers, so values from
datablocks or computed attributes are treated as unknown, which can cause false positives or negatives. -
The scan root is a trust boundary. Functions such as
file()cannot read outside the scanned directory; scan from the repository root when configurations share files. -
Plan JSON and
for_each. A plan does not carry complete references for expressions usingeachorcount, so some cross-resource checks cannot be evaluated from the plan alone.
Source: Trivy — Terraform coverage
Ignoring findings
#trivy:ignore:AVD-AWS-0107
resource "aws_security_group_rule" "metrics_scrape" {
type = "ingress"
from_port = 9100
to_port = 9100
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
The inline comment form # trivy:ignore is mentioned by the Trivy docs, and the exact AVD-... syntax shown here is the one
in the pre-commit-terraform README. Ignores by ID for the whole project go in .trivyignore, one ID per line with comments,
and an optional exp:yyyy-mm-dd expiry; the experimental .trivyignore.yaml supports paths, expired_at and a
statement explaining the decision.
# .trivyignore
# Accept the risk until 2027-01-01
AVD-AWS-0107 exp:2027-01-01
Source: Trivy — Filtering
Custom Trivy checks are written in Rego and loaded with --config-check; they are covered next to the other policy engines
in Policy as Code.
Trivy in CI
- name: Trivy IaC scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'config'
hide-progress: true
format: 'sarif'
output: 'trivy-results.sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
- name: Upload Trivy results to the GitHub Security tab
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'
Source: Trivy Action — README
If the configuration uses private modules, the action README shows configuring Git credentials in a step before the scan. Pin third-party actions to a full commit SHA in security-sensitive repositories, and let Dependabot keep the pin current.
Checkov or Trivy
Both tools read Terraform statically and ship a large catalogue; the choice is mostly about ecosystem. Checkov’s strength is its graph-based YAML policies and Python checks, and its baseline feature; Trivy’s is a single binary that also scans container images, secrets and dependencies, plus Rego for custom rules. Many teams run both in CI because their catalogues overlap but do not coincide, and accept some duplicate findings; the ignore mechanisms are separate, so a deliberate exception must be recorded in both. Neither can see runtime state or drift, and neither replaces least-privilege IAM design or the policies that HCP Terraform evaluates at plan time.
Infracost
Infracost estimates the monthly cost of Terraform (and Terragrunt, CloudFormation and AWS CDK) changes before they are applied, so the price of a pull request is a review comment rather than a surprise on the bill. The project describes itself as supporting over 1,100 resources across AWS, Azure and Google Cloud.
Infracost went through a major rewrite. The current CLI (the infracost/cli repository, observed v2.18.2) is installed with
brew install infracost or the project’s install script, authenticates with infracost auth login (or the
INFRACOST_CLI_AUTHENTICATION_TOKEN variable in non-interactive environments), and is used with infracost setup,
infracost scan and infracost inspect --summary. infracost scan takes a directory (the current one by default) and
auto-detects the IaC type, or reads projects from an infracost.yml. The older 0.10 line used infracost breakdown,
infracost diff and infracost comment, and some third-party material, including the pre-commit-terraform
infracost_breakdown hook, still shows those commands; check which major your installation is before copying a recipe.
brew install infracost
infracost --version
infracost setup # free API key, editor and CI wiring
infracost scan # cost breakdown, FinOps policy violations, tagging issues
infracost inspect --summary
Source: Infracost Docs — Get started
In a pull request workflow the documented pattern checks out the base and head commits side by side and runs a diff inside
the Infracost CI image. The token is a repository secret, and GITHUB_TOKEN must be mapped into the environment or diff
exits before scanning.
name: Infracost
on: [pull_request]
jobs:
infracost:
runs-on: ubuntu-latest
container: ghcr.io/infracost/ci:2
permissions:
contents: read
pull-requests: write
env:
INFRACOST_CLI_AUTHENTICATION_TOKEN: ${{ secrets.INFRACOST_CLI_AUTHENTICATION_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
path: head
- run: infracost-ci diff --base-path base --head-path head
Source: Infracost Docs — GitHub Actions
A red check does not block merging by itself; to block, mark the job as a required status check in the branch ruleset. The
infracost/actions repository offers diff and scan actions as an alternative (infracost/actions/diff@v5 and
infracost/actions/scan@v5), and recommends the Infracost GitHub App where an organisation allows it. Infracost has free and
paid plans (budgets, tagging policies and guardrails live in Infracost Cloud); consult the pricing page before relying on
a feature in a pipeline. The estimate covers resources the tool knows; usage-based resources such as requests or data
transfer need a usage file, and the figure is an estimate, not a bill.
Keeping versions current: Dependabot and Renovate
Terraform configurations depend on providers, modules, the Terraform or OpenTofu version, GitHub Actions and, with
.terraform.lock.hcl, on exact provider hashes. Static tools cannot tell you that a provider has a security fix; a bot that
opens pull requests can.
Dependabot
Dependabot is configured in .github/dependabot.yml. The package-ecosystem value for Terraform is terraform, and
opentofu exists for OpenTofu. The options reference lists directory (one location, no globbing) and directories (a list
that supports *), schedule.interval values daily, weekly, monthly, quarterly, semiannually, yearly or
cron, and cooldown: for Terraform and OpenTofu only default-days is supported among the day settings, and when no
cooldown is configured Dependabot applies a default period of three days to version updates. The options reference’s
table of supported versions lists Terraform as ">= 0.13, ⇐ 1.15.x" at the time of reading, which is behind Terraform 1.17;
verify that your Terraform language features parse before relying on it.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "terraform"
directories:
- "/environments/*"
- "/modules/*"
schedule:
interval: "weekly"
cooldown:
default-days: 7
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Renovate
Renovate’s Terraform manager matches **/*.tf and **/*.tofu by default and understands the dependency types module,
provider, required_provider and required_version, as well as helm_release, tfe_workspace and the Docker and
Kubernetes provider resources. It maintains .terraform.lock.hcl itself (for lockFileMaintenance) without calling the
Terraform binary. It defaults to the Terraform registry; to prefer the OpenTofu registry, add a package rule.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"lockFileMaintenance": { "enabled": true },
"packageRules": [
{
"matchDatasources": ["terraform-provider", "terraform-module"],
"registryUrls": ["https://registry.opentofu.org"]
}
]
}
The package rule is only for repositories that use OpenTofu; remove it for Terraform. Whichever bot you choose, run the
whole quality pipeline on its pull requests: a provider bump is exactly the change that terraform validate, terraform
test and the scanners should judge, and for production a terraform plan on the update is the real review.
Provider upgrade notes are collected in What Changed and Migration.
A Makefile that ties it together
A Makefile gives the team one vocabulary (make check) that works the same locally and in CI, and documents which tools
the repository expects. This one is original to Bookshelf and illustrative. Recipe lines must start with a tab.
.DEFAULT_GOAL := help
TF ?= terraform # set TF=tofu to use OpenTofu
MODULES := $(shell find modules -name main.tf -exec dirname {} \;)
ROOTS := $(shell find environments -mindepth 1 -maxdepth 1 -type d)
.PHONY: help fmt fmt-check validate lint docs docs-check scan test plan-json check
help: ## List targets
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | sed 's/:.*## /\t/'
fmt: ## Rewrite files to the canonical format
$(TF) fmt -recursive
fmt-check: ## Fail if any file is not formatted
$(TF) fmt -check -recursive -diff
validate: ## Validate every module and environment without a backend
@set -e; for d in $(MODULES) $(ROOTS); do \
echo "== validate $$d"; \
$(TF) -chdir=$$d init -backend=false -input=false >/dev/null; \
$(TF) -chdir=$$d validate; \
done
lint: ## TFLint over the whole tree
tflint --init
tflint --recursive --call-module-type=local --minimum-failure-severity=warning
docs: ## Regenerate module READMEs
@for d in $(MODULES); do terraform-docs $$d; done
docs-check: ## Fail if a module README is out of date
@set -e; for d in $(MODULES); do terraform-docs --output-check $$d; done
scan: ## Security scan with Checkov and Trivy
checkov -d . --framework terraform --quiet --compact
trivy config --severity HIGH,CRITICAL --exit-code 1 .
test: ## Native tests of every module that has them
@set -e; for d in $(MODULES); do \
if ls $$d/tests/*.tftest.hcl >/dev/null 2>&1; then $(TF) -chdir=$$d init -backend=false -input=false >/dev/null; $(TF) -chdir=$$d test; fi; \
done
plan-json: ## Plan an environment and keep the JSON for scanners (ENV=dev)
$(TF) -chdir=environments/$(ENV) plan -input=false -out=tfplan.binary
$(TF) -chdir=environments/$(ENV) show -json tfplan.binary > environments/$(ENV)/tfplan.json
check: fmt-check validate lint docs-check scan test ## Everything CI runs
Source: Terraform Docs — terraform fmt
The -chdir global option, the -backend=false and -input=false flags, and the tfplan.json pipeline were described
earlier on this page and in CLI Essentials. Keep the plan JSON out of version
control and out of build artifacts that outlive the job, because it can contain secret values; add tfplan* to .gitignore.
A pull request pipeline
The GitHub Actions job below runs the same ladder. It uses hashicorp/setup-terraform (current major v4 at the time of
writing, where older material says v3), terraform-linters/setup-tflint (v6) and, for OpenTofu, opentofu/setup-opentofu
(v2). The fuller pipeline, with plans commented on the pull request, approvals and OIDC credentials, is built in
CI/CD and Automation.
name: quality
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
security-events: write # upload SARIF
jobs:
static:
runs-on: ubuntu-latest
strategy:
matrix:
engine: [terraform, tofu]
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
if: matrix.engine == 'terraform'
with:
terraform_wrapper: false
- uses: opentofu/setup-opentofu@v2
if: matrix.engine == 'tofu'
with:
tofu_wrapper: false
- uses: terraform-linters/setup-tflint@v6
- name: Format
run: ${{ matrix.engine }} fmt -check -recursive -diff
- name: Validate
run: |
for d in $(find modules environments -name main.tf -exec dirname {} \;); do
${{ matrix.engine }} -chdir="$d" init -backend=false -input=false
${{ matrix.engine }} -chdir="$d" validate
done
- name: TFLint
run: |
tflint --init
tflint --recursive --minimum-failure-severity=warning --format=compact
env:
GITHUB_TOKEN: ${{ github.token }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Checkov
run: |
pip install checkov==3.3.30
checkov -d . --framework terraform --quiet --compact
- name: Trivy
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: 'config'
format: 'sarif'
output: 'trivy-results.sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'
The GITHUB_TOKEN in the TFLint step avoids anonymous GitHub API rate limits when tflint --init downloads ruleset
plugins. terraform_wrapper: false avoids the wrapper that captures standard output, which interferes with commands that
print machine-readable output. The tofu_wrapper input name for opentofu/setup-opentofu follows that action’s
convention and should be confirmed on its README before use. In the matrix, opentofu/setup-opentofu and the tofu
binary run the same fmt and validate commands, which is a cheap way to detect accidental use of a Terraform-only
feature.
Rolling the tools out
Adopting everything on a mature repository at once produces a wall of findings and a team that learns to ignore them. A sequence that works:
-
Start with
terraform fmt -checkandterraform validate, which have no false positives. -
Add TFLint with the
recommendedpreset, fix or annotate what it finds, then enable the optional rules the team agrees on. -
Add Checkov or Trivy in report-only mode (
--soft-fail, or--exit-code 0) and publish SARIF so findings show up in the Security tab, then create a baseline, then switch to blocking on new findings only. -
Add terraform-docs in check mode once the module READMEs have been generated once.
-
Add Infracost to pull requests that touch environment directories, not to module-only changes.
-
Turn on Dependabot or Renovate last, because it generates the most pull requests.
Every suppression should carry a reason and, where the tool supports it, an expiry (exp: in .trivyignore). Review the
suppressions periodically: a skipped check that nobody remembers is a hole in the net.
Pitfalls
-
Formatting a generated file. Files produced by terraform-docs or by code generators must be generated before
fmt -checkruns, or the check fails on every run. -
Validating without init.
terraform validateneeds the providers. A fresh clone in CI must runterraform init -backend=falsefirst. -
Scanning only the root. A scan of the current directory sees a
moduleblock, not the resources behind it. Use--download-external-modules true(Checkov), runterraform initand keep.terraform/modules(Trivy), or--call-module-type=all(TFLint). -
Trusting a green scan. The scanners work on configuration. They cannot see manual console changes, secrets that are injected at run time, or the behaviour of the cloud account. Drift detection and policy at plan time cover those gaps.
-
Secrets in plan JSON. The plan used for scanning can contain sensitive values. Keep it inside the job.
-
Network access during scans. Checkov and Trivy download modules, and TFLint downloads plugins. Allow outbound access only to the hosts you expect, and never scan untrusted configurations on a runner with access to internal services.
-
Version drift between laptop and CI. Pin the tools in
.pre-commit-config.yaml, in the workflow and (for TFLint) in.tflint.hcl, or two environments will disagree about the same file. -
Bot noise. A bot opening twenty provider-bump pull requests a week trains people to merge without reading. Group, schedule and use a cooldown.
What changed recently
-
tfsec to Trivy. The tfsec project consolidated its scanning into Trivy; pre-commit-terraform deprecated the
terraform_tfsechook in favour ofterraform_trivy. Older books and tutorials still show tfsec. -
Checkov hook IDs. In pre-commit-terraform the
checkovhook is deprecated andterraform_checkovis the one to use. -
TFLint module inspection. The
--moduleflag became--call-module-type, withall,local(default) andnone. -
TFLint bundled ruleset. The Terraform language ruleset is bundled in the TFLint binary and enabled without a
pluginblock, with therecommendedpreset. -
Infracost 2. The CLI was rewritten (
infracost scan,infracost setup, token-based authentication); the 0.10breakdown/diff/commentworkflow is the legacy one. -
setup-terraform v4. The action’s README now uses
hashicorp/setup-terraform@v4; older posts and tutorials use v3. -
terraform-docs markers. pre-commit-terraform uses the terraform-docs markers
BEGIN_TF_DOCSandEND_TF_DOCSby default since its version 1.93, replacing its own older markers.
The two books this section draws on predate several of these: they teach tfsec rather than Trivy, and cover the TFLint and Checkov command lines of their time. Where they differ from the official documentation cited here, the official documentation wins.