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

terraform fmt

Layout and alignment drift

Nothing

Editor, pre-commit, CI

terraform validate

Syntax errors, unknown arguments, wrong types, bad references

terraform init (no backend needed)

Pre-commit, CI

TFLint

Unused declarations, missing versions, deprecated syntax, provider-specific mistakes (invalid instance type)

The TFLint binary and a .tflint.hcl

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

flowchart LR A[Edit .tf files] --> B[terraform fmt] B --> C[terraform validate] C --> D[TFLint] D --> E[terraform-docs] E --> F[Commit] F --> G[Pull request CI] G --> H[Checkov and Trivy] G --> I[terraform test] G --> J[terraform plan] J --> K[Infracost diff] H --> L[Review and merge] I --> L K --> L

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

-list=false

Do not list the files that were (or would be) changed

-diff

Show the formatting changes as a diff

-write=false

Do not overwrite files. Implied by -check and by reading standard input

-check

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

-recursive

Also process subdirectories. By default only the given or current directory is processed

-no-color

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.

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

-var 'NAME=VALUE'

Set one input variable of the root module. Repeatable

-var-file=FILENAME

Load input variables from a .tfvars file. Repeatable

-json

Machine-readable JSON output for editors and automation. Always disables colour

-no-color

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

--init

Install the plugins declared in the configuration file

--recursive

Run in each directory recursively (a flat module layout needs this)

--chdir=DIR

Switch to another working directory before running

--config=FILE

Config file name. The default is .tflint.hcl

--format

Output format: default, json, checkstyle, junit, compact or sarif

--fix

Fix auto-fixable issues

--minimum-failure-severity

Lowest severity that causes a non-zero exit: error, warning or notice

--call-module-type

Which module calls to inspect: all, local (the default) or none

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
}

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)

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

terraform_deprecated_index

Legacy dot index syntax

yes

terraform_deprecated_interpolation

Deprecated 0.11-style interpolation

yes

terraform_deprecated_lookup

lookup() with only two arguments

yes

terraform_empty_list_equality

Comparing with [] to test for an empty collection

yes

terraform_json_syntax

JSON files without a root object

yes

terraform_map_duplicate_keys

Duplicate keys in a map object

yes

terraform_module_pinned_source

A Git or Mercurial module source that is not pinned to a version

yes

terraform_module_version

A registry module without a version

yes

terraform_required_providers

Providers without version constraints in required_providers

yes

terraform_required_version

A terraform block without required_version

yes

terraform_typed_variables

variable declarations without a type

yes

terraform_unused_declarations

Variables, data sources and locals that are never used

yes

terraform_workspace_remote

terraform.workspace with the remote backend on Terraform 1.0.x

yes

terraform_comment_syntax

// comments instead of #

no

terraform_documented_outputs

output without a description

no

terraform_documented_variables

variable without a description

no

terraform_module_shallow_clone

Pinned Git modules not using shallow cloning

no

terraform_naming_convention

Names that break the configured convention

no

terraform_standard_module_structure

A module that departs from the standard module structure

no

terraform_unused_required_providers

A required_providers entry that is never used

no

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

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"
}

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 {

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

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

--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

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 to checkov and terraform_checkov, which are installed from PyPI.

  • Plugin cache. If you set TF_PLUGIN_CACHE_DIR, the README recommends --hook-config=--retry-once-with-cleanup=true or --hook-config=--parallelism-limit=1 on terraform_validate, to avoid races when several terraform init runs 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

The flags worth knowing:

Flag Effect

-d, --directory

IaC root directory. Cannot be combined with --file

-f, --file

A file to scan. Cannot be combined with --directory

--framework

Restrict the run to one framework, such as terraform

-c, --check / --skip-check

Run only (or skip) the listed check IDs, with wildcards. Severities need the Prisma Cloud platform integration and an API key

--skip-path

Path to skip, as a regular expression relative to the working directory

-o, --output

Output format; cli, json, junitxml, sarif and github_failed_only are documented

--output-file-path

Where to save the chosen formats, for example console,results.sarif to print one and save the other

--quiet / --compact

Show only failed checks / hide code blocks

-s, --soft-fail

Always exit with 0

--soft-fail-on / --hard-fail-on

Exit status decided per check ID or severity

--var-file

Extra variable files, currently supported for Terraform source

--download-external-modules

Download remote Terraform modules so their content is scanned

--external-checks-dir

Directory with custom checks. Repeatable

--config-file / --create-config / --show-config

Use, write or print the configuration

--create-baseline / --baseline

Record current findings in .checkov.baseline and report only new failures

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.

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

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.

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.

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 .

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

-f, --format

Output format, for example json or sarif

-o, --output

Write the report to a file

-s, --severity

Show only the listed severities

--exit-code

Exit code to use when issues are found (use 1 in CI)

--tf-vars

Paths to tfvars files overriding the defaults

--tf-exclude-downloaded-modules

Mark findings from downloaded modules as ignored

--misconfig-scanners

Enable a subset of misconfiguration scanners, for example terraform,dockerfile

--skip-dirs / --skip-files

Directories or files (globs) to skip

--ignorefile

Ignore file. The default is .trivyignore

--ignore-policy

A Rego file that decides which findings to ignore

--config-check

Load custom Rego checks from files or directories

--check-namespaces

Package prefixes to evaluate for custom checks (default builtin.*)

--include-non-failures

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 init in an environment you control and include the .terraform/modules directory; Trivy reuses that cache. Private registries authenticate through TF_TOKEN_ environment variables, not through .terraformrc.

  • Static analysis only. Trivy does not call providers, so values from data blocks 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 using each or count, so some cross-resource checks cannot be evaluated from the plan alone.

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

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'

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

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

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

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 -check and terraform validate, which have no false positives.

  • Add TFLint with the recommended preset, 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 -check runs, or the check fails on every run.

  • Validating without init. terraform validate needs the providers. A fresh clone in CI must run terraform init -backend=false first.

  • Scanning only the root. A scan of the current directory sees a module block, not the resources behind it. Use --download-external-modules true (Checkov), run terraform init and 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_tfsec hook in favour of terraform_trivy. Older books and tutorials still show tfsec.

  • Checkov hook IDs. In pre-commit-terraform the checkov hook is deprecated and terraform_checkov is the one to use.

  • TFLint module inspection. The --module flag became --call-module-type, with all, local (default) and none.

  • TFLint bundled ruleset. The Terraform language ruleset is bundled in the TFLint binary and enabled without a plugin block, with the recommended preset.

  • Infracost 2. The CLI was rewritten (infracost scan, infracost setup, token-based authentication); the 0.10 breakdown / diff / comment workflow 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_DOCS and END_TF_DOCS by 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.