Skip to content

Lint Rules

llms.txt lists every page for an agent

The Terraform lexicon ships one source-level lint rule and forty-four post-synth checks, plus TF023, which runs only in chant audit and is documented here beside the rest of the family. See Lint Rules for the generated, always-current table of the checks that run in a build; this page is the reasoning, the fix and the credit behind each entry.

TF001: root module declares no remote backend

Section titled “TF001: root module declares no remote backend”

A terraform block with no backend "<type>" and no cloud {}, or one whose only backend is backend "local".

Why it matters. No backend falls back to the local one, which puts terraform.tfstate in the working directory. That file holds every attribute of every managed resource, secrets included, and it is unshared, unlocked and unversioned. The first apply from a second machine or a CI runner starts from an empty state and proposes to create the whole estate again. backend "local" asks for the same file by name and gets the same three problems, so the check reads the backend block’s type label rather than its presence (#2218) and reports both, naming which one it found. cloud {} counts as remote: the state is held by Terraform Cloud or HCP, not by the machine that plans.

The fix. Add a backend "<type>" block naming a remote type (s3, gcs, azurerm, http) or a cloud {} block to the root module’s terraform block, then run terraform init -migrate-state.

A root that keeps local state on purpose says so instead of switching. Put a # chant-ignore-block: TF001 on the line above its terraform block, with the reason on the line above that, the way the shipped examples do:

# The state stays on local disk on purpose: this example is run by hand from a
# checkout, and a remote backend would make a first apply need credentials and
# a bucket nobody has.
# chant-ignore-block: TF001
terraform {
backend "local" {
path = "terraform.tfstate"
}
}

Prior art. No tool in the survey covers this; see audit-lineage.ts’s header comment for where it looked. Checkov’s CKV_TF_* family has no rule for a missing backend block, and tflint-ruleset-terraform’s own index has none either. Its closest neighbors check something else entirely (unconstrained provider versions, a terraform.workspace compatibility concern), not the absence of a backend.

A root with no terraform block at all is not flagged: it declares no version constraints either, and that is a different finding.

terraform {
required_version = ">= 1.5.0"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
provider "null" {}
resource "null_resource" "first" {
triggers = {
name = "first"
}
}
resource "null_resource" "second" {
triggers = {
name = "second"
}
}

The second positive, the root that names the local backend instead of falling back into it:

terraform {
required_version = ">= 1.5.0"
backend "local" {
path = "terraform.tfstate"
}
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}

TF002: provider implied by the root has no required_providers entry

Section titled “TF002: provider implied by the root has no required_providers entry”

A provider "<name>" {} block, or a resource/data type prefix (aws_instance implies aws), with no matching entry in the terraform block’s required_providers, or an entry that is missing source or version.

Why it matters. With no version constraint, terraform init installs whatever release of the provider is current the day someone first runs it, and every teammate and CI runner after that installs whatever is current the day they first run it. A provider release that changes a resource’s defaults or removes an attribute becomes a plan diff nobody asked for, instead of a version-check failure caught before it can run.

The fix. Add the provider to required_providers, with both source ("hashicorp/aws") and version ("~> 5.0") set.

Prior art. tflint’s terraform_required_providers checks the identical condition (equivalent): every implied provider needs a required_providers entry with source and version. HashiCorp’s own reference Sentinel policy require-all-providers-have-version-constraint ships the same check (equivalent). AWS’s prescriptive guidance for Terraform names it directly: “CI must fail builds when a provider version constraint is undefined” (equivalent).

terraform {
required_version = ">= 1.5.0"
}
resource "google_compute_instance" "vm" {
name = "vm"
}

TF003: root module’s terraform block has no required_version

Section titled “TF003: root module’s terraform block has no required_version”

A terraform block with no required_version attribute.

Why it matters. Sits beside TF002 on the same block: without required_version, a root can be applied by whatever Terraform binary happens to be on the runner’s PATH. A binary upgrade that changes behavior surfaces as a surprise plan diff instead of a version-check failure before anything runs.

The fix. Add required_version = ">= <lowest supported version>" to the terraform block.

Prior art. tflint’s terraform_required_version checks the identical condition (equivalent). HashiCorp’s style guide names version pinning directly, including required_version for the binary (overlaps: the guide’s advice covers provider and module pinning in the same breath, not just this one attribute). HashiCorp’s reference Sentinel policy restrict-terraform-versions checks an allowed range once required_version is set, a narrower question than presence (overlaps).

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

TF004: registry-sourced module block has no version

Section titled “TF004: registry-sourced module block has no version”

A module block whose source parses as a Terraform registry address (namespace/name/target-system, optionally hostname-prefixed) with no version.

Why it matters. With no version constraint, terraform init resolves to the newest release of the module every time it runs. A registry module that changes what it provisions between releases becomes an unreviewed change the next time anyone runs init, not a diff caught by a pinned version bump.

The fix. Add a version constraint to the module block (e.g. ~> 5.0).

Prior art. tflint’s terraform_module_version checks the identical condition (equivalent). checkov’s CKV_TF_2 checks the same thing for a registry source (equivalent).

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
}

TF005: git/hg module source is unpinned, or pinned to a mutable ref

Section titled “TF005: git/hg module source is unpinned, or pinned to a mutable ref”

A module block whose source is a git or hg source (an explicit git::/hg:: prefix, the github.com/bitbucket.org shorthands, an scp-style address, or anything ending in .git) with no ?ref=, or a ?ref= that is neither a semver-shaped tag nor a full 40-hex commit SHA.

Why it matters. An unpinned git source resolves to whatever the branch’s tip happens to be the moment terraform init runs, which can differ between two runs an hour apart. A ?ref= pinned to a branch name (main, master, develop, trunk, or any other moving target) has exactly the same problem, just with an extra step: it looks pinned until the branch moves again.

The fix. Pin ?ref= to a tag (v1.2.0) or a full 40-character commit SHA, never a branch name.

Prior art. tflint’s terraform_module_pinned_source checks the identical condition in its default flexible style, rejecting the same four branch names (equivalent); its semver style additionally requires the ref to parse as a semantic version, which this rule’s own default already does unconditionally, since chant has no per-project rule configuration yet. checkov’s CKV_TF_1 also requires a git module source to carry a ?ref=, but its lenient fallback (CKV_TF_2) accepts any ref matching \d\.\d, so v1.2-dev passes checkov and fails here (extends: a strict superset of checkov’s check). KICS’s 3a81fc06-566f-492a-91dd-7448e409e2cd checks only the git:: prefix, missing the github.com/bitbucket.org shorthands and scp-style sources this rule also covers (overlaps).

A local (./, ../) source is never flagged: it can’t be pinned to a ref at all, the same reasoning checkov’s own module-pinning checks use when they report UNKNOWN rather than a failure for one.

module "vpc" {
source = "git::https://example.com/vpc.git"
}

TF006: a sensitive variable declares a default

Section titled “TF006: a sensitive variable declares a default”

A variable marked sensitive = true that also has a default, whatever the default’s value is.

Why it matters. sensitive = true says the value must not be seen, and a default is a value committed to the repository, so the two together contradict each other: the secret is already in git, and the flag only hides it from plan output, which was never where the exposure was. An empty or null default is the same finding for a second reason, because it turns a required secret into an optional one and every environment that forgets to pass the variable silently gets the committed value.

The fix. Delete the default. Pass the value in at apply time, from a tfvars file kept out of git, a TF_VAR_ environment variable, or a workspace variable.

Prior art. Azure’s AVM ruleset ships avm_terraform_sensitive_variable_default_disallowed (equivalent). The official tflint ruleset has no equivalent, which is part of why it is worth shipping.

variable "deploy_token" {
type = string
description = "Token the deploy job authenticates with"
sensitive = true
default = "carried-over-from-the-old-staging-account"
}

TF007: a secret-shaped literal in a variable default or a locals value

Section titled “TF007: a secret-shaped literal in a variable default or a locals value”

A variable default or a locals value that is a committed literal and looks like a credential, by either heuristic in src/lint/secret-shape.ts: the name reads like a credential (password, passwd, secret, token, api_key, private_key, access_key, case-insensitive and word-bounded, with the _file, _path, _arn, _id and _name locator suffixes excluded), or the value matches a credential shape (an AWS key id, a JWT, a PEM header, a high-entropy token). Placeholders such as changeme and CHANGE_ME are excluded, and so is anything that is a reference rather than a constant.

Why it matters. A committed credential is in git history, which no later edit removes, and Terraform copies it into state and into plan output on every run. This is the check with the largest gap behind it in the survey: tfsec shipped it, trivy dropped it when it absorbed tfsec, and no maintained tool covers it today.

The fix. Remove the literal, mark the variable sensitive = true, and read the value from a secret manager data source or an input at apply time. Then rotate the credential, because it is already public to everyone who can clone the repository.

Prior art. tfsec’s general-secrets-sensitive-in-variable and general-secrets-sensitive-in-local, both equivalent. Both are cited at the v0.61.3 tag: tfsec’s docs site 404s above that version, and the checks did not survive the move into trivy, so the pinned repository path is the only stable citation. Neither trivy-checks nor KICS, checkov, tflint or any of its rulesets has a replacement.

variable "db_password" {
type = string
description = "Password for the application database user"
default = "hunter2-prod-db"
}
locals {
ci_token = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
}

TF008: a provider block configures a credential inline

Section titled “TF008: a provider block configures a credential inline”

A provider block whose body sets a credential attribute to a literal: credentials, client_certificate, client_key, shared_credentials_file, or any attribute whose name matches the credential-name heuristic (access_key, secret_key, token, password, client_secret, and the rest). A value that is a reference, a placeholder, or a path to a key file is not a finding.

Why it matters. A provider’s credential is used by every resource that provider manages, so it is the highest-value secret in the root, and it is readable by everyone who can clone the repository. Every provider already accepts the same credential from the environment, a shared config file, or a role assumption.

The fix. Delete the credential from the provider block and let the provider find it the way it is designed to. Then rotate it.

Prior art. The most corroborated rule in the survey, and the only Terraform-language-shaped check some of these tools ship at all. Checkov’s CKV_AWS_41 and its six credentials.py siblings, KICS’s d7b9d850-3e06-4a75-852f-c46c2e92240b, and semgrep’s aws-provider-static-credentials are all credited as overlaps: each is written per provider, while this rule is written against attribute names and so covers every provider. Snyk ships the same idea as SNYK-CC-TF-74, cited here by name only: Snyk IaC is a product rather than an open rule source, so it is not in chant’s prior-art registry. Semgrep is registered without an SPDX id, because its rules ship under the proprietary Semgrep Rules License v1.0 even though its engine is LGPL-2.1.

provider "aws" {
region = "eu-west-1"
access_key = "AKIA2E0XYZ4PQRSTUVWX"
secret_key = "8Jd0hVn2XqB7sLtR4mYcE1zPgWfKa5UiQ3oNrTbA"
}

TF009: a credential-named variable is not marked sensitive

Section titled “TF009: a credential-named variable is not marked sensitive”

A variable whose name matches the credential-name heuristic and that does not set sensitive = true. Only the name is read: a variable with no default is still reported, because the finding is about where the value goes at run time.

Why it matters. Without the flag Terraform echoes the value in plan and apply output, which puts it in CI logs, in the comment a plan bot posts on a pull request, and in every terminal scrollback. The flag does not keep the value out of state, where it is stored in plaintext regardless, which is why the remediation says both things.

The fix. Add sensitive = true, and keep the state remote and encrypted.

Prior art. No tool in the survey checks this. The authority is HashiCorp’s own style guide, whose variables section says: “For sensitive variables, such as passwords and private keys, set the sensitive parameter to true.” That section is credited as lineage as well as authority, the way the other specification entries in the registry are: it is where the rule was written down first.

variable "db_password" {
type = string
description = "Password for the application database user"
}

A variable block with no type, or with a type that trims to empty.

Why it matters. Without a type constraint Terraform accepts whatever it is given and infers the type from the value, so a variable meant to hold a list arrives as a string from a TF_VAR_ environment variable, or a number arrives as "3" from a tfvars file. The failure then surfaces deep inside a resource argument rather than at the module boundary where the wrong value came in.

The fix. Declare the constraint: string, number, bool, list(string), an object({...}).

Prior art. tflint’s terraform_typed_variables, a member of its recommended preset, and KICS’s fc5109bf-01fd-49fb-8bde-4492b543c34a, both equivalent. KICS also fails a type that trims to empty, which is why this rule treats a blank one as absent.

variable "instance_count" {
description = "How many web instances to run"
default = 3
}

A variable block with no description, or a blank one. Report-only.

Why it matters. A module’s variables are its API. The description is what terraform-docs renders into the README, what the HCP Terraform variable UI shows next to the input box, and what a caller reads before deciding what to pass. Without it the only documentation is the variable’s name.

The fix. Add a description saying what the value is for and what a valid one looks like.

Prior art. Three, all equivalent: tflint’s terraform_documented_variables, KICS’s 2a153952-2544-4687-bcc9-cc8fea814a9b, and HashiCorp’s reference Sentinel policy validate-variables-have-descriptions. KICS files it as INFO and tflint keeps it out of recommended, so chant tiers it report-only for the same reason.

variable "region" {
type = string
default = "eu-west-1"
}

An output block with no description, or a blank one. Report-only.

Why it matters. The other half of a module’s API. An output is what a caller wires into the next module, and the description is the only place to say what the value actually is, an id or an ARN, a full URL or a bare hostname, and whether it is stable enough to depend on.

The fix. Add a description saying what the value is and what a caller can rely on it for.

Prior art. tflint’s terraform_documented_outputs and KICS’s 59312e8a-a64e-41e7-a252-618533dd1ea8, both equivalent.

output "bucket_name" {
value = aws_s3_bucket.assets.bucket
}

TF013: lifecycle ignore_changes is set to all

Section titled “TF013: lifecycle ignore_changes is set to all”

A resource or data block whose lifecycle block sets ignore_changes to the bare keyword all. A list of named attributes is not reported.

Why it matters. all tells Terraform to stop reconciling every attribute of the resource after it is created. A manual console edit, a deleted security group rule, a downgraded instance class: none of it appears in a plan again. The masking is silent, because a plan that finds nothing looks exactly like a plan that was told to look at nothing, and it hides the resource from drift detection.

The fix. Replace all with the specific attributes that genuinely change outside Terraform, so everything else is still reconciled. Naming them is what makes the intent auditable later.

Prior art. The community Redeploy ruleset’s terraform_ignore_changes_all (equivalent). Nothing in the official tflint ruleset, KICS, checkov or trivy covers it.

resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
lifecycle {
ignore_changes = all
}
}

TF014: a child module configures a provider block

Section titled “TF014: a child module configures a provider block”

A provider block inside a module the root calls, setting anything other than alias. A provider block in the root module is where providers belong, and its contents are TF008’s business, not this rule’s.

Why it matters. A module that configures its own provider takes the choice away from whoever calls it. The caller cannot point it at another region or account, and two calls of the same module in one root cannot differ, which is the whole reason to have a module. The worse half is removal: Terraform needs a provider configuration to destroy the resources a module created, and deleting the module call deletes the configuration the destroy needs, so the root is left unable to plan until the module is temporarily put back.

The fix. Configure the provider in the root module and pass it in:

module "cdn" {
source = "./modules/cdn"
providers = {
aws = aws
aws.replica = aws.replica
}
}

A block that declares nothing but alias is a slot for the caller to fill, not a configuration, so it is never reported.

Prior art. Azure Verified Modules’ avm_terraform_provider_block_disallowed checks the same condition (equivalent). Google Cloud’s reusable-modules guidance and AWS’s prescriptive guidance on structure both state the rule directly (equivalent), which is why both are credited beside the ruleset that automates it.

This rule needs the module descent to see anything at all, so it fires on chant build, where a root’s local modules are parsed as child scopes. In chant audit a nested directory is discovered as a root module of its own, so there is no caller to make it a child, and the rule stays quiet. See Module descent and scope.

# The provider block TF014 reports: this module configures its own region, so
# its caller cannot point it anywhere else, and Terraform cannot remove the
# module without deleting the provider its own destroy needs.
provider "aws" {
region = "us-east-1"
profile = "cdn"
}
variable "bucket" {
type = string
}
resource "aws_s3_bucket" "assets" {
bucket = var.bucket
}

TF015: a child module declares a backend or cloud block

Section titled “TF015: a child module declares a backend or cloud block”

A terraform block inside a called module carrying a backend "<type>" or a cloud {} block. This is TF001’s mirror image: TF001 wants a root to declare a remote one, and this rule wants a child module to declare neither.

Why it matters. State belongs to the root module, one state for the whole tree however many modules it calls, and Terraform says so: a backend block may appear only in a root module. Depending on the version the block is ignored with a warning or the root refuses to initialize, and either way the module is making a claim about where state lives that it does not get to make. The usual cause is a directory that used to be a root and kept its terraform block when someone factored it out into a module.

The fix. Delete the backend/cloud block from the module. The rest of its terraform block is welcome to stay: required_version and required_providers are meaningful in a child module and are not reported here.

Unlike TF001, this rule does not read the backend’s type label, and the fixture below declares backend "local" on purpose. What a child module may not do is name a backend at all; Terraform’s own refusal (“A backend block can only appear in the root module”) does not read the label either.

Prior art. No tool in the survey checks this. tflint’s official ruleset has nothing about backends at all, Redeploy’s community ruleset has nothing either, and the Azure Verified Modules rules stop at the provider block TF014 credits. The one source that states the rule is a document rather than a linter, Google Cloud’s reusable-modules guidance, and it is credited as such (equivalent). See audit-lineage.ts for where the survey looked.

Like TF014, this rule reads a child scope and so fires on chant build. See Module descent and scope.

# This directory used to be a root module and kept its backend when it became
# a child. State belongs to the root that calls it, so the block below is the
# TF015 finding. `required_version` beside it is fine and is not reported.
terraform {
required_version = ">= 1.5.0"
backend "local" {
path = "cdn.tfstate"
}
}
resource "aws_s3_bucket" "assets" {
bucket = "assets"
}

TF016: an attribute value is a quoted interpolation of a single expression

Section titled “TF016: an attribute value is a quoted interpolation of a single expression”

An attribute written x = "${var.y}", the pre-0.12 style Terraform deprecated in 2019. Report-only, with a deterministic fix.

Why it matters. The quotes buy nothing and cost readability. The expression is already an expression, and wrapping it in a template makes every value in the file look like a string whether it is one or not, which hides the real type of an argument that takes a list or a bool.

The fix. Drop the quotes and the ${}. chant audit renders the patch in its quick-wins section, because the edit is mechanical and touches only the flagged line.

Prior art. tflint’s terraform_deprecated_interpolation, credited as an overlap rather than an equivalent for two reasons. tflint also reports the deprecated interpolation in an object KEY ("${var.k}" = value); that is a different line shape whose fix is not a simple unquote, so this rule leaves it alone rather than emit a diff it cannot justify. And chant’s version reads the block’s source text rather than its parsed body, which is the one rule in the family that has to: @cdktn/hcl2json renders x = var.y and x = "${var.y}" as the same string, so a check over the parse alone would report every reference in the root. The quotes only survive in the text.

variable "assets_bucket" {
type = string
description = "Name of the bucket the site is served from"
}
resource "aws_s3_bucket" "assets" {
bucket = "${var.assets_bucket}"
}
output "assets_bucket_arn" {
description = "ARN of the assets bucket"
value = "${aws_s3_bucket.assets.arn}"
}

A module call that carries the depends_on meta-argument. Report-only.

Why it matters. depends_on on a module applies to every resource inside it, including the ones that had no reason to wait, so the whole module serializes behind the named dependency and a plan that could have run in parallel does not. The ordering is also invisible from inside the module, where the resource that actually needed it lives.

The fix. Remove depends_on and pass an attribute of the dependency into a module input. Terraform derives the same edge from the reference, only narrower and self-documenting.

Prior art. Redeploy’s terraform_module_depends_on (equivalent).

module "cdn" {
source = "./modules/cdn"
bucket = aws_s3_bucket.assets.bucket
depends_on = [aws_s3_bucket.assets]
}

TF018: an output returns a whole resource or data source

Section titled “TF018: an output returns a whole resource or data source”

An output whose value is a bare type.name reference to a managed resource, or data.type.name for a data source, with no attribute selected. Report-only. A bare module.x is not reported: a module’s outputs are already a curated surface, which is the property this rule asks for.

Why it matters. Exporting the whole resource exports every attribute the provider schema happens to have, which is a much larger promise than the module meant to make. Consumers reach into whatever they find, the module can no longer change any of it, and a provider upgrade that adds or renames an attribute moves the module’s public interface without anyone editing it. Sensitive attributes ride along too.

The fix. Return the attribute the caller needs: .id, .arn, .endpoint.

Prior art. Redeploy’s terraform_output_resource (equivalent).

output "web_instance" {
description = "The web instance"
value = aws_instance.web
}

TF019: a meta-argument is explicitly set to its default of false

Section titled “TF019: a meta-argument is explicitly set to its default of false”

sensitive or ephemeral on a variable or an output, or prevent_destroy or create_before_destroy inside a lifecycle block, written as a literal false. Report-only, with a deterministic fix. An expression such as prevent_destroy = var.protect is a real decision and is not reported.

Why it matters. All four default to false, so writing it changes nothing and reads as though someone considered the setting and turned it off. That is exactly the impression a reviewer should not get from a variable called password.

The fix. Delete the line. This is the first TF rule with a mechanical fix, so chant audit renders the deletion as a diff in its quick-wins section, the same way it does for a merge-worthy deterministic rule.

Prior art. Redeploy’s terraform_redundant_default (equivalent), which covers the same four arguments in the same contexts, also reports only a literal false, and also ships an autofix that removes the line.

variable "log_level" {
type = string
description = "Log level the application starts with"
default = "info"
sensitive = false
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
lifecycle {
prevent_destroy = false
create_before_destroy = false
}
}

TF020: a declaration nothing in its module references

Section titled “TF020: a declaration nothing in its module references”

A variable, an entry in a locals block, a data source, or an aliased provider that no other block in the same module scope refers to. Report-only.

Why it matters. An unused declaration is not a defect, which is why this never fails a build. It is still worth a finding: a variable nobody reads is usually the leftover of a refactor, and the next person to touch the file has to prove that before deleting it. An unused data source costs more than attention, since it is read from the provider’s API on every plan, with the permission that read requires, for a value nothing consumes.

The fix. Delete it, or reference it where it was meant to be used.

Scope is per module, the way Terraform’s own namespaces are: a var.region declared inside module.cdn is the child module’s variable, and a reference to var.region in the root says nothing about it. The index behind this rule is keyed the same way.

Prior art. tflint’s terraform_unused_declarations, credited as an overlap rather than an equivalent because of how the two find their references. tflint evaluates expressions; chant scans strings. hcl2json hands back an expression as a string with its ${...} wrapper intact, so chant’s index is a set of regexes over those strings, which makes it generous in one direction (a name inside a prose description reads as a reference, so the declaration is not reported) and blind in another (a variable supplied only through TF_VAR_ is not a reference at all, and a root’s variables often are exactly that). Both errors are why the rule is report-only. See hcl/references.ts for the scan and what it collects.

A provider block with no alias is never reported: it is the default configuration for its type and every resource of that type uses it without naming it. Only an aliased provider has to be asked for by name.

variable "region" {
type = string
default = "us-east-1"
}
variable "retention_days" {
type = number
validation {
condition = var.retention_days > 0
error_message = "retention_days must be positive."
}
}
locals {
common_tags = { Team = "platform" }
bucket_name = "assets"
}
data "aws_ami" "ubuntu" {
most_recent = true
}
provider "aws" {
alias = "replica"
region = "eu-west-1"
}
resource "aws_s3_bucket" "assets" {
bucket = local.bucket_name
region = var.region
}

Four findings there: var.retention_days (read only by its own validation block, which does not count as a use), local.common_tags, data.aws_ami.ubuntu and the aws.replica provider. var.region and local.bucket_name are read by the bucket and are not reported.

TF021: a count builds instance identities out of count.index

Section titled “TF021: a count builds instance identities out of count.index”

A resource whose count is a numeric literal of two or more, or length(var.x) / length(local.x), and whose body uses count.index to build an identity-bearing attribute: a name, a bucket, a key, a tags.Name. Report-only.

Why it matters. count addresses instances by position: aws_instance.web[0], [1], [2]. Remove the middle element of the list the count came from and every later instance shifts down one index, so Terraform plans to destroy and recreate all of them to move each identity onto the next position. With for_each the instances are addressed by key, and removing one element removes exactly one instance.

The fix. Use for_each over a map or a set, then terraform state mv the existing indexed instances onto their new keys so the switch itself does not recreate anything.

Prior art. The community Redeploy ruleset’s terraform_prefer_for_each (overlaps) reports every count over a collection. This rule is deliberately narrower: both conditions have to hold, plural count and an identity built from count.index, so a count.index that only picks a subnet out of a list is not reported and neither is the count = var.enabled ? 1 : 0 switch, which for_each does not replace. choudoufu’s RuleCountIndex (internal/live/lint/count_index.go) is the survey’s deepest treatment of which attributes carry an instance’s identity, and is credited the same way (overlaps). HashiCorp’s style guide states the preference itself in its dynamic resource count section (overlaps).

resource "aws_instance" "web" {
count = 3
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}"
}
}

TF022: a credential-named resource attribute holds a plaintext literal

Section titled “TF022: a credential-named resource attribute holds a plaintext literal”

An attribute of a resource or data block whose name matches the credential-name heuristic and whose value is a committed literal. The walk descends into nested blocks, so a credential inside connection {} or assume_role {} is not invisible.

Why it matters. The value is in git history, in state, and in plan output, and the blast radius is whatever the resource is: an RDS master password, an API token on a provider resource, the data of a Kubernetes secret. This is broader and noisier than TF007, which is why the name heuristic gates it rather than the value heuristic alone.

The fix. Replace the literal with a reference: a sensitive variable, or a data source that reads the value from a secret manager at apply time. Then rotate the credential.

Prior art. tfsec’s general-secrets-no-plaintext-exposure, the v1 consolidation of its three general/secrets checks, and KICS’s a88baa34-e2ad-44ea-ad6f-8cac87bc7c71, both overlaps. tfsec’s consolidated check also covers the variable and locals cases TF007 reports separately, and the KICS query is a regex sweep over every file type rather than a rule about Terraform attributes.

resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
username = "app"
password = "Pr0dDbP4ssw0rd"
}

TF023: Terraform state is committed to the repository

Section titled “TF023: Terraform state is committed to the repository”

A terraform.tfstate, a terraform.tfstate.backup, any other *.tfstate, or a .terraform/ directory, tracked in the repository being audited.

This is the one TF rule that never runs during chant build. It is not a check over a parsed root module at all: it reads the file list chant audit discovers, so it lives in core beside the other lexicon-independent families (packages/core/src/audit/terraform-state.ts, catalogued in core’s own RULE_CATALOG) and fires whether or not the terraform lexicon is installed. On a local path the .gitignore files are what separate “committed” from “merely on disk”, since almost every Terraform working tree has an ignored .terraform/ in it; a fetched repository’s file list is already exactly its tracked files. Nothing reads the contents of either: the finding is that the path is there.

Every .gitignore between the file and the scan root is read, the root one included, so infra/terraform.tfstate is not reported when infra/.gitignore ignores it, and auditing from the repository root and from infra/ agree (#2528; up to v0.80.0 only the root .gitignore was read).

Why it matters. A state file records every attribute of every managed resource in plaintext JSON, including the ones providers mark sensitive, so a committed state file is a committed secret store, readable by everyone with clone access and preserved in history after any later deletion. .terraform/ is milder, downloaded providers and vendored child modules, but it is hundreds of megabytes of machine-generated content and the local backend keeps its state there too.

The fix. Delete the file or directory from version control, add *.tfstate, *.tfstate.* and .terraform/ to .gitignore, move the state to a remote backend (see TF001), and rotate every credential the file held.

Prior art. No linter or scanner in the survey checks for committed state. HashiCorp’s style guide names the exact files in its .gitignore section, “Your terraform.tfstate state file, including terraform.tfstate.* backup state files … Your .terraform directory”, so the document is both the authority and the first written statement of the rule, and is credited as both.

# fixtures/TF023/positive/ is a directory rather than a single file: the finding
# is a path in the repository, not a block in a root module.
#
# positive/main.tf a perfectly ordinary root
# positive/terraform.tfstate committed beside it

TF024: live root declares a backend or cloud block

Section titled “TF024: live root declares a backend or cloud block”

A root that is live (terraform.binary: "choudoufu" plus a declared estate, as a live block or an estate.chdf.hcl sidecar) whose terraform block also carries a backend "<type>" or cloud {} block.

Why it matters. Under live markers there is no state to store: prior state is a projection of the tofu-estate and tofu-address tags, rebuilt from the live system every run. choudoufu refuses the combination at init, before any command runs (“Both a backend and a live configuration are present”), so the root never plans. TF001 is the same block read the other way round and does not fire on a live root.

The fix. Remove the backend or cloud block from a live root. If the root is meant to stay on stock state, drop the estate declaration instead and switch terraform.binary back to terraform or tofu.

Prior art. choudoufu RuleStateBackend (internal/live/lint), equivalent: the identical condition, checked the same way at init.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
backend "s3" {
bucket = "tfstate"
key = "fixture/terraform.tfstate"
}
}
resource "null_resource" "first" {}

TF025: live root references a non-default workspace

Section titled “TF025: live root references a non-default workspace”

A live root whose configuration references terraform.workspace, or whose terraform.roots.<name>.workspace names anything but default.

Why it matters. choudoufu refuses every workspace but default, along with workspace new and workspace select: a workspace is a second state file, and a live root has none. A terraform.workspace reference in a live root can only ever read default, which is a sign the configuration was written for a stock multi-workspace layout and has not been adapted.

The fix. Replace the terraform.workspace reference with a variable or the estate name, and drop the workspace setting from the root’s entry in terraform.roots. Separate environments become separate estates.

Prior art. No tool in the survey covers this. choudoufu refuses the workspace at its CLI argument layer (internal/command/live_mode.go) rather than through a lint rule, so there is no rule constant to cite; see audit-lineage.ts for the note.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
}
resource "null_resource" "first" {
triggers = {
workspace = "${terraform.workspace}"
}
}

TF026: live root’s delete: “never” against its policy block

Section titled “TF026: live root’s delete: “never” against its policy block”

A live root whose terraform.roots.<name>.delete is "never", but whose policy block leaves undeclared_tagged at its default ("delete") or sets it to anything but "keep", "untag" or "report".

Why it matters. chant’s delete: "never" | "owned-only" | "gated" maps onto choudoufu’s policy block: undeclared_tagged governs what happens to a resource this estate marked that the configuration no longer declares, and defaults to "delete" when the policy block omits it, or the root declares no policy block at all. delete: "never" promises that TerraformApplyOp never proposes deleting a resource it owns; a root making that promise without actually turning the quadrant’s default off is a promise the configuration cannot keep.

The fix. Add a policy block (nested in the root’s live block) setting undeclared_tagged to "keep", "untag" or "report", or change the root’s delete to "owned-only" or "gated" if an owned orphan really should be deleted.

Prior art. No tool in the survey covers this. choudoufu’s own internal/live/lint package checks a policy block for internal validity (an unrecognized or wrong-quadrant verb, an unscoped undeclared_untagged = "delete"), never against a delete: "never" | "owned-only" | "gated" classification. That vocabulary is chant’s own, applied here to a tool that does not itself carry the concept. See audit-lineage.ts for the full reasoning.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
}
resource "null_resource" "first" {}

TF027: live root’s policy block deletes what chant does not own

Section titled “TF027: live root’s policy block deletes what chant does not own”

A live root whose policy block sets undeclared_untagged = "delete", with or without a scope block beside it.

Why it matters. The undeclared_untagged quadrant covers a live resource this estate never marked and the configuration does not declare: a resource chant does not own. "delete" there is account-scoped reconciliation, and it can reach a resource nothing in the project has ever named. chant never proposes deleting a resource it does not own, and neither does an Op it generates, so the setting is refused on any live root the project builds. TerraformApplyOp refuses the same thing when it is built, but only for a project whose root it can resolve from a chant.config.json; this check reads the parsed HCL, so it fires whatever the config file is written in, and on a live root with no apply Op at all.

The fix. Remove undeclared_untagged = "delete" from the policy block. Where the estate really should reclaim orphans, narrow its own ownership answer instead: undeclared_tagged governs the resources this estate marked, and its default is already "delete".

Prior art. choudoufu RulePolicyScope (internal/live/lint/policy.go), extends. choudoufu refuses the same assignment when no scope block narrows the purge, on the reasoning of its own issue #67. chant refuses it either way: a scope block bounds the sweep to a service, type or region, and every resource inside that boundary is still one this estate never marked.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
policy {
undeclared_untagged = "delete"
}
}
}
resource "null_resource" "first" {}

TF028: live root watched by a stock-mode watch Op

Section titled “TF028: live root watched by a stock-mode watch Op”

A root that runs choudoufu with a declared estate, named by a TerraformWatchOp built without live: true.

Why it matters. live is declared, not detected: a composite is built by chant build without reading a root module’s .tf files, so the Op author says which shape the Plan phase takes and, until this check, nothing compared that answer with the root. Getting it wrong is silent and lossy. live: true swaps the Plan step for choudoufuLivePlan, whose single read of the live system answers three questions: whether the estate drifted, how many live resources sit at a declared identity carrying no marker, and how many of those an exact content match makes claimable. A stock terraformPlan step answers the first alone, so the watch reports drift and stays quiet about every unowned and adoptable resource in the estate.

The fix. Set live: true on the TerraformWatchOp that names the root. Drop the root’s estate instead if it is not a live root. TerraformWatchOp also refuses the mismatch outright where it can read the root’s mode from a chant.config.json; this check covers the projects on a chant.config.ts, where that read returns nothing.

Prior art. No tool in the survey covers this. The check reads a chant Op’s own configuration against a root’s mode, and no surveyed tool has an Op model to read: choudoufu lints HCL, and so does every tflint ruleset, tfsec and trivy-checks, and the reference Sentinel policies. See audit-lineage.ts.

terraform {
required_version = ">= 1.5.0"
live {
estate = "fixture-estate"
}
}
resource "null_resource" "first" {}

The Op that turns that root into a finding, missing the one field:

const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate" });

TF101: terraformApply’s planFile must reference a preceding terraformPlan step

Section titled “TF101: terraformApply’s planFile must reference a preceding terraformPlan step”

A terraformApply step’s planFile is a string or template literal in source, or a reference to a step that is not a terraformPlan call.

Why it matters. The entire point of the plan/apply split is to review exactly what will change, then apply exactly that. Spelling the plan path out as a literal breaks the link between the two steps: the file applied can be stale, hand-edited by something else in the meantime, or the wrong plan for this root. Carrying the path forward from the producing step (stepOutput(plan, "planFile") or plan.out.planFile) ties the apply to the plan it runs.

The fix. Reference the preceding terraformPlan step’s output instead of a literal path: stepOutput(plan, "planFile") or plan.out.planFile, where plan is the const a terraformPlan(...) call was assigned to.

Prior art. No tool in the survey covers this. TF101 is a rule about chant’s own Op-builder DSL, not about a Terraform root. Nothing in the survey (linters, scanners, policy engines, or HashiCorp’s own Sentinel examples) models “does this plan file trace back to a specific preceding plan step”, because that question does not exist outside chant’s own authoring surface. See audit-lineage.ts’s header comment for the full reasoning.

When the referenced identifier cannot be resolved at all in the same file, this rule stays silent rather than guess.

Live roots are checked the same way. For one release this rule skipped a terraformApply call over a choudoufu live root, because choudoufu refused -out and apply <planfile> and the activity ran a bare apply -auto-approve with no plan file to pair. choudoufu v0.13.0 admits both under a live block (choudoufu #878), and there the pairing carries more weight than on a stock root: the apply re-plans the live system and compares its own fresh plan against the file it was handed, refusing with exit status 3 when they differ. A plan file spelled out as a literal path is an approval for a run nobody can trace back to a plan step, so the exemption is gone with the condition that produced it, and this rule reads no project config at all.

const plan = terraformPlan("app", { id: "plan" });
const apply = terraformApply("app", { planFile: "/tmp/plan.out" });

To write custom rules for your project, see the lint-rules authoring guide.

A root module’s parse does not stop at its own directory. A module block whose source is a local path (./modules/cdn, ../shared) names a directory on disk, and that directory is parsed as a child scope of the root that calls it. Its blocks are keyed <root>/module.<name>/<address>, one key segment per call, which is the address terraform show -json writes in child_modules[] and the one choudoufu’s marker tags carry.

This is what lets TF014 and TF015 exist at all, and what makes TF020 answer per module rather than per repository. Every other rule reads a child module’s blocks exactly as it reads a root’s, with two groups of exceptions: TF001, TF002, TF003, TF024 and TF025 are about how a ROOT is assembled and skip a child’s blocks (a module beneath a root with a perfectly good backend must not make TF001 fire), and TF014 and TF015 are the mirror, reading only child modules. Each rule’s own file says which it is.

A finding inside a child module names the call that reached it:

TF014: Child module block "provider.aws" configures a provider (profile, region). ...
Callers: app -> module.cdn (main.tf:13).

The chain is tflint’s idea, printed there as a Callers: block under the diagnostic. A chant post-synth finding carries no source ranges of its own, so the chain goes in the message, where every output format shows it.

What is followed. terraform.callModuleType in chant.config.* spells the policy, with tflint’s three values and its default:

export default {
lexicons: ["terraform"],
terraform: {
callModuleType: "local", // the default
roots: { app: { dir: "./terraform/app" } },
},
};
  • local reads modules sourced from a relative path. A registry or git source is not fetched, so its contents are never linted and every finding about it is reported against the module block at the call site, which is where TF004 and TF005 report already. The skipped calls are named in a build warning rather than passed over in silence.
  • none reads each root’s own directory and nothing else.
  • all is tflint’s “fetch registry and git modules too”, which needs a terraform init first so the modules are on disk. chant fetches nothing, so this is refused with a message instead of falling back to local.

Two refusals keep the walk inside the repository it was pointed at. A source that resolves outside the project root is not read, and a source already on the current call chain is a cycle and stops there. Both are build warnings naming the call site.

Where the walk stops. The boundary is the project root, or a root’s own directory when its dir lies outside the project. terraform.moduleRoot names another directory instead, absolute or relative to the project root, and a source that resolves outside it is refused the same way. chant workspace graph sets it to the workspace root when it reads a terraform or choudoufu member through a reader project of its own (#2874), so a member root that calls ../modules/x keeps its modules. A project rarely needs it, since widening the boundary means reading modules the project doesn’t own.

chant audit walks the same scopes. Discovery reads each directory of .tf files, drops the ones another directory calls with a relative source (they are child modules, not roots), and hands the audit the directory each root was read from. The parse then follows the same local module calls a build follows, so TF014 and TF015 report under chant audit with the same Callers: line, and the root-only rules still see only a root’s own blocks. A scope is named after its directory there (envs/prod reads as envs.prod in a finding), since an audit has no terraform.roots to take names from. The two refusals above still apply, but an audit has no warning list to print them on, so there they are silent.

The TypeScript lint’s chant-disable comments (see Disable Directives) don’t apply here: a post-synth check like TF001 reads the parsed HCL model, not a ts.SourceFile, so it has no AST position to disable at. Terraform gets its own comment forms instead, read from the raw .tf text in a second pass over the file (hcl2json drops comments entirely), so terraform fmt leaves every one of them alone. Every form works identically through chant build and chant audit, and a suppressed finding is always counted in the build/audit summary, never dropped silently.

# chant-ignore: TF010, TF011
variable "region" {
type = string
}

# chant-ignore on the line before a block (or an attribute) suppresses the listed rule ids on that block. Write all instead of a list to suppress every rule id there.

# chant-ignore-file: TF011

# chant-ignore-file suppresses a rule id for the whole file, but only when it is the first non-blank line of the file. Anywhere else it has no effect and is itself reported as a finding, the same constraint tflint’s tflint-ignore-file enforces: a file-level suppression that could sit anywhere in the file is one a reader has to read the whole file to rule out, so it isn’t findable.

# chant-ignore-block: TF001
terraform {
required_version = ">= 1.5.0"
}

# chant-ignore-block anchors to the next block rather than to a finding’s entity, borrowed from KICS’s kics-scan ignore-block. This is the form TF001 needs: its finding is about a backend block that is not there, so there is no attribute or sub-block a plain # chant-ignore could sit in front of. The comment goes on the terraform block itself instead, the nearest thing the missing backend has to a location. A plain # chant-ignore placed on the same terraform block works too, because the block TF001 reports against and the block the comment precedes happen to be the same one here; reach for # chant-ignore-block on purpose whenever what’s wrong is an absence, so the comment reads as “nothing here should trigger a finding” rather than “this block has a problem.” trivy’s own suppression comments can’t do this at all, because trivy’s ctx.entities never carries a location for a block that isn’t present; chant’s does, because the terraform lexicon always parses a root’s terraform block into an entity even when the thing it’s missing lives inside it.

Add exp:2026-12-31 to any of the three forms to make the suppression expire:

# chant-ignore: TF010 exp:2026-12-31

Past that date the suppression stops suppressing and is reported once as its own finding, so an ignore written for a known-in-progress fix doesn’t rot into a permanent blind spot.

A project can lock a rule id against being ignored at all. In chant.config.ts:

export default {
lint: {
rules: {
TF008: ["error", { ignorable: false }],
},
},
};

A # chant-ignore (in any form) that names an id configured ignorable: false has no suppressing effect on that id, and is itself reported as a finding, matching tflint’s own ignorable = false setting. Every rule is ignorable by default; TF008 (hardcoded provider credentials) is the kind of rule a project might want to lock this way.

A root that names its estate in both terraform.roots.<name>.estate and its own HCL, in a live { } block or an estate.chdf.hcl sidecar.

Why it matters. The two are not equal in authority. A declaration is what choudoufu reads for itself; terraform.roots.<name>.estate is what this lexicon would otherwise hand it as -estate. choudoufu refuses that flag beside a declaration outright — “This configuration’s live block is what names its estate, and -estate=... would name a second one for this run only” — so the lexicon does not pass it, and the config value is never used.

A setting written down and silently ignored is worse than one that errors. An author who moves an environment by editing roots.app.estate gets a run against the old estate and nothing tells them the edit was disregarded. The diagnostic names both values even when they agree today, because two settings that happen to match still means one of them does nothing, and the next edit to the ignored one is the incident this rule exists to prevent.

The fix. Pick one. A root whose directory is shared across environments declares no estate in HCL and names it per root in chant.config. That is the environment dimension, and it is why terraform.roots.<name>.estate exists. A root with its own live block already has an estate and needs nothing in chant.config.

TF030: a security group rule opens a sensitive port to the internet

Section titled “TF030: a security group rule opens a sensitive port to the internet”

An ingress rule that admits 0.0.0.0/0 or ::/0 on SSH (22), RDP (3389), MySQL (3306) or PostgreSQL (5432), the same four ports the aws lexicon’s WAW019 checks. TF030 reads all three shapes Terraform has for a rule:

  • ingress on aws_security_group, written as nested blocks or as an attribute list (the provider types it as set(object(...)), and the parse reads both spellings the same way), with cidr_blocks and ipv6_cidr_blocks;
  • a standalone aws_security_group_rule whose type is "ingress";
  • an aws_vpc_security_group_ingress_rule, with cidr_ipv4 and cidr_ipv6.

Ports are a range. Protocol tcp or udp (or 6, 17) reaches every port from from_port to to_port, so 0-65535 reaches all four; protocol -1 (or "all") reaches every port whatever the range says. ICMP and other port-less protocols are never reported, and tcp with 0-0 is port 0, not every port. Egress rules are never reported.

Why it matters. These are the ports automated scanners try first. A database or a remote shell reachable from every address on the internet is one leaked or guessed credential away from being someone else’s, and the security group is the layer that should have stopped the connection before any password was asked for.

The fix. Narrow the CIDR to the sources that need the port: an office range, a VPN, the VPC’s own CIDR. Better still, take the port off the internet altogether and reach the host through a bastion, a VPN or SSM Session Manager, or let the database accept connections only from its clients’ security group (source_security_group_id, referenced_security_group_id), which TF030 does not report.

What absence means. An aws_security_group with no ingress admits nothing: the provider’s default is no rules, and any rules the group does have live in standalone resources, which TF030 reads in their own right. So a missing ingress is closed, not unknown, and nothing is reported for it.

What is not determined. TF030 evaluates no expressions. It reports a finding only when literals prove all three facts: the rule is ingress, a CIDR is open, and the ports reach a sensitive one. When the answer depends on a value the parse hands back as an expression, such as cidr_blocks = var.allowed_ssh_cidrs, from_port = var.ssh_port, type = var.direction, ingress = local.rules, a for_each over each.value, or a dynamic "ingress" block, it emits one info finding that starts Not determined: and names each attribute it could not read. It never turns an unread value into a warning or an error. One known fact still settles a rule by itself: cidr_blocks = var.allowed on port 443 is silent, because no CIDR makes 443 one of the four ports, and so is a private CIDR literal on from_port = var.port.

Prior art. Every credit overlaps; none checks quite the same condition. Checkov’s CKV_AWS_24 and CKV_AWS_25 check 22 and 3389, one port per id, read the same three shapes, and read tcp 0-0 as every port. tfsec’s aws-vpc-no-public-ingress-sgr flags a /0 on any port. trivy-checks’ aws-ec2-no-public-ingress-sgr checks SSH and RDP. KICS’s 381c3f2a-ef6f-4eff-99f7-b169cda3422c checks a longer port list. HashiCorp’s reference Sentinel policies restrict-ingress-sg-rule-ssh and restrict-ingress-sg-rule-rdp read only aws_security_group_rule, and run against a plan rather than the source.

The fixtures under fixtures/TF030/ are vendored from public repositories under MIT-0 and Apache-2.0, each with its source, path, commit and licence in a comment header. The one below is aws-samples/terraform-ec2-image-builder-container-hardening-pipeline’s sec-groups.tf. TF030 reports its second ingress rule, whose 1025-65535 range takes in MySQL, RDP and PostgreSQL, and not the first, which opens 443:

resource "aws_security_group" "image_builder_sg" {
depends_on = [
aws_vpc.hardening_pipeline
]
name = "${var.image_name}-sg"
description = "Security group for EC2 Image Builder"
vpc_id = aws_vpc.hardening_pipeline.id
ingress {
description = "TLS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "Ephemeral"
from_port = 1025
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all eggress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = local.core_tags
}

TF031: an IAM policy allows a wildcard Action or Resource

Section titled “TF031: an IAM policy allows a wildcard Action or Resource”

An Allow statement whose Action or Resource is the literal "*", or a list that holds it. The rule reads aws_iam_policy, aws_iam_role_policy, aws_iam_user_policy and aws_iam_group_policy (policy), aws_iam_role (assume_role_policy and every inline_policy block), and the aws_iam_policy_document data source (its statement blocks). Like the aws lexicon’s WAW020, it reads the trust policy too. A trust policy has no Resource, so in practice only Action = "*" fires there.

Why it matters. Action = "*" grants every AWS action, including the IAM ones that let the holder grant itself anything else. Resource = "*" beside a scoped action still reaches every bucket, table or key in the account, including the ones created after the policy was written. Both are easy to write while debugging and are rarely taken out again.

The fix. Name the actions the workload calls and the ARNs it touches. A few actions accept only Resource = "*" (ecr:GetAuthorizationToken, sts:GetCallerIdentity, most Describe* and List* calls). Put those in a statement of their own and keep it with a # chant-ignore-block: TF031 above the block, so the exception is written down where a reviewer sees it.

What the rule can read. A policy argument is a string, and the HCL parse does not evaluate it, so TF031 reads three shapes:

  • jsonencode({ ... }). The parse keeps the call as one string with HCL object syntax inside, and TF031 reads the literal parts of that argument as structure. An expression inside it (a reference, a function call, a conditional, a template string) is skipped as one unknown value, so a literal Action = "*" beside Resource = aws_s3_bucket.logs.arn still fires.
  • A heredoc or quoted string with no interpolation. The parse hands this over as the JSON text itself, and TF031 reads it with a JSON parser. This is more than #2286 expected: a plain heredoc turned out to be readable.
  • The aws_iam_policy_document data source, whose statement blocks are plain HCL. effect defaults to "Allow" in the provider, so a statement without one is an allow. A policy argument that is exactly data.aws_iam_policy_document.<name>.json naming a document in the same module is not reported a second time; the finding is on the document.

A Deny statement never fires. Deny with Action = "*" is the usual guard rail, for example denying everything from outside an address range.

What absence means. A resource with no policy argument, a role with no inline_policy blocks, and a data source with no statement are unconfigured, not insecure. TF031 reports nothing for them.

What is not determined. TF031 does not evaluate expressions. When the value it needs cannot be read from the source, it reports one info diagnostic starting Not determined: that names the attribute and the reason, never a warning. That happens for:

  • a policy that is a reference (var.policy, a module output, a document declared in another module) or a file() or templatefile() call;
  • a heredoc or string with ${...} interpolation, or one that does not parse as JSON;
  • a jsonencode() whose whole argument is an expression (jsonencode(local.policy)), or whose Statement is built by concat() or a for-expression;
  • an Action, Resource or Effect that is an expression which could be "*" (a variable, a local, a function call);
  • a dynamic "statement" block.

Some expressions can never be "*", and TF031 reads them as scoped rather than not determined: an attribute of a managed resource or data source (aws_s3_bucket.logs.arn) is an ARN or an id, and a template whose literal text already holds something other than * ("arn:aws:s3:::${var.bucket}/*") cannot equal *.

Out of scope: NotAction and NotResource, service wildcards such as s3:*, Condition blocks (a wildcard under a condition still fires), the documents an aws_iam_policy_document merges in through source_policy_documents or override_policy_documents, and resource-based policies (S3 bucket, SQS, KMS, VPC endpoint), where Resource = "*" usually means the resource itself and the risk is in Principal.

Prior art. tfsec’s aws-iam-no-policy-wildcards (overlaps: it also flags service wildcards such as s3:*). checkov’s CKV_AWS_63 (overlaps: Action "*" on the policy resources, plus the SSO permission-set inline policy TF031 does not read) and CKV_AWS_49 (extends: the same on the data source, where TF031 also checks resources), and CKV_AWS_355 and CKV_AWS_356 (overlaps: Resource "*" only for actions that can be restricted). trivy-checks is not credited: its aws-iam-no-policy-wildcards is a deprecated stub with no rule body.

resource "aws_iam_policy" "privesc-AssumeRole-high-priv-policy" {
name = "privesc-AssumeRole-high-priv-policy"
path = "/"
description = "Allows privesc via targeted sts:AssumeRole"
# Terraform's "jsonencode" function converts a
# Terraform expression result to valid JSON syntax.
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "*"
Resource = "*"
},
]
})
}

From fixtures/TF031/positive-jsonencode.tf, vendored from BishopFox/iam-vulnerable (MIT).

TF032: an ECS container definition passes a credential as a plaintext environment value

Section titled “TF032: an ECS container definition passes a credential as a plaintext environment value”

An aws_ecs_task_definition whose container_definitions gives a container an environment entry with a literal, secret-shaped value. The Terraform counterpart of the aws lexicon’s WAW046.

Why it matters. An environment value is stored verbatim in the task definition. Anyone allowed ecs:DescribeTaskDefinition can read it, the console shows it, and it sits in Terraform state and plan output beside the commit that introduced it. ECS has a field for exactly this case: secrets, whose valueFrom names a Secrets Manager secret or an SSM parameter that the agent resolves when the task starts.

The fix. Move the entry from environment to secrets with a valueFrom, grant the task execution role read access to that secret or parameter, and rotate the value that was committed.

secrets = [
{ name = "DB_PASSWORD", valueFrom = aws_secretsmanager_secret.db_password.arn },
]

What it can read. container_definitions is a JSON string, and hcl2json does not evaluate expressions, so jsonencode([...]) reaches the rule as the call’s source text. The rule reads that text with the shared literal reader (src/hcl/jsonencode.ts) and never evaluates anything. A reference or function call in one field (image = var.image, value = var.db_password) leaves that field unknown and the literal fields around it readable. A plain string with no interpolation is parsed as JSON, which covers a heredoc that contains none.

Which entries fire. The judgment is src/lint/secret-shape.ts, the same module TF007, TF008, TF009 and TF022 use, so “secret-shaped” means one thing across the family. An entry fires when its value is a literal string of at least 8 characters that is not a placeholder (changeme, example, your-api-key, a run of one character), and either:

  • its name, lowercased with every non-alphanumeric run folded to _, contains one of password, passwd, secret, token, api_key, private_key or access_key as a whole _-separated word, and does not end in _file, _path, _arn, _id or _name; or
  • the value has a credential’s shape on its own, whatever the name: a vendor key format core’s secret scanner knows (AWS key ids, GitHub, Slack, Google, Stripe), a connection string with real credentials in it, a JWT, a PEM private key, or a long high-entropy token.

The word boundary is what keeps TOKENIZER and KEYSTORE_TYPE quiet. The locator suffixes keep DB_SECRET_ARN and API_KEY_PATH quiet, since a value that points at a credential is the shape the fix asks for. credential is not on the list, unlike WAW046’s pattern: in an environment the common name containing it is GOOGLE_APPLICATION_CREDENTIALS, which holds a file path. The 8-character floor keeps PASSWORD_MIN_LENGTH = "8" and TOKEN_TTL = "3600" quiet. Below 8 characters a literal is not reported, so "hunter2" passes; the floor is shared with TF022 and not tuned per rule. A numeric or boolean value is never a finding.

What absence means. A container with no environment is not insecure, and neither is one that passes every credential through secrets: the rule never reads secrets at all. A task definition without container_definitions is not reported either, since the attribute is required and that is a plan error.

What is not determined. When the definitions cannot be read as literal structure, the rule says so in one info diagnostic starting Not determined:, never a warning or an error. That happens when container_definitions is a reference (local.defs, data.template_file.app.rendered), a file() or templatefile() call, a heredoc or string with interpolation, or jsonencode of something other than a literal list (jsonencode(concat(...)), jsonencode(local.containers)). It is reported per container when a container’s environment is an expression (environment = local.env_vars, a for expression), or when an entry in it is. A credential in any of those places can be neither found nor ruled out without evaluating HCL, which this rule does not attempt. An entry whose value alone is a reference is skipped without an info. A reference is not a committed literal, and where the referenced value comes from is TF007’s and TF022’s question.

Where this does not reach. A plaintext password on aws_db_instance, aws_rds_cluster, aws_elasticache_replication_group and similar resources is an ordinary attribute, and TF022 already reports it with the same heuristic. TF032 exists because a credential inside container_definitions is inside a JSON string, which TF022’s attribute walk sees only as one string named container_definitions.

Prior art. tfsec’s aws-ecs-no-plaintext-secrets and its successor in trivy-checks, aws-ecs-no-plaintext-secrets (AVD-AWS-0036), both overlaps. They fire on a sensitive environment name or a value their secret scanner flags, after evaluating the definition, so they also see what TF032 reports as not determined, and their name list is their own. Checkov has no Terraform check for ECS environment values. Its ECS checks cover privilege, a read-only root filesystem, host processes, the task role and EFS encryption.

The failing fixture, vendored from edstem-tech/paylink-platform (MIT), fires on DB_PASSWORD and PAYMENT_API_KEY:

resource "aws_ecs_task_definition" "api" {
family = "paylink-api"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "1024"
memory = "2048"
task_role_arn = aws_iam_role.task.arn
execution_role_arn = aws_iam_role.execution.arn
container_definitions = jsonencode([{
name = "api"
image = "${var.ecr_repo}:latest"
portMappings = [{ containerPort = 8080 }]
environment = [
{ name = "SPRING_PROFILES_ACTIVE", value = "prod" },
{ name = "DB_PASSWORD", value = "Pl-Pr0d-Db-2025!" },
{ name = "PAYMENT_API_KEY", value = "pk_live_4RtYuIoP2aSdF6gH8jK" }
]
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"]
interval = 10
timeout = 2
retries = 3
}
}])
}

The other fixtures under fixtures/TF032/ cover the remaining cases. The passing ones are vapor/penny-bot (secrets through valueFrom), trivy-checks’ own passing heredoc, and testdrivenio/django-ecs-terraform (reference values, a container with no environment, and a data.template_file definition reported as not determined). The failing ones are trivy-checks’ failing heredoc and govuk-forms/forms-admin, where the Postgres sidecar’s password fires while the two containers fed from local.forms_admin_env_vars are not determined.

The flat encryption and immutability rules (TF033 to TF037)

Section titled “The flat encryption and immutability rules (TF033 to TF037)”

TF033 to TF037 are the Terraform counterparts of the aws lexicon’s WAW021, WAW025, WAW026, WAW028 and WAW054. Each reads one or two scalar attributes, and each was a perfect rename in the fidelity probe. That is the hazard. A WAW rule reads a template chant synthesized, where an absent property is false. A Terraform configuration someone else wrote is three-valued: an attribute is present, absent and insecure, or absent and set by a provider or AWS default the configuration never mentions. The probe found that WAW026, bridged, reports every correctly encrypted SQS queue.

So each rule below answers, for its own resource, what absence means, cited to the provider schema and docs (hashicorp/aws v6.67.0) and to the AWS default. The answers differ:

RuleResource and attributeAbsent means
TF033aws_db_instance.storage_encryptedinsecure, reported
TF033aws_rds_cluster.storage_encrypted, Auroranot determined
TF033aws_rds_cluster.storage_encrypted, Multi-AZ DB clusterinsecure, reported
TF034aws_sns_topic.kms_master_key_idinsecure, reported
TF035aws_sqs_queue.sqs_managed_sse_enabled / kms_master_key_idsecure, silent
TF036aws_ebs_volume.encryptednot determined
TF037aws_ecr_repository.image_tag_mutabilityinsecure, reported

All five share the not-determined convention TF030 to TF032 use. A finding needs a literal that proves it. When the value a rule needs is a reference, a template, a conditional or a function call, the rule reports one info diagnostic starting Not determined: that names the attribute and why, and never a warning or an error. A key argument that names an attribute of a managed resource or data source (aws_kms_key.sns.arn) is read as set, because the provider computes a key’s id or ARN and it is never empty; a var.kms_key can be null and is not determined.

TF033: an RDS DB instance or cluster does not encrypt its storage

Section titled “TF033: an RDS DB instance or cluster does not encrypt its storage”

An aws_db_instance or aws_rds_cluster whose storage_encrypted is false, or absent where the default is false. Severity error, as WAW021.

Why it matters. The storage, its automated backups, snapshots and read replicas are all unencrypted, and encryption cannot be turned on later in place: the database has to be restored from an encrypted copy of a snapshot, which means a cut-over.

The fix. Set storage_encrypted = true, and kms_key_id for a customer managed key. For an existing database, copy a snapshot with encryption on, restore from the copy and cut over.

What absence means. It depends on the resource and on how the database is created.

aws_db_instance: insecure. storage_encrypted is optional and not computed, and the provider docs say “The default is false if not specified.” The plain create path and the s3_import path send that false to AWS. The replica (replicate_source_db), snapshot (snapshot_identifier) and point-in-time (restore_to_point_in_time) paths never send storage_encrypted at all: the instance takes its encryption from the source, or for a cross-region replica from kms_key_id. For those TF033 reports not determined unless storage_encrypted = true is written. A literal snapshot_identifier = "" is not a source, because the provider reads it with GetOk.

aws_rds_cluster running Aurora (engine aurora-mysql or aurora-postgresql, or absent, which provider v5 defaulted to aurora): not determined. storage_encrypted is optional and computed, and AWS has encrypted every new Aurora cluster at rest since 2026-02-16 with an AWS-owned key, whatever StorageEncrypted says. The API then reports StorageEncrypted: false with StorageEncryptionType: sse-rds. A cluster created before that date with encryption off is unencrypted, and nothing in the configuration says when the cluster was created. Most Aurora clusters in an existing estate predate the change, so TF033 does not read absence (or an explicit false) as secure. It reports not determined and suggests writing storage_encrypted = true. engine_mode = "serverless" (Serverless v1) was always encrypted and is silent.

aws_rds_cluster running a Multi-AZ DB cluster (engine mysql or postgres): insecure. The provider docs say the default is false, and the Aurora change does not cover these. A cluster restored or replicated from a source (snapshot_identifier, restore_to_point_in_time, replication_source_identifier, global_cluster_identifier) is not determined.

Not checked: aws_rds_cluster_instance, whose storage_encrypted is computed-only and never appears in a configuration; aws_rds_global_cluster; and whether the key is customer managed.

Prior art. tfsec’s aws-rds-encrypt-instance-storage-data and aws-rds-encrypt-cluster-storage-data, their successors in trivy-checks (instance, cluster), checkov’s CKV_AWS_16 and CKV_AWS_96, and KICS’s DB Instance Storage Not Encrypted. All overlap: each reports an absent storage_encrypted on every cluster and every restored instance, where TF033 answers not determined.

resource "aws_db_instance" "default" {
name = var.dbname
engine = "mysql"
option_group_name = aws_db_option_group.default.name
parameter_group_name = aws_db_parameter_group.default.name
db_subnet_group_name = aws_db_subnet_group.default.name
vpc_security_group_ids = ["${aws_security_group.default.id}"]
identifier = "rds-${local.resource_prefix.value}"
engine_version = "8.0" # Latest major version
instance_class = "db.t3.micro"
allocated_storage = "20"
username = "admin"
password = var.password
apply_immediately = true
multi_az = false
backup_retention_period = 0
storage_encrypted = false
skip_final_snapshot = true
monitoring_interval = 0
publicly_accessible = true

From fixtures/TF033/positive.tf (the first twenty-one lines of the block), vendored from bridgecrewio/terragoat (Apache-2.0), a deliberately vulnerable estate.

TF034: an SNS topic has no server-side encryption

Section titled “TF034: an SNS topic has no server-side encryption”

An aws_sns_topic with no kms_master_key_id, or with kms_master_key_id = "". Severity warning, as WAW025.

Why it matters. SNS keeps a message until every subscription has it. With no key, SNS stores those messages without server-side encryption, and the payloads of alarms, events and notifications often carry account ids, resource names and customer data.

The fix. Set kms_master_key_id to a customer managed KMS key, or to "alias/aws/sns" for the AWS managed one. A customer managed key also lets a key policy decide which publishers may use it.

What absence means. Insecure, and reported. kms_master_key_id is optional with no default, and SNS server-side encryption is on only when the topic names a KMS key. Unlike SQS, no AWS default turns it on for new topics. kms_master_key_id = null is the same as absent.

Prior art. tfsec’s aws-sns-enable-topic-encryption, trivy-checks’ aws-sns-enable-topic-encryption, checkov’s CKV_AWS_26 and KICS’s SNS Topic Not Encrypted are equivalent: they agree that absence is insecure.

resource "aws_sns_topic" "user_updates" {
name = "user-updates-topic"
kms_master_key_id = ""
}

From fixtures/TF034/positive.tf, vendored from KICS’s own test corpus (Checkmarx/kics, Apache-2.0). fixtures/TF034/positive-absent.tf, from radian-software/riju (MIT), is the same finding for a topic that never mentions a key.

TF035: an SQS queue turns its server-side encryption off

Section titled “TF035: an SQS queue turns its server-side encryption off”

An aws_sqs_queue with sqs_managed_sse_enabled = false and no kms_master_key_id. Severity warning, as WAW026.

Why it matters. Turning SSE-SQS off stores message bodies unencrypted, and it is never an accident of omission: someone wrote false. It is often a leftover from a queue created outside Terraform before SQS encrypted by default, as in the fixture below.

The fix. Remove the line (new queues are encrypted by default), set it to true, or set kms_master_key_id to "alias/aws/sqs" or a customer managed key.

What absence means. Secure, and silent. This is the case that made the bridge unshippable. sqs_managed_sse_enabled is optional and computed, the provider docs say Terraform “will only perform drift detection of its value when present in a configuration”, and since October 2022 SQS encrypts every new queue with SSE-SQS by default when it is created over HTTPS, which the provider always uses. A queue that sets neither attribute is encrypted. A queue created before that date and never changed may not be; the configuration cannot show its age, and TF035 does not guess. A kms_master_key_id from a variable with sqs_managed_sse_enabled unset is silent too: whether the variable is a key or null, the queue is encrypted.

Not determined: sqs_managed_sse_enabled from an expression with no key, and sqs_managed_sse_enabled = false beside a key from a variable.

Prior art. tfsec’s aws-sqs-enable-queue-encryption, trivy-checks’ aws-sqs-enable-queue-encryption, checkov’s CKV_AWS_27 and KICS’s SQS With SSE Disabled overlap: all four report a queue that sets neither attribute, which TF035 reads as encrypted.

resource "aws_sqs_queue" "notify_internal_tasks_queue" {
provider = aws.core_services
name = "${var.celery_queue_prefix}notify-internal-tasks"
# This queue was created outside of terraform and has this value set to false in staging and production.
sqs_managed_sse_enabled = false
max_message_size = var.sqs_max_message_size
# This queue was created outside of terraform and has this value set to default in staging and production.
visibility_timeout_seconds = var.sqs_visibility_timeout_default
}

From fixtures/TF035/positive.tf, vendored from cds-snc/notification-terraform (MIT). fixtures/TF035/negative-absent.tf is a queue from the same file that sets neither attribute, and TF035 is silent on it.

TF036: an EBS volume does not ask for encryption

Section titled “TF036: an EBS volume does not ask for encryption”

An aws_ebs_volume with encrypted = false. Severity warning, as WAW028.

Why it matters. An unencrypted volume leaves its data, its snapshots and every volume restored from those snapshots unencrypted.

The fix. Set encrypted = true, and kms_key_id for a customer managed key, or remove the line and rely on the Region’s EBS encryption by default, made explicit as below.

What absence means. Not determined. encrypted is optional, computed and forces replacement. Whether a volume created without it is encrypted depends on the Region’s EBS encryption by default, which is account state the parse cannot see, and a volume built from snapshot_id also follows its snapshot. TF036 reports one info diagnostic.

One configuration does settle absence: the volume depends_on an aws_ebs_encryption_by_default in the same module whose enabled is absent (the provider default is true) or true, with the same provider and region. The depends_on matters. Without it Terraform may create the volume first, and encryption by default “has no effect on existing EBS volumes”.

encrypted = false is a finding even though the account default can override it, because the literal is wrong either way. With encryption by default off, the volume is unencrypted. With it on, AWS encrypts the volume anyway (“you cannot disable it for individual volumes”), the volume stops matching the configuration, and since encrypted forces replacement Terraform plans to replace the volume on every apply. TerraGoat’s source records this in a comment: #encrypted = false # Setting this causes the volume to be recreated on apply.

Prior art. trivy-checks’ aws-ec2-enable-volume-encryption, checkov’s CKV_AWS_3 and KICS’s EBS Volume Encryption Disabled overlap: they report an unset encrypted as unencrypted. tfsec’s page for the same check exists, but its frozen rule index no longer lists it, so it is not credited.

resource "aws_ebs_volume" "vol2" {
availability_zone = "us-west-2a"
size = 40
tags {
Name = "HelloWorld"
}
encrypted = false
}

From fixtures/TF036/positive.tf, vendored from config-lint’s example files (stelligent/config-lint, MIT).

TF037: an ECR repository lets an image tag be repointed

Section titled “TF037: an ECR repository lets an image tag be repointed”

An aws_ecr_repository whose image_tag_mutability is MUTABLE, MUTABLE_WITH_EXCLUSION, or absent. Severity error, as WAW054.

Why it matters. With mutable tags, anyone who can push can move prod or v1.4.2 to different image content after it was deployed, scanned or signed. The next task that pulls the tag runs something nobody reviewed, and the deployment record still names the old tag.

The fix. Set image_tag_mutability = "IMMUTABLE" and push a new tag for every build. If a workflow needs a few moving tags (latest, stage), use IMMUTABLE_WITH_EXCLUSION and name them in image_tag_mutability_exclusion_filter blocks; every other tag stays immutable.

What absence means. Insecure, and reported. image_tag_mutability is optional with Default: MUTABLE in the provider schema.

Provider v6 accepts four values, two of them added for the exclusion filters AWS shipped in 2025. IMMUTABLE_WITH_EXCLUSION is silent: the excluded tags are a written-down exception. A filter that is nothing but * matches every tag and is reported, and a filter from an expression or a dynamic block is not determined. MUTABLE_WITH_EXCLUSION keeps every tag mutable except the ones its filters name, and is reported.

Prior art. tfsec’s aws-ecr-enforce-immutable-repository, trivy-checks’ aws-ecr-enforce-immutable-repository, checkov’s CKV_AWS_51 and KICS’s ECR Image Tag Not Immutable overlap: they agree on absence, and none of them reads IMMUTABLE_WITH_EXCLUSION, which they report and TF037 passes.

resource aws_ecr_repository "repository" {
name = "${local.resource_prefix.value}-repository"
image_tag_mutability = "MUTABLE"

From fixtures/TF037/positive.tf (the first three lines of the block), vendored from bridgecrewio/terragoat (Apache-2.0).

TF038: an OCI module source names no tag or digest, or a mutable tag

Section titled “TF038: an OCI module source names no tag or digest, or a mutable tag”

A module block whose source is an OpenTofu oci:// address with no tag and no digest, or with a mutable tag such as latest. Severity warning.

Why it matters. OpenTofu resolves an oci:// source with no tag to the latest tag, so an unpinned source and ?tag=latest are the same thing. A push to the registry changes what the root provisions with no diff to review. A digest cannot move. An exact version tag is read as pinned, but a registry can still repoint it, which is why this is a warning and why the message prefers a digest.

The fix. Pin the source with ?digest=sha256:... or an exact version tag (?tag=1.4.0). The rule also reads the container-image spellings oci://host/repo:1.4.0 and oci://host/repo@sha256:.... A colon in the host is a registry port, never a tag. A source that names a digest passes whatever tag sits beside it. The tags read as mutable are latest, main, master, develop, dev, stable, edge and nightly.

Prior art. No tool in the survey covers oci:// module sources. The OpenTofu module sources page documents the tag and digest query arguments and the latest default. The suffix spellings are not documented there and are not verified against OpenTofu’s parser.

module "network" {
source = "oci://registry.example.com/platform/network"
}

From fixtures/TF038/positive.tf.

TF039: a registry module version is a range

Section titled “TF039: a registry module version is a range”

A registry module block whose version is a range (~> 1.4, >= 1.4, >= 1.0, < 2.0) rather than an exact version (1.4.0 or = 1.4.0). Severity warning, and off by default.

Why it matters. TF004 passes any version, so a range passes it. A release inside the range changes what the root provisions with no diff to review, and a reader of a change cannot say which module release it picked up. Ranges are common and often intended, so this is for projects that pin modules.

The fix. Pin an exact version: version = "1.4.0".

Off by default. TF039 is a report-only rule, so the recommended preset leaves it out. Turn it on with lint.presets: { terraform: "all" }, or for this rule alone with a lint.rules entry ({ TF039: "warning" }), which keeps it under any preset. It is a separate rule rather than an option on TF004 because a post-synth check takes no options, while a preset already carries the on/off choice per rule id. A module with no version is TF004’s finding and is skipped here.

Prior art. tflint’s terraform_module_version has an exact style that rejects a range. It is not credited in the lineage file because that mapping was not checked against the rule’s source.

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}

From fixtures/TF039/positive.tf.

TF040: a root has no committed .terraform.lock.hcl

Section titled “TF040: a root has no committed .terraform.lock.hcl”

A root module that uses a provider (a provider block, or a resource or data block whose type implies one) with no .terraform.lock.hcl beside its .tf files. Severity warning, and off by default.

Why it matters. The lock file records the provider versions and hashes terraform init selected. Without it every init resolves the required_providers constraints again, so a provider release changes a plan with no diff. None of TF001 to TF029 checks for it.

The fix. Run terraform init in the root and commit the file it writes.

How “committed” is known. The rule tests for the file’s presence next to the root’s .tf files in the source tree being parsed. It does not ask git whether the file is tracked, so a lock file that exists but is gitignored counts as present. The check runs where the root’s directory is known: a build over terraform.roots, and chant audit over a local path. A fetched repository and inline source carry no directory, and the rule reports nothing for them. A root that implies no provider, and a live choudoufu root, are skipped.

Off by default. TF040 is a report-only rule, since many estates run init in CI and never commit the file. Enable it as described under TF039. chant audit runs every rule, so TF040 is also marked to stay out of the audit unless the project’s lint.rules names it. The audit reads lint.rules from chant.config.ts or chant.config.json without running the config, and a lint.rules entry for TF040 turns it on in both a build and an audit.

Prior art. No tool in the survey covers this; see the lineage file.

resource "aws_s3_bucket" "b" {
bucket = "x"
}

A root whose directory holds only this file triggers the rule. The test builds that directory in tf040.test.ts.

TF041: a Terragrunt dependency lets mock_outputs stand in for apply

Section titled “TF041: a Terragrunt dependency lets mock_outputs stand in for apply”

A dependency block that sets mock_outputs and either sets no mock_outputs_allowed_terraform_commands or lists apply in it. Severity warning. The rule reads terragrunt.hcl and root.hcl, in a build over terraform.roots and in chant audit.

Why it matters. Terragrunt returns the mocks when the upstream unit has no outputs yet and the running command is allowed. A missing list and an empty list both mean every command (shouldReturnMockOutputs in Terragrunt v1.1.6). An apply that runs before the upstream unit is applied then writes the mock values to real state and reports success.

The fix. Set mock_outputs_allowed_terraform_commands = ["validate", "plan"]. A list that is an expression, such as local.mock_commands, is not read and passes.

Prior art. No tool in the survey covers this; see the lineage file.

dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-00000000"
}
}

From fixtures/TF041/positive/terragrunt.hcl. positive-apply lists apply.

TF042: a Terragrunt dependency sets skip_outputs with mock_outputs

Section titled “TF042: a Terragrunt dependency sets skip_outputs with mock_outputs”

A dependency block with skip_outputs = true and mock_outputs. Severity warning.

Why it matters. With skip_outputs = true Terragrunt never reads the upstream unit’s outputs, so the mocks are the only values the dependency can return. Every plan and apply they are allowed for runs on mock data, however far the upstream unit has been applied. The pair is sometimes intended, to break a cycle; the values are then configuration.

The fix. Drop skip_outputs, or pass the values as inputs where a reader of the unit can see them. A skip_outputs that is not a literal true is not read.

Prior art. No tool in the survey covers this; see the lineage file.

dependency "vpc" {
config_path = "../vpc"
skip_outputs = true
mock_outputs = {
vpc_id = "vpc-00000000"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}

From fixtures/TF042/positive/terragrunt.hcl.

TF043: a Terragrunt terraform.source names no version

Section titled “TF043: a Terragrunt terraform.source names no version”

The source of a unit’s terraform block is a git address with no ?ref=, a tfr:// address with no ?version=, or an oci:// address with no tag and no digest. Severity warning.

Why it matters. A git source with no ref follows the default branch, a tfr:// source follows the registry’s latest release, and an oci:// source pulls latest. A push or a release changes the unit with no diff to review. TF005 and TF038 cover module blocks; a unit has no module block.

The fix. Add ?ref= with a tag or commit SHA, ?version= with an exact version, or ?digest=sha256:... (or ?tag=) for oci://. A local path and a source built from an expression (${get_repo_root()}/modules/vpc) are not read. The rule asks whether a version is named, not whether it is mutable, so ?ref=main passes.

Prior art. No tool in the survey covers this; TF005 and TF038 are the same check on module blocks.

terraform {
source = "git::https://github.com/example/modules.git//vpc"
}

From fixtures/TF043/positive-git/terragrunt.hcl. positive-tfr and positive-oci cover the other two schemes.

TF044: a Terragrunt config keeps state in a local backend

Section titled “TF044: a Terragrunt config keeps state in a local backend”

A remote_state block with backend = "local", or a generate block whose contents declare backend "local". Severity warning.

Why it matters. State on the runner’s disk is gone when the job ends, a later job cannot read what an earlier one applied, and a git-range run, which checks out each side in a temporary directory, never sees it.

The fix. Use a shared backend such as S3 or GCS. A throwaway sandbox can keep a local backend with # chant-ignore TF044 above the block.

Prior art. No tool in the survey covers this; see the lineage file.

remote_state {
backend = "local"
config = {
path = "${get_parent_terragrunt_dir()}/.state/${path_relative_to_include()}/terraform.tfstate"
}
}

From fixtures/TF044/positive/terragrunt.hcl. positive-generate writes the backend from a generate block.

TF045: no Terragrunt config sets terragrunt_version_constraint

Section titled “TF045: no Terragrunt config sets terragrunt_version_constraint”

None of the terragrunt.hcl and root.hcl files in the scan sets terragrunt_version_constraint. Severity warning, and off by default.

Why it matters. The attribute makes Terragrunt refuse a release outside the range. Without it each machine and CI image runs whatever it installed, and a behaviour change between releases reaches a plan with no diff.

The fix. Set it once in the root config every unit includes: terragrunt_version_constraint = ">= 1.1.0".

What the rule can know. It reports once per scan, anchored on root.hcl when there is one, and only when the scan holds a config that includes no other file. A scan made only of units that include a parent it did not read (one unit directory passed alone) is not determined, since the parent may set the attribute. A file with another name, passed to Terragrunt with --config, is not read.

Off by default. TF045 is a report-only rule, since many repos pin Terragrunt in the toolchain instead. Enable it with lint.presets: { terraform: "all" } or a lint.rules entry.

Prior art. No tool in the survey covers this; see the lineage file.

remote_state {
backend = "s3"
# ...
}

From fixtures/TF045/positive/root.hcl, which sets no constraint. The fixture directory also holds a unit that includes it.

chant